@agentprojectcontext/apx 1.31.2 → 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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/constants.js +5 -0
  3. package/src/core/agent/run-agent.js +29 -1
  4. package/src/host/daemon/api/artifacts.js +117 -0
  5. package/src/host/daemon/api/code.js +12 -0
  6. package/src/host/daemon/plugins/desktop.js +34 -0
  7. package/src/host/daemon/plugins/telegram-ask.js +309 -0
  8. package/src/host/daemon/plugins/telegram.js +330 -2
  9. package/src/host/daemon/super-agent-tools/tools/ask-questions.js +96 -13
  10. package/src/interfaces/cli/commands/artifact.js +99 -0
  11. package/src/interfaces/cli/index.js +4 -0
  12. package/src/interfaces/cli/terminal-chat/renderer.js +22 -2
  13. package/src/interfaces/web/dist/assets/index-63P_ji1a.js +571 -0
  14. package/src/interfaces/web/dist/assets/index-63P_ji1a.js.map +1 -0
  15. package/src/interfaces/web/dist/assets/index-DLWy6dYz.css +1 -0
  16. package/src/interfaces/web/dist/index.html +2 -2
  17. package/src/interfaces/web/package-lock.json +6 -6
  18. package/src/interfaces/web/src/components/chat/AskQuestionsCard.tsx +72 -0
  19. package/src/interfaces/web/src/components/chat/InlineAskPanel.tsx +399 -0
  20. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
  21. package/src/interfaces/web/src/components/chat/MessageList.tsx +2 -1
  22. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +230 -0
  23. package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +12 -4
  24. package/src/interfaces/web/src/i18n/en.ts +20 -0
  25. package/src/interfaces/web/src/i18n/es.ts +20 -0
  26. package/src/interfaces/web/src/lib/api/artifacts.ts +47 -0
  27. package/src/interfaces/web/src/lib/api.ts +1 -0
  28. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +23 -2
  29. package/src/interfaces/web/src/screens/project/ChatTab.tsx +15 -0
  30. package/src/interfaces/web/dist/assets/index-BDUsA6L6.css +0 -1
  31. package/src/interfaces/web/dist/assets/index-BV615I9p.js +0 -548
  32. package/src/interfaces/web/dist/assets/index-BV615I9p.js.map +0 -1
@@ -43,6 +43,7 @@ import { registerSender, resolveAllowedTools } from "../../../core/telegram-iden
43
43
  import { buildRelationshipBlock } from "../../../core/agent/index.js";
44
44
  import { getConfirmationStore as getConfirmStore } from "../../../core/confirmation/pending-store.js";
45
45
  import { createTelegramConfirmAdapter } from "../../../core/confirmation/adapters/telegram.js";
46
+ import * as askFlow from "./telegram-ask.js";
46
47
 
47
48
  const API_BASE = "https://api.telegram.org";
48
49
  const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -577,6 +578,30 @@ class ChannelPoller {
577
578
  text = text ? `${audioBody}\n${text}` : audioBody;
578
579
  }
579
580
 
581
+ // If there's a pending ask_questions flow for this chat AND the current
582
+ // question is free-text, treat this message as the answer rather than a
583
+ // brand-new turn. Returns true when the message was consumed.
584
+ if (chat_id && text && await this._maybeConsumeAskTextAnswer({ chat_id, text })) {
585
+ // Still log the inbound so the chat history records what the user said.
586
+ appendGlobalMessage({
587
+ channel: "telegram",
588
+ direction: "in",
589
+ type: "user",
590
+ actor_id: msg.from?.id ? String(msg.from.id) : author,
591
+ external_id: String(u.update_id),
592
+ author,
593
+ body: text,
594
+ meta: {
595
+ chat_id,
596
+ user_id: msg.from?.id || null,
597
+ message_id: msg.message_id,
598
+ tg_channel: this.channel.name,
599
+ ask_answer: true,
600
+ },
601
+ });
602
+ return;
603
+ }
604
+
580
605
  // /reset or /new wipes the rolling context for this chat. We just
581
606
  // remember a marker timestamp; subsequent inbounds will only consider
582
607
  // history newer than this. Implemented by writing a synthetic message
