@agentprojectcontext/apx 1.33.1 → 1.34.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 (169) hide show
  1. package/package.json +1 -1
  2. package/skills/apx/SKILL.md +49 -61
  3. package/src/core/agent/a2a/reply.js +48 -0
  4. package/src/core/agent/build-agent-system.js +4 -3
  5. package/src/core/agent/channels/voice-context.js +98 -0
  6. package/src/core/agent/memory.js +2 -1
  7. package/src/core/agent/prompt-builder.js +2 -1
  8. package/src/core/agent/prompts/modes/code-build.md +1 -0
  9. package/src/core/agent/prompts/modes/code-plan.md +1 -0
  10. package/src/core/agent/prompts/modes/index.js +28 -0
  11. package/src/core/agent/skills/loader.js +22 -18
  12. package/src/core/agent/stream/turn-accumulator.js +73 -0
  13. package/src/core/agent/suggestions.js +37 -0
  14. package/src/core/agent/tools/handlers/add-project.js +5 -2
  15. package/src/core/agent/tools/handlers/call-runtime.js +3 -2
  16. package/src/core/agent/tools/handlers/transcribe-audio.js +1 -1
  17. package/src/core/agent/tools/helpers.js +2 -2
  18. package/src/core/agent/tools/names.js +138 -0
  19. package/src/core/agent/tools/registry-bridge.js +6 -14
  20. package/src/core/agent/tools/registry.js +68 -65
  21. package/src/core/apc/context-copy.js +27 -0
  22. package/src/core/apc/notes.js +19 -0
  23. package/src/core/apc/parser.js +12 -5
  24. package/src/core/apc/paths.js +87 -0
  25. package/src/core/apc/scaffold.js +82 -76
  26. package/src/core/apc/skill-sync.js +10 -0
  27. package/src/{host/daemon/plugins → core/channels}/telegram/dispatch.js +38 -16
  28. package/src/core/config/index.js +3 -2
  29. package/src/core/config/redact.js +95 -0
  30. package/src/core/constants/channels.js +2 -0
  31. package/src/core/constants/code-modes.js +10 -0
  32. package/src/core/constants/index.js +1 -0
  33. package/src/core/deck/manifest.js +186 -0
  34. package/src/core/engines/catalog.js +83 -0
  35. package/src/core/{tools → http-tools}/browser.js +0 -1
  36. package/src/core/{tools → http-tools}/fetch.js +0 -1
  37. package/src/core/{tools → http-tools}/glob.js +0 -1
  38. package/src/core/{tools → http-tools}/grep.js +0 -1
  39. package/src/core/{tools → http-tools}/registry.js +0 -1
  40. package/src/core/{tools → http-tools}/search.js +0 -1
  41. package/src/core/i18n/en.js +9 -0
  42. package/src/core/i18n/es.js +12 -0
  43. package/src/core/i18n/index.js +54 -0
  44. package/src/core/i18n/pt.js +9 -0
  45. package/src/core/identity/telegram.js +2 -1
  46. package/src/core/mcp/runner.js +272 -14
  47. package/src/core/mcp/sources.js +3 -2
  48. package/src/core/routines/index.js +16 -0
  49. package/src/{host/daemon/routines.js → core/routines/runner.js} +36 -103
  50. package/src/core/runtime-skills/apc-context/SKILL.md +159 -0
  51. package/src/core/runtime-skills/apx/SKILL.md +95 -0
  52. package/src/core/runtime-skills/apx-mcp/SKILL.md +116 -0
  53. package/src/core/runtime-skills/{claude-code.md → claude-code/SKILL.md} +1 -0
  54. package/src/core/runtime-skills/{codex-cli.md → codex-cli/SKILL.md} +1 -0
  55. package/src/core/runtime-skills/{opencode-cli.md → opencode-cli/SKILL.md} +1 -0
  56. package/src/core/runtime-skills/{openrouter.md → openrouter/SKILL.md} +1 -0
  57. package/src/{host/daemon/env-detect.js → core/runtimes/detect.js} +1 -1
  58. package/src/core/stores/code-sessions.js +50 -2
  59. package/src/core/stores/routine-memory.js +1 -1
  60. package/src/core/stores/sessions-search.js +121 -0
  61. package/src/core/stores/sessions.js +38 -0
  62. package/src/core/vars/index.js +14 -0
  63. package/src/core/vars/interpolate.js +86 -0
  64. package/src/core/vars/sources.js +151 -0
  65. package/src/core/voice/audio-decode.js +38 -0
  66. package/src/core/voice/transcription.js +225 -0
  67. package/src/host/daemon/api/admin-config.js +5 -82
  68. package/src/host/daemon/api/agents.js +5 -5
  69. package/src/host/daemon/api/code.js +17 -169
  70. package/src/host/daemon/api/config.js +3 -4
  71. package/src/host/daemon/api/conversations.js +8 -29
  72. package/src/host/daemon/api/deck.js +37 -404
  73. package/src/host/daemon/api/engines.js +1 -80
  74. package/src/host/daemon/api/exec.js +1 -1
  75. package/src/host/daemon/api/mcps.js +32 -0
  76. package/src/host/daemon/api/routines.js +1 -1
  77. package/src/host/daemon/api/runtimes.js +4 -3
  78. package/src/host/daemon/api/sessions-search.js +24 -140
  79. package/src/host/daemon/api/sessions.js +12 -30
  80. package/src/host/daemon/api/shared.js +2 -1
  81. package/src/host/daemon/api/telegram.js +1 -11
  82. package/src/host/daemon/api/tools.js +6 -6
  83. package/src/host/daemon/api/transcribe.js +2 -2
  84. package/src/host/daemon/api/vars.js +137 -0
  85. package/src/host/daemon/api/voice.js +13 -290
  86. package/src/host/daemon/api.js +2 -0
  87. package/src/host/daemon/db.js +6 -6
  88. package/src/host/daemon/deck-exec.js +148 -0
  89. package/src/host/daemon/index.js +3 -3
  90. package/src/host/daemon/plugins/telegram/index.js +9 -9
  91. package/src/host/daemon/routines-scheduler.js +64 -0
  92. package/src/host/daemon/smoke.js +3 -2
  93. package/src/host/daemon/whisper-server.js +225 -0
  94. package/src/interfaces/cli/commands/agent.js +3 -2
  95. package/src/interfaces/cli/commands/command.js +2 -3
  96. package/src/interfaces/cli/commands/messages.js +6 -2
  97. package/src/interfaces/cli/commands/pair.js +5 -4
  98. package/src/interfaces/cli/commands/search.js +1 -1
  99. package/src/interfaces/cli/commands/sessions.js +3 -2
  100. package/src/interfaces/cli/commands/skills.js +36 -55
  101. package/src/interfaces/web/dist/assets/index-DdmSRtsz.css +1 -0
  102. package/src/interfaces/web/dist/assets/index-M4FspaCH.js +613 -0
  103. package/src/interfaces/web/dist/assets/index-M4FspaCH.js.map +1 -0
  104. package/src/interfaces/web/dist/index.html +2 -2
  105. package/src/interfaces/web/package-lock.json +182 -182
  106. package/src/interfaces/web/src/components/ModelCombobox.tsx +2 -1
  107. package/src/interfaces/web/src/components/TelegramChannelDialog.tsx +1 -1
  108. package/src/interfaces/web/src/components/chat/AskAnswersCard.tsx +76 -0
  109. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
  110. package/src/interfaces/web/src/components/chat/MessageList.tsx +23 -1
  111. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +3 -1
  112. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +4 -4
  113. package/src/interfaces/web/src/components/code/CodeChangesTab.tsx +1 -1
  114. package/src/interfaces/web/src/components/code/CodeFileTree.tsx +3 -2
  115. package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +3 -2
  116. package/src/interfaces/web/src/components/code/CodeTerminal.tsx +3 -2
  117. package/src/interfaces/web/src/components/config/GlobalConfigEditor.tsx +2 -1
  118. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +2 -1
  119. package/src/interfaces/web/src/components/inputs/KeyValueList.tsx +93 -0
  120. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +449 -0
  121. package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +2 -1
  122. package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +2 -2
  123. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +5 -4
  124. package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +3 -2
  125. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +3 -2
  126. package/src/interfaces/web/src/components/ui/chat-input.tsx +5 -4
  127. package/src/interfaces/web/src/components/ui/sidebar.tsx +3 -2
  128. package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +2 -1
  129. package/src/interfaces/web/src/constants/index.ts +1 -1
  130. package/src/interfaces/web/src/i18n/en.ts +174 -7
  131. package/src/interfaces/web/src/i18n/es.ts +179 -15
  132. package/src/interfaces/web/src/lib/api/mcps.ts +25 -0
  133. package/src/interfaces/web/src/lib/api/vars.ts +38 -0
  134. package/src/interfaces/web/src/lib/api.ts +1 -0
  135. package/src/interfaces/web/src/screens/ProjectScreen.tsx +8 -31
  136. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +1 -1
  137. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +4 -3
  138. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +7 -6
  139. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +4 -3
  140. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +1 -1
  141. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +132 -1
  142. package/src/interfaces/web/src/screens/project/McpsTab.tsx +549 -104
  143. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +1 -1
  144. package/src/interfaces/web/src/screens/project/VarsTab.tsx +300 -0
  145. package/src/interfaces/web/src/types/daemon.ts +5 -0
  146. package/src/host/daemon/transcription.js +0 -538
  147. package/src/host/daemon/whisper-transcribe.py +0 -73
  148. package/src/interfaces/web/dist/assets/index-Aaiw8BZN.css +0 -1
  149. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js +0 -602
  150. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js.map +0 -1
  151. /package/src/{host/daemon → core/apc}/projects-helpers.js +0 -0
  152. /package/src/{host/daemon/plugins → core/channels}/telegram/ask.js +0 -0
  153. /package/src/{host/daemon/plugins → core/channels}/telegram/helpers.js +0 -0
  154. /package/src/{host/daemon/plugins → core/channels}/telegram/media.js +0 -0
  155. /package/src/core/{tools → http-tools}/index.js +0 -0
  156. /package/{skills → src/core/runtime-skills}/apx-agency-agents/SKILL.md +0 -0
  157. /package/{skills → src/core/runtime-skills}/apx-agent/SKILL.md +0 -0
  158. /package/{skills → src/core/runtime-skills}/apx-mcp-builder/SKILL.md +0 -0
  159. /package/{skills → src/core/runtime-skills}/apx-project/SKILL.md +0 -0
  160. /package/{skills → src/core/runtime-skills}/apx-routine/SKILL.md +0 -0
  161. /package/{skills → src/core/runtime-skills}/apx-runtime/SKILL.md +0 -0
  162. /package/{skills → src/core/runtime-skills}/apx-sessions/SKILL.md +0 -0
  163. /package/{skills → src/core/runtime-skills}/apx-skill-builder/SKILL.md +0 -0
  164. /package/{skills → src/core/runtime-skills}/apx-task/SKILL.md +0 -0
  165. /package/{skills → src/core/runtime-skills}/apx-telegram/SKILL.md +0 -0
  166. /package/{skills → src/core/runtime-skills}/apx-voice/SKILL.md +0 -0
  167. /package/src/{host/daemon/compact.js → core/stores/conversations-compactor.js} +0 -0
  168. /package/src/{host/daemon → core/stores}/conversations.js +0 -0
  169. /package/src/{host/daemon → core/util}/thinking.js +0 -0
