pikiclaw 0.2.49 → 0.2.51

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/README.md CHANGED
@@ -2,13 +2,11 @@
2
2
 
3
3
  # pikiclaw
4
4
 
5
- **Run Claude Code / Codex / Gemini on your own computer from Telegram or Feishu.**
5
+ **Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via best IM.**
6
6
 
7
- *把 IM 变成你电脑上的远程 Agent 控制台。*
7
+ *让最好用的 IM 变成你电脑上的顶级 Agent 控制台*
8
8
 
9
- ```bash
10
- npx pikiclaw@latest
11
- ```
9
+ > npx pikiclaw@latest
12
10
 
13
11
  <p align="center">
14
12
  <a href="https://www.npmjs.com/package/pikiclaw"><img src="https://img.shields.io/npm/v/pikiclaw" alt="npm"></a>
@@ -92,48 +90,48 @@ npx pikiclaw@latest --doctor
92
90
 
93
91
  ---
94
92
 
95
- ## What Exists Today
96
-
97
- ### Channels
93
+ ## Current Capabilities
98
94
 
99
- - Telegram 已可用
100
- - 飞书已可用
101
- - 两个渠道可以同时启动
95
+ ### Channels And Agents
102
96
 
103
- ### Agents
104
-
105
- - Claude Code
106
- - Codex CLI
107
- - Gemini CLI
108
-
109
- Agent 通过 driver registry 接入,模型列表、会话列表、usage 展示都走统一接口。
97
+ - Telegram、飞书都可用,也可以同时启动
98
+ - Claude Code、Codex CLI、Gemini CLI 都已接入
99
+ - agent 通过统一 driver registry 管理,模型列表、session 列表、usage 展示走同一套接口
110
100
 
111
101
  ### Runtime
112
102
 
113
103
  - 流式预览和持续消息更新
114
104
  - 会话切换、恢复和多轮续聊
115
105
  - 工作目录浏览与切换
116
- - 长任务防休眠
117
- - watchdog 守护和自动重启
118
- - 长文本自动拆分,文件和图片自动回传
106
+ - 文件附件自动进入 session workspace
107
+ - 长任务防休眠、watchdog 守护和自动重启
108
+ - 长文本自动拆分,图片和文件可直接回传到 IM
109
+ - Dashboard 可查看运行状态、sessions、usage、主机状态和 macOS 权限状态
119
110
 
120
111
  ### Skills
121
112
 
122
- - 支持项目级 `.pikiclaw/skills/*/SKILL.md`
113
+ - 项目级 skills 以 `.pikiclaw/skills/*/SKILL.md` 为 canonical 入口
123
114
  - 兼容 `.claude/commands/*.md`
115
+ - 兼容 legacy `.claude/skills` / `.agents/skills`,并可合并回 `.pikiclaw/skills`
124
116
  - IM 内可通过 `/skills` 和 `/sk_<name>` 触发
125
117
 
126
- ### MCP Session Bridge
118
+ ### Codex Human Loop
119
+
120
+ 当 Codex 在运行过程中请求额外用户输入时,pikiclaw 会把问题转成 Telegram / 飞书里的交互提示,用户回复后再继续当前任务。
121
+
122
+ ### MCP And GUI Automation
127
123
 
128
- 每次 Agent stream 会启动一个会话级 MCP server,把 IM 能力暴露给 Agent。
124
+ 每次 Agent stream 都会启动一个会话级 MCP bridge,把本地工具按本次任务注入给 Agent。
129
125
 
130
- 当前已接入的工具:
126
+ 当前内置工具:
131
127
 
132
128
  - `im_list_files`:列出 session workspace 文件
133
129
  - `im_send_file`:把文件实时发回 IM
134
- - `take_screenshot`:跨平台截图并返回路径
135
130
 
136
- 当前 `guiTools` 模块已经预留,但点击、输入、窗口控制等顶级 GUI 工具还没接上。
131
+ 可选 GUI 能力:
132
+
133
+ - 浏览器自动化:通过 `@playwright/mcp` 补充接入,默认支持 Chrome extension mode,也可切到 headless / isolated 模式
134
+ - macOS 桌面自动化:通过 Appium Mac2 提供 `desktop_open_app`、`desktop_snapshot`、`desktop_click`、`desktop_type`、`desktop_screenshot` 等工具
137
135
 
138
136
  ---
139
137
 
