dsh-automations 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-11-06
4
+
5
+ 首个版本。
6
+
7
+ - 9 种触发时机:`startup`、`session-start`、`session-idle`、`session-end`、`user-message`、`tool-after`、`daily`、`interval`、`manual`。
8
+ - 3 种动作:本地 `shell` 命令(超时 + 进程树终止 + 有界输出)、`http` 请求、`log` 记录。
9
+ - 左侧栏「生命周期自动化」页面:规则卡片、启停、立即运行、编辑器(按 catalog 动态生成字段)、试运行、最近运行输出。
10
+ - 5 个模型工具:`automation_list` / `automation_save` / `automation_run` / `automation_remove` / `automation_runs`。
11
+ - 规则持久化到 `$DSH_HOME/storages/dsh-automations/`,含调度记忆(daily 当天去重、interval 计时)。
12
+ - 同一 turn 的多条 `user/message` 事件按 turn 去重,一条用户消息只触发一次。
13
+ - 中性命令串行执行队列(上限 200),触发侧 cooldown。
14
+ - 12 项离线自检(`node test/selftest.mjs`)。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-automations contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,167 @@
1
- # Temporary Holding Version
1
+ # dsh-automations
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/dsh-automations.svg)](https://www.npmjs.com/package/dsh-automations)
4
+ [![ci](https://github.com/439436269-ctrl/dsh-automations/actions/workflows/ci.yml/badge.svg)](https://github.com/439436269-ctrl/dsh-automations/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/dsh-automations.svg)](LICENSE)
6
+
7
+ > DSH 自带的「自动化任务」按**时间**触发(每天 / 每周 / cron / 间隔)。
8
+ > 这个插件补上另一半:因为**发生了什么**而触发 —— DSH 启动、会话开始 / 空闲 / 结束、
9
+ > 收到用户消息、某个工具执行完 —— 再加上每天 / 固定间隔,动作可以是本机命令、
10
+ > HTTP 请求或一条运行记录。
11
+
12
+ 左侧栏新增 **生命周期自动化**(英文界面为 **Automations**)入口,打开就是规则管理页:
13
+ 建规则、启停、立即运行、看最近运行输出。同一批规则也能由模型用工具读写。
14
+
15
+ ---
16
+
17
+ ## 1. 触发时机(triggers)
18
+
19
+ | 类型 | 什么时候触发 | 参数 |
20
+ |---|---|---|
21
+ | `startup` | 宿主进程启动后一次(热重载不会重复) | `delayMs` 额外延迟 |
22
+ | `session-start` | 会话建立 / 恢复 / 清空 / 压缩后 | `source`:`any`/`startup`/`resume`/`clear`/`compact` |
23
+ | `session-idle` | 会话超过 N 秒没有任何新事件 | `idleSeconds`(≥30) |
24
+ | `session-end` | 会话被释放,或 DSH 退出 | — |
25
+ | `user-message` | 用户发来消息 | `mode`:`any` / `first-of-session` / `first-of-day`;`match` 子串过滤 |
26
+ | `tool-after` | 任意工具执行完成 | `tool` 工具名(`*` 全部);`when`:`always`/`success`/`error` |
27
+ | `daily` | 本地时间每天 HH:MM | `time`、`weekdays`(1=周一…7=周日,空=每天) |
28
+ | `interval` | 每隔 N 分钟 | `minutes`(1–1440) |
29
+ | `manual` | 只在面板/工具里点「运行」 | — |
30
+
31
+ **语义细节(实测过的):**
32
+
33
+ - 一次用户消息会产生多条 `user/message` 事件(消息本体 + 上下文)。插件按
34
+ **turn** 去重,一条消息只触发一次。
35
+ - `daily` 错过窗口后会在启动后**补跑一次**(窗口 4 小时),不会再补更早的。
36
+ - `interval` 的计时从规则首次被看到时开始,持久化在 `meta.json`,重启后继续。
37
+ - `session-end` 在 DSH 正常退出时也会触发;此时动作可能来不及写盘,长命令不要挂在这里。
38
+ - `startup` 的「一次」是按**进程**算的:插件被 HMR 重载不会重复执行。
39
+
40
+ ## 2. 动作(actions)
41
+
42
+ | 类型 | 做什么 | 说明 |
43
+ |---|---|---|
44
+ | `shell` | 起一个**非交互**子进程 | `command`、`cwd`、`shell`(`auto`/`powershell`/`cmd`/`bash`)、`timeoutMs`。捕获 stdout+stderr 尾部与退出码;超时会杀掉整个进程树 |
45
+ | `http` | 发一个 HTTP 请求 | `url`、`method`、`headers`(JSON 对象字符串)、`body`、`timeoutMs`。适合 webhook、飞书机器人、本地探活 |
46
+ | `log` | 只写一条运行记录 | 无副作用,用来验证「时机到底有没有命中」 |
47
+
48
+ shell 动作会拿到这些环境变量,脚本里可以直接用:
49
+
50
+ ```text
51
+ DSH_AUTOMATION_RULE_ID 规则 id
52
+ DSH_AUTOMATION_RULE_NAME 规则名
53
+ DSH_AUTOMATION_TRIGGER 触发类型
54
+ DSH_AUTOMATION_SESSION_ID 触发时的会话(可能为空)
55
+ DSH_AUTOMATION_TOOL 触发时的工具名(tool-after)
56
+ DSH_AUTOMATION_CONTEXT 一行 JSON(kind/sessionId/tool/source/at)
57
+ ```
58
+
59
+ > ⚠️ `shell` 会以**当前用户权限**执行任意命令。规则文件在 `$DSH_HOME/storages/dsh-automations/`,
60
+ > 面板 API 走宿主已鉴权的 `/api` 通道(会话 cookie + Host/Origin 校验),但能改规则的人
61
+ > 本来就能在这台机器上执行命令 —— 请像对待 `~/.bashrc` 一样对待它。
62
+
63
+ ## 3. 面板
64
+
65
+ - 左栏图标 → 主区页面:统计条、规则卡片(启停 / 运行 / 编辑 / 删除)、最近运行(点开看输出)。
66
+ - 「新建规则」用同一份 **catalog** 动态生成表单:选触发时机后只出现相关字段,换动作同理。
67
+ - 「试运行」不保存也能执行一次,便于调命令。
68
+ - 首次使用提供三个模板:启动写日志、每天 08:30 探活、会话空闲记录。
69
+
70
+ 界面文案跟随 DSH 的语言(中文 / English)。
71
+
72
+ ## 4. 给模型的工具
73
+
74
+ | 工具 | 用途 |
75
+ |---|---|
76
+ | `automation_list` | 列出规则(含触发/动作摘要、上次状态) |
77
+ | `automation_save` | 新建或按 id 更新规则;`triggerType` + `trigger` 字段包、`actionType` + `action` 字段包 |
78
+ | `automation_run` | 立即执行并返回输出/退出码 |
79
+ | `automation_remove` | 删除规则 |
80
+ | `automation_runs` | 查看最近运行,可只查一条规则 |
81
+
82
+ 系统提示里加了一段短说明,因此用户说「每次 DSH 启动时帮我把 X 起起来」「每天 8:30 提醒我」
83
+ 时,模型会建规则而不是每次手动执行。
84
+
85
+ ## 5. 存储
86
+
87
+ ```text
88
+ $DSH_HOME/storages/dsh-automations/
89
+ rules.json 规则(原子写,损坏时另存 .broken-<ts> 并从空开始)
90
+ runs.json 最近运行(上限 maxRunHistory)
91
+ meta.json 调度记忆:今天哪些 daily 已跑、interval 上次时间、最近用户消息日
92
+ ```
93
+
94
+ `stateDir` 配置项可以整体改到别处。
95
+
96
+ ## 6. 配置(cordis.patch.yml)
97
+
98
+ ```yaml
99
+ - id: dsh-automations
100
+ name: dsh-automations
101
+ config:
102
+ enabled: true
103
+ stateDir: "" # 空 = $DSH_HOME/storages/dsh-automations
104
+ maxRunHistory: 200
105
+ maxOutputChars: 8000
106
+ defaultTimeoutMs: 120000
107
+ startupDelayMs: 2000
108
+ tickSeconds: 15
109
+ promptEnabled: true
110
+ promptOrder: 62
111
+ ```
112
+
113
+ `patch` 对 `config` 是整块替换:想改一个键,就把要保留的键一起写上。
114
+
115
+ ## 7. 安装
116
+
117
+ ```powershell
118
+ # 从 npm 安装(推荐)
119
+ node "<dsh>/lib/bin.js" plugin --profile <profile> add dsh-automations
120
+ ```
121
+
122
+ 然后把 `dsh-automations` 加进该 profile `package.json` 的 `dsh.profile.bundles`,
123
+ 重启宿主(或让插件管理器热加载)。
124
+
125
+ 开发时可以直接链到源码目录,改完重启宿主即生效:
126
+
127
+ ```powershell
128
+ node "<dsh>/lib/bin.js" plugin --profile <profile> add "link:D:\path\to\dsh-automations"
129
+ ```
130
+
131
+ > `<dsh>` 指 `@deepseek-ai/dsh` 的安装目录(其 `lib/bin.js`)。桌面版则由插件页「安装」完成,
132
+ > 等价于上面的 pnpm 命令。
133
+
134
+ ## 8. 兼容性
135
+
136
+ - DSH `0.2.0-rc.2` / `0.2.1-rc.3` 实测;宿主靠 `peerDependencies` 做真正的兼容门。
137
+ - Node `>=22`(用到全局 `fetch`、`AbortController`)。
138
+ - 面板通过客户端的 `slots` + `locale` 服务注册;没有这两个服务的极简组合只加载宿主半(工具与触发器仍可用)。
139
+ - `@deepseek-ai/schemastery` 是可选依赖:拿不到时插件照常加载,配置回落到代码内默认值。
140
+
141
+ ## 9. 验证
142
+
143
+ ```powershell
144
+ cd dsh-automations
145
+ node test/selftest.mjs # 模型归一化 / 存储 / 引擎 / 触发器接线,12 项
146
+ ```
147
+
148
+ 真机验证(本仓库作者的流程):临时 profile + `--no-open --port 0` 起 Web,用输出的
149
+ `?token=` 换 cookie 调 `/api/dsh-automations/state`,再用 Playwright 点侧栏入口、
150
+ 跑规则、开编辑器;生命周期触发则新建一个会话发一条消息,检查
151
+ `session-start` / `user-message` / `tool-after` / `session-idle` 的运行记录。
152
+
153
+ ## 10. 已知边界
154
+
155
+ - 没有「给 agent 发 prompt」的动作:本插件只做确定性动作(命令/HTTP/日志)。
156
+ 需要 agent 参与的定时任务,继续用自带的 `schedule` 工具/面板。
157
+ - 同一时刻的命令串行执行(队列上限 200),避免两条规则同时动同一个文件/端口。
158
+ - 规则由面板或工具修改;直接手改 `rules.json` 需要重启宿主才会读到。
159
+ - `session-end` 在进程退出路径上,动作可能来不及完成。
160
+
161
+ ## 11. 反馈
162
+
163
+ - 仓库:<https://github.com/439436269-ctrl/dsh-automations>
164
+ - 问题与建议:<https://github.com/439436269-ctrl/dsh-automations/issues>
165
+ - npm:<https://www.npmjs.com/package/dsh-automations>
166
+
167
+ MIT License。
@@ -0,0 +1,8 @@
1
+ # Bundle patch: one row. The profile layer may override this `config` block
2
+ # (patch replaces the whole object, not a deep merge).
3
+ - insert:
4
+ - id: dsh-automations
5
+ name: dsh-automations
6
+ config:
7
+ enabled: true
8
+ startupDelayMs: 2000
package/lib/actions.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Action executors: one non-interactive process, one HTTP request, or one log
3
+ * line. Every executor resolves (never rejects) to a uniform outcome so the
4
+ * engine only has one shape to record.
5
+ *
6
+ * @module dsh-automations/actions
7
+ */
8
+
9
+ import { spawn, spawnSync } from "node:child_process";
10
+
11
+ import { tailText } from "./util.js";
12
+
13
+ /**
14
+ * @typedef {object} ActionOutcome
15
+ * @property {"ok"|"error"|"timeout"} status
16
+ * @property {number|null} exitCode
17
+ * @property {string} output
18
+ * @property {string} [error]
19
+ */
20
+
21
+ /**
22
+ * Build the child environment for one run.
23
+ *
24
+ * @param {object} context - trigger context.
25
+ * @returns {NodeJS.ProcessEnv}
26
+ */
27
+ function childEnv(context) {
28
+ const env = { ...process.env };
29
+ env.DSH_AUTOMATION_RULE_ID = String(context.ruleId ?? "");
30
+ env.DSH_AUTOMATION_RULE_NAME = String(context.ruleName ?? "");
31
+ env.DSH_AUTOMATION_TRIGGER = String(context.triggerType ?? "");
32
+ env.DSH_AUTOMATION_SESSION_ID = String(context.sessionId ?? "");
33
+ if (context.tool !== undefined) env.DSH_AUTOMATION_TOOL = String(context.tool);
34
+ env.DSH_AUTOMATION_CONTEXT = JSON.stringify({
35
+ kind: context.kind ?? "",
36
+ sessionId: context.sessionId ?? null,
37
+ tool: context.tool ?? null,
38
+ source: context.source ?? null,
39
+ at: new Date().toISOString(),
40
+ });
41
+ return env;
42
+ }
43
+
44
+ /**
45
+ * Kill a process tree (a PowerShell parent may have spawned children).
46
+ *
47
+ * @param {import("node:child_process").ChildProcess} child - the process.
48
+ */
49
+ function killTree(child) {
50
+ if (child.pid === undefined) return;
51
+ if (process.platform === "win32") {
52
+ try {
53
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
54
+ return;
55
+ } catch {
56
+ /* fall through to the generic kill */
57
+ }
58
+ }
59
+ try {
60
+ child.kill("SIGKILL");
61
+ } catch {
62
+ /* already gone */
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Resolve the interpreter invocation for a shell action.
68
+ *
69
+ * `cmd.exe` re-parses its own command line, so a normally quoted argument (with
70
+ * `\"` escapes) reaches it verbatim and breaks on quoted paths. The cmd branch
71
+ * therefore passes one pre-quoted command line with
72
+ * `windowsVerbatimArguments`, which is the shape cmd actually expects.
73
+ *
74
+ * @param {{shell?: string, command: string}} action - shell action.
75
+ * @returns {{file: string, args: string[], verbatim: boolean}}
76
+ */
77
+ export function shellInvocation(action) {
78
+ const requested = action.shell ?? "auto";
79
+ if (requested === "cmd") {
80
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", `"${action.command}"`], verbatim: true };
81
+ }
82
+ if (requested === "bash") {
83
+ return { file: "bash", args: ["-lc", action.command], verbatim: false };
84
+ }
85
+ if (requested === "powershell") {
86
+ return { file: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", action.command], verbatim: false };
87
+ }
88
+ if (process.platform === "win32") {
89
+ return { file: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", action.command], verbatim: false };
90
+ }
91
+ return { file: "bash", args: ["-lc", action.command], verbatim: false };
92
+ }
93
+
94
+ /**
95
+ * Run a shell action with a hard timeout and bounded output.
96
+ *
97
+ * @param {object} action - normalized shell action.
98
+ * @param {object} context - trigger context.
99
+ * @param {{timeoutMs: number, maxOutputChars: number}} limits - resolved limits.
100
+ * @returns {Promise<ActionOutcome>}
101
+ */
102
+ function runShell(action, context, limits) {
103
+ const { file, args, verbatim } = shellInvocation(action);
104
+ const timeoutMs = action.timeoutMs ?? limits.timeoutMs;
105
+ const budget = limits.maxOutputChars;
106
+ return new Promise((resolve) => {
107
+ const started = Date.now();
108
+ let output = "";
109
+ let settled = false;
110
+ let timedOut = false;
111
+ let child;
112
+ const append = (chunk) => {
113
+ output += chunk;
114
+ // Keep memory bounded while a chatty command runs; the tail is what matters.
115
+ if (output.length > budget * 4) output = output.slice(output.length - budget * 2);
116
+ };
117
+ const finish = (result) => {
118
+ if (settled) return;
119
+ settled = true;
120
+ clearTimeout(timer);
121
+ resolve({ ...result, durationMs: Date.now() - started });
122
+ };
123
+ const timer = setTimeout(() => {
124
+ timedOut = true;
125
+ if (child !== undefined) killTree(child);
126
+ }, Math.max(1000, timeoutMs));
127
+
128
+ try {
129
+ child = spawn(file, args, {
130
+ cwd: typeof action.cwd === "string" && action.cwd.trim() !== "" ? action.cwd : process.cwd(),
131
+ env: childEnv(context),
132
+ windowsHide: true,
133
+ windowsVerbatimArguments: verbatim,
134
+ stdio: ["ignore", "pipe", "pipe"],
135
+ });
136
+ } catch (error) {
137
+ finish({ status: "error", exitCode: null, output: "", error: error instanceof Error ? error.message : String(error) });
138
+ return;
139
+ }
140
+
141
+ child.stdout?.setEncoding("utf8");
142
+ child.stderr?.setEncoding("utf8");
143
+ child.stdout?.on("data", append);
144
+ child.stderr?.on("data", append);
145
+ child.on("error", (error) => {
146
+ finish({ status: "error", exitCode: null, output: tailText(output, budget), error: error.message });
147
+ });
148
+ child.on("close", (code, signal) => {
149
+ if (timedOut) {
150
+ finish({ status: "timeout", exitCode: code, output: tailText(output, budget), error: `命令超过 ${timeoutMs}ms 未结束,已终止` });
151
+ return;
152
+ }
153
+ const ok = code === 0;
154
+ finish({
155
+ status: ok ? "ok" : "error",
156
+ exitCode: typeof code === "number" ? code : null,
157
+ output: tailText(output, budget),
158
+ ...(ok ? {} : { error: signal ? `进程被信号 ${signal} 终止` : `退出码 ${code}` }),
159
+ });
160
+ });
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Perform one HTTP action.
166
+ *
167
+ * @param {object} action - normalized http action.
168
+ * @param {{maxOutputChars: number}} limits - resolved limits.
169
+ * @returns {Promise<ActionOutcome>}
170
+ */
171
+ async function runHttp(action, limits) {
172
+ const timeoutMs = action.timeoutMs ?? 30000;
173
+ const controller = new AbortController();
174
+ const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs));
175
+ try {
176
+ const method = String(action.method ?? "GET").toUpperCase();
177
+ /** @type {Record<string, string>} */
178
+ const headers = {};
179
+ const rawHeaders = String(action.headers ?? "{}").trim();
180
+ if (rawHeaders !== "") {
181
+ const parsed = JSON.parse(rawHeaders);
182
+ for (const [key, value] of Object.entries(parsed)) headers[key] = String(value);
183
+ }
184
+ const body = typeof action.body === "string" && action.body.trim() !== "" && method !== "GET" && method !== "HEAD" ? action.body : undefined;
185
+ const response = await fetch(action.url, { method, headers, body, signal: controller.signal });
186
+ const text = await response.text().catch(() => "");
187
+ const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
188
+ return {
189
+ status: response.ok ? "ok" : "error",
190
+ exitCode: response.status,
191
+ output: tailText(summary, limits.maxOutputChars),
192
+ ...(response.ok ? {} : { error: `HTTP ${response.status}` }),
193
+ };
194
+ } catch (error) {
195
+ const aborted = error instanceof Error && error.name === "AbortError";
196
+ return {
197
+ status: aborted ? "timeout" : "error",
198
+ exitCode: null,
199
+ output: "",
200
+ error: aborted ? `请求超过 ${timeoutMs}ms 未完成` : error instanceof Error ? error.message : String(error),
201
+ };
202
+ } finally {
203
+ clearTimeout(timer);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Create the action dispatcher.
209
+ *
210
+ * @param {{settings: object, log: {warn: Function}}} options - resolved settings.
211
+ * @returns {{run: (action: object, context: object) => Promise<ActionOutcome & {durationMs: number}>}}
212
+ */
213
+ export function createActions({ settings }) {
214
+ return {
215
+ /**
216
+ * Execute one normalized action.
217
+ *
218
+ * @param {object} action - normalized action.
219
+ * @param {object} context - trigger context.
220
+ * @returns {Promise<ActionOutcome & {durationMs: number}>}
221
+ */
222
+ async run(action, context) {
223
+ const limits = { timeoutMs: settings.defaultTimeoutMs, maxOutputChars: settings.maxOutputChars };
224
+ const started = Date.now();
225
+ if (action.type === "shell") return runShell(action, context, limits);
226
+ if (action.type === "http") return runHttp(action, limits);
227
+ const message = String(action.message ?? "").trim();
228
+ return {
229
+ status: "ok",
230
+ exitCode: 0,
231
+ output: message === "" ? "" : message,
232
+ durationMs: Date.now() - started,
233
+ };
234
+ },
235
+ };
236
+ }