@humwick/runner 0.0.0-stage → 0.130.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.
- package/LICENSE +167 -0
- package/agent-bin/agent-relay-browser +10 -0
- package/agent-bin/humwick +10 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
- package/hooks/fff-search-redirect/hooks.json +13 -0
- package/hooks/rg-r-guard/hooks.json +18 -0
- package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/hooks/web-tools-redirect/hooks.json +13 -0
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
- package/node_modules/@humwick/offload/LICENSE +167 -0
- package/node_modules/@humwick/offload/README.md +57 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
- package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.js +80 -0
- package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
- package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/index.js +5 -0
- package/node_modules/@humwick/offload/dist/index.js.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
- package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/types.js +176 -0
- package/node_modules/@humwick/offload/dist/types.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.js +1 -0
- package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
- package/node_modules/@humwick/offload/package.json +46 -0
- package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
- package/node_modules/@humwick/offload/src/envelope.ts +92 -0
- package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
- package/node_modules/@humwick/offload/src/index.ts +4 -0
- package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
- package/node_modules/@humwick/offload/src/types.ts +235 -0
- package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
- package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
- package/node_modules/@humwick/offload/src/worker.ts +5 -0
- package/package.json +47 -4
- package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
- package/plugins/claude/.claude-plugin/plugin.json +8 -0
- package/plugins/claude/claude-statusline-probe.cjs +144 -0
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
- package/plugins/claude/hooks/gate-run-guard.sh +9 -0
- package/plugins/claude/hooks/gate-run-guard.ts +237 -0
- package/plugins/claude/hooks/hooks.json +158 -0
- package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
- package/plugins/claude/hooks/kill-guard.sh +341 -0
- package/plugins/claude/hooks/kill-guard.ts +337 -0
- package/plugins/claude/hooks/permission-request.sh +22 -0
- package/plugins/claude/hooks/post-compact.sh +10 -0
- package/plugins/claude/hooks/post-tool-use.sh +8 -0
- package/plugins/claude/hooks/pre-compact.sh +12 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
- package/plugins/claude/hooks/relay-status.sh +373 -0
- package/plugins/claude/hooks/session-end.sh +26 -0
- package/plugins/claude/hooks/session-start.sh +31 -0
- package/plugins/claude/hooks/shell-trust.ts +28 -0
- package/plugins/claude/hooks/stop-failure.sh +47 -0
- package/plugins/claude/hooks/stop.sh +48 -0
- package/plugins/claude/hooks/subagent-guard.cjs +26 -0
- package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
- package/plugins/claude/hooks/subagent-start.sh +13 -0
- package/plugins/claude/hooks/subagent-stop.sh +19 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
- package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
- package/plugins/claude/monitors/monitors.json +7 -0
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
- package/plugins/claude/monitors/relay-monitor.ts +58 -0
- package/plugins/claude/skills/disconnect/SKILL.md +16 -0
- package/plugins/claude/skills/label/SKILL.md +23 -0
- package/plugins/claude/skills/message/SKILL.md +40 -0
- package/plugins/claude/skills/pair/SKILL.md +30 -0
- package/plugins/claude/skills/react/SKILL.md +18 -0
- package/plugins/claude/skills/read-message/SKILL.md +24 -0
- package/plugins/claude/skills/reply/SKILL.md +44 -0
- package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
- package/plugins/claude/skills/slice-first/SKILL.md +32 -0
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/claude/skills/status/SKILL.md +16 -0
- package/plugins/claude/skills/tags/SKILL.md +25 -0
- package/plugins/claude/skills/task/SKILL.md +28 -0
- package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
- package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
- package/plugins/codex/skills/disconnect/SKILL.md +15 -0
- package/plugins/codex/skills/guide/SKILL.md +15 -0
- package/plugins/codex/skills/label/SKILL.md +22 -0
- package/plugins/codex/skills/message/SKILL.md +40 -0
- package/plugins/codex/skills/pair/SKILL.md +29 -0
- package/plugins/codex/skills/react/SKILL.md +17 -0
- package/plugins/codex/skills/read-message/SKILL.md +23 -0
- package/plugins/codex/skills/reply/SKILL.md +44 -0
- package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
- package/plugins/codex/skills/slice-first/SKILL.md +32 -0
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/codex/skills/status/SKILL.md +15 -0
- package/plugins/codex/skills/tags/SKILL.md +24 -0
- package/plugins/codex/skills/task/SKILL.md +28 -0
- package/src/adapter.ts +1061 -0
- package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
- package/src/adapters/claude-api.ts +243 -0
- package/src/adapters/claude-context-command.ts +16 -0
- package/src/adapters/claude-credentials.ts +22 -0
- package/src/adapters/claude-delivery-lanes.ts +28 -0
- package/src/adapters/claude-delivery.ts +166 -0
- package/src/adapters/claude-effort.ts +94 -0
- package/src/adapters/claude-exit.ts +290 -0
- package/src/adapters/claude-headless-mirror.ts +88 -0
- package/src/adapters/claude-headless-refusal.ts +51 -0
- package/src/adapters/claude-headless.ts +617 -0
- package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
- package/src/adapters/claude-permission.ts +243 -0
- package/src/adapters/claude-prompt-gates.ts +269 -0
- package/src/adapters/claude-prompt-hook.ts +145 -0
- package/src/adapters/claude-session-capture.ts +293 -0
- package/src/adapters/claude-session-probe.ts +414 -0
- package/src/adapters/claude-shutdown.ts +190 -0
- package/src/adapters/claude-status-detectors.ts +426 -0
- package/src/adapters/claude-subagents.ts +93 -0
- package/src/adapters/claude-submit-confirmation.ts +287 -0
- package/src/adapters/claude-tmux.ts +548 -0
- package/src/adapters/claude-token-usage-capture.ts +253 -0
- package/src/adapters/claude-tool-policy.ts +165 -0
- package/src/adapters/claude-transcript-tail.ts +193 -0
- package/src/adapters/claude-transcript.ts +812 -0
- package/src/adapters/claude.ts +1097 -0
- package/src/adapters/codex-agent-message-capture.ts +189 -0
- package/src/adapters/codex-app-server.ts +70 -0
- package/src/adapters/codex-client.ts +362 -0
- package/src/adapters/codex-effective-config.ts +87 -0
- package/src/adapters/codex-launch-config.ts +153 -0
- package/src/adapters/codex-rollout-context.ts +272 -0
- package/src/adapters/codex-skill-discovery.ts +18 -0
- package/src/adapters/codex-token-usage-capture.ts +163 -0
- package/src/adapters/codex-tool-output.ts +53 -0
- package/src/adapters/codex-turn-outcome.ts +27 -0
- package/src/adapters/codex-version.ts +64 -0
- package/src/adapters/codex.ts +1442 -0
- package/src/adapters/incremental-file.ts +110 -0
- package/src/adapters/opencode-api.ts +71 -0
- package/src/adapters/opencode-context.ts +277 -0
- package/src/adapters/opencode-model-registry.ts +126 -0
- package/src/adapters/opencode-provider.ts +169 -0
- package/src/adapters/opencode-server-retry.ts +50 -0
- package/src/adapters/opencode-soft-steer.ts +302 -0
- package/src/adapters/opencode-spawn.ts +145 -0
- package/src/adapters/opencode-subscribe.ts +62 -0
- package/src/adapters/opencode-token-usage-capture.ts +187 -0
- package/src/adapters/opencode-tool-events.ts +302 -0
- package/src/adapters/opencode-tools-config.ts +17 -0
- package/src/adapters/opencode.ts +2264 -0
- package/src/adapters/project-shell.ts +80 -0
- package/src/adapters/provider-launch-config.ts +21 -0
- package/src/adapters/provider-quota-harvest.ts +127 -0
- package/src/adapters/service-access.ts +153 -0
- package/src/adapters/tmux-offload.ts +207 -0
- package/src/adapters/tool-summary.ts +214 -0
- package/src/agent-redact.ts +75 -0
- package/src/agent-runner/commands/dispatch.ts +185 -0
- package/src/agent-runner/commands/lifecycle.ts +84 -0
- package/src/agent-runner/commands/prompt.ts +284 -0
- package/src/agent-runner/credentials.ts +208 -0
- package/src/agent-runner/http-liveness.ts +85 -0
- package/src/agent-runner/lifecycle-milestones.ts +207 -0
- package/src/agent-runner/lifecycle-phase.ts +80 -0
- package/src/agent-runner/registration.ts +163 -0
- package/src/agent-runner/runner-context.ts +158 -0
- package/src/agent-runner/session-mirror.ts +278 -0
- package/src/agent-runner/task-claims.ts +116 -0
- package/src/agent-runner/token-usage-coverage.ts +54 -0
- package/src/agent-runner/turn-report.ts +269 -0
- package/src/api-budget-accounting.ts +91 -0
- package/src/area-memory.ts +255 -0
- package/src/artifact-path-materialize.ts +52 -0
- package/src/async-guard.ts +35 -0
- package/src/attachment-cache-sweep.ts +31 -0
- package/src/attachment-cache.ts +195 -0
- package/src/background-script-reconcile.ts +106 -0
- package/src/bounded-log.ts +59 -0
- package/src/bounded-spawn-sync.ts +18 -0
- package/src/busy-reconciler.ts +282 -0
- package/src/claim-tracker.ts +140 -0
- package/src/claude-settings.ts +167 -0
- package/src/claude-workspace-trust.ts +289 -0
- package/src/config.ts +547 -0
- package/src/context-boundary.ts +101 -0
- package/src/context-command.ts +151 -0
- package/src/context-probe-state.ts +6 -0
- package/src/continuation-archive.ts +179 -0
- package/src/control-server.ts +730 -0
- package/src/delivery-policy.ts +9 -0
- package/src/delivery-result.ts +24 -0
- package/src/effort-command.ts +125 -0
- package/src/empty-turn.ts +816 -0
- package/src/free-port.ts +25 -0
- package/src/fs-offload.ts +26 -0
- package/src/gate-run-config.ts +35 -0
- package/src/gate-run-lease-probe.ts +38 -0
- package/src/host-asset-plan.ts +450 -0
- package/src/host-claude-config-worker.ts +34 -0
- package/src/host-sources.ts +40 -0
- package/src/inbox-carrier.ts +7 -0
- package/src/index.ts +386 -0
- package/src/launch-assembly.ts +955 -0
- package/src/liveness.ts +105 -0
- package/src/logger.ts +108 -0
- package/src/mcp-outbox.ts +204 -0
- package/src/mcp-preflight.ts +86 -0
- package/src/mcp-source-selection.ts +54 -0
- package/src/mcp-tool-deferral.ts +35 -0
- package/src/mcp-vault-env.ts +25 -0
- package/src/message-body-config/config.ts +16 -0
- package/src/message-body-config.ts +1 -0
- package/src/mid-turn-delivery.ts +43 -0
- package/src/native-memory-lever.ts +329 -0
- package/src/native-self-resume.ts +244 -0
- package/src/outbox.ts +453 -0
- package/src/park-post.ts +17 -0
- package/src/pending-permission-store.ts +148 -0
- package/src/process-meta.ts +9 -0
- package/src/profile-home.ts +419 -0
- package/src/profile-projection.ts +411 -0
- package/src/project-hooks.ts +65 -0
- package/src/project-instructions.ts +119 -0
- package/src/provider-double.ts +59 -0
- package/src/provider-hold.ts +228 -0
- package/src/provider-home-marker.ts +3 -0
- package/src/provider-home-tombstone.ts +4 -0
- package/src/provider-input-queue.ts +134 -0
- package/src/provider-integration.ts +93 -0
- package/src/provider-processes.ts +17 -0
- package/src/provider-submit-failure.ts +15 -0
- package/src/providers.ts +35 -0
- package/src/provisioning.ts +703 -0
- package/src/question-decision.ts +18 -0
- package/src/quota.ts +22 -0
- package/src/rate-limit.ts +351 -0
- package/src/refused-prompt-store.ts +36 -0
- package/src/relay-injection-events.ts +161 -0
- package/src/relay-instructions.ts +107 -0
- package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
- package/src/relay-mcp-proxy.ts +704 -0
- package/src/relay-mcp.ts +583 -0
- package/src/reply-obligation-cache.ts +211 -0
- package/src/response-capture-report.ts +111 -0
- package/src/response-routing.ts +6 -0
- package/src/runner-core.ts +2955 -0
- package/src/runner-exit.ts +12 -0
- package/src/runner-helpers.ts +582 -0
- package/src/runner-insights.ts +105 -0
- package/src/runner-mcp-proxy.ts +46 -0
- package/src/runner-spawn-config.ts +68 -0
- package/src/runner-stale-scratch-sweep.ts +21 -0
- package/src/runner.ts +9 -0
- package/src/runtime-token-file.ts +45 -0
- package/src/runtime-token-remint.ts +56 -0
- package/src/service-access.ts +88 -0
- package/src/session-destroy.ts +27 -0
- package/src/session-insights.ts +118 -0
- package/src/session-scratch.ts +296 -0
- package/src/spawn-input.ts +147 -0
- package/src/startup-config-fetch.ts +48 -0
- package/src/subreaper.ts +113 -0
- package/src/template-resolver.ts +29 -0
- package/src/test-run-dedup.ts +435 -0
- package/src/transcript-context.ts +99 -0
- package/src/transport-errors.ts +48 -0
- package/src/turn-opener.ts +55 -0
- package/src/turn-reconcile-event.ts +88 -0
- package/src/vault-proxy-hop.ts +99 -0
- package/src/version.ts +44 -0
- package/src/web-tool-call-proxy.ts +128 -0
- package/src/web-tools.ts +59 -0
- package/README.md +0 -3
package/src/free-port.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// #2719 — the ONE free-loopback-port allocator for provider launches.
|
|
2
|
+
//
|
|
3
|
+
// A provider serve port must never be derived from another port: the runner control port
|
|
4
|
+
// comes from the OS (`bind :0`), and `controlPort + 1000` overflows 65535 whenever the OS
|
|
5
|
+
// answers from a high ephemeral range (macOS: 49152-65535).
|
|
6
|
+
|
|
7
|
+
// The child does not fail loudly on an out-of-range port — it binds a DIFFERENT one, so
|
|
8
|
+
// readiness polling the derived URL times out and the launch dies with no diagnosis. Binding
|
|
9
|
+
// :0 and reading the kernel's own answer is always a valid, currently-free port.
|
|
10
|
+
|
|
11
|
+
export function allocateLoopbackPort(): number {
|
|
12
|
+
// Bun binds before serve() returns, so `.port` already holds the kernel-assigned number;
|
|
13
|
+
// read it BEFORE stop(): the probe reports 0 once closed.
|
|
14
|
+
const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response(null, { status: 204 }) });
|
|
15
|
+
try {
|
|
16
|
+
const port = probe.port;
|
|
17
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`free-port probe reported an unusable port: ${port}`);
|
|
18
|
+
return port;
|
|
19
|
+
} finally {
|
|
20
|
+
// TOCTOU: the probe closes before the provider binds, so a concurrent launch can still
|
|
21
|
+
// take the port in between — the same window the server's ephemeral-profile allocator
|
|
22
|
+
// takes (src/ephemeral/ports.ts), bounded by the provider's own bind/readiness failure.
|
|
23
|
+
probe.stop(true);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Process-scoped, bounded async filesystem work for runner paths that must never pin
|
|
2
|
+
// its event loop. The runner's normal delivery and boot sequencing can enqueue work
|
|
3
|
+
// here without letting a slow host filesystem stall bus frames or timers.
|
|
4
|
+
import { createAsyncRunner, createWorkerPool, type AsyncRunner, type WorkerPool } from "@humwick/offload";
|
|
5
|
+
|
|
6
|
+
let runner: AsyncRunner | undefined;
|
|
7
|
+
let hostConfigPool: WorkerPool | undefined;
|
|
8
|
+
|
|
9
|
+
export function runnerFsOffload(): AsyncRunner {
|
|
10
|
+
return (runner ??= createAsyncRunner({ concurrency: 2 }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Full JSON parse belongs off-thread too: host ~/.claude.json can be tens of MB. */
|
|
14
|
+
export function runnerHostConfigOffload(): WorkerPool {
|
|
15
|
+
return (hostConfigPool ??= createWorkerPool({
|
|
16
|
+
worker: new URL("./host-claude-config-worker.js", import.meta.url), size: 1,
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function destroyRunnerFsOffload(): Promise<void> {
|
|
21
|
+
const current = runner;
|
|
22
|
+
const currentPool = hostConfigPool;
|
|
23
|
+
runner = undefined;
|
|
24
|
+
hostConfigPool = undefined;
|
|
25
|
+
await Promise.all([current?.destroy(), currentPool?.destroy()]);
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// #2461 — resolve the repo's CONFIGURED gate commands for a runner session's cwd.
|
|
2
|
+
//
|
|
3
|
+
// The backstop sweep used to classify a gate run by argv shape alone, which is why a foreign repo's
|
|
4
|
+
// own runner (`pnpm tier:run static fast` — observed live on wbox) was invisible to it. Relay
|
|
5
|
+
// already knows every repo's real gate commands from `.agent-relay/land-gates.json`; this is the
|
|
6
|
+
// runner-side reader for them. Mirrors orchestrator/src/gate-run-guard.ts's `gateCommandsForCwd` —
|
|
7
|
+
// the two workspaces cannot import each other, and the shared piece that matters (the matching
|
|
8
|
+
// rule) lives in sdk/src/gate-run-classify.ts, so only this four-line projection is repeated.
|
|
9
|
+
import { findRepoLandGatesConfig, resolveIterateCommand } from "@humwick/sdk/land-gates";
|
|
10
|
+
import type { GateRunCommands } from "@humwick/sdk/gate-run-classify";
|
|
11
|
+
|
|
12
|
+
/** Never throws: a cwd with no (or an unreadable) land-gates config just leaves the heuristic. */
|
|
13
|
+
export function gateRunCommandsForCwd(cwd: string): GateRunCommands {
|
|
14
|
+
try {
|
|
15
|
+
const found = findRepoLandGatesConfig(cwd);
|
|
16
|
+
if (!found) return {};
|
|
17
|
+
return {
|
|
18
|
+
iterate: resolveIterateCommand(found.config),
|
|
19
|
+
delivery: found.config.delivery,
|
|
20
|
+
gates: found.config.gates.map((gate) => gate.command),
|
|
21
|
+
};
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Per-cwd memo of {@link gateRunCommandsForCwd}. The config is a repo file: re-reading it every
|
|
28
|
+
* 15s for an agent's whole life buys nothing, and a change to it means a new checkout anyway. */
|
|
29
|
+
const cache = new Map<string, GateRunCommands>();
|
|
30
|
+
|
|
31
|
+
export function cachedGateRunCommandsForCwd(cwd: string): GateRunCommands {
|
|
32
|
+
let commands = cache.get(cwd);
|
|
33
|
+
if (!commands) { commands = gateRunCommandsForCwd(cwd); cache.set(cwd, commands); }
|
|
34
|
+
return commands;
|
|
35
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// #2461 — keep the runner's view of this agent's gate-run LEASE HOLDER fresh, off the dedup sweep's
|
|
2
|
+
// own path. The sweep must not block on a file read plus a `ps` every tick, and the holder only
|
|
3
|
+
// changes when a gate run starts or ends, so a cheap self-arming probe on the same cadence is
|
|
4
|
+
// enough. When a holder exists it is AUTHORITATIVE for the sweep: the launch-time lease already
|
|
5
|
+
// decided which run survives, and the backstop must not independently re-decide.
|
|
6
|
+
import { readLiveGateRunHolderPid } from "@humwick/sdk/gate-run-lease";
|
|
7
|
+
|
|
8
|
+
export class GateRunLeaseProbe {
|
|
9
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
10
|
+
private holderPid: number | undefined;
|
|
11
|
+
|
|
12
|
+
constructor(private readonly agentId: string, private readonly intervalMs: number) {}
|
|
13
|
+
|
|
14
|
+
/** Last observed holder pid, or undefined when this agent has no live gate run. */
|
|
15
|
+
pid(): number | undefined {
|
|
16
|
+
return this.holderPid;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
start(): void {
|
|
20
|
+
if (this.timer) return;
|
|
21
|
+
const probe = () => {
|
|
22
|
+
void readLiveGateRunHolderPid(this.agentId)
|
|
23
|
+
.then((pid) => { this.holderPid = pid; })
|
|
24
|
+
// A failed read must read as "no holder", which returns the sweep to newest-wins rather
|
|
25
|
+
// than pinning it to a stale pid it can no longer verify.
|
|
26
|
+
.catch(() => { this.holderPid = undefined; });
|
|
27
|
+
};
|
|
28
|
+
probe();
|
|
29
|
+
this.timer = setInterval(probe, this.intervalMs);
|
|
30
|
+
this.timer.unref?.();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
stop(): void {
|
|
34
|
+
if (this.timer) clearInterval(this.timer);
|
|
35
|
+
this.timer = undefined;
|
|
36
|
+
this.holderPid = undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { CLAUDE_OWNED_PLUGIN_IDS, selectClaudePluginDirs } from "./provider-integration";
|
|
2
|
+
import { runnerMcpGeneration } from "./relay-mcp";
|
|
3
|
+
// #3387 — the host half of a launch plan. Every runner-managed profiled launch runs in a Relay-owned
|
|
4
|
+
// provider home; this module decides, per host asset class, what that home receives from the real
|
|
5
|
+
// host home, and materializes exactly that (removing what an opted-out class left behind on reuse).
|
|
6
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { effectiveHostSources, isRecord, type HostSourceClass, type HostSourceSelection, type ToolGeneration } from "@humwick/sdk";
|
|
10
|
+
import type { RunnerSpawnConfig } from "./adapter";
|
|
11
|
+
|
|
12
|
+
export type HostSourceDisposition = "applied" | "selected-empty" | "disabled" | "unsupported";
|
|
13
|
+
export type HostSourceReport = Record<HostSourceClass, HostSourceDisposition>;
|
|
14
|
+
|
|
15
|
+
export function hostSourcesFor(config: Pick<RunnerSpawnConfig, "agentProfile">): HostSourceSelection {
|
|
16
|
+
return effectiveHostSources(config.agentProfile);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Central provider defaultArgs are host settings: they reach a launch only with Settings/defaults on. */
|
|
20
|
+
export function hostProviderDefaultArgs(config: Pick<RunnerSpawnConfig, "agentProfile" | "providerConfig">, providerConfig = config.providerConfig): string[] {
|
|
21
|
+
return hostSourcesFor(config).settings ? providerConfig.defaultArgs : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A named launch error: a selected host class whose source cannot be honored. Never fall back. */
|
|
25
|
+
export class HostSourceError extends Error {
|
|
26
|
+
constructor(hostClass: HostSourceClass, path: string, detail: string) {
|
|
27
|
+
super(`host ${hostClass}: ${detail} (${path})`);
|
|
28
|
+
this.name = "HostSourceError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function disposition(selected: boolean, applied: boolean): HostSourceDisposition {
|
|
33
|
+
if (!selected) return "disabled";
|
|
34
|
+
return applied ? "applied" : "selected-empty";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read a selected JSON source: missing = empty class, unreadable/malformed = named launch error. */
|
|
38
|
+
export function readSelectedJson(hostClass: HostSourceClass, path: string): Record<string, unknown> | undefined {
|
|
39
|
+
if (!existsSync(path)) return undefined;
|
|
40
|
+
let parsed: unknown;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new HostSourceError(hostClass, path, `unreadable or malformed (${(error as Error).message})`);
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(parsed)) throw new HostSourceError(hostClass, path, "not a JSON object");
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Claude settings partition ─────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
// Keys of the host settings.json that belong to a specific class. Everything else is the residual
|
|
53
|
+
// Settings/defaults class. Relay-owned runtime keys win over every host class in the final writer.
|
|
54
|
+
const CLAUDE_SETTINGS_CLASS: Readonly<Record<string, HostSourceClass>> = {
|
|
55
|
+
hooks: "hooks",
|
|
56
|
+
disableAllHooks: "hooks",
|
|
57
|
+
enabledPlugins: "plugins",
|
|
58
|
+
extraKnownMarketplaces: "plugins",
|
|
59
|
+
enableAllProjectMcpServers: "mcp",
|
|
60
|
+
enabledMcpjsonServers: "mcp",
|
|
61
|
+
disabledMcpjsonServers: "mcp",
|
|
62
|
+
claudeMdExcludes: "instructions",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function claudeSettingsClass(key: string): HostSourceClass {
|
|
66
|
+
return CLAUDE_SETTINGS_CLASS[key] ?? "settings";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The host settings keys of one class. */
|
|
70
|
+
export function claudeHostSettingsFor(settings: Record<string, unknown> | undefined, hostClass: HostSourceClass): Record<string, unknown> {
|
|
71
|
+
if (!settings) return {};
|
|
72
|
+
return Object.fromEntries(Object.entries(settings).filter(([key]) => claudeSettingsClass(key) === hostClass));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Claude native plugin registry ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface ClaudeHostPluginRegistry {
|
|
78
|
+
/** plugins/installed_plugins.json, user-scope entries only. */
|
|
79
|
+
installed?: Record<string, unknown>;
|
|
80
|
+
/** plugins/known_marketplaces.json, the marketplaces the kept entries or declarations need. */
|
|
81
|
+
known?: Record<string, unknown>;
|
|
82
|
+
/** The plugin-owned settings keys (enabledPlugins / extraKnownMarketplaces), filtered to Host scope. */
|
|
83
|
+
settings: Record<string, unknown>;
|
|
84
|
+
/** Kept plugin ids, enabled or not. */
|
|
85
|
+
ids: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The Host share of Claude's native plugin state: user-scope installs (project/local ones are
|
|
90
|
+
* Project), their host enablement (a disabled plugin stays disabled) and their marketplaces.
|
|
91
|
+
* Payload paths stay absolute; only the registry files are private copies.
|
|
92
|
+
*/
|
|
93
|
+
export function selectClaudeHostPlugins(sourceHome: string, hostSettings: Record<string, unknown> | undefined, bridge?: { generation: ToolGeneration; ownedProvided: boolean }): ClaudeHostPluginRegistry {
|
|
94
|
+
const installedPath = join(sourceHome, "plugins", "installed_plugins.json");
|
|
95
|
+
const knownPath = join(sourceHome, "plugins", "known_marketplaces.json");
|
|
96
|
+
const installed = readSelectedJson("plugins", installedPath);
|
|
97
|
+
const known = readSelectedJson("plugins", knownPath);
|
|
98
|
+
const pluginSettings = claudeHostSettingsFor(hostSettings, "plugins");
|
|
99
|
+
const enabled = isRecord(pluginSettings.enabledPlugins) ? pluginSettings.enabledPlugins : {};
|
|
100
|
+
const keptPlugins: Record<string, unknown[]> = {};
|
|
101
|
+
if (installed) {
|
|
102
|
+
if (installed.version !== 2 || !isRecord(installed.plugins)) throw new HostSourceError("plugins", installedPath, `unsupported registry format (version ${String(installed.version)})`);
|
|
103
|
+
for (const [id, entries] of Object.entries(installed.plugins)) {
|
|
104
|
+
if (!Array.isArray(entries)) continue;
|
|
105
|
+
const user = entries.filter((entry) => isRecord(entry) && entry.scope === "user");
|
|
106
|
+
if (!user.length) continue;
|
|
107
|
+
keptPlugins[id] = user;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (bridge) {
|
|
111
|
+
const preferred = bridge.generation === "humwick" ? CLAUDE_OWNED_PLUGIN_IDS[1]! : CLAUDE_OWNED_PLUGIN_IDS[0]!;
|
|
112
|
+
const candidates = [preferred, ...CLAUDE_OWNED_PLUGIN_IDS].filter(id => keptPlugins[id]);
|
|
113
|
+
const selected = bridge.ownedProvided ? undefined : candidates.find(id => enabled[id] === true) ?? candidates[0];
|
|
114
|
+
for (const id of CLAUDE_OWNED_PLUGIN_IDS) if (id !== selected) delete keptPlugins[id];
|
|
115
|
+
}
|
|
116
|
+
for (const [id, user] of Object.entries(keptPlugins)) {
|
|
117
|
+
if (enabled[id] === true) {
|
|
118
|
+
for (const entry of user as Array<Record<string, unknown>>) {
|
|
119
|
+
if (typeof entry.installPath !== "string" || !existsSync(entry.installPath)) {
|
|
120
|
+
throw new HostSourceError("plugins", installedPath, `enabled plugin ${id} has no payload at ${String(entry.installPath)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const ids = Object.keys(keptPlugins).sort();
|
|
126
|
+
const extra = isRecord(pluginSettings.extraKnownMarketplaces) ? pluginSettings.extraKnownMarketplaces : undefined;
|
|
127
|
+
const marketplaces = new Set([...ids.map((id) => id.slice(id.lastIndexOf("@") + 1)), ...Object.keys(extra ?? {})]);
|
|
128
|
+
const keptKnown = known ? Object.fromEntries(Object.entries(known).filter(([name]) => marketplaces.has(name))) : {};
|
|
129
|
+
const keptEnabled = Object.fromEntries(Object.entries(enabled).filter(([id]) => Object.hasOwn(keptPlugins, id)));
|
|
130
|
+
return {
|
|
131
|
+
...(ids.length ? { installed: { ...installed, plugins: keptPlugins } } : {}),
|
|
132
|
+
...(Object.keys(keptKnown).length ? { known: keptKnown } : {}),
|
|
133
|
+
settings: {
|
|
134
|
+
...(Object.keys(keptEnabled).length ? { enabledPlugins: keptEnabled } : {}),
|
|
135
|
+
...(extra ? { extraKnownMarketplaces: extra } : {}),
|
|
136
|
+
},
|
|
137
|
+
ids,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Host links (instructions, rules, commands, agents) ────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export interface HostLink {
|
|
144
|
+
hostClass: HostSourceClass;
|
|
145
|
+
/** Path relative to the managed home. */
|
|
146
|
+
target: string;
|
|
147
|
+
/** Absolute host path the link points at. */
|
|
148
|
+
source: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function existing(sourceHome: string, name: string): string | undefined {
|
|
152
|
+
const path = join(sourceHome, name);
|
|
153
|
+
return existsSync(path) ? path : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Relative `@path` imports in a linked instruction file resolve against the managed home, so the
|
|
157
|
+
// top-level entry each import names is linked beside it.
|
|
158
|
+
function instructionImports(sourceHome: string, file: string): string[] {
|
|
159
|
+
let text: string;
|
|
160
|
+
try {
|
|
161
|
+
text = readFileSync(file, "utf8");
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const names = new Set<string>();
|
|
166
|
+
for (const match of text.matchAll(/(?:^|\s)@([^\s`'"]+)/g)) {
|
|
167
|
+
const ref = match[1]!;
|
|
168
|
+
if (isAbsolute(ref) || ref.startsWith("~") || ref.includes("://")) continue;
|
|
169
|
+
const resolved = resolve(dirname(file), ref);
|
|
170
|
+
const inside = relative(sourceHome, resolved);
|
|
171
|
+
if (!inside || inside.startsWith("..") || isAbsolute(inside) || !existsSync(resolved)) continue;
|
|
172
|
+
names.add(inside.split(sep)[0]!);
|
|
173
|
+
}
|
|
174
|
+
return [...names];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Links a Claude home receives for its selected Instructions / Skills / Settings classes. */
|
|
178
|
+
export function claudeHostLinks(sourceHome: string, selection: HostSourceSelection, instructionFiles: string[]): HostLink[] {
|
|
179
|
+
const links: HostLink[] = [];
|
|
180
|
+
const add = (hostClass: HostSourceClass, name: string) => {
|
|
181
|
+
const source = existing(sourceHome, name);
|
|
182
|
+
if (source && !links.some((link) => link.target === name)) links.push({ hostClass, target: name, source });
|
|
183
|
+
};
|
|
184
|
+
if (selection.instructions) {
|
|
185
|
+
for (const file of instructionFiles) {
|
|
186
|
+
add("instructions", file);
|
|
187
|
+
const source = existing(sourceHome, file);
|
|
188
|
+
if (source) for (const name of instructionImports(sourceHome, source)) add("instructions", name);
|
|
189
|
+
}
|
|
190
|
+
add("instructions", "rules");
|
|
191
|
+
}
|
|
192
|
+
if (selection.skills) add("skills", "commands");
|
|
193
|
+
if (selection.settings) add("settings", "agents");
|
|
194
|
+
return links;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Links a Codex home receives for its selected Instructions class. */
|
|
198
|
+
export function codexHostLinks(sourceHome: string, selection: HostSourceSelection, instructionFiles: string[]): HostLink[] {
|
|
199
|
+
if (!selection.instructions) return [];
|
|
200
|
+
return instructionFiles.flatMap((file) => {
|
|
201
|
+
const source = existing(sourceHome, file);
|
|
202
|
+
return source ? [{ hostClass: "instructions" as const, target: file, source }] : [];
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The links and host-only registry files a previous launch wrote into this home. Reuse removes
|
|
207
|
+
// whatever the current plan no longer selects, so on→off→on in one home never keeps stale assets.
|
|
208
|
+
const HOST_ASSET_MANIFEST = ".relay-host-assets.json";
|
|
209
|
+
|
|
210
|
+
interface HostAssetManifest {
|
|
211
|
+
links: string[];
|
|
212
|
+
files: string[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stringList(value: unknown): string[] {
|
|
216
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readManifest(home: string): HostAssetManifest {
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(readFileSync(join(home, HOST_ASSET_MANIFEST), "utf8")) as unknown;
|
|
222
|
+
return isRecord(parsed) ? { links: stringList(parsed.links), files: stringList(parsed.files) } : { links: [], files: [] };
|
|
223
|
+
} catch {
|
|
224
|
+
return { links: [], files: [] };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isSymlink(path: string): boolean {
|
|
229
|
+
return lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() ?? false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Point `target` at `source`, replacing a stale link but never writing through one. */
|
|
233
|
+
function ensureLink(source: string, target: string): void {
|
|
234
|
+
if (isSymlink(target)) {
|
|
235
|
+
if (readlinkSync(target) === source) return;
|
|
236
|
+
unlinkSync(target);
|
|
237
|
+
} else if (existsSync(target)) {
|
|
238
|
+
rmSync(target, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
241
|
+
symlinkSync(source, target);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function writeFileAtomic(path: string, content: string): void {
|
|
245
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
246
|
+
if (isSymlink(path)) unlinkSync(path);
|
|
247
|
+
const pending = `${path}.relay-${process.pid}.tmp`;
|
|
248
|
+
writeFileSync(pending, content, { mode: 0o600 });
|
|
249
|
+
renameSync(pending, path);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Materialize one plan's host links and generated files after dropping what the previous plan wrote
|
|
254
|
+
* and this one does not select (a stale link only while still a link). Generated files are copies,
|
|
255
|
+
* never links. `extraLinks` tracks links another materializer creates (host skills).
|
|
256
|
+
*/
|
|
257
|
+
export function materializeHostAssets(home: string, links: HostLink[], files: Array<{ target: string; content: string }>, extraLinks: string[] = []): void {
|
|
258
|
+
const next: HostAssetManifest = { links: [...links.map((link) => link.target), ...extraLinks].sort(), files: files.map((file) => file.target).sort() };
|
|
259
|
+
const previous = readManifest(home);
|
|
260
|
+
for (const stale of previous.links) {
|
|
261
|
+
if (!next.links.includes(stale) && isSymlink(join(home, stale))) unlinkSync(join(home, stale));
|
|
262
|
+
}
|
|
263
|
+
for (const stale of previous.files) {
|
|
264
|
+
if (!next.files.includes(stale)) rmSync(join(home, stale), { force: true });
|
|
265
|
+
}
|
|
266
|
+
for (const link of links) ensureLink(link.source, join(home, link.target));
|
|
267
|
+
for (const file of files) writeFileAtomic(join(home, file.target), file.content);
|
|
268
|
+
writeFileAtomic(join(home, HOST_ASSET_MANIFEST), JSON.stringify(next));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Claude plan ───────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/** Marks a host settings snapshot the off-thread reader could not parse (fs-offload worker). */
|
|
274
|
+
export const HOST_SNAPSHOT_READ_ERROR = "__relayHostReadError";
|
|
275
|
+
|
|
276
|
+
/** The host settings.json a selected Settings/Plugins class reads: the hydrated snapshot, else disk. */
|
|
277
|
+
export function claudeHostSettingsSnapshot(config: Pick<RunnerSpawnConfig, "hostClaudeSettings">, sourceHome: string, hostClass: HostSourceClass): Record<string, unknown> | undefined {
|
|
278
|
+
const path = join(sourceHome, "settings.json");
|
|
279
|
+
const snapshot = config.hostClaudeSettings;
|
|
280
|
+
if (!snapshot) return readSelectedJson(hostClass, path);
|
|
281
|
+
const error = snapshot[HOST_SNAPSHOT_READ_ERROR];
|
|
282
|
+
if (typeof error === "string") throw new HostSourceError(hostClass, path, `unreadable or malformed (${error})`);
|
|
283
|
+
return snapshot;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface ClaudeHostPlan {
|
|
287
|
+
selection: HostSourceSelection;
|
|
288
|
+
links: HostLink[];
|
|
289
|
+
/** Host-only generated files (the native plugin registry copies). */
|
|
290
|
+
files: Array<{ target: string; content: string }>;
|
|
291
|
+
/** Host settings keys the managed settings.json receives (residual Settings + plugin keys). */
|
|
292
|
+
settings: Record<string, unknown>;
|
|
293
|
+
/** Native installed plugin ids the home carries. */
|
|
294
|
+
pluginIds: string[];
|
|
295
|
+
/** Explicit providerConfig.pluginDirs passed as --plugin-dir. */
|
|
296
|
+
pluginDirs: string[];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A profile-less launch keeps the provider's native home: every class is native, nothing is planned. */
|
|
300
|
+
export function nativeHostPlan(config: RunnerSpawnConfig, providerConfig = config.providerConfig): ClaudeHostPlan {
|
|
301
|
+
return { selection: hostSourcesFor(config), links: [], files: [], settings: {}, pluginIds: [], pluginDirs: providerConfig.pluginDirs };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function claudeHostPlan(config: RunnerSpawnConfig, sourceHome: string, providerConfig = config.providerConfig): ClaudeHostPlan {
|
|
305
|
+
const selection = hostSourcesFor(config);
|
|
306
|
+
const settingsClass: HostSourceClass | undefined = selection.plugins ? "plugins" : selection.settings ? "settings" : undefined;
|
|
307
|
+
const hostSettings = settingsClass ? claudeHostSettingsSnapshot(config, sourceHome, settingsClass) : undefined;
|
|
308
|
+
const ownedProvided = !config.agentProfile || Boolean(config.agentProfile.provisioning?.plugins.some(plugin => plugin.name === "agent-relay-claude-plugin"));
|
|
309
|
+
const registry = selection.plugins ? selectClaudeHostPlugins(sourceHome, hostSettings, { generation: runnerMcpGeneration(config.env), ownedProvided }) : undefined;
|
|
310
|
+
const files = [
|
|
311
|
+
...(registry?.installed ? [{ target: join("plugins", "installed_plugins.json"), content: JSON.stringify(registry.installed, null, 2) }] : []),
|
|
312
|
+
...(registry?.known ? [{ target: join("plugins", "known_marketplaces.json"), content: JSON.stringify(registry.known, null, 2) }] : []),
|
|
313
|
+
];
|
|
314
|
+
return {
|
|
315
|
+
selection,
|
|
316
|
+
links: claudeHostLinks(sourceHome, selection, providerConfig.globalInstructionFiles ?? []),
|
|
317
|
+
files,
|
|
318
|
+
settings: { ...(selection.settings ? claudeHostSettingsFor(hostSettings, "settings") : {}), ...(registry?.settings ?? {}) },
|
|
319
|
+
pluginIds: registry?.ids ?? [],
|
|
320
|
+
pluginDirs: selection.plugins ? selectClaudePluginDirs(providerConfig.pluginDirs, runnerMcpGeneration(config.env), ownedProvided || Boolean(registry?.ids.some(id => CLAUDE_OWNED_PLUGIN_IDS.includes(id) && (registry.settings.enabledPlugins as Record<string, unknown> | undefined)?.[id] === true))) : [],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Per-class disposition of a Claude launch, from the same plan and resolvers the launch uses. */
|
|
325
|
+
export function claudeHostReport(plan: ClaudeHostPlan, applied: { mcp: boolean; hooks: boolean; skills: boolean; defaultArgs: boolean; env: boolean }): HostSourceReport {
|
|
326
|
+
const linked = (hostClass: HostSourceClass) => plan.links.some((link) => link.hostClass === hostClass);
|
|
327
|
+
const { selection } = plan;
|
|
328
|
+
return {
|
|
329
|
+
instructions: disposition(selection.instructions, linked("instructions")),
|
|
330
|
+
mcp: disposition(selection.mcp, applied.mcp),
|
|
331
|
+
hooks: disposition(selection.hooks, applied.hooks),
|
|
332
|
+
skills: disposition(selection.skills, applied.skills || linked("skills")),
|
|
333
|
+
plugins: disposition(selection.plugins, plan.pluginIds.length > 0 || plan.pluginDirs.length > 0),
|
|
334
|
+
settings: disposition(selection.settings, linked("settings") || applied.defaultArgs || applied.env || Object.keys(plan.settings).some((key) => claudeSettingsClass(key) === "settings")),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Codex plan ────────────────────────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
// Native skill roots outside CODEX_HOME that Codex discovers from the user's HOME.
|
|
341
|
+
function codexAmbientSkillRoots(userHome: string): string[] {
|
|
342
|
+
return [join(userHome, ".agents", "skills")];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Skill dirs under `root`: real or symlinked directories holding a SKILL.md. */
|
|
346
|
+
export function skillDirsUnder(root: string): string[] {
|
|
347
|
+
if (!existsSync(root)) return [];
|
|
348
|
+
return readdirSync(root, { withFileTypes: true })
|
|
349
|
+
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith(".") && existsSync(join(root, entry.name, "SKILL.md")))
|
|
350
|
+
.map((entry) => join(root, entry.name))
|
|
351
|
+
.sort();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Top-level config.toml keys another class or Relay's runtime owns. Codex-written state in the
|
|
355
|
+
// managed home (trust, features) stays Relay's; MCP and hooks have their own classes.
|
|
356
|
+
const CODEX_NON_SETTINGS_KEYS = new Set(["mcp_servers", "hooks", "skills", "projects", "features"]);
|
|
357
|
+
|
|
358
|
+
export interface CodexHostPlan {
|
|
359
|
+
selection: HostSourceSelection;
|
|
360
|
+
links: HostLink[];
|
|
361
|
+
/** Host skill dirs enabled and registered as discovery roots. */
|
|
362
|
+
skillRoots: string[];
|
|
363
|
+
/** Ambient host skill dirs Codex would discover on its own, explicitly disabled. */
|
|
364
|
+
disabledSkillRoots: string[];
|
|
365
|
+
/** Residual host config.toml (Settings class), top-level keys only. */
|
|
366
|
+
settings: Record<string, unknown>;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function codexHostPlan(config: RunnerSpawnConfig, sourceHome: string, userHome = homedir()): CodexHostPlan {
|
|
370
|
+
const selection = hostSourcesFor(config);
|
|
371
|
+
const ambient = codexAmbientSkillRoots(userHome).flatMap(skillDirsUnder);
|
|
372
|
+
let settings: Record<string, unknown> = {};
|
|
373
|
+
if (selection.settings) {
|
|
374
|
+
const path = join(sourceHome, "config.toml");
|
|
375
|
+
if (existsSync(path)) {
|
|
376
|
+
let parsed: unknown;
|
|
377
|
+
try {
|
|
378
|
+
parsed = Bun.TOML.parse(readFileSync(path, "utf8"));
|
|
379
|
+
} catch (error) {
|
|
380
|
+
throw new HostSourceError("settings", path, `unreadable or malformed (${(error as Error).message})`);
|
|
381
|
+
}
|
|
382
|
+
if (isRecord(parsed)) settings = Object.fromEntries(Object.entries(parsed).filter(([key]) => !CODEX_NON_SETTINGS_KEYS.has(key)));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
selection,
|
|
387
|
+
links: codexHostLinks(sourceHome, selection, config.providerConfig.globalInstructionFiles ?? []),
|
|
388
|
+
skillRoots: selection.skills ? [...skillDirsUnder(join(sourceHome, "skills")), ...ambient] : [],
|
|
389
|
+
disabledSkillRoots: selection.skills ? [] : ambient,
|
|
390
|
+
settings,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function codexHostReport(plan: CodexHostPlan, applied: { mcp: boolean; hooks: HostSourceDisposition; defaultArgs: boolean; env: boolean }): HostSourceReport {
|
|
395
|
+
const { selection } = plan;
|
|
396
|
+
return {
|
|
397
|
+
instructions: disposition(selection.instructions, plan.links.length > 0),
|
|
398
|
+
mcp: disposition(selection.mcp, applied.mcp),
|
|
399
|
+
hooks: applied.hooks,
|
|
400
|
+
skills: disposition(selection.skills, plan.skillRoots.length > 0),
|
|
401
|
+
// Relay has no Codex native-plugin serializer yet: never claim an inherited plugin.
|
|
402
|
+
plugins: selection.plugins ? "unsupported" : "disabled",
|
|
403
|
+
settings: disposition(selection.settings, Object.keys(plan.settings).length > 0 || applied.defaultArgs || applied.env),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const CODEX_SETTINGS_BEGIN = "# >>> agent-relay host settings (generated; edits are replaced)";
|
|
408
|
+
const CODEX_SETTINGS_END = "# <<< agent-relay host settings";
|
|
409
|
+
|
|
410
|
+
function withoutCodexSettingsRegion(text: string): string {
|
|
411
|
+
const begin = text.indexOf(CODEX_SETTINGS_BEGIN);
|
|
412
|
+
if (begin < 0) return text;
|
|
413
|
+
const end = text.indexOf(CODEX_SETTINGS_END, begin);
|
|
414
|
+
if (end < 0) return text;
|
|
415
|
+
return (text.slice(0, begin) + text.slice(end + CODEX_SETTINGS_END.length)).replace(/^\n+/, "");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Write the selected residual host settings as a generated region at the top of the managed
|
|
420
|
+
* config.toml (inline values, so later tables cannot absorb them). Keys the file already defines
|
|
421
|
+
* outside the region win; an off class removes the region on reuse.
|
|
422
|
+
*/
|
|
423
|
+
export function writeCodexHostSettings(codexHome: string, settings: Record<string, unknown>, inline: (value: unknown) => string): void {
|
|
424
|
+
const path = join(codexHome, "config.toml");
|
|
425
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
426
|
+
const rest = withoutCodexSettingsRegion(current);
|
|
427
|
+
let owned: Record<string, unknown> = {};
|
|
428
|
+
try {
|
|
429
|
+
owned = Bun.TOML.parse(rest) as Record<string, unknown>;
|
|
430
|
+
} catch {
|
|
431
|
+
// An unparseable managed file is the provider's to report; never guess which keys it owns.
|
|
432
|
+
owned = {};
|
|
433
|
+
}
|
|
434
|
+
const lines: string[] = [];
|
|
435
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
436
|
+
if (Object.hasOwn(owned, key)) continue;
|
|
437
|
+
try {
|
|
438
|
+
lines.push(`${JSON.stringify(key)} = ${inline(value)}`);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
throw new HostSourceError("settings", join(codexHome, "config.toml"), `host config.toml key ${key} has no supported form (${(error as Error).message})`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const next = lines.length ? `${CODEX_SETTINGS_BEGIN}\n${lines.join("\n")}\n${CODEX_SETTINGS_END}\n\n${rest}` : rest;
|
|
444
|
+
if (next !== current) writeFileAtomic(path, next);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** A profile-less launch runs in the provider's native home: every class is native. */
|
|
448
|
+
export function nativeHostReport(): HostSourceReport {
|
|
449
|
+
return { instructions: "applied", mcp: "applied", hooks: "applied", skills: "applied", plugins: "applied", settings: "applied" };
|
|
450
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isRecord } from "@humwick/sdk";
|
|
2
|
+
import type { OffloadWorkerTask } from "@humwick/offload/worker";
|
|
3
|
+
|
|
4
|
+
// Mirrors HOST_SNAPSHOT_READ_ERROR (host-asset-plan.ts); a worker entry stays dependency-light.
|
|
5
|
+
const READ_ERROR = "__relayHostReadError";
|
|
6
|
+
|
|
7
|
+
const task: OffloadWorkerTask<{ path: string; kind?: "config" | "settings" }, Record<string, unknown> | undefined> = async ({ path, kind = "config" }) => {
|
|
8
|
+
try {
|
|
9
|
+
const file = Bun.file(path);
|
|
10
|
+
if (!await file.exists()) return undefined;
|
|
11
|
+
let parsed: unknown;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(await file.text());
|
|
14
|
+
} catch (error) {
|
|
15
|
+
// #3387 — a selected host class must fail loudly on a malformed settings.json, never launch as if empty.
|
|
16
|
+
if (kind === "settings") return { [READ_ERROR]: (error as Error).message };
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
if (!isRecord(parsed)) return kind === "settings" ? { [READ_ERROR]: "not a JSON object" } : undefined;
|
|
20
|
+
// settings.json is small; the launch partitions it per host class (#3387).
|
|
21
|
+
if (kind === "settings") return parsed;
|
|
22
|
+
// Keep only the three host-level launch inputs. Returning Claude's entire history/session
|
|
23
|
+
// object would transfer tens of MB back to the runner and recreate the same event-loop stall.
|
|
24
|
+
const snapshot: Record<string, unknown> = {};
|
|
25
|
+
if (typeof parsed.theme === "string") snapshot.theme = parsed.theme;
|
|
26
|
+
if (typeof parsed.lastOnboardingVersion === "string") snapshot.lastOnboardingVersion = parsed.lastOnboardingVersion;
|
|
27
|
+
if (isRecord(parsed.mcpServers)) snapshot.mcpServers = parsed.mcpServers;
|
|
28
|
+
return snapshot;
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default task;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isRecord, type ProvisioningHookSet, type ProvisioningMcpServer } from "@humwick/sdk";
|
|
4
|
+
import type { RunnerSpawnConfig } from "./adapter";
|
|
5
|
+
import { claudeHostSettingsSnapshot } from "./host-asset-plan";
|
|
6
|
+
import { providerSourceHome } from "./profile-home";
|
|
7
|
+
|
|
8
|
+
/** Host Claude hooks are stored in the host settings.json, not .claude.json; a host that set
|
|
9
|
+
* `disableAllHooks` carries none, so a managed home never re-enables them (#3387).
|
|
10
|
+
* #2513 — keyed by provider id so a dedicatedHome claude-family provider (claude-b)
|
|
11
|
+
* reads ITS OWN source home's settings, never ~/.claude's. */
|
|
12
|
+
export function hostClaudeHooks(config: Pick<RunnerSpawnConfig, "hostClaudeSettings">, provider: string = "claude"): ProvisioningHookSet {
|
|
13
|
+
const settings = claudeHostSettingsSnapshot(config, providerSourceHome(provider), "hooks");
|
|
14
|
+
if (!settings || settings.disableAllHooks === true || !isRecord(settings.hooks)) return {};
|
|
15
|
+
return settings.hooks as ProvisioningHookSet;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Read the host Codex MCP tables that an isolated CODEX_HOME would otherwise lose. */
|
|
19
|
+
export function hostCodexMcpServers(): Record<string, ProvisioningMcpServer> {
|
|
20
|
+
const path = join(providerSourceHome("codex"), "config.toml");
|
|
21
|
+
if (!existsSync(path)) return {};
|
|
22
|
+
try {
|
|
23
|
+
const parsed = Bun.TOML.parse(readFileSync(path, "utf8")) as { mcp_servers?: unknown };
|
|
24
|
+
return isRecord(parsed.mcp_servers) ? parsed.mcp_servers as Record<string, ProvisioningMcpServer> : {};
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Host Codex hooks are consumed from CODEX_HOME/hooks.json by supported Codex releases. */
|
|
31
|
+
export function hostCodexHooks(): ProvisioningHookSet {
|
|
32
|
+
const path = join(providerSourceHome("codex"), "hooks.json");
|
|
33
|
+
if (!existsSync(path)) return {};
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
36
|
+
return isRecord(parsed) && isRecord(parsed.hooks) ? parsed.hooks as ProvisioningHookSet : {};
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { isMechanicalMessageKind, type Message } from "@humwick/sdk";
|
|
2
|
+
|
|
3
|
+
/** A delivered notice owes no answer, so it must not arm the empty-turn/input-stalled ladder. */
|
|
4
|
+
export function isNotificationOnlyInput(message: Message): boolean {
|
|
5
|
+
return isMechanicalMessageKind(message.kind) || message.replyExpected === false ||
|
|
6
|
+
message.payload?.notification != null;
|
|
7
|
+
}
|