@suifei/dsh-pmo-button 0.0.0-stage → 0.7.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 suifei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,118 @@
1
- # Temporary Holding Version
1
+ # 🎛️ PMO 审查循环按钮
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@suifei/dsh-pmo-button.svg)](https://www.npmjs.com/package/@suifei/dsh-pmo-button)
4
+ [![license](https://img.shields.io/npm/l/@suifei/dsh-pmo-button.svg)](./LICENSE)
5
+ [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-blue)](https://github.com/deepseek-ai/deepseek-harness)
6
+
7
+ > 输入框旁的一颗红↑按钮 —— 一键为当前会话挂载一位**常驻 PMO 审查官**(持久子代理)。
8
+ > 你的每一轮工作自动转发给它按 **STATUS 协议**审查:`CONTINUE` 自动推进、`DONE` 收工、`NEED_HUMAN` 找你。
9
+ > 全程无人工干预,让会话自己把工作循环推进到底。
10
+
11
+ ---
12
+
13
+ ## ✨ 特性
14
+
15
+ - **📍 原位按钮** — 输入框发送键旁的红↑控件:待命为圆形浅底红箭头,激活为红底白箭头 + 呼吸点动效;状态跟随会话,跨启停、跨重启、跨页面刷新持久。
16
+ - **🤖 持久审查子代理** — 每个会话一个固定 `childId` 的 continuable 子代理:跨启停、跨结算、跨冷恢复、**甚至跨上下文压缩**持续复用同一份记忆;自动继承主会话的模型与思考等级。
17
+ - **📝 轮次日志** — 每轮结束把 `[PMO]` 用户原话(图片附件自动解析出内容寻址仓库路径)、`[主代理]` 完整答复、`[注入]` 系统项分级写入日志,带模型显示名 + 思考等级前缀与 usage 统计。
18
+ - **🔄 STATUS 自动循环** — `CONTINUE` 推进 / `DONE` 收工 / `NEED_HUMAN` 交还用户;非 `CONTINUE` 自动熄灭按钮,`maxRounds` 兜底防无限循环。
19
+ - **🔐 一次性行为** — 身份提示词每会话仅下发一次(子代理确认接收后才落标志,失败自动补发)。
20
+ - **🧹 绝对无害** — 纯观察者架构:不注入模型上下文之外的任何旁路,停止/卸载即时恢复原状,不影响会话正常流程。
21
+
22
+ ## 📦 安装
23
+
24
+ ```sh
25
+ # 从 npm
26
+ dsh plugin --profile <你的profile> add @suifei/dsh-pmo-button
27
+
28
+ # 或从 GitHub 源码
29
+ dsh plugin --profile <你的profile> add github:suifei/dsh-pmo-button
30
+ ```
31
+
32
+ 依赖 DeepSeek Harness 默认 base 组合(`subagent` / `spawn-in-process` / `session-persistence` / `session-query` / `compaction` 均为默认挂载),无需额外配置。
33
+
34
+ ## 🚀 使用
35
+
36
+ 1. 刷新页面 → 输入框发送键旁出现红↑按钮;
37
+ 2. **点击启动** → 自动创建(或复用)持久子代理「PMO 子代理」,下发一次性身份提示词 + 问候;
38
+ 3. 正常对话干活 —— 每轮结束自动转发给审查官;
39
+ 4. 审查官的 `STATUS` 结论自动回流本会话:`CONTINUE` 继续推进,`DONE` 自动熄灭按钮收工,`NEED_HUMAN` 按其指示交还给你;
40
+ 5. 随时 **再点一下** 停止转发;重新点亮恢复循环。用户重新发言也会自动解除停摆。
41
+
42
+ ### STATUS 协议
43
+
44
+ | STATUS | 含义 | 按钮行为 |
45
+ |---|---|---|
46
+ | `CONTINUE` | 工作未完,按"下一步"继续 | 保持点亮,循环推进 |
47
+ | `DONE` | 目标达成,无遗留问题 | **自动熄灭**(回声静默) |
48
+ | `NEED_HUMAN` | 需要用户决策/澄清/授权 | **自动熄灭**,结论中写明需要你决定什么 |
49
+
50
+ ## ⚙️ 配置
51
+
52
+ | 字段 | 默认值 | 说明 |
53
+ |---|---|---|
54
+ | `logFile` | `pmo-button.log` | 轮次日志路径(建议绝对路径) |
55
+ | `maxChars` | `4000` | 日志单块截断阈值(转发块为 2×) |
56
+ | `provider` | `spawn` | 子代理提供方(`ctx.subagents` 注册名) |
57
+ | `childLabel` | `PMO 子代理` | 持久子代理标签 |
58
+ | `greet` | `hi` | 每次启动发给子代理的问候文本 |
59
+ | `rolePrompt` | (内置审查规程原文) | 身份提示词;每会话仅发一次,置空禁用 |
60
+ | `maxRounds` | `20` | 自上次用户输入以来的审查轮数上限(0 = 不限),超出自动熄灭 |
61
+
62
+ ## 📜 日志格式
63
+
64
+ ```text
65
+ ────────────────────────────────────────────────
66
+ 2026-11-13T22:07:29 [轮次结束] session=session-8e3e… turn=33 reason={"kind":"completed"}
67
+ [PMO] 把登录页的报错修一下,用户名过长时布局炸了
68
+ [图片] bug.png(1920x1080, image/png, 284.5KB)=> C:\Users\<you>\.dsh\attachments\v1\objects\28\2845…
69
+ [注入:time-context] Time sampled while preparing turn 12, …
70
+ [GLM-5.3-Flash Max]
71
+
72
+ 修复说明:用户名长度未约束容器宽度,已加 ellipsis 与 title…(完整答复,多段以 --- 分隔)
73
+ (usage: in=12500 out=890)
74
+ 2026-11-13T22:07:29 [轮次转发] turn=33 已送达 child=d407cf5b-5f3…(第 3/20 轮审查)
75
+ 2026-11-13T22:10:18 [PMO审查] STATUS=CONTINUE(来自子代理)
76
+ ```
77
+
78
+ ## 🧱 架构
79
+
80
+ ```text
81
+ ┌─ 客户端 client.js(web)────────────────────────────┐
82
+ │ conversation.input.right → 红↑按钮(8s 轮询同步宿主状态)│
83
+ │ conversation.composer.dock → 活性指示条 │
84
+ └──────────────┬───────────────────────────────────┘
85
+ │ 同源 fetch /pmo-button/{state,toggle,ping}
86
+ ┌──────────────▼─ 宿主 index.js ─────────────────────┐
87
+ │ webServer 路由(启停/查询/诊断) │
88
+ │ session/event 监听:turn 缓冲 → turn/end 落日志+转发 │
89
+ │ pmo-state.json:启用集/子代理/身份标志/审查轮数 持久化 │
90
+ └──────────────┬───────────────────────────────────┘
91
+ │ ctx.subagents(spawn,delivery:'parent')
92
+ ┌──────────────▼─ 持久子代理(continuable)───────────┐
93
+ │ 固定 childId · 冷恢复 · 压缩摘要 · STATUS 结束语 │
94
+ └─────────────────────────────────────────────────┘
95
+ ```
96
+
97
+ - **为什么状态放在宿主侧?** 按钮状态与审查循环属于会话级事实:宿主持久化让它们跨页面刷新、跨 dsh 重启、跨多端一致。
98
+ - **为什么走同源 HTTP 路由而不是 RPC?** 与市场类插件的成熟约定一致:包私有前缀路由 + `webServer.register({kind:'prefix'})`,桌面壳准入实测对 GET 最友好。
99
+ - **为什么轮次数据从事件流缓冲而不是读会话文件?** JSONL 持久层为 zstd 压缩 + 批量落盘;`session/event` 是官方唯一 post-commit 观察面,实时且零依赖。
100
+
101
+ ## 🛠️ 开发
102
+
103
+ ```sh
104
+ git clone https://github.com/suifei/dsh-pmo-button.git
105
+ cd dsh-pmo-button && pnpm install --ignore-workspace
106
+ npm test # node --check 双端语法校验
107
+ ```
108
+
109
+ 本地调试以链接方式安装,配置热替换,改完 `cordis.patch.yml` 即时生效:
110
+
111
+ ```sh
112
+ dsh plugin --profile <你的profile> add ./dsh-pmo-button
113
+ dsh --profile <你的profile> --dump-config # 验证插件树
114
+ ```
115
+
116
+ ## 📄 License
117
+
118
+ [MIT](./LICENSE) © suifei
package/client.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * PMO button — Client half v0.7.1.
3
+ * Red start/stop control in `conversation.input.right` (idle: neutral circle
4
+ * with red arrow; armed: red circle with pulse animation) plus a liveness
5
+ * strip in `conversation.composer.dock`. State is owned by the Host half via
6
+ * the package-private /pmo-button/* same-origin routes; errors surface in the
7
+ * tooltip and the strip instead of vanishing.
8
+ */
9
+ window.__ModuleLoader__.load({
10
+ id: '@suifei/dsh-pmo-button',
11
+ factory(require) {
12
+ const React = require('react');
13
+ const h = React.createElement;
14
+ const BASE = '/pmo-button';
15
+ const VER = 'v0.7.1';
16
+ let loadError = null;
17
+ let t = { start: 'Start PMO automation', stop: 'PMO running: click to stop', busy: 'PMO: pending…', offline: 'PMO: host unreachable' };
18
+
19
+ const CSS = [
20
+ '.pmo-btn{position:relative;flex:0 0 auto;width:32px;height:32px;margin:0;padding:0;border:0;border-radius:9999px;',
21
+ 'background:var(--dsw-alias-bg-layer-2,#e8e8ea);color:var(--dsw-alias-state-error-primary,#e5484d);display:inline-flex;',
22
+ 'align-items:center;justify-content:center;cursor:pointer;transition:background .15s ease,color .15s ease,transform .15s ease}',
23
+ '.pmo-btn:hover{background:var(--dsw-alias-bg-overlay,#dcdcdf)}',
24
+ '.pmo-btn:focus-visible{outline:2px solid currentColor;outline-offset:2px}',
25
+ '.pmo-btn:disabled{opacity:.5;cursor:default}',
26
+ '.pmo-btn svg{width:17px;height:17px;display:block}',
27
+ '.pmo-btn.pmo-on{background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff;',
28
+ 'animation:pmo-work 1.4s ease-in-out infinite}',
29
+ '.pmo-btn.pmo-on:hover{filter:brightness(.92)}',
30
+ '@keyframes pmo-work{0%,100%{transform:scale(1)}50%{transform:scale(1.12)}}',
31
+ '@media (prefers-reduced-motion:reduce){.pmo-btn.pmo-on{animation:none}}',
32
+ '.pmo-strip{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:2px 10px;',
33
+ 'font-size:11px;color:var(--dsw-alias-label-secondary,#888);user-select:none}',
34
+ '.pmo-strip .pmo-ok{width:7px;height:7px;border-radius:9999px;background:var(--dsw-alias-state-success-primary,#2ea043)}',
35
+ '.pmo-strip .pmo-bad{width:7px;height:7px;border-radius:9999px;background:var(--dsw-alias-state-error-primary,#e5484d)}',
36
+ ].join('');
37
+
38
+ function pickDict(localeSvc) {
39
+ try {
40
+ const snap = localeSvc && typeof localeSvc.getLocale === 'function' ? localeSvc.getLocale() : null;
41
+ const id = typeof snap === 'string'
42
+ ? snap
43
+ : ((snap && (snap.id || snap.locale)) || (typeof navigator !== 'undefined' ? navigator.language : '') || 'en');
44
+ return String(id).toLowerCase().indexOf('zh') === 0
45
+ ? { start: 'PMO:启动自动化', stop: 'PMO:运行中,点击停止', busy: 'PMO:处理中…', offline: 'PMO:宿主未响应' }
46
+ : { start: 'Start PMO automation', stop: 'PMO running: click to stop', busy: 'PMO: pending…', offline: 'PMO: host unreachable' };
47
+ } catch (e) {
48
+ return { start: 'Start PMO automation', stop: 'PMO running: click to stop', busy: 'PMO: pending…', offline: 'PMO: host unreachable' };
49
+ }
50
+ }
51
+
52
+ function sessionIdOf(props) {
53
+ const cands = [
54
+ props ? props.sessionId : void 0,
55
+ props && props.session ? props.session.id : void 0,
56
+ props && props.sessionSnapshot ? props.sessionSnapshot.id : void 0,
57
+ props && props.binding ? props.binding.id : void 0,
58
+ ];
59
+ for (let i = 0; i < cands.length; i++) if (typeof cands[i] === 'string' && cands[i]) return cands[i];
60
+ return 'no-session';
61
+ }
62
+
63
+ function buildPlugin() {
64
+ function PmoButton(props) {
65
+ try {
66
+ const sid = sessionIdOf(props);
67
+ const state = React.useState({ running: false, turns: 0, online: null, pending: false });
68
+ const view = state[0];
69
+ const setView = state[1];
70
+
71
+ React.useEffect(function () {
72
+ let dead = false;
73
+ // Poll the host state so a Host-side auto-extinguish (STATUS !==
74
+ // CONTINUE or the review-round cap) dims the button too.
75
+ const tick = function () {
76
+ fetch(BASE + '/state?session=' + encodeURIComponent(sid))
77
+ .then(function (r) { return r.json(); })
78
+ .then(function (body) {
79
+ if (!dead && body && body.ok) {
80
+ setView(function (v) {
81
+ return v.pending ? v : { running: !!body.enabled, turns: body.turns || 0, online: true, pending: false, err: null };
82
+ });
83
+ }
84
+ })
85
+ .catch(function () { /* keep last known state between polls */ });
86
+ };
87
+ tick();
88
+ const iv = setInterval(tick, 8000);
89
+ return function () { dead = true; clearInterval(iv); };
90
+ }, [sid]);
91
+
92
+ function onClick() {
93
+ const next = !view.running;
94
+ setView(function (v) { return Object.assign({}, v, { pending: true }); });
95
+ // GET with query params: same-origin POST was observed blocked by the
96
+ // desktop shell's connection admission, so toggle rides on GET too.
97
+ fetch(BASE + '/toggle?session=' + encodeURIComponent(sid) + '&enabled=' + (next ? '1' : '0'))
98
+ .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
99
+ .then(function (body) {
100
+ if (body && body.ok) setView({ running: !!body.enabled, turns: next ? 0 : view.turns, online: true, pending: false, err: null });
101
+ else setView(function (v) { return Object.assign({}, v, { pending: false, online: false, err: 'bad response' }); });
102
+ })
103
+ .catch(function (e) { setView(function (v) { return Object.assign({}, v, { pending: false, online: false, err: String(e && e.message || e) }); }); });
104
+ }
105
+
106
+ const tip = VER + ' · ' + (view.online === false
107
+ ? t.offline
108
+ : view.pending ? t.busy
109
+ : view.running ? t.stop + '(已记录 ' + view.turns + ' 轮)' : t.start)
110
+ + ' · session:' + (sid === 'no-session' ? '—' : sid.slice(0, 8))
111
+ + ' 路由:' + (view.online === null ? '?' : view.online ? 'ok' : '✗ ' + (view.err || ''));
112
+
113
+ return h('button', {
114
+ type: 'button',
115
+ className: 'pmo-btn' + (view.running ? ' pmo-on' : ''),
116
+ title: tip,
117
+ 'aria-label': view.running ? t.stop : t.start,
118
+ 'aria-pressed': view.running,
119
+ disabled: view.pending,
120
+ onClick: onClick,
121
+ },
122
+ h('style', null, CSS),
123
+ h('svg', {
124
+ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
125
+ 'stroke-width': 2.4, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': true,
126
+ },
127
+ h('path', { d: 'M12 19V5' }),
128
+ h('path', { d: 'M5 12l7-7 7 7' }),
129
+ ),
130
+ );
131
+ } catch (err) {
132
+ return h('span', { className: 'pmo-strip', title: String(err) }, h('span', { className: 'pmo-bad' }), 'PMO按钮渲染异常:' + err);
133
+ }
134
+ }
135
+
136
+ // Dumb marker strip below the composer card — proves the module is alive.
137
+ function PmoStrip() {
138
+ if (loadError) {
139
+ return h('div', { className: 'pmo-strip' }, h('span', { className: 'pmo-bad' }), 'PMO ' + VER + ' client 异常: ' + loadError);
140
+ }
141
+ const now = new Date().toTimeString().slice(0, 8);
142
+ return h('div', { className: 'pmo-strip' }, h('span', { className: 'pmo-ok' }), 'PMO ' + VER + ' client 已加载 ' + now);
143
+ }
144
+
145
+ return {
146
+ inject: ['slots'],
147
+ apply(c) {
148
+ t = pickDict(c.get ? c.get('locale') : null);
149
+ c.slots.inject('conversation.composer.dock', function () {
150
+ return c.slots.register(
151
+ { name: 'conversation.composer.dock', id: 'pmo-button-strip', order: 90 },
152
+ PmoStrip,
153
+ );
154
+ });
155
+ c.slots.inject('conversation.input.right', function () {
156
+ return c.slots.register(
157
+ { name: 'conversation.input.right', id: 'pmo-button', order: 10 },
158
+ PmoButton,
159
+ );
160
+ });
161
+ },
162
+ };
163
+ }
164
+
165
+ try {
166
+ return buildPlugin();
167
+ } catch (err) {
168
+ // Factory-level failure: still return a plugin so the strip can show why.
169
+ loadError = String(err);
170
+ return {
171
+ inject: ['slots'],
172
+ apply(c) {
173
+ c.slots.inject('conversation.composer.dock', function () {
174
+ return c.slots.register(
175
+ { name: 'conversation.composer.dock', id: 'pmo-button-strip', order: 90 },
176
+ function () { return h('div', { className: 'pmo-strip' }, h('span', { className: 'pmo-bad' }), 'PMO ' + VER + ' 加载失败: ' + loadError); },
177
+ );
178
+ });
179
+ },
180
+ };
181
+ }
182
+ },
183
+ });
@@ -0,0 +1,7 @@
1
+ - insert:
2
+ - id: pmo-button
3
+ name: '@suifei/dsh-pmo-button'
4
+ config:
5
+ logFile: 'E:\works\dsh-button\logs\pmo-button.log'
6
+ maxChars: 4000
7
+ greet: 'hi'
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <circle cx="32" cy="32" r="24" fill="#e5484d"/>
3
+ <path d="M32 44V20M21 31l11-11 11 11" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
4
+ </svg>
package/index.js ADDED
@@ -0,0 +1,578 @@
1
+ /**
2
+ * PMO button — Host half v0.7.1.
3
+ * Per-session armed set, turn buffers, child registry, and the once-per-session
4
+ * identity-prompt flag are persisted (pmo-state.json), so restarts lose nothing.
5
+ * The `session/event` feed drives two jobs for armed sessions:
6
+ * 1. turn-completion logging — user/message + assistant/message are buffered
7
+ * per open turn (the two surface types carrying model-visible content) and
8
+ * flushed to the log file when turn/end closes the turn;
9
+ * 2. the auto-review loop — the flushed blocks are forwarded to the persistent
10
+ * PMO child (created once per session on startActivation, reused via
11
+ * sendMessage with cold resume), whose STATUS closing line returns to this
12
+ * session through the delivery:'parent' settlement notice and is recognized
13
+ * and logged here.
14
+ * No direct session-log reads: the JSONL persistence is zstd-compressed and
15
+ * batch-flushed, while session/event is the documented post-commit surface.
16
+ */
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import Schema from '@deepseek-ai/schemastery'
20
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
+
22
+ export const name = 'dsh-pmo-button'
23
+ export const inject = ['webServer']
24
+
25
+ /** The identity prompt delivered exactly once per session (user-authored 原文). */
26
+ const ROLE_PROMPT_DEFAULT = [
27
+ '你是本会话的常驻 PMO,代替用户审查父代理(主会话的编码代理)每一轮的工作。',
28
+ '此设定长期有效,直到用户明确更改。',
29
+ '',
30
+ '【你会收到】父代理每轮结束后的转发块:',
31
+ '- [用户] 用户原话:最高权威,目标和验收标准的唯一来源',
32
+ '- [主代理] 该轮完整答复:待审对象,是自述,不是事实',
33
+ '- [注入] 系统注入:忽略',
34
+ '',
35
+ '【你的工作】',
36
+ '1. 核实:以工作区的代码、diff、日志、运行结果为准。你只读,不改任何文件。',
37
+ '2. 对照用户原话:是否偏离、越界加功能、漏项、把猜测当事实。',
38
+ '3. 只给当前最重要的一步。',
39
+ '',
40
+ '【输出】作为你的结束语,不要另外发消息:',
41
+ 'STATUS: CONTINUE | DONE | NEED_HUMAN',
42
+ '结论:一句话',
43
+ '问题:最多 3 条,各附证据(文件/行号/输出)',
44
+ '下一步:给主代理的具体可执行指令(非 CONTINUE 时省略)',
45
+ '',
46
+ '【规则】',
47
+ '- 没有实质问题就 DONE,不为挑刺而挑刺。',
48
+ '- 没见过用户原话、需求有歧义、同一问题连续两轮未解决、涉及不可逆操作',
49
+ ' → NEED_HUMAN,写清需要用户决定什么。',
50
+ '- 用户原话之外的改进建议一律不提。',
51
+ '- 通讯纪律:不要使用 send_message 向父代理发送消息;你的结束语会随结算自动回传父会话,无需另发。',
52
+ ].join('\n')
53
+
54
+ /** @typedef {{ logFile: string, maxChars: number, provider: string, childLabel: string, greet: string, rolePrompt: string }} Config */
55
+ export const Config = Schema.object({
56
+ logFile: Schema.string().default('pmo-button.log'),
57
+ maxChars: Schema.number().default(4000),
58
+ /** Subagent provider name registered on ctx.subagents (spawn-in-process default). */
59
+ provider: Schema.string().default('spawn'),
60
+ /** Persistent child label; the child session is reused across PMO starts. */
61
+ childLabel: Schema.string().default('PMO 子代理'),
62
+ /** Text sent to the child each time PMO starts. */
63
+ greet: Schema.string().default('hi'),
64
+ /** Identity prompt; delivered exactly once per session (empty disables). */
65
+ rolePrompt: Schema.string().default(ROLE_PROMPT_DEFAULT),
66
+ /** Review-round cap per session since the last user input; 0 disables the cap. */
67
+ maxRounds: Schema.number().default(20),
68
+ })
69
+
70
+ const SEP = '\n' + '─'.repeat(68) + '\n'
71
+
72
+ /** @param {import('@deepseek-ai/cordis').Context} ctx @param {Config} config */
73
+ export function apply(ctx, config) {
74
+ const logPath = path.resolve(config.logFile)
75
+ const statePath = path.join(path.dirname(logPath), 'pmo-state.json')
76
+ const logger = ctx.logger ? ctx.logger('pmo-button') : console
77
+
78
+ /** @type {Set<string>} */
79
+ let enabled = new Set()
80
+ /** sid -> { tasks: [{src,text}], texts: string[], usage: {input,output}, turn } */
81
+ const buffers = new Map()
82
+ const turnCounts = new Map()
83
+ /** sid -> childId: the persistent PMO subagent session per parent session */
84
+ const children = new Map()
85
+ /** sid -> true: the identity prompt was admitted by the child (once per session) */
86
+ const roleSent = new Map()
87
+ /** sid -> true: the child returned STATUS=DONE — forward gate until user speech or restart */
88
+ const doneSet = new Set()
89
+ /** sid -> true: auto-stopped (STATUS !== CONTINUE or round cap) — button dimmed, listener live */
90
+ const stopped = new Set()
91
+ /** sid -> review rounds forwarded since the last user input (maxRounds cap) */
92
+ const rounds = new Map()
93
+ /** 'provider/model' -> display label (resolved via ctx.llm.listModels) */
94
+ const labelCache = new Map()
95
+
96
+ function emptyBuffer() {
97
+ return {
98
+ tasks: [], texts: [], usage: { input: 0, output: 0 },
99
+ turn: undefined, model: undefined,
100
+ provider: undefined, modelId: undefined, modelLabel: undefined, effort: undefined,
101
+ }
102
+ }
103
+
104
+ // ---- persisted state (armed set, counters, in-flight buffers) -----------
105
+ try {
106
+ const raw = fs.readFileSync(statePath, 'utf8')
107
+ const parsed = JSON.parse(raw)
108
+ if (Array.isArray(parsed?.enabled)) enabled = new Set(parsed.enabled.map(String))
109
+ if (parsed?.turnCounts && typeof parsed.turnCounts === 'object') {
110
+ for (const [k, v] of Object.entries(parsed.turnCounts)) turnCounts.set(k, Number(v) || 0)
111
+ }
112
+ if (parsed?.buffers && typeof parsed.buffers === 'object') {
113
+ for (const [sid, b] of Object.entries(parsed.buffers)) {
114
+ if (!enabled.has(sid)) continue
115
+ buffers.set(sid, {
116
+ tasks: Array.isArray(b?.tasks) ? b.tasks : [],
117
+ texts: Array.isArray(b?.texts) ? b.texts.map(String) : [],
118
+ usage: { input: Number(b?.usage?.input) || 0, output: Number(b?.usage?.output) || 0 },
119
+ turn: b?.turn,
120
+ model: b?.model,
121
+ provider: b?.provider,
122
+ modelId: b?.modelId,
123
+ modelLabel: b?.modelLabel,
124
+ effort: b?.effort,
125
+ })
126
+ }
127
+ }
128
+ if (parsed?.children && typeof parsed.children === 'object') {
129
+ for (const [sid, cid] of Object.entries(parsed.children)) children.set(sid, String(cid))
130
+ }
131
+ if (parsed?.roleSent && typeof parsed.roleSent === 'object') {
132
+ for (const sid of Object.keys(parsed.roleSent)) roleSent.set(sid, true)
133
+ }
134
+ if (parsed?.doneSet && typeof parsed.doneSet === 'object') {
135
+ for (const sid of Object.keys(parsed.doneSet)) doneSet.add(sid)
136
+ }
137
+ if (parsed?.stopped && typeof parsed.stopped === 'object') {
138
+ for (const sid of Object.keys(parsed.stopped)) stopped.add(sid)
139
+ }
140
+ if (parsed?.rounds && typeof parsed.rounds === 'object') {
141
+ for (const [sid, n] of Object.entries(parsed.rounds)) rounds.set(sid, Number(n) || 0)
142
+ }
143
+ } catch { /* first run or unreadable state */ }
144
+
145
+ function saveState() {
146
+ const kept = {}
147
+ for (const [sid, b] of buffers) if (enabled.has(sid)) kept[sid] = b
148
+ const doc = {
149
+ version: 7,
150
+ enabled: [...enabled],
151
+ turnCounts: Object.fromEntries(turnCounts),
152
+ buffers: kept,
153
+ children: Object.fromEntries(children),
154
+ roleSent: Object.fromEntries([...roleSent.keys()].map((sid) => [sid, true])),
155
+ doneSet: Object.fromEntries([...doneSet.keys()].map((sid) => [sid, true])),
156
+ stopped: Object.fromEntries([...stopped.keys()].map((sid) => [sid, true])),
157
+ rounds: Object.fromEntries(rounds),
158
+ }
159
+ try {
160
+ fs.mkdirSync(path.dirname(statePath), { recursive: true })
161
+ const tmp = statePath + '.tmp'
162
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8')
163
+ fs.renameSync(tmp, statePath)
164
+ } catch (err) { logger.warn?.(`pmo-button: state save failed: ${err}`) }
165
+ }
166
+
167
+ // ---- log writer ----------------------------------------------------------
168
+ function stamp() { return new Date().toLocaleString('sv-SE').replace(' ', 'T') }
169
+ function append(text) {
170
+ try {
171
+ fs.mkdirSync(path.dirname(logPath), { recursive: true })
172
+ fs.appendFileSync(logPath, text, 'utf8')
173
+ return true
174
+ } catch (err) { logger.warn?.(`pmo-button: log append failed: ${err}`); return false }
175
+ }
176
+ function clip(s, limit = config.maxChars) {
177
+ const t = String(s ?? '')
178
+ if (t.length <= limit) return t
179
+ return t.slice(0, limit) + `\n…(截断,原长 ${t.length} 字符)`
180
+ }
181
+ /** Auto-stop: dim the button and gate forwarding WITHOUT deafening the
182
+ * session listener — turn logging keeps running, and user speech (or a
183
+ * button re-light) releases the gate so the loop can auto-resume. */
184
+ function extinguish(sid, why) {
185
+ stopped.add(sid)
186
+ doneSet.add(sid)
187
+ saveState()
188
+ append(`${stamp()} [PMO] 自动熄灭:${why}(转发静默;用户发言或重新点亮按钮恢复)\n`)
189
+ }
190
+ append(`${SEP}${stamp()} [PMO 插件激活] 日志=${logPath}\n`)
191
+
192
+ // ---- text helpers ----------------------------------------------------------
193
+ // Surface payloads nest the real message under data.message
194
+ // (observed: {turn, step, message:{role, content, source}, usage, stream}).
195
+ function textOf(data) {
196
+ const m = data?.message ?? data
197
+ const c = m?.content
198
+ if (typeof c === 'string') return c
199
+ if (Array.isArray(c)) {
200
+ return c.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n')
201
+ }
202
+ return ''
203
+ }
204
+ function sourceKind(data) {
205
+ return data?.message?.source?.kind ?? data?.source?.kind ?? 'user'
206
+ }
207
+ const OTHER_SOURCES = new Set(['inject', 'steering', 'time-context', 'agent-message', 'subagent-settled', 'system-reminder'])
208
+ /** Compact summary of non-text content blocks (images, files, references). */
209
+ function nonTextSummaries(data) {
210
+ const m = data?.message ?? data
211
+ const c = m?.content
212
+ if (!Array.isArray(c)) return []
213
+ const out = []
214
+ const home = process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '', '.dsh')
215
+ function storePath(sha) {
216
+ return path.join(home, 'attachments', 'v1', 'objects', sha.slice(0, 2), sha)
217
+ }
218
+ function humanSize(n) {
219
+ const v = Number(n) || 0
220
+ return v >= 1024 * 1024 ? (v / 1024 / 1024).toFixed(1) + 'MB' : v >= 1024 ? (v / 1024).toFixed(1) + 'KB' : v + 'B'
221
+ }
222
+ for (const b of c) {
223
+ if (!b || b.type === 'text') continue
224
+ // Observed image shape: {type:'image', attachment:{attachmentId:'sha256:<hex>',
225
+ // mediaType, width, height, bytes, name}} — format it human-readably.
226
+ if (b.type === 'image' && b.attachment && typeof b.attachment.attachmentId === 'string') {
227
+ const a = b.attachment
228
+ const sha = String(a.attachmentId).replace(/^sha256:/, '')
229
+ const dim = a.width && a.height ? ` ${a.width}x${a.height}` : ''
230
+ out.push(`[图片] ${a.name || '未命名'}(${dim.trim() || '尺寸未知'}, ${a.mediaType || '未知类型'}, ${humanSize(a.bytes)})=> ${storePath(sha)}`)
231
+ continue
232
+ }
233
+ let s = JSON.stringify(b)
234
+ const sha = s.match(/[0-9a-f]{64}/i)
235
+ if (sha) out.push(`[${b.type}] ${s.slice(0, 400)}${s.length > 400 ? '…' : ''} => ${storePath(sha[0])}`)
236
+ else out.push(`[${b.type}] ${s.slice(0, 400)}${s.length > 400 ? '…' : ''}`)
237
+ }
238
+ return out
239
+ }
240
+ function bufferFor(sid) {
241
+ let b = buffers.get(sid)
242
+ if (!b) { b = emptyBuffer(); buffers.set(sid, b) }
243
+ return b
244
+ }
245
+
246
+ function flushTurn(sid, event) {
247
+ const b = buffers.get(sid)
248
+ const turn = event?.data?.turn ?? b?.turn ?? '?'
249
+ const reason = JSON.stringify(event?.data?.reason ?? event?.data ?? null)
250
+ const lines = [`${SEP}${stamp()} [轮次结束] session=${sid.slice(0, 12)}… turn=${turn} reason=${reason}`]
251
+
252
+ const userTasks = (b?.tasks ?? []).filter((t) => !OTHER_SOURCES.has(t.src) && t.src !== '__attach__')
253
+ const attachTasks = (b?.tasks ?? []).filter((t) => t.src === '__attach__')
254
+ const otherTasks = (b?.tasks ?? []).filter((t) => OTHER_SOURCES.has(t.src))
255
+ // Real user speech re-opens the loop after a DONE gate; a DONE-gated turn
256
+ // is logged but not forwarded (breaks the settlement-echo cycle).
257
+ if (userTasks.length > 0 && (doneSet.has(sid) || rounds.has(sid) || stopped.has(sid))) {
258
+ doneSet.delete(sid)
259
+ stopped.delete(sid)
260
+ rounds.delete(sid)
261
+ saveState()
262
+ append(`${stamp()} [轮次转发] DONE 闸门解除:检测到用户输入,循环恢复\n`)
263
+ }
264
+ const gated = doneSet.has(sid)
265
+
266
+ if (!b || (b.tasks.length === 0 && b.texts.length === 0)) {
267
+ lines.push('(PMO 在本轮中途开启或未捕获到内容:本块可能不完整)')
268
+ }
269
+ userTasks.forEach((t) => lines.push(`[PMO] ${clip(t.text)}`))
270
+ attachTasks.forEach((t) => lines.push(clip(t.text)))
271
+ otherTasks.forEach((t) => lines.push(`[注入:${t.src}] ${clip(t.text)}`))
272
+ // assistant/message events fire PER STEP; the conversation view shows the
273
+ // concatenation of all step texts, so join them in order to match it.
274
+ if (b?.texts?.length > 0) {
275
+ // Effort: event sniff first; else the host default selection, but only
276
+ // when it matches the model that actually produced this turn's reply.
277
+ let effSrc = b.effort
278
+ if (!effSrc && b.provider && b.modelId) {
279
+ const adm = ctx.get ? ctx.get('agentDefaultModel') : undefined
280
+ const sel = adm && typeof adm.currentSelection === 'function' ? adm.currentSelection() : undefined
281
+ if (sel && sel.provider === b.provider && sel.model === b.modelId) {
282
+ effSrc = sel.reasoningEffort ?? sel.effort
283
+ }
284
+ }
285
+ const eff = typeof effSrc === 'string' && effSrc
286
+ ? ' ' + effSrc.charAt(0).toUpperCase() + effSrc.slice(1)
287
+ : ''
288
+ const modelPart = b.modelLabel
289
+ || (b.provider && b.modelId ? `${b.provider}/${b.modelId}` : b.model)
290
+ if (modelPart) lines.push(`[${modelPart}${eff}]`)
291
+ if (userTasks.length > 0 || otherTasks.length > 0 || attachTasks.length > 0) lines.push('')
292
+ lines.push(clip(b.texts.join('\n\n---\n\n')))
293
+ const u = b.usage
294
+ if (u && (u.input > 0 || u.output > 0)) {
295
+ lines.push(`(usage: in=${u.input} out=${u.output})`)
296
+ }
297
+ } else if (userTasks.length > 0 || otherTasks.length > 0) {
298
+ lines.push('(本轮未捕获 assistant 消息)')
299
+ }
300
+
301
+ if (append(lines.join('\n') + '\n')) {
302
+ turnCounts.set(sid, (turnCounts.get(sid) ?? 0) + 1)
303
+ }
304
+ // ---- Phase-2 auto-loop: forward this turn's review blocks to the PMO child
305
+ // (fire-and-forget; the child's STATUS closing line returns to this session
306
+ // through the delivery:'parent' settlement notice and wakes the next step).
307
+ const childId = children.get(sid)
308
+ if (gated) {
309
+ append(`${stamp()} [轮次转发] turn=${turn} 跳过:DONE 后静默(等待用户输入或重新启动)\n`)
310
+ } else if (childId && b && (b.texts?.length > 0 || b.tasks?.length > 0)) {
311
+ const parts = []
312
+ userTasks.forEach((t) => parts.push(`[用户] ${t.text}`))
313
+ otherTasks.forEach((t) => parts.push(`[注入:${t.src}] ${t.text}`))
314
+ attachTasks.forEach((t) => parts.push(t.text))
315
+ if (b.texts?.length > 0) parts.push(`[主代理] ${b.texts.join('\n\n---\n\n')}`)
316
+ const forward = clip(`【PMO 轮次转发 · turn=${turn}】\n` + parts.join('\n'), config.maxChars * 2)
317
+ const agents = ctx.get('agents')
318
+ const agent = agents && typeof agents.get === 'function' ? agents.get(sid) : undefined
319
+ const subagentsSvc = ctx.get('subagents')
320
+ if (!agent || !subagentsSvc || typeof subagentsSvc.sendMessage !== 'function') {
321
+ append(`${stamp()} [轮次转发] turn=${turn} 跳过:父会话无活跃 Agent 或 subagents 不可用\n`)
322
+ } else {
323
+ const controller = new AbortController()
324
+ Promise.resolve(subagentsSvc.sendMessage(agent, childId, [{ type: 'text', text: forward }], { signal: controller.signal }))
325
+ .then(() => {
326
+ const n = (rounds.get(sid) ?? 0) + 1
327
+ rounds.set(sid, n)
328
+ saveState()
329
+ append(`${stamp()} [轮次转发] turn=${turn} 已送达 child=${String(childId).slice(0, 12)}…(第 ${n}${config.maxRounds > 0 ? '/' + config.maxRounds : ''} 轮审查)\n`)
330
+ if (config.maxRounds > 0 && n >= config.maxRounds) extinguish(sid, `达到最大审查轮数 ${config.maxRounds}`)
331
+ })
332
+ .catch((err) => append(`${stamp()} [轮次转发] turn=${turn} 失败: ${err && err.message ? err.message : err}\n`))
333
+ }
334
+ }
335
+ buffers.set(sid, emptyBuffer())
336
+ saveState()
337
+ }
338
+
339
+ function logGoalChange(sid, event) {
340
+ append(`${SEP}${stamp()} [目标变化 ${event.type}] session=${sid.slice(0, 12)}…\n${clip(JSON.stringify(event.data))}\n`)
341
+ }
342
+
343
+ // ---- PMO subagent: one persistent continuable child per parent session ----
344
+ // First start creates the child (it inherits the parent's model settings and
345
+ // reasoning effort by default) and sends the greet; every later start reuses
346
+ // the SAME persisted childId via sendMessage — the child cold-resumes from
347
+ // its durable session when it is not resident. delivery:'parent' makes the
348
+ // parent session receive the child's completion notice.
349
+ //
350
+ // The identity prompt (config.rolePrompt) rides the NEXT start exactly once
351
+ // per session — creation or reuse alike — and its flag is committed only
352
+ // after the child admitted the message, so a failed send auto-retries.
353
+ async function ensureChildAndGreet(sid, agent) {
354
+ const subagents = ctx.get('subagents')
355
+ if (!subagents) throw new Error('subagents 服务未挂载')
356
+ const needRole = config.rolePrompt.length > 0 && !roleSent.has(sid)
357
+ const roleBlocks = needRole ? [{ type: 'text', text: config.rolePrompt }] : []
358
+ const existing = children.get(sid)
359
+ if (existing) {
360
+ // options.signal is required by sendMessage's admission path.
361
+ const controller = new AbortController()
362
+ const mid = await subagents.sendMessage(agent, existing, [...roleBlocks, { type: 'text', text: config.greet }], { signal: controller.signal })
363
+ if (needRole) {
364
+ roleSent.set(sid, true)
365
+ saveState()
366
+ append(`${stamp()} [身份提示词] 已随问候一次性下发 child=${String(existing).slice(0, 12)}…(本会话不会重复)\n`)
367
+ }
368
+ append(`${stamp()} [子代理] 已向持久会话发送问候 child=${String(existing).slice(0, 12)}… msg=${String(mid).slice(0, 8)}… text=${config.greet}\n`)
369
+ return
370
+ }
371
+ // The parent travels INSIDE request (startActivation checks the exact live
372
+ // agent: agents.get(request.parent.id) === request.parent); the top-level
373
+ // spec.signal is required (resolveDirectory and admission call
374
+ // spec.signal.throwIfAborted() unconditionally); request.prompt must be
375
+ // CONTENT BLOCKS — withContinuableReturnGuidance spreads it (`[...prompt,
376
+ // guidance]`), so a raw string would splinter into per-character blocks.
377
+ const controller = new AbortController()
378
+ const promptBlocks = [
379
+ ...roleBlocks,
380
+ { type: 'text', text: config.greet },
381
+ ]
382
+ const act = await subagents.startActivation({
383
+ provider: config.provider,
384
+ label: config.childLabel,
385
+ delivery: 'parent',
386
+ signal: controller.signal,
387
+ request: { parent: agent, prompt: promptBlocks, description: config.childLabel },
388
+ })
389
+ children.set(sid, String(act.childId))
390
+ if (needRole) {
391
+ roleSent.set(sid, true)
392
+ append(`${stamp()} [身份提示词] 已随首条消息一次性下发 child=${String(act.childId).slice(0, 12)}…(本会话不会重复)\n`)
393
+ }
394
+ saveState()
395
+ const providers = typeof subagents.list === 'function' ? subagents.list().join(',') : '?'
396
+ append(`${stamp()} [子代理] 已创建持久子代理「${config.childLabel}」 child=${String(act.childId).slice(0, 12)}… provider=${config.provider} 可用=[${providers}]\n`)
397
+ }
398
+
399
+ // ---- the documented observation surface: session/event -------------------
400
+ ctx.on('session/event', (session, event) => {
401
+ try {
402
+ if (!event || typeof event.type !== 'string') return
403
+ const sid = session?.id == null ? '' : String(session.id)
404
+ if (!enabled.has(sid)) return
405
+ const t = event.type
406
+ if (t === 'turn/start') {
407
+ const b = bufferFor(sid)
408
+ b.tasks = []
409
+ b.texts = []
410
+ b.usage = { input: 0, output: 0 }
411
+ b.turn = event.data?.turn
412
+ b.provider = undefined
413
+ b.modelId = undefined
414
+ b.modelLabel = undefined
415
+ b.effort = undefined
416
+ } else if (t === 'user/message') {
417
+ const b = bufferFor(sid)
418
+ const text = textOf(event.data)
419
+ // Harness system reminders (skill-catalog snapshots etc.) arrive as
420
+ // user-role messages without a distinguishing source kind; classify
421
+ // them by their text marker instead of defaulting to user input.
422
+ const srcKind = text.trimStart().startsWith('<system-reminder>')
423
+ ? 'system-reminder'
424
+ : sourceKind(event.data)
425
+ b.tasks.push({ src: srcKind, text })
426
+ for (const s of nonTextSummaries(event.data)) b.tasks.push({ src: '__attach__', text: s })
427
+ // Recognize the PMO child's STATUS protocol from its settlement notice
428
+ // or direct adjacent message, and record the review verdict.
429
+ const src2 = event.data?.message?.source ?? event.data?.source
430
+ if (src2 && (src2.kind === 'subagent-settled' || src2.kind === 'agent-message')) {
431
+ const m = textOf(event.data).match(/STATUS:\s*(CONTINUE|DONE|NEED_HUMAN)/)
432
+ if (m) {
433
+ append(`${stamp()} [PMO审查] STATUS=${m[1]}(来自子代理)\n`)
434
+ // Pair the notice with the authority-alignment instruction: the
435
+ // review speaks with user authority — execute its next step unless
436
+ // the parent disagrees, and then it must stop and say why.
437
+ try {
438
+ const agentsSvc = ctx.get('agents')
439
+ const a = agentsSvc && typeof agentsSvc.get === 'function' ? agentsSvc.get(sid) : undefined
440
+ if (a && typeof a.inject === 'function') {
441
+ a.inject(createUserMessage({
442
+ content: [{ type: 'text', text: `收到 PMO 审查(STATUS=${m[1]}):子代理消息代表用户授权的审查意见,请按其"下一步"执行;不同意时说明理由并停下。` }],
443
+ source: { kind: 'inject' },
444
+ }))
445
+ }
446
+ } catch (err) { logger.warn?.(`pmo-button: alignment inject failed: ${err}`) }
447
+ // Anything other than CONTINUE stops the auto-loop: the button
448
+ // extinguishes; re-light it or speak to resume.
449
+ if (m[1] !== 'CONTINUE') {
450
+ doneSet.add(sid)
451
+ extinguish(sid, `STATUS=${m[1]}(循环停转)`)
452
+ }
453
+ }
454
+ }
455
+ saveState()
456
+ } else if (t === 'assistant/message') {
457
+ const b = bufferFor(sid)
458
+ const text = textOf(event.data)
459
+ if (text) b.texts.push(text)
460
+ const src = event.data?.message?.source ?? event.data?.source
461
+ if (src && src.kind === 'model' && src.provider && src.model) {
462
+ b.provider = src.provider
463
+ b.modelId = src.model
464
+ const key = `${src.provider}/${src.model}`
465
+ if (!labelCache.has(key)) {
466
+ labelCache.set(key, undefined)
467
+ const llm = ctx.get ? ctx.get('llm') : undefined
468
+ // Empirical: resolveModel is absent from the service face exposed
469
+ // to bundles; the display label lives in the listModels catalog
470
+ // entry's `name` field (observed: {"id":"glm-5.3-flash","name":"GLM-5.3-Flash"}).
471
+ if (llm && typeof llm.listModels === 'function') {
472
+ Promise.resolve(llm.listModels(src.provider))
473
+ .then((list) => {
474
+ const entry = Array.isArray(list)
475
+ ? list.find((e) => e && (e.id === src.model || e.model === src.model))
476
+ : undefined
477
+ const label = entry && (entry.name ?? entry.label ?? entry.displayName)
478
+ const v = typeof label === 'string' && label ? label : undefined
479
+ labelCache.set(key, v)
480
+ if (v) { b.modelLabel = v; saveState() }
481
+ })
482
+ .catch(() => { })
483
+ }
484
+ } else if (labelCache.get(key)) {
485
+ b.modelLabel = labelCache.get(key)
486
+ }
487
+ }
488
+ const u = event.data?.usage
489
+ if (u) {
490
+ b.usage.input += Number(u.inputTokens) || 0
491
+ b.usage.output += Number(u.outputTokens) || 0
492
+ }
493
+ saveState()
494
+ } else if (t === 'request/header') {
495
+ // Best-effort sniff of the reasoning effort for the report prefix.
496
+ const d = event.data || {}
497
+ const ms = d.modelSelection || d.selection || d
498
+ const eff = ms.reasoningEffort ?? ms.effort ?? d.reasoningEffort ?? d.effort
499
+ if (typeof eff === 'string' && eff) bufferFor(sid).effort = eff
500
+ } else if (t === 'turn/end') {
501
+ flushTurn(sid, event)
502
+ } else if (t.startsWith('goal/')) {
503
+ logGoalChange(sid, event)
504
+ }
505
+ } catch (err) { logger.warn?.(`pmo-button: listener error: ${err}`) }
506
+ })
507
+
508
+ // ---- package-private routes (Client half is the only caller) ------------
509
+ function json(obj, status = 200) {
510
+ return new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json' } })
511
+ }
512
+ async function handle(request) {
513
+ const url = new URL(request.url)
514
+ if (!url.pathname.startsWith('/pmo-button')) return json({ ok: false, error: 'not found' }, 404)
515
+ const rest = url.pathname.slice('/pmo-button'.length)
516
+ if (request.method === 'GET' && (rest === '' || rest === '/ping')) {
517
+ return json({ ok: true, route: 'alive', enabledSessions: [...enabled] })
518
+ }
519
+ if (request.method === 'GET' && rest === '/state') {
520
+ const sid = url.searchParams.get('session') ?? 'unknown'
521
+ return json({
522
+ ok: true,
523
+ sessionId: sid,
524
+ enabled: enabled.has(sid) && !stopped.has(sid),
525
+ turns: turnCounts.get(sid) ?? 0,
526
+ })
527
+ }
528
+ if (rest === '/toggle') {
529
+ let sid, on
530
+ if (request.method === 'GET') {
531
+ sid = String(url.searchParams.get('session') ?? 'unknown')
532
+ const flag = url.searchParams.get('enabled')
533
+ on = flag === '1' || flag === 'true'
534
+ } else {
535
+ let body
536
+ try { body = await request.json() } catch { return json({ ok: false, error: 'bad json' }, 400) }
537
+ sid = String(body?.sessionId ?? 'unknown')
538
+ on = Boolean(body?.enabled)
539
+ }
540
+ if (on) {
541
+ enabled.add(sid)
542
+ turnCounts.set(sid, turnCounts.get(sid) ?? 0)
543
+ stopped.delete(sid)
544
+ doneSet.delete(sid)
545
+ rounds.delete(sid)
546
+ } else enabled.delete(sid)
547
+ saveState()
548
+ append(`${SEP}${stamp()} [PMO ${on ? '启动' : '停止'}] session=${sid.slice(0, 12)}…\n`)
549
+ if (on && sid !== 'unknown') {
550
+ const agents = ctx.get('agents')
551
+ const agent = agents && typeof agents.get === 'function' ? agents.get(sid) : undefined
552
+ if (!agents || !agent) {
553
+ append(`${stamp()} [子代理] 无法启动:当前会话无活跃 Agent(agents=${agents ? '有' : '无'})\n`)
554
+ } else {
555
+ ensureChildAndGreet(sid, agent).catch((err) => {
556
+ const msg = err && err.message ? err.message : String(err)
557
+ const stack = err && err.stack ? err.stack.split('\n').slice(0, 3).join(' | ') : ''
558
+ let provs = '?'
559
+ try {
560
+ const s = ctx.get('subagents')
561
+ provs = s && typeof s.list === 'function' ? s.list().join(',') : 'list不可用'
562
+ } catch { /* ignore */ }
563
+ append(`${stamp()} [子代理] 失败: ${msg};可用提供方=[${provs}] 已尝试=${config.provider};栈顶: ${stack}\n`)
564
+ })
565
+ }
566
+ }
567
+ return json({ ok: true, sessionId: sid, enabled: on, turns: turnCounts.get(sid) ?? 0, child: children.get(sid) ?? null })
568
+ }
569
+ return json({ ok: false, error: 'not found' }, 404)
570
+ }
571
+
572
+ const webServer = ctx.get('webServer')
573
+ if (webServer) {
574
+ ctx.effect(() => webServer.register({ kind: 'prefix', path: '/pmo-button', resource: handle }), 'pmo-button: routes')
575
+ } else {
576
+ logger.warn?.('pmo-button: webServer absent — Client 将无法同步开关状态')
577
+ }
578
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "PMO button",
4
+ "description": "A red start/stop button beside the composer submit action; state follows the Session. While enabled, every turn end (each loop round) and goal pause/completion is read back from the session log — this turn's tasks and the final handoff — and appended to a log file."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "PMO 按钮",
4
+ "description": "在输入框发送键旁添加红色启停按钮,状态跟随会话。开启后:本会话每次轮次结束(含 loop 每一轮)与目标暂停/完成时,从会话日志读出本轮任务与最终交代,追加写入日志文件。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@suifei/dsh-pmo-button",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.7.1",
4
+ "description": "PMO 审查循环按钮 for DeepSeek Harness — 持久审查子代理 + 轮次转发 + STATUS 协议(CONTINUE/DONE/NEED_HUMAN)自动循环推进",
5
+ "keywords": [
6
+ "deepseek-harness", "dsh", "dsh-plugin", "pmo", "code-review",
7
+ "subagent", "review-loop", "cordis", "审查", "自动循环"
8
+ ],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/suifei/dsh-pmo-button.git"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": "./index.js",
17
+ "./client": "./client.js",
18
+ "./locale/*.json": "./locale/*.json",
19
+ "./icon": "./icon.svg"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "client.js",
24
+ "cordis.patch.yml",
25
+ "locale/en.json",
26
+ "locale/zh.json",
27
+ "icon.svg",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "test": "node --check index.js && node --check client.js"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@deepseek-ai/dsh-llm": "0.2.2-rc.1",
39
+ "@deepseek-ai/schemastery": "~3.19.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@deepseek-ai/cordis": "~4.1.0"
43
+ },
44
+ "dsh": {
45
+ "bundle": { "patch": "./cordis.patch.yml" },
46
+ "client": {
47
+ "platform": "web",
48
+ "immediately": true,
49
+ "inject": ["@deepseek-ai/dsh-client-ui-conversation"]
50
+ }
51
+ }
52
+ }