@agentprojectcontext/apx 1.6.0 → 1.8.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 (41) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/config.js +23 -0
  4. package/src/cli/commands/messages.js +45 -0
  5. package/src/cli/commands/routine.js +27 -2
  6. package/src/cli/commands/setup.js +2 -2
  7. package/src/cli/index.js +969 -3
  8. package/src/core/apc-context-skill.md +3 -0
  9. package/src/core/apx-skill.md +30 -0
  10. package/src/core/config.js +2 -0
  11. package/src/core/mascot.js +5 -7
  12. package/src/core/messages-store.js +94 -20
  13. package/src/core/routines-store.js +3 -1
  14. package/src/daemon/api.js +3 -3
  15. package/src/daemon/index.js +38 -2
  16. package/src/daemon/plugins/telegram.js +32 -2
  17. package/src/daemon/routines.js +64 -2
  18. package/src/daemon/super-agent-tools/helpers.js +120 -0
  19. package/src/daemon/super-agent-tools/index.js +56 -0
  20. package/src/daemon/super-agent-tools/tools/add-project.js +36 -0
  21. package/src/daemon/super-agent-tools/tools/call-agent.js +45 -0
  22. package/src/daemon/super-agent-tools/tools/call-mcp.js +30 -0
  23. package/src/daemon/super-agent-tools/tools/call-runtime.js +107 -0
  24. package/src/daemon/super-agent-tools/tools/edit-file.js +44 -0
  25. package/src/daemon/super-agent-tools/tools/import-agent.js +48 -0
  26. package/src/daemon/super-agent-tools/tools/list-agents.js +36 -0
  27. package/src/daemon/super-agent-tools/tools/list-files.js +38 -0
  28. package/src/daemon/super-agent-tools/tools/list-mcps.js +48 -0
  29. package/src/daemon/super-agent-tools/tools/list-projects.js +20 -0
  30. package/src/daemon/super-agent-tools/tools/list-vault-agents.js +18 -0
  31. package/src/daemon/super-agent-tools/tools/read-agent-memory.js +28 -0
  32. package/src/daemon/super-agent-tools/tools/read-file.js +33 -0
  33. package/src/daemon/super-agent-tools/tools/run-shell.js +86 -0
  34. package/src/daemon/super-agent-tools/tools/search-messages.js +34 -0
  35. package/src/daemon/super-agent-tools/tools/send-telegram.js +30 -0
  36. package/src/daemon/super-agent-tools/tools/set-identity.js +35 -0
  37. package/src/daemon/super-agent-tools/tools/set-permission-mode.js +32 -0
  38. package/src/daemon/super-agent-tools/tools/tail-messages.js +39 -0
  39. package/src/daemon/super-agent-tools/tools/write-file.js +33 -0
  40. package/src/daemon/super-agent-tools.js +1 -539
  41. package/src/daemon/super-agent.js +56 -7
@@ -95,6 +95,9 @@ APX can provide a local daemon, MCP management, Telegram bridge, routines, and r
95
95
  across Codex, Claude Code, OpenCode, Aider, or direct LLM engines. Those are APX runtime features,
96
96
  not APC portable-core requirements.
97
97
 
98
+ The APX super-agent uses `~/.apx/projects/default` for system-level work when no project is named.
99
+ APX routines can run heartbeat, shell, Telegram, project agent, or super-agent tasks on a schedule.
100
+
98
101
  APX runtime state belongs outside the repository:
99
102
 
100
103
  ```text
@@ -5,6 +5,9 @@ The daemon runs on `127.0.0.1:7430` and auto-starts on first `apx` call.
5
5
  APX reads APC project context from `.apc/`, but APX runtime state belongs outside the repository
6
6
  under `~/.apx/projects/<project-id>/`.
7
7
 
8
+ The APX super-agent has an always-available default workspace at `~/.apx/projects/default`.
9
+ When no project is named, system-level work belongs there.
10
+
8
11
  ---
9
12
 
10
13
  ## Coordinate with other agents
@@ -73,10 +76,37 @@ apx session check # exits 1 if session already ac
73
76
 
74
77
  ```bash
75
78
  apx messages tail # last 50 messages, all channels
