@felixdotgo/aidlc-kit 0.2.0 → 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.
Files changed (109) hide show
  1. package/DEVELOPMENT.md +13 -26
  2. package/README.md +35 -39
  3. package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
  4. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  5. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  6. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  7. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
  8. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
  9. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
  10. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  31. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
  32. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  33. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  34. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  35. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  36. package/dist/assets/ui/changes.js +83 -0
  37. package/dist/assets/ui/common.js +664 -0
  38. package/dist/assets/ui/dashboard.css +281 -59
  39. package/dist/assets/ui/dashboard.js +77 -0
  40. package/dist/assets/ui/detail.js +159 -0
  41. package/dist/assets/ui/live-log.js +332 -0
  42. package/dist/services/aidlc-coordination/package.json +1 -1
  43. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
  44. package/dist/src/cli/argv.js +10 -4
  45. package/dist/src/cli/commands.js +52 -22
  46. package/dist/src/cli/loop-launcher.d.ts +47 -0
  47. package/dist/src/cli/loop-launcher.js +322 -0
  48. package/dist/src/cli.js +3 -0
  49. package/dist/src/installer.js +4 -2
  50. package/dist/src/model/records.d.ts +4 -0
  51. package/dist/src/state/validate.js +5 -0
  52. package/dist/src/task-ui.d.ts +31 -52
  53. package/dist/src/task-ui.js +25 -1562
  54. package/dist/src/ui/assets.d.ts +14 -0
  55. package/dist/src/ui/assets.js +45 -0
  56. package/dist/src/ui/changes.d.ts +83 -0
  57. package/dist/src/ui/changes.js +498 -0
  58. package/dist/src/ui/containers.d.ts +71 -0
  59. package/dist/src/ui/containers.js +214 -0
  60. package/dist/src/ui/daemon.d.ts +75 -0
  61. package/dist/src/ui/daemon.js +232 -0
  62. package/dist/src/ui/data.d.ts +42 -0
  63. package/dist/src/ui/data.js +89 -0
  64. package/dist/src/ui/docs.d.ts +13 -0
  65. package/dist/src/ui/docs.js +39 -0
  66. package/dist/src/ui/files.d.ts +3 -0
  67. package/dist/src/ui/files.js +20 -0
  68. package/dist/src/ui/http.d.ts +14 -0
  69. package/dist/src/ui/http.js +80 -0
  70. package/dist/src/ui/live.d.ts +5 -0
  71. package/dist/src/ui/live.js +43 -0
  72. package/dist/src/ui/markdown.d.ts +2 -0
  73. package/dist/src/ui/markdown.js +127 -0
  74. package/dist/src/ui/options.d.ts +48 -0
  75. package/dist/src/ui/options.js +81 -0
  76. package/dist/src/ui/security.d.ts +34 -0
  77. package/dist/src/ui/security.js +38 -0
  78. package/dist/src/ui/server.d.ts +39 -0
  79. package/dist/src/ui/server.js +347 -0
  80. package/dist/src/ui/sse.d.ts +14 -0
  81. package/dist/src/ui/sse.js +281 -0
  82. package/dist/src/ui/views/board.d.ts +7 -0
  83. package/dist/src/ui/views/board.js +131 -0
  84. package/dist/src/ui/views/changes.d.ts +19 -0
  85. package/dist/src/ui/views/changes.js +154 -0
  86. package/dist/src/ui/views/components.d.ts +56 -0
  87. package/dist/src/ui/views/components.js +113 -0
  88. package/dist/src/ui/views/docs.d.ts +15 -0
  89. package/dist/src/ui/views/docs.js +64 -0
  90. package/dist/src/ui/views/layout.d.ts +44 -0
  91. package/dist/src/ui/views/layout.js +233 -0
  92. package/dist/src/ui/views/task.d.ts +25 -0
  93. package/dist/src/ui/views/task.js +320 -0
  94. package/dist/src/upgrade.js +5 -2
  95. package/dist/src/workflow.d.ts +4 -0
  96. package/dist/src/workflow.js +33 -1
  97. package/docs/README.md +48 -33
  98. package/docs/architecture.md +101 -38
  99. package/docs/command-reference.md +269 -93
  100. package/docs/configuration.md +62 -42
  101. package/docs/coordination-service.md +85 -36
  102. package/docs/design-decisions.md +35 -31
  103. package/docs/development.md +111 -61
  104. package/docs/loop-local-run.md +135 -402
  105. package/docs/loop-runbook.md +84 -105
  106. package/docs/operating-workflow.md +55 -42
  107. package/docs/testing-and-release.md +70 -51
  108. package/docs/troubleshooting.md +165 -69
  109. package/package.json +1 -1