@@ -156,42 +154,37 @@ Agent 通过 driver registry 接入,模型列表、会话列表、usage 展示
156
154
 
157
155
  ---
158
156
 
159
- ## Skills And MCP
157
+ ## Config And Setup Notes
160
158
 
161
- 项目里现在有两条能力扩展线:
159
+ - 持久化配置在 `~/.pikiclaw/setting.json`
160
+ - Dashboard 是主配置入口,环境变量仍然可用
161
+ - 浏览器 GUI 相关常用变量:
162
+ - `PIKICLAW_BROWSER_GUI`
163
+ - `PIKICLAW_BROWSER_USE_EXTENSION`
164
+ - `PIKICLAW_BROWSER_HEADLESS`
165
+ - `PIKICLAW_BROWSER_ISOLATED`
166
+ - `PLAYWRIGHT_MCP_EXTENSION_TOKEN`
167
+ - 桌面 GUI 相关常用变量:
168
+ - `PIKICLAW_DESKTOP_GUI`
169
+ - `PIKICLAW_DESKTOP_APPIUM_URL`
162
170
 
163
- - Skills:偏“高层工作流提示词”,来源于 `.pikiclaw/skills` `.claude/commands`
164
- - MCP tools:偏“可执行工具能力”,目前是会话级 bridge,由 pikiclaw 在每次 stream 时注入
171
+ 如果要启用 macOS 桌面自动化,需要先准备 Appium Mac2:
165
172
 
166
- 这两条线已经能工作,但都还是偏“session / project 内部接入”,还不是仓库级统一入口。
173
+ ```bash
174
+ npm install -g appium
175
+ appium driver install mac2
176
+ appium
177
+ ```
167
178
 
168
- ---
179
+ 然后给运行 `pikiclaw` 的终端应用授予 macOS 的辅助功能权限。
169
180
 
170
- ## Status
181
+ ---
171
182
 
172
- ### 已完成
183
+ ## Roadmap
173
184
 
174
- | 项目 | 状态 |
175
- |---|---|
176
- | Telegram 渠道 | ✅ |
177
- | 飞书渠道 | ✅ |
178
- | Claude Code driver | ✅ |
179
- | Codex CLI driver | ✅ |
180
- | Gemini CLI driver | ✅ |
181
- | Web Dashboard | ✅ |
182
- | 项目级 Skills | ✅ |
183
- | 会话级 MCP bridge | ✅ |
184
- | 文件回传 / 截图回传 | ✅ |
185
- | 守护重启 / 防休眠 | ✅ |
186
-
187
- ### 待办
188
-
189
- | 项目 | 说明 |
190
- |---|---|
191
- | 顶级 Skills 接入 | 把 skills 从当前项目级入口提升为更统一的顶级接入能力 |
192
- | 顶级 MCP 工具接入 | 把当前会话级 MCP bridge 扩展成更完整的顶级工具接入层 |
193
- | GUI 自动化工具补全 | 在 `src/tools/gui.ts` 上接入点击、输入、聚焦、窗口控制等工具 |
194
- | 更多渠道 | WhatsApp 仍在规划中 |
185
+ - 把当前会话级 MCP bridge 继续扩展成更完整的顶级工具接入层
186
+ - 继续完善 GUI 自动化能力,尤其是浏览器与桌面工具的协同链路
187
+ - 增加更多 IM 渠道,WhatsApp 仍在规划中
195
188
 
196
189
  ---
197
190
 
@@ -216,6 +209,9 @@ npx vitest run test/channel-feishu.unit.test.ts
216
209
  npx pikiclaw@latest --doctor
