@uchibeke/gallivant 0.0.0-stage → 0.2.1

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 (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +239 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +261 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +72 -0
  12. package/docs/agent-setup.md +228 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +649 -0
  18. package/docs/config.md +437 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +398 -0
  22. package/docs/launch.md +45 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +149 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +466 -0
  43. package/lib/commands/init.mjs +245 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +249 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +102 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +707 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +140 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1654 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. package/templates/tracker/tracker.js +721 -0
package/lib/run.mjs ADDED
@@ -0,0 +1,385 @@
1
+ // Detached runs, adapted from command-center's lib/run.mjs so its log viewer can read a Gallivant run unchanged.
2
+ //
3
+ // A run is a child process whose stdout is a file. Nothing about it lives only in this process: the pid is on disk,
4
+ // liveness is asked of the operating system, and a daemon restarted mid-run picks it up from the folder. Each entry
5
+ // gets its own root, so one run per entry is the natural lock and entries never block each other.
6
+ import { spawn, execFileSync } from "node:child_process";
7
+ import { readFileSync, writeFileSync, openSync, closeSync, readSync, statSync, rmSync, existsSync, realpathSync } from "node:fs";
8
+ import { join, basename, resolve, sep } from "node:path";
9
+ import { ensureDir } from "./fs.mjs";
10
+ import { stamp } from "./time.mjs";
11
+ import { toPrdId } from "./prd.mjs";
12
+ import { readJson, writeJsonAtomic } from "./fs.mjs";
13
+ import { journalFor } from "./execution_journal.mjs";
14
+ import { LOG_LIMITS } from "./run_log.mjs";
15
+ import { claudeRuntimeSettings, prepareRuntimeCache, RUNTIME_SETTINGS_FILE } from "./runtime-environment.mjs";
16
+
17
+ /** Signal 0 says whether a pid exists; EPERM means it exists but belongs to someone else, which still counts. */
18
+ export const alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
19
+
20
+ /** A signal request is not confirmation: descendants may still own the run. */
21
+ export function requestRunCancellation(runner, id) {
22
+ runner.kill(id);
23
+ const job = runner.current();
24
+ return { confirmed: job?.id === id && job.running === false };
25
+ }
26
+
27
+ const sameProcess = (a, b) => a.pid === b.pid && a.started === b.started;
28
+ const ownedGroups = (pid, members) => new Set([pid, ...members.filter(m => m.pid === m.group).map(m => m.group)]);
29
+
30
+ /** Live descendants, including tool helpers that create their own process groups. */
31
+ export function runProcesses(pid, known = [], exec = execFileSync) {
32
+ try {
33
+ const text = exec("ps", ["-axo", "pid=,ppid=,pgid=,stat=,lstart="], {
34
+ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"],
35
+ env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" },
36
+ });
37
+ const rows = text.split("\n").filter(row => row.trim()).map(row => {
38
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(row);
39
+ if (!m) throw new Error("unreadable process row");
40
+ return { pid: Number(m[1]), parent: Number(m[2]), group: Number(m[3]), zombie: m[4].includes("Z"), started: m[5] };
41
+ });
42
+ // Recorded group leaders keep orphaned tools discoverable after a daemon restart.
43
+ // A tool in a shared outside group never grants ownership of that entire group.
44
+ const groups = ownedGroups(pid, known);
45
+ const ids = new Set(rows.filter(m => groups.has(m.group) || known.some(old => sameProcess(m, old))).map(m => m.pid));
46
+ let count;
47
+ do {
48
+ count = ids.size;
49
+ for (const m of rows) if (ids.has(m.parent)) ids.add(m.pid);
50
+ } while (ids.size !== count);
51
+ return rows.filter(m => ids.has(m.pid) && !m.zombie).map(({ pid, group, started }) => ({ pid, group, started }));
52
+ } catch { return null; } // Unknown must not authorize another writer or a signal.
53
+ }
54
+
55
+ /**
56
+ * Liveness that survives pid reuse: after a daemon crash the exit is never recorded, and days later the same pid
57
+ * can belong to a stranger. A run is ours only if the process's command still names the harness binary.
58
+ */
59
+ export function isOurProcess(pid, cmd) {
60
+ if (!alive(pid)) return false;
61
+ try {
62
+ const comm = execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" }).trim();
63
+ return comm.includes(basename(cmd)) || comm.includes(cmd);
64
+ } catch { return false; }
65
+ }
66
+
67
+ // The run folder name. One shape with the brief's id, capped because this is a path segment.
68
+ const slug = s => toPrdId(s, { max: 40 }) || "run";
69
+ export const LOCAL_OUTPUT_LIMITS = Object.freeze({ records: 100, bytes: LOG_LIMITS.windowBytes, pages: 2 });
70
+ const PRIVATE_OUTPUT_RETENTION_MS = 86_400_000;
71
+
72
+ export function makeRunner(root, { clock = Date, probe = isOurProcess, processes = runProcesses, signal = (pid, sig) => process.kill(pid, sig) } = {}) {
73
+ const pendingKills = new Map();
74
+ const pointer = join(root, "latest-run");
75
+ const projectionQueue = join(root, "local-output-queue.json");
76
+ const dirOf = id => {
77
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`invalid run id ${id}`);
78
+ return join(root, id);
79
+ };
80
+ const currentId = () => { try { return readFileSync(pointer, "utf8").trim() || null; } catch { return null; } };
81
+ const read = id => { try { return JSON.parse(readFileSync(join(dirOf(id), "job.json"), "utf8")); } catch { return null; } };
82
+ const write = (id, job) => writeFileSync(join(dirOf(id), "job.json"), JSON.stringify(job, null, 1), { mode: 0o600, flush: true });
83
+ const lines = id => { try { return readFileSync(join(dirOf(id), read(id)?.privateOutput ? "provider.log" : "job.log"), "utf8").split("\n").filter(Boolean); } catch { return []; } };
84
+ const outputQueue = () => {
85
+ const saved = readJson(projectionQueue, { version: 1, runs: [] });
86
+ if (!saved || saved.version !== 1 || !Array.isArray(saved.runs) || saved.runs.some(id => typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) || new Set(saved.runs).size !== saved.runs.length) {
87
+ throw Object.assign(new Error("unsupported local output queue; preserve the run tree and restore a compatible package/queue backup before dispatch"), { code: "RUN_OUTPUT_VERSION" });
88
+ }
89
+ return saved;
90
+ };
91
+ const outputCheckpoint = id => {
92
+ const checkpoint = readJson(join(dirOf(id), "local-output.json"), { offset: 0 });
93
+ if (!checkpoint || !Number.isSafeInteger(checkpoint.offset) || checkpoint.offset < 0 || (checkpoint.discarding !== undefined && typeof checkpoint.discarding !== "boolean")) throw new Error("invalid local output cursor; preserve diagnostics and restore its matching checkpoint before dispatch or replay");
94
+ return checkpoint;
95
+ };
96
+ const privateFiles = id => ["provider.log", "sensitive-redactions.json"].map(name => join(dirOf(id), name));
97
+ // Fixed run-owned filename, never a cleanup path supplied by the harness.
98
+ // Unknown process ownership or surviving tools retain the snapshot. Recovery
99
+ // calls this again after restart; cleanup does not depend on an exit callback.
100
+ const cleanupRuntime = job => {
101
+ if (!job?.runtimeSettings) return;
102
+ const file = join(dirOf(job.id), RUNTIME_SETTINGS_FILE);
103
+ if (!existsSync(file)) return;
104
+ if (job.pid) {
105
+ const members = processes(job.pid, job.stopMembers ?? []);
106
+ if (members === null || members.length) return;
107
+ }
108
+ rmSync(file, { force: true });
109
+ };
110
+ // Register projection and cleanup before replacing latest-run. Called before the
111
+ // execution boundary saves a paid intent, and again by standalone start.
112
+ function prepareStart() {
113
+ const job = current(), queue = outputQueue();
114
+ if (job?.running) throw Object.assign(new Error(`run in progress: ${job.id}`), { code: "BUSY" });
115
+ cleanupRuntime(job);
116
+ if (!job?.privateOutput) return;
117
+ const checkpoint = outputCheckpoint(job.id);
118
+ if ((checkpoint.pending || privateFiles(job.id).some(file => existsSync(file))) && !queue.runs.includes(job.id)) {
119
+ writeJsonAtomic(projectionQueue, { version: 1, runs: [...queue.runs, job.id] });
120
+ }
121
+ }
122
+ // Only coordinator calls observe. Public readers never ingest raw diagnostics.
123
+ function observeRun(job, meter) {
124
+ if (!job?.privateOutput) return false;
125
+ const dir = dirOf(job.id), [file, redactionsFile] = privateFiles(job.id);
126
+ const redactions = readJson(redactionsFile);
127
+ const checkpointFile = join(dir, "local-output.json"), checkpoint = outputCheckpoint(job.id);
128
+ const present = existsSync(file), size = present ? statSync(file).size : 0;
129
+ const retained = present || existsSync(redactionsFile);
130
+ const journal = () => journalFor(dir, { ...job.privateOutput, secrets: redactions?.secrets || [] });
131
+ const gap = reason => {
132
+ journal().append({ eventId: `gap-${checkpoint.offset}-${reason}`, type: "gap", provenance: "coordinator", timestamp: clock.now(), data: `[gap: ${reason}; remaining local diagnostic history is unavailable]` });
133
+ writeJsonAtomic(checkpointFile, { offset: size, pending: false, gap: reason });
134
+ meter.records = (meter.records || 0) + 1;
135
+ };
136
+ const expiresAt = redactions?.expiresAt ?? job.startedAt + PRIVATE_OUTPUT_RETENTION_MS;
137
+ if (expiresAt <= clock.now() && existsSync(join(dir, "result.json"))) {
138
+ if (checkpoint.pending || size > checkpoint.offset) gap("diagnostic retention expired");
139
+ rmSync(file, { force: true }); rmSync(redactionsFile, { force: true }); return false;
140
+ }
141
+ if (!present) { if (checkpoint.pending) gap("diagnostic file missing"); return retained; }
142
+ if (size < checkpoint.offset) { gap("diagnostic file truncated"); return retained; }
143
+ if (!redactions && size > checkpoint.offset) { gap("redaction metadata missing"); return retained; }
144
+ // Drained runs still need expiry checks, but no diagnostic read or journal write.
145
+ if (checkpoint.pending === false && size === checkpoint.offset) return retained;
146
+ const fd = openSync(file, "r");
147
+ try {
148
+ let discarding = checkpoint.discarding === true, prefixBytes = 0;
149
+ // Older cursors advanced a window into an omitted record without saving
150
+ // discard state. A bounded lookbehind repairs that mid-record cursor.
151
+ if (!discarding && checkpoint.pending && checkpoint.offset > 0) {
152
+ const previous = Buffer.alloc(1);
153
+ prefixBytes = readSync(fd, previous, 0, 1, checkpoint.offset - 1);
154
+ discarding = prefixBytes === 1 && previous[0] !== 10;
155
+ }
156
+ const pageBytes = LOCAL_OUTPUT_LIMITS.bytes - prefixBytes;
157
+ const length = Math.min(pageBytes, Math.max(0, size - checkpoint.offset));
158
+ const buffer = Buffer.alloc(length), n = readSync(fd, buffer, 0, length, checkpoint.offset), data = buffer.subarray(0, n);
159
+ meter.pages = (meter.pages || 0) + 1; meter.bytesRead = (meter.bytesRead || 0) + n + prefixBytes;
160
+ meter.maxBufferBytes = Math.max(meter.maxBufferBytes || 0, length);
161
+ const log = journal();
162
+ let consumed = 0;
163
+ let records = 0;
164
+ if (discarding) {
165
+ const newline = data.indexOf(10);
166
+ consumed = newline < 0 ? n : newline + 1;
167
+ discarding = newline < 0 && (job.running || checkpoint.offset + n < size);
168
+ }
169
+ while (consumed < n && records < LOCAL_OUTPUT_LIMITS.records) {
170
+ const newline = data.indexOf(10, consumed);
171
+ if (newline < 0) break;
172
+ const part = data.subarray(consumed, newline).toString("utf8");
173
+ log.append({ eventId: `byte-${checkpoint.offset + consumed}`, type: "output", timestamp: clock.now(), provenance: "local-cli", data: part });
174
+ consumed = newline + 1;
175
+ records++;
176
+ }
177
+ if (!consumed && n === pageBytes) {
178
+ log.append({ eventId: `byte-${checkpoint.offset}`, type: "gap", data: "oversized local record omitted", timestamp: clock.now() });
179
+ consumed = n; records++;
180
+ discarding = job.running || checkpoint.offset + n < size;
181
+ }
182
+ // An exit can leave a final line without newline. Preserve it once.
183
+ if (!discarding && !job.running && records < LOCAL_OUTPUT_LIMITS.records && n && consumed < n && checkpoint.offset + n === size) {
184
+ log.append({ eventId: `byte-${checkpoint.offset + consumed}`, type: "output", data: data.subarray(consumed).toString("utf8"), timestamp: clock.now(), provenance: "local-cli" });
185
+ consumed = n; records++;
186
+ }
187
+ meter.records = (meter.records || 0) + records;
188
+ const pending = discarding || checkpoint.offset + consumed < size;
189
+ writeJsonAtomic(checkpointFile, { offset: checkpoint.offset + consumed, pending, discarding });
190
+ return pending || retained;
191
+ } finally { closeSync(fd); }
192
+ }
193
+ function observe({ meter = {} } = {}) {
194
+ const queue = outputQueue(), job = current(), old = queue.runs[0], remaining = queue.runs.slice(1);
195
+ cleanupRuntime(job);
196
+ if (old && old !== job?.id) {
197
+ const previous = read(old);
198
+ cleanupRuntime(previous);
199
+ if (!previous?.privateOutput) throw new Error(`queued output ${old} lacks its owned job metadata; preserve the queue and restore job.json before replay`);
200
+ if (observeRun({ ...previous, running: running(previous) }, meter)) remaining.push(old);
201
+ }
202
+ const retained = job ? observeRun(job, meter) : false;
203
+ if (old === job?.id && retained) remaining.push(old);
204
+ if (old) writeJsonAtomic(projectionQueue, { version: 1, runs: remaining });
205
+ return meter;
206
+ }
207
+ // A terminated leader can leave tool subprocesses behind. Do not start a second
208
+ // writer until its tool processes are gone, even though cancellation recorded an exit.
209
+ const running = job => {
210
+ if (!job?.pid) return false;
211
+ if (!job.killed) return job.exit === null && probe(job.pid, job.bin);
212
+ const members = processes(job.pid, job.stopMembers ?? []);
213
+ return members === null || members.length > 0;
214
+ };
215
+ const signalRun = (job, members, sig) => {
216
+ const fail = () => { throw Object.assign(new Error("Cannot verify the interrupted process tree. Inspect its remaining processes before retrying; no replacement was started."), { code: "RUN_STOP_UNVERIFIED" }); };
217
+ if (members === null) fail();
218
+ const groups = ownedGroups(job.pid, job.stopMembers ?? []), targets = new Set();
219
+ for (const m of members) {
220
+ if (groups.has(m.group)) {
221
+ if (!members.some(peer => peer.group === m.group && job.stopMembers?.some(old => sameProcess(peer, old)))) fail();
222
+ targets.add(-m.group);
223
+ } else {
224
+ if (!job.stopMembers?.some(old => sameProcess(m, old))) fail();
225
+ targets.add(m.pid);
226
+ }
227
+ }
228
+ // Stop tools first, then their owner. Validate every target before signalling any.
229
+ for (const target of [...targets].sort((a, b) => (a === -job.pid) - (b === -job.pid))) {
230
+ try { signal(target, sig); } catch (e) { if (e.code !== "ESRCH") throw e; }
231
+ }
232
+ };
233
+ const finishKill = job => {
234
+ if (job?.killed) signalRun(job, processes(job.pid, job.stopMembers ?? []), "SIGKILL");
235
+ };
236
+
237
+ /** Spawn durably. inheritEnv=false uses only the caller's env plus run-directory metadata. */
238
+ async function start(name, cmd, args, { cwd, env = {}, inheritEnv = true, runId = null, privateOutput = null, runtimeSettings = null }) {
239
+ prepareStart();
240
+ prepareRuntimeCache(cwd, env);
241
+ const id = runId || `${stamp(clock)}-${slug(name)}`;
242
+ ensureDir(dirOf(id), 0o700);
243
+ if (privateOutput) {
244
+ const redactions = Object.entries(env).filter(([key]) => /token|password|secret|api_key/i.test(key)).map(([, value]) => value).filter(Boolean);
245
+ writeJsonAtomic(join(dirOf(id), "sensitive-redactions.json"), { expiresAt: clock.now() + PRIVATE_OUTPUT_RETENTION_MS, secrets: redactions });
246
+ writeFileSync(join(dirOf(id), "job.log"), "", { mode: 0o600 });
247
+ }
248
+ const out = openSync(join(dirOf(id), privateOutput ? "provider.log" : "job.log"), "w", 0o600);
249
+ let ownedSettings = false, spawnedJob = null;
250
+ try {
251
+ if (runtimeSettings) {
252
+ const index = args.indexOf("--settings");
253
+ if (index < 0 || args[index + 1] !== runtimeSettings.settings) throw new Error("runtime settings do not match the launch arguments");
254
+ const directory = realpathSync(dirOf(id)), checkout = realpathSync(cwd || process.cwd());
255
+ if (directory === checkout || directory.startsWith(checkout + sep)) throw new Error("runtime settings must be outside the agent checkout");
256
+ const destination = join(dirOf(id), RUNTIME_SETTINGS_FILE);
257
+ const settings = claudeRuntimeSettings(runtimeSettings.settings, destination, runtimeSettings.node);
258
+ ownedSettings = settings === destination;
259
+ args = [...args]; args[index + 1] = settings;
260
+ }
261
+ writeFileSync(join(dirOf(id), "cmd.json"), JSON.stringify({ cmd, args, cwd }, null, 1), { mode: 0o600 });
262
+ // spawn changes the OS directory but inherits PWD. OpenCode 1.18.31 prefers PWD for its tool root;
263
+ // leaving the daemon's value here can send edits and Git writes to the wrong checkout.
264
+ const p = spawn(cmd, args, { cwd, env: { ...(inheritEnv ? process.env : {}), ...env, PWD: resolve(cwd || process.cwd()), CP_RUN_DIR: dirOf(id) }, detached: true, stdio: ["ignore", out, out] });
265
+ const job = { id, name, pid: p.pid ?? null, bin: cmd, startedAt: clock.now(), exit: null, cmd: `${cmd} ${args.slice(0, 4).join(" ")}`, ...(privateOutput ? { privateOutput } : {}), ...(ownedSettings ? { runtimeSettings: true } : {}) };
266
+ spawnedJob = job;
267
+ let startupFailed = false;
268
+ p.on("error", err => {
269
+ if (startupFailed) return;
270
+ const j = read(id) || job;
271
+ write(id, { ...j, exit: 127, endedAt: clock.now(), error: err.message });
272
+ cleanupRuntime(job);
273
+ });
274
+ // A death by signal has no exit code; record the signal so it is never mistaken for success.
275
+ p.on("exit", (code, signal) => {
276
+ const j = read(id);
277
+ // kill owns the terminal record while its fallback is pending. If that write
278
+ // failed, retrying it here could throw out of band before descendants are killed.
279
+ if (!startupFailed && j && j.pid === p.pid && j.exit === null && !pendingKills.has(id)) write(id, { ...j, exit: code ?? 128, signal: signal ?? null, endedAt: clock.now() });
280
+ cleanupRuntime(j || job);
281
+ });
282
+ p.unref();
283
+ try {
284
+ write(id, job);
285
+ writeFileSync(pointer, id);
286
+ return job;
287
+ } catch (error) {
288
+ startupFailed = true;
289
+ // This PID belongs to the child just spawned. Do not depend on job.json or
290
+ // latest-run: either may be missing, truncated, or still point to an older run.
291
+ if (job.pid) {
292
+ terminate(job);
293
+ await waitForKill(id);
294
+ }
295
+ throw error;
296
+ }
297
+ } catch (error) {
298
+ if (ownedSettings) cleanupRuntime(spawnedJob || { id, runtimeSettings: true });
299
+ throw error;
300
+ } finally {
301
+ // spawn duplicates this descriptor for the child. Close the parent's copy
302
+ // on successful startup and on pre/post-spawn failures alike.
303
+ try { closeSync(out); } catch { /* closed */ }
304
+ }
305
+ }
306
+
307
+ /** The newest run with liveness resolved: { ...job, running }. Null when nothing has run. */
308
+ function current() {
309
+ const id = currentId();
310
+ const job = id && read(id);
311
+ return job ? { ...job, running: running(job) } : null;
312
+ }
313
+
314
+ /**
315
+ * Record the outcome of a run whose process is gone but whose exit was never written (the daemon was not there
316
+ * when it ended). `inferExit` decides from the log lines the caller already read.
317
+ */
318
+ function settle(id, inferExit = () => 1) {
319
+ const job = read(id);
320
+ cleanupRuntime(job);
321
+ if (!job || job.exit !== null) return job;
322
+ if (running(job)) return { ...job, running: true };
323
+ const settled = { ...job, exit: inferExit(), endedAt: clock.now(), inferred: true };
324
+ write(id, settled);
325
+ return settled;
326
+ }
327
+
328
+ // A persisted, ownership-checked job or a child just created by start owns this PID.
329
+ function terminate(job) {
330
+ if (pendingKills.has(job.id)) return;
331
+ try { process.kill(-job.pid, "SIGTERM"); } catch { try { process.kill(job.pid, "SIGTERM"); } catch { /* gone */ } }
332
+ scheduleKill(job.id, () => {
333
+ // A leader may exit on TERM while its descendants keep the group alive.
334
+ // Do not signal a reused leader PID now belonging to another command.
335
+ if (!alive(job.pid) || probe(job.pid, job.bin)) {
336
+ try { process.kill(-job.pid, "SIGKILL"); }
337
+ catch { if (probe(job.pid, job.bin)) { try { process.kill(job.pid, "SIGKILL"); } catch { /* gone */ } } }
338
+ }
339
+ });
340
+ }
341
+
342
+ function scheduleKill(id, fallback) {
343
+ if (pendingKills.has(id)) return;
344
+ let finish;
345
+ const done = new Promise(resolve => { finish = resolve; });
346
+ const timer = setTimeout(() => {
347
+ let error = null;
348
+ try { fallback(); } catch (e) { error = e; }
349
+ finally { pendingKills.delete(id); finish(error); }
350
+ }, 5000);
351
+ timer.unref();
352
+ pendingKills.set(id, { timer, done });
353
+ }
354
+
355
+ /** SIGTERM the process group, then SIGKILL if it is still there. Only a process that is still ours. */
356
+ function kill(id) {
357
+ const job = read(id);
358
+ if (job?.killed) {
359
+ // Recovery is based on surviving process identities, not an arbitrary deadline.
360
+ // Reads (including tracker GETs) never signal.
361
+ if (clock.now() >= job.killAfter) finishKill(job);
362
+ return false;
363
+ }
364
+ if (!job || !running(job)) return false;
365
+ const stopMembers = processes(job.pid);
366
+ if (stopMembers === null) throw Object.assign(new Error("Cannot inspect the active process group; nothing was stopped."), { code: "RUN_STOP_UNVERIFIED" });
367
+ const stopped = { ...job, exit: 137, endedAt: clock.now(), killed: true, stopMembers, killAfter: clock.now() + 5000 };
368
+ // Own the fallback before writing evidence: a full disk must not leave the
369
+ // child alive or let an exit callback throw before its tools are stopped.
370
+ scheduleKill(id, () => finishKill(stopped));
371
+ try { write(id, stopped); }
372
+ finally { signalRun(stopped, stopMembers, "SIGTERM"); }
373
+ return true;
374
+ }
375
+
376
+ /** Keep a short-lived caller alive until its requested process-group fallback has run. */
377
+ function waitForKill(id) {
378
+ const pending = pendingKills.get(id);
379
+ if (!pending) return Promise.resolve();
380
+ pending.timer.ref();
381
+ return pending.done.then(error => { if (error) throw error; });
382
+ }
383
+
384
+ return { root, start, prepareStart, current, read, lines, observe, settle, kill, waitForKill, dirOf, annotate: (id, patch) => { const job = read(id); if (job) write(id, { ...job, ...patch }); } };
385
+ }
@@ -0,0 +1,94 @@
1
+ // Public log projection with fixed-width offsets. Reads are capped independently
2
+ // of file size; legacy files without an index use a bounded suffix with a gap.
3
+ import { openSync, closeSync, readSync, writeSync, statSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { redact } from "./execution_io.mjs";
6
+ import { readJson, writeJsonAtomic } from "./fs.mjs";
7
+ export const LOG_LIMITS = Object.freeze({ windowBytes: 262_144, lineBytes: 65_536, lines: 400, bufferedBytes: 327_680 });
8
+ const stat = file => { try { return statSync(file); } catch { return { size: 0, ino: 0 }; } };
9
+ const boundedItems = items => {
10
+ let bytes = 2;
11
+ const bounded = items.map(item => {
12
+ const line = Buffer.byteLength(JSON.stringify(item.line)) > LOG_LIMITS.lineBytes ? "[gap: record exceeds encoded response limit]" : item.line;
13
+ bytes += Buffer.byteLength(JSON.stringify(line)) + 1;
14
+ return { ...item, line };
15
+ });
16
+ while (bytes > LOG_LIMITS.windowBytes && bounded.length) bytes -= Buffer.byteLength(JSON.stringify(bounded.shift().line)) + 1;
17
+ return bounded;
18
+ };
19
+ export function appendProjection(dir, line, { sequence = null } = {}) {
20
+ const file = join(dir, "job.log"), index = join(dir, "job.idx");
21
+ const text = redact(String(line)).replace(/\n/g, "\\n");
22
+ const data = Buffer.from(text + "\n");
23
+ if (data.length > LOG_LIMITS.lineBytes) throw new Error("projection line exceeds limit; journal a gap instead");
24
+ const offset = stat(file).size, previous = readJson(join(dir, "log-meta.json"), { version: 2, firstSequence: 1, lastSequence: 0, size: 0, ino: stat(file).ino });
25
+ const id = sequence ?? previous.lastSequence + 1;
26
+ if (previous.ino && (previous.ino !== stat(file).ino || offset < previous.size)) previous.firstSequence = id;
27
+ const fd = openSync(file, "a", 0o600), ix = openSync(index, existsSync(index) ? "r+" : "w+", 0o600);
28
+ try {
29
+ writeSync(fd, data);
30
+ const pointer = Buffer.alloc(8); pointer.writeBigUInt64LE(BigInt(offset + 1)); // zero means a retained-history gap.
31
+ writeSync(ix, pointer, 0, 8, (id - 1) * 8);
32
+ writeJsonAtomic(join(dir, "log-meta.json"), { ...previous, version: 2, lastSequence: Math.max(previous.lastSequence, id), ino: stat(file).ino, size: offset + data.length });
33
+ } finally { closeSync(fd); closeSync(ix); }
34
+ }
35
+ const readRange = (file, start, length, meter) => {
36
+ if (length <= 0) return Buffer.alloc(0);
37
+ const fd = openSync(file, "r");
38
+ try { const buf = Buffer.alloc(length); const n = readSync(fd, buf, 0, length, start); meter.bytesRead += n; return buf.subarray(0, n); }
39
+ finally { closeSync(fd); }
40
+ };
41
+ export function logWindow(dir, { before = null, after = null, count = LOG_LIMITS.lines, meter = { bytesRead: 0 } } = {}) {
42
+ const file = join(dir, "job.log"), index = join(dir, "job.idx");
43
+ const size = stat(file).size;
44
+ const n = Math.min(LOG_LIMITS.lines, Math.max(1, Math.trunc(Number(count)) || LOG_LIMITS.lines));
45
+ if (existsSync(index)) {
46
+ const meta = readJson(join(dir, "log-meta.json"));
47
+ const total = Math.floor(stat(index).size / 8);
48
+ const to = after !== null ? Math.min(total, Math.max(0, Number(after)) + n) : before === null ? total : Math.min(total, Math.max(0, Number(before) - 1));
49
+ const requestedFrom = after !== null ? Math.min(total, Math.max(0, Number(after))) : Math.max(0, to - n);
50
+ const from = Math.min(to, Math.max(requestedFrom, (meta?.firstSequence || 1) - 1));
51
+ if (to <= from) return { total, from: from + 1, to, before: from, lines: [], ids: [], ...(to > 0 && meta?.firstSequence > 1 ? { gap: "rotation/retention removed earlier history" } : {}), generation: stat(file).ino };
52
+ const offsets = readRange(index, from * 8, (to - from + (to < total ? 1 : 0)) * 8, meter);
53
+ if (!offsets.length) return { total, from: from + 1, to, before: from, lines: [], ids: [], generation: stat(file).ino };
54
+ const position = i => Number(offsets.readBigUInt64LE(i * 8)) - (meta?.version === 2 ? 1 : 0);
55
+ let availableFrom = from;
56
+ while (availableFrom < to && position(availableFrom - from) < 0) availableFrom++;
57
+ if (availableFrom === to) return { total, from: to + 1, to, before: from, lines: [], ids: [], gap: "history retained as an explicit gap", generation: stat(file).ino };
58
+ const start = position(availableFrom - from);
59
+ const end = to < total ? position(to - from) : size;
60
+ if (start > size || end > size || end < start) return { total, from: from + 1, to, before: from, lines: [], ids: [], gap: "log rotated or index recovery required", generation: stat(file).ino };
61
+ // Prefer the newest complete lines within the response byte budget.
62
+ let first = availableFrom;
63
+ while (end - position(first - from) > LOG_LIMITS.windowBytes && first < to - 1) first++;
64
+ const pos = position(first - from);
65
+ const data = readRange(file, pos, Math.min(end - pos, LOG_LIMITS.windowBytes), meter).toString("utf8");
66
+ const parsed = data.split("\n").filter(Boolean).map(redactLine);
67
+ const items = boundedItems(parsed.map((line, i) => ({ line, id: first + i + 1 })));
68
+ const actualFirst = items[0]?.id || first + 1;
69
+ return { total, from: actualFirst, to: items.at(-1)?.id || first, before: actualFirst - 1, lines: items.map(x => x.line), ids: items.map(x => x.id), ...(actualFirst - 1 > requestedFrom ? { gap: actualFirst - 1 > from ? "window byte limit or interrupted projection; request an older page" : "rotation/retention removed earlier history" } : {}), generation: stat(file).ino };
70
+ }
71
+ const missingIndex = readJson(join(dir, "log-meta.json"));
72
+ if (missingIndex) return { total: missingIndex.lastSequence, from: missingIndex.lastSequence + 1, to: missingIndex.lastSequence, before: 0, lines: [], ids: [], gap: "projection index unavailable; restore it from the journal before replay", generation: stat(file).ino };
73
+ if (!size) return { total: 0, from: 1, to: 0, before: 0, lines: [], ids: [], generation: stat(file).ino };
74
+ // Small legacy logs keep exact line-number contracts. Large legacy histories
75
+ // use stable byte positions until a coordinator has built their projection.
76
+ const byteMode = size > LOG_LIMITS.windowBytes;
77
+ const end = byteMode && before !== null ? Math.min(size, Math.max(0, Number(before) - 1)) : size;
78
+ const start = Math.max(0, end - LOG_LIMITS.windowBytes);
79
+ const data = readRange(file, start, end - start, meter);
80
+ let pos = 0, items = [];
81
+ while (pos < data.length) {
82
+ const newline = data.indexOf(10, pos), endOfLine = newline < 0 ? data.length : newline;
83
+ const part = data.subarray(pos, endOfLine).toString("utf8");
84
+ if (part && !(start > 0 && pos === 0)) items.push({ line: redactLine(part), id: byteMode ? start + pos + 1 : items.length + 1 });
85
+ pos = endOfLine + 1;
86
+ }
87
+ const total = byteMode ? size : items.length;
88
+ if (!byteMode && before !== null) items = items.slice(0, Math.min(items.length, Math.max(0, Number(before) - 1)));
89
+ if (after !== null) items = items.filter(item => item.id > after).slice(0, n);
90
+ else items = items.slice(-n);
91
+ items = boundedItems(items);
92
+ return { total, from: items[0]?.id ?? 1, to: items.at(-1)?.id ?? 0, before: Math.max(0, (items[0]?.id ?? 1) - 1), lines: items.map(x => x.line), ids: items.map(x => x.id), ...(byteMode ? { gap: "legacy log has no line index; replay IDs use byte positions", byteMode: true } : {}), generation: stat(file).ino };
93
+ }
94
+ const redactLine = line => Buffer.byteLength(line) > LOG_LIMITS.lineBytes ? "[gap: oversized log record]" : redact(line);
@@ -0,0 +1,87 @@
1
+ import { dirname, delimiter, join } from "node:path";
2
+ import { readFileSync, writeFileSync, realpathSync, openSync, closeSync, rmSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { repositoryPath } from "./repository_files.mjs";
5
+
6
+ export const shellQuote = value => `'${value.replaceAll("'", "'\\''")}'`;
7
+
8
+ // Use the interpreter that successfully started Gallivant, not a second Node
9
+ // selected by a harness shell snapshot (which may have a different ABI/arch).
10
+ export function runtimeEnvironment(env = process.env, node = process.execPath, cwd) {
11
+ const bin = dirname(node);
12
+ const cache = cwd ? join(cwd, ".cache", "gallivant", "npm") : null;
13
+ return {
14
+ PATH: [bin, ...(env.PATH || "/usr/bin:/bin").split(delimiter).filter(p => p && p !== bin)].join(delimiter),
15
+ ...(cache ? { NPM_CONFIG_CACHE: cache, npm_config_cache: cache } : {}),
16
+ };
17
+ }
18
+
19
+ // Materialize only at dispatch, never while describing/speculating a launch.
20
+ // Keep npm ci's cache outside node_modules and ignored without changing a
21
+ // repository's tracked ignore rules or granting access to the operator's cache.
22
+ export function prepareRuntimeCache(cwd, env) {
23
+ if (!cwd || env.NPM_CONFIG_CACHE !== runtimeEnvironment({}, process.execPath, cwd).NPM_CONFIG_CACHE) return;
24
+ const ignore = repositoryPath(cwd, ".cache/gallivant/npm/.gitignore", { createParents: true });
25
+ try { writeFileSync(ignore, "*\n", { flag: "wx", mode: 0o600 }); }
26
+ catch (error) {
27
+ if (error.code !== "EEXIST" || readFileSync(ignore, "utf8") !== "*\n") throw error;
28
+ }
29
+ }
30
+
31
+ export function sessionExports(node = process.execPath, cwd) {
32
+ const env = runtimeEnvironment({}, node, cwd);
33
+ return `export PATH=${shellQuote(dirname(node))}:"$PATH"\n` +
34
+ Object.entries(env).filter(([key]) => key !== "PATH").map(([key, value]) => `export ${key}=${shellQuote(value)}\n`).join("");
35
+ }
36
+
37
+ export function runtimeReadPaths(node = process.execPath) {
38
+ const paths = [realpathSync(node)];
39
+ for (const name of ["npm", "npx", "pnpm", "corepack"]) {
40
+ let target;
41
+ try { target = realpathSync(join(dirname(node), name)); } catch { continue; }
42
+ let directory = dirname(target);
43
+ for (let depth = 0; depth < 5; depth++) {
44
+ try {
45
+ const pkg = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
46
+ if (["npm", "pnpm", "corepack"].includes(pkg.name)) { paths.push(directory); break; }
47
+ } catch {}
48
+ const parent = dirname(directory);
49
+ if (parent === directory) break;
50
+ directory = parent;
51
+ }
52
+ }
53
+ return [...new Set(paths)];
54
+ }
55
+
56
+ export const RUNTIME_SETTINGS_FILE = "runtime-settings.json";
57
+
58
+ export function claudeRuntimeSettings(settings, destination, node = process.execPath) {
59
+ if (!settings) return settings;
60
+ // Leave missing settings to the native client's existing error handling.
61
+ let source;
62
+ try { source = readFileSync(settings, "utf8"); }
63
+ catch (error) { if (error.code === "ENOENT") return settings; throw error; }
64
+ const original = JSON.parse(source);
65
+ const command = `${shellQuote(node)} ${shellQuote(fileURLToPath(new URL("../bin/session-runtime.mjs", import.meta.url)))}`;
66
+ const merged = {
67
+ ...original,
68
+ sandbox: {
69
+ ...original.sandbox,
70
+ filesystem: {
71
+ ...original.sandbox?.filesystem,
72
+ allowRead: [...new Set([...(original.sandbox?.filesystem?.allowRead || []), ...runtimeReadPaths(node)])],
73
+ },
74
+ },
75
+ hooks: {
76
+ ...original.hooks,
77
+ SessionStart: [...(original.hooks?.SessionStart || []), { hooks: [{ type: "command", command }] }],
78
+ },
79
+ };
80
+ // The actual launcher owns this private file, outside the checkout. Exclusive
81
+ // creation refuses symlinks/existing snapshots and never edits source settings.
82
+ const content = JSON.stringify(merged), fd = openSync(destination, "wx", 0o600);
83
+ try { writeFileSync(fd, content); }
84
+ catch (error) { rmSync(destination, { force: true }); throw error; }
85
+ finally { closeSync(fd); }
86
+ return destination;
87
+ }
@@ -0,0 +1,15 @@
1
+ // Stable dependency constructors for Node adapter integrations and conformance.
2
+ export { makeState, newEntry, STATES, idSlug } from "./state.mjs";
3
+ export { DEFAULTS, DEFAULT_DELIVERABLE } from "./config.mjs";
4
+ export { silentLog, makeLog } from "./log.mjs";
5
+ export { NO_IDENTITY } from "./identity.mjs";
6
+ export { MIN_VERSIONS, PREPARATION_BOUNDARY } from "./harness.mjs";
7
+ export { readJson, writeJsonAtomic, writeText } from "./fs.mjs";
8
+ export { collectCommands } from "./task_contract.mjs";
9
+ export { logWindow, appendProjection } from "./run_log.mjs";
10
+ export { makeTick } from "./tick.mjs";
11
+ export { requestControl, controlAvailability } from "./control.mjs";
12
+ export { ready } from "./commands/ready.mjs";
13
+ export { normalizePublicBody } from "./block.mjs";
14
+ export { criteriaFromPrd } from "./deliverable.mjs";
15
+ export { parsePrd } from "./prd.mjs";