alp-code 0.9.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 (204) hide show
  1. package/CHANGELOG.md +770 -0
  2. package/LICENSE +21 -0
  3. package/README.md +295 -0
  4. package/alp.config.yaml +5 -0
  5. package/dist/src/agents/agent-definition.js +28 -0
  6. package/dist/src/agents/capability-catalog.js +33 -0
  7. package/dist/src/agents/compaction.js +36 -0
  8. package/dist/src/agents/errors.js +12 -0
  9. package/dist/src/agents/librarian.js +38 -0
  10. package/dist/src/agents/main.js +37 -0
  11. package/dist/src/agents/memory-grant.js +29 -0
  12. package/dist/src/agents/model-context.js +70 -0
  13. package/dist/src/agents/modes.js +134 -0
  14. package/dist/src/agents/oracle.js +36 -0
  15. package/dist/src/agents/read-thread.js +38 -0
  16. package/dist/src/agents/registry.js +238 -0
  17. package/dist/src/agents/render-identity.js +38 -0
  18. package/dist/src/agents/review.js +37 -0
  19. package/dist/src/agents/search.js +37 -0
  20. package/dist/src/agents/shared/house-rules.js +33 -0
  21. package/dist/src/agents/shared/principal.js +18 -0
  22. package/dist/src/agents/shared/voice.js +29 -0
  23. package/dist/src/agents/titling.js +32 -0
  24. package/dist/src/agents/types.js +15 -0
  25. package/dist/src/backend/execution-backend.js +2 -0
  26. package/dist/src/backend/local-execution-store.js +144 -0
  27. package/dist/src/backend/local-process-backend.js +533 -0
  28. package/dist/src/backend/local-supervisor.js +104 -0
  29. package/dist/src/cli/alp.js +380 -0
  30. package/dist/src/cli/commands/context.js +203 -0
  31. package/dist/src/cli/commands/delegate.js +136 -0
  32. package/dist/src/cli/commands/identity-sync.js +31 -0
  33. package/dist/src/cli/commands/init.js +184 -0
  34. package/dist/src/cli/commands/mode.js +22 -0
  35. package/dist/src/cli/commands/principal.js +114 -0
  36. package/dist/src/cli/commands/run-main.js +90 -0
  37. package/dist/src/cli/commands/runtime.js +21 -0
  38. package/dist/src/cli/mode-preference-store.js +62 -0
  39. package/dist/src/cli/mode-selector.js +178 -0
  40. package/dist/src/cli/update-check.js +77 -0
  41. package/dist/src/context/checkpoint.js +134 -0
  42. package/dist/src/context/compact-journal.js +153 -0
  43. package/dist/src/context/compact-payload.js +121 -0
  44. package/dist/src/context/continuity.js +70 -0
  45. package/dist/src/context/types.js +2 -0
  46. package/dist/src/delegation/backend-registry.js +40 -0
  47. package/dist/src/delegation/delegation-service.js +300 -0
  48. package/dist/src/delegation/types.js +12 -0
  49. package/dist/src/execution/execution-policy.js +96 -0
  50. package/dist/src/execution/execution-service.js +115 -0
  51. package/dist/src/execution/execution-store.js +78 -0
  52. package/dist/src/execution/identity-capsule.js +65 -0
  53. package/dist/src/execution/types.js +12 -0
  54. package/dist/src/hooks/execution-bridge.js +84 -0
  55. package/dist/src/index.js +4 -0
  56. package/dist/src/memory/adapters/markdown-file-store.js +257 -0
  57. package/dist/src/memory/adapters/memory-api-client.js +2 -0
  58. package/dist/src/memory/adapters/memory-path-mapper.js +76 -0
  59. package/dist/src/memory/adapters/remote-api-store.js +25 -0
  60. package/dist/src/memory/context-ranker.js +21 -0
  61. package/dist/src/memory/errors.js +58 -0
  62. package/dist/src/memory/memory-service.js +149 -0
  63. package/dist/src/memory/memory-store.js +2 -0
  64. package/dist/src/memory/types.js +2 -0
  65. package/dist/src/policy/capability-policy.js +29 -0
  66. package/dist/src/policy/delegation-policy.js +25 -0
  67. package/dist/src/policy/errors.js +10 -0
  68. package/dist/src/policy/invariants.js +31 -0
  69. package/dist/src/policy/memory-policy.js +22 -0
  70. package/dist/src/policy/policy-engine.js +85 -0
  71. package/dist/src/policy/types.js +8 -0
  72. package/dist/src/policy/workspace-policy.js +77 -0
  73. package/dist/src/principal/principal-profile-store.js +89 -0
  74. package/dist/src/runtime/adapter-files.js +147 -0
  75. package/dist/src/runtime/claude-adapter.js +177 -0
  76. package/dist/src/runtime/codex-adapter.js +169 -0
  77. package/dist/src/runtime/permission-rules.js +156 -0
  78. package/dist/src/runtime/render-session-context.js +124 -0
  79. package/dist/src/runtime/render-task-input.js +33 -0
  80. package/dist/src/runtime/runtime-adapter.js +2 -0
  81. package/dist/src/runtime/runtime-preference-store.js +66 -0
  82. package/dist/src/runtime/runtime-selector.js +178 -0
  83. package/dist/src/runtime/types.js +2 -0
  84. package/dist/src/runtime/windows-shim.js +57 -0
  85. package/dist/src/state-paths.js +49 -0
  86. package/dist/src/workflow/output-validator.js +27 -0
  87. package/dist/src/workflow/repair-policy.js +8 -0
  88. package/dist/src/workflow/types.js +22 -0
  89. package/dist/src/workflow/workflow-runner.js +81 -0
  90. package/hooks/compact-record.cjs +109 -0
  91. package/hooks/session-boot.cjs +112 -0
  92. package/hooks/session-end.cjs +34 -0
  93. package/package.json +48 -0
  94. package/scaffold/memory/INDEX.md +27 -0
  95. package/scaffold/memory/README.md +76 -0
  96. package/scaffold/memory/projects/INDEX.md +22 -0
  97. package/scaffold/memory/projects/PROTOCOL.md +128 -0
  98. package/scaffold/memory/projects/_template/PROJECT.md +45 -0
  99. package/scripts/alp.cjs +126 -0
  100. package/scripts/alp.ps1 +4 -0
  101. package/scripts/alp.sh +3 -0
  102. package/scripts/bootstrap.cjs +144 -0
  103. package/scripts/checkout-release.cjs +30 -0
  104. package/scripts/delegate.cjs +19 -0
  105. package/scripts/doctor.cjs +158 -0
  106. package/scripts/doctor.sh +3 -0
  107. package/scripts/ensure-state.cjs +22 -0
  108. package/scripts/lib/cli-link.cjs +375 -0
  109. package/scripts/lib/codex-role.cjs +18 -0
  110. package/scripts/lib/delegation/command-runner.cjs +108 -0
  111. package/scripts/lib/delegation/config.cjs +81 -0
  112. package/scripts/lib/install-paths.cjs +154 -0
  113. package/scripts/lib/release-manifest.cjs +42 -0
  114. package/scripts/lib/semver-lite.cjs +20 -0
  115. package/scripts/lib/state.cjs +274 -0
  116. package/scripts/lib/uninstall.cjs +252 -0
  117. package/scripts/lib/update-check-worker.cjs +21 -0
  118. package/scripts/lib/update.cjs +395 -0
  119. package/scripts/run-role.cjs +42 -0
  120. package/scripts/run-role.ps1 +4 -0
  121. package/scripts/run-role.sh +3 -0
  122. package/scripts/sync-project-index.sh +167 -0
  123. package/skills/agent-memory/SKILL.md +109 -0
  124. package/skills/alp-debug/SKILL.md +90 -0
  125. package/skills/alp-debug/references/defense-in-depth.md +118 -0
  126. package/skills/alp-debug/references/investigation-methodology.md +106 -0
  127. package/skills/alp-debug/references/log-and-ci-analysis.md +96 -0
  128. package/skills/alp-debug/references/performance-diagnostics.md +112 -0
  129. package/skills/alp-debug/references/reporting-standards.md +120 -0
  130. package/skills/alp-debug/references/root-cause-tracing.md +134 -0
  131. package/skills/alp-debug/references/systematic-debugging.md +93 -0
  132. package/skills/alp-debug/references/verification.md +86 -0
  133. package/skills/alp-debug/scripts/find-polluter.sh +63 -0
  134. package/skills/alp-debug/scripts/find-polluter.test.md +102 -0
  135. package/skills/alp-plan/SKILL.md +128 -0
  136. package/skills/alp-plan/references/archive-workflow.md +77 -0
  137. package/skills/alp-plan/references/codebase-understanding.md +55 -0
  138. package/skills/alp-plan/references/output-standards.md +96 -0
  139. package/skills/alp-plan/references/plan-organization.md +129 -0
  140. package/skills/alp-plan/references/red-team-personas.md +76 -0
  141. package/skills/alp-plan/references/red-team-workflow.md +81 -0
  142. package/skills/alp-plan/references/research-phase.md +57 -0
  143. package/skills/alp-plan/references/scope-challenge.md +82 -0
  144. package/skills/alp-plan/references/solution-design.md +76 -0
  145. package/skills/alp-plan/references/validate-question-framework.md +89 -0
  146. package/skills/alp-plan/references/validate-workflow.md +83 -0
  147. package/skills/alp-predict/SKILL.md +98 -0
  148. package/skills/alp-scenario/SKILL.md +86 -0
  149. package/skills/code-review/SKILL.md +111 -0
  150. package/skills/code-review/references/code-review-reception.md +114 -0
  151. package/skills/code-review/references/edge-case-scouting.md +78 -0
  152. package/skills/code-review/references/verification-before-completion.md +117 -0
  153. package/skills/delegation/SKILL.md +46 -0
  154. package/skills/docs-seeker/.env.example +15 -0
  155. package/skills/docs-seeker/SKILL.md +87 -0
  156. package/skills/docs-seeker/package.json +25 -0
  157. package/skills/docs-seeker/references/advanced.md +82 -0
  158. package/skills/docs-seeker/references/context7-patterns.md +68 -0
  159. package/skills/docs-seeker/references/errors.md +72 -0
  160. package/skills/docs-seeker/scripts/analyze-llms-txt.js +211 -0
  161. package/skills/docs-seeker/scripts/detect-topic.js +172 -0
  162. package/skills/docs-seeker/scripts/fetch-docs.js +213 -0
  163. package/skills/docs-seeker/scripts/tests/run-tests.js +72 -0
  164. package/skills/docs-seeker/scripts/tests/test-analyze-llms.js +119 -0
  165. package/skills/docs-seeker/scripts/tests/test-detect-topic.js +112 -0
  166. package/skills/docs-seeker/scripts/tests/test-fetch-docs.js +84 -0
  167. package/skills/docs-seeker/scripts/utils/env-loader.js +94 -0
  168. package/skills/docs-seeker/workflows/library-search.md +73 -0
  169. package/skills/docs-seeker/workflows/repo-analysis.md +90 -0
  170. package/skills/docs-seeker/workflows/topic-search.md +69 -0
  171. package/skills/git/SKILL.md +121 -0
  172. package/skills/git/references/branch-management.md +90 -0
  173. package/skills/git/references/commit-standards.md +82 -0
  174. package/skills/git/references/gh-cli-guide.md +132 -0
  175. package/skills/git/references/safety-protocols.md +86 -0
  176. package/skills/git/references/workflow-commit.md +89 -0
  177. package/skills/git/references/workflow-merge.md +63 -0
  178. package/skills/git/references/workflow-pr.md +70 -0
  179. package/skills/git/references/workflow-push.md +62 -0
  180. package/skills/gkg/SKILL.md +87 -0
  181. package/skills/gkg/references/cli-commands.md +92 -0
  182. package/skills/gkg/references/http-api.md +99 -0
  183. package/skills/gkg/references/language-support.md +54 -0
  184. package/skills/problem-solving/SKILL.md +86 -0
  185. package/skills/problem-solving/references/attribution.md +48 -0
  186. package/skills/problem-solving/references/collision-zone-thinking.md +71 -0
  187. package/skills/problem-solving/references/inversion-exercise.md +88 -0
  188. package/skills/problem-solving/references/meta-pattern-recognition.md +80 -0
  189. package/skills/problem-solving/references/scale-game.md +82 -0
  190. package/skills/problem-solving/references/simplification-cascades.md +83 -0
  191. package/skills/problem-solving/references/when-stuck.md +76 -0
  192. package/skills/repomix/SKILL.md +94 -0
  193. package/skills/repomix/references/configuration.md +134 -0
  194. package/skills/repomix/references/usage-patterns.md +106 -0
  195. package/skills/repomix/scripts/.coverage +0 -0
  196. package/skills/repomix/scripts/README.md +179 -0
  197. package/skills/repomix/scripts/repomix_batch.py +455 -0
  198. package/skills/repomix/scripts/repos.example.json +15 -0
  199. package/skills/repomix/scripts/requirements.txt +15 -0
  200. package/skills/repomix/scripts/tests/test_repomix_batch.py +531 -0
  201. package/skills/research/SKILL.md +107 -0
  202. package/skills/security-scan/SKILL.md +101 -0
  203. package/skills/security-scan/references/secret-patterns.md +75 -0
  204. package/skills/security-scan/references/vulnerability-patterns.md +136 -0
@@ -0,0 +1,533 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LocalProcessBackend = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_fs_1 = require("node:fs");
6
+ const promises_1 = require("node:fs/promises");
7
+ const node_os_1 = require("node:os");
8
+ const node_path_1 = require("node:path");
9
+ const types_1 = require("../delegation/types");
10
+ const adapter_files_1 = require("../runtime/adapter-files");
11
+ const windows_shim_1 = require("../runtime/windows-shim");
12
+ const local_execution_store_1 = require("./local-execution-store");
13
+ /** Lines of transcript carried on a result. */
14
+ const TRANSCRIPT_LINES = 200;
15
+ const RESULT_POLL_MS = 250;
16
+ async function cleanupFiles(files) {
17
+ const errors = [];
18
+ for (const file of files) {
19
+ try {
20
+ await (0, promises_1.rm)(file, { force: true });
21
+ }
22
+ catch (error) {
23
+ errors.push(error);
24
+ }
25
+ }
26
+ if (errors.length > 0)
27
+ throw new AggregateError(errors, "failed to clean temporary runtime files");
28
+ }
29
+ /** Last `TRANSCRIPT_LINES` lines of a transcript, or "" when there is none to read. */
30
+ function tailLog(logFile) {
31
+ if (!logFile)
32
+ return "";
33
+ try {
34
+ const lines = (0, node_fs_1.readFileSync)(logFile, "utf8").split(/\r?\n/);
35
+ return lines.slice(Math.max(0, lines.length - TRANSCRIPT_LINES)).join("\n").trim();
36
+ }
37
+ catch {
38
+ return "";
39
+ }
40
+ }
41
+ /**
42
+ * Whether a pid names a process that is still running.
43
+ *
44
+ * Signal 0 performs the permission and existence checks without delivering anything.
45
+ * `EPERM` means the process exists but belongs to someone else, which for our purposes is
46
+ * still alive — treating it as dead would report a healthy run as an orphan.
47
+ */
48
+ function processAlive(pid) {
49
+ if (pid === null)
50
+ return false;
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ }
55
+ catch (error) {
56
+ return error.code === "EPERM";
57
+ }
58
+ }
59
+ function terminalStatus(record, result) {
60
+ if (record.cancelled)
61
+ return "cancelled";
62
+ if (result.spawnError)
63
+ return "failed";
64
+ return result.exitCode === 0 ? "completed" : "failed";
65
+ }
66
+ function failureError(record, result, transcript) {
67
+ if (record.cancelled)
68
+ return undefined;
69
+ if (result.spawnError) {
70
+ // A runtime that is not on PATH is a machine problem, not a failure of this execution;
71
+ // naming it `BACKEND_UNAVAILABLE` is what tells the caller to install rather than retry.
72
+ const unavailable = /ENOENT|not found|no such file/i.test(result.spawnError);
73
+ return {
74
+ code: unavailable ? "BACKEND_UNAVAILABLE" : "SpawnFailed",
75
+ message: unavailable
76
+ ? `local backend could not start the runtime: ${result.spawnError}. Check that \`claude\`/\`codex\` is on PATH.`
77
+ : `local backend could not start the runtime: ${result.spawnError}`,
78
+ };
79
+ }
80
+ if (result.exitCode === 0)
81
+ return undefined;
82
+ // The transcript is the whole point: before this, a delegated run that crashed before the
83
+ // Stop hook could finalize `state.json` came back as a bare `failed` with nothing to read.
84
+ const detail = result.signal ? `killed by ${result.signal}` : `exit code ${result.exitCode}`;
85
+ return {
86
+ code: "ExecutionFailed",
87
+ message: transcript
88
+ ? `local execution ended with ${detail}. Last output:\n${transcript}`
89
+ : `local execution ended with ${detail}.`,
90
+ };
91
+ }
92
+ /**
93
+ * Runs delegated agents as child processes of this machine, with no daemon in the way.
94
+ *
95
+ * It hands the runtime its own settings file, which is what makes `permissions.deny` and
96
+ * `sandbox.filesystem.denyWrite` actually reach the agent — verified 2026-09-03: a delegated
97
+ * `search` role reading another role's private memory was refused with "File is in a
98
+ * directory that is denied by your permission settings", and every write path was refused at
99
+ * three independent layers. A backend that spawns the runtime through a daemon of its own
100
+ * cannot reproduce that, because its permission requests carry no path; that is why the
101
+ * alternative was dropped rather than kept alongside.
102
+ *
103
+ * Everything below exists so that guarantee survives the CLI process exiting. State is on
104
+ * disk rather than in a field, background runs are owned by a detached supervisor that
105
+ * outlives us, and a dead pid with no result file is reported as an orphan rather than as
106
+ * work still in progress.
107
+ */
108
+ class LocalProcessBackend {
109
+ name = "local";
110
+ env;
111
+ stdioOverride;
112
+ spawnProcess;
113
+ stateDir;
114
+ store;
115
+ supervisorScript;
116
+ platform;
117
+ killProcess;
118
+ probeRuntimes;
119
+ /** Handles for executions this process started, so it need not poll its own children. */
120
+ inFlight = new Map();
121
+ constructor(options = {}) {
122
+ this.env = options.env ?? process.env;
123
+ this.stdioOverride = options.stdio;
124
+ this.spawnProcess = options.spawnProcess ?? ((command, args, spawnOptions) => {
125
+ const stdio = spawnOptions.stdio;
126
+ return (0, node_child_process_1.spawn)(command, [...args], {
127
+ cwd: spawnOptions.cwd,
128
+ env: spawnOptions.env,
129
+ ...(spawnOptions.detached === undefined ? {} : { detached: spawnOptions.detached }),
130
+ // Node's own option type is mutable; ours is readonly so a caller cannot alter it
131
+ // after the fact. Copying is what bridges the two.
132
+ stdio: (typeof stdio === "string" ? stdio : [...stdio]),
133
+ });
134
+ });
135
+ this.stateDir = (0, node_path_1.resolve)(options.stateDir ?? (0, node_path_1.join)(this.env.HOME ?? (0, node_os_1.homedir)(), ".alp", "local"));
136
+ this.store = options.store ?? (options.stateDir
137
+ ? new local_execution_store_1.FileLocalExecutionStore({ file: (0, local_execution_store_1.localStateFile)(options.stateDir) })
138
+ : new local_execution_store_1.InMemoryLocalExecutionStore());
139
+ this.supervisorScript = options.supervisorScript ?? (0, node_path_1.join)(__dirname, "local-supervisor.js");
140
+ this.platform = options.platform ?? process.platform;
141
+ this.killProcess = options.killProcess ?? ((pid, signal) => process.kill(pid, signal));
142
+ this.probeRuntimes = options.probeRuntimes ?? (async () => {
143
+ const found = [];
144
+ for (const runtime of ["claude", "codex"]) {
145
+ if (await (0, adapter_files_1.resolveRuntimeCommand)(runtime, this.platform, this.env))
146
+ found.push(runtime);
147
+ }
148
+ return found;
149
+ });
150
+ }
151
+ /**
152
+ * Reports whether a delegated agent could actually start here.
153
+ *
154
+ * This used to return a constant `ok: true`, which deferred the failure on a machine with
155
+ * no runtime installed to the first `spawn`, where it surfaced as a bare ENOENT naming a
156
+ * path rather than the CLI to install. `DelegationService` asks before it records the
157
+ * execution, so the answer has to be the real one.
158
+ */
159
+ async healthCheck() {
160
+ const runtimes = await this.probeRuntimes();
161
+ return runtimes.length > 0
162
+ ? { ok: true, message: `local process backend available (${runtimes.join(", ")})` }
163
+ : {
164
+ ok: false,
165
+ message: "local process backend has no runtime on PATH (looked for `claude` and `codex`)",
166
+ remediation: "install the Claude Code or Codex CLI and make sure it is on PATH",
167
+ };
168
+ }
169
+ async spawn(input) {
170
+ if (this.store.get(input.executionId))
171
+ throw new Error(`execution \`${input.executionId}\` already exists`);
172
+ return input.lifecycle?.background === true
173
+ ? this.spawnDetached(input)
174
+ : this.spawnAttached(input);
175
+ }
176
+ /**
177
+ * Background: hand the run to a detached supervisor and return immediately.
178
+ *
179
+ * Measured before this existed: `--background` returned a `running` result but the child
180
+ * kept the principal's terminal and the CLI process stayed alive until it finished, so the
181
+ * flag bought nothing and the execution was unreachable from the next command.
182
+ */
183
+ async spawnDetached(input) {
184
+ const { executionId, launchSpec } = input;
185
+ const logFile = this.logFile(executionId);
186
+ const resultFile = this.resultFile(executionId);
187
+ const specFile = (0, node_path_1.join)(this.stateDir, "specs", `${executionId}.json`);
188
+ const supervisorSpec = {
189
+ executionId,
190
+ command: launchSpec.command,
191
+ args: [...launchSpec.args],
192
+ cwd: launchSpec.cwd,
193
+ env: { ...launchSpec.env },
194
+ logFile,
195
+ resultFile,
196
+ temporaryFiles: [...launchSpec.temporaryFiles],
197
+ };
198
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(this.stateDir, "specs"), { recursive: true, mode: 0o700 });
199
+ (0, node_fs_1.writeFileSync)(specFile, `${JSON.stringify(supervisorSpec, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
200
+ const child = this.spawnProcess(process.execPath, [this.supervisorScript, specFile], {
201
+ cwd: launchSpec.cwd,
202
+ env: { ...this.env },
203
+ stdio: "ignore",
204
+ detached: true,
205
+ });
206
+ child.unref?.();
207
+ this.store.put(this.newRecord(input, {
208
+ pid: child.pid ?? null,
209
+ detached: true,
210
+ logFile,
211
+ resultFile,
212
+ }));
213
+ return { executionId, status: "running", metadata: { mode: "background", logFile } };
214
+ }
215
+ /**
216
+ * Foreground: run as our own child, streaming to the terminal and to the transcript.
217
+ *
218
+ * An interactive launch keeps `inherit` because it owns a tty; everything else is teed, so
219
+ * the caller still watches the agent work while the transcript survives for `status()` to
220
+ * quote when the run fails before its Stop hook can record an answer.
221
+ */
222
+ async spawnAttached(input) {
223
+ const { executionId, launchSpec } = input;
224
+ const interactive = input.lifecycle?.interactive === true;
225
+ const stdio = this.stdioOverride ?? (interactive ? "inherit" : "pipe");
226
+ const logFile = stdio === "pipe" ? this.logFile(executionId) : null;
227
+ const env = { ...this.env, ...launchSpec.env };
228
+ // A Windows `.cmd` runtime shim cannot be spawned directly; unwrap it to the Node
229
+ // script it fronts so argv reaches the runtime unmodified.
230
+ const spec = (0, windows_shim_1.resolveSpawnCommand)(launchSpec.command, launchSpec.args, env);
231
+ // stdin is closed, not piped. A delegated agent receives its task in argv and has no
232
+ // interactive input, but a bare `"pipe"` leaves it holding an fd nobody ever writes:
233
+ // Claude then waits out its own stdin timeout, adding three seconds to every run.
234
+ const child = this.spawnProcess(spec.command, spec.args, {
235
+ cwd: launchSpec.cwd,
236
+ env,
237
+ stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : stdio,
238
+ });
239
+ let log = null;
240
+ if (logFile) {
241
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(this.stateDir, "logs"), { recursive: true, mode: 0o700 });
242
+ log = (0, node_fs_1.createWriteStream)(logFile, { flags: "a", mode: 0o600 });
243
+ for (const stream of [child.stdout, child.stderr]) {
244
+ stream?.on("data", (chunk) => {
245
+ process.stdout.write(chunk);
246
+ log?.write(chunk);
247
+ });
248
+ }
249
+ }
250
+ this.store.put(this.newRecord(input, {
251
+ pid: child.pid ?? null,
252
+ detached: false,
253
+ logFile,
254
+ resultFile: null,
255
+ }));
256
+ let resolveSettled;
257
+ let rejectSettled;
258
+ const settled = new Promise((settle, reject) => {
259
+ resolveSettled = settle;
260
+ rejectSettled = reject;
261
+ });
262
+ this.inFlight.set(executionId, { settled, child });
263
+ const conclude = async (outcome) => {
264
+ // `end()` only schedules the flush. Reading the transcript before it lands truncates
265
+ // exactly the last lines — the ones that say why a failing run failed.
266
+ if (log)
267
+ await new Promise((settle) => log.end(() => settle()));
268
+ const current = this.store.get(executionId);
269
+ const cancelled = current?.cancelled === true;
270
+ const transcript = tailLog(logFile);
271
+ const status = terminalStatus({ cancelled }, outcome);
272
+ const error = failureError({ cancelled }, outcome, transcript);
273
+ const result = {
274
+ executionId,
275
+ status,
276
+ exitCode: outcome.exitCode ?? null,
277
+ signal: outcome.signal ?? null,
278
+ ...(transcript ? { output: transcript } : {}),
279
+ ...(error ? { error } : {}),
280
+ };
281
+ this.store.update(executionId, {
282
+ status,
283
+ exitCode: result.exitCode,
284
+ signal: result.signal,
285
+ ...(transcript ? { output: transcript } : {}),
286
+ ...(error ? { error } : {}),
287
+ });
288
+ return result;
289
+ };
290
+ child.on("error", (error) => {
291
+ void conclude({ exitCode: null, signal: null, spawnError: error.message })
292
+ .then(async (result) => {
293
+ try {
294
+ await cleanupFiles(launchSpec.temporaryFiles);
295
+ }
296
+ catch (cleanupError) {
297
+ return rejectSettled(cleanupError);
298
+ }
299
+ // A runtime that never started is an error for the caller that is waiting on it,
300
+ // but `status()` must still find a terminal record rather than an absent one.
301
+ void result;
302
+ rejectSettled(error);
303
+ });
304
+ });
305
+ child.on("close", (code, signal) => {
306
+ void conclude({ exitCode: code, signal })
307
+ .then(async (result) => {
308
+ try {
309
+ await cleanupFiles(launchSpec.temporaryFiles);
310
+ }
311
+ catch (cleanupError) {
312
+ return rejectSettled(cleanupError);
313
+ }
314
+ resolveSettled(result);
315
+ });
316
+ });
317
+ return { executionId, status: "running", ...(logFile ? { metadata: { mode: "foreground", logFile } } : {}) };
318
+ }
319
+ async status(executionId) {
320
+ const record = this.record(executionId);
321
+ if (["completed", "failed", "cancelled"].includes(record.status))
322
+ return this.resultOf(record);
323
+ const supervised = this.readResult(record);
324
+ if (supervised)
325
+ return this.finalize(record, supervised);
326
+ // An execution this process started and still holds is running by definition: we have
327
+ // not seen its `close` yet. Probing the pid instead would call it an orphan the moment
328
+ // the pid were unprobeable, which is how the first version of this reported a healthy
329
+ // foreground run as failed.
330
+ if (this.inFlight.has(executionId) || processAlive(record.pid)) {
331
+ return { executionId, status: record.status };
332
+ }
333
+ // No result file and no process: the supervisor died without recording an outcome, or
334
+ // the machine was rebooted under it. Reporting `running` here is what let an execution
335
+ // sit unreachable forever, so it is named as the orphan it is.
336
+ return this.finalize(record, {
337
+ executionId,
338
+ exitCode: null,
339
+ signal: null,
340
+ endedAt: new Date().toISOString(),
341
+ spawnError: undefined,
342
+ }, {
343
+ code: "ExecutionFailed",
344
+ message: `local execution \`${executionId}\` is orphaned: process ${record.pid ?? "?"} is gone and no result was recorded.`,
345
+ });
346
+ }
347
+ async wait(executionId, options = {}) {
348
+ const record = this.record(executionId);
349
+ const timeoutMs = options.timeoutMs ?? null;
350
+ const inFlight = this.inFlight.get(executionId);
351
+ if (inFlight)
352
+ return this.waitInProcess(executionId, inFlight, timeoutMs);
353
+ if (["completed", "failed", "cancelled"].includes(record.status))
354
+ return this.resultOf(record);
355
+ return this.pollUntilTerminal(executionId, timeoutMs);
356
+ }
357
+ /**
358
+ * `wait` used to take `timeoutMs` and ignore it, so `alp delegate --timeout-ms` was a
359
+ * no-op and an agent that hung held the caller forever.
360
+ *
361
+ * A foreground timeout stops the run, which is where this deliberately parts company with
362
+ * the background path below. There, the agent survives a lapsed wait because the detached
363
+ * supervisor is still holding it and will record how it ends. An attached run has no such
364
+ * owner: it is our own child, writing to our stdout, and if we
365
+ * merely walked away it would keep the terminal, finish unobserved, and then show up as an
366
+ * orphan on the next `status`. Stopping it is what makes the timeout mean something.
367
+ */
368
+ async waitInProcess(executionId, inFlight, timeoutMs) {
369
+ if (timeoutMs === null)
370
+ return inFlight.settled;
371
+ let timer;
372
+ try {
373
+ return await Promise.race([
374
+ inFlight.settled,
375
+ new Promise((_, reject) => {
376
+ timer = setTimeout(() => {
377
+ void this.cancel(executionId).catch(() => undefined);
378
+ reject(new types_1.DelegationError("EXECUTION_TIMEOUT", `local execution \`${executionId}\` did not finish within ${timeoutMs}ms and was stopped`));
379
+ }, timeoutMs);
380
+ }),
381
+ ]);
382
+ }
383
+ finally {
384
+ if (timer)
385
+ clearTimeout(timer);
386
+ }
387
+ }
388
+ async pollUntilTerminal(executionId, timeoutMs) {
389
+ const deadline = timeoutMs === null ? null : Date.now() + timeoutMs;
390
+ for (;;) {
391
+ const result = await this.status(executionId);
392
+ if (["completed", "failed", "cancelled"].includes(result.status))
393
+ return result;
394
+ if (deadline !== null && Date.now() >= deadline) {
395
+ throw new types_1.DelegationError("EXECUTION_TIMEOUT", `local execution \`${executionId}\` did not finish within ${timeoutMs}ms`);
396
+ }
397
+ const remaining = deadline === null ? RESULT_POLL_MS : Math.min(RESULT_POLL_MS, deadline - Date.now());
398
+ await new Promise((settle) => setTimeout(settle, Math.max(remaining, 1)));
399
+ }
400
+ }
401
+ async cancel(executionId, signal = "SIGTERM") {
402
+ const record = this.record(executionId);
403
+ if (["completed", "failed", "cancelled"].includes(record.status))
404
+ return this.resultOf(record);
405
+ this.store.update(executionId, { status: "cancelled", cancelled: true });
406
+ this.terminate(record, signal);
407
+ return { executionId, status: "cancelled" };
408
+ }
409
+ /**
410
+ * Signals the whole runtime tree, not just the process we launched.
411
+ *
412
+ * A detached supervisor leads its own process group, so the negative pid reaches the
413
+ * runtime it started; killing only the supervisor would leave the agent running with
414
+ * nobody recording its exit. Windows has no process groups, so the tree is torn down with
415
+ * `taskkill /T`.
416
+ */
417
+ terminate(record, signal) {
418
+ const inFlight = this.inFlight.get(record.executionId);
419
+ if (inFlight && !record.detached) {
420
+ inFlight.child.kill(signal);
421
+ return;
422
+ }
423
+ if (record.pid === null)
424
+ return;
425
+ if (this.platform === "win32") {
426
+ (0, node_child_process_1.spawn)("taskkill", ["/PID", String(record.pid), "/T", "/F"], { stdio: "ignore" }).unref();
427
+ return;
428
+ }
429
+ try {
430
+ this.killProcess(record.detached ? -record.pid : record.pid, signal);
431
+ }
432
+ catch { /* the process finished between our status read and this signal */ }
433
+ }
434
+ /**
435
+ * Forgets an execution, keeping its transcript.
436
+ *
437
+ * The log is what makes a past failure diagnosable and costs a few kilobytes; the record,
438
+ * the result file and any leftover runtime temporaries are what actually accumulate.
439
+ */
440
+ async cleanup(executionId) {
441
+ const record = this.record(executionId);
442
+ await cleanupFiles(record.temporaryFiles);
443
+ for (const file of [record.resultFile, (0, node_path_1.join)(this.stateDir, "specs", `${executionId}.json`)]) {
444
+ if (file)
445
+ (0, node_fs_1.rmSync)(file, { force: true });
446
+ }
447
+ this.inFlight.delete(executionId);
448
+ this.store.remove(executionId);
449
+ }
450
+ /**
451
+ * Executions this backend still calls running whose process is gone without a result.
452
+ *
453
+ * Nothing reconciles these on our behalf — there is no daemon behind this backend — so it
454
+ * has to answer the question directly.
455
+ */
456
+ orphanExecutions() {
457
+ return Object.freeze(this.store.list().filter((record) => ["queued", "running"].includes(record.status)
458
+ && !this.inFlight.has(record.executionId)
459
+ && !processAlive(record.pid)
460
+ && this.readResult(record) === null));
461
+ }
462
+ newRecord(input, fields) {
463
+ return {
464
+ executionId: input.executionId,
465
+ status: "running",
466
+ cancelled: false,
467
+ cwd: input.launchSpec.cwd,
468
+ temporaryFiles: [...input.launchSpec.temporaryFiles],
469
+ // Provenance for whatever reads `local.json` after this process is gone.
470
+ labels: Object.freeze({
471
+ ...(input.lifecycle?.requestId ? { "alp.request-id": input.lifecycle.requestId } : {}),
472
+ ...(input.lifecycle?.parentExecutionId ? { "alp.parent-execution-id": input.lifecycle.parentExecutionId } : {}),
473
+ ...(input.launchSpec.env.ALP_ROLE ? { "alp.target-role": input.launchSpec.env.ALP_ROLE } : {}),
474
+ }),
475
+ createdAt: new Date().toISOString(),
476
+ ...fields,
477
+ };
478
+ }
479
+ readResult(record) {
480
+ if (!record.resultFile || !(0, node_fs_1.existsSync)(record.resultFile))
481
+ return null;
482
+ try {
483
+ return JSON.parse((0, node_fs_1.readFileSync)(record.resultFile, "utf8"));
484
+ }
485
+ catch {
486
+ return null;
487
+ }
488
+ }
489
+ finalize(record, outcome, override) {
490
+ const transcript = tailLog(record.logFile);
491
+ const status = terminalStatus(record, outcome);
492
+ const error = override ?? failureError(record, outcome, transcript);
493
+ this.store.update(record.executionId, {
494
+ status,
495
+ exitCode: outcome.exitCode,
496
+ signal: outcome.signal,
497
+ ...(transcript ? { output: transcript } : {}),
498
+ ...(error ? { error } : {}),
499
+ });
500
+ return {
501
+ executionId: record.executionId,
502
+ status,
503
+ exitCode: outcome.exitCode,
504
+ signal: outcome.signal,
505
+ ...(transcript ? { output: transcript } : {}),
506
+ ...(error ? { error } : {}),
507
+ };
508
+ }
509
+ resultOf(record) {
510
+ const output = record.output ?? tailLog(record.logFile);
511
+ return {
512
+ executionId: record.executionId,
513
+ status: record.status,
514
+ ...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }),
515
+ ...(record.signal === undefined ? {} : { signal: record.signal }),
516
+ ...(output ? { output } : {}),
517
+ ...(record.error ? { error: record.error } : {}),
518
+ };
519
+ }
520
+ logFile(executionId) {
521
+ return (0, node_path_1.join)(this.stateDir, "logs", `${executionId}.log`);
522
+ }
523
+ resultFile(executionId) {
524
+ return (0, node_path_1.join)(this.stateDir, "results", `${executionId}.json`);
525
+ }
526
+ record(executionId) {
527
+ const record = this.store.get(executionId);
528
+ if (!record)
529
+ throw new Error(`unknown local execution \`${executionId}\``);
530
+ return record;
531
+ }
532
+ }
533
+ exports.LocalProcessBackend = LocalProcessBackend;
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.superviseExecution = superviseExecution;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_fs_1 = require("node:fs");
6
+ const node_path_1 = require("node:path");
7
+ const windows_shim_1 = require("../runtime/windows-shim");
8
+ /**
9
+ * Writes the result where a reader can never observe it half-written.
10
+ *
11
+ * `status()` treats the existence of this file as proof the run is over, so a partial write
12
+ * would be read as a corrupt terminal state rather than as work still in progress. Rename
13
+ * is atomic within a directory, so the file appears complete or not at all.
14
+ */
15
+ function writeResultAtomically(file, result) {
16
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(file), { recursive: true, mode: 0o700 });
17
+ const temporary = `${file}.${process.pid}.tmp`;
18
+ try {
19
+ (0, node_fs_1.writeFileSync)(temporary, `${JSON.stringify(result, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
20
+ (0, node_fs_1.renameSync)(temporary, file);
21
+ }
22
+ finally {
23
+ (0, node_fs_1.rmSync)(temporary, { force: true });
24
+ }
25
+ }
26
+ function removeFiles(files) {
27
+ for (const file of files) {
28
+ try {
29
+ (0, node_fs_1.rmSync)(file, { force: true });
30
+ }
31
+ catch { /* a leftover temp file must not mask the exit status */ }
32
+ }
33
+ }
34
+ /**
35
+ * Runs one delegated runtime to completion on behalf of a CLI process that has already exited.
36
+ *
37
+ * This is the part of a daemon that ALP actually needs, and nothing more: survive the caller,
38
+ * keep a transcript, remember how the agent ended. Without those three, a `--background`
39
+ * delegation held the principal's terminal until it finished and then became unreachable,
40
+ * because its only record lived in the memory of a process that was gone.
41
+ *
42
+ * Deliberately dependency-free beyond the Windows shim: it is spawned detached, so an
43
+ * exception here is unobservable. Every failure path ends in a result file, including the
44
+ * one where the runtime never starts.
45
+ */
46
+ async function superviseExecution(spec) {
47
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(spec.logFile), { recursive: true, mode: 0o700 });
48
+ const log = (0, node_fs_1.createWriteStream)(spec.logFile, { flags: "a", mode: 0o600 });
49
+ const resolved = (0, windows_shim_1.resolveSpawnCommand)(spec.command, spec.args, { ...process.env, ...spec.env });
50
+ await new Promise((settle) => {
51
+ // A failed spawn emits `error` and then `close` with a synthetic exit code, so without
52
+ // this guard the second event overwrites the first: a runtime missing from PATH was
53
+ // recorded as `exit code -2` and classified as an execution failure, when the whole
54
+ // point of `spawnError` is to name it a machine problem the caller should fix.
55
+ let finished = false;
56
+ const finish = (result) => {
57
+ if (finished)
58
+ return;
59
+ finished = true;
60
+ removeFiles([...spec.temporaryFiles, ...(spec.specFile ? [spec.specFile] : [])]);
61
+ // The log is flushed before the result file appears, never after: `status()` treats
62
+ // the result file as proof the run is over and reads the transcript in the same
63
+ // breath, so publishing the outcome first would hand it a truncated tail.
64
+ log.end(() => {
65
+ writeResultAtomically(spec.resultFile, {
66
+ executionId: spec.executionId,
67
+ endedAt: new Date().toISOString(),
68
+ ...result,
69
+ });
70
+ settle();
71
+ });
72
+ };
73
+ let child;
74
+ try {
75
+ child = (0, node_child_process_1.spawn)(resolved.command, [...resolved.args], {
76
+ cwd: spec.cwd,
77
+ env: { ...process.env, ...spec.env },
78
+ stdio: ["ignore", "pipe", "pipe"],
79
+ });
80
+ }
81
+ catch (error) {
82
+ log.write(`[alp] failed to spawn runtime: ${error.message}\n`);
83
+ return finish({ exitCode: null, signal: null, spawnError: error.message });
84
+ }
85
+ child.stdout?.pipe(log, { end: false });
86
+ child.stderr?.pipe(log, { end: false });
87
+ child.on("error", (error) => {
88
+ log.write(`[alp] failed to spawn runtime: ${error.message}\n`);
89
+ finish({ exitCode: null, signal: null, spawnError: error.message });
90
+ });
91
+ child.on("close", (code, signal) => finish({ exitCode: code, signal }));
92
+ });
93
+ }
94
+ /* c8 ignore start -- entry point exercised as a spawned process, not by unit tests */
95
+ if (require.main === module) {
96
+ const specFile = process.argv[2];
97
+ if (!specFile) {
98
+ process.stderr.write("local-supervisor requires a spec file path\n");
99
+ process.exit(2);
100
+ }
101
+ const spec = JSON.parse((0, node_fs_1.readFileSync)(specFile, "utf8"));
102
+ void superviseExecution({ ...spec, specFile }).then(() => process.exit(0));
103
+ }
104
+ /* c8 ignore stop */