@agentprojectcontext/apx 1.31.1 → 1.31.2

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 (29) 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/confirmation/adapters/code.js +41 -0
  5. package/src/core/confirmation/adapters/telegram.js +134 -0
  6. package/src/core/confirmation/adapters/terminal.js +35 -0
  7. package/src/core/confirmation/adapters/web.js +53 -0
  8. package/src/core/confirmation/index.js +44 -0
  9. package/src/core/confirmation/pending-store.js +68 -0
  10. package/src/host/daemon/api/code.js +2 -0
  11. package/src/host/daemon/api/confirm.js +30 -0
  12. package/src/host/daemon/api/super-agent.js +12 -4
  13. package/src/host/daemon/api.js +2 -0
  14. package/src/host/daemon/plugins/telegram.js +28 -0
  15. package/src/host/daemon/super-agent-tools/helpers.js +27 -6
  16. package/src/host/daemon/super-agent-tools/index.js +1 -0
  17. package/src/host/daemon/super-agent-tools/tools/add-project.js +2 -2
  18. package/src/host/daemon/super-agent-tools/tools/call-mcp.js +1 -1
  19. package/src/host/daemon/super-agent-tools/tools/call-runtime.js +1 -1
  20. package/src/host/daemon/super-agent-tools/tools/edit-file.js +2 -2
  21. package/src/host/daemon/super-agent-tools/tools/import-agent.js +2 -2
  22. package/src/host/daemon/super-agent-tools/tools/run-shell.js +1 -1
  23. package/src/host/daemon/super-agent-tools/tools/search-files.js +1 -4
  24. package/src/host/daemon/super-agent-tools/tools/send-telegram.js +1 -1
  25. package/src/host/daemon/super-agent-tools/tools/set-identity.js +2 -2
  26. package/src/host/daemon/super-agent-tools/tools/set-permission-mode.js +2 -2
  27. package/src/host/daemon/super-agent-tools/tools/write-file.js +2 -2
  28. package/src/host/daemon/super-agent.js +5 -1
  29. package/src/interfaces/web/package-lock.json +3 -3
package/README.md CHANGED
@@ -64,7 +64,6 @@ Runtime state — local machine only, never committed:
64
64
 
