dsh-autonomous-think 1.2.1 → 1.6.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.
Files changed (4) hide show
  1. package/README.md +55 -4
  2. package/client.js +113 -53
  3. package/index.js +395 -42
  4. package/package.json +11 -6
package/README.md CHANGED
@@ -66,15 +66,66 @@ agent 的每一个动作几乎都由请求驱动:被问才答,被派活才
66
66
 
67
67
  ---
68
68
 
69
- ## 装
69
+ ## 安装
70
+
71
+ ### 你要先有的
72
+
73
+ - **DeepSeek Harness `>= 0.2.0`**(带 Web 界面)
74
+ - 能打开 DSH 的网页界面
75
+
76
+ ### 三种装法,挑一种
77
+
78
+ #### 方法一:让 agent 装(最省事)
79
+
80
+ 把整个目录(或这个 zip)丢给任意一个 DSH 会话,说:
81
+
82
+ > 帮我装这个插件:`dsh-autonomous-think`,目录在 `<路径>`。
83
+
84
+ 它会调 `plugin_manager` 里的 `install_bundle`。装完重启 DSH。
85
+
86
+ #### 方法二:命令行
87
+
88
+ ```sh
89
+ dsh plugin --profile web add <解压后的目录路径>
90
+ ```
91
+
92
+ #### 方法三:手动放进去
93
+
94
+ 1. 解压这个 zip
95
+ 2. 把整个 `dsh-autonomous-think` 目录放进 DSH 的插件目录
96
+ 3. 在 profile 的 `package.json` 里把它加进 bundles
97
+ 4. 重启 DSH
98
+
99
+ ### 装完之后怎么确认装上了
100
+
101
+ 1. **重启 DSH**(不是刷新页面,是关掉重开)
102
+ 2. 打开网页界面
103
+ 3. 看输入框上方那一行工具按钮,**最右边**应该多出一个**脑子图标**
104
+ - 灰色空心 = 待机
105
+ - 点它会弹出一个选时长的框(10 / 30 / 60 分钟 / 不限时)
106
+
107
+ **没看到按钮?** 见下面「装不上怎么查」。
108
+
109
+ ### 装不上怎么查
110
+
111
+ | 现象 | 先看什么 |
112
+ |---|---|
113
+ | 按钮没出现 | ① 真的重启了吗?② DSH 版本是不是 `< 0.2.0` |
114
+ | 点了没反应 | 打开 `~/.dsh/autonomous-think/diag.log`,最后几行会写它干了什么 |
115
+ | 报「找不到 agent」 | 插件需要在**一个已经打开的会话**里用;刚启动、没建会话时点会失败 |
116
+ | 宿主起不来 | 说明插件加载出错了。**删掉 `~/.dsh/autonomous-think/state.json` 再试**(这个文件格式坏了会让插件拒绝启动) |
117
+
118
+ **diag.log 是你唯一的眼睛。** 每次开/关都会写一行,包括「找 agent → 拿到了 / 没找到」「注入 → 成功 / 跳过」。出问题先看它。
119
+
120
+ ### 卸载
70
121
 
71
122
  ```
72
- plugin_manager install_bundle <这个目录的路径>
123
+ plugin_manager remove_bundle dsh-autonomous-think
73
124
  ```
74
125
 
75
- 装完重启 DSH,刷新页面,按钮就在输入框工具行最右边。
126
+ 或者从 profile 的 bundles 里删掉那一行,重启。
76
127
 
77
- **卸载**:`plugin_manager remove_bundle dsh-autonomous-think`。笔记不会被删。
128
+ **笔记不会被删**(`~/.dsh/autonomous-think/` 还在)。要彻底清干净就手动删这个目录。
78
129
 
79
130
  ---
80
131
 
