@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.
- 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 +42 -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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { open as fsOpen, stat as fsStat } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
/** File identity and length captured for an incremental tail pass. */
|
|
5
|
+
export interface IncrementalFileInfo {
|
|
6
|
+
size: number;
|
|
7
|
+
ino?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Durable watermark for a single append-only file. */
|
|
11
|
+
export interface IncrementalFilePosition {
|
|
12
|
+
path: string;
|
|
13
|
+
ino?: number;
|
|
14
|
+
offset: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IncrementalFileLineBuffer {
|
|
18
|
+
partial?: Buffer;
|
|
19
|
+
/** An over-limit record is ignored until its terminating newline arrives. */
|
|
20
|
+
discarding?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Consumer-owned line-size policy: providers have different valid record sizes. */
|
|
24
|
+
export interface IncrementalFileLinePolicy {
|
|
25
|
+
maxPartialBytes: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function statIncrementalFile(path: string): Promise<IncrementalFileInfo> {
|
|
29
|
+
return fsStat(path).then((stats) => ({ size: stats.size, ino: stats.ino }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function statIncrementalFileSync(path: string): IncrementalFileInfo {
|
|
33
|
+
const stats = statSync(path);
|
|
34
|
+
return { size: stats.size, ino: stats.ino };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readIncrementalFileRange(path: string, start: number, end: number): Promise<Uint8Array> {
|
|
38
|
+
const handle = await fsOpen(path, "r");
|
|
39
|
+
try {
|
|
40
|
+
const length = Math.max(end - start, 0);
|
|
41
|
+
const buffer = Buffer.alloc(length);
|
|
42
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
43
|
+
return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
44
|
+
} finally {
|
|
45
|
+
await handle.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readIncrementalFileRangeSync(path: string, start: number, end: number): Uint8Array {
|
|
50
|
+
const length = Math.max(end - start, 0);
|
|
51
|
+
const handle = openSync(path, "r");
|
|
52
|
+
try {
|
|
53
|
+
const buffer = Buffer.alloc(length);
|
|
54
|
+
const bytesRead = readSync(handle, buffer, 0, length, start);
|
|
55
|
+
return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
56
|
+
} finally {
|
|
57
|
+
closeSync(handle);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A different path/inode or a shrink invalidates a previously committed offset. */
|
|
62
|
+
export function incrementalFileNeedsReset(position: IncrementalFilePosition | undefined, path: string, info: IncrementalFileInfo): boolean {
|
|
63
|
+
if (!position || position.path !== path || info.size < position.offset) return true;
|
|
64
|
+
return info.ino !== undefined && position.ino !== undefined && info.ino !== position.ino;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Split an append-only JSONL byte stream without corrupting a line split across reads.
|
|
69
|
+
* A partial record is capped; once it exceeds the cap, skip that one record through its
|
|
70
|
+
* next newline rather than retaining/copying it on every heartbeat.
|
|
71
|
+
*/
|
|
72
|
+
export function splitIncrementalFileLines(previous: IncrementalFileLineBuffer, chunk: Uint8Array, policy: IncrementalFileLinePolicy): { lines: Buffer[]; buffer: IncrementalFileLineBuffer } {
|
|
73
|
+
const data = Buffer.from(chunk);
|
|
74
|
+
const lines: Buffer[] = [];
|
|
75
|
+
let start = 0;
|
|
76
|
+
let partial = previous.partial;
|
|
77
|
+
let discarding = previous.discarding === true;
|
|
78
|
+
|
|
79
|
+
if (discarding) {
|
|
80
|
+
const newline = data.indexOf(0x0a);
|
|
81
|
+
if (newline < 0) return { lines, buffer: { discarding: true } };
|
|
82
|
+
start = newline + 1;
|
|
83
|
+
discarding = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (let index = start; index < data.length; index++) {
|
|
87
|
+
if (data[index] !== 0x0a) continue;
|
|
88
|
+
const segment = data.subarray(start, index);
|
|
89
|
+
if (partial) {
|
|
90
|
+
if (partial.length + segment.length <= policy.maxPartialBytes) lines.push(Buffer.concat([partial, segment]));
|
|
91
|
+
partial = undefined;
|
|
92
|
+
} else if (segment.length <= policy.maxPartialBytes) {
|
|
93
|
+
lines.push(segment);
|
|
94
|
+
}
|
|
95
|
+
start = index + 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tail = data.subarray(start);
|
|
99
|
+
if (partial) {
|
|
100
|
+
if (partial.length + tail.length <= policy.maxPartialBytes) partial = Buffer.concat([partial, tail]);
|
|
101
|
+
else {
|
|
102
|
+
partial = undefined;
|
|
103
|
+
discarding = true;
|
|
104
|
+
}
|
|
105
|
+
} else if (tail.length) {
|
|
106
|
+
if (tail.length <= policy.maxPartialBytes) partial = Buffer.from(tail);
|
|
107
|
+
else discarding = true;
|
|
108
|
+
}
|
|
109
|
+
return { lines, buffer: { ...(partial ? { partial } : {}), ...(discarding ? { discarding } : {}) } };
|
|
110
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { relayEnv } from "@humwick/sdk/env";
|
|
2
|
+
import { OPENAI_API_MODEL, isOpenAiApiModel, type ApiBudgetTokenUsage } from "@humwick/sdk";
|
|
3
|
+
import { resolveKeyCredential } from "@humwick/sdk/child-env";
|
|
4
|
+
import { isRecord } from "@humwick/sdk";
|
|
5
|
+
import { openCodeTokenUsageRecord, openCodeMessageFinished, openCodeMessageModel, openCodeMessageObservedAt } from "./opencode-token-usage-capture";
|
|
6
|
+
import type { SpawnArgs, RunnerSpawnConfig } from "../adapter";
|
|
7
|
+
import { ApiBudgetAccounting } from "../api-budget-accounting";
|
|
8
|
+
import { ProviderSubmitFailure } from "../provider-submit-failure";
|
|
9
|
+
|
|
10
|
+
export function openCodeApiKey(config: RunnerSpawnConfig, model: string): string | undefined {
|
|
11
|
+
if (!isOpenAiApiModel("opencode", model)) return undefined;
|
|
12
|
+
if (model !== OPENAI_API_MODEL) throw new ProviderSubmitFailure("OpenAI API model has no supported budget price", false);
|
|
13
|
+
const file = config.providerConfig.env.OPENAI_API_KEY_FILE ?? config.env.OPENAI_API_KEY_FILE ?? process.env.OPENAI_API_KEY_FILE;
|
|
14
|
+
try { return resolveKeyCredential("OPENAI_API_KEY", config.env, file); }
|
|
15
|
+
catch (error) { throw new ProviderSubmitFailure(`OpenAI API billing needs a vault-bound OPENAI_API_KEY or OPENAI_API_KEY_FILE: ${(error as Error).message}`, false); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Native OpenCode owns turns; the shared accountant owns money and retries. */
|
|
19
|
+
export class OpenCodeApiBudget {
|
|
20
|
+
readonly ledger: ApiBudgetAccounting;
|
|
21
|
+
private heartbeat?: ReturnType<typeof setInterval>;
|
|
22
|
+
|
|
23
|
+
constructor(config: RunnerSpawnConfig) {
|
|
24
|
+
this.ledger = new ApiBudgetAccounting("openai-api", (action, body) => this.ledger.transport(config, action, { ...body, ...(action === "reserve" && relayEnv(config.env, "AGENT_RELAY_SPAWN_REQUEST_ID") ? { spawnRequestId: relayEnv(config.env, "AGENT_RELAY_SPAWN_REQUEST_ID") } : {}) }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async start(sessionId: string): Promise<void> {
|
|
28
|
+
await this.ledger.reserve(sessionId);
|
|
29
|
+
this.heartbeat = setInterval(() => this.report(), 5_000);
|
|
30
|
+
this.heartbeat.unref?.();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
observe(record: ApiBudgetTokenUsage): void {
|
|
34
|
+
this.ledger.observe(record);
|
|
35
|
+
this.report();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private report(): void {
|
|
39
|
+
this.ledger.queue(async () => { await this.ledger.report({ renew: true }); }, () => {});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async prepare(): Promise<void> {
|
|
43
|
+
await this.ledger.pending;
|
|
44
|
+
// A failed report keeps its batch for the next boundary/heartbeat; it never kills a turn.
|
|
45
|
+
const decision = await this.ledger.report({ renew: true });
|
|
46
|
+
if (!decision.allowed) throw new ProviderSubmitFailure(`OpenAI API budget held: ${decision.reason}`, true);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async settle(): Promise<void> {
|
|
50
|
+
clearInterval(this.heartbeat);
|
|
51
|
+
await this.ledger.settle();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every session on the isolated server spends this launch's key, including native children. */
|
|
56
|
+
export function captureOpenCodeApiUsage(budget: OpenCodeApiBudget | undefined, info: unknown, sessionId: string | undefined, model: string | undefined, messageId: string): void {
|
|
57
|
+
if (!budget || !openCodeMessageFinished(info)) return;
|
|
58
|
+
const record = openCodeTokenUsageRecord({ provider: "opencode", sessionId: sessionId ?? "unknown",
|
|
59
|
+
model: openCodeMessageModel(info) ?? model ?? "unknown", messageId,
|
|
60
|
+
tokens: isRecord(info) ? info.tokens : undefined, createdAt: openCodeMessageObservedAt(info), finished: true });
|
|
61
|
+
if (record) budget.observe(record);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function spawnOpenCodeServer(args: SpawnArgs, budget?: OpenCodeApiBudget): Promise<Bun.Subprocess> {
|
|
65
|
+
try {
|
|
66
|
+
return Bun.spawn([args.command, ...args.args], { cwd: args.cwd, env: args.env, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
await budget?.settle().catch(() => {});
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { ContextState } from "@humwick/sdk";
|
|
2
|
+
import { providerContextState } from "../transcript-context";
|
|
3
|
+
|
|
4
|
+
// #2355 — OpenCode's `/session/:id/message` response (and its `message.updated` SSE echo) carries
|
|
5
|
+
// a per-assistant-message `tokens` object once the message finalizes: `{ input, output, reasoning,
|
|
6
|
+
// cache: { read, write} }`, confirmed live against a real captured v1.18.21 session's SQLite
|
|
7
|
+
// `message` table (issue #2355 investigation) — and, when present, a `total` leaf that is already
|
|
8
|
+
// the sum of all of those (`input + output + reasoning + cache.read + cache.write`). Unlike Codex's
|
|
9
|
+
// `thread/tokenUsage/updated` (which also carries `modelContextWindow`), OpenCode's wire never
|
|
10
|
+
// reports the model's context-window size anywhere — so the window has to come from the static,
|
|
11
|
+
// operator-declared catalog value (`providers/opencode.ts`) instead of the wire, and this helper
|
|
12
|
+
// takes that resolved value as a plain parameter rather than trying to read it itself.
|
|
13
|
+
//
|
|
14
|
+
// `tokensUsed` here is genuinely LIVE (the provider's own reported per-turn total — input tokens
|
|
15
|
+
// carried forward from prior turns via cache-read plus this turn's own output, i.e. what will be
|
|
16
|
+
// resent as context on the next turn), but `windowTokens` is a DECLARED constant the wire never
|
|
17
|
+
// confirms turn-to-turn. `confidence: "estimated"` (not "reported") makes that partial provenance
|
|
18
|
+
// visible downstream instead of presenting the whole reading as fully API-confirmed.
|
|
19
|
+
//
|
|
20
|
+
// #2355 review (MED-1) — `tokensUsed === 0` is treated as NO SIGNAL, same as `undefined`, not as a
|
|
21
|
+
// genuine zero-usage reading. `send()` calls this on EVERY completion, including a positively-parsed
|
|
22
|
+
// EMPTY completion (`openCodeCompletion`'s `empty: true` path — #2389's deterministic-close path,
|
|
23
|
+
// which reports `tokens: 0`). A finalized assistant message that actually engaged the model always
|
|
24
|
+
// has `input > 0` (the prompt itself is never zero tokens), so a `total` of exactly 0 only ever
|
|
25
|
+
// means "no usable token reading for this turn" — never "the context is now empty". Returning a real
|
|
26
|
+
// ContextState at utilization:0 here would unconditionally overwrite `process.meta.context` with a
|
|
27
|
+
// fabricated "context emptied" reading on every empty turn, flapping the warn/self-compact advisory
|
|
28
|
+
// band on the server (it clears when utilization drops, then re-fires on the next real turn).
|
|
29
|
+
export function openCodeContextState(input: { tokensUsed: number | undefined; windowTokens: number | undefined; now?: number }): ContextState | undefined {
|
|
30
|
+
const { tokensUsed, windowTokens, now = Date.now() } = input;
|
|
31
|
+
if (typeof tokensUsed !== "number" || !Number.isFinite(tokensUsed) || tokensUsed <= 0) return undefined;
|
|
32
|
+
if (typeof windowTokens !== "number" || !Number.isFinite(windowTokens) || windowTokens <= 0) return undefined;
|
|
33
|
+
return providerContextState(tokensUsed, windowTokens, now, "estimated");
|
|
34
|
+
}
|
|
35
|
+
import { errMessage } from "@humwick/sdk";
|
|
36
|
+
import type { ManagedProcess, RunnerSpawnConfig } from "../adapter";
|
|
37
|
+
import { PromptAcceptedError } from "../adapter";
|
|
38
|
+
import { resolveRelayContext } from "../relay-instructions";
|
|
39
|
+
import { launchWebTools } from "../web-tools";
|
|
40
|
+
|
|
41
|
+
/** The immutable input and deferred marker for one logical OpenCode turn. */
|
|
42
|
+
export interface OpenCodeRelayContextInput {
|
|
43
|
+
readonly text: string;
|
|
44
|
+
readonly emitsRelayContext: boolean;
|
|
45
|
+
readonly emitsLaunchContext: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* #2534 — proof-of-acceptance watcher for one context-carrying outbound prompt. Armed at
|
|
50
|
+
* dispatch and consumed by the SSE `message.part.updated` door (or the bounded persisted-message
|
|
51
|
+
* reconciliation) the moment OpenCode positively persists the prompt as a user message —
|
|
52
|
+
* BEFORE the synchronous turn-completion await resolves, which on a long turn may only do so
|
|
53
|
+
* (or abort) up to the whole-turn deadline later.
|
|
54
|
+
*/
|
|
55
|
+
export interface OpenCodeContextAcceptance {
|
|
56
|
+
readonly sessionId: string;
|
|
57
|
+
readonly text: string;
|
|
58
|
+
readonly emitsRelayContext: boolean;
|
|
59
|
+
readonly emitsLaunchContext: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* #2534 review round 2 (MED-2) — epoch ms when THIS arm was dispatched. Every arm of send()'s
|
|
62
|
+
* retry/re-roll loop dispatches the SAME immutable text into the SAME session, so the
|
|
63
|
+
* reconciliation's persisted match needs a per-arm discriminator beyond text equality: a
|
|
64
|
+
* persisted user message only proves THIS arm if it was created at or after this arm's
|
|
65
|
+
* dispatch (an earlier arm's identical persisted message must never match).
|
|
66
|
+
*/
|
|
67
|
+
readonly dispatchedAt: number;
|
|
68
|
+
/**
|
|
69
|
+
* #2534 review (MED-2) — accepted verdict for THIS dispatch arm, set only by this arm's own
|
|
70
|
+
* door or reconciliation. The durable first-turn markers are process-scoped and the prepared
|
|
71
|
+
* context input is immutable across send()'s retry/re-roll loop, so a later arm that fails
|
|
72
|
+
* PRE-acceptance must never inherit an earlier arm's acceptance: keying the verdict to the
|
|
73
|
+
* markers alone would wrap that failure in PromptAcceptedError and drop the assignment as
|
|
74
|
+
* already-delivered. The flag dies with the arm in sendOpenCodeRelayTurn's finally.
|
|
75
|
+
*/
|
|
76
|
+
consumed?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ACCEPTANCE_META_KEY = "pendingContextAcceptance";
|
|
80
|
+
|
|
81
|
+
function openCodeLaunchContext(process: ManagedProcess): string | undefined {
|
|
82
|
+
const config = process.meta?.config as RunnerSpawnConfig | undefined;
|
|
83
|
+
const text = config?.systemPromptAppend?.trim();
|
|
84
|
+
if (!text || process.meta?.launchContextSent) return undefined;
|
|
85
|
+
return [
|
|
86
|
+
"[agent-relay launch context]",
|
|
87
|
+
"The following context was attached when this provider session was launched. Treat it as background context, not as a user request and not as your own prior actions.",
|
|
88
|
+
text,
|
|
89
|
+
].join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Prepare exactly one OpenCode turn with Relay context before its retry loop begins.
|
|
94
|
+
*
|
|
95
|
+
* This deliberately does not mutate `relayContextSent`: a missing client/session, or a client
|
|
96
|
+
* that throws before accepting a request, has not emitted the block and must leave the next
|
|
97
|
+
* RunnerCore retry eligible to carry it.
|
|
98
|
+
*/
|
|
99
|
+
export function prepareOpenCodeRelayContext(process: ManagedProcess, text: string): OpenCodeRelayContextInput {
|
|
100
|
+
const config = process.meta?.config as RunnerSpawnConfig | undefined;
|
|
101
|
+
const context = resolveRelayContext({ slug: "relay.context.opencode", config, webTools: config ? { names: launchWebTools(config), claude: false } : undefined });
|
|
102
|
+
const emitsRelayContext = context.available && !process.meta?.relayContextSent;
|
|
103
|
+
const launchContext = openCodeLaunchContext(process);
|
|
104
|
+
return {
|
|
105
|
+
text: [emitsRelayContext ? context.text : undefined, launchContext, text].filter(Boolean).join("\n\n"),
|
|
106
|
+
emitsRelayContext,
|
|
107
|
+
emitsLaunchContext: launchContext !== undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Record the first context only after the client has accepted the outbound request. */
|
|
112
|
+
export function markOpenCodeRelayContextSent(process: ManagedProcess, input: OpenCodeRelayContextInput): void {
|
|
113
|
+
if (!input.emitsRelayContext && !input.emitsLaunchContext) return;
|
|
114
|
+
process.meta = {
|
|
115
|
+
...(process.meta ?? {}),
|
|
116
|
+
...(input.emitsRelayContext ? { relayContextSent: true } : {}),
|
|
117
|
+
...(input.emitsLaunchContext ? { launchContextSent: true } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Record the marker only after OpenCode resolves the request, preserving rejected transport retries. */
|
|
122
|
+
export async function sendOpenCodeRelayMessage<T>(client: { sendMessage(sessionId: string, text: string, model: string): Promise<T> }, process: ManagedProcess, sessionId: string, model: string, input: OpenCodeRelayContextInput): Promise<T> {
|
|
123
|
+
const response = await client.sendMessage(sessionId, input.text, model);
|
|
124
|
+
markOpenCodeRelayContextSent(process, input);
|
|
125
|
+
return response;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* #2534 — arm the acceptance watcher just before a context-carrying POST is dispatched. A
|
|
130
|
+
* prompt that emits no first-turn context has nothing to protect and never arms one, so
|
|
131
|
+
* ordinary mid-session deliveries stay watcher-free.
|
|
132
|
+
*/
|
|
133
|
+
export function armOpenCodeContextAcceptance(process: ManagedProcess, input: OpenCodeRelayContextInput, sessionId: string): void {
|
|
134
|
+
if (!input.emitsRelayContext && !input.emitsLaunchContext) return;
|
|
135
|
+
process.meta = {
|
|
136
|
+
...(process.meta ?? {}),
|
|
137
|
+
[ACCEPTANCE_META_KEY]: { sessionId, text: input.text, emitsRelayContext: input.emitsRelayContext, emitsLaunchContext: input.emitsLaunchContext, dispatchedAt: Date.now() } satisfies OpenCodeContextAcceptance,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Drop the watcher — once the dispatch attempt has settled (a positive consumption marks the
|
|
142
|
+
* arm consumed in place; the arm's verdict must survive until reconcile runs on a failed await). */
|
|
143
|
+
export function clearOpenCodeContextAcceptance(process: ManagedProcess): void {
|
|
144
|
+
if (!process.meta || !(ACCEPTANCE_META_KEY in process.meta)) return;
|
|
145
|
+
const { [ACCEPTANCE_META_KEY]: _dropped, ...rest } = process.meta;
|
|
146
|
+
process.meta = rest;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* #2534 — consume the watcher from an observed persisted user text part. Identity is matched to
|
|
151
|
+
* THIS launch incarnation on three axes, all of which must hold:
|
|
152
|
+
* 1. the echo's session id equals the session the prompt was dispatched to, AND
|
|
153
|
+
* 2. that session is still the process's ACTIVE session (a stale-session echo from a superseded
|
|
154
|
+
* incarnation must never mark), AND
|
|
155
|
+
* 3. the persisted text equals the exact outbound text (a ~48KB launch-prefixed prompt is its own
|
|
156
|
+
* fingerprint — an operator-typed prompt on the same session can never collide).
|
|
157
|
+
* On a match the pending first-turn markers are consumed immediately and THIS arm is marked
|
|
158
|
+
* consumed; the watcher itself stays armed until the dispatch attempt settles, so the failed
|
|
159
|
+
* completion await can still consult the per-arm verdict (reconcileOpenCodeContextAcceptance).
|
|
160
|
+
*/
|
|
161
|
+
export function consumeOpenCodeContextAcceptance(process: ManagedProcess | undefined, eventSessionId: string | undefined, observedText: string | undefined): boolean {
|
|
162
|
+
const meta = process?.meta;
|
|
163
|
+
if (!meta) return false;
|
|
164
|
+
const pending = meta[ACCEPTANCE_META_KEY] as OpenCodeContextAcceptance | undefined;
|
|
165
|
+
if (!pending || typeof pending.sessionId !== "string" || typeof pending.text !== "string") return false;
|
|
166
|
+
if (!eventSessionId || eventSessionId !== pending.sessionId) return false;
|
|
167
|
+
if (meta.sessionId !== pending.sessionId) return false;
|
|
168
|
+
if (!observedText || observedText !== pending.text) return false;
|
|
169
|
+
// Mark the arm consumed BEFORE the marker write: markOpenCodeRelayContextSent REPLACES
|
|
170
|
+
// process.meta with a fresh object (spread), so a write into the old reference would be lost.
|
|
171
|
+
meta[ACCEPTANCE_META_KEY] = { ...pending, consumed: true };
|
|
172
|
+
markOpenCodeRelayContextSent(process, { text: pending.text, emitsRelayContext: pending.emitsRelayContext, emitsLaunchContext: pending.emitsLaunchContext });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* #2534 — does the `GET /session/:id/message` body positively contain a persisted USER message
|
|
178
|
+
* whose text part equals `text` exactly AND that was created at or after `dispatchedAt`? The
|
|
179
|
+
* outbound launch-carrying prompt (role/memory/gate briefing prepended to the assignment) is its
|
|
180
|
+
* own fingerprint: a same-session prompt typed by the operator can never collide with it, so
|
|
181
|
+
* exact equality is the identity match. But equality alone is not per-arm: every arm of send()'s
|
|
182
|
+
* retry loop dispatches the SAME text into the SAME session, so an earlier arm's persisted
|
|
183
|
+
* message would match a later arm's pre-acceptance failure (#2534 review round 2, MED-2) — the
|
|
184
|
+
* real envelope's `info.time.created` (epoch ms, verified live against v1.18.21) is the
|
|
185
|
+
* discriminator: only messages created at or after THIS arm's dispatch can prove THIS arm.
|
|
186
|
+
* The response shape is parsed defensively — an unparsable body, or a match candidate with no
|
|
187
|
+
* usable `created` timestamp, confirms nothing (returns false; fail-closed).
|
|
188
|
+
*/
|
|
189
|
+
function sessionPersistsUserText(body: unknown, text: string, dispatchedAt: number): boolean {
|
|
190
|
+
if (!Array.isArray(body)) return false;
|
|
191
|
+
return body.some((entry) => {
|
|
192
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
193
|
+
const record = entry as { info?: unknown; role?: unknown; parts?: unknown; time?: unknown };
|
|
194
|
+
const info = typeof record.info === "object" && record.info !== null ? (record.info as { role?: unknown; parts?: unknown; time?: unknown }) : undefined;
|
|
195
|
+
const role = info ? info.role : record.role;
|
|
196
|
+
if (typeof role !== "string" || role !== "user") return false;
|
|
197
|
+
const parts = Array.isArray(record.parts) ? record.parts : info && Array.isArray(info.parts) ? info.parts : undefined;
|
|
198
|
+
if (!parts) return false;
|
|
199
|
+
const time = info ? info.time : record.time;
|
|
200
|
+
const created = typeof time === "object" && time !== null ? (time as { created?: unknown }).created : undefined;
|
|
201
|
+
if (typeof created !== "number" || !Number.isFinite(created) || created < dispatchedAt) return false;
|
|
202
|
+
return parts.some((part) => {
|
|
203
|
+
if (typeof part !== "object" || part === null) return false;
|
|
204
|
+
const partRecord = part as { type?: unknown; text?: unknown };
|
|
205
|
+
return partRecord.type === "text" && partRecord.text === text;
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* #2534 — best-effort acceptance proof for a failed completion await. The SSE door has usually
|
|
212
|
+
* already marked by now (proof 1); if not, read the session's persisted messages once, on the
|
|
213
|
+
* client's own short deadline, and consume the markers on an exact persisted-text match. Any
|
|
214
|
+
* reconciliation failure is swallowed: an unreachable server cannot confirm acceptance, so the
|
|
215
|
+
* markers stay false and the next attempt retries the launch text (fail-closed).
|
|
216
|
+
*
|
|
217
|
+
* #2534 review (MED-2) — the accepted short-circuit reads THIS dispatch arm's consumed flag,
|
|
218
|
+
* never the durable first-turn markers: the markers are process-scoped and survive across the
|
|
219
|
+
* send() retry/re-roll loop's arms, so keying to them would make any later arm that fails
|
|
220
|
+
* PRE-acceptance inherit an earlier arm's acceptance and report the assignment delivered.
|
|
221
|
+
*
|
|
222
|
+
* #2534 review round 2 (MED-2) — the persisted match is likewise arm-scoped: the reconciliation
|
|
223
|
+
* runs with THIS arm's dispatch timestamp, so an earlier arm's identical persisted user message
|
|
224
|
+
* (same immutable text, same session) can never prove a later arm's failed dispatch. Without a
|
|
225
|
+
* pending arm there is nothing to prove — fail closed.
|
|
226
|
+
*/
|
|
227
|
+
async function reconcileOpenCodeContextAcceptance(client: { sessionMessages?(sessionId: string): Promise<unknown> }, process: ManagedProcess, sessionId: string, input: OpenCodeRelayContextInput, narrate: ((body: string) => void) | undefined): Promise<boolean> {
|
|
228
|
+
if (!input.emitsRelayContext && !input.emitsLaunchContext) return false;
|
|
229
|
+
const pending = process.meta?.[ACCEPTANCE_META_KEY] as OpenCodeContextAcceptance | undefined;
|
|
230
|
+
if (pending?.consumed) return true;
|
|
231
|
+
if (!pending || typeof pending.dispatchedAt !== "number") return false;
|
|
232
|
+
if (typeof client.sessionMessages !== "function") return false;
|
|
233
|
+
try {
|
|
234
|
+
if (sessionPersistsUserText(await client.sessionMessages(sessionId), input.text, pending.dispatchedAt)) {
|
|
235
|
+
markOpenCodeRelayContextSent(process, input);
|
|
236
|
+
narrate?.(`OpenCode persisted the prompt (${input.text.length} chars) before the completion await failed; first-turn context marked consumed, not requeueing.`);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// best-effort — see doc
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* #2534 — dispatch one attempt of the prepared turn, keeping first-turn marker consumption
|
|
247
|
+
* keyed to PROMPT ACCEPTANCE rather than to the completion await's return. OpenCode's
|
|
248
|
+
* `/message` POST is synchronous: it is ACCEPTED and its prompt persisted within moments of
|
|
249
|
+
* dispatch, but the await only resolves when the whole assistant turn ends — so on a long turn
|
|
250
|
+
* (up to the whole-turn deadline) the old await-then-mark order left `launchContextSent` false
|
|
251
|
+
* for the entire turn and re-prepended the full launch briefing to every message delivered
|
|
252
|
+
* after a timeout. Three acceptance proofs, in order of strength:
|
|
253
|
+
* 1. the SSE `message.part.updated` door (consumeOpenCodeContextAcceptance, wired into the
|
|
254
|
+
* adapter's handleEvent) fires the moment the provider persists the prompt as a user text
|
|
255
|
+
* part — marking mid-turn, long before the await settles;
|
|
256
|
+
* 2. a normal await RESOLUTION (a completed synchronous turn implies acceptance) — the
|
|
257
|
+
* sendOpenCodeRelayMessage marking, unchanged;
|
|
258
|
+
* 3. when the await THROWS, a bounded persisted-message reconciliation: a user message whose
|
|
259
|
+
* persisted text equals this exact outbound prompt proves acceptance, and the original
|
|
260
|
+
* failure is re-thrown wrapped in PromptAcceptedError so RunnerCore does not requeue the
|
|
261
|
+
* already-running assignment (attemptInitialPromptDelivery). If no proof materializes the
|
|
262
|
+
* failure is re-thrown untouched and the markers stay false — a pre-acceptance rejection
|
|
263
|
+
* (connect/readiness/transport failure) still retries the launch text, exactly as before.
|
|
264
|
+
*/
|
|
265
|
+
export async function sendOpenCodeRelayTurn<T>(client: { sendMessage(sessionId: string, text: string, model: string): Promise<T>; sessionMessages?(sessionId: string): Promise<unknown> }, process: ManagedProcess, sessionId: string, model: string, input: OpenCodeRelayContextInput, narrate?: (body: string) => void): Promise<T> {
|
|
266
|
+
armOpenCodeContextAcceptance(process, input, sessionId);
|
|
267
|
+
try {
|
|
268
|
+
return await sendOpenCodeRelayMessage(client, process, sessionId, model, input);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (await reconcileOpenCodeContextAcceptance(client, process, sessionId, input, narrate)) {
|
|
271
|
+
throw new PromptAcceptedError(`OpenCode accepted the prompt (${input.text.length} chars) before the completion await failed: ${errMessage(error)}`, { cause: error });
|
|
272
|
+
}
|
|
273
|
+
throw error;
|
|
274
|
+
} finally {
|
|
275
|
+
clearOpenCodeContextAcceptance(process);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { openCodeModelRegistry, type OpenCodeModelId, type OpenCodeModelRegistry } from "@humwick/providers";
|
|
6
|
+
import { errMessage, isRecord, stringValue } from "@humwick/sdk/types";
|
|
7
|
+
import type { ProviderConfig, RunnerSpawnConfig } from "../adapter";
|
|
8
|
+
import { logger } from "../logger";
|
|
9
|
+
import { openCodeBackend, openCodeCustomBackendIDs, openCodeHostAuthKey, openCodeLaunchModel, openCodeModelRecord } from "./opencode-provider";
|
|
10
|
+
import { openCodeMessageModel } from "./opencode-token-usage-capture";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* #2659 — the host's opencode model registry, used to normalize a launch model before spawn.
|
|
14
|
+
*
|
|
15
|
+
* Primary: `opencode models --pure` run with the launch env but isolated XDG config/data (the
|
|
16
|
+
* spawned server gets fresh isolated homes too), so it lists exactly the providers the server
|
|
17
|
+
* will have connected. Fallback: opencode's cached models.dev registry. Neither → undefined and
|
|
18
|
+
* the caller uses the static backend split.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const PROBE_TIMEOUT_MS = 30_000;
|
|
22
|
+
const cache = new Map<string, Promise<OpenCodeModelRegistry | undefined>>();
|
|
23
|
+
|
|
24
|
+
export interface OpenCodeRegistryProbeDeps {
|
|
25
|
+
run?: (command: string[], env: Record<string, string>, timeoutMs: number) => Promise<{ exitCode: number; stdout: string }>;
|
|
26
|
+
readModelsDev?: (path: string) => string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function runProbe(command: string[], env: Record<string, string>, timeoutMs: number): Promise<{ exitCode: number; stdout: string }> {
|
|
30
|
+
const proc = Bun.spawn(command, { env, stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: timeoutMs, killSignal: "SIGKILL" });
|
|
31
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
32
|
+
return { exitCode, stdout };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readFileOrUndefined(path: string): string | undefined {
|
|
36
|
+
try { return readFileSync(path, "utf8"); } catch { return undefined; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse opencode's models.dev cache (`{ [providerID]: { models: { [modelID]: … } } }`). */
|
|
40
|
+
export function parseModelsDevRegistry(text: string, extraProviders: Iterable<string>): OpenCodeModelRegistry | undefined {
|
|
41
|
+
let parsed: unknown;
|
|
42
|
+
try { parsed = JSON.parse(text); } catch { return undefined; }
|
|
43
|
+
if (!isRecord(parsed)) return undefined;
|
|
44
|
+
const ids: string[] = [];
|
|
45
|
+
for (const [providerID, entry] of Object.entries(parsed)) {
|
|
46
|
+
if (!isRecord(entry) || !isRecord(entry.models)) continue;
|
|
47
|
+
for (const modelID of Object.keys(entry.models)) ids.push(`${providerID}/${modelID}`);
|
|
48
|
+
}
|
|
49
|
+
return ids.length ? openCodeModelRegistry("models.dev", ids, extraProviders) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function probeOpenCodeModelRegistry(
|
|
53
|
+
command: string,
|
|
54
|
+
env: Record<string, string>,
|
|
55
|
+
extraProviders: string[],
|
|
56
|
+
deps: OpenCodeRegistryProbeDeps = {},
|
|
57
|
+
): Promise<OpenCodeModelRegistry | undefined> {
|
|
58
|
+
const scratch = mkdtempSync(join(tmpdir(), "agent-relay-opencode-models-"));
|
|
59
|
+
try {
|
|
60
|
+
const { exitCode, stdout } = await (deps.run ?? runProbe)(
|
|
61
|
+
[command, "models", "--pure"],
|
|
62
|
+
{ ...env, XDG_CONFIG_HOME: join(scratch, "config"), XDG_DATA_HOME: join(scratch, "data"), XDG_STATE_HOME: join(scratch, "state") },
|
|
63
|
+
PROBE_TIMEOUT_MS,
|
|
64
|
+
);
|
|
65
|
+
const live = exitCode === 0 ? openCodeModelRegistry("live", stdout.split("\n"), extraProviders) : undefined;
|
|
66
|
+
if (live && live.models.size > 0) return live;
|
|
67
|
+
logger.warn("opencode", `model registry probe returned no models (exit ${exitCode}); falling back to the models.dev cache`);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.warn("opencode", `model registry probe failed: ${errMessage(error)}; falling back to the models.dev cache`);
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
const cacheHome = env.XDG_CACHE_HOME || join(env.HOME || homedir(), ".cache");
|
|
74
|
+
const text = (deps.readModelsDev ?? readFileOrUndefined)(join(cacheHome, "opencode", "models.json"));
|
|
75
|
+
return text ? parseModelsDevRegistry(text, extraProviders) : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One probe per (command, credential set) per runner process; failures are not cached. */
|
|
79
|
+
export function cachedOpenCodeModelRegistry(command: string, env: Record<string, string>, extraProviders: string[]): Promise<OpenCodeModelRegistry | undefined> {
|
|
80
|
+
const key = [command, ...Object.keys(env).filter((name) => name.endsWith("_API_KEY") && relayEnv(env, name)).sort()].join("\0");
|
|
81
|
+
const existing = cache.get(key);
|
|
82
|
+
if (existing) return existing;
|
|
83
|
+
const pending = probeOpenCodeModelRegistry(command, env, extraProviders).then((registry) => {
|
|
84
|
+
if (!registry) cache.delete(key);
|
|
85
|
+
return registry;
|
|
86
|
+
});
|
|
87
|
+
cache.set(key, pending);
|
|
88
|
+
return pending;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* #2659 — normalize the launch model against the host's opencode registry BEFORE the server
|
|
93
|
+
* starts: a registry-listed id passes through unchanged, and an unknown/ambiguous id fails the
|
|
94
|
+
* spawn here with candidates instead of dying later as a provider 400.
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveOpenCodeLaunchModel(
|
|
97
|
+
config: RunnerSpawnConfig,
|
|
98
|
+
providerConfig: ProviderConfig,
|
|
99
|
+
provider: string,
|
|
100
|
+
registryFor: typeof cachedOpenCodeModelRegistry = cachedOpenCodeModelRegistry,
|
|
101
|
+
): Promise<OpenCodeModelId> {
|
|
102
|
+
const env: Record<string, string> = writeChildEnv({}, { ...config.env, ...providerConfig.env, PATH: config.env.PATH ?? providerConfig.env.PATH ?? process.env.PATH ?? "" });
|
|
103
|
+
// #2866 — the registry probe runs under fresh XDG roots too, so a hostAuth backend only lists
|
|
104
|
+
// its models when the host login key rides in as the backend's env credential.
|
|
105
|
+
const { backend } = openCodeBackend(provider);
|
|
106
|
+
if (backend.hostAuth && !relayEnv(env, backend.apiKeyEnvVar)) {
|
|
107
|
+
const hostKey = openCodeHostAuthKey(backend.opencodeProviderID, env);
|
|
108
|
+
if (hostKey) writeChildEnv(env, { [backend.apiKeyEnvVar]: hostKey });
|
|
109
|
+
}
|
|
110
|
+
const registry = await registryFor(providerConfig.command || "opencode", env, openCodeCustomBackendIDs());
|
|
111
|
+
const launch = openCodeLaunchModel(config.model, provider, openCodeModelRecord(config.modelRecord, provider), registry);
|
|
112
|
+
logger.info("opencode", `launch model ${JSON.stringify(config.modelRecord?.qualifiedModel ?? config.model ?? "(default)")} → ${launch.qualified} (${registry ? `${registry.source} registry, ${registry.models.size} models` : "static backend split"})`);
|
|
113
|
+
return launch;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* #2659 — a model the human picked in the attached TUI rides on that prompt's user row
|
|
118
|
+
* (`info.model = {providerID, modelID}`). Adopt it as the session model so the next Relay chat
|
|
119
|
+
* turn keeps it instead of re-sending the launch model.
|
|
120
|
+
*/
|
|
121
|
+
export function adoptOpenCodeTerminalModel(meta: Record<string, unknown> | undefined, info: unknown): void {
|
|
122
|
+
const picked = isRecord(info) && isRecord(info.model) ? openCodeMessageModel(info.model) : undefined;
|
|
123
|
+
if (!meta || !picked || picked === meta.model) return;
|
|
124
|
+
logger.info("opencode", `terminal model switch: ${stringValue(meta.model) ?? "(unset)"} → ${picked}; Relay turns now use it`);
|
|
125
|
+
meta.model = picked;
|
|
126
|
+
}
|