@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.
- package/README.md +0 -1
- package/package.json +1 -1
- package/skills/apc-context/SKILL.md +0 -1
- package/src/core/confirmation/adapters/code.js +41 -0
- package/src/core/confirmation/adapters/telegram.js +134 -0
- package/src/core/confirmation/adapters/terminal.js +35 -0
- package/src/core/confirmation/adapters/web.js +53 -0
- package/src/core/confirmation/index.js +44 -0
- package/src/core/confirmation/pending-store.js +68 -0
- package/src/host/daemon/api/code.js +2 -0
- package/src/host/daemon/api/confirm.js +30 -0
- package/src/host/daemon/api/super-agent.js +12 -4
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/plugins/telegram.js +28 -0
- package/src/host/daemon/super-agent-tools/helpers.js +27 -6
- package/src/host/daemon/super-agent-tools/index.js +1 -0
- package/src/host/daemon/super-agent-tools/tools/add-project.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/call-mcp.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/call-runtime.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/edit-file.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/import-agent.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/run-shell.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/search-files.js +1 -4
- package/src/host/daemon/super-agent-tools/tools/send-telegram.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/set-identity.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/set-permission-mode.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/write-file.js +2 -2
- package/src/host/daemon/super-agent.js +5 -1
- package/src/interfaces/web/package-lock.json +3 -3
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
98
|
-
|
|
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 });
|
package/src/host/daemon/api.js
CHANGED
|
@@ -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 = {}, {
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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.
|
|
2602
|
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
|
2603
|
-
"integrity": "sha512-
|
|
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
|
},
|