217
210
  ```
218
211
 
212
+ `npm run dev` 只跑本地源码链路,会固定使用 `--no-daemon`,避免跳转到生产/自举用的 `npx pikiclaw@latest`。
213
+ 同时会把本次启动的全部日志写到 `~/.pikiclaw/dev/dev.log`,并在每次启动时先清空旧日志。
214
+
219
215
  更多实现细节见:
220
216
 
221
217
  - [ARCHITECTURE.md](ARCHITECTURE.md)
@@ -9,6 +9,7 @@ import { fmtUptime, fmtTokens, fmtBytes, formatThinkingForDisplay, thinkLabel }
9
9
  import { summarizePromptForStatus } from './bot-commands.js';
10
10
  import { formatProviderUsageLines } from './bot-telegram-render.js';
11
11
  import { formatActivityCommandSummary, parseActivitySummary, renderPlanForPreview, summarizeActivityForPreview } from './bot-streaming.js';
12
+ import { currentHumanLoopQuestion, humanLoopAnsweredCount, isHumanLoopAwaitingText, isHumanLoopQuestionAnswered, summarizeHumanLoopAnswer, } from './human-loop.js';
12
13
  import path from 'node:path';
13
14
  import { listSubdirs } from './bot.js';
14
15
  // ---------------------------------------------------------------------------
@@ -163,6 +164,38 @@ export function renderSessionTurnMarkdown(userText, assistantText) {
163
164
  parts.push('**Assistant**', assistant);
164
165
  return parts.join('\n\n');
165
166
  }
167
+ export function buildHumanLoopPromptMarkdown(prompt) {
168
+ const question = currentHumanLoopQuestion(prompt);
169
+ const lines = [`**${prompt.title}**`];
170
+ if (prompt.detail)
171
+ lines.push(`\`${prompt.detail}\``);
172
+ lines.push(`*${humanLoopAnsweredCount(prompt)}/${prompt.questions.length} answered*`);
173
+ if (!question)
174
+ return lines.join('\n\n');
175
+ if (question.header.trim())
176
+ lines.push(`**${question.header}**`);
177
+ lines.push(question.prompt);
178
+ const options = question.options || [];
179
+ if (options.length) {
180
+ lines.push(options.map((option, index) => {
181
+ const detail = option.description ? `\n ${option.description}` : '';
182
+ return `${index + 1}. ${option.label}${detail}`;
183
+ }).join('\n'));
184
+ }
185
+ if (isHumanLoopAwaitingText(prompt)) {
186
+ lines.push(`*${question.secret ? 'Reply in chat with the secret value.' : 'Reply in chat with text to answer.'}*`);
187
+ }
188
+ if (prompt.hint)
189
+ lines.push(`*${prompt.hint}*`);
190
+ if (prompt.questions.length > 1) {
191
+ lines.push(prompt.questions.map((item, index) => {
192
+ const summary = summarizeHumanLoopAnswer(prompt, item);
193
+ const answered = isHumanLoopQuestionAnswered(prompt, index);
194
+ return `${answered ? '●' : '○'} ${item.header || item.prompt}: ${summary.display}`;
195
+ }).join('\n'));
196
+ }
197
+ return lines.join('\n\n');
198
+ }
166
199
  // ---------------------------------------------------------------------------
167
200
  // LivePreview renderer — produces Markdown for Feishu card elements
168
201
  // ---------------------------------------------------------------------------
