@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
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.32.0",
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/
@@ -1,3 +1,8 @@
1
1
  export const MAX_TOOL_ITERS = 6;
2
2
  export const ACK_ONLY_TOOLS = new Set(["send_telegram"]);
3
3
  export const MAX_CONSECUTIVE_ACKS = 2;
4
+ // Tools whose semantics REQUIRE handing control back to the user. After the
5
+ // tool runs we break the loop — even under completionContract — because the
6
+ // task literally cannot advance without a human reply. Without this, models
7
+ // under forced toolChoice spam the same question across iterations.
8
+ export const TURN_ENDING_TOOLS = new Set(["ask_questions"]);
@@ -4,7 +4,7 @@ import {
4
4
  cleanTextOfPseudoToolCalls,
5
5
  } from "./tool-call-parser.js";
6
6
  import { resolveActiveModel, fallbackModels } from "./model-router.js";
7
- import { MAX_TOOL_ITERS, ACK_ONLY_TOOLS, MAX_CONSECUTIVE_ACKS } from "./constants.js";
7
+ import { MAX_TOOL_ITERS, ACK_ONLY_TOOLS, MAX_CONSECUTIVE_ACKS, TURN_ENDING_TOOLS } from "./constants.js";
8
8
  import {
9
9
  isShortConfirmation,
10
10
  lastAssistantAskedForConfirmation,
@@ -347,6 +347,7 @@ export async function runAgent({
347
347
  });
348
348
 
349
349
  let finishSummary = null;
350
+ let turnEndingQuestions = null;
350
351
  for (const tc of toolCalls) {
351
352
  const fn = tc.function || tc;
352
353
  const name = fn.name;
@@ -409,6 +410,20 @@ export async function runAgent({
409
410
  tool_name: name,
410
411
  content: JSON.stringify(toolResult),
411
412
  });
413
+
414
+ // Capture turn-ending intents (e.g. ask_questions). The loop cannot
415
+ // legitimately advance without a user reply; under completionContract
416
+ // forcing another tool call just produces ask_questions spam.
417
+ if (TURN_ENDING_TOOLS.has(name) && !turnEndingQuestions) {
418
+ // Questions may be plain strings (legacy) or {question, options, ...}.
419
+ // For the assistant_text fallback we only need the prompt strings.
420
+ const qs = Array.isArray(args.questions)
421
+ ? args.questions
422
+ .map((q) => (typeof q === "string" ? q : q && typeof q.question === "string" ? q.question : null))
423
+ .filter(Boolean)
424
+ : [];
425
+ turnEndingQuestions = qs;
426
+ }
412
427
  }
413
428
 
414
429
  // Task declared complete via the contract — emit the summary as the final
@@ -421,6 +436,19 @@ export async function runAgent({
421
436
  break;
422
437
  }
423
438
 
439
+ // ask_questions (or future turn-ending tools): the task is genuinely
440
+ // blocked on user input. Exit the loop — completionContract or not,
441
+ // asking again gets us nowhere. We deliberately do NOT emit a synthetic
442
+ // assistant_text and we leave lastText empty so persistence and one-shot
443
+ // API callers don't end up with a duplicate bullet list next to the
444
+ // rendering surfaces' own UI (web AskQuestionsCard, terminal renderer,
445
+ // telegram inline keyboard). The structured questions live on the tool
446
+ // trace — that's the canonical source.
447
+ if (turnEndingQuestions) {
448
+ if (!lastText) lastText = "";
449
+ break;
450
+ }
451
+
424
452
  const allAckOnly = toolCalls.every((tc) => {
425
453
  const n = (tc.function?.name) || tc.name;
426
454
  return ACK_ONLY_TOOLS.has(n);
@@ -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
+ }
@@ -2,14 +2,93 @@
2
2
  // GET /projects/:pid/artifacts
3
3
  // POST /projects/:pid/artifacts
4
4
  // GET /projects/:pid/artifacts/:name
5
+ // POST /projects/:pid/artifacts/:name/run body: { args?: string[] }
5
6
  // DELETE /projects/:pid/artifacts/:name
7
+ import fs from "node:fs";
8
+ import { spawn } from "node:child_process";
9
+ import path from "node:path";
6
10
  import {
11
+ artifactPath,
7
12
  createArtifact,
8
13
  listArtifacts,
9
14
  readArtifact,
10
15
  removeArtifact,
11
16
  } from "../../../core/artifacts-store.js";
12
17
 
18
+ // Same heuristic as `apx artifact run` (cli/commands/artifact.js): exec bit
19
+ // OR shebang counts as runnable. We auto-chmod when shebang-only so the
20
+ // web Run button "just works" the way it would from the terminal.
21
+ function detectRunnable(absPath) {
22
+ let stat;
23
+ try {
24
+ stat = fs.statSync(absPath);
25
+ } catch {
26
+ return { runnable: false, reason: "not_found" };
27
+ }
28
+ if (!stat.isFile()) return { runnable: false, reason: "not_a_file" };
29
+ const execBit = (stat.mode & 0o111) !== 0;
30
+ let hasShebang = false;
31
+ try {
32
+ const fd = fs.openSync(absPath, "r");
33
+ const buf = Buffer.alloc(2);
34
+ fs.readSync(fd, buf, 0, 2, 0);
35
+ fs.closeSync(fd);
36
+ hasShebang = buf.toString("utf8") === "#!";
37
+ } catch { /* leave hasShebang = false */ }
38
+ if (execBit) return { runnable: true, autoChmod: false };
39
+ if (hasShebang) return { runnable: true, autoChmod: true };
40
+ return { runnable: false, reason: "no_exec_no_shebang" };
41
+ }
42
+
43
+ // Cap stdout/stderr captured per run so a runaway script can't blow up the
44
+ // daemon. 256 KiB each — enough for typical script output, small enough to
45
+ // fit in one HTTP response without streaming.
46
+ const MAX_CAPTURE_BYTES = 256 * 1024;
47
+ // Hard timeout for synchronous web execution. Long-running scripts should
48
+ // be invoked from the terminal where the user has direct stdio.
49
+ const RUN_TIMEOUT_MS = 30_000;
50
+
51
+ function runArtifact({ absPath, cwd, args, timeoutMs = RUN_TIMEOUT_MS }) {
52
+ return new Promise((resolve) => {
53
+ const started = Date.now();
54
+ const child = spawn(absPath, Array.isArray(args) ? args : [], { cwd });
55
+ let stdout = "";
56
+ let stderr = "";
57
+ let truncated = false;
58
+ let timedOut = false;
59
+ const cap = (s, chunk) => {
60
+ if (s.length >= MAX_CAPTURE_BYTES) { truncated = true; return s; }
61
+ const next = s + chunk.toString("utf8");
62
+ if (next.length > MAX_CAPTURE_BYTES) { truncated = true; return next.slice(0, MAX_CAPTURE_BYTES); }
63
+ return next;
64
+ };
65
+ child.stdout.on("data", (c) => { stdout = cap(stdout, c); });
66
+ child.stderr.on("data", (c) => { stderr = cap(stderr, c); });
67
+ const killer = setTimeout(() => {
68
+ timedOut = true;
69
+ try { child.kill("SIGTERM"); } catch { /* ignore */ }
70
+ setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* ignore */ } }, 1500);
71
+ }, timeoutMs);
72
+ child.on("error", (err) => {
73
+ clearTimeout(killer);
74
+ resolve({ ok: false, error: err.message, durationMs: Date.now() - started });
75
+ });
76
+ child.on("exit", (code, signal) => {
77
+ clearTimeout(killer);
78
+ resolve({
79
+ ok: !timedOut && code === 0,
80
+ exitCode: code,
81
+ signal,
82
+ timedOut,
83
+ truncated,
84
+ stdout,
85
+ stderr,
86
+ durationMs: Date.now() - started,
87
+ });
88
+ });
89
+ });
90
+ }
91
+
13
92
  export function register(app, { project }) {
14
93
  app.get("/projects/:pid/artifacts", (req, res) => {
15
94
  const p = project(req, res);
@@ -49,4 +128,42 @@ export function register(app, { project }) {
49
128
  );
50
129
  res.status(ok ? 204 : 404).end();
51
130
  });
131
+
132
+ // Synchronous execute. Web's "Run" button hits this; the terminal CLI uses
133
+ // its own local spawn (stdio inherited) so it can run interactively. Output
134
+ // is captured up to MAX_CAPTURE_BYTES and the call is bounded by
135
+ // RUN_TIMEOUT_MS — anything longer should go through the terminal.
136
+ app.post("/projects/:pid/artifacts/:name/run", async (req, res) => {
137
+ const p = project(req, res);
138
+ if (!p) return;
139
+ const name = decodeURIComponent(req.params.name);
140
+ const absPath = artifactPath(p.storagePath, name);
141
+ if (!fs.existsSync(absPath)) {
142
+ return res.status(404).json({ error: `artifact "${name}" not found` });
143
+ }
144
+ const detection = detectRunnable(absPath);
145
+ if (!detection.runnable) {
146
+ return res.status(400).json({
147
+ error: `artifact "${name}" is not runnable`,
148
+ reason: detection.reason,
149
+ });
150
+ }
151
+ if (detection.autoChmod) {
152
+ try {
153
+ const st = fs.statSync(absPath);
154
+ fs.chmodSync(absPath, st.mode | 0o111);
155
+ } catch (e) {
156
+ return res.status(500).json({ error: `chmod failed: ${e.message}` });
157
+ }
158
+ }
159
+ const args = Array.isArray(req.body?.args)
160
+ ? req.body.args.filter((a) => typeof a === "string")
161
+ : [];
162
+ const result = await runArtifact({
163
+ absPath,
164
+ cwd: path.dirname(absPath),
165
+ args,
166
+ });
167
+ res.json(result);
168
+ });
52
169
  }