@@ -848,6 +873,35 @@ class ChannelPoller {
848
873
  replyActorId = SUPERAGENT_ACTOR_ID;
849
874
  replyKind = "superagent";
850
875
  saUsage = sa.usage;
876
+
877
+ // ── ask_questions integration ────────────────────────────────────
878
+ // If the super-agent ended this turn by calling ask_questions, hand
879
+ // off to the inline-keyboard flow instead of sending the bare
880
+ // assistant text. The flow keeps state per chat_id and re-runs the
881
+ // super-agent once every answer is collected.
882
+ const askQuestions = askFlow.extractAskQuestionsFromTrace(sa.trace);
883
+ if (askQuestions && chat_id) {
884
+ if (chat_id) this.activeRequests.delete(chat_id);
885
+ stopTyping();
886
+ try {
887
+ await this._startAskFlow({
888
+ chat_id,
889
+ projectId: target?.id,
890
+ authorId: msg.from?.id,
891
+ questions: askQuestions,
892
+ author,
893
+ agentDisplay,
894
+ relationshipBlock,
895
+ allowedTools,
896
+ target,
897
+ sender,
898
+ update_id: u.update_id,
899
+ });
900
+ } catch (e) {
901
+ this.log(`telegram[${this.channel.name}] ask flow start failed: ${e.message}`);
902
+ }
903
+ return; // The reply for this turn IS the ask flow.
904
+ }
851
905
  } catch (e) {
852
906
  if (abortCtrl.signal.aborted) {
853
907
  // A newer message superseded this one. Whatever streamed so far is
@@ -928,6 +982,14 @@ class ChannelPoller {
928
982
  }
929
983
 
930
984
  async _handleCallbackQuery(callbackQuery) {
985
+ // Route ask_questions button presses before the confirmation adapter —
986
+ // both use `apx:<verb>:...` namespacing but ask owns its own state.
987
+ const data = callbackQuery.data || "";
988
+ if (data.startsWith("apx:ask:")) {
989
+ await this._handleAskCallback(callbackQuery);
990
+ return;
991
+ }
992
+
931
993
  const adapter = createTelegramConfirmAdapter({
932
994
  token: resolveBotToken(this.channel),
933
995
  chatId: callbackQuery.message?.chat?.id,
@@ -939,6 +1001,230 @@ class ChannelPoller {
939
1001
  }
940
1002
  }
941
1003
 
1004
+ // ── ask_questions: state-machine helpers ───────────────────────────────
1005
+ // The flow lives in telegram-ask.js; this class owns the I/O (sending
1006
+ // messages, editing keyboards, re-entering the super-agent loop with the
1007
+ // compiled answer once the flow finishes).
1008
+
1009
+ async _renderQuestion(state) {
1010
+ const text = askFlow.formatQuestionText(state);
1011
+ const reply_markup = askFlow.buildKeyboard(state);
1012
+ // If we already have a message for the previous question, leave its
1013
+ // keyboard wiped — we draw a fresh message per question for clearer
1014
+ // history in the chat (the question text stays as a record).
1015
+ if (state.messageId) {
1016
+ try {
1017
+ await this._editKeyboard({
1018
+ chat_id: state.chatId,
1019
+ message_id: state.messageId,
1020
+ reply_markup: { inline_keyboard: [] },
1021
+ });
1022
+ } catch { /* best-effort */ }
1023
+ }
1024
+ const sent = await this._send({
1025
+ chat_id: state.chatId,
1026
+ text,
1027
+ reply_markup,
1028
+ parse_mode: "Markdown",
1029
+ });
1030
+ state.messageId = sent?.message_id || null;
1031
+ askFlow.saveState(state.chatId, state);
1032
+ }
1033
+
1034
+ // Kick off a brand-new ask flow after the super-agent called ask_questions.
1035
+ // The flow's `resume` callback captures the per-turn context (sender,
1036
+ // relationship, project) so when the compiled answer arrives we can run
1037
+ // another super-agent turn without retyping all the inputs.
1038
+ async _startAskFlow(ctx) {
1039
+ const state = askFlow.startFlow({
1040
+ chatId: ctx.chat_id,
1041
+ projectId: ctx.projectId,
1042
+ authorId: ctx.authorId,
1043
+ questions: ctx.questions,
1044
+ resume: async (compiled) => {
1045
+ await this._runResumedTurn({ ...ctx, compiled });
1046
+ },
1047
+ });
1048
+ await this._renderQuestion(state);
1049
+ }
1050
+
1051
+ // Apply an inline-keyboard press, then react: redraw, advance, or finish.
1052
+ async _handleAskCallback(callbackQuery) {
1053
+ const chatId = callbackQuery.message?.chat?.id;
1054
+ if (!chatId) return;
1055
+ const result = askFlow.applyCallback(chatId, callbackQuery.data || "");
1056
+ // Ack the press regardless — keeps the spinner from hanging client-side.
1057
+ await this._answerCallback({ callback_query_id: callbackQuery.id });
1058
+ if (!result) return; // stale or unknown — adapter already ack'd.
1059
+
1060
+ if (result.action === "redraw") {
1061
+ // Multi-select toggle: just refresh the keyboard on the SAME message.
1062
+ try {
1063
+ await this._editKeyboard({
1064
+ chat_id: chatId,
1065
+ message_id: callbackQuery.message?.message_id,
1066
+ reply_markup: askFlow.buildKeyboard(result.state),
1067
+ });
1068
+ } catch (e) {
1069
+ this.log(`telegram[${this.channel.name}] redraw failed: ${e.message}`);
1070
+ }
1071
+ return;
1072
+ }
1073
+ if (result.action === "advance") {
1074
+ await this._renderQuestion(result.state);
1075
+ return;
1076
+ }
1077
+ if (result.action === "cancel") {
1078
+ try {
1079
+ await this._editKeyboard({
1080
+ chat_id: chatId,
1081
+ message_id: callbackQuery.message?.message_id,
1082
+ reply_markup: { inline_keyboard: [] },
1083
+ });
1084
+ await this._send({ chat_id: chatId, text: "Pregunta cancelada." });
1085
+ } catch { /* best-effort */ }
1086
+ return;
1087
+ }
1088
+ if (result.action === "done") {
1089
+ try {
1090
+ await this._editKeyboard({
1091
+ chat_id: chatId,
1092
+ message_id: callbackQuery.message?.message_id,
1093
+ reply_markup: { inline_keyboard: [] },
1094
+ });
1095
+ } catch { /* best-effort */ }
1096
+ // Feed the compiled answer back as a synthetic user turn.
1097
+ if (typeof result.state.resume === "function") {
1098
+ await result.state.resume(result.compiled);
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ // Apply a free-text user reply when there's a pending free-text question.
1104
+ // Returns true iff the message was consumed by the ask flow (so the normal
1105
+ // super-agent path should be skipped for this update).
1106
+ async _maybeConsumeAskTextAnswer({ chat_id, text }) {
1107
+ if (!chat_id || !text) return false;
1108
+ if (!askFlow.hasPendingFreeText(chat_id)) return false;
1109
+ const state = askFlow.applyTextAnswer(chat_id, text);
1110
+ if (!state) return false;
1111
+ // Advance: emit a synthetic "next" to move past this question.
1112
+ const next = askFlow.applyCallback(
1113
+ chat_id,
1114
+ `apx:ask:${state.correlationId}:next`,
1115
+ );
1116
+ if (!next) return true;
1117
+ if (next.action === "advance") {
1118
+ await this._renderQuestion(next.state);
1119
+ return true;
1120
+ }
1121
+ if (next.action === "done") {
1122
+ if (typeof next.state.resume === "function") {
1123
+ await next.state.resume(next.compiled);
1124
+ }
1125
+ return true;
1126
+ }
1127
+ return true;
1128
+ }
1129
+
1130
+ // Run a follow-up super-agent turn with the compiled answers as the user
1131
+ // prompt. Mirrors the post-runSuperAgent reply path in _handleUpdate but
1132
+ // skipped of the photo/audio/reset preamble. Re-enters the ask flow if the
1133
+ // model decides to ask again.
1134
+ async _runResumedTurn(ctx) {
1135
+ const { chat_id, compiled, target, relationshipBlock, allowedTools, author, agentDisplay, update_id, sender, authorId } = ctx;
1136
+ if (!chat_id) return;
1137
+ // Log the synthetic user message so getRecentTelegramTurnsFromFs picks
1138
+ // it up on the NEXT inbound. Mirrors how a normal text reply would be
1139
+ // recorded.
1140
+ appendGlobalMessage({
1141
+ channel: "telegram",
1142
+ direction: "in",
1143
+ type: "user",
1144
+ actor_id: authorId ? String(authorId) : (author || "ask_flow"),
1145
+ external_id: `ask-${Date.now()}`,
1146
+ author: author || "user",
1147
+ body: compiled,
1148
+ meta: {
1149
+ chat_id,
1150
+ user_id: authorId || null,
1151
+ tg_channel: this.channel.name,
1152
+ ask_flow: true,
1153
+ },
1154
+ });
1155
+
1156
+ const previousMessages = getRecentTelegramTurnsFromFs({
1157
+ chat_id,
1158
+ keepRecent: 40,
1159
+ max_age_hours: 24,
1160
+ });
1161
+
1162
+ const stopTyping = this._startTyping(chat_id);
1163
+ try {
1164
+ const sa = await runSuperAgent({
1165
+ globalConfig: this.globalConfig,
1166
+ projects: this.projects,
1167
+ plugins: this.plugins,
1168
+ registries: this.registries,
1169
+ prompt: compiled,
1170
+ previousMessages,
1171
+ channel: "telegram",
1172
+ relationshipBlock,
1173
+ allowedTools,
1174
+ channelMeta: { channel: "telegram", chat_id, author, route_to_agent: this.channel.route_to_agent },
1175
+ });
1176
+ stopTyping();
1177
+
1178
+ // Did the model ask again? Restart the flow instead of replying.
1179
+ const followupAsk = askFlow.extractAskQuestionsFromTrace(sa.trace);
1180
+ if (followupAsk) {
1181
+ await this._startAskFlow({
1182
+ chat_id,
1183
+ projectId: target?.id,
1184
+ authorId,
1185
+ questions: followupAsk,
1186
+ author,
1187
+ agentDisplay,
1188
+ relationshipBlock,
1189
+ allowedTools,
1190
+ target,
1191
+ sender,
1192
+ update_id,
1193
+ });
1194
+ return;
1195
+ }
1196
+
1197
+ const replyText = sa.text ? stripThinking(sa.text).trim() : "";
1198
+ if (replyText) {
1199
+ await this._send({ chat_id, text: replyText });
1200
+ appendGlobalMessage({
1201
+ channel: "telegram",
1202
+ direction: "out",
1203
+ type: "agent",
1204
+ actor_id: SUPERAGENT_ACTOR_ID,
1205
+ actor_kind: "superagent",
1206
+ agent_slug: SUPERAGENT_ACTOR_ID,
1207
+ author: sa.name || agentDisplay,
1208
+ body: replyText,
1209
+ meta: {
1210
+ chat_id,
1211
+ tg_channel: this.channel.name,
1212
+ in_reply_to: update_id,
1213
+ final: true,
1214
+ ask_resume: true,
1215
+ ...(sa.usage ? { usage: sa.usage } : {}),
1216
+ },
1217
+ });
1218
+ }
1219
+ } catch (e) {
1220
+ stopTyping();
1221
+ this.log(`telegram[${this.channel.name}] ask resume failed: ${e.message}`);
1222
+ try {
1223
+ await this._send({ chat_id, text: `⚠️ Error procesando tus respuestas (${e.message}).` });
1224
+ } catch { /* best-effort */ }
1225
+ }
1226
+ }
1227
+
942
1228
  // Show "typing..." indicator in the chat. Telegram clears it automatically
943
1229
  // after 5 seconds, so call this every ~4s while a long operation is going.
944
1230
  async _typing(chat_id) {
@@ -971,22 +1257,64 @@ class ChannelPoller {
971
1257
  return () => { stopped = true; };
972
1258
  }
973
1259
 
974
- async _send({ chat_id, text }) {
1260
+ async _send({ chat_id, text, reply_markup, parse_mode }) {
975
1261
  const token = resolveBotToken(this.channel);
976
1262
  if (!token) throw new Error(`channel ${this.channel.name}: no bot_token`);
977
1263
  const target = chat_id || resolveChatId(this.channel);
978
1264
  if (!target) throw new Error(`channel ${this.channel.name}: no chat_id`);
979
1265
  const url = `${API_BASE}/bot${token}/sendMessage`;
1266
+ const body = { chat_id: target, text };
1267
+ if (reply_markup) body.reply_markup = reply_markup;
1268
+ if (parse_mode) body.parse_mode = parse_mode;
980
1269
  const res = await fetch(url, {
981
1270
  method: "POST",
982
1271
  headers: { "content-type": "application/json" },
983
- body: JSON.stringify({ chat_id: target, text }),
1272
+ body: JSON.stringify(body),
984
1273
  });
985
1274
  const json = await res.json();
986
1275
  if (!json.ok) throw new Error(json.description || `send failed (${res.status})`);
987
1276
  return json.result;
988
1277
  }
989
1278
 
1279
+ // Replace just the inline keyboard on a previously-sent message (used to
1280
+ // refresh after a multi-select toggle, or to wipe buttons once the flow
1281
+ // has moved on). Best-effort: failures are logged but don't break the flow.
1282
+ async _editKeyboard({ chat_id, message_id, reply_markup }) {
1283
+ const token = resolveBotToken(this.channel);
1284
+ if (!token) return;
1285
+ try {
1286
+ const url = `${API_BASE}/bot${token}/editMessageReplyMarkup`;
1287
+ const body = { chat_id, message_id };
1288
+ if (reply_markup) body.reply_markup = reply_markup;
1289
+ await fetch(url, {
1290
+ method: "POST",
1291
+ headers: { "content-type": "application/json" },
1292
+ body: JSON.stringify(body),
1293
+ });
1294
+ } catch (e) {
1295
+ this.log(`telegram[${this.channel.name}] editMessageReplyMarkup failed: ${e.message}`);
1296
+ }
1297
+ }
1298
+
1299
+ // Acknowledge a callback button press so the user's Telegram client clears
1300
+ // the spinner on the tapped button. Optional `text` shows a small toast.
1301
+ async _answerCallback({ callback_query_id, text }) {
1302
+ const token = resolveBotToken(this.channel);
1303
+ if (!token) return;
1304
+ try {
1305
+ const url = `${API_BASE}/bot${token}/answerCallbackQuery`;
1306
+ const body = { callback_query_id };
1307
+ if (text) body.text = text;
1308
+ await fetch(url, {
1309
+ method: "POST",
1310
+ headers: { "content-type": "application/json" },
1311
+ body: JSON.stringify(body),
1312
+ });
1313
+ } catch (e) {
1314
+ this.log(`telegram[${this.channel.name}] answerCallbackQuery failed: ${e.message}`);
1315
+ }
1316
+ }
1317
+
990
1318
  /** Send a photo via this channel */
991
1319
  async _sendPhoto({ chat_id, photo, caption, parse_mode }) {
992
1320
  const token = resolveBotToken(this.channel);
@@ -1,3 +1,38 @@
1
+ // Normalize a raw question entry into the canonical shape rendered by every
2
+ // surface (web InlineAskPanel, future desktop/telegram/CLI). The model can
3
+ // pass either a plain string (legacy) or a rich object with options.
4
+ function normalizeQuestion(q) {
5
+ if (typeof q === "string") {
6
+ return { question: q, options: [], multiSelect: false, allowText: true };
7
+ }
8
+ if (!q || typeof q !== "object") return null;
9
+ const text = typeof q.question === "string" ? q.question : "";
10
+ if (!text) return null;
11
+ const options = Array.isArray(q.options)
12
+ ? q.options
13
+ .map((o) => {
14
+ if (typeof o === "string") return { label: o };
15
+ if (o && typeof o === "object" && typeof o.label === "string") {
16
+ return {
17
+ label: o.label,
18
+ description: typeof o.description === "string" ? o.description : undefined,
19
+ };
20
+ }
21
+ return null;
22
+ })
23
+ .filter(Boolean)
24
+ : [];
25
+ return {
26
+ question: text,
27
+ header: typeof q.header === "string" ? q.header : undefined,
28
+ options,
29
+ multiSelect: q.multiSelect === true,
30
+ // Free-text fallback: on by default. Set false explicitly to force a
31
+ // pick from `options`. Has no effect when options is empty.
32
+ allowText: q.allowText === false ? false : true,
33
+ };
34
+ }
35
+
1
36
  export default {
2
37
  name: "ask_questions",
3
38
  schema: {
@@ -7,26 +42,74 @@ export default {
7
42
  type: "function",
8
43
  function: {
9
44
  name: "ask_questions",
10
- description: "Ask the user one or more specific questions to clarify the task or gather requirements.",
45
+ description:
46
+ "Ask the user one or more questions when you genuinely need input to proceed. " +
47
+ "Each question can be free-text OR a selectable list of options (single- or multi-select). " +
48
+ "Call this ONCE per turn — the loop hands control back to the user immediately.",
11
49
  parameters: {
12
50
  type: "object",
13
51
  properties: {
14
52
  questions: {
15
53
  type: "array",
16
- items: { type: "string" },
17
- description: "A list of questions for the user."
18
- }
54
+ description:
55
+ "Questions for the user. Each item is an object with the question text " +
56
+ "and optional `options` for selectable answers (single- or multi-select). " +
57
+ "Leave `options` empty for free-text questions.",
58
+ items: {
59
+ type: "object",
60
+ properties: {
61
+ question: { type: "string", description: "The question text." },
62
+ header: {
63
+ type: "string",
64
+ description: "Optional short chip (≤12 chars) shown next to the question.",
65
+ },
66
+ options: {
67
+ type: "array",
68
+ description:
69
+ "Selectable answers. Omit or leave empty for a free-text question. " +
70
+ "Prefer 2–4 distinct, mutually-exclusive choices.",
71
+ items: {
72
+ type: "object",
73
+ properties: {
74
+ label: { type: "string", description: "Visible label." },
75
+ description: {
76
+ type: "string",
77
+ description: "Optional explanation shown under the label.",
78
+ },
79
+ },
80
+ required: ["label"],
81
+ },
82
+ },
83
+ multiSelect: {
84
+ type: "boolean",
85
+ description:
86
+ "true → user can pick several options (checkboxes). Default false (single-select).",
87
+ },
88
+ allowText: {
89
+ type: "boolean",
90
+ description:
91
+ "When options is non-empty, also show an 'Otro' free-text field. Default true.",
92
+ },
93
+ },
94
+ required: ["question"],
95
+ },
96
+ },
19
97
  },
20
- required: ["questions"]
21
- }
22
- }
98
+ required: ["questions"],
99
+ },
100
+ },
23
101
  },
24
102
  makeHandler: () => async ({ questions }) => {
25
- // This tool is used by the agent to explicitly signal that it is waiting for
26
- // answers to specific questions. The UI can then highlight these.
27
- return {
28
- status: "Questions presented to user. Waiting for input.",
29
- count: questions.length
103
+ // Normalize so downstream code (UI panels, persistence) always sees the
104
+ // canonical shape. The agent loop treats this tool as turn-ending
105
+ // (see TURN_ENDING_TOOLS in src/core/agent/constants.js).
106
+ const normalized = Array.isArray(questions)
107
+ ? questions.map(normalizeQuestion).filter(Boolean)
108
+ : [];
109
+ return {
110
+ status: "Questions presented to user. Waiting for input.",
111
+ count: normalized.length,
112
+ questions: normalized,
30
113
  };
31
- }
114
+ },
32
115
  };
@@ -1,6 +1,40 @@
1
+ import fs from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import path from "node:path";
1
4
  import { http } from "../http.js";
2
5
  import { resolveProjectId } from "./project.js";
3
6
 
7
+ // First two bytes of an executable script. Used as a hint when the file
8
+ // doesn't have the exec bit but clearly intends to run (shebang line).
9
+ const SHEBANG = "#!";
10
+
11
+ // Decide if an artifact is "runnable": exec bit on the file, OR the file
12
+ // starts with a shebang. If shebang-but-not-exec, we set the bit before
13
+ // spawning so `./script` works without the user having to chmod +x.
14
+ function detectRunnable(absPath) {
15
+ let stat;
16
+ try {
17
+ stat = fs.statSync(absPath);
18
+ } catch {
19
+ return { runnable: false, reason: "not_found" };
20
+ }
21
+ if (!stat.isFile()) return { runnable: false, reason: "not_a_file" };
22
+ const execBit = (stat.mode & 0o111) !== 0;
23
+ let hasShebang = false;
24
+ try {
25
+ const fd = fs.openSync(absPath, "r");
26
+ const buf = Buffer.alloc(2);
27
+ fs.readSync(fd, buf, 0, 2, 0);
28
+ fs.closeSync(fd);
29
+ hasShebang = buf.toString("utf8") === SHEBANG;
30
+ } catch {
31
+ // ignore: hasShebang stays false
32
+ }
33
+ if (execBit) return { runnable: true, reason: "exec_bit", autoChmod: false };
34
+ if (hasShebang) return { runnable: true, reason: "shebang", autoChmod: true };
35
+ return { runnable: false, reason: "no_exec_no_shebang" };
36
+ }
37
+
4
38
  export async function cmdArtifactCreate(args) {
5
39
  const name = args._[0];
6
40
  if (!name) throw new Error("apx artifact create: missing <name>");
@@ -43,3 +77,68 @@ export async function cmdArtifactRemove(args) {
43
77
  await http.delete(`/projects/${pid}/artifacts/${encodeURIComponent(name)}`);
44
78
  console.log(`removed artifact "${name}"`);
45
79
  }
80
+
81
+ // `apx artifact run <name> [-- args...]`
82
+ //
83
+ // Resolves the artifact's absolute path via the daemon (single source of
84
+ // truth for project storage), then spawns the file LOCALLY with stdio
85
+ // inherited so the caller sees output as if they typed `./artifact <args>`.
86
+ // Detection is lenient: exec bit OR shebang → runnable; shebang-only files
87
+ // get a one-shot chmod +x so the user doesn't have to do it themselves.
88
+ //
89
+ // The remaining argv after `run <name>` is passed straight to the script,
90
+ // so `apx artifact run hello.sh -- hola mundo` becomes `./hello.sh hola mundo`.
91
+ export async function cmdArtifactRun(args) {
92
+ const name = args._[0];
93
+ if (!name) throw new Error("apx artifact run: missing <name>");
94
+ const pid = await resolveProjectId(args?.flags?.project);
95
+ // Pull the artifact record from the daemon for the absolute path.
96
+ let entry;
97
+ try {
98
+ entry = await http.get(`/projects/${pid}/artifacts/${encodeURIComponent(name)}`);
99
+ } catch (e) {
100
+ throw new Error(`artifact "${name}" not found in project #${pid}: ${e.message}`);
101
+ }
102
+ const absPath = entry.path;
103
+ if (!absPath || !fs.existsSync(absPath)) {
104
+ throw new Error(`artifact "${name}" path missing on disk: ${absPath}`);
105
+ }
106
+
107
+ const detection = detectRunnable(absPath);
108
+ if (!detection.runnable) {
109
+ const hint = detection.reason === "no_exec_no_shebang"
110
+ ? "no es ejecutable (sin shebang ni bit +x). Probá `apx artifact show` para ver el contenido."
111
+ : `no se puede ejecutar (${detection.reason}).`;
112
+ throw new Error(`artifact "${name}" ${hint}`);
113
+ }
114
+ if (detection.autoChmod) {
115
+ try {
116
+ const st = fs.statSync(absPath);
117
+ fs.chmodSync(absPath, st.mode | 0o111);
118
+ } catch (e) {
119
+ throw new Error(`could not chmod +x ${absPath}: ${e.message}`);
120
+ }
121
+ }
122
+
123
+ // Everything after the artifact name is forwarded to the script. The CLI's
124
+ // argv parser already strips top-level flags; what's left in `_` is name
125
+ // + script args.
126
+ const scriptArgs = (args._ || []).slice(1);
127
+ const cwd = path.dirname(absPath);
128
+ const child = spawn(absPath, scriptArgs, { stdio: "inherit", cwd });
129
+
130
+ await new Promise((resolve) => {
131
+ child.on("exit", (code, signal) => {
132
+ if (signal) {
133
+ // Killed by signal — surface a non-zero exit for shell pipelines.
134
+ process.exit(128 + (signal === "SIGINT" ? 2 : 15));
135
+ }
136
+ process.exit(code ?? 0);
137
+ });
138
+ child.on("error", (err) => {
139
+ console.error(`apx artifact run: spawn failed — ${err.message}`);
140
+ resolve();
141
+ process.exit(1);
142
+ });
143
+ });
144
+ }
@@ -125,6 +125,7 @@ import {
125
125
  cmdArtifactList,
126
126
  cmdArtifactShow,
127
127
  cmdArtifactRemove,
128
+ cmdArtifactRun,
128
129
  } from "./commands/artifact.js";
129
130
  import {
130
131
  cmdTaskAdd,
@@ -1273,12 +1274,14 @@ const HELP_TOPICS = new Map(Object.entries({
1273
1274
  ["create <name>", "Create a new empty artifact. Prints its absolute path."],
1274
1275
  ["list | ls", "List artifacts in the project."],
1275
1276
  ["show <name>", "Print artifact content."],
1277
+ ["run <name> [args...]", "Execute a runnable artifact (shebang or +x). Stdio is inherited."],
1276
1278
  ["remove | rm <name>", "Delete an artifact."],
1277
1279
  ],
1278
1280
  examples: [
1279
1281
  "apx artifact create check_asana.sh --project 0",
1280
1282
  "apx artifact list",
1281
1283
  "apx artifact show check_asana.sh",
1284
+ "apx artifact run check_asana.sh",
1282
1285
  ],
1283
1286
  }),
1284
1287
  "artifact create": topic({
@@ -2490,6 +2493,7 @@ async function dispatch(cmd, rest) {
2490
2493
  else if (sub === "create" || sub === "new") await cmdArtifactCreate(a);
2491
2494
  else if (sub === "show" || sub === "get") await cmdArtifactShow(a);
2492
2495
  else if (sub === "remove" || sub === "rm") await cmdArtifactRemove(a);
2496
+ else if (sub === "run") await cmdArtifactRun(a);
2493
2497
  else die(`unknown artifact subcommand: ${sub}`);
2494
2498
  break;
2495
2499
  }
@@ -326,11 +326,31 @@ function transcriptLines(transcript, width) {
326
326
  if (isQuestion && trace.args?.questions) {
327
327
  addLine(lines, "", C.bg);
328
328
  addLine(lines, margin + label + C.muted + " (…)" + C.bg, C.bg);
329
- for (const q of trace.args.questions) {
330
- const qWrapped = wrapText(`• ${q}`, inner - 2);
329
+ for (const rawQ of trace.args.questions) {
330
+ // Rich shape: {question, options[], multiSelect, allowText}. Legacy: string.
331
+ const q = typeof rawQ === "string" ? { question: rawQ } : (rawQ || {});
332
+ const qText = typeof q.question === "string" ? q.question : "";
333
+ if (!qText) continue;
334
+ const qWrapped = wrapText(`• ${qText}`, inner - 2);
331
335
  for (const line of qWrapped) {
332
336
  addLine(lines, margin + C.primary + "┃ " + C.text + padAnsi(line, inner - 2), C.bg);
333
337
  }
338
+ const opts = Array.isArray(q.options) ? q.options : [];
339
+ if (opts.length > 0) {
340
+ const marker = q.multiSelect ? "[ ]" : "( )";
341
+ opts.forEach((opt, i) => {
342
+ const label = (opt && typeof opt === "object" && typeof opt.label === "string")
343
+ ? opt.label
344
+ : (typeof opt === "string" ? opt : "");
345
+ if (!label) return;
346
+ const desc = (opt && typeof opt === "object" && typeof opt.description === "string")
347
+ ? ` — ${opt.description}` : "";
348
+ const optLine = ` ${marker} ${i + 1}. ${label}${desc}`;
349
+ for (const line of wrapText(optLine, inner - 2)) {
350
+ addLine(lines, margin + C.primary + "┃ " + C.muted + padAnsi(line, inner - 2), C.bg);
351
+ }
352
+ });
353
+ }
334
354
  }
335
355
  } else {
336
356
  addLine(lines, margin + label + " " + name + C.bg, C.bg);