@@ -18,7 +18,9 @@ import { getStartData, getSessionsPageData, getModelsListData, getSessionTurnPre
18
18
  import { buildAgentsCommandView, buildModelsCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, executeCommandAction, } from './bot-command-ui.js';
19
19
  import { LivePreview } from './bot-telegram-live-preview.js';
20
20
  import { formatActiveTaskRestartError, getActiveTaskCount, registerProcessRuntime, requestProcessRestart, } from './process-control.js';
21
- import { feishuPreviewRenderer, feishuStreamingPreviewRenderer, buildInitialPreviewMarkdown, buildFinalReplyRender, renderCommandNotice, renderCommandSelectionCard, renderSessionTurnMarkdown, renderStart, renderStatus, renderHost, buildSwitchWorkdirCard, resolveFeishuRegisteredPath, } from './bot-feishu-render.js';
21
+ import { feishuPreviewRenderer, feishuStreamingPreviewRenderer, buildInitialPreviewMarkdown, buildHumanLoopPromptMarkdown, buildFinalReplyRender, renderCommandNotice, renderCommandSelectionCard, renderSessionTurnMarkdown, renderStart, renderStatus, renderHost, buildSwitchWorkdirCard, resolveFeishuRegisteredPath, } from './bot-feishu-render.js';
22
+ import { buildCodexHumanLoopPrompt } from './human-loop-codex.js';
23
+ import { currentHumanLoopQuestion, humanLoopOptionSelected } from './human-loop.js';
22
24
  import { FeishuChannel } from './channel-feishu.js';
23
25
  import { splitText, supportsChannelCapability } from './channel-base.js';
24
26
  import { getActiveUserConfig } from './user-config.js';
@@ -376,11 +378,110 @@ export class FeishuBot extends Bot {
376
378
  parts.push(`cancelled ${cancelledQueued} queued ${cancelledQueued === 1 ? 'task' : 'tasks'}`);
377
379
  await ctx.reply(`Stopped current session: ${parts.join(', ')}.`);
378
380
  }
381
+ buildHumanLoopKeyboard(promptId) {
382
+ const prompt = this.humanLoopPrompt(promptId);
383
+ const question = prompt ? currentHumanLoopQuestion(prompt) : null;
384
+ const rows = [];
385
+ for (let index = 0; index < (question?.options?.length || 0); index++) {
386
+ const option = question.options[index];
387
+ rows.push({
388
+ actions: [{
389
+ tag: 'button',
390
+ text: { tag: 'plain_text', content: `${humanLoopOptionSelected(prompt, option.value) ? '●' : '○'} ${option.label}`.slice(0, 32) },
391
+ value: { action: `hl:o:${promptId}:${index}` },
392
+ }],
393
+ });
394
+ }
395
+ if (question?.options?.length && question.allowFreeform) {
396
+ rows.push({
397
+ actions: [{
398
+ tag: 'button',
399
+ text: { tag: 'plain_text', content: 'Other...' },
400
+ value: { action: `hl:other:${promptId}` },
401
+ }],
402
+ });
403
+ }
404
+ if (question?.allowEmpty) {
405
+ rows.push({
406
+ actions: [{
407
+ tag: 'button',
408
+ text: { tag: 'plain_text', content: 'Skip' },
409
+ value: { action: `hl:skip:${promptId}` },
410
+ }],
411
+ });
412
+ }
413
+ rows.push({
414
+ actions: [{
415
+ tag: 'button',
416
+ text: { tag: 'plain_text', content: 'Cancel' },
417
+ value: { action: `hl:cancel:${promptId}` },
418
+ }],
419
+ });
420
+ return { rows };
421
+ }
422
+ async refreshHumanLoopPrompt(chatId, promptId, suffix) {
423
+ const prompt = this.humanLoopPrompt(promptId);
424
+ if (!prompt)
425
+ return;
426
+ const messageId = prompt.messageIds[0];
427
+ if (!messageId)
428
+ return;
429
+ const markdown = `${buildHumanLoopPromptMarkdown(prompt)}${suffix ? `\n\n*${suffix}*` : ''}`;
430
+ await this.channel.editMessage(chatId, String(messageId), markdown, {
431
+ keyboard: this.buildHumanLoopKeyboard(promptId),
432
+ }).catch(() => { });
433
+ }
434
+ async finalizeHumanLoopPrompt(prompt, suffix) {
435
+ if (!prompt)
436
+ return;
437
+ const messageId = prompt.messageIds[0];
438
+ if (!messageId)
439
+ return;
440
+ const markdown = `${buildHumanLoopPromptMarkdown(prompt)}\n\n*${suffix}*`;
441
+ await this.channel.editMessage(prompt.chatId, String(messageId), markdown, {
442
+ keyboard: { rows: [] },
443
+ }).catch(() => { });
444
+ }
445
+ createCodexHumanLoopHandler(ctx, taskId) {
446
+ return async (request) => {
447
+ const blueprint = buildCodexHumanLoopPrompt(request);
448
+ const active = this.beginHumanLoopPrompt({
449
+ taskId,
450
+ chatId: ctx.chatId,
451
+ ...blueprint,
452
+ });
453
+ try {
454
+ const sent = await ctx.reply(buildHumanLoopPromptMarkdown(active.prompt), {
455
+ keyboard: this.buildHumanLoopKeyboard(active.prompt.promptId),
456
+ });
457
+ if (sent)
458
+ this.registerHumanLoopMessage(active.prompt.promptId, sent);
459
+ }
460
+ catch (error) {
461
+ this.humanLoopCancel(active.prompt.promptId, error?.message || 'Failed to send prompt.');
462
+ throw error;
463
+ }
464
+ return active.result;
465
+ };
466
+ }
379
467
  // ---- streaming bridge -----------------------------------------------------
380
468
  async handleMessage(msg, ctx) {
381
469
  const text = msg.text.trim();
382
470
  if (!text && !msg.files.length)
383
471
  return;
472
+ const pendingPrompt = this.pendingHumanLoopPrompt(ctx.chatId);
473
+ if (pendingPrompt && text && !msg.files.length && !text.startsWith('/')) {
474
+ const result = this.humanLoopSubmitText(ctx.chatId, text);
475
+ if (!result) {
476
+ await ctx.reply('Please answer the active prompt using the buttons above.');
477
+ return;
478
+ }
479
+ if (result.completed)
480
+ await this.finalizeHumanLoopPrompt(result.prompt, 'Answer submitted.');
481
+ else
482
+ await this.refreshHumanLoopPrompt(ctx.chatId, result.prompt.promptId);
483
+ return;
484
+ }
384
485
  const session = this.resolveIncomingSession(ctx, text, msg.files);
385
486
  const cs = this.chat(ctx.chatId);
386
487
  this.applySessionSelection(cs, session);
@@ -484,7 +585,7 @@ export class FeishuBot extends Bot {
484
585
  const mcpSendFile = this.createMcpSendFileCallback(ctx);
485
586
  const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
486
587
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
487
- }, undefined, mcpSendFile, abortController.signal);
588
+ }, undefined, mcpSendFile, abortController.signal, this.createCodexHumanLoopHandler(ctx, taskId));
488
589
  await livePreview?.settle();