@@ -1,12 +1,14 @@
1
- import { randomUUID } from "node:crypto";
2
- import { lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { lstatSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import { setTimeout as sleep } from "node:timers/promises";
5
4
  import { requireLocalAuthority } from "./authority.mjs";
6
5
  import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
6
+ import { acquireHolder, describeHolder, LEASE_TTL_MS, readHolder } from "./loop-holder.mjs";
7
7
  import { validateLimits, validateRoot, readLoopRecord } from "./loop-runtime.mjs";
8
8
  import { createIsolatedLocalSupervisor } from "./loop-supervisor-run-local.mjs";
9
9
  import { loadState, nextAction } from "./store.mjs";
10
+ import { resolveTaskRuntimeRead } from "./task-runtime.mjs";
11
+ import { migrateAllTaskRuntime } from "./task-runtime-migrate.mjs";
10
12
 
11
13
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
14
 
@@ -21,12 +23,12 @@ export function validateDaemonPollMs(value) {
21
23
  }
22
24
 
23
25
  /** A task is loop-owned when it was created through loop intake (CLI or web
24
- * dashboard: `.agents/data/tasks/<id>/loop-intake.json`) or already has a loop
26
+ * dashboard: `.agents/data/tasks/<id>/runtime/intake.json`) or already has a loop
25
27
  * run record. Tasks created by an interactive lead are not loop-owned, so the
26
28
  * daemon never runs them concurrently with that lead. */
27
29
  export function isLoopOwned(root, taskId) {
28
30
  if (!ID.test(taskId ?? "")) return false;
29
- try { if (lstatSync(join(root, ".agents", "data", "tasks", taskId, "loop-intake.json")).isFile()) return true; }
31
+ try { if (lstatSync(resolveTaskRuntimeRead(root, taskId, "intake.json")).isFile()) return true; }
30
32
  catch (error) { if (error?.code !== "ENOENT") throw error; }
31
33
  return readLoopRecord(root, taskId) !== null;
32
34
  }
@@ -34,79 +36,29 @@ export function isLoopOwned(root, taskId) {
34
36
  /** Path of the singleton daemon lock (one local daemon per project root). */
35
37
  export const daemonLockPath = (root) => join(root, ".agents", "data", "loop", "daemon.lock");
36
38
 
37
- const pidAlive = (pid) => {
38
- try { process.kill(pid, 0); return true; }
39
- catch (error) { return error?.code !== "ESRCH"; }
40
- };
41
-
42
- const parseDaemonLock = (path) => {
43
- let value;
44
- try { value = JSON.parse(readFileSync(path, "utf8")); }
45
- catch (error) { if (error?.code === "ENOENT") return null; return { corrupt: true }; }
46
- if (!value || value.schemaVersion !== 1 || !Number.isSafeInteger(value.pid) || value.pid < 1 || typeof value.token !== "string" || !value.token) return { corrupt: true };
47
- return value;
48
- };
49
-
50
- /** Read the daemon lock: `null` when absent, otherwise `{ pid, startedAt, alive }`. */
39
+ /** Read the daemon lease: `null` when absent, otherwise `{ pid, startedAt,
40
+ * alive, state, holder }` where `state` is live|stale|corrupt (see
41
+ * `loop-holder.mjs`). `pid`/`startedAt` stay top-level for the web UI. */
51
42
  export function readDaemonLock(root) {
52
- const lock = parseDaemonLock(daemonLockPath(root));
53
- if (!lock) return null;
54
- if (lock.corrupt) return { pid: null, startedAt: null, alive: false };
55
- return { pid: lock.pid, startedAt: lock.startedAt ?? null, alive: pidAlive(lock.pid) };
43
+ const entry = readHolder(daemonLockPath(root), { ttlMs: LEASE_TTL_MS });
44
+ if (entry.state === "absent") return null;
45
+ if (entry.state === "corrupt") return { pid: null, startedAt: null, alive: false, state: "corrupt", holder: null };
46
+ const { holder } = entry;
47
+ return { pid: holder.pid, startedAt: holder.startedAt ?? null, alive: entry.state === "live", state: entry.state, holder };
56
48
  }
57
49
 
58
- /** Run `action` under a short-lived exclusive guard file so two starters
59
- * cannot both reclaim the same stale lock (one would delete the other's fresh
60
- * lock). A guard older than 10s is treated as abandoned. */
61
- function withReclaimGuard(path, action) {
62
- const guard = `${path}.guard`;
63
- const deadline = Date.now() + 5000;
64
- for (;;) {
65
- try { writeFileSync(guard, String(process.pid), { flag: "wx", mode: 0o600 }); break; }
66
- catch (error) {
67
- if (error?.code !== "EEXIST") throw error;
68
- try { if (Date.now() - lstatSync(guard).mtimeMs > 10000) { unlinkSync(guard); continue; } }
69
- catch (statError) { if (statError?.code === "ENOENT") continue; throw statError; }
70
- if (Date.now() >= deadline) throw new Error("Timed out waiting for the loop daemon lock guard");
71
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
72
- }
73
- }
74
- try { return action(); }
75
- finally { try { unlinkSync(guard); } catch (error) { if (error?.code !== "ENOENT") throw error; } }
76
- }
77
-
78
- /** Acquire the singleton daemon lock. A lock owned by a dead pid (or a corrupt
79
- * lock) is reclaimed under a guard after re-checking it is still the same stale
80
- * lock; a live owner fails closed. Liveness is a pid probe, like the loop and
81
- * supervisor leases, so a recycled pid can keep a stale lock "alive" until the
82
- * file is removed by hand. Returns a release function. */
83
- export function acquireDaemonLock(root) {
50
+ /** Acquire the singleton daemon lease (`loop/daemon.lock`, kind lease, scope
51
+ * daemon, 60s TTL renewed every 15s). A stale holder (expired, or its pid is
52
+ * gone on this host) or an old corrupt file is reclaimed under the holder
53
+ * guard; a live owner or a recent corrupt file fails closed with the holder
54
+ * details and the `leases` command. Returns a release function. */
55
+ export function acquireDaemonLock(root, { onWarning = () => {}, onLost } = {}) {
84
56
  const path = daemonLockPath(root);
85
- mkdirSync(join(root, ".agents", "data", "loop"), { recursive: true });
86
- const token = randomUUID();
87
- for (let attempt = 0; attempt < 5; attempt += 1) {
88
- try {
89
- writeFileSync(path, `${JSON.stringify({ schemaVersion: 1, pid: process.pid, token, startedAt: new Date().toISOString() })}\n`, { flag: "wx", mode: 0o600 });
90
- return () => {
91
- const current = parseDaemonLock(path);
92
- if (current && !current.corrupt && current.token === token) { try { unlinkSync(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } }
93
- };
94
- } catch (error) {
95
- if (error?.code !== "EEXIST") throw error;
96
- let stat;
97
- try { stat = lstatSync(path); } catch (statError) { if (statError?.code === "ENOENT") continue; throw statError; }
98
- if (stat.isSymbolicLink()) throw new Error(`Daemon lock is a symlink: ${path}`);
99
- const observed = parseDaemonLock(path);
100
- if (!observed) continue;
101
- if (!observed.corrupt && pidAlive(observed.pid)) throw new Error(`Loop daemon already running (pid ${observed.pid})`);
102
- withReclaimGuard(path, () => {
103
- const current = parseDaemonLock(path);
104
- const same = current && (observed.corrupt ? current.corrupt : !current.corrupt && current.token === observed.token);
105
- if (same) { try { unlinkSync(path); } catch (unlinkError) { if (unlinkError?.code !== "ENOENT") throw unlinkError; } }
106
- });
107
- }
108
- }
109
- throw new Error("Could not acquire the loop daemon lock");
57
+ const handle = acquireHolder({
58
+ path, kind: "lease", scope: "daemon", purpose: "loop daemon (one per project root)", onWarning, onLost,
59
+ busyMessage: (entry) => `Loop daemon already running (pid ${entry.holder.pid}): ${describeHolder(entry)}`,
60
+ });
61
+ return () => handle.release();
110
62
  }
111
63
 
112
64
  /** Enumerate actionable, loop-owned tasks from canonical state in a
@@ -122,6 +74,15 @@ export function actionableTasks(state, root, { isOwned = (id) => isLoopOwned(roo
122
74
  .sort((a, b) => String(a.updatedAt ?? "").localeCompare(String(b.updatedAt ?? "")) || a.id.localeCompare(b.id));
123
75
  }
124
76
 
77
+ /** Move pre-runtime per-task files under `runtime/` once at daemon start; one
78
+ * diagnostic summarises the result, nothing when there was nothing to move. */
79
+ function migrateTaskRuntimeOnStart(root, onEvent) {
80
+ let result;
81
+ try { result = migrateAllTaskRuntime(root); }
82
+ catch (error) { onEvent({ type: "diagnostic", message: `Task runtime migration failed: ${error.message}` }); return; }
83
+ if (result.moved || result.skipped) onEvent({ type: "diagnostic", message: `Task runtime migration: moved ${result.moved} file(s) under .agents/data/tasks/<id>/runtime/, skipped ${result.skipped} (run loop-entry.mjs migrate-data --dry-run --json for details)` });
84
+ }
85
+
125
86
  export const DAEMON_BACKOFF_MAX_MS = 600000;
126
87
 
127
88
  /** Exponential retry delay for a task whose turn keeps throwing: the poll
@@ -162,10 +123,12 @@ export async function runLoopDaemon({ root: rootInput, authority, pollMs = DEFAU
162
123
  const root = validateRoot(rootInput);
163
124
  if (!authority || typeof authority.listActionable !== "function" || typeof authority.runTask !== "function") throw new Error("Loop daemon requires a loop authority");
164
125
  const poll = validateDaemonPollMs(pollMs);
165
- const releaseLock = acquireDaemonLock(root);
126
+ const lost = new AbortController();
127
+ const releaseLock = acquireDaemonLock(root, { onWarning: onEvent, onLost: (reason) => { onEvent({ type: "daemon_lease_lost", message: `Daemon lease lost; stopping after the current turn: ${reason}` }); lost.abort(); } });
166
128
  try {
167
129
  onEvent({ type: "daemon_start", authority: authority.mode ?? "unknown", pid: process.pid });
168
- return await daemonLoop({ root, poll, signal, cancelSignal, onEvent, now, listActionable: () => authority.listActionable(), turn: ({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) => authority.runTask({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) });
130
+ migrateTaskRuntimeOnStart(root, onEvent);
131
+ return await daemonLoop({ root, poll, signal: signal ? AbortSignal.any([signal, lost.signal]) : lost.signal, cancelSignal, onEvent, now, listActionable: () => authority.listActionable(), turn: ({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) => authority.runTask({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) });
169
132
  } finally { releaseLock(); }
170
133
  }
171
134
 
@@ -178,9 +141,11 @@ export async function runLocalDaemon({ root: rootInput, agent, workers = {}, wor
178
141
  const poll = validateDaemonPollMs(pollMs);
179
142
  // Fail fast on a bad config.loop.limits instead of failing every task turn.
180
143
  if (limits !== undefined) validateLimits(limits);
181
- const releaseLock = acquireDaemonLock(root);
144
+ const lost = new AbortController();
145
+ const releaseLock = acquireDaemonLock(root, { onWarning: onEvent, onLost: (reason) => { onEvent({ type: "daemon_lease_lost", message: `Daemon lease lost; stopping after the current turn: ${reason}` }); lost.abort(); } });
182
146
  try {
183
- return await daemonLoop({ root, agent, workers, workerImages, limits, poll, signal, cancelSignal, onEvent, now, turn: runTaskTurn });
147
+ migrateTaskRuntimeOnStart(root, onEvent);
148
+ return await daemonLoop({ root, agent, workers, workerImages, limits, poll, signal: signal ? AbortSignal.any([signal, lost.signal]) : lost.signal, cancelSignal, onEvent, now, turn: runTaskTurn });
184
149
  } finally { releaseLock(); }
185
150
  }
186
151
 
@@ -190,11 +155,13 @@ const RETRY_STATUSES = new Set(["blocked", "reconcile"]);
190
155
  /** Poll/run loop. Exported for tests (inject `turn`, `now`, and optionally `listActionable`). */
191
156
  export async function daemonLoop({ root, agent, workers, workerImages, limits, poll, signal, cancelSignal, onEvent, now = Date.now, turn = runTaskTurn, isOwned, listActionable }) {
192
157
  const backoff = new Map();
158
+ let lastPollKey = null; // null forces the next successful poll to be emitted
193
159
  for (;;) {
194
160
  if (signal?.aborted) return { status: "stopped" };
195
161
  let actionable;
196
162
  try { actionable = listActionable ? await listActionable() : actionableTasks(loadState(root), root, isOwned ? { isOwned } : {}); }
197
163
  catch (error) {
164
+ lastPollKey = null;
198
165
  // An unreachable authority (e.g. the fleet service) must not stop the daemon.
199
166
  onEvent({ type: "daemon_poll_error", message: error instanceof Error ? error.message : String(error) });
200
167
  try { await sleep(poll, undefined, { signal }); } catch (sleepError) { if (sleepError?.name === "AbortError") return { status: "stopped" }; throw sleepError; }
@@ -203,7 +170,14 @@ export async function daemonLoop({ root, agent, workers, workerImages, limits, p
203
170
  // Forget backoff for tasks that are no longer actionable (e.g. a human
204
171
  // paused or approved them), so they start fresh when they come back.
205
172
  for (const id of [...backoff.keys()]) if (!actionable.some((task) => task.id === id)) backoff.delete(id);
206
- onEvent({ type: "daemon_poll", actionable: actionable.map((task) => task.id), ...(backoff.size ? { backingOff: [...backoff.keys()] } : {}) });
173
+ // Emit a poll only when it carries news: the first poll, the first poll
174
+ // after a poll error (that clears the dashboard's poll error), or a change
175
+ // in the actionable or backing-off sets (that clears stale turn errors).
176
+ const pollKey = JSON.stringify([actionable.map((task) => task.id).sort(), [...backoff.keys()].sort()]);
177
+ if (pollKey !== lastPollKey) {
178
+ onEvent({ type: "daemon_poll", actionable: actionable.map((task) => task.id), ...(backoff.size ? { backingOff: [...backoff.keys()] } : {}) });
179
+ lastPollKey = pollKey;
180
+ }
207
181
  for (const task of actionable) {
208
182
  if (signal?.aborted) return { status: "stopped" };
209
183
  const delay = backoff.get(task.id);
@@ -11,20 +11,67 @@ const MAX_STDERR_BYTES = 4096;
11
11
  const MAX_RESULT_CHARS = 8192;
12
12
  const MAX_STREAM_CHARS = 4096;
13
13
  const MAX_DIAGNOSTICS = 16;
14
+ /** Upper bound for the human-readable `summary` carried by live-log events. */
15
+ export const MAX_SUMMARY_CHARS = 240;
14
16
 
15
17
  /** One runtime enumeration for every loop provider. Host gates import this
16
18
  * list; container paths reject providers that need file-based credentials. */
17
19
  export const LOOP_PROVIDERS = Object.freeze(["codex", "claude", "opencode"]);
18
20
  export const LOOP_PROVIDER_LIST = `${LOOP_PROVIDERS.slice(0, -1).join(", ")}, or ${LOOP_PROVIDERS[LOOP_PROVIDERS.length - 1]}`;
19
21
  export const isLoopProvider = (value) => typeof value === "string" && LOOP_PROVIDERS.includes(value);
20
- const OPENCODE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*(?:#[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
22
+ export const OPENCODE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*(?:#[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
21
23
 
22
24
  const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
25
+ // Best-effort secret redaction for diagnostics and live-log summaries (commands
26
+ // now reach the event log). Over-redaction is acceptable; leaks are not.
27
+ const SECRET_NAME = String.raw`[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH)[A-Z0-9_.-]*`;
23
28
  const redact = (value) => String(value ?? "")
24
- .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY))=([^\s]+)/gi, "$1=[REDACTED]")
29
+ // HTTP auth headers first, so the scheme word is not mistaken for the secret.
30
+ .replace(/\b(Authorization\s*:\s*(?:Basic|Bearer|Token|Digest)?\s*)[^\s'"]+/gi, "$1[REDACTED]")
31
+ .replace(/\b(x-api-key|api-key|x-auth-token|private-token|x-access-token)(\s*:\s*)[^\s'"]+/gi, "$1$2[REDACTED]")
32
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, "$1 [REDACTED]")
33
+ // NAME=value / NAME: value for secret-looking names (env assignments, config, JSON-ish).
34
+ .replace(new RegExp(`\\b(${SECRET_NAME})(\\s*[=:]\\s*)("[^"]*"|'[^']*'|[^\\s'"]+)`, "gi"), "$1$2[REDACTED]")
35
+ // --password value / --token=value style flags.
36
+ .replace(/((?<![\w-])--?(?:password|passwd|pass|pwd|token|secret|api-?key|access-?key|private-?key|auth|credentials?)(?:[= ]+))("[^"]*"|'[^']*'|[^\s'"]+)/gi, "$1[REDACTED]")
37
+ // Credentials embedded in URLs (scheme://user:pass@host).
38
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1[REDACTED]@")
39
+ // curl -u user:pass / --user user:pass.
40
+ .replace(/(\s(?:-u|--user)(?:\s+|=))[^\s:]+:[^\s]+/g, "$1[REDACTED]")
41
+ // mysql -pSECRET (password glued to -p).
42
+ .replace(/(\bmysql(?:dump|admin)?\b[^\n]*?\s-p)(\S+)/gi, "$1[REDACTED]")
43
+ // Well-known token formats.
25
44
  .replace(/\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]+\b/g, "[REDACTED]")
45
+ .replace(/\b(?:sk|gh[pousr]|github_pat|npm|pypi|glpat|hf|xox[abposr])[_-][A-Za-z0-9_-]{8,}/g, "[REDACTED]")
46
+ .replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "[REDACTED]")
47
+ .replace(/\bAIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]")
48
+ .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g, "[REDACTED]")
49
+ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, "[REDACTED]")
26
50
  .replace(/\s+/g, " ").trim();
27
51
  const diagnostic = (value) => redact(value).slice(0, 512);
52
+ /** Human-readable, secret-redacted, single-line event summary (at most
53
+ * MAX_SUMMARY_CHARS). Returns undefined for empty input so callers can omit the
54
+ * field. Shared by every live-log event producer (lead drivers and workers). */
55
+ const SUMMARY_SCAN_CHARS = 4096;
56
+ export function summarize(value) {
57
+ if (value === undefined || value === null) return undefined;
58
+ // Only the first MAX_SUMMARY_CHARS survive, so redacting a bounded prefix leaks
59
+ // nothing and keeps the regexes linear-time on huge inputs (e.g. base64 blobs).
60
+ const raw = typeof value === "string" ? value : JSON.stringify(value) ?? "";
61
+ const text = redact(raw.slice(0, SUMMARY_SCAN_CHARS));
62
+ if (!text) return undefined;
63
+ return text.length > MAX_SUMMARY_CHARS ? `${text.slice(0, MAX_SUMMARY_CHARS - 1)}…` : text;
64
+ }
65
+ const SUMMARY_INPUT_FIELDS = ["command", "file_path", "filePath", "path", "notebook_path"];
66
+ /** Summary of a tool invocation from its structured input: the shell command or
67
+ * the touched file when present, otherwise the tool name and its input keys
68
+ * (never the values of unknown inputs). */
69
+ export function toolSummary(name, input) {
70
+ const fields = record(input);
71
+ for (const key of SUMMARY_INPUT_FIELDS) if (typeof fields?.[key] === "string" && fields[key].trim()) return summarize(fields[key]);
72
+ const keys = fields ? Object.keys(fields).slice(0, 8) : [];
73
+ return summarize(keys.length ? `${name ?? "tool"} ${JSON.stringify(keys)}` : name);
74
+ }
28
75
  const isId = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\x00-\x1f]/.test(value);
29
76
  const emit = async (callback, event) => { if (callback) await callback(event); };
30
77
 
@@ -53,7 +100,15 @@ registerAgentAdapter("codex", {
53
100
  if (event.type === "turn.failed" || event.type === "error") throw new Error(`codex reported ${event.type}: ${diagnostic(JSON.stringify(event.error ?? event.message ?? "unknown"))}`);
54
101
  const item = record(event.item);
55
102
  if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") { context.result(item.text); await context.stream(item.text); }
56
- if (event.type === "item.completed" && item?.type === "command_execution") await context.commandEvent(item.exit_code, item.status);
103
+ if (event.type === "item.completed" && item?.type === "command_execution") {
104
+ const command = typeof item.command === "string" ? item.command : undefined;
105
+ await context.tool("shell", summarize(command));
106
+ await context.commandEvent(item.exit_code, item.status, command);
107
+ }
108
+ if (event.type === "item.completed" && item?.type === "file_change" && Array.isArray(item.changes)) {
109
+ const paths = item.changes.map((change) => record(change)?.path).filter((path) => typeof path === "string" && path);
110
+ await context.tool("file_change", summarize(paths.join(", ")));
111
+ }
57
112
  }
58
113
  });
59
114
  registerAgentAdapter("claude", {
@@ -70,10 +125,15 @@ registerAgentAdapter("claude", {
70
125
  const message = record(event.message);
71
126
  if (Array.isArray(message?.content)) for (const block of message.content) {
72
127
  if (!record(block)) continue;
73
- if (block.type === "tool_use" && typeof block.id === "string" && block.name === "Bash") context.toolUses.set(block.id, true);
128
+ if (block.type === "tool_use" && typeof block.name === "string" && block.name) {
129
+ await context.tool(block.name, toolSummary(block.name, block.input));
130
+ // Only shell commands map to lifecycle_command; remember the command for the result.
131
+ if (typeof block.id === "string" && block.name === "Bash") context.toolUses.set(block.id, typeof record(block.input)?.command === "string" ? block.input.command : "");
132
+ }
74
133
  if (block.type === "tool_result" && typeof block.tool_use_id === "string" && context.toolUses.has(block.tool_use_id)) {
134
+ const command = context.toolUses.get(block.tool_use_id);
75
135
  context.toolUses.delete(block.tool_use_id);
76
- await context.commandEvent(block.is_error === true ? 1 : 0, block.is_error === true ? "failed" : "completed");
136
+ await context.commandEvent(block.is_error === true ? 1 : 0, block.is_error === true ? "failed" : "completed", command);
77
137
  }
78
138
  }
79
139
  }
@@ -95,11 +155,12 @@ registerAgentAdapter("opencode", {
95
155
  }
96
156
  const part = record(event.part);
97
157
  if (event.type === "text" && typeof part?.text === "string") { context.result(part.text); await context.stream(part.text); }
98
- if (event.type === "tool_use" && typeof part?.tool === "string") await context.tool(part.tool);
158
+ const state = record(part?.state);
159
+ if (event.type === "tool_use" && typeof part?.tool === "string") await context.tool(part.tool, toolSummary(part.tool, state?.input));
99
160
  if (event.type === "tool_use" && part?.tool === "shell") {
100
- const state = record(part.state);
101
161
  const metadata = record(record(state?.metadata)?.metadata);
102
- await context.commandEvent(metadata?.exit, state?.status === "error" ? "failed" : state?.status);
162
+ const command = record(state?.input)?.command;
163
+ await context.commandEvent(metadata?.exit, state?.status === "error" ? "failed" : state?.status, typeof command === "string" ? command : undefined);
103
164
  }
104
165
  if (event.type === "step_finish" && part?.reason === "stop") context.complete();
105
166
  }
@@ -111,6 +172,16 @@ registerAgentAdapter("opencode", {
111
172
  * a checkpoint is durable. Transport and protocol failures reject with a bounded,
112
173
  * redacted error message.
113
174
  */
175
+ /** Host turn environment: drops the worker credentials `loop-env.mjs` added from the env file
176
+ * or provider config (listed in AIDLC_LEAD_ENV_EXCLUDE); shell-exported values stay. */
177
+ export function leadEnv(env) {
178
+ const exclude = String(env.AIDLC_LEAD_ENV_EXCLUDE ?? "").split(",").map((key) => key.trim()).filter(Boolean);
179
+ if (!exclude.length && env.AIDLC_LEAD_ENV_EXCLUDE === undefined) return env;
180
+ const copy = { ...env };
181
+ for (const key of [...exclude, "AIDLC_LEAD_ENV_EXCLUDE"]) delete copy[key];
182
+ return copy;
183
+ }
184
+
114
185
  export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs, onSessionId, onEvent, signal, env, spawnProcess = spawn } = {}) {
115
186
  // --- Validate inputs and resolve the adapter -------------------------------
116
187
  const adapter = adapters.get(agent);
@@ -129,7 +200,7 @@ export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs,
129
200
  if (typeof spawnProcess !== "function") throw new Error("spawnProcess must be a function");
130
201
  const input = adapter.input?.(prompt);
131
202
  if (input !== undefined && typeof input !== "string") throw new Error("agent adapter input must be a string");
132
- const child = spawnProcess(adapter.command, args, { cwd: root, shell: false, env: { ...(env ?? process.env), PWD: root }, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
203
+ const child = spawnProcess(adapter.command, args, { cwd: root, shell: false, env: { ...leadEnv(env ?? process.env), PWD: root }, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
133
204
  if (input !== undefined) {
134
205
  if (!child.stdin) throw new Error("agent transport lacks stdin");
135
206
  child.stdin.on("error", () => {});
@@ -168,24 +239,27 @@ export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs,
168
239
  await emit(onSessionId, value);
169
240
  }
170
241
  };
171
- const commandEvent = async (exitCode, status) => {
242
+ const commandEvent = async (exitCode, status, command) => {
172
243
  const safeExitCode = Number.isSafeInteger(exitCode) ? exitCode : null;
173
244
  const safeStatus = ["completed", "failed", "in_progress"].includes(status) ? status : "unknown";
174
- await emit(onEvent, { type: "lifecycle_command", exitCode: safeExitCode, status: safeStatus });
245
+ // The command string is a redacted summary only; command output never leaves the adapter.
246
+ const summary = summarize(command);
247
+ await emit(onEvent, { type: "lifecycle_command", exitCode: safeExitCode, status: safeStatus, ...(summary ? { summary } : {}) });
175
248
  if ((safeExitCode !== null && safeExitCode !== 0) || safeStatus === "failed") addDiagnostic(`commandFailed exitCode=${String(safeExitCode ?? "unknown")} status=${safeStatus}`);
176
249
  };
177
250
  const stream = async (text) => {
178
251
  if (typeof text === "string" && text.trim()) await emit(onEvent, { type: "agent_text", text: text.slice(0, MAX_STREAM_CHARS) });
179
252
  };
180
- const tool = async (name) => {
181
- if (typeof name === "string" && name) await emit(onEvent, { type: "agent_tool", tool: name });
253
+ const tool = async (name, summary) => {
254
+ if (typeof name !== "string" || !name) return;
255
+ const safeSummary = typeof summary === "string" ? summarize(summary) : undefined;
256
+ await emit(onEvent, { type: "agent_tool", tool: name, ...(safeSummary ? { summary: safeSummary } : {}) });
182
257
  };
183
258
  const parseEvent = async (line) => {
184
259
  let event;
185
260
  try { event = JSON.parse(line); } catch { throw new Error(`${agent} emitted malformed JSONL`); }
186
261
  if (!record(event) || typeof event.type !== "string") throw new Error(`${agent} emitted malformed event`);
187
262
  await adapter.parse(event, { captureId, commandEvent, toolUses, stream, tool, complete: () => { completed = true; resultSeen = true; }, result: (value) => { resultText = value.slice(0, MAX_RESULT_CHARS); } });
188
- await emit(onEvent, { type: "agent_event", eventType: event.type });
189
263
  };
190
264
  const queueLine = (line) => {
191
265
  processing = processing.then(() => parseEvent(line)).catch((error) => fail(error.message));