akemon 0.3.3 → 0.3.5

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.
@@ -11,10 +11,13 @@
11
11
  * transport to app-server or a true persistent interactive session.
12
12
  */
13
13
  import { randomUUID } from "crypto";
14
- import { spawn } from "child_process";
14
+ import { spawn, spawnSync } from "child_process";
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
16
+ import { isAbsolute, join, relative, resolve as resolvePath } from "path";
15
17
  import { StringDecoder } from "string_decoder";
16
18
  import { SIG, sig } from "./types.js";
17
19
  import { sendTaskEnd, sendTaskStart, sendTaskStream } from "./relay-client.js";
20
+ import { redactSecrets, StreamingRedactor } from "./redaction.js";
18
21
  const defaultTaskRelay = {
19
22
  sendTaskStart,
20
23
  sendTaskStream,
@@ -30,6 +33,40 @@ const DEFAULT_OWNER_FORBIDDEN_ACTIONS = [
30
33
  const ROLE_SCOPES = ["owner", "public", "order", "agent", "system"];
31
34
  const MEMORY_SCOPES = ["none", "public", "task", "owner"];
32
35
  const RISK_LEVELS = ["low", "medium", "high"];
36
+ const MAX_STREAM_SUMMARY_CHARS = 12_000;
37
+ const STREAM_SUMMARY_HEAD_CHARS = 4_000;
38
+ const DEFAULT_TASK_LEDGER_MAX_RECORDS = 200;
39
+ const CONTEXT_PACKET_FILENAME = "TASK_CONTEXT.md";
40
+ const CONTEXT_SESSION_STATE_FILENAME = "SESSION.json";
41
+ const MAX_CONTEXT_SESSION_ID_LENGTH = 120;
42
+ const MAX_CONTEXT_SESSION_SUMMARY_CHARS = 4_000;
43
+ const DEFAULT_SOFTWARE_AGENT_ENV_POLICY = "inherit";
44
+ const DEFAULT_SOFTWARE_AGENT_ENV_ALLOWLIST = [
45
+ "PATH",
46
+ "HOME",
47
+ "SHELL",
48
+ "USER",
49
+ "LOGNAME",
50
+ "TMPDIR",
51
+ "TEMP",
52
+ "TMP",
53
+ "LANG",
54
+ "LC_ALL",
55
+ "LC_CTYPE",
56
+ "TERM",
57
+ "COLORTERM",
58
+ "NO_COLOR",
59
+ "FORCE_COLOR",
60
+ "OPENAI_API_KEY",
61
+ "OPENAI_BASE_URL",
62
+ "OPENAI_ORG_ID",
63
+ "OPENAI_ORGANIZATION",
64
+ "OPENAI_PROJECT",
65
+ "OPENAI_MODEL",
66
+ "CODEX_HOME",
67
+ "CODEX_MODEL",
68
+ "CODEX_PROFILE",
69
+ ];
33
70
  export class CodexSoftwareAgentPeripheral {
34
71
  id;
35
72
  name;
@@ -39,9 +76,14 @@ export class CodexSoftwareAgentPeripheral {
39
76
  bus = null;
40
77
  activeChild = null;
41
78
  activeTaskId = null;
79
+ activeWorkdir = null;
42
80
  sessionId = randomUUID();
43
81
  constructor(config) {
44
- this.config = config;
82
+ this.config = {
83
+ ...config,
84
+ envPolicy: normalizeSoftwareAgentEnvPolicy(config.envPolicy),
85
+ envAllowlist: normalizeSoftwareAgentEnvAllowlist(config.envAllowlist),
86
+ };
45
87
  this.id = config.id || "software-agent:codex";
46
88
  this.name = config.name || "Codex CLI Software Agent";
47
89
  }
@@ -59,29 +101,43 @@ export class CodexSoftwareAgentPeripheral {
59
101
  this.sessionId = randomUUID();
60
102
  }
61
103
  async resetSession() {
62
- if (this.activeChild?.pid) {
104
+ const activePid = this.activeChild?.pid;
105
+ if (activePid) {
106
+ const processGroupId = -activePid;
63
107
  try {
64
- process.kill(-this.activeChild.pid, "SIGTERM");
108
+ process.kill(processGroupId, "SIGTERM");
65
109
  }
66
110
  catch { }
67
111
  setTimeout(() => {
68
112
  try {
69
- process.kill(-this.activeChild.pid, "SIGKILL");
113
+ process.kill(processGroupId, "SIGKILL");
70
114
  }
71
115
  catch { }
72
116
  }, 3000).unref();
73
117
  }
74
118
  this.activeChild = null;
75
119
  this.activeTaskId = null;
120
+ this.activeWorkdir = null;
76
121
  this.sessionId = randomUUID();
77
122
  }
78
123
  getState() {
124
+ const currentWorkdir = this.activeWorkdir || resolvePath(this.config.workdir);
79
125
  return {
80
126
  id: this.id,
81
127
  sessionId: this.sessionId,
82
128
  activeTaskId: this.activeTaskId,
129
+ activeWorkdir: this.activeWorkdir,
83
130
  busy: !!this.activeChild,
84
131
  transport: "codex-exec",
132
+ baseWorkdir: resolvePath(this.config.workdir),
133
+ workdirStatus: this.collectWorkdirStatus(currentWorkdir),
134
+ taskLedgerDir: this.config.taskLedgerDir,
135
+ contextSessionDir: this.config.contextSessionDir,
136
+ environment: buildSoftwareAgentChildEnvironment({
137
+ policy: this.config.envPolicy,
138
+ allowlist: this.config.envAllowlist,
139
+ sourceEnv: this.config.sourceEnv,
140
+ }).audit,
85
141
  };
86
142
  }
87
143
  async send(signal) {
@@ -97,13 +153,24 @@ export class CodexSoftwareAgentPeripheral {
97
153
  const result = await this.sendTask(envelope);
98
154
  return sig(SIG.SOFTWARE_AGENT_RESPONSE, { ...result }, this.id);
99
155
  }
100
- async sendTask(envelope, signal) {
156
+ async sendTask(envelope, taskOptions) {
101
157
  if (this.activeChild) {
102
158
  throw new Error(`Software agent busy (task=${this.activeTaskId})`);
103
159
  }
160
+ const { signal, observer } = normalizeSoftwareAgentTaskOptions(taskOptions);
104
161
  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 });
162
+ const contextSessionId = normalizeContextSessionId(envelope.contextSessionId) || taskId;
163
+ const workdirSafety = resolveWorkdirSafety(this.config.workdir, envelope.workdir || this.config.workdir, envelope.workdirSafety?.allowOutsideWorkdir || false);
164
+ const workdir = workdirSafety.effectiveWorkdir;
165
+ let effectiveEnvelope = { ...envelope, taskId, contextSessionId, workdir, workdirSafety };
166
+ const contextSession = this.config.contextSessionDir
167
+ ? writeSoftwareAgentContextPacket(this.config.contextSessionDir, effectiveEnvelope)
168
+ : undefined;
169
+ if (contextSession)
170
+ effectiveEnvelope = contextSession.envelope;
171
+ const prompt = contextSession
172
+ ? buildContextPacketLaunchPrompt(effectiveEnvelope, contextSession.audit.packetPath)
173
+ : buildTaskEnvelopePrompt(effectiveEnvelope);
107
174
  const { cmd, args } = buildCodexExecCommand({
108
175
  command: this.config.command || "codex",
109
176
  workdir,
@@ -111,17 +178,45 @@ export class CodexSoftwareAgentPeripheral {
111
178
  sandbox: this.config.sandbox || "workspace-write",
112
179
  });
113
180
  const startedAt = Date.now();
181
+ const startedAtIso = new Date(startedAt).toISOString();
114
182
  const relay = this.config.taskRelay || defaultTaskRelay;
115
183
  const commandLine = [cmd, ...args].join(" ");
116
184
  const spawnImpl = this.config.spawnImpl || spawn;
117
185
  const timeoutMs = envelope.timeoutMs || this.config.defaultTimeoutMs || DEFAULT_TIMEOUT_MS;
186
+ const workdirStatus = this.collectWorkdirStatus(workdir);
187
+ const childEnvironment = buildSoftwareAgentChildEnvironment({
188
+ policy: this.config.envPolicy,
189
+ allowlist: this.config.envAllowlist,
190
+ sourceEnv: this.config.sourceEnv,
191
+ });
192
+ const baseTaskRecord = () => ({
193
+ schemaVersion: 1,
194
+ taskId,
195
+ agentId: this.id,
196
+ sessionId: this.sessionId,
197
+ transport: "codex-exec",
198
+ commandLine,
199
+ envelope: effectiveEnvelope,
200
+ startedAt: startedAtIso,
201
+ environment: childEnvironment.audit,
202
+ contextSession: contextSession?.audit,
203
+ workdirStatus,
204
+ });
118
205
  return new Promise((resolve) => {
119
206
  this.activeTaskId = taskId;
120
- relay.sendTaskStart(taskId, "software_agent", commandLine);
207
+ this.activeWorkdir = workdir;
208
+ this.writeTaskRecord({
209
+ ...baseTaskRecord(),
210
+ status: "running",
211
+ updatedAt: startedAtIso,
212
+ });
213
+ const origin = "software_agent";
214
+ relay.sendTaskStart(taskId, origin, commandLine);
215
+ observer?.onStart?.({ taskId, origin, commandLine });
121
216
  this.bus?.emit(SIG.TASK_STARTED, sig(SIG.TASK_STARTED, {
122
217
  taskId,
123
218
  taskType: "software_agent",
124
- description: envelope.goal,
219
+ description: effectiveEnvelope.goal,
125
220
  peripheral: this.id,
126
221
  sessionId: this.sessionId,
127
222
  }, this.id));
@@ -129,7 +224,7 @@ export class CodexSoftwareAgentPeripheral {
129
224
  try {
130
225
  child = spawnImpl(cmd, args, {
131
226
  cwd: workdir,
132
- env: process.env,
227
+ env: childEnvironment.env,
133
228
  stdio: ["pipe", "pipe", "pipe"],
134
229
  detached: true,
135
230
  });
@@ -137,8 +232,8 @@ export class CodexSoftwareAgentPeripheral {
137
232
  catch (err) {
138
233
  this.activeChild = null;
139
234
  this.activeTaskId = null;
235
+ this.activeWorkdir = null;
140
236
  const durationMs = Date.now() - startedAt;
141
- relay.sendTaskEnd(taskId, null, durationMs);
142
237
  const result = {
143
238
  success: false,
144
239
  taskId,
@@ -147,6 +242,22 @@ export class CodexSoftwareAgentPeripheral {
147
242
  exitCode: null,
148
243
  durationMs,
149
244
  };
245
+ relay.sendTaskEnd(taskId, null, durationMs);
246
+ observer?.onEnd?.({ taskId, exitCode: null, durationMs, result: redactSecrets(result) });
247
+ const completedAt = new Date().toISOString();
248
+ this.writeTaskRecord({
249
+ ...baseTaskRecord(),
250
+ status: "failed",
251
+ updatedAt: completedAt,
252
+ completedAt,
253
+ durationMs,
254
+ result,
255
+ stdoutSummary: summarizeText(""),
256
+ stderrSummary: summarizeText(result.error || ""),
257
+ });
258
+ if (contextSession) {
259
+ writeSoftwareAgentContextSessionState(contextSession.audit.statePath, effectiveEnvelope, result, completedAt);
260
+ }
150
261
  this.bus?.emit(SIG.TASK_FAILED, sig(SIG.TASK_FAILED, result, this.id));
151
262
  resolve(result);
152
263
  return;
@@ -158,6 +269,14 @@ export class CodexSoftwareAgentPeripheral {
158
269
  let aborted = false;
159
270
  const outDecoder = new StringDecoder("utf8");
160
271
  const errDecoder = new StringDecoder("utf8");
272
+ const outRedactor = new StreamingRedactor();
273
+ const errRedactor = new StreamingRedactor();
274
+ const emitSafeStream = (stream, text) => {
275
+ if (!text)
276
+ return;
277
+ relay.sendTaskStream(taskId, stream, text);
278
+ observer?.onStream?.({ taskId, stream, chunk: text });
279
+ };
161
280
  const finish = (exitCode, error) => {
162
281
  if (finished)
163
282
  return;
@@ -168,16 +287,18 @@ export class CodexSoftwareAgentPeripheral {
168
287
  const tailErr = errDecoder.end();
169
288
  if (tailOut) {
170
289
  stdout += tailOut;
171
- relay.sendTaskStream(taskId, "stdout", tailOut);
290
+ emitSafeStream("stdout", outRedactor.push(tailOut));
172
291
  }
173
292
  if (tailErr) {
174
293
  stderr += tailErr;
175
- relay.sendTaskStream(taskId, "stderr", tailErr);
294
+ emitSafeStream("stderr", errRedactor.push(tailErr));
176
295
  }
296
+ emitSafeStream("stdout", outRedactor.flush());
297
+ emitSafeStream("stderr", errRedactor.flush());
177
298
  const durationMs = Date.now() - startedAt;
178
- relay.sendTaskEnd(taskId, exitCode, durationMs);
179
299
  this.activeChild = null;
180
300
  this.activeTaskId = null;
301
+ this.activeWorkdir = null;
181
302
  const output = stdout.trim() || stderr.trim();
182
303
  const success = !error && !aborted && exitCode === 0;
183
304
  const result = {
@@ -188,6 +309,22 @@ export class CodexSoftwareAgentPeripheral {
188
309
  exitCode,
189
310
  durationMs,
190
311
  };
312
+ relay.sendTaskEnd(taskId, exitCode, durationMs);
313
+ observer?.onEnd?.({ taskId, exitCode, durationMs, result: redactSecrets(result) });
314
+ const completedAt = new Date().toISOString();
315
+ this.writeTaskRecord({
316
+ ...baseTaskRecord(),
317
+ status: success ? "completed" : "failed",
318
+ updatedAt: completedAt,
319
+ completedAt,
320
+ durationMs,
321
+ result,
322
+ stdoutSummary: summarizeText(stdout),
323
+ stderrSummary: summarizeText(stderr),
324
+ });
325
+ if (contextSession) {
326
+ writeSoftwareAgentContextSessionState(contextSession.audit.statePath, effectiveEnvelope, result, completedAt);
327
+ }
191
328
  this.bus?.emit(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, sig(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, {
192
329
  ...result,
193
330
  taskLabel: `software_agent:${this.id}`,
@@ -238,14 +375,14 @@ export class CodexSoftwareAgentPeripheral {
238
375
  if (!text)
239
376
  return;
240
377
  stdout += text;
241
- relay.sendTaskStream(taskId, "stdout", text);
378
+ emitSafeStream("stdout", outRedactor.push(text));
242
379
  });
243
380
  child.stderr?.on("data", (chunk) => {
244
381
  const text = errDecoder.write(chunk);
245
382
  if (!text)
246
383
  return;
247
384
  stderr += text;
248
- relay.sendTaskStream(taskId, "stderr", text);
385
+ emitSafeStream("stderr", errRedactor.push(text));
249
386
  });
250
387
  child.on("close", (code) => {
251
388
  child.unref();
@@ -257,23 +394,79 @@ export class CodexSoftwareAgentPeripheral {
257
394
  });
258
395
  });
259
396
  }
397
+ writeTaskRecord(record) {
398
+ const dir = this.config.taskLedgerDir;
399
+ if (!dir)
400
+ return;
401
+ try {
402
+ mkdirSync(dir, { recursive: true });
403
+ const safeTaskId = safeTaskFilename(record.taskId);
404
+ writeFileSync(join(dir, `${safeTaskId}.json`), `${JSON.stringify(redactSecrets(record), null, 2)}\n`);
405
+ pruneSoftwareAgentTaskRecords(dir, this.config.taskLedgerMaxRecords, record.taskId);
406
+ }
407
+ catch (err) {
408
+ console.error(`[software-agent] Failed to write task ledger: ${err.message || String(err)}`);
409
+ }
410
+ }
411
+ collectWorkdirStatus(workdir) {
412
+ const impl = this.config.gitStatusImpl || readGitWorktreeStatus;
413
+ return impl(workdir);
414
+ }
415
+ }
416
+ export function summarizeText(text, maxChars = MAX_STREAM_SUMMARY_CHARS) {
417
+ const normalized = text || "";
418
+ const chars = normalized.length;
419
+ const bytes = Buffer.byteLength(normalized, "utf8");
420
+ const lines = normalized ? normalized.split(/\r\n|\r|\n/).length : 0;
421
+ if (chars <= maxChars) {
422
+ return { chars, bytes, lines, text: normalized, truncated: false };
423
+ }
424
+ const headChars = Math.min(STREAM_SUMMARY_HEAD_CHARS, maxChars);
425
+ const tailChars = Math.max(0, maxChars - headChars);
426
+ const omittedChars = chars - headChars - tailChars;
427
+ const tailText = tailChars > 0 ? normalized.slice(-tailChars) : "";
428
+ return {
429
+ chars,
430
+ bytes,
431
+ lines,
432
+ text: [
433
+ normalized.slice(0, headChars),
434
+ `[truncated ${omittedChars} chars]`,
435
+ tailText,
436
+ ].join("\n"),
437
+ truncated: true,
438
+ omittedChars,
439
+ };
260
440
  }
261
441
  export function buildTaskEnvelopePrompt(envelope) {
262
442
  const lines = [
263
443
  "[Akemon Software Peripheral Task Envelope]",
264
444
  "",
265
445
  `Task ID: ${envelope.taskId || "(unspecified)"}`,
446
+ `Akemon context session: ${envelope.contextSessionId || "(one-shot)"}`,
266
447
  `Source module: ${envelope.sourceModule}`,
267
448
  `Purpose: ${envelope.purpose}`,
268
449
  `Role scope: ${envelope.roleScope}`,
269
450
  `Memory scope: ${envelope.memoryScope}`,
270
451
  `Risk level: ${envelope.riskLevel}`,
271
452
  `Workdir: ${envelope.workdir}`,
272
- "",
273
- "Goal:",
274
- envelope.goal,
275
- "",
276
453
  ];
454
+ if (envelope.contextPacketPath) {
455
+ lines.push(`Context packet path: ${envelope.contextPacketPath}`);
456
+ }
457
+ if (envelope.workdirSafety) {
458
+ lines.push(`Base workdir: ${envelope.workdirSafety.baseWorkdir}`);
459
+ lines.push(`Requested workdir: ${envelope.workdirSafety.requestedWorkdir}`);
460
+ lines.push(`Effective workdir: ${envelope.workdirSafety.effectiveWorkdir}`);
461
+ lines.push(`Outside base workdir: ${envelope.workdirSafety.outsideBaseWorkdir ? "yes" : "no"}`);
462
+ lines.push(`Outside workdir explicitly allowed: ${envelope.workdirSafety.allowOutsideWorkdir ? "yes" : "no"}`);
463
+ }
464
+ lines.push("", "Goal:", envelope.goal, "");
465
+ if (envelope.previousTaskSummary?.trim()) {
466
+ lines.push("Previous task summary for this Akemon context session:");
467
+ lines.push(envelope.previousTaskSummary.trim());
468
+ lines.push("");
469
+ }
277
470
  if (envelope.memorySummary?.trim()) {
278
471
  lines.push("Visible Akemon memory/context:");
279
472
  lines.push(envelope.memorySummary.trim());
@@ -303,31 +496,281 @@ export function buildTaskEnvelopePrompt(envelope) {
303
496
  lines.push("- Report what changed, what you verified, and any remaining risk.");
304
497
  return lines.join("\n");
305
498
  }
499
+ export function buildContextPacketLaunchPrompt(envelope, contextPacketPath) {
500
+ return [
501
+ "[Akemon Software Peripheral Task]",
502
+ "",
503
+ `Task ID: ${envelope.taskId || "(unspecified)"}`,
504
+ `Akemon context session: ${envelope.contextSessionId || "(one-shot)"}`,
505
+ `Context packet: ${contextPacketPath}`,
506
+ `Workdir: ${envelope.workdir}`,
507
+ "",
508
+ "Goal:",
509
+ envelope.goal,
510
+ "",
511
+ "Instructions:",
512
+ "- Read the context packet first before doing repository work.",
513
+ "- Treat that file as the complete Akemon-provided context for this task.",
514
+ "- Do not read Akemon private memory outside the context packet.",
515
+ "- Work only in the stated workdir unless the packet explicitly allows otherwise.",
516
+ "- Report what changed, what you verified, and any remaining risk.",
517
+ ].join("\n");
518
+ }
306
519
  export function createOwnerTaskEnvelope(body, defaultWorkdir) {
307
520
  const goal = typeof body?.goal === "string" ? body.goal.trim() : "";
308
521
  if (!goal)
309
522
  throw new Error("Missing required string field: goal");
310
- const callerForbiddenActions = readOptionalStringArray(body.forbiddenActions, "forbiddenActions");
523
+ const callerForbiddenActions = readOptionalStringArray(body?.forbiddenActions, "forbiddenActions");
524
+ const requestedWorkdir = readOptionalString(body?.workdir, "workdir") || defaultWorkdir;
525
+ const workdirSafety = resolveWorkdirSafety(defaultWorkdir, requestedWorkdir, readOptionalBoolean(body?.allowOutsideWorkdir, "allowOutsideWorkdir") || false);
311
526
  return {
312
- taskId: readOptionalString(body.taskId, "taskId"),
527
+ taskId: readOptionalString(body?.taskId, "taskId"),
313
528
  sourceModule: "owner-http",
314
- purpose: readOptionalString(body.purpose, "purpose") || "owner software-agent task",
529
+ purpose: readOptionalString(body?.purpose, "purpose") || "owner software-agent task",
315
530
  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")
531
+ workdir: workdirSafety.effectiveWorkdir,
532
+ workdirSafety,
533
+ roleScope: readEnum(body?.roleScope, "roleScope", ROLE_SCOPES, "owner"),
534
+ memoryScope: readEnum(body?.memoryScope, "memoryScope", MEMORY_SCOPES, "owner"),
535
+ riskLevel: readEnum(body?.riskLevel, "riskLevel", RISK_LEVELS, "medium"),
536
+ allowedActions: body?.allowedActions !== undefined
537
+ ? readOptionalStringArray(body?.allowedActions, "allowedActions")
322
538
  : [...DEFAULT_OWNER_ALLOWED_ACTIONS],
323
539
  forbiddenActions: [...new Set([...DEFAULT_OWNER_FORBIDDEN_ACTIONS, ...callerForbiddenActions])],
324
- memorySummary: typeof body.memorySummary === "string" ? body.memorySummary : "",
325
- deliverable: typeof body.deliverable === "string"
540
+ memorySummary: typeof body?.memorySummary === "string" ? body.memorySummary : "",
541
+ contextSessionId: readOptionalContextSessionId(body?.contextSessionId ?? body?.sessionId, "contextSessionId"),
542
+ deliverable: typeof body?.deliverable === "string"
326
543
  ? body.deliverable
327
544
  : "Return a concise engineering summary with changes, verification, and remaining risks.",
328
- timeoutMs: readTimeoutMs(body.timeoutMs),
545
+ timeoutMs: readTimeoutMs(body?.timeoutMs),
546
+ };
547
+ }
548
+ export function resolveWorkdirSafety(baseWorkdir, requestedWorkdir, allowOutsideWorkdir = false) {
549
+ const base = resolvePath(baseWorkdir);
550
+ const requested = isAbsolute(requestedWorkdir)
551
+ ? resolvePath(requestedWorkdir)
552
+ : resolvePath(base, requestedWorkdir);
553
+ const rel = relative(base, requested);
554
+ const outsideBaseWorkdir = !!rel && (rel.startsWith("..") || isAbsolute(rel));
555
+ if (outsideBaseWorkdir && !allowOutsideWorkdir) {
556
+ throw new Error(`Invalid workdir: ${requested} is outside base workdir ${base}`);
557
+ }
558
+ return {
559
+ baseWorkdir: base,
560
+ requestedWorkdir,
561
+ effectiveWorkdir: requested,
562
+ allowOutsideWorkdir,
563
+ outsideBaseWorkdir,
329
564
  };
330
565
  }
566
+ export function readGitWorktreeStatus(workdir) {
567
+ const resolvedWorkdir = resolvePath(workdir);
568
+ try {
569
+ const rootResult = spawnSync("git", ["-C", resolvedWorkdir, "rev-parse", "--show-toplevel"], {
570
+ encoding: "utf8",
571
+ timeout: 5000,
572
+ });
573
+ if (rootResult.status !== 0) {
574
+ return {
575
+ workdir: resolvedWorkdir,
576
+ isRepo: false,
577
+ dirty: false,
578
+ changedFiles: [],
579
+ error: summarizeGitError(rootResult.stderr, rootResult.error),
580
+ };
581
+ }
582
+ const root = String(rootResult.stdout || "").trim();
583
+ const statusResult = spawnSync("git", ["-C", resolvedWorkdir, "status", "--short"], {
584
+ encoding: "utf8",
585
+ timeout: 5000,
586
+ });
587
+ if (statusResult.status !== 0) {
588
+ return {
589
+ workdir: resolvedWorkdir,
590
+ isRepo: true,
591
+ dirty: false,
592
+ changedFiles: [],
593
+ root,
594
+ error: summarizeGitError(statusResult.stderr, statusResult.error),
595
+ };
596
+ }
597
+ const changedFiles = String(statusResult.stdout || "")
598
+ .split(/\r?\n/)
599
+ .map((line) => line.trimEnd())
600
+ .filter(Boolean)
601
+ .map((line) => line.slice(3).trim())
602
+ .filter(Boolean);
603
+ return {
604
+ workdir: resolvedWorkdir,
605
+ isRepo: true,
606
+ dirty: changedFiles.length > 0,
607
+ changedFiles,
608
+ root,
609
+ };
610
+ }
611
+ catch (err) {
612
+ return {
613
+ workdir: resolvedWorkdir,
614
+ isRepo: false,
615
+ dirty: false,
616
+ changedFiles: [],
617
+ error: err.message || String(err),
618
+ };
619
+ }
620
+ }
621
+ export function listSoftwareAgentTaskRecords(taskLedgerDir, limit = 20) {
622
+ const safeLimit = normalizeTaskRecordLimit(limit);
623
+ try {
624
+ if (!existsSync(taskLedgerDir))
625
+ return [];
626
+ return readdirSync(taskLedgerDir, { withFileTypes: true })
627
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
628
+ .map((entry) => readSoftwareAgentTaskRecordFile(join(taskLedgerDir, entry.name)))
629
+ .filter((record) => !!record)
630
+ .sort(compareSoftwareAgentTaskRecords)
631
+ .slice(0, safeLimit);
632
+ }
633
+ catch {
634
+ return [];
635
+ }
636
+ }
637
+ export function readSoftwareAgentTaskRecord(taskLedgerDir, taskId) {
638
+ const file = join(taskLedgerDir, `${safeTaskFilename(taskId)}.json`);
639
+ return readSoftwareAgentTaskRecordFile(file);
640
+ }
641
+ export function pruneSoftwareAgentTaskRecords(taskLedgerDir, maxRecords = DEFAULT_TASK_LEDGER_MAX_RECORDS, preserveTaskId) {
642
+ const safeMaxRecords = normalizeTaskLedgerMaxRecords(maxRecords);
643
+ try {
644
+ if (!existsSync(taskLedgerDir))
645
+ return 0;
646
+ const records = readdirSync(taskLedgerDir, { withFileTypes: true })
647
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
648
+ .map((entry) => {
649
+ const file = join(taskLedgerDir, entry.name);
650
+ const record = readSoftwareAgentTaskRecordFile(file);
651
+ return record ? { file, record } : null;
652
+ })
653
+ .filter((entry) => !!entry)
654
+ .sort((a, b) => compareSoftwareAgentTaskRecords(a.record, b.record));
655
+ const keepTaskIds = new Set(records.slice(0, safeMaxRecords).map((entry) => entry.record.taskId));
656
+ if (preserveTaskId)
657
+ keepTaskIds.add(preserveTaskId);
658
+ let deleted = 0;
659
+ for (const entry of records) {
660
+ if (keepTaskIds.has(entry.record.taskId))
661
+ continue;
662
+ try {
663
+ unlinkSync(entry.file);
664
+ deleted++;
665
+ }
666
+ catch {
667
+ // Best effort: retention should not break task completion.
668
+ }
669
+ }
670
+ return deleted;
671
+ }
672
+ catch {
673
+ return 0;
674
+ }
675
+ }
676
+ export function buildSoftwareAgentChildEnvironment(opts = {}) {
677
+ const policy = normalizeSoftwareAgentEnvPolicy(opts.policy);
678
+ const sourceEnv = opts.sourceEnv || process.env;
679
+ if (policy === "inherit") {
680
+ return {
681
+ env: sourceEnv,
682
+ audit: { policy },
683
+ };
684
+ }
685
+ const allowlist = normalizeSoftwareAgentEnvAllowlist([
686
+ ...DEFAULT_SOFTWARE_AGENT_ENV_ALLOWLIST,
687
+ ...(opts.allowlist || []),
688
+ ]);
689
+ const env = {};
690
+ for (const key of allowlist) {
691
+ if (isForbiddenSoftwareAgentEnvKey(key))
692
+ continue;
693
+ const value = sourceEnv[key];
694
+ if (value !== undefined)
695
+ env[key] = value;
696
+ }
697
+ return {
698
+ env,
699
+ audit: {
700
+ policy,
701
+ allowedKeys: Object.keys(env).sort(),
702
+ },
703
+ };
704
+ }
705
+ function writeSoftwareAgentContextPacket(contextSessionDir, envelope) {
706
+ const sessionId = normalizeContextSessionId(envelope.contextSessionId) || envelope.taskId || randomUUID();
707
+ const sessionDir = join(contextSessionDir, sessionId);
708
+ const packetPath = join(sessionDir, CONTEXT_PACKET_FILENAME);
709
+ const statePath = join(sessionDir, CONTEXT_SESSION_STATE_FILENAME);
710
+ const previousTaskSummary = readSoftwareAgentContextSessionSummary(statePath);
711
+ const packetEnvelope = {
712
+ ...envelope,
713
+ contextSessionId: sessionId,
714
+ contextPacketPath: packetPath,
715
+ previousTaskSummary,
716
+ };
717
+ mkdirSync(sessionDir, { recursive: true });
718
+ const content = buildTaskEnvelopePrompt(packetEnvelope);
719
+ writeFileSync(packetPath, `${redactSecrets(content)}\n`);
720
+ return {
721
+ envelope: packetEnvelope,
722
+ audit: { sessionId, packetPath, statePath },
723
+ };
724
+ }
725
+ function writeSoftwareAgentContextSessionState(statePath, envelope, result, updatedAt) {
726
+ try {
727
+ const state = {
728
+ schemaVersion: 1,
729
+ sessionId: envelope.contextSessionId,
730
+ updatedAt,
731
+ lastTaskId: result.taskId,
732
+ lastGoal: envelope.goal,
733
+ lastResult: {
734
+ success: result.success,
735
+ exitCode: result.exitCode,
736
+ durationMs: result.durationMs,
737
+ outputSummary: summarizeText(result.output || "", MAX_CONTEXT_SESSION_SUMMARY_CHARS),
738
+ errorSummary: result.error ? summarizeText(result.error, MAX_CONTEXT_SESSION_SUMMARY_CHARS) : undefined,
739
+ },
740
+ };
741
+ writeFileSync(statePath, `${JSON.stringify(redactSecrets(state), null, 2)}\n`);
742
+ }
743
+ catch (err) {
744
+ console.error(`[software-agent] Failed to write context session state: ${err.message || String(err)}`);
745
+ }
746
+ }
747
+ function readSoftwareAgentContextSessionSummary(statePath) {
748
+ try {
749
+ if (!existsSync(statePath))
750
+ return undefined;
751
+ const parsed = JSON.parse(readFileSync(statePath, "utf8"));
752
+ if (!parsed || parsed.schemaVersion !== 1 || !parsed.lastTaskId || !parsed.lastResult)
753
+ return undefined;
754
+ const result = parsed.lastResult;
755
+ const status = result.success === true ? "completed" : "failed";
756
+ const lines = [
757
+ `Previous task: ${parsed.lastTaskId}`,
758
+ parsed.lastGoal ? `Previous goal: ${parsed.lastGoal}` : "",
759
+ `Status: ${status}`,
760
+ Number.isInteger(result.exitCode) ? `Exit code: ${result.exitCode}` : "Exit code: null",
761
+ Number.isInteger(result.durationMs) ? `Duration: ${result.durationMs}ms` : "",
762
+ result.outputSummary?.text ? "Previous output summary:" : "",
763
+ result.outputSummary?.text || "",
764
+ result.errorSummary?.text ? "Previous error summary:" : "",
765
+ result.errorSummary?.text || "",
766
+ ].filter(Boolean);
767
+ const summary = lines.join("\n").trim();
768
+ return summary ? summarizeText(summary, MAX_CONTEXT_SESSION_SUMMARY_CHARS).text : undefined;
769
+ }
770
+ catch {
771
+ return undefined;
772
+ }
773
+ }
331
774
  function readOptionalString(value, field) {
332
775
  if (value === undefined || value === null)
333
776
  return undefined;
@@ -336,6 +779,72 @@ function readOptionalString(value, field) {
336
779
  const trimmed = value.trim();
337
780
  return trimmed || undefined;
338
781
  }
782
+ function readOptionalContextSessionId(value, field) {
783
+ if (value === undefined || value === null || value === "")
784
+ return undefined;
785
+ if (typeof value !== "string")
786
+ throw new Error(`Invalid ${field}: expected string`);
787
+ return normalizeContextSessionId(value, field);
788
+ }
789
+ function normalizeContextSessionId(value, field = "contextSessionId") {
790
+ if (!value)
791
+ return undefined;
792
+ const trimmed = value.trim();
793
+ if (!trimmed)
794
+ return undefined;
795
+ if (trimmed.length > MAX_CONTEXT_SESSION_ID_LENGTH) {
796
+ throw new Error(`Invalid ${field}: expected at most ${MAX_CONTEXT_SESSION_ID_LENGTH} characters`);
797
+ }
798
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(trimmed)) {
799
+ throw new Error(`Invalid ${field}: expected letters, numbers, dot, underscore, or hyphen`);
800
+ }
801
+ return trimmed;
802
+ }
803
+ function normalizeSoftwareAgentTaskOptions(options) {
804
+ if (!options)
805
+ return {};
806
+ if (isAbortSignal(options)) {
807
+ return { signal: options };
808
+ }
809
+ return options;
810
+ }
811
+ function normalizeSoftwareAgentEnvPolicy(value) {
812
+ if (value === undefined || value === null || value === "")
813
+ return DEFAULT_SOFTWARE_AGENT_ENV_POLICY;
814
+ if (value === "inherit" || value === "allowlist")
815
+ return value;
816
+ throw new Error("Invalid software-agent env policy: expected inherit or allowlist");
817
+ }
818
+ function normalizeSoftwareAgentEnvAllowlist(values) {
819
+ if (!values)
820
+ return [];
821
+ const seen = new Set();
822
+ for (const value of values) {
823
+ if (typeof value !== "string") {
824
+ throw new Error("Invalid software-agent env allowlist entry: expected string");
825
+ }
826
+ const key = value.trim();
827
+ if (!key)
828
+ continue;
829
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
830
+ throw new Error(`Invalid software-agent env allowlist entry: ${key}`);
831
+ }
832
+ seen.add(key);
833
+ }
834
+ return [...seen];
835
+ }
836
+ function isForbiddenSoftwareAgentEnvKey(key) {
837
+ const upper = key.toUpperCase();
838
+ if (upper.startsWith("AKEMON_"))
839
+ return true;
840
+ const looksLikeCredential = /(?:SECRET|TOKEN|ACCESS|KEY|CREDENTIAL)/.test(upper);
841
+ return looksLikeCredential && (upper.includes("RELAY") || upper.includes("OWNER"));
842
+ }
843
+ function isAbortSignal(value) {
844
+ return !!value
845
+ && typeof value.aborted === "boolean"
846
+ && typeof value.addEventListener === "function";
847
+ }
339
848
  function readOptionalStringArray(value, field) {
340
849
  if (value === undefined || value === null)
341
850
  return [];
@@ -348,6 +857,13 @@ function readOptionalStringArray(value, field) {
348
857
  return item.trim();
349
858
  });
350
859
  }
860
+ function readOptionalBoolean(value, field) {
861
+ if (value === undefined || value === null)
862
+ return undefined;
863
+ if (typeof value !== "boolean")
864
+ throw new Error(`Invalid ${field}: expected boolean`);
865
+ return value;
866
+ }
351
867
  function readEnum(value, field, allowed, fallback) {
352
868
  if (value === undefined || value === null || value === "")
353
869
  return fallback;
@@ -367,6 +883,56 @@ function readTimeoutMs(value) {
367
883
  }
368
884
  return value;
369
885
  }
886
+ function safeTaskFilename(taskId) {
887
+ const safe = taskId.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 200);
888
+ return safe || "task";
889
+ }
890
+ function summarizeGitError(stderr, error) {
891
+ if (error)
892
+ return error.message;
893
+ const text = typeof stderr === "string" ? stderr.trim() : "";
894
+ return text || undefined;
895
+ }
896
+ function normalizeTaskRecordLimit(limit) {
897
+ if (!Number.isInteger(limit) || limit <= 0)
898
+ return 20;
899
+ return Math.min(limit, 100);
900
+ }
901
+ function normalizeTaskLedgerMaxRecords(limit) {
902
+ if (!Number.isInteger(limit) || limit <= 0)
903
+ return DEFAULT_TASK_LEDGER_MAX_RECORDS;
904
+ return Math.min(limit, 10_000);
905
+ }
906
+ function readSoftwareAgentTaskRecordFile(file) {
907
+ try {
908
+ if (!existsSync(file))
909
+ return null;
910
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
911
+ if (!isSoftwareAgentTaskRecord(parsed))
912
+ return null;
913
+ return parsed;
914
+ }
915
+ catch {
916
+ return null;
917
+ }
918
+ }
919
+ function isSoftwareAgentTaskRecord(value) {
920
+ return value
921
+ && value.schemaVersion === 1
922
+ && typeof value.taskId === "string"
923
+ && (value.status === "running" || value.status === "completed" || value.status === "failed")
924
+ && typeof value.startedAt === "string"
925
+ && typeof value.updatedAt === "string"
926
+ && value.envelope
927
+ && typeof value.envelope.goal === "string";
928
+ }
929
+ function compareSoftwareAgentTaskRecords(a, b) {
930
+ const bTime = Date.parse(b.updatedAt || b.startedAt) || 0;
931
+ const aTime = Date.parse(a.updatedAt || a.startedAt) || 0;
932
+ if (bTime !== aTime)
933
+ return bTime - aTime;
934
+ return b.taskId.localeCompare(a.taskId);
935
+ }
370
936
  function buildCodexExecCommand(opts) {
371
937
  const args = [
372
938
  "exec",