dsh-offpeak-guard 0.3.0 → 0.3.2

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 — 2026-11-06
4
+
5
+ - 修复设置面板切换峰时策略时报 `Unexpected end of JSON input`:Host 启动顺序可能让 `webServer` 在插件 apply 时尚未就绪,导致设置路由根本没注册;改用 `ctx.inject(['webServer'], ...)` 后再挂载路由。
6
+ - 客户端对非 JSON/空响应改为显示 `HTTP <status>`,不再把浏览器 JSON 解析异常直接抛到面板底部。
7
+ - 已验证设置面板「立即暂停 / 完成当前轮后暂停」切换成功,路由返回 200。
8
+
9
+ ## 0.3.1 — 2026-11-06
10
+
11
+ - 左侧会话行悬停操作新增盾牌按钮:直接在对话索引里豁免/取消豁免,豁免中的会话行保留盾牌标记。
12
+ - 输入框状态条新增「豁免本会话 / 取消豁免」按钮,不用打开设置面板即可切换当前对话。
13
+ - 设置面板的会话豁免列表新增搜索过滤,会话多时可按标题或 session id 查找。
14
+ - 设置接口的浏览器请求补 `credentials: same-origin`,确保桌面 Web 主机下开关生效。
15
+
3
16
  ## 0.3.0 — 2026-11-06
4
17
 
5
18
  - 新增峰时策略 `pauseMode`:`pause-now` 立即暂停,`finish-turn` 完成当前轮后暂停;设置面板可切换。
package/README.en.md CHANGED
@@ -16,7 +16,7 @@ Schedule work around **DeepSeek peak/off-peak pricing** inside DeepSeek Harness:
16
16
  - a task started during peak → stopped immediately, no peak-priced tokens spent;
17
17
  - off-peak starts → interrupted tasks are handed back to their session automatically;
18
18
  - the `offpeak_plan` tool queues "run this at noon" work for the next off-peak window.
19
- - **UI**: a status strip under the composer (state, countdown, model, paused/planned counts), plus a compact clock at the sidebar foot that blinks red before peak starts and green before off-peak starts.
19
+ - **UI**: a status strip under the composer (state, countdown, model, paused/planned counts), plus a compact clock at the sidebar foot that blinks red before peak starts and green before off-peak starts. Click the clock to open settings. Hover a session row in the left sidebar for a shield button to exempt/un-exempt it; exempt rows keep a shield marker, and the composer strip has an **Exempt this session** toggle.
20
20
 
21
21
  ![Status strip under the composer](docs/peak-strip.png)
22
22
 
@@ -103,11 +103,11 @@ Deployments can inject settings with environment variables. Priority: **env > co
103
103
 
104
104
  ## Settings panel
105
105
 
106
- Click the sidebar clock:
106
+ Click the sidebar clock to open it:
107
107
 
108
108
  - **Peak strategy**: pause immediately or finish the current turn first;
109
109
  - **Models**: provider-grouped catalog with a checkbox per model (persisted as `modelRules`, overriding `modelPattern`);
110
- - **Session exemptions**: exempt/un-exempt live root sessions;
110
+ - **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
111
  - **Holidays**: show the dynamic sync state.
112
112
 
113
113
  ## Subagents and waiting
package/README.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - 谷价开始 → 自动把被打断的任务交还会话继续执行;
18
18
  - 另提供 `offpeak_plan` 工具,把「12 点再跑」这类事排进下一个谷价窗口。
19
19
  - **界面**:输入框下方一条状态条,显示当前峰/谷、距下次切换的倒计时、当前模型,以及暂停/规划数量;左侧栏底部另有一枚常驻计时,临近切换时闪色提醒(峰价快开始闪红、谷价快开始闪绿)。**点击左栏计时打开设置面板**:切换峰时策略、逐模型管理、会话豁免、查看节假日同步状态。
20
+ - **就地豁免**:把鼠标移到左侧会话行,行尾会出现盾牌按钮,点一下即可豁免/取消该会话;豁免中的会话行会显示盾牌标记。输入框状态条右侧也有「豁免本会话」按钮,直接在当前对话里切换。
20
21
 
21
22
  ![输入框下方的峰谷状态条](docs/peak-strip.png)
22
23
 
