dsh-offpeak-guard 0.3.3 → 0.3.4
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 +10 -0
- package/README.en.md +13 -4
- package/README.md +14 -5
- package/lib/guard.js +68 -22
- package/lib/tools.js +126 -38
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.4 — 2026-11-15
|
|
4
|
+
|
|
5
|
+
- 收紧模型工具的会话边界:`offpeak_plan` 的 add/list/remove/clear 只能作用于调用者当前会话;显式传其它 `sessionId` 会被拒绝。
|
|
6
|
+
- `offpeak_guard` 的 `pause_now` / `resume_now` / `run_plans_now` / `exempt` / `unexempt` 全部只作用于当前会话;`pause_now` 不再全局 cancel 其它会话。
|
|
7
|
+
- 移除模型工具修改全局 `pause_mode` 的能力,峰时策略只能由用户在设置面板切换。
|
|
8
|
+
- `offpeak_status` / `offpeak_guard status` 只返回当前会话的暂停、豁免、规划明细;规则/倒计时仍为全局只读信息。
|
|
9
|
+
- 规划新增 `createdBySessionId`;自动执行时只投递创建者与目标一致的规划,旧版无创建者的规划标记失败并跳过。
|
|
10
|
+
- 允许目标会话删除/清空自己名下的旧版规划(没有 `createdBySessionId` 但 `sessionId` 匹配)。
|
|
11
|
+
- 新增 `test/guard-authorization.test.mjs`(真实 guard 的旧计划隔离、owner 过滤、addPlan 校验)和工具层 pause/status 授权测试。
|
|
12
|
+
|
|
3
13
|
## 0.3.3 — 2026-11-14
|
|
4
14
|
|
|
5
15
|
- 设置接口 `/plugins/dsh-offpeak-guard/action` 补上准入:路由只在 `webServer` 与 `connection` 两个服务都在位时注册,每个请求先过 `connection.requestRejection(req)`(Host/Origin 校验 + 浏览器令牌鉴权),返回 401/403 直接拒绝。此前本机任意网页都能改会话豁免、峰谷规则和暂停方式。
|
package/README.en.md
CHANGED
|
@@ -10,7 +10,8 @@ Schedule work around **DeepSeek peak/off-peak pricing** inside DeepSeek Harness:
|
|
|
10
10
|
- **Time awareness**: per DeepSeek's official pricing, Beijing-time weekdays (excluding Chinese statutory holidays) `09:00–12:00` and `14:00–18:00` are peak; every other hour (weekends, holidays, lunch, night) is off-peak at 50%. Holidays sync from a public API with a built-in 2026 fallback.
|
|
11
11
|
- **Model awareness**: exact `provider / model` detection; models containing `deepseek` are managed by default, and the settings panel can toggle each model. `peakPricedModels` / `exemptModels` provide exact control.
|
|
12
12
|
- **Peak strategy**: choose immediate pause or finish the current turn first; if a session has running subagents, they are allowed to finish and the parent is paused afterwards, then resumed at off-peak.
|
|
13
|
-
- **Session exemptions**: exempt any session from the
|
|
13
|
+
- **Session exemptions**: exempt the current session via `offpeak_guard exempt/unexempt`, or any session from the settings panel / sidebar row; un-exempt at any time. Model tools cannot target other sessions.
|
|
14
|
+
- **Tool boundary**: every session-level `offpeak_plan` / `offpeak_guard` action is limited to the calling session; tools cannot deliver prompts to, resume, or wake another session. Cross-session exemptions are a user action in the UI.
|
|
14
15
|
- **Work planning**:
|
|
15
16
|
- peak starts → running tasks are paused (the pending inbox is kept, no message is lost);
|
|
16
17
|
- a task started during peak → stopped immediately, no peak-priced tokens spent;
|
|
@@ -38,7 +39,7 @@ Click the clock to open the settings panel:
|
|
|
38
39
|
| --- | --- |
|
|
39
40
|
| `offpeak_status` | Current window, next switch, whether the current model is peak-priced, paused/planned lists |
|
|
40
41
|
| `offpeak_plan` | `add` / `list` / `remove` / `clear` off-peak plans |
|
|
41
|
-
| `offpeak_guard` | `status` / `pause_now` / `resume_now` / `run_plans_now` manual control |
|
|
42
|
+
| `offpeak_guard` | `status` / `pause_now` / `resume_now` / `run_plans_now` manual control (all limited to the current session; global strategy lives in settings) |
|
|
42
43
|
|
|
43
44
|
## Configuration
|
|
44
45
|
|
|
@@ -135,6 +136,14 @@ State and snapshots live in `$DSH_HOME/offpeak-guard/`:
|
|
|
135
136
|
|
|
136
137
|
Nothing is written into user workspaces.
|
|
137
138
|
|
|
139
|
+
## Tool session boundaries
|
|
140
|
+
|
|
141
|
+
- `offpeak_plan` can only add/list/remove/clear plans for the session that calls it; any other `sessionId` is rejected.
|
|
142
|
+
- `offpeak_guard`'s `pause_now`, `resume_now`, `run_plans_now`, `exempt`, and `unexempt` only act on the calling session; omitting `sessionId` never falls back to "all sessions". `status` returns only the current session's details, and `pause_mode` can only be changed by the user in the settings panel.
|
|
143
|
+
- Automatic peak pause/resume is unchanged: the guard still handles every root session by rule.
|
|
144
|
+
- Legacy plans without `createdBySessionId` have no provable authorization source; they are marked failed and skipped, and must be re-created from the target session.
|
|
145
|
+
- Cross-session exemptions are a user action in the settings panel or the sidebar row; model tools provide no cross-session delivery.
|
|
146
|
+
|
|
138
147
|
## How resuming works
|
|
139
148
|
|
|
140
149
|
Pause uses `sessionController.cancel`, which ends only the current turn and keeps the pending inbox. Resume uses `sessionController.prompt` to deliver a "continue where you left off" instruction to the original session; cold sessions are woken automatically. The resume text mentions the interruption time and the paused model so it stays traceable in the conversation.
|
|
@@ -146,6 +155,6 @@ Pause uses `sessionController.cancel`, which ends only the current turn and keep
|
|
|
146
155
|
- Named presets have fixed windows; use `preset: custom` for other providers or self-hosted pricing.
|
|
147
156
|
- Only root sessions with a working directory are handled; subagent sessions are skipped so parents do not hang.
|
|
148
157
|
- Subagent results are consumed by the parent after it resumes; the guard stops the parent at child completion but cannot intercept messages already written into the parent history.
|
|
149
|
-
- A session exemption affects future pauses/resumes only; use `resume_now` to resume an already paused
|
|
158
|
+
- A session exemption affects future pauses/resumes only; use `resume_now` in the current session to resume an already paused task.
|
|
150
159
|
- A plugin restart does not cancel tasks that are already running (so installing the plugin cannot kill the current conversation); the next peak or new task follows the rules again.
|
|
151
|
-
- Manual `resume_now` grants
|
|
160
|
+
- Manual `resume_now` grants the current session a 10-minute grace period so it is not paused again immediately.
|
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
- **时间识别**:按 DeepSeek 官方规则,北京时间周一至周五(不含中国法定节假日)`09:00–12:00`、`14:00–18:00` 为峰值原价;其余时间(含周末、法定节假日全天)为谷价,按 5 折计费。节假日默认从公开 API 动态同步,失败时回退内置 2026 列表。
|
|
11
11
|
- **模型识别**:按会话真实 `provider / model` 精确判断;默认名字含 `deepseek` 的模型受管理,可在设置面板逐模型开关,或用 `peakPricedModels` / `exemptModels` 精确控制。
|
|
12
12
|
- **峰时策略**:可选「立即暂停」或「完成当前轮后暂停」;会话有子代理在跑时先放行子代理,子代理结束后再暂停父会话,谷价开始时恢复。
|
|
13
|
-
-
|
|
13
|
+
- **会话豁免**:可让某个会话不参与插件的暂停/恢复(设置面板、左侧会话行,或 `offpeak_guard exempt/unexempt` 当前会话),随时取消豁免。
|
|
14
|
+
- **工具边界**:`offpeak_plan` / `offpeak_guard` 的所有会话级操作都只作用于调用它的当前会话;不能通过工具向其它会话投递指令、恢复任务或唤醒 Agent。跨会话的豁免请在界面里由用户操作。
|
|
14
15
|
- **工作规划**:
|
|
15
16
|
- 峰时开始 → 暂停正在运行的任务(保留待处理队列,不丢消息);
|
|
16
17
|
- 峰时新起的任务 → 立即停住,不花峰时 token;
|
|
@@ -38,8 +39,8 @@
|
|
|
38
39
|
| 工具 | 用途 |
|
|
39
40
|
| --- | --- |
|
|
40
41
|
| `offpeak_status` | 当前时段、下次切换、当前模型是否适用、暂停与规划清单 |
|
|
41
|
-
| `offpeak_plan` | `add` / `list` / `remove` / `clear`
|
|
42
|
-
| `offpeak_guard` | `status` / `pause_now` / `resume_now` / `run_plans_now`
|
|
42
|
+
| `offpeak_plan` | `add` / `list` / `remove` / `clear` 错峰规划(只作用于当前会话) |
|
|
43
|
+
| `offpeak_guard` | `status` / `pause_now` / `resume_now` / `run_plans_now` 手动控制(全部只作用于当前会话;全局策略在设置面板) |
|
|
43
44
|
|
|
44
45
|
## 配置
|
|
45
46
|
|
|
@@ -138,6 +139,14 @@
|
|
|
138
139
|
|
|
139
140
|
不写用户工作区。
|
|
140
141
|
|
|
142
|
+
## 工具会话边界
|
|
143
|
+
|
|
144
|
+
- `offpeak_plan` 只能为**调用工具的那个会话**排定/查看/删除/清空规划;显式传其它 `sessionId` 会被拒绝。
|
|
145
|
+
- `offpeak_guard` 的 `pause_now`、`resume_now`、`run_plans_now`、`exempt`、`unexempt` 同样只作用于当前会话;省略 `sessionId` 也不会退化成“操作所有会话”。`status` 只返回当前会话的暂停/豁免/规划明细,`pause_mode` 不再由模型工具修改,只能在设置面板切换。
|
|
146
|
+
- 自动峰谷暂停/恢复不受影响:守卫仍会按规则处理所有根会话。
|
|
147
|
+
- 旧版本创建的、没有 `createdBySessionId` 的规划没有可证明的授权来源,自动执行时会被标记失败并跳过,需要从目标会话重新排定。
|
|
148
|
+
- 跨会话的豁免/取消豁免请在设置面板或左侧会话行由用户操作;工具不提供跨会话投递。
|
|
149
|
+
|
|
141
150
|
## 恢复是怎么做的
|
|
142
151
|
|
|
143
152
|
暂停用 `sessionController.cancel`,它只结束当前一轮、保留待处理队列;恢复用 `sessionController.prompt` 向原会话投递一条「继续上次中断处」的指令,冷会话会被自动唤醒。恢复文本会注明中断时间与被暂停的模型,方便对话里追溯。
|
|
@@ -149,6 +158,6 @@
|
|
|
149
158
|
- 命名预设的窗口是固定的;需要其他供应商/自建计价时用 `preset: custom` 配 `peakWindows`。
|
|
150
159
|
- 只处理有工作目录、非子代理的根会话,避免打断子代理让父会话卡住。
|
|
151
160
|
- 子代理结果在父会话恢复后由模型继续消费;插件能在子代理结束时停住父会话,但无法在传输层拦截已经进入父会话历史的消息。
|
|
152
|
-
-
|
|
161
|
+
- 「会话豁免」只影响后续暂停/恢复,不会自动把已经暂停的任务立即恢复;需要时在当前会话用 `resume_now`。
|
|
153
162
|
- 插件自身重启时不会主动打断正在跑的任务(避免刚安装就停掉当前对话);之后进入峰时或新起任务仍按规则处理。
|
|
154
|
-
-
|
|
163
|
+
- 手动「立即恢复」会给当前会话 10 分钟豁免,避免峰时刚恢复又被停回去。
|
package/lib/guard.js
CHANGED
|
@@ -293,6 +293,10 @@ export function createGuard(ctx, config = {}) {
|
|
|
293
293
|
}
|
|
294
294
|
const label = await resolveModelLabel(selected.provider, selected.model);
|
|
295
295
|
const paused = state.interrupted.filter((item) => item.status === 'paused');
|
|
296
|
+
const interruptedItems = state.interrupted.filter(
|
|
297
|
+
(item) =>
|
|
298
|
+
item.status === 'paused' || item.status === 'waiting-turn' || item.status === 'waiting-children',
|
|
299
|
+
);
|
|
296
300
|
const plans = state.plans.filter((item) => item.status === 'queued');
|
|
297
301
|
const sessions = {};
|
|
298
302
|
for (const agent of liveAgents().slice(0, 30)) {
|
|
@@ -353,19 +357,22 @@ export function createGuard(ctx, config = {}) {
|
|
|
353
357
|
deferNewTurnsOnPeak: options.deferNewTurnsOnPeak,
|
|
354
358
|
resumeOnOffPeak: options.resumeOnOffPeak,
|
|
355
359
|
},
|
|
356
|
-
interrupted:
|
|
360
|
+
interrupted: interruptedItems.map((item) => ({
|
|
357
361
|
id: item.id,
|
|
358
362
|
sessionId: item.sessionId,
|
|
359
363
|
title: item.title,
|
|
360
364
|
model: item.model,
|
|
361
365
|
at: item.at,
|
|
362
366
|
cause: item.cause,
|
|
367
|
+
status: item.status,
|
|
363
368
|
})),
|
|
364
369
|
plans: plans.map((item) => ({
|
|
365
370
|
id: item.id,
|
|
366
371
|
title: item.title,
|
|
372
|
+
sessionId: item.sessionId,
|
|
367
373
|
createdAt: item.createdAt,
|
|
368
374
|
hasPrompt: typeof item.prompt === 'string' && item.prompt.trim() !== '',
|
|
375
|
+
createdBySessionId: item.createdBySessionId ?? null,
|
|
369
376
|
})),
|
|
370
377
|
counts: {
|
|
371
378
|
interrupted: paused.length,
|
|
@@ -434,7 +441,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
434
441
|
* 有子代理在跑时先放行,子代理结束后再按规则处理父会话。
|
|
435
442
|
* @param {'window-start'|'manual'} cause
|
|
436
443
|
*/
|
|
437
|
-
async function pauseRunning(cause) {
|
|
444
|
+
async function pauseRunning(cause, onlySessionId) {
|
|
438
445
|
if (busy.pause) return [];
|
|
439
446
|
busy.pause = true;
|
|
440
447
|
try {
|
|
@@ -443,6 +450,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
443
450
|
const handled = [];
|
|
444
451
|
let changed = false;
|
|
445
452
|
for (const agent of liveAgents()) {
|
|
453
|
+
if (onlySessionId !== undefined && agent.id !== onlySessionId) continue;
|
|
446
454
|
if (agent.status !== 'running') continue;
|
|
447
455
|
const session = guardableSession(agent);
|
|
448
456
|
if (session === null) continue;
|
|
@@ -629,17 +637,40 @@ export function createGuard(ctx, config = {}) {
|
|
|
629
637
|
}
|
|
630
638
|
}
|
|
631
639
|
|
|
632
|
-
/**
|
|
633
|
-
async function runDuePlans() {
|
|
640
|
+
/** 到谷价时段后执行排定的工作;ownerSessionId 用于工具只执行当前会话的规划。 */
|
|
641
|
+
async function runDuePlans(ownerSessionId) {
|
|
634
642
|
if (busy.plans) return [];
|
|
635
643
|
busy.plans = true;
|
|
636
644
|
try {
|
|
637
645
|
const state = await readState();
|
|
638
|
-
const due = state.plans.filter(
|
|
646
|
+
const due = state.plans.filter(
|
|
647
|
+
(item) =>
|
|
648
|
+
item.status === 'queued' &&
|
|
649
|
+
(ownerSessionId === undefined || item.createdBySessionId === ownerSessionId),
|
|
650
|
+
);
|
|
639
651
|
if (due.length === 0) return [];
|
|
640
652
|
const executed = [];
|
|
641
653
|
for (const plan of due) {
|
|
654
|
+
// 只执行创建者与目标一致的规划;旧版本没有创建者的规划无法证明
|
|
655
|
+
// 是当前会话排定的,直接隔离,避免被当成跨会话投递通道。
|
|
656
|
+
if (
|
|
657
|
+
typeof plan.createdBySessionId !== 'string' ||
|
|
658
|
+
plan.createdBySessionId === '' ||
|
|
659
|
+
plan.createdBySessionId !== plan.sessionId
|
|
660
|
+
) {
|
|
661
|
+
plan.status = 'failed';
|
|
662
|
+
plan.failedAt = Date.now();
|
|
663
|
+
plan.error = '旧版或跨会话规划没有明确授权,已停用;请从目标会话重新排定';
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
642
666
|
if (typeof plan.prompt === 'string' && plan.prompt.trim() !== '') {
|
|
667
|
+
const target = service('sessions')?.get?.(plan.sessionId);
|
|
668
|
+
if (target !== undefined && target !== null && target.header?.parentSession !== undefined) {
|
|
669
|
+
plan.status = 'failed';
|
|
670
|
+
plan.failedAt = Date.now();
|
|
671
|
+
plan.error = '规划目标不是根会话,已停用';
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
643
674
|
try {
|
|
644
675
|
await promptSession(plan.sessionId, planText(plan));
|
|
645
676
|
plan.status = 'done';
|
|
@@ -925,8 +956,8 @@ export function createGuard(ctx, config = {}) {
|
|
|
925
956
|
return { desc, state, snapshot: await snapshotOf(desc, state) };
|
|
926
957
|
}
|
|
927
958
|
|
|
928
|
-
async function pauseNow() {
|
|
929
|
-
const paused = await pauseRunning('manual');
|
|
959
|
+
async function pauseNow(sessionId) {
|
|
960
|
+
const paused = await pauseRunning('manual', sessionId);
|
|
930
961
|
await status();
|
|
931
962
|
return paused;
|
|
932
963
|
}
|
|
@@ -941,16 +972,18 @@ export function createGuard(ctx, config = {}) {
|
|
|
941
972
|
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
|
942
973
|
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
|
|
943
974
|
if (title === '' && prompt === '') throw new Error('规划至少要有 title 或 prompt');
|
|
944
|
-
const
|
|
945
|
-
const
|
|
946
|
-
if (
|
|
947
|
-
throw new Error('
|
|
975
|
+
const owner = typeof input.createdBySessionId === 'string' ? input.createdBySessionId.trim() : '';
|
|
976
|
+
const requested = typeof input.sessionId === 'string' && input.sessionId.trim() !== '' ? input.sessionId.trim() : '';
|
|
977
|
+
if (owner === '' || requested === '' || requested !== owner) {
|
|
978
|
+
throw new Error('规划只能排给调用它的当前会话');
|
|
948
979
|
}
|
|
980
|
+
const state = await readState();
|
|
949
981
|
const plan = {
|
|
950
982
|
id: shortId('plan'),
|
|
951
983
|
title: title === '' ? prompt.slice(0, 40) : title,
|
|
952
984
|
prompt,
|
|
953
|
-
sessionId,
|
|
985
|
+
sessionId: owner,
|
|
986
|
+
createdBySessionId: owner,
|
|
954
987
|
createdAt: Date.now(),
|
|
955
988
|
status: 'queued',
|
|
956
989
|
runAt: 'next-offpeak',
|
|
@@ -961,27 +994,39 @@ export function createGuard(ctx, config = {}) {
|
|
|
961
994
|
return plan;
|
|
962
995
|
}
|
|
963
996
|
|
|
964
|
-
async function removePlan(id) {
|
|
997
|
+
async function removePlan(id, ownerSessionId) {
|
|
998
|
+
if (typeof ownerSessionId !== 'string' || ownerSessionId === '') return false;
|
|
965
999
|
const state = await readState();
|
|
966
|
-
const
|
|
1000
|
+
const plan = state.plans.find((item) => item.id === id);
|
|
1001
|
+
const owned =
|
|
1002
|
+
plan !== undefined &&
|
|
1003
|
+
(plan.createdBySessionId === ownerSessionId ||
|
|
1004
|
+
((plan.createdBySessionId === undefined || plan.createdBySessionId === null) &&
|
|
1005
|
+
plan.sessionId === ownerSessionId));
|
|
1006
|
+
if (!owned) return false;
|
|
967
1007
|
state.plans = state.plans.filter((item) => item.id !== id);
|
|
968
|
-
|
|
969
|
-
if (removed) await stateFile.write(state);
|
|
1008
|
+
await stateFile.write(state);
|
|
970
1009
|
await status();
|
|
971
|
-
return
|
|
1010
|
+
return true;
|
|
972
1011
|
}
|
|
973
1012
|
|
|
974
|
-
async function clearPlans() {
|
|
1013
|
+
async function clearPlans(ownerSessionId) {
|
|
1014
|
+
if (typeof ownerSessionId !== 'string' || ownerSessionId === '') return 0;
|
|
975
1015
|
const state = await readState();
|
|
976
|
-
const
|
|
977
|
-
|
|
1016
|
+
const owned = (item) =>
|
|
1017
|
+
item.createdBySessionId === ownerSessionId ||
|
|
1018
|
+
((item.createdBySessionId === undefined || item.createdBySessionId === null) &&
|
|
1019
|
+
item.sessionId === ownerSessionId);
|
|
1020
|
+
const removed = state.plans.filter((item) => item.status === 'queued' && owned(item)).length;
|
|
1021
|
+
state.plans = state.plans.filter((item) => !(item.status === 'queued' && owned(item)));
|
|
978
1022
|
await stateFile.write(state);
|
|
979
1023
|
await status();
|
|
980
1024
|
return removed;
|
|
981
1025
|
}
|
|
982
1026
|
|
|
983
|
-
async function runPlansNow() {
|
|
984
|
-
|
|
1027
|
+
async function runPlansNow(ownerSessionId) {
|
|
1028
|
+
if (typeof ownerSessionId !== 'string' || ownerSessionId === '') return [];
|
|
1029
|
+
const executed = await runDuePlans(ownerSessionId);
|
|
985
1030
|
await status();
|
|
986
1031
|
return executed;
|
|
987
1032
|
}
|
|
@@ -1036,6 +1081,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
1036
1081
|
removePlan,
|
|
1037
1082
|
clearPlans,
|
|
1038
1083
|
runPlansNow,
|
|
1084
|
+
runDuePlans,
|
|
1039
1085
|
setExempt,
|
|
1040
1086
|
setModelRule,
|
|
1041
1087
|
setPauseMode,
|
package/lib/tools.js
CHANGED
|
@@ -19,6 +19,86 @@ const OUTPUT = {
|
|
|
19
19
|
|
|
20
20
|
const result = (text, data = {}) => ({ text, data });
|
|
21
21
|
|
|
22
|
+
/** 工具只能在真实会话内调用;返回调用者自己的 session id。 */
|
|
23
|
+
function callerSessionId(exec) {
|
|
24
|
+
const id = exec?.agent?.id;
|
|
25
|
+
if (typeof id !== 'string' || id === '') {
|
|
26
|
+
throw new Error('该操作只能在会话内调用,无法确定当前会话');
|
|
27
|
+
}
|
|
28
|
+
return id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 会话级操作默认且只允许作用于当前会话;显式传其它 sessionId 直接拒绝。 */
|
|
32
|
+
function ownSessionId(exec, requested) {
|
|
33
|
+
const own = callerSessionId(exec);
|
|
34
|
+
const target = typeof requested === 'string' && requested.trim() !== '' ? requested.trim() : own;
|
|
35
|
+
if (target !== own) {
|
|
36
|
+
throw new Error('只能操作当前会话;跨会话请从目标会话调用,或使用设置面板由用户操作');
|
|
37
|
+
}
|
|
38
|
+
return own;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 快照里的规划按创建者过滤,避免工具读取/管理其它会话的规划。 */
|
|
42
|
+
function ownPlanSnapshot(snapshot, owner) {
|
|
43
|
+
const plans = (Array.isArray(snapshot.plans) ? snapshot.plans : []).filter(
|
|
44
|
+
(item) =>
|
|
45
|
+
item.createdBySessionId === owner ||
|
|
46
|
+
((item.createdBySessionId === null || item.createdBySessionId === undefined) &&
|
|
47
|
+
item.sessionId === owner),
|
|
48
|
+
);
|
|
49
|
+
return {
|
|
50
|
+
...snapshot,
|
|
51
|
+
plans,
|
|
52
|
+
counts: { ...(snapshot.counts ?? {}), plans: plans.length },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 状态工具只返回当前会话的暂停/豁免/规划明细;规则与倒计时保持全局。 */
|
|
57
|
+
function ownStatusSnapshot(snapshot, owner) {
|
|
58
|
+
const interrupted = (Array.isArray(snapshot.interrupted) ? snapshot.interrupted : []).filter(
|
|
59
|
+
(item) => item.sessionId === owner,
|
|
60
|
+
);
|
|
61
|
+
const exemptSessions = (Array.isArray(snapshot.exemptSessions) ? snapshot.exemptSessions : []).filter(
|
|
62
|
+
(item) => item.sessionId === owner,
|
|
63
|
+
);
|
|
64
|
+
const plans = (Array.isArray(snapshot.plans) ? snapshot.plans : []).filter(
|
|
65
|
+
(item) =>
|
|
66
|
+
item.createdBySessionId === owner ||
|
|
67
|
+
((item.createdBySessionId === null || item.createdBySessionId === undefined) &&
|
|
68
|
+
item.sessionId === owner),
|
|
69
|
+
);
|
|
70
|
+
const ownSession =
|
|
71
|
+
snapshot.sessions !== null && typeof snapshot.sessions === 'object' ? snapshot.sessions[owner] : undefined;
|
|
72
|
+
const sessions = ownSession !== undefined ? { [owner]: ownSession } : {};
|
|
73
|
+
const model =
|
|
74
|
+
ownSession !== undefined
|
|
75
|
+
? {
|
|
76
|
+
id: ownSession.id,
|
|
77
|
+
provider: ownSession.provider,
|
|
78
|
+
label: ownSession.id,
|
|
79
|
+
peakPriced: ownSession.peakPriced === true,
|
|
80
|
+
}
|
|
81
|
+
: snapshot.model;
|
|
82
|
+
return {
|
|
83
|
+
...snapshot,
|
|
84
|
+
model,
|
|
85
|
+
interrupted,
|
|
86
|
+
exemptSessions,
|
|
87
|
+
plans,
|
|
88
|
+
sessions,
|
|
89
|
+
counts: {
|
|
90
|
+
...(snapshot.counts ?? {}),
|
|
91
|
+
interrupted: interrupted.filter((item) => item.status === 'paused').length,
|
|
92
|
+
waiting: interrupted.filter(
|
|
93
|
+
(item) => item.status === 'waiting-turn' || item.status === 'waiting-children',
|
|
94
|
+
).length,
|
|
95
|
+
exempt: exemptSessions.length,
|
|
96
|
+
plans: plans.length,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
22
102
|
function timeLine(desc) {
|
|
23
103
|
return `现在:${desc.localTime} ${desc.weekday}(${desc.timezone})`;
|
|
24
104
|
}
|
|
@@ -135,40 +215,50 @@ export function createOffpeakTools(guard) {
|
|
|
135
215
|
name: 'offpeak_status',
|
|
136
216
|
description:
|
|
137
217
|
'查看 DeepSeek 峰谷计价状态:现在处于峰值原价还是谷价时段、距下次切换多久、当前会话使用的模型是否适用峰谷计价,' +
|
|
138
|
-
'
|
|
218
|
+
'以及当前会话被暂停了哪些任务、排定了哪些错峰规划。要在动手前判断「现在做这件事是否贵」,或想确认插件是否在工作时用它。' +
|
|
219
|
+
'会话明细只返回当前会话,其它会话请在界面查看。',
|
|
139
220
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
140
221
|
output: OUTPUT,
|
|
141
|
-
async execute() {
|
|
222
|
+
async execute(_args, exec) {
|
|
223
|
+
const owner = callerSessionId(exec);
|
|
142
224
|
const { desc, snapshot } = await guard.status();
|
|
143
|
-
|
|
225
|
+
const mine = ownStatusSnapshot(snapshot, owner);
|
|
226
|
+
return result(statusText(desc, mine), mine);
|
|
144
227
|
},
|
|
145
228
|
},
|
|
146
229
|
{
|
|
147
230
|
name: 'offpeak_plan',
|
|
148
231
|
description:
|
|
149
|
-
'
|
|
232
|
+
'错峰工作规划:把一件稍后再做的事排进队列,峰时排定、谷价时段自动执行(只会向调用本工具的当前会话发一条继续指令)。' +
|
|
150
233
|
'action=add 需要 title 或 prompt;action=list 查看;action=remove 需要 id;action=clear 清空未执行的规划。' +
|
|
151
|
-
'
|
|
234
|
+
'所有操作只作用于当前会话,显式传其它 sessionId 会被拒绝;适合「现在峰时,别开长任务,等 12:00 谷价再跑」这类安排。',
|
|
152
235
|
parameters: {
|
|
153
236
|
type: 'object',
|
|
154
237
|
properties: {
|
|
155
238
|
action: { type: 'string', enum: ['add', 'list', 'remove', 'clear'], description: '要执行的操作,默认 add' },
|
|
156
239
|
title: { type: 'string', description: '规划名称(add 时建议填写)' },
|
|
157
|
-
prompt: { type: 'string', description: '
|
|
240
|
+
prompt: { type: 'string', description: '到谷价时段要发给当前会话的完整指令(add 时可选;省略则只做提醒)' },
|
|
158
241
|
id: { type: 'string', description: 'remove 时要删除的规划 id' },
|
|
159
|
-
sessionId: {
|
|
242
|
+
sessionId: {
|
|
243
|
+
type: 'string',
|
|
244
|
+
description: '仅为兼容保留:只接受当前会话 id;省略即当前会话,传其它会话会被拒绝',
|
|
245
|
+
},
|
|
160
246
|
},
|
|
161
247
|
additionalProperties: false,
|
|
162
248
|
},
|
|
163
249
|
output: OUTPUT,
|
|
164
250
|
async execute(args, exec) {
|
|
165
251
|
const action = typeof args?.action === 'string' ? args.action : 'add';
|
|
252
|
+
const owner = callerSessionId(exec);
|
|
166
253
|
if (action === 'add') {
|
|
254
|
+
if (typeof args?.sessionId === 'string' && args.sessionId.trim() !== '' && args.sessionId.trim() !== owner) {
|
|
255
|
+
throw new Error('只能为当前会话排定规划;跨会话请从目标会话调用');
|
|
256
|
+
}
|
|
167
257
|
const plan = await guard.addPlan({
|
|
168
258
|
title: args?.title,
|
|
169
259
|
prompt: args?.prompt,
|
|
170
|
-
sessionId:
|
|
171
|
-
|
|
260
|
+
sessionId: owner,
|
|
261
|
+
createdBySessionId: owner,
|
|
172
262
|
});
|
|
173
263
|
const desc = guard.describe();
|
|
174
264
|
const when = desc.phase === 'offpeak' ? `下一轮检查(约 ${Math.round(guard.options.pollMs / 1000)} 秒内)` : `${desc.next.label} 谷价开始后`;
|
|
@@ -179,40 +269,39 @@ export function createOffpeakTools(guard) {
|
|
|
179
269
|
}
|
|
180
270
|
if (action === 'remove') {
|
|
181
271
|
if (typeof args?.id !== 'string' || args.id === '') throw new Error('remove 需要 id');
|
|
182
|
-
const removed = await guard.removePlan(args.id);
|
|
272
|
+
const removed = await guard.removePlan(args.id, owner);
|
|
183
273
|
return result(removed ? `已删除规划 ${args.id}。` : `没有找到规划 ${args.id}。`, { removed, id: args.id });
|
|
184
274
|
}
|
|
185
275
|
if (action === 'clear') {
|
|
186
|
-
const removed = await guard.clearPlans();
|
|
276
|
+
const removed = await guard.clearPlans(owner);
|
|
187
277
|
return result(`已清空 ${removed} 项未执行的规划。`, { removed });
|
|
188
278
|
}
|
|
189
279
|
const { desc, snapshot } = await guard.status();
|
|
190
|
-
|
|
280
|
+
const mine = ownPlanSnapshot(snapshot, owner);
|
|
281
|
+
return result(planLine(mine) + `\n${nextLine(desc)}`, {
|
|
282
|
+
plans: mine.plans,
|
|
283
|
+
counts: { plans: mine.plans.length },
|
|
284
|
+
});
|
|
191
285
|
},
|
|
192
286
|
},
|
|
193
287
|
{
|
|
194
288
|
name: 'offpeak_guard',
|
|
195
289
|
description:
|
|
196
|
-
'峰谷守卫的手动控制:status
|
|
197
|
-
'resume_now
|
|
198
|
-
'exempt / unexempt
|
|
199
|
-
'
|
|
290
|
+
'峰谷守卫的手动控制:status 查看当前会话状态;pause_now 按当前策略暂停当前会话的峰谷计价任务(峰时才生效);' +
|
|
291
|
+
'resume_now 立刻恢复当前会话被暂停的任务(谷价时才自动恢复,手动可在任何时段强制恢复);run_plans_now 立刻执行当前会话排定的错峰规划;' +
|
|
292
|
+
'exempt / unexempt 让当前会话豁免/取消豁免。' +
|
|
293
|
+
'所有操作都只作用于调用本工具的当前会话;全局峰时策略请在设置面板由用户修改。',
|
|
200
294
|
parameters: {
|
|
201
295
|
type: 'object',
|
|
202
296
|
properties: {
|
|
203
297
|
action: {
|
|
204
298
|
type: 'string',
|
|
205
|
-
enum: ['status', 'pause_now', 'resume_now', 'run_plans_now', 'exempt', 'unexempt'
|
|
299
|
+
enum: ['status', 'pause_now', 'resume_now', 'run_plans_now', 'exempt', 'unexempt'],
|
|
206
300
|
description: '要执行的操作,默认 status',
|
|
207
301
|
},
|
|
208
302
|
sessionId: {
|
|
209
303
|
type: 'string',
|
|
210
|
-
description: 'resume_now
|
|
211
|
-
},
|
|
212
|
-
mode: {
|
|
213
|
-
type: 'string',
|
|
214
|
-
enum: ['pause-now', 'finish-turn'],
|
|
215
|
-
description: 'pause_mode 时的策略:立即暂停或完成当前轮后暂停',
|
|
304
|
+
description: '仅为兼容保留:只接受当前会话 id;resume_now / exempt / unexempt 传其它会话会被拒绝',
|
|
216
305
|
},
|
|
217
306
|
},
|
|
218
307
|
additionalProperties: false,
|
|
@@ -220,15 +309,17 @@ export function createOffpeakTools(guard) {
|
|
|
220
309
|
output: OUTPUT,
|
|
221
310
|
async execute(args, exec) {
|
|
222
311
|
const action = typeof args?.action === 'string' ? args.action : 'status';
|
|
312
|
+
const owner = callerSessionId(exec);
|
|
223
313
|
if (action === 'pause_now') {
|
|
224
|
-
|
|
314
|
+
ownSessionId(exec, args?.sessionId);
|
|
315
|
+
const handled = await guard.pauseNow(owner);
|
|
225
316
|
const paused = handled.filter((item) => item.status === 'paused');
|
|
226
317
|
const waiting = handled.filter((item) => item.status === 'waiting-turn' || item.status === 'waiting-children');
|
|
227
318
|
const names = handled.map((item) => item.title ?? item.sessionId).join('、');
|
|
228
319
|
return result(
|
|
229
320
|
handled.length === 0
|
|
230
|
-
? '
|
|
231
|
-
:
|
|
321
|
+
? '当前会话没有可暂停的任务(可能不在峰时,或任务模型不适用峰谷计价)。'
|
|
322
|
+
: `已处理当前会话的 ${handled.length} 个任务:${names}。` +
|
|
232
323
|
(paused.length > 0 ? ` 已暂停 ${paused.length} 个。` : '') +
|
|
233
324
|
(waiting.length > 0 ? ` ${waiting.length} 个等当前轮/子代理结束后暂停。` : '') +
|
|
234
325
|
' 谷价开始后会自动恢复。',
|
|
@@ -236,18 +327,16 @@ export function createOffpeakTools(guard) {
|
|
|
236
327
|
);
|
|
237
328
|
}
|
|
238
329
|
if (action === 'resume_now') {
|
|
239
|
-
|
|
240
|
-
const resumed = await guard.resumeNow(
|
|
241
|
-
return result(resumed.length === 0 ? '
|
|
330
|
+
ownSessionId(exec, args?.sessionId);
|
|
331
|
+
const resumed = await guard.resumeNow([owner]);
|
|
332
|
+
return result(resumed.length === 0 ? '当前会话没有需要恢复的任务。' : `已恢复 ${resumed.length} 个任务。`, { resumed });
|
|
242
333
|
}
|
|
243
334
|
if (action === 'run_plans_now') {
|
|
244
|
-
const executed = await guard.runPlansNow();
|
|
245
|
-
return result(executed.length === 0 ? '
|
|
335
|
+
const executed = await guard.runPlansNow(owner);
|
|
336
|
+
return result(executed.length === 0 ? '当前会话没有待执行的错峰规划。' : `已执行 ${executed.length} 项规划。`, { executed });
|
|
246
337
|
}
|
|
247
338
|
if (action === 'exempt' || action === 'unexempt') {
|
|
248
|
-
const sessionId =
|
|
249
|
-
typeof args?.sessionId === 'string' && args.sessionId !== '' ? args.sessionId : exec?.agent?.id;
|
|
250
|
-
if (typeof sessionId !== 'string' || sessionId === '') throw new Error('需要 sessionId');
|
|
339
|
+
const sessionId = ownSessionId(exec, args?.sessionId);
|
|
251
340
|
const resultState = await guard.setExempt(sessionId, action === 'exempt');
|
|
252
341
|
return result(
|
|
253
342
|
action === 'exempt' ? `已豁免会话 ${sessionId},守卫不会再暂停它。` : `已取消会话 ${sessionId} 的豁免。`,
|
|
@@ -255,12 +344,11 @@ export function createOffpeakTools(guard) {
|
|
|
255
344
|
);
|
|
256
345
|
}
|
|
257
346
|
if (action === 'pause_mode') {
|
|
258
|
-
|
|
259
|
-
await guard.setPauseMode(mode);
|
|
260
|
-
return result(mode === 'finish-turn' ? '已设置为:完成当前轮后暂停。' : '已设置为:峰时立即暂停。', { mode });
|
|
347
|
+
throw new Error('pause_mode 只能由用户在设置面板修改,模型工具不提供全局策略修改');
|
|
261
348
|
}
|
|
262
349
|
const { desc, snapshot } = await guard.status();
|
|
263
|
-
|
|
350
|
+
const mine = ownStatusSnapshot(snapshot, owner);
|
|
351
|
+
return result(statusText(desc, mine), mine);
|
|
264
352
|
},
|
|
265
353
|
},
|
|
266
354
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-offpeak-guard",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "DeepSeek peak/off-peak pricing guard for DSH: detects the price window and current model, pauses running tasks at peak, resumes them automatically when off-peak begins, and schedules work for the next off-peak window.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|