@agentprojectcontext/apx 1.31.1 → 1.32.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.
Files changed (57) hide show
  1. package/README.md +0 -1
  2. package/package.json +1 -1
  3. package/skills/apc-context/SKILL.md +0 -1
  4. package/src/core/agent/constants.js +5 -0
  5. package/src/core/agent/run-agent.js +29 -1
  6. package/src/core/confirmation/adapters/code.js +41 -0
  7. package/src/core/confirmation/adapters/telegram.js +134 -0
  8. package/src/core/confirmation/adapters/terminal.js +35 -0
  9. package/src/core/confirmation/adapters/web.js +53 -0
  10. package/src/core/confirmation/index.js +44 -0
  11. package/src/core/confirmation/pending-store.js +68 -0
  12. package/src/host/daemon/api/artifacts.js +117 -0
  13. package/src/host/daemon/api/code.js +14 -0
  14. package/src/host/daemon/api/confirm.js +30 -0
  15. package/src/host/daemon/api/super-agent.js +12 -4
  16. package/src/host/daemon/api.js +2 -0
  17. package/src/host/daemon/plugins/desktop.js +34 -0
  18. package/src/host/daemon/plugins/telegram-ask.js +309 -0
  19. package/src/host/daemon/plugins/telegram.js +358 -2
  20. package/src/host/daemon/super-agent-tools/helpers.js +27 -6
  21. package/src/host/daemon/super-agent-tools/index.js +1 -0
  22. package/src/host/daemon/super-agent-tools/tools/add-project.js +2 -2
  23. package/src/host/daemon/super-agent-tools/tools/ask-questions.js +96 -13
  24. package/src/host/daemon/super-agent-tools/tools/call-mcp.js +1 -1
  25. package/src/host/daemon/super-agent-tools/tools/call-runtime.js +1 -1
  26. package/src/host/daemon/super-agent-tools/tools/edit-file.js +2 -2
  27. package/src/host/daemon/super-agent-tools/tools/import-agent.js +2 -2
  28. package/src/host/daemon/super-agent-tools/tools/run-shell.js +1 -1
  29. package/src/host/daemon/super-agent-tools/tools/search-files.js +1 -4
  30. package/src/host/daemon/super-agent-tools/tools/send-telegram.js +1 -1
  31. package/src/host/daemon/super-agent-tools/tools/set-identity.js +2 -2
  32. package/src/host/daemon/super-agent-tools/tools/set-permission-mode.js +2 -2
  33. package/src/host/daemon/super-agent-tools/tools/write-file.js +2 -2
  34. package/src/host/daemon/super-agent.js +5 -1
  35. package/src/interfaces/cli/commands/artifact.js +99 -0
  36. package/src/interfaces/cli/index.js +4 -0
  37. package/src/interfaces/cli/terminal-chat/renderer.js +22 -2
  38. package/src/interfaces/web/dist/assets/index-63P_ji1a.js +571 -0
  39. package/src/interfaces/web/dist/assets/index-63P_ji1a.js.map +1 -0
  40. package/src/interfaces/web/dist/assets/index-DLWy6dYz.css +1 -0
  41. package/src/interfaces/web/dist/index.html +2 -2
  42. package/src/interfaces/web/package-lock.json +6 -6
  43. package/src/interfaces/web/src/components/chat/AskQuestionsCard.tsx +72 -0
  44. package/src/interfaces/web/src/components/chat/InlineAskPanel.tsx +399 -0
  45. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
  46. package/src/interfaces/web/src/components/chat/MessageList.tsx +2 -1
  47. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +230 -0
  48. package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +12 -4
  49. package/src/interfaces/web/src/i18n/en.ts +20 -0
  50. package/src/interfaces/web/src/i18n/es.ts +20 -0
  51. package/src/interfaces/web/src/lib/api/artifacts.ts +47 -0
  52. package/src/interfaces/web/src/lib/api.ts +1 -0
  53. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +23 -2
  54. package/src/interfaces/web/src/screens/project/ChatTab.tsx +15 -0
  55. package/src/interfaces/web/dist/assets/index-BDUsA6L6.css +0 -1
  56. package/src/interfaces/web/dist/assets/index-BV615I9p.js +0 -548
  57. package/src/interfaces/web/dist/assets/index-BV615I9p.js.map +0 -1
