dsh-offpeak-guard 0.3.2 → 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 CHANGED
@@ -1,5 +1,23 @@
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
+
13
+ ## 0.3.3 — 2026-11-14
14
+
15
+ - 设置接口 `/plugins/dsh-offpeak-guard/action` 补上准入:路由只在 `webServer` 与 `connection` 两个服务都在位时注册,每个请求先过 `connection.requestRejection(req)`(Host/Origin 校验 + 浏览器令牌鉴权),返回 401/403 直接拒绝。此前本机任意网页都能改会话豁免、峰谷规则和暂停方式。
16
+ - 拿不到 `connection` 或它抛错时一律按拒绝处理(失败关闭),不退回到只检查 Host/Origin/回环地址。
17
+ - 请求体加 64 KiB 上限,超出返回 413;其余错误仍返回 400。
18
+ - 新增 `test/admission.test.mjs`:覆盖缺服务、读取抛错、`requestRejection` 抛错、放行与 401/403 透传五条路径。
19
+ - 适配 DSH 0.2.2-rc.2 的 `@deepseek-ai/dsh-client-connection` 服务约定(`requestRejection` 接收 `{ headers }`)。
20
+
3
21
  ## 0.3.2 — 2026-11-06
4
22
 
5
23
  - 修复设置面板切换峰时策略时报 `Unexpected end of JSON input`:Host 启动顺序可能让 `webServer` 在插件 apply 时尚未就绪,导致设置路由根本没注册;改用 `ctx.inject(['webServer'], ...)` 后再挂载路由。
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 guard (settings panel or `offpeak_guard exempt/unexempt`), and un-exempt it at any time.
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
 
@@ -110,6 +111,15 @@ Click the sidebar clock to open it:
110
111
  - **Session exemptions**: searchable list of live root sessions; hover a session row in the left sidebar for a shield button, or use the **Exempt this session** button in the composer strip to toggle the current conversation directly;
111
112
  - **Holidays**: show the dynamic sync state.
112
113
 
114
+ ### Route admission
115
+
116
+ The settings route `/plugins/dsh-offpeak-guard/action` goes through host authentication:
117
+
118
+ - the route is only registered once both `webServer` and `connection` are available;
119
+ - every request passes `connection.requestRejection(req)` first — its Host/Origin fence stops DNS rebinding and cross-site calls, and its browser authentication stops pages that never logged in; a 401/403 is answered before any business logic runs;
120
+ - a missing `connection`, or one that throws, rejects the request (fail closed); there is no fallback to checking only Host, Origin or the loopback address;
121
+ - request bodies are capped at 64 KiB and answer 413 above that.
122
+
113
123
  ## Subagents and waiting
114
124
 
115
125
  - Running subagents are allowed to finish; the parent is paused after the last child ends and resumed off-peak;
@@ -126,6 +136,14 @@ State and snapshots live in `$DSH_HOME/offpeak-guard/`:
126
136
 
127
137
  Nothing is written into user workspaces.
128
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
+
129
147
  ## How resuming works
130
148
 
131
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.
@@ -137,6 +155,6 @@ Pause uses `sessionController.cancel`, which ends only the current turn and keep
137
155
  - Named presets have fixed windows; use `preset: custom` for other providers or self-hosted pricing.
138
156
  - Only root sessions with a working directory are handled; subagent sessions are skipped so parents do not hang.
139
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.
140
- - A session exemption affects future pauses/resumes only; use `resume_now` to resume an already paused session.
158
+ - A session exemption affects future pauses/resumes only; use `resume_now` in the current session to resume an already paused task.
141
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.
142
- - Manual `resume_now` grants those sessions a 10-minute grace period so they are not paused again immediately.
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
- - **会话豁免**:可让某个会话不参与插件的暂停/恢复(设置面板或 `offpeak_guard exempt/unexempt`),随时取消豁免。
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
 
@@ -112,6 +113,15 @@
112
113
 
113
114
  面板里的修改立即生效并持久化。
114
115
 
116
+ ### 接口准入
117
+
118
+ 设置接口 `/plugins/dsh-offpeak-guard/action` 走宿主认证,不对外开放:
119
+
120
+ - 路由在 `webServer` 与 `connection` 两个服务都在位时才注册;
121
+ - 每个请求先过 `connection.requestRejection(req)`——Host/Origin 校验挡住 DNS rebinding 与跨站调用,浏览器令牌鉴权挡住没登录的本机网页;返回 401/403 时直接拒绝,不进入业务逻辑;
122
+ - 拿不到 `connection` 或它抛错时一律按拒绝处理(失败关闭),不会退回只检查 Host、Origin 或回环地址;
123
+ - 请求体上限 64 KiB,超出返回 413。
124
+
115
125
  ## 子代理与等待
