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,1310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram bot orchestration: commands, callbacks, streaming lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Rendering, workdir browsing, and live preview state live in dedicated helper modules.
|
|
5
|
+
* New IM integrations should stay parallel to this file and compose shared runtime helpers
|
|
6
|
+
* instead of growing a single multi-platform bot entrypoint.
|
|
7
|
+
*/
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { Bot, fmtTokens, fmtUptime, fmtBytes, buildPrompt, formatGitStatusLine, 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 { SKILL_CMD_PREFIX, } from '../../bot/menu.js';
|
|
17
|
+
import { getStartData, getStatusDataAsync, getHostDataSync, getSessionTurnPreviewData, getWorkspacesData, resolveSkillPrompt, summarizePromptForStatus, handleGoalCommand, } from '../../bot/commands.js';
|
|
18
|
+
import { buildAgentsCommandView, buildModelsCommandView, buildModeCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, executeCommandAction, } from '../../bot/command-ui.js';
|
|
19
|
+
import { buildSwitchWorkdirView, buildWorkspacesView, resolveRegisteredPath } from './directory.js';
|
|
20
|
+
import { LivePreview } from './live-preview.js';
|
|
21
|
+
import { registerProcessRuntime, buildRestartCommand, requestProcessRestart, } from '../../core/process-control.js';
|
|
22
|
+
import { buildInitialPreviewHtml, buildHumanLoopPromptHtml, buildAnsweredHumanLoopPromptHtml, buildStreamPreviewHtml, buildFinalReplyRender, dispatchImageBlocks, escapeHtml, formatMenuLines, formatProviderUsageLines, renderCommandNoticeHtml, renderCommandSelectionHtml, renderCommandSelectionKeyboard, renderSessionTurnHtml, truncateMiddle, } from './render.js';
|
|
23
|
+
import { currentHumanLoopQuestion, humanLoopOptionSelected } from '../../bot/human-loop.js';
|
|
24
|
+
import { TelegramChannel } from './channel.js';
|
|
25
|
+
import { splitText, supportsChannelCapability } from '../base.js';
|
|
26
|
+
import { getActiveUserConfig, loadKnownChatIds } from '../../core/config/user-config.js';
|
|
27
|
+
/** Telegram HTML renderer for LivePreview. */
|
|
28
|
+
const telegramPreviewRenderer = {
|
|
29
|
+
renderInitial: buildInitialPreviewHtml,
|
|
30
|
+
renderStream: buildStreamPreviewHtml,
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Echo message rendered after a human-loop prompt resolves — a small chat
|
|
34
|
+
* message that records the decision in the conversation history (independent
|
|
35
|
+
* of the now-closed card above). Returns null when there's nothing useful to
|
|
36
|
+
* say (e.g. an empty cancellation).
|
|
37
|
+
*/
|
|
38
|
+
function buildInteractionEchoHtml(summary) {
|
|
39
|
+
if (summary.status === 'cancelled') {
|
|
40
|
+
return `<i>⊘ Prompt cancelled.</i>`;
|
|
41
|
+
}
|
|
42
|
+
if (!summary.rows.length)
|
|
43
|
+
return null;
|
|
44
|
+
if (summary.rows.length === 1) {
|
|
45
|
+
return `<b>✓ Answered</b> · ${escapeHtml(summary.rows[0].display)}`;
|
|
46
|
+
}
|
|
47
|
+
const lines = ['<b>✓ Answered</b>'];
|
|
48
|
+
for (const row of summary.rows) {
|
|
49
|
+
lines.push(`• ${escapeHtml(row.label)}: ${escapeHtml(row.display)}`);
|
|
50
|
+
}
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
const SHUTDOWN_EXIT_CODE = {
|
|
54
|
+
SIGINT: 130,
|
|
55
|
+
SIGTERM: 143,
|
|
56
|
+
};
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// TelegramBot
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export class TelegramBot extends Bot {
|
|
61
|
+
token;
|
|
62
|
+
channel;
|
|
63
|
+
sessionMessages = new SessionMessageRegistry();
|
|
64
|
+
nextTaskId = 1;
|
|
65
|
+
shutdownInFlight = false;
|
|
66
|
+
shutdownExitCode = null;
|
|
67
|
+
shutdownForceExitTimer = null;
|
|
68
|
+
signalHandlers = {};
|
|
69
|
+
processRuntimeCleanup = null;
|
|
70
|
+
constructor() {
|
|
71
|
+
super();
|
|
72
|
+
const config = getActiveUserConfig();
|
|
73
|
+
// merge Telegram-specific allowed IDs into base
|
|
74
|
+
if (config.telegramAllowedChatIds) {
|
|
75
|
+
for (const id of parseAllowedChatIds(config.telegramAllowedChatIds))
|
|
76
|
+
this.allowedChatIds.add(id);
|
|
77
|
+
}
|
|
78
|
+
// NOTE: persisted known chats are restored into channel.knownChats in run()
|
|
79
|
+
// (for the startup greeting / per-chat menu) — deliberately NOT into
|
|
80
|
+
// allowedChatIds. allowedChatIds is the explicit allowlist; folding known
|
|
81
|
+
// chats into it flips _isAllowed() into allowlist-only mode.
|
|
82
|
+
this.token = String(config.telegramBotToken || process.env.TELEGRAM_BOT_TOKEN || '').trim();
|
|
83
|
+
if (!this.token)
|
|
84
|
+
throw new Error('Missing Telegram token. Configure via dashboard or set TELEGRAM_BOT_TOKEN');
|
|
85
|
+
}
|
|
86
|
+
requestStop() {
|
|
87
|
+
super.requestStop();
|
|
88
|
+
try {
|
|
89
|
+
this.channel?.disconnect();
|
|
90
|
+
}
|
|
91
|
+
catch { }
|
|
92
|
+
}
|
|
93
|
+
onManagedConfigChange(config, opts = {}) {
|
|
94
|
+
const nextToken = String(config.telegramBotToken || process.env.TELEGRAM_BOT_TOKEN || '').trim();
|
|
95
|
+
if (nextToken && nextToken !== this.token) {
|
|
96
|
+
this.token = nextToken;
|
|
97
|
+
if (!opts.initial)
|
|
98
|
+
this.log('telegram token reloaded from setting.json');
|
|
99
|
+
}
|
|
100
|
+
const mergedAllowed = parseAllowedChatIds(process.env.PIKILOOP_ALLOWED_IDS || '');
|
|
101
|
+
for (const id of parseAllowedChatIds(String(config.telegramAllowedChatIds || '')))
|
|
102
|
+
mergedAllowed.add(id);
|
|
103
|
+
// Known chats are NOT merged here — doing so would re-pollute the allowlist on
|
|
104
|
+
// every config reload. They live in channel.knownChats (restored in run()).
|
|
105
|
+
this.allowedChatIds = mergedAllowed;
|
|
106
|
+
}
|
|
107
|
+
/** Skill command prefix used in Telegram bot commands. */
|
|
108
|
+
static SKILL_CMD_PREFIX = SKILL_CMD_PREFIX;
|
|
109
|
+
/** Register bot menu commands. Called automatically after connect. */
|
|
110
|
+
async setupMenu() {
|
|
111
|
+
if (!supportsChannelCapability(this.channel, 'commandMenu'))
|
|
112
|
+
return;
|
|
113
|
+
const { commands, skillCount } = buildBotMenuState(this);
|
|
114
|
+
await this.channel.setMenu(commands);
|
|
115
|
+
this.log(`menu: ${commands.length} commands (${skillCount} skills)`);
|
|
116
|
+
}
|
|
117
|
+
afterSwitchWorkdir(_oldPath, _newPath) {
|
|
118
|
+
if (!this.channel)
|
|
119
|
+
return;
|
|
120
|
+
void this.setupMenu().catch(err => this.log(`menu refresh failed after workdir switch: ${err}`));
|
|
121
|
+
}
|
|
122
|
+
clearShutdownForceExitTimer() {
|
|
123
|
+
if (!this.shutdownForceExitTimer)
|
|
124
|
+
return;
|
|
125
|
+
clearTimeout(this.shutdownForceExitTimer);
|
|
126
|
+
this.shutdownForceExitTimer = null;
|
|
127
|
+
}
|
|
128
|
+
removeSignalHandlers() {
|
|
129
|
+
for (const sig of Object.keys(this.signalHandlers)) {
|
|
130
|
+
const handler = this.signalHandlers[sig];
|
|
131
|
+
if (handler)
|
|
132
|
+
process.off(sig, handler);
|
|
133
|
+
}
|
|
134
|
+
this.signalHandlers = {};
|
|
135
|
+
}
|
|
136
|
+
installSignalHandlers() {
|
|
137
|
+
this.removeSignalHandlers();
|
|
138
|
+
const onSigint = () => this.beginShutdown('SIGINT');
|
|
139
|
+
const onSigterm = () => this.beginShutdown('SIGTERM');
|
|
140
|
+
const onSigusr2 = () => this.performRestart();
|
|
141
|
+
this.signalHandlers = {
|
|
142
|
+
SIGINT: onSigint,
|
|
143
|
+
SIGTERM: onSigterm,
|
|
144
|
+
SIGUSR2: onSigusr2,
|
|
145
|
+
};
|
|
146
|
+
process.once('SIGINT', onSigint);
|
|
147
|
+
process.once('SIGTERM', onSigterm);
|
|
148
|
+
process.on('SIGUSR2', onSigusr2);
|
|
149
|
+
}
|
|
150
|
+
cleanupRuntimeForExit() {
|
|
151
|
+
try {
|
|
152
|
+
this.channel.disconnect();
|
|
153
|
+
}
|
|
154
|
+
catch { }
|
|
155
|
+
this.stopKeepAlive();
|
|
156
|
+
shutdownAllDrivers();
|
|
157
|
+
}
|
|
158
|
+
buildRestartEnv() {
|
|
159
|
+
// Hand off only the explicit allowlist. Known chats persist to setting.json
|
|
160
|
+
// and are restored via loadKnownChatIds, so they must NOT ride along in the
|
|
161
|
+
// allowlist env — that would re-pollute allowedChatIds on the next boot.
|
|
162
|
+
return buildKnownChatEnv(this.allowedChatIds, [], 'TELEGRAM_ALLOWED_CHAT_IDS');
|
|
163
|
+
}
|
|
164
|
+
beginShutdown(sig) {
|
|
165
|
+
if (this.shutdownInFlight)
|
|
166
|
+
return;
|
|
167
|
+
this.shutdownInFlight = true;
|
|
168
|
+
this.shutdownExitCode = SHUTDOWN_EXIT_CODE[sig];
|
|
169
|
+
this.log(`${sig}, shutting down...`);
|
|
170
|
+
this.cleanupRuntimeForExit();
|
|
171
|
+
this.clearShutdownForceExitTimer();
|
|
172
|
+
this.shutdownForceExitTimer = setTimeout(() => {
|
|
173
|
+
this.log(`shutdown still pending after ${Math.floor(BOT_SHUTDOWN_FORCE_EXIT_MS / 1000)}s, forcing exit`);
|
|
174
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
175
|
+
}, BOT_SHUTDOWN_FORCE_EXIT_MS);
|
|
176
|
+
this.shutdownForceExitTimer.unref?.();
|
|
177
|
+
}
|
|
178
|
+
performRestart() {
|
|
179
|
+
this.cleanupRuntimeForExit();
|
|
180
|
+
const { bin, args } = buildRestartCommand(process.argv.slice(2));
|
|
181
|
+
const child = spawn(bin, args, {
|
|
182
|
+
stdio: 'inherit',
|
|
183
|
+
detached: true,
|
|
184
|
+
env: {
|
|
185
|
+
...process.env,
|
|
186
|
+
npm_config_yes: process.env.npm_config_yes || 'true',
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
child.unref();
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
createTaskId(session) {
|
|
193
|
+
return buildSessionTaskId(session, this.nextTaskId++);
|
|
194
|
+
}
|
|
195
|
+
registerSessionMessage(chatId, messageId, session) {
|
|
196
|
+
this.sessionMessages.register(chatId, messageId, session, session.workdir);
|
|
197
|
+
}
|
|
198
|
+
registerSessionMessages(chatId, messageIds, session) {
|
|
199
|
+
this.sessionMessages.registerMany(chatId, messageIds, session, session.workdir);
|
|
200
|
+
}
|
|
201
|
+
sessionFromMessage(chatId, messageId) {
|
|
202
|
+
const sessionRef = this.sessionMessages.resolve(chatId, messageId);
|
|
203
|
+
if (!sessionRef)
|
|
204
|
+
return null;
|
|
205
|
+
return this.getSessionRuntimeByKey(sessionRef.key, { allowAnyWorkdir: true })
|
|
206
|
+
|| this.hydrateSessionRuntime(sessionRef);
|
|
207
|
+
}
|
|
208
|
+
ensureSession(chatId, title, files) {
|
|
209
|
+
return this.ensureSessionForChat(chatId, title, files);
|
|
210
|
+
}
|
|
211
|
+
resolveIncomingSession(ctx, text, files) {
|
|
212
|
+
const cs = this.chat(ctx.chatId);
|
|
213
|
+
const replyMessageId = typeof ctx.raw?.reply_to_message?.message_id === 'number'
|
|
214
|
+
? ctx.raw.reply_to_message.message_id
|
|
215
|
+
: null;
|
|
216
|
+
const repliedSession = this.sessionFromMessage(ctx.chatId, replyMessageId);
|
|
217
|
+
if (repliedSession) {
|
|
218
|
+
this.log(`[resolveSession] reply matched session=${repliedSession.sessionId} chat=${ctx.chatId}`);
|
|
219
|
+
this.applySessionSelection(cs, repliedSession);
|
|
220
|
+
return repliedSession;
|
|
221
|
+
}
|
|
222
|
+
const selected = this.getSelectedSession(cs);
|
|
223
|
+
if (selected)
|
|
224
|
+
return selected;
|
|
225
|
+
return this.ensureSession(ctx.chatId, text, files);
|
|
226
|
+
}
|
|
227
|
+
// ---- commands -------------------------------------------------------------
|
|
228
|
+
async cmdStart(ctx) {
|
|
229
|
+
const d = getStartData(this, ctx.chatId);
|
|
230
|
+
await ctx.reply(this.renderStartHtml(d), { parseMode: 'HTML' });
|
|
231
|
+
}
|
|
232
|
+
renderStartHtml(d) {
|
|
233
|
+
const lines = [
|
|
234
|
+
`<b>${escapeHtml(d.title)}</b> v${escapeHtml(d.version)}`,
|
|
235
|
+
escapeHtml(d.subtitle),
|
|
236
|
+
'',
|
|
237
|
+
`<b>Agent:</b> ${escapeHtml(d.agent)}`,
|
|
238
|
+
`<b>Workdir:</b> <code>${escapeHtml(d.workdir)}</code>`,
|
|
239
|
+
'',
|
|
240
|
+
'<b>Agents</b>',
|
|
241
|
+
...d.agentDetails.map(a => {
|
|
242
|
+
const parts = [` <b>${escapeHtml(a.agent)}</b>: ${escapeHtml(a.model)}`];
|
|
243
|
+
if (a.effort)
|
|
244
|
+
parts[0] += ` (effort: ${escapeHtml(a.effort)})`;
|
|
245
|
+
return parts[0];
|
|
246
|
+
}),
|
|
247
|
+
'',
|
|
248
|
+
'<b>Commands</b>',
|
|
249
|
+
...formatMenuLines(d.commands),
|
|
250
|
+
];
|
|
251
|
+
return lines.join('\n');
|
|
252
|
+
}
|
|
253
|
+
async cmdSkills(ctx) {
|
|
254
|
+
await this.sendCommandView(ctx, buildSkillsCommandView(this, ctx.chatId));
|
|
255
|
+
}
|
|
256
|
+
async cmdExt(ctx) {
|
|
257
|
+
const { getExtensionSummaryData } = await import('../../bot/commands.js');
|
|
258
|
+
const data = getExtensionSummaryData(this, ctx.chatId);
|
|
259
|
+
const lines = ['<b>Extensions</b>', ''];
|
|
260
|
+
// MCP servers
|
|
261
|
+
lines.push(`<b>MCP Servers</b> (${data.mcpCount})`);
|
|
262
|
+
if (data.mcpExtensions.length === 0) {
|
|
263
|
+
lines.push(' <i>No MCP extensions configured</i>');
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
for (const ext of data.mcpExtensions) {
|
|
267
|
+
const icon = ext.enabled ? '●' : '○';
|
|
268
|
+
const scope = ext.scope === 'global' ? '[G]' : ext.scope === 'workspace' ? '[W]' : '[B]';
|
|
269
|
+
lines.push(` ${icon} ${scope} <b>${escapeHtml(ext.name)}</b>`);
|
|
270
|
+
if (ext.command)
|
|
271
|
+
lines.push(` <code>${escapeHtml(ext.command.slice(0, 80))}</code>`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
lines.push('');
|
|
275
|
+
// Skills
|
|
276
|
+
lines.push(`<b>Skills</b> (${data.skillCount})`);
|
|
277
|
+
if (data.skills.length === 0) {
|
|
278
|
+
lines.push(' <i>No skills installed</i>');
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
for (const skill of data.skills) {
|
|
282
|
+
const scope = skill.scope === 'global' ? '[G]' : '[P]';
|
|
283
|
+
lines.push(` ${scope} <b>${escapeHtml(skill.label)}</b> <i>(${escapeHtml(skill.name)})</i>`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
lines.push('', '<i>Manage extensions in the Dashboard → Extensions tab.</i>');
|
|
287
|
+
await ctx.reply(lines.join('\n'), { parseMode: 'HTML' });
|
|
288
|
+
}
|
|
289
|
+
async sendCommandView(ctx, view) {
|
|
290
|
+
await ctx.reply(renderCommandSelectionHtml(view), { parseMode: 'HTML', keyboard: renderCommandSelectionKeyboard(view) });
|
|
291
|
+
}
|
|
292
|
+
async replyCommandResult(ctx, result) {
|
|
293
|
+
if (result.kind === 'view') {
|
|
294
|
+
await this.sendCommandView(ctx, result.view);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (result.kind === 'skill') {
|
|
298
|
+
await this.handleMessage({ text: result.prompt, files: [] }, ctx);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (result.kind === 'notice') {
|
|
302
|
+
const sent = await ctx.reply(renderCommandNoticeHtml(result.notice), { parseMode: 'HTML' });
|
|
303
|
+
if (result.session && typeof sent === 'number')
|
|
304
|
+
this.registerSessionMessage(ctx.chatId, sent, result.session);
|
|
305
|
+
if (result.previewSession) {
|
|
306
|
+
await this.previewCurrentSessionTurn(ctx.chatId, result.previewSession.agent, result.previewSession.sessionId);
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await ctx.reply(escapeHtml(result.message), { parseMode: 'HTML' });
|
|
311
|
+
}
|
|
312
|
+
async applyCommandCallbackResult(ctx, result) {
|
|
313
|
+
if (result.kind === 'noop') {
|
|
314
|
+
await ctx.answerCallback(result.message);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (result.kind === 'view') {
|
|
318
|
+
await ctx.editReply(ctx.messageId, renderCommandSelectionHtml(result.view), { parseMode: 'HTML', keyboard: renderCommandSelectionKeyboard(result.view) });
|
|
319
|
+
await ctx.answerCallback(result.callbackText ?? undefined);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (result.kind === 'skill') {
|
|
323
|
+
await ctx.answerCallback(result.callbackText ?? undefined);
|
|
324
|
+
await this.handleMessage({ text: result.prompt, files: [] }, ctx);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await ctx.answerCallback(result.callbackText ?? undefined);
|
|
328
|
+
await ctx.editReply(ctx.messageId, renderCommandNoticeHtml(result.notice), { parseMode: 'HTML' });
|
|
329
|
+
if (result.session)
|
|
330
|
+
this.registerSessionMessage(ctx.chatId, ctx.messageId, result.session);
|
|
331
|
+
if (result.previewSession) {
|
|
332
|
+
await this.previewCurrentSessionTurn(ctx.chatId, result.previewSession.agent, result.previewSession.sessionId);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
sessionsPageSize = 5;
|
|
336
|
+
buildStopKeyboard(actionId, opts) {
|
|
337
|
+
if (!actionId)
|
|
338
|
+
return undefined;
|
|
339
|
+
if (opts?.queued) {
|
|
340
|
+
return {
|
|
341
|
+
inline_keyboard: [[
|
|
342
|
+
{ text: 'Recall', callback_data: `tsk:stop:${actionId}` },
|
|
343
|
+
{ text: 'Steer', callback_data: `tsk:steer:${actionId}` },
|
|
344
|
+
]],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
inline_keyboard: [[
|
|
349
|
+
{ text: 'Stop', callback_data: `tsk:stop:${actionId}` },
|
|
350
|
+
]],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
async cmdSessions(ctx) {
|
|
354
|
+
await this.sendCommandView(ctx, await buildSessionsCommandView(this, ctx.chatId, 0, this.sessionsPageSize));
|
|
355
|
+
}
|
|
356
|
+
async cmdStatus(ctx) {
|
|
357
|
+
const d = await getStatusDataAsync(this, ctx.chatId);
|
|
358
|
+
const gitLine = formatGitStatusLine(d.git);
|
|
359
|
+
const lines = [
|
|
360
|
+
`<b>pikiloop</b> v${d.version}\n`,
|
|
361
|
+
`<b>Uptime:</b> ${fmtUptime(d.uptime)}`,
|
|
362
|
+
`<b>Memory:</b> ${(d.memRss / 1024 / 1024).toFixed(0)}MB RSS / ${(d.memHeap / 1024 / 1024).toFixed(0)}MB heap`,
|
|
363
|
+
`<b>PID:</b> ${d.pid}`,
|
|
364
|
+
`<b>Workdir:</b> <code>${escapeHtml(d.workdir)}</code>`,
|
|
365
|
+
...(gitLine ? [`<b>Git:</b> ${escapeHtml(gitLine)}`] : []),
|
|
366
|
+
'',
|
|
367
|
+
`<b>Agent:</b> ${escapeHtml(d.agent)}`,
|
|
368
|
+
`<b>Model:</b> ${escapeHtml(d.model)}`,
|
|
369
|
+
`<b>Session:</b> ${d.sessionId ? `<code>${escapeHtml(d.sessionId.slice(0, 16))}</code>` : '(new)'}`,
|
|
370
|
+
`<b>Active Tasks:</b> ${d.activeTasksCount}`,
|
|
371
|
+
];
|
|
372
|
+
if (d.running) {
|
|
373
|
+
lines.push(`<b>Running:</b> ${fmtUptime(Date.now() - d.running.startedAt)} - ${escapeHtml(summarizePromptForStatus(d.running.prompt))}`);
|
|
374
|
+
}
|
|
375
|
+
lines.push(...formatProviderUsageLines(d.usage), '', '<b>Bot Usage</b>', ` Turns: ${d.stats.totalTurns}`);
|
|
376
|
+
if (d.stats.totalInputTokens || d.stats.totalOutputTokens) {
|
|
377
|
+
lines.push(` In: ${fmtTokens(d.stats.totalInputTokens)} Out: ${fmtTokens(d.stats.totalOutputTokens)}`);
|
|
378
|
+
if (d.stats.totalCachedTokens)
|
|
379
|
+
lines.push(` Cached: ${fmtTokens(d.stats.totalCachedTokens)}`);
|
|
380
|
+
}
|
|
381
|
+
await ctx.reply(lines.join('\n'), { parseMode: 'HTML' });
|
|
382
|
+
}
|
|
383
|
+
async cmdSwitch(ctx) {
|
|
384
|
+
const wd = this.chatWorkdir(ctx.chatId);
|
|
385
|
+
const browsePath = path.dirname(wd);
|
|
386
|
+
const savedCount = getWorkspacesData(this, ctx.chatId).workspaces.length;
|
|
387
|
+
const view = buildSwitchWorkdirView(wd, browsePath, 0, { savedWorkspaceCount: savedCount });
|
|
388
|
+
await ctx.reply(view.text, { parseMode: 'HTML', keyboard: view.keyboard });
|
|
389
|
+
}
|
|
390
|
+
async cmdWorkspaces(ctx) {
|
|
391
|
+
const data = getWorkspacesData(this, ctx.chatId);
|
|
392
|
+
const view = buildWorkspacesView(data);
|
|
393
|
+
await ctx.reply(view.text, { parseMode: 'HTML', keyboard: view.keyboard });
|
|
394
|
+
}
|
|
395
|
+
async cmdHost(ctx) {
|
|
396
|
+
const d = getHostDataSync(this);
|
|
397
|
+
const lines = [
|
|
398
|
+
`<b>Host</b>\n`,
|
|
399
|
+
`<b>Name:</b> ${escapeHtml(d.hostName)}`,
|
|
400
|
+
`<b>CPU:</b> ${escapeHtml(d.cpuModel)} x${d.cpuCount}`,
|
|
401
|
+
d.cpuUsage
|
|
402
|
+
? `<b>CPU Usage:</b> ${d.cpuUsage.usedPercent.toFixed(1)}% (${d.cpuUsage.userPercent.toFixed(1)}% user, ${d.cpuUsage.sysPercent.toFixed(1)}% sys, ${d.cpuUsage.idlePercent.toFixed(1)}% idle)`
|
|
403
|
+
: '<b>CPU Usage:</b> unavailable',
|
|
404
|
+
`<b>Memory:</b> ${fmtBytes(d.memoryUsed)} / ${fmtBytes(d.totalMem)} (${d.memoryPercent.toFixed(0)}%)`,
|
|
405
|
+
`<b>Available:</b> ${fmtBytes(d.memoryAvailable)}`,
|
|
406
|
+
`<b>Battery:</b> ${d.battery ? `${escapeHtml(d.battery.percent)} (${escapeHtml(d.battery.state)})` : 'unavailable'}`,
|
|
407
|
+
];
|
|
408
|
+
if (d.disk)
|
|
409
|
+
lines.push(`<b>Disk:</b> ${escapeHtml(d.disk.used)} used / ${escapeHtml(d.disk.total)} total (${escapeHtml(d.disk.percent)})`);
|
|
410
|
+
lines.push(`\n<b>Process:</b> PID ${d.selfPid} | RSS ${fmtBytes(d.selfRss)} | Heap ${fmtBytes(d.selfHeap)}`);
|
|
411
|
+
if (d.topProcs.length > 1) {
|
|
412
|
+
lines.push(`\n<b>Top Processes:</b>`);
|
|
413
|
+
lines.push(`<pre>${d.topProcs.map(l => escapeHtml(l)).join('\n')}</pre>`);
|
|
414
|
+
}
|
|
415
|
+
await ctx.reply(lines.join('\n'), { parseMode: 'HTML' });
|
|
416
|
+
}
|
|
417
|
+
async cmdAgents(ctx) {
|
|
418
|
+
await this.sendCommandView(ctx, buildAgentsCommandView(this, ctx.chatId));
|
|
419
|
+
}
|
|
420
|
+
async cmdModels(ctx) {
|
|
421
|
+
await this.sendCommandView(ctx, await buildModelsCommandView(this, ctx.chatId));
|
|
422
|
+
}
|
|
423
|
+
async cmdMode(ctx) {
|
|
424
|
+
await this.sendCommandView(ctx, buildModeCommandView(this, ctx.chatId));
|
|
425
|
+
}
|
|
426
|
+
async cmdRestart(ctx) {
|
|
427
|
+
await ctx.reply(`<b>Restarting pikiloop...</b>\n\n` +
|
|
428
|
+
`The bot will be back shortly.`, { parseMode: 'HTML' });
|
|
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 inline_keyboard = [];
|
|
461
|
+
const optionRows = (question?.options || []).map((option, index) => ([{
|
|
462
|
+
text: `${humanLoopOptionSelected(prompt, option.value) ? '●' : '○'} ${truncateMiddle(option.label, 28)}`,
|
|
463
|
+
callback_data: `hl:o:${promptId}:${index}`,
|
|
464
|
+
}]));
|
|
465
|
+
inline_keyboard.push(...optionRows);
|
|
466
|
+
if (question?.options?.length && question.allowFreeform) {
|
|
467
|
+
inline_keyboard.push([{ text: 'Other...', callback_data: `hl:other:${promptId}` }]);
|
|
468
|
+
}
|
|
469
|
+
if (question?.allowEmpty) {
|
|
470
|
+
inline_keyboard.push([{ text: 'Skip', callback_data: `hl:skip:${promptId}` }]);
|
|
471
|
+
}
|
|
472
|
+
inline_keyboard.push([{ text: 'Cancel', callback_data: `hl:cancel:${promptId}` }]);
|
|
473
|
+
return { inline_keyboard };
|
|
474
|
+
}
|
|
475
|
+
async refreshHumanLoopPrompt(chatId, promptId) {
|
|
476
|
+
const prompt = this.humanLoopPrompt(promptId);
|
|
477
|
+
if (!prompt)
|
|
478
|
+
return;
|
|
479
|
+
const messageId = prompt.messageIds[0];
|
|
480
|
+
if (typeof messageId !== 'number')
|
|
481
|
+
return;
|
|
482
|
+
await this.channel.editMessage(chatId, messageId, buildHumanLoopPromptHtml(prompt), {
|
|
483
|
+
parseMode: 'HTML',
|
|
484
|
+
keyboard: this.buildHumanLoopKeyboard(promptId),
|
|
485
|
+
}).catch(() => { });
|
|
486
|
+
}
|
|
487
|
+
async onInteractionAnswered(prompt, summary) {
|
|
488
|
+
const messageId = prompt.messageIds[0];
|
|
489
|
+
const chatId = prompt.chatId;
|
|
490
|
+
const closedHtml = buildAnsweredHumanLoopPromptHtml(prompt, summary);
|
|
491
|
+
if (typeof messageId === 'number') {
|
|
492
|
+
try {
|
|
493
|
+
await this.channel.editMessage(chatId, messageId, closedHtml, {
|
|
494
|
+
parseMode: 'HTML',
|
|
495
|
+
keyboard: { inline_keyboard: [] },
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
this.debug(`[human-loop] close-card edit failed chat=${chatId} prompt=${prompt.promptId}: ${err?.message || err}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const echoLines = buildInteractionEchoHtml(summary);
|
|
503
|
+
if (!echoLines)
|
|
504
|
+
return;
|
|
505
|
+
try {
|
|
506
|
+
await this.channel.send(chatId, echoLines, { parseMode: 'HTML' });
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
this.debug(`[human-loop] echo send failed chat=${chatId} prompt=${prompt.promptId}: ${err?.message || err}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* IM presenter for programmatic submissions (e.g. /goal-driven turns) so
|
|
514
|
+
* they stream to Telegram the same way a typed message does — placeholder
|
|
515
|
+
* card, LivePreview edits, sendFinalReply at the end. Without this, /goal
|
|
516
|
+
* just emits a confirmation reply and the rest of the turn is invisible.
|
|
517
|
+
*/
|
|
518
|
+
async createImTaskPresenter(opts) {
|
|
519
|
+
if (typeof opts.chatId !== 'number')
|
|
520
|
+
return null;
|
|
521
|
+
const chatId = opts.chatId;
|
|
522
|
+
const startTimeMs = Date.now();
|
|
523
|
+
const startConfig = this.resolveSessionStreamConfig(opts.session);
|
|
524
|
+
const canEditMessages = supportsChannelCapability(this.channel, 'editMessages');
|
|
525
|
+
const canSendTyping = supportsChannelCapability(this.channel, 'typingIndicators');
|
|
526
|
+
let phId = null;
|
|
527
|
+
if (canEditMessages) {
|
|
528
|
+
try {
|
|
529
|
+
const sent = await this.channel.send(chatId, buildInitialPreviewHtml(opts.agent, startConfig.model, startConfig.effort, false), { parseMode: 'HTML' });
|
|
530
|
+
phId = typeof sent === 'number' ? sent : null;
|
|
531
|
+
if (phId != null)
|
|
532
|
+
this.registerSessionMessage(chatId, phId, opts.session);
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
this.debug(`[im-presenter telegram] placeholder send failed chat=${chatId}: ${e?.message || e}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
this.registerTaskPlaceholders(opts.taskId, [phId]);
|
|
539
|
+
let livePreview = null;
|
|
540
|
+
if (phId != null || canSendTyping) {
|
|
541
|
+
livePreview = new LivePreview({
|
|
542
|
+
agent: opts.agent,
|
|
543
|
+
chatId,
|
|
544
|
+
placeholderMessageId: phId,
|
|
545
|
+
channel: this.channel,
|
|
546
|
+
renderer: telegramPreviewRenderer,
|
|
547
|
+
streamEditIntervalMs: opts.agent === 'codex' ? 400 : 800,
|
|
548
|
+
startTimeMs,
|
|
549
|
+
canEditMessages,
|
|
550
|
+
canSendTyping,
|
|
551
|
+
model: startConfig.model,
|
|
552
|
+
effort: startConfig.effort,
|
|
553
|
+
log: (msg) => this.debug(msg),
|
|
554
|
+
});
|
|
555
|
+
livePreview.start();
|
|
556
|
+
}
|
|
557
|
+
const session = opts.session;
|
|
558
|
+
return {
|
|
559
|
+
onText: (text, thinking, activity, meta, plan) => {
|
|
560
|
+
livePreview?.update(text, thinking, activity, meta, plan);
|
|
561
|
+
},
|
|
562
|
+
onSuccess: async (result) => {
|
|
563
|
+
try {
|
|
564
|
+
await livePreview?.settle();
|
|
565
|
+
}
|
|
566
|
+
catch { }
|
|
567
|
+
const finalReply = await this.sendFinalReply({ chatId }, phId, opts.agent, result);
|
|
568
|
+
this.registerSessionMessages(chatId, finalReply.messageIds, session);
|
|
569
|
+
},
|
|
570
|
+
onFailure: async (error) => {
|
|
571
|
+
try {
|
|
572
|
+
await livePreview?.settle();
|
|
573
|
+
}
|
|
574
|
+
catch { }
|
|
575
|
+
const errorHtml = `<b>Error</b>\n\n<code>${escapeHtml(error.slice(0, 500))}</code>`;
|
|
576
|
+
if (phId != null) {
|
|
577
|
+
try {
|
|
578
|
+
await this.channel.editMessage(chatId, phId, errorHtml, { parseMode: 'HTML', keyboard: { inline_keyboard: [] } });
|
|
579
|
+
this.registerSessionMessage(chatId, phId, session);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
catch { }
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
const sent = await this.channel.send(chatId, errorHtml, { parseMode: 'HTML' });
|
|
586
|
+
this.registerSessionMessage(chatId, typeof sent === 'number' ? sent : null, session);
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
this.debug(`[im-presenter telegram] error send failed chat=${chatId}: ${e?.message || e}`);
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
dispose: () => {
|
|
593
|
+
livePreview?.dispose();
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async renderInteractionPrompt(prompt, chatId) {
|
|
598
|
+
const messageThreadId = this.interactionThreadIds.get(prompt.taskId);
|
|
599
|
+
const sent = await this.channel.send(chatId, buildHumanLoopPromptHtml(prompt), {
|
|
600
|
+
parseMode: 'HTML',
|
|
601
|
+
messageThreadId,
|
|
602
|
+
keyboard: this.buildHumanLoopKeyboard(prompt.promptId),
|
|
603
|
+
});
|
|
604
|
+
if (typeof sent === 'number')
|
|
605
|
+
this.registerHumanLoopMessage(prompt.promptId, sent);
|
|
606
|
+
}
|
|
607
|
+
/** Cache the messageThreadId per task so renderInteractionPrompt can use it. */
|
|
608
|
+
interactionThreadIds = new Map();
|
|
609
|
+
// ---- streaming bridge -----------------------------------------------------
|
|
610
|
+
async handleMessage(msg, ctx) {
|
|
611
|
+
const text = msg.text.trim();
|
|
612
|
+
if (!text && !msg.files.length)
|
|
613
|
+
return;
|
|
614
|
+
const pendingPrompt = this.pendingHumanLoopPrompt(ctx.chatId);
|
|
615
|
+
if (pendingPrompt && text && !msg.files.length && !text.startsWith('/')) {
|
|
616
|
+
const result = this.humanLoopSubmitText(ctx.chatId, text);
|
|
617
|
+
if (!result) {
|
|
618
|
+
await ctx.reply('Please answer the active prompt using the buttons above.');
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
// Completed prompts close themselves via onInteractionAnswered.
|
|
622
|
+
if (!result.completed)
|
|
623
|
+
await this.refreshHumanLoopPrompt(ctx.chatId, result.prompt.promptId);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const session = this.resolveIncomingSession(ctx, text, msg.files);
|
|
627
|
+
const cs = this.chat(ctx.chatId);
|
|
628
|
+
this.applySessionSelection(cs, session);
|
|
629
|
+
const messageThreadId = typeof ctx.raw?.message_thread_id === 'number' ? ctx.raw.message_thread_id : undefined;
|
|
630
|
+
if (!text && msg.files.length) {
|
|
631
|
+
const hadPendingWork = this.sessionHasPendingWork(session);
|
|
632
|
+
const stageTask = this.queueSessionTask(session, async () => {
|
|
633
|
+
try {
|
|
634
|
+
if (this.isSourceMessageWithdrawn(ctx.chatId, ctx.messageId)) {
|
|
635
|
+
this.debug(`[handleMessage] skipped withdrawn file stage chat=${ctx.chatId} msg=${ctx.messageId}`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const staged = stageSessionFiles({
|
|
639
|
+
agent: session.agent,
|
|
640
|
+
workdir: session.workdir,
|
|
641
|
+
files: msg.files,
|
|
642
|
+
sessionId: session.sessionId,
|
|
643
|
+
title: undefined,
|
|
644
|
+
threadId: session.threadId,
|
|
645
|
+
});
|
|
646
|
+
session.workspacePath = staged.workspacePath;
|
|
647
|
+
session.threadId = staged.threadId;
|
|
648
|
+
this.syncSelectedChats(session);
|
|
649
|
+
if (!staged.importedFiles.length)
|
|
650
|
+
throw new Error('no files persisted');
|
|
651
|
+
this.debug(`[handleMessage] staged workspace files chat=${ctx.chatId} session=${staged.sessionId} files=${staged.importedFiles.length}`);
|
|
652
|
+
this.registerSessionMessage(ctx.chatId, ctx.messageId, session);
|
|
653
|
+
await this.safeSetMessageReaction(ctx.chatId, ctx.messageId, ['👌']);
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
this.warn(`[handleMessage] stage files failed: ${e?.message || e}`);
|
|
657
|
+
await this.safeSetMessageReaction(ctx.chatId, ctx.messageId, ['⚠️']);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
if (hadPendingWork) {
|
|
661
|
+
void stageTask.catch(e => this.warn(`[handleMessage] stage queue failed: ${e}`));
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
await stageTask.catch(e => this.warn(`[handleMessage] stage queue failed: ${e}`));
|
|
665
|
+
}
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const files = msg.files;
|
|
669
|
+
const prompt = buildPrompt(text, files);
|
|
670
|
+
const start = Date.now();
|
|
671
|
+
const canEditMessages = supportsChannelCapability(this.channel, 'editMessages');
|
|
672
|
+
const canSendTyping = supportsChannelCapability(this.channel, 'typingIndicators');
|
|
673
|
+
this.debug(`[handleMessage] queued chat=${ctx.chatId} agent=${session.agent} session=${session.sessionId || '(new)'} prompt="${prompt.slice(0, 100)}" files=${files.length}`);
|
|
674
|
+
const taskId = this.createTaskId(session);
|
|
675
|
+
this.beginTask({
|
|
676
|
+
taskId,
|
|
677
|
+
chatId: ctx.chatId,
|
|
678
|
+
agent: session.agent,
|
|
679
|
+
sessionKey: session.key,
|
|
680
|
+
prompt,
|
|
681
|
+
attachments: files,
|
|
682
|
+
startedAt: start,
|
|
683
|
+
sourceMessageId: ctx.messageId,
|
|
684
|
+
});
|
|
685
|
+
this.emitStreamQueued(session.key, taskId);
|
|
686
|
+
const waiting = this.sessionHasPendingWork(session);
|
|
687
|
+
const queuePosition = waiting ? this.getQueuePosition(session.key, taskId) : 0;
|
|
688
|
+
const placeholderKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId), { queued: waiting });
|
|
689
|
+
const startConfig = this.resolveSessionStreamConfig(session);
|
|
690
|
+
let phId = null;
|
|
691
|
+
if (canEditMessages) {
|
|
692
|
+
const placeholderId = await ctx.reply(buildInitialPreviewHtml(session.agent, startConfig.model, startConfig.effort, waiting, queuePosition), { parseMode: 'HTML', messageThreadId, keyboard: placeholderKeyboard });
|
|
693
|
+
phId = typeof placeholderId === 'number' ? placeholderId : null;
|
|
694
|
+
if (phId != null) {
|
|
695
|
+
this.registerSessionMessage(ctx.chatId, phId, session);
|
|
696
|
+
this.debug(`[handleMessage] placeholder sent msg_id=${phId}, task queued`);
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
this.debug(`[handleMessage] placeholder unavailable for chat=${ctx.chatId}; continuing without live preview`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
this.debug(`[handleMessage] skipping placeholder for chat=${ctx.chatId}; channel does not support message edits`);
|
|
704
|
+
}
|
|
705
|
+
this.registerTaskPlaceholders(taskId, [phId]);
|
|
706
|
+
void this.queueSessionTask(session, async () => {
|
|
707
|
+
let livePreview = null;
|
|
708
|
+
let task = null;
|
|
709
|
+
const abortController = new AbortController();
|
|
710
|
+
try {
|
|
711
|
+
task = this.markTaskRunning(taskId, () => abortController.abort());
|
|
712
|
+
if (!task || task.cancelled) {
|
|
713
|
+
this.emitStreamCancelled(taskId, session.key);
|
|
714
|
+
if (phId != null) {
|
|
715
|
+
try {
|
|
716
|
+
await this.channel.deleteMessage(ctx.chatId, phId);
|
|
717
|
+
}
|
|
718
|
+
catch { }
|
|
719
|
+
}
|
|
720
|
+
this.debug(`[handleMessage] skipped cancelled queued task chat=${ctx.chatId} msg=${ctx.messageId}`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
this.emitStreamStart(taskId, session);
|
|
724
|
+
// Task is now running — update keyboard from Recall/Steer to Stop
|
|
725
|
+
const runningKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
|
|
726
|
+
if (phId != null && waiting) {
|
|
727
|
+
try {
|
|
728
|
+
await this.channel.editMessage(ctx.chatId, phId, buildInitialPreviewHtml(session.agent, startConfig.model, startConfig.effort, false), { parseMode: 'HTML', keyboard: runningKeyboard });
|
|
729
|
+
}
|
|
730
|
+
catch { }
|
|
731
|
+
}
|
|
732
|
+
if (phId != null || canSendTyping) {
|
|
733
|
+
livePreview = new LivePreview({
|
|
734
|
+
agent: session.agent,
|
|
735
|
+
chatId: ctx.chatId,
|
|
736
|
+
placeholderMessageId: phId,
|
|
737
|
+
channel: this.channel,
|
|
738
|
+
renderer: telegramPreviewRenderer,
|
|
739
|
+
streamEditIntervalMs: session.agent === 'codex' ? 400 : 800,
|
|
740
|
+
startTimeMs: start,
|
|
741
|
+
canEditMessages,
|
|
742
|
+
canSendTyping,
|
|
743
|
+
messageThreadId,
|
|
744
|
+
keyboard: runningKeyboard,
|
|
745
|
+
model: startConfig.model,
|
|
746
|
+
effort: startConfig.effort,
|
|
747
|
+
log: (message) => this.debug(message),
|
|
748
|
+
});
|
|
749
|
+
livePreview.start();
|
|
750
|
+
}
|
|
751
|
+
// MCP sendFile callback: sends files to IM in real-time during the stream
|
|
752
|
+
const mcpSendFile = this.createMcpSendFileCallback(ctx, messageThreadId);
|
|
753
|
+
this.interactionThreadIds.set(taskId, messageThreadId);
|
|
754
|
+
const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
|
|
755
|
+
livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
|
|
756
|
+
this.emitStreamText(taskId, session.key, nextText, nextThinking, nextActivity, meta, plan);
|
|
757
|
+
}, undefined, mcpSendFile, abortController.signal, this.createInteractionHandler(ctx.chatId, taskId), (steer) => {
|
|
758
|
+
const currentTask = this.activeTasks.get(taskId);
|
|
759
|
+
if (!currentTask || currentTask.cancelled || currentTask.status !== 'running')
|
|
760
|
+
return;
|
|
761
|
+
currentTask.steer = steer;
|
|
762
|
+
});
|
|
763
|
+
await livePreview?.settle();
|
|
764
|
+
if (task?.freezePreviewOnAbort && result.stopReason === 'interrupted') {
|
|
765
|
+
this.emitStreamDone(taskId, session.key, {
|
|
766
|
+
sessionId: result.sessionId || session.sessionId,
|
|
767
|
+
incomplete: true,
|
|
768
|
+
});
|
|
769
|
+
const frozenMessageIds = await this.freezeSteerHandoffPreview(ctx, phId, livePreview);
|
|
770
|
+
this.registerSessionMessages(ctx.chatId, frozenMessageIds, session);
|
|
771
|
+
this.debug(`[handleMessage] steer handoff preserved previous preview chat=${ctx.chatId} task=${taskId}`);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
this.emitStreamDone(taskId, session.key, {
|
|
775
|
+
sessionId: result.sessionId || session.sessionId,
|
|
776
|
+
incomplete: !!result.incomplete,
|
|
777
|
+
...(result.ok ? {} : { error: result.error || result.message }),
|
|
778
|
+
});
|
|
779
|
+
this.debug(`[handleMessage] done agent=${session.agent} ok=${result.ok} session=${result.sessionId || '?'} elapsed=${result.elapsedS.toFixed(1)}s edits=${livePreview?.getEditCount() || 0} ` +
|
|
780
|
+
`tokens=in:${fmtTokens(result.inputTokens)}/cached:${fmtTokens(result.cachedInputTokens)}/out:${fmtTokens(result.outputTokens)}`);
|
|
781
|
+
this.debug(`[handleMessage] response preview: "${result.message.slice(0, 150)}"`);
|
|
782
|
+
const finalReply = await this.sendFinalReply({ chatId: ctx.chatId, replyTo: ctx.messageId, messageThreadId }, phId, session.agent, result);
|
|
783
|
+
this.registerSessionMessages(ctx.chatId, finalReply.messageIds, session);
|
|
784
|
+
this.debug(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
|
|
785
|
+
}
|
|
786
|
+
catch (e) {
|
|
787
|
+
if (task?.freezePreviewOnAbort && abortController.signal.aborted) {
|
|
788
|
+
this.emitStreamDone(taskId, session.key, {
|
|
789
|
+
sessionId: session.sessionId,
|
|
790
|
+
incomplete: true,
|
|
791
|
+
});
|
|
792
|
+
const frozenMessageIds = await this.freezeSteerHandoffPreview(ctx, phId, livePreview);
|
|
793
|
+
this.registerSessionMessages(ctx.chatId, frozenMessageIds, session);
|
|
794
|
+
this.debug(`[handleMessage] steer handoff preserved preview after abort chat=${ctx.chatId} task=${taskId}`);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const msgText = String(e?.message || e || 'Unknown error');
|
|
798
|
+
this.emitStreamDone(taskId, session.key, {
|
|
799
|
+
sessionId: session.sessionId,
|
|
800
|
+
incomplete: true,
|
|
801
|
+
error: msgText,
|
|
802
|
+
});
|
|
803
|
+
this.warn(`[handleMessage] task failed chat=${ctx.chatId} session=${session.sessionId} error=${msgText}`);
|
|
804
|
+
const errorHtml = `<b>Error</b>\n\n<code>${escapeHtml(msgText.slice(0, 500))}</code>`;
|
|
805
|
+
if (phId != null) {
|
|
806
|
+
try {
|
|
807
|
+
await this.channel.editMessage(ctx.chatId, phId, errorHtml, { parseMode: 'HTML', keyboard: { inline_keyboard: [] } });
|
|
808
|
+
this.registerSessionMessage(ctx.chatId, phId, session);
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
const sent = await this.channel.send(ctx.chatId, errorHtml, { parseMode: 'HTML', replyTo: ctx.messageId, messageThreadId }).catch(() => null);
|
|
812
|
+
this.registerSessionMessage(ctx.chatId, typeof sent === 'number' ? sent : null, session);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
const sent = await this.channel.send(ctx.chatId, errorHtml, { parseMode: 'HTML', replyTo: ctx.messageId, messageThreadId }).catch(() => null);
|
|
817
|
+
this.registerSessionMessage(ctx.chatId, typeof sent === 'number' ? sent : null, session);
|
|
818
|
+
}
|
|
819
|
+
await this.safeSetMessageReaction(ctx.chatId, ctx.messageId, ['⚠️']);
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
livePreview?.dispose();
|
|
823
|
+
this.interactionThreadIds.delete(taskId);
|
|
824
|
+
this.finishTask(taskId);
|
|
825
|
+
this.syncSelectedChats(session);
|
|
826
|
+
}
|
|
827
|
+
}, taskId).catch(e => {
|
|
828
|
+
this.warn(`[handleMessage] queue execution failed: ${e}`);
|
|
829
|
+
this.finishTask(taskId);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
async freezeSteerHandoffPreview(ctx, phId, livePreview) {
|
|
833
|
+
if (phId == null)
|
|
834
|
+
return [];
|
|
835
|
+
const previewHtml = livePreview?.getRenderedPreview()?.trim() || '';
|
|
836
|
+
if (!previewHtml)
|
|
837
|
+
return [phId];
|
|
838
|
+
try {
|
|
839
|
+
await this.channel.editMessage(ctx.chatId, phId, previewHtml, {
|
|
840
|
+
parseMode: 'HTML',
|
|
841
|
+
keyboard: { inline_keyboard: [] },
|
|
842
|
+
});
|
|
843
|
+
return [phId];
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
return [];
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/** Create an MCP sendFile callback bound to a Telegram chat context. */
|
|
850
|
+
createMcpSendFileCallback(ctx, messageThreadId) {
|
|
851
|
+
return async (filePath, opts) => {
|
|
852
|
+
try {
|
|
853
|
+
await this.channel.sendFile(ctx.chatId, filePath, {
|
|
854
|
+
caption: opts?.caption,
|
|
855
|
+
replyTo: ctx.messageId,
|
|
856
|
+
messageThreadId,
|
|
857
|
+
asPhoto: opts?.kind === 'photo',
|
|
858
|
+
});
|
|
859
|
+
return { ok: true };
|
|
860
|
+
}
|
|
861
|
+
catch (e) {
|
|
862
|
+
this.log(`[mcp] sendFile failed: ${filePath} error=${e?.message || e}`);
|
|
863
|
+
return { ok: false, error: e?.message || 'send failed' };
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
async safeSetMessageReaction(chatId, messageId, reactions) {
|
|
868
|
+
if (!supportsChannelCapability(this.channel, 'messageReactions'))
|
|
869
|
+
return;
|
|
870
|
+
const setReaction = this.channel?.setMessageReaction;
|
|
871
|
+
if (typeof setReaction !== 'function')
|
|
872
|
+
return;
|
|
873
|
+
try {
|
|
874
|
+
await setReaction.call(this.channel, chatId, messageId, reactions);
|
|
875
|
+
}
|
|
876
|
+
catch { }
|
|
877
|
+
}
|
|
878
|
+
async sendFinalReply(anchor, phId, agent, result) {
|
|
879
|
+
const rendered = buildFinalReplyRender(agent, result);
|
|
880
|
+
const messageIds = [];
|
|
881
|
+
const remember = (messageId) => {
|
|
882
|
+
if (typeof messageId === 'number' && !messageIds.includes(messageId))
|
|
883
|
+
messageIds.push(messageId);
|
|
884
|
+
return messageId;
|
|
885
|
+
};
|
|
886
|
+
const sendFinalText = (text, replyTo) => this.channel.send(anchor.chatId, text, {
|
|
887
|
+
parseMode: 'HTML',
|
|
888
|
+
replyTo: replyTo ?? anchor.replyTo,
|
|
889
|
+
messageThreadId: anchor.messageThreadId,
|
|
890
|
+
});
|
|
891
|
+
const replacePreview = async (text) => {
|
|
892
|
+
if (phId != null) {
|
|
893
|
+
try {
|
|
894
|
+
await this.channel.editMessage(anchor.chatId, phId, text, { parseMode: 'HTML', keyboard: { inline_keyboard: [] } });
|
|
895
|
+
return remember(phId);
|
|
896
|
+
}
|
|
897
|
+
catch { }
|
|
898
|
+
}
|
|
899
|
+
return remember(await sendFinalText(text));
|
|
900
|
+
};
|
|
901
|
+
let finalMsgId = phId;
|
|
902
|
+
if (rendered.fullHtml.length <= 3900) {
|
|
903
|
+
finalMsgId = await replacePreview(rendered.fullHtml);
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
// Split: header on first message, footer on last message
|
|
907
|
+
const maxFirst = 3900 - rendered.headerHtml.length;
|
|
908
|
+
let firstBody;
|
|
909
|
+
let remaining;
|
|
910
|
+
if (maxFirst > 200) {
|
|
911
|
+
let cut = rendered.bodyHtml.lastIndexOf('\n', maxFirst);
|
|
912
|
+
if (cut < maxFirst * 0.3)
|
|
913
|
+
cut = maxFirst;
|
|
914
|
+
firstBody = rendered.bodyHtml.slice(0, cut);
|
|
915
|
+
remaining = rendered.bodyHtml.slice(cut);
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
firstBody = '';
|
|
919
|
+
remaining = rendered.bodyHtml;
|
|
920
|
+
}
|
|
921
|
+
if (remaining.trim()) {
|
|
922
|
+
// Multi-message: header on first, footer on last
|
|
923
|
+
const firstHtml = `${rendered.headerHtml}${firstBody}`;
|
|
924
|
+
finalMsgId = await replacePreview(firstHtml);
|
|
925
|
+
const chunks = splitText(remaining, 3800);
|
|
926
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
927
|
+
const isLast = i === chunks.length - 1;
|
|
928
|
+
const chunkText = isLast ? `${chunks[i]}${rendered.footerHtml}` : chunks[i];
|
|
929
|
+
remember(await sendFinalText(chunkText, finalMsgId ?? phId ?? anchor.replyTo));
|
|
930
|
+
}
|
|
931
|
+
// Safety: re-clear the Stop keyboard on the placeholder in case the first edit silently failed
|
|
932
|
+
if (phId != null) {
|
|
933
|
+
try {
|
|
934
|
+
await this.channel.editMessage(anchor.chatId, phId, firstHtml || '(done)', { parseMode: 'HTML', keyboard: { inline_keyboard: [] } });
|
|
935
|
+
}
|
|
936
|
+
catch { }
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
// Body fits on first message; only footer pushes it over — keep together
|
|
941
|
+
const firstHtml = `${rendered.headerHtml}${firstBody}${rendered.footerHtml}`;
|
|
942
|
+
finalMsgId = await replacePreview(firstHtml);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// Dispatch any image MessageBlocks the agent produced this turn (Codex
|
|
946
|
+
// built-in `image_gen`, MCP / Skill tool_result images, …). Each goes out
|
|
947
|
+
// as a separate Telegram photo with the optional caption attached.
|
|
948
|
+
const dispatched = await dispatchImageBlocks(this.channel, result.assistantBlocks, {
|
|
949
|
+
chatId: anchor.chatId,
|
|
950
|
+
replyTo: finalMsgId ?? phId ?? anchor.replyTo,
|
|
951
|
+
messageThreadId: anchor.messageThreadId,
|
|
952
|
+
log: (message) => this.debug(message),
|
|
953
|
+
});
|
|
954
|
+
for (const entry of dispatched) {
|
|
955
|
+
if (typeof entry.messageId === 'number')
|
|
956
|
+
remember(entry.messageId);
|
|
957
|
+
}
|
|
958
|
+
return { primaryMessageId: finalMsgId, messageIds };
|
|
959
|
+
}
|
|
960
|
+
// ---- callbacks ------------------------------------------------------------
|
|
961
|
+
async handleSwitchNavigateCallback(data, ctx) {
|
|
962
|
+
if (!data.startsWith('sw:n:'))
|
|
963
|
+
return false;
|
|
964
|
+
const [pathId, pageRaw] = data.slice(5).split(':');
|
|
965
|
+
const browsePath = resolveRegisteredPath(parseInt(pathId, 10));
|
|
966
|
+
if (!browsePath) {
|
|
967
|
+
await ctx.answerCallback('Expired, use /switch again');
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
const wd = this.chatWorkdir(ctx.chatId);
|
|
971
|
+
const view = buildSwitchWorkdirView(wd, browsePath, parseInt(pageRaw, 10) || 0);
|
|
972
|
+
await ctx.editReply(ctx.messageId, view.text, { parseMode: 'HTML', keyboard: view.keyboard });
|
|
973
|
+
await ctx.answerCallback();
|
|
974
|
+
return true;
|
|
975
|
+
}
|
|
976
|
+
async handleSwitchSelectCallback(data, ctx) {
|
|
977
|
+
if (!data.startsWith('sw:s:'))
|
|
978
|
+
return false;
|
|
979
|
+
const dirPath = resolveRegisteredPath(parseInt(data.slice(5), 10));
|
|
980
|
+
if (!dirPath) {
|
|
981
|
+
await ctx.answerCallback('Expired, use /switch again');
|
|
982
|
+
return true;
|
|
983
|
+
}
|
|
984
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
985
|
+
await ctx.answerCallback('Not a valid directory');
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
const oldPath = this.switchWorkdir(dirPath);
|
|
989
|
+
await ctx.answerCallback('Switched!');
|
|
990
|
+
await ctx.editReply(ctx.messageId, `<b>Workdir</b>\n● <code>${escapeHtml(truncateMiddle(oldPath, 42))}</code>\n→ <code>${escapeHtml(truncateMiddle(dirPath, 42))}</code>`, { parseMode: 'HTML' });
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
993
|
+
async handleWorkspacesCallback(data, ctx) {
|
|
994
|
+
if (data.startsWith('wsp:p:')) {
|
|
995
|
+
const page = parseInt(data.slice('wsp:p:'.length), 10) || 0;
|
|
996
|
+
const view = buildWorkspacesView(getWorkspacesData(this, ctx.chatId), page);
|
|
997
|
+
await ctx.editReply(ctx.messageId, view.text, { parseMode: 'HTML', keyboard: view.keyboard });
|
|
998
|
+
await ctx.answerCallback();
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
if (data.startsWith('wsp:s:')) {
|
|
1002
|
+
const dirPath = resolveRegisteredPath(parseInt(data.slice('wsp:s:'.length), 10));
|
|
1003
|
+
if (!dirPath) {
|
|
1004
|
+
await ctx.answerCallback('Expired, use /workspaces again');
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
1008
|
+
await ctx.answerCallback('Workspace path is missing on disk');
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
const oldPath = this.switchWorkdir(dirPath);
|
|
1012
|
+
await ctx.answerCallback('Switched!');
|
|
1013
|
+
await ctx.editReply(ctx.messageId, `<b>Workdir</b>\n● <code>${escapeHtml(truncateMiddle(oldPath, 42))}</code>\n→ <code>${escapeHtml(truncateMiddle(dirPath, 42))}</code>`, { parseMode: 'HTML' });
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
async handleSessionsPageCallback(data, ctx) {
|
|
1019
|
+
const action = decodeCommandAction(data);
|
|
1020
|
+
if (!action)
|
|
1021
|
+
return false;
|
|
1022
|
+
const result = await executeCommandAction(this, ctx.chatId, action, {
|
|
1023
|
+
sessionsPageSize: this.sessionsPageSize,
|
|
1024
|
+
});
|
|
1025
|
+
await this.applyCommandCallbackResult(ctx, result);
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
async handleTaskStopCallback(data, ctx) {
|
|
1029
|
+
if (!data.startsWith('tsk:stop:'))
|
|
1030
|
+
return false;
|
|
1031
|
+
const actionId = data.slice('tsk:stop:'.length).trim();
|
|
1032
|
+
const result = this.stopTaskByActionId(actionId);
|
|
1033
|
+
if (!result.task) {
|
|
1034
|
+
await ctx.answerCallback('This task already finished.');
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
if (result.cancelled) {
|
|
1038
|
+
try {
|
|
1039
|
+
await this.channel.deleteMessage(ctx.chatId, ctx.messageId);
|
|
1040
|
+
}
|
|
1041
|
+
catch { }
|
|
1042
|
+
await ctx.answerCallback('Queued task cancelled.');
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
if (result.interrupted) {
|
|
1046
|
+
await ctx.answerCallback('Stopping...');
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
await ctx.answerCallback('Nothing to stop.');
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
async handleTaskSteerCallback(data, ctx) {
|
|
1053
|
+
if (!data.startsWith('tsk:steer:'))
|
|
1054
|
+
return false;
|
|
1055
|
+
const actionId = data.slice('tsk:steer:'.length).trim();
|
|
1056
|
+
const result = await this.steerTaskByActionId(actionId);
|
|
1057
|
+
if (!result.task) {
|
|
1058
|
+
await ctx.answerCallback('This task already finished.');
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
if (result.task.status !== 'queued') {
|
|
1062
|
+
await ctx.answerCallback('Task is already running.');
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
await ctx.answerCallback(result.interrupted ? 'Steering — switching to the queued reply...' : 'No running task to interrupt.');
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
async handleHumanLoopCallback(data, ctx) {
|
|
1069
|
+
if (!data.startsWith('hl:'))
|
|
1070
|
+
return false;
|
|
1071
|
+
const [, action, promptId, rawIndex] = data.split(':');
|
|
1072
|
+
const prompt = this.humanLoopPrompt(promptId);
|
|
1073
|
+
if (!prompt) {
|
|
1074
|
+
await ctx.answerCallback('This prompt is no longer active.');
|
|
1075
|
+
return true;
|
|
1076
|
+
}
|
|
1077
|
+
if (action === 'cancel') {
|
|
1078
|
+
this.humanLoopCancel(promptId, 'Prompt cancelled from Telegram.');
|
|
1079
|
+
await ctx.answerCallback('Cancelled.');
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
if (action === 'skip') {
|
|
1083
|
+
const result = this.humanLoopSkip(promptId);
|
|
1084
|
+
if (!result) {
|
|
1085
|
+
await ctx.answerCallback('This prompt is no longer active.');
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
if (!result.completed)
|
|
1089
|
+
await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
|
|
1090
|
+
await ctx.answerCallback(result.completed ? 'Submitted.' : 'Skipped.');
|
|
1091
|
+
return true;
|
|
1092
|
+
}
|
|
1093
|
+
if (action === 'other') {
|
|
1094
|
+
const result = this.humanLoopSelectOption(promptId, '__other__', { requestFreeform: true });
|
|
1095
|
+
if (!result) {
|
|
1096
|
+
await ctx.answerCallback('This prompt is no longer active.');
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
|
|
1100
|
+
await ctx.answerCallback('Reply with text to continue.');
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
if (action === 'o') {
|
|
1104
|
+
const index = Number.parseInt(rawIndex || '', 10);
|
|
1105
|
+
const question = this.humanLoopCurrentQuestion(promptId);
|
|
1106
|
+
const option = Number.isFinite(index) ? question?.options?.[index] : null;
|
|
1107
|
+
if (!option) {
|
|
1108
|
+
await ctx.answerCallback('Option expired.');
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
const result = this.humanLoopSelectOption(promptId, option.value);
|
|
1112
|
+
if (!result) {
|
|
1113
|
+
await ctx.answerCallback('This prompt is no longer active.');
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
if (!result.completed)
|
|
1117
|
+
await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
|
|
1118
|
+
await ctx.answerCallback(result.completed ? 'Submitted.' : 'Recorded.');
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
await ctx.answerCallback();
|
|
1122
|
+
return true;
|
|
1123
|
+
}
|
|
1124
|
+
async handleCallback(data, ctx) {
|
|
1125
|
+
if (await this.handleHumanLoopCallback(data, ctx))
|
|
1126
|
+
return;
|
|
1127
|
+
if (await this.handleTaskStopCallback(data, ctx))
|
|
1128
|
+
return;
|
|
1129
|
+
if (await this.handleTaskSteerCallback(data, ctx))
|
|
1130
|
+
return;
|
|
1131
|
+
if (await this.handleSwitchNavigateCallback(data, ctx))
|
|
1132
|
+
return;
|
|
1133
|
+
if (await this.handleSwitchSelectCallback(data, ctx))
|
|
1134
|
+
return;
|
|
1135
|
+
if (await this.handleWorkspacesCallback(data, ctx))
|
|
1136
|
+
return;
|
|
1137
|
+
if (await this.handleSessionsPageCallback(data, ctx))
|
|
1138
|
+
return;
|
|
1139
|
+
await ctx.answerCallback();
|
|
1140
|
+
}
|
|
1141
|
+
async previewCurrentSessionTurn(chatId, agent, sessionId) {
|
|
1142
|
+
try {
|
|
1143
|
+
const preview = await getSessionTurnPreviewData(this, agent, sessionId, 50, this.chatWorkdir(chatId));
|
|
1144
|
+
if (!preview)
|
|
1145
|
+
return;
|
|
1146
|
+
const previewHtml = renderSessionTurnHtml(preview.userText, preview.assistantText);
|
|
1147
|
+
if (!previewHtml)
|
|
1148
|
+
return;
|
|
1149
|
+
const sent = await this.channel.send(chatId, previewHtml, { parseMode: 'HTML' });
|
|
1150
|
+
if (sessionId) {
|
|
1151
|
+
const runtime = this.getSessionRuntimeByKey(this.sessionKey(agent, sessionId));
|
|
1152
|
+
if (runtime && typeof sent === 'number')
|
|
1153
|
+
this.registerSessionMessage(chatId, sent, runtime);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
catch {
|
|
1157
|
+
// non-critical
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
// ---- command router -------------------------------------------------------
|
|
1161
|
+
async handleCommand(cmd, args, ctx) {
|
|
1162
|
+
try {
|
|
1163
|
+
switch (cmd) {
|
|
1164
|
+
case 'start':
|
|
1165
|
+
await this.cmdStart(ctx);
|
|
1166
|
+
return;
|
|
1167
|
+
case 'sessions':
|
|
1168
|
+
await this.cmdSessions(ctx);
|
|
1169
|
+
return;
|
|
1170
|
+
case 'agents':
|
|
1171
|
+
await this.cmdAgents(ctx);
|
|
1172
|
+
return;
|
|
1173
|
+
case 'models':
|
|
1174
|
+
await this.cmdModels(ctx);
|
|
1175
|
+
return;
|
|
1176
|
+
case 'mode':
|
|
1177
|
+
await this.cmdMode(ctx);
|
|
1178
|
+
return;
|
|
1179
|
+
case 'skills':
|
|
1180
|
+
await this.cmdSkills(ctx);
|
|
1181
|
+
return;
|
|
1182
|
+
case 'ext':
|
|
1183
|
+
await this.cmdExt(ctx);
|
|
1184
|
+
return;
|
|
1185
|
+
case 'goal':
|
|
1186
|
+
await this.cmdGoal(ctx, args);
|
|
1187
|
+
return;
|
|
1188
|
+
case 'stop':
|
|
1189
|
+
await this.cmdStop(ctx);
|
|
1190
|
+
return;
|
|
1191
|
+
case 'status':
|
|
1192
|
+
await this.cmdStatus(ctx);
|
|
1193
|
+
return;
|
|
1194
|
+
case 'host':
|
|
1195
|
+
await this.cmdHost(ctx);
|
|
1196
|
+
return;
|
|
1197
|
+
case 'switch':
|
|
1198
|
+
await this.cmdSwitch(ctx);
|
|
1199
|
+
return;
|
|
1200
|
+
case 'workspaces':
|
|
1201
|
+
await this.cmdWorkspaces(ctx);
|
|
1202
|
+
return;
|
|
1203
|
+
case 'restart':
|
|
1204
|
+
await this.cmdRestart(ctx);
|
|
1205
|
+
return;
|
|
1206
|
+
default:
|
|
1207
|
+
// Intercept skill commands (sk_<name>) and route to agent
|
|
1208
|
+
if (cmd.startsWith(TelegramBot.SKILL_CMD_PREFIX)) {
|
|
1209
|
+
await this.cmdSkill(cmd, args, ctx);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
await this.handleMessage({ text: `/${cmd}${args ? ' ' + args : ''}`, files: [] }, ctx);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
catch (e) {
|
|
1216
|
+
this.log(`cmd error: ${e}`);
|
|
1217
|
+
await ctx.reply(`Error: ${String(e).slice(0, 200)}`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
/** Execute a project-defined skill by routing it to the current agent. */
|
|
1221
|
+
async cmdSkill(cmd, args, ctx) {
|
|
1222
|
+
const resolved = resolveSkillPrompt(this, ctx.chatId, cmd, args);
|
|
1223
|
+
if (!resolved) {
|
|
1224
|
+
await ctx.reply(`Skill not found for command /${cmd} in:\n<code>${escapeHtml(this.chatWorkdir(ctx.chatId))}</code>`, { parseMode: 'HTML' });
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
this.log(`skill: ${resolved.skillName} agent=${this.chat(ctx.chatId).agent}${args.trim() ? ` args="${args.trim()}"` : ''}`);
|
|
1228
|
+
await this.handleMessage({ text: resolved.prompt, files: [] }, ctx);
|
|
1229
|
+
}
|
|
1230
|
+
// ---- lifecycle ------------------------------------------------------------
|
|
1231
|
+
async run() {
|
|
1232
|
+
const tmpDir = path.join(os.tmpdir(), 'pikiloop');
|
|
1233
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
1234
|
+
this.channel = new TelegramChannel({
|
|
1235
|
+
token: this.token,
|
|
1236
|
+
workdir: tmpDir,
|
|
1237
|
+
allowedChatIds: this.allowedChatIds.size ? this.allowedChatIds : undefined,
|
|
1238
|
+
});
|
|
1239
|
+
this.processRuntimeCleanup?.();
|
|
1240
|
+
this.processRuntimeCleanup = registerProcessRuntime({
|
|
1241
|
+
label: 'telegram',
|
|
1242
|
+
getActiveTaskCount: () => this.activeTasks.size,
|
|
1243
|
+
prepareForRestart: () => this.cleanupRuntimeForExit(),
|
|
1244
|
+
buildRestartEnv: () => this.buildRestartEnv(),
|
|
1245
|
+
});
|
|
1246
|
+
this.installSignalHandlers();
|
|
1247
|
+
try {
|
|
1248
|
+
const bot = await this.channel.connect();
|
|
1249
|
+
this.connected = true;
|
|
1250
|
+
this.log(`bot: @${bot.username} (id=${bot.id})`);
|
|
1251
|
+
this.channel.skipPendingUpdatesOnNextListen();
|
|
1252
|
+
// Seed knownChats so setupMenu applies per-chat commands and the startup
|
|
1253
|
+
// greeting can reach them: the explicit allowlist + persisted known chats
|
|
1254
|
+
// (restored here instead of via allowedChatIds, which stays explicit-only).
|
|
1255
|
+
for (const cid of this.allowedChatIds)
|
|
1256
|
+
if (typeof cid === 'number')
|
|
1257
|
+
this.channel.knownChats.add(cid);
|
|
1258
|
+
for (const id of loadKnownChatIds('telegram')) {
|
|
1259
|
+
for (const parsed of parseAllowedChatIds(id)) {
|
|
1260
|
+
if (typeof parsed === 'number')
|
|
1261
|
+
this.channel.knownChats.add(parsed);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
for (const ag of this.fetchAgents().agents) {
|
|
1265
|
+
this.log(`agent ${ag.agent}: ${ag.path || 'NOT FOUND'}`);
|
|
1266
|
+
}
|
|
1267
|
+
this.log(`config: agent=${this.defaultAgent} workdir=${this.workdir} timeout=${this.runTimeout}s`);
|
|
1268
|
+
this.channel.onCommand((cmd, args, ctx) => this.handleCommand(cmd, args, ctx));
|
|
1269
|
+
this.channel.onMessage((msg, ctx) => this.handleMessage(msg, ctx));
|
|
1270
|
+
this.channel.onCallback((data, ctx) => this.handleCallback(data, ctx));
|
|
1271
|
+
this.channel.onError(err => this.log(`error: ${err}`));
|
|
1272
|
+
this.startKeepAlive();
|
|
1273
|
+
void this.setupMenu().catch(err => this.log(`menu setup failed: ${err}`));
|
|
1274
|
+
void this.sendStartupNotice().catch(err => this.log(`startup notice failed: ${err}`));
|
|
1275
|
+
this.log('✓ Telegram connected, polling started — ready to receive messages');
|
|
1276
|
+
await this.channel.listen();
|
|
1277
|
+
this.stopKeepAlive();
|
|
1278
|
+
this.log('stopped');
|
|
1279
|
+
}
|
|
1280
|
+
finally {
|
|
1281
|
+
this.stopKeepAlive();
|
|
1282
|
+
this.clearShutdownForceExitTimer();
|
|
1283
|
+
this.removeSignalHandlers();
|
|
1284
|
+
this.processRuntimeCleanup?.();
|
|
1285
|
+
this.processRuntimeCleanup = null;
|
|
1286
|
+
if (this.shutdownInFlight)
|
|
1287
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
async sendStartupNotice() {
|
|
1291
|
+
const targets = new Set(this.allowedChatIds);
|
|
1292
|
+
for (const cid of this.channel.knownChats)
|
|
1293
|
+
targets.add(cid);
|
|
1294
|
+
if (!targets.size) {
|
|
1295
|
+
this.log('no known chats for startup notice');
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
for (const cid of targets) {
|
|
1299
|
+
try {
|
|
1300
|
+
const d = getStartData(this, cid);
|
|
1301
|
+
const text = this.renderStartHtml(d);
|
|
1302
|
+
await this.channel.send(cid, text, { parseMode: 'HTML' });
|
|
1303
|
+
this.log(`startup notice sent to chat=${cid}`);
|
|
1304
|
+
}
|
|
1305
|
+
catch (e) {
|
|
1306
|
+
this.log(`startup notice failed for chat=${cid}: ${e}`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|