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,1275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bot-feishu.ts — Feishu bot orchestration: commands, streaming, artifacts, lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Follows the same pattern as bot-telegram.ts:
|
|
5
|
+
* - Commands use shared data layer (bot-commands.ts) + Feishu renderer
|
|
6
|
+
* - Messages flow through the streaming pipeline
|
|
7
|
+
* - LivePreview provides real-time streaming updates via card edits
|
|
8
|
+
*/
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { Bot, normalizeAgent, fmtTokens, buildPrompt, parseAllowedChatIds, } from '../../bot/bot.js';
|
|
13
|
+
import { BOT_SHUTDOWN_FORCE_EXIT_MS, SessionMessageRegistry, buildBotMenuState, buildKnownChatEnv, buildSessionTaskId, } from '../../bot/orchestration.js';
|
|
14
|
+
import { stageSessionFiles, } from '../../agent/index.js';
|
|
15
|
+
import { shutdownAllDrivers } from '../../agent/driver.js';
|
|
16
|
+
import { expandTilde } from '../../core/platform.js';
|
|
17
|
+
import { SKILL_CMD_PREFIX, } from '../../bot/menu.js';
|
|
18
|
+
import { getStartData, getSessionsPageData, getModelsListData, getSessionTurnPreviewData, getStatusDataAsync, getHostDataSync, getWorkspacesData, resolveSkillPrompt, handleGoalCommand, } from '../../bot/commands.js';
|
|
19
|
+
import { buildAgentsCommandView, buildModelsCommandView, buildModeCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, executeCommandAction, } from '../../bot/command-ui.js';
|
|
20
|
+
import { LivePreview } from '../telegram/live-preview.js';
|
|
21
|
+
import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
|
|
22
|
+
import { feishuPreviewRenderer, buildInitialPreviewMarkdown, buildHumanLoopPromptMarkdown, buildAnsweredHumanLoopPromptMarkdown, buildFinalReplyRender, dispatchImageBlocks, renderCommandNotice, renderCommandSelectionCard, renderSessionTurnMarkdown, renderStart, renderStatus, renderHost, buildSwitchWorkdirCard, buildWorkspacesCard, resolveFeishuRegisteredPath, } from './render.js';
|
|
23
|
+
import { currentHumanLoopQuestion, humanLoopOptionSelected } from '../../bot/human-loop.js';
|
|
24
|
+
import { FeishuChannel } from './channel.js';
|
|
25
|
+
import { splitText, supportsChannelCapability } from '../base.js';
|
|
26
|
+
import { getActiveUserConfig, loadKnownChatIds } from '../../core/config/user-config.js';
|
|
27
|
+
import { FEISHU_BOT_CARD_MAX } from '../../core/constants.js';
|
|
28
|
+
const SHUTDOWN_EXIT_CODE = {
|
|
29
|
+
SIGINT: 130,
|
|
30
|
+
SIGTERM: 143,
|
|
31
|
+
};
|
|
32
|
+
const FEISHU_FILE_STAGE_REACTION = 'Get';
|
|
33
|
+
function describeError(err) {
|
|
34
|
+
if (!(err instanceof Error))
|
|
35
|
+
return String(err ?? 'unknown error');
|
|
36
|
+
const parts = [`${err.name}: ${err.message}`];
|
|
37
|
+
for (const key of ['code', 'errno', 'syscall', 'address', 'port', 'host', 'hostname', 'path']) {
|
|
38
|
+
const value = err?.[key];
|
|
39
|
+
if (value != null && value !== '')
|
|
40
|
+
parts.push(`${key}=${value}`);
|
|
41
|
+
}
|
|
42
|
+
const cause = err?.cause;
|
|
43
|
+
if (cause && cause !== err)
|
|
44
|
+
parts.push(`cause=${describeError(cause)}`);
|
|
45
|
+
return parts.join(' | ');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decision echo rendered as a separate Feishu message after a human-loop
|
|
49
|
+
* prompt resolves — so the answer becomes part of the conversation history
|
|
50
|
+
* rather than living only inside the now-closed card.
|
|
51
|
+
*/
|
|
52
|
+
function buildInteractionEchoMarkdown(summary) {
|
|
53
|
+
if (summary.status === 'cancelled')
|
|
54
|
+
return '*⊘ Prompt cancelled.*';
|
|
55
|
+
if (!summary.rows.length)
|
|
56
|
+
return null;
|
|
57
|
+
if (summary.rows.length === 1) {
|
|
58
|
+
return `**✓ Answered** · ${summary.rows[0].display}`;
|
|
59
|
+
}
|
|
60
|
+
const lines = ['**✓ Answered**'];
|
|
61
|
+
for (const row of summary.rows) {
|
|
62
|
+
lines.push(`• ${row.label}: ${row.display}`);
|
|
63
|
+
}
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
function formatToolLog(activity) {
|
|
67
|
+
const lines = String(activity || '')
|
|
68
|
+
.split('\n')
|
|
69
|
+
.map(line => line.trim())
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.filter(line => (/^Read\b/.test(line)
|
|
72
|
+
|| /^Edit\b/.test(line)
|
|
73
|
+
|| /^Write\b/.test(line)
|
|
74
|
+
|| /^List files\b/.test(line)
|
|
75
|
+
|| /^Search text\b/.test(line)
|
|
76
|
+
|| /^Fetch\b/.test(line)
|
|
77
|
+
|| /^Search web\b/.test(line)
|
|
78
|
+
|| /^Update plan\b/.test(line)
|
|
79
|
+
|| /^Run task\b/.test(line)
|
|
80
|
+
|| /^Run shell\b/.test(line)
|
|
81
|
+
|| /^Use\b/.test(line)
|
|
82
|
+
|| /^Using\b/.test(line)
|
|
83
|
+
|| /^Updated\b/.test(line)
|
|
84
|
+
|| /^Inspect image\b/.test(line)
|
|
85
|
+
|| /^Request user input\b/.test(line)
|
|
86
|
+
|| /^Run multiple tools\b/.test(line)
|
|
87
|
+
|| /\bdone\b/.test(line)
|
|
88
|
+
|| /\bfailed\b/.test(line)));
|
|
89
|
+
const deduped = [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
if (seen.has(line))
|
|
93
|
+
continue;
|
|
94
|
+
seen.add(line);
|
|
95
|
+
deduped.push(line);
|
|
96
|
+
}
|
|
97
|
+
if (!deduped.length)
|
|
98
|
+
return '-';
|
|
99
|
+
const summary = deduped.slice(0, 6).join(' | ');
|
|
100
|
+
return summary.length <= 240 ? summary : `${summary.slice(0, 237).trimEnd()}...`;
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// FeishuBot
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
export class FeishuBot extends Bot {
|
|
106
|
+
appId;
|
|
107
|
+
appSecret;
|
|
108
|
+
domain;
|
|
109
|
+
channel;
|
|
110
|
+
/** Maps chatId → (messageId → sessionKey) for reply-chain session tracking. */
|
|
111
|
+
sessionMessages = new SessionMessageRegistry();
|
|
112
|
+
nextTaskId = 1;
|
|
113
|
+
shutdownInFlight = false;
|
|
114
|
+
shutdownExitCode = null;
|
|
115
|
+
shutdownForceExitTimer = null;
|
|
116
|
+
signalHandlers = {};
|
|
117
|
+
processRuntimeCleanup = null;
|
|
118
|
+
constructor() {
|
|
119
|
+
super();
|
|
120
|
+
const config = getActiveUserConfig();
|
|
121
|
+
// Merge Feishu-specific allowed IDs into base
|
|
122
|
+
if (process.env.FEISHU_ALLOWED_CHAT_IDS) {
|
|
123
|
+
for (const id of parseAllowedChatIds(process.env.FEISHU_ALLOWED_CHAT_IDS))
|
|
124
|
+
this.allowedChatIds.add(id);
|
|
125
|
+
}
|
|
126
|
+
// NOTE: persisted known chats are restored into channel.knownChats in run()
|
|
127
|
+
// (for the startup greeting / per-chat menu) — deliberately NOT into
|
|
128
|
+
// allowedChatIds. allowedChatIds is the explicit allowlist; folding known
|
|
129
|
+
// chats into it flips _isAllowed() from allow-all into allowlist-only and
|
|
130
|
+
// silently drops every new chat.
|
|
131
|
+
this.appId = String(config.feishuAppId || process.env.FEISHU_APP_ID || '').trim();
|
|
132
|
+
this.appSecret = String(config.feishuAppSecret || process.env.FEISHU_APP_SECRET || '').trim();
|
|
133
|
+
this.domain = (process.env.FEISHU_DOMAIN || 'https://open.feishu.cn').trim();
|
|
134
|
+
if (!this.appId || !this.appSecret) {
|
|
135
|
+
throw new Error('Missing Feishu credentials. Set FEISHU_APP_ID and FEISHU_APP_SECRET');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
requestStop() {
|
|
139
|
+
super.requestStop();
|
|
140
|
+
try {
|
|
141
|
+
this.channel?.disconnect();
|
|
142
|
+
}
|
|
143
|
+
catch { }
|
|
144
|
+
}
|
|
145
|
+
onManagedConfigChange(config, opts = {}) {
|
|
146
|
+
const nextAppId = String(config.feishuAppId || process.env.FEISHU_APP_ID || '').trim();
|
|
147
|
+
const nextAppSecret = String(config.feishuAppSecret || process.env.FEISHU_APP_SECRET || '').trim();
|
|
148
|
+
if (nextAppId && nextAppId !== this.appId) {
|
|
149
|
+
this.appId = nextAppId;
|
|
150
|
+
if (!opts.initial)
|
|
151
|
+
this.log('feishu appId reloaded from setting.json');
|
|
152
|
+
}
|
|
153
|
+
if (nextAppSecret && nextAppSecret !== this.appSecret) {
|
|
154
|
+
this.appSecret = nextAppSecret;
|
|
155
|
+
if (!opts.initial)
|
|
156
|
+
this.log('feishu appSecret reloaded from setting.json');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
static SKILL_CMD_PREFIX = SKILL_CMD_PREFIX;
|
|
160
|
+
async setupMenu() {
|
|
161
|
+
if (!supportsChannelCapability(this.channel, 'commandMenu'))
|
|
162
|
+
return;
|
|
163
|
+
const { commands, skillCount } = buildBotMenuState(this);
|
|
164
|
+
await this.channel.setMenu(commands);
|
|
165
|
+
this.log(`menu: ${commands.length} commands (${skillCount} skills)`);
|
|
166
|
+
}
|
|
167
|
+
afterSwitchWorkdir(_oldPath, _newPath) {
|
|
168
|
+
if (!this.channel)
|
|
169
|
+
return;
|
|
170
|
+
void this.setupMenu().catch(err => this.log(`menu refresh failed: ${err}`));
|
|
171
|
+
}
|
|
172
|
+
// ---- signal handling ------------------------------------------------------
|
|
173
|
+
installSignalHandlers() {
|
|
174
|
+
this.removeSignalHandlers();
|
|
175
|
+
const onSigint = () => this.beginShutdown('SIGINT');
|
|
176
|
+
const onSigterm = () => this.beginShutdown('SIGTERM');
|
|
177
|
+
this.signalHandlers = { SIGINT: onSigint, SIGTERM: onSigterm };
|
|
178
|
+
process.once('SIGINT', onSigint);
|
|
179
|
+
process.once('SIGTERM', onSigterm);
|
|
180
|
+
}
|
|
181
|
+
removeSignalHandlers() {
|
|
182
|
+
for (const sig of Object.keys(this.signalHandlers)) {
|
|
183
|
+
const handler = this.signalHandlers[sig];
|
|
184
|
+
if (handler)
|
|
185
|
+
process.off(sig, handler);
|
|
186
|
+
}
|
|
187
|
+
this.signalHandlers = {};
|
|
188
|
+
}
|
|
189
|
+
beginShutdown(sig) {
|
|
190
|
+
if (this.shutdownInFlight)
|
|
191
|
+
return;
|
|
192
|
+
this.shutdownInFlight = true;
|
|
193
|
+
this.shutdownExitCode = SHUTDOWN_EXIT_CODE[sig];
|
|
194
|
+
this.log(`${sig}, shutting down...`);
|
|
195
|
+
this.cleanupRuntimeForExit();
|
|
196
|
+
if (this.shutdownForceExitTimer)
|
|
197
|
+
clearTimeout(this.shutdownForceExitTimer);
|
|
198
|
+
this.shutdownForceExitTimer = setTimeout(() => {
|
|
199
|
+
this.log(`shutdown still pending after ${Math.floor(BOT_SHUTDOWN_FORCE_EXIT_MS / 1000)}s, forcing exit`);
|
|
200
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
201
|
+
}, BOT_SHUTDOWN_FORCE_EXIT_MS);
|
|
202
|
+
this.shutdownForceExitTimer.unref?.();
|
|
203
|
+
}
|
|
204
|
+
cleanupRuntimeForExit() {
|
|
205
|
+
try {
|
|
206
|
+
this.channel.disconnect();
|
|
207
|
+
}
|
|
208
|
+
catch { }
|
|
209
|
+
this.stopKeepAlive();
|
|
210
|
+
shutdownAllDrivers();
|
|
211
|
+
}
|
|
212
|
+
buildRestartEnv() {
|
|
213
|
+
// Hand off only the explicit allowlist. Known chats persist to setting.json
|
|
214
|
+
// (recordKnownChatId) and are restored via loadKnownChatIds, so they must NOT
|
|
215
|
+
// ride along in the allowlist env — that would re-pollute allowedChatIds on
|
|
216
|
+
// the next boot and lock out new chats.
|
|
217
|
+
return buildKnownChatEnv(this.allowedChatIds, [], 'FEISHU_ALLOWED_CHAT_IDS');
|
|
218
|
+
}
|
|
219
|
+
// ---- session tracking -----------------------------------------------------
|
|
220
|
+
createTaskId(session) {
|
|
221
|
+
return buildSessionTaskId(session, this.nextTaskId++);
|
|
222
|
+
}
|
|
223
|
+
registerSessionMessage(chatId, messageId, session) {
|
|
224
|
+
this.sessionMessages.register(chatId, messageId, session, session.workdir);
|
|
225
|
+
}
|
|
226
|
+
registerSessionMessages(chatId, messageIds, session) {
|
|
227
|
+
this.sessionMessages.registerMany(chatId, messageIds, session, session.workdir);
|
|
228
|
+
}
|
|
229
|
+
sessionFromMessage(chatId, messageId) {
|
|
230
|
+
const sessionRef = this.sessionMessages.resolve(chatId, messageId);
|
|
231
|
+
if (!sessionRef)
|
|
232
|
+
return null;
|
|
233
|
+
return this.getSessionRuntimeByKey(sessionRef.key, { allowAnyWorkdir: true })
|
|
234
|
+
|| this.hydrateSessionRuntime(sessionRef);
|
|
235
|
+
}
|
|
236
|
+
ensureSession(chatId, title, files) {
|
|
237
|
+
return this.ensureSessionForChat(chatId, title, files);
|
|
238
|
+
}
|
|
239
|
+
resolveIncomingSession(ctx, text, files) {
|
|
240
|
+
const cs = this.chat(ctx.chatId);
|
|
241
|
+
const replyMessageId = ctx.replyToMessageId || null;
|
|
242
|
+
const repliedSession = this.sessionFromMessage(ctx.chatId, replyMessageId);
|
|
243
|
+
if (repliedSession) {
|
|
244
|
+
this.log(`[resolveSession] reply matched session=${repliedSession.sessionId} chat=${ctx.chatId}`);
|
|
245
|
+
this.applySessionSelection(cs, repliedSession);
|
|
246
|
+
return repliedSession;
|
|
247
|
+
}
|
|
248
|
+
const selected = this.getSelectedSession(cs);
|
|
249
|
+
if (selected)
|
|
250
|
+
return selected;
|
|
251
|
+
return this.ensureSession(ctx.chatId, text, files);
|
|
252
|
+
}
|
|
253
|
+
// ---- commands -------------------------------------------------------------
|
|
254
|
+
async cmdStart(ctx) {
|
|
255
|
+
const d = getStartData(this, ctx.chatId);
|
|
256
|
+
await ctx.reply(renderStart(d));
|
|
257
|
+
}
|
|
258
|
+
async cmdSkills(ctx) {
|
|
259
|
+
await this.sendCommandView(ctx, buildSkillsCommandView(this, ctx.chatId));
|
|
260
|
+
}
|
|
261
|
+
async sendCommandView(ctx, view) {
|
|
262
|
+
await ctx.channel.sendCard(ctx.chatId, renderCommandSelectionCard(view));
|
|
263
|
+
}
|
|
264
|
+
async replyCommandResult(ctx, result) {
|
|
265
|
+
if (result.kind === 'view') {
|
|
266
|
+
await this.sendCommandView(ctx, result.view);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (result.kind === 'skill') {
|
|
270
|
+
await this.handleMessage({ text: result.prompt, files: [] }, ctx);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (result.kind === 'notice') {
|
|
274
|
+
const sent = await ctx.reply(renderCommandNotice(result.notice));
|
|
275
|
+
if (result.session && sent)
|
|
276
|
+
this.registerSessionMessage(ctx.chatId, sent, result.session);
|
|
277
|
+
if (result.previewSession) {
|
|
278
|
+
await this.previewCurrentSessionTurn(ctx.chatId, result.previewSession.agent, result.previewSession.sessionId);
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
await ctx.reply(result.message);
|
|
283
|
+
}
|
|
284
|
+
async applyCommandCallbackResult(ctx, result) {
|
|
285
|
+
if (result.kind === 'noop')
|
|
286
|
+
return;
|
|
287
|
+
if (result.kind === 'view') {
|
|
288
|
+
await ctx.channel.editCard(ctx.chatId, ctx.messageId, renderCommandSelectionCard(result.view));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (result.kind === 'skill') {
|
|
292
|
+
await this.handleMessage({ text: result.prompt, files: [] }, this.callbackToMessageContext(ctx));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
await ctx.editReply(ctx.messageId, renderCommandNotice(result.notice));
|
|
296
|
+
if (result.session)
|
|
297
|
+
this.registerSessionMessage(ctx.chatId, ctx.messageId, result.session);
|
|
298
|
+
if (result.previewSession) {
|
|
299
|
+
await this.previewCurrentSessionTurn(ctx.chatId, result.previewSession.agent, result.previewSession.sessionId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
sessionsPageSize = 5;
|
|
303
|
+
buildStopKeyboard(actionId, opts) {
|
|
304
|
+
if (!actionId)
|
|
305
|
+
return undefined;
|
|
306
|
+
if (opts?.queued) {
|
|
307
|
+
return {
|
|
308
|
+
rows: [{
|
|
309
|
+
actions: [
|
|
310
|
+
{
|
|
311
|
+
tag: 'button',
|
|
312
|
+
text: { tag: 'plain_text', content: 'Recall' },
|
|
313
|
+
value: { action: `tsk:stop:${actionId}` },
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
tag: 'button',
|
|
317
|
+
text: { tag: 'plain_text', content: 'Steer' },
|
|
318
|
+
value: { action: `tsk:steer:${actionId}` },
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
}],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
rows: [{
|
|
326
|
+
actions: [{
|
|
327
|
+
tag: 'button',
|
|
328
|
+
text: { tag: 'plain_text', content: 'Stop' },
|
|
329
|
+
value: { action: `tsk:stop:${actionId}` },
|
|
330
|
+
}],
|
|
331
|
+
}],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
async cmdSessions(ctx, args) {
|
|
335
|
+
const arg = args.trim().toLowerCase();
|
|
336
|
+
if (arg === 'new') {
|
|
337
|
+
await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'session.new' }, { sessionsPageSize: this.sessionsPageSize }));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const pageMatch = arg.match(/^p(\d+)$/);
|
|
341
|
+
if (pageMatch) {
|
|
342
|
+
await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'sessions.page', page: parseInt(pageMatch[1], 10) - 1 }, { sessionsPageSize: this.sessionsPageSize }));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const idx = parseInt(arg, 10);
|
|
346
|
+
if (!isNaN(idx) && idx >= 1) {
|
|
347
|
+
const d = await getSessionsPageData(this, ctx.chatId, 0, 100);
|
|
348
|
+
const target = d.sessions[idx - 1];
|
|
349
|
+
if (target) {
|
|
350
|
+
await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'session.switch', sessionId: target.key }));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
await ctx.reply(`Session #${idx} not found.`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await this.sendCommandView(ctx, await buildSessionsCommandView(this, ctx.chatId, 0, this.sessionsPageSize));
|
|
357
|
+
}
|
|
358
|
+
async cmdStatus(ctx) {
|
|
359
|
+
const d = await getStatusDataAsync(this, ctx.chatId);
|
|
360
|
+
await ctx.reply(renderStatus(d));
|
|
361
|
+
}
|
|
362
|
+
async cmdHost(ctx) {
|
|
363
|
+
const d = getHostDataSync(this);
|
|
364
|
+
await ctx.reply(renderHost(d));
|
|
365
|
+
}
|
|
366
|
+
async cmdAgents(ctx, args) {
|
|
367
|
+
const arg = args.trim().toLowerCase();
|
|
368
|
+
if (arg) {
|
|
369
|
+
try {
|
|
370
|
+
const agent = normalizeAgent(arg);
|
|
371
|
+
await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'agent.switch', agent }));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// Not a valid agent name — show list
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
await this.sendCommandView(ctx, buildAgentsCommandView(this, ctx.chatId));
|
|
379
|
+
}
|
|
380
|
+
async cmdModels(ctx, args) {
|
|
381
|
+
const arg = args.trim();
|
|
382
|
+
if (arg) {
|
|
383
|
+
const d = await getModelsListData(this, ctx.chatId);
|
|
384
|
+
const idx = parseInt(arg, 10);
|
|
385
|
+
let modelId = null;
|
|
386
|
+
if (!isNaN(idx) && idx >= 1 && idx <= d.models.length) {
|
|
387
|
+
modelId = d.models[idx - 1].id;
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
const match = d.models.find(m => m.id === arg || m.alias === arg);
|
|
391
|
+
if (match)
|
|
392
|
+
modelId = match.id;
|
|
393
|
+
}
|
|
394
|
+
if (modelId) {
|
|
395
|
+
await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'model.switch', modelId }));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
await this.sendCommandView(ctx, await buildModelsCommandView(this, ctx.chatId));
|
|
400
|
+
}
|
|
401
|
+
async cmdMode(ctx) {
|
|
402
|
+
await this.sendCommandView(ctx, buildModeCommandView(this, ctx.chatId));
|
|
403
|
+
}
|
|
404
|
+
async cmdSwitch(ctx, args) {
|
|
405
|
+
const arg = args.trim();
|
|
406
|
+
if (arg) {
|
|
407
|
+
const resolvedPath = path.resolve(expandTilde(arg));
|
|
408
|
+
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) {
|
|
409
|
+
await ctx.reply(`Not a valid directory: \`${resolvedPath}\``);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const oldPath = this.switchWorkdir(resolvedPath);
|
|
413
|
+
await ctx.reply(`**Workdir switched**\n\n\`${oldPath}\`\n↓\n\`${resolvedPath}\``);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const wd = this.chatWorkdir(ctx.chatId);
|
|
417
|
+
const browsePath = path.dirname(wd);
|
|
418
|
+
const savedCount = getWorkspacesData(this, ctx.chatId).workspaces.length;
|
|
419
|
+
const view = buildSwitchWorkdirCard(wd, browsePath, 0, { savedWorkspaceCount: savedCount });
|
|
420
|
+
await ctx.channel.sendCard(ctx.chatId, view);
|
|
421
|
+
}
|
|
422
|
+
async cmdWorkspaces(ctx) {
|
|
423
|
+
const data = getWorkspacesData(this, ctx.chatId);
|
|
424
|
+
const view = buildWorkspacesCard(data);
|
|
425
|
+
await ctx.channel.sendCard(ctx.chatId, view);
|
|
426
|
+
}
|
|
427
|
+
async cmdRestart(ctx) {
|
|
428
|
+
await ctx.reply('**Restarting pikiloop...**\n\nPulling latest version. The bot will be back shortly.');
|
|
429
|
+
void requestProcessRestart({ log: msg => this.log(msg) });
|
|
430
|
+
}
|
|
431
|
+
async cmdGoal(ctx, args) {
|
|
432
|
+
const reply = await handleGoalCommand(this, ctx.chatId, args);
|
|
433
|
+
if (reply == null) {
|
|
434
|
+
await ctx.reply('No session selected. Use /sessions to pick one first.');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await ctx.reply(reply);
|
|
438
|
+
}
|
|
439
|
+
async cmdStop(ctx) {
|
|
440
|
+
const session = this.selectedSession(ctx.chatId);
|
|
441
|
+
if (!session) {
|
|
442
|
+
await ctx.reply('No active session to stop.');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const { interrupted, cancelledQueued } = this.stopTasksForSession(session.key);
|
|
446
|
+
if (!interrupted && cancelledQueued === 0) {
|
|
447
|
+
await ctx.reply('No running or queued work for the current session.');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const parts = [];
|
|
451
|
+
if (interrupted)
|
|
452
|
+
parts.push('interrupted the current run');
|
|
453
|
+
if (cancelledQueued > 0)
|
|
454
|
+
parts.push(`cancelled ${cancelledQueued} queued ${cancelledQueued === 1 ? 'task' : 'tasks'}`);
|
|
455
|
+
await ctx.reply(`Stopped current session: ${parts.join(', ')}.`);
|
|
456
|
+
}
|
|
457
|
+
buildHumanLoopKeyboard(promptId) {
|
|
458
|
+
const prompt = this.humanLoopPrompt(promptId);
|
|
459
|
+
const question = prompt ? currentHumanLoopQuestion(prompt) : null;
|
|
460
|
+
const rows = [];
|
|
461
|
+
for (let index = 0; index < (question?.options?.length || 0); index++) {
|
|
462
|
+
const option = question.options[index];
|
|
463
|
+
rows.push({
|
|
464
|
+
actions: [{
|
|
465
|
+
tag: 'button',
|
|
466
|
+
text: { tag: 'plain_text', content: `${humanLoopOptionSelected(prompt, option.value) ? '●' : '○'} ${option.label}`.slice(0, 32) },
|
|
467
|
+
value: { action: `hl:o:${promptId}:${index}` },
|
|
468
|
+
}],
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (question?.options?.length && question.allowFreeform) {
|
|
472
|
+
rows.push({
|
|
473
|
+
actions: [{
|
|
474
|
+
tag: 'button',
|
|
475
|
+
text: { tag: 'plain_text', content: 'Other...' },
|
|
476
|
+
value: { action: `hl:other:${promptId}` },
|
|
477
|
+
}],
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (question?.allowEmpty) {
|
|
481
|
+
rows.push({
|
|
482
|
+
actions: [{
|
|
483
|
+
tag: 'button',
|
|
484
|
+
text: { tag: 'plain_text', content: 'Skip' },
|
|
485
|
+
value: { action: `hl:skip:${promptId}` },
|
|
486
|
+
}],
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
rows.push({
|
|
490
|
+
actions: [{
|
|
491
|
+
tag: 'button',
|
|
492
|
+
text: { tag: 'plain_text', content: 'Cancel' },
|
|
493
|
+
value: { action: `hl:cancel:${promptId}` },
|
|
494
|
+
}],
|
|
495
|
+
});
|
|
496
|
+
return { rows };
|
|
497
|
+
}
|
|
498
|
+
async refreshHumanLoopPrompt(chatId, promptId) {
|
|
499
|
+
const prompt = this.humanLoopPrompt(promptId);
|
|
500
|
+
if (!prompt)
|
|
501
|
+
return;
|
|
502
|
+
const messageId = prompt.messageIds[0];
|
|
503
|
+
if (!messageId)
|
|
504
|
+
return;
|
|
505
|
+
await this.channel.editMessage(chatId, String(messageId), buildHumanLoopPromptMarkdown(prompt), {
|
|
506
|
+
keyboard: this.buildHumanLoopKeyboard(promptId),
|
|
507
|
+
}).catch(err => this.debug(`[human-loop] refresh edit failed chat=${chatId} prompt=${promptId}: ${err?.message || err}`));
|
|
508
|
+
}
|
|
509
|
+
async onInteractionAnswered(prompt, summary) {
|
|
510
|
+
const messageId = prompt.messageIds[0];
|
|
511
|
+
const chatId = prompt.chatId;
|
|
512
|
+
const closedMarkdown = buildAnsweredHumanLoopPromptMarkdown(prompt, summary);
|
|
513
|
+
if (messageId) {
|
|
514
|
+
try {
|
|
515
|
+
await this.channel.editMessage(chatId, String(messageId), closedMarkdown, {
|
|
516
|
+
keyboard: { rows: [] },
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
this.debug(`[human-loop] close-card edit failed chat=${chatId} prompt=${prompt.promptId}: ${err?.message || err}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const echoText = buildInteractionEchoMarkdown(summary);
|
|
524
|
+
if (!echoText)
|
|
525
|
+
return;
|
|
526
|
+
try {
|
|
527
|
+
await this.channel.send(chatId, echoText);
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
this.debug(`[human-loop] echo send failed chat=${chatId} prompt=${prompt.promptId}: ${err?.message || err}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* IM presenter for programmatic submissions (e.g. /goal-driven turns) so
|
|
535
|
+
* they stream to Feishu the same way a typed message does. Without this,
|
|
536
|
+
* /goal just emits a confirmation reply and the rest of the turn is
|
|
537
|
+
* invisible to the IM user.
|
|
538
|
+
*/
|
|
539
|
+
async createImTaskPresenter(opts) {
|
|
540
|
+
if (typeof opts.chatId !== 'string')
|
|
541
|
+
return null;
|
|
542
|
+
const chatId = opts.chatId;
|
|
543
|
+
const startTimeMs = Date.now();
|
|
544
|
+
const startConfig = this.resolveSessionStreamConfig(opts.session);
|
|
545
|
+
const canEditMessages = supportsChannelCapability(this.channel, 'editMessages');
|
|
546
|
+
let placeholderId = null;
|
|
547
|
+
try {
|
|
548
|
+
placeholderId = await this.channel.send(chatId, buildInitialPreviewMarkdown(opts.agent, startConfig.model, startConfig.effort, false));
|
|
549
|
+
if (placeholderId)
|
|
550
|
+
this.registerSessionMessage(chatId, placeholderId, opts.session);
|
|
551
|
+
}
|
|
552
|
+
catch (e) {
|
|
553
|
+
this.debug(`[im-presenter feishu] placeholder send failed chat=${chatId}: ${e?.message || e}`);
|
|
554
|
+
}
|
|
555
|
+
this.registerTaskPlaceholders(opts.taskId, [placeholderId]);
|
|
556
|
+
let livePreview = null;
|
|
557
|
+
if (placeholderId) {
|
|
558
|
+
livePreview = new LivePreview({
|
|
559
|
+
agent: opts.agent,
|
|
560
|
+
chatId,
|
|
561
|
+
placeholderMessageId: placeholderId,
|
|
562
|
+
channel: this.channel,
|
|
563
|
+
renderer: feishuPreviewRenderer,
|
|
564
|
+
streamEditIntervalMs: 700,
|
|
565
|
+
startTimeMs,
|
|
566
|
+
canEditMessages,
|
|
567
|
+
canSendTyping: false,
|
|
568
|
+
parseMode: 'Markdown',
|
|
569
|
+
model: startConfig.model,
|
|
570
|
+
effort: startConfig.effort,
|
|
571
|
+
log: (msg) => this.debug(`[live-preview task=${opts.taskId}] ${msg}`),
|
|
572
|
+
});
|
|
573
|
+
livePreview.start();
|
|
574
|
+
}
|
|
575
|
+
const session = opts.session;
|
|
576
|
+
return {
|
|
577
|
+
onText: (text, thinking, activity, meta, plan) => {
|
|
578
|
+
livePreview?.update(text, thinking, activity, meta, plan);
|
|
579
|
+
},
|
|
580
|
+
onSuccess: async (result) => {
|
|
581
|
+
try {
|
|
582
|
+
await livePreview?.settle();
|
|
583
|
+
}
|
|
584
|
+
catch { }
|
|
585
|
+
const effectivePlaceholderId = livePreview?.isPlaceholderAbandoned() ? null : placeholderId;
|
|
586
|
+
const finalReplyIds = await this.sendFinalReply({ chatId }, effectivePlaceholderId, opts.agent, result);
|
|
587
|
+
this.registerSessionMessages(chatId, finalReplyIds, session);
|
|
588
|
+
},
|
|
589
|
+
onFailure: async (error) => {
|
|
590
|
+
try {
|
|
591
|
+
await livePreview?.settle();
|
|
592
|
+
}
|
|
593
|
+
catch { }
|
|
594
|
+
const fallbackPlaceholderId = livePreview?.isPlaceholderAbandoned() ? null : placeholderId;
|
|
595
|
+
const errorText = `**Error**\n\n\`${error.slice(0, 500)}\``;
|
|
596
|
+
await this.editOrSendFresh(chatId, fallbackPlaceholderId, errorText, { logTag: 'im-presenter-error' })
|
|
597
|
+
.catch(err => this.debug(`[im-presenter feishu] error send failed chat=${chatId}: ${err?.message || err}`));
|
|
598
|
+
},
|
|
599
|
+
dispose: () => {
|
|
600
|
+
livePreview?.dispose();
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
async renderInteractionPrompt(prompt, chatId) {
|
|
605
|
+
const sent = await this.channel.send(chatId, buildHumanLoopPromptMarkdown(prompt), {
|
|
606
|
+
keyboard: this.buildHumanLoopKeyboard(prompt.promptId),
|
|
607
|
+
});
|
|
608
|
+
if (sent)
|
|
609
|
+
this.registerHumanLoopMessage(prompt.promptId, sent);
|
|
610
|
+
}
|
|
611
|
+
async safeSetMessageReaction(chatId, messageId, reactions) {
|
|
612
|
+
if (!supportsChannelCapability(this.channel, 'messageReactions'))
|
|
613
|
+
return;
|
|
614
|
+
const setReaction = this.channel?.setMessageReaction;
|
|
615
|
+
if (typeof setReaction !== 'function')
|
|
616
|
+
return;
|
|
617
|
+
try {
|
|
618
|
+
await setReaction.call(this.channel, chatId, messageId, reactions);
|
|
619
|
+
}
|
|
620
|
+
catch { }
|
|
621
|
+
}
|
|
622
|
+
// ---- streaming bridge -----------------------------------------------------
|
|
623
|
+
async handleMessage(msg, ctx) {
|
|
624
|
+
const text = msg.text.trim();
|
|
625
|
+
if (!text && !msg.files.length)
|
|
626
|
+
return;
|
|
627
|
+
const pendingPrompt = this.pendingHumanLoopPrompt(ctx.chatId);
|
|
628
|
+
if (pendingPrompt && text && !msg.files.length && !text.startsWith('/')) {
|
|
629
|
+
const result = this.humanLoopSubmitText(ctx.chatId, text);
|
|
630
|
+
if (!result) {
|
|
631
|
+
await ctx.reply('Please answer the active prompt using the buttons above.');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// Completed prompts close themselves via onInteractionAnswered.
|
|
635
|
+
if (!result.completed)
|
|
636
|
+
await this.refreshHumanLoopPrompt(ctx.chatId, result.prompt.promptId);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const session = this.resolveIncomingSession(ctx, text, msg.files);
|
|
640
|
+
const cs = this.chat(ctx.chatId);
|
|
641
|
+
this.applySessionSelection(cs, session);
|
|
642
|
+
// Tie the user's own message to its session so a future Feishu reply to it
|
|
643
|
+
// (their own message, or a quote-chain rooted at it) resolves back to this
|
|
644
|
+
// session instead of falling through to the chat's current default.
|
|
645
|
+
this.registerSessionMessage(ctx.chatId, ctx.messageId, session);
|
|
646
|
+
// File-only message: stage files
|
|
647
|
+
if (!text && msg.files.length) {
|
|
648
|
+
const hadPendingWork = this.sessionHasPendingWork(session);
|
|
649
|
+
const stageTask = this.queueSessionTask(session, async () => {
|
|
650
|
+
try {
|
|
651
|
+
if (this.isSourceMessageWithdrawn(ctx.chatId, ctx.messageId)) {
|
|
652
|
+
this.debug(`[handleMessage] skipped withdrawn file stage chat=${ctx.chatId} msg=${ctx.messageId}`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const staged = stageSessionFiles({
|
|
656
|
+
agent: session.agent,
|
|
657
|
+
workdir: session.workdir,
|
|
658
|
+
files: msg.files,
|
|
659
|
+
sessionId: session.sessionId,
|
|
660
|
+
title: undefined,
|
|
661
|
+
threadId: session.threadId,
|
|
662
|
+
});
|
|
663
|
+
session.workspacePath = staged.workspacePath;
|
|
664
|
+
session.threadId = staged.threadId;
|
|
665
|
+
this.syncSelectedChats(session);
|
|
666
|
+
if (!staged.importedFiles.length)
|
|
667
|
+
throw new Error('no files persisted');
|
|
668
|
+
this.debug(`[handleMessage] staged files chat=${ctx.chatId} session=${staged.sessionId} files=${staged.importedFiles.length}`);
|
|
669
|
+
this.registerSessionMessage(ctx.chatId, ctx.messageId, session);
|
|
670
|
+
await this.safeSetMessageReaction(ctx.chatId, ctx.messageId, [FEISHU_FILE_STAGE_REACTION]);
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
this.warn(`[handleMessage] stage files failed: ${e?.message || e}`);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
if (hadPendingWork) {
|
|
677
|
+
void stageTask.catch(e => this.warn(`[handleMessage] stage queue failed: ${e}`));
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
await stageTask.catch(e => this.warn(`[handleMessage] stage queue failed: ${e}`));
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const files = msg.files;
|
|
685
|
+
const prompt = buildPrompt(text, files);
|
|
686
|
+
const start = Date.now();
|
|
687
|
+
this.debug(`[handleMessage] start chat=${ctx.chatId} agent=${session.agent} session=${session.sessionId || '(new)'} ` +
|
|
688
|
+
`files=${files.length} prompt="${prompt.slice(0, 100)}"`);
|
|
689
|
+
const waiting = this.sessionHasPendingWork(session);
|
|
690
|
+
const taskId = this.createTaskId(session);
|
|
691
|
+
this.beginTask({
|
|
692
|
+
taskId,
|
|
693
|
+
chatId: ctx.chatId,
|
|
694
|
+
agent: session.agent,
|
|
695
|
+
sessionKey: session.key,
|
|
696
|
+
prompt,
|
|
697
|
+
attachments: files,
|
|
698
|
+
startedAt: start,
|
|
699
|
+
sourceMessageId: ctx.messageId,
|
|
700
|
+
});
|
|
701
|
+
this.emitStreamQueued(session.key, taskId);
|
|
702
|
+
const queuePosition = waiting ? this.getQueuePosition(session.key, taskId) : 0;
|
|
703
|
+
const placeholderKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId), { queued: waiting });
|
|
704
|
+
// Use the canonical resolver so the labels in the IM card match what
|
|
705
|
+
// runStream actually invokes the agent with (and what the dashboard's
|
|
706
|
+
// `start` event reports).
|
|
707
|
+
const startConfig = this.resolveSessionStreamConfig(session);
|
|
708
|
+
const model = startConfig.model;
|
|
709
|
+
const effort = startConfig.effort;
|
|
710
|
+
const placeholderId = await this.channel.send(ctx.chatId, buildInitialPreviewMarkdown(session.agent, model, effort, waiting, queuePosition), {
|
|
711
|
+
replyTo: ctx.messageId || undefined,
|
|
712
|
+
keyboard: placeholderKeyboard,
|
|
713
|
+
});
|
|
714
|
+
if (placeholderId) {
|
|
715
|
+
this.registerSessionMessage(ctx.chatId, placeholderId, session);
|
|
716
|
+
}
|
|
717
|
+
this.registerTaskPlaceholders(taskId, [placeholderId]);
|
|
718
|
+
void this.queueSessionTask(session, async () => {
|
|
719
|
+
let livePreview = null;
|
|
720
|
+
let task = null;
|
|
721
|
+
const abortController = new AbortController();
|
|
722
|
+
try {
|
|
723
|
+
task = this.markTaskRunning(taskId, () => abortController.abort());
|
|
724
|
+
if (!task || task.cancelled) {
|
|
725
|
+
this.emitStreamCancelled(taskId, session.key);
|
|
726
|
+
if (placeholderId) {
|
|
727
|
+
try {
|
|
728
|
+
await this.channel.deleteMessage(ctx.chatId, placeholderId);
|
|
729
|
+
}
|
|
730
|
+
catch { }
|
|
731
|
+
}
|
|
732
|
+
this.debug(`[handleMessage] skipped cancelled queued task chat=${ctx.chatId} msg=${ctx.messageId}`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
this.emitStreamStart(taskId, session);
|
|
736
|
+
// Task is now running — update keyboard from Recall/Steer to Stop
|
|
737
|
+
const runningKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
|
|
738
|
+
if (placeholderId && waiting) {
|
|
739
|
+
try {
|
|
740
|
+
await this.channel.editMessage(ctx.chatId, placeholderId, buildInitialPreviewMarkdown(session.agent, model, effort, false), { keyboard: runningKeyboard });
|
|
741
|
+
}
|
|
742
|
+
catch (e) {
|
|
743
|
+
this.debug(`[handleMessage] queued→running keyboard refresh failed task=${taskId} placeholder=${placeholderId}: ${e?.message || e}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (placeholderId) {
|
|
747
|
+
livePreview = new LivePreview({
|
|
748
|
+
agent: session.agent,
|
|
749
|
+
chatId: ctx.chatId,
|
|
750
|
+
placeholderMessageId: placeholderId,
|
|
751
|
+
channel: this.channel,
|
|
752
|
+
renderer: feishuPreviewRenderer,
|
|
753
|
+
streamEditIntervalMs: 700,
|
|
754
|
+
startTimeMs: start,
|
|
755
|
+
canEditMessages: supportsChannelCapability(this.channel, 'editMessages'),
|
|
756
|
+
canSendTyping: false,
|
|
757
|
+
parseMode: 'Markdown',
|
|
758
|
+
keyboard: runningKeyboard,
|
|
759
|
+
model,
|
|
760
|
+
effort,
|
|
761
|
+
log: (message) => this.debug(`[live-preview task=${taskId} placeholder=${placeholderId}] ${message}`),
|
|
762
|
+
});
|
|
763
|
+
livePreview.start();
|
|
764
|
+
}
|
|
765
|
+
// MCP sendFile callback: sends files to IM in real-time during the stream
|
|
766
|
+
const mcpSendFile = this.createMcpSendFileCallback(ctx);
|
|
767
|
+
const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
|
|
768
|
+
livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
|
|
769
|
+
this.emitStreamText(taskId, session.key, nextText, nextThinking, nextActivity, meta, plan);
|
|
770
|
+
}, undefined, mcpSendFile, abortController.signal, this.createInteractionHandler(ctx.chatId, taskId), (steer) => {
|
|
771
|
+
const currentTask = this.activeTasks.get(taskId);
|
|
772
|
+
if (!currentTask || currentTask.cancelled || currentTask.status !== 'running')
|
|
773
|
+
return;
|
|
774
|
+
currentTask.steer = steer;
|
|
775
|
+
});
|
|
776
|
+
await livePreview?.settle();
|
|
777
|
+
if (task?.freezePreviewOnAbort && result.stopReason === 'interrupted') {
|
|
778
|
+
this.emitStreamDone(taskId, session.key, {
|
|
779
|
+
sessionId: result.sessionId || session.sessionId,
|
|
780
|
+
incomplete: true,
|
|
781
|
+
});
|
|
782
|
+
const freezePlaceholderId = livePreview?.isPlaceholderAbandoned() ? null : placeholderId;
|
|
783
|
+
const frozenMessageIds = await this.freezeSteerHandoffPreview(ctx, freezePlaceholderId, livePreview);
|
|
784
|
+
this.registerSessionMessages(ctx.chatId, frozenMessageIds, session);
|
|
785
|
+
this.debug(`[handleMessage] steer handoff preserved previous preview chat=${ctx.chatId} task=${taskId}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
this.emitStreamDone(taskId, session.key, {
|
|
789
|
+
sessionId: result.sessionId || session.sessionId,
|
|
790
|
+
incomplete: !!result.incomplete,
|
|
791
|
+
...(result.ok ? {} : { error: result.error || result.message }),
|
|
792
|
+
});
|
|
793
|
+
// If LivePreview already gave up on the placeholder (Feishu rejected too
|
|
794
|
+
// many consecutive edits) skip the doomed edit attempt and send fresh.
|
|
795
|
+
const effectivePlaceholderId = livePreview?.isPlaceholderAbandoned() ? null : placeholderId;
|
|
796
|
+
const finalReplyIds = await this.sendFinalReply({ chatId: ctx.chatId }, effectivePlaceholderId, session.agent, result);
|
|
797
|
+
this.registerSessionMessages(ctx.chatId, finalReplyIds, session);
|
|
798
|
+
this.debug(`[handleMessage] end chat=${ctx.chatId} agent=${session.agent} ok=${result.ok} session=${result.sessionId || session.sessionId || '(new)'} ` +
|
|
799
|
+
`elapsed=${result.elapsedS.toFixed(1)}s tokens=in:${fmtTokens(result.inputTokens)}/out:${fmtTokens(result.outputTokens)} ` +
|
|
800
|
+
`tools=${formatToolLog(result.activity)}`);
|
|
801
|
+
}
|
|
802
|
+
catch (e) {
|
|
803
|
+
const fallbackPlaceholderId = livePreview?.isPlaceholderAbandoned() ? null : placeholderId;
|
|
804
|
+
if (task?.freezePreviewOnAbort && abortController.signal.aborted) {
|
|
805
|
+
this.emitStreamDone(taskId, session.key, {
|
|
806
|
+
sessionId: session.sessionId,
|
|
807
|
+
incomplete: true,
|
|
808
|
+
});
|
|
809
|
+
const frozenMessageIds = await this.freezeSteerHandoffPreview(ctx, fallbackPlaceholderId, livePreview);
|
|
810
|
+
this.registerSessionMessages(ctx.chatId, frozenMessageIds, session);
|
|
811
|
+
this.debug(`[handleMessage] steer handoff preserved preview after abort chat=${ctx.chatId} task=${taskId}`);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const msgText = String(e?.message || e || 'Unknown error');
|
|
815
|
+
this.emitStreamDone(taskId, session.key, {
|
|
816
|
+
sessionId: session.sessionId,
|
|
817
|
+
incomplete: true,
|
|
818
|
+
error: msgText,
|
|
819
|
+
});
|
|
820
|
+
this.warn(`[handleMessage] end chat=${ctx.chatId} agent=${session.agent} ok=false session=${session.sessionId || '(new)'} ` +
|
|
821
|
+
`elapsed=${((Date.now() - start) / 1000).toFixed(1)}s error="${msgText.slice(0, 240)}" tools=-`);
|
|
822
|
+
const errorText = `**Error**\n\n\`${msgText.slice(0, 500)}\``;
|
|
823
|
+
await this.editOrSendFresh(ctx.chatId, fallbackPlaceholderId, errorText, { logTag: 'final-error' })
|
|
824
|
+
.catch(err => this.debug(`[handleMessage] error reply send failed chat=${ctx.chatId}: ${err?.message || err}`));
|
|
825
|
+
}
|
|
826
|
+
finally {
|
|
827
|
+
livePreview?.dispose();
|
|
828
|
+
this.finishTask(taskId);
|
|
829
|
+
this.syncSelectedChats(session);
|
|
830
|
+
}
|
|
831
|
+
}, taskId).catch(e => {
|
|
832
|
+
this.warn(`[handleMessage] queue execution failed: ${e}`);
|
|
833
|
+
this.finishTask(taskId);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
/** Edit the placeholder if possible; otherwise send `text` as a fresh card
|
|
837
|
+
* anchored to the placeholder so the user always sees the result. Returns
|
|
838
|
+
* the message id that now carries `text` (the placeholder id if the edit
|
|
839
|
+
* succeeded, or a newly-created id on fallback). */
|
|
840
|
+
async editOrSendFresh(chatId, placeholderId, text, opts = {}) {
|
|
841
|
+
const tag = opts.logTag || 'editOrSendFresh';
|
|
842
|
+
if (placeholderId) {
|
|
843
|
+
try {
|
|
844
|
+
await this.channel.editMessage(chatId, placeholderId, text, opts.keyboard ? { keyboard: opts.keyboard } : undefined);
|
|
845
|
+
return placeholderId;
|
|
846
|
+
}
|
|
847
|
+
catch (e) {
|
|
848
|
+
this.debug(`[${tag}] placeholder edit failed, falling back to send chat=${chatId} placeholder=${placeholderId}: ${e?.message || e}`);
|
|
849
|
+
}
|
|
850
|
+
// Try anchoring the fresh send to the placeholder so the result stays in
|
|
851
|
+
// the same visual thread.
|
|
852
|
+
try {
|
|
853
|
+
const sent = await this.channel.send(chatId, text, {
|
|
854
|
+
replyTo: placeholderId,
|
|
855
|
+
keyboard: opts.keyboard,
|
|
856
|
+
});
|
|
857
|
+
if (sent)
|
|
858
|
+
return sent;
|
|
859
|
+
}
|
|
860
|
+
catch (e) {
|
|
861
|
+
this.debug(`[${tag}] reply send failed, dropping reply anchor chat=${chatId} placeholder=${placeholderId}: ${e?.message || e}`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// Last resort: standalone fresh card.
|
|
865
|
+
try {
|
|
866
|
+
return await this.channel.send(chatId, text, { keyboard: opts.keyboard });
|
|
867
|
+
}
|
|
868
|
+
catch (e) {
|
|
869
|
+
this.warn(`[${tag}] all send paths failed chat=${chatId}: ${e?.message || e}`);
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async freezeSteerHandoffPreview(ctx, placeholderId, livePreview) {
|
|
874
|
+
if (!placeholderId)
|
|
875
|
+
return [];
|
|
876
|
+
const previewMarkdown = livePreview?.getRenderedPreview()?.trim() || '';
|
|
877
|
+
if (!previewMarkdown)
|
|
878
|
+
return [placeholderId];
|
|
879
|
+
const finalId = await this.editOrSendFresh(ctx.chatId, placeholderId, previewMarkdown, {
|
|
880
|
+
keyboard: { rows: [] },
|
|
881
|
+
logTag: 'freeze-steer',
|
|
882
|
+
});
|
|
883
|
+
return finalId ? [finalId] : [];
|
|
884
|
+
}
|
|
885
|
+
async sendFinalReply(anchor, placeholderId, agent, result) {
|
|
886
|
+
const rendered = buildFinalReplyRender(agent, result);
|
|
887
|
+
const messageIds = [];
|
|
888
|
+
const MAX_CARD = FEISHU_BOT_CARD_MAX;
|
|
889
|
+
if (rendered.fullText.length <= MAX_CARD) {
|
|
890
|
+
// Fits in one card — try to edit the placeholder, fall back to a fresh
|
|
891
|
+
// card if Feishu rejected the patch (message too old, recalled, ...).
|
|
892
|
+
const finalId = await this.editOrSendFresh(anchor.chatId, placeholderId, rendered.fullText, { logTag: 'final-reply' });
|
|
893
|
+
if (finalId)
|
|
894
|
+
messageIds.push(finalId);
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
// Split: first card has header + truncated body + footer, continuation as separate cards
|
|
898
|
+
const maxFirst = MAX_CARD - rendered.headerText.length - rendered.footerText.length;
|
|
899
|
+
let firstBody;
|
|
900
|
+
let remaining;
|
|
901
|
+
if (maxFirst > 200) {
|
|
902
|
+
let cut = rendered.bodyText.lastIndexOf('\n', maxFirst);
|
|
903
|
+
if (cut < maxFirst * 0.3)
|
|
904
|
+
cut = maxFirst;
|
|
905
|
+
firstBody = rendered.bodyText.slice(0, cut);
|
|
906
|
+
remaining = rendered.bodyText.slice(cut);
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
firstBody = '';
|
|
910
|
+
remaining = rendered.bodyText;
|
|
911
|
+
}
|
|
912
|
+
const firstText = `${rendered.headerText}${firstBody}${rendered.footerText}`;
|
|
913
|
+
const firstId = await this.editOrSendFresh(anchor.chatId, placeholderId, firstText, { logTag: 'final-reply-head' });
|
|
914
|
+
if (firstId)
|
|
915
|
+
messageIds.push(firstId);
|
|
916
|
+
if (remaining.trim()) {
|
|
917
|
+
const chunks = splitText(remaining, MAX_CARD);
|
|
918
|
+
for (const chunk of chunks) {
|
|
919
|
+
const sent = await this.channel.send(anchor.chatId, chunk);
|
|
920
|
+
if (sent)
|
|
921
|
+
messageIds.push(sent);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
// Dispatch any image MessageBlocks the agent produced this turn.
|
|
926
|
+
const lastId = messageIds[messageIds.length - 1];
|
|
927
|
+
const dispatched = await dispatchImageBlocks(this.channel, result.assistantBlocks, {
|
|
928
|
+
chatId: anchor.chatId,
|
|
929
|
+
replyTo: lastId,
|
|
930
|
+
log: (message) => this.debug(message),
|
|
931
|
+
});
|
|
932
|
+
for (const entry of dispatched) {
|
|
933
|
+
if (typeof entry.messageId === 'string')
|
|
934
|
+
messageIds.push(entry.messageId);
|
|
935
|
+
}
|
|
936
|
+
return messageIds;
|
|
937
|
+
}
|
|
938
|
+
/** Create an MCP sendFile callback bound to a Feishu chat context. */
|
|
939
|
+
createMcpSendFileCallback(ctx) {
|
|
940
|
+
return async (filePath, opts) => {
|
|
941
|
+
try {
|
|
942
|
+
await this.channel.sendFile(ctx.chatId, filePath, {
|
|
943
|
+
caption: opts?.caption,
|
|
944
|
+
replyTo: ctx.messageId,
|
|
945
|
+
asPhoto: opts?.kind === 'photo',
|
|
946
|
+
});
|
|
947
|
+
return { ok: true };
|
|
948
|
+
}
|
|
949
|
+
catch (e) {
|
|
950
|
+
this.log(`[mcp] sendFile failed: ${filePath} error=${e?.message || e}`);
|
|
951
|
+
return { ok: false, error: e?.message || 'send failed' };
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
// ---- command router -------------------------------------------------------
|
|
956
|
+
async handleCommand(cmd, args, ctx) {
|
|
957
|
+
try {
|
|
958
|
+
switch (cmd) {
|
|
959
|
+
case 'start':
|
|
960
|
+
await this.cmdStart(ctx);
|
|
961
|
+
return;
|
|
962
|
+
case 'sessions':
|
|
963
|
+
await this.cmdSessions(ctx, args);
|
|
964
|
+
return;
|
|
965
|
+
case 'agents':
|
|
966
|
+
await this.cmdAgents(ctx, args);
|
|
967
|
+
return;
|
|
968
|
+
case 'models':
|
|
969
|
+
await this.cmdModels(ctx, args);
|
|
970
|
+
return;
|
|
971
|
+
case 'mode':
|
|
972
|
+
await this.cmdMode(ctx);
|
|
973
|
+
return;
|
|
974
|
+
case 'skills':
|
|
975
|
+
await this.cmdSkills(ctx);
|
|
976
|
+
return;
|
|
977
|
+
case 'goal':
|
|
978
|
+
await this.cmdGoal(ctx, args);
|
|
979
|
+
return;
|
|
980
|
+
case 'stop':
|
|
981
|
+
await this.cmdStop(ctx);
|
|
982
|
+
return;
|
|
983
|
+
case 'status':
|
|
984
|
+
await this.cmdStatus(ctx);
|
|
985
|
+
return;
|
|
986
|
+
case 'host':
|
|
987
|
+
await this.cmdHost(ctx);
|
|
988
|
+
return;
|
|
989
|
+
case 'switch':
|
|
990
|
+
await this.cmdSwitch(ctx, args);
|
|
991
|
+
return;
|
|
992
|
+
case 'workspaces':
|
|
993
|
+
await this.cmdWorkspaces(ctx);
|
|
994
|
+
return;
|
|
995
|
+
case 'restart':
|
|
996
|
+
await this.cmdRestart(ctx);
|
|
997
|
+
return;
|
|
998
|
+
default:
|
|
999
|
+
// Skill commands
|
|
1000
|
+
if (cmd.startsWith(FeishuBot.SKILL_CMD_PREFIX)) {
|
|
1001
|
+
await this.cmdSkill(cmd, args, ctx);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
// Unknown command — treat as message
|
|
1005
|
+
await this.handleMessage({ text: `/${cmd}${args ? ' ' + args : ''}`, files: [] }, ctx);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
catch (e) {
|
|
1009
|
+
this.log(`cmd error: ${e}`);
|
|
1010
|
+
await ctx.reply(`Error: ${String(e).slice(0, 200)}`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
async cmdSkill(cmd, args, ctx) {
|
|
1014
|
+
const resolved = resolveSkillPrompt(this, ctx.chatId, cmd, args);
|
|
1015
|
+
if (!resolved) {
|
|
1016
|
+
await ctx.reply(`Skill not found for command /${cmd} in:\n\`${this.chatWorkdir(ctx.chatId)}\``);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
this.log(`skill: ${resolved.skillName} agent=${this.chat(ctx.chatId).agent}${args.trim() ? ` args="${args.trim()}"` : ''}`);
|
|
1020
|
+
await this.handleMessage({ text: resolved.prompt, files: [] }, ctx);
|
|
1021
|
+
}
|
|
1022
|
+
callbackToMessageContext(ctx) {
|
|
1023
|
+
return {
|
|
1024
|
+
chatId: ctx.chatId,
|
|
1025
|
+
messageId: ctx.messageId,
|
|
1026
|
+
from: ctx.from,
|
|
1027
|
+
chatType: 'p2p',
|
|
1028
|
+
replyToMessageId: null,
|
|
1029
|
+
reply: (text, opts) => ctx.channel.send(ctx.chatId, text, opts),
|
|
1030
|
+
editReply: (msgId, text, opts) => ctx.channel.editMessage(ctx.chatId, msgId, text, opts),
|
|
1031
|
+
channel: ctx.channel,
|
|
1032
|
+
raw: ctx.raw,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
// ---- callback handlers ----------------------------------------------------
|
|
1036
|
+
async handleCallback(data, ctx) {
|
|
1037
|
+
try {
|
|
1038
|
+
if (await this.handleHumanLoopCallback(data, ctx))
|
|
1039
|
+
return;
|
|
1040
|
+
if (await this.handleTaskStopCallback(data, ctx))
|
|
1041
|
+
return;
|
|
1042
|
+
if (await this.handleTaskSteerCallback(data, ctx))
|
|
1043
|
+
return;
|
|
1044
|
+
if (await this.handleSwitchNavigateCallback(data, ctx))
|
|
1045
|
+
return;
|
|
1046
|
+
if (await this.handleSwitchSelectCallback(data, ctx))
|
|
1047
|
+
return;
|
|
1048
|
+
if (await this.handleWorkspacesCallback(data, ctx))
|
|
1049
|
+
return;
|
|
1050
|
+
const action = decodeCommandAction(data);
|
|
1051
|
+
if (!action)
|
|
1052
|
+
return;
|
|
1053
|
+
const result = await executeCommandAction(this, ctx.chatId, action, {
|
|
1054
|
+
sessionsPageSize: this.sessionsPageSize,
|
|
1055
|
+
});
|
|
1056
|
+
await this.applyCommandCallbackResult(ctx, result);
|
|
1057
|
+
}
|
|
1058
|
+
catch (e) {
|
|
1059
|
+
this.log(`callback error: ${e}`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
async handleHumanLoopCallback(data, ctx) {
|
|
1063
|
+
if (!data.startsWith('hl:'))
|
|
1064
|
+
return false;
|
|
1065
|
+
const [, action, promptId, rawIndex] = data.split(':');
|
|
1066
|
+
const prompt = this.humanLoopPrompt(promptId);
|
|
1067
|
+
if (!prompt)
|
|
1068
|
+
return true;
|
|
1069
|
+
if (action === 'cancel') {
|
|
1070
|
+
this.humanLoopCancel(promptId, 'Prompt cancelled from Feishu.');
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
if (action === 'skip') {
|
|
1074
|
+
const result = this.humanLoopSkip(promptId);
|
|
1075
|
+
if (!result)
|
|
1076
|
+
return true;
|
|
1077
|
+
if (!result.completed)
|
|
1078
|
+
await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
if (action === 'other') {
|
|
1082
|
+
const result = this.humanLoopSelectOption(promptId, '__other__', { requestFreeform: true });
|
|
1083
|
+
if (!result)
|
|
1084
|
+
return true;
|
|
1085
|
+
await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
if (action === 'o') {
|
|
1089
|
+
const index = Number.parseInt(rawIndex || '', 10);
|
|
1090
|
+
const question = this.humanLoopCurrentQuestion(promptId);
|
|
1091
|
+
const option = Number.isFinite(index) ? question?.options?.[index] : null;
|
|
1092
|
+
if (!option)
|
|
1093
|
+
return true;
|
|
1094
|
+
const result = this.humanLoopSelectOption(promptId, option.value);
|
|
1095
|
+
if (!result)
|
|
1096
|
+
return true;
|
|
1097
|
+
if (!result.completed)
|
|
1098
|
+
await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
|
|
1099
|
+
return true;
|
|
1100
|
+
}
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
async handleTaskStopCallback(data, ctx) {
|
|
1104
|
+
if (!data.startsWith('tsk:stop:'))
|
|
1105
|
+
return false;
|
|
1106
|
+
const actionId = data.slice('tsk:stop:'.length).trim();
|
|
1107
|
+
const result = this.stopTaskByActionId(actionId);
|
|
1108
|
+
if (!result.task)
|
|
1109
|
+
return true;
|
|
1110
|
+
if (result.cancelled) {
|
|
1111
|
+
try {
|
|
1112
|
+
await this.channel.deleteMessage(ctx.chatId, ctx.messageId);
|
|
1113
|
+
}
|
|
1114
|
+
catch { }
|
|
1115
|
+
}
|
|
1116
|
+
return true;
|
|
1117
|
+
}
|
|
1118
|
+
async handleTaskSteerCallback(data, ctx) {
|
|
1119
|
+
if (!data.startsWith('tsk:steer:'))
|
|
1120
|
+
return false;
|
|
1121
|
+
const actionId = data.slice('tsk:steer:'.length).trim();
|
|
1122
|
+
const result = await this.steerTaskByActionId(actionId);
|
|
1123
|
+
if (!result.task)
|
|
1124
|
+
return true;
|
|
1125
|
+
// The queued task will naturally run next after the running task is interrupted
|
|
1126
|
+
return true;
|
|
1127
|
+
}
|
|
1128
|
+
async handleSwitchNavigateCallback(data, ctx) {
|
|
1129
|
+
if (!data.startsWith('sw:n:'))
|
|
1130
|
+
return false;
|
|
1131
|
+
const [pathId, pageRaw] = data.slice(5).split(':');
|
|
1132
|
+
const browsePath = resolveFeishuRegisteredPath(parseInt(pathId, 10));
|
|
1133
|
+
if (!browsePath)
|
|
1134
|
+
return true;
|
|
1135
|
+
const wd = this.chatWorkdir(ctx.chatId);
|
|
1136
|
+
const view = buildSwitchWorkdirCard(wd, browsePath, parseInt(pageRaw, 10) || 0);
|
|
1137
|
+
await ctx.channel.editCard(ctx.chatId, ctx.messageId, view);
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
async handleSwitchSelectCallback(data, ctx) {
|
|
1141
|
+
if (!data.startsWith('sw:s:'))
|
|
1142
|
+
return false;
|
|
1143
|
+
const dirPath = resolveFeishuRegisteredPath(parseInt(data.slice(5), 10));
|
|
1144
|
+
if (!dirPath || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory())
|
|
1145
|
+
return true;
|
|
1146
|
+
const oldPath = this.switchWorkdir(dirPath);
|
|
1147
|
+
await ctx.editReply(ctx.messageId, `**Workdir**\n● \`${oldPath}\`\n→ \`${dirPath}\``);
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
async handleWorkspacesCallback(data, ctx) {
|
|
1151
|
+
if (data.startsWith('wsp:p:')) {
|
|
1152
|
+
const page = parseInt(data.slice('wsp:p:'.length), 10) || 0;
|
|
1153
|
+
const view = buildWorkspacesCard(getWorkspacesData(this, ctx.chatId), page);
|
|
1154
|
+
await ctx.channel.editCard(ctx.chatId, ctx.messageId, view);
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
if (data.startsWith('wsp:s:')) {
|
|
1158
|
+
const dirPath = resolveFeishuRegisteredPath(parseInt(data.slice('wsp:s:'.length), 10));
|
|
1159
|
+
if (!dirPath || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory())
|
|
1160
|
+
return true;
|
|
1161
|
+
const oldPath = this.switchWorkdir(dirPath);
|
|
1162
|
+
await ctx.editReply(ctx.messageId, `**Workdir**\n● \`${oldPath}\`\n→ \`${dirPath}\``);
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
async previewCurrentSessionTurn(chatId, agent, sessionId) {
|
|
1168
|
+
try {
|
|
1169
|
+
const preview = await getSessionTurnPreviewData(this, agent, sessionId, 50, this.chatWorkdir(chatId));
|
|
1170
|
+
if (!preview)
|
|
1171
|
+
return;
|
|
1172
|
+
const previewMarkdown = renderSessionTurnMarkdown(preview.userText, preview.assistantText);
|
|
1173
|
+
if (!previewMarkdown)
|
|
1174
|
+
return;
|
|
1175
|
+
const sent = await this.channel.send(chatId, previewMarkdown);
|
|
1176
|
+
if (sessionId) {
|
|
1177
|
+
const runtime = this.getSessionRuntimeByKey(this.sessionKey(agent, sessionId));
|
|
1178
|
+
if (runtime && sent)
|
|
1179
|
+
this.registerSessionMessage(chatId, sent, runtime);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
// non-critical
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
async handleMessageRecalled(messageId, chatId) {
|
|
1187
|
+
const task = this.withdrawQueuedTaskBySourceMessage(chatId, messageId);
|
|
1188
|
+
if (!task)
|
|
1189
|
+
return;
|
|
1190
|
+
for (const placeholderId of task.placeholderMessageIds || []) {
|
|
1191
|
+
try {
|
|
1192
|
+
await this.channel.deleteMessage(chatId, String(placeholderId));
|
|
1193
|
+
}
|
|
1194
|
+
catch { }
|
|
1195
|
+
}
|
|
1196
|
+
this.log(`[message-recalled] cancelled queued task chat=${chatId} msg=${messageId} session=${task.sessionKey}`);
|
|
1197
|
+
}
|
|
1198
|
+
// ---- lifecycle ------------------------------------------------------------
|
|
1199
|
+
async run() {
|
|
1200
|
+
const tmpDir = path.join(os.tmpdir(), 'pikiloop');
|
|
1201
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
1202
|
+
this.channel = new FeishuChannel({
|
|
1203
|
+
appId: this.appId,
|
|
1204
|
+
appSecret: this.appSecret,
|
|
1205
|
+
domain: this.domain,
|
|
1206
|
+
workdir: tmpDir,
|
|
1207
|
+
allowedChatIds: this.allowedChatIds.size
|
|
1208
|
+
? this.allowedChatIds
|
|
1209
|
+
: undefined,
|
|
1210
|
+
});
|
|
1211
|
+
// Restore persisted known chats into the channel so the startup greeting and
|
|
1212
|
+
// per-chat menu can address them — without polluting the allowlist.
|
|
1213
|
+
for (const id of loadKnownChatIds('feishu'))
|
|
1214
|
+
this.channel.knownChats.add(id);
|
|
1215
|
+
this.processRuntimeCleanup?.();
|
|
1216
|
+
this.processRuntimeCleanup = registerProcessRuntime({
|
|
1217
|
+
label: 'feishu',
|
|
1218
|
+
getActiveTaskCount: () => this.activeTasks.size,
|
|
1219
|
+
prepareForRestart: () => this.cleanupRuntimeForExit(),
|
|
1220
|
+
buildRestartEnv: () => this.buildRestartEnv(),
|
|
1221
|
+
});
|
|
1222
|
+
this.installSignalHandlers();
|
|
1223
|
+
try {
|
|
1224
|
+
const bot = await this.channel.connect();
|
|
1225
|
+
this.connected = true;
|
|
1226
|
+
this.log(`bot: ${bot.displayName} (id=${bot.id})`);
|
|
1227
|
+
for (const ag of this.fetchAgents().agents) {
|
|
1228
|
+
this.log(`agent ${ag.agent}: ${ag.path || 'NOT FOUND'}`);
|
|
1229
|
+
}
|
|
1230
|
+
this.log(`config: agent=${this.defaultAgent} workdir=${this.workdir} timeout=${this.runTimeout}s`);
|
|
1231
|
+
this.channel.onCommand((cmd, args, ctx) => this.handleCommand(cmd, args, ctx));
|
|
1232
|
+
this.channel.onMessage((msg, ctx) => this.handleMessage(msg, ctx));
|
|
1233
|
+
this.channel.onCallback((data, ctx) => this.handleCallback(data, ctx));
|
|
1234
|
+
this.channel.onMessageRecalled((messageId, chatId) => this.handleMessageRecalled(messageId, chatId));
|
|
1235
|
+
this.channel.onError(err => this.log(`error: ${err}`));
|
|
1236
|
+
this.startKeepAlive();
|
|
1237
|
+
void this.setupMenu().catch(err => this.log(`menu setup failed: ${err}`));
|
|
1238
|
+
void this.sendStartupNotice().catch(err => this.log(`startup notice failed: ${err}`));
|
|
1239
|
+
this.log('✓ Feishu connected, WebSocket listening — ready to receive messages');
|
|
1240
|
+
await this.channel.listen();
|
|
1241
|
+
this.stopKeepAlive();
|
|
1242
|
+
this.log('stopped');
|
|
1243
|
+
}
|
|
1244
|
+
finally {
|
|
1245
|
+
this.stopKeepAlive();
|
|
1246
|
+
if (this.shutdownForceExitTimer)
|
|
1247
|
+
clearTimeout(this.shutdownForceExitTimer);
|
|
1248
|
+
this.removeSignalHandlers();
|
|
1249
|
+
this.processRuntimeCleanup?.();
|
|
1250
|
+
this.processRuntimeCleanup = null;
|
|
1251
|
+
if (this.shutdownInFlight)
|
|
1252
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
async sendStartupNotice() {
|
|
1256
|
+
const targets = new Set(this.allowedChatIds);
|
|
1257
|
+
for (const cid of this.channel.knownChats)
|
|
1258
|
+
targets.add(cid);
|
|
1259
|
+
if (!targets.size) {
|
|
1260
|
+
this.log('no known chats for startup notice');
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
for (const cid of targets) {
|
|
1264
|
+
try {
|
|
1265
|
+
const d = getStartData(this, cid);
|
|
1266
|
+
const text = renderStart(d);
|
|
1267
|
+
await this.channel.send(cid, text);
|
|
1268
|
+
this.log(`startup notice sent to chat=${cid}`);
|
|
1269
|
+
}
|
|
1270
|
+
catch (e) {
|
|
1271
|
+
this.log(`startup notice failed for chat=${cid}: ${e}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|