pikiloop 0.4.0
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 +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat bot orchestration.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Bot, buildPrompt, fmtUptime, fmtBytes, formatGitStatusLine, normalizeAgent, parseAllowedChatIds, } from '../../bot/bot.js';
|
|
8
|
+
import { currentHumanLoopQuestion, } from '../../bot/human-loop.js';
|
|
9
|
+
import { buildAgentsCommandView, buildModeCommandView, buildModelsCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, encodeCommandAction, executeCommandAction, } from '../../bot/command-ui.js';
|
|
10
|
+
import { BOT_SHUTDOWN_FORCE_EXIT_MS, buildSessionTaskId } from '../../bot/orchestration.js';
|
|
11
|
+
import { shutdownAllDrivers } from '../../agent/driver.js';
|
|
12
|
+
import { expandTilde } from '../../core/platform.js';
|
|
13
|
+
import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
|
|
14
|
+
import { getStatusDataAsync, getHostDataSync, getModelsListData, getSessionsPageData, getStartData, getWorkspacesData, handleGoalCommand, } from '../../bot/commands.js';
|
|
15
|
+
import { WeixinChannel } from './channel.js';
|
|
16
|
+
import { getActiveUserConfig } from '../../core/config/user-config.js';
|
|
17
|
+
const SHUTDOWN_EXIT_CODE = {
|
|
18
|
+
SIGINT: 130,
|
|
19
|
+
SIGTERM: 143,
|
|
20
|
+
};
|
|
21
|
+
function describeError(error) {
|
|
22
|
+
return error instanceof Error ? error.message : String(error ?? 'unknown error');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Plain-text echo of a resolved human-loop prompt — WeChat doesn't render
|
|
26
|
+
* markdown or cards, so we keep the format minimal and obviously distinct
|
|
27
|
+
* from a regular agent reply.
|
|
28
|
+
*/
|
|
29
|
+
function buildInteractionEchoPlain(summary) {
|
|
30
|
+
if (summary.status === 'cancelled')
|
|
31
|
+
return '⊘ Prompt cancelled.';
|
|
32
|
+
if (!summary.rows.length)
|
|
33
|
+
return null;
|
|
34
|
+
if (summary.rows.length === 1) {
|
|
35
|
+
return `✓ Answered · ${summary.rows[0].display}`;
|
|
36
|
+
}
|
|
37
|
+
const lines = ['✓ Answered'];
|
|
38
|
+
for (const row of summary.rows) {
|
|
39
|
+
lines.push(`• ${row.label}: ${row.display}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
export class WeixinBot extends Bot {
|
|
44
|
+
botToken;
|
|
45
|
+
accountId;
|
|
46
|
+
baseUrl;
|
|
47
|
+
channel;
|
|
48
|
+
nextTaskId = 1;
|
|
49
|
+
shutdownInFlight = false;
|
|
50
|
+
shutdownExitCode = null;
|
|
51
|
+
shutdownForceExitTimer = null;
|
|
52
|
+
signalHandlers = {};
|
|
53
|
+
processRuntimeCleanup = null;
|
|
54
|
+
constructor() {
|
|
55
|
+
super();
|
|
56
|
+
const config = getActiveUserConfig();
|
|
57
|
+
if (process.env.WEIXIN_ALLOWED_USER_IDS) {
|
|
58
|
+
for (const id of parseAllowedChatIds(process.env.WEIXIN_ALLOWED_USER_IDS))
|
|
59
|
+
this.allowedChatIds.add(id);
|
|
60
|
+
}
|
|
61
|
+
this.baseUrl = String(config.weixinBaseUrl || process.env.WEIXIN_BASE_URL || '').trim();
|
|
62
|
+
this.botToken = String(config.weixinBotToken || process.env.WEIXIN_BOT_TOKEN || '').trim();
|
|
63
|
+
this.accountId = String(config.weixinAccountId || process.env.WEIXIN_ACCOUNT_ID || '').trim();
|
|
64
|
+
if (!this.baseUrl || !this.botToken || !this.accountId) {
|
|
65
|
+
throw new Error('Missing Weixin credentials. Configure via dashboard QR login first.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
requestStop() {
|
|
69
|
+
super.requestStop();
|
|
70
|
+
try {
|
|
71
|
+
this.channel?.disconnect();
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
}
|
|
75
|
+
onManagedConfigChange(config, opts = {}) {
|
|
76
|
+
const nextBaseUrl = String(config.weixinBaseUrl || process.env.WEIXIN_BASE_URL || '').trim();
|
|
77
|
+
const nextBotToken = String(config.weixinBotToken || process.env.WEIXIN_BOT_TOKEN || '').trim();
|
|
78
|
+
const nextAccountId = String(config.weixinAccountId || process.env.WEIXIN_ACCOUNT_ID || '').trim();
|
|
79
|
+
if (nextBaseUrl && nextBaseUrl !== this.baseUrl) {
|
|
80
|
+
this.baseUrl = nextBaseUrl;
|
|
81
|
+
if (!opts.initial)
|
|
82
|
+
this.log('weixin baseUrl reloaded from setting.json');
|
|
83
|
+
}
|
|
84
|
+
if (nextBotToken && nextBotToken !== this.botToken) {
|
|
85
|
+
this.botToken = nextBotToken;
|
|
86
|
+
if (!opts.initial)
|
|
87
|
+
this.log('weixin botToken reloaded from setting.json');
|
|
88
|
+
}
|
|
89
|
+
if (nextAccountId && nextAccountId !== this.accountId) {
|
|
90
|
+
this.accountId = nextAccountId;
|
|
91
|
+
if (!opts.initial)
|
|
92
|
+
this.log('weixin accountId reloaded from setting.json');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
installSignalHandlers() {
|
|
96
|
+
this.removeSignalHandlers();
|
|
97
|
+
const onSigint = () => this.beginShutdown('SIGINT');
|
|
98
|
+
const onSigterm = () => this.beginShutdown('SIGTERM');
|
|
99
|
+
this.signalHandlers = { SIGINT: onSigint, SIGTERM: onSigterm };
|
|
100
|
+
process.once('SIGINT', onSigint);
|
|
101
|
+
process.once('SIGTERM', onSigterm);
|
|
102
|
+
}
|
|
103
|
+
removeSignalHandlers() {
|
|
104
|
+
for (const signal of Object.keys(this.signalHandlers)) {
|
|
105
|
+
const handler = this.signalHandlers[signal];
|
|
106
|
+
if (handler)
|
|
107
|
+
process.off(signal, handler);
|
|
108
|
+
}
|
|
109
|
+
this.signalHandlers = {};
|
|
110
|
+
}
|
|
111
|
+
clearShutdownForceExitTimer() {
|
|
112
|
+
if (!this.shutdownForceExitTimer)
|
|
113
|
+
return;
|
|
114
|
+
clearTimeout(this.shutdownForceExitTimer);
|
|
115
|
+
this.shutdownForceExitTimer = null;
|
|
116
|
+
}
|
|
117
|
+
cleanupRuntimeForExit() {
|
|
118
|
+
try {
|
|
119
|
+
this.channel.disconnect();
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
this.stopKeepAlive();
|
|
123
|
+
shutdownAllDrivers();
|
|
124
|
+
}
|
|
125
|
+
beginShutdown(signal) {
|
|
126
|
+
if (this.shutdownInFlight)
|
|
127
|
+
return;
|
|
128
|
+
this.shutdownInFlight = true;
|
|
129
|
+
this.shutdownExitCode = SHUTDOWN_EXIT_CODE[signal];
|
|
130
|
+
this.log(`${signal}, shutting down...`);
|
|
131
|
+
this.cleanupRuntimeForExit();
|
|
132
|
+
this.clearShutdownForceExitTimer();
|
|
133
|
+
this.shutdownForceExitTimer = setTimeout(() => {
|
|
134
|
+
this.log(`shutdown still pending after ${Math.floor(BOT_SHUTDOWN_FORCE_EXIT_MS / 1000)}s, forcing exit`);
|
|
135
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
136
|
+
}, BOT_SHUTDOWN_FORCE_EXIT_MS);
|
|
137
|
+
this.shutdownForceExitTimer.unref?.();
|
|
138
|
+
}
|
|
139
|
+
resolveSession(chatId, title, files) {
|
|
140
|
+
return this.ensureSessionForChat(chatId, title, files);
|
|
141
|
+
}
|
|
142
|
+
async handleCommand(text, ctx) {
|
|
143
|
+
const [rawCommand, ...rest] = text.trim().slice(1).split(/\s+/);
|
|
144
|
+
const command = rawCommand?.toLowerCase() || '';
|
|
145
|
+
const args = rest.join(' ').trim();
|
|
146
|
+
// `/cancel` aborts any pending command-UI / im_ask_user prompt for this
|
|
147
|
+
// chat. Special-cased before the auto-cancel below so a bare `/cancel`
|
|
148
|
+
// doesn't get treated as the start of a new interactive command.
|
|
149
|
+
if (command === 'cancel' || command === 'quit') {
|
|
150
|
+
const pending = this.pendingHumanLoopPrompt(ctx.chatId);
|
|
151
|
+
if (pending) {
|
|
152
|
+
this.humanLoopCancel(pending.promptId, 'Cancelled by user.');
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
await ctx.reply('没有正在等待的交互。');
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
// Auto-clear any stale picker before starting a new interactive command
|
|
160
|
+
// so the user isn't trapped behind a forgotten `/agent` / `/models` etc.
|
|
161
|
+
const pendingPrompt = this.pendingHumanLoopPrompt(ctx.chatId);
|
|
162
|
+
if (pendingPrompt) {
|
|
163
|
+
this.humanLoopCancel(pendingPrompt.promptId, 'Cancelled — new command issued.');
|
|
164
|
+
}
|
|
165
|
+
switch (command) {
|
|
166
|
+
case 'help':
|
|
167
|
+
await ctx.reply([
|
|
168
|
+
'/help - Show commands',
|
|
169
|
+
'/new - New session',
|
|
170
|
+
'/status - Session status',
|
|
171
|
+
'/host - Host system info',
|
|
172
|
+
'/agent [codex|claude|gemini] - Switch agent (interactive when no arg)',
|
|
173
|
+
'/models [name|#] - Switch model (interactive when no arg)',
|
|
174
|
+
'/mode [plan|code] - Toggle plan mode (claude only)',
|
|
175
|
+
'/switch [path] - Change workdir',
|
|
176
|
+
'/workspaces [#] - Pick saved workspace',
|
|
177
|
+
'/sessions [new|#] - List/switch sessions',
|
|
178
|
+
'/skills - List & run project skills',
|
|
179
|
+
'/cancel - Cancel an active interactive prompt',
|
|
180
|
+
'/stop - Stop current task',
|
|
181
|
+
'/restart - Restart pikiloop',
|
|
182
|
+
].join('\n'));
|
|
183
|
+
return true;
|
|
184
|
+
case 'new': {
|
|
185
|
+
this.resetConversationForChat(ctx.chatId);
|
|
186
|
+
await ctx.reply('Started a new session.');
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
case 'status':
|
|
190
|
+
await this.cmdStatus(ctx);
|
|
191
|
+
return true;
|
|
192
|
+
case 'host':
|
|
193
|
+
await this.cmdHost(ctx);
|
|
194
|
+
return true;
|
|
195
|
+
case 'agent':
|
|
196
|
+
await this.cmdAgent(ctx, args);
|
|
197
|
+
return true;
|
|
198
|
+
case 'models':
|
|
199
|
+
await this.cmdModels(ctx, args);
|
|
200
|
+
return true;
|
|
201
|
+
case 'mode':
|
|
202
|
+
await this.cmdMode(ctx, args);
|
|
203
|
+
return true;
|
|
204
|
+
case 'switch':
|
|
205
|
+
await this.cmdSwitch(ctx, args);
|
|
206
|
+
return true;
|
|
207
|
+
case 'workspaces':
|
|
208
|
+
await this.cmdWorkspaces(ctx, args);
|
|
209
|
+
return true;
|
|
210
|
+
case 'sessions':
|
|
211
|
+
await this.cmdSessions(ctx, args);
|
|
212
|
+
return true;
|
|
213
|
+
case 'skills':
|
|
214
|
+
await this.cmdSkills(ctx);
|
|
215
|
+
return true;
|
|
216
|
+
case 'goal':
|
|
217
|
+
await this.cmdGoal(ctx, args);
|
|
218
|
+
return true;
|
|
219
|
+
case 'stop':
|
|
220
|
+
await this.cmdStop(ctx);
|
|
221
|
+
return true;
|
|
222
|
+
case 'restart':
|
|
223
|
+
await this.cmdRestart(ctx);
|
|
224
|
+
return true;
|
|
225
|
+
case 'start':
|
|
226
|
+
await this.cmdStart(ctx);
|
|
227
|
+
return true;
|
|
228
|
+
default:
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async cmdStart(ctx) {
|
|
233
|
+
const d = getStartData(this, ctx.chatId);
|
|
234
|
+
const lines = [`pikiloop v${d.version}`, `Workdir: ${d.workdir}`, '', `Agent: ${d.agent}`];
|
|
235
|
+
for (const a of d.agentDetails) {
|
|
236
|
+
const parts = [` ${a.agent}: ${a.model}`];
|
|
237
|
+
if (a.effort)
|
|
238
|
+
parts[0] += ` (effort: ${a.effort})`;
|
|
239
|
+
lines.push(parts[0]);
|
|
240
|
+
}
|
|
241
|
+
lines.push('', 'Ready. Send a message to start.');
|
|
242
|
+
await ctx.reply(lines.join('\n'));
|
|
243
|
+
}
|
|
244
|
+
async cmdStatus(ctx) {
|
|
245
|
+
const d = await getStatusDataAsync(this, ctx.chatId);
|
|
246
|
+
const gitLine = formatGitStatusLine(d.git);
|
|
247
|
+
const lines = [
|
|
248
|
+
`pikiloop v${d.version}`,
|
|
249
|
+
`Uptime: ${fmtUptime(d.uptime)}`,
|
|
250
|
+
`PID: ${d.pid} | RSS: ${fmtBytes(d.memRss)} | Heap: ${fmtBytes(d.memHeap)}`,
|
|
251
|
+
`Workdir: ${d.workdir}`,
|
|
252
|
+
...(gitLine ? [`Git: ${gitLine}`] : []),
|
|
253
|
+
'',
|
|
254
|
+
`Agent: ${d.agent}`,
|
|
255
|
+
`Model: ${d.model || '-'}`,
|
|
256
|
+
`Session: ${d.sessionId ? d.sessionId.slice(0, 16) : '(new)'}`,
|
|
257
|
+
`Tasks: ${d.activeTasksCount}`,
|
|
258
|
+
];
|
|
259
|
+
if (d.running) {
|
|
260
|
+
lines.push(`Running: ${fmtUptime(Date.now() - d.running.startedAt)}`);
|
|
261
|
+
}
|
|
262
|
+
await ctx.reply(lines.join('\n'));
|
|
263
|
+
}
|
|
264
|
+
async cmdHost(ctx) {
|
|
265
|
+
const d = getHostDataSync(this);
|
|
266
|
+
const lines = [
|
|
267
|
+
`Host: ${d.hostName}`,
|
|
268
|
+
`CPU: ${d.cpuModel} x${d.cpuCount}`,
|
|
269
|
+
];
|
|
270
|
+
if (d.cpuUsage) {
|
|
271
|
+
lines.push(`CPU Usage: ${d.cpuUsage.usedPercent.toFixed(1)}% (user ${d.cpuUsage.userPercent.toFixed(1)}%, sys ${d.cpuUsage.sysPercent.toFixed(1)}%)`);
|
|
272
|
+
}
|
|
273
|
+
lines.push(`Memory: ${fmtBytes(d.memoryUsed)} / ${fmtBytes(d.totalMem)} (${d.memoryPercent.toFixed(0)}%)`, `Available: ${fmtBytes(d.memoryAvailable)}`);
|
|
274
|
+
if (d.battery)
|
|
275
|
+
lines.push(`Battery: ${d.battery.percent} (${d.battery.state})`);
|
|
276
|
+
if (d.disk)
|
|
277
|
+
lines.push(`Disk: ${d.disk.used} / ${d.disk.total} (${d.disk.percent})`);
|
|
278
|
+
lines.push(`Process: PID ${d.selfPid} | RSS ${fmtBytes(d.selfRss)} | Heap ${fmtBytes(d.selfHeap)}`);
|
|
279
|
+
if (d.topProcs.length > 1) {
|
|
280
|
+
lines.push('', 'Top Processes:');
|
|
281
|
+
lines.push(...d.topProcs);
|
|
282
|
+
}
|
|
283
|
+
await ctx.reply(lines.join('\n'));
|
|
284
|
+
}
|
|
285
|
+
async cmdAgent(ctx, args) {
|
|
286
|
+
if (args) {
|
|
287
|
+
// Power-user shortcut: `/agent claude` switches directly without the
|
|
288
|
+
// interactive picker — same behaviour as before, preserved for muscle
|
|
289
|
+
// memory and scripted flows.
|
|
290
|
+
try {
|
|
291
|
+
const agent = normalizeAgent(args);
|
|
292
|
+
this.switchAgentForChat(ctx.chatId, agent);
|
|
293
|
+
await ctx.reply(`Agent switched to ${agent}.`);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
await ctx.reply('Unknown agent. Use: /agent codex|claude|gemini');
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
await this.runCommandUiLoop(ctx, () => buildAgentsCommandView(this, ctx.chatId));
|
|
301
|
+
}
|
|
302
|
+
async cmdModels(ctx, args) {
|
|
303
|
+
if (args) {
|
|
304
|
+
// Power-user shortcut: `/models <number|name>` switches directly. Skips
|
|
305
|
+
// the multi-step picker (model + effort + Apply) used by the interactive
|
|
306
|
+
// path; effort stays unchanged.
|
|
307
|
+
const d = await getModelsListData(this, ctx.chatId);
|
|
308
|
+
const idx = parseInt(args, 10);
|
|
309
|
+
let modelId = null;
|
|
310
|
+
if (!isNaN(idx) && idx >= 1 && idx <= d.models.length) {
|
|
311
|
+
modelId = d.models[idx - 1].id;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const match = d.models.find(m => m.id === args || m.alias === args);
|
|
315
|
+
if (match)
|
|
316
|
+
modelId = match.id;
|
|
317
|
+
}
|
|
318
|
+
if (modelId) {
|
|
319
|
+
this.switchModelForChat(ctx.chatId, modelId);
|
|
320
|
+
await ctx.reply(`Model switched to ${modelId}.`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await ctx.reply(`Unknown model: ${args}`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
await this.runCommandUiLoop(ctx, () => buildModelsCommandView(this, ctx.chatId));
|
|
327
|
+
}
|
|
328
|
+
async cmdMode(ctx, args) {
|
|
329
|
+
if (this.chat(ctx.chatId).agent !== 'claude') {
|
|
330
|
+
await ctx.reply('Mode toggle is only available for Claude agent.');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (args === 'plan') {
|
|
334
|
+
this.switchPermissionModeForChat(ctx.chatId, 'plan');
|
|
335
|
+
await ctx.reply('Mode: Plan (read-only)');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (args === 'code') {
|
|
339
|
+
this.switchPermissionModeForChat(ctx.chatId, 'bypassPermissions');
|
|
340
|
+
await ctx.reply('Mode: Code (full access)');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await this.runCommandUiLoop(ctx, () => buildModeCommandView(this, ctx.chatId));
|
|
344
|
+
}
|
|
345
|
+
async cmdSwitch(ctx, args) {
|
|
346
|
+
const wd = this.chatWorkdir(ctx.chatId);
|
|
347
|
+
if (args) {
|
|
348
|
+
const resolvedPath = path.resolve(expandTilde(args));
|
|
349
|
+
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) {
|
|
350
|
+
await ctx.reply(`Not a valid directory: ${resolvedPath}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const oldPath = this.switchWorkdir(resolvedPath);
|
|
354
|
+
await ctx.reply(`Workdir switched:\n${oldPath}\n→ ${resolvedPath}`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const savedCount = getWorkspacesData(this, ctx.chatId).workspaces.length;
|
|
358
|
+
const hint = savedCount > 0
|
|
359
|
+
? `\n\nTip: ${savedCount} saved workspace${savedCount === 1 ? '' : 's'} — use /workspaces to pick one.`
|
|
360
|
+
: '';
|
|
361
|
+
await ctx.reply(`Current workdir: ${wd}\n\nUsage: /switch <path>${hint}`);
|
|
362
|
+
}
|
|
363
|
+
async cmdWorkspaces(ctx, args) {
|
|
364
|
+
const data = getWorkspacesData(this, ctx.chatId);
|
|
365
|
+
if (data.workspaces.length === 0) {
|
|
366
|
+
await ctx.reply('No saved workspaces yet.\n\n' +
|
|
367
|
+
'Add workspaces from the Dashboard (Sessions → Add Workspace), then use /workspaces to switch with one tap.\n\n' +
|
|
368
|
+
'You can still browse the file system with /switch <path>.');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const trimmed = args.trim();
|
|
372
|
+
if (trimmed) {
|
|
373
|
+
// Power-user shortcut: `/workspaces <#>` switches directly.
|
|
374
|
+
const idx = parseInt(trimmed, 10);
|
|
375
|
+
if (Number.isNaN(idx) || idx < 1 || idx > data.workspaces.length) {
|
|
376
|
+
await ctx.reply(`Workspace #${trimmed} not found. Use /workspaces to list.`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const ws = data.workspaces[idx - 1];
|
|
380
|
+
if (!ws.exists) {
|
|
381
|
+
await ctx.reply(`Workspace path is missing on disk:\n${ws.path}`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const oldPath = this.switchWorkdir(ws.path);
|
|
385
|
+
await ctx.reply(`Workdir switched:\n${oldPath}\n→ ${ws.path}`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// Interactive picker — there is no `command-ui` builder for workspaces
|
|
389
|
+
// (Telegram/Feishu render this via channel-local helpers), so we open a
|
|
390
|
+
// tailored human-loop prompt here. Disabled rows for missing paths are
|
|
391
|
+
// marked so users see them but can't pick.
|
|
392
|
+
const taskId = `wxcmd-ws-${Date.now().toString(36)}`;
|
|
393
|
+
const promptLines = [
|
|
394
|
+
'【Workspaces】',
|
|
395
|
+
`Current: ${data.currentWorkdir}`,
|
|
396
|
+
'',
|
|
397
|
+
];
|
|
398
|
+
const pickable = [];
|
|
399
|
+
data.workspaces.forEach((ws, i) => {
|
|
400
|
+
const marker = ws.isCurrent ? '✓' : ws.exists ? ' ' : '⚠';
|
|
401
|
+
promptLines.push(`${marker} ${i + 1}. ${ws.name}`);
|
|
402
|
+
promptLines.push(` ${ws.path}${!ws.exists ? ' [missing]' : ''}`);
|
|
403
|
+
if (ws.exists && !ws.isCurrent)
|
|
404
|
+
pickable.push({ index: i, ws });
|
|
405
|
+
});
|
|
406
|
+
if (!pickable.length) {
|
|
407
|
+
promptLines.push('', 'No switchable workspaces (all current or missing).');
|
|
408
|
+
await ctx.reply(promptLines.join('\n'));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
promptLines.push('', '━━━━━━');
|
|
412
|
+
pickable.forEach((p, i) => promptLines.push(`${i + 1}. ${p.ws.name} — ${p.ws.path}`));
|
|
413
|
+
promptLines.push('', '回复编号选择,或回复 /cancel 取消');
|
|
414
|
+
const promptText = promptLines.join('\n');
|
|
415
|
+
const options = pickable.map(p => ({
|
|
416
|
+
label: `${p.ws.name} — ${p.ws.path}`,
|
|
417
|
+
description: null,
|
|
418
|
+
value: `ws:${p.index}`,
|
|
419
|
+
}));
|
|
420
|
+
await new Promise((resolve) => {
|
|
421
|
+
const active = this.beginHumanLoopPrompt({
|
|
422
|
+
taskId,
|
|
423
|
+
chatId: ctx.chatId,
|
|
424
|
+
title: 'Workspaces',
|
|
425
|
+
hint: 'Reply with the option number to switch.',
|
|
426
|
+
questions: [{
|
|
427
|
+
id: 'pick',
|
|
428
|
+
header: 'Workspaces',
|
|
429
|
+
prompt: promptText,
|
|
430
|
+
options,
|
|
431
|
+
allowFreeform: false,
|
|
432
|
+
}],
|
|
433
|
+
silent: true,
|
|
434
|
+
resolveWith: (answers) => {
|
|
435
|
+
const picked = answers['pick']?.[0] || '';
|
|
436
|
+
if (!picked.startsWith('ws:'))
|
|
437
|
+
return null;
|
|
438
|
+
const idx = parseInt(picked.slice(3), 10);
|
|
439
|
+
if (!Number.isFinite(idx) || idx < 0 || idx >= data.workspaces.length)
|
|
440
|
+
return null;
|
|
441
|
+
return { workspaceIndex: idx };
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
void this.channel.send(ctx.chatId, promptText)
|
|
445
|
+
.catch(err => this.log(`weixin /workspaces send failed: ${describeError(err)}`));
|
|
446
|
+
active.result
|
|
447
|
+
.then(async (resolved) => {
|
|
448
|
+
const idx = resolved?.workspaceIndex;
|
|
449
|
+
if (typeof idx !== 'number') {
|
|
450
|
+
resolve();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const ws = data.workspaces[idx];
|
|
454
|
+
if (!ws?.exists) {
|
|
455
|
+
await ctx.reply('Workspace path is missing on disk.');
|
|
456
|
+
resolve();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const oldPath = this.switchWorkdir(ws.path);
|
|
460
|
+
await ctx.reply(`Workdir switched:\n${oldPath}\n→ ${ws.path}`);
|
|
461
|
+
resolve();
|
|
462
|
+
})
|
|
463
|
+
.catch(() => resolve());
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
async cmdSessions(ctx, args) {
|
|
467
|
+
const arg = args.trim().toLowerCase();
|
|
468
|
+
if (arg === 'new') {
|
|
469
|
+
this.resetConversationForChat(ctx.chatId);
|
|
470
|
+
await ctx.reply('Started a new session.');
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const idx = parseInt(arg, 10);
|
|
474
|
+
if (!isNaN(idx) && idx >= 1) {
|
|
475
|
+
// Power-user shortcut: `/sessions <#>` jumps directly using the same
|
|
476
|
+
// first-100 lookup the old text flow used.
|
|
477
|
+
const d = await getSessionsPageData(this, ctx.chatId, 0, 100);
|
|
478
|
+
const target = d.sessions[idx - 1];
|
|
479
|
+
if (target) {
|
|
480
|
+
const result = await this.fetchSessions(undefined, this.chatWorkdir(ctx.chatId));
|
|
481
|
+
const session = result.sessions.find(s => s.sessionId === target.key);
|
|
482
|
+
if (session) {
|
|
483
|
+
this.resumeSessionForChat(ctx.chatId, session);
|
|
484
|
+
await ctx.reply(`Switched to [${session.agent}] ${target.title}`);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
await ctx.reply(`Session not found.`);
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
await ctx.reply(`Session #${idx} not found.`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// Interactive list with pagination — `executeCommandAction` returns the
|
|
495
|
+
// next page as a fresh view, so the loop re-renders without leaving.
|
|
496
|
+
await this.runCommandUiLoop(ctx, () => buildSessionsCommandView(this, ctx.chatId, 0));
|
|
497
|
+
}
|
|
498
|
+
async cmdSkills(ctx) {
|
|
499
|
+
await this.runCommandUiLoop(ctx, () => buildSkillsCommandView(this, ctx.chatId));
|
|
500
|
+
}
|
|
501
|
+
async cmdStop(ctx) {
|
|
502
|
+
const session = this.selectedSession(ctx.chatId);
|
|
503
|
+
if (!session) {
|
|
504
|
+
await ctx.reply('No active session to stop.');
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const { interrupted, cancelledQueued } = this.stopTasksForSession(session.key);
|
|
508
|
+
if (!interrupted && cancelledQueued === 0) {
|
|
509
|
+
await ctx.reply('No running or queued work for the current session.');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const parts = [];
|
|
513
|
+
if (interrupted)
|
|
514
|
+
parts.push('interrupted current run');
|
|
515
|
+
if (cancelledQueued > 0)
|
|
516
|
+
parts.push(`cancelled ${cancelledQueued} queued task(s)`);
|
|
517
|
+
await ctx.reply(`Stopped: ${parts.join(', ')}.`);
|
|
518
|
+
}
|
|
519
|
+
async cmdGoal(ctx, args) {
|
|
520
|
+
const reply = await handleGoalCommand(this, ctx.chatId, args);
|
|
521
|
+
if (reply == null) {
|
|
522
|
+
await ctx.reply('No session selected. Use /sessions to pick one first.');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
await ctx.reply(reply);
|
|
526
|
+
}
|
|
527
|
+
async cmdRestart(ctx) {
|
|
528
|
+
await ctx.reply('Restarting pikiloop...');
|
|
529
|
+
void requestProcessRestart({ log: msg => this.log(msg) });
|
|
530
|
+
}
|
|
531
|
+
createMcpSendFile(chatId) {
|
|
532
|
+
return async (filePath) => {
|
|
533
|
+
try {
|
|
534
|
+
await this.channel.send(chatId, `Artifact ready: ${path.basename(filePath)}\n${filePath}`);
|
|
535
|
+
return { ok: true };
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
return { ok: false, error: describeError(error) };
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
async sendResult(chatId, result) {
|
|
543
|
+
const text = result.ok
|
|
544
|
+
? (result.message.trim() || 'Task finished.')
|
|
545
|
+
: ['Task failed.', result.error || result.message || 'Unknown error.'].filter(Boolean).join('\n');
|
|
546
|
+
await this.channel.send(chatId, text);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Format a human-in-the-loop prompt as plain text for WeChat (no card / button
|
|
550
|
+
* support on personal accounts). Options are numbered so the user can either
|
|
551
|
+
* reply with the number or freeform text — both are routed back through the
|
|
552
|
+
* same handler in `handleMessage`.
|
|
553
|
+
*/
|
|
554
|
+
formatHumanLoopPromptText(prompt) {
|
|
555
|
+
const question = currentHumanLoopQuestion(prompt);
|
|
556
|
+
const lines = [];
|
|
557
|
+
lines.push(`【${prompt.title || 'Pikiloop needs your input'}】`);
|
|
558
|
+
if (prompt.hint)
|
|
559
|
+
lines.push(prompt.hint);
|
|
560
|
+
if (prompt.questions.length > 1) {
|
|
561
|
+
lines.push(`(${prompt.currentIndex + 1}/${prompt.questions.length})`);
|
|
562
|
+
}
|
|
563
|
+
if (question) {
|
|
564
|
+
lines.push('');
|
|
565
|
+
lines.push(question.prompt);
|
|
566
|
+
if (question.options && question.options.length) {
|
|
567
|
+
lines.push('');
|
|
568
|
+
question.options.forEach((opt, idx) => {
|
|
569
|
+
lines.push(`${idx + 1}. ${opt.label}${opt.description ? ` — ${opt.description}` : ''}`);
|
|
570
|
+
});
|
|
571
|
+
lines.push('');
|
|
572
|
+
lines.push('Reply with a number to pick an option, or type your own answer.');
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
lines.push('');
|
|
576
|
+
lines.push('Reply with your answer.');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return lines.join('\n');
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* IM presenter for programmatic submissions (e.g. /goal-driven turns).
|
|
583
|
+
* WeChat has no edit / card primitive, so streaming preview isn't possible
|
|
584
|
+
* — the result is just posted once when the turn completes, mirroring
|
|
585
|
+
* handleMessage's plain-text path.
|
|
586
|
+
*/
|
|
587
|
+
async createImTaskPresenter(opts) {
|
|
588
|
+
const chatId = String(opts.chatId);
|
|
589
|
+
return {
|
|
590
|
+
onText: () => {
|
|
591
|
+
// No streaming render in WeChat — text accumulates and is sent once.
|
|
592
|
+
},
|
|
593
|
+
onSuccess: async (result) => {
|
|
594
|
+
await this.sendResult(chatId, result);
|
|
595
|
+
},
|
|
596
|
+
onFailure: async (error) => {
|
|
597
|
+
try {
|
|
598
|
+
await this.channel.send(chatId, `Error: ${error}`);
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
this.log(`[im-presenter weixin] error send failed: ${describeError(e)}`);
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
dispose: () => { },
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
async renderInteractionPrompt(prompt, chatId) {
|
|
608
|
+
const text = this.formatHumanLoopPromptText(prompt);
|
|
609
|
+
try {
|
|
610
|
+
await this.channel.send(String(chatId), text);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
this.log(`weixin renderInteractionPrompt failed: ${describeError(error)}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* WeChat has no message-edit primitive, so the original prompt text stays
|
|
618
|
+
* frozen in chat history. The echo message is the only way to record the
|
|
619
|
+
* decision — keep it short and prefix-marked so it's easy to skim.
|
|
620
|
+
*/
|
|
621
|
+
async onInteractionAnswered(prompt, summary) {
|
|
622
|
+
const text = buildInteractionEchoPlain(summary);
|
|
623
|
+
if (!text)
|
|
624
|
+
return;
|
|
625
|
+
try {
|
|
626
|
+
await this.channel.send(String(prompt.chatId), text);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
this.log(`weixin onInteractionAnswered echo failed: ${describeError(error)}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* If the incoming text looks like an option number (e.g. "1", "2.", "③"),
|
|
634
|
+
* resolve it against the current question's options. Otherwise return null
|
|
635
|
+
* and let the freeform path take over.
|
|
636
|
+
*/
|
|
637
|
+
parseHumanLoopOptionPick(text, prompt) {
|
|
638
|
+
const question = currentHumanLoopQuestion(prompt);
|
|
639
|
+
if (!question?.options?.length)
|
|
640
|
+
return null;
|
|
641
|
+
const trimmed = text.replace(/[.。、)]\s*$/, '').trim();
|
|
642
|
+
const match = trimmed.match(/^(\d+)$/);
|
|
643
|
+
if (!match)
|
|
644
|
+
return null;
|
|
645
|
+
const idx = parseInt(match[1], 10);
|
|
646
|
+
if (!Number.isFinite(idx) || idx < 1 || idx > question.options.length)
|
|
647
|
+
return null;
|
|
648
|
+
const opt = question.options[idx - 1];
|
|
649
|
+
return opt?.value || opt?.label || null;
|
|
650
|
+
}
|
|
651
|
+
// ---- Command-UI adapter (numbered-text fallback for non-card IMs) -------
|
|
652
|
+
//
|
|
653
|
+
// Telegram / Feishu render `CommandSelectionView` as tap-able button cards.
|
|
654
|
+
// WeChat (personal accounts) can't render cards, so we flatten each view's
|
|
655
|
+
// button rows into a numbered text prompt and let the user reply with a
|
|
656
|
+
// digit. Re-uses the SAME `executeCommandAction` data layer — no parallel
|
|
657
|
+
// logic — so `/agents` / `/models` / `/sessions` / `/skills` / `/mode`
|
|
658
|
+
// behave identically across channels, just rendered differently.
|
|
659
|
+
decorateCommandButtonLabel(button) {
|
|
660
|
+
let label = button.label.trim();
|
|
661
|
+
if (button.state === 'current' || button.primary)
|
|
662
|
+
label += ' ✓';
|
|
663
|
+
if (button.state === 'running')
|
|
664
|
+
label += ' [running]';
|
|
665
|
+
if (button.state === 'unavailable')
|
|
666
|
+
label += ' [n/a]';
|
|
667
|
+
return label;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Render a `CommandSelectionView` as plain WeChat text: title + meta lines +
|
|
671
|
+
* detailed item list (when present) + numbered button list. Always ends with
|
|
672
|
+
* "回复编号选择" so the user knows what to do next.
|
|
673
|
+
*/
|
|
674
|
+
formatCommandViewText(view, buttons) {
|
|
675
|
+
const lines = [];
|
|
676
|
+
lines.push(`【${view.title}】`);
|
|
677
|
+
if (view.detail)
|
|
678
|
+
lines.push(view.detail);
|
|
679
|
+
for (const meta of view.metaLines)
|
|
680
|
+
lines.push(meta);
|
|
681
|
+
if (view.items?.length) {
|
|
682
|
+
lines.push('');
|
|
683
|
+
for (const item of view.items) {
|
|
684
|
+
const marker = item.state === 'current' ? '✓' : item.state === 'running' ? '⟳' : ' ';
|
|
685
|
+
lines.push(`${marker} ${item.label}`);
|
|
686
|
+
if (item.detail)
|
|
687
|
+
lines.push(` ${item.detail}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (buttons.length) {
|
|
691
|
+
lines.push('');
|
|
692
|
+
lines.push('━━━━━━');
|
|
693
|
+
buttons.forEach((b, i) => lines.push(`${i + 1}. ${this.decorateCommandButtonLabel(b)}`));
|
|
694
|
+
lines.push('', '回复编号选择,或回复 /cancel 取消');
|
|
695
|
+
}
|
|
696
|
+
else if (view.emptyText) {
|
|
697
|
+
lines.push('', view.emptyText);
|
|
698
|
+
}
|
|
699
|
+
else if (view.helperText) {
|
|
700
|
+
lines.push('', view.helperText);
|
|
701
|
+
}
|
|
702
|
+
return lines.join('\n');
|
|
703
|
+
}
|
|
704
|
+
formatCommandNotice(notice) {
|
|
705
|
+
const parts = [`【${notice.title}】`];
|
|
706
|
+
if (notice.value)
|
|
707
|
+
parts.push(notice.value);
|
|
708
|
+
if (notice.detail)
|
|
709
|
+
parts.push(notice.detail);
|
|
710
|
+
return parts.join('\n');
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Open one prompt for a `CommandSelectionView` and resolve once the user
|
|
714
|
+
* picks an option (or cancels). The picked CommandAction is executed via the
|
|
715
|
+
* shared `executeCommandAction` so the side effects (state mutations,
|
|
716
|
+
* notices) match every other channel exactly.
|
|
717
|
+
*/
|
|
718
|
+
async promptCommandView(ctx, view) {
|
|
719
|
+
const buttons = view.rows.flat();
|
|
720
|
+
if (!buttons.length) {
|
|
721
|
+
await ctx.reply(this.formatCommandViewText(view, []));
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
const taskId = `wxcmd-${Date.now().toString(36)}`;
|
|
725
|
+
const promptText = this.formatCommandViewText(view, buttons);
|
|
726
|
+
const options = buttons.map(button => ({
|
|
727
|
+
label: this.decorateCommandButtonLabel(button),
|
|
728
|
+
description: null,
|
|
729
|
+
value: encodeCommandAction(button.action),
|
|
730
|
+
}));
|
|
731
|
+
return new Promise((resolve) => {
|
|
732
|
+
const active = this.beginHumanLoopPrompt({
|
|
733
|
+
taskId,
|
|
734
|
+
chatId: ctx.chatId,
|
|
735
|
+
title: view.title,
|
|
736
|
+
hint: view.helperText || null,
|
|
737
|
+
questions: [{
|
|
738
|
+
id: 'pick',
|
|
739
|
+
header: view.title,
|
|
740
|
+
prompt: promptText,
|
|
741
|
+
options,
|
|
742
|
+
allowFreeform: false,
|
|
743
|
+
}],
|
|
744
|
+
silent: true,
|
|
745
|
+
resolveWith: (answers) => {
|
|
746
|
+
const picked = answers['pick']?.[0];
|
|
747
|
+
if (!picked)
|
|
748
|
+
return null;
|
|
749
|
+
const action = decodeCommandAction(picked);
|
|
750
|
+
return action ? { action } : null;
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
void this.channel.send(ctx.chatId, promptText)
|
|
754
|
+
.catch(err => this.log(`weixin command UI send failed: ${describeError(err)}`));
|
|
755
|
+
active.result
|
|
756
|
+
.then(async (resolved) => {
|
|
757
|
+
const action = resolved?.action ?? null;
|
|
758
|
+
if (!action) {
|
|
759
|
+
resolve(null);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
const result = await executeCommandAction(this, ctx.chatId, action);
|
|
764
|
+
resolve(result);
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
this.log(`weixin executeCommandAction failed: ${describeError(err)}`);
|
|
768
|
+
resolve(null);
|
|
769
|
+
}
|
|
770
|
+
})
|
|
771
|
+
.catch(() => resolve(null));
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Multi-step command flow driver. Some views (notably `/models`'s "select
|
|
776
|
+
* model → set effort → Apply") return another `CommandSelectionView` from
|
|
777
|
+
* `executeCommandAction` rather than a terminal notice; this loop keeps
|
|
778
|
+
* re-prompting until we hit a notice / skill / noop / cancellation. Capped
|
|
779
|
+
* at 12 iterations to guard against pathological loops.
|
|
780
|
+
*/
|
|
781
|
+
async runCommandUiLoop(ctx, viewBuilder) {
|
|
782
|
+
let view = await Promise.resolve(viewBuilder());
|
|
783
|
+
let safety = 12;
|
|
784
|
+
while (view && safety-- > 0) {
|
|
785
|
+
const result = await this.promptCommandView(ctx, view);
|
|
786
|
+
if (!result)
|
|
787
|
+
return;
|
|
788
|
+
switch (result.kind) {
|
|
789
|
+
case 'view':
|
|
790
|
+
view = result.view;
|
|
791
|
+
continue;
|
|
792
|
+
case 'notice':
|
|
793
|
+
await ctx.reply(this.formatCommandNotice(result.notice));
|
|
794
|
+
return;
|
|
795
|
+
case 'skill':
|
|
796
|
+
// Dispatch the resolved skill prompt back through the regular task
|
|
797
|
+
// pipeline (same machinery as a typed message), so the agent picks
|
|
798
|
+
// it up with the right session + workspace.
|
|
799
|
+
await ctx.reply(`Running /${result.skillName}…`);
|
|
800
|
+
await this.dispatchUserPrompt(ctx, result.prompt, []);
|
|
801
|
+
return;
|
|
802
|
+
case 'noop':
|
|
803
|
+
await ctx.reply(result.message || '(no change)');
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (safety <= 0)
|
|
808
|
+
await ctx.reply('Command UI loop terminated (too many steps).');
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Submit a user-authored prompt through the same path `handleMessage` uses
|
|
812
|
+
* for natural-language messages. Lets the command-UI loop forward a resolved
|
|
813
|
+
* `/skill` expansion as if the user had typed it.
|
|
814
|
+
*/
|
|
815
|
+
async dispatchUserPrompt(ctx, text, files) {
|
|
816
|
+
const session = this.resolveSession(ctx.chatId, text, files);
|
|
817
|
+
const prompt = buildPrompt(text, files);
|
|
818
|
+
const taskId = buildSessionTaskId(session, this.nextTaskId++);
|
|
819
|
+
this.beginTask({
|
|
820
|
+
taskId,
|
|
821
|
+
chatId: ctx.chatId,
|
|
822
|
+
agent: session.agent,
|
|
823
|
+
sessionKey: session.key,
|
|
824
|
+
prompt,
|
|
825
|
+
startedAt: Date.now(),
|
|
826
|
+
sourceMessageId: ctx.messageId,
|
|
827
|
+
});
|
|
828
|
+
this.emitStreamQueued(session.key, taskId);
|
|
829
|
+
void this.queueSessionTask(session, async () => {
|
|
830
|
+
const abortController = new AbortController();
|
|
831
|
+
const task = this.markTaskRunning(taskId, () => abortController.abort());
|
|
832
|
+
if (task?.cancelled) {
|
|
833
|
+
this.emitStreamCancelled(taskId, session.key);
|
|
834
|
+
this.finishTask(taskId);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
this.emitStreamStart(taskId, session);
|
|
838
|
+
try {
|
|
839
|
+
const result = await this.runStream(prompt, session, files, (t, th, act, meta, plan) => {
|
|
840
|
+
this.emitStreamText(taskId, session.key, t, th, act, meta, plan);
|
|
841
|
+
}, undefined, this.createMcpSendFile(ctx.chatId), abortController.signal, this.createInteractionHandler(ctx.chatId, taskId));
|
|
842
|
+
this.emitStreamDone(taskId, session.key, {
|
|
843
|
+
sessionId: result.sessionId || session.sessionId,
|
|
844
|
+
incomplete: !!result.incomplete,
|
|
845
|
+
...(result.ok ? {} : { error: result.error || result.message }),
|
|
846
|
+
});
|
|
847
|
+
await this.sendResult(ctx.chatId, result);
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
this.emitStreamDone(taskId, session.key, {
|
|
851
|
+
sessionId: session.sessionId,
|
|
852
|
+
incomplete: true,
|
|
853
|
+
error: describeError(error),
|
|
854
|
+
});
|
|
855
|
+
await ctx.reply(`Error: ${describeError(error)}`);
|
|
856
|
+
}
|
|
857
|
+
finally {
|
|
858
|
+
this.finishTask(taskId);
|
|
859
|
+
this.syncSelectedChats(session);
|
|
860
|
+
}
|
|
861
|
+
}, taskId).catch(error => {
|
|
862
|
+
this.finishTask(taskId);
|
|
863
|
+
this.log(`weixin queue execution failed: ${describeError(error)}`);
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
async handleMessage(msg, ctx) {
|
|
867
|
+
const text = msg.text.trim();
|
|
868
|
+
if (text.startsWith('/') && await this.handleCommand(text, ctx))
|
|
869
|
+
return;
|
|
870
|
+
if (!text && !msg.files.length) {
|
|
871
|
+
await ctx.reply('This Weixin channel currently supports text input only.');
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
// Active human-in-the-loop prompt takes priority — text-only replies are
|
|
875
|
+
// routed back to the pending question (option-number pick OR freeform).
|
|
876
|
+
const pendingPrompt = this.pendingHumanLoopPrompt(ctx.chatId);
|
|
877
|
+
if (pendingPrompt && text && !msg.files.length) {
|
|
878
|
+
const optionValue = this.parseHumanLoopOptionPick(text, pendingPrompt);
|
|
879
|
+
const result = optionValue
|
|
880
|
+
? this.humanLoopSelectOption(pendingPrompt.promptId, optionValue)
|
|
881
|
+
: this.humanLoopSubmitText(ctx.chatId, text);
|
|
882
|
+
if (!result) {
|
|
883
|
+
await ctx.reply('Could not record that answer. Please retry or wait for the agent.');
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (result.completed) {
|
|
887
|
+
// Closing message comes from onInteractionAnswered — no extra reply.
|
|
888
|
+
}
|
|
889
|
+
else if (result.advanced) {
|
|
890
|
+
const next = this.humanLoopPrompt(pendingPrompt.promptId);
|
|
891
|
+
if (next)
|
|
892
|
+
await this.channel.send(ctx.chatId, this.formatHumanLoopPromptText(next));
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
await ctx.reply('Answer recorded.');
|
|
896
|
+
}
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const session = this.resolveSession(ctx.chatId, text, msg.files);
|
|
900
|
+
const prompt = buildPrompt(text, msg.files);
|
|
901
|
+
const taskId = buildSessionTaskId(session, this.nextTaskId++);
|
|
902
|
+
this.beginTask({
|
|
903
|
+
taskId,
|
|
904
|
+
chatId: ctx.chatId,
|
|
905
|
+
agent: session.agent,
|
|
906
|
+
sessionKey: session.key,
|
|
907
|
+
prompt,
|
|
908
|
+
startedAt: Date.now(),
|
|
909
|
+
sourceMessageId: ctx.messageId,
|
|
910
|
+
});
|
|
911
|
+
this.emitStreamQueued(session.key, taskId);
|
|
912
|
+
void this.queueSessionTask(session, async () => {
|
|
913
|
+
const abortController = new AbortController();
|
|
914
|
+
const task = this.markTaskRunning(taskId, () => abortController.abort());
|
|
915
|
+
if (task?.cancelled) {
|
|
916
|
+
this.emitStreamCancelled(taskId, session.key);
|
|
917
|
+
this.finishTask(taskId);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
this.emitStreamStart(taskId, session);
|
|
921
|
+
let typingTimer = null;
|
|
922
|
+
try {
|
|
923
|
+
await ctx.sendTyping().catch(() => { });
|
|
924
|
+
typingTimer = setInterval(() => {
|
|
925
|
+
void ctx.sendTyping().catch(() => { });
|
|
926
|
+
}, 4_000);
|
|
927
|
+
typingTimer.unref?.();
|
|
928
|
+
const result = await this.runStream(prompt, session, msg.files, (t, th, act, meta, plan) => {
|
|
929
|
+
this.emitStreamText(taskId, session.key, t, th, act, meta, plan);
|
|
930
|
+
}, undefined, this.createMcpSendFile(ctx.chatId), abortController.signal, this.createInteractionHandler(ctx.chatId, taskId));
|
|
931
|
+
this.emitStreamDone(taskId, session.key, {
|
|
932
|
+
sessionId: result.sessionId || session.sessionId,
|
|
933
|
+
incomplete: !!result.incomplete,
|
|
934
|
+
...(result.ok ? {} : { error: result.error || result.message }),
|
|
935
|
+
});
|
|
936
|
+
await this.sendResult(ctx.chatId, result);
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
this.emitStreamDone(taskId, session.key, {
|
|
940
|
+
sessionId: session.sessionId,
|
|
941
|
+
incomplete: true,
|
|
942
|
+
error: describeError(error),
|
|
943
|
+
});
|
|
944
|
+
await ctx.reply(`Error: ${describeError(error)}`);
|
|
945
|
+
}
|
|
946
|
+
finally {
|
|
947
|
+
if (typingTimer)
|
|
948
|
+
clearInterval(typingTimer);
|
|
949
|
+
this.finishTask(taskId);
|
|
950
|
+
this.syncSelectedChats(session);
|
|
951
|
+
}
|
|
952
|
+
}, taskId).catch(error => {
|
|
953
|
+
this.finishTask(taskId);
|
|
954
|
+
this.log(`weixin queue execution failed: ${describeError(error)}`);
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
async run() {
|
|
958
|
+
const tmpDir = path.join(os.tmpdir(), 'pikiloop');
|
|
959
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
960
|
+
this.channel = new WeixinChannel({
|
|
961
|
+
token: this.botToken,
|
|
962
|
+
accountId: this.accountId,
|
|
963
|
+
baseUrl: this.baseUrl,
|
|
964
|
+
allowedChatIds: this.allowedChatIds.size ? new Set([...this.allowedChatIds].map(value => String(value))) : undefined,
|
|
965
|
+
});
|
|
966
|
+
this.processRuntimeCleanup?.();
|
|
967
|
+
this.processRuntimeCleanup = registerProcessRuntime({
|
|
968
|
+
label: 'weixin',
|
|
969
|
+
getActiveTaskCount: () => this.activeTasks.size,
|
|
970
|
+
prepareForRestart: () => this.cleanupRuntimeForExit(),
|
|
971
|
+
});
|
|
972
|
+
this.installSignalHandlers();
|
|
973
|
+
try {
|
|
974
|
+
const bot = await this.channel.connect();
|
|
975
|
+
this.connected = true;
|
|
976
|
+
this.log(`bot: ${bot.displayName} (id=${bot.id})`);
|
|
977
|
+
for (const agent of this.fetchAgents().agents) {
|
|
978
|
+
this.log(`agent ${agent.agent}: ${agent.path || 'NOT FOUND'}`);
|
|
979
|
+
}
|
|
980
|
+
this.log(`config: agent=${this.defaultAgent} workdir=${this.workdir} timeout=${this.runTimeout}s`);
|
|
981
|
+
this.channel.onMessage((msg, ctx) => this.handleMessage(msg, ctx));
|
|
982
|
+
this.channel.onError(error => this.log(`error: ${describeError(error)}`, 'warn'));
|
|
983
|
+
this.channel.onLog((msg, level) => this.log(msg, level));
|
|
984
|
+
this.startKeepAlive();
|
|
985
|
+
this.log('✓ Weixin connected, long-polling started — ready to receive messages');
|
|
986
|
+
await this.channel.listen();
|
|
987
|
+
this.stopKeepAlive();
|
|
988
|
+
this.log('stopped');
|
|
989
|
+
}
|
|
990
|
+
finally {
|
|
991
|
+
this.stopKeepAlive();
|
|
992
|
+
this.clearShutdownForceExitTimer();
|
|
993
|
+
this.removeSignalHandlers();
|
|
994
|
+
this.processRuntimeCleanup?.();
|
|
995
|
+
this.processRuntimeCleanup = null;
|
|
996
|
+
if (this.shutdownInFlight)
|
|
997
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|