dsh-offpeak-guard 0.3.0 → 0.3.1

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 — 2026-11-06
4
+
5
+ - 左侧会话行悬停操作新增盾牌按钮:直接在对话索引里豁免/取消豁免,豁免中的会话行保留盾牌标记。
6
+ - 输入框状态条新增「豁免本会话 / 取消豁免」按钮,不用打开设置面板即可切换当前对话。
7
+ - 设置面板的会话豁免列表新增搜索过滤,会话多时可按标题或 session id 查找。
8
+ - 设置接口的浏览器请求补 `credentials: same-origin`,确保桌面 Web 主机下开关生效。
9
+
3
10
  ## 0.3.0 — 2026-11-06
4
11
 
5
12
  - 新增峰时策略 `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,96 @@ 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 json = await response.json();
235
+ if (!response.ok || json.ok === false) throw new Error(json.error ?? `HTTP ${response.status}`);
236
+ if (sharedStatus.ctx !== null) await loadSharedStatus(sharedStatus.ctx);
237
+ return json;
238
+ }
239
+
155
240
  function makeStrip(pluginCtx) {
156
241
  return function Strip(props) {
157
242
  const ctx = pluginCtx;
158
243
  const t = useLocale(ctx);
159
- const { snapshot, error } = useSnapshot(ctx, props?.sessionId);
160
- const [, force] = React.useState(0);
244
+ const { snapshot, error, refresh } = useSnapshot(ctx, props?.sessionId);
245
+ const [busy, setBusy] = React.useState(false);
246
+ const [, force] = React.useState(0);
161
247
 
162
248
  React.useEffect(() => {
163
249
  const timer = setInterval(() => force((value) => value + 1), 1000);
@@ -218,6 +304,38 @@ window.__ModuleLoader__.load({
218
304
  if (modelId !== '') children.push(h('span', { key: 'model', style: { opacity: 0.75 } }, `· ${modelId}`));
219
305
  if (paused > 0) children.push(h('span', { key: 'paused', style: { color: '#e0a33e' } }, `· ${t('paused')} ${paused}`));
220
306
  if (plans > 0) children.push(h('span', { key: 'plans' }, `· ${t('plans')} ${plans}`));
307
+ const currentSessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
308
+ const currentExempt =
309
+ currentSessionId !== '' &&
310
+ (snapshot.exemptSessions ?? []).some((item) => item.sessionId === currentSessionId);
311
+ if (currentSessionId !== '') {
312
+ children.push(
313
+ h(
314
+ 'button',
315
+ {
316
+ key: 'exempt',
317
+ type: 'button',
318
+ className: 'opg-exempt',
319
+ 'data-exempt': currentExempt ? 'true' : undefined,
320
+ disabled: busy,
321
+ title: currentExempt ? '取消本会话的峰谷豁免' : '让本会话豁免峰谷守卫(不暂停/不恢复)',
322
+ onClick: async (event) => {
323
+ event.stopPropagation();
324
+ setBusy(true);
325
+ try {
326
+ await postAction({ action: 'exempt', sessionId: currentSessionId, exempt: !currentExempt });
327
+ await refresh();
328
+ } catch {
329
+ /* 下一次点击重试 */
330
+ } finally {
331
+ setBusy(false);
332
+ }
333
+ },
334
+ },
335
+ [h(ShieldIcon, { key: 'icon', active: currentExempt }), currentExempt ? '取消豁免' : '豁免本会话'],
336
+ ),
337
+ );
338
+ }
221
339
 
222
340
  return h(
223
341
  'div',
@@ -299,6 +417,13 @@ window.__ModuleLoader__.load({
299
417
  .opg-seg button[data-active="true"]{background:var(--dsw-alias-state-business-primary,#4d6bfe);color:#fff;}
300
418
  .opg-note{color:var(--dsw-alias-label-secondary,#8a8d94);font-size:11px;line-height:1.6;}
301
419
  .opg-error{color:#e0554e;font-size:12px;margin-top:8px;}
420
+ .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;}
421
+ .opg-row-action:hover{background:var(--dsw-alias-interactive-bg-hover,#4d6bfe12);color:var(--dsw-alias-label-primary,#252936);}
422
+ .opg-row-action[data-exempt="true"]{color:#e0554e;}
423
+ .opg-trail{display:inline-flex;align-items:center;justify-content:center;flex:none;color:#e0554e;opacity:.85;}
424
+ .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;}
425
+ .opg-exempt[data-exempt="true"]{color:#e0554e;border-color:#e0554e66;}
426
+ .opg-exempt:hover{border-color:var(--dsw-alias-label-secondary,#8a8d94);}
302
427
  `;
303
428
 