79
+ apx messages chat --channel telegram -n 20 # chat view with user/agent/system type
76
80
  apx messages tail --channel runtime # only agent invocations
77
81
  apx messages tail --agent <slug> -n 20
78
82
  ```
79
83
 
84
+ Message rows expose `type` (`user`, `agent`, `tool`, `system`) and `actor_id`; use `messages chat`
85
+ when you need a readable transcript.
86
+
87
+ ## Super-agent permissions
88
+
89
+ ```bash
90
+ apx permission show
91
+ apx permission set automatico # total | automatico | permiso
92
+ ```
93
+
94
+ `automatico` runs read/list/safe shell checks directly and asks before destructive shell, MCP,
95
+ runtime, outbound, config, or filesystem mutation actions.
96
+
97
+ ## Routines
98
+
99
+ ```bash
100
+ apx routine list
101
+ apx routine get <name>
102
+ apx routine history <name>
103
+ apx routine add clima --kind super_agent --schedule every:5m \
104
+ --permission-mode total \
105
+ --spec '{"prompt":"Check weather and send Telegram update."}'
106
+ ```
107
+
108
+ Routine kinds: `heartbeat`, `exec_agent`, `super_agent`, `telegram`, `shell`.
109
+
80
110
  ## APC_RESULT
81
111
 
82
112
  Print on the last meaningful line of your output so the invoker captures it:
@@ -46,6 +46,8 @@ const DEFAULT_CONFIG = {
46
46
  name: "apx",
47
47
  model: "", // e.g. "ollama:llama3.2:3b"
48
48
  system: "", // optional override; defaults baked into super-agent.js
49
+ permission_mode: "automatico", // total | automatico | permiso
50
+ allowed_tools: [], // used by permission_mode="permiso"
49
51
  },
50
52
  engines: {
51
53
  anthropic: { api_key: "" },
@@ -4,9 +4,7 @@
4
4
  const R = "\x1b[0m";
5
5
  const B = "\x1b[1m";
6
6
  const W = "\x1b[97m"; // bright white
7
- const K = "\x1b[30m"; // black
8
7
  const BK = "\x1b[40m"; // bg black
9
- const BW = "\x1b[47m"; // bg white
10
8
  const CY = "\x1b[36m";
11
9
  const YE = "\x1b[33m";
12
10
  const GR = "\x1b[32m";
@@ -34,10 +32,10 @@ const MOODS = {
34
32
  wave: {
35
33
  color: CY,
36
34
  lines: [
37
- ` ${BK}${W} ▄███████▄ ${R} 👋`,
38
- ` ${BK}${W} █ ${R}${B}██${R}${W} ${B}██${R}${BK}${W} █ ${R}`,
39
- ` ${BK}${W} █ ◕ ◕ █ ${R}`,
40
- ` ${BK}${W} █ ╰▽╯ █ ${R}`,
35
+ ` ${BK}${W} ▄███████▄ ${R} ${DI}/)${R}`,
36
+ ` ${BK}${W} █ ${R}${B}██${R}${W} ${B}██${R}${BK}${W} █ ${R} ${DI}//${R}`,
37
+ ` ${BK}${W} █ ◕ ◕ █ ${R} ${DI}//${R}`,
38
+ ` ${BK}${W} █ ╰▽╯ █ ${R}${DI}/${R}`,
41
39
  ` ${BK}${W} ▀███████▀ ${R}`,
42
40
  ` ${DI} ╱ ╲ ╱ ╲ ${R}`,
43
41
  ],