65
65
  ```text
66
66
  ~/.apx/projects/<project-id>/
67
- ├── project.db ← regenerable SQLite cache
68
67
  ├── messages/ ← local message history
69
68
  └── agents/
70
69
  ├── <slug>/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.31.1",
3
+ "version": "1.31.2",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -81,7 +81,6 @@ Do not store:
81
81
  .apc/sessions/
82
82
  .apc/conversations/
83
83
  .apc/messages/
84
- .apc/project.db
85
84
  .apc/cache/
86
85
  .apc/tmp/
87
86
  .apc/private/
@@ -0,0 +1,41 @@
1
+ // Code-surface confirmation adapter.
2
+ //
3
+ // Same stdin readline pattern as terminal, but formatted for the code TUI:
4
+ // more structured output so it's visually distinct from agent output in the
5
+ // split-pane Build mode. Uses stderr to avoid polluting the code output stream.
6
+
7
+ import readline from "node:readline";
8
+
9
+ const SEPARATOR = "─".repeat(60);
10
+
11
+ /**
12
+ * Creates a requestConfirmation function for the code channel.
13
+ *
14
+ * @returns {(tool: string, args: object, description: string) => Promise<boolean>}
15
+ */
16
+ export function createCodeConfirmAdapter() {
17
+ return async function requestConfirmation(_tool, _args, description) {
18
+ const rl = readline.createInterface({
19
+ input: process.stdin,
20
+ output: process.stderr,
21
+ terminal: false,
22
+ });
23
+
24
+ return new Promise((resolve) => {
25
+ process.stderr.write(
26
+ `\n${SEPARATOR}\n` +
27
+ `[apx] CONFIRM ACTION\n` +
28
+ ` ${description}\n` +
29
+ ` Answer [y = yes / N = no]: `
30
+ );
31
+ rl.once("line", (answer) => {
32
+ rl.close();
33
+ const confirmed = /^(y|yes|ok)$/i.test(answer.trim());
34
+ process.stderr.write(confirmed ? "✓ Confirmed\n" : "✗ Cancelled\n");
35
+ process.stderr.write(`${SEPARATOR}\n`);
36
+ resolve(confirmed);
37
+ });
38
+ rl.once("close", () => resolve(false));
39
+ });
40
+ };
41
+ }
@@ -0,0 +1,134 @@
1
+ // Telegram confirmation adapter — async inline keyboard.
2
+ //
3
+ // Flow (why a Promise survives across two independent HTTP calls):
4
+ //
5
+ // 1. requestConfirmation() is called by the agent loop mid-tool-execution.
6
+ // It creates a pending entry in the shared store, embeds the correlationId
7
+ // in the button callback_data, sends the keyboard to Telegram, and returns
8
+ // a Promise that is NOT yet resolved.
9
+ //
10
+ // 2. The agent loop is now suspended at `await requestConfirmation(...)`.
11
+ // The Telegram bot's polling loop keeps running independently.
12
+ //
13
+ // 3. When the user taps a button, Telegram sends a callback_query update.
14
+ // The plugin's _handleUpdate() routes it to handleCallbackQuery() here.
15
+ //
16
+ // 4. handleCallbackQuery() calls pendingStore.resolve(correlationId, value),
17
+ // which finds the Promise's resolve function in the in-memory map and
18
+ // calls it. The agent loop resumes with true or false.
19
+ //
20
+ // Idempotency: once the promise resolves the entry is removed from the store.
21
+ // Any subsequent tap on the same button won't find an entry and is a no-op.
22
+ //
23
+ // Post-restart stale buttons: if the process restarted after sending the
24
+ // keyboard but before the user tapped, pendingStore.wasKnown() detects the
25
+ // SQLite row with no memory entry and we show "Expirado" instead of an error.
26
+
27
+ const API_BASE = "https://api.telegram.org";
28
+ const TIMEOUT_MS = 60_000; // 60 s — long enough for a human, short enough to not block forever
29
+
30
+ /**
31
+ * @param {{ token: string, chatId: string|number, pendingStore: ConfirmationPendingStore }} opts
32
+ * @returns {{ requestConfirmation, handleCallbackQuery }}
33
+ */
34
+ export function createTelegramConfirmAdapter({ token, chatId, pendingStore }) {
35
+ async function requestConfirmation(tool, _args, description) {
36
+ const { correlationId, promise } = pendingStore.create({ timeoutMs: TIMEOUT_MS });
37
+
38
+ await sendConfirmKeyboard(token, chatId, description, correlationId, TIMEOUT_MS);
39
+
40
+ return promise;
41
+ }
42
+
43
+ // Called by ChannelPoller._handleUpdate() when a callback_query arrives.
44
+ // Returns true if the callback matched our pattern (consumed it), false otherwise.
45
+ async function handleCallbackQuery(callbackQuery) {
46
+ const data = callbackQuery.data || "";
47
+ const match = data.match(/^apx:confirm:([a-f0-9]{16}):(yes|no)$/);
48
+ if (!match) return false;
49
+
50
+ const [, correlationId, answer] = match;
51
+ const confirmed = answer === "yes";
52
+
53
+ // ACK the callback immediately to clear the loading spinner on the button.
54
+ // Fire-and-forget — a slow ACK is annoying but not fatal.
55
+ await answerCallbackQuery(token, callbackQuery.id, confirmed ? "✅ Confirmed" : "❌ Cancelled");
56
+
57
+ const resolved = pendingStore.resolve(correlationId, confirmed);
58
+
59
+ // If not resolved, the entry timed out or the process restarted — show "Expired"
60
+ // so the user knows the button is no longer actionable.
61
+ const inlineKeyboard = resolved
62
+ ? [[{ text: confirmed ? "✅ Confirmed" : "❌ Cancelled", callback_data: "apx:noop" }]]
63
+ : [[{ text: "⏱ Expired", callback_data: "apx:noop" }]];
64
+
65
+ if (callbackQuery.message?.chat?.id && callbackQuery.message?.message_id) {
66
+ await editMessageButtons(
67
+ token,
68
+ callbackQuery.message.chat.id,
69
+ callbackQuery.message.message_id,
70
+ inlineKeyboard
71
+ );
72
+ }
73
+
74
+ return true;
75
+ }
76
+
77
+ return { requestConfirmation, handleCallbackQuery };
78
+ }
79
+
80
+ // ---------- Telegram API helpers --------------------------------------------
81
+
82
+ async function sendConfirmKeyboard(token, chatId, description, correlationId, timeoutMs) {
83
+ const timeoutSec = Math.round(timeoutMs / 1000);
84
+ await fetch(`${API_BASE}/bot${token}/sendMessage`, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({
88
+ chat_id: chatId,
89
+ text:
90
+ `⚠️ *Confirm action*\n\n${escapeMarkdown(description)}\n\n` +
91
+ `_Expires in ${timeoutSec}s. No response → cancelled._`,
92
+ parse_mode: "Markdown",
93
+ reply_markup: {
94
+ inline_keyboard: [[
95
+ { text: "✅ Yes", callback_data: `apx:confirm:${correlationId}:yes` },
96
+ { text: "❌ No", callback_data: `apx:confirm:${correlationId}:no` },
97
+ ]],
98
+ },
99
+ }),
100
+ });
101
+ }
102
+
103
+ async function answerCallbackQuery(token, callbackQueryId, text) {
104
+ try {
105
+ await fetch(`${API_BASE}/bot${token}/answerCallbackQuery`, {
106
+ method: "POST",
107
+ headers: { "content-type": "application/json" },
108
+ body: JSON.stringify({ callback_query_id: callbackQueryId, text }),
109
+ });
110
+ } catch {
111
+ // best-effort — Telegram gives only 30s to answer; after that it's already cleared
112
+ }
113
+ }
114
+
115
+ async function editMessageButtons(token, chatId, messageId, inlineKeyboard) {
116
+ try {
117
+ await fetch(`${API_BASE}/bot${token}/editMessageReplyMarkup`, {
118
+ method: "POST",
119
+ headers: { "content-type": "application/json" },
120
+ body: JSON.stringify({
121
+ chat_id: chatId,
122
+ message_id: messageId,
123
+ reply_markup: { inline_keyboard: inlineKeyboard },
124
+ }),
125
+ });
126
+ } catch {
127
+ // best-effort
128
+ }
129
+ }
130
+
131
+ // Escape Markdown special chars so description text doesn't break Telegram markup.
132
+ function escapeMarkdown(text) {
133
+ return String(text || "").replace(/[_*[\]()~`>#+\-=|{}.!]/g, "\\$&");
134
+ }
@@ -0,0 +1,35 @@
1
+ // Terminal confirmation adapter — synchronous stdin readline.
2
+ //
3
+ // The agent loop blocks here while waiting for user input. This is fine on
4
+ // the terminal surface because the whole process is interactive and
5
+ // single-threaded from the user's perspective.
6
+
7
+ import readline from "node:readline";
8
+
9
+ /**
10
+ * Creates a requestConfirmation function for the terminal channel.
11
+ * Uses stderr so stdout remains clean (useful when output is piped).
12
+ *
13
+ * @returns {(tool: string, args: object, description: string) => Promise<boolean>}
14
+ */
15
+ export function createTerminalConfirmAdapter() {
16
+ return async function requestConfirmation(_tool, _args, description) {
17
+ const rl = readline.createInterface({
18
+ input: process.stdin,
19
+ output: process.stderr,
20
+ terminal: false,
21
+ });
22
+
23
+ return new Promise((resolve) => {
24
+ process.stderr.write(
25
+ `\n⚠ Confirmation required\n ${description}\n Continue? [y/N] `
26
+ );
27
+ rl.once("line", (answer) => {
28
+ rl.close();
29
+ resolve(/^(y|yes|ok)$/i.test(answer.trim()));
30
+ });
31
+ // Covers Ctrl+C / EOF while waiting.
32
+ rl.once("close", () => resolve(false));
33
+ });
34
+ };
35
+ }
@@ -0,0 +1,53 @@
1
+ // Web / TUI confirmation adapter — async SSE + HTTP confirm endpoint.
2
+ //
3
+ // How the Promise resolves across two separate HTTP calls:
4
+ //
5
+ // 1. The agent loop (running inside an SSE request handler) calls
6
+ // requestConfirmation(). This emits a "confirmation_required" SSE event
7
+ // containing the correlationId and description, then suspends by awaiting
8
+ // the pending store's Promise.
9
+ //
10
+ // 2. The browser / TUI receives the SSE event and renders a confirm/cancel
11
+ // dialog. The dialog is keyed by correlationId.
12
+ //
13
+ // 3. The user responds → frontend POSTs to
14
+ // POST /super-agent/confirm/:correlationId { confirmed: boolean }
15
+ //
16
+ // 4. The API handler (api/confirm.js) calls pendingStore.resolve(correlationId,
17
+ // value). This finds the in-memory resolve callback and calls it, unblocking
18
+ // the agent loop. The SSE stream receives the next event and continues.
19
+ //
20
+ // `onEvent` is the SSE emitter for the current turn. It's injected at adapter
21
+ // creation time (each turn gets its own adapter instance) so the "please show
22
+ // a dialog" event reaches the right open SSE connection.
23
+
24
+ import { getConfirmationStore } from "../pending-store.js";
25
+
26
+ const TIMEOUT_MS = 120_000; // 2 min — humans on screens are slower than keyboard
27
+
28
+ /**
29
+ * Factory — call once per SSE turn, passing the turn's `onEvent` emitter.
30
+ *
31
+ * @param {{ onEvent: (event: object) => Promise<void>|void }} opts
32
+ * @returns {(tool: string, args: object, description: string) => Promise<boolean>}
33
+ */
34
+ export function createWebConfirmAdapter({ onEvent }) {
35
+ return async function requestConfirmation(tool, _args, description) {
36
+ const store = getConfirmationStore();
37
+
38
+ const { correlationId, promise } = store.create({ timeoutMs: TIMEOUT_MS });
39
+
40
+ // Push a structured event to the open SSE stream so the frontend knows
41
+ // to render a confirmation dialog. The correlationId is the shared key
42
+ // between this pending promise and the POST /confirm/:correlationId call.
43
+ await onEvent({
44
+ type: "confirmation_required",
45
+ correlationId,
46
+ tool,
47
+ description,
48
+ timeout_ms: TIMEOUT_MS,
49
+ });
50
+
51
+ return promise;
52
+ };
53
+ }
@@ -0,0 +1,44 @@
1
+ // Human-in-the-loop confirmation system.
2
+ //
3
+ // Public surface:
4
+ // getConfirmationStore() shared SQLite-backed pending store
5
+ // buildConfirmDescription(t, a) human-readable action summary
6
+ // isConfirmationRequired(err) true when a tool threw requires_confirmation:
7
+ //
8
+ // Adapters (one per channel type) live under ./adapters/.
9
+
10
+ export { getConfirmationStore, ConfirmationPendingStore } from "./pending-store.js";
11
+
12
+ /**
13
+ * Returns true when `error` was thrown by createPermissionGuard() to signal
14
+ * that this tool call needs explicit user approval before proceeding.
15
+ */
16
+ export function isConfirmationRequired(error) {
17
+ return (
18
+ error != null &&
19
+ typeof error.message === "string" &&
20
+ error.message.startsWith("requires_confirmation:")
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Build a short, human-readable description of the action being confirmed.
26
+ * Shown in all confirmation channels (terminal prompt, Telegram message, web dialog).
27
+ */
28
+ export function buildConfirmDescription(tool, args) {
29
+ const text = (s, max = 150) => String(s || "").slice(0, max);
30
+
31
+ const builders = {
32
+ send_telegram: (a) => `Send Telegram message: "${text(a.text)}"`,
33
+ run_shell: (a) => `Run shell command: \`${text(a.command)}\``,
34
+ write_file: (a) => `Write file: ${a.path || a.file || "(no path)"}`,
35
+ edit_file: (a) => `Edit file: ${a.path || a.file || "(no path)"}`,
36
+ create_task: (a) => `Create task: "${a.title || a.name || "?"}"`,
37
+ add_project: (a) => `Add project: ${a.path || a.name || "?"}`,
38
+ set_identity: (a) => `Change agent identity to: "${a.name || "?"}"`,
39
+ call_runtime: (a) => `Call runtime: ${a.runtime || a.name || "?"}`,
40
+ };
41
+
42
+ const fn = builders[tool];
43
+ return fn ? fn(args) : `Run tool: \`${tool}\``;
44
+ }
@@ -0,0 +1,68 @@
1
+ // Pending confirmation store: in-memory map of unresolved confirmations.
2
+ //
3
+ // Each entry holds a Promise's resolve callback and a timeout timer.
4
+ // When the user responds (any channel), resolve() is called and the agent
5
+ // loop that was suspended at `await requestConfirmation(...)` resumes.
6
+ //
7
+ // No persistence needed: confirmations are ephemeral (30–120s window).
8
+ // If the daemon restarts, the agent runs that created them are also dead,
9
+ // so there is nothing to resume. Stale Telegram buttons simply won't find
10
+ // an entry and the handleCallbackQuery path shows "Expired" gracefully.
11
+
12
+ import { randomBytes } from "node:crypto";
13
+
14
+ function generateId() {
15
+ return randomBytes(8).toString("hex"); // 16 hex chars, URL-safe
16
+ }
17
+
18
+ export class ConfirmationPendingStore {
19
+ constructor() {
20
+ // correlationId -> { resolve: (boolean) => void, timer: NodeJS.Timeout }
21
+ this._pending = new Map();
22
+ }
23
+
24
+ /**
25
+ * Register a new pending confirmation.
26
+ *
27
+ * Returns { correlationId, promise } where:
28
+ * - correlationId: embed in the reply (button callback_data, SSE event…)
29
+ * - promise: resolves to true (confirmed) or false (denied / timeout)
30
+ *
31
+ * After timeoutMs with no response the promise auto-resolves to false.
32
+ */
33
+ create({ timeoutMs = 30_000 } = {}) {
34
+ const correlationId = generateId();
35
+
36
+ const promise = new Promise((resolve) => {
37
+ const timer = setTimeout(() => {
38
+ this._pending.delete(correlationId);
39
+ resolve(false);
40
+ }, timeoutMs);
41
+ this._pending.set(correlationId, { resolve, timer });
42
+ });
43
+
44
+ return { correlationId, promise };
45
+ }
46
+
47
+ /**
48
+ * Resolve a pending confirmation.
49
+ * Returns true if found and resolved, false if not found (timed out, already
50
+ * resolved, or stale button after a process restart).
51
+ */
52
+ resolve(correlationId, value) {
53
+ const entry = this._pending.get(correlationId);
54
+ if (!entry) return false;
55
+ clearTimeout(entry.timer);
56
+ this._pending.delete(correlationId);
57
+ entry.resolve(value);
58
+ return true;
59
+ }
60
+ }
61
+
62
+ // Singleton — one store per daemon process, shared by all adapters.
63
+ let _store = null;
64
+
65
+ export function getConfirmationStore() {
66
+ if (!_store) _store = new ConfirmationPendingStore();
67
+ return _store;
68
+ }
@@ -14,6 +14,7 @@
14
14
  // + per-mode tool gating), then persists the rich assistant turn.
15
15
  import { runSuperAgent } from "../super-agent.js";
16
16
  import { appendSuperAgentErrorTrace } from "./shared.js";
17
+ import { createWebConfirmAdapter } from "../../../core/confirmation/adapters/web.js";
17
18
  import {
18
19
  listCodeSessions,
19
20
  getCodeSession,
@@ -295,6 +296,7 @@ export function register(app, { projects, project, config, registries, plugins }
295
296
  // it keeps the normal "text ends the turn" behavior.
296
297
  completionContract: mode === "build",
297
298
  onEvent,
299
+ requestConfirmation: createWebConfirmAdapter({ onEvent }),
298
300
  });
299
301
  projects.rebuild(p.id);
300
302
 
@@ -0,0 +1,30 @@
1
+ // Confirmation resolution endpoint for web and TUI channels.
2
+ //
3
+ // POST /super-agent/confirm/:correlationId
4
+ // Body: { confirmed: boolean }
5
+ //
6
+ // Called by the frontend after the user responds to a "confirmation_required"
7
+ // SSE event emitted by the web confirmation adapter. Resolves the pending
8
+ // Promise in the agent loop, unblocking it to continue with confirmed: true
9
+ // or to return a cancelled error.
10
+
11
+ import { getConfirmationStore } from "../../../core/confirmation/pending-store.js";
12
+
13
+ export function register(app) {
14
+ app.post("/super-agent/confirm/:correlationId", async (req, res) => {
15
+ const { correlationId } = req.params;
16
+ const { confirmed } = req.body;
17
+
18
+ if (typeof confirmed !== "boolean") {
19
+ return res.status(400).json({ error: "confirmed must be a boolean" });
20
+ }
21
+
22
+ const resolved = getConfirmationStore().resolve(correlationId, confirmed);
23
+
24
+ if (!resolved) {
25
+ return res.status(404).json({ error: "confirmation not found or already expired" });
26
+ }
27
+
28
+ return res.json({ ok: true, confirmed });
29
+ });
30
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from "./shared.js";
13
13
  import { loggerFor } from "../../../core/logging.js";
14
14
  import { appendGlobalMessage } from "../../../core/messages-store.js";
15
+ import { createWebConfirmAdapter } from "../../../core/confirmation/adapters/web.js";
15
16
 
16
17
  const log = loggerFor("super-agent");
17
18
 
@@ -79,6 +80,15 @@ export function register(app, { projects, registries, plugins, project, config }
79
80
  res.write(JSON.stringify(event) + "\n");
80
81
  };
81
82
 
83
+ const onEvent = wrapOnEventForLog(send, {
84
+ trace_id: req.apxTraceId,
85
+ channel: ctx.channel,
86
+ });
87
+
88
+ // Web/TUI channels receive a "confirmation_required" SSE event and respond
89
+ // via POST /super-agent/confirm/:correlationId (see api/confirm.js).
90
+ const requestConfirmation = createWebConfirmAdapter({ onEvent });
91
+
82
92
  try {
83
93
  const saResult = await runSuperAgent({
84
94
  globalConfig: config,
@@ -94,10 +104,8 @@ export function register(app, { projects, registries, plugins, project, config }
94
104
  ...(Number.isFinite(Number(maxIters)) ? { maxIters: Number(maxIters) } : {}),
95
105
  ...(Number.isFinite(Number(maxTokens)) ? { maxTokens: Number(maxTokens) } : {}),
96
106
  ...(completionContract ? { completionContract: true } : {}),
97
- onEvent: wrapOnEventForLog(send, {
98
- trace_id: req.apxTraceId,
99
- channel: ctx.channel,
100
- }),
107
+ onEvent,
108
+ requestConfirmation,
101
109
  });
102
110
  projects.rebuild(p.id);
103
111
  logWebTurn(ctx.channel, { prompt, replyText: saResult.text });
@@ -46,6 +46,7 @@ import { register as registerAdmin } from "./api/admin.js";
46
46
  import { register as registerAdminConfig } from "./api/admin-config.js";
47
47
  import { register as registerIdentity } from "./api/identity.js";
48
48
  import { register as registerWeb } from "./api/web.js";
49
+ import { register as registerConfirm } from "./api/confirm.js";
49
50
 
50
51
  export function buildApi({
51
52
  projects,
@@ -108,6 +109,7 @@ export function buildApi({
108
109
  registerEngines(app, ctx);
109
110
  registerExec(app, ctx);
110
111
  registerSuperAgent(app, ctx);
112
+ registerConfirm(app, ctx);
111
113
  registerCode(app, ctx);
112
114
  registerConversations(app, ctx);
113
115
  registerConnections(app, ctx);
@@ -41,6 +41,8 @@ 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";
44
46
 
45
47
  const API_BASE = "https://api.telegram.org";
46
48
  const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -422,6 +424,13 @@ class ChannelPoller {
422
424
 
423
425
  async _handleUpdate(u) {
424
426
  this.lastUpdateAt = nowIso();
427
+
428
+ // Inline keyboard button press: route to the confirmation adapter.
429
+ if (u.callback_query) {
430
+ await this._handleCallbackQuery(u.callback_query);
431
+ return;
432
+ }
433
+
425
434
  const msg = u.message || u.edited_message;
426
435
  if (!msg) return;
427
436
  const target = this.resolveProject();
@@ -806,6 +815,12 @@ class ChannelPoller {
806
815
  }
807
816
  };
808
817
 
818
+ const confirmAdapter = createTelegramConfirmAdapter({
819
+ token: resolveBotToken(this.channel),
820
+ chatId: chat_id,
821
+ pendingStore: getConfirmStore(),
822
+ });
823
+
809
824
  try {
810
825
  const sa = await runSuperAgent({
811
826
  globalConfig: this.globalConfig,
@@ -826,6 +841,7 @@ class ChannelPoller {
826
841
  }),
827
842
  signal: abortCtrl.signal,
828
843
  onEvent,
844
+ requestConfirmation: confirmAdapter.requestConfirmation,
829
845
  });
830
846
  replyText = sa.text;
831
847
  replyAuthor = sa.name || agentDisplay;
@@ -911,6 +927,18 @@ class ChannelPoller {
911
927
  }
912
928
  }
913
929
 
930
+ async _handleCallbackQuery(callbackQuery) {
931
+ const adapter = createTelegramConfirmAdapter({
932
+ token: resolveBotToken(this.channel),
933
+ chatId: callbackQuery.message?.chat?.id,
934
+ pendingStore: getConfirmStore(),
935
+ });
936
+ const handled = await adapter.handleCallbackQuery(callbackQuery);
937
+ if (!handled) {
938
+ this.log(`telegram[${this.channel.name}] unhandled callback_query: ${callbackQuery.data}`);
939
+ }
940
+ }
941
+
914
942
  // Show "typing..." indicator in the chat. Telegram clears it automatically
915
943
  // after 5 seconds, so call this every ~4s while a long operation is going.
916
944
  async _typing(chat_id) {
@@ -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);
@@ -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`);
@@ -64,7 +64,7 @@ export default {
64
64
  },
65
65
  },
66
66
  makeHandler: ({ projects, requirePermission }) => async ({ project, cwd = ".", command, timeout_s = 60, confirmed = false }) => {
67
- requirePermission("run_shell", { dangerous: !isSafeShellCommand(command), confirmed });
67
+ await requirePermission("run_shell", { dangerous: !isSafeShellCommand(command), confirmed, args: { command } });
68
68
  if (!command) throw new Error("run_shell: command required");
69
69
 
70
70
  const p = resolveProject(projects, project);
@@ -22,10 +22,7 @@ export default {
22
22
  },
23
23
  },
24
24
  },
25
- makeHandler: ({ projects, requirePermission }) => async ({ query, project, path: sub = "." } = {}) => {
26
- // Optional permission check if it's considered destructive, but search is safe read-only
27
- await requirePermission("search_files", { query, project, path: sub }, "safe");
28
-
25
+ makeHandler: ({ projects }) => async ({ query, project, path: sub = "." } = {}) => {
29
26
  const p = resolveProject(projects, project);
30
27
  const target = safePathJoin(p.path, sub);
31
28
 
@@ -87,7 +87,7 @@ export default {
87
87
  confirmed = false,
88
88
  } = args;
89
89
 
90
- requirePermission("send_telegram", { dangerous: true, confirmed });
90
+ await requirePermission("send_telegram", { dangerous: true, confirmed, args: { text } });
91
91
  if (!plugins) throw new Error("plugins unavailable");
92
92
  const telegram = plugins.get("telegram");
93
93
  if (!telegram) throw new Error("telegram plugin not loaded");
@@ -20,8 +20,8 @@ export default {
20
20
  },
21
21
  },
22
22
  },
23
- makeHandler: ({ requirePermission }) => ({ agent_name, owner_name, owner_context, personality, confirmed = false } = {}) => {
24
- requirePermission("set_identity", { dangerous: true, confirmed });
23
+ makeHandler: ({ requirePermission }) => async ({ agent_name, owner_name, owner_context, personality, confirmed = false } = {}) => {
24
+ await requirePermission("set_identity", { dangerous: true, confirmed, args: { agent_name } });
25
25
  const fields = {};
26
26
  if (agent_name) fields.agent_name = agent_name;
27
27
  if (owner_name) fields.owner_name = owner_name;
@@ -20,8 +20,8 @@ export default {
20
20
  },
21
21
  },
22
22
  },
23
- makeHandler: ({ requirePermission }) => ({ mode, confirmed = false }) => {
24
- requirePermission("set_permission_mode", { dangerous: true, confirmed });
23
+ makeHandler: ({ requirePermission }) => async ({ mode, confirmed = false }) => {
24
+ await requirePermission("set_permission_mode", { dangerous: true, confirmed, args: { mode } });
25
25
  if (!MODES.has(mode)) throw new Error("mode must be total, automatico, or permiso");
26
26
  const cfg = readConfig();
27
27
  cfg.super_agent = cfg.super_agent || {};
@@ -21,8 +21,8 @@ export default {
21
21
  },
22
22
  },
23
23
  },
24
- makeHandler: ({ projects, requirePermission }) => ({ project, path: sub, content, confirmed = false }) => {
25
- requirePermission("write_file", { dangerous: true, confirmed });
24
+ makeHandler: ({ projects, requirePermission }) => async ({ project, path: sub, content, confirmed = false }) => {
25
+ await requirePermission("write_file", { dangerous: true, confirmed, args: { path: sub } });
26
26
  if (!sub) throw new Error("write_file: path required");
27
27
  const p = resolveProject(projects, project);
28
28
  const target = safePathJoin(p.path, sub);
@@ -54,6 +54,10 @@ export async function runSuperAgent({
54
54
  // restricts the visible tool schemas to those names; [] means no tools.
55
55
  // Used to gate guests/limited roles on Telegram (see resolveAllowedTools).
56
56
  allowedTools = "*",
57
+ // Channel-specific confirmation handler. See run-agent.js for contract.
58
+ // Null disables human-in-the-loop (tools that need confirmation fail
59
+ // immediately instead of waiting for user input).
60
+ requestConfirmation = null,
57
61
  }) {
58
62
  if (!isSuperAgentEnabled(globalConfig)) {
59
63
  throw new Error("super-agent not enabled (set super_agent.enabled and .model in ~/.apx/config.json)");
@@ -114,7 +118,7 @@ export async function runSuperAgent({
114
118
  overrideModel,
115
119
  toolSchemas,
116
120
  makeToolHandlers,
117
- toolHandlerCtx: { projects, plugins, registries, globalConfig, channel, toolSession },
121
+ toolHandlerCtx: { projects, plugins, registries, globalConfig, channel, toolSession, requestConfirmation },
118
122
  onEvent,
119
123
  signal,
120
124
  onToken,
@@ -2598,9 +2598,9 @@
2598
2598
  "license": "MIT"
2599
2599
  },
2600
2600
  "node_modules/electron-to-chromium": {
2601
- "version": "1.5.368",
2602
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
2603
- "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==",
2601
+ "version": "1.5.370",
2602
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.370.tgz",
2603
+ "integrity": "sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g==",
2604
2604
  "dev": true,
2605
2605
  "license": "ISC"
2606
2606
  },