489
590
  const finalReplyIds = await this.sendFinalReply(ctx, placeholderId, session.agent, result);
490
591
  this.registerSessionMessages(ctx.chatId, finalReplyIds, session);
@@ -680,6 +781,8 @@ export class FeishuBot extends Bot {
680
781
  // ---- callback handlers ----------------------------------------------------
681
782
  async handleCallback(data, ctx) {
682
783
  try {
784
+ if (await this.handleHumanLoopCallback(data, ctx))
785
+ return;
683
786
  if (await this.handleTaskStopCallback(data, ctx))
684
787
  return;
685
788
  if (await this.handleSwitchNavigateCallback(data, ctx))
@@ -698,6 +801,52 @@ export class FeishuBot extends Bot {
698
801
  this.log(`callback error: ${e}`);
699
802
  }
700
803
  }
804
+ async handleHumanLoopCallback(data, ctx) {
805
+ if (!data.startsWith('hl:'))
806
+ return false;
807
+ const [, action, promptId, rawIndex] = data.split(':');
808
+ const prompt = this.humanLoopPrompt(promptId);
809
+ if (!prompt)
810
+ return true;
811
+ if (action === 'cancel') {
812
+ const cancelled = this.humanLoopCancel(promptId, 'Prompt cancelled from Feishu.');
813
+ await this.finalizeHumanLoopPrompt(cancelled, 'Cancelled.');
814
+ return true;
815
+ }
816
+ if (action === 'skip') {
817
+ const result = this.humanLoopSkip(promptId);
818
+ if (!result)
819
+ return true;
820
+ if (result.completed)
821
+ await this.finalizeHumanLoopPrompt(result.prompt, 'Answer submitted.');
822
+ else
823
+ await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
824
+ return true;
825
+ }
826
+ if (action === 'other') {
827
+ const result = this.humanLoopSelectOption(promptId, '__other__', { requestFreeform: true });
828
+ if (!result)
829
+ return true;
830
+ await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
831
+ return true;
832
+ }
833
+ if (action === 'o') {
834
+ const index = Number.parseInt(rawIndex || '', 10);
835
+ const question = this.humanLoopCurrentQuestion(promptId);
836
+ const option = Number.isFinite(index) ? question?.options?.[index] : null;
837
+ if (!option)
838
+ return true;
839
+ const result = this.humanLoopSelectOption(promptId, option.value);
840
+ if (!result)
841
+ return true;
842
+ if (result.completed)
843
+ await this.finalizeHumanLoopPrompt(result.prompt, 'Answer submitted.');
844
+ else
845
+ await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
846
+ return true;
847
+ }
848
+ return true;
849
+ }
701
850
  async handleTaskStopCallback(data, ctx) {
702
851
  if (!data.startsWith('tsk:stop:'))
703
852
  return false;
@@ -1,6 +1,7 @@
1
1
  import { encodeCommandAction } from './bot-command-ui.js';
2
2
  import { fmtUptime, formatThinkingForDisplay, thinkLabel } from './bot.js';
3
3
  import { formatActivityCommandSummary, parseActivitySummary, renderPlanForPreview, summarizeActivityForPreview } from './bot-streaming.js';
4
+ import { currentHumanLoopQuestion, humanLoopAnsweredCount, isHumanLoopAwaitingText, isHumanLoopQuestionAnswered, summarizeHumanLoopAnswer, } from './human-loop.js';
4
5
  export function escapeHtml(t) {
5
6
  return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
6
7
  }
@@ -91,6 +92,44 @@ export function renderCommandSelectionKeyboard(view) {
91
92
  }))),