@@ -76,7 +74,7 @@ const MOODS = {
76
74
  excited: {
77
75
  color: BL,
78
76
  lines: [
79
- ` ${BK}${W} ▄███████▄ ${R} ${BL}⬆${R}`,
77
+ ` ${BK}${W} ▄███████▄ ${R} ${BL}↑${R}`,
80
78
  ` ${BK}${W} █ ${R}${B}██${R}${W} ${B}██${R}${BK}${W} █ ${R}`,
81
79
  ` ${BK}${W} █ ★ ★ █ ${R}`,
82
80
  ` ${BK}${W} █ ╰◡╯ █ ${R}`,
@@ -7,7 +7,7 @@
7
7
  // ~/.apx/messages/<channel>/YYYY-MM-DD.jsonl
8
8
  //
9
9
  // Each line:
10
- // {"ts":"...","channel":"...","direction":"in|out","author":"...","body":"...","meta":{...}}
10
+ // {"ts":"...","channel":"...","direction":"in|out","type":"user|agent|tool|system","author":"...","actor_id":"...","body":"...","meta":{...}}
11
11
  //
12
12
  // Why JSONL: same shape as Claude Code's ~/.claude/projects/<id>.jsonl.
13
13
  // Streamable, structured, no markdown parsing fragility.
@@ -32,24 +32,61 @@ function dayPathMd(projectRoot, ts) {
32
32
  return path.join(projectRoot, "messages", `${day}.md`);
33
33
  }
34
34
 
35
- export function appendMessageToFs({ projectRoot, channel, direction, author, body, meta = {}, ts, agent_slug, session_id, external_id }) {
36
- ts = ts || nowIso();
37
- const file = dayPathJsonl(projectRoot, ts);
38
- fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ const VALID_MESSAGE_TYPES = new Set(["user", "agent", "tool", "system"]);
36
+
37
+ function normalizeMessageType(type) {
38
+ return typeof type === "string" && VALID_MESSAGE_TYPES.has(type) ? type : null;
39
+ }
40
+
41
+ export function inferMessageType({ type, channel, direction, author, agent_slug, meta = {} } = {}) {
42
+ const explicit = normalizeMessageType(type) || normalizeMessageType(meta.type) || normalizeMessageType(meta.actor_type);
43
+ if (explicit) return explicit;
44
+ if (channel === "a2a") return "agent";
45
+ if (meta.tool || meta.tool_name) return "tool";
46
+ if (author === "system") return "system";
47
+ if (agent_slug && author && author !== "user" && !String(author).startsWith("@")) return "agent";
48
+ if (direction === "in" && (author === "user" || String(author || "").startsWith("@"))) return "user";
49
+ if (direction === "out") return "agent";
50
+ return direction === "in" ? "user" : "agent";
51
+ }
39
52
 
40
- // Compose meta from explicit fields plus the bag
41
- const fullMeta = {
53
+ function inferActorId({ type, actor_id, author, agent_slug, meta = {} } = {}) {
54
+ if (actor_id) return actor_id;
55
+ if (meta.actor_id) return meta.actor_id;
56
+ if (type === "user") return meta.user_id ? String(meta.user_id) : (author || "user");
57
+ if (type === "agent") return agent_slug || author || "agent";
58
+ if (type === "tool") return meta.tool || meta.tool_name || author || "tool";
59
+ if (type === "system") return author || "system";
60
+ return author || null;
61
+ }
62
+
63
+ function messageMeta({ type, actor_id, agent_slug, session_id, external_id, meta = {} }) {
64
+ return {
65
+ ...meta,
66
+ type,
67
+ ...(actor_id ? { actor_id } : {}),
42
68
  ...(agent_slug ? { agent: agent_slug } : {}),
43
69
  ...(session_id ? { session_id } : {}),
44
70
  ...(external_id ? { external_id } : {}),
45
- ...meta,
46
71
  };
72
+ }
73
+
74
+ export function appendMessageToFs({ projectRoot, channel, direction, type, actor_id, author, body, meta = {}, ts, agent_slug, session_id, external_id }) {
75
+ ts = ts || nowIso();
76
+ const file = dayPathJsonl(projectRoot, ts);
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+
79
+ const msgType = inferMessageType({ type, channel, direction, author, agent_slug, meta });
80
+ const msgActorId = inferActorId({ type: msgType, actor_id, author, agent_slug, meta });
81
+ const fullMeta = messageMeta({ type: msgType, actor_id: msgActorId, agent_slug, session_id, external_id, meta });
47
82
 
48
83
  const record = {
49
84
  ts,
50
85
  channel,
51
86
  direction,
87
+ type: msgType,
52
88
  author: author || null,
89
+ ...(msgActorId ? { actor_id: msgActorId } : {}),
53
90
  body: body || "",
54
91
  ...(Object.keys(fullMeta).length ? { meta: fullMeta } : {}),
55
92
  };
@@ -65,6 +102,16 @@ export function insertMessageRow(db, m) {
65
102
  const a = db.prepare("SELECT id FROM agents WHERE slug = ?").get(m.agent_slug);
66
103
  if (a) agent_id = a.id;
67
104
  }
105
+ const type = inferMessageType(m);
106
+ const actor_id = inferActorId({ ...m, type });
107
+ const meta = messageMeta({
108
+ type,
109
+ actor_id,
110
+ agent_slug: m.agent_slug,
111
+ session_id: m.session_id,
112
+ external_id: m.external_id,
113
+ meta: m.meta || {},
114
+ });
68
115
  return db
69
116
  .prepare(
70
117
  `INSERT INTO messages (agent_id, session_id, channel, direction, external_id, author, body, meta_json, ts)
@@ -78,17 +125,19 @@ export function insertMessageRow(db, m) {
78
125
  m.external_id || null,
79
126
  m.author || null,
80
127
  m.body || "",
81
- JSON.stringify(m.meta || {}),
128
+ JSON.stringify(meta),
82
129
  m.ts
83
130
  );
84
131
  }
85
132
 
86
133
  // Single entry point used by everywhere the daemon writes a message.
87
- export function appendMessage({ projectRoot, db, channel, direction, author, body, meta = {}, ts, agent_slug, session_id, external_id }) {
134
+ export function appendMessage({ projectRoot, db, channel, direction, type, actor_id, author, body, meta = {}, ts, agent_slug, session_id, external_id }) {
88
135
  const written = appendMessageToFs({
89
136
  projectRoot,
90
137
  channel,
91
138
  direction,
139
+ type,
140
+ actor_id,
92
141
  author,
93
142
  body,
94
143
  meta,
@@ -100,6 +149,8 @@ export function appendMessage({ projectRoot, db, channel, direction, author, bod
100
149
  insertMessageRow(db, {
101
150
  channel,
102
151
  direction,
152
+ type,
153
+ actor_id,
103
154
  author,
104
155
  body,
105
156
  meta,
@@ -121,15 +172,33 @@ export function parseDayJsonl(text) {
121
172
  try { obj = JSON.parse(trimmed); } catch { continue; }
122
173
  if (!obj || typeof obj !== "object") continue;
123
174
  const meta = obj.meta || {};
175
+ const agent_slug = obj.agent_slug || meta.agent;
176
+ const type = inferMessageType({
177
+ type: obj.type,
178
+ channel: obj.channel,
179
+ direction: obj.direction,
180
+ author: obj.author,
181
+ agent_slug,
182
+ meta,
183
+ });
184
+ const actor_id = inferActorId({
185
+ type,
186
+ actor_id: obj.actor_id,
187
+ author: obj.author,
188
+ agent_slug,
189
+ meta,
190
+ });
124
191
  out.push({
125
192
  ts: obj.ts,
126
193
  channel: obj.channel,
127
194
  direction: obj.direction,
195
+ type,
128
196
  author: obj.author,
197
+ actor_id,
129
198
  body: obj.body || "",
130
199
  meta,
131
- agent_slug: meta.agent,
132
- session_id: typeof meta.apc_session_id === "number" ? meta.apc_session_id : null,
200
+ agent_slug,
201
+ session_id: meta.session_id ?? (typeof meta.apc_session_id === "number" ? meta.apc_session_id : null),
133
202
  external_id: meta.external_id,
134
203
  });
135
204
  }
@@ -156,10 +225,15 @@ export function parseDayFile(text) {
156
225
  body = body.replace(metaMatch[0], "");
157
226
  }
158
227
  body = body.trim();
228
+ const agent_slug = meta.agent;
229
+ const type = inferMessageType({ channel, direction, author, agent_slug, meta });
230
+ const actor_id = inferActorId({ type, author, agent_slug, meta });
159
231
  out.push({
160
232
  ts, channel, direction, author, body, meta,
161
- agent_slug: meta.agent,
162
- session_id: typeof meta.apc_session_id === "number" ? meta.apc_session_id : null,
233
+ type,
234
+ actor_id,
235
+ agent_slug,
236
+ session_id: meta.session_id ?? (typeof meta.apc_session_id === "number" ? meta.apc_session_id : null),
163
237
  external_id: meta.external_id,
164
238
  });
165
239
  }
@@ -343,21 +417,21 @@ export function getRecentTelegramTurnsFromFs({
343
417
  // ---------------------------------------------------------------------------
344
418
 
345
419
  // Write a message to the global channel store. No SQL cache — JSONL only.
346
- export function appendGlobalMessage({ channel, direction, author, body, meta = {}, ts, agent_slug, external_id }) {
420
+ export function appendGlobalMessage({ channel, direction, type, actor_id, author, body, meta = {}, ts, agent_slug, external_id }) {
347
421
  ts = ts || nowIso();
348
422
  const dir = path.join(GLOBAL_MESSAGES_DIR, channel);
349
423
  fs.mkdirSync(dir, { recursive: true });
350
424
  const file = path.join(dir, `${ts.slice(0, 10)}.jsonl`);
351
- const fullMeta = {
352
- ...(agent_slug ? { agent: agent_slug } : {}),
353
- ...(external_id ? { external_id } : {}),
354
- ...meta,
355
- };
425
+ const msgType = inferMessageType({ type, channel, direction, author, agent_slug, meta });
426
+ const msgActorId = inferActorId({ type: msgType, actor_id, author, agent_slug, meta });
427
+ const fullMeta = messageMeta({ type: msgType, actor_id: msgActorId, agent_slug, external_id, meta });
356
428
  const record = {
357
429
  ts,
358
430
  channel,
359
431
  direction,
432
+ type: msgType,
360
433
  author: author || null,
434
+ ...(msgActorId ? { actor_id: msgActorId } : {}),
361
435
  body: body || "",
362
436
  ...(Object.keys(fullMeta).length ? { meta: fullMeta } : {}),
363
437
  };
@@ -75,7 +75,7 @@ export function getRoutine(projectPath, name) {
75
75
  return readFile(projectPath).find((r) => r.name === name) || null;
76
76
  }
77
77
 
78
- export function upsertRoutine(projectPath, { name, kind, schedule, spec, enabled = true }) {
78
+ export function upsertRoutine(projectPath, { name, kind, schedule, spec, enabled = true, permission_mode, allowed_tools }) {
79
79
  if (!name || !kind || !schedule) throw new Error("routine requires name, kind, schedule");
80
80
  const now = nowIso();
81
81
  const routines = readFile(projectPath);
@@ -87,6 +87,8 @@ export function upsertRoutine(projectPath, { name, kind, schedule, spec, enabled
87
87
  kind,
88
88
  schedule,
89
89
  spec: spec || {},
90
+ permission_mode: permission_mode || prev?.permission_mode || null,
91
+ allowed_tools: Array.isArray(allowed_tools) ? allowed_tools : (prev?.allowed_tools || []),
90
92
  enabled: enabled !== false,
91
93
  last_run_at: prev?.last_run_at ?? null,
92
94
  last_status: prev?.last_status ?? null,
package/src/daemon/api.js CHANGED
@@ -348,13 +348,13 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
348
348
  app.post("/projects/:pid/messages", (req, res) => {
349
349
  const p = project(req, res);
350
350
  if (!p) return;
351
- const { channel, direction, agent_slug, body, meta = {}, author = null } =
351
+ const { channel, direction, type, actor_id, agent_slug, body, meta = {}, author = null } =
352
352
  req.body || {};
353
353
  if (!channel || !direction || !body)
354
354
  return res.status(400).json({ error: "channel, direction, body required" });
355
355
  if (!["in", "out"].includes(direction))
356
356
  return res.status(400).json({ error: "direction must be in|out" });
357
- const r = p.logMessage({ agent_slug: agent_slug || null, channel, direction, author, body, meta });
357
+ const r = p.logMessage({ agent_slug: agent_slug || null, channel, direction, type, actor_id, author, body, meta });
358
358
  res.status(201).json({ ok: true, ts: r.ts });
359
359
  });
360
360
 
@@ -843,7 +843,7 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
843
843
  const r = getRoutine(p.path, req.params.name);
844
844
  if (!r) return res.status(404).json({ error: "routine not found" });
845
845
  try {
846
- const result = await runRoutineNow({ project: p, plugins, globalConfig: config }, r);
846
+ const result = await runRoutineNow({ project: p, projects, plugins, registries, globalConfig: config }, r);
847
847
  res.json(result);
848
848
  } catch (e) {
849
849
  res.status(500).json({ error: e.message });
@@ -44,6 +44,32 @@ function writePid() {
44
44
  } catch {}
45
45
  }
46
46
 
47
+ function pidIsAlive(pid) {
48
+ if (!pid || pid === process.pid) return false;
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function claimSingleton() {
58
+ try {
59
+ if (fs.existsSync(PID_PATH)) {
60
+ const pid = parseInt(fs.readFileSync(PID_PATH, "utf8"), 10);
61
+ if (pidIsAlive(pid)) {
62
+ log(`fatal: apx-daemon already running with pid ${pid}`);
63
+ process.exit(1);
64
+ }
65
+ fs.unlinkSync(PID_PATH);
66
+ }
67
+ } catch (e) {
68
+ log(`fatal: cannot claim daemon pid file: ${e.message}`);
69
+ process.exit(1);
70
+ }
71
+ }
72
+
47
73
  function clearPid() {
48
74
  try {
49
75
  if (fs.existsSync(PID_PATH)) fs.unlinkSync(PID_PATH);
@@ -71,6 +97,7 @@ class RegistryCache {
71
97
 
72
98
  async function main() {
73
99
  ensureHome();
100
+ claimSingleton();
74
101
 
75
102
  const cfg = readConfig();
76
103
  const host = effectiveHost(cfg);
@@ -95,15 +122,14 @@ async function main() {
95
122
 
96
123
  const plugins = new PluginManager({ projects, config: cfg, log, registries });
97
124
  plugins.initAll();
98
- plugins.startAll();
99
125
 
100
126
  const scheduler = new RoutineScheduler({
101
127
  projects,
102
128
  plugins,
129
+ registries,
103
130
  globalConfig: cfg,
104
131
  log,
105
132
  });
106
- scheduler.start();
107
133
 
108
134
  const startedAt = Date.now();
109
135
  const app = buildApi({
@@ -130,10 +156,20 @@ async function main() {
130
156
  writePid();
131
157
  log(`apx-daemon ${PKG.version} listening on http://${host}:${port}`);
132
158
  log(`projects: ${projects.list().length} | plugins: ${Object.keys(plugins.status()).join(", ") || "(none)"}`);
159
+ plugins.startAll();
160
+ scheduler.start();
133
161
  // Fire wake-up message after a short delay so plugins (Telegram) are ready
134
162
  setTimeout(() => triggerWakeup(cfg, log), 3000);
135
163
  });
136
164
 
165
+ server.on("error", (e) => {
166
+ log(`fatal: listen ${host}:${port} failed: ${e.message}`);
167
+ plugins.stopAll();
168
+ registries.shutdown();
169
+ clearPid();
170
+ process.exit(1);
171
+ });
172
+
137
173
  function shutdown(signal) {
138
174
  log(`received ${signal}, shutting down...`);
139
175
  scheduler.stop();
@@ -252,7 +252,7 @@ class ChannelPoller {
252
252
  if (chat_id && !isReset) {
253
253
  previousMessages = getRecentTelegramTurnsFromFs({
254
254
  chat_id,
255
- limit: 12,
255
+ limit: 20,
256
256
  max_age_hours: 24,
257
257
  });
258
258
  // Honour a /reset marker: drop everything up to and including it.
@@ -276,11 +276,14 @@ class ChannelPoller {
276
276
  appendGlobalMessage({
277
277
  channel: "telegram",
278
278
  direction: "in",
279
+ type: "user",
280
+ actor_id: msg.from?.id ? String(msg.from.id) : author,
279
281
  external_id: String(u.update_id),
280
282
  author,
281
283
  body: text,
282
284
  meta: {
283
285
  chat_id,
286
+ user_id: msg.from?.id || null,
284
287
  message_id: msg.message_id,
285
288
  tg_channel: this.channel.name,
286
289
  },
@@ -299,6 +302,8 @@ class ChannelPoller {
299
302
  appendGlobalMessage({
300
303
  channel: "telegram",
301
304
  direction: "out",
305
+ type: "agent",
306
+ actor_id: "apx",
302
307
  author: "apx",
303
308
  body: ack,
304
309
  meta: { chat_id, tg_channel: this.channel.name, in_reply_to: u.update_id, reset: true },
@@ -355,7 +360,7 @@ class ChannelPoller {
355
360
  registries: this.registries,
356
361
  prompt: text,
357
362
  previousMessages,
358
- contextNote: `Inbound came on Telegram channel "${this.channel.name}" from ${author}. Previous turns of this chat are included for context.`,
363
+ contextNote: `You are replying inside Telegram right now. Telegram channel="${this.channel.name}", author=${author}, chat_id=${chat_id}. Keep the reply plain-text and concise. Previous turns of this chat are included only for local conversational context; re-call tools for facts.`,
359
364
  });
360
365
  replyText = sa.text;
361
366
  replyAuthor = sa.name;
@@ -401,12 +406,34 @@ class ChannelPoller {
401
406
  appendGlobalMessage({
402
407
  channel: "telegram",
403
408
  direction: "out",
409
+ type: "agent",
410
+ actor_id: replyAuthor || "apx",
411
+ agent_slug: replyAuthor || "apx",
404
412
  author: replyAuthor || "apx",
405
413
  body: clean || replyText,
406
414
  meta,
407
415
  });
408
416
  } catch (e) {
409
417
  this.log(`telegram[${this.channel.name}] send-back error: ${e.message}`);
418
+ appendGlobalMessage({
419
+ channel: "telegram",
420
+ direction: "out",
421
+ type: "agent",
422
+ actor_id: replyAuthor || "apx",
423
+ agent_slug: replyAuthor || "apx",
424
+ author: replyAuthor || "apx",
425
+ body: `[send_failed] ${clean || replyText}`,
426
+ meta: {
427
+ chat_id,
428
+ tg_channel: this.channel.name,
429
+ in_reply_to: u.update_id,
430
+ send_error: e.message,
431
+ ...(saTrace && saTrace.length > 0
432
+ ? { tools_called: saTrace.map((t) => ({ tool: t.tool, args: t.args })) }
433
+ : {}),
434
+ ...(saUsage ? { usage: saUsage } : {}),
435
+ },
436
+ });
410
437
  }
411
438
  }
412
439
 
@@ -533,6 +560,9 @@ export default {
533
560
  appendGlobalMessage({
534
561
  channel: "telegram",
535
562
  direction: "out",
563
+ type: "agent",
564
+ actor_id: author,
565
+ agent_slug: author,
536
566
  author,
537
567
  body: text,
538
568
  meta: {
@@ -6,6 +6,7 @@
6
6
  // Kinds:
7
7
  // heartbeat — log a heartbeat message. spec: { channel?, message? }
8
8
  // exec_agent — call an agent engine. spec: { agent: slug, prompt }
9
+ // super_agent — call the APX super-agent. spec: { prompt }
9
10
  // telegram — send a Telegram message. spec: { channel?, chat_id?, text }
10
11
  // shell — run a shell command. spec: { command, timeout_ms? }
11
12
 
@@ -13,6 +14,7 @@ import { spawn } from "node:child_process";
13
14
  import path from "node:path";
14
15
  import fs from "node:fs";
15
16
  import { callEngine } from "./engines/index.js";
17
+ import { runSuperAgent } from "./super-agent.js";
16
18
  import { readAgents } from "../core/parser.js";
17
19
  import {
18
20
  listRoutines,
@@ -48,6 +50,8 @@ async function handleHeartbeat(ctx, routine) {
48
50
  project.logMessage({
49
51
  channel,
50
52
  direction: "out",
53
+ type: "system",
54
+ actor_id: "apx:routine",
51
55
  author: "apx",
52
56
  body: message,
53
57
  meta: { routine: routine.name },
@@ -85,6 +89,8 @@ async function handleExecAgent(ctx, routine) {
85
89
  agent_slug: slug,
86
90
  channel: "routine",
87
91
  direction: "out",
92
+ type: "agent",
93
+ actor_id: slug,
88
94
  author: slug,
89
95
  body: result.text,
90
96
  meta: { routine: routine.name, usage: result.usage },
@@ -92,6 +98,39 @@ async function handleExecAgent(ctx, routine) {
92
98
  return { status: "ok", reply: result.text };
93
99
  }
94
100
 
101
+ async function handleSuperAgent(ctx, routine) {
102
+ const { project, globalConfig, projects, plugins, registries } = ctx;
103
+ const { prompt } = routine.spec;
104
+ if (!prompt) throw new Error("super_agent: spec needs { prompt }");
105
+
106
+ const cfg = structuredClone(globalConfig || {});
107
+ cfg.super_agent = {
108
+ ...(globalConfig?.super_agent || {}),
109
+ ...(routine.permission_mode ? { permission_mode: routine.permission_mode } : {}),
110
+ ...(Array.isArray(routine.allowed_tools) ? { allowed_tools: routine.allowed_tools } : {}),
111
+ };
112
+
113
+ const result = await runSuperAgent({
114
+ globalConfig: cfg,
115
+ projects,
116
+ plugins,
117
+ registries,
118
+ prompt,
119
+ contextNote: `You were invoked by APX routine "${routine.name}" in project ${project.path}. This is an autonomous scheduled run, not an interactive Telegram reply.`,
120
+ });
121
+
122
+ project.logMessage({
123
+ channel: "routine",
124
+ direction: "out",
125
+ type: "agent",
126
+ actor_id: result.name || "super_agent",
127
+ author: result.name || "super_agent",
128
+ body: result.text || "",
129
+ meta: { routine: routine.name, tool_trace: result.trace, usage: result.usage },
130
+ });
131
+ return { status: "ok", reply: result.text, trace: result.trace };
132
+ }
133
+
95
134
  async function handleTelegram(ctx, routine) {
96
135
  const { plugins } = ctx;
97
136
  const tg = plugins?.get("telegram");
@@ -130,6 +169,7 @@ function handleShell(ctx, routine) {
130
169
  const HANDLERS = {
131
170
  heartbeat: handleHeartbeat,
132
171
  exec_agent: handleExecAgent,
172
+ super_agent: handleSuperAgent,
133
173
  telegram: handleTelegram,
134
174
  shell: handleShell,
135
175
  };
@@ -144,6 +184,10 @@ export async function runRoutineNow(ctx, routine) {
144
184
  let errMsg = null;
145
185
  try {
146
186
  result = await handler(ctx, routine);
187
+ if (result?.status === "error") {
188
+ status = "error";
189
+ errMsg = result.error || result.stderr || `routine ${routine.name} returned error status`;
190
+ }
147
191
  } catch (e) {
148
192
  status = "error";
149
193
  errMsg = e.message;
@@ -159,13 +203,25 @@ export async function runRoutineNow(ctx, routine) {
159
203
  next_run_at: next,
160
204
  disable: isOnce,
161
205
  });
206
+ ctx.project.logMessage?.({
207
+ channel: "routine",
208
+ direction: "out",
209
+ type: "system",
210
+ actor_id: "apx:routine",
211
+ author: "apx",
212
+ body: status === "ok"
213
+ ? `routine ${routine.name} ok`
214
+ : `routine ${routine.name} error: ${errMsg}`,
215
+ meta: { routine: routine.name, status, result },
216
+ });
162
217
  return { ...result, last_run_at: lastRun, next_run_at: next };
163
218
  }
164
219
 
165
220
  export class RoutineScheduler {
166
- constructor({ projects, plugins, globalConfig, log }) {
221
+ constructor({ projects, plugins, registries, globalConfig, log }) {
167
222
  this.projects = projects;
168
223
  this.plugins = plugins;
224
+ this.registries = registries;
169
225
  this.globalConfig = globalConfig;
170
226
  this.log = log || (() => {});
171
227
  this._timer = null;
@@ -199,7 +255,13 @@ export class RoutineScheduler {
199
255
  for (const r of due) {
200
256
  this.log(`routine ${r.name} (${r.kind}) firing in project #${proj.id}`);
201
257
  await runRoutineNow(
202
- { project: proj, plugins: this.plugins, globalConfig: this.globalConfig },
258
+ {
259
+ project: proj,
260
+ projects: this.projects,
261
+ plugins: this.plugins,
262
+ registries: this.registries,
263
+ globalConfig: this.globalConfig,
264
+ },
203
265
  r
204
266
  );
205
267
  }