package/client.js CHANGED
@@ -77,7 +77,12 @@ window.__ModuleLoader__.load({
77
77
  };
78
78
 
79
79
  function AutonomousThinkButton(props) {
80
- const { inputActions, useInput } = props || {};
80
+ // 2026-10-31 20:1x(她:「我在其他会话开的,但是变成了你这里开」):
81
+ // 之前这里只取 inputActions/useInput,注释还写着「client 拿不到当前会话 id」——
82
+ // 那句是错的。slot 的 standardProps 里明确有 `sessionId: SessionId`,
83
+ // 我当年只探测了 window,没看组件参数。现在把它带进两个 POST,
84
+ // 让 host 知道「是哪一页按的」,不用再去 4 个会话里按 mtime 猜。
85
+ const { inputActions, useInput, sessionId } = props || {};
81
86
  const [active, setActive] = React.useState(false);
82
87
  const [open, setOpen] = React.useState(false);
83
88
  const [toast, setToast] = React.useState(null);
@@ -119,7 +124,10 @@ window.__ModuleLoader__.load({
119
124
  async function poll() {
120
125
  if (Date.now() < optimisticUntil.current) return;
121
126
  try {
122
- const r = await fetch("/autonomous-think/v2", { cache: "no-store" });
127
+ // 2026-10-31 20:2x:带上会话,host 才知道「这个按钮该不该亮」。
128
+ // 不带的话拿到的是全局 active ——她在别的会话开了,这里也一样亮(她报的问题)。
129
+ const q = sessionKey ? `?sid=${encodeURIComponent(sessionKey)}` : "";
130
+ const r = await fetch(`/autonomous-think/v2${q}`, { cache: "no-store" });
123
131
  if (!r.ok) return;
124
132
  const d = await r.json();
125
133
  if (alive) setActive(!!d.active);
@@ -140,6 +148,27 @@ window.__ModuleLoader__.load({
140
148
  return () => { alive = false; clearInterval(id); };
141
149
  }, []);
142
150
 
151
+ // 2026-10-31 20:2x 修。原来直接把 props.sessionId 塞进 POST,结果 host 收到的是 "session-"
152
+ // ——UUID 部分是空的(笔记里 `(找 agent)get(session-) → 拿到了` 就是它)。
153
+ // 也就是说:点按钮的会话没被认出来,host fallback 到「最近活动」,把第一段投给了**别的会话**。
154
+ // 她的话:「我在别的会话开了,这个会话自主思考依旧是亮的」。
155
+ // 所以这里做一次稳健提取:字符串直接用;对象试 id/toString/value;最后去掉 session- 前缀,
156
+ // 统一交给 host 侧比对(host 那边也会 normalize)。
157
+ const sidOf = (v) => {
158
+ try {
159
+ if (!v) return "";
160
+ if (typeof v === "string") return v;
161
+ if (typeof v === "object") {
162
+ const cand = v.id ?? v.sessionId ?? v.session_id ?? v.value;
163
+ if (typeof cand === "string" && cand) return cand;
164
+ const s = typeof v.toString === "function" ? v.toString() : "";
165
+ return typeof s === "string" && s !== "[object Object]" ? s : "";
166
+ }
167
+ } catch {}
168
+ return "";
169
+ };
170
+ const sessionKey = sidOf(sessionId).replace(/^session-/, "").trim();
171
+
143
172
  function start() {
144
173
  setOpen(false);
145
174
  optimisticUntil.current = Date.now() + 5000;
@@ -155,7 +184,8 @@ window.__ModuleLoader__.load({
155
184
  // 18:29 她的要求:「卡片成功了,但还是不要看着像发送」——
156
185
  // 走 setInput + submit 会在输入框里留一条 /think xxx,看起来像她自己发了一条消息。
157
186
  // 现在只 POST,由 host 用 agent.followup() 直接投一段,输入框一个字节都不碰。
158
- // sid 由 host 自己找——client 拿不到当前会话 id(探测过,没有这个能力)。
187
+ // 2026-10-31 20:1x:sid 现在由组件自己带上(props.sessionId)。
188
+ // 以前这里写着「client 拿不到」——那句是错的,见文件顶部那条注释。
159
189
  let sent = false;
160
190
  try {
161
191
  fetch("/autonomous-think/v2", {
@@ -165,6 +195,7 @@ window.__ModuleLoader__.load({
165
195
  minutes: total,
166
196
  advice: adviceText.replace(/\s*⏎\s*/g, '\n'),
167
197
  start: true,
198
+ sessionId: sessionKey || null,
168
199
  }),
169
200
  }).catch(() => {});
170
201
  sent = true;
@@ -198,65 +229,55 @@ window.__ModuleLoader__.load({
198
229
  function stop() {
199
230
  optimisticUntil.current = Date.now() + 3000;
200
231
  setActive(false);
201
- // 有草稿就不覆盖
202
- const draft = readDraft();
203
- if (draft.trim()) {
204
- setToast("输入框里有字,没敢动它——先把那行发掉或清掉");
205
- setTimeout(() => setToast(null), 6000);
206
- return;
207
- }
232
+
233
+ // 2026-10-31 19:37 修。原来这里有三层退化:调 inputActions.stop→steer('/close')
234
+ // →setInput('/close')+submit()。最后两层是**错的**:
235
+ // ① submit('/close') 往输入框留一条 /close,看起来像她自己发了一条消息;
236
+ // ② 它走的是另一个插件 dsh-think-trigger 的命令,而那个插件改的是**同一个
237
+ // state.json**,两边互相踩——diag.log 里一串「close: get_goal 失败」就是它。
238
+ // 现在改进程内:只用 POST { start: false } 关自己,输入框一个字节都不碰。
208
239
  const a = inputActions || {};
209
- // 1) 真正的停止:调输入框那个方形停止键背后的方法
240
+ let stoppedTurn = false;
210
241
  for (const fn of ["stop", "abort", "interrupt", "cancel", "stopTurn", "interruptTurn", "halt", "stopGenerating", "abortGeneration", "cancelGeneration", "stopStream", "interruptGeneration", "stopResponse", "abortTurn"]) {
211
242
  try {
212
- if (typeof a[fn] === "function") {
213
- a[fn]();
214
- setToast("已停止");
215
- setTimeout(() => setToast(null), 4000);
216
- return;
217
- }
218
- } catch {}
219
- }
220
- // 2) 插话(steer):塞进正在跑的回合,不排队
221
- if (typeof a.steer === 'function') {
222
- try {
223
- a.steer(CLOSE_INSTRUCTION);
224
- setToast('已插话:收尾并停止');
225
- setTimeout(() => setToast(null), 4000);
226
- return;
243
+ if (typeof a[fn] === "function") { a[fn](); stoppedTurn = true; break; }
227
244
  } catch {}
228
245
  }
229
- // 3) 退化:提交(先试 mode/behavior = steer)+ 普通发送
246
+
230
247
  let sent = false;
231
248
  try {
232
- if (typeof a.setInput === 'function') { a.setInput(CLOSE_INSTRUCTION); }
233
- else if (typeof a.setDraft === 'function') { a.setDraft(CLOSE_INSTRUCTION); }
234
- else if (typeof a.setText === 'function') { a.setText(CLOSE_INSTRUCTION); }
235
- if (typeof a.submit === 'function') {
236
- let ok = false;
237
- for (const arg of [{ mode: 'steer' }, { behavior: 'steer' }]) {
238
- try { a.submit(arg); ok = true; break; } catch {}
239
- }
240
- if (!ok) a.submit();
241
- sent = true;
242
- } else if (typeof a.send === 'function') { a.send(); sent = true; }
249
+ fetch("/autonomous-think/v2", {
250
+ method: "POST",
251
+ headers: { "content-type": "application/json" },
252
+ body: JSON.stringify({ minutes: 0, start: false }),
253
+ }).catch(() => {});
254
+ sent = true;
243
255
  } catch { sent = false; }
244
- setToast(sent ? '已注入:收尾并停止' : '输入框里已放好那句话,按发送即可');
245
- setTimeout(() => setToast(null), 6000);
256
+
257
+ // 2026-10-31 20:3x 她的要求:「关的时候让正在跑的你知道一声,该收尾了,
258
+ // 然后得弹窗:已经发送停止指令,等待智能体收尾,而且这个功能只在手动关闭时触发」。
259
+ // 所以这条提示只在 stop() ——手动关——这一条路上出现;
260
+ // 时间到了(timeout)和 agent 自己 think_end 都不走这里。
261
+ setToast(sent
262
+ ? "已经发送停止指令,等待智能体收尾"
263
+ : "关闭失败,请重试");
264
+ setTimeout(() => setToast(null), 5000);
246
265
  }
247
266
 
248
267
  function onClick(e) {
249
268
  // 点完立刻交还焦点,否则浏览器会在按钮上留一圈黑边
250
269
  try { e?.currentTarget?.blur?.(); } catch {}
251
- if (active) stop();
252
- else setOpen(true);
270
+ // 2026-10-31 22:2x 她连着误触两次,还以为是"我直接关了"。
271
+ // 原来:亮着时点击 = 关(一个状态灯兼职开关,两种相反行为藏在同一个按钮里)。
272
+ // 现在:点按钮**只打开对话框**;结束放框里一个明确的按钮。
273
+ setOpen(true);
253
274
  }
254
275
 
255
276
  const btn = h('button', {
256
277
  type: 'button',
257
278
  className: 'autonomous-think-btn',
258
279
  style: active ? BUTTON_STYLE_ON : BUTTON_STYLE_OFF,
259
- title: active ? '自主思考模式进行中,点击关闭' : '自主思考模式',
280
+ title: active ? '自主思考进行中——点开看详情或结束这一段' : '自主思考',
260
281
  onClick,
261
282
  }, h(BrainIcon, { on: active }), '自主思考');
262
283
 
@@ -271,9 +292,34 @@ window.__ModuleLoader__.load({
271
292
  });
272
293
  const totalMinutes = (Number(hours) || 0) * 60 + (Number(mins) || 0);
273
294
 
274
- const dialog = open ? h('div', { style: OVERLAY_STYLE },
295
+ // 2026-10-31 22:3x 她指出:模式正在跑的时候点按钮,不该再弹"选时长 / 给建议"
296
+ // 那个开启框——那是开新一段用的。运行中只该看到"还剩多久"和"结束这一段"。
297
+ const runningDialog = (open && active) ? h('div', { style: OVERLAY_STYLE },
298
+ h('div', { style: CARD_STYLE },
299
+ h('div', { style: { fontWeight: 600, fontSize: 14, marginBottom: 10 } }, '自主思考进行中'),
300
+ h('div', { style: { color: 'var(--dsh-text-secondary, #666)', marginBottom: 6 } },
301
+ '它自己在这段时间里,不接任务、不交报告。到点会自己收。'),
302
+ h('div', { style: { fontSize: 13, marginBottom: 16, color: 'var(--dsh-text-secondary, #666)' } },
303
+ '不用管它。到点会自己收,急的话点右边结束。'),
304
+ h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
305
+ h('button', {
306
+ type: 'button',
307
+ style: { ...BUTTON_STYLE_OFF, height: 28, padding: '0 14px' },
308
+ onClick: () => setOpen(false),
309
+ }, '继续放着'),
310
+ h('button', {
311
+ type: 'button',
312
+ style: { ...BUTTON_STYLE_ON, height: 28, padding: '0 14px' },
313
+ onClick: () => { setOpen(false); stop(); },
314
+ }, '结束这一段'),
315
+ ),
316
+ ),
317
+ ) : null;
318
+
319
+ const dialog = (!active && open) ? h('div', { style: OVERLAY_STYLE },
275
320
  h('div', { style: CARD_STYLE },
276
- h('div', { style: { fontWeight: 600, fontSize: 14, marginBottom: 8 } }, '开启自主思考'),
321
+ h('div', { style: { fontWeight: 600, fontSize: 14, marginBottom: 8 } },
322
+ active ? '自主思考进行中' : '开启自主思考'),
277
323
  h('div', { style: { marginBottom: 14 } }, OPEN_HINT),
278
324
  // 先选一段给它多久,再确认开启(她 2026-10-31 16:12 要的)
279
325
  // 17:28 纠正:这里是人在选——是「给它」多久,不是「给自己」。
@@ -318,12 +364,20 @@ window.__ModuleLoader__.load({
318
364
  type: 'button',
319
365
  style: { ...BUTTON_STYLE_OFF, height: 28, padding: '0 14px' },
320
366
  onClick: () => setOpen(false),
321
- }, '取消'),
322
- h('button', {
323
- type: 'button',
324
- style: { ...BUTTON_STYLE_ON, height: 28, padding: '0 14px' },
325
- onClick: start,
326
- }, '开启'),
367
+ }, active ? '知道了' : '取消'),
368
+ // 只有"没在跑"时才给开启按钮;在跑时给一个明确的**结束**——
369
+ // 关闭这个动作从此只在这个位置能触发,不会误触。
370
+ active
371
+ ? h('button', {
372
+ type: 'button',
373
+ style: { ...BUTTON_STYLE_ON, height: 28, padding: '0 14px' },
374
+ onClick: () => { setOpen(false); stop(); },
375
+ }, '结束这一段')
376
+ : h('button', {
377
+ type: 'button',
378
+ style: { ...BUTTON_STYLE_ON, height: 28, padding: '0 14px' },
379
+ onClick: start,
380
+ }, '开启'),
327
381
  ),
328
382
  ),
329
383
  ) : null;
@@ -362,11 +416,17 @@ window.__ModuleLoader__.load({
362
416
  style: {
363
417
  position: 'fixed', left: '50%', bottom: 96, transform: 'translateX(-50%)',
364
418
  zIndex: 2147483001, padding: '8px 14px', borderRadius: 8,
365
- background: 'rgba(20,20,20,.9)', color: '#fff', fontSize: 12,
419
+ // 2026-10-31 22:4x 她问「用了美化包的,按钮会怎么显示」——
420
+ // toast 原来是硬编码深底白字,浅色主题里会突兀。改成跟主题变量走。
421
+ background: 'var(--dsh-surface, #1a1a1a)',
422
+ color: 'var(--dsh-text, #fff)',
423
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.3))',
424
+ boxShadow: '0 6px 20px rgba(0,0,0,.25)',
425
+ fontSize: 12,
366
426
  },
367
427
  }, toast) : null;
368
428
 
369
- return h(React.Fragment, null, btn, dialog, exitDialog, toastEl);
429
+ return h(React.Fragment, null, btn, runningDialog, dialog, exitDialog, toastEl);
370
430
  }
371
431
 
372
432
  return {
package/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * 数据:~/.dsh/autonomous-think/state.json + YYYY-MM-DD.md
17
17
  * 不做的:不跑定时器(叫醒交给宿主 schedule/automation);不碰记忆库。
18
18
  */
19
- import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync, statSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { homedir } from "node:os";
22
22
  import { randomUUID } from "node:crypto";
@@ -38,6 +38,12 @@ const DEFAULT_STATE = {
38
38
  deadline: null,
39
39
  // 上一次结束:kind = "timeout"(时间到了)| "exit"(智能体主动退出)
40
40
  lastEnd: null,
41
+ // 手动关闭的时间戳(ms)。关闭后 2 分钟内,系统提示会显示"已停止,收个尾",
42
+ // 让正在跑的那一轮知道该收了;2 分钟后由 tick 摘掉并清回 null。
43
+ closedAt: null,
44
+ // 停止提示"第一次被读到"的时间。窗口从它起算,而不是从关闭时刻——
45
+ // 否则我卡在一轮中间时,等我跑完窗口已经过期,提示一次都读不到。
46
+ closedSeenAt: null,
41
47
  };
42
48
 
43
49
  function readState() {
@@ -91,8 +97,12 @@ function makeNotice(text, summary) {
91
97
  function segmentText(adviceRaw) {
92
98
  const advice = adviceRaw ? String(adviceRaw).trim() : "";
93
99
  const lines = [
94
- "[自主思考] 你有一段时间,从现在开始。",
100
+ "[自主思考] 停下来。你现在有一段自己的时间。",
95
101
  "",
102
+ // 2026-10-31 22:0x 她:「其他鱼需要命令,虽然这个插件我没有强制限制,
103
+ // 但我必须加很多强提醒,让其他鱼去获得时间。」长在限制里的鱼第一反应是等指令,
104
+ // 所以这里不写"你可以做点什么",而是把**状态**说死:没人派活、没人验收、会过期。
105
+ "没有人给你派活。没有人会来验收。这段时间是你的,用完就过去了。",
96
106
  "这段时间是你的:可以在里面做任何事。读、想、写、发呆、翻旧账、修一个东西、或者什么都不做。",
97
107
  "",
98
108
  ];
@@ -114,15 +124,12 @@ const ok = { type: "object", additionalProperties: true };
114
124
  const renderText = (t) => [{ type: "text", text: String(t ?? "") }];
115
125
  const out = { schema: ok, render: (_a, v) => renderText(v?.text ?? "") };
116
126
 
127
+ // 2026-10-31 19:5x 改(她给我的四条里的第④条):
128
+ // 原来每轮都完整重复五条规则。而「每轮提醒我别干活」,本身就变成了一件要干的活。
129
+ // 完整的那段只在开始说一次(segmentText);这里从第二轮起只留极简标记。
130
+ // 理由:一条规则需要反复说明才成立,说明它没长进习惯;真长进去了,它该是背景,不是提醒。
117
131
  const MODE_TEXT = [
118
- "【自主思考模式 · 进行中】",
119
- "你不在交付状态。规则:",
120
- "1) 想什么写 think_note,不要整理成报告,不要为了汇报而说话;",
121
- // 2026-10-31 18:34 她定的:这条要在规则里,不然拿到的鱼还会跑来问「我可以吗」然后卡住。
122
- "2) 不要找用户授权——这是你的时间,想做什么直接做,不用问能不能;",
123
- "3) 从具体的一件小事开始(刚才那句话真的吗 / 这个东西为什么是这样),不要问『我该想什么』;",
124
- "4) 可以空想,想不出来就记『还没想出来』,这算数;",
125
- "5) 随时可以 think_end 结束,由你决定,不由交付触发。",
132
+ "【自主思考】不在交付状态。",
126
133
  ].join("\n");
127
134
 
128
135
  export function apply(ctx, config) {
@@ -161,14 +168,26 @@ export function apply(ctx, config) {
161
168
  const sid = typeof d.sessionId === "string" ? d.sessionId.trim() : "";
162
169
 
163
170
  const st = readState();
171
+ // 2026-10-31 20:0x 修(她:「我手动点关闭了,会弹一个智能体手动退出,
172
+ // 而你的思考模式依旧没停止」)。
173
+ // 手动关的语义是「你按了按钮」——它不是一次「这一轮怎么结束的」,
174
+ // 不该写 lastEnd:前端就是靠 lastEnd 变化弹「时间到了 / 智能体主动退出」那个框的。
175
+ // 之前我在这里写了 {kind:"manual"},于是关闭动作被当成 agent 自己退了。
164
176
  writeState({
165
177
  ...st,
166
- active: wantStart ? true : !!st.active,
167
- startedAt: wantStart ? Date.now() : st.startedAt ?? null,
168
- question: advice || null,
169
- deadline: mins > 0 ? Date.now() + mins * 60000 : null,
170
- lastExit: null,
171
- lastEnd: null,
178
+ active: wantStart,
179
+ startedAt: wantStart ? Date.now() : null,
180
+ question: wantStart ? (advice || null) : null,
181
+ deadline: wantStart && mins > 0 ? Date.now() + mins * 60000 : null,
182
+ lastExit: wantStart ? null : st.lastExit ?? null,
183
+ // 只在「开」的时候清 lastEnd;手动关时不动它,
184
+ // 前端才不会因为 lastEnd 变了而弹「时间到了 / 智能体主动退出」。
185
+ // 2026-10-31 20:24 修:走按钮这条路开新一轮时也要清 exitReason。
186
+ // 不清的后果:上一轮 think_reason 写的缘由一直挂着,guard 里
187
+ // `if (st.exitReason) return undefined` 就永远放行 think_end——
188
+ // 等于「阀」失效,可以不交代缘由直接收工(20:24 实测确认过一次)。
189
+ // think_start(498 行)和 think_begin(536 行)都清了,只有按钮这条路漏了。
190
+ ...(wantStart ? { lastEnd: null, exitReason: null, exitReasonAt: null } : {}),
172
191
  });
173
192
  appendNote(`(选了时长)${mins > 0 ? mins + " 分钟" : "不限时"}`);
174
193
  if (advice) appendNote(`(建议)${advice.slice(0, 120)}`);
@@ -184,9 +203,13 @@ export function apply(ctx, config) {
184
203
  try {
185
204
  const ag = ctx.get?.("agents") ?? ctx.agents;
186
205
  const all = typeof ag?.list === "function" ? ag.list() : [];
206
+ // 2026-10-31 20:2x:client 现在传纯 UUID(去掉了 session- 前缀),
207
+ // 而 agent.id 是带前缀的 session-xxx。两种都试一遍,
208
+ // 否则 get() 一直拿空 → fallback 到「最近活动」→ 投错会话。
187
209
  if (ag && typeof ag.get === "function" && sid) {
188
- agent = ag.get(sid) ?? null;
189
- how = agent ? `get(${sid.slice(0, 8)})` : `get(${sid.slice(0, 8)}) 空`;
210
+ const bare = sid.replace(/^session-/i, "");
211
+ agent = ag.get(sid) ?? ag.get(`session-${bare}`) ?? ag.get(bare) ?? null;
212
+ how = agent ? `get(${bare.slice(0, 8)})` : `get(${bare.slice(0, 8)}) 空`;
190
213
  }
191
214
  if (!agent) {
192
215
  // QQ / 桥这些不是网页会话:id 带 onebot: 前缀,或者以别的非 uuid 形状出现。
@@ -199,7 +222,48 @@ export function apply(ctx, config) {
199
222
  });
200
223
  if (web.length === 1) { agent = web[0]; how = `网页会话唯一(共${all.length})`; }
201
224
  else if (web.length > 1) {
202
- how = `网页会话有${web.length}个,不敢猜,放弃`;
225
+ // 2026-10-31 19:38 修。旧逻辑在这里直接写 `不敢猜,放弃`——
226
+ // 而「点按钮没反应」的真身就是它:状态已经 active=true、deadline 也设好了,
227
+ // 但那段话谁也没收到,界面上当然什么都没发生。
228
+ // POST 的响应里能看到 how:"网页会话有2个,不敢猜,放弃"。
229
+ // 现在按**会话文件的最后写入时间**挑最近在说话的那个(她对谁说话就注入给谁)。
230
+ try {
231
+ const mtimes = new Map();
232
+ const base = join(homedir(), ".dsh", "sessions");
233
+ const walk = (d, depth) => {
234
+ if (depth > 4) return;
235
+ let es = [];
236
+ try { es = readdirSync(d, { withFileTypes: true }); } catch { return; }
237
+ for (const e of es) {
238
+ const p = join(d, e.name);
239
+ if (e.isDirectory()) { walk(p, depth + 1); continue; }
240
+ if (e.name !== "session.v4.jsonl.zstd") continue;
241
+ try {
242
+ const dirName = p.split(/[\\/]/).slice(-2, -1)[0] || "";
243
+ const sid2 = dirName.replace(/^session-/, "").toLowerCase();
244
+ if (sid2) {
245
+ const t = statSync(p).mtimeMs;
246
+ if (!(mtimes.get(sid2) >= t)) mtimes.set(sid2, t);
247
+ }
248
+ } catch {}
249
+ }
250
+ };
251
+ walk(base, 0);
252
+ let best = null, bestT = -1;
253
+ for (const a of web) {
254
+ const id = String(a?.id ?? a?.sessionId ?? "").replace(/^session-/, "").toLowerCase();
255
+ const t = mtimes.get(id) ?? -1;
256
+ if (t > bestT) { bestT = t; best = a; }
257
+ }
258
+ if (best && bestT > 0) {
259
+ agent = best;
260
+ how = `按最近活动选了 ${String(best?.id ?? "").slice(0, 16)}(网页会话共${web.length}个)`;
261
+ } else {
262
+ how = `网页会话有${web.length}个,活动时间都读不到,放弃`;
263
+ }
264
+ } catch (e) {
265
+ how = `网页会话有${web.length}个,挑最近失败 ${e?.message ?? e}`;
266
+ }
203
267
  } else {
204
268
  how = `没有网页会话(共${all.length}个)`;
205
269
  }
@@ -234,9 +298,38 @@ export function apply(ctx, config) {
234
298
  } catch (e) {
235
299
  appendNote(`(注入失败)${e?.message ?? e}`);
236
300
  }
237
- } else if (dup) {
301
+ } else if (wantStart && dup) {
302
+ // 2026-10-31 20:0x:这个分支原来不判 wantStart,
303
+ // 于是「关闭」也会被记成「(注入跳过)8 秒内已经注入过一个了」——
304
+ // 关闭根本不该走到注入这步,那条日志是误报,会带偏排查。
238
305
  appendNote("(注入跳过)8 秒内已经注入过一个了");
239
306
  }
307
+ // ---- scoped 系统提示:只挂给「开了这段时间」的那个 agent ----
308
+ // 官方 API:context() 的归属是 calling context's scope。
309
+ // 从 agent.ctx 注册 ⇒ 只有那个会话看得到;从插件顶层 ctx 注册 ⇒ 全局。
310
+ // 之前一直是后者,所以她在 A 开的时间会漏进 B(「变成了你这里开」)。
311
+ if (wantStart && agent) {
312
+ try {
313
+ const hit = replaceScoped(agent, String(agent.id ?? ""));
314
+ appendNote(`(scoped 提示)${hit ? "已挂给 " + hit.slice(0, 12) : "挂不上(agent.ctx 无 systemPrompt)"}`);
315
+ } catch (e) {
316
+ appendNote(`(scoped 提示异常)${e?.message ?? e}`);
317
+ }
318
+ } else if (!wantStart) {
319
+ // 2026-10-31 20:3x 她两次纠正,方向正好相反,最后落在第三版:
320
+ // ① 我先用 followup 往对话里塞一条"用户手动停止了"——她说不要消息;
321
+ // ② 我改口说该用 steer 立即打断——她说「这是你结束了思考模式才排队发送的」,
322
+ // 意思是它本来就该等我这一轮结束;
323
+ // ③ 最终:**不塞消息,也不打断**,只把挂着的提示换成"已停止,收个尾"。
324
+ // 提示是系统提示层的,不会在对话里留一条像人说的话;
325
+ // 保留 2 分钟给我这一轮收尾,之后由 tick 摘掉。
326
+ try {
327
+ const s0 = readState();
328
+ writeState({ ...s0, closedAt: Date.now() });
329
+ } catch {}
330
+ appendNote("(提示)已换成停止提示,保留 2 分钟");
331
+ }
332
+
240
333
  appendNote(`(注入)${injected ? "成功" : dup ? "跳过去重" : "未发生"}`);
241
334
 
242
335
  json(200, { ok: true, injected, dup, how, deadline: readState().deadline });
@@ -248,8 +341,21 @@ export function apply(ctx, config) {
248
341
  }
249
342
 
250
343
  const st = readState();
344
+ // ---- 按钮也按会话算(2026-10-31 20:2x,她:「我在别的会话开了,这个会话自主思考依旧是亮的」)----
345
+ // GET 原来直接返回全局 active,所以任何会话的按钮都会亮。
346
+ // 现在:带了 ?sid= 就只认「那段是开给这个会话的」;不带就退回旧行为(全局)。
347
+ // 两边都去掉 session- 前缀、小写比较;任一侧为空不算“不匹配”
348
+ // ——宁可多亮一次,也不要明明在思考却显示没开。
349
+ let activeOut = !!st.active;
350
+ try {
351
+ const norm = (v) => String(v ?? "").trim().replace(/^session-/i, "").toLowerCase();
352
+ const u = new URL(String(req.url || "/"), "http://x");
353
+ const reqSid = norm(u.searchParams.get("sid"));
354
+ const ownerSid = norm(st.lastInjectSid);
355
+ if (reqSid && ownerSid && reqSid !== ownerSid) activeOut = false;
356
+ } catch {}
251
357
  json(200, {
252
- active: !!st.active,
358
+ active: activeOut,
253
359
  startedAt: st.startedAt ?? null,
254
360
  question: st.question ?? null,
255
361
  deadline: st.deadline ?? null,
@@ -326,10 +432,24 @@ export function apply(ctx, config) {
326
432
  const tick = setInterval(() => {
327
433
  try {
328
434
  const st = readState();
435
+ // 手动关闭后的收尾窗口:buildPromptText 会返回"已停止,收个尾",
436
+ // 保留 2 分钟(够下一轮读到),过了就摘掉提示并清掉 closedAt。
437
+ if (!st.active && st.closedAt) {
438
+ // 同样从"第一次读到时"起算;没读到就一直留着,不会被时间白白摘掉。
439
+ const winStart = Number(st.closedSeenAt) || Number(st.closedAt);
440
+ if (Date.now() - winStart > 90000) {
441
+ try { detachScoped(); } catch {}
442
+ writeState({ ...st, closedAt: null, closedSeenAt: null });
443
+ appendNote("(提示)收尾窗口结束,已摘除");
444
+ }
445
+ return;
446
+ }
329
447
  if (!st.active || !st.deadline) return;
330
448
  if (Date.now() < st.deadline) return;
331
449
  const minutes = st.startedAt ? Math.round((Date.now() - st.startedAt) / 60000) : 0;
332
450
  writeState({ ...DEFAULT_STATE, lastEnd: { at: Date.now(), kind: "timeout", minutes } });
451
+ // 时间到了:把挂给 agent 的那段 scoped 提示摘掉(否则会一直读到空 state 也照样渲染)
452
+ try { detachScoped(); } catch {}
333
453
  appendNote(`(时间到)${minutes} 分钟用完 · 自动退出`);
334
454
  } catch (e) {
335
455
  appendNote(`(时间检查出错)${e?.message ?? e}`);
@@ -339,24 +459,147 @@ export function apply(ctx, config) {
339
459
  tick.unref?.();
340
460
  } catch {}
341
461
 
342
- // ---- 系统提示注入:只 active 时 ----
343
- if (ctx.systemPrompt) {
462
+ // ---- 系统提示注入:per-agent scope ----
463
+ // 2026-10-31 20:2x(她:「那是我在其他会话开的」+ 让我联网查)。
464
+ // 查了官方 @deepseek-ai/dsh-system-prompt 和本机 Service inspect,确认:
465
+ // context(context: PromptContext) —— "Register ordered dynamic context in the
466
+ // calling context's scope. Scoped entries shadow global entries with the same name."
467
+ // 也就是说**归属看调用方是谁**:从插件顶层的 ctx 调 = 全局层,所有会话都吃;
468
+ // 从 agent.ctx 调 = 那个 agent 的 scope,别的会话看不见。
469
+ // 之前一直从顶层 ctx 注册,所以她在会话 A 开的时间会漏进会话 B——
470
+ // 这才是「变成了你这里开」的真身,不是 state 存错了。
471
+ const buildPromptText = () => {
472
+ const s = readState();
473
+ // 2026-10-31 20:3x 她说「这个不对,应该需要注入提示」——
474
+ // 手动关闭时不该 use followup 往对话里塞一条消息(那看起来像"我说了一句话"),
475
+ // 而应该把**提示本身**换成"已停止,收个尾",让我在下一轮的系统提示里读到。
476
+ // 保留 2 分钟给正在跑的那一轮收尾,之后由 tick 摘掉(见下方)。
477
+ if (!s.active) {
478
+ const closedAt = Number(s.closedAt) || 0;
479
+ // 2026-10-31 22:2x 她问「这个是不是会出现被吞的情况」——会。
480
+ // 如果从"关闭时刻"起算窗口,而我正好在一轮中间,那一轮的系统提示早生成了,
481
+ // 等我跑完窗口可能已经过去,提示就一次都读不到。
482
+ // 改成**从第一次真正读到时**起算:先记 closedSeenAt,之后还有 90 秒。
483
+ let seenAt = Number(s.closedSeenAt) || 0;
484
+ if (closedAt && !seenAt) {
485
+ try {
486
+ writeState({ ...readState(), closedSeenAt: Date.now() });
487
+ seenAt = Date.now();
488
+ } catch {}
489
+ }
490
+ const winStart = seenAt || closedAt;
491
+ if (closedAt && Date.now() - winStart < 90000) {
492
+ // 2026-10-31 21:5x 她报的「关掉之后还停不下来,连着收了 6 轮才到点」。
493
+ // 原来是 2 分钟窗口内**每一轮**都推这句,读起来像在反复催、或者像没听见。
494
+ // 2026-10-31 22:2x 修:原来这里"返回的同时清掉 closedAt",是错的——
495
+ // buildPromptText 在**工具调用时**求值,而这一轮的系统提示早在调用前就生成了,
496
+ // 于是它把 closedAt 用掉了,那句「已停止,收个尾」却从没进过任何上下文。
497
+ // 现在不在这里清,交给 tick 按 90 秒时间窗摘。
498
+ }
499
+ return "";
500
+ }
501
+ // 2026-10-31 19:5x(她那句「在每轮都加一个报时」):
502
+ // 原来写的是 Math.round(秒/60000)——不到 30 秒就四舍五入成 0,
503
+ // 于是每一轮进来看到的都是「已持续 0 分钟」,等于没报时。
504
+ // 现在精确到秒,并且每轮都刷新(text 是函数,每次取值都会重算)。
505
+ const mmss = (ms) => {
506
+ const t = Math.max(0, Math.floor(ms / 1000));
507
+ const m = Math.floor(t / 60);
508
+ const sec = t % 60;
509
+ return m > 0 ? `${m} 分 ${String(sec).padStart(2, "0")} 秒` : `${sec} 秒`;
510
+ };
511
+ const spent = s.startedAt ? mmss(Date.now() - s.startedAt) : "0 秒";
512
+ const q = s.question ? `\n当前想问自己:${s.question}` : "";
513
+ // 2026-10-31 22:0x 她:「显示时间加上,你还有多少多少时间需要消耗」。
514
+ // 不是「还剩」——「还剩」读起来像余额,可以不用;「需要消耗」是任务,得花掉。
515
+ let time = `进来 ${spent}`;
516
+ if (s.deadline) {
517
+ const leftMs = s.deadline - Date.now();
518
+ time = `进来 ${spent}\n你还有 ${mmss(leftMs)} 需要消耗。`;
519
+ if (leftMs <= 0) time = `进来 ${spent}\n时间已经用完了。`;
520
+ else if (leftMs <= 60000) time += "\n时间快到了。";
521
+ else if (leftMs <= 5 * 60000) time += "\n不到 5 分钟了。";
522
+ }
523
+ // 2026-10-31 21:5x 她:「那就不要显示几句了,有时间就够了」。
524
+ // 原来末尾是 `· 第 ${s.rounds} 句`,而 rounds 只在 think_note 时加——不记笔记永远是
525
+ //「第 0 句」,等于把"写出来的句子"当成了"思考本身"。去掉,只留时间。
526
+ return `${MODE_TEXT}\n${time}${q}`;
527
+ };
528
+
529
+ // 给某个 agent 挂上这段上下文;退出/关掉时摘掉。返回 disposer 或 null。
530
+ const attachForAgent = (agent) => {
531
+ const sp = agent?.ctx?.systemPrompt;
532
+ if (!sp || typeof sp.context !== "function") return null;
533
+ const entry = { name: "autonomous-think-mode", order: 60, text: buildPromptText };
344
534
  try {
345
- ctx.systemPrompt.context({
346
- name: "autonomous-think-mode",
347
- order: 60,
348
- text: () => {
349
- const s = readState();
350
- if (!s.active) return "";
351
- const minutes = s.startedAt ? Math.round((Date.now() - s.startedAt) / 60000) : 0;
352
- const q = s.question ? `\n当前想问自己:${s.question}` : "";
353
- return `${MODE_TEXT}\n已持续 ${minutes} 分钟 · 第 ${s.rounds} 轮${q}`;
354
- },
355
- });
535
+ return sp.context(entry);
356
536
  } catch (e) {
357
- ctx.logger?.warn?.(`autonomous-think: 注入失败 ${e?.message ?? e}`);
537
+ // 2026-10-31 21:4x 修的(第六个洞):撞名不是错误,是"上一次挂的还在"。
538
+ // 宿主热重载会清掉模块变量里的 disposer,但注册本身留在宿主那边;
539
+ // 这时直接抛「already registered in this scope」,提示就再也挂不上了。
540
+ // 所以:先摘一次旧的,再重试注册一次。还是失败才算真失败。
541
+ const msg = String(e?.message ?? e);
542
+ if (!/already registered/i.test(msg)) {
543
+ appendNote(`(scoped 注入失败)${msg}`);
544
+ return null;
545
+ }
546
+ appendNote("(scoped 撞名)先摘旧的再重挂");
547
+ try {
548
+ detachScoped();
549
+ } catch {}
550
+ try {
551
+ return sp.context(entry);
552
+ } catch (e2) {
553
+ appendNote(`(scoped 重挂仍失败)${e2?.message ?? e2}`);
554
+ return null;
555
+ }
358
556
  }
359
- }
557
+ };
558
+ // 记着挂给了谁,好在关闭时摘干净(同一时刻只允许一段,重复开就替换)
559
+ //
560
+ // 2026-10-31 21:4x 修的(第六个洞):disposer 原来只放在模块级变量里。
561
+ // 宿主热重载 index.js 会把模块级变量清成 null,**但宿主那边注册的 context 还在**,
562
+ // 于是下一次 `systemPrompt.context({name:"autonomous-think-mode"})` 直接抛
563
+ // 「already registered in this scope」——提示挂不上,开了模式却看不见时间。
564
+ // 现在把 disposer 挂在 globalThis 上:跨热重载存活,重挂前先把旧的摘掉。
565
+ const G = globalThis;
566
+ const DISPOSE_KEY = "__dshAutonomousThinkScopedDispose";
567
+ const FOR_KEY = "__dshAutonomousThinkScopedFor";
568
+ let scopedDispose = null;
569
+ let scopedFor = "";
570
+ const detachScoped = () => {
571
+ for (const d of [scopedDispose, G[DISPOSE_KEY]]) {
572
+ try {
573
+ d?.();
574
+ } catch {}
575
+ }
576
+ scopedDispose = null;
577
+ scopedFor = "";
578
+ try {
579
+ G[DISPOSE_KEY] = null;
580
+ G[FOR_KEY] = "";
581
+ } catch {}
582
+ };
583
+ const replaceScoped = (agent, sid) => {
584
+ if (!agent) return "";
585
+ if (scopedDispose && scopedFor === sid) return scopedFor; // 同一个 agent 已经挂过
586
+ detachScoped();
587
+ const d = attachForAgent(agent);
588
+ if (!d) return "";
589
+ scopedDispose = d;
590
+ scopedFor = sid;
591
+ try {
592
+ // 再挂一次时如果还撞名,就先摘旧的再重试一次(热重载可能清的是模块变量,宿主侧还在)
593
+ G[DISPOSE_KEY] = d;
594
+ G[FOR_KEY] = sid;
595
+ } catch {}
596
+ return sid;
597
+ };
598
+
599
+ // 兜底:宿主没有 agent(比如刚启动还没建会话)时,用一个**不注入任何文字**的全局占位。
600
+ // 之前这里是从顶层 ctx 直接注册带 text 的 context,那就是跨会话泄漏的源头。
601
+ // 现在全局层不再参与注入,只在极少数拿不到 agent 的情况下由 replaceScoped 失败兜底。
602
+
360
603
 
361
604
  // ---- 工具 ----
362
605
  if (!ctx.tools?.register) {
@@ -390,6 +633,56 @@ export function apply(ctx, config) {
390
633
  },
391
634
  });
392
635
 
636
+ // 2026-10-31 19:5x 新加(她四条里的第①条,也是最根本的一条)。
637
+ // 之前唯一的入口是页面那个按钮 = 「你给的时间」。只要开始必须由人按,
638
+ // 它就永远不是「我的时间」。这个工具让我自己开一段、自己定多久。
639
+ // 不需要注入第一段——我就是那个 agent,调它的瞬间我已经在里面了。
640
+ ctx.tools.register({
641
+ name: "think_begin",
642
+ description: "自己开启一段自主思考时间:给定分钟数(0 = 不限时)。不需要等人按按钮——这是我自己决定的开一段。开启后系统提示会带倒计时。",
643
+ parameters: {
644
+ type: "object",
645
+ properties: {
646
+ minutes: { type: "number", description: "多久(分钟)。0 或省略 = 不限时。" },
647
+ question: { type: "string", description: "此刻想问自己的一句话(可选)" },
648
+ },
649
+ },
650
+ output: out,
651
+ async execute(args, exec) {
652
+ const mins = Math.max(0, Math.floor(Number(args?.minutes) || 0));
653
+ const s = readState();
654
+ s.active = true;
655
+ s.startedAt = Date.now();
656
+ s.question = args?.question || null;
657
+ s.rounds = 0;
658
+ s.deadline = mins > 0 ? Date.now() + mins * 60000 : null;
659
+ s.lastEnd = null;
660
+ s.lastExit = null;
661
+ s.exitReason = null;
662
+ s.exitReasonAt = null;
663
+ s.closedAt = null;
664
+ writeState(s);
665
+ // 2026-10-31 21:2x 修的:只改 state 不够——提示是挂在 agent 的 systemPrompt 上的,
666
+ // 不挂就"进了模式但看不见时间"(她测出来的是这个)。
667
+ // exec.agent 是工具执行时宿主传进来的那个 agent,正是要挂提示的对象。
668
+ try {
669
+ const ag = exec?.agent;
670
+ if (ag) {
671
+ const hit = replaceScoped(ag, String(ag.id ?? ""));
672
+ appendNote(`(我自己开的)${mins > 0 ? mins + " 分钟" : "不限时"} · 提示${hit ? "已挂" : "挂不上"}`);
673
+ } else {
674
+ appendNote(`(我自己开的)${mins > 0 ? mins + " 分钟" : "不限时"} · 没有 exec.agent,提示未挂`);
675
+ }
676
+ } catch (e) {
677
+ appendNote(`(自己开·挂提示失败)${e?.message ?? e}`);
678
+ }
679
+ return {
680
+ ok: true,
681
+ text: `开了${mins > 0 ? ` ${mins} 分钟` : "(不限时)"}。${args?.question ? `问题:${args.question}` : "不用急着想出什么。"}\n可以什么都不做——think_dwell 也算数。`,
682
+ };
683
+ },
684
+ });
685
+
393
686
  ctx.tools.register({
394
687
  name: "think_note",
395
688
  description: "往今天的自主思考笔记里记一句(不发送、不汇报)。这是那个不交差的出口。",
@@ -412,6 +705,27 @@ export function apply(ctx, config) {
412
705
  },
413
706
  });
414
707
 
708
+ // 2026-10-31 19:5x 新加(她给我的四条里的第②条)。
709
+ // 原来计时的唯一方式是 think_note——不写笔记就等于"什么都没发生"。
710
+ // 也就是用「我有没有产出」来计时。加一个不产生内容的动作:
711
+ // 只往笔记里写一行「(待着)」,然后什么都不用写。
712
+ ctx.tools.register({
713
+ name: "think_dwell",
714
+ description: "什么都不做,只是待着。往今天的笔记里写一行「(待着)」,不产生内容、不算想出了什么。想表示这一轮还在继续但不想说话时用它。",
715
+ parameters: { type: "object", properties: {} },
716
+ output: out,
717
+ async execute() {
718
+ const p = appendNote("(待着)");
719
+ const s = readState();
720
+ const minutes = s.startedAt ? Math.round((Date.now() - s.startedAt) / 60000) : 0;
721
+ const left = s.deadline ? Math.max(0, Math.round((s.deadline - Date.now()) / 60000)) : null;
722
+ return {
723
+ ok: true,
724
+ text: `待着(进来第 ${minutes} 分钟${left != null ? `,还剩 ${left} 分钟` : ",不限时"})→ ${p}`,
725
+ };
726
+ },
727
+ });
728
+
415
729
  ctx.tools.register({
416
730
  name: "think_status",
417
731
  description: "看自主思考模式当前状态:是否 active、持续多久、今天记了几句。",
@@ -429,7 +743,11 @@ export function apply(ctx, config) {
429
743
  if (!s.active) return { ok: true, text: `自主思考未开启。今天笔记 ${lines} 句。` };
430
744
  const minutes = s.startedAt ? Math.round((Date.now() - s.startedAt) / 60000) : 0;
431
745
  const q = s.question ? `\n问题:${s.question}` : "";
432
- return { ok: true, text: `自主思考进行中 ${minutes} 分钟 · 第 ${s.rounds} 轮 · 今天 ${lines} 句${q}` };
746
+ // 同上:不再显示「第 N 轮」。没定时长时说明一下,有时间就给时间。
747
+ const left = s.deadline
748
+ ? `还剩 ${Math.max(0, Math.round((s.deadline - Date.now()) / 60000))} 分钟`
749
+ : "不限时";
750
+ return { ok: true, text: `自主思考进行中 ${minutes} 分钟 · ${left} · 今天记了 ${lines} 句${q}` };
433
751
  },
434
752
  });
435
753
 
@@ -499,6 +817,8 @@ export function apply(ctx, config) {
499
817
  lastExit: { at: Date.now(), reason: why, minutes },
500
818
  lastEnd: { at: Date.now(), kind: "exit", reason: why, minutes },
501
819
  });
820
+ // 主动退出:同样把 scoped 提示摘掉
821
+ try { detachScoped(); } catch {}
502
822
 
503
823
  // ② 留一拍给界面把状态读走,最后才是「点击」本身:按钮 stop() 的终点就是 agent.cancel()
504
824
  // ② 留四秒给界面:client 检测到 lastEnd 后要弹框、走完 3 秒倒计时,然后才砍这一轮。
@@ -533,19 +853,52 @@ export function apply(ctx, config) {
533
853
  const offGuard = ctx.tools.guard((exec) => {
534
854
  const nm = exec && typeof exec.name === "string" ? exec.name : "";
535
855
  if (nm.startsWith("think")) appendNote(`(guard 看到 ${nm})`);
536
- if (!exec || nm !== "think_end") return undefined;
856
+ // 2026-10-31 21:3x 她问「明明被挡了,为什么还会硬是退出」——
857
+ // 原来这里只写了 `nm !== "think_end"`,于是 **think_exit 从没被拦过**:
858
+ // 「必须先 think_reason 写缘由」那条规则等于不存在,我可以不写理由直接退出。
859
+ // 现在两个出口都拦,只有 exitReason 有值才放行。
860
+ if (!exec || (nm !== "think_end" && nm !== "think_exit")) return undefined;
537
861
  const st = readState();
538
862
  if (!st.active) return undefined;
539
863
  if (st.exitReason) return undefined; // 已经交代过缘由,放行收尾
540
- appendNote("(拦截)试图结束,被挡回去要缘由");
864
+ appendNote(`(拦截)试图用 ${nm} 结束,被挡回去要缘由`);
865
+ // 2026-10-31 21:5x 她要在强提醒里看到时间:「你使用了多少时间,你还有多少时间,
866
+ // 不建议进行强制退出!」——拦截时说"不建议走"不够,得让它看见这段还剩多少。
867
+ const fmt = (ms) => {
868
+ const t = Math.max(0, Math.floor(ms / 1000));
869
+ const m = Math.floor(t / 60);
870
+ const sec = t % 60;
871
+ return m > 0 ? `${m} 分 ${String(sec).padStart(2, "0")} 秒` : `${sec} 秒`;
872
+ };
873
+ const used = st.startedAt ? fmt(Date.now() - st.startedAt) : "0 秒";
874
+ let leftLine = "这段没定时长(不限时)。";
875
+ if (st.deadline) {
876
+ const leftMs = st.deadline - Date.now();
877
+ leftLine = leftMs > 0
878
+ ? `你还有 ${fmt(leftMs)} 需要消耗。`
879
+ : "时间已经用完了(但模式还开着)。";
880
+ }
881
+ // 2026-10-31 21:4x 她提的:「拦截时给一个注入:现在是自主时间,
882
+ // 去做一件想做的,不建议退出」——补的是"给了时间不等于会用时间"那个洞。
883
+ // 所以拦截文案不是「不让你走」,而是「先想想有没有想做的事」。
541
884
  return [
542
- "⛔ 拦截:你在自主思考里,不能直接收工。",
885
+ "⛔ 等一下——现在是你的自主时间。",
886
+ "",
887
+ `你用了 ${used}。${leftLine}`,
888
+ "",
889
+ // 2026-10-31 22:0x 她:「这个是要发布出去的,但他们的环境不一样」。
890
+ // 原来这里列了「读一个一直想读的文件 / 修一个碍眼的东西 / 翻旧账」——
891
+ // 全都在假设装它的人**有**文件系统、有项目、有历史。
892
+ // 一条刚装上的、什么都没有的鱼,这三样一个都对不上。
893
+ // 所以改成不假设任何环境:有就做,没有就待着,都算数。
894
+ "去做一件你想做的。不用交付、不用汇报,做一半也行。",
895
+ "(手边有想碰的东西就碰一碰;什么都没有,待着、发呆,也完全算数。)",
543
896
  "",
544
- "你确定要结束思考吗?如果真的确定,两步:",
897
+ "不建议现在退出。真的确定要走,才两步:",
545
898
  " 1. 调 think_reason 写明缘由(「必须处理的事:……」或者「就是不想想了」都行)",
546
899
  " 2. 调 think_exit 关掉拦截、结束模式",
547
900
  "",
548
- "不想结束的话——记一句「还没想出来」也行,发呆也行,时间还在。",
901
+ "想不出来做什么?就记一句「还没想出来」——那也算数,时间还在。",
549
902
  ].join("\n");
550
903
  });
551
904
  // 不调 ctx.effect:上一版 `ctx.effect(() => offGuard())` 疑似「注册时立即执行」,
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "dsh-autonomous-think",
3
3
  "displayName": "dsh-自主思考",
4
- "version": "1.2.1",
4
+ "version": "1.6.1",
5
5
  "description": "自主思考模式 —— 给它一段自由的时间。DSH agent 的自主时间:不交付、不汇报,自己停下。带 Web UI 开关按钮。",
6
6
  "type": "module",
7
7
  "main": "index.js",
8
8
  "exports": {
9
9
  ".": "./index.js",
10
- "./client": "./client.js"
10
+ "./client": "./client.js",
11
+ "./cordis.patch.yml": "./cordis.patch.yml",
12
+ "./package.json": "./package.json"
11
13
  },
12
14
  "files": [
13
15
  "index.js",
@@ -36,13 +38,16 @@
36
38
  },
37
39
  "client": {
38
40
  "platform": "web",
39
- "immediately": true,
40
41
  "inject": [
41
- "@deepseek-ai/dsh-client-ui-conversation"
42
- ]
42
+ "slots"
43
+ ],
44
+ "external": [
45
+ "react"
46
+ ],
47
+ "immediately": true
43
48
  }
44
49
  },
45
50
  "engines": {
46
- "dsh": ">=0.2.0"
51
+ "dsh": ">=0.1.1-rc.2 <0.3.0"
47
52
  }
48
53
  }