92
93
  };
93
94
  }
95
+ export function buildHumanLoopPromptHtml(prompt) {
96
+ const question = currentHumanLoopQuestion(prompt);
97
+ const lines = [`<b>${escapeHtml(prompt.title)}</b>`];
98
+ if (prompt.detail)
99
+ lines.push(compactCode(prompt.detail, 40));
100
+ lines.push(`<i>${humanLoopAnsweredCount(prompt)}/${prompt.questions.length} answered</i>`);
101
+ if (!question)
102
+ return lines.join('\n');
103
+ lines.push('');
104
+ if (question.header.trim())
105
+ lines.push(`<b>${escapeHtml(question.header)}</b>`);
106
+ lines.push(escapeHtml(question.prompt));
107
+ const options = question.options || [];
108
+ if (options.length) {
109
+ lines.push('');
110
+ for (let i = 0; i < options.length; i++) {
111
+ const option = options[i];
112
+ lines.push(`${i + 1}. ${escapeHtml(option.label)}`);
113
+ if (option.description)
114
+ lines.push(`<i>${escapeHtml(option.description)}</i>`);
115
+ }
116
+ }
117
+ if (isHumanLoopAwaitingText(prompt)) {
118
+ lines.push('', `<i>${question.secret ? 'Reply with the secret value in chat.' : 'Reply with text in chat to answer.'}</i>`);
119
+ }
120
+ if (prompt.hint)
121
+ lines.push('', `<i>${escapeHtml(prompt.hint)}</i>`);
122
+ if (prompt.questions.length > 1) {
123
+ lines.push('');
124
+ for (let i = 0; i < prompt.questions.length; i++) {
125
+ const item = prompt.questions[i];
126
+ const summary = summarizeHumanLoopAnswer(prompt, item);
127
+ const answered = isHumanLoopQuestionAnswered(prompt, i);
128
+ lines.push(`${answered ? '●' : '○'} ${escapeHtml(item.header || item.prompt)}: ${escapeHtml(summary.display)}`);
129
+ }
130
+ }
131
+ return lines.join('\n');
132
+ }
94
133
  function mdInline(line) {
95
134
  const parts = [];
96
135
  let rest = line;
@@ -19,7 +19,9 @@ import { buildAgentsCommandView, buildModelsCommandView, buildSessionsCommandVie
19
19
  import { buildSwitchWorkdirView, resolveRegisteredPath } from './bot-telegram-directory.js';
20
20
  import { LivePreview } from './bot-telegram-live-preview.js';
21
21
  import { formatActiveTaskRestartError, getActiveTaskCount, registerProcessRuntime, buildRestartCommand, requestProcessRestart, } from './process-control.js';
22
- import { buildInitialPreviewHtml, buildStreamPreviewHtml, buildFinalReplyRender, escapeHtml, formatMenuLines, formatProviderUsageLines, renderCommandNoticeHtml, renderCommandSelectionHtml, renderCommandSelectionKeyboard, renderSessionTurnHtml, truncateMiddle, } from './bot-telegram-render.js';
22
+ import { buildInitialPreviewHtml, buildHumanLoopPromptHtml, buildStreamPreviewHtml, buildFinalReplyRender, escapeHtml, formatMenuLines, formatProviderUsageLines, renderCommandNoticeHtml, renderCommandSelectionHtml, renderCommandSelectionKeyboard, renderSessionTurnHtml, truncateMiddle, } from './bot-telegram-render.js';
23
+ import { buildCodexHumanLoopPrompt } from './human-loop-codex.js';
24
+ import { currentHumanLoopQuestion, humanLoopOptionSelected } from './human-loop.js';
23
25
  import { TelegramChannel } from './channel-telegram.js';
24
26
  import { splitText, supportsChannelCapability } from './channel-base.js';
25
27
  import { getActiveUserConfig } from './user-config.js';
@@ -358,11 +360,91 @@ export class TelegramBot extends Bot {
358
360
  parts.push(`cancelled ${cancelledQueued} queued ${cancelledQueued === 1 ? 'task' : 'tasks'}`);
359
361
  await ctx.reply(`Stopped current session: ${parts.join(', ')}.`);
360
362
  }
363
+ buildHumanLoopKeyboard(promptId) {
364
+ const prompt = this.humanLoopPrompt(promptId);
365
+ const question = prompt ? currentHumanLoopQuestion(prompt) : null;
366
+ const inline_keyboard = [];
367
+ const optionRows = (question?.options || []).map((option, index) => ([{
368
+ text: `${humanLoopOptionSelected(prompt, option.value) ? '●' : '○'} ${truncateMiddle(option.label, 28)}`,
369
+ callback_data: `hl:o:${promptId}:${index}`,
370
+ }]));
371
+ inline_keyboard.push(...optionRows);
372
+ if (question?.options?.length && question.allowFreeform) {
373
+ inline_keyboard.push([{ text: 'Other...', callback_data: `hl:other:${promptId}` }]);
374
+ }
375
+ if (question?.allowEmpty) {
376
+ inline_keyboard.push([{ text: 'Skip', callback_data: `hl:skip:${promptId}` }]);
377
+ }
378
+ inline_keyboard.push([{ text: 'Cancel', callback_data: `hl:cancel:${promptId}` }]);
379
+ return { inline_keyboard };
380
+ }
381
+ async refreshHumanLoopPrompt(chatId, promptId, opts = {}) {
382
+ const prompt = this.humanLoopPrompt(promptId);
383
+ if (!prompt)
384
+ return;
385
+ const messageId = prompt.messageIds[0];
386
+ if (typeof messageId !== 'number')
387
+ return;
388
+ const html = `${buildHumanLoopPromptHtml(prompt)}${opts.suffix ? `\n\n<i>${escapeHtml(opts.suffix)}</i>` : ''}`;
389
+ await this.channel.editMessage(chatId, messageId, html, {
390
+ parseMode: 'HTML',
391
+ keyboard: opts.submitted ? { inline_keyboard: [] } : this.buildHumanLoopKeyboard(promptId),
392
+ }).catch(() => { });
393
+ }
394
+ async finalizeHumanLoopPrompt(prompt, suffix) {
395
+ if (!prompt)
396
+ return;
397
+ const messageId = prompt.messageIds[0];
398
+ if (typeof messageId !== 'number')
399
+ return;
400
+ const html = `${buildHumanLoopPromptHtml(prompt)}\n\n<i>${escapeHtml(suffix)}</i>`;
401
+ await this.channel.editMessage(prompt.chatId, messageId, html, {
402
+ parseMode: 'HTML',
403
+ keyboard: { inline_keyboard: [] },
404
+ }).catch(() => { });
405
+ }
406
+ createCodexHumanLoopHandler(ctx, taskId, messageThreadId) {
407
+ return async (request) => {
408
+ const blueprint = buildCodexHumanLoopPrompt(request);
409
+ const active = this.beginHumanLoopPrompt({
410
+ taskId,
411
+ chatId: ctx.chatId,
412
+ ...blueprint,
413
+ });
414
+ try {
415
+ const sent = await ctx.reply(buildHumanLoopPromptHtml(active.prompt), {
416
+ parseMode: 'HTML',
417
+ messageThreadId,
418
+ keyboard: this.buildHumanLoopKeyboard(active.prompt.promptId),
419
+ });
420
+ if (typeof sent === 'number')
421
+ this.registerHumanLoopMessage(active.prompt.promptId, sent);
422
+ }
423
+ catch (error) {
424
+ this.humanLoopCancel(active.prompt.promptId, error?.message || 'Failed to send prompt.');
425
+ throw error;
426
+ }
427
+ return active.result;
428
+ };
429
+ }
361
430
  // ---- streaming bridge -----------------------------------------------------
362
431
  async handleMessage(msg, ctx) {
363
432
  const text = msg.text.trim();
364
433
  if (!text && !msg.files.length)
365
434
  return;
435
+ const pendingPrompt = this.pendingHumanLoopPrompt(ctx.chatId);
436
+ if (pendingPrompt && text && !msg.files.length && !text.startsWith('/')) {
437
+ const result = this.humanLoopSubmitText(ctx.chatId, text);
438
+ if (!result) {
439
+ await ctx.reply('Please answer the active prompt using the buttons above.');
440
+ return;
441
+ }
442
+ if (result.completed)
443
+ await this.finalizeHumanLoopPrompt(result.prompt, 'Answer submitted.');
444
+ else
445
+ await this.refreshHumanLoopPrompt(ctx.chatId, result.prompt.promptId);
446
+ return;
447
+ }
366
448
  const session = this.resolveIncomingSession(ctx, text, msg.files);
367
449
  const cs = this.chat(ctx.chatId);
368
450
  this.applySessionSelection(cs, session);
@@ -472,7 +554,7 @@ export class TelegramBot extends Bot {
472
554
  const mcpSendFile = this.createMcpSendFileCallback(ctx, messageThreadId);
473
555
  const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
474
556
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
475
- }, undefined, mcpSendFile, abortController.signal);
557
+ }, undefined, mcpSendFile, abortController.signal, this.createCodexHumanLoopHandler(ctx, taskId, messageThreadId));
476
558
  await livePreview?.settle();