package/client.js CHANGED
@@ -131,6 +131,8 @@ window.__ModuleLoader__.load({
131
131
  if (mounted.current && parsed !== null && typeof parsed === 'object') {
132
132
  setSnapshot(parsed);
133
133
  setError(null);
134
+ sharedStatus.snapshot = parsed;
135
+ for (const listener of sharedStatus.listeners) listener();
134
136
  }
135
137
  } catch (caught) {
136
138
  if (mounted.current) setError(String(caught?.message ?? caught));
@@ -152,12 +154,102 @@ window.__ModuleLoader__.load({
152
154
  return { snapshot, error, refresh: load };
153
155
  }
154
156
 
157
+ const sharedStatus = { snapshot: null, listeners: new Set(), timer: null, loading: false, ctx: null };
158
+
159
+ async function readSharedStatus(ctx) {
160
+ const remote = ctx.remote;
161
+ if (!remote || typeof remote.session?.list !== 'function' || typeof remote.workspaceFiles?.read !== 'function') return null;
162
+ const listed = await remote.session.list({});
163
+ const items = listed?.value?.items ?? listed?.items ?? [];
164
+ if (!Array.isArray(items) || items.length === 0) return null;
165
+ const sorted = [...items].sort(
166
+ (left, right) => Number(right.updatedAt ?? right.lastActivityAt ?? 0) - Number(left.updatedAt ?? left.lastActivityAt ?? 0),
167
+ );
168
+ const home = remote.$host?.home;
169
+ if (typeof home !== 'string' || home.length === 0) return null;
170
+ const candidates = sorted
171
+ .filter((item) => item?.parentSession === undefined && item?.parentSessionId === undefined)
172
+ .map((item) => item?.sessionId ?? item?.id)
173
+ .filter((id) => typeof id === 'string' && id.length > 0)
174
+ .slice(0, 5);
175
+ for (const sessionId of candidates) {
176
+ try {
177
+ const result = await remote.workspaceFiles.read(sessionId, `${home}/.dsh/offpeak-guard/status.json`, {});
178
+ const parsed = JSON.parse(snapshotTextOf(result));
179
+ if (parsed !== null && typeof parsed === 'object') return parsed;
180
+ } catch {
181
+ /* 换下一个最近会话再试 */
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ async function loadSharedStatus(ctx) {
188
+ if (sharedStatus.loading) return;
189
+ sharedStatus.loading = true;
190
+ try {
191
+ const snapshot = await readSharedStatus(ctx);
192
+ if (snapshot !== null) {
193
+ sharedStatus.snapshot = snapshot;
194
+ for (const listener of sharedStatus.listeners) listener();
195
+ }
196
+ } catch {
197
+ /* 状态文件尚未生成时保持旧值 */
198
+ } finally {
199
+ sharedStatus.loading = false;
200
+ }
201
+ }
202
+
203
+ function ensureSharedStatus(ctx) {
204
+ sharedStatus.ctx = ctx;
205
+ if (sharedStatus.timer !== null) return;
206
+ void loadSharedStatus(ctx);
207
+ sharedStatus.timer = setInterval(() => void loadSharedStatus(ctx), 30000);
208
+ }
209
+
210
+ function useSharedStatus(ctx) {
211
+ const [, force] = React.useState(0);
212
+ React.useEffect(() => {
213
+ const listener = () => force((value) => value + 1);
214
+ sharedStatus.listeners.add(listener);
215
+ ensureSharedStatus(ctx);
216
+ return () => {
217
+ sharedStatus.listeners.delete(listener);
218
+ if (sharedStatus.listeners.size === 0 && sharedStatus.timer !== null) {
219
+ clearInterval(sharedStatus.timer);
220
+ sharedStatus.timer = null;
221
+ }
222
+ };
223
+ }, []);
224
+ return sharedStatus.snapshot;
225
+ }
226
+
227
+ async function postAction(body) {
228
+ const response = await fetch('/plugins/dsh-offpeak-guard/action', {
229
+ method: 'POST',
230
+ credentials: 'same-origin',
231
+ headers: { 'content-type': 'application/json' },
232
+ body: JSON.stringify(body),
233
+ });
234
+ const text = await response.text();
235
+ let json = {};
236
+ try {
237
+ json = text === '' ? {} : JSON.parse(text);
238
+ } catch {
239
+ json = {};
240
+ }
241
+ if (!response.ok || json.ok === false) throw new Error(json.error ?? `HTTP ${response.status}`);
242
+ if (sharedStatus.ctx !== null) await loadSharedStatus(sharedStatus.ctx);
243
+ return json;
244
+ }
245
+
155
246
  function makeStrip(pluginCtx) {
156
247
  return function Strip(props) {
157
248
  const ctx = pluginCtx;
158
249
  const t = useLocale(ctx);
159
- const { snapshot, error } = useSnapshot(ctx, props?.sessionId);
160
- const [, force] = React.useState(0);
250
+ const { snapshot, error, refresh } = useSnapshot(ctx, props?.sessionId);
251
+ const [busy, setBusy] = React.useState(false);
252
+ const [, force] = React.useState(0);
161
253
 
162
254
  React.useEffect(() => {
163
255
  const timer = setInterval(() => force((value) => value + 1), 1000);
@@ -218,6 +310,38 @@ window.__ModuleLoader__.load({
218
310
  if (modelId !== '') children.push(h('span', { key: 'model', style: { opacity: 0.75 } }, `· ${modelId}`));
219
311
  if (paused > 0) children.push(h('span', { key: 'paused', style: { color: '#e0a33e' } }, `· ${t('paused')} ${paused}`));
220
312
  if (plans > 0) children.push(h('span', { key: 'plans' }, `· ${t('plans')} ${plans}`));
313
+ const currentSessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
314
+ const currentExempt =
315
+ currentSessionId !== '' &&
316
+ (snapshot.exemptSessions ?? []).some((item) => item.sessionId === currentSessionId);
317
+ if (currentSessionId !== '') {
318
+ children.push(
319
+ h(
320
+ 'button',
321
+ {
322
+ key: 'exempt',
323
+ type: 'button',
324
+ className: 'opg-exempt',
325
+ 'data-exempt': currentExempt ? 'true' : undefined,
326
+ disabled: busy,
327
+ title: currentExempt ? '取消本会话的峰谷豁免' : '让本会话豁免峰谷守卫(不暂停/不恢复)',
328
+ onClick: async (event) => {
329
+ event.stopPropagation();
330
+ setBusy(true);
331
+ try {
332
+ await postAction({ action: 'exempt', sessionId: currentSessionId, exempt: !currentExempt });
333
+ await refresh();
334
+ } catch {
335
+ /* 下一次点击重试 */
336
+ } finally {
337
+ setBusy(false);
338
+ }
339
+ },
340
+ },
341
+ [h(ShieldIcon, { key: 'icon', active: currentExempt }), currentExempt ? '取消豁免' : '豁免本会话'],
342
+ ),
343
+ );
344
+ }
221
345
 
222
346
  return h(
223
347
  'div',
@@ -299,6 +423,13 @@ window.__ModuleLoader__.load({
299
423
  .opg-seg button[data-active="true"]{background:var(--dsw-alias-state-business-primary,#4d6bfe);color:#fff;}
300
424
  .opg-note{color:var(--dsw-alias-label-secondary,#8a8d94);font-size:11px;line-height:1.6;}
301
425
  .opg-error{color:#e0554e;font-size:12px;margin-top:8px;}
426
+ .opg-row-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:26px;height:26px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#6c7769);cursor:pointer;padding:0;}
427
+ .opg-row-action:hover{background:var(--dsw-alias-interactive-bg-hover,#4d6bfe12);color:var(--dsw-alias-label-primary,#252936);}
428
+ .opg-row-action[data-exempt="true"]{color:#e0554e;}
429
+ .opg-trail{display:inline-flex;align-items:center;justify-content:center;flex:none;color:#e0554e;opacity:.85;}
430
+ .opg-exempt{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l1,#dde0e6);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#6c7769);font:11px/16px var(--dsw-font-family,system-ui,sans-serif);padding:1px 6px;cursor:pointer;white-space:nowrap;}
431
+ .opg-exempt[data-exempt="true"]{color:#e0554e;border-color:#e0554e66;}
432
+ .opg-exempt:hover{border-color:var(--dsw-alias-label-secondary,#8a8d94);}
302
433
  `;
303
434
 
304
435
  function ClockIcon() {
@@ -344,16 +475,24 @@ window.__ModuleLoader__.load({
344
475
  function SettingsPanel({ ctx, snapshot, refresh, groups, onClose }) {
345
476
  const [busy, setBusy] = React.useState('');
346
477
  const [error, setError] = React.useState('');
478
+ const [query, setQuery] = React.useState('');
347
479
  const post = async (body) => {
348
480
  setBusy(`${body.action}:${body.sessionId ?? body.provider ?? body.mode ?? ''}`);
349
481
  setError('');
350
482
  try {
351
483
  const response = await fetch('/plugins/dsh-offpeak-guard/action', {
352
484
  method: 'POST',
485
+ credentials: 'same-origin',
353
486
  headers: { 'content-type': 'application/json' },
354
487
  body: JSON.stringify(body),
355
488
  });
356
- const json = await response.json();
489
+ const text = await response.text();
490
+ let json = {};
491
+ try {
492
+ json = text === '' ? {} : JSON.parse(text);
493
+ } catch {
494
+ json = {};
495
+ }
357
496
  if (!response.ok || json.ok === false) throw new Error(json.error ?? `HTTP ${response.status}`);
358
497
  await refresh();
359
498
  } catch (caught) {
@@ -372,7 +511,11 @@ window.__ModuleLoader__.load({
372
511
  const needle = String(snapshot.modelPattern ?? 'deepseek').toLowerCase();
373
512
  return model.toLowerCase().includes(needle) || provider.toLowerCase().includes(needle);
374
513
  };
375
- const sessions = Object.values(snapshot.sessions ?? {});
514
+ const sessions = Object.values(snapshot.sessions ?? {}).filter((session) => {
515
+ if (query.trim() === '') return true;
516
+ const needle = query.trim().toLowerCase();
517
+ return String(session.title ?? '').toLowerCase().includes(needle) || String(session.id).toLowerCase().includes(needle);
518
+ });
376
519
  return h('div', { className: 'opg-panel-shell' }, [
377
520
  h('div', { className: 'opg-panel-head', key: 'head' }, [
378
521
  h('strong', { key: 'title' }, '峰谷守卫设置'),
@@ -443,8 +586,26 @@ window.__ModuleLoader__.load({
443
586
  ]),
444
587
  h('div', { className: 'opg-section', key: 'sessions' }, [
445
588
  h('h4', { key: 'h' }, '会话豁免(不参与暂停/恢复)'),
589
+ h('input', {
590
+ key: 'search',
591
+ type: 'search',
592
+ value: query,
593
+ placeholder: '按标题或 session id 过滤…',
594
+ onChange: (event) => setQuery(event.target.value),
595
+ style: {
596
+ width: '100%',
597
+ boxSizing: 'border-box',
598
+ margin: '0 0 6px',
599
+ padding: '5px 8px',
600
+ border: '1px solid var(--dsw-alias-border-l1,#dde0e6)',
601
+ borderRadius: '7px',
602
+ background: 'transparent',
603
+ color: 'inherit',
604
+ font: 'inherit',
605
+ },
606
+ }),
446
607
  sessions.length === 0
447
- ? h('div', { className: 'opg-note', key: 'empty' }, '当前没有运行中的根会话。')
608
+ ? h('div', { className: 'opg-note', key: 'empty' }, '没有匹配的运行中根会话。')
448
609
  : sessions.map((session) =>
449
610
  h('label', { className: 'opg-row', key: `s:${session.id}` }, [
450
611
  h('input', {
@@ -477,6 +638,69 @@ window.__ModuleLoader__.load({
477
638
  ]);
478
639
  }
479
640
 
641
+ function ShieldIcon({ active }) {
642
+ return h(
643
+ 'svg',
644
+ {
645
+ width: 15,
646
+ height: 15,
647
+ viewBox: '0 0 24 24',
648
+ fill: active ? 'currentColor' : 'none',
649
+ stroke: 'currentColor',
650
+ strokeWidth: 1.8,
651
+ strokeLinecap: 'round',
652
+ strokeLinejoin: 'round',
653
+ 'aria-hidden': true,
654
+ },
655
+ [h('path', { key: 'p', d: 'M12 3l7 3v5c0 4.4-2.9 8.1-7 10-4.1-1.9-7-5.6-7-10V6z' })],
656
+ );
657
+ }
658
+
659
+ function makeRowAction(pluginCtx) {
660
+ return function RowAction(props) {
661
+ const snapshot = useSharedStatus(pluginCtx);
662
+ const [busy, setBusy] = React.useState(false);
663
+ const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
664
+ if (sessionId === '') return null;
665
+ const exempt = (snapshot?.exemptSessions ?? []).some((item) => item.sessionId === sessionId);
666
+ return h(
667
+ 'button',
668
+ {
669
+ type: 'button',
670
+ className: 'opg-row-action',
671
+ title: exempt ? '取消峰谷豁免' : '本会话豁免峰谷守卫',
672
+ 'aria-label': exempt ? '取消峰谷豁免' : '本会话豁免峰谷守卫',
673
+ 'aria-pressed': exempt,
674
+ 'data-exempt': exempt ? 'true' : undefined,
675
+ disabled: busy,
676
+ onClick: async (event) => {
677
+ event.stopPropagation();
678
+ setBusy(true);
679
+ try {
680
+ await postAction({ action: 'exempt', sessionId, exempt: !exempt });
681
+ } catch {
682
+ /* 状态文件/路由暂不可用时静默,下一次点击重试 */
683
+ } finally {
684
+ setBusy(false);
685
+ }
686
+ },
687
+ },
688
+ h(ShieldIcon, { active: exempt }),
689
+ );
690
+ };
691
+ }
692
+
693
+ function makeTrailing(pluginCtx) {
694
+ return function Trailing(props) {
695
+ const snapshot = useSharedStatus(pluginCtx);
696
+ const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
697
+ if (snapshot === null || sessionId === '') return null;
698
+ const exempt = (snapshot.exemptSessions ?? []).some((item) => item.sessionId === sessionId);
699
+ if (!exempt) return null;
700
+ return h('span', { className: 'opg-trail', title: '峰谷豁免中', 'aria-hidden': true }, h(ShieldIcon, { active: true }));
701
+ };
702
+ }
703
+
480
704
  function makeClock(pluginCtx) {
481
705
  return function Clock(props) {
482
706
  const t = useLocale(pluginCtx);
@@ -606,6 +830,28 @@ window.__ModuleLoader__.load({
606
830
  Clock,
607
831
  ),
608
832
  );
833
+ const RowAction = makeRowAction(ctx);
834
+ const Trail = makeTrailing(ctx);
835
+ ctx.slots.inject('sidebar.workspaces.session.row.action', () =>
836
+ ctx.slots.register(
837
+ {
838
+ name: 'sidebar.workspaces.session.row.action',
839
+ id: 'offpeak-guard-exempt',
840
+ order: 150,
841
+ },
842
+ RowAction,
843
+ ),
844
+ );
845
+ ctx.slots.inject('sidebar.session.row.trailing', () =>
846
+ ctx.slots.register(
847
+ {
848
+ name: 'sidebar.session.row.trailing',
849
+ id: 'offpeak-guard-exempt-trail',
850
+ order: 20,
851
+ },
852
+ Trail,
853
+ ),
854
+ );
609
855
  },
610
856
  };
611
857
  },
package/index.js CHANGED
@@ -197,11 +197,10 @@ export function apply(ctx, rawConfig = {}) {
197
197
  };
198
198
  }, 'offpeak-guard: tools');
199
199
 
200
- const webServer = ctx.get('webServer');
201
- if (webServer !== undefined && typeof webServer.register === 'function') {
202
- ctx.effect(
200
+ ctx.inject(['webServer'], (webCtx) => {
201
+ webCtx.effect(
203
202
  () =>
204
- webServer.register({
203
+ webCtx.webServer.register({
205
204
  kind: 'prefix',
206
205
  path: '/plugins/dsh-offpeak-guard',
207
206
  async handler(req, res) {
@@ -241,5 +240,5 @@ export function apply(ctx, rawConfig = {}) {
241
240
  }),
242
241
  'offpeak-guard: settings route',
243
242
  );
244
- }
243
+ });
245
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-offpeak-guard",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",