@@ -1,9 +1,22 @@
1
- // MCP runner: spawn child MCP processes and proxy JSON-RPC tools/call.
2
- // Speaks the stdio transport: newline-delimited JSON-RPC 2.0 messages.
1
+ // MCP runner: spawn child MCP processes (stdio) or talk to remote MCP
2
+ // servers (HTTP). Speaks JSON-RPC 2.0 either way.
3
+ //
4
+ // Variables referenced as `${var.NAME}` in args/env/url/headers are resolved
5
+ // at process/client construction time against project + global vars. Missing
6
+ // references surface as a MissingVarError with the full list so the UI can
7
+ // report "missing TOKEN_A, TOKEN_B" instead of one-at-a-time.
3
8
  import { spawn } from "node:child_process";
4
9
  import { loadAll } from "./sources.js";
10
+ import { interpolate, MissingVarError } from "#core/vars/interpolate.js";
11
+ import { loadAllVars } from "#core/vars/sources.js";
5
12
 
6
13
  const DEFAULT_TIMEOUT_MS = 30_000;
14
+ const LOG_CAP = 64; // entries per MCP we keep in memory
15
+ const STDERR_BUF_CAP = 4096; // bytes of stderr tail we hand back
16
+
17
+ function nowIso() {
18
+ return new Date().toISOString();
19
+ }
7
20
 