304
429
  function ClockIcon() {
@@ -344,12 +469,14 @@ window.__ModuleLoader__.load({
344
469
  function SettingsPanel({ ctx, snapshot, refresh, groups, onClose }) {
345
470
  const [busy, setBusy] = React.useState('');
346
471
  const [error, setError] = React.useState('');
472
+ const [query, setQuery] = React.useState('');
347
473
  const post = async (body) => {
348
474
  setBusy(`${body.action}:${body.sessionId ?? body.provider ?? body.mode ?? ''}`);
349
475
  setError('');
350
476
  try {
351
477
  const response = await fetch('/plugins/dsh-offpeak-guard/action', {
352
478
  method: 'POST',
479
+ credentials: 'same-origin',
353
480
  headers: { 'content-type': 'application/json' },
354
481
  body: JSON.stringify(body),
355
482
  });
@@ -372,7 +499,11 @@ window.__ModuleLoader__.load({
372
499
  const needle = String(snapshot.modelPattern ?? 'deepseek').toLowerCase();
373
500
  return model.toLowerCase().includes(needle) || provider.toLowerCase().includes(needle);
374
501
  };
375
- const sessions = Object.values(snapshot.sessions ?? {});
502
+ const sessions = Object.values(snapshot.sessions ?? {}).filter((session) => {
503
+ if (query.trim() === '') return true;
504
+ const needle = query.trim().toLowerCase();
505
+ return String(session.title ?? '').toLowerCase().includes(needle) || String(session.id).toLowerCase().includes(needle);
506
+ });
376
507
  return h('div', { className: 'opg-panel-shell' }, [
377
508
  h('div', { className: 'opg-panel-head', key: 'head' }, [
378
509
  h('strong', { key: 'title' }, '峰谷守卫设置'),
@@ -443,8 +574,26 @@ window.__ModuleLoader__.load({
443
574
  ]),
444
575
  h('div', { className: 'opg-section', key: 'sessions' }, [
445
576
  h('h4', { key: 'h' }, '会话豁免(不参与暂停/恢复)'),
577
+ h('input', {
578
+ key: 'search',
579
+ type: 'search',
580
+ value: query,
581
+ placeholder: '按标题或 session id 过滤…',
582
+ onChange: (event) => setQuery(event.target.value),
583
+ style: {
584
+ width: '100%',
585
+ boxSizing: 'border-box',
586
+ margin: '0 0 6px',
587
+ padding: '5px 8px',
588
+ border: '1px solid var(--dsw-alias-border-l1,#dde0e6)',
589
+ borderRadius: '7px',
590
+ background: 'transparent',
591
+ color: 'inherit',
592
+ font: 'inherit',
593
+ },
594
+ }),
446
595
  sessions.length === 0
447
- ? h('div', { className: 'opg-note', key: 'empty' }, '当前没有运行中的根会话。')
596
+ ? h('div', { className: 'opg-note', key: 'empty' }, '没有匹配的运行中根会话。')
448
597
  : sessions.map((session) =>
449
598
  h('label', { className: 'opg-row', key: `s:${session.id}` }, [
450
599
  h('input', {
@@ -477,6 +626,69 @@ window.__ModuleLoader__.load({
477
626
  ]);
478
627
  }
479
628
 
629
+ function ShieldIcon({ active }) {
630
+ return h(
631
+ 'svg',
632
+ {
633
+ width: 15,
634
+ height: 15,
635
+ viewBox: '0 0 24 24',
636
+ fill: active ? 'currentColor' : 'none',
637
+ stroke: 'currentColor',
638
+ strokeWidth: 1.8,
639
+ strokeLinecap: 'round',
640
+ strokeLinejoin: 'round',
641
+ 'aria-hidden': true,
642
+ },
643
+ [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' })],
644
+ );
645
+ }
646
+
647
+ function makeRowAction(pluginCtx) {
648
+ return function RowAction(props) {
649
+ const snapshot = useSharedStatus(pluginCtx);
650
+ const [busy, setBusy] = React.useState(false);
651
+ const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
652
+ if (sessionId === '') return null;
653
+ const exempt = (snapshot?.exemptSessions ?? []).some((item) => item.sessionId === sessionId);
654
+ return h(
655
+ 'button',
656
+ {
657
+ type: 'button',
658
+ className: 'opg-row-action',
659
+ title: exempt ? '取消峰谷豁免' : '本会话豁免峰谷守卫',
660
+ 'aria-label': exempt ? '取消峰谷豁免' : '本会话豁免峰谷守卫',
661
+ 'aria-pressed': exempt,
662
+ 'data-exempt': exempt ? 'true' : undefined,
663
+ disabled: busy,
664
+ onClick: async (event) => {
665
+ event.stopPropagation();
666
+ setBusy(true);
667
+ try {
668
+ await postAction({ action: 'exempt', sessionId, exempt: !exempt });
669
+ } catch {
670
+ /* 状态文件/路由暂不可用时静默,下一次点击重试 */
671
+ } finally {
672
+ setBusy(false);
673
+ }
674
+ },
675
+ },
676
+ h(ShieldIcon, { active: exempt }),
677
+ );
678
+ };
679
+ }
680
+
681
+ function makeTrailing(pluginCtx) {
682
+ return function Trailing(props) {
683
+ const snapshot = useSharedStatus(pluginCtx);
684
+ const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
685
+ if (snapshot === null || sessionId === '') return null;
686
+ const exempt = (snapshot.exemptSessions ?? []).some((item) => item.sessionId === sessionId);
687
+ if (!exempt) return null;
688
+ return h('span', { className: 'opg-trail', title: '峰谷豁免中', 'aria-hidden': true }, h(ShieldIcon, { active: true }));
689
+ };
690
+ }
691
+
480
692
  function makeClock(pluginCtx) {
481
693
  return function Clock(props) {
482
694
  const t = useLocale(pluginCtx);
@@ -606,6 +818,28 @@ window.__ModuleLoader__.load({
606
818
  Clock,
607
819
  ),
608
820
  );
821
+ const RowAction = makeRowAction(ctx);
822
+ const Trail = makeTrailing(ctx);
823
+ ctx.slots.inject('sidebar.workspaces.session.row.action', () =>
824
+ ctx.slots.register(
825
+ {
826
+ name: 'sidebar.workspaces.session.row.action',
827
+ id: 'offpeak-guard-exempt',
828
+ order: 150,
829
+ },
830
+ RowAction,
831
+ ),
832
+ );
833
+ ctx.slots.inject('sidebar.session.row.trailing', () =>
834
+ ctx.slots.register(
835
+ {
836
+ name: 'sidebar.session.row.trailing',
837
+ id: 'offpeak-guard-exempt-trail',
838
+ order: 20,
839
+ },
840
+ Trail,
841
+ ),
842
+ );
609
843
  },
610
844
  };
611
845
  },
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.1",
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",