pikiclaw 0.3.16 → 0.3.18

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
@@ -258,6 +258,16 @@ See also: [ARCHITECTURE.md](ARCHITECTURE.md) · [INTEGRATION.md](INTEGRATION.md)
258
258
 
259
259
  ---
260
260
 
261
+ ## Contributing
262
+
263
+ Contributions are welcome! Whether it's a bug fix, new feature, or documentation improvement — we appreciate your help.
264
+
265
+ - Read the **[Contributing Guide](CONTRIBUTING.md)** to get started
266
+ - Check out [`good first issue`](https://github.com/xiaotonng/pikiclaw/labels/good%20first%20issue) and [`help wanted`](https://github.com/xiaotonng/pikiclaw/labels/help%20wanted) labels for contribution ideas
267
+ - Open an issue first for larger changes so we can discuss the approach
268
+
269
+ ---
270
+
261
271
  ## License
262
272
 
263
273
  [MIT](LICENSE)
@@ -432,36 +432,43 @@ function overlayCodexManagedPreview(workdir, sessionId, richMessages) {
432
432
  };
433
433
  return merged;
434
434
  }
435
- function buildCodexInteractionRequest(method, params, requestId) {
435
+ function toAgentInteraction(method, params, requestId) {
436
436
  if (method === 'item/tool/requestUserInput') {
437
- const questions = Array.isArray(params?.questions) ? params.questions : [];
437
+ const raw = Array.isArray(params?.questions) ? params.questions : [];
438
+ const questions = raw
439
+ .map((q) => ({
440
+ id: String(q?.id || ''),
441
+ header: String(q?.header || '') || 'Question',
442
+ prompt: String(q?.question || ''),
443
+ options: Array.isArray(q?.options)
444
+ ? q.options.map((o) => ({
445
+ label: String(o?.label || ''),
446
+ description: String(o?.description || ''),
447
+ value: String(o?.label || ''),
448
+ }))
449
+ : null,
450
+ allowFreeform: !!q?.isOther || !Array.isArray(q?.options) || !q.options.length,
451
+ secret: !!q?.isSecret,
452
+ allowEmpty: true,
453
+ }))
454
+ .filter((q) => q.id && q.prompt);
438
455
  return {
439
- kind: 'requestUserInput',
440
- requestId,
441
- threadId: String(params?.threadId || ''),
442
- turnId: String(params?.turnId || ''),
443
- itemId: String(params?.itemId || ''),
444
- questions: questions.map((question) => ({
445
- id: String(question?.id || ''),
446
- header: String(question?.header || ''),
447
- question: String(question?.question || ''),
448
- isOther: !!question?.isOther,
449
- isSecret: !!question?.isSecret,
450
- options: Array.isArray(question?.options)
451
- ? question.options.map((option) => ({
452
- label: String(option?.label || ''),
453
- description: String(option?.description || ''),
454
- }))
455
- : null,
456
- })).filter((question) => question.id && question.question),
456
+ kind: 'user-input',
457
+ id: requestId,
458
+ title: 'User Input Required',
459
+ hint: 'Use the buttons when available. Reply with text when prompted.',
460
+ questions,
461
+ resolveWith: (answers) => ({
462
+ answers: Object.fromEntries(Object.entries(answers).map(([id, vals]) => [id, { answers: vals }])),
463
+ }),
457
464
  };
458
465
  }
459
466
  return null;
460
467
  }
461
- function defaultCodexInteractionResponse(request) {
468
+ function defaultAgentInteractionResponse(interaction) {
462
469
  const answers = {};
463
- for (const question of request.questions)
464
- answers[question.id] = { answers: [] };
470
+ for (const q of interaction.questions)
471
+ answers[q.id] = { answers: [] };
465
472
  return { answers };
466
473
  }
467
474
  function defaultCodexServerRequestResponse(method) {
@@ -836,22 +843,22 @@ function handleTurnPlanUpdated(params, s, emit) {
836
843
  // Stream request handler (extracted from doCodexStream)
837
844
  // ---------------------------------------------------------------------------
838
845
  async function handleCodexRequest(method, params, requestId, s, opts, emit) {
839
- const interaction = buildCodexInteractionRequest(method, params, requestId);
846
+ const interaction = toAgentInteraction(method, params, requestId);
840
847
  if (!interaction)
841
848
  return defaultCodexServerRequestResponse(method);
842
- pushRecentActivity(s.recentNarrative, interaction.kind === 'requestUserInput' ? 'Waiting for user input' : 'Waiting for approval');
849
+ pushRecentActivity(s.recentNarrative, interaction.kind === 'user-input' ? 'Waiting for user input' : 'Waiting for approval');
843
850
  emit();
844
851
  try {
845
- if (opts.onCodexInteractionRequest) {
846
- const response = await opts.onCodexInteractionRequest(interaction);
847
- return response ?? defaultCodexInteractionResponse(interaction);
852
+ if (opts.onInteraction) {
853
+ const response = await opts.onInteraction(interaction);
854
+ return response ?? defaultAgentInteractionResponse(interaction);
848
855
  }
849
856
  }
850
857
  catch (error) {
851
858
  pushRecentActivity(s.recentFailures, `Human input failed: ${shortValue(error?.message || error, 120)}`, 4);
852
859
  emit();
853
860
  }
854
- return defaultCodexInteractionResponse(interaction);
861
+ return defaultAgentInteractionResponse(interaction);
855
862
  }
856
863
  // ---------------------------------------------------------------------------
857
864
  // Stream via app-server
package/dist/bot/bot.js CHANGED
@@ -279,6 +279,26 @@ export class Bot {
279
279
  }
280
280
  break;
281
281
  }
282
+ case 'interaction': {
283
+ const snap = this.streamSnapshots.get(sessionKey);
284
+ if (snap) {
285
+ const list = snap.interactions || [];
286
+ list.push(event.interaction);
287
+ snap.interactions = list;
288
+ snap.updatedAt = now;
289
+ }
290
+ break;
291
+ }
292
+ case 'interaction-resolved': {
293
+ const snap = this.streamSnapshots.get(sessionKey);
294
+ if (snap?.interactions) {
295
+ snap.interactions = snap.interactions.filter(i => i.promptId !== event.promptId);
296
+ if (!snap.interactions.length)
297
+ delete snap.interactions;
298
+ snap.updatedAt = now;
299
+ }
300
+ break;
301
+ }
282
302
  }
283
303
  // Push to dashboard SSE — throttle text events, push everything else immediately
284
304
  try {
@@ -903,6 +923,7 @@ export class Bot {
903
923
  this.humanLoopPrompts.delete(promptId);
904
924
  this.removeHumanLoopPromptFromChat(prompt.chatId, promptId);
905
925
  prompt.resolve(buildHumanLoopResponse(prompt));
926
+ this.emitInteractionResolved(prompt.taskId, promptId);
906
927
  return prompt;
907
928
  }
908
929
  clearHumanLoopPrompt(promptId, error) {
@@ -913,8 +934,14 @@ export class Bot {
913
934
  this.removeHumanLoopPromptFromChat(prompt.chatId, promptId);
914
935
  if (error)
915
936
  prompt.reject(error);
937
+ this.emitInteractionResolved(prompt.taskId, promptId);
916
938
  return prompt;
917
939
  }
940
+ emitInteractionResolved(taskId, promptId) {
941
+ const task = this.activeTasks.get(taskId);
942
+ if (task)
943
+ this.emitStream(task.sessionKey, { type: 'interaction-resolved', promptId });
944
+ }
918
945
  humanLoopSelectOption(promptId, optionValue, opts = {}) {
919
946
  const prompt = this.humanLoopPrompts.get(promptId) || null;
920
947
  if (!prompt)
@@ -963,6 +990,79 @@ export class Bot {
963
990
  else
964
991
  this.humanLoopPromptIdsByChat.delete(chatKey);
965
992
  }
993
+ /**
994
+ * Create an interaction handler that bridges agent requests to the human-loop
995
+ * state machine and pushes SSE events to the dashboard.
996
+ *
997
+ * IM channel subclasses override `renderInteractionPrompt()` to render
998
+ * buttons/cards in their native UI. Dashboard clients receive the
999
+ * `interaction` SSE event and respond via REST.
1000
+ */
1001
+ createInteractionHandler(chatId, taskId, sessionKey) {
1002
+ return async (request) => {
1003
+ const active = this.beginHumanLoopPrompt({
1004
+ taskId,
1005
+ chatId,
1006
+ title: request.title,
1007
+ hint: request.hint,
1008
+ questions: request.questions,
1009
+ resolveWith: request.resolveWith,
1010
+ });
1011
+ const interactionSnapshot = {
1012
+ promptId: active.prompt.promptId,
1013
+ kind: request.kind,
1014
+ title: request.title,
1015
+ hint: request.hint,
1016
+ questions: request.questions,
1017
+ };
1018
+ this.emitStream(sessionKey, { type: 'interaction', taskId, interaction: interactionSnapshot });
1019
+ try {
1020
+ await this.renderInteractionPrompt(active.prompt, chatId);
1021
+ }
1022
+ catch (error) {
1023
+ this.humanLoopCancel(active.prompt.promptId, error?.message || 'Failed to send prompt.');
1024
+ throw error;
1025
+ }
1026
+ return active.result;
1027
+ };
1028
+ }
1029
+ /**
1030
+ * Render an interaction prompt in the IM channel.
1031
+ * Override in channel subclasses (Telegram, Feishu, etc.).
1032
+ * Dashboard-only sessions (chatId='dashboard') are a no-op by default.
1033
+ */
1034
+ async renderInteractionPrompt(_prompt, _chatId) {
1035
+ // Default: no-op (dashboard-only sessions use SSE events instead)
1036
+ }
1037
+ // ---- Public interaction API (used by dashboard routes) --------------------
1038
+ /** Respond to a pending interaction prompt with a selected option. */
1039
+ interactionSelectOption(promptId, optionValue, opts) {
1040
+ return this.humanLoopSelectOption(promptId, optionValue, opts);
1041
+ }
1042
+ /** Submit freeform text to a pending interaction prompt. */
1043
+ interactionSubmitText(promptId, text) {
1044
+ const prompt = this.humanLoopPrompt(promptId);
1045
+ if (!prompt)
1046
+ return null;
1047
+ if (!isHumanLoopAwaitingText(prompt))
1048
+ return null;
1049
+ const result = setHumanLoopText(prompt, text);
1050
+ if (result.completed)
1051
+ this.resolveHumanLoopPrompt(prompt.promptId);
1052
+ return { prompt, ...result };
1053
+ }
1054
+ /** Skip the current question in a pending interaction prompt. */
1055
+ interactionSkip(promptId) {
1056
+ return this.humanLoopSkip(promptId);
1057
+ }
1058
+ /** Cancel a pending interaction prompt. */
1059
+ interactionCancel(promptId, reason = 'Cancelled from dashboard.') {
1060
+ return this.humanLoopCancel(promptId, reason);
1061
+ }
1062
+ /** Get a specific interaction prompt by ID. */
1063
+ interactionPrompt(promptId) {
1064
+ return this.humanLoopPrompt(promptId);
1065
+ }
966
1066
  selectedSession(chatId) {
967
1067
  return this.getSelectedSession(this.chat(chatId));
968
1068
  }
@@ -1003,7 +1103,7 @@ export class Bot {
1003
1103
  const result = await this.runStream(prompt, session, attachments, (text, thinking, activity, meta, plan) => {
1004
1104
  opts.onText?.(text, thinking, activity, meta, plan);
1005
1105
  this.emitStream(currentSessionKey(), { type: 'text', text, thinking, activity, plan });
1006
- }, undefined, undefined, abortController.signal);
1106
+ }, undefined, undefined, abortController.signal, this.createInteractionHandler(opts.chatId ?? 'dashboard', taskId, currentSessionKey()));
1007
1107
  this.emitStream(currentSessionKey(), {
1008
1108
  type: 'done',
1009
1109
  taskId,
@@ -1272,7 +1372,7 @@ export class Bot {
1272
1372
  if (!opts.initial)
1273
1373
  this.onManagedConfigChange(config, opts);
1274
1374
  }
1275
- async runStream(prompt, cs, attachments, onText, systemPrompt, mcpSendFile, abortSignal, onCodexInteractionRequest, onSteerReady, onCodexTurnReady) {
1375
+ async runStream(prompt, cs, attachments, onText, systemPrompt, mcpSendFile, abortSignal, onInteraction, onSteerReady, onCodexTurnReady) {
1276
1376
  const resolvedModel = cs.modelId || this.modelForAgent(cs.agent);
1277
1377
  const agentConfig = this.agentConfigs[cs.agent] || {};
1278
1378
  const resolvedThinkingEffort = ('thinkingEffort' in cs && typeof cs.thinkingEffort === 'string' && cs.thinkingEffort.trim())
@@ -1359,7 +1459,7 @@ export class Bot {
1359
1459
  // MCP bridge
1360
1460
  mcpSendFile,
1361
1461
  abortSignal,
1362
- onCodexInteractionRequest,
1462
+ onInteraction,
1363
1463
  onSteerReady,
1364
1464
  onCodexTurnReady,
1365
1465
  };
@@ -7,6 +7,36 @@
7
7
  import { fmtUptime, formatThinkingForDisplay, thinkLabel } from './bot.js';
8
8
  import { formatActivityCommandSummary, parseActivitySummary, renderPlanForPreview, summarizeActivityForPreview } from './streaming.js';
9
9
  // ---------------------------------------------------------------------------
10
+ // GFM table parsing
11
+ // ---------------------------------------------------------------------------
12
+ /** Parse GFM table lines into structured headers + rows. */
13
+ export function parseGfmTable(tableLines) {
14
+ if (tableLines.length < 3)
15
+ return null;
16
+ const parseRow = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
17
+ const isSep = (line) => {
18
+ const cells = parseRow(line);
19
+ return cells.length > 0 && cells.every(c => /^:?-{2,}:?$/.test(c));
20
+ };
21
+ let headerIdx = -1;
22
+ for (let i = 0; i < tableLines.length - 1; i++) {
23
+ if (isSep(tableLines[i + 1])) {
24
+ headerIdx = i;
25
+ break;
26
+ }
27
+ }
28
+ if (headerIdx < 0)
29
+ return null;
30
+ const headers = parseRow(tableLines[headerIdx]);
31
+ const rows = [];
32
+ for (let i = headerIdx + 2; i < tableLines.length; i++) {
33
+ if (isSep(tableLines[i]))
34
+ continue;
35
+ rows.push(parseRow(tableLines[i]));
36
+ }
37
+ return rows.length ? { headers, rows } : null;
38
+ }
39
+ // ---------------------------------------------------------------------------
10
40
  // Footer helpers
11
41
  // ---------------------------------------------------------------------------
12
42
  export function fmtCompactUptime(ms) {
@@ -17,9 +17,8 @@ import { SKILL_CMD_PREFIX, } from '../../bot/menu.js';
17
17
  import { getStartData, getSessionsPageData, getModelsListData, getSessionTurnPreviewData, getStatusDataAsync, getHostDataSync, resolveSkillPrompt, } from '../../bot/commands.js';
18
18
  import { buildAgentsCommandView, buildModelsCommandView, buildModeCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, executeCommandAction, } from '../../bot/command-ui.js';
19
19
  import { LivePreview } from '../telegram/live-preview.js';
20
- import { formatActiveTaskRestartError, getActiveTaskCount, registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
20
+ import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
21
21
  import { feishuPreviewRenderer, buildInitialPreviewMarkdown, buildHumanLoopPromptMarkdown, buildFinalReplyRender, renderCommandNotice, renderCommandSelectionCard, renderSessionTurnMarkdown, renderStart, renderStatus, renderHost, buildSwitchWorkdirCard, resolveFeishuRegisteredPath, } from './render.js';
22
- import { buildCodexHumanLoopPrompt } from '../../bot/human-loop-codex.js';
23
22
  import { currentHumanLoopQuestion, humanLoopOptionSelected } from '../../bot/human-loop.js';
24
23
  import { FeishuChannel } from './channel.js';
25
24
  import { splitText, supportsChannelCapability } from '../base.js';
@@ -384,11 +383,6 @@ export class FeishuBot extends Bot {
384
383
  await ctx.channel.sendCard(ctx.chatId, view);
385
384
  }
386
385
  async cmdRestart(ctx) {
387
- const activeTasks = getActiveTaskCount();
388
- if (activeTasks > 0) {
389
- await ctx.reply(`⚠ ${formatActiveTaskRestartError(activeTasks)}`);
390
- return;
391
- }
392
386
  await ctx.reply('**Restarting pikiclaw...**\n\nPulling latest version. The bot will be back shortly.');
393
387
  void requestProcessRestart({ log: msg => this.log(msg) });
394
388
  }
@@ -474,27 +468,12 @@ export class FeishuBot extends Bot {
474
468
  keyboard: { rows: [] },
475
469
  }).catch(() => { });
476
470
  }
477
- createCodexHumanLoopHandler(ctx, taskId) {
478
- return async (request) => {
479
- const blueprint = buildCodexHumanLoopPrompt(request);
480
- const active = this.beginHumanLoopPrompt({
481
- taskId,
482
- chatId: ctx.chatId,
483
- ...blueprint,
484
- });
485
- try {
486
- const sent = await ctx.reply(buildHumanLoopPromptMarkdown(active.prompt), {
487
- keyboard: this.buildHumanLoopKeyboard(active.prompt.promptId),
488
- });
489
- if (sent)
490
- this.registerHumanLoopMessage(active.prompt.promptId, sent);
491
- }
492
- catch (error) {
493
- this.humanLoopCancel(active.prompt.promptId, error?.message || 'Failed to send prompt.');
494
- throw error;
495
- }
496
- return active.result;
497
- };
471
+ async renderInteractionPrompt(prompt, chatId) {
472
+ const sent = await this.channel.send(chatId, buildHumanLoopPromptMarkdown(prompt), {
473
+ keyboard: this.buildHumanLoopKeyboard(prompt.promptId),
474
+ });
475
+ if (sent)
476
+ this.registerHumanLoopMessage(prompt.promptId, sent);
498
477
  }
499
478
  async safeSetMessageReaction(chatId, messageId, reactions) {
500
479
  if (!supportsChannelCapability(this.channel, 'messageReactions'))
@@ -640,7 +619,7 @@ export class FeishuBot extends Bot {
640
619
  const mcpSendFile = this.createMcpSendFileCallback(ctx);
641
620
  const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
642
621
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
643
- }, undefined, mcpSendFile, abortController.signal, this.createCodexHumanLoopHandler(ctx, taskId), (steer) => {
622
+ }, undefined, mcpSendFile, abortController.signal, this.createInteractionHandler(ctx.chatId, taskId, session.key), (steer) => {
644
623
  const currentTask = this.activeTasks.get(taskId);
645
624
  if (!currentTask || currentTask.cancelled || currentTask.status !== 'running')
646
625
  return;
@@ -122,28 +122,42 @@ function buildCardFromView(view) {
122
122
  const content = adapted.length > FEISHU_CARD_MAX
123
123
  ? adapted.slice(0, FEISHU_CARD_MAX) + '\n\n...(truncated)'
124
124
  : adapted;
125
- const card = {
126
- config: { wide_screen_mode: true, update_multi: true },
127
- elements: [{ tag: 'markdown', content }],
128
- };
129
- if (view.title) {
130
- card.header = {
131
- template: view.template || 'blue',
132
- title: { content: view.title, tag: 'plain_text' },
133
- };
134
- }
125
+ const actionElements = [];
135
126
  for (const row of view.rows || []) {
136
127
  const actions = row.actions.filter(Boolean);
137
128
  if (!actions.length)
138
129
  continue;
139
- const element = {
140
- tag: 'action',
141
- actions,
142
- };
130
+ const element = { tag: 'action', actions };
143
131
  const layout = row.layout || inferActionLayout(actions);
144
132
  if (layout)
145
133
  element.layout = layout;
146
- card.elements.push(element);
134
+ actionElements.push(element);
135
+ }
136
+ // Card JSON 2.0 supports tables in markdown but dropped `tag: action`.
137
+ // Use v2 for content-only cards; fall back to v1 when buttons are needed.
138
+ if (actionElements.length) {
139
+ const card = {
140
+ config: { wide_screen_mode: true, update_multi: true },
141
+ elements: [{ tag: 'markdown', content }, ...actionElements],
142
+ };
143
+ if (view.title) {
144
+ card.header = {
145
+ template: view.template || 'blue',
146
+ title: { content: view.title, tag: 'plain_text' },
147
+ };
148
+ }
149
+ return card;
150
+ }
151
+ const card = {
152
+ schema: '2.0',
153
+ config: { update_multi: true },
154
+ body: { elements: [{ tag: 'markdown', content }] },
155
+ };
156
+ if (view.title) {
157
+ card.header = {
158
+ template: view.template || 'blue',
159
+ title: { content: view.title, tag: 'plain_text' },
160
+ };
147
161
  }
148
162
  return card;
149
163
  }
@@ -74,7 +74,7 @@ export function adaptMarkdownForFeishu(markdown) {
74
74
  i++;
75
75
  continue;
76
76
  }
77
- // Pass GFM tables through — Feishu card markdown supports tables natively
77
+ // Pass GFM tables through — rendered natively with card schema 2.0
78
78
  if (i + 1 < lines.length && isGfmTableRow(lines[i]) && isGfmTableSeparator(lines[i + 1])) {
79
79
  while (i < lines.length && isGfmTableRow(lines[i])) {
80
80
  out.push(lines[i]);
@@ -18,9 +18,8 @@ import { getStartData, getStatusDataAsync, getHostDataSync, getSessionTurnPrevie
18
18
  import { buildAgentsCommandView, buildModelsCommandView, buildModeCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, executeCommandAction, } from '../../bot/command-ui.js';
19
19
  import { buildSwitchWorkdirView, resolveRegisteredPath } from './directory.js';
20
20
  import { LivePreview } from './live-preview.js';
21
- import { formatActiveTaskRestartError, getActiveTaskCount, registerProcessRuntime, buildRestartCommand, requestProcessRestart, } from '../../core/process-control.js';
21
+ import { registerProcessRuntime, buildRestartCommand, requestProcessRestart, } from '../../core/process-control.js';
22
22
  import { buildInitialPreviewHtml, buildHumanLoopPromptHtml, buildStreamPreviewHtml, buildFinalReplyRender, escapeHtml, formatMenuLines, formatProviderUsageLines, renderCommandNoticeHtml, renderCommandSelectionHtml, renderCommandSelectionKeyboard, renderSessionTurnHtml, truncateMiddle, } from './render.js';
23
- import { buildCodexHumanLoopPrompt } from '../../bot/human-loop-codex.js';
24
23
  import { currentHumanLoopQuestion, humanLoopOptionSelected } from '../../bot/human-loop.js';
25
24
  import { TelegramChannel } from './channel.js';
26
25
  import { splitText, supportsChannelCapability } from '../base.js';
@@ -348,11 +347,6 @@ export class TelegramBot extends Bot {
348
347
  await this.sendCommandView(ctx, buildModeCommandView(this, ctx.chatId));
349
348
  }
350
349
  async cmdRestart(ctx) {
351
- const activeTasks = getActiveTaskCount();
352
- if (activeTasks > 0) {
353
- await ctx.reply(`⚠ ${formatActiveTaskRestartError(activeTasks)}`, { parseMode: 'HTML' });
354
- return;
355
- }
356
350
  await ctx.reply(`<b>Restarting pikiclaw...</b>\n\n` +
357
351
  `The bot will be back shortly.`, { parseMode: 'HTML' });
358
352
  void requestProcessRestart({ log: msg => this.log(msg) });
@@ -418,30 +412,18 @@ export class TelegramBot extends Bot {
418
412
  keyboard: { inline_keyboard: [] },
419
413
  }).catch(() => { });
420
414
  }
421
- createCodexHumanLoopHandler(ctx, taskId, messageThreadId) {
422
- return async (request) => {
423
- const blueprint = buildCodexHumanLoopPrompt(request);
424
- const active = this.beginHumanLoopPrompt({
425
- taskId,
426
- chatId: ctx.chatId,
427
- ...blueprint,
428
- });
429
- try {
430
- const sent = await ctx.reply(buildHumanLoopPromptHtml(active.prompt), {
431
- parseMode: 'HTML',
432
- messageThreadId,
433
- keyboard: this.buildHumanLoopKeyboard(active.prompt.promptId),
434
- });
435
- if (typeof sent === 'number')
436
- this.registerHumanLoopMessage(active.prompt.promptId, sent);
437
- }
438
- catch (error) {
439
- this.humanLoopCancel(active.prompt.promptId, error?.message || 'Failed to send prompt.');
440
- throw error;
441
- }
442
- return active.result;
443
- };
415
+ async renderInteractionPrompt(prompt, chatId) {
416
+ const messageThreadId = this.interactionThreadIds.get(prompt.taskId);
417
+ const sent = await this.channel.send(chatId, buildHumanLoopPromptHtml(prompt), {
418
+ parseMode: 'HTML',
419
+ messageThreadId,
420
+ keyboard: this.buildHumanLoopKeyboard(prompt.promptId),
421
+ });
422
+ if (typeof sent === 'number')
423
+ this.registerHumanLoopMessage(prompt.promptId, sent);
444
424
  }
425
+ /** Cache the messageThreadId per task so renderInteractionPrompt can use it. */
426
+ interactionThreadIds = new Map();
445
427
  // ---- streaming bridge -----------------------------------------------------
446
428
  async handleMessage(msg, ctx) {
447
429
  const text = msg.text.trim();
@@ -581,9 +563,10 @@ export class TelegramBot extends Bot {
581
563
  }
582
564
  // MCP sendFile callback: sends files to IM in real-time during the stream
583
565
  const mcpSendFile = this.createMcpSendFileCallback(ctx, messageThreadId);
566
+ this.interactionThreadIds.set(taskId, messageThreadId);
584
567
  const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
585
568
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
586
- }, undefined, mcpSendFile, abortController.signal, this.createCodexHumanLoopHandler(ctx, taskId, messageThreadId), (steer) => {
569
+ }, undefined, mcpSendFile, abortController.signal, this.createInteractionHandler(ctx.chatId, taskId, session.key), (steer) => {
587
570
  const currentTask = this.activeTasks.get(taskId);
588
571
  if (!currentTask || currentTask.cancelled || currentTask.status !== 'running')
589
572
  return;
@@ -631,6 +614,7 @@ export class TelegramBot extends Bot {
631
614
  }
632
615
  finally {
633
616
  livePreview?.dispose();
617
+ this.interactionThreadIds.delete(taskId);
634
618
  this.finishTask(taskId);
635
619
  this.syncSelectedChats(session);
636
620
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { encodeCommandAction } from '../../bot/command-ui.js';
5
5
  import { currentHumanLoopQuestion, humanLoopAnsweredCount, isHumanLoopAwaitingText, isHumanLoopQuestionAnswered, summarizeHumanLoopAnswer, } from '../../bot/human-loop.js';
6
- import { footerStatusSymbol, formatFooterSummary, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, } from '../../bot/render-shared.js';
6
+ import { footerStatusSymbol, formatFooterSummary, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, parseGfmTable, } from '../../bot/render-shared.js';
7
7
  export function escapeHtml(t) {
8
8
  return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
9
9
  }
@@ -194,15 +194,28 @@ export function mdToTgHtml(text) {
194
194
  const tableLine = lines[i].trim();
195
195
  if (!tableLine.startsWith('|'))
196
196
  break;
197
- if (/^\|[\s\-:|]+\|$/.test(tableLine)) {
198
- i++;
199
- continue;
200
- }
201
197
  tableLines.push(tableLine);
202
198
  i++;
203
199
  }
204
- if (tableLines.length)
205
- result.push(`<pre>${escapeHtml(tableLines.join('\n'))}</pre>`);
200
+ const parsed = parseGfmTable(tableLines);
201
+ if (parsed && parsed.headers.length >= 2) {
202
+ const parts = [];
203
+ for (let r = 0; r < parsed.rows.length; r++) {
204
+ if (r > 0)
205
+ parts.push('');
206
+ const cells = parsed.rows[r];
207
+ parts.push(`<b>${escapeHtml(cells[0] || '')}</b>`);
208
+ for (let c = 1; c < parsed.headers.length; c++) {
209
+ parts.push(`${escapeHtml(parsed.headers[c])}: ${escapeHtml(cells[c] || '')}`);
210
+ }
211
+ }
212
+ result.push(parts.join('\n'));
213
+ }
214
+ else {
215
+ const plain = tableLines.filter(l => !/^\|[\s\-:|]+\|$/.test(l.trim()));
216
+ if (plain.length)
217
+ result.push(`<pre>${escapeHtml(plain.join('\n'))}</pre>`);
218
+ }
206
219
  continue;
207
220
  }
208
221
  const heading = line.match(/^(#{1,6})\s+(.+)$/);
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import { Bot, buildPrompt, fmtUptime, fmtBytes, normalizeAgent, parseAllowedChatIds, } from '../../bot/bot.js';
8
8
  import { BOT_SHUTDOWN_FORCE_EXIT_MS, buildSessionTaskId } from '../../bot/orchestration.js';
9
9
  import { shutdownAllDrivers } from '../../agent/driver.js';
10
- import { formatActiveTaskRestartError, getActiveTaskCount, registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
10
+ import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
11
11
  import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getStartData, } from '../../bot/commands.js';
12
12
  import { WeixinChannel } from './channel.js';
13
13
  import { getActiveUserConfig } from '../../core/config/user-config.js';
@@ -386,11 +386,6 @@ export class WeixinBot extends Bot {
386
386
  await ctx.reply(`Stopped: ${parts.join(', ')}.`);
387
387
  }
388
388
  async cmdRestart(ctx) {
389
- const activeTasks = getActiveTaskCount();
390
- if (activeTasks > 0) {
391
- await ctx.reply(formatActiveTaskRestartError(activeTasks));
392
- return;
393
- }
394
389
  await ctx.reply('Restarting pikiclaw...');
395
390
  void requestProcessRestart({ log: msg => this.log(msg) });
396
391
  }
@@ -87,9 +87,6 @@ export function getActiveTaskCount() {
87
87
  }
88
88
  return total;
89
89
  }
90
- export function formatActiveTaskRestartError(activeTasks) {
91
- return `${activeTasks} task(s) still running. Wait for them to finish or try again.`;
92
- }
93
90
  export function createRestartStateFilePath(ownerPid = process.pid) {
94
91
  const dir = path.join(os.tmpdir(), 'pikiclaw');
95
92
  fs.mkdirSync(dir, { recursive: true });
@@ -188,15 +185,6 @@ function spawnReplacementProcess(bin, args, env, log) {
188
185
  return child;
189
186
  }
190
187
  export async function requestProcessRestart(opts = {}) {
191
- const activeTasks = getActiveTaskCount();
192
- if (activeTasks > 0) {
193
- return {
194
- ok: false,
195
- restarting: false,
196
- error: formatActiveTaskRestartError(activeTasks),
197
- activeTasks,
198
- };
199
- }
200
188
  if (restartInFlight) {
201
189
  return {
202
190
  ok: true,
@@ -12,7 +12,7 @@ import { validateFeishuConfig, validateTelegramConfig, validateWeixinConfig } fr
12
12
  import { resolveGuiIntegrationConfig } from '../../agent/mcp/bridge.js';
13
13
  import { normalizeWeixinBaseUrl, startWeixinQrLogin, waitForWeixinQrLogin, } from '../../channels/weixin/api.js';
14
14
  import { getManagedBrowserStatus, launchManagedBrowserSetup, } from '../../browser-profile.js';
15
- import { formatActiveTaskRestartError, getActiveTaskCount, requestProcessRestart, } from '../../core/process-control.js';
15
+ import { requestProcessRestart, } from '../../core/process-control.js';
16
16
  import { checkPermissions, detectHostTerminalApp, installAppium, isAppiumInstalled, isManagedAppiumRunning, isValidPermissionKey, requestPermission, startManagedAppium, stopManagedAppium, } from '../platform.js';
17
17
  import { VERSION } from '../../core/version.js';
18
18
  import { runtime } from '../runtime.js';
@@ -258,10 +258,6 @@ app.post('/api/open-preferences', async (c) => {
258
258
  });
259
259
  // Restart process
260
260
  app.post('/api/restart', (c) => {
261
- const activeTasks = getActiveTaskCount();
262
- if (activeTasks > 0) {
263
- return c.json({ ok: false, error: formatActiveTaskRestartError(activeTasks) }, 409);
264
- }
265
261
  setTimeout(() => {
266
262
  void requestProcessRestart({ log: message => runtime.log(message) });
267
263
  }, 50);
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import { loadUserConfig } from '../../core/config/user-config.js';
9
9
  import { listAgents, listSkills } from '../../agent/index.js';
10
10
  import { getSessionStatusForBot } from '../../bot/session-status.js';
11
- import { cancelSessionTask, getSessionStreamState, queueDashboardSessionTask, steerSessionTask, } from '../session-control.js';
11
+ import { cancelSessionTask, getSessionStreamState, queueDashboardSessionTask, steerSessionTask, interactionSelectOption, interactionSubmitText, interactionSkip, interactionCancel, getInteractionPrompt, } from '../session-control.js';
12
12
  import { querySessions, querySessionTail, querySessionMessages, getWorkspaceOverviews, updateSession, linkSessions, buildMigrationContext, exportSession, importSession, loadWorkspaces, addWorkspace, removeWorkspace, updateWorkspace, } from '../../bot/session-hub.js';
13
13
  import { DASHBOARD_PAGINATION } from '../../core/constants.js';
14
14
  import { runtime } from '../runtime.js';
@@ -505,4 +505,67 @@ app.post('/api/session-hub/session/steer', async (c) => {
505
505
  return c.json({ ok: false, error: e.message }, 500);
506
506
  }
507
507
  });
508
+ // ==========================================================================
509
+ // Interaction prompts (human-in-the-loop)
510
+ // ==========================================================================
511
+ /** GET /api/interaction/:promptId — Get interaction prompt state. */
512
+ app.get('/api/interaction/:promptId', (c) => {
513
+ const { promptId } = c.req.param();
514
+ const result = getInteractionPrompt(promptId);
515
+ return c.json(result, result.ok ? 200 : 503);
516
+ });
517
+ /** POST /api/interaction/:promptId/select — Select an option. */
518
+ app.post('/api/interaction/:promptId/select', async (c) => {
519
+ try {
520
+ const { promptId } = c.req.param();
521
+ const body = await c.req.json();
522
+ const { value, requestFreeform } = body || {};
523
+ if (!value && !requestFreeform) {
524
+ return c.json({ ok: false, error: 'value is required' }, 400);
525
+ }
526
+ const result = interactionSelectOption(promptId, value || '__other__', { requestFreeform: !!requestFreeform });
527
+ return c.json(result, result.ok ? 200 : (result.error === 'Bot is not running' ? 503 : 404));
528
+ }
529
+ catch (e) {
530
+ return c.json({ ok: false, error: e.message }, 500);
531
+ }
532
+ });
533
+ /** POST /api/interaction/:promptId/text — Submit freeform text. */
534
+ app.post('/api/interaction/:promptId/text', async (c) => {
535
+ try {
536
+ const { promptId } = c.req.param();
537
+ const body = await c.req.json();
538
+ const { text } = body || {};
539
+ if (typeof text !== 'string') {
540
+ return c.json({ ok: false, error: 'text is required' }, 400);
541
+ }
542
+ const result = interactionSubmitText(promptId, text);
543
+ return c.json(result, result.ok ? 200 : (result.error === 'Bot is not running' ? 503 : 404));
544
+ }
545
+ catch (e) {
546
+ return c.json({ ok: false, error: e.message }, 500);
547
+ }
548
+ });
549
+ /** POST /api/interaction/:promptId/skip — Skip current question. */
550
+ app.post('/api/interaction/:promptId/skip', async (c) => {
551
+ try {
552
+ const { promptId } = c.req.param();
553
+ const result = interactionSkip(promptId);
554
+ return c.json(result, result.ok ? 200 : (result.error === 'Bot is not running' ? 503 : 404));
555
+ }
556
+ catch (e) {
557
+ return c.json({ ok: false, error: e.message }, 500);
558
+ }
559
+ });
560
+ /** POST /api/interaction/:promptId/cancel — Cancel interaction prompt. */
561
+ app.post('/api/interaction/:promptId/cancel', async (c) => {
562
+ try {
563
+ const { promptId } = c.req.param();
564
+ const result = interactionCancel(promptId);
565
+ return c.json(result, result.ok ? 200 : (result.error === 'Bot is not running' ? 503 : 404));
566
+ }
567
+ catch (e) {
568
+ return c.json({ ok: false, error: e.message }, 500);
569
+ }
570
+ });
508
571
  export default app;
@@ -104,3 +104,62 @@ export async function steerSessionTask(taskId) {
104
104
  const result = await bot.steerTask(taskId);
105
105
  return { ok: true, steered: result.steered };
106
106
  }
107
+ // ---------------------------------------------------------------------------
108
+ // Interaction prompt control (human-in-the-loop)
109
+ // ---------------------------------------------------------------------------
110
+ export function interactionSelectOption(promptId, optionValue, opts) {
111
+ const bot = runtime.getBotRef();
112
+ if (!bot)
113
+ return { ok: false, error: 'Bot is not running' };
114
+ const result = bot.interactionSelectOption(promptId, optionValue, opts);
115
+ if (!result)
116
+ return { ok: false, error: 'Prompt not found or no longer active' };
117
+ return { ok: true, completed: result.completed, advanced: result.advanced };
118
+ }
119
+ export function interactionSubmitText(promptId, text) {
120
+ const bot = runtime.getBotRef();
121
+ if (!bot)
122
+ return { ok: false, error: 'Bot is not running' };
123
+ const result = bot.interactionSubmitText(promptId, text);
124
+ if (!result)
125
+ return { ok: false, error: 'Prompt not found or not awaiting text' };
126
+ return { ok: true, completed: result.completed, advanced: result.advanced };
127
+ }
128
+ export function interactionSkip(promptId) {
129
+ const bot = runtime.getBotRef();
130
+ if (!bot)
131
+ return { ok: false, error: 'Bot is not running' };
132
+ const result = bot.interactionSkip(promptId);
133
+ if (!result)
134
+ return { ok: false, error: 'Prompt not found or no longer active' };
135
+ return { ok: true, completed: result.completed, advanced: result.advanced };
136
+ }
137
+ export function interactionCancel(promptId) {
138
+ const bot = runtime.getBotRef();
139
+ if (!bot)
140
+ return { ok: false, error: 'Bot is not running' };
141
+ const result = bot.interactionCancel(promptId);
142
+ if (!result)
143
+ return { ok: false, error: 'Prompt not found or no longer active' };
144
+ return { ok: true };
145
+ }
146
+ export function getInteractionPrompt(promptId) {
147
+ const bot = runtime.getBotRef();
148
+ if (!bot)
149
+ return { ok: false, error: 'Bot is not running' };
150
+ const prompt = bot.interactionPrompt(promptId);
151
+ if (!prompt)
152
+ return { ok: true, prompt: null };
153
+ return {
154
+ ok: true,
155
+ prompt: {
156
+ promptId: prompt.promptId,
157
+ taskId: prompt.taskId,
158
+ title: prompt.title,
159
+ hint: prompt.hint,
160
+ questions: prompt.questions,
161
+ currentIndex: prompt.currentIndex,
162
+ answers: prompt.answers,
163
+ },
164
+ };
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "test:watch": "vitest"
38
38
  },
39
39
  "engines": {
40
- "node": ">=18.0.0"
40
+ "node": ">=20.0.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@tailwindcss/vite": "^4.2.1",
@@ -1,26 +0,0 @@
1
- /**
2
- * Maps Codex user-input requests into IM human-loop prompts.
3
- */
4
- export function buildCodexHumanLoopPrompt(request) {
5
- return {
6
- title: 'User Input Required',
7
- detail: 'codex',
8
- hint: 'Use the buttons when available. Reply with text when prompted.',
9
- questions: request.questions.map(question => ({
10
- id: question.id,
11
- header: question.header || 'Question',
12
- prompt: question.question,
13
- options: question.options?.map(option => ({
14
- label: option.label,
15
- description: option.description,
16
- value: option.label,
17
- })) || null,
18
- allowFreeform: question.isOther || !question.options?.length,
19
- secret: question.isSecret,
20
- allowEmpty: true,
21
- })),
22
- resolveWith: answers => ({
23
- answers: Object.fromEntries(Object.entries(answers).map(([id, values]) => [id, { answers: values }])),
24
- }),
25
- };
26
- }