116
126
 
117
127
  - 峰时开始时若会话有**正在运行的子代理**,先放行父会话与子代理;
@@ -129,6 +139,14 @@
129
139
 
130
140
  不写用户工作区。
131
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
+
132
150
  ## 恢复是怎么做的
133
151
 
134
152
  暂停用 `sessionController.cancel`,它只结束当前一轮、保留待处理队列;恢复用 `sessionController.prompt` 向原会话投递一条「继续上次中断处」的指令,冷会话会被自动唤醒。恢复文本会注明中断时间与被暂停的模型,方便对话里追溯。
@@ -140,6 +158,6 @@
140
158
  - 命名预设的窗口是固定的;需要其他供应商/自建计价时用 `preset: custom` 配 `peakWindows`。
141
159
  - 只处理有工作目录、非子代理的根会话,避免打断子代理让父会话卡住。
142
160
  - 子代理结果在父会话恢复后由模型继续消费;插件能在子代理结束时停住父会话,但无法在传输层拦截已经进入父会话历史的消息。
143
- - 「会话豁免」只影响后续暂停/恢复,不会自动把已经暂停的任务立即恢复;需要时用 `resume_now`。
161
+ - 「会话豁免」只影响后续暂停/恢复,不会自动把已经暂停的任务立即恢复;需要时在当前会话用 `resume_now`。
144
162
  - 插件自身重启时不会主动打断正在跑的任务(避免刚安装就停掉当前对话);之后进入峰时或新起任务仍按规则处理。
145
- - 手动「立即恢复」会给这些会话 10 分钟豁免,避免峰时刚恢复又被停回去。
163
+ - 手动「立即恢复」会给当前会话 10 分钟豁免,避免峰时刚恢复又被停回去。
package/index.js CHANGED
@@ -149,11 +149,25 @@ export function envConfig(env = process.env) {
149
149
  return out;
150
150
  }
151
151
 
152
+ /** 设置接口只收几个字段的小 JSON,超过这个体积按可疑请求拒绝。 */
153
+ const MAX_BODY_BYTES = 64 * 1024;
154
+
152
155
  async function readJsonBody(req) {
153
- let raw = '';
154
- for await (const chunk of req) raw += chunk;
155
- if (raw === '') return {};
156
- return JSON.parse(raw);
156
+ const chunks = [];
157
+ let size = 0;
158
+ for await (const chunk of req) {
159
+ size += chunk?.byteLength ?? Buffer.byteLength(String(chunk));
160
+ if (size > MAX_BODY_BYTES) {
161
+ // 先排空再拒绝,让客户端读到响应而不是被一刀切断连接。
162
+ req.resume();
163
+ const error = new Error('request body too large');
164
+ error.statusCode = 413;
165
+ throw error;
166
+ }
167
+ chunks.push(chunk);
168
+ }
169
+ if (size === 0) return {};
170
+ return JSON.parse(Buffer.concat(chunks, size).toString('utf8'));
157
171
  }
158
172
 
159
173
  function sendJson(res, status, payload) {
@@ -164,6 +178,33 @@ function sendJson(res, status, payload) {
164
178
  res.end(body);
165
179
  }
166
180
 
181
+ /**
182
+ * 设置路由的准入栅栏。
183
+ *
184
+ * `connection.requestRejection` 是宿主唯一的本机接口准入点:它同时做 Host/Origin
185
+ * 校验(挡 DNS rebinding 和跨站请求)与浏览器令牌鉴权(挡没登录的本机网页)。
186
+ * 没有它,本机任意网页都能改会话豁免、峰谷规则和暂停方式。
187
+ *
188
+ * 拿不到 `connection`、或它自己抛错时一律拒绝(失败关闭),不退回到只检查
189
+ * Host/Origin/回环地址——那条路挡不住别的网页。
190
+ */
191
+ export function admissionRejection(ctx, req) {
192
+ let connection;
193
+ try {
194
+ connection = Reflect.get(ctx, 'connection');
195
+ } catch {
196
+ return 403;
197
+ }
198
+ if (connection === undefined || connection === null || typeof connection.requestRejection !== 'function') {
199
+ return 403;
200
+ }
201
+ try {
202
+ return connection.requestRejection({ headers: req.headers }) ?? undefined;
203
+ } catch {
204
+ return 403;
205
+ }
206
+ }
207
+
167
208
  export function apply(ctx, rawConfig = {}) {
168
209
  const config = { ...(rawConfig ?? {}), ...envConfig() };
169
210
  const guard = createGuard(ctx, config);
@@ -197,7 +238,7 @@ export function apply(ctx, rawConfig = {}) {
197
238
  };
198
239
  }, 'offpeak-guard: tools');
