@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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
type EmitAgentMessage = (body: string) => void;
|
|
2
|
+
type ResponseEntry = { itemId?: string; body: string };
|
|
3
|
+
type CompleteItemOptions = { includeInResponse?: boolean; phase?: unknown };
|
|
4
|
+
|
|
5
|
+
const MAX_CAPTURED_RESPONSE_CHARS = 16_000;
|
|
6
|
+
// `\s` is defined as exactly the character set String#trim strips, so this tests
|
|
7
|
+
// `text.trim() !== ""` without allocating the trimmed copy. It is NOT free — matching forces a
|
|
8
|
+
// flatten of the accumulated buffer's rope — so it must stay off the per-delta hot path and is
|
|
9
|
+
// only used where the pre-#2504 whitespace guard actually changed the outcome.
|
|
10
|
+
const NON_WHITESPACE = /\S/;
|
|
11
|
+
const TRUNCATION_MARKER_PREFIX = "[truncated:";
|
|
12
|
+
|
|
13
|
+
export class CodexAgentMessageCapture {
|
|
14
|
+
private segments: ResponseEntry[][] = [];
|
|
15
|
+
private currentSegment: ResponseEntry[] = [];
|
|
16
|
+
private readonly buffers = new Map<string, string>();
|
|
17
|
+
private readonly flushedLengths = new Map<string, number>();
|
|
18
|
+
private readonly responseRecordedLengths = new Map<string, number>();
|
|
19
|
+
|
|
20
|
+
reset(): void {
|
|
21
|
+
this.segments = [];
|
|
22
|
+
this.currentSegment = [];
|
|
23
|
+
this.buffers.clear();
|
|
24
|
+
this.flushedLengths.clear();
|
|
25
|
+
this.responseRecordedLengths.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
appendDelta(itemId: string | undefined, delta: string): void {
|
|
29
|
+
if (!itemId || !delta) return;
|
|
30
|
+
this.buffers.set(itemId, `${this.buffers.get(itemId) ?? ""}${delta}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
completeItem(itemId: string | undefined, text: string | undefined, emit: EmitAgentMessage, options: CompleteItemOptions = {}): void {
|
|
34
|
+
const fullText = text ?? (itemId ? this.buffers.get(itemId) : undefined);
|
|
35
|
+
this.flushText(itemId, fullText, emit);
|
|
36
|
+
if (options.includeInResponse === false || options.phase === "commentary") {
|
|
37
|
+
this.forgetResponseItem(itemId);
|
|
38
|
+
} else {
|
|
39
|
+
this.recordResponseText(itemId, fullText);
|
|
40
|
+
}
|
|
41
|
+
if (!itemId) return;
|
|
42
|
+
this.buffers.delete(itemId);
|
|
43
|
+
this.flushedLengths.delete(itemId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
closeSegment(emit: EmitAgentMessage): void {
|
|
47
|
+
this.flushPending(emit);
|
|
48
|
+
if (!this.currentSegment.length) return;
|
|
49
|
+
this.segments.push(this.currentSegment);
|
|
50
|
+
this.currentSegment = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Deterministic turn-final response: prefer the trailing structured report span, not a generic
|
|
54
|
+
// post-handoff coda. The original #1079/#1086 trailing-run rule correctly kept early narration
|
|
55
|
+
// out, but it over-trimmed Codex turns shaped "summary -> workspace ready -> Landed..." and made
|
|
56
|
+
// report-up deliver only the coda (#1136). We still drop leading narration; we just let structured
|
|
57
|
+
// report markers beat a terse final status line.
|
|
58
|
+
finish(emit: EmitAgentMessage): string | undefined {
|
|
59
|
+
this.closeSegment(emit);
|
|
60
|
+
const trailingSegment = selectFinalResponseSegments(this.segments);
|
|
61
|
+
this.segments = [];
|
|
62
|
+
const text = trailingSegment ? trailingSegment.map(segmentBody).join("\n\n").trim() : undefined;
|
|
63
|
+
return truncateCapturedResponse(text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private flushPending(emit: EmitAgentMessage): void {
|
|
67
|
+
// #2504 hot path: EVERY reasoning/plan delta calls closeSegment -> flushPending, so this runs
|
|
68
|
+
// thousands of times per turn. Bail before the defensive entry-array copy when there is no
|
|
69
|
+
// open agentMessage buffer (the common case) — that snapshot exists only so `emit` re-entering
|
|
70
|
+
// and mutating `buffers` cannot disturb the iteration, and there is nothing to iterate here.
|
|
71
|
+
if (!this.buffers.size) return;
|
|
72
|
+
for (const [itemId, text] of [...this.buffers.entries()]) {
|
|
73
|
+
this.flushText(itemId, text, emit);
|
|
74
|
+
this.recordResponseText(itemId, text);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private flushText(itemId: string | undefined, text: string | undefined, emit: EmitAgentMessage): void {
|
|
79
|
+
if (!text) return;
|
|
80
|
+
const offset = itemId ? (this.flushedLengths.get(itemId) ?? 0) : 0;
|
|
81
|
+
// #2504: `text` is the WHOLE accumulated item buffer and this is re-entered on every
|
|
82
|
+
// reasoning/plan delta. `.length` is O(1) on a rope, `.slice()`/`.trim()` are not — they
|
|
83
|
+
// flatten and copy the entire buffer. Skip both when nothing new has arrived, which is the
|
|
84
|
+
// overwhelmingly common case. Equivalent to the old code: slicing at/past the end yields ""
|
|
85
|
+
// and an empty body returned without emitting; the only other effect was rewinding the offset
|
|
86
|
+
// on a shorter (non-extension) text, preserved below.
|
|
87
|
+
if (itemId && text.length <= offset) {
|
|
88
|
+
if (text.length < offset) this.flushedLengths.set(itemId, text.length);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const chunk = text.slice(offset);
|
|
92
|
+
if (itemId) this.flushedLengths.set(itemId, text.length);
|
|
93
|
+
const body = chunk.trim();
|
|
94
|
+
if (!body) return;
|
|
95
|
+
emit(body);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// #2504 root cause. This used to open with `const body = text.trim()` — a full flatten+copy of
|
|
99
|
+
// the entire accumulated agentMessage buffer — before branching, and on the itemId path `body`
|
|
100
|
+
// was then DEAD: that branch computes its own incremental `chunk` and pushes that. Because every
|
|
101
|
+
// reasoning/plan delta re-enters here via closeSegment -> flushPending with the same growing
|
|
102
|
+
// buffer, the copy ran per delta over the whole buffer: O(N^2) transient-string churn that
|
|
103
|
+
// fragmented the runner's allocator into multi-GB RSS bun never returned. Work here is now
|
|
104
|
+
// bounded by the number of NEW characters, and is O(1) when nothing new arrived.
|
|
105
|
+
private recordResponseText(itemId: string | undefined, text: string | undefined): void {
|
|
106
|
+
if (!text) return;
|
|
107
|
+
if (itemId) {
|
|
108
|
+
const offset = this.responseRecordedLengths.get(itemId) ?? 0;
|
|
109
|
+
// THE hot path — every reasoning/plan delta lands here with an unchanged buffer. `.length`
|
|
110
|
+
// is O(1); the pre-fix full-buffer trim was not, and its outcome was always "record
|
|
111
|
+
// nothing" anyway (slicing at the end yields an empty chunk, and rewriting the offset to
|
|
112
|
+
// the value it already holds is a no-op).
|
|
113
|
+
if (text.length === offset) return;
|
|
114
|
+
if (text.length < offset) {
|
|
115
|
+
// The item's text got SHORTER than what we recorded — a buffer re-opened by fresh deltas
|
|
116
|
+
// after completeItem, which clears `buffers` but deliberately keeps this offset. Nothing
|
|
117
|
+
// can be recorded from it; the pre-fix code only rewound the offset, and only for
|
|
118
|
+
// non-blank text.
|
|
119
|
+
if (NON_WHITESPACE.test(text)) this.responseRecordedLengths.set(itemId, text.length);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Only the NEW tail is examined. `slice` on the accumulated buffer is rope-aware in JSC and
|
|
123
|
+
// stays proportional to the tail; a regex probe over `text` would NOT be — it forces a full
|
|
124
|
+
// flatten of the rope on every delta, which is the very cost this fix exists to remove.
|
|
125
|
+
const chunk = text.slice(offset).trim();
|
|
126
|
+
// The pre-fix guard was `if (!text.trim()) return` — skip the offset advance while the item
|
|
127
|
+
// is still ENTIRELY whitespace. A non-blank chunk already proves the text is non-blank (the
|
|
128
|
+
// chunk is a substring of it), so the whole-text probe below is only reached for an all-blank
|
|
129
|
+
// tail, and then it stops at the buffer's first real character.
|
|
130
|
+
if (!chunk && !NON_WHITESPACE.test(text)) return;
|
|
131
|
+
this.responseRecordedLengths.set(itemId, text.length);
|
|
132
|
+
if (!chunk) return;
|
|
133
|
+
this.currentSegment.push({ itemId, body: chunk });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const body = text.trim();
|
|
137
|
+
if (!body) return;
|
|
138
|
+
this.currentSegment.push({ body });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private forgetResponseItem(itemId: string | undefined): void {
|
|
142
|
+
if (!itemId) return;
|
|
143
|
+
this.responseRecordedLengths.delete(itemId);
|
|
144
|
+
this.currentSegment = this.currentSegment.filter((entry) => entry.itemId !== itemId);
|
|
145
|
+
this.segments = this.segments
|
|
146
|
+
.map((segment) => segment.filter((entry) => entry.itemId !== itemId))
|
|
147
|
+
.filter((segment) => segment.length > 0);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function segmentBody(segment: ResponseEntry[]): string {
|
|
152
|
+
return segment.map((entry) => entry.body).join("\n\n").trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function selectFinalResponseSegments(segments: ResponseEntry[][]): ResponseEntry[][] | undefined {
|
|
156
|
+
const bodies = segments.map(segmentBody).filter(Boolean);
|
|
157
|
+
if (!bodies.length) return undefined;
|
|
158
|
+
let end = bodies.length - 1;
|
|
159
|
+
while (end > 0 && isGenericFinalCoda(bodies[end]!) && bodies.slice(0, end).some(isSubstantiveReportSegment)) end--;
|
|
160
|
+
const firstReport = bodies.slice(0, end + 1).findIndex(isReportBoundarySegment);
|
|
161
|
+
if (firstReport >= 0) return segments.slice(firstReport, end + 1);
|
|
162
|
+
for (let i = end; i >= 0; i--) {
|
|
163
|
+
if (isSubstantiveReportSegment(bodies[i]!)) return [segments[i]!];
|
|
164
|
+
}
|
|
165
|
+
return [segments[end]!];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const REPORT_BOUNDARY = /(^|\n)(#{1,6}\s|\*\*(summary|what changed|rca|fix|tests?|gates?|verification|risk)\b|(?:summary|what changed|rca|fix|tests?|gates?|verification|risk):)/i;
|
|
169
|
+
const REPORT_STRUCTURE = /(^|\n)\s*[-*]\s+\S|`[^`]+`|\b(typecheck|bun test|tests? pass(?:ed)?|gates? green|rca|verified|implemented)\b/i;
|
|
170
|
+
const GENERIC_FINAL_CODA = /^(?:done|landed(?: on main)?|committed|merged|ready|no further action|nothing further|all set)\b|(?:\bno further action needed\b|\bnothing further needed\b)$/i;
|
|
171
|
+
|
|
172
|
+
function isReportBoundarySegment(body: string): boolean {
|
|
173
|
+
return REPORT_BOUNDARY.test(body.trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isSubstantiveReportSegment(body: string): boolean {
|
|
177
|
+
const trimmed = body.trim();
|
|
178
|
+
return !isGenericFinalCoda(trimmed) && (trimmed.length >= 120 || isReportBoundarySegment(trimmed) || REPORT_STRUCTURE.test(trimmed));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isGenericFinalCoda(body: string): boolean {
|
|
182
|
+
const trimmed = body.trim();
|
|
183
|
+
return trimmed.length <= 180 && GENERIC_FINAL_CODA.test(trimmed) && !isReportBoundarySegment(trimmed);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function truncateCapturedResponse(text: string | undefined): string | undefined {
|
|
187
|
+
if (!text || text.length <= MAX_CAPTURED_RESPONSE_CHARS) return text;
|
|
188
|
+
return `${text.slice(0, MAX_CAPTURED_RESPONSE_CHARS).trimEnd()}\n\n${TRUNCATION_MARKER_PREFIX} showing first ${MAX_CAPTURED_RESPONSE_CHARS} of ${text.length} chars]`;
|
|
189
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { errMessage } from "@humwick/sdk";
|
|
2
|
+
import type { SpawnArgs } from "../adapter";
|
|
3
|
+
import { CodexAppClient } from "./codex-client";
|
|
4
|
+
|
|
5
|
+
const STARTUP_TIMEOUT_MS = 60_000;
|
|
6
|
+
const STDERR_TAIL_CHARS = 8_192;
|
|
7
|
+
|
|
8
|
+
/** Connect only after our child announces its bound socket, not after a guessed delay (#3225). */
|
|
9
|
+
export async function spawnCodexAppServer(args: SpawnArgs, signal?: AbortSignal) {
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
const appServerUrl = args.env.CODEX_APP_SERVER_URL!;
|
|
12
|
+
const appServer = Bun.spawn([args.command, ...args.args], {
|
|
13
|
+
cwd: args.cwd, env: args.env, stdin: "ignore", stdout: "inherit", stderr: "pipe",
|
|
14
|
+
});
|
|
15
|
+
const client = new CodexAppClient(appServerUrl);
|
|
16
|
+
const listening = Promise.withResolvers<void>();
|
|
17
|
+
const stderrReader = appServer.stderr.getReader();
|
|
18
|
+
let stderrTail = "";
|
|
19
|
+
let announced = false;
|
|
20
|
+
const stderrDone = (async () => {
|
|
21
|
+
const decoder = new TextDecoder();
|
|
22
|
+
try {
|
|
23
|
+
while (true) {
|
|
24
|
+
const { done, value: chunk } = await stderrReader.read();
|
|
25
|
+
if (done) return;
|
|
26
|
+
process.stderr.write(chunk);
|
|
27
|
+
stderrTail = (stderrTail + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
|
|
28
|
+
if (announced) continue;
|
|
29
|
+
const match = /^\s*listening on:\s*(ws:\/\/\S+)[\r\n]/m.exec(stderrTail);
|
|
30
|
+
if (!match) continue;
|
|
31
|
+
announced = true;
|
|
32
|
+
if (new URL(match[1]!).href !== new URL(appServerUrl).href) {
|
|
33
|
+
listening.reject(new Error(`Codex app-server listened on ${match[1]} instead of ${appServerUrl}`));
|
|
34
|
+
} else {
|
|
35
|
+
listening.resolve();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
stderrReader.releaseLock();
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
// Keep draining after startup so the child cannot block on a full stderr pipe.
|
|
43
|
+
void stderrDone.catch((error) => listening.reject(error));
|
|
44
|
+
const exited = appServer.exited.then(async (code) => {
|
|
45
|
+
await stderrDone;
|
|
46
|
+
throw new Error(`Codex app-server exited during startup (code ${code}, signal ${appServer.signalCode ?? "none"})`);
|
|
47
|
+
});
|
|
48
|
+
const deadline = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => deadline.abort(new Error(`Codex app-server startup timed out after ${STARTUP_TIMEOUT_MS}ms`)), STARTUP_TIMEOUT_MS);
|
|
50
|
+
const startupSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
|
|
51
|
+
const aborted = Promise.withResolvers<never>();
|
|
52
|
+
const onAbort = () => aborted.reject(startupSignal.reason);
|
|
53
|
+
startupSignal.addEventListener("abort", onAbort, { once: true });
|
|
54
|
+
if (startupSignal.aborted) onAbort();
|
|
55
|
+
try {
|
|
56
|
+
await Promise.race([listening.promise, exited, aborted.promise]);
|
|
57
|
+
await Promise.race([client.connect(), exited, aborted.promise]);
|
|
58
|
+
return { appServer, appServerUrl, client };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
client.close();
|
|
61
|
+
appServer.kill("SIGKILL");
|
|
62
|
+
await appServer.exited;
|
|
63
|
+
await stderrReader.cancel().catch(() => {});
|
|
64
|
+
await stderrDone.catch(() => {});
|
|
65
|
+
throw new Error(`${errMessage(error)}; endpoint ${appServerUrl}; app-server stderr: ${stderrTail.trim() || "(empty)"}`);
|
|
66
|
+
} finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
startupSignal.removeEventListener("abort", onAbort);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
type JsonRpcId = number | string;
|
|
4
|
+
|
|
5
|
+
type JsonRpcRequest = {
|
|
6
|
+
id: JsonRpcId;
|
|
7
|
+
method: string;
|
|
8
|
+
params?: unknown;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type JsonRpcResponse = {
|
|
12
|
+
id: JsonRpcId;
|
|
13
|
+
result?: unknown;
|
|
14
|
+
error?: { code: number; message: string; data?: unknown };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type JsonRpcNotification = {
|
|
18
|
+
method: string;
|
|
19
|
+
params?: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ThreadStatus =
|
|
23
|
+
| { type: "notLoaded" }
|
|
24
|
+
| { type: "idle" }
|
|
25
|
+
| { type: "systemError" }
|
|
26
|
+
| { type: "active"; activeFlags: string[] };
|
|
27
|
+
|
|
28
|
+
type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress";
|
|
29
|
+
|
|
30
|
+
interface Turn {
|
|
31
|
+
id: string;
|
|
32
|
+
status: TurnStatus;
|
|
33
|
+
startedAt: number | null;
|
|
34
|
+
completedAt: number | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Thread {
|
|
38
|
+
id: string;
|
|
39
|
+
cwd: string;
|
|
40
|
+
status: ThreadStatus;
|
|
41
|
+
updatedAt: number;
|
|
42
|
+
preview: string;
|
|
43
|
+
turns?: Turn[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ClientEvent =
|
|
47
|
+
| { type: "notification"; message: JsonRpcNotification }
|
|
48
|
+
| { type: "server-request"; message: JsonRpcRequest }
|
|
49
|
+
| { type: "response"; message: JsonRpcResponse };
|
|
50
|
+
|
|
51
|
+
interface TurnStartResponse {
|
|
52
|
+
turn: Turn;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ThreadStartResponse {
|
|
56
|
+
thread: Thread;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ThreadResumeResponse {
|
|
60
|
+
thread: Thread;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ThreadReadResponse {
|
|
64
|
+
thread: Thread;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ThreadListResponse {
|
|
68
|
+
data: Thread[];
|
|
69
|
+
nextCursor: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ThreadLoadedListResponse {
|
|
73
|
+
data: string[];
|
|
74
|
+
nextCursor: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface AccountRateLimitsResponse {
|
|
78
|
+
rateLimits?: unknown;
|
|
79
|
+
rate_limits?: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const CODEX_APP_CLIENT_EVENT_CAP = 5_000;
|
|
83
|
+
|
|
84
|
+
// #2504: the frame ring is diagnostic-only (getEvents()), but a COUNT cap alone bounds nothing —
|
|
85
|
+
// a single app-server frame can carry a whole file diff or a large tool payload, so 5,000 frames
|
|
86
|
+
// is an unbounded number of bytes. Cap the retained frame text too and evict oldest-first. The
|
|
87
|
+
// budget is charged in raw frame characters; the parsed objects retained alongside them cost a
|
|
88
|
+
// multiple of that, so keep the ceiling modest. The newest frame is always retained even if it
|
|
89
|
+
// alone exceeds the budget, so an oversized frame can never empty the ring or spin the evictor.
|
|
90
|
+
export const CODEX_APP_CLIENT_EVENT_BYTE_CAP = 4_000_000;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* #2539 Phase A — a JSON-RPC error response from the app-server, kept STRUCTURED. The client
|
|
94
|
+
* used to flatten every error into `"<message> (<code>)"`, which forced callers to classify
|
|
95
|
+
* rejections by message substring and made the turn/steer fallbacks (not-steerable vs
|
|
96
|
+
* stale-turn vs transport) indistinguishable from real faults. The message keeps the old
|
|
97
|
+
* `"<message> (<code>)"` shape so existing diagnostics and message-matching keep working.
|
|
98
|
+
*/
|
|
99
|
+
export class CodexRpcError extends Error {
|
|
100
|
+
readonly code: number;
|
|
101
|
+
readonly data?: unknown;
|
|
102
|
+
constructor(code: number, message: string, data?: unknown) {
|
|
103
|
+
super(`${message} (${code})`);
|
|
104
|
+
this.name = "CodexRpcError";
|
|
105
|
+
this.code = code;
|
|
106
|
+
this.data = data;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// #1339: every JSON-RPC method the app-server exposes acknowledges promptly — turn/start
|
|
111
|
+
// returns the created turn immediately (turn progress/completion arrive later as separate
|
|
112
|
+
// notifications), compact/start acks and reports completion via thread/compacted, etc. So a
|
|
113
|
+
// request whose response never arrives (a half-open socket, a lost frame, an app-server that
|
|
114
|
+
// wedged mid-handling) is always a fault, never normal latency. Without a ceiling, request()
|
|
115
|
+
// would hang forever; because deliver() awaits turnStart under the runner's single-flight
|
|
116
|
+
// `delivering` latch, one hung request permanently wedges the whole input pump — the agent
|
|
117
|
+
// looks "unresponsive": input is accepted but no turn ever starts and nothing new drains.
|
|
118
|
+
// Reject after this ceiling so the hang surfaces (logged delivery failure) and the pump
|
|
119
|
+
// recovers by re-queuing. Generous vs. the sub-second normal ack, so no false timeouts.
|
|
120
|
+
export const CODEX_APP_CLIENT_REQUEST_TIMEOUT_MS = 30_000;
|
|
121
|
+
|
|
122
|
+
export class CodexAppClient {
|
|
123
|
+
private ws!: WebSocket;
|
|
124
|
+
private nextId = 1;
|
|
125
|
+
private pending = new Map<JsonRpcId, { resolve: (value: any) => void; reject: (err: unknown) => void }>();
|
|
126
|
+
// Each retained frame carries the raw character count it was charged, so eviction never has to
|
|
127
|
+
// re-measure — re-serializing frames inside record() would be its own per-frame leak.
|
|
128
|
+
private events: { event: ClientEvent; size: number }[] = [];
|
|
129
|
+
private eventBytes = 0;
|
|
130
|
+
private listeners = new Set<(event: ClientEvent) => void>();
|
|
131
|
+
private connected = false;
|
|
132
|
+
private connectionListeners = new Set<(connected: boolean) => void>();
|
|
133
|
+
|
|
134
|
+
constructor(
|
|
135
|
+
private readonly url: string,
|
|
136
|
+
private readonly log: (msg: string) => void = () => {},
|
|
137
|
+
private readonly requestTimeoutMs: number = CODEX_APP_CLIENT_REQUEST_TIMEOUT_MS,
|
|
138
|
+
) {}
|
|
139
|
+
|
|
140
|
+
async connect(): Promise<void> {
|
|
141
|
+
if (this.connected) return;
|
|
142
|
+
await new Promise<void>((resolve, reject) => {
|
|
143
|
+
const ws = new WebSocket(this.url);
|
|
144
|
+
this.ws = ws;
|
|
145
|
+
|
|
146
|
+
ws.onopen = () => {
|
|
147
|
+
this.connected = true;
|
|
148
|
+
this.emitConnection(true);
|
|
149
|
+
resolve();
|
|
150
|
+
};
|
|
151
|
+
ws.onerror = (event) => reject(new Error(`websocket error: ${String((event as ErrorEvent).message || "unknown")}`));
|
|
152
|
+
ws.onclose = (event) => {
|
|
153
|
+
this.connected = false;
|
|
154
|
+
this.emitConnection(false);
|
|
155
|
+
const err = new Error(`websocket closed code=${event.code} reason=${event.reason || "(none)"}`);
|
|
156
|
+
for (const pending of this.pending.values()) pending.reject(err);
|
|
157
|
+
this.pending.clear();
|
|
158
|
+
};
|
|
159
|
+
ws.onmessage = (event) => this.handleMessage(String(event.data));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
close(): void {
|
|
164
|
+
if (!this.ws) return;
|
|
165
|
+
this.ws.close();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
isConnected(): boolean {
|
|
169
|
+
return this.connected;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async initialize(): Promise<unknown> {
|
|
173
|
+
return this.request("initialize", {
|
|
174
|
+
clientInfo: {
|
|
175
|
+
name: "agent-relay-runner-codex",
|
|
176
|
+
title: "Agent Relay Codex Runner",
|
|
177
|
+
version: "0.1.0",
|
|
178
|
+
},
|
|
179
|
+
capabilities: {
|
|
180
|
+
experimentalApi: true,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
onEvent(listener: (event: ClientEvent) => void): () => void {
|
|
186
|
+
this.listeners.add(listener);
|
|
187
|
+
return () => this.listeners.delete(listener);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
onConnectionChange(listener: (connected: boolean) => void): () => void {
|
|
191
|
+
this.connectionListeners.add(listener);
|
|
192
|
+
return () => this.connectionListeners.delete(listener);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getEvents(): ClientEvent[] {
|
|
196
|
+
return this.events.map((frame) => frame.event);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async settle(ms = 150): Promise<void> {
|
|
200
|
+
await delay(ms);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async threadStart(params: Record<string, unknown>): Promise<ThreadStartResponse> {
|
|
204
|
+
return this.request<ThreadStartResponse>("thread/start", params);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async skillsSetExtraRoots(extraRoots: string[]): Promise<Record<string, never>> {
|
|
208
|
+
return this.request<Record<string, never>>("skills/extraRoots/set", { extraRoots });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async threadResume(params: Record<string, unknown>): Promise<ThreadResumeResponse> {
|
|
212
|
+
return this.request<ThreadResumeResponse>("thread/resume", params);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async threadRead(threadId: string, includeTurns = false): Promise<ThreadReadResponse> {
|
|
216
|
+
return this.request<ThreadReadResponse>("thread/read", { threadId, includeTurns });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async threadList(params: Record<string, unknown>): Promise<ThreadListResponse> {
|
|
220
|
+
return this.request<ThreadListResponse>("thread/list", params);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async threadLoadedList(limit = 20): Promise<ThreadLoadedListResponse> {
|
|
224
|
+
return this.request<ThreadLoadedListResponse>("thread/loaded/list", { limit });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async threadCompactStart(threadId: string): Promise<Record<string, never>> {
|
|
228
|
+
return this.request<Record<string, never>>("thread/compact/start", { threadId });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async turnStart(threadId: string, text: string): Promise<TurnStartResponse> {
|
|
232
|
+
return this.request<TurnStartResponse>("turn/start", {
|
|
233
|
+
threadId,
|
|
234
|
+
input: [{ type: "text", text }],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async turnSteer(threadId: string, turnId: string, text: string, clientUserMessageId?: string): Promise<{ turnId: string }> {
|
|
239
|
+
return this.request<{ turnId: string }>("turn/steer", {
|
|
240
|
+
threadId,
|
|
241
|
+
expectedTurnId: turnId,
|
|
242
|
+
input: [{ type: "text", text }],
|
|
243
|
+
// #2539 Phase A — stable acceptance-correlation id (echoed back as the userMessage
|
|
244
|
+
// item's clientId). Absent keeps the legacy wire shape.
|
|
245
|
+
...(clientUserMessageId ? { clientUserMessageId } : {}),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async turnInterrupt(threadId: string, turnId: string): Promise<Record<string, never>> {
|
|
250
|
+
return this.request<Record<string, never>>("turn/interrupt", { threadId, turnId });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async accountRateLimitsRead(): Promise<AccountRateLimitsResponse> {
|
|
254
|
+
return this.request<AccountRateLimitsResponse>("account/rateLimits/read");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
respondToServerRequest(id: JsonRpcId, result: unknown): void {
|
|
258
|
+
if (!this.connected) {
|
|
259
|
+
throw new Error("websocket not connected");
|
|
260
|
+
}
|
|
261
|
+
this.ws.send(JSON.stringify({ id, result }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
rejectServerRequest(id: JsonRpcId, code: number, message: string, data?: unknown): void {
|
|
265
|
+
if (!this.connected) {
|
|
266
|
+
throw new Error("websocket not connected");
|
|
267
|
+
}
|
|
268
|
+
this.ws.send(JSON.stringify({ id, error: { code, message, ...(data !== undefined ? { data } : {}) } }));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async request<T = unknown>(method: string, params?: unknown): Promise<T> {
|
|
272
|
+
if (!this.connected) {
|
|
273
|
+
throw new Error("websocket not connected");
|
|
274
|
+
}
|
|
275
|
+
const id = this.nextId++;
|
|
276
|
+
const payload: JsonRpcRequest = { id, method, params };
|
|
277
|
+
const promise = new Promise<T>((resolve, reject) => {
|
|
278
|
+
// #1339: cap the wait so a response that never arrives can't hang forever. The timer
|
|
279
|
+
// is armed here and cleared by settle() the moment the matching frame lands (or the
|
|
280
|
+
// socket closes and onclose rejects every pending). unref so a pending request never
|
|
281
|
+
// by itself keeps the process alive.
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
if (!this.pending.has(id)) return;
|
|
284
|
+
this.pending.delete(id);
|
|
285
|
+
this.log(`request timed out after ${this.requestTimeoutMs}ms: ${method} (id=${id})`);
|
|
286
|
+
reject(new Error(`codex request timed out after ${this.requestTimeoutMs}ms: ${method}`));
|
|
287
|
+
}, this.requestTimeoutMs);
|
|
288
|
+
(timer as { unref?: () => void }).unref?.();
|
|
289
|
+
const settle = (fn: (value: any) => void) => (value: unknown) => {
|
|
290
|
+
clearTimeout(timer);
|
|
291
|
+
fn(value);
|
|
292
|
+
};
|
|
293
|
+
this.pending.set(id, { resolve: settle(resolve), reject: settle(reject) });
|
|
294
|
+
});
|
|
295
|
+
this.ws.send(JSON.stringify(payload));
|
|
296
|
+
return promise;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private handleMessage(raw: string): void {
|
|
300
|
+
// The frame comes from an external process over a WebSocket; a partial or
|
|
301
|
+
// malformed frame must not throw out of onmessage and drop the connection.
|
|
302
|
+
let parsed: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
|
|
303
|
+
try {
|
|
304
|
+
parsed = JSON.parse(raw) as JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
|
|
305
|
+
} catch {
|
|
306
|
+
this.log(`dropped malformed frame (${raw.length} bytes)`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if ("id" in parsed && ("result" in parsed || "error" in parsed)) {
|
|
311
|
+
const pending = this.pending.get(parsed.id);
|
|
312
|
+
if (pending) {
|
|
313
|
+
this.pending.delete(parsed.id);
|
|
314
|
+
if (parsed.error) {
|
|
315
|
+
// #2539 Phase A — reject with the STRUCTURED error (code + data preserved) so the
|
|
316
|
+
// adapter can classify turn/steer rejections; the message keeps the legacy shape.
|
|
317
|
+
pending.reject(new CodexRpcError(parsed.error.code, parsed.error.message, parsed.error.data));
|
|
318
|
+
} else {
|
|
319
|
+
pending.resolve(parsed.result);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
this.record({ type: "response", message: parsed }, raw.length);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if ("id" in parsed && "method" in parsed) {
|
|
327
|
+
this.log(`server-request ${parsed.method}`);
|
|
328
|
+
this.record({ type: "server-request", message: parsed }, raw.length);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if ("method" in parsed) {
|
|
333
|
+
this.record({ type: "notification", message: parsed }, raw.length);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private record(event: ClientEvent, size: number): void {
|
|
338
|
+
this.events.push({ event, size });
|
|
339
|
+
this.eventBytes += size;
|
|
340
|
+
// Decide how many oldest frames both ceilings require dropping, then drop them in ONE splice —
|
|
341
|
+
// this runs on every received frame, so a second shift of a 5,000-entry array would itself be
|
|
342
|
+
// the kind of steady per-frame churn #2504 is about.
|
|
343
|
+
let drop = Math.max(0, this.events.length - CODEX_APP_CLIENT_EVENT_CAP);
|
|
344
|
+
let bytes = this.eventBytes;
|
|
345
|
+
for (let i = 0; i < drop; i++) bytes -= this.events[i]!.size;
|
|
346
|
+
// `drop < length - 1` retains the newest frame however large it is, so an oversized frame can
|
|
347
|
+
// neither empty the ring nor spin this loop.
|
|
348
|
+
while (bytes > CODEX_APP_CLIENT_EVENT_BYTE_CAP && drop < this.events.length - 1) {
|
|
349
|
+
bytes -= this.events[drop]!.size;
|
|
350
|
+
drop++;
|
|
351
|
+
}
|
|
352
|
+
if (drop) {
|
|
353
|
+
this.events.splice(0, drop);
|
|
354
|
+
this.eventBytes = bytes;
|
|
355
|
+
}
|
|
356
|
+
for (const listener of this.listeners) listener(event);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private emitConnection(connected: boolean): void {
|
|
360
|
+
for (const listener of this.connectionListeners) listener(connected);
|
|
361
|
+
}
|
|
362
|
+
}
|