@@ -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,
@@ -67,6 +68,18 @@ function modeGuidanceFor(mode) {
67
68
  "confirmation and do not stop after one step — keep calling tools until the",
68
69
  "entire task is done, then briefly summarize what you changed and why.",
69
70
  "Prefer surgical edits over rewrites.",
71
+ "When the user asks for a reusable script, snippet, or 'artifact' (something",
72
+ "they want to keep and run later), put it under `artifacts/<name>` inside",
73
+ "the project — it then shows up in the Artifacts tab. Don't drop reusable",
74
+ "scripts at the project root.",
75
+ "If a parameter you need is missing (API key, app id, target URL, …), call",
76
+ "`ask_questions` ONCE with all your questions and stop — control returns",
77
+ "to the user. Do not call ask_questions again in the same turn; you'll just",
78
+ "get the same blank state back. Each question can be a string (free-text",
79
+ "answer) OR an object {question, options:[{label, description}], multiSelect}",
80
+ "for choices. Prefer 2–4 mutually-exclusive options when a question has a",
81
+ "natural shortlist (yes/no, which-of-these, …); leave options empty for",
82
+ "open-ended answers (API keys, names, free-form ideas).",
70
83
  ].join(" ");
71
84
  }
72
85
 
@@ -295,6 +308,7 @@ export function register(app, { projects, project, config, registries, plugins }
295
308
  // it keeps the normal "text ends the turn" behavior.
296
309
  completionContract: mode === "build",
297
310
  onEvent,
311
+ requestConfirmation: createWebConfirmAdapter({ onEvent }),
298
312
  });
299
313
  projects.rebuild(p.id);
300
314
 
@@ -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
+ }