dsh-automations 0.0.0-stage → 0.2.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,22 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-11-06
4
+
5
+ - 面板拆成「规则 / 运行」两个 tab:规则页只放规则卡片与模板,运行页展示完整运行历史(含触发类型、退出码、可展开输出),tab 选择记在 `localStorage`。
6
+ - shell 动作的工作目录有了默认值:规则里留空时使用 `defaultCwd` 配置,未配置则用**用户主目录**(不再落到宿主进程目录)。新建规则时表单会**预填**这个默认路径。
7
+ - 新增配置项 `defaultCwd`。
8
+ - 自检增至 14 项(默认工作目录、面板视图的 `defaultCwd`)。
9
+
10
+ ## 0.1.0 — 2026-11-06
11
+
12
+ 首个版本。
13
+
14
+ - 9 种触发时机:`startup`、`session-start`、`session-idle`、`session-end`、`user-message`、`tool-after`、`daily`、`interval`、`manual`。
15
+ - 3 种动作:本地 `shell` 命令(超时 + 进程树终止 + 有界输出)、`http` 请求、`log` 记录。
16
+ - 左侧栏「生命周期自动化」页面:规则卡片、启停、立即运行、编辑器(按 catalog 动态生成字段)、试运行、最近运行输出。
17
+ - 5 个模型工具:`automation_list` / `automation_save` / `automation_run` / `automation_remove` / `automation_runs`。
18
+ - 规则持久化到 `$DSH_HOME/storages/dsh-automations/`,含调度记忆(daily 当天去重、interval 计时)。
19
+ - 同一 turn 的多条 `user/message` 事件按 turn 去重,一条用户消息只触发一次。
20
+ - 中性命令串行执行队列(上限 200),触发侧 cooldown。
21
+ - 12 项离线自检(`node test/selftest.mjs`)。
22
+
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,170 @@
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 尾部与退出码;超时会杀掉整个进程树。**`cwd` 留空 = `defaultCwd`(默认用户主目录)** |
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
+ - 左栏图标 → 主区页面:统计条 + **「规则 / 运行」两个 tab**。
66
+ - **规则** tab:规则卡片(启停 / 运行 / 编辑 / 删除);空面板时给三个模板(启动写日志、每天 08:30 探活、会话空闲记录)。
67
+ - **运行** tab:完整运行历史(时间 / 规则 / 触发类型 / 状态 / 耗时),点一行展开输出,展开头部带触发与退出码。
68
+ - tab 选择记在 `localStorage`,刷新后停在上次那个 tab。
69
+ - 「新建规则」用同一份 **catalog** 动态生成表单:选触发时机后只出现相关字段,换动作同理;shell 规则的**工作目录会预填默认路径**(见下节)。
70
+ - 「试运行」不保存也能执行一次,便于调命令。
71
+
72
+ 界面文案跟随 DSH 的语言(中文 / English)。
73
+
74
+ ## 4. 给模型的工具
75
+
76
+ | 工具 | 用途 |
77
+ |---|---|
78
+ | `automation_list` | 列出规则(含触发/动作摘要、上次状态) |
79
+ | `automation_save` | 新建或按 id 更新规则;`triggerType` + `trigger` 字段包、`actionType` + `action` 字段包 |
80
+ | `automation_run` | 立即执行并返回输出/退出码 |
81
+ | `automation_remove` | 删除规则 |
82
+ | `automation_runs` | 查看最近运行,可只查一条规则 |
83
+
84
+ 系统提示里加了一段短说明,因此用户说「每次 DSH 启动时帮我把 X 起起来」「每天 8:30 提醒我」
85
+ 时,模型会建规则而不是每次手动执行。
86
+
87
+ ## 5. 存储
88
+
89
+ ```text
90
+ $DSH_HOME/storages/dsh-automations/
91
+ rules.json 规则(原子写,损坏时另存 .broken-<ts> 并从空开始)
92
+ runs.json 最近运行(上限 maxRunHistory)
93
+ meta.json 调度记忆:今天哪些 daily 已跑、interval 上次时间、最近用户消息日
94
+ ```
95
+
96
+ `stateDir` 配置项可以整体改到别处。
97
+
98
+ ## 6. 配置(cordis.patch.yml)
99
+
100
+ ```yaml
101
+ - id: dsh-automations
102
+ name: dsh-automations
103
+ config:
104
+ enabled: true
105
+ stateDir: "" # 空 = $DSH_HOME/storages/dsh-automations
106
+ maxRunHistory: 200
107
+ maxOutputChars: 8000
108
+ defaultTimeoutMs: 120000
109
+ defaultCwd: "" # shell 规则的默认工作目录;空 = 用户主目录
110
+ startupDelayMs: 2000
111
+ tickSeconds: 15
112
+ promptEnabled: true
113
+ promptOrder: 62
114
+ ```
115
+
116
+ `patch` 对 `config` 是整块替换:想改一个键,就把要保留的键一起写上。
117
+
118
+ ## 7. 安装
119
+
120
+ ```powershell
121
+ # 从 npm 安装(推荐)
122
+ node "<dsh>/lib/bin.js" plugin --profile <profile> add dsh-automations
123
+ ```
124
+
125
+ 然后把 `dsh-automations` 加进该 profile `package.json` 的 `dsh.profile.bundles`,
126
+ 重启宿主(或让插件管理器热加载)。
127
+
128
+ 开发时可以直接链到源码目录,改完重启宿主即生效:
129
+
130
+ ```powershell
131
+ node "<dsh>/lib/bin.js" plugin --profile <profile> add "link:D:\path\to\dsh-automations"
132
+ ```
133
+
134
+ > `<dsh>` 指 `@deepseek-ai/dsh` 的安装目录(其 `lib/bin.js`)。桌面版则由插件页「安装」完成,
135
+ > 等价于上面的 pnpm 命令。
136
+
137
+ ## 8. 兼容性
138
+
139
+ - DSH `0.2.0-rc.2` / `0.2.1-rc.3` 实测;宿主靠 `peerDependencies` 做真正的兼容门。
140
+ - Node `>=22`(用到全局 `fetch`、`AbortController`)。
141
+ - 面板通过客户端的 `slots` + `locale` 服务注册;没有这两个服务的极简组合只加载宿主半(工具与触发器仍可用)。
142
+ - `@deepseek-ai/schemastery` 是可选依赖:拿不到时插件照常加载,配置回落到代码内默认值。
143
+
144
+ ## 9. 验证
145
+
146
+ ```powershell
147
+ cd dsh-automations
148
+ node test/selftest.mjs # 模型归一化 / 存储 / 引擎 / 触发器接线,12 项
149
+ ```
150
+
151
+ 真机验证(本仓库作者的流程):临时 profile + `--no-open --port 0` 起 Web,用输出的
152
+ `?token=` 换 cookie 调 `/api/dsh-automations/state`,再用 Playwright 点侧栏入口、
153
+ 跑规则、开编辑器;生命周期触发则新建一个会话发一条消息,检查
154
+ `session-start` / `user-message` / `tool-after` / `session-idle` 的运行记录。
155
+
156
+ ## 10. 已知边界
157
+
158
+ - 没有「给 agent 发 prompt」的动作:本插件只做确定性动作(命令/HTTP/日志)。
159
+ 需要 agent 参与的定时任务,继续用自带的 `schedule` 工具/面板。
160
+ - 同一时刻的命令串行执行(队列上限 200),避免两条规则同时动同一个文件/端口。
161
+ - 规则由面板或工具修改;直接手改 `rules.json` 需要重启宿主才会读到。
162
+ - `session-end` 在进程退出路径上,动作可能来不及完成。
163
+
164
+ ## 11. 反馈
165
+
166
+ - 仓库:<https://github.com/439436269-ctrl/dsh-automations>
167
+ - 问题与建议:<https://github.com/439436269-ctrl/dsh-automations/issues>
168
+ - npm:<https://www.npmjs.com/package/dsh-automations>
169
+
170
+ 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,241 @@
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
+ import { homedir } from "node:os";
11
+
12
+ import { tailText } from "./util.js";
13
+
14
+ /**
15
+ * @typedef {object} ActionOutcome
16
+ * @property {"ok"|"error"|"timeout"} status
17
+ * @property {number|null} exitCode
18
+ * @property {string} output
19
+ * @property {string} [error]
20
+ */
21
+
22
+ /**
23
+ * Build the child environment for one run.
24
+ *
25
+ * @param {object} context - trigger context.
26
+ * @returns {NodeJS.ProcessEnv}
27
+ */
28
+ function childEnv(context) {
29
+ const env = { ...process.env };
30
+ env.DSH_AUTOMATION_RULE_ID = String(context.ruleId ?? "");
31
+ env.DSH_AUTOMATION_RULE_NAME = String(context.ruleName ?? "");
32
+ env.DSH_AUTOMATION_TRIGGER = String(context.triggerType ?? "");
33
+ env.DSH_AUTOMATION_SESSION_ID = String(context.sessionId ?? "");
34
+ if (context.tool !== undefined) env.DSH_AUTOMATION_TOOL = String(context.tool);
35
+ env.DSH_AUTOMATION_CONTEXT = JSON.stringify({
36
+ kind: context.kind ?? "",
37
+ sessionId: context.sessionId ?? null,
38
+ tool: context.tool ?? null,
39
+ source: context.source ?? null,
40
+ at: new Date().toISOString(),
41
+ });
42
+ return env;
43
+ }
44
+
45
+ /**
46
+ * Kill a process tree (a PowerShell parent may have spawned children).
47
+ *
48
+ * @param {import("node:child_process").ChildProcess} child - the process.
49
+ */
50
+ function killTree(child) {
51
+ if (child.pid === undefined) return;
52
+ if (process.platform === "win32") {
53
+ try {
54
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
55
+ return;
56
+ } catch {
57
+ /* fall through to the generic kill */
58
+ }
59
+ }
60
+ try {
61
+ child.kill("SIGKILL");
62
+ } catch {
63
+ /* already gone */
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Resolve the interpreter invocation for a shell action.
69
+ *
70
+ * `cmd.exe` re-parses its own command line, so a normally quoted argument (with
71
+ * `\"` escapes) reaches it verbatim and breaks on quoted paths. The cmd branch
72
+ * therefore passes one pre-quoted command line with
73
+ * `windowsVerbatimArguments`, which is the shape cmd actually expects.
74
+ *
75
+ * @param {{shell?: string, command: string}} action - shell action.
76
+ * @returns {{file: string, args: string[], verbatim: boolean}}
77
+ */
78
+ export function shellInvocation(action) {
79
+ const requested = action.shell ?? "auto";
80
+ if (requested === "cmd") {
81
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", `"${action.command}"`], verbatim: true };
82
+ }
83
+ if (requested === "bash") {
84
+ return { file: "bash", args: ["-lc", action.command], verbatim: false };
85
+ }
86
+ if (requested === "powershell") {
87
+ return { file: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", action.command], verbatim: false };
88
+ }
89
+ if (process.platform === "win32") {
90
+ return { file: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", action.command], verbatim: false };
91
+ }
92
+ return { file: "bash", args: ["-lc", action.command], verbatim: false };
93
+ }
94
+
95
+ /**
96
+ * Run a shell action with a hard timeout and bounded output.
97
+ *
98
+ * @param {object} action - normalized shell action.
99
+ * @param {object} context - trigger context.
100
+ * @param {{timeoutMs: number, maxOutputChars: number, defaultCwd: string}} limits - resolved limits.
101
+ * @returns {Promise<ActionOutcome>}
102
+ */
103
+ function runShell(action, context, limits) {
104
+ const { file, args, verbatim } = shellInvocation(action);
105
+ const timeoutMs = action.timeoutMs ?? limits.timeoutMs;
106
+ const budget = limits.maxOutputChars;
107
+ return new Promise((resolve) => {
108
+ const started = Date.now();
109
+ let output = "";
110
+ let settled = false;
111
+ let timedOut = false;
112
+ let child;
113
+ const append = (chunk) => {
114
+ output += chunk;
115
+ // Keep memory bounded while a chatty command runs; the tail is what matters.
116
+ if (output.length > budget * 4) output = output.slice(output.length - budget * 2);
117
+ };
118
+ const finish = (result) => {
119
+ if (settled) return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ resolve({ ...result, durationMs: Date.now() - started });
123
+ };
124
+ const timer = setTimeout(() => {
125
+ timedOut = true;
126
+ if (child !== undefined) killTree(child);
127
+ }, Math.max(1000, timeoutMs));
128
+
129
+ try {
130
+ child = spawn(file, args, {
131
+ cwd: typeof action.cwd === "string" && action.cwd.trim() !== "" ? action.cwd : limits.defaultCwd,
132
+ env: childEnv(context),
133
+ windowsHide: true,
134
+ windowsVerbatimArguments: verbatim,
135
+ stdio: ["ignore", "pipe", "pipe"],
136
+ });
137
+ } catch (error) {
138
+ finish({ status: "error", exitCode: null, output: "", error: error instanceof Error ? error.message : String(error) });
139
+ return;
140
+ }
141
+
142
+ child.stdout?.setEncoding("utf8");
143
+ child.stderr?.setEncoding("utf8");
144
+ child.stdout?.on("data", append);
145
+ child.stderr?.on("data", append);
146
+ child.on("error", (error) => {
147
+ finish({ status: "error", exitCode: null, output: tailText(output, budget), error: error.message });
148
+ });
149
+ child.on("close", (code, signal) => {
150
+ if (timedOut) {
151
+ finish({ status: "timeout", exitCode: code, output: tailText(output, budget), error: `命令超过 ${timeoutMs}ms 未结束,已终止` });
152
+ return;
153
+ }
154
+ const ok = code === 0;
155
+ finish({
156
+ status: ok ? "ok" : "error",
157
+ exitCode: typeof code === "number" ? code : null,
158
+ output: tailText(output, budget),
159
+ ...(ok ? {} : { error: signal ? `进程被信号 ${signal} 终止` : `退出码 ${code}` }),
160
+ });
161
+ });
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Perform one HTTP action.
167
+ *
168
+ * @param {object} action - normalized http action.
169
+ * @param {{maxOutputChars: number}} limits - resolved limits.
170
+ * @returns {Promise<ActionOutcome>}
171
+ */
172
+ async function runHttp(action, limits) {
173
+ const timeoutMs = action.timeoutMs ?? 30000;
174
+ const controller = new AbortController();
175
+ const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs));
176
+ try {
177
+ const method = String(action.method ?? "GET").toUpperCase();
178
+ /** @type {Record<string, string>} */
179
+ const headers = {};
180
+ const rawHeaders = String(action.headers ?? "{}").trim();
181
+ if (rawHeaders !== "") {
182
+ const parsed = JSON.parse(rawHeaders);
183
+ for (const [key, value] of Object.entries(parsed)) headers[key] = String(value);
184
+ }
185
+ const body = typeof action.body === "string" && action.body.trim() !== "" && method !== "GET" && method !== "HEAD" ? action.body : undefined;
186
+ const response = await fetch(action.url, { method, headers, body, signal: controller.signal });
187
+ const text = await response.text().catch(() => "");
188
+ const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
189
+ return {
190
+ status: response.ok ? "ok" : "error",
191
+ exitCode: response.status,
192
+ output: tailText(summary, limits.maxOutputChars),
193
+ ...(response.ok ? {} : { error: `HTTP ${response.status}` }),
194
+ };
195
+ } catch (error) {
196
+ const aborted = error instanceof Error && error.name === "AbortError";
197
+ return {
198
+ status: aborted ? "timeout" : "error",
199
+ exitCode: null,
200
+ output: "",
201
+ error: aborted ? `请求超过 ${timeoutMs}ms 未完成` : error instanceof Error ? error.message : String(error),
202
+ };
203
+ } finally {
204
+ clearTimeout(timer);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Create the action dispatcher.
210
+ *
211
+ * @param {{settings: object, log: {warn: Function}}} options - resolved settings.
212
+ * @returns {{run: (action: object, context: object) => Promise<ActionOutcome & {durationMs: number}>}}
213
+ */
214
+ export function createActions({ settings }) {
215
+ return {
216
+ /**
217
+ * Execute one normalized action.
218
+ *
219
+ * @param {object} action - normalized action.
220
+ * @param {object} context - trigger context.
221
+ * @returns {Promise<ActionOutcome & {durationMs: number}>}
222
+ */
223
+ async run(action, context) {
224
+ const limits = {
225
+ timeoutMs: settings.defaultTimeoutMs,
226
+ maxOutputChars: settings.maxOutputChars,
227
+ defaultCwd: settings.defaultCwd !== "" ? settings.defaultCwd : homedir(),
228
+ };
229
+ const started = Date.now();
230
+ if (action.type === "shell") return runShell(action, context, limits);
231
+ if (action.type === "http") return runHttp(action, limits);
232
+ const message = String(action.message ?? "").trim();
233
+ return {
234
+ status: "ok",
235
+ exitCode: 0,
236
+ output: message === "" ? "" : message,
237
+ durationMs: Date.now() - started,
238
+ };
239
+ },
240
+ };
241
+ }