dsh-plugin-t-expert 0.2.16 → 0.3.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.md +71 -13
- package/THIRD-PARTY-NOTICES +18 -1
- package/lib/client.js +1303 -181
- package/lib/i18n.js +30 -0
- package/lib/index.js +153 -1
- package/lib/remote-schemas.js +76 -0
- package/lib/remote.js +161 -1
- package/lib/schedule.js +423 -0
- package/package.json +2 -1
- package/skills/t-expert-manager/SKILL.md +7 -1
- package/vendor/third-party-licenses/croner.LICENSE +22 -0
package/lib/schedule.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* T专家 定时事项引擎:按 cron 表达式把提示词交给一个**全新的** agent 会话执行。
|
|
4
|
+
*
|
|
5
|
+
* 为什么自建而不是直接依赖 `@weibaohui/dsh-tasks`:用户明确要求两个插件可以同时安装、
|
|
6
|
+
* 同时可用。所以这里与它**零共享**——
|
|
7
|
+
* · 数据不落它的 `dsh_tasks` 存储域,而是 T专家 自己的数据目录(`<root 同级>/schedule.json`);
|
|
8
|
+
* · 服务名 `tTeamSchedule`、错误码前缀 `tTeam/schedule-*`、消息来源 `plugin: dsh-plugin-t-expert`
|
|
9
|
+
* 全部独立,两个插件的任务互不可见、互不干扰。
|
|
10
|
+
*
|
|
11
|
+
* 三件事在这里闭环:
|
|
12
|
+
* 1. 持久化:一个 JSON 文件,原子写(与 `teams.json` 同级、同风格);
|
|
13
|
+
* 2. 调度:每个启用项一个 croner job,宿主启动时全量重建,卸载时全部 stop;
|
|
14
|
+
* 3. 执行:新建 agent 会话 → 挂部署默认 preset(不挂就没有工具)→ 提交提示词。
|
|
15
|
+
*
|
|
16
|
+
* 开关语义(用户口径):`setArmed(false)` = 定时事项功能整体停用——**停止全部定时触发**
|
|
17
|
+
* 且拒绝手动执行;任务与执行记录完整保留,重新 armed 后自动恢复调度。
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { Cron } from "croner";
|
|
22
|
+
import { writeFileAtomic } from "./catalog.js";
|
|
23
|
+
|
|
24
|
+
/** 定时事项服务名(remote 侧的服务访问器用它)。 */
|
|
25
|
+
export const SCHEDULE_SERVICE = "tTeamSchedule";
|
|
26
|
+
/** 数据文件名(落在 T专家 数据根下,与 teams.json 同级)。 */
|
|
27
|
+
export const SCHEDULE_FILE_NAME = "schedule.json";
|
|
28
|
+
/** 每条事项保留的执行记录条数(最旧的先丢)。 */
|
|
29
|
+
export const MAX_RUNS = 20;
|
|
30
|
+
/** 事项条数上限(防止一次误操作把设置文件写成天文数字)。 */
|
|
31
|
+
export const MAX_ITEMS = 200;
|
|
32
|
+
/** 标题 / 提示词的字符上限。 */
|
|
33
|
+
export const MAX_TITLE_CHARS = 200;
|
|
34
|
+
export const MAX_PROMPT_CHARS = 20000;
|
|
35
|
+
/** 写进会话消息来源的插件名(与 dsh-tasks 区分开,两个插件的任务是两回事)。 */
|
|
36
|
+
export const PLUGIN_ID = "dsh-plugin-t-expert";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* cron 表达式校验:构造一个 `paused` 的探测 job(只解析、不真的挂定时器),
|
|
40
|
+
* 随即 stop 掉,保证探测本身不会让事件循环多活一秒。
|
|
41
|
+
* @param {unknown} expression - 待校验的表达式。
|
|
42
|
+
* @param {(key: string, params?: Record<string, unknown>) => string} t - host 侧文案函数。
|
|
43
|
+
*/
|
|
44
|
+
export function validateCron(expression, t) {
|
|
45
|
+
const text = typeof expression === "string" ? expression.trim() : "";
|
|
46
|
+
if (text === "") throw new Error(t("schedule.cronEmpty"));
|
|
47
|
+
try {
|
|
48
|
+
const probe = new Cron(text, { paused: true }, () => undefined);
|
|
49
|
+
probe.stop();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
52
|
+
throw new Error(t("schedule.invalidCron", { detail }));
|
|
53
|
+
}
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 该表达式接下来的若干次触发时间(ISO 串),供面板在保存前预览。 */
|
|
58
|
+
export function previewRuns(expression, t, count = 5) {
|
|
59
|
+
const text = validateCron(expression, t);
|
|
60
|
+
const probe = new Cron(text, { paused: true });
|
|
61
|
+
try {
|
|
62
|
+
return probe.nextRuns(count).map((date) => date.toISOString());
|
|
63
|
+
} finally {
|
|
64
|
+
probe.stop();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 短本地时间戳(`MM-DD HH:mm`):同一个事项的多次运行在侧栏里要能区分开。 */
|
|
69
|
+
export function runStamp(iso) {
|
|
70
|
+
const date = new Date(iso);
|
|
71
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
72
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 追加一条执行记录(超过上限丢最旧的),并刷新最近一次执行摘要字段。 */
|
|
76
|
+
export function withRun(item, entry) {
|
|
77
|
+
const runs = [...(Array.isArray(item.runs) ? item.runs : []), entry].slice(-MAX_RUNS);
|
|
78
|
+
const next = { ...item, runs, lastRunAt: entry.at };
|
|
79
|
+
// 成功时**删掉**失败字段而不是置 undefined:线上(remote 严格 schema)与 JSON 都不需要
|
|
80
|
+
// 一个"值为 undefined 的键",留着只会让序列化前后形状不一致。
|
|
81
|
+
if (entry.ok === true) delete next.lastRunError;
|
|
82
|
+
else next.lastRunError = entry.error || "failed";
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 校验一条事项的入参(面板提交的形状);返回规范化后的字段。 */
|
|
87
|
+
export function normalizeInput(input, t) {
|
|
88
|
+
if (input === null || typeof input !== "object") throw new Error(t("schedule.inputInvalid"));
|
|
89
|
+
const raw = /** @type {Record<string, unknown>} */ (input);
|
|
90
|
+
const title = typeof raw.title === "string" ? raw.title.trim() : "";
|
|
91
|
+
const prompt = typeof raw.prompt === "string" ? raw.prompt : "";
|
|
92
|
+
if (title === "") throw new Error(t("schedule.titleEmpty"));
|
|
93
|
+
if (title.length > MAX_TITLE_CHARS) throw new Error(t("schedule.titleTooLong", { limit: MAX_TITLE_CHARS }));
|
|
94
|
+
if (prompt.trim() === "") throw new Error(t("schedule.promptEmpty"));
|
|
95
|
+
if (prompt.length > MAX_PROMPT_CHARS) throw new Error(t("schedule.promptTooLong", { limit: MAX_PROMPT_CHARS }));
|
|
96
|
+
const cron = validateCron(raw.cron, t);
|
|
97
|
+
const enabled = raw.enabled !== false;
|
|
98
|
+
const workspaceId = typeof raw.workspaceId === "string" && raw.workspaceId !== "" ? raw.workspaceId : undefined;
|
|
99
|
+
return { title, prompt, cron, enabled, ...(workspaceId === undefined ? {} : { workspaceId }) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 创建定时事项引擎。
|
|
104
|
+
*
|
|
105
|
+
* @param {object} options
|
|
106
|
+
* @param {any} options.ctx - 插件上下文(用 `ctx.get(...)` 懒查可选服务)。
|
|
107
|
+
* @param {string} options.file - 数据文件绝对路径。
|
|
108
|
+
* @param {any} options.logger - cordis logger(可缺省)。
|
|
109
|
+
* @param {(key: string, params?: Record<string, unknown>) => string} options.t - host 文案函数。
|
|
110
|
+
* @param {string} options.defaultCwd - 不绑定工作区时的会话工作目录。
|
|
111
|
+
* @param {boolean} [options.armed] - 初始是否启用调度(= 界面开关的当前值)。
|
|
112
|
+
* @param {(text: string) => Promise<string>} [options.expandPrompt] - 提交前的提示词改写
|
|
113
|
+
* (提示词里挑中的 由它落成召唤指令)。缺省就是原文。
|
|
114
|
+
*/
|
|
115
|
+
export function createScheduleEngine(options) {
|
|
116
|
+
const { ctx, file, logger, t, defaultCwd } = options;
|
|
117
|
+
/** 提交前的提示词改写:没有注入就原样提交(定时任务本身不依赖它)。 */
|
|
118
|
+
const expandPrompt = typeof options.expandPrompt === "function"
|
|
119
|
+
? options.expandPrompt
|
|
120
|
+
: async (text) => text;
|
|
121
|
+
/** @type {Map<string, any>} id → 事项 */
|
|
122
|
+
const items = new Map();
|
|
123
|
+
/** @type {Map<string, any>} id → croner job */
|
|
124
|
+
const jobs = new Map();
|
|
125
|
+
/** 正在执行中的事项 id:同一事项不并发跑两次。 */
|
|
126
|
+
const running = new Set();
|
|
127
|
+
let armed = options.armed !== false;
|
|
128
|
+
/** 写盘串行链:并发变更不能交错写同一个文件。 */
|
|
129
|
+
let writeChain = Promise.resolve();
|
|
130
|
+
|
|
131
|
+
/** 读盘(同步,apply 期就要拿到数据)。读不动时**出声**并按空列表继续。 */
|
|
132
|
+
function load() {
|
|
133
|
+
let text;
|
|
134
|
+
try {
|
|
135
|
+
text = readFileSync(file, "utf8");
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const code = /** @type {any} */ (error)?.code;
|
|
138
|
+
if (code !== "ENOENT") {
|
|
139
|
+
logger?.warn?.(`[t-team] 定时事项数据文件读取失败(按空列表继续):${file}:${error instanceof Error ? error.message : String(error)}`);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(text);
|
|
145
|
+
const list = Array.isArray(parsed?.items) ? parsed.items : [];
|
|
146
|
+
for (const entry of list) {
|
|
147
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
148
|
+
if (typeof entry.id !== "string" || typeof entry.cron !== "string") continue;
|
|
149
|
+
items.set(entry.id, {
|
|
150
|
+
id: entry.id,
|
|
151
|
+
title: typeof entry.title === "string" ? entry.title : "",
|
|
152
|
+
prompt: typeof entry.prompt === "string" ? entry.prompt : "",
|
|
153
|
+
cron: entry.cron,
|
|
154
|
+
enabled: entry.enabled !== false,
|
|
155
|
+
...(typeof entry.workspaceId === "string" && entry.workspaceId !== "" ? { workspaceId: entry.workspaceId } : {}),
|
|
156
|
+
createdAt: typeof entry.createdAt === "string" ? entry.createdAt : new Date().toISOString(),
|
|
157
|
+
updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : new Date().toISOString(),
|
|
158
|
+
...(typeof entry.lastRunAt === "string" ? { lastRunAt: entry.lastRunAt } : {}),
|
|
159
|
+
...(typeof entry.lastRunError === "string" ? { lastRunError: entry.lastRunError } : {}),
|
|
160
|
+
runs: Array.isArray(entry.runs) ? entry.runs.slice(-MAX_RUNS) : [],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
logger?.warn?.(`[t-team] 定时事项数据文件解析失败(按空列表继续):${file}:${error instanceof Error ? error.message : String(error)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 落盘:串行 + 原子写(临时文件 + rename),失败只告警不打断内存态。 */
|
|
169
|
+
function persist() {
|
|
170
|
+
const payload = `${JSON.stringify({ version: 1, items: [...items.values()] }, null, 2)}\n`;
|
|
171
|
+
writeChain = writeChain
|
|
172
|
+
.then(() => writeFileAtomic(file, payload))
|
|
173
|
+
.catch((error) => {
|
|
174
|
+
logger?.warn?.(`[t-team] 定时事项写盘失败:${file}:${error instanceof Error ? error.message : String(error)}`);
|
|
175
|
+
});
|
|
176
|
+
return writeChain;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 停掉一个 job(幂等)。 */
|
|
180
|
+
function stopJob(id) {
|
|
181
|
+
const job = jobs.get(id);
|
|
182
|
+
if (job === undefined) return;
|
|
183
|
+
try {
|
|
184
|
+
job.stop();
|
|
185
|
+
} catch {
|
|
186
|
+
/* 已经停掉的 job 再 stop 无所谓 */
|
|
187
|
+
}
|
|
188
|
+
jobs.delete(id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 按当前 armed 状态与 enabled 标记给一条事项挂/摘定时器。 */
|
|
192
|
+
function scheduleOne(item) {
|
|
193
|
+
stopJob(item.id);
|
|
194
|
+
if (!armed || item.enabled !== true) return;
|
|
195
|
+
try {
|
|
196
|
+
// `unref: true`:不让定时器把宿主进程的事件循环钉住(进程想退出就能退出)。
|
|
197
|
+
// 它是 croner 的**构造选项**,不是 job 上的方法(写成 job.unref() 会静默无效)。
|
|
198
|
+
const job = new Cron(item.cron, { unref: true }, () => {
|
|
199
|
+
void runScheduled(item.id);
|
|
200
|
+
});
|
|
201
|
+
jobs.set(item.id, job);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// 表达式在写入时已校验过,这里失败只可能是运行期异常(例如 croner 版本差异):
|
|
204
|
+
// 记一条 warn 并保持该事项不调度,别让一个坏 job 拖垮整张表。
|
|
205
|
+
logger?.warn?.(`[t-team] 定时事项「${item.title}」调度失败(该事项不会自动触发):${error instanceof Error ? error.message : String(error)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 全量重建调度表(启动、开关切换、批量变更后调用)。 */
|
|
210
|
+
function rescheduleAll() {
|
|
211
|
+
for (const id of [...jobs.keys()]) stopJob(id);
|
|
212
|
+
if (!armed) return;
|
|
213
|
+
for (const item of items.values()) scheduleOne(item);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 定时触发的入口:跑完更新记录并落盘。 */
|
|
217
|
+
async function runScheduled(id) {
|
|
218
|
+
const current = items.get(id);
|
|
219
|
+
if (current === undefined || !armed) return;
|
|
220
|
+
if (running.has(id)) return; // 上一轮还没跑完,这一轮跳过(不排队、不叠加)
|
|
221
|
+
const updated = await execute(current);
|
|
222
|
+
items.set(id, updated);
|
|
223
|
+
await persist();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 执行一条事项:新建会话 + 提交提示词,把结果记进 runs。
|
|
228
|
+
* 失败**不抛**(定时路径没人接异常),而是把原因写进 `lastRunError`。
|
|
229
|
+
*/
|
|
230
|
+
async function execute(item) {
|
|
231
|
+
const startedAt = new Date().toISOString();
|
|
232
|
+
running.add(item.id);
|
|
233
|
+
try {
|
|
234
|
+
const agents = ctx.get("agents");
|
|
235
|
+
if (agents === undefined || typeof agents.create !== "function") {
|
|
236
|
+
throw new Error(t("schedule.agentsMissing"));
|
|
237
|
+
}
|
|
238
|
+
const workspace = item.workspaceId === undefined
|
|
239
|
+
? undefined
|
|
240
|
+
: ctx.get("workspaceRegistry")?.get?.(item.workspaceId);
|
|
241
|
+
if (item.workspaceId !== undefined && workspace === undefined) {
|
|
242
|
+
throw new Error(t("schedule.workspaceMissing", { id: item.workspaceId }));
|
|
243
|
+
}
|
|
244
|
+
const selection = ctx.get("agentDefaultModel")?.currentSelection?.() ?? {};
|
|
245
|
+
const sessionId = `session-${randomUUID()}`;
|
|
246
|
+
const handle = await agents.create({
|
|
247
|
+
sessionId,
|
|
248
|
+
meta: { cwd: workspace ? workspace.path : defaultCwd },
|
|
249
|
+
...(typeof selection.provider === "string" && typeof selection.model === "string"
|
|
250
|
+
? { agentOptions: { provider: selection.provider, model: selection.model } }
|
|
251
|
+
: {}),
|
|
252
|
+
// 没有 preset 挂载的新会话**没有任何工具**(模型只能空口回答),所以这里按 web 网关
|
|
253
|
+
// 建会话的同款做法解析并挂上部署的默认 preset。
|
|
254
|
+
setup: async (agentCtx) => {
|
|
255
|
+
const presets = ctx.get("agentPresets");
|
|
256
|
+
if (!presets || typeof presets.resolve !== "function" || typeof presets.mount !== "function") return;
|
|
257
|
+
const resolved = await presets.resolve(undefined);
|
|
258
|
+
if (resolved && resolved.id) await presets.mount(agentCtx, resolved.id);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
if (workspace !== undefined && typeof workspace.attachSession === "function") {
|
|
262
|
+
await workspace.attachSession(sessionId);
|
|
263
|
+
}
|
|
264
|
+
// 标题钉成「事项标题 · 时间」:同一事项的多次运行在侧栏里能一一区分。
|
|
265
|
+
// 以 `user` 来源直接写日志,避免自动标题生成把它覆盖掉(失败不致命)。
|
|
266
|
+
try {
|
|
267
|
+
handle.agent.session.append("session/title", {
|
|
268
|
+
title: `${item.title} · ${runStamp(startedAt)}`,
|
|
269
|
+
messageSeqs: [],
|
|
270
|
+
source: { kind: "user" },
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
/* 标题没钉上也照样执行 */
|
|
274
|
+
}
|
|
275
|
+
// 提示词里可能带 `@专家名`(面板里挑中的那位):交给注入的改写器落成召唤指令。
|
|
276
|
+
// 改写失败不致命 —— 退回原文提交,任务照跑,只在日志里留一句可查的线索。
|
|
277
|
+
let promptText = item.prompt;
|
|
278
|
+
try {
|
|
279
|
+
promptText = await expandPrompt(item.prompt);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
logger?.warn?.(`[t-team] 定时任务「${item.title}」的提示词改写失败,按原文提交:${error instanceof Error ? error.message : String(error)}`);
|
|
282
|
+
}
|
|
283
|
+
handle.agent.followup({
|
|
284
|
+
id: randomUUID(),
|
|
285
|
+
role: "user",
|
|
286
|
+
content: [{ type: "text", text: promptText }],
|
|
287
|
+
source: { kind: "plugin", plugin: PLUGIN_ID },
|
|
288
|
+
});
|
|
289
|
+
return withRun(item, { at: startedAt, ok: true, sessionId });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return withRun(item, {
|
|
292
|
+
at: startedAt,
|
|
293
|
+
ok: false,
|
|
294
|
+
error: error instanceof Error ? error.message : String(error),
|
|
295
|
+
});
|
|
296
|
+
} finally {
|
|
297
|
+
running.delete(item.id);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
load();
|
|
302
|
+
// 读盘之后必须**立刻**按当前开关挂上定时器:少了这一句,插件每次启动都是"任务在、但永远不触发",
|
|
303
|
+
// 直到用户碰一下开关或增删一条才恢复 —— 那种"看着正常、其实没在跑"的失效最难发现,所以这里
|
|
304
|
+
// 也有对应自检(verify [20c] 的「重启后 enabled 的任务自动进入调度」)。
|
|
305
|
+
rescheduleAll();
|
|
306
|
+
|
|
307
|
+
/** 面板要的全部数据:事项 + 开关状态 + 可选工作区(一次调用拿全,省一次往返)。 */
|
|
308
|
+
function snapshot() {
|
|
309
|
+
const registry = ctx.get("workspaceRegistry") ?? ctx.get("workspace");
|
|
310
|
+
const list = typeof registry?.list === "function" ? registry.list() : [];
|
|
311
|
+
return {
|
|
312
|
+
items: [...items.values()],
|
|
313
|
+
armed,
|
|
314
|
+
file,
|
|
315
|
+
running: [...running],
|
|
316
|
+
capabilities: {
|
|
317
|
+
agents: typeof ctx.get("agents")?.create === "function",
|
|
318
|
+
presets: typeof ctx.get("agentPresets")?.mount === "function",
|
|
319
|
+
},
|
|
320
|
+
workspaces: list.map((workspace) => ({
|
|
321
|
+
id: String(workspace.id ?? ""),
|
|
322
|
+
title: String(workspace.title ?? workspace.path ?? ""),
|
|
323
|
+
})).filter((workspace) => workspace.id !== ""),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 新建一条事项。 */
|
|
328
|
+
async function create(input) {
|
|
329
|
+
if (items.size >= MAX_ITEMS) throw new Error(t("schedule.tooManyItems", { limit: MAX_ITEMS }));
|
|
330
|
+
const fields = normalizeInput(input, t);
|
|
331
|
+
const now = new Date().toISOString();
|
|
332
|
+
const item = {
|
|
333
|
+
id: `item-${randomUUID()}`,
|
|
334
|
+
...fields,
|
|
335
|
+
createdAt: now,
|
|
336
|
+
updatedAt: now,
|
|
337
|
+
runs: [],
|
|
338
|
+
};
|
|
339
|
+
items.set(item.id, item);
|
|
340
|
+
scheduleOne(item);
|
|
341
|
+
await persist();
|
|
342
|
+
return item;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 改一条事项(只覆盖传进来的字段)。 */
|
|
346
|
+
async function update(id, patch) {
|
|
347
|
+
const current = items.get(id);
|
|
348
|
+
if (current === undefined) throw new Error(t("schedule.itemMissing", { id }));
|
|
349
|
+
const source = patch === null || typeof patch !== "object" ? {} : patch;
|
|
350
|
+
const merged = {
|
|
351
|
+
title: source.title === undefined ? current.title : source.title,
|
|
352
|
+
prompt: source.prompt === undefined ? current.prompt : source.prompt,
|
|
353
|
+
cron: source.cron === undefined ? current.cron : source.cron,
|
|
354
|
+
enabled: source.enabled === undefined ? current.enabled : source.enabled,
|
|
355
|
+
workspaceId: source.workspaceId === undefined ? current.workspaceId : source.workspaceId,
|
|
356
|
+
};
|
|
357
|
+
const fields = normalizeInput(merged, t);
|
|
358
|
+
const next = { ...current, ...fields, updatedAt: new Date().toISOString() };
|
|
359
|
+
// 展开旧记录会把旧的 workspaceId 一起带过来:用户把绑定改回「不绑定」时
|
|
360
|
+
// fields 里没有这个键,必须显式删掉,否则界面上看着解绑了、执行时还挂在旧工作区。
|
|
361
|
+
if (fields.workspaceId === undefined) delete next.workspaceId;
|
|
362
|
+
items.set(id, next);
|
|
363
|
+
scheduleOne(next);
|
|
364
|
+
await persist();
|
|
365
|
+
return next;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 删一条事项(顺带摘掉它的定时器)。 */
|
|
369
|
+
async function remove(id) {
|
|
370
|
+
if (!items.has(id)) throw new Error(t("schedule.itemMissing", { id }));
|
|
371
|
+
stopJob(id);
|
|
372
|
+
items.delete(id);
|
|
373
|
+
await persist();
|
|
374
|
+
return { removed: true };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 立即执行一条事项(面板上的「立即执行」)。 */
|
|
378
|
+
async function runNow(id) {
|
|
379
|
+
if (!armed) throw new Error(t("schedule.disabled"));
|
|
380
|
+
const current = items.get(id);
|
|
381
|
+
if (current === undefined) throw new Error(t("schedule.itemMissing", { id }));
|
|
382
|
+
if (running.has(id)) return current; // 正在跑就原样返回,面板保持「执行中…」
|
|
383
|
+
const updated = await execute(current);
|
|
384
|
+
items.set(id, updated);
|
|
385
|
+
await persist();
|
|
386
|
+
return updated;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 开关联动:关掉 = 停止全部定时触发并拒绝手动执行;打开 = 按 stored enabled 全量重建。
|
|
391
|
+
* 只改运行态,不写数据文件(开关本身是界面偏好,存在宿主设置里)。
|
|
392
|
+
*/
|
|
393
|
+
function setArmed(next) {
|
|
394
|
+
const value = next !== false;
|
|
395
|
+
if (value === armed) return { armed };
|
|
396
|
+
armed = value;
|
|
397
|
+
rescheduleAll();
|
|
398
|
+
return { armed };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 卸载:停掉全部定时器(数据留着,下次启动重建)。 */
|
|
402
|
+
function dispose() {
|
|
403
|
+
for (const id of [...jobs.keys()]) stopJob(id);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
file,
|
|
408
|
+
snapshot,
|
|
409
|
+
list: () => [...items.values()],
|
|
410
|
+
create,
|
|
411
|
+
update,
|
|
412
|
+
remove,
|
|
413
|
+
runNow,
|
|
414
|
+
preview: (cron) => ({ fires: previewRuns(cron, t) }),
|
|
415
|
+
setArmed,
|
|
416
|
+
isArmed: () => armed,
|
|
417
|
+
/** 当前真正挂着定时器的事项 id(自检与诊断用:能看出"任务在、但没排定"这种失效)。 */
|
|
418
|
+
scheduledIds: () => [...jobs.keys()],
|
|
419
|
+
dispose,
|
|
420
|
+
/** 开关变化后重建调度(settings 回调里用)。 */
|
|
421
|
+
rescheduleAll,
|
|
422
|
+
};
|
|
423
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-t-expert",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "T Expert — a 316-expert, 22-division roster with full Chinese translations, as a standalone DeepSeek Harness plugin, with a built-in multi-agent team engine (T Team).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -172,6 +172,7 @@
|
|
|
172
172
|
"typescript": "^5.9.3"
|
|
173
173
|
},
|
|
174
174
|
"dependencies": {
|
|
175
|
+
"croner": "^9.1.0",
|
|
175
176
|
"zod": "^4.4.3"
|
|
176
177
|
}
|
|
177
178
|
}
|
|
@@ -103,9 +103,15 @@ bash "{{TZ}}" install # 装到 profile(默认 desktop);可先 --
|
|
|
103
103
|
|
|
104
104
|
```bash
|
|
105
105
|
bash "{{TZ}}" publish-dry # 预演:只跑预检与打包预览
|
|
106
|
-
bash "{{TZ}}" publish
|
|
106
|
+
bash "{{TZ}}" publish --current # 直接发布 package.json 里的当前版本(不升版)—— 版本号在开发期就写好了
|
|
107
|
+
bash "{{TZ}}" publish # 交互选版本(菜单里选项 1 就是"发布当前版本");也可 --patch / --minor / --major / --version X.Y.Z
|
|
107
108
|
```
|
|
108
109
|
|
|
110
|
+
> 版本号的单一来源是 `package.json`:菜单 15 会把**当前版本号自动读出来**并作为首选,
|
|
111
|
+
> 客户端构建版本(`CLIENT_BUILD_VERSION`)与它必须一致(verify 有断言盯着),所以通常不需要在发布时再 bump。
|
|
112
|
+
> 回车=取消(发布不可逆,没有"回车即发"的默认)。
|
|
113
|
+
> 选「发布当前版本」不跑 `npm version`,但会**给该版本补一个 tag** —— 发出去的版本要能对应到具体提交,失败后也才能用 `--retry` 重试。
|
|
114
|
+
|
|
109
115
|
**发布前必须**:(a) 得到用户明确同意;(b) `experts check` 与 `verify` 全绿;(c) 工作区没有未提交的意外改动。
|
|
110
116
|
|
|
111
117
|
## 汇报约定
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015-2021 Hexagon <github.com/Hexagon>
|
|
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.
|
|
22
|
+
|