@@ -41,6 +41,9 @@ import { transcribe as transcribeAudioFile } from "../transcription.js";
41
41
  import { resolveAgentName, SUPERAGENT_ACTOR_ID } from "../../../core/identity.js";
42
42
  import { registerSender, resolveAllowedTools } from "../../../core/telegram-identity.js";
43
43
  import { buildRelationshipBlock } from "../../../core/agent/index.js";
44
+ import { getConfirmationStore as getConfirmStore } from "../../../core/confirmation/pending-store.js";
45
+ import { createTelegramConfirmAdapter } from "../../../core/confirmation/adapters/telegram.js";
46
+ import * as askFlow from "./telegram-ask.js";
44
47
 
45
48
  const API_BASE = "https://api.telegram.org";
46
49
  const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -422,6 +425,13 @@ class ChannelPoller {
422
425
 
423
426
  async _handleUpdate(u) {
424
427
  this.lastUpdateAt = nowIso();
428
+
429
+ // Inline keyboard button press: route to the confirmation adapter.
430
+ if (u.callback_query) {
431
+ await this._handleCallbackQuery(u.callback_query);
432
+ return;
433
+ }
434
+
425
435
  const msg = u.message || u.edited_message;
426
436
  if (!msg) return;
427
437
  const target = this.resolveProject();
@@ -568,6 +578,30 @@ class ChannelPoller {
568
578
  text = text ? `${audioBody}\n${text}` : audioBody;
569
579
  }
570
580
 
581
+ // If there's a pending ask_questions flow for this chat AND the current
582
+ // question is free-text, treat this message as the answer rather than a
583
+ // brand-new turn. Returns true when the message was consumed.
584
+ if (chat_id && text && await this._maybeConsumeAskTextAnswer({ chat_id, text })) {
585
+ // Still log the inbound so the chat history records what the user said.
586
+ appendGlobalMessage({
587
+ channel: "telegram",
588
+ direction: "in",
589
+ type: "user",
590
+ actor_id: msg.from?.id ? String(msg.from.id) : author,
591
+ external_id: String(u.update_id),
592
+ author,
593
+ body: text,
594
+ meta: {
595
+ chat_id,
596
+ user_id: msg.from?.id || null,
597
+ message_id: msg.message_id,
598
+ tg_channel: this.channel.name,
599
+ ask_answer: true,
600
+ },
601
+ });
602
+ return;
603
+ }
604
+
571
605
  // /reset or /new wipes the rolling context for this chat. We just
572
606
  // remember a marker timestamp; subsequent inbounds will only consider
573
607
  // history newer than this. Implemented by writing a synthetic message
@@ -806,6 +840,12 @@ class ChannelPoller {
806
840
  }
807
841
  };
808
842
 
843
+ const confirmAdapter = createTelegramConfirmAdapter({
844
+ token: resolveBotToken(this.channel),
845
+ chatId: chat_id,
846
+ pendingStore: getConfirmStore(),
847
+ });
848
+
809
849
  try {
810
850
  const sa = await runSuperAgent({
811
851
  globalConfig: this.globalConfig,
@@ -826,12 +866,42 @@ class ChannelPoller {
826
866
  }),
827
867
  signal: abortCtrl.signal,
828
868
  onEvent,
869
+ requestConfirmation: confirmAdapter.requestConfirmation,
829
870
  });
830
871
  replyText = sa.text;
831
872
  replyAuthor = sa.name || agentDisplay;
832
873
  replyActorId = SUPERAGENT_ACTOR_ID;
833
874
  replyKind = "superagent";
834
875
  saUsage = sa.usage;
876
+
877
+ // ── ask_questions integration ────────────────────────────────────
878
+ // If the super-agent ended this turn by calling ask_questions, hand
879
+ // off to the inline-keyboard flow instead of sending the bare
880
+ // assistant text. The flow keeps state per chat_id and re-runs the
881
+ // super-agent once every answer is collected.
882
+ const askQuestions = askFlow.extractAskQuestionsFromTrace(sa.trace);
883
+ if (askQuestions && chat_id) {
884
+ if (chat_id) this.activeRequests.delete(chat_id);
885
+ stopTyping();
886
+ try {
887
+ await this._startAskFlow({
888
+ chat_id,
889
+ projectId: target?.id,
890
+ authorId: msg.from?.id,
891
+ questions: askQuestions,
892
+ author,
893
+ agentDisplay,
894
+ relationshipBlock,
895
+ allowedTools,
896
+ target,
897
+ sender,
898
+ update_id: u.update_id,
899
+ });
900
+ } catch (e) {
901
+ this.log(`telegram[${this.channel.name}] ask flow start failed: ${e.message}`);
902
+ }
903
+ return; // The reply for this turn IS the ask flow.
904
+ }
835
905
  } catch (e) {
836
906
  if (abortCtrl.signal.aborted) {
837
907
  // A newer message superseded this one. Whatever streamed so far is
@@ -911,6 +981,250 @@ class ChannelPoller {
911
981
  }
912
982
  }
913
983
 
984
+ async _handleCallbackQuery(callbackQuery) {
985
+ // Route ask_questions button presses before the confirmation adapter —
986
+ // both use `apx:<verb>:...` namespacing but ask owns its own state.
987
+ const data = callbackQuery.data || "";
988
+ if (data.startsWith("apx:ask:")) {
989
+ await this._handleAskCallback(callbackQuery);
990
+ return;
991
+ }
992
+
993
+ const adapter = createTelegramConfirmAdapter({
994
+ token: resolveBotToken(this.channel),
995
+ chatId: callbackQuery.message?.chat?.id,
996
+ pendingStore: getConfirmStore(),
997
+ });
998
+ const handled = await adapter.handleCallbackQuery(callbackQuery);
999
+ if (!handled) {
1000
+ this.log(`telegram[${this.channel.name}] unhandled callback_query: ${callbackQuery.data}`);
1001
+ }
1002
+ }
1003
+
1004
+ // ── ask_questions: state-machine helpers ───────────────────────────────
1005
+ // The flow lives in telegram-ask.js; this class owns the I/O (sending
1006
+ // messages, editing keyboards, re-entering the super-agent loop with the
1007
+ // compiled answer once the flow finishes).
1008
+
1009
+ async _renderQuestion(state) {
1010
+ const text = askFlow.formatQuestionText(state);
1011
+ const reply_markup = askFlow.buildKeyboard(state);
1012
+ // If we already have a message for the previous question, leave its
1013
+ // keyboard wiped — we draw a fresh message per question for clearer
1014
+ // history in the chat (the question text stays as a record).
1015
+ if (state.messageId) {
1016
+ try {
1017
+ await this._editKeyboard({
1018
+ chat_id: state.chatId,
1019
+ message_id: state.messageId,
1020
+ reply_markup: { inline_keyboard: [] },
1021
+ });
1022
+ } catch { /* best-effort */ }
1023
+ }
1024
+ const sent = await this._send({
1025
+ chat_id: state.chatId,
1026
+ text,
1027
+ reply_markup,
1028
+ parse_mode: "Markdown",
1029
+ });
1030
+ state.messageId = sent?.message_id || null;
1031
+ askFlow.saveState(state.chatId, state);
1032
+ }
1033
+
1034
+ // Kick off a brand-new ask flow after the super-agent called ask_questions.
1035
+ // The flow's `resume` callback captures the per-turn context (sender,
1036
+ // relationship, project) so when the compiled answer arrives we can run
1037
+ // another super-agent turn without retyping all the inputs.
1038
+ async _startAskFlow(ctx) {
1039
+ const state = askFlow.startFlow({
1040
+ chatId: ctx.chat_id,
1041
+ projectId: ctx.projectId,
1042
+ authorId: ctx.authorId,
1043
+ questions: ctx.questions,
1044
+ resume: async (compiled) => {
1045
+ await this._runResumedTurn({ ...ctx, compiled });
1046
+ },
1047
+ });
1048
+ await this._renderQuestion(state);
1049
+ }
1050
+
1051
+ // Apply an inline-keyboard press, then react: redraw, advance, or finish.
1052
+ async _handleAskCallback(callbackQuery) {
1053
+ const chatId = callbackQuery.message?.chat?.id;
1054
+ if (!chatId) return;
1055
+ const result = askFlow.applyCallback(chatId, callbackQuery.data || "");
1056
+ // Ack the press regardless — keeps the spinner from hanging client-side.
1057
+ await this._answerCallback({ callback_query_id: callbackQuery.id });
1058
+ if (!result) return; // stale or unknown — adapter already ack'd.
1059
+
1060
+ if (result.action === "redraw") {
1061
+ // Multi-select toggle: just refresh the keyboard on the SAME message.
1062
+ try {
1063
+ await this._editKeyboard({
1064
+ chat_id: chatId,
1065
+ message_id: callbackQuery.message?.message_id,
1066
+ reply_markup: askFlow.buildKeyboard(result.state),
1067
+ });
1068
+ } catch (e) {
1069
+ this.log(`telegram[${this.channel.name}] redraw failed: ${e.message}`);
1070
+ }
1071
+ return;
1072
+ }
1073
+ if (result.action === "advance") {
1074
+ await this._renderQuestion(result.state);
1075
+ return;
1076
+ }
1077
+ if (result.action === "cancel") {
1078
+ try {
1079
+ await this._editKeyboard({
1080
+ chat_id: chatId,
1081
+ message_id: callbackQuery.message?.message_id,
1082
+ reply_markup: { inline_keyboard: [] },
1083
+ });
1084
+ await this._send({ chat_id: chatId, text: "Pregunta cancelada." });
1085
+ } catch { /* best-effort */ }
1086
+ return;
1087
+ }
1088
+ if (result.action === "done") {
1089
+ try {
1090
+ await this._editKeyboard({
1091
+ chat_id: chatId,
1092
+ message_id: callbackQuery.message?.message_id,
1093
+ reply_markup: { inline_keyboard: [] },
1094
+ });
1095
+ } catch { /* best-effort */ }
1096
+ // Feed the compiled answer back as a synthetic user turn.
1097
+ if (typeof result.state.resume === "function") {
1098
+ await result.state.resume(result.compiled);
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ // Apply a free-text user reply when there's a pending free-text question.
1104
+ // Returns true iff the message was consumed by the ask flow (so the normal
1105
+ // super-agent path should be skipped for this update).
1106
+ async _maybeConsumeAskTextAnswer({ chat_id, text }) {
1107
+ if (!chat_id || !text) return false;
1108
+ if (!askFlow.hasPendingFreeText(chat_id)) return false;
1109
+ const state = askFlow.applyTextAnswer(chat_id, text);
1110
+ if (!state) return false;
1111
+ // Advance: emit a synthetic "next" to move past this question.
1112
+ const next = askFlow.applyCallback(
1113
+ chat_id,
1114
+ `apx:ask:${state.correlationId}:next`,
1115
+ );
1116
+ if (!next) return true;
1117
+ if (next.action === "advance") {
1118
+ await this._renderQuestion(next.state);
1119
+ return true;
1120
+ }
1121
+ if (next.action === "done") {
1122
+ if (typeof next.state.resume === "function") {
1123
+ await next.state.resume(next.compiled);
1124
+ }
1125
+ return true;
1126
+ }
1127
+ return true;
1128
+ }
1129
+
1130
+ // Run a follow-up super-agent turn with the compiled answers as the user
1131
+ // prompt. Mirrors the post-runSuperAgent reply path in _handleUpdate but
1132
+ // skipped of the photo/audio/reset preamble. Re-enters the ask flow if the
1133
+ // model decides to ask again.
1134
+ async _runResumedTurn(ctx) {
1135
+ const { chat_id, compiled, target, relationshipBlock, allowedTools, author, agentDisplay, update_id, sender, authorId } = ctx;
1136
+ if (!chat_id) return;
1137
+ // Log the synthetic user message so getRecentTelegramTurnsFromFs picks
1138
+ // it up on the NEXT inbound. Mirrors how a normal text reply would be
1139
+ // recorded.
1140
+ appendGlobalMessage({
1141
+ channel: "telegram",
1142
+ direction: "in",
1143
+ type: "user",
1144
+ actor_id: authorId ? String(authorId) : (author || "ask_flow"),
1145
+ external_id: `ask-${Date.now()}`,
1146
+ author: author || "user",
1147
+ body: compiled,
1148
+ meta: {
1149
+ chat_id,
1150
+ user_id: authorId || null,
1151
+ tg_channel: this.channel.name,
1152
+ ask_flow: true,
1153
+ },
1154
+ });
1155
+
1156
+ const previousMessages = getRecentTelegramTurnsFromFs({
1157
+ chat_id,
1158
+ keepRecent: 40,
1159
+ max_age_hours: 24,
1160
+ });
1161
+
1162
+ const stopTyping = this._startTyping(chat_id);
1163
+ try {
1164
+ const sa = await runSuperAgent({
1165
+ globalConfig: this.globalConfig,
1166
+ projects: this.projects,
1167
+ plugins: this.plugins,
1168
+ registries: this.registries,
1169
+ prompt: compiled,
1170
+ previousMessages,
1171
+ channel: "telegram",
1172
+ relationshipBlock,
1173
+ allowedTools,
1174
+ channelMeta: { channel: "telegram", chat_id, author, route_to_agent: this.channel.route_to_agent },
1175
+ });
1176
+ stopTyping();
1177
+
1178
+ // Did the model ask again? Restart the flow instead of replying.
1179
+ const followupAsk = askFlow.extractAskQuestionsFromTrace(sa.trace);
1180
+ if (followupAsk) {
1181
+ await this._startAskFlow({
1182
+ chat_id,
1183
+ projectId: target?.id,
1184
+ authorId,
1185
+ questions: followupAsk,
1186
+ author,
1187
+ agentDisplay,
1188
+ relationshipBlock,
1189
+ allowedTools,
1190
+ target,
1191
+ sender,
1192
+ update_id,
1193
+ });
1194
+ return;
1195
+ }
1196
+
1197
+ const replyText = sa.text ? stripThinking(sa.text).trim() : "";
1198
+ if (replyText) {
1199
+ await this._send({ chat_id, text: replyText });
1200
+ appendGlobalMessage({
1201
+ channel: "telegram",
1202
+ direction: "out",
1203
+ type: "agent",
1204
+ actor_id: SUPERAGENT_ACTOR_ID,
1205
+ actor_kind: "superagent",
1206
+ agent_slug: SUPERAGENT_ACTOR_ID,
1207
+ author: sa.name || agentDisplay,
1208
+ body: replyText,
1209
+ meta: {
1210
+ chat_id,
1211
+ tg_channel: this.channel.name,
1212
+ in_reply_to: update_id,
1213
+ final: true,
1214
+ ask_resume: true,
1215
+ ...(sa.usage ? { usage: sa.usage } : {}),
1216
+ },
1217
+ });
1218
+ }
1219
+ } catch (e) {
1220
+ stopTyping();
1221
+ this.log(`telegram[${this.channel.name}] ask resume failed: ${e.message}`);
1222
+ try {
1223
+ await this._send({ chat_id, text: `⚠️ Error procesando tus respuestas (${e.message}).` });
1224
+ } catch { /* best-effort */ }
1225
+ }
1226
+ }
1227
+
914
1228
  // Show "typing..." indicator in the chat. Telegram clears it automatically
915
1229
  // after 5 seconds, so call this every ~4s while a long operation is going.
916
1230
  async _typing(chat_id) {
@@ -943,22 +1257,64 @@ class ChannelPoller {
943
1257
  return () => { stopped = true; };
944
1258
  }
945
1259
 
946
- async _send({ chat_id, text }) {
1260
+ async _send({ chat_id, text, reply_markup, parse_mode }) {
947
1261
  const token = resolveBotToken(this.channel);
948
1262
  if (!token) throw new Error(`channel ${this.channel.name}: no bot_token`);
949
1263
  const target = chat_id || resolveChatId(this.channel);
950
1264
  if (!target) throw new Error(`channel ${this.channel.name}: no chat_id`);
951
1265
  const url = `${API_BASE}/bot${token}/sendMessage`;
1266
+ const body = { chat_id: target, text };
1267
+ if (reply_markup) body.reply_markup = reply_markup;
1268
+ if (parse_mode) body.parse_mode = parse_mode;
952
1269
  const res = await fetch(url, {
953
1270
  method: "POST",
954
1271
  headers: { "content-type": "application/json" },
955
- body: JSON.stringify({ chat_id: target, text }),
1272
+ body: JSON.stringify(body),
956
1273
  });
957
1274
  const json = await res.json();
958
1275
  if (!json.ok) throw new Error(json.description || `send failed (${res.status})`);
959
1276
  return json.result;
960
1277
  }
961
1278
 
1279
+ // Replace just the inline keyboard on a previously-sent message (used to
1280
+ // refresh after a multi-select toggle, or to wipe buttons once the flow
1281
+ // has moved on). Best-effort: failures are logged but don't break the flow.
1282
+ async _editKeyboard({ chat_id, message_id, reply_markup }) {
1283
+ const token = resolveBotToken(this.channel);
1284
+ if (!token) return;
1285
+ try {
1286
+ const url = `${API_BASE}/bot${token}/editMessageReplyMarkup`;
1287
+ const body = { chat_id, message_id };
1288
+ if (reply_markup) body.reply_markup = reply_markup;
1289
+ await fetch(url, {
1290
+ method: "POST",
1291
+ headers: { "content-type": "application/json" },
1292
+ body: JSON.stringify(body),
1293
+ });
1294
+ } catch (e) {
1295
+ this.log(`telegram[${this.channel.name}] editMessageReplyMarkup failed: ${e.message}`);
1296
+ }
1297
+ }
1298
+
1299
+ // Acknowledge a callback button press so the user's Telegram client clears
1300
+ // the spinner on the tapped button. Optional `text` shows a small toast.
1301
+ async _answerCallback({ callback_query_id, text }) {
1302
+ const token = resolveBotToken(this.channel);
1303
+ if (!token) return;
1304
+ try {
1305
+ const url = `${API_BASE}/bot${token}/answerCallbackQuery`;
1306
+ const body = { callback_query_id };
1307
+ if (text) body.text = text;
1308
+ await fetch(url, {
1309
+ method: "POST",
1310
+ headers: { "content-type": "application/json" },
1311
+ body: JSON.stringify(body),
1312
+ });
1313
+ } catch (e) {
1314
+ this.log(`telegram[${this.channel.name}] answerCallbackQuery failed: ${e.message}`);
1315
+ }
1316
+ }
1317
+
962
1318
  /** Send a photo via this channel */
963
1319
  async _sendPhoto({ chat_id, photo, caption, parse_mode }) {
964
1320
  const token = resolveBotToken(this.channel);
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { agentSkills, buildAgentSystem as buildCoreAgentSystem } from "../../../core/agent-system.js";
3
+ import { buildConfirmDescription } from "../../../core/confirmation/index.js";
3
4
 
4
5
  export function projectMeta(projects, entry) {
5
6
  const meta = projects.list().find((p) => p.id === entry.id);
@@ -79,19 +80,39 @@ export function buildAgentSystem(project, agent, opts = {}) {
79
80
  return buildCoreAgentSystem(project, agent, opts);
80
81
  }
81
82
 
82
- export function createPermissionGuard(globalConfig = {}, { implicitConfirmation = false } = {}) {
83
+ export function createPermissionGuard(globalConfig = {}, {
84
+ implicitConfirmation = false,
85
+ requestConfirmation = null,
86
+ } = {}) {
83
87
  const permissionMode = globalConfig.super_agent?.permission_mode || "automatico";
84
88
  const allowedTools = new Set(globalConfig.super_agent?.allowed_tools || []);
85
89
 
86
- return function requirePermission(tool, { dangerous = false, confirmed = false } = {}) {
90
+ // async so tools can `await requirePermission(...)` and the confirmation
91
+ // dialog resolves transparently before execution continues.
92
+ return async function requirePermission(tool, { dangerous = false, confirmed = false, args } = {}) {
87
93
  const ok = confirmed || implicitConfirmation;
88
94
  if (permissionMode === "total") return;
89
- if (permissionMode === "permiso" && !allowedTools.has(tool) && !ok) {
90
- throw new Error(`requires_confirmation: permission_mode=permiso blocks ${tool}`);
95
+
96
+ const blocked =
97
+ (permissionMode === "permiso" && !allowedTools.has(tool) && !ok) ||
98
+ (permissionMode === "automatico" && dangerous && !ok);
99
+
100
+ if (!blocked) return;
101
+
102
+ const description = buildConfirmDescription(tool, args || {});
103
+
104
+ if (!requestConfirmation) {
105
+ // No confirmation channel wired for this invocation context (e.g. routine,
106
+ // autonomous agent). Surface a clear message so the model can explain it.
107
+ throw new Error(`Action requires user confirmation: ${description}`);
91
108
  }
92
- if (permissionMode === "automatico" && dangerous && !ok) {
93
- throw new Error(`requires_confirmation: permission_mode=automatico requires confirmation for ${tool}`);
109
+
110
+ const userConfirmed = await requestConfirmation(tool, args || {}, description);
111
+
112
+ if (!userConfirmed) {
113
+ throw new Error(`User did not confirm: ${description}`);
94
114
  }
115
+ // Confirmed — fall through, tool executes normally.
95
116
  };
96
117
  }
97
118
 
@@ -335,6 +335,7 @@ export function makeToolHandlers(ctx) {
335
335
  ...ctx,
336
336
  requirePermission: createPermissionGuard(ctx.globalConfig || {}, {
337
337
  implicitConfirmation: !!ctx.implicitConfirmation,
338
+ requestConfirmation: ctx.requestConfirmation || null,
338
339
  }),
339
340
  };
340
341
  return Object.fromEntries(TOOLS.map((tool) => [tool.name, tool.makeHandler(toolCtx)]));
@@ -31,8 +31,8 @@ export default {
31
31
  },
32
32
  },
33
33
  },
34
- makeHandler: ({ projects, requirePermission }) => ({ path: projectPath, name, init = true, confirmed = false }) => {
35
- requirePermission("add_project", { dangerous: true, confirmed });
34
+ makeHandler: ({ projects, requirePermission }) => async ({ path: projectPath, name, init = true, confirmed = false }) => {
35
+ await requirePermission("add_project", { dangerous: true, confirmed, args: { path: projectPath } });
36
36
  if (!projectPath) throw new Error("add_project: path required");
37
37
 
38
38
  const abs = path.resolve(projectPath);
@@ -1,3 +1,38 @@
1
+ // Normalize a raw question entry into the canonical shape rendered by every
2
+ // surface (web InlineAskPanel, future desktop/telegram/CLI). The model can
3
+ // pass either a plain string (legacy) or a rich object with options.
4
+ function normalizeQuestion(q) {
5
+ if (typeof q === "string") {
6
+ return { question: q, options: [], multiSelect: false, allowText: true };
7
+ }
8
+ if (!q || typeof q !== "object") return null;
9
+ const text = typeof q.question === "string" ? q.question : "";
10
+ if (!text) return null;
11
+ const options = Array.isArray(q.options)
12
+ ? q.options
13
+ .map((o) => {
14
+ if (typeof o === "string") return { label: o };
15
+ if (o && typeof o === "object" && typeof o.label === "string") {
16
+ return {
17
+ label: o.label,
18
+ description: typeof o.description === "string" ? o.description : undefined,
19
+ };
20
+ }
21
+ return null;
22
+ })
23
+ .filter(Boolean)
24
+ : [];
25
+ return {
26
+ question: text,
27
+ header: typeof q.header === "string" ? q.header : undefined,
28
+ options,
29
+ multiSelect: q.multiSelect === true,
30
+ // Free-text fallback: on by default. Set false explicitly to force a
31
+ // pick from `options`. Has no effect when options is empty.
32
+ allowText: q.allowText === false ? false : true,
33
+ };
34
+ }
35
+
1
36
  export default {
2
37
  name: "ask_questions",
3
38
  schema: {
@@ -7,26 +42,74 @@ export default {
7
42
  type: "function",
8
43
  function: {
9
44
  name: "ask_questions",
10
- description: "Ask the user one or more specific questions to clarify the task or gather requirements.",
45
+ description:
46
+ "Ask the user one or more questions when you genuinely need input to proceed. " +
47
+ "Each question can be free-text OR a selectable list of options (single- or multi-select). " +
48
+ "Call this ONCE per turn — the loop hands control back to the user immediately.",
11
49
  parameters: {
12
50
  type: "object",
13
51
  properties: {
14
52
  questions: {
15
53
  type: "array",
16
- items: { type: "string" },
17
- description: "A list of questions for the user."
18
- }
54
+ description:
55
+ "Questions for the user. Each item is an object with the question text " +
56
+ "and optional `options` for selectable answers (single- or multi-select). " +
57
+ "Leave `options` empty for free-text questions.",
58
+ items: {
59
+ type: "object",
60
+ properties: {
61
+ question: { type: "string", description: "The question text." },
62
+ header: {
63
+ type: "string",
64
+ description: "Optional short chip (≤12 chars) shown next to the question.",
65
+ },
66
+ options: {
67
+ type: "array",
68
+ description:
69
+ "Selectable answers. Omit or leave empty for a free-text question. " +
70
+ "Prefer 2–4 distinct, mutually-exclusive choices.",
71
+ items: {
72
+ type: "object",
73
+ properties: {
74
+ label: { type: "string", description: "Visible label." },
75
+ description: {
76
+ type: "string",
77
+ description: "Optional explanation shown under the label.",
78
+ },
79
+ },
80
+ required: ["label"],
81
+ },
82
+ },
83
+ multiSelect: {
84
+ type: "boolean",
85
+ description:
86
+ "true → user can pick several options (checkboxes). Default false (single-select).",
87
+ },
88
+ allowText: {
89
+ type: "boolean",
90
+ description:
91
+ "When options is non-empty, also show an 'Otro' free-text field. Default true.",
92
+ },
93
+ },
94
+ required: ["question"],
95
+ },
96
+ },
19
97
  },
20
- required: ["questions"]
21
- }
22
- }
98
+ required: ["questions"],
99
+ },
100
+ },
23
101
  },
24
102
  makeHandler: () => async ({ questions }) => {
25
- // This tool is used by the agent to explicitly signal that it is waiting for
26
- // answers to specific questions. The UI can then highlight these.
27
- return {
28
- status: "Questions presented to user. Waiting for input.",
29
- count: questions.length
103
+ // Normalize so downstream code (UI panels, persistence) always sees the
104
+ // canonical shape. The agent loop treats this tool as turn-ending
105
+ // (see TURN_ENDING_TOOLS in src/core/agent/constants.js).
106
+ const normalized = Array.isArray(questions)
107
+ ? questions.map(normalizeQuestion).filter(Boolean)
108
+ : [];
109
+ return {
110
+ status: "Questions presented to user. Waiting for input.",
111
+ count: normalized.length,
112
+ questions: normalized,
30
113
  };
31
- }
114
+ },
32
115
  };
@@ -21,7 +21,7 @@ export default {
21
21
  },
22
22
  },
23
23
  makeHandler: ({ projects, registries, requirePermission }) => async ({ project, mcp, tool, args = {}, confirmed = false }) => {
24
- requirePermission("call_mcp", { dangerous: true, confirmed });
24
+ await requirePermission("call_mcp", { dangerous: true, confirmed, args: { mcp, tool } });
25
25
  const p = resolveProject(projects, project);
26
26
  if (!registries) throw new Error("MCP registry unavailable");
27
27
  const registry = registries.for ? registries.for(p) : registries.ensure(p);
@@ -174,7 +174,7 @@ export default {
174
174
  },
175
175
  },
176
176
  makeHandler: ({ projects, requirePermission }) => async ({ project, agent: slug, runtime, prompt, resume_session_id = null, timeout_s = 300, confirmed = false }) => {
177
- requirePermission("call_runtime", { dangerous: true, confirmed });
177
+ await requirePermission("call_runtime", { dangerous: true, confirmed, args: { runtime } });
178
178
 
179
179
  const p = slug ? resolveProjectForAgent(projects, project, slug) : resolveProject(projects, project);
180
180
  const agent = slug ? readAgents(p.path).find((a) => a.slug === slug) : null;
@@ -22,8 +22,8 @@ export default {
22
22
  },
23
23
  },
24
24
  },
25
- makeHandler: ({ projects, requirePermission }) => ({ project, path, search, replace, all = false, confirmed = false }) => {
26
- requirePermission("edit_file", { dangerous: true, confirmed });
25
+ makeHandler: ({ projects, requirePermission }) => async ({ project, path, search, replace, all = false, confirmed = false }) => {
26
+ await requirePermission("edit_file", { dangerous: true, confirmed, args: { path } });
27
27
  if (!path) throw new Error("edit_file: path required");
28
28
  if (!search) throw new Error("edit_file: search required");
29
29
 
@@ -23,8 +23,8 @@ export default {
23
23
  },
24
24
  },
25
25
  },
26
- makeHandler: ({ projects, requirePermission }) => ({ project, agent: slug, confirmed = false }) => {
27
- requirePermission("import_agent", { dangerous: true, confirmed });
26
+ makeHandler: ({ projects, requirePermission }) => async ({ project, agent: slug, confirmed = false }) => {
27
+ await requirePermission("import_agent", { dangerous: true, confirmed, args: { agent: slug } });
28
28
  if (!slug) throw new Error("import_agent: agent required");
29
29
 
30
30
  const vaultPath = path.join(VAULT_DIR, `${slug}.md`);