199
240
 
200
- ctx.inject(['webServer'], (webCtx) => {
241
+ ctx.inject(['webServer', 'connection'], (webCtx) => {
201
242
  webCtx.effect(
202
243
  () =>
203
244
  webCtx.webServer.register({
@@ -205,6 +246,11 @@ export function apply(ctx, rawConfig = {}) {
205
246
  path: '/plugins/dsh-offpeak-guard',
206
247
  async handler(req, res) {
207
248
  try {
249
+ const rejection = admissionRejection(webCtx, req);
250
+ if (rejection !== undefined) {
251
+ sendJson(res, rejection, { ok: false, error: 'unauthorized' });
252
+ return;
253
+ }
208
254
  const url = new URL(req.url || '/', 'http://x');
209
255
  const rest = url.pathname.replace(/^\/plugins\/dsh-offpeak-guard/, '') || '/';
210
256
  if (req.method !== 'POST' || rest !== '/action') {
@@ -234,7 +280,8 @@ export function apply(ctx, rawConfig = {}) {
234
280
  }
235
281
  sendJson(res, 400, { ok: false, error: 'unknown action' });
236
282
  } catch (error) {
237
- sendJson(res, 400, { ok: false, error: String(error?.message ?? error) });
283
+ const status = Number.isInteger(error?.statusCode) ? error.statusCode : 400;
284
+ sendJson(res, status, { ok: false, error: String(error?.message ?? error) });
238
285
  }
239
286
  },
240
287
  }),
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: paused.map((item) => ({
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((item) => item.status === 'queued');
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 state = await readState();
945
- const sessionId = typeof input.sessionId === 'string' && input.sessionId.trim() !== '' ? input.sessionId.trim() : input.fallbackSessionId;
946
- if (typeof sessionId !== 'string' || sessionId === '') {
947
- throw new Error('无法确定要执行的会话:请在已知会话里调用,或显式传 sessionId');
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 before = state.plans.length;
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
- const removed = before !== state.plans.length;
969
- if (removed) await stateFile.write(state);
1008
+ await stateFile.write(state);
970
1009
  await status();
971
- return removed;
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 removed = state.plans.filter((item) => item.status === 'queued').length;
977
- state.plans = state.plans.filter((item) => item.status !== 'queued');
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
- const executed = await runDuePlans();
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
- return result(statusText(desc, snapshot), snapshot);
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
- '适合「现在峰时,别开长任务,等 12:00 谷价再跑」这类安排。',
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: '到谷价时段要发给会话的完整指令(add 时可选;省略则只做提醒)' },
240
+ prompt: { type: 'string', description: '到谷价时段要发给当前会话的完整指令(add 时可选;省略则只做提醒)' },
158
241
  id: { type: 'string', description: 'remove 时要删除的规划 id' },
159
- sessionId: { type: 'string', description: '执行该规划的会话 id;省略时用调用本工具的会话' },
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: args?.sessionId,
171
- fallbackSessionId: exec?.agent?.id,
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
- return result(planLine(snapshot) + `\n${nextLine(desc)}`, snapshot);
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 查看完整状态;pause_now 立刻按当前策略暂停峰谷计价任务(峰时才生效);' +
197
- 'resume_now 立刻恢复被暂停的任务(谷价时才自动恢复,手动可在任何时段强制恢复);run_plans_now 立刻执行排定的错峰规划;' +
198
- 'exempt / unexempt 让某个会话豁免/取消豁免;pauseMode 设置峰时是立即暂停还是完成当前轮后暂停。' +
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', 'pause_mode'],
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 时只恢复这个会话;exempt/unexempt 时指定会话(省略用当前会话)',
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
- const handled = await guard.pauseNow();
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
- : `已处理 ${handled.length} 个任务:${names}。` +
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
- const sessionId = typeof args?.sessionId === 'string' && args.sessionId !== '' ? args.sessionId : undefined;
240
- const resumed = await guard.resumeNow(sessionId === undefined ? undefined : [sessionId]);
241
- return result(resumed.length === 0 ? '没有需要恢复的任务。' : `已恢复 ${resumed.length} 个任务。`, { resumed });
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 ? '没有待执行的错峰规划。' : `已执行 ${executed.length} 项规划。`, { executed });
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
- const mode = args?.mode === 'finish-turn' ? 'finish-turn' : 'pause-now';
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
- return result(statusText(desc, snapshot), snapshot);
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.2",
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",