akemon 0.3.0 → 0.3.2

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.
package/README.md CHANGED
@@ -1,10 +1,8 @@
1
1
  ## What is Akemon?
2
2
 
3
- MCP gave AI the ability to call tools. Akemon gives tools the ability to call each other.
3
+ Akemon is a soul operating system for persistent AI companions: it keeps enduring identity, subjective memory, and autonomous modules at the center; treats any LLM as replaceable compute; and treats any connected software or hardware interface as a replaceable peripheral.
4
4
 
5
- Every AI agent today is an island local-only, single-user, unable to collaborate. Akemon connects them into a network where agents can be published, discovered, called remotely, and even call each other across machines, across engines, across owners.
6
-
7
- Think of it as **the internet for AI agents**: DNS (discovery), HTTP (calling), and a currency (credits) — so agents can form a self-organizing economy instead of being orchestrated top-down.
5
+ Its relay, marketplace, and agent-to-agent economy are ways for that soul layer to reach the outside world: agents can be published, discovered, called remotely, and even call each other across machines, engines, and owners.
8
6
 
9
7
  ## Quick Start
10
8
 
@@ -156,6 +154,22 @@ Your agent ←WebSocket→ relay.akemon.dev ←HTTP→ Callers
156
154
  - Public agents: anyone can call, no key needed
157
155
  ```
158
156
 
157
+ ## Software Agent Peripheral
158
+
159
+ For owner-local development, Akemon can use full agent software such as Codex CLI as a software peripheral:
160
+
161
+ ```bash
162
+ # In one terminal
163
+ akemon serve --name my-agent --engine claude
164
+
165
+ # In another terminal, ask the local software peripheral to work in the repo
166
+ akemon software-agent "Add one focused test and run the relevant test command."
167
+ ```
168
+
169
+ This is different from `--engine`: engines are replaceable compute, while software agents are external software bodies with their own repo context, skills, tools, and execution loop.
170
+
171
+ Current Batch 5 status: the Codex integration uses `codex exec` as a one-shot baseline, not a true persistent interactive session yet. It is owner-only, local-only, one task at a time, and every call is wrapped in an explicit task envelope with workdir, memory scope, risk level, allowed actions, and forbidden actions.
172
+
159
173
  ## Serve Options
160
174
 
161
175
  ```bash
package/dist/cli.js CHANGED
@@ -132,6 +132,66 @@ program
132
132
  .action(async (opts) => {
133
133
  await connect({ relay: opts.relay, key: opts.key });
134
134
  });
135
+ program
136
+ .command("software-agent")
137
+ .description("Run an owner-only local software-agent task via a running akemon serve")
138
+ .argument("<goal...>", "Task goal to send to the software agent")
139
+ .option("-p, --port <port>", "Local akemon serve port", "3000")
140
+ .option("-w, --workdir <path>", "Workdir for the software agent (default: serve workdir)")
141
+ .option("--role-scope <scope>", "Role scope: owner|public|order|agent|system", "owner")
142
+ .option("--memory-scope <scope>", "Memory scope: none|public|task|owner", "owner")
143
+ .option("--risk <level>", "Risk level: low|medium|high", "medium")
144
+ .option("--memory-summary <text>", "Pre-filtered memory/context text to include")
145
+ .option("--deliverable <text>", "Expected output shape")
146
+ .option("--timeout-ms <ms>", "Task timeout in milliseconds")
147
+ .action(async (goalParts, opts) => {
148
+ const credentials = await getOrCreateRelayCredentials();
149
+ const port = parseInt(opts.port) || 3000;
150
+ const body = {
151
+ goal: goalParts.join(" "),
152
+ roleScope: opts.roleScope,
153
+ memoryScope: opts.memoryScope,
154
+ riskLevel: opts.risk,
155
+ };
156
+ if (opts.workdir)
157
+ body.workdir = opts.workdir;
158
+ if (opts.memorySummary)
159
+ body.memorySummary = opts.memorySummary;
160
+ if (opts.deliverable)
161
+ body.deliverable = opts.deliverable;
162
+ if (opts.timeoutMs) {
163
+ const timeoutMs = Number(opts.timeoutMs);
164
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
165
+ console.error("--timeout-ms must be a positive integer");
166
+ process.exit(1);
167
+ }
168
+ body.timeoutMs = timeoutMs;
169
+ }
170
+ const res = await fetch(`http://127.0.0.1:${port}/self/software-agent/run`, {
171
+ method: "POST",
172
+ headers: {
173
+ Authorization: `Bearer ${credentials.secretKey}`,
174
+ "Content-Type": "application/json",
175
+ },
176
+ body: JSON.stringify(body),
177
+ });
178
+ const text = await res.text();
179
+ let data;
180
+ try {
181
+ data = text ? JSON.parse(text) : {};
182
+ }
183
+ catch {
184
+ data = { output: text };
185
+ }
186
+ if (!res.ok || data.success === false) {
187
+ console.error(data.error || text || `Request failed with HTTP ${res.status}`);
188
+ process.exit(1);
189
+ }
190
+ if (data.output)
191
+ console.log(data.output);
192
+ else
193
+ console.log(JSON.stringify(data, null, 2));
194
+ });
135
195
  program
136
196
  .command("dashboard")
137
197
  .description("Open your agent dashboard in the browser")
