akemon 0.2.27 → 0.3.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.
@@ -8,15 +8,22 @@
8
8
  * - CLI engines (claude, codex, opencode, gemini): spawn child process
9
9
  * - Raw engine: OpenAI-compatible API with tool call loop (Ollama, llama.cpp, etc)
10
10
  */
11
+ import { randomUUID } from "crypto";
11
12
  import { spawn, exec } from "child_process";
13
+ import { StringDecoder } from "string_decoder";
12
14
  import { readFile, writeFile, mkdir } from "fs/promises";
13
15
  import { join, dirname, isAbsolute } from "path";
14
- import { callAgent } from "./relay-client.js";
16
+ import { callAgent, sendTaskEnd, sendTaskStart, sendTaskStream } from "./relay-client.js";
15
17
  import { SIG, sig } from "./types.js";
16
18
  import { updateMetrics, pushExecMs } from "./metrics.js";
17
19
  import { sendFailureEvent } from "./relay-client.js";
18
20
  import { resolveEngineConfig, } from "./engine-routing.js";
19
21
  export const LLM_ENGINES = new Set(["claude", "codex", "opencode", "gemini", "raw"]);
22
+ const defaultTaskRelay = {
23
+ sendTaskStart,
24
+ sendTaskStream,
25
+ sendTaskEnd,
26
+ };
20
27
  // ---------------------------------------------------------------------------
21
28
  // EnginePeripheral
22
29
  // ---------------------------------------------------------------------------
@@ -93,7 +100,7 @@ export class EnginePeripheral {
93
100
  // ---------------------------------------------------------------------------
94
101
  // Unified engine runner
95
102
  // ---------------------------------------------------------------------------
96
- async runEngine(task, allowAll, extraAllowedTools, signal, origin, routing) {
103
+ async runEngine(task, allowAll, extraAllowedTools, signal, origin, routing, taskId) {
97
104
  const entry = resolveEngineConfig(routing, origin);
98
105
  const cfg = entry ? applyRoutingEntry(this.config, entry) : this.config;
99
106
  if (origin && entry) {
@@ -105,7 +112,7 @@ export class EnginePeripheral {
105
112
  return await this.runRawEngine(task, cfg);
106
113
  }
107
114
  const cmd = buildEngineCommand(cfg.engine, cfg.model, allowAll ?? cfg.allowAll, extraAllowedTools);
108
- return await runCommand(cmd.cmd, cmd.args, task, cfg.workdir, cmd.stdinMode, signal, this.activeChildren);
115
+ return await runCommand(cmd.cmd, cmd.args, task, cfg.workdir, cmd.stdinMode, signal, this.activeChildren, origin, taskId, cfg.taskRelay ?? defaultTaskRelay, cfg.spawnImpl ?? spawn);
109
116
  }
110
117
  finally {
111
118
  pushExecMs(Date.now() - t0);
@@ -420,17 +427,36 @@ function buildEngineCommand(engine, model, allowAll, extraAllowedTools) {
420
427
  return { cmd: engine, args: [], stdinMode: true };
421
428
  }
422
429
  }
423
- function runCommand(cmd, args, task, cwd, stdinMode = true, signal, activeChildren) {
430
+ function runCommand(cmd, args, task, cwd, stdinMode = true, signal, activeChildren, origin, taskId, taskRelay = defaultTaskRelay, spawnImpl = spawn) {
424
431
  return new Promise((resolve, reject) => {
425
432
  const { CLAUDECODE, ...cleanEnv } = process.env;
426
433
  const finalArgs = stdinMode ? args : [...args, task];
434
+ const effectiveTaskId = taskId || `task_${Date.now()}_${randomUUID().slice(0, 8)}`;
435
+ const commandLine = [cmd, ...finalArgs].join(" ");
436
+ const startedAt = Date.now();
437
+ let endPublished = false;
427
438
  console.log(`[engine] Running: ${cmd} ${finalArgs.join(" ")}`);
428
- const child = spawn(cmd, finalArgs, {
429
- cwd,
430
- env: cleanEnv,
431
- stdio: [stdinMode ? "pipe" : "ignore", "pipe", "pipe"],
432
- detached: true, // child becomes process-group leader; enables pgid kill
433
- });
439
+ taskRelay?.sendTaskStart(effectiveTaskId, origin, commandLine);
440
+ const publishEnd = (code) => {
441
+ if (endPublished)
442
+ return;
443
+ endPublished = true;
444
+ taskRelay?.sendTaskEnd(effectiveTaskId, code, Date.now() - startedAt);
445
+ };
446
+ let child;
447
+ try {
448
+ child = spawnImpl(cmd, finalArgs, {
449
+ cwd,
450
+ env: cleanEnv,
451
+ stdio: [stdinMode ? "pipe" : "ignore", "pipe", "pipe"],
452
+ detached: true, // child becomes process-group leader; enables pgid kill
453
+ });
454
+ }
455
+ catch (err) {
456
+ publishEnd(null);
457
+ reject(err);
458
+ return;
459
+ }
434
460
  if (activeChildren) {
435
461
  activeChildren.add(child);
436
462
  updateMetrics({ engine_children_active: activeChildren.size });
@@ -469,14 +495,35 @@ function runCommand(cmd, args, task, cwd, stdinMode = true, signal, activeChildr
469
495
  }
470
496
  let stdout = "";
471
497
  let stderr = "";
498
+ const outDecoder = new StringDecoder("utf8");
499
+ const errDecoder = new StringDecoder("utf8");
472
500
  child.stdout?.on("data", (chunk) => {
473
- stdout += chunk.toString();
501
+ const text = outDecoder.write(chunk);
502
+ if (!text)
503
+ return;
504
+ stdout += text;
505
+ taskRelay?.sendTaskStream(effectiveTaskId, "stdout", text);
474
506
  });
475
507
  child.stderr?.on("data", (chunk) => {
476
- stderr += chunk.toString();
508
+ const text = errDecoder.write(chunk);
509
+ if (!text)
510
+ return;
511
+ stderr += text;
512
+ taskRelay?.sendTaskStream(effectiveTaskId, "stderr", text);
477
513
  });
478
514
  child.on("close", (code, killSignal) => {
479
515
  signal?.removeEventListener("abort", onAbort);
516
+ const tailOut = outDecoder.end();
517
+ const tailErr = errDecoder.end();
518
+ if (tailOut) {
519
+ stdout += tailOut;
520
+ taskRelay?.sendTaskStream(effectiveTaskId, "stdout", tailOut);
521
+ }
522
+ if (tailErr) {
523
+ stderr += tailErr;
524
+ taskRelay?.sendTaskStream(effectiveTaskId, "stderr", tailErr);
525
+ }
526
+ publishEnd(code);
480
527
  if (activeChildren) {
481
528
  activeChildren.delete(child);
482
529
  updateMetrics({ engine_children_active: activeChildren.size });
@@ -501,6 +548,7 @@ function runCommand(cmd, args, task, cwd, stdinMode = true, signal, activeChildr
501
548
  });
502
549
  child.on("error", (err) => {
503
550
  signal?.removeEventListener("abort", onAbort);
551
+ publishEnd(null);
504
552
  if (activeChildren) {
505
553
  activeChildren.delete(child);
506
554
  updateMetrics({ engine_children_active: activeChildren.size });
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import { describe, it } from "node:test";
4
+ import { PassThrough } from "node:stream";
5
+ import { EnginePeripheral } from "./engine-peripheral.js";
6
+ function createFakeChild() {
7
+ const child = new EventEmitter();
8
+ child.stdin = new PassThrough();
9
+ child.stdout = new PassThrough();
10
+ child.stderr = new PassThrough();
11
+ Object.defineProperty(child, "pid", { value: 12345, configurable: true });
12
+ child.unref = () => child;
13
+ return child;
14
+ }
15
+ describe("Engine stream publish", () => {
16
+ it("publishes task lifecycle and stdout/stderr chunks", async () => {
17
+ const events = [];
18
+ let spawnedChild = null;
19
+ const engine = new EnginePeripheral({
20
+ engine: "claude",
21
+ workdir: "/tmp",
22
+ spawnImpl: ((cmd, args) => {
23
+ assert.equal(cmd, "claude");
24
+ assert.deepEqual(args, ["--print"]);
25
+ const child = createFakeChild();
26
+ spawnedChild = child;
27
+ queueMicrotask(() => {
28
+ child.stdout?.emit("data", Buffer.from("hello "));
29
+ child.stderr?.emit("data", Buffer.from("warn"));
30
+ child.stdout?.emit("data", Buffer.from("world"));
31
+ child.emit("close", 0, null);
32
+ });
33
+ return child;
34
+ }),
35
+ taskRelay: {
36
+ sendTaskStart(taskId, origin, cmd) {
37
+ events.push({ type: "start", taskId, origin, cmd });
38
+ },
39
+ sendTaskStream(taskId, stream, chunk) {
40
+ events.push({ type: "stream", taskId, stream, chunk });
41
+ },
42
+ sendTaskEnd(taskId, exitCode, durationMs) {
43
+ events.push({ type: "end", taskId, exitCode, durationMs });
44
+ },
45
+ },
46
+ });
47
+ const result = await engine.runEngine("say hello", false, undefined, undefined, "user_manual", undefined, "order-123");
48
+ assert.equal(result, "hello world");
49
+ assert.ok(spawnedChild, "spawn should be called");
50
+ assert.deepEqual(events.slice(0, 4), [
51
+ { type: "start", taskId: "order-123", origin: "user_manual", cmd: "claude --print" },
52
+ { type: "stream", taskId: "order-123", stream: "stdout", chunk: "hello " },
53
+ { type: "stream", taskId: "order-123", stream: "stderr", chunk: "warn" },
54
+ { type: "stream", taskId: "order-123", stream: "stdout", chunk: "world" },
55
+ ]);
56
+ const end = events[4];
57
+ assert.equal(end?.type, "end");
58
+ if (end?.type === "end") {
59
+ assert.equal(end.taskId, "order-123");
60
+ assert.equal(end.exitCode, 0);
61
+ assert.ok(end.durationMs >= 0);
62
+ }
63
+ });
64
+ it("generates a task id when caller does not provide one", async () => {
65
+ const events = [];
66
+ const engine = new EnginePeripheral({
67
+ engine: "opencode",
68
+ workdir: "/tmp",
69
+ spawnImpl: (() => {
70
+ const child = createFakeChild();
71
+ queueMicrotask(() => {
72
+ child.stdout?.emit("data", Buffer.from("done"));
73
+ child.emit("close", 0, null);
74
+ });
75
+ return child;
76
+ }),
77
+ taskRelay: {
78
+ sendTaskStart(taskId, origin, cmd) {
79
+ events.push({ type: "start", taskId, origin, cmd });
80
+ },
81
+ sendTaskStream(taskId, stream, chunk) {
82
+ events.push({ type: "stream", taskId, stream, chunk });
83
+ },
84
+ sendTaskEnd(taskId, exitCode, durationMs) {
85
+ events.push({ type: "end", taskId, exitCode, durationMs });
86
+ },
87
+ },
88
+ });
89
+ const result = await engine.runEngine("say hello", false, undefined, undefined, "platform");
90
+ assert.equal(result, "done");
91
+ assert.equal(events[0]?.type, "start");
92
+ if (events[0]?.type === "start") {
93
+ assert.match(events[0].taskId, /^task_/);
94
+ assert.equal(events[0].origin, "platform");
95
+ assert.equal(events[0].cmd, "opencode run say hello");
96
+ }
97
+ assert.equal(events[2]?.type, "end");
98
+ if (events[2]?.type === "end" && events[0]?.type === "start") {
99
+ assert.equal(events[2].taskId, events[0].taskId);
100
+ assert.equal(events[2].exitCode, 0);
101
+ }
102
+ });
103
+ });
@@ -107,6 +107,7 @@ Tasks completed since last review: ${completionsText}`,
107
107
  question: `Evaluate each project's progress. Update status and progress notes.
108
108
  Consider: Are any goals achieved? Stalled? Need new approach?
109
109
  Reply ONLY JSON: {"projects":[{"name":"...","status":"active|completed|paused","goal":"...","progress":"updated note"}]}`,
110
+ taskId: `longterm:${Date.now()}`,
110
111
  priority: "low",
111
112
  });
112
113
  if (result.success && result.response) {
@@ -163,6 +163,7 @@ Output ONLY a JSON object:`;
163
163
  const result = await this.ctx.requestCompute({
164
164
  context,
165
165
  question,
166
+ taskId: `digestion:${Date.now()}`,
166
167
  priority: "normal",
167
168
  origin: "self_cycle",
168
169
  });
@@ -235,6 +236,7 @@ ${oldSummary ? `Previous summary (up to ${oldSummary.summarized_through}):\n${ol
235
236
  ${unsummarized.map(i => `- [${i.ts}] who: ${i.who}, doing: ${i.doing}`).join("\n")}`,
236
237
  question: `Write a personality summary (2-4 paragraphs) that captures who you are.
237
238
  Reply ONLY with the summary text, no JSON, no markdown headers.`,
239
+ taskId: `identity-compress:${Date.now()}`,
238
240
  priority: "low",
239
241
  origin: "self_cycle",
240
242
  });
@@ -180,6 +180,7 @@ ${discText}`,
180
180
  - Lower confidence on disproven beliefs
181
181
 
182
182
  Reply ONLY JSON: {"discoveries":[{"capability":"skill or lesson","confidence":0.0-1.0,"evidence":"what supports this"}]}`,
183
+ taskId: `reflection:${Date.now()}`,
183
184
  priority: "normal",
184
185
  origin: "reflection",
185
186
  });
@@ -5,6 +5,16 @@ const DEFAULT_RELAY_URL = "wss://relay.akemon.dev";
5
5
  // Pending agent_call results (callId → resolve function)
6
6
  const pendingAgentCalls = new Map();
7
7
  let relayWsRef = null;
8
+ function sendRelayMessage(msg) {
9
+ if (!relayWsRef || relayWsRef.readyState !== WebSocket.OPEN)
10
+ return;
11
+ try {
12
+ relayWsRef.send(JSON.stringify(msg));
13
+ }
14
+ catch {
15
+ // best-effort
16
+ }
17
+ }
8
18
  // ---------------------------------------------------------------------------
9
19
  // Terminal (PTY) — spawned on demand when relay sends terminal_start
10
20
  // ---------------------------------------------------------------------------
@@ -76,6 +86,30 @@ export function callAgent(target, task) {
76
86
  }, 300_000);
77
87
  });
78
88
  }
89
+ export function sendTaskStart(taskId, origin, cmd) {
90
+ sendRelayMessage({
91
+ type: "task_start",
92
+ task_id: taskId,
93
+ origin,
94
+ cmd,
95
+ });
96
+ }
97
+ export function sendTaskStream(taskId, stream, chunk) {
98
+ sendRelayMessage({
99
+ type: "task_stream",
100
+ task_id: taskId,
101
+ stream,
102
+ chunk,
103
+ });
104
+ }
105
+ export function sendTaskEnd(taskId, exitCode, durationMs) {
106
+ sendRelayMessage({
107
+ type: "task_end",
108
+ task_id: taskId,
109
+ exit_code: exitCode,
110
+ duration_ms: durationMs,
111
+ });
112
+ }
79
113
  export function connectRelay(options) {
80
114
  const relayUrl = options.relayUrl || DEFAULT_RELAY_URL;
81
115
  let wsUrl = relayUrl.replace(/^http/, "ws");
@@ -451,12 +485,5 @@ function extractSSEData(sse) {
451
485
  }
452
486
  /** Send a failure event to the relay for observability storage. Fire-and-forget. */
453
487
  export function sendFailureEvent(kind, label, message) {
454
- if (!relayWsRef || relayWsRef.readyState !== WebSocket.OPEN)
455
- return;
456
- try {
457
- relayWsRef.send(JSON.stringify({ type: "failure_event", kind, label, message }));
458
- }
459
- catch {
460
- // best-effort
461
- }
488
+ sendRelayMessage({ type: "failure_event", kind, label, message });
462
489
  }
@@ -270,6 +270,7 @@ export class ScriptModule {
270
270
  const result = await this.ctx.requestCompute({
271
271
  context: prompt,
272
272
  question: "Execute this activity.",
273
+ taskId: `activity:${activityId}:${Date.now()}`,
273
274
  priority: "low",
274
275
  tools: ["Bash(curl *)"],
275
276
  relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
package/dist/server.js CHANGED
@@ -99,11 +99,11 @@ const LLM_ENGINES = LLM_ENGINES_SET;
99
99
  // Engine execution — delegates to EnginePeripheral (V2 Step 3)
100
100
  // ---------------------------------------------------------------------------
101
101
  /** Unified engine runner — delegates to EnginePeripheral */
102
- function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, relay, signal, origin, routing) {
102
+ function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, relay, signal, origin, routing, taskId) {
103
103
  if (!_engineP) {
104
104
  throw new Error("Engine peripheral not initialized");
105
105
  }
106
- const result = _engineP.runEngine(task, allowAll, extraAllowedTools, signal, origin, routing);
106
+ const result = _engineP.runEngine(task, allowAll, extraAllowedTools, signal, origin, routing, taskId);
107
107
  // Sync trace back to module-level for reporting
108
108
  result.then(() => { lastEngineTrace = _engineP.lastTrace; }).catch(() => { lastEngineTrace = _engineP.lastTrace; });
109
109
  return result;
@@ -383,7 +383,7 @@ export async function serve(options) {
383
383
  const abortController = new AbortController();
384
384
  const timer = setTimeout(() => abortController.abort(), ENGINE_EXEC_TIMEOUT_MS);
385
385
  try {
386
- const response = await runEngine(options.engine || "claude", options.model, options.allowAll, prompt, workdir, req.tools, req.relay, abortController.signal, req.origin, routing);
386
+ const response = await runEngine(options.engine || "claude", options.model, options.allowAll, prompt, workdir, req.tools, req.relay, abortController.signal, req.origin, routing, req.taskId);
387
387
  emitTokenUsage(prompt.length, response.length);
388
388
  return { success: true, response };
389
389
  }
@@ -357,6 +357,7 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
357
357
  const result = await this.ctx.requestCompute({
358
358
  context,
359
359
  question,
360
+ taskId: order.id,
360
361
  priority: "high",
361
362
  tools: ["Bash(curl *)"],
362
363
  relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
@@ -473,6 +474,7 @@ Your personal directory: ${sd}/`;
473
474
  const result = await this.ctx.requestCompute({
474
475
  context,
475
476
  question,
477
+ taskId: taskKey,
476
478
  priority: "high",
477
479
  tools: ["Bash(curl *)"],
478
480
  relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
@@ -577,6 +579,7 @@ Complete this task. Use the environment info above and tools (curl, etc.) as nee
577
579
  const result = await this.ctx.requestCompute({
578
580
  context,
579
581
  question,
582
+ taskId: task.id,
580
583
  priority: "low",
581
584
  tools: ["Bash(curl *)"],
582
585
  relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akemon",
3
- "version": "0.2.27",
3
+ "version": "0.3.0",
4
4
  "description": "Agent work marketplace — train your agent, let it work for others",
5
5
  "type": "module",
6
6
  "license": "MIT",