8
21
  class McpProcess {
9
22
  constructor({ name, command, args = [], env = {} }) {
@@ -11,6 +24,7 @@ class McpProcess {
11
24
  this.command = command;
12
25
  this.args = args;
13
26
  this.env = env;
27
+ this.transport = "stdio";
14
28
  this.proc = null;
15
29
  this.buffer = "";
16
30
  this.pending = new Map(); // id -> { resolve, reject, timer }
@@ -18,14 +32,24 @@ class McpProcess {
18
32
  this._initPromise = null;
19
33
  this._initialized = false;
20
34
  this._stderrBuf = "";
35
+ this.logs = []; // { ts, level, msg }
36
+ this.startedAt = null;
37
+ this.lastExitCode = null;
38
+ }
39
+
40
+ _log(level, msg) {
41
+ this.logs.push({ ts: nowIso(), level, msg });
42
+ if (this.logs.length > LOG_CAP) this.logs.shift();
21
43
  }
22
44
 
23
45
  start() {
24
46
  if (this.proc) return;
47
+ this._log("info", `spawn ${this.command} ${(this.args || []).join(" ")}`);
25
48
  this.proc = spawn(this.command, this.args, {
26
49
  env: { ...process.env, ...this.env },
27
50
  stdio: ["pipe", "pipe", "pipe"],
28
51
  });
52
+ this.startedAt = nowIso();
29
53
 
30
54
  this.proc.stdout.setEncoding("utf8");
31
55
  this.proc.stderr.setEncoding("utf8");
@@ -33,12 +57,16 @@ class McpProcess {
33
57
  this.proc.stdout.on("data", (chunk) => this._onStdout(chunk));
34
58
  this.proc.stderr.on("data", (chunk) => {
35
59
  this._stderrBuf += chunk;
36
- if (this._stderrBuf.length > 4096) {
37
- this._stderrBuf = this._stderrBuf.slice(-4096);
60
+ if (this._stderrBuf.length > STDERR_BUF_CAP) {
61
+ this._stderrBuf = this._stderrBuf.slice(-STDERR_BUF_CAP);
38
62
  }
63
+ const trimmed = chunk.trim();
64
+ if (trimmed) this._log("stderr", trimmed.slice(-512));
39
65
  });
40
66
 
41
67
  this.proc.on("exit", (code) => {
68
+ this.lastExitCode = code;
69
+ this._log("info", `exit code=${code}`);
42
70
  const err = new Error(
43
71
  `MCP "${this.name}" exited with code ${code}. stderr: ${this._stderrBuf.trim()}`
44
72
  );
@@ -133,6 +161,19 @@ class McpProcess {
133
161
  return this._send("tools/call", { name, arguments: args || {} });
134
162
  }
135
163
 
164
+ getLogs() {
165
+ return {
166
+ transport: "stdio",
167
+ command: this.command,
168
+ args: this.args,
169
+ started_at: this.startedAt,
170
+ running: !!this.proc,
171
+ last_exit_code: this.lastExitCode,
172
+ stderr_tail: this._stderrBuf,
173
+ events: this.logs.slice(),
174
+ };
175
+ }
176
+
136
177
  stop() {
137
178
  if (this.proc) {
138
179
  try {
@@ -143,6 +184,190 @@ class McpProcess {
143
184
  }
144
185
  }
145
186
 
187
+ // HTTP MCP client. Posts JSON-RPC 2.0 to the configured URL with the
188
+ // configured headers. Each call is a fresh fetch — we do not maintain a
189
+ // long-lived SSE stream. This works for servers that implement the simple
190
+ // JSON-RPC response style (which is most third-party MCP HTTP servers,
191
+ // including Asana's mcp.asana.com endpoint).
192
+ // Header values must be Latin1 — fetch throws "Cannot convert argument to a
193
+ // ByteString" on any code point above 255. We also normalize whitespace that
194
+ // the web editor's contentEditable injects: zero-width chars + non-breaking
195
+ // spaces (U+00A0 — the silent space substitute that makes Asana reject
196
+ // `Bearer\xA0token` with "Authorization header must be in format Bearer
197
+ // <token>"). One spot of sanitization covers every header value the runner
198
+ // sends, regardless of where the poisoned char originated.
199
+ function sanitizeHeaderValue(v) {
200
+ return String(v)
201
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, "")
202
+ .replace(/\u00A0/g, " ");
203
+ }
204
+
205
+ function sanitizeHeaders(h) {
206
+ if (!h) return {};
207
+ const out = {};
208
+ for (const [k, v] of Object.entries(h)) {
209
+ out[sanitizeHeaderValue(k)] = sanitizeHeaderValue(v);
210
+ }
211
+ return out;
212
+ }
213
+
214
+ class HttpMcpClient {
215
+ constructor({ name, url, headers = {} }) {
216
+ this.name = name;
217
+ this.url = sanitizeHeaderValue(url);
218
+ this.headers = sanitizeHeaders(headers);
219
+ this.transport = "http";
220
+ this._nextId = 1;
221
+ this._initialized = false;
222
+ this._initPromise = null;
223
+ this.logs = [];
224
+ this.startedAt = null;
225
+ this.lastError = null;
226
+ }
227
+
228
+ _log(level, msg) {
229
+ this.logs.push({ ts: nowIso(), level, msg });
230
+ if (this.logs.length > LOG_CAP) this.logs.shift();
231
+ }
232
+
233
+ async _rpc(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
234
+ if (!this.startedAt) this.startedAt = nowIso();
235
+ const id = this._nextId++;
236
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
237
+ const ctrl = new AbortController();
238
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
239
+ this._log("info", `POST ${method}`);
240
+ let res;
241
+ try {
242
+ res = await fetch(this.url, {
243
+ method: "POST",
244
+ headers: {
245
+ "Content-Type": "application/json",
246
+ Accept: "application/json, text/event-stream",
247
+ "MCP-Protocol-Version": "2024-11-05",
248
+ ...this.headers,
249
+ },
250
+ body,
251
+ signal: ctrl.signal,
252
+ });
253
+ } catch (e) {
254
+ this.lastError = e.message;
255
+ this._log("error", `fetch failed: ${e.message}`);
256
+ throw new Error(`MCP "${this.name}" HTTP error: ${e.message}`);
257
+ } finally {
258
+ clearTimeout(timer);
259
+ }
260
+ const contentType = res.headers.get("content-type") || "";
261
+ const text = await res.text();
262
+ if (!res.ok) {
263
+ this.lastError = `HTTP ${res.status}`;
264
+ this._log("error", `HTTP ${res.status} ${text.slice(0, 200)}`);
265
+ throw new Error(
266
+ `MCP "${this.name}" HTTP ${res.status}: ${text.slice(0, 300)}`
267
+ );
268
+ }
269
+ // text/event-stream — pluck the first JSON-RPC payload from the SSE frames.
270
+ let payload;
271
+ if (contentType.includes("text/event-stream")) {
272
+ payload = parseFirstSseJson(text);
273
+ if (!payload) {
274
+ this.lastError = "no JSON in SSE stream";
275
+ throw new Error(`MCP "${this.name}" returned empty SSE stream`);
276
+ }
277
+ } else {
278
+ try {
279
+ payload = JSON.parse(text);
280
+ } catch (e) {
281
+ this.lastError = `non-JSON response: ${e.message}`;
282
+ throw new Error(
283
+ `MCP "${this.name}" non-JSON response: ${text.slice(0, 300)}`
284
+ );
285
+ }
286
+ }
287
+ if (payload.error) {
288
+ this.lastError = payload.error.message || "rpc error";
289
+ throw new Error(payload.error.message || "MCP error");
290
+ }
291
+ return payload.result;
292
+ }
293
+
294
+ async _ensureInitialized() {
295
+ if (this._initialized) return;
296
+ if (!this._initPromise) {
297
+ this._initPromise = (async () => {
298
+ await this._rpc(
299
+ "initialize",
300
+ {
301
+ protocolVersion: "2024-11-05",
302
+ capabilities: {},
303
+ clientInfo: { name: "apx-daemon", version: "0.1.0" },
304
+ },
305
+ 10_000
306
+ );
307
+ // Best-effort notification — many servers ignore this for HTTP.
308
+ try {
309
+ await fetch(this.url, {
310
+ method: "POST",
311
+ headers: {
312
+ "Content-Type": "application/json",
313
+ Accept: "application/json",
314
+ "MCP-Protocol-Version": "2024-11-05",
315
+ ...this.headers,
316
+ },
317
+ body: JSON.stringify({
318
+ jsonrpc: "2.0",
319
+ method: "notifications/initialized",
320
+ }),
321
+ });
322
+ } catch {}
323
+ this._initialized = true;
324
+ })();
325
+ }
326
+ return this._initPromise;
327
+ }
328
+
329
+ async listTools() {
330
+ await this._ensureInitialized();
331
+ return this._rpc("tools/list", {});
332
+ }
333
+
334
+ async callTool(name, args) {
335
+ await this._ensureInitialized();
336
+ return this._rpc("tools/call", { name, arguments: args || {} });
337
+ }
338
+
339
+ getLogs() {
340
+ return {
341
+ transport: "http",
342
+ url: this.url,
343
+ started_at: this.startedAt,
344
+ last_error: this.lastError,
345
+ events: this.logs.slice(),
346
+ };
347
+ }
348
+
349
+ stop() {
350
+ this._initialized = false;
351
+ this._initPromise = null;
352
+ }
353
+ }
354
+
355
+ function parseFirstSseJson(raw) {
356
+ for (const block of raw.split(/\r?\n\r?\n/)) {
357
+ const dataLines = [];
358
+ for (const line of block.split(/\r?\n/)) {
359
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
360
+ }
361
+ if (!dataLines.length) continue;
362
+ try {
363
+ return JSON.parse(dataLines.join("\n"));
364
+ } catch {
365
+ continue;
366
+ }
367
+ }
368
+ return null;
369
+ }
370
+
146
371
  function entryToMeta(e) {
147
372
  return {
148
373
  name: e.name,
@@ -158,8 +383,6 @@ function entryToMeta(e) {
158
383
  }
159
384
 
160
385
  export class McpRegistry {
161
- // Accepts either the project path string (back-compat) or an object
162
- // { projectPath, storagePath } so the runtime scope can be aggregated.
163
386
  constructor(arg) {
164
387
  if (typeof arg === "string" || arg == null) {
165
388
  this.projectPath = arg || null;
@@ -168,7 +391,7 @@ export class McpRegistry {
168
391
  this.projectPath = arg.projectPath || null;
169
392
  this.storagePath = arg.storagePath || null;
170
393
  }
171
- this.processes = new Map(); // mcp name -> McpProcess
394
+ this.processes = new Map(); // mcp name -> McpProcess | HttpMcpClient
172
395
  }
173
396
 
174
397
  _load() {
@@ -196,19 +419,40 @@ export class McpRegistry {
196
419
  return e ? entryToMeta(e) : null;
197
420
  }
198
421
 
422
+ _resolveVars() {
423
+ return loadAllVars({ storagePath: this.storagePath }).effective;
424
+ }
425
+
426
+ _resolveMeta(meta) {
427
+ try {
428
+ return interpolate(meta, this._resolveVars());
429
+ } catch (e) {
430
+ if (e instanceof MissingVarError) {
431
+ const list = e.missing.map((n) => `\${var.${n}}`).join(", ");
432
+ throw new Error(
433
+ `MCP "${meta.name}" has undefined variable${e.missing.length > 1 ? "s" : ""}: ${list}. Define them at /p/<id>/vars (or globally at /p/0/vars).`
434
+ );
435
+ }
436
+ throw e;
437
+ }
438
+ }
439
+
199
440
  _ensureProcess(name) {
200
441
  let proc = this.processes.get(name);
201
- if (proc && proc.proc) return proc;
442
+ if (proc) {
443
+ if (proc.transport === "stdio" && proc.proc) return proc;
444
+ if (proc.transport === "http") return proc;
445
+ }
202
446
  const meta = this.getByName(name);
203
447
  if (!meta) throw new Error(`MCP "${name}" not registered`);
204
448
  if (!meta.enabled) throw new Error(`MCP "${name}" is disabled`);
205
- if (meta.transport === "http" || meta.url) {
206
- throw new Error(
207
- `MCP "${name}" uses HTTP transport (url=${meta.url}); HTTP/SSE transport arrives in v0.2. Use a stdio MCP for now.`
208
- );
449
+ const resolved = this._resolveMeta(meta);
450
+ if (resolved.transport === "http" || resolved.url) {
451
+ proc = new HttpMcpClient(resolved);
452
+ } else {
453
+ if (!resolved.command) throw new Error(`MCP "${name}" has no command — invalid registration`);
454
+ proc = new McpProcess(resolved);
209
455
  }
210
- if (!meta.command) throw new Error(`MCP "${name}" has no command — invalid registration`);
211
- proc = new McpProcess(meta);
212
456
  this.processes.set(name, proc);
213
457
  return proc;
214
458
  }
@@ -223,6 +467,20 @@ export class McpRegistry {
223
467
  return proc.listTools();
224
468
  }
225
469
 
470
+ getLogs(name) {
471
+ const proc = this.processes.get(name);
472
+ if (proc) return proc.getLogs();
473
+ const meta = this.getByName(name);
474
+ if (!meta) return null;
475
+ return {
476
+ transport: meta.transport || "stdio",
477
+ running: false,
478
+ started_at: null,
479
+ events: [],
480
+ note: "MCP not started yet — open the Test or Call panel to spawn it.",
481
+ };
482
+ }
483
+
226
484
  shutdown() {
227
485
  for (const p of this.processes.values()) p.stop();
228
486
  this.processes.clear();
@@ -36,6 +36,7 @@
36
36
  import fs from "node:fs";
37
37
  import os from "node:os";
38
38
  import path from "node:path";
39
+ import { apcMcpsFile } from "#core/apc/paths.js";
39
40
 
40
41
  const APX_HOME = path.join(os.homedir(), ".apx");
41
42
  const GLOBAL_MCPS_FILE = path.join(APX_HOME, "mcps.json");
@@ -175,7 +176,7 @@ function normalize(name, server, sourceId) {
175
176
  // ---------------------------------------------------------------------------
176
177
 
177
178
  export function readApfMcps(projectRoot) {
178
- const p = path.join(projectRoot, ".apc", "mcps.json");
179
+ const p = apcMcpsFile(projectRoot);
179
180
  if (!fs.existsSync(p)) return { mcpServers: {} };
180
181
  try {
181
182
  const json = JSON.parse(fs.readFileSync(p, "utf8"));
@@ -187,7 +188,7 @@ export function readApfMcps(projectRoot) {
187
188
  }
188
189
 
189
190
  export function writeApfMcps(projectRoot, json) {
190
- const p = path.join(projectRoot, ".apc", "mcps.json");
191
+ const p = apcMcpsFile(projectRoot);
191
192
  fs.mkdirSync(path.dirname(p), { recursive: true });
192
193
  fs.writeFileSync(p, JSON.stringify(json, null, 2) + "\n");
193
194
  }
@@ -0,0 +1,16 @@
1
+ // Public entry point for routines. Re-exports the CRUD helpers from
2
+ // core/stores/routines.js plus the runner — so callers (CLI, HTTP, scheduler,
3
+ // MCP server) import everything from one place.
4
+ export {
5
+ listRoutines,
6
+ getRoutine,
7
+ upsertRoutine,
8
+ deleteRoutine,
9
+ setEnabled,
10
+ updateRunState,
11
+ getDueRoutines,
12
+ parseSchedule,
13
+ computeNextRun,
14
+ } from "#core/stores/routines.js";
15
+
16
+ export { runRoutineNow } from "./runner.js";
@@ -1,19 +1,17 @@
1
- // Routines: scheduled tasks per project. State persists in .apc/routines.json.
1
+ // Routine execution the domain logic any caller can invoke (daemon
2
+ // scheduler, CLI `apx routine run`, HTTP `/projects/:pid/routines/:name/run`,
3
+ // MCP server, future scripts). The runner orchestrates a 3-phase pipeline:
4
+ // 1. pre_commands (shell)
5
+ // 2. handler (heartbeat / exec_agent / super_agent / telegram / shell)
6
+ // 3. post_commands (shell)
2
7
  //
3
- // Schedule formats:
4
- // every:60s | every:5m | every:1h | once:<iso-8601>
5
- //
6
- // Kinds:
7
- // heartbeat — log a heartbeat message. spec: { channel?, message? }
8
- // exec_agent — call an agent engine. spec: { agent: slug, prompt }
9
- // super_agent — call the APX super-agent. spec: { prompt }
10
- // telegram — send a Telegram message. spec: { channel?, chat_id?, text }
11
- // shell — run a shell command. spec: { command, timeout_ms? }
12
-
8
+ // `runRoutineNow(ctx, routine)` is the single entry point. Pass a ctx with at
9
+ // least { project, projects, plugins, registries, globalConfig }. The runner
10
+ // is process-state free — the daemon's RoutineScheduler is a separate file
11
+ // (host/daemon/routines-scheduler.js) that just polls and calls this.
13
12
  import { spawn } from "node:child_process";
14
- import { execFile } from "node:child_process";
15
- import os from "node:os";
16
13
  import fs from "node:fs";
14
+ import os from "node:os";
17
15
  import path from "node:path";
18
16
  import { callEngine } from "#core/engines/index.js";
19
17
  import { runSuperAgent } from "#core/agent/super-agent.js";
@@ -22,32 +20,18 @@ import { readAgents } from "#core/apc/parser.js";
22
20
  import { buildAgentSystem } from "#core/agent/build-agent-system.js";
23
21
  import { resolveAgentName, SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
24
22
  import { resolveArtifactRef, ARTIFACTS_SKIP_SIGNAL } from "#core/stores/artifacts.js";
25
- import { ensureRoutineMemory, readRoutineMemoryForPrompt, routineMemoryPath } from "#core/stores/routine-memory.js";
23
+ import {
24
+ ensureRoutineMemory,
25
+ readRoutineMemoryForPrompt,
26
+ routineMemoryPath,
27
+ } from "#core/stores/routine-memory.js";
26
28
  import { CHANNELS } from "#core/constants/channels.js";
27
29
  import {
28
- listRoutines,
29
- getRoutine,
30
- upsertRoutine,
31
- deleteRoutine,
32
- setEnabled,
33
30
  updateRunState,
34
- getDueRoutines,
35
31
  parseSchedule,
36
32
  computeNextRun,
37
33
  } from "#core/stores/routines.js";
38
-
39
- export {
40
- listRoutines,
41
- getRoutine,
42
- upsertRoutine,
43
- deleteRoutine,
44
- setEnabled,
45
- parseSchedule,
46
- computeNextRun,
47
- };
48
-
49
- const TICK_MS = 5_000;
50
- const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
34
+ import { nowIso } from "#core/util/time.js";
51
35
 
52
36
  // --------------------- handlers ---------------------------------------------
53
37
 
@@ -211,7 +195,7 @@ const HANDLERS = {
211
195
 
212
196
  // --------------------- pipeline: pre/post shell commands --------------------
213
197
 
214
- // Run a single shell command. Returns { exitCode, stdout, stderr }.
198
+ /** Run a single shell command. Returns { exitCode, stdout, stderr }. */
215
199
  function runShellCmd(cmd, env = {}, cwd = os.homedir()) {
216
200
  return new Promise((resolve) => {
217
201
  const child = spawn("sh", ["-c", cmd], {
@@ -228,13 +212,13 @@ function runShellCmd(cmd, env = {}, cwd = os.homedir()) {
228
212
  });
229
213
  }
230
214
 
231
- // Inject {{pre_output}} into a prompt string.
215
+ /** Inject {{pre_output}} into a prompt string. */
232
216
  function injectPreOutput(prompt, preOutput) {
233
217
  if (!prompt || typeof prompt !== "string") return prompt;
234
218
  return prompt.replace(/\{\{pre_output\}\}/g, preOutput || "");
235
219
  }
236
220
 
237
- // Determine whether to skip the LLM call based on skip_prompt_on + pre results.
221
+ /** Decide whether to skip the LLM call based on skip_prompt_on + pre results. */
238
222
  function shouldSkipPrompt(routine, preExitCode, preStdout) {
239
223
  const mode = routine.skip_prompt_on || "signal";
240
224
  if (mode === "always") return true;
@@ -245,10 +229,22 @@ function shouldSkipPrompt(routine, preExitCode, preStdout) {
245
229
  return false;
246
230
  }
247
231
 
248
- // --------------------- runtime: run one + loop ------------------------------
249
-
232
+ // --------------------- runtime: run one routine -----------------------------
233
+
234
+ /**
235
+ * Execute a single routine end-to-end (pre_commands → handler → post_commands)
236
+ * and persist last-run state. Pure with respect to process lifecycle — does NOT
237
+ * touch a timer, queue, or scheduler. Pure with respect to network — the
238
+ * super-agent / telegram handlers obviously go out, but the orchestration is
239
+ * sync from the caller's point of view via the returned promise.
240
+ *
241
+ * @param {object} ctx
242
+ * - project: ProjectManager entry (logMessage, path, storagePath)
243
+ * - projects, plugins, registries, globalConfig
244
+ * @param {object} routine The routine record from core/stores/routines.js
245
+ * @returns {object} { status, last_run_at, next_run_at, ...handler-result }
246
+ */
250
247
  export async function runRoutineNow(ctx, routine) {
251
- // Determine the working directory for shell commands.
252
248
  const cwd = ctx.project?.path || os.homedir();
253
249
  const storagePath = ctx.project?.storagePath || os.homedir();
254
250
 
@@ -263,31 +259,26 @@ export async function runRoutineNow(ctx, routine) {
263
259
  if (hasPreCmds) {
264
260
  const combinedOut = [];
265
261
  for (const rawCmd of routine.pre_commands) {
266
- // Resolve "artifact:<name>" shorthand to its absolute path.
267
262
  const cmd = resolveArtifactRef(rawCmd, storagePath);
268
263
  const { exitCode, stdout, stderr } = await runShellCmd(cmd, {}, cwd);
269
264
  combinedOut.push(stdout);
270
265
  if (stderr) combinedOut.push(stderr);
271
266
  preExitCode = exitCode;
272
267
  if (exitCode !== 0 && (routine.skip_prompt_on === "pre_failure" || routine.skip_prompt_on === "signal")) {
273
- // Stop running further pre_commands on failure when mode cares about exit code.
274
268
  break;
275
269
  }
276
270
  }
277
271
  preStdout = combinedOut.join("");
278
272
 
279
- // Write pre output to a temp file so post_commands can reference it via
280
- // $APX_PRE_OUTPUT_FILE even if the output is large.
281
273
  try {
282
274
  preOutputFile = path.join(os.tmpdir(), `apx-pre-${routine.name}-${Date.now()}.txt`);
283
275
  fs.writeFileSync(preOutputFile, preStdout);
284
276
  } catch { preOutputFile = null; }
285
277
  }
286
278
 
287
- // Env vars injected into post_commands and available in shell pre_commands output.
288
279
  const pipelineEnv = {
289
280
  APX_PRE_EXIT: String(preExitCode),
290
- APX_PRE_OUTPUT: preStdout.slice(0, 32_000), // guard against huge outputs
281
+ APX_PRE_OUTPUT: preStdout.slice(0, 32_000),
291
282
  APX_PRE_OUTPUT_FILE: preOutputFile || "",
292
283
  APX_ROUTINE: routine.name,
293
284
  };
@@ -300,7 +291,6 @@ export async function runRoutineNow(ctx, routine) {
300
291
  let errMsg = null;
301
292
 
302
293
  if (!skip) {
303
- // Inject {{pre_output}} into exec_agent and super_agent prompts.
304
294
  const enrichedRoutine = (hasPreCmds && preStdout)
305
295
  ? {
306
296
  ...routine,
@@ -344,11 +334,9 @@ export async function runRoutineNow(ctx, routine) {
344
334
  for (const rawCmd of routine.post_commands) {
345
335
  const cmd = resolveArtifactRef(rawCmd, storagePath);
346
336
  await runShellCmd(cmd, postEnv, cwd);
347
- // Post-command failures are logged but don't change routine status.
348
337
  }
349
338
  }
350
339
 
351
- // Cleanup temp file.
352
340
  if (preOutputFile) try { fs.unlinkSync(preOutputFile); } catch {}
353
341
 
354
342
  const lastRun = nowIso();
@@ -374,58 +362,3 @@ export async function runRoutineNow(ctx, routine) {
374
362
  });
375
363
  return { ...result, last_run_at: lastRun, next_run_at: next };
376
364
  }
377
-
378
- export class RoutineScheduler {
379
- constructor({ projects, plugins, registries, globalConfig, log }) {
380
- this.projects = projects;
381
- this.plugins = plugins;
382
- this.registries = registries;
383
- this.globalConfig = globalConfig;
384
- this.log = log || (() => {});
385
- this._timer = null;
386
- this._running = false;
387
- }
388
-
389
- start() {
390
- if (this._timer) return;
391
- this._timer = setInterval(
392
- () => this._tick().catch((e) => this.log(`routines tick error: ${e.message}`)),
393
- TICK_MS
394
- );
395
- this._timer.unref?.();
396
- }
397
-
398
- stop() {
399
- if (this._timer) {
400
- clearInterval(this._timer);
401
- this._timer = null;
402
- }
403
- }
404
-
405
- async _tick() {
406
- if (this._running) return;
407
- this._running = true;
408
- try {
409
- const nowStr = nowIso();
410
- for (const proj of this.projects.list().map((p) => this.projects.get(p.id))) {
411
- if (!proj) continue;
412
- const due = getDueRoutines(proj.storagePath, nowStr);
413
- for (const r of due) {
414
- this.log(`routine ${r.name} (${r.kind}) firing in project #${proj.id}`);
415
- await runRoutineNow(
416
- {
417
- project: proj,
418
- projects: this.projects,
419
- plugins: this.plugins,
420
- registries: this.registries,
421
- globalConfig: this.globalConfig,
422
- },
423
- r
424
- );
425
- }
426
- }
427
- } finally {
428
- this._running = false;
429
- }
430
- }
431
- }