477
559
  this.log(`[handleMessage] done agent=${session.agent} ok=${result.ok} session=${result.sessionId || '?'} elapsed=${result.elapsedS.toFixed(1)}s edits=${livePreview?.getEditCount() || 0} ` +
478
560
  `tokens=in:${fmtTokens(result.inputTokens)}/cached:${fmtTokens(result.cachedInputTokens)}/out:${fmtTokens(result.outputTokens)}`);
@@ -659,7 +741,70 @@ export class TelegramBot extends Bot {
659
741
  await ctx.answerCallback('Nothing to stop.');
660
742
  return true;
661
743
  }
744
+ async handleHumanLoopCallback(data, ctx) {
745
+ if (!data.startsWith('hl:'))
746
+ return false;
747
+ const [, action, promptId, rawIndex] = data.split(':');
748
+ const prompt = this.humanLoopPrompt(promptId);
749
+ if (!prompt) {
750
+ await ctx.answerCallback('This prompt is no longer active.');
751
+ return true;
752
+ }
753
+ if (action === 'cancel') {
754
+ const cancelled = this.humanLoopCancel(promptId, 'Prompt cancelled from Telegram.');
755
+ await this.finalizeHumanLoopPrompt(cancelled, 'Cancelled.');
756
+ await ctx.answerCallback('Cancelled.');
757
+ return true;
758
+ }
759
+ if (action === 'skip') {
760
+ const result = this.humanLoopSkip(promptId);
761
+ if (!result) {
762
+ await ctx.answerCallback('This prompt is no longer active.');
763
+ return true;
764
+ }
765
+ if (result.completed)
766
+ await this.finalizeHumanLoopPrompt(result.prompt, 'Answer submitted.');
767
+ else
768
+ await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
769
+ await ctx.answerCallback(result.completed ? 'Submitted.' : 'Skipped.');
770
+ return true;
771
+ }
772
+ if (action === 'other') {
773
+ const result = this.humanLoopSelectOption(promptId, '__other__', { requestFreeform: true });
774
+ if (!result) {
775
+ await ctx.answerCallback('This prompt is no longer active.');
776
+ return true;
777
+ }
778
+ await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
779
+ await ctx.answerCallback('Reply with text to continue.');
780
+ return true;
781
+ }
782
+ if (action === 'o') {
783
+ const index = Number.parseInt(rawIndex || '', 10);
784
+ const question = this.humanLoopCurrentQuestion(promptId);
785
+ const option = Number.isFinite(index) ? question?.options?.[index] : null;
786
+ if (!option) {
787
+ await ctx.answerCallback('Option expired.');
788
+ return true;
789
+ }
790
+ const result = this.humanLoopSelectOption(promptId, option.value);
791
+ if (!result) {
792
+ await ctx.answerCallback('This prompt is no longer active.');
793
+ return true;
794
+ }
795
+ if (result.completed)
796
+ await this.finalizeHumanLoopPrompt(result.prompt, 'Answer submitted.');
797
+ else
798
+ await this.refreshHumanLoopPrompt(ctx.chatId, promptId);
799
+ await ctx.answerCallback(result.completed ? 'Submitted.' : 'Recorded.');
800
+ return true;
801
+ }
802
+ await ctx.answerCallback();
803
+ return true;
804
+ }
662
805
  async handleCallback(data, ctx) {
806
+ if (await this.handleHumanLoopCallback(data, ctx))
807
+ return;
663
808
  if (await this.handleTaskStopCallback(data, ctx))
664
809
  return;
665
810
  if (await this.handleSwitchNavigateCallback(data, ctx))