mocode-ai 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/index.js +22 -1
- package/dist/pet/bridge.js +418 -0
- package/dist/pet/protocol.js +112 -0
- package/dist/pet/state.js +67 -0
- package/dist/repl/index.js +61 -0
- package/dist/tools/builtins/ask-human.js +4 -0
- package/package.json +8 -2
package/dist/agent/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as layout from '../ui/layout.js';
|
|
|
10
10
|
import { beginTurn } from '../rollback/index.js';
|
|
11
11
|
import { config } from '../config/index.js';
|
|
12
12
|
import { runAgentCore, isMutationTool, } from './core.js';
|
|
13
|
+
import { createPetHooks } from '../pet/state.js';
|
|
13
14
|
/** 工具调用 ● 头:工具名 + 参数摘要(按 tool_calls 原顺序打印,让用户看到本轮跑哪些工具)。 */
|
|
14
15
|
function writeToolHeader(tc) {
|
|
15
16
|
const summary = summarizeToolCall(tc.name, tc.arguments);
|
|
@@ -104,16 +105,36 @@ onContextUpdate) {
|
|
|
104
105
|
},
|
|
105
106
|
onDone: (elapsedMs) => layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${ui.reset}\n`),
|
|
106
107
|
};
|
|
108
|
+
// 桌宠状态广播:与 TUI hooks 并列注入,互不干扰(petHooks 只调 bridge.sendState,不写屏;
|
|
109
|
+
// 未 /pet 连接时 sendState 内部 no-op)。仅主 agent 走这里——子 agent(spawn.ts)不引用 createPetHooks,
|
|
110
|
+
// 故子 agent 永不广播桌宠状态。
|
|
111
|
+
const petHooks = createPetHooks();
|
|
112
|
+
const combinedHooks = mergeHooks(hooks, petHooks);
|
|
107
113
|
try {
|
|
108
114
|
await runAgentCore({
|
|
109
115
|
history,
|
|
110
116
|
userInput,
|
|
111
117
|
signal,
|
|
112
118
|
onContextUpdate,
|
|
113
|
-
hooks,
|
|
119
|
+
hooks: combinedHooks,
|
|
114
120
|
});
|
|
115
121
|
}
|
|
116
122
|
finally {
|
|
117
123
|
spinner.stop();
|
|
118
124
|
}
|
|
119
125
|
}
|
|
126
|
+
/** 把两组 AgentHooks 合并为一组:每个方法依次调用两侧已定义的实现(顺序不保证跨方法一致,
|
|
127
|
+
* 但同一事件内先 a 后 b)。用于把桌宠状态广播 hooks 与 TUI 渲染 hooks 并列挂载,互不影响。 */
|
|
128
|
+
function mergeHooks(a, b) {
|
|
129
|
+
const merged = {};
|
|
130
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
131
|
+
for (const key of keys) {
|
|
132
|
+
const fa = a[key];
|
|
133
|
+
const fb = b[key];
|
|
134
|
+
merged[key] = (...args) => {
|
|
135
|
+
fa?.(...args);
|
|
136
|
+
fb?.(...args);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return merged;
|
|
140
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// 桌宠 WS 客户端 + 生命周期管理(mocode 主包侧,不 import electron)。
|
|
2
|
+
// /pet 命令(src/repl/index.ts)调 togglePet();主 agent hooks(src/pet/state.ts createPetHooks)
|
|
3
|
+
// 调 sendState() 广播状态。所有失败路径均静默降级——桌宠是可选增强,绝不能拖垮/中断主 agent 循环。
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import WebSocket from 'ws';
|
|
8
|
+
import { parseServerMessage } from './protocol.js';
|
|
9
|
+
/** 默认端口;MOCODE_PET_PORT 环境变量覆盖(design.md 默认假设)。 */
|
|
10
|
+
export const DEFAULT_PET_PORT = 47821;
|
|
11
|
+
function petPort() {
|
|
12
|
+
const v = process.env.MOCODE_PET_PORT;
|
|
13
|
+
const n = v ? Number(v) : NaN;
|
|
14
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PET_PORT;
|
|
15
|
+
}
|
|
16
|
+
/** 探测端口超时(ms)。 */
|
|
17
|
+
const PROBE_TIMEOUT_MS = 300;
|
|
18
|
+
/** spawn 拉起后的退避重试序列(ms),design.md 默认假设。 */
|
|
19
|
+
const RETRY_DELAYS_MS = [200, 400, 800, 1600, 3200];
|
|
20
|
+
/** 心跳间隔 / 超时(design.md 默认假设)。 */
|
|
21
|
+
const HEARTBEAT_INTERVAL_MS = 15000;
|
|
22
|
+
const HEARTBEAT_TIMEOUT_MS = 10000;
|
|
23
|
+
/** 本进程稳定的 clientId(进程存活期间不变)。 */
|
|
24
|
+
const clientId = `${process.pid}-${randomUUID()}`;
|
|
25
|
+
let socket = null;
|
|
26
|
+
let lastSentState = null;
|
|
27
|
+
let heartbeatTimer = null;
|
|
28
|
+
let heartbeatTimeoutTimer = null;
|
|
29
|
+
/** list_skins 请求的等待队列(先进先出;桌宠单连接场景下不会有并发歧义)。 */
|
|
30
|
+
let pendingSkinListResolvers = [];
|
|
31
|
+
function clearHeartbeat() {
|
|
32
|
+
if (heartbeatTimer)
|
|
33
|
+
clearInterval(heartbeatTimer);
|
|
34
|
+
if (heartbeatTimeoutTimer)
|
|
35
|
+
clearTimeout(heartbeatTimeoutTimer);
|
|
36
|
+
heartbeatTimer = null;
|
|
37
|
+
heartbeatTimeoutTimer = null;
|
|
38
|
+
}
|
|
39
|
+
/** 心跳超时未收到 pong → 视为死连接,清理本地状态(不重连;下次 /pet 触发时重新探测)。 */
|
|
40
|
+
function startHeartbeat(ws) {
|
|
41
|
+
clearHeartbeat();
|
|
42
|
+
heartbeatTimer = setInterval(() => {
|
|
43
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// 发送失败:静默,等超时定时器兜底清理
|
|
50
|
+
}
|
|
51
|
+
heartbeatTimeoutTimer = setTimeout(() => {
|
|
52
|
+
try {
|
|
53
|
+
ws.terminate();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// no-op
|
|
57
|
+
}
|
|
58
|
+
}, HEARTBEAT_TIMEOUT_MS);
|
|
59
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
60
|
+
heartbeatTimer.unref?.();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 探测本地端口是否已有 WS server 监听并可完成一次 WS 握手。
|
|
64
|
+
* 前置条件:port 为合法端口号。
|
|
65
|
+
* 后置条件:返回 true 表示 <timeoutMs> 内握手成功(桌宠已在跑);false 表示超时/拒绝连接。
|
|
66
|
+
* 无副作用(探测用的临时连接在返回前关闭)。
|
|
67
|
+
*/
|
|
68
|
+
export function probePort(port, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
let settled = false;
|
|
71
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
if (settled)
|
|
74
|
+
return;
|
|
75
|
+
settled = true;
|
|
76
|
+
try {
|
|
77
|
+
ws.terminate();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// no-op
|
|
81
|
+
}
|
|
82
|
+
resolve(false);
|
|
83
|
+
}, timeoutMs);
|
|
84
|
+
ws.once('open', () => {
|
|
85
|
+
if (settled)
|
|
86
|
+
return;
|
|
87
|
+
settled = true;
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
try {
|
|
90
|
+
ws.close();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// no-op
|
|
94
|
+
}
|
|
95
|
+
resolve(true);
|
|
96
|
+
});
|
|
97
|
+
ws.once('error', () => {
|
|
98
|
+
if (settled)
|
|
99
|
+
return;
|
|
100
|
+
settled = true;
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
resolve(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 拉起独立 Electron 桌宠进程(detached,不随当前 mocode 进程退出而杀死)。
|
|
108
|
+
* 前置条件:调用方已确认端口未被占用(避免重复 spawn)。
|
|
109
|
+
* 后置条件:
|
|
110
|
+
* - resolve() 表示 spawn 系统调用成功发出(不代表桌宠已可连接,调用方需配合 connectWithBackoff)。
|
|
111
|
+
* - reject(err) 表示可执行文件不可解析(mocode-pet-app 未安装/安装失败)——降级路径。
|
|
112
|
+
*/
|
|
113
|
+
export function spawnPetProcess() {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
let binPath;
|
|
116
|
+
try {
|
|
117
|
+
const require = createRequire(import.meta.url);
|
|
118
|
+
binPath = require.resolve('mocode-pet-app/bin/pet-app.js');
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
reject(new Error('mocode-pet-app 未安装,请运行 npm install mocode-pet-app'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const child = spawn(process.execPath, [binPath], {
|
|
126
|
+
detached: true,
|
|
127
|
+
stdio: 'ignore',
|
|
128
|
+
windowsHide: true,
|
|
129
|
+
});
|
|
130
|
+
child.once('error', (err) => reject(err));
|
|
131
|
+
child.unref();
|
|
132
|
+
resolve();
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 按退避序列重试连接,直到成功或耗尽重试次数。
|
|
141
|
+
* 前置条件:retryDelaysMs 非空、单调(本设计取 [200,400,800,1600,3200])。
|
|
142
|
+
* 后置条件:
|
|
143
|
+
* - resolve(ws) 表示某次尝试内 probePort/connect 成功。
|
|
144
|
+
* - reject(err) 表示所有尝试均失败,err 汇总最后一次失败原因。
|
|
145
|
+
* 循环不变量:每次尝试前 attempts < retryDelaysMs.length;每次失败后 attempts 严格 +1。
|
|
146
|
+
*/
|
|
147
|
+
export async function connectWithBackoff(port, retryDelaysMs = RETRY_DELAYS_MS) {
|
|
148
|
+
let lastErr = new Error('连接失败');
|
|
149
|
+
for (let attempts = 0; attempts < retryDelaysMs.length; attempts++) {
|
|
150
|
+
await sleep(retryDelaysMs[attempts]);
|
|
151
|
+
const ok = await probePort(port, PROBE_TIMEOUT_MS);
|
|
152
|
+
if (ok) {
|
|
153
|
+
try {
|
|
154
|
+
return await openConnection(port);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
lastErr = e instanceof Error ? e : new Error(String(e));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
lastErr = new Error('桌宠尚未就绪(端口未监听)');
|
|
162
|
+
}
|
|
163
|
+
throw lastErr;
|
|
164
|
+
}
|
|
165
|
+
function sleep(ms) {
|
|
166
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
167
|
+
}
|
|
168
|
+
/** 真正建立并返回一条打开的 WS 连接(不做探测,假定端口已确认可连)。 */
|
|
169
|
+
function openConnection(port) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
try {
|
|
174
|
+
ws.terminate();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// no-op
|
|
178
|
+
}
|
|
179
|
+
reject(new Error('连接超时'));
|
|
180
|
+
}, PROBE_TIMEOUT_MS * 2);
|
|
181
|
+
ws.once('open', () => {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
resolve(ws);
|
|
184
|
+
});
|
|
185
|
+
ws.once('error', (err) => {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** 连接建立后的公共收尾:发 hello、挂消息/关闭监听、起心跳。 */
|
|
192
|
+
function wireConnection(ws) {
|
|
193
|
+
socket = ws;
|
|
194
|
+
lastSentState = null;
|
|
195
|
+
const hello = {
|
|
196
|
+
type: 'hello',
|
|
197
|
+
clientId,
|
|
198
|
+
pid: process.pid,
|
|
199
|
+
cwd: process.cwd(),
|
|
200
|
+
ts: Date.now(),
|
|
201
|
+
};
|
|
202
|
+
try {
|
|
203
|
+
ws.send(JSON.stringify(hello));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// 静默:hello 发送失败不影响后续状态广播尝试(sendState 内部会再检查连接状态)
|
|
207
|
+
}
|
|
208
|
+
ws.on('message', (data) => {
|
|
209
|
+
const msg = parseServerMessage(String(data));
|
|
210
|
+
if (!msg)
|
|
211
|
+
return; // 解析失败静默丢弃(协议层已保证不抛错)
|
|
212
|
+
if (msg.type === 'pong' && heartbeatTimeoutTimer) {
|
|
213
|
+
clearTimeout(heartbeatTimeoutTimer);
|
|
214
|
+
heartbeatTimeoutTimer = null;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (msg.type === 'skin_list') {
|
|
218
|
+
const resolver = pendingSkinListResolvers.shift();
|
|
219
|
+
resolver?.(msg);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
ws.once('close', () => {
|
|
224
|
+
if (socket === ws) {
|
|
225
|
+
socket = null;
|
|
226
|
+
clearHeartbeat();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
ws.once('error', () => {
|
|
230
|
+
// 连接异常:不自动重连(避免用户已关闭桌宠窗口时无限重连刷日志);下次 /pet 触发时重新走探测/拉起流程。
|
|
231
|
+
if (socket === ws) {
|
|
232
|
+
socket = null;
|
|
233
|
+
clearHeartbeat();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
startHeartbeat(ws);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 发送一次状态消息(经节流:与上次发送状态相同则跳过)。
|
|
240
|
+
* 前置条件:无(未连接时静默 no-op,不抛错、不阻塞 agent 主循环)。
|
|
241
|
+
* 后置条件:若 state !== lastSentState,构造并发送一条合法 StateMessage;否则无副作用。
|
|
242
|
+
*/
|
|
243
|
+
export function sendState(state, meta) {
|
|
244
|
+
if (!socket || socket.readyState !== WebSocket.OPEN)
|
|
245
|
+
return; // 未连接:no-op,不缓冲不报错
|
|
246
|
+
if (state === lastSentState)
|
|
247
|
+
return; // 节流:状态未变化不重发
|
|
248
|
+
lastSentState = state;
|
|
249
|
+
const msg = { type: 'state', clientId, state, meta, ts: Date.now() };
|
|
250
|
+
try {
|
|
251
|
+
socket.send(JSON.stringify(msg));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// 静默:发送失败不影响 agent 主循环
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** 当前是否已建立活跃连接。 */
|
|
258
|
+
export function isConnected() {
|
|
259
|
+
return !!socket && socket.readyState === WebSocket.OPEN;
|
|
260
|
+
}
|
|
261
|
+
/** 主动断开连接(best-effort 发 bye 后 close code=1000)。 */
|
|
262
|
+
export function disconnect() {
|
|
263
|
+
clearHeartbeat();
|
|
264
|
+
if (!socket)
|
|
265
|
+
return;
|
|
266
|
+
const ws = socket;
|
|
267
|
+
socket = null;
|
|
268
|
+
try {
|
|
269
|
+
ws.send(JSON.stringify({ type: 'bye', clientId, ts: Date.now() }));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// no-op
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
ws.close(1000);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// no-op
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* 请求桌宠进程整体退出(方案C:CLI 侧退出入口;托盘图标是桌面侧的另一入口,见 packages/pet-app/src/main.ts)。
|
|
283
|
+
* 不要求本连接是活跃连接——任何已连接的 mocode 进程都可以关闭桌宠。
|
|
284
|
+
* 前置条件:当前进程已建立连接(未连接则先尝试探测端口直连,再发 shutdown)。
|
|
285
|
+
* 后置条件:发送 shutdown 消息后主动断开本地连接;不等待桌宠进程确认退出(best-effort,不阻塞 REPL)。
|
|
286
|
+
*/
|
|
287
|
+
export async function killPetProcess() {
|
|
288
|
+
const port = petPort();
|
|
289
|
+
let ws = socket;
|
|
290
|
+
let owned = false; // 是否为本函数临时建立的连接(需要负责关闭)
|
|
291
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
292
|
+
const already = await probePort(port, PROBE_TIMEOUT_MS);
|
|
293
|
+
if (!already) {
|
|
294
|
+
return { ok: false, reason: '桌宠未运行' };
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
ws = await openConnection(port);
|
|
298
|
+
owned = true;
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
return { ok: false, reason: e instanceof Error ? e.message : '无法连接桌宠' };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
ws.send(JSON.stringify({ type: 'shutdown', clientId, ts: Date.now() }));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// 静默:发送失败不影响后续清理
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
ws.close(1000);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// no-op
|
|
315
|
+
}
|
|
316
|
+
if (!owned && socket === ws) {
|
|
317
|
+
// 本连接原本就是活跃连接:同步清理本地状态(桌宠进程退出后 WS 也会被动关闭,这里主动先清)。
|
|
318
|
+
socket = null;
|
|
319
|
+
clearHeartbeat();
|
|
320
|
+
}
|
|
321
|
+
return { ok: true };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 请求当前可用皮肤列表(供 /pet skin 菜单展示)。
|
|
325
|
+
* 前置条件:当前进程已建立连接(未连接则先尝试探测端口直连;若桌宠未运行则失败)。
|
|
326
|
+
* 后置条件:resolve 桌宠回复的 SkinListMessage;超时(2s 内无回复)reject。
|
|
327
|
+
*/
|
|
328
|
+
export async function listSkins() {
|
|
329
|
+
const port = petPort();
|
|
330
|
+
let ws = socket;
|
|
331
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
332
|
+
const already = await probePort(port, PROBE_TIMEOUT_MS);
|
|
333
|
+
if (!already)
|
|
334
|
+
throw new Error('桌宠未运行,请先 /pet 打开');
|
|
335
|
+
ws = await openConnection(port);
|
|
336
|
+
wireConnection(ws);
|
|
337
|
+
}
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
const timer = setTimeout(() => {
|
|
340
|
+
const idx = pendingSkinListResolvers.indexOf(onMsg);
|
|
341
|
+
if (idx >= 0)
|
|
342
|
+
pendingSkinListResolvers.splice(idx, 1);
|
|
343
|
+
reject(new Error('桌宠未响应皮肤列表请求'));
|
|
344
|
+
}, 2000);
|
|
345
|
+
const onMsg = (msg) => {
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
resolve(msg);
|
|
348
|
+
};
|
|
349
|
+
pendingSkinListResolvers.push(onMsg);
|
|
350
|
+
try {
|
|
351
|
+
ws.send(JSON.stringify({ type: 'list_skins', ts: Date.now() }));
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
clearTimeout(timer);
|
|
355
|
+
const idx = pendingSkinListResolvers.indexOf(onMsg);
|
|
356
|
+
if (idx >= 0)
|
|
357
|
+
pendingSkinListResolvers.splice(idx, 1);
|
|
358
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 请求桌宠切换皮肤(选宠物)。
|
|
364
|
+
* 前置条件:当前进程已建立连接(未连接则静默 no-op,与 sendState 的降级策略一致)。
|
|
365
|
+
* 后置条件:已连接时发送 set_skin 消息;未连接时不抛错、无副作用。
|
|
366
|
+
*/
|
|
367
|
+
export function setSkin(skinId) {
|
|
368
|
+
if (!socket || socket.readyState !== WebSocket.OPEN)
|
|
369
|
+
return;
|
|
370
|
+
try {
|
|
371
|
+
socket.send(JSON.stringify({ type: 'set_skin', clientId, skinId, ts: Date.now() }));
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// 静默:发送失败不影响 REPL 主流程
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* /pet 命令入口。
|
|
379
|
+
* 前置条件:REPL 主循环已初始化(不要求 agent 正在运行)。
|
|
380
|
+
* 后置条件:
|
|
381
|
+
* - 若调用前无活跃连接:调用后 either 已建立连接(connected=true)
|
|
382
|
+
* 或已尝试 spawn+重试全部失败(connected=false,附错误原因)。
|
|
383
|
+
* - 若调用前有活跃连接:调用后连接已关闭(connected=false)。
|
|
384
|
+
* 不抛异常(所有失败路径转为返回值,供 REPL 渲染提示行)。
|
|
385
|
+
*/
|
|
386
|
+
export async function togglePet() {
|
|
387
|
+
if (isConnected()) {
|
|
388
|
+
disconnect();
|
|
389
|
+
return { connected: false, reason: '已断开桌宠连接' };
|
|
390
|
+
}
|
|
391
|
+
const port = petPort();
|
|
392
|
+
try {
|
|
393
|
+
const already = await probePort(port, PROBE_TIMEOUT_MS);
|
|
394
|
+
if (already) {
|
|
395
|
+
const ws = await openConnection(port);
|
|
396
|
+
wireConnection(ws);
|
|
397
|
+
return { connected: true };
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await spawnPetProcess();
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
return {
|
|
404
|
+
connected: false,
|
|
405
|
+
reason: e instanceof Error ? e.message : '无法启动桌宠进程',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const ws = await connectWithBackoff(port, RETRY_DELAYS_MS);
|
|
409
|
+
wireConnection(ws);
|
|
410
|
+
return { connected: true };
|
|
411
|
+
}
|
|
412
|
+
catch (e) {
|
|
413
|
+
return {
|
|
414
|
+
connected: false,
|
|
415
|
+
reason: e instanceof Error ? e.message : '桌宠启动超时,请手动检查',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 桌宠 WS 协议:mocode 主包侧的类型定义与消息校验。
|
|
2
|
+
// 与 packages/pet-app/src/protocol.ts 字段保持一致,但两处各自维护副本——
|
|
3
|
+
// 主包不 import 子包源码(子包是 optionalDependency,可能未安装;设计上刻意解耦)。
|
|
4
|
+
/** 全部合法状态值(供运行时校验,如 Set 成员判断)。 */
|
|
5
|
+
export const PET_STATES = [
|
|
6
|
+
'idle',
|
|
7
|
+
'thinking',
|
|
8
|
+
'speaking',
|
|
9
|
+
'tool_call',
|
|
10
|
+
'done',
|
|
11
|
+
'aborted',
|
|
12
|
+
'error',
|
|
13
|
+
'waiting_human',
|
|
14
|
+
];
|
|
15
|
+
/** 判断值是否为合法 PetState(供消息校验,非法值丢弃不崩)。 */
|
|
16
|
+
export function isValidPetState(v) {
|
|
17
|
+
return typeof v === 'string' && PET_STATES.includes(v);
|
|
18
|
+
}
|
|
19
|
+
/** 校验并解析一条原始 JSON 字符串为 ClientMessage;失败(JSON 非法/缺字段/type 未知)返回 null。
|
|
20
|
+
* 永不抛错——调用方(bridge/server)据此静默丢弃畸形消息,不断开连接。 */
|
|
21
|
+
export function parseClientMessage(raw) {
|
|
22
|
+
let obj;
|
|
23
|
+
try {
|
|
24
|
+
obj = JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (typeof obj !== 'object' || obj === null)
|
|
30
|
+
return null;
|
|
31
|
+
const m = obj;
|
|
32
|
+
if (typeof m.ts !== 'number')
|
|
33
|
+
return null;
|
|
34
|
+
switch (m.type) {
|
|
35
|
+
case 'hello':
|
|
36
|
+
if (typeof m.clientId !== 'string' || typeof m.pid !== 'number' || typeof m.cwd !== 'string') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return { type: 'hello', clientId: m.clientId, pid: m.pid, cwd: m.cwd, ts: m.ts };
|
|
40
|
+
case 'state': {
|
|
41
|
+
if (typeof m.clientId !== 'string' || !isValidPetState(m.state))
|
|
42
|
+
return null;
|
|
43
|
+
let meta;
|
|
44
|
+
if (m.meta && typeof m.meta === 'object') {
|
|
45
|
+
const mm = m.meta;
|
|
46
|
+
meta = {};
|
|
47
|
+
if (typeof mm.toolName === 'string')
|
|
48
|
+
meta.toolName = mm.toolName;
|
|
49
|
+
if (typeof mm.errorMessage === 'string')
|
|
50
|
+
meta.errorMessage = mm.errorMessage;
|
|
51
|
+
}
|
|
52
|
+
return { type: 'state', clientId: m.clientId, state: m.state, meta, ts: m.ts };
|
|
53
|
+
}
|
|
54
|
+
case 'ping':
|
|
55
|
+
return { type: 'ping', ts: m.ts };
|
|
56
|
+
case 'bye':
|
|
57
|
+
if (typeof m.clientId !== 'string')
|
|
58
|
+
return null;
|
|
59
|
+
return { type: 'bye', clientId: m.clientId, ts: m.ts };
|
|
60
|
+
case 'shutdown':
|
|
61
|
+
if (typeof m.clientId !== 'string')
|
|
62
|
+
return null;
|
|
63
|
+
return { type: 'shutdown', clientId: m.clientId, ts: m.ts };
|
|
64
|
+
case 'set_skin':
|
|
65
|
+
if (typeof m.clientId !== 'string' || typeof m.skinId !== 'string')
|
|
66
|
+
return null;
|
|
67
|
+
return { type: 'set_skin', clientId: m.clientId, skinId: m.skinId, ts: m.ts };
|
|
68
|
+
case 'list_skins':
|
|
69
|
+
return { type: 'list_skins', ts: m.ts };
|
|
70
|
+
default:
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** 校验并解析一条原始 JSON 字符串为 ServerMessage;失败返回 null(同上,永不抛错)。 */
|
|
75
|
+
export function parseServerMessage(raw) {
|
|
76
|
+
let obj;
|
|
77
|
+
try {
|
|
78
|
+
obj = JSON.parse(raw);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (typeof obj !== 'object' || obj === null)
|
|
84
|
+
return null;
|
|
85
|
+
const m = obj;
|
|
86
|
+
if (typeof m.ts !== 'number')
|
|
87
|
+
return null;
|
|
88
|
+
switch (m.type) {
|
|
89
|
+
case 'welcome':
|
|
90
|
+
if (typeof m.isActive !== 'boolean')
|
|
91
|
+
return null;
|
|
92
|
+
return { type: 'welcome', isActive: m.isActive, ts: m.ts };
|
|
93
|
+
case 'pong':
|
|
94
|
+
return { type: 'pong', ts: m.ts };
|
|
95
|
+
case 'skin_list': {
|
|
96
|
+
if (!Array.isArray(m.skins) || typeof m.currentSkinId !== 'string')
|
|
97
|
+
return null;
|
|
98
|
+
const skins = [];
|
|
99
|
+
for (const s of m.skins) {
|
|
100
|
+
if (!s || typeof s !== 'object')
|
|
101
|
+
continue;
|
|
102
|
+
const ss = s;
|
|
103
|
+
if (typeof ss.id === 'string' && typeof ss.name === 'string') {
|
|
104
|
+
skins.push({ id: ss.id, name: ss.name });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { type: 'skin_list', skins, currentSkinId: m.currentSkinId, ts: m.ts };
|
|
108
|
+
}
|
|
109
|
+
default:
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// AgentHooks → PetState 映射(mocode 主包侧)。
|
|
2
|
+
// createPetHooks 产出的 hooks 只在 src/agent/index.ts(主 agent hooks 组装处)使用,
|
|
3
|
+
// 与既有 TUI hooks 并列注入——两组 hooks 各自独立触发,互不干扰,不修改 core.ts 的任何行为。
|
|
4
|
+
// 子 agent(src/agent/spawn.ts 的 spawnAgent)不引用本模块,故子 agent 永不广播桌宠状态。
|
|
5
|
+
import * as bridge from './bridge.js';
|
|
6
|
+
/**
|
|
7
|
+
* 纯函数:给定当前 hook 事件与其参数,推导下一个 PetState。
|
|
8
|
+
* 前置条件:event 是 AgentHooks 定义的方法名之一。
|
|
9
|
+
* 后置条件:返回值 ∈ PetState 枚举;对同一 (event, args) 输入,任意调用时刻返回值相同(确定性,可测)。
|
|
10
|
+
* 不依赖调用历史之外的隐藏状态——纯函数式转移表。
|
|
11
|
+
*/
|
|
12
|
+
export function deriveState(event, args) {
|
|
13
|
+
switch (event) {
|
|
14
|
+
case 'onStepStart':
|
|
15
|
+
return 'thinking';
|
|
16
|
+
case 'onText':
|
|
17
|
+
return 'speaking';
|
|
18
|
+
case 'onToolCall':
|
|
19
|
+
case 'onToolStart':
|
|
20
|
+
return 'tool_call';
|
|
21
|
+
case 'onToolResult':
|
|
22
|
+
if (args?.toolOutput && args.toolOutput.startsWith('错误'))
|
|
23
|
+
return 'error';
|
|
24
|
+
return 'tool_call';
|
|
25
|
+
case 'onDone':
|
|
26
|
+
return 'done';
|
|
27
|
+
case 'onAbort':
|
|
28
|
+
return 'aborted';
|
|
29
|
+
case 'onMaxSteps':
|
|
30
|
+
return 'error';
|
|
31
|
+
case 'onChatDone':
|
|
32
|
+
case 'onToolBatchEnd':
|
|
33
|
+
return 'thinking';
|
|
34
|
+
case 'onNoReply':
|
|
35
|
+
return 'idle';
|
|
36
|
+
default:
|
|
37
|
+
return 'idle';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 把 AgentHooks 事件流映射为 PetState 变化并调用 bridge.sendState。
|
|
42
|
+
* 前置条件:传入的 sender 已存在(可能尚未连接,sendState 内部自行处理未连接情形——no-op)。
|
|
43
|
+
* 后置条件:返回的 AgentHooks 对象的每个方法都是纯粹的"状态推导 + 转发",
|
|
44
|
+
* 不修改 core.ts 的任何行为、不影响现有 TUI hooks 的调用结果。
|
|
45
|
+
*/
|
|
46
|
+
export function createPetHooks(sender = bridge) {
|
|
47
|
+
return {
|
|
48
|
+
onStepStart: () => sender.sendState(deriveState('onStepStart')),
|
|
49
|
+
onText: () => sender.sendState(deriveState('onText')),
|
|
50
|
+
onToolCall: (name) => sender.sendState(deriveState('onToolCall', { toolName: name }), { toolName: name }),
|
|
51
|
+
onToolStart: (name) => sender.sendState(deriveState('onToolStart', { toolName: name }), { toolName: name }),
|
|
52
|
+
onToolResult: (tc, output) => {
|
|
53
|
+
const state = deriveState('onToolResult', { toolOutput: output });
|
|
54
|
+
if (state === 'error') {
|
|
55
|
+
sender.sendState(state, { errorMessage: output.slice(0, 200) });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
sender.sendState(state, { toolName: tc.name });
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
onToolBatchEnd: () => sender.sendState(deriveState('onToolBatchEnd')),
|
|
62
|
+
onDone: () => sender.sendState(deriveState('onDone')),
|
|
63
|
+
onAbort: () => sender.sendState(deriveState('onAbort')),
|
|
64
|
+
onMaxSteps: () => sender.sendState(deriveState('onMaxSteps')),
|
|
65
|
+
onNoReply: () => sender.sendState(deriveState('onNoReply')),
|
|
66
|
+
};
|
|
67
|
+
}
|
package/dist/repl/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { config, PLAN_MODE_SUFFIX, updateModelConfig, isModelConfigured } from '
|
|
|
5
5
|
import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
|
|
6
6
|
import { runAgent } from '../agent/index.js';
|
|
7
7
|
import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
|
|
8
|
+
import { togglePet, killPetProcess, listSkins, setSkin, sendState } from '../pet/bridge.js';
|
|
8
9
|
import { setSandboxRoot } from '../sandbox/root.js';
|
|
9
10
|
import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js';
|
|
10
11
|
import { bannerString, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
|
|
@@ -39,6 +40,9 @@ const SLASH_COMMANDS = [
|
|
|
39
40
|
{ name: '/model', desc: '配置大模型(baseURL/key/model/窗口)' },
|
|
40
41
|
{ name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
|
|
41
42
|
{ name: '/auto', desc: '切回 auto 模式(全工具执行)' },
|
|
43
|
+
{ name: '/pet', desc: '开关桌宠(独立悬浮窗,展示 agent 状态动画)' },
|
|
44
|
+
{ name: '/pet skin', desc: '选择桌宠皮肤(↑↓·Enter)' },
|
|
45
|
+
{ name: '/pet quit', desc: '完全关闭桌宠进程(而非仅断开本连接)' },
|
|
42
46
|
];
|
|
43
47
|
/** 主题名 → 一句描述(供 /theme 菜单 / 列表显示)。新增主题时在 src/ui/theme.ts THEMES 加键后于此补一句。 */
|
|
44
48
|
const THEME_DESCRIPTIONS = {
|
|
@@ -164,6 +168,8 @@ function runningStateFor(cmd) {
|
|
|
164
168
|
return { status: '切主题', placeholder: '选择主题…' };
|
|
165
169
|
case '/model':
|
|
166
170
|
return { status: '配模型', placeholder: '配置中…' };
|
|
171
|
+
case '/pet':
|
|
172
|
+
return { status: '桌宠', placeholder: '处理中…' };
|
|
167
173
|
default:
|
|
168
174
|
// 输入框留空(运行中可 typeahead 打字,dim 回显);运行状态由内联 spinner 承载(思考中/执行…),
|
|
169
175
|
// 状态行只显走时——故常态 status 留空,不塞「处理」这种与内联重复的泛标签。
|
|
@@ -632,6 +638,57 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
632
638
|
}
|
|
633
639
|
continue;
|
|
634
640
|
}
|
|
641
|
+
if (line === '/pet quit') {
|
|
642
|
+
// /pet quit:完全关闭桌宠进程(区别于 /pet 的仅断开本连接)。方案C的 CLI 侧退出入口,
|
|
643
|
+
// 另一入口是桌宠托盘菜单"退出桌宠"(见 packages/pet-app/src/main.ts)。
|
|
644
|
+
const { ok, reason } = await killPetProcess();
|
|
645
|
+
layout.contentWrite(`${ui.dim}(${ok ? '已关闭桌宠进程' : reason ?? '关闭失败'})${ui.reset}\n`);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (line === '/pet skin') {
|
|
649
|
+
// /pet skin:菜单选皮(↑↓ 选,Enter 切换,Esc 取消),仿 /theme 的交互。要求桌宠已在运行
|
|
650
|
+
// (未运行则先提示 /pet 打开;不在此处自动 spawn,避免选皮命令产生"顺带开桌宠"的意外副作用)。
|
|
651
|
+
let skinList;
|
|
652
|
+
try {
|
|
653
|
+
skinList = await listSkins();
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
layout.contentWrite(`${ui.dim}(${e instanceof Error ? e.message : '获取皮肤列表失败'})${ui.reset}\n`);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const items = [
|
|
660
|
+
{ id: 'default', title: '默认(mascot)', subtitle: skinList.currentSkinId === 'default' ? '当前' : '' },
|
|
661
|
+
...skinList.skins.map((s) => ({
|
|
662
|
+
id: s.id,
|
|
663
|
+
title: s.name,
|
|
664
|
+
subtitle: skinList.currentSkinId === s.id ? '当前' : '',
|
|
665
|
+
})),
|
|
666
|
+
];
|
|
667
|
+
let pick;
|
|
668
|
+
try {
|
|
669
|
+
pick = await promptThemePicker(items);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
continue; // Ctrl+C(SIGINT)→ 取消
|
|
673
|
+
}
|
|
674
|
+
if (pick === null)
|
|
675
|
+
continue; // Esc / Ctrl+D 取消
|
|
676
|
+
setSkin(pick.id);
|
|
677
|
+
layout.contentWrite(`${ui.dim}(已切换桌宠皮肤:${pick.title})${ui.reset}\n`);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (line === '/pet') {
|
|
681
|
+
// /pet:开关桌宠。已连接→断开;未连接→探测端口(已有实例则直连)或 spawn 拉起 + 退避重试连接。
|
|
682
|
+
// togglePet 不抛异常,所有失败路径转为返回值——桌宠是可选增强,任何异常都不能影响 REPL 主流程。
|
|
683
|
+
const { connected, reason } = await togglePet();
|
|
684
|
+
if (connected) {
|
|
685
|
+
layout.contentWrite(`${ui.dim}(桌宠已连接)${ui.reset}\n`);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
layout.contentWrite(`${ui.dim}(${reason ?? '桌宠已断开'})${ui.reset}\n`);
|
|
689
|
+
}
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
635
692
|
if (line === '/clear') {
|
|
636
693
|
history.length = 1; // 保留 system 提示
|
|
637
694
|
resetState(); // 同步清空回滚轮次/快照
|
|
@@ -983,12 +1040,16 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
983
1040
|
// - 仍 plan:LLM 没自切(只产计划就 STOP)→ 弹审批面板(原行为)。
|
|
984
1041
|
// - 已 auto:LLM 调了 switch_mode('auto') 在同轮自主执行了 → 跳过审批,不重复打扰。
|
|
985
1042
|
if (initialPlan && ok && getAgentMode() === 'plan') {
|
|
1043
|
+
// 桌宠:计划审批面板弹出期间广播 waiting_human(红灯闪烁);面板不在 runAgent/hooks 体系内,
|
|
1044
|
+
// 需在此单独广播——用户响应后由下一次 /pet 状态事件(或 idle 兜底)覆盖。
|
|
1045
|
+
sendState('waiting_human');
|
|
986
1046
|
const res = await promptIntervention({
|
|
987
1047
|
type: 'choice',
|
|
988
1048
|
title: '计划已就绪',
|
|
989
1049
|
detail: '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
|
|
990
1050
|
options: ['切 auto 执行', '留 plan 细化'],
|
|
991
1051
|
});
|
|
1052
|
+
sendState('idle');
|
|
992
1053
|
if (res.action === 'selected' && res.value === '切 auto 执行') {
|
|
993
1054
|
// setAgentMode('auto') 由 runTurn 入口做(listener 重写 history[0] 回 auto);这里只切运行态 + 合成执行轮。
|
|
994
1055
|
layout.enterRunningMode('执行', '按计划执行…');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { promptIntervention } from '../../ui/intervention.js';
|
|
2
|
+
import { sendState } from '../../pet/bridge.js';
|
|
2
3
|
// ---------- ask_human ----------
|
|
3
4
|
export const askHumanTool = {
|
|
4
5
|
name: 'ask_human',
|
|
@@ -33,6 +34,9 @@ export const askHumanTool = {
|
|
|
33
34
|
? args.options.map((o) => String(o))
|
|
34
35
|
: [];
|
|
35
36
|
const context = args.context ? String(args.context) : undefined;
|
|
37
|
+
// 桌宠:面板弹出期间广播 waiting_human(红灯闪烁,提示需要人工介入);拿到响应后 sendState 会被
|
|
38
|
+
// 下一个 hook 事件(如 onToolDone→tool_call)覆盖,这里不用手动切回——与其它工具状态转移逻辑一致。
|
|
39
|
+
sendState('waiting_human');
|
|
36
40
|
const result = await promptIntervention({
|
|
37
41
|
type: options.length > 0 ? 'choice' : 'input',
|
|
38
42
|
title: question,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mocode-ai",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,10 +24,16 @@
|
|
|
24
24
|
"cli-highlight": "^2.1.11",
|
|
25
25
|
"dotenv": "^16.0.0",
|
|
26
26
|
"fast-glob": "^3.0.0",
|
|
27
|
-
"openai": "^4.0.0"
|
|
27
|
+
"openai": "^4.0.0",
|
|
28
|
+
"ws": "8.21.0"
|
|
29
|
+
},
|
|
30
|
+
"optionalDependencies": {
|
|
31
|
+
"mocode-pet-app": "0.1.0"
|
|
28
32
|
},
|
|
29
33
|
"devDependencies": {
|
|
30
34
|
"@types/node": "^22.0.0",
|
|
35
|
+
"@types/ws": "8.5.13",
|
|
36
|
+
"fast-check": "3.23.1",
|
|
31
37
|
"tsx": "^4.0.0",
|
|
32
38
|
"typescript": "^5.0.0"
|
|
33
39
|
},
|