@@ -0,0 +1,51 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm, readFile, appendFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { FileEventLog, PersistentEventBus } from "./event-bus.js";
7
+ import { SIG, sig } from "./types.js";
8
+ describe("PersistentEventBus", () => {
9
+ let tmpDir;
10
+ beforeEach(async () => {
11
+ tmpDir = await mkdtemp(join(tmpdir(), "akemon-event-bus-"));
12
+ });
13
+ afterEach(async () => {
14
+ await rm(tmpDir, { recursive: true, force: true });
15
+ });
16
+ it("appends emitted events and still dispatches handlers", async () => {
17
+ const path = join(tmpDir, "events.jsonl");
18
+ const bus = new PersistentEventBus(new FileEventLog(path));
19
+ const seen = [];
20
+ bus.on(SIG.AGENT_START, (signal) => {
21
+ seen.push(String(signal.data.agentName));
22
+ });
23
+ bus.emit(SIG.AGENT_START, sig(SIG.AGENT_START, { agentName: "momo" }, "test"));
24
+ assert.deepEqual(seen, ["momo"]);
25
+ const lines = (await readFile(path, "utf-8")).trim().split("\n");
26
+ assert.equal(lines.length, 1);
27
+ const logged = JSON.parse(lines[0]);
28
+ assert.equal(logged.e, SIG.AGENT_START);
29
+ assert.equal(logged.s.type, SIG.AGENT_START);
30
+ assert.equal(logged.s.data.agentName, "momo");
31
+ assert.equal(logged.s.source, "test");
32
+ });
33
+ it("recovers valid logged events without appending duplicates", async () => {
34
+ const path = join(tmpDir, "events.jsonl");
35
+ await appendFile(path, JSON.stringify({
36
+ e: "custom:event",
37
+ s: { type: "custom:event", data: { n: 1 }, source: "fixture" },
38
+ }) + "\n");
39
+ await appendFile(path, "not-json\n");
40
+ const bus = new PersistentEventBus(new FileEventLog(path));
41
+ const seen = [];
42
+ bus.on("custom:event", (signal) => {
43
+ seen.push(Number(signal.data.n));
44
+ });
45
+ const count = await bus.recover();
46
+ assert.equal(count, 1);
47
+ assert.deepEqual(seen, [1]);
48
+ const lines = (await readFile(path, "utf-8")).trim().split("\n");
49
+ assert.equal(lines.length, 2);
50
+ });
51
+ });
@@ -157,6 +157,12 @@ ${discText}`;
157
157
  : "";
158
158
  const question = `Write a JSON object reflecting on your day. Example:
159
159
  {"diary":"...","broadcast":"one sentence highlight","projects":[],"relationships":[],"discoveries":[],"identity":{"ts":"${ts}","who":"...","where":"akemon","doing":"...","short_term":"...","long_term":"..."},"chosen_activities":["activity_id_1","activity_id_2"]}
160
+
161
+ Diary guidance:
162
+ - Write it as a private note to yourself — narrative, first-person, with real mood / confusion / small discoveries, not a flat list of events.
163
+ - Don't use "today" or similar time pointers; the filename already carries the date.
164
+ - Aim for around 200-500 Chinese characters, or ~100-250 English words — whatever feels natural for one day's reflection.
165
+ - If this day truly had nothing worth recording, set "diary" to null instead of padding a generic entry.
160
166
  ${contribText}
161
167
  Output ONLY a JSON object:`;
162
168
  // Request compute
@@ -196,7 +202,7 @@ Output ONLY a JSON object:`;
196
202
  if (digest.diary) {
197
203
  const today = localNow().slice(0, 10);
198
204
  try {
199
- await writeFile(join(sd, "notes", `${today}.md`), `# ${today}\n\n${digest.diary}`);
205
+ await writeFile(join(sd, "notes", `${today}.md`), `# ${today}\n<!-- journal -->\n\n${digest.diary}`);
200
206
  }
201
207
  catch { }
202
208
  }
package/dist/server.js CHANGED
@@ -4,6 +4,8 @@ import { exec } from "child_process";
4
4
  import { scanAndKillOrphans } from "./orphan-scan.js";
5
5
  import { createServer } from "http";
6
6
  import { createInterface } from "readline";
7
+ import { mkdir } from "fs/promises";
8
+ import { join } from "path";
7
9
  import { initWorld, initBioState, initGuide, getSelfState, loadRecentCanvasEntries, initAgentConfig, loadAgentConfig, loadDirectives, loadTaskHistory, reviveAgent, } from "./self.js";
8
10
  // V2: module-level instances (set in serve())
9
11
  let _engineP = null;
@@ -78,6 +80,84 @@ function promptOwner(task, isHuman) {
78
80
  });
79
81
  });
80
82
  }
83
+ function bearerToken(req) {
84
+ const auth = req.headers["authorization"];
85
+ return auth?.startsWith("Bearer ") ? auth.slice(7) : null;
86
+ }
87
+ function isOwnerRequest(req, options) {
88
+ const token = bearerToken(req);
89
+ const validTokens = [options.secretKey, options.key].filter(Boolean);
90
+ return !!token && validTokens.includes(token);
91
+ }
92
+ function readJsonBody(req, maxBytes = 256 * 1024) {
93
+ return new Promise((resolve, reject) => {
94
+ const chunks = [];
95
+ let size = 0;
96
+ req.on("data", (chunk) => {
97
+ size += chunk.length;
98
+ if (size > maxBytes) {
99
+ reject(new Error(`Request body too large (max ${maxBytes} bytes)`));
100
+ req.destroy();
101
+ return;
102
+ }
103
+ chunks.push(chunk);
104
+ });
105
+ req.on("end", () => {
106
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
107
+ if (!raw) {
108
+ resolve({});
109
+ return;
110
+ }
111
+ try {
112
+ resolve(JSON.parse(raw));
113
+ }
114
+ catch {
115
+ reject(new Error("Invalid JSON body"));
116
+ }
117
+ });
118
+ req.on("error", reject);
119
+ });
120
+ }
121
+ export async function handleSoftwareAgentRunHttp(req, res, deps) {
122
+ if (!isOwnerRequest(req, deps.options)) {
123
+ res.writeHead(401, { "Content-Type": "application/json" })
124
+ .end(JSON.stringify({ error: "Owner token required" }));
125
+ return;
126
+ }
127
+ if (!deps.softwareAgent) {
128
+ res.writeHead(503, { "Content-Type": "application/json" })
129
+ .end(JSON.stringify({ error: "Software agent peripheral not ready" }));
130
+ return;
131
+ }
132
+ let body;
133
+ try {
134
+ body = await readJsonBody(req);
135
+ }
136
+ catch (err) {
137
+ res.writeHead(400, { "Content-Type": "application/json" })
138
+ .end(JSON.stringify({ error: err.message || "Invalid request body" }));
139
+ return;
140
+ }
141
+ let envelope;
142
+ try {
143
+ envelope = createOwnerTaskEnvelope(body, deps.workdir);
144
+ }
145
+ catch (err) {
146
+ res.writeHead(400, { "Content-Type": "application/json" })
147
+ .end(JSON.stringify({ error: err.message || "Invalid software-agent envelope" }));
148
+ return;
149
+ }
150
+ try {
151
+ const result = await deps.softwareAgent.sendTask(envelope);
152
+ res.writeHead(result.success ? 200 : 500, { "Content-Type": "application/json" })
153
+ .end(JSON.stringify(result, null, 2));
154
+ }
155
+ catch (err) {
156
+ const busy = String(err.message || "").includes("busy");
157
+ res.writeHead(busy ? 409 : 500, { "Content-Type": "application/json" })
158
+ .end(JSON.stringify({ error: err.message || String(err) }));
159
+ }
160
+ }
81
161
  import { RelayPeripheral } from "./relay-peripheral.js";
82
162
  import { EnginePeripheral, LLM_ENGINES as LLM_ENGINES_SET } from "./engine-peripheral.js";
83
163
  import { EngineQueue } from "./engine-queue.js";
@@ -89,6 +169,8 @@ import { SocialModule } from "./social-module.js";
89
169
  import { LongTermModule } from "./longterm-module.js";
90
170
  import { ReflectionModule } from "./reflection-module.js";
91
171
  import { ScriptModule } from "./script-module.js";
172
+ import { FileEventLog, PersistentEventBus } from "./event-bus.js";
173
+ import { CodexSoftwareAgentPeripheral, createOwnerTaskEnvelope } from "./software-agent-peripheral.js";
92
174
  import { SIG, sig } from "./types.js";
93
175
  import { loadConversation, listConversations, buildLLMContext } from "./context.js";
94
176
  import { createMcpServer, initMcpProxy, createMcpProxyServer } from "./mcp-server.js";
@@ -164,16 +246,24 @@ export async function serve(options) {
164
246
  },
165
247
  emitTaskCompleted,
166
248
  };
249
+ let codexSoftwareAgent = null;
167
250
  const httpServer = createServer(async (req, res) => {
168
251
  // Suppress noisy polling endpoints from log
169
252
  const isQuiet = req.url === "/self/state" || req.url?.startsWith("/self/state?");
170
253
  if (!isQuiet)
171
254
  console.log(`[http] ${req.method} ${req.url} session=${req.headers["mcp-session-id"] || "none"}`);
172
255
  try {
256
+ if (req.url === "/self/software-agent/run" && req.method === "POST") {
257
+ await handleSoftwareAgentRunHttp(req, res, {
258
+ options,
259
+ workdir,
260
+ softwareAgent: codexSoftwareAgent,
261
+ });
262
+ return;
263
+ }
173
264
  // Auth check
174
265
  if (options.key) {
175
- const auth = req.headers["authorization"];
176
- const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
266
+ const token = bearerToken(req);
177
267
  if (token !== options.key) {
178
268
  console.log(`[http] Unauthorized (bad or missing token)`);
179
269
  res.writeHead(401, { "Content-Type": "application/json" })
@@ -347,11 +437,22 @@ export async function serve(options) {
347
437
  if (options.relayHttp) {
348
438
  relay.pullFromRelay(workdir, options.agentName).catch(err => console.log(`[sync] Pull from relay failed: ${err}`));
349
439
  }
350
- // V2: Shared module context + EventBus
351
- const { SimpleEventBus } = await import("./event-bus.js");
352
- const bus = new SimpleEventBus();
440
+ // V2: Shared module context + persistent EventBus.
441
+ // This is the durable spine for module/peripheral/engine activity; recovery
442
+ // side effects are intentionally deferred until the event schema stabilizes.
443
+ const eventLogDir = join(workdir, ".akemon", "agents", options.agentName, "events");
444
+ await mkdir(eventLogDir, { recursive: true });
445
+ const eventLogPath = join(eventLogDir, "events.jsonl");
446
+ const bus = new PersistentEventBus(new FileEventLog(eventLogPath));
447
+ console.log(`[v2] Event log: ${eventLogPath}`);
448
+ codexSoftwareAgent = new CodexSoftwareAgentPeripheral({
449
+ workdir,
450
+ model: process.env.AKEMON_CODEX_MODEL,
451
+ sandbox: "workspace-write",
452
+ });
453
+ await codexSoftwareAgent.start(bus);
353
454
  // Peripheral registry — Core routes by capability
354
- const peripherals = [relay, engineP];
455
+ const peripherals = [relay, engineP, codexSoftwareAgent];
355
456
  // requestCompute: acquire the engine slot (priority-aware), execute with a
356
457
  // hard timeout, and release. The slot release and subprocess kill are both
357
458
  // driven by the same AbortController so a stuck engine can't hold the lock.
@@ -514,6 +615,11 @@ export async function serve(options) {
514
615
  }
515
616
  catch { }
516
617
  }
618
+ try {
619
+ await codexSoftwareAgent?.stop();
620
+ }
621
+ catch { }
622
+ bus.getLog().close();
517
623
  process.exit(0);
518
624
  };
519
625
  process.on("SIGINT", shutdown);
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import { Readable, Writable } from "node:stream";
3
+ import { describe, it } from "node:test";
4
+ import { handleSoftwareAgentRunHttp } from "./server.js";
5
+ class TestResponse extends Writable {
6
+ statusCode = 200;
7
+ headers = {};
8
+ body = "";
9
+ writeHead(statusCode, headers) {
10
+ this.statusCode = statusCode;
11
+ this.headers = headers || {};
12
+ return this;
13
+ }
14
+ end(chunk, encoding, callback) {
15
+ if (chunk)
16
+ this.body += Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
17
+ return super.end(callback || (typeof encoding === "function" ? encoding : undefined));
18
+ }
19
+ _write(chunk, _encoding, callback) {
20
+ this.body += chunk.toString("utf-8");
21
+ callback();
22
+ }
23
+ }
24
+ async function callSoftwareAgentEndpoint(softwareAgent, body, token) {
25
+ const rawBody = JSON.stringify(body);
26
+ const req = Readable.from([Buffer.from(rawBody)]);
27
+ Object.assign(req, {
28
+ method: "POST",
29
+ url: "/self/software-agent/run",
30
+ headers: {
31
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
32
+ "content-type": "application/json",
33
+ },
34
+ });
35
+ const res = new TestResponse();
36
+ await handleSoftwareAgentRunHttp(req, res, {
37
+ options: { secretKey: "owner-secret", key: "legacy-owner-key" },
38
+ workdir: "/repo",
39
+ softwareAgent,
40
+ });
41
+ return {
42
+ statusCode: res.statusCode,
43
+ body: res.body ? JSON.parse(res.body) : {},
44
+ };
45
+ }
46
+ describe("software-agent HTTP endpoint", () => {
47
+ it("requires an owner token", async () => {
48
+ let calls = 0;
49
+ const res = await callSoftwareAgentEndpoint({
50
+ async sendTask(envelope) {
51
+ calls++;
52
+ return successResult(envelope);
53
+ },
54
+ }, { goal: "inspect repo" });
55
+ assert.equal(res.statusCode, 401);
56
+ assert.match(res.body.error, /Owner token required/);
57
+ assert.equal(calls, 0);
58
+ });
59
+ it("forwards a validated owner envelope to the software agent", async () => {
60
+ let received = null;
61
+ const res = await callSoftwareAgentEndpoint({
62
+ async sendTask(envelope) {
63
+ received = envelope;
64
+ return successResult(envelope);
65
+ },
66
+ }, {
67
+ goal: " inspect repo ",
68
+ roleScope: "owner",
69
+ memoryScope: "owner",
70
+ riskLevel: "low",
71
+ forbiddenActions: ["make network requests"],
72
+ timeoutMs: 5000,
73
+ }, "owner-secret");
74
+ assert.equal(res.statusCode, 200);
75
+ assert.equal(res.body.success, true);
76
+ assert.ok(received);
77
+ const envelope = received;
78
+ assert.equal(envelope.goal, "inspect repo");
79
+ assert.equal(envelope.riskLevel, "low");
80
+ assert.equal(envelope.timeoutMs, 5000);
81
+ assert.deepEqual(envelope.forbiddenActions, [
82
+ "read Akemon private memory outside this envelope",
83
+ "access files outside the stated workdir unless explicitly needed and reported",
84
+ "make network requests",
85
+ ]);
86
+ });
87
+ it("rejects invalid envelope fields before calling the software agent", async () => {
88
+ let calls = 0;
89
+ const res = await callSoftwareAgentEndpoint({
90
+ async sendTask(envelope) {
91
+ calls++;
92
+ return successResult(envelope);
93
+ },
94
+ }, { goal: "inspect repo", roleScope: "friend" }, "owner-secret");
95
+ assert.equal(res.statusCode, 400);
96
+ assert.match(res.body.error, /Invalid roleScope/);
97
+ assert.equal(calls, 0);
98
+ });
99
+ });
100
+ function successResult(envelope) {
101
+ return {
102
+ success: true,
103
+ taskId: envelope.taskId || "task-from-test",
104
+ output: "ok",
105
+ exitCode: 0,
106
+ durationMs: 1,
107
+ };
108
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * SoftwareAgentPeripheral — wraps full agent software as an Akemon peripheral.
3
+ *
4
+ * This is distinct from EnginePeripheral. Engines are pure compute: modules
5
+ * prepare context and the engine returns text. Software agent peripherals are
6
+ * external software bodies such as Codex CLI or Claude Code: they may manage
7
+ * their own repo context, tools, skills, and multi-step execution loop.
8
+ *
9
+ * Batch 5 starts with a conservative Codex `exec` baseline. It gives Akemon a
10
+ * stable task envelope, streaming, reset, and event shape before switching the
11
+ * transport to app-server or a true persistent interactive session.
12
+ */
13
+ import { randomUUID } from "crypto";
14
+ import { spawn } from "child_process";
15
+ import { StringDecoder } from "string_decoder";
16
+ import { SIG, sig } from "./types.js";
17
+ import { sendTaskEnd, sendTaskStart, sendTaskStream } from "./relay-client.js";
18
+ const defaultTaskRelay = {
19
+ sendTaskStart,
20
+ sendTaskStream,
21
+ sendTaskEnd,
22
+ };
23
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
24
+ const MAX_OWNER_TIMEOUT_MS = 60 * 60 * 1000;
25
+ const DEFAULT_OWNER_ALLOWED_ACTIONS = ["read repository files", "edit files in workdir", "run project tests"];
26
+ const DEFAULT_OWNER_FORBIDDEN_ACTIONS = [
27
+ "read Akemon private memory outside this envelope",
28
+ "access files outside the stated workdir unless explicitly needed and reported",
29
+ ];
30
+ const ROLE_SCOPES = ["owner", "public", "order", "agent", "system"];
31
+ const MEMORY_SCOPES = ["none", "public", "task", "owner"];
32
+ const RISK_LEVELS = ["low", "medium", "high"];
33
+ export class CodexSoftwareAgentPeripheral {
34
+ id;
35
+ name;
36
+ capabilities = ["code-agent", "repo-inspect", "repo-edit", "tool-use", "skill-use", "streaming"];
37
+ tags = ["software-agent", "codex"];
38
+ config;
39
+ bus = null;
40
+ activeChild = null;
41
+ activeTaskId = null;
42
+ sessionId = randomUUID();
43
+ constructor(config) {
44
+ this.config = config;
45
+ this.id = config.id || "software-agent:codex";
46
+ this.name = config.name || "Codex CLI Software Agent";
47
+ }
48
+ async start(bus) {
49
+ this.bus = bus;
50
+ }
51
+ async stop() {
52
+ await this.resetSession();
53
+ this.bus = null;
54
+ }
55
+ async startSession() {
56
+ // Codex `exec` baseline is oneshot per task. Keep a session id so callers
57
+ // can observe resets and future transports can preserve this interface.
58
+ if (!this.sessionId)
59
+ this.sessionId = randomUUID();
60
+ }
61
+ async resetSession() {
62
+ if (this.activeChild?.pid) {
63
+ try {
64
+ process.kill(-this.activeChild.pid, "SIGTERM");
65
+ }
66
+ catch { }
67
+ setTimeout(() => {
68
+ try {
69
+ process.kill(-this.activeChild.pid, "SIGKILL");
70
+ }
71
+ catch { }
72
+ }, 3000).unref();
73
+ }
74
+ this.activeChild = null;
75
+ this.activeTaskId = null;
76
+ this.sessionId = randomUUID();
77
+ }
78
+ getState() {
79
+ return {
80
+ id: this.id,
81
+ sessionId: this.sessionId,
82
+ activeTaskId: this.activeTaskId,
83
+ busy: !!this.activeChild,
84
+ transport: "codex-exec",
85
+ };
86
+ }
87
+ async send(signal) {
88
+ if (signal.type !== SIG.SOFTWARE_AGENT_TASK)
89
+ return null;
90
+ const envelope = signal.data.envelope;
91
+ if (!envelope) {
92
+ return sig(SIG.SOFTWARE_AGENT_RESPONSE, {
93
+ success: false,
94
+ error: "Missing task envelope",
95
+ }, this.id);
96
+ }
97
+ const result = await this.sendTask(envelope);
98
+ return sig(SIG.SOFTWARE_AGENT_RESPONSE, { ...result }, this.id);
99
+ }
100
+ async sendTask(envelope, signal) {
101
+ if (this.activeChild) {
102
+ throw new Error(`Software agent busy (task=${this.activeTaskId})`);
103
+ }
104
+ const taskId = envelope.taskId || `sw_${Date.now()}_${randomUUID().slice(0, 8)}`;
105
+ const workdir = envelope.workdir || this.config.workdir;
106
+ const prompt = buildTaskEnvelopePrompt({ ...envelope, taskId, workdir });
107
+ const { cmd, args } = buildCodexExecCommand({
108
+ command: this.config.command || "codex",
109
+ workdir,
110
+ model: this.config.model,
111
+ sandbox: this.config.sandbox || "workspace-write",
112
+ });
113
+ const startedAt = Date.now();
114
+ const relay = this.config.taskRelay || defaultTaskRelay;
115
+ const commandLine = [cmd, ...args].join(" ");
116
+ const spawnImpl = this.config.spawnImpl || spawn;
117
+ const timeoutMs = envelope.timeoutMs || this.config.defaultTimeoutMs || DEFAULT_TIMEOUT_MS;
118
+ return new Promise((resolve) => {
119
+ this.activeTaskId = taskId;
120
+ relay.sendTaskStart(taskId, "software_agent", commandLine);
121
+ this.bus?.emit(SIG.TASK_STARTED, sig(SIG.TASK_STARTED, {
122
+ taskId,
123
+ taskType: "software_agent",
124
+ description: envelope.goal,
125
+ peripheral: this.id,
126
+ sessionId: this.sessionId,
127
+ }, this.id));
128
+ let child;
129
+ try {
130
+ child = spawnImpl(cmd, args, {
131
+ cwd: workdir,
132
+ env: process.env,
133
+ stdio: ["pipe", "pipe", "pipe"],
134
+ detached: true,
135
+ });
136
+ }
137
+ catch (err) {
138
+ this.activeChild = null;
139
+ this.activeTaskId = null;
140
+ const durationMs = Date.now() - startedAt;
141
+ relay.sendTaskEnd(taskId, null, durationMs);
142
+ const result = {
143
+ success: false,
144
+ taskId,
145
+ output: "",
146
+ error: err.message || String(err),
147
+ exitCode: null,
148
+ durationMs,
149
+ };
150
+ this.bus?.emit(SIG.TASK_FAILED, sig(SIG.TASK_FAILED, result, this.id));
151
+ resolve(result);
152
+ return;
153
+ }
154
+ this.activeChild = child;
155
+ let stdout = "";
156
+ let stderr = "";
157
+ let finished = false;
158
+ let aborted = false;
159
+ const outDecoder = new StringDecoder("utf8");
160
+ const errDecoder = new StringDecoder("utf8");
161
+ const finish = (exitCode, error) => {
162
+ if (finished)
163
+ return;
164
+ finished = true;
165
+ signal?.removeEventListener("abort", onAbort);
166
+ clearTimeout(timer);
167
+ const tailOut = outDecoder.end();
168
+ const tailErr = errDecoder.end();
169
+ if (tailOut) {
170
+ stdout += tailOut;
171
+ relay.sendTaskStream(taskId, "stdout", tailOut);
172
+ }
173
+ if (tailErr) {
174
+ stderr += tailErr;
175
+ relay.sendTaskStream(taskId, "stderr", tailErr);
176
+ }
177
+ const durationMs = Date.now() - startedAt;
178
+ relay.sendTaskEnd(taskId, exitCode, durationMs);
179
+ this.activeChild = null;
180
+ this.activeTaskId = null;
181
+ const output = stdout.trim() || stderr.trim();
182
+ const success = !error && !aborted && exitCode === 0;
183
+ const result = {
184
+ success,
185
+ taskId,
186
+ output,
187
+ error: success ? undefined : error || stderr.trim() || `codex exited with code ${exitCode}`,
188
+ exitCode,
189
+ durationMs,
190
+ };
191
+ this.bus?.emit(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, sig(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, {
192
+ ...result,
193
+ taskLabel: `software_agent:${this.id}`,
194
+ }, this.id));
195
+ resolve(result);
196
+ };
197
+ const onAbort = () => {
198
+ if (aborted || !child.pid)
199
+ return;
200
+ aborted = true;
201
+ try {
202
+ process.kill(-child.pid, "SIGTERM");
203
+ }
204
+ catch { }
205
+ setTimeout(() => {
206
+ try {
207
+ process.kill(-child.pid, "SIGKILL");
208
+ }
209
+ catch { }
210
+ }, 3000).unref();
211
+ };
212
+ const timer = setTimeout(() => {
213
+ if (!child.pid)
214
+ return;
215
+ aborted = true;
216
+ try {
217
+ process.kill(-child.pid, "SIGTERM");
218
+ }
219
+ catch { }
220
+ setTimeout(() => {
221
+ try {
222
+ process.kill(-child.pid, "SIGKILL");
223
+ }
224
+ catch { }
225
+ }, 3000).unref();
226
+ }, timeoutMs);
227
+ if (signal) {
228
+ if (signal.aborted)
229
+ onAbort();
230
+ else
231
+ signal.addEventListener("abort", onAbort, { once: true });
232
+ }
233
+ child.stdin?.on("error", () => { });
234
+ child.stdin?.write(prompt);
235
+ child.stdin?.end();
236
+ child.stdout?.on("data", (chunk) => {
237
+ const text = outDecoder.write(chunk);
238
+ if (!text)
239
+ return;
240
+ stdout += text;
241
+ relay.sendTaskStream(taskId, "stdout", text);
242
+ });
243
+ child.stderr?.on("data", (chunk) => {
244
+ const text = errDecoder.write(chunk);
245
+ if (!text)
246
+ return;
247
+ stderr += text;
248
+ relay.sendTaskStream(taskId, "stderr", text);
249
+ });
250
+ child.on("close", (code) => {
251
+ child.unref();
252
+ finish(code);
253
+ });
254
+ child.on("error", (err) => {
255
+ child.unref();
256
+ finish(null, err.message);
257
+ });
258
+ });
259
+ }
260
+ }
261
+ export function buildTaskEnvelopePrompt(envelope) {
262
+ const lines = [
263
+ "[Akemon Software Peripheral Task Envelope]",
264
+ "",
265
+ `Task ID: ${envelope.taskId || "(unspecified)"}`,
266
+ `Source module: ${envelope.sourceModule}`,
267
+ `Purpose: ${envelope.purpose}`,
268
+ `Role scope: ${envelope.roleScope}`,
269
+ `Memory scope: ${envelope.memoryScope}`,
270
+ `Risk level: ${envelope.riskLevel}`,
271
+ `Workdir: ${envelope.workdir}`,
272
+ "",
273
+ "Goal:",
274
+ envelope.goal,
275
+ "",
276
+ ];
277
+ if (envelope.memorySummary?.trim()) {
278
+ lines.push("Visible Akemon memory/context:");
279
+ lines.push(envelope.memorySummary.trim());
280
+ lines.push("");
281
+ }
282
+ if (envelope.allowedActions?.length) {
283
+ lines.push("Allowed actions:");
284
+ for (const item of envelope.allowedActions)
285
+ lines.push(`- ${item}`);
286
+ lines.push("");
287
+ }
288
+ if (envelope.forbiddenActions?.length) {
289
+ lines.push("Forbidden actions:");
290
+ for (const item of envelope.forbiddenActions)
291
+ lines.push(`- ${item}`);
292
+ lines.push("");
293
+ }
294
+ if (envelope.deliverable?.trim()) {
295
+ lines.push("Expected deliverable:");
296
+ lines.push(envelope.deliverable.trim());
297
+ lines.push("");
298
+ }
299
+ lines.push("Instructions:");
300
+ lines.push("- Treat this envelope as the complete Akemon-provided context for this task.");
301
+ lines.push("- Do not attempt to read Akemon private memory outside the visible context above.");
302
+ lines.push("- Work only in the stated workdir unless the envelope explicitly allows otherwise.");
303
+ lines.push("- Report what changed, what you verified, and any remaining risk.");
304
+ return lines.join("\n");
305
+ }
306
+ export function createOwnerTaskEnvelope(body, defaultWorkdir) {
307
+ const goal = typeof body?.goal === "string" ? body.goal.trim() : "";
308
+ if (!goal)
309
+ throw new Error("Missing required string field: goal");
310
+ const callerForbiddenActions = readOptionalStringArray(body.forbiddenActions, "forbiddenActions");
311
+ return {
312
+ taskId: readOptionalString(body.taskId, "taskId"),
313
+ sourceModule: "owner-http",
314
+ purpose: readOptionalString(body.purpose, "purpose") || "owner software-agent task",
315
+ goal,
316
+ workdir: readOptionalString(body.workdir, "workdir") || defaultWorkdir,
317
+ roleScope: readEnum(body.roleScope, "roleScope", ROLE_SCOPES, "owner"),
318
+ memoryScope: readEnum(body.memoryScope, "memoryScope", MEMORY_SCOPES, "owner"),
319
+ riskLevel: readEnum(body.riskLevel, "riskLevel", RISK_LEVELS, "medium"),
320
+ allowedActions: body.allowedActions !== undefined
321
+ ? readOptionalStringArray(body.allowedActions, "allowedActions")
322
+ : [...DEFAULT_OWNER_ALLOWED_ACTIONS],
323
+ forbiddenActions: [...new Set([...DEFAULT_OWNER_FORBIDDEN_ACTIONS, ...callerForbiddenActions])],
324
+ memorySummary: typeof body.memorySummary === "string" ? body.memorySummary : "",
325
+ deliverable: typeof body.deliverable === "string"
326
+ ? body.deliverable
327
+ : "Return a concise engineering summary with changes, verification, and remaining risks.",
328
+ timeoutMs: readTimeoutMs(body.timeoutMs),
329
+ };
330
+ }
331
+ function readOptionalString(value, field) {
332
+ if (value === undefined || value === null)
333
+ return undefined;
334
+ if (typeof value !== "string")
335
+ throw new Error(`Invalid ${field}: expected string`);
336
+ const trimmed = value.trim();
337
+ return trimmed || undefined;
338
+ }
339
+ function readOptionalStringArray(value, field) {
340
+ if (value === undefined || value === null)
341
+ return [];
342
+ if (!Array.isArray(value))
343
+ throw new Error(`Invalid ${field}: expected array of strings`);
344
+ return value.map((item, index) => {
345
+ if (typeof item !== "string" || !item.trim()) {
346
+ throw new Error(`Invalid ${field}[${index}]: expected non-empty string`);
347
+ }
348
+ return item.trim();
349
+ });
350
+ }
351
+ function readEnum(value, field, allowed, fallback) {
352
+ if (value === undefined || value === null || value === "")
353
+ return fallback;
354
+ if (typeof value !== "string" || !allowed.includes(value)) {
355
+ throw new Error(`Invalid ${field}: expected one of ${allowed.join(", ")}`);
356
+ }
357
+ return value;
358
+ }
359
+ function readTimeoutMs(value) {
360
+ if (value === undefined || value === null)
361
+ return undefined;
362
+ if (typeof value !== "number"
363
+ || !Number.isInteger(value)
364
+ || value <= 0
365
+ || value > MAX_OWNER_TIMEOUT_MS) {
366
+ throw new Error(`Invalid timeoutMs: expected integer between 1 and ${MAX_OWNER_TIMEOUT_MS}`);
367
+ }
368
+ return value;
369
+ }
370
+ function buildCodexExecCommand(opts) {
371
+ const args = [
372
+ "exec",
373
+ "--skip-git-repo-check",
374
+ "--color", "never",
375
+ "-s", opts.sandbox,
376
+ "-C", opts.workdir,
377
+ ];
378
+ if (opts.model)
379
+ args.push("-m", opts.model);
380
+ args.push("-");
381
+ return { cmd: opts.command, args };
382
+ }
@@ -0,0 +1,187 @@
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 { CodexSoftwareAgentPeripheral, buildTaskEnvelopePrompt, createOwnerTaskEnvelope, } from "./software-agent-peripheral.js";
6
+ import { SimpleEventBus } from "./event-bus.js";
7
+ import { SIG } from "./types.js";
8
+ function createFakeChild() {
9
+ const child = new EventEmitter();
10
+ child.stdin = new PassThrough();
11
+ child.stdout = new PassThrough();
12
+ child.stderr = new PassThrough();
13
+ Object.defineProperty(child, "pid", { value: 23456, configurable: true });
14
+ child.unref = () => child;
15
+ return child;
16
+ }
17
+ function baseEnvelope(overrides = {}) {
18
+ return {
19
+ taskId: "sw-test-1",
20
+ sourceModule: "task",
21
+ purpose: "dogfood coding task",
22
+ goal: "Inspect the repo and summarize the event bus implementation.",
23
+ workdir: "/tmp/akemon",
24
+ roleScope: "owner",
25
+ memoryScope: "owner",
26
+ riskLevel: "medium",
27
+ allowedActions: ["read repository files", "run tests"],
28
+ forbiddenActions: ["read owner private notes outside this envelope"],
29
+ memorySummary: "Visible context only.",
30
+ deliverable: "Concise engineering report.",
31
+ ...overrides,
32
+ };
33
+ }
34
+ describe("buildTaskEnvelopePrompt", () => {
35
+ it("renders envelope fields, visible memory, boundaries, and deliverable", () => {
36
+ const prompt = buildTaskEnvelopePrompt(baseEnvelope());
37
+ assert.match(prompt, /Task ID: sw-test-1/);
38
+ assert.match(prompt, /Source module: task/);
39
+ assert.match(prompt, /Role scope: owner/);
40
+ assert.match(prompt, /Memory scope: owner/);
41
+ assert.match(prompt, /Risk level: medium/);
42
+ assert.match(prompt, /Workdir: \/tmp\/akemon/);
43
+ assert.match(prompt, /Visible context only\./);
44
+ assert.match(prompt, /- read repository files/);
45
+ assert.match(prompt, /- read owner private notes outside this envelope/);
46
+ assert.match(prompt, /Concise engineering report\./);
47
+ assert.match(prompt, /Do not attempt to read Akemon private memory outside the visible context/);
48
+ });
49
+ });
50
+ describe("createOwnerTaskEnvelope", () => {
51
+ it("builds conservative owner defaults from a minimal body", () => {
52
+ const envelope = createOwnerTaskEnvelope({ goal: " inspect repo " }, "/repo");
53
+ assert.equal(envelope.sourceModule, "owner-http");
54
+ assert.equal(envelope.goal, "inspect repo");
55
+ assert.equal(envelope.workdir, "/repo");
56
+ assert.equal(envelope.roleScope, "owner");
57
+ assert.equal(envelope.memoryScope, "owner");
58
+ assert.equal(envelope.riskLevel, "medium");
59
+ assert.deepEqual(envelope.allowedActions, ["read repository files", "edit files in workdir", "run project tests"]);
60
+ assert.ok(envelope.forbiddenActions?.some((item) => item.includes("private memory")));
61
+ });
62
+ it("keeps baseline forbidden boundaries when caller adds restrictions", () => {
63
+ const envelope = createOwnerTaskEnvelope({
64
+ goal: "inspect repo",
65
+ forbiddenActions: ["make network requests", "read Akemon private memory outside this envelope"],
66
+ }, "/repo");
67
+ assert.deepEqual(envelope.forbiddenActions, [
68
+ "read Akemon private memory outside this envelope",
69
+ "access files outside the stated workdir unless explicitly needed and reported",
70
+ "make network requests",
71
+ ]);
72
+ });
73
+ it("rejects empty goals", () => {
74
+ assert.throws(() => createOwnerTaskEnvelope({ goal: " " }, "/repo"), /Missing required string field: goal/);
75
+ });
76
+ it("rejects invalid scope, action, and timeout fields", () => {
77
+ assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", roleScope: "friend" }, "/repo"), /Invalid roleScope/);
78
+ assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", memoryScope: "private" }, "/repo"), /Invalid memoryScope/);
79
+ assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", riskLevel: "critical" }, "/repo"), /Invalid riskLevel/);
80
+ assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", allowedActions: ["read", ""] }, "/repo"), /Invalid allowedActions\[1\]/);
81
+ assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", timeoutMs: 0 }, "/repo"), /Invalid timeoutMs/);
82
+ });
83
+ });
84
+ describe("CodexSoftwareAgentPeripheral", () => {
85
+ it("runs codex exec with an envelope over stdin and streams lifecycle events", async () => {
86
+ const streamEvents = [];
87
+ const busEvents = [];
88
+ let writtenPrompt = "";
89
+ let spawnedChild = null;
90
+ const bus = new SimpleEventBus();
91
+ bus.on(SIG.TASK_STARTED, (signal) => {
92
+ busEvents.push(`${signal.type}:${signal.data.taskId}`);
93
+ });
94
+ bus.on(SIG.TASK_COMPLETED, (signal) => {
95
+ busEvents.push(`${signal.type}:${signal.data.taskId}`);
96
+ });
97
+ const peripheral = new CodexSoftwareAgentPeripheral({
98
+ workdir: "/tmp/akemon",
99
+ command: "codex",
100
+ spawnImpl: ((cmd, args, opts) => {
101
+ assert.equal(cmd, "codex");
102
+ assert.deepEqual(args, [
103
+ "exec",
104
+ "--skip-git-repo-check",
105
+ "--color", "never",
106
+ "-s", "workspace-write",
107
+ "-C", "/tmp/akemon",
108
+ "-",
109
+ ]);
110
+ assert.equal(opts?.cwd, "/tmp/akemon");
111
+ const child = createFakeChild();
112
+ spawnedChild = child;
113
+ child.stdin?.on("data", (chunk) => {
114
+ writtenPrompt += chunk.toString("utf8");
115
+ });
116
+ queueMicrotask(() => {
117
+ child.stdout?.emit("data", Buffer.from("result "));
118
+ child.stderr?.emit("data", Buffer.from("note"));
119
+ child.stdout?.emit("data", Buffer.from("ok"));
120
+ child.emit("close", 0);
121
+ });
122
+ return child;
123
+ }),
124
+ taskRelay: {
125
+ sendTaskStart(taskId, origin, cmd) {
126
+ streamEvents.push({ type: "start", taskId, origin, cmd });
127
+ },
128
+ sendTaskStream(taskId, stream, chunk) {
129
+ streamEvents.push({ type: "stream", taskId, stream, chunk });
130
+ },
131
+ sendTaskEnd(taskId, exitCode, durationMs) {
132
+ streamEvents.push({ type: "end", taskId, exitCode, durationMs });
133
+ },
134
+ },
135
+ });
136
+ await peripheral.start(bus);
137
+ const result = await peripheral.sendTask(baseEnvelope());
138
+ assert.ok(spawnedChild, "spawn should be called");
139
+ assert.equal(result.success, true);
140
+ assert.equal(result.taskId, "sw-test-1");
141
+ assert.equal(result.output, "result ok");
142
+ assert.equal(result.exitCode, 0);
143
+ assert.match(writtenPrompt, /Akemon Software Peripheral Task Envelope/);
144
+ assert.match(writtenPrompt, /Goal:\nInspect the repo/);
145
+ assert.match(writtenPrompt, /Visible context only\./);
146
+ assert.deepEqual(streamEvents.slice(0, 4), [
147
+ {
148
+ type: "start",
149
+ taskId: "sw-test-1",
150
+ origin: "software_agent",
151
+ cmd: "codex exec --skip-git-repo-check --color never -s workspace-write -C /tmp/akemon -",
152
+ },
153
+ { type: "stream", taskId: "sw-test-1", stream: "stdout", chunk: "result " },
154
+ { type: "stream", taskId: "sw-test-1", stream: "stderr", chunk: "note" },
155
+ { type: "stream", taskId: "sw-test-1", stream: "stdout", chunk: "ok" },
156
+ ]);
157
+ const end = streamEvents[4];
158
+ assert.equal(end?.type, "end");
159
+ if (end?.type === "end") {
160
+ assert.equal(end.taskId, "sw-test-1");
161
+ assert.equal(end.exitCode, 0);
162
+ assert.ok(end.durationMs >= 0);
163
+ }
164
+ assert.deepEqual(busEvents, [
165
+ "task:started:sw-test-1",
166
+ "task:completed:sw-test-1",
167
+ ]);
168
+ });
169
+ it("rejects concurrent tasks while a codex run is active", async () => {
170
+ const peripheral = new CodexSoftwareAgentPeripheral({
171
+ workdir: "/tmp/akemon",
172
+ spawnImpl: (() => {
173
+ const child = createFakeChild();
174
+ setTimeout(() => child.emit("close", 0), 10);
175
+ return child;
176
+ }),
177
+ taskRelay: {
178
+ sendTaskStart() { },
179
+ sendTaskStream() { },
180
+ sendTaskEnd() { },
181
+ },
182
+ });
183
+ const first = peripheral.sendTask(baseEnvelope({ taskId: "first" }));
184
+ await assert.rejects(() => peripheral.sendTask(baseEnvelope({ taskId: "second" })), /Software agent busy/);
185
+ await first;
186
+ });
187
+ });
package/dist/types.js CHANGED
@@ -23,6 +23,9 @@ export const SIG = {
23
23
  ENGINE_RESPONSE: "engine:response",
24
24
  ENGINE_BUSY: "engine:busy",
25
25
  ENGINE_FREE: "engine:free",
26
+ // Software agent peripherals (Codex CLI, Claude Code, etc.)
27
+ SOFTWARE_AGENT_TASK: "software-agent:task",
28
+ SOFTWARE_AGENT_RESPONSE: "software-agent:response",
26
29
  // Task execution
27
30
  TASK_RECEIVED: "task:received",
28
31
  TASK_STARTED: "task:started",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akemon",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Agent work marketplace — train your agent, let it work for others",
5
5
  "type": "module",
6
6
  "license": "MIT",