@humwick/runner 0.0.0-stage → 0.130.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 (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +42 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,296 @@
1
+ import { accessSync, appendFileSync, constants, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { chmod, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { sanitizeFsName } from "@humwick/sdk/fs-name";
6
+ import { spawnSyncBounded } from "./bounded-spawn-sync";
7
+
8
+ const SCRATCH_DIR_NAME = ".agent-relay";
9
+ // The local-ignore entry. Leading + trailing slash scopes it to the dir at the
10
+ // base, matching git's gitignore semantics.
11
+ const EXCLUDE_ENTRY = "/.agent-relay/";
12
+ const LAUNCHER_DIR = join(tmpdir(), `agent-relay-launchers-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
13
+ const DEFAULT_LAUNCHER_SWEEP_MIN_AGE_MS = 24 * 60 * 60 * 1000;
14
+
15
+ export interface SessionScratchLayout {
16
+ baseDir: string; // dir that contains .agent-relay (cwd, or fallback base dir)
17
+ rootDir: string; // <base>/.agent-relay
18
+ sessionsDir: string; // <base>/.agent-relay/sessions
19
+ sessionDir: string; // <base>/.agent-relay/sessions/<id>
20
+ tmpDir: string; // <base>/.agent-relay/sessions/<id>/tmp
21
+ replyFile: string; // <tmp>/reply.md
22
+ }
23
+
24
+ interface SessionScratchTarget {
25
+ agentId: string;
26
+ cwd: string;
27
+ // Orchestrator base dir, used only when cwd is not writable. NEVER home — a
28
+ // home fallback would place scratch above the orchestrator base dir and break
29
+ // cwd containment.
30
+ fallbackBaseDir?: string;
31
+ }
32
+
33
+ interface SessionLauncherTarget {
34
+ launcherScript?: string;
35
+ launcherDir?: string;
36
+ tmuxSession?: string;
37
+ }
38
+
39
+ function isWritableDir(dir: string): boolean {
40
+ try {
41
+ if (!statSync(dir).isDirectory()) return false;
42
+ accessSync(dir, constants.W_OK);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ // Prefer cwd; fall back to the orchestrator base dir only when cwd is not a
50
+ // writable dir. Returns cwd as a last resort so callers can still attempt mkdir
51
+ // (and surface the real error) rather than silently picking an unrelated path.
52
+ export function resolveScratchBase(cwd: string, fallbackBaseDir?: string): string {
53
+ if (isWritableDir(cwd)) return cwd;
54
+ if (fallbackBaseDir && isWritableDir(fallbackBaseDir)) return fallbackBaseDir;
55
+ return cwd;
56
+ }
57
+
58
+ function sessionScratchLayout(baseDir: string, agentId: string): SessionScratchLayout {
59
+ const rootDir = join(baseDir, SCRATCH_DIR_NAME);
60
+ const sessionsDir = join(rootDir, "sessions");
61
+ const sessionDir = join(sessionsDir, agentId);
62
+ const tmpDir = join(sessionDir, "tmp");
63
+ return { baseDir, rootDir, sessionsDir, sessionDir, tmpDir, replyFile: join(tmpDir, "reply.md") };
64
+ }
65
+
66
+ // Resolve git's exclude file for baseDir, correctly handling plain repos,
67
+ // worktrees, and submodules (git rev-parse returns the shared commondir's
68
+ // info/exclude). Returns null when baseDir is not inside a git work tree.
69
+ function gitExcludePath(baseDir: string): string | null {
70
+ try {
71
+ const result = spawnSyncBounded(["git", "-C", baseDir, "rev-parse", "--git-path", "info/exclude"], {
72
+ stdin: "ignore", stdout: "pipe", stderr: "ignore",
73
+ });
74
+ if (result.exitCode !== 0) return null;
75
+ const out = result.stdout.toString().trim();
76
+ if (!out) return null;
77
+ return isAbsolute(out) ? out : resolve(baseDir, out);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // Idempotently append EXCLUDE_ENTRY to an ignore file (grep-before-write).
84
+ function appendIgnoreEntry(file: string): void {
85
+ let existing = "";
86
+ try {
87
+ existing = readFileSync(file, "utf8");
88
+ } catch {}
89
+ const wanted = EXCLUDE_ENTRY.trim();
90
+ if (existing.split("\n").some((line) => line.trim() === wanted)) return;
91
+ mkdirSync(dirname(file), { recursive: true });
92
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
93
+ appendFileSync(file, `${prefix}${EXCLUDE_ENTRY}\n`);
94
+ }
95
+
96
+ // Ensure `.agent-relay/` is locally ignored. Prefers .git/info/exclude (git's
97
+ // local, uncommitted ignore list) so no tracked file is ever modified; falls
98
+ // back to .gitignore only when baseDir is not a git repo. Returns which file was
99
+ // used (for logging/tests).
100
+ export function ensureScratchIgnored(baseDir: string): "exclude" | "gitignore" {
101
+ const excludePath = gitExcludePath(baseDir);
102
+ if (excludePath) {
103
+ appendIgnoreEntry(excludePath);
104
+ return "exclude";
105
+ }
106
+ appendIgnoreEntry(join(baseDir, ".gitignore"));
107
+ return "gitignore";
108
+ }
109
+
110
+ // SessionStart: create the session tmp dir and ensure local ignore. Dir
111
+ // creation is the load-bearing part; ignore setup is best-effort.
112
+ //
113
+ // #2310 P1 — this dir can hold sensitive spilled content (an over-cap memory/continuation
114
+ // injection's full text, #1935's monitor over-limit handling), so it's session-private: 0700, not
115
+ // the default (umask-dependent, typically 0755/0775). `mode` here is safe from umask stripping it
116
+ // back down: umask can only CLEAR bits from the requested mode, and 0700 already has none of the
117
+ // group/other bits a realistic umask would clear — so this always lands exactly on 0700, with no
118
+ // separate chmod needed (avoiding both a redundant blocking call and a chmod-vs-mkdir TOCTOU gap).
119
+ export function ensureSessionScratch(target: SessionScratchTarget): SessionScratchLayout {
120
+ const base = resolveScratchBase(target.cwd, target.fallbackBaseDir);
121
+ const layout = sessionScratchLayout(base, target.agentId);
122
+ mkdirSync(layout.tmpDir, { recursive: true, mode: 0o700 });
123
+ try {
124
+ ensureScratchIgnored(base);
125
+ } catch {
126
+ // best-effort: a missing ignore entry never pollutes git because the dir is
127
+ // already created and the entry is the only thing at risk.
128
+ }
129
+ return layout;
130
+ }
131
+
132
+ function resolveLauncherDir(launcherDir?: string): string {
133
+ return launcherDir ?? LAUNCHER_DIR;
134
+ }
135
+
136
+ export function launcherScriptPathForSession(sessionName: string, launcherDir?: string): string {
137
+ const sanitized = sanitizeFsName(sessionName, { replacement: "-", collapse: false });
138
+ return join(resolveLauncherDir(launcherDir), `${sanitized}.sh`);
139
+ }
140
+
141
+ // Named-pipe path the launcher script blocks on before exec'ing the real provider
142
+ // command (#1317) — lets the caller attach `tmux pipe-pane` while the pane is still
143
+ // idle, so a process that exits within milliseconds can't slip output past it.
144
+ export function launcherGatePathForSession(sessionName: string, launcherDir?: string): string {
145
+ return `${launcherScriptPathForSession(sessionName, launcherDir)}.gate`;
146
+ }
147
+
148
+ /** Stage a transient provider's one-shot user assignment before its session starts. */
149
+ export async function stageOneShotPrompt(stdinFile: string, exitCodeFile: string, prompt: string): Promise<void> {
150
+ await writeFile(stdinFile, prompt, { mode: 0o600 });
151
+ await chmod(stdinFile, 0o600);
152
+ await rm(exitCodeFile, { force: true });
153
+ }
154
+
155
+ /** Missing is deliberately distinct from a clean exit: the launcher may have been killed. */
156
+ export async function readOneShotExitCode(exitCodeFile: string): Promise<string | undefined> {
157
+ try { return (await readFile(exitCodeFile, "utf8")).trim(); } catch { return undefined; }
158
+ }
159
+
160
+ function dedupeBases(bases: Array<string | undefined>): string[] {
161
+ const seen = new Set<string>();
162
+ const out: string[] = [];
163
+ for (const b of bases) {
164
+ if (!b || seen.has(b)) continue;
165
+ seen.add(b);
166
+ out.push(b);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function dedupePaths(paths: Array<string | undefined>): string[] {
172
+ const seen = new Set<string>();
173
+ const out: string[] = [];
174
+ for (const path of paths) {
175
+ if (!path || seen.has(path)) continue;
176
+ seen.add(path);
177
+ out.push(path);
178
+ }
179
+ return out;
180
+ }
181
+
182
+ // SessionEnd: remove this session's dir from every candidate base.
183
+ export function reapSessionScratch(target: SessionScratchTarget): void {
184
+ for (const base of dedupeBases([target.cwd, target.fallbackBaseDir])) {
185
+ const { sessionDir } = sessionScratchLayout(base, target.agentId);
186
+ try {
187
+ rmSync(sessionDir, { recursive: true, force: true });
188
+ } catch {}
189
+ }
190
+ }
191
+
192
+ // Claude headless sessions externalize their provider launch command into a
193
+ // per-session script under /tmp. Remove it on runner shutdown alongside the
194
+ // scratch dir reap to avoid leaking one file per spawn.
195
+ export function reapSessionLauncher(target: SessionLauncherTarget): string[] {
196
+ const removed: string[] = [];
197
+ for (const path of dedupePaths([
198
+ target.launcherScript,
199
+ target.tmuxSession ? launcherScriptPathForSession(target.tmuxSession, target.launcherDir) : undefined,
200
+ target.tmuxSession ? launcherGatePathForSession(target.tmuxSession, target.launcherDir) : undefined,
201
+ ])) {
202
+ try {
203
+ const exists = statSync(path).isFile();
204
+ rmSync(path, { force: true });
205
+ if (exists) removed.push(path);
206
+ } catch {}
207
+ }
208
+ return removed;
209
+ }
210
+
211
+ interface SweepOptions {
212
+ cwd: string;
213
+ fallbackBaseDir?: string;
214
+ // Agent ids to keep (currently-known agents + self). Any session dir whose id
215
+ // is not in this set AND older than minAgeMs is removed.
216
+ keepAgentIds: Set<string>;
217
+ now: number;
218
+ minAgeMs?: number;
219
+ /** Caps a single background pass so a historical buildup cannot monopolize I/O. */
220
+ maxEntries?: number;
221
+ }
222
+
223
+ interface LauncherSweepOptions {
224
+ // Agent ids to keep (currently-known agents + self). Launcher scripts embed
225
+ // AGENT_RELAY_ID, so a stale sweep can safely remove only scripts whose agent
226
+ // is no longer live.
227
+ keepAgentIds: Set<string>;
228
+ launcherDir?: string;
229
+ now: number;
230
+ minAgeMs?: number;
231
+ maxEntries?: number;
232
+ }
233
+
234
+ function maybeShellUnquote(value: string): string {
235
+ const trimmed = value.trim();
236
+ if (!trimmed.startsWith("'") || !trimmed.endsWith("'")) return trimmed;
237
+ return trimmed.slice(1, -1).replace(/'\\''/g, "'");
238
+ }
239
+
240
+ async function launcherAgentId(scriptPath: string): Promise<string | null> {
241
+ try {
242
+ const match = (await Bun.file(scriptPath).text()).match(/^export AGENT_RELAY_ID=(.+)$/m);
243
+ if (!match) return null;
244
+ const agentId = maybeShellUnquote(match[1] ?? "");
245
+ return agentId || null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ // Periodic, bounded sweep of leftover session dirs from agents no longer known to the
252
+ // relay. The minAge grace avoids racing a peer that just created its dir but has not yet
253
+ // appeared in the agent list.
254
+ export async function sweepStaleSessionsAsync(opts: SweepOptions): Promise<string[]> {
255
+ const minAge = opts.minAgeMs ?? 60 * 60 * 1000;
256
+ const maxEntries = opts.maxEntries ?? 100;
257
+ const removed: string[] = [];
258
+ for (const base of dedupeBases([opts.cwd, opts.fallbackBaseDir])) {
259
+ const sessionsDir = join(base, SCRATCH_DIR_NAME, "sessions");
260
+ let ids: string[];
261
+ try { ids = await readdir(sessionsDir); } catch { continue; }
262
+ for (const id of ids.slice(0, maxEntries)) {
263
+ if (opts.keepAgentIds.has(id)) continue;
264
+ const dir = join(sessionsDir, id);
265
+ try {
266
+ if (opts.now - (await stat(dir)).mtimeMs < minAge) continue;
267
+ await rm(dir, { recursive: true, force: true });
268
+ removed.push(dir);
269
+ } catch {}
270
+ }
271
+ }
272
+ return removed;
273
+ }
274
+
275
+ // Periodic, bounded sweep of leaked per-spawn launcher scripts. Old scripts whose agent
276
+ // id is no longer live are safe to prune; old unparsable files are treated as stale too.
277
+ export async function sweepStaleLaunchersAsync(opts: LauncherSweepOptions): Promise<string[]> {
278
+ const minAge = opts.minAgeMs ?? DEFAULT_LAUNCHER_SWEEP_MIN_AGE_MS;
279
+ const maxEntries = opts.maxEntries ?? 100;
280
+ const removed: string[] = [];
281
+ const launcherDir = resolveLauncherDir(opts.launcherDir);
282
+ let entries: string[];
283
+ try { entries = await readdir(launcherDir); } catch { return removed; }
284
+ for (const entry of entries.slice(0, maxEntries)) {
285
+ const scriptPath = join(launcherDir, entry);
286
+ try {
287
+ const current = await stat(scriptPath);
288
+ if (!current.isFile() || opts.now - current.mtimeMs < minAge) continue;
289
+ const agentId = await launcherAgentId(scriptPath);
290
+ if (agentId && opts.keepAgentIds.has(agentId)) continue;
291
+ await rm(scriptPath, { force: true });
292
+ removed.push(scriptPath);
293
+ } catch {}
294
+ }
295
+ return removed;
296
+ }
@@ -0,0 +1,147 @@
1
+ import { basename } from "node:path";
2
+ import { isatty } from "node:tty";
3
+ import { LAUNCH_MODES, isLaunchMode, type LaunchMode, type SpawnProvider } from "@humwick/sdk";
4
+ import { parseResolvedModelRecord, type ResolvedModelRecord } from "@humwick/sdk/provider-catalog";
5
+ import { getAllManifests } from "@humwick/providers";
6
+
7
+ export interface RunnerCliOptions {
8
+ provider: SpawnProvider;
9
+ model?: string;
10
+ effort?: string;
11
+ /** #2595 — effective-catalog window resolved server-side; preferred over the static manifest. */
12
+ contextWindowTokens?: number;
13
+ /** #2599 — the full server-resolved model record (protocol ≥ 2); precedence payload > manifest. */
14
+ modelRecord?: ResolvedModelRecord;
15
+ headless: boolean;
16
+ interactive: boolean;
17
+ /** #2773 — interactive|headless driver; absent keeps the provider's legacy selection. */
18
+ launchMode?: LaunchMode;
19
+ cwd?: string;
20
+ rig?: string;
21
+ profile?: string;
22
+ relayUrl?: string;
23
+ token?: string;
24
+ approvalMode?: string;
25
+ label?: string;
26
+ agentId?: string;
27
+ prompt?: string;
28
+ systemPromptAppend?: string;
29
+ tags: string[];
30
+ caps: string[];
31
+ providerArgs: string[];
32
+ help: boolean;
33
+ }
34
+
35
+ function detectProviderFromBin(argv: string[], processBin = process.env._): SpawnProvider {
36
+ const bin = [argv[1], processBin].map((s) => basename(s || "")).join(" ");
37
+ for (const manifest of getAllManifests()) {
38
+ if (bin.includes(manifest.id)) return manifest.id as SpawnProvider;
39
+ }
40
+ return getAllManifests()[0]?.id as SpawnProvider ?? "claude";
41
+ }
42
+
43
+ function isRegisteredProvider(value: string): value is SpawnProvider {
44
+ return getAllManifests().some((manifest) => manifest.id === value);
45
+ }
46
+
47
+ function csvSplit(value: string): string[] {
48
+ return value.split(",").map((part) => part.trim()).filter(Boolean);
49
+ }
50
+
51
+ /** Pure runner argv parsing shared by the production CLI and effective-launch harness. */
52
+ export function parseRunnerCliArgs(
53
+ argv: string[],
54
+ options: { stdinIsTty?: boolean; processBin?: string } = {},
55
+ ): RunnerCliOptions {
56
+ let provider = detectProviderFromBin(argv, options.processBin);
57
+ const relayArgs = argv.slice(2);
58
+ const providerSep = relayArgs.indexOf("--");
59
+ const ownArgs = providerSep >= 0 ? relayArgs.slice(0, providerSep) : relayArgs;
60
+ const providerArgs = providerSep >= 0 ? relayArgs.slice(providerSep + 1) : [];
61
+ let headless = false;
62
+ let launchMode: LaunchMode | undefined;
63
+ let help = false;
64
+ let model: string | undefined;
65
+ let effort: string | undefined;
66
+ let contextWindowTokens: number | undefined;
67
+ // #2599 — raw --model-record-* fields, assembled then parsed ONCE through the shared parser
68
+ // (the same parser the orchestrator uses), so CLI parsing can never drift from the payload seam.
69
+ const modelRecordRaw: Record<string, unknown> = {};
70
+ let cwd: string | undefined;
71
+ let rig: string | undefined;
72
+ let profile: string | undefined;
73
+ let relayUrl: string | undefined;
74
+ let token: string | undefined;
75
+ let approvalMode: string | undefined;
76
+ let label: string | undefined;
77
+ let agentId: string | undefined;
78
+ let prompt: string | undefined;
79
+ let systemPromptAppend: string | undefined;
80
+ let tags: string[] = [];
81
+ let caps: string[] = [];
82
+
83
+ for (let i = 0; i < ownArgs.length; i += 1) {
84
+ const arg = ownArgs[i];
85
+ if (arg && isRegisteredProvider(arg)) provider = arg;
86
+ else if (arg === "--headless") headless = true;
87
+ else if (arg === "--launch-mode" && ownArgs[i + 1]) {
88
+ const value = ownArgs[++i];
89
+ if (!isLaunchMode(value)) throw new Error(`--launch-mode must be one of: ${LAUNCH_MODES.join(", ")}`);
90
+ launchMode = value;
91
+ }
92
+ else if (arg === "--model" && ownArgs[i + 1]) model = ownArgs[++i];
93
+ else if (arg === "--effort" && ownArgs[i + 1]) effort = ownArgs[++i];
94
+ // #2595 — server-resolved effective-catalog window; positive-integer gate keeps a malformed
95
+ // value from ever becoming a fabricated denominator (openCodeContextState would clamp anyway).
96
+ else if (arg === "--context-window-tokens" && ownArgs[i + 1]) {
97
+ const parsed = Number(ownArgs[++i]);
98
+ contextWindowTokens = Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
99
+ }
100
+ // #2599 — full-record fields (protocol ≥ 2). Values ride raw; the shared parser at the end
101
+ // applies the SAME gates as the orchestrator seam. --model-record-threshold carries a JSON
102
+ // threshold object; a malformed JSON is untrusted and drops the whole record field set to
103
+ // the parser's verdict (never a fabricated threshold).
104
+ else if (arg === "--model-record-alias" && ownArgs[i + 1]) modelRecordRaw.alias = ownArgs[++i];
105
+ else if (arg === "--model-record-provider-model" && ownArgs[i + 1]) modelRecordRaw.providerModel = ownArgs[++i];
106
+ else if (arg === "--model-record-qualified" && ownArgs[i + 1]) modelRecordRaw.qualifiedModel = ownArgs[++i];
107
+ else if (arg === "--model-record-effort" && ownArgs[i + 1]) modelRecordRaw.effort = ownArgs[++i];
108
+ else if (arg === "--model-record-window" && ownArgs[i + 1]) {
109
+ const parsed = Number(ownArgs[++i]);
110
+ if (Number.isSafeInteger(parsed) && parsed > 0) modelRecordRaw.contextWindowTokens = parsed;
111
+ }
112
+ else if (arg === "--model-record-max-output" && ownArgs[i + 1]) {
113
+ const parsed = Number(ownArgs[++i]);
114
+ if (Number.isSafeInteger(parsed) && parsed > 0) modelRecordRaw.maxOutputTokens = parsed;
115
+ }
116
+ else if (arg === "--model-record-threshold" && ownArgs[i + 1]) {
117
+ try {
118
+ const parsed = JSON.parse(ownArgs[++i]!) as unknown;
119
+ if (parsed !== undefined) modelRecordRaw.contextThreshold = parsed;
120
+ } catch {
121
+ // Malformed threshold JSON is untrusted — omit it; the record still parses without it.
122
+ }
123
+ }
124
+ else if (arg === "--cwd" && ownArgs[i + 1]) cwd = ownArgs[++i];
125
+ else if (arg === "--rig" && ownArgs[i + 1]) rig = ownArgs[++i];
126
+ else if (arg === "--profile" && ownArgs[i + 1]) profile = ownArgs[++i];
127
+ else if (arg === "--relay-url" && ownArgs[i + 1]) relayUrl = ownArgs[++i];
128
+ else if (arg === "--token" && ownArgs[i + 1]) token = ownArgs[++i];
129
+ else if ((arg === "--approval" || arg === "--approval-mode") && ownArgs[i + 1]) approvalMode = ownArgs[++i];
130
+ else if (arg === "--label" && ownArgs[i + 1]) label = ownArgs[++i];
131
+ else if (arg === "--agent-id" && ownArgs[i + 1]) agentId = ownArgs[++i];
132
+ else if (arg === "--prompt" && ownArgs[i + 1]) prompt = ownArgs[++i];
133
+ else if ((arg === "--system-prompt-append" || arg === "--append-system-prompt") && ownArgs[i + 1]) systemPromptAppend = ownArgs[++i];
134
+ else if (arg === "--tags" && ownArgs[i + 1]) tags = csvSplit(ownArgs[++i]!);
135
+ else if (arg === "--caps" && ownArgs[i + 1]) caps = csvSplit(ownArgs[++i]!);
136
+ else if (arg === "--help" || arg === "-h") help = true;
137
+ else if (arg !== undefined) providerArgs.push(arg);
138
+ }
139
+
140
+ const interactive = !headless && (options.stdinIsTty ?? isatty(0));
141
+ const modelRecord = Object.keys(modelRecordRaw).length ? parseResolvedModelRecord(modelRecordRaw) : undefined;
142
+ return { provider, model, effort, ...(contextWindowTokens !== undefined ? { contextWindowTokens } : {}), ...(modelRecord ? { modelRecord } : {}), headless, interactive, ...(launchMode ? { launchMode } : {}), cwd, rig, profile, relayUrl, token, approvalMode, label, agentId, prompt, systemPromptAppend, tags, caps, providerArgs, help };
143
+ }
144
+
145
+ export function runnerHelp(provider: string): string {
146
+ return `${provider}-relay [--headless] [--launch-mode interactive|headless] [--model ALIAS] [--effort LEVEL] [--context-window-tokens N] [--model-record-alias A --model-record-provider-model M --model-record-qualified Q --model-record-effort E --model-record-window N --model-record-max-output N --model-record-threshold JSON] [--rig NAME] [--profile NAME] [--cwd PATH] [--relay-url URL] [--approval MODE] [--label NAME] [--agent-id ID] [--prompt TEXT] [--system-prompt-append TEXT] [--tags a,b] [--caps x,y] [-- provider-args...]`;
147
+ }
@@ -0,0 +1,48 @@
1
+ import { ReconnectionManager, RELAY_TOKEN_HEADER, errMessage } from "@humwick/sdk";
2
+ import type { SettingEntry } from "@humwick/sdk";
3
+ import { isRetryableTransportError } from "./transport-errors";
4
+
5
+ export interface StartupFetchDeps {
6
+ deadlineAt?: number;
7
+ now: () => number;
8
+ sleep: (ms: number) => Promise<unknown>;
9
+ reconnect: Pick<ReconnectionManager, "nextDelay">;
10
+ }
11
+
12
+ class TransientHubError extends Error {}
13
+
14
+ function transient(error: unknown): boolean {
15
+ return error instanceof TransientHubError || isRetryableTransportError(error)
16
+ || (error instanceof Error && error.name === "TimeoutError");
17
+ }
18
+
19
+ // Use registration's backoff and initial-handshake budget for managed bootstrap reads (#3326).
20
+ export async function fetchStartupConfig(url: URL, token: string | undefined, timeoutMs: number, deps: Partial<StartupFetchDeps> = {}) {
21
+ const d = { now: Date.now, sleep: Bun.sleep, reconnect: new ReconnectionManager(), ...deps };
22
+ const deadline = d.deadlineAt ?? d.now() + timeoutMs;
23
+ for (;;) {
24
+ if (d.deadlineAt !== undefined && d.now() >= deadline) throw new Error("provider config retry budget expired");
25
+ try {
26
+ const response = await fetch(url, {
27
+ headers: token ? { [RELAY_TOKEN_HEADER]: token } : undefined,
28
+ signal: AbortSignal.timeout(timeoutMs > 0 ? Math.max(1, Math.min(5_000, deadline - d.now())) : 5_000),
29
+ });
30
+ if (response.status >= 500) {
31
+ await response.body?.cancel();
32
+ throw new TransientHubError(`HTTP ${response.status}`);
33
+ }
34
+ const body = await response.json().catch((error: unknown) => {
35
+ if (response.ok && transient(error)) throw error;
36
+ return null;
37
+ }) as (Partial<SettingEntry> & { error?: string }) | null;
38
+ return { response, body };
39
+ } catch (error) {
40
+ const remaining = deadline - d.now();
41
+ if (!transient(error) || remaining <= 0) throw error;
42
+ const delay = Math.min(d.reconnect.nextDelay(), remaining);
43
+ console.warn(`[agent-relay] provider config relay read failed (${errMessage(error)}); retrying in ${delay}ms.`);
44
+ await d.sleep(delay);
45
+ if (d.now() >= deadline) throw error;
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { dlopen } from "bun:ffi";
3
+ import { SYSTEMD_MANAGED_SESSION_PREFIX } from "@humwick/sdk/systemd-unit";
4
+
5
+ const REAP_INTERVAL_MS = 5_000;
6
+ const ZOMBIE_GRACE_MS = 5_000;
7
+ let reaperStarted = false;
8
+
9
+ export function hasSystemdSessionOwner(cgroup: string, parentComm: string): boolean {
10
+ if (parentComm.trim() !== "systemd") return false;
11
+ return cgroup.split("\n").some((line) => line.startsWith("0::/") && line.slice(4).split("/").some((part) =>
12
+ part.startsWith(SYSTEMD_MANAGED_SESSION_PREFIX)
13
+ && part.length > SYSTEMD_MANAGED_SESSION_PREFIX.length + ".service".length
14
+ && part.endsWith(".service")));
15
+ }
16
+
17
+ function zombieChildren(): Map<number, string> {
18
+ const zombies = new Map<number, string>();
19
+ // A process may fork from any Bun thread. The per-thread children files avoid
20
+ // reading every process on a busy host for every managed runner.
21
+ for (const tid of readdirSync("/proc/self/task")) {
22
+ let children: string;
23
+ try { children = readFileSync(`/proc/self/task/${tid}/children`, "utf8"); }
24
+ catch { continue; } // A thread can disappear between readdir and read.
25
+ for (const child of children.trim().split(/\s+/)) {
26
+ if (!child) continue;
27
+ const pid = Number(child);
28
+ let stat: string;
29
+ try { stat = readFileSync(`/proc/${pid}/stat`, "utf8"); }
30
+ catch { continue; } // The child may have exited and been reaped already.
31
+ // comm is parenthesized and may itself contain spaces or closing parens.
32
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
33
+ if (fields[0] === "Z" && Number(fields[1]) === process.pid) {
34
+ zombies.set(pid, fields[19] ?? ""); // starttime distinguishes PID reuse.
35
+ }
36
+ }
37
+ }
38
+ return zombies;
39
+ }
40
+
41
+ // Dedicated systemd sessions already own descendant teardown via KillMode=control-group.
42
+ // Let their manager promptly reap orphans (#2858); only unsupervised runners need
43
+ // to adopt them here. Darwin has no process-local equivalent; launchd remains the reaper.
44
+ export function enableRunnerSubreaper(): boolean {
45
+ if (process.platform !== "linux") return false;
46
+ try {
47
+ if (hasSystemdSessionOwner(readFileSync("/proc/self/cgroup", "utf8"), readFileSync(`/proc/${process.ppid}/comm`, "utf8"))) return false;
48
+ } catch { /* Unknown ownership keeps the fallback reaper. */ }
49
+ try {
50
+ const mappedLibc = readFileSync("/proc/self/maps", "utf8")
51
+ .split("\n")
52
+ .map((line) => line.match(/\s(\/\S*(?:libc\.so(?:\.\d+)*|ld-musl-\S+\.so\.1))$/)?.[1])
53
+ .find((path) => path && existsSync(path));
54
+ const libc = mappedLibc ?? [
55
+ "/lib/x86_64-linux-gnu/libc.so.6",
56
+ "/lib/aarch64-linux-gnu/libc.so.6",
57
+ "/lib64/libc.so.6",
58
+ "/usr/lib/libc.so.6",
59
+ ].find(existsSync);
60
+ if (!libc) {
61
+ console.error("[runner] cannot enable child subreaper: libc was not found");
62
+ return false;
63
+ }
64
+
65
+ const library = dlopen(libc, {
66
+ prctl: { args: ["int", "int", "int", "int", "int"], returns: "int" },
67
+ waitpid: { args: ["int", "ptr", "int"], returns: "int" },
68
+ });
69
+ // PR_SET_CHILD_SUBREAPER = 36 (linux/prctl.h). Children do not inherit the flag
70
+ // across fork/clone; execve preserves it for this process.
71
+ if (library.symbols.prctl(36, 1, 0, 0, 0) !== 0) {
72
+ library.close();
73
+ console.error("[runner] prctl(PR_SET_CHILD_SUBREAPER) failed");
74
+ return false;
75
+ }
76
+
77
+ if (!reaperStarted) {
78
+ reaperStarted = true;
79
+ const firstSeen = new Map<number, { starttime: string; at: number }>();
80
+ const timer = setInterval(() => {
81
+ try {
82
+ const now = Date.now();
83
+ const zombies = zombieChildren();
84
+ for (const [pid, starttime] of zombies) {
85
+ const seen = firstSeen.get(pid);
86
+ if (!seen || seen.starttime !== starttime) {
87
+ firstSeen.set(pid, { starttime, at: now });
88
+ continue;
89
+ }
90
+ if (now - seen.at >= ZOMBIE_GRACE_MS) {
91
+ // WNOHANG = 1. Never waitpid(-1): Bun owns its spawned children's
92
+ // exit statuses and must be allowed to collect them itself.
93
+ library.symbols.waitpid(pid, null, 1);
94
+ firstSeen.delete(pid);
95
+ }
96
+ }
97
+ for (const pid of firstSeen.keys()) {
98
+ if (!zombies.has(pid)) firstSeen.delete(pid);
99
+ }
100
+ } catch (error) {
101
+ console.error(`[runner] child reaper scan failed: ${error}`);
102
+ }
103
+ }, REAP_INTERVAL_MS);
104
+ timer.unref();
105
+ } else {
106
+ library.close();
107
+ }
108
+ return true;
109
+ } catch (error) {
110
+ console.error(`[runner] cannot enable child subreaper: ${error}`);
111
+ return false;
112
+ }
113
+ }
@@ -0,0 +1,29 @@
1
+ import type { HumwickHttpClient } from "@humwick/sdk";
2
+ import { RUNNER_PROMPT_TEMPLATE_DEFAULTS } from "@humwick/sdk/prompt-template-defaults";
3
+
4
+ const RUNNER_DEFAULTS = new Map<string, string>(RUNNER_PROMPT_TEMPLATE_DEFAULTS.map((template) => [template.slug, template.defaultTemplate]));
5
+ const templateCache = new Map<string, string>();
6
+
7
+ function renderValue(value: string | undefined): string {
8
+ return value ?? "";
9
+ }
10
+
11
+ function applyVariables(template: string, vars: Record<string, string>): string {
12
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => renderValue(vars[key]));
13
+ }
14
+
15
+ export async function initializeRunnerTemplates(http: HumwickHttpClient): Promise<void> {
16
+ try {
17
+ const templates = await http.listPromptTemplates();
18
+ templateCache.clear();
19
+ for (const template of templates) templateCache.set(template.slug, template.template);
20
+ } catch {
21
+ templateCache.clear();
22
+ }
23
+ }
24
+
25
+ export function renderRunnerTemplate(slug: string, vars: Record<string, string> = {}): string {
26
+ const template = templateCache.get(slug) ?? RUNNER_DEFAULTS.get(slug);
27
+ if (template === undefined) throw new Error(`unknown runner prompt template slug: ${slug}`);
28
+ return applyVariables(template, vars);
29
+ }