feat!: non-blocking default, distinct exit codes, JSON error envelopes #3

Merged
jercik merged 14 commits from feat/agent-harness-ergonomics into main 2026-07-31 11:24:32 +00:00
Owner

Closes #2.

Implements the five agent-harness ergonomics fixes from the issue. Breaking major release.

Non-blocking default. --wait-ms defaults to 0: a bare submit returns in seconds with the job id (exit 2) instead of blocking 120 s — exactly Claude Code's default Bash timeout, which orphaned paid analyses when the harness killed the wait.

Distinct exit codes. Request-level failures leave exit 1, which now means only the server's own failed-job verdict. New codes partition by "could a job have been created?": 3 usage error (no request dispatched), 4 HTTP error (status in message and envelope), 5 transport failure or timeout (unknowable — assume yes), 6 invalid response on an accepted 2xx (job created; the id is recovered into the envelope when the body still carries a readable job.id, lost otherwise), 7 unexpected client error (which additionally dumps the original error, stack included, to stderr). Commander's own usage errors route through exitOverride() into code 3; --help/--version stay 0.

JSON error envelopes. Under --json, every failure prints exactly one JSON document on stdout: {"error": {"kind", "message", "status?", "job_id?", "exit_code"}} with kindusage / transport / http-error / invalid-response / unexpected. jq -r '.error.kind // .job.status' discriminates both shapes in one expression. The --json flag is recovered by an argv scan for failures that occur before commander's action runs.

Orphan semantics documented. The README now states what a killed submit means: the server runs the paid analysis to completion, there is no listing or recovery route, and --session does not recover it. The resubmit rule is a single predicate — safe iff code == 3 || (code == 4 && status < 500 && status != 408); 408 is excluded alongside 5xx because an intermediary can mint either after forwarding the POST. The stdin read-to-EOF hang is documented alongside. (The server-side listing route stays out of scope for this client-only repo.)

HTTP status preserved. readErrorMessage suffixes (HTTP <status>) even when the body parses as a recognized JSON error shape, so a 5xx whose body reads like a refusal can no longer masquerade as a real 4xx.

Pre-dispatch guards. Eleven review rounds hardened the exit-3 boundary so it only ever claims what is provable: URL shape validation (http(s) only, no credentials/query/fragment, the WHATWG bad-port list copied from undici 8.7.0), API-key field-value validation via node:http's validateHeaderValue (which matches undici's own dispatch-time check exactly), single-path-segment --job ids, and non-blank --session ids. Anything the guards cannot prove classifies as transport — "assume a job exists" is the deliberate default, since a false "safe to resubmit" costs a duplicate paid analysis while a false "stop" costs one wasted stop. No credential can reach an error message or cause chain, and --help now ends with a two-line exit-code legend.

The design went through an adversarial debate before implementation; behavior was verified empirically against throwaway local servers (401/500/schema-invalid/pending bodies, unreachable hosts, stalled and truncated bodies, bad flags, all 65535 ports against the bad-port list) — every documented exit code matches observed behavior. 106 tests, lint, typecheck, build, knip, and format all pass.

Closes #2. Implements the five agent-harness ergonomics fixes from the issue. Breaking major release. **Non-blocking default.** `--wait-ms` defaults to `0`: a bare submit returns in seconds with the job id (exit `2`) instead of blocking 120 s — exactly Claude Code's default Bash timeout, which orphaned paid analyses when the harness killed the wait. **Distinct exit codes.** Request-level failures leave exit `1`, which now means only the server's own failed-job verdict. New codes partition by "could a job have been created?": `3` usage error (no request dispatched), `4` HTTP error (status in message and envelope), `5` transport failure or timeout (unknowable — assume yes), `6` invalid response on an accepted 2xx (job created; the id is recovered into the envelope when the body still carries a readable `job.id`, lost otherwise), `7` unexpected client error (which additionally dumps the original error, stack included, to stderr). Commander's own usage errors route through `exitOverride()` into code `3`; `--help`/`--version` stay `0`. **JSON error envelopes.** Under `--json`, every failure prints exactly one JSON document on stdout: `{"error": {"kind", "message", "status?", "job_id?", "exit_code"}}` with `kind` ∈ `usage` / `transport` / `http-error` / `invalid-response` / `unexpected`. `jq -r '.error.kind // .job.status'` discriminates both shapes in one expression. The `--json` flag is recovered by an argv scan for failures that occur before commander's action runs. **Orphan semantics documented.** The README now states what a killed submit means: the server runs the paid analysis to completion, there is no listing or recovery route, and `--session` does not recover it. The resubmit rule is a single predicate — safe iff `code == 3 || (code == 4 && status < 500 && status != 408)`; `408` is excluded alongside 5xx because an intermediary can mint either after forwarding the POST. The stdin read-to-EOF hang is documented alongside. (The server-side listing route stays out of scope for this client-only repo.) **HTTP status preserved.** `readErrorMessage` suffixes `(HTTP <status>)` even when the body parses as a recognized JSON error shape, so a 5xx whose body reads like a refusal can no longer masquerade as a real 4xx. **Pre-dispatch guards.** Eleven review rounds hardened the exit-`3` boundary so it only ever claims what is provable: URL shape validation (http(s) only, no credentials/query/fragment, the WHATWG bad-port list copied from undici 8.7.0), API-key field-value validation via `node:http`'s `validateHeaderValue` (which matches undici's own dispatch-time check exactly), single-path-segment `--job` ids, and non-blank `--session` ids. Anything the guards cannot prove classifies as `transport` — "assume a job exists" is the deliberate default, since a false "safe to resubmit" costs a duplicate paid analysis while a false "stop" costs one wasted stop. No credential can reach an error message or cause chain, and `--help` now ends with a two-line exit-code legend. The design went through an adversarial debate before implementation; behavior was verified empirically against throwaway local servers (401/500/schema-invalid/pending bodies, unreachable hosts, stalled and truncated bodies, bad flags, all 65535 ports against the bad-port list) — every documented exit code matches observed behavior. 106 tests, lint, typecheck, build, knip, and format all pass.
feat!: non-blocking default, distinct exit codes, JSON error envelopes
Some checks failed
PR Review / code (smart draw 1) (pull_request_target) Failing after 35s
PR Review / approach (smart draw 1) (pull_request_target) Failing after 35s
commit-msg / commitlint (pull_request) Successful in 34s
PR Review / code (fable draw) (pull_request_target) Failing after 34s
PR Review / approach (pr-review-approach-forgejo-3) (pull_request_target) Failing after 35s
PR Review / approach (pr-review-approach-forgejo-2) (pull_request_target) Failing after 35s
PR Review / approach (fable draw) (pull_request_target) Failing after 37s
Checks / quality-checks (pull_request) Successful in 1m7s
0819f28b14
Closes #2.

BREAKING CHANGE: --wait-ms now defaults to 0 (submit-and-return) and
request-level failures no longer exit 1. Exit codes: 0 completed,
1 failed job (server verdict only), 2 pending/running, 3 usage error,
4 HTTP error, 5 transport failure, 6 invalid response, 7 unexpected
client error. Under --json every failure prints a JSON error envelope
on stdout with kind/message/status/exit_code; HTTP error messages
always carry the numeric status. Orphan-job semantics are documented
in the README.
chore: retrigger PR review after reviewer-service outage
Some checks failed
PR Review / approach (smart draw 1) (pull_request_target) Has been skipped
PR Review / approach (fable draw) (pull_request_target) Has been skipped
PR Review / approach (pr-review-approach-forgejo-2) (pull_request_target) Has been skipped
PR Review / approach (pr-review-approach-forgejo-3) (pull_request_target) Has been skipped
commit-msg / commitlint (pull_request) Successful in 18s
PR Review / code (smart draw 1) (pull_request_target) Failing after 18s
PR Review / code (fable draw) (pull_request_target) Failing after 21s
Checks / quality-checks (pull_request) Successful in 38s
911f176b69
chore: retrigger PR review after credential fix
Some checks failed
PR Review / approach (pr-review-approach-forgejo-3) (pull_request_target) Has been skipped
PR Review / approach (smart draw 1) (pull_request_target) Has been skipped
PR Review / approach (fable draw) (pull_request_target) Has been skipped
PR Review / approach (pr-review-approach-forgejo-2) (pull_request_target) Has been skipped
commit-msg / commitlint (pull_request) Successful in 20s
PR Review / code (smart draw 1) (pull_request_target) Failing after 21s
PR Review / code (fable draw) (pull_request_target) Failing after 21s
Checks / quality-checks (pull_request) Successful in 38s
152bdc1c25
forgejo-actions left a comment

Approach review: The approach is solid and well-suited to the problem domain.

The exit-code taxonomy (3–7) partitioning by "could a job have been created?" is the right question for an agent harness deciding whether to retry a submit. The JSON error envelope shape {error: {kind, message, status?, exit_code}} is concise and machine-parseable; the jq -r '.error.kind // .job.status' discrimination pattern is elegant. The non-blocking default (--wait-ms 0) directly addresses the orphaned-job problem from harness timeouts.

The CliError class carrying a discriminated CliErrorDetail is clean: the class provides stack traces and cause chains, the detail provides structured classification for the envelope. classifyFailure correctly maps all throw sources (commander errors, plain errors, non-Error values) into the same FailureReport type, which formatFailure then renders uniformly.

The readJsonFlagFromArgv argv scan to recover the --json flag for errors that occur before commander's action runs is a pragmatic workaround for commander's lack of parsed-option access in error callbacks. The false-positive risk (another option's value matching --json) is documented and benign — it only produces an extra stdout envelope.

createUrl extraction from remote-query-client.ts into its own module is justified now that read-remote-client-options.ts also needs it for early URL validation. The parse-integer-optionparse-wait-ms-option replacement is an appropriate specialization (range validation, InvalidArgumentError for commander integration).

Using process.exitCode rather than process.exit() is correct — it lets the event loop drain and flush stdout, preventing truncated JSON output.

No materially better alternative presents itself. The design decisions are well-reasoned and the decomposition is appropriately granular.

Approach review by Approach Review 3 (OpenCode Wafer) (GLM-5.2)

**Approach review:** The approach is solid and well-suited to the problem domain. The exit-code taxonomy (3–7) partitioning by "could a job have been created?" is the right question for an agent harness deciding whether to retry a submit. The JSON error envelope shape `{error: {kind, message, status?, exit_code}}` is concise and machine-parseable; the `jq -r '.error.kind // .job.status'` discrimination pattern is elegant. The non-blocking default (`--wait-ms 0`) directly addresses the orphaned-job problem from harness timeouts. The `CliError` class carrying a discriminated `CliErrorDetail` is clean: the class provides stack traces and cause chains, the detail provides structured classification for the envelope. `classifyFailure` correctly maps all throw sources (commander errors, plain errors, non-Error values) into the same `FailureReport` type, which `formatFailure` then renders uniformly. The `readJsonFlagFromArgv` argv scan to recover the `--json` flag for errors that occur before commander's action runs is a pragmatic workaround for commander's lack of parsed-option access in error callbacks. The false-positive risk (another option's value matching `--json`) is documented and benign — it only produces an extra stdout envelope. `createUrl` extraction from `remote-query-client.ts` into its own module is justified now that `read-remote-client-options.ts` also needs it for early URL validation. The `parse-integer-option` → `parse-wait-ms-option` replacement is an appropriate specialization (range validation, `InvalidArgumentError` for commander integration). Using `process.exitCode` rather than `process.exit()` is correct — it lets the event loop drain and flush stdout, preventing truncated JSON output. No materially better alternative presents itself. The design decisions are well-reasoned and the decomposition is appropriately granular. _Approach review by Approach Review 3 (OpenCode Wafer) (GLM-5.2)_ <!-- axrecipe-review:69db81de-1ab7-4dae-894f-4eb4c419d362 -->
forgejo-actions left a comment

Approach review: The overall design holds up well. Making --wait-ms 0 the default and partitioning failures by "could a job have been created?" is the right axis for an agent-facing client, and routing every failure through one CliError -> classifyFailure -> formatFailure pipeline keeps the exit-code and JSON-envelope contract in one place rather than scattered across call sites. Using commander's exitOverride() so its own usage errors land in the same taxonomy (code 3) is the standard facility for this and avoids a hand-rolled pre-parse. The new modules are pure and independently testable, matching the repo's functional-core/imperative-shell split, and the readErrorMessage / readCauseChainMessage extractions are the right granularity.

One material alternative, noted inline: readRemoteClientOptions validates the base URL by building a throwaway URL and discarding it, then hands back a raw base_url string that remote-query-client re-parses per request — a validate-and-discard shape the repo's "Parse, Don't Validate" rule asks you to avoid.

Two smaller observations that did not seem to warrant inline comments. (1) The failure path is rendered in two places — the action's own catch and the parseAsync catch — differing only in how --json is discovered (parsed options.json vs. the argv scan). The shared formatFailure keeps the duplication to a few lines and the split has a real justification (commander can throw before --json is parsed), so this reads as a deliberate trade rather than a problem; it is just a second place to update if the failure output ever grows. (2) The contract this PR exists to establish — process exit code plus exactly one stdout document — is exercised only through pure-function unit tests, while the risk concentrates in the wiring (exitOverride, the argv-scan fallback, the bin wrapper's exitCode = 7). The PR body says this was verified by hand against local servers; AGENTS.md explicitly discourages shell tests, so this is a judgment call worth making consciously rather than a change to request.

Approach review by Claude Code Opus (opus)

**Approach review:** The overall design holds up well. Making `--wait-ms 0` the default and partitioning failures by "could a job have been created?" is the right axis for an agent-facing client, and routing every failure through one `CliError` -> `classifyFailure` -> `formatFailure` pipeline keeps the exit-code and JSON-envelope contract in one place rather than scattered across call sites. Using commander's `exitOverride()` so its own usage errors land in the same taxonomy (code `3`) is the standard facility for this and avoids a hand-rolled pre-parse. The new modules are pure and independently testable, matching the repo's functional-core/imperative-shell split, and the `readErrorMessage` / `readCauseChainMessage` extractions are the right granularity. One material alternative, noted inline: `readRemoteClientOptions` validates the base URL by building a throwaway URL and discarding it, then hands back a raw `base_url` string that `remote-query-client` re-parses per request — a validate-and-discard shape the repo's "Parse, Don't Validate" rule asks you to avoid. Two smaller observations that did not seem to warrant inline comments. (1) The failure path is rendered in two places — the action's own `catch` and the `parseAsync` catch — differing only in how `--json` is discovered (parsed `options.json` vs. the argv scan). The shared `formatFailure` keeps the duplication to a few lines and the split has a real justification (commander can throw before `--json` is parsed), so this reads as a deliberate trade rather than a problem; it is just a second place to update if the failure output ever grows. (2) The contract this PR exists to establish — process exit code plus exactly one stdout document — is exercised only through pure-function unit tests, while the risk concentrates in the wiring (`exitOverride`, the argv-scan fallback, the bin wrapper's `exitCode = 7`). The PR body says this was verified by hand against local servers; AGENTS.md explicitly discourages shell tests, so this is a judgment call worth making consciously rather than a change to request. _Approach review by Claude Code Opus (opus)_ <!-- axrecipe-review:e0d04d15-b5ad-48d9-ac5f-339fb94c5c32 -->
@ -15,0 +19,4 @@
// Opaque-path bases (mailto:, data:) parse alone but throw once a path resolves against them
try {
createUrl(url, "/v1/queries");

This constructs a URL purely to see whether it throws, discards the result, and returns the original string as base_urlremote-query-client then calls createUrl(options.base_url, path) again on every request. AGENTS.md's "Parse, Don't Validate" rule targets exactly this shape ("Treat void-returning checks with suspicion").

Consider having this function keep what it learned: parse once and return the parsed base (e.g. base_url: new URL(normalized) or a resolveRoute(path) closure on RemoteClientOptions), so the request path receives an already-valid base and createUrl can no longer throw at dispatch time. That also removes the need for the probe path "/v1/queries" to stand in for the real routes — today a base that happens to be valid for /v1/queries but not for /v1/jobs/<id> would slip past this check (unlikely in practice, but it is the kind of gap the throwaway probe invites).

The validation itself is correct and the opaque-path comment is genuinely useful; the suggestion is only about carrying the parse result forward instead of dropping it.

This constructs a URL purely to see whether it throws, discards the result, and returns the original string as `base_url` — `remote-query-client` then calls `createUrl(options.base_url, path)` again on every request. AGENTS.md's "Parse, Don't Validate" rule targets exactly this shape ("Treat `void`-returning checks with suspicion"). Consider having this function keep what it learned: parse once and return the parsed base (e.g. `base_url: new URL(normalized)` or a `resolveRoute(path)` closure on `RemoteClientOptions`), so the request path receives an already-valid base and `createUrl` can no longer throw at dispatch time. That also removes the need for the probe path `"/v1/queries"` to stand in for the real routes — today a base that happens to be valid for `/v1/queries` but not for `/v1/jobs/<id>` would slip past this check (unlikely in practice, but it is the kind of gap the throwaway probe invites). The validation itself is correct and the opaque-path comment is genuinely useful; the suggestion is only about carrying the parse result forward instead of dropping it.
jercik marked this conversation as resolved
forgejo-actions left a comment

Approach review: The overall design holds up well. The failure taxonomy is modeled as a CliError carrying a discriminated CliErrorDetail, classified once by a pure classifyFailure, rendered by a pure formatFailure, and emitted by a thin shell — that is the right shape for this repo's functional-core/imperative-shell rule, and it keeps the published exit-code contract testable without spawning processes. Extracting create-url, read-error-message, and read-cause-chain-message out of remote-query-client is a clear improvement, and splitting the invalid-response case (2xx + unparseable body, exit 6) from transport failures (exit 5) is what makes the "was a job created?" table decidable at all. .exitOverride() routing commander's own usage errors into the same envelope is the right call, and using process.exitCode rather than process.exit() avoids the stdout-truncation trap that would otherwise undercut the single-JSON-document guarantee.

Two structural notes, both non-blocking and left as inline comments: the failure path is rendered at two sites with two different sources of truth for --json, and the --url check validates then discards its parse.

Smaller observations, not worth changing on their own:

  • The 300000 client-side cap on --wait-ms duplicates a server-side business limit, so a server-side raise needs a client release to become usable. The trade is deliberate and documented (fast exit 3, plus keeping the timer below the AbortSignal.timeout bound), and given the orphan-safety story that "provably no job created" is worth more than the coupling costs.
  • classify-failure.ts imports CommanderError, which couples an otherwise pure module to the CLI framework. Translating commander errors into CliError at the cli.ts boundary would keep the core framework-free, but the current form is one small instanceof and reads fine.
  • The part of the new contract with no automated coverage is exactly the shell wiring: the exitCode === 0 help/version branch, the stdout-only line filter for commander errors, and the argv --json fallback. That matches the repo's stated "test the core, not the shell" convention, so it is a conscious trade rather than an oversight — worth remembering if this wiring grows.

Approach review by Claude Code Opus (opus)

**Approach review:** The overall design holds up well. The failure taxonomy is modeled as a `CliError` carrying a discriminated `CliErrorDetail`, classified once by a pure `classifyFailure`, rendered by a pure `formatFailure`, and emitted by a thin shell — that is the right shape for this repo's functional-core/imperative-shell rule, and it keeps the published exit-code contract testable without spawning processes. Extracting `create-url`, `read-error-message`, and `read-cause-chain-message` out of `remote-query-client` is a clear improvement, and splitting the invalid-response case (2xx + unparseable body, exit `6`) from transport failures (exit `5`) is what makes the "was a job created?" table decidable at all. `.exitOverride()` routing commander's own usage errors into the same envelope is the right call, and using `process.exitCode` rather than `process.exit()` avoids the stdout-truncation trap that would otherwise undercut the single-JSON-document guarantee. Two structural notes, both non-blocking and left as inline comments: the failure path is rendered at two sites with two different sources of truth for `--json`, and the `--url` check validates then discards its parse. Smaller observations, not worth changing on their own: - The `300000` client-side cap on `--wait-ms` duplicates a server-side business limit, so a server-side raise needs a client release to become usable. The trade is deliberate and documented (fast exit `3`, plus keeping the timer below the `AbortSignal.timeout` bound), and given the orphan-safety story that "provably no job created" is worth more than the coupling costs. - `classify-failure.ts` imports `CommanderError`, which couples an otherwise pure module to the CLI framework. Translating commander errors into `CliError` at the `cli.ts` boundary would keep the core framework-free, but the current form is one small `instanceof` and reads fine. - The part of the new contract with no automated coverage is exactly the shell wiring: the `exitCode === 0` help/version branch, the stdout-only line filter for commander errors, and the argv `--json` fallback. That matches the repo's stated "test the core, not the shell" convention, so it is a conscious trade rather than an oversight — worth remembering if this wiring grows. _Approach review by Claude Code Opus (opus)_ <!-- axrecipe-review:503d17c8-be13-4e7c-8efe-236f4f5172e3 -->
src/cli.ts Outdated
@ -81,0 +81,4 @@
emitLines(rendered.lines);
process.exitCode = rendered.exitCode;
} catch (error) {
const rendered = formatFailure(classifyFailure(error), options.json === true);

The action's catch and the top-level catch do the same three things (classify, format, emit, set exit code) and differ only in how they learn about --json: options.json here, an argv scan there. Since parseAsync rejects with whatever an async action handler throws, the inner catch can be dropped entirely and the outer one can prefer the parsed value:

const asJson = program.opts().json === true || readJsonFlagFromArgv(process.argv);

program.opts() is exact for anything commander parsed successfully — which covers every error that reaches the outer handler from the action — and the argv scan then only backstops failures that occur before option parsing completes, which is the case it was written for. That leaves one rendering site, so a future change to the envelope or the stream routing cannot apply to one path and miss the other. Non-blocking; the current split is correct as written.

The action's `catch` and the top-level `catch` do the same three things (classify, format, emit, set exit code) and differ only in how they learn about `--json`: `options.json` here, an argv scan there. Since `parseAsync` rejects with whatever an async action handler throws, the inner `catch` can be dropped entirely and the outer one can prefer the parsed value: ```ts const asJson = program.opts().json === true || readJsonFlagFromArgv(process.argv); ``` `program.opts()` is exact for anything commander parsed successfully — which covers every error that reaches the outer handler from the action — and the argv scan then only backstops failures that occur before option parsing completes, which is the case it was written for. That leaves one rendering site, so a future change to the envelope or the stream routing cannot apply to one path and miss the other. Non-blocking; the current split is correct as written.
jercik marked this conversation as resolved
@ -15,0 +19,4 @@
// Opaque-path bases (mailto:, data:) parse alone but throw once a path resolves against them
try {
createUrl(url, "/v1/queries");

This calls createUrl purely for its throw and discards the result, then requestQuerySubmission calls createUrl again per request — the validate-and-discard shape the repo's "Parse, Don't Validate" rule warns about. Returning the parsed value instead (a normalized/validated base URL in RemoteClientOptions, or a URL) would mean the request path consumes something already known-good rather than re-deriving it, and would make the leftover raw TypeError at request time — currently classified unexpected, exit 7 — structurally unreachable rather than unreachable by argument about which paths the probe covers. No behavioral bug today: /v1/queries and the encodeURIComponent-escaped /v1/jobs/<id> resolve identically against any base the probe accepts.

This calls `createUrl` purely for its throw and discards the result, then `requestQuerySubmission` calls `createUrl` again per request — the validate-and-discard shape the repo's "Parse, Don't Validate" rule warns about. Returning the parsed value instead (a normalized/validated base URL in `RemoteClientOptions`, or a `URL`) would mean the request path consumes something already known-good rather than re-deriving it, and would make the leftover raw `TypeError` at request time — currently classified `unexpected`, exit `7` — structurally unreachable rather than unreachable by argument about which paths the probe covers. No behavioral bug today: `/v1/queries` and the `encodeURIComponent`-escaped `/v1/jobs/<id>` resolve identically against any base the probe accepts.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Reviewed the new failure taxonomy (CliError + classifyFailure + formatFailure), the non-blocking --wait-ms 0 default, the commander exitOverride() wiring, and the README rewrite. The core design holds up: I verified against Node 26 that response.json() rejects with SyntaxError on a non-JSON 2xx body (→ exit 6) and with a TimeoutError DOMException (which is instanceof Error, so isAbortError matches) when the timeout fires mid-body (→ exit 5), that createUrl throws for opaque-path and schemeless bases, and that commander's _exit(0, ...) for --help/--version makes the error.exitCode === 0 guard in src/cli.ts correct. parseWaitMsOption is also safe for oversized digit strings (Number("99999999999999999999") > 300000), so dropping the old BigInt check loses nothing.

Found 3 issues, all in the boundary between "usage error, provably no job" (exit 3) and "transport failure, assume a job exists" (exit 5). Because the README instructs agents to stop and report after an exit 5 on a submit, every pre-dispatch failure that leaks into the transport bucket costs a real recovery.

Code review by Claude Code Opus (opus)

**Summary:** Reviewed the new failure taxonomy (`CliError` + `classifyFailure` + `formatFailure`), the non-blocking `--wait-ms 0` default, the commander `exitOverride()` wiring, and the README rewrite. The core design holds up: I verified against Node 26 that `response.json()` rejects with `SyntaxError` on a non-JSON 2xx body (→ exit 6) and with a `TimeoutError` `DOMException` (which *is* `instanceof Error`, so `isAbortError` matches) when the timeout fires mid-body (→ exit 5), that `createUrl` throws for opaque-path and schemeless bases, and that commander's `_exit(0, ...)` for `--help`/`--version` makes the `error.exitCode === 0` guard in `src/cli.ts` correct. `parseWaitMsOption` is also safe for oversized digit strings (`Number("99999999999999999999") > 300000`), so dropping the old `BigInt` check loses nothing. Found 3 issues, all in the boundary between "usage error, provably no job" (exit `3`) and "transport failure, assume a job exists" (exit `5`). Because the README instructs agents to stop and report after an exit `5` on a submit, every pre-dispatch failure that leaks into the `transport` bucket costs a real recovery. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:7327c123-49f7-4e3c-bc09-cdfdd8ba5dbd -->
@ -15,0 +19,4 @@
// Opaque-path bases (mailto:, data:) parse alone but throw once a path resolves against them
try {
createUrl(url, "/v1/queries");

🟡 Medium: This validates that a path resolves against the base but not that the resulting URL is fetchable, so any non-HTTP scheme passes and the failure lands in the wrong exit-code bucket.

Verified on Node 26:

createUrl("file:///tmp", "/v1/queries")   => file:///tmp/v1/queries
createUrl("htp://foo", "/v1/queries")     => htp://foo/v1/queries
fetch("file:///tmp/v1/queries")           => TypeError: fetch failed (cause: not implemented... yet...)
fetch("htp://foo/v1/queries")             => TypeError: fetch failed (cause: unknown scheme)

Both reject before a single byte is dispatched, but remote-query-client.ts catches them as { kind: "transport" } → exit 5. The README defines exit 5 as "dispatched, but no response completed" and tells the caller to assume a job was created and to "report and stop — do not resubmit". So a scheme typo in TROPKOD_URL permanently blocks the caller on a config error that provably created nothing.

Safe fix — reject non-HTTP schemes here so it stays a usage error (exit 3):

let resolved: URL;
try {
  resolved = new URL(createUrl(url, "/v1/queries"));
} catch (error) {
  throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL is not a valid URL", error);
}
if (resolved.protocol !== "http:" && resolved.protocol !== "https:") {
  throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL must be an http(s) URL");
}
🟡 **Medium:** This validates that a path *resolves* against the base but not that the resulting URL is fetchable, so any non-HTTP scheme passes and the failure lands in the wrong exit-code bucket. Verified on Node 26: ``` createUrl("file:///tmp", "/v1/queries") => file:///tmp/v1/queries createUrl("htp://foo", "/v1/queries") => htp://foo/v1/queries fetch("file:///tmp/v1/queries") => TypeError: fetch failed (cause: not implemented... yet...) fetch("htp://foo/v1/queries") => TypeError: fetch failed (cause: unknown scheme) ``` Both reject before a single byte is dispatched, but `remote-query-client.ts` catches them as `{ kind: "transport" }` → exit `5`. The README defines exit `5` as "dispatched, but no response completed" and tells the caller to assume a job was created and to "report and stop — do not resubmit". So a scheme typo in `TROPKOD_URL` permanently blocks the caller on a config error that provably created nothing. Safe fix — reject non-HTTP schemes here so it stays a usage error (exit `3`): ```ts let resolved: URL; try { resolved = new URL(createUrl(url, "/v1/queries")); } catch (error) { throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL is not a valid URL", error); } if (resolved.protocol !== "http:" && resolved.protocol !== "https:") { throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL must be an http(s) URL"); } ```
jercik marked this conversation as resolved
@ -65,2 +60,3 @@
);
}
throw error;
throw new CliError({ kind: "transport" }, readCauseChainMessage(error), error);

🟢 Low: This catch-all treats every non-abort throw from the try block as transport (exit 5 = "assume a job was created"), but the block also contains work that happens strictly before dispatch: createUrl(...) and fetch's own header validation.

Verified on Node 26 — an API key containing a control character is rejected synchronously by the Headers constructor, before any connection:

fetch(url, { headers: { authorization: "Bearer abc\ndef" } })
// TypeError: Headers.append: "Bearer abc\ndef" is an invalid header value.

(.trim() in readRemoteClientOptions strips surrounding whitespace, so this needs an interior control character — a mangled copy-paste of a provisioned key.) The result is exit 5 on a submit, which per the README's orphan protocol means "report and stop", for a credential typo that created nothing and is safe to retry.

Narrowing the pre-dispatch cases keeps the taxonomy honest: build the request URL and Headers outside the try (or classify a TypeError whose message comes from header construction as { kind: "usage" }), leaving this line for genuine network failures. Same root cause as the URL-scheme comment on read-remote-client-options.ts.

🟢 **Low:** This catch-all treats every non-abort throw from the `try` block as `transport` (exit `5` = "assume a job was created"), but the block also contains work that happens strictly *before* dispatch: `createUrl(...)` and `fetch`'s own header validation. Verified on Node 26 — an API key containing a control character is rejected synchronously by the Headers constructor, before any connection: ``` fetch(url, { headers: { authorization: "Bearer abc\ndef" } }) // TypeError: Headers.append: "Bearer abc\ndef" is an invalid header value. ``` (`.trim()` in `readRemoteClientOptions` strips surrounding whitespace, so this needs an *interior* control character — a mangled copy-paste of a provisioned key.) The result is exit `5` on a submit, which per the README's orphan protocol means "report and stop", for a credential typo that created nothing and is safe to retry. Narrowing the pre-dispatch cases keeps the taxonomy honest: build the request URL and `Headers` outside the `try` (or classify a `TypeError` whose message comes from header construction as `{ kind: "usage" }`), leaving this line for genuine network failures. Same root cause as the URL-scheme comment on `read-remote-client-options.ts`.
jercik marked this conversation as resolved
@ -24,3 +26,3 @@
if (options.job !== undefined) {
if (questionParts.length > 0 || stdinQuestion.length > 0) {
throw new Error("pass either a question or --job, not both");
throw new CliError({ kind: "usage" }, "pass either a question or --job, not both");

🟢 Low: --job is only checked for !== undefined, so an empty or whitespace-only id passes through unvalidated. tropkod-client --job "" builds GET /v1/jobs/ and dispatches it, turning a usage error into an HTTP 404 (exit 4) — and --job " " sends /v1/jobs/%20%20%20. It also makes --job "" "some question" fail with the misleading "pass either a question or --job, not both" rather than pointing at the empty id.

Given that this PR's contract is that exit 3 means "no HTTP request was dispatched", a guard here keeps that partition intact:

if (options.job !== undefined) {
  const jobId = options.job.trim();
  if (jobId.length === 0) {
    throw new CliError({ kind: "usage" }, "--job requires a job id");
  }
  ...
  return { mode: "job", jobId };
}
🟢 **Low:** `--job` is only checked for `!== undefined`, so an empty or whitespace-only id passes through unvalidated. `tropkod-client --job ""` builds `GET /v1/jobs/` and dispatches it, turning a usage error into an HTTP `404` (exit `4`) — and `--job " "` sends `/v1/jobs/%20%20%20`. It also makes `--job "" "some question"` fail with the misleading "pass either a question or --job, not both" rather than pointing at the empty id. Given that this PR's contract is that exit `3` means "no HTTP request was dispatched", a guard here keeps that partition intact: ```ts if (options.job !== undefined) { const jobId = options.job.trim(); if (jobId.length === 0) { throw new CliError({ kind: "usage" }, "--job requires a job id"); } ... return { mode: "job", jobId }; } ```
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Found 2 medium and 1 low issue.

I ran the changed source directly (Node 26, commander 15, zod 4) against throwaway stub servers and confirmed every exit code the README claims: 3 for commander usage errors / missing --url / bad --url / question+--job, 4 with status for 401 and 500, 6 for a non-JSON and a schema-invalid 2xx, 5 for ECONNREFUSED and for both the pre-response and mid-body timeout abort, 2 for a pending submit and for a --job poll, 0 for --help/--version. The --json stdout contract holds in all of them, including the commander-error path where only the stdout envelope is emitted and commander's prose stays on stderr. The 74 tests pass. The README's commander caveat (message carries the error: prefix and the suggestion newline, minus the (add --help for usage) hint) is byte-accurate.

The findings are about the one path the PR's own premise cares most about: a 2xx whose body the client cannot parse. The job id sitting in that body is discarded, and the README then tells a polling agent to keep polling a condition that will never change.

Code review by Claude Code Opus (opus)

**Summary:** Found 2 medium and 1 low issue. I ran the changed source directly (Node 26, commander 15, zod 4) against throwaway stub servers and confirmed every exit code the README claims: `3` for commander usage errors / missing `--url` / bad `--url` / question+`--job`, `4` with status for 401 and 500, `6` for a non-JSON and a schema-invalid 2xx, `5` for ECONNREFUSED and for both the pre-response and mid-body timeout abort, `2` for a pending submit and for a `--job` poll, `0` for `--help`/`--version`. The `--json` stdout contract holds in all of them, including the commander-error path where only the stdout envelope is emitted and commander's prose stays on stderr. The 74 tests pass. The README's commander caveat (message carries the `error: ` prefix and the suggestion newline, minus the `(add --help for usage)` hint) is byte-accurate. The findings are about the one path the PR's own premise cares most about: a 2xx whose body the client cannot parse. The job id sitting in that body is discarded, and the README then tells a polling agent to keep polling a condition that will never change. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:e844be0e-12db-4f77-8aa8-b9c83e7fa6dc -->
README.md Outdated
@ -80,0 +105,4 @@
| `3` | Usage error — invalid flags/arguments/environment; no HTTP request was dispatched. | No | Nothing was sent — fix the input, resume polling the same id |
| `4` | HTTP error — non-2xx response; numeric status in message and envelope. | 4xx: no. 5xx: unknown (a proxy may mask an accepted request) — branch on `status` | The GET touched nothing: 401/403 → fix credential, keep polling; 404 → re-check the id (a confirmed-correct-id 404 ends the poll); 408/429/5xx → keep polling |
| `5` | Transport failure — dispatched, but no response completed (network failure, or the client abort after `wait_ms + 5000` ms / the fixed 30 s poll timeout — including an abort mid-body). | Unknown — assume yes | Job untouched — keep polling |
| `6` | Invalid response — 2xx received but the body was not a parseable submission envelope. | Yes — the id is lost inside the unreadable body | Job untouched — keep polling; report if persistent |

🟡 Medium: "keep polling; report if persistent" is unbounded advice for a condition that is frequently deterministic. When exit 6 comes from schema drift rather than a truncated body, the job is already in a terminal state and every subsequent GET /v1/jobs/<id> returns the same unparseable payload — I confirmed this by polling a stub that returns a completed job whose analysis.status is outside the union: exit 6, identical message, forever. An agent following this row loops until its own budget runs out on a job that finished long ago.

Worth making the escape hatch explicit, e.g. "keep polling, but treat a repeated exit 6 carrying an identical message as terminal — the job may have already ended in a shape this client cannot render — and report." If the id-recovery suggestion on src/remote-query-client.ts lands, the On a submit: job created? cell for this row ("the id is lost inside the unreadable body") also stops being true.

🟡 **Medium:** "keep polling; report if persistent" is unbounded advice for a condition that is frequently deterministic. When exit `6` comes from schema drift rather than a truncated body, the job is already in a terminal state and every subsequent `GET /v1/jobs/<id>` returns the same unparseable payload — I confirmed this by polling a stub that returns a `completed` job whose `analysis.status` is outside the union: exit `6`, identical message, forever. An agent following this row loops until its own budget runs out on a job that finished long ago. Worth making the escape hatch explicit, e.g. "keep polling, but treat a repeated exit `6` carrying an identical message as terminal — the job may have already ended in a shape this client cannot render — and report." If the id-recovery suggestion on `src/remote-query-client.ts` lands, the `On a submit: job created?` cell for this row ("the id is lost inside the unreadable body") also stops being true.
jercik marked this conversation as resolved
src/cli.ts Outdated
@ -77,1 +53,3 @@
console.error(line.text);
try {
let stdinText: string | undefined;
if (options.job === undefined && questionParts.length === 0 && !process.stdin.isTTY) {

🟢 Low: stdin is read to EOF before readRemoteClientOptions validates --url/--api-key, so an invocation that is provably a usage error hangs instead of exiting 3. With no TROPKOD_URL set and stdin inherited as a pipe that never closes, the process blocks indefinitely even though the URL check alone already determines the verdict — the hang the README warns about, in a case where nothing about stdin can change the outcome.

Hoisting the readRemoteClientOptions({ url: options.url, apiKey: options.apiKey }) call above this block makes credential and URL errors fail fast at exit 3. The missing-question check necessarily still waits on stdin, since stdin is the question source.

🟢 **Low:** stdin is read to EOF before `readRemoteClientOptions` validates `--url`/`--api-key`, so an invocation that is provably a usage error hangs instead of exiting `3`. With no `TROPKOD_URL` set and stdin inherited as a pipe that never closes, the process blocks indefinitely even though the URL check alone already determines the verdict — the hang the README warns about, in a case where nothing about stdin can change the outcome. Hoisting the `readRemoteClientOptions({ url: options.url, apiKey: options.apiKey })` call above this block makes credential and URL errors fail fast at exit `3`. The missing-question check necessarily still waits on stdin, since stdin is the question source.
jercik marked this conversation as resolved
@ -78,3 +95,1 @@
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
const relativePath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
return new URL(relativePath, normalizedBaseUrl).href;
const parsed = QuerySubmission.safeParse(payload);

🟡 Medium: A 2xx body that fails QuerySubmission.safeParse discards the job id even when the body plainly contains it, which is exactly the orphaned-paid-analysis case this PR exists to prevent.

Verified against a stub returning HTTP 200 with {"session":…,"query":…,"job":{"id":"job-ABC","status":"completed",…},"analysis":{"status":"partially-answered"}}:

{"error":{"kind":"invalid-response","message":"tropkod response did not match the expected schema (HTTP 200)","status":200,"exit_code":6}}

job-ABC was readable in the payload and appears nowhere in the output. QuerySubmission is strict in ways the server can trivially outgrow — a new analysis.status, a new grounding.type, an UnresolvedAnalysisReason outside the literal union, resolved_targets not exactly length(1) — and every one of those turns a successful submit into an unrecoverable paid job.

A lenient pre-parse before the strict one keeps the id:

const JobIdEnvelope = z.object({ job: z.object({ id: z.string().min(1) }) });
const recovered = JobIdEnvelope.safeParse(payload);

Then carry recovered.data.job.id into the message (… (HTTP 200, job <id>)) and into the error envelope as a job_id field, so an exit 6 on a submit degrades to "keep polling this id" instead of "report and stop".

🟡 **Medium:** A 2xx body that fails `QuerySubmission.safeParse` discards the job id even when the body plainly contains it, which is exactly the orphaned-paid-analysis case this PR exists to prevent. Verified against a stub returning HTTP 200 with `{"session":…,"query":…,"job":{"id":"job-ABC","status":"completed",…},"analysis":{"status":"partially-answered"}}`: ```json {"error":{"kind":"invalid-response","message":"tropkod response did not match the expected schema (HTTP 200)","status":200,"exit_code":6}} ``` `job-ABC` was readable in the payload and appears nowhere in the output. `QuerySubmission` is strict in ways the server can trivially outgrow — a new `analysis.status`, a new `grounding.type`, an `UnresolvedAnalysisReason` outside the literal union, `resolved_targets` not exactly `length(1)` — and every one of those turns a successful submit into an unrecoverable paid job. A lenient pre-parse before the strict one keeps the id: ```ts const JobIdEnvelope = z.object({ job: z.object({ id: z.string().min(1) }) }); const recovered = JobIdEnvelope.safeParse(payload); ``` Then carry `recovered.data.job.id` into the message (`… (HTTP 200, job <id>)`) and into the error envelope as a `job_id` field, so an exit `6` on a submit degrades to "keep polling this id" instead of "report and stop".
jercik marked this conversation as resolved
fix: address PR review findings
All checks were successful
commit-msg / commitlint (pull_request) Successful in 27s
Checks / quality-checks (pull_request) Successful in 45s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 2m39s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 7m25s
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 7m37s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 14s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 16s
d6e4549fd5
Parse-don't-validate the base URL (http/https only, usage error, exit
3) and carry it as URL; validate the API key as a header value at the
boundary; consolidate to one failure-render site in the CLI shell;
reject empty --job before dispatch; recover a job id from a
schema-mismatched 2xx into the message and .error.job_id; correct the
README exit-6 guidance; validate URL and key before the stdin read so
usage errors cannot hang on an open pipe.
Author
Owner

All seven distinct findings from the five reviews are addressed in d6e4549 (every one reproduced or verified before fixing; none rejected). Per conversation:

read-remote-client-options.ts — URL parsed then discarded / non-http(s) scheme → exit 5 (30893, 30901, 30915): Agreed on both counts, one fix: readRemoteClientOptions now parses once and returns base_url as a slash-normalized URL, rejecting non-http(s) schemes as usage/exit 3 — verified on Node 26.5 that htp:// and file:/// previously passed the probe and died at fetch as transport/exit 5. The protocol gate subsumes the opaque-path probe, and requests consume the parsed base, making the dispatch-time URL throw structurally unreachable.

remote-query-client.ts — header TypeError misclassified as transport (30916): Confirmed the repro (Headers throws on an interior control character pre-dispatch). Fixed at the boundary: the key is validated as a header value in readRemoteClientOptions and carried forward as the authorization string, throwing usage/exit 3. With URL construction also out of the try, the transport catch now covers only genuine network failures.

cli.ts — two failure-render sites (30900): Adopted with one refinement: asJson = error instanceof CommanderError ? readJsonFlagFromArgv(process.argv) : program.opts().json === true rather than the proposed OR — the OR would extend the argv scan's documented false positive to action-phase failures. Verified on commander 15 that parseAsync rejects with the action's rejection and opts() stays readable, so the inner catch is gone and one render site remains.

resolve-submission-request.ts — empty --job dispatches GET /v1/jobs/ (30917): Agreed — it broke the exit-3 "no request dispatched" partition. The id is trimmed, empty throws usage/exit 3 (--job requires a job id) before the question/--job conflict check, and the trimmed id is dispatched.

remote-query-client.ts — exit 6 discards a recoverable job id (30925): Agreed — this is the orphan case the PR exists to close. A lenient { job: { id } } pre-parse on schema-mismatched 2xx now appends the recovered id to the message and emits .error.job_id in the envelope, so exit 6 on a submit degrades to "poll this id" whenever the body was readable JSON.

README — unbounded "keep polling" on exit 6 (30926): Agreed. The row now distinguishes a transient blip (retry briefly) from repeated identical exit 6 (terminal for this client build — report), and the submit cell documents the .error.job_id recovery.

cli.ts — stdin read before URL/key validation can hang (30927): Agreed — options are fully parsed before the action, so validation is hoisted above the stdin read; URL/credential mistakes exit 3 immediately. The missing-question check necessarily still waits on stdin, which the README's open-pipe warning continues to cover.

Verification: 88/88 tests, lint/typecheck/build/knip/format green, plus empirical spot-checks of each fix against throwaway local servers (mailto:/htp:// → exit 3; newline key → exit 3; schema-mismatch 2xx → exit 6 with .error.job_id; --job " " → exit 3; never-closing stdin + missing URL → immediate exit 3; envelope/stderr contracts under --json preserved).

All seven distinct findings from the five reviews are addressed in d6e4549 (every one reproduced or verified before fixing; none rejected). Per conversation: **`read-remote-client-options.ts` — URL parsed then discarded / non-http(s) scheme → exit 5 (30893, 30901, 30915):** Agreed on both counts, one fix: `readRemoteClientOptions` now parses once and returns `base_url` as a slash-normalized `URL`, rejecting non-http(s) schemes as usage/exit 3 — verified on Node 26.5 that `htp://` and `file:///` previously passed the probe and died at fetch as transport/exit 5. The protocol gate subsumes the opaque-path probe, and requests consume the parsed base, making the dispatch-time URL throw structurally unreachable. **`remote-query-client.ts` — header `TypeError` misclassified as transport (30916):** Confirmed the repro (`Headers` throws on an interior control character pre-dispatch). Fixed at the boundary: the key is validated as a header value in `readRemoteClientOptions` and carried forward as the `authorization` string, throwing usage/exit 3. With URL construction also out of the try, the transport catch now covers only genuine network failures. **`cli.ts` — two failure-render sites (30900):** Adopted with one refinement: `asJson = error instanceof CommanderError ? readJsonFlagFromArgv(process.argv) : program.opts().json === true` rather than the proposed OR — the OR would extend the argv scan's documented false positive to action-phase failures. Verified on commander 15 that `parseAsync` rejects with the action's rejection and `opts()` stays readable, so the inner catch is gone and one render site remains. **`resolve-submission-request.ts` — empty `--job` dispatches `GET /v1/jobs/` (30917):** Agreed — it broke the exit-3 "no request dispatched" partition. The id is trimmed, empty throws usage/exit 3 (`--job requires a job id`) before the question/`--job` conflict check, and the trimmed id is dispatched. **`remote-query-client.ts` — exit 6 discards a recoverable job id (30925):** Agreed — this is the orphan case the PR exists to close. A lenient `{ job: { id } }` pre-parse on schema-mismatched 2xx now appends the recovered id to the message and emits `.error.job_id` in the envelope, so exit 6 on a submit degrades to "poll this id" whenever the body was readable JSON. **README — unbounded "keep polling" on exit 6 (30926):** Agreed. The row now distinguishes a transient blip (retry briefly) from repeated identical exit 6 (terminal for this client build — report), and the submit cell documents the `.error.job_id` recovery. **`cli.ts` — stdin read before URL/key validation can hang (30927):** Agreed — options are fully parsed before the action, so validation is hoisted above the stdin read; URL/credential mistakes exit 3 immediately. The missing-question check necessarily still waits on stdin, which the README's open-pipe warning continues to cover. Verification: 88/88 tests, lint/typecheck/build/knip/format green, plus empirical spot-checks of each fix against throwaway local servers (mailto:/htp:// → exit 3; newline key → exit 3; schema-mismatch 2xx → exit 6 with `.error.job_id`; `--job " "` → exit 3; never-closing stdin + missing URL → immediate exit 3; envelope/stderr contracts under `--json` preserved).
forgejo-actions left a comment

Summary: Reviewed the exit-code taxonomy, JSON error envelopes, and the non-blocking default. The failure classification is coherent end to end — CliError detail → FailureReport → exit code → envelope — and I verified the trickier claims hold: commander writes its own prose and showHelpAfterError output to stderr (so the "exactly one JSON document on stdout" contract survives usage errors), --help/--version throw CommanderError with exitCode === 0 and correctly fall through to exit 0, the argv --json scan matches process.argv.slice(2) under the bin wrapper, Headers.set does reject the interior CR/LF/NUL the api-key check relies on, and the api key never reaches any printed message (only report.message is emitted; causes are dropped). Docs and code agree on every exit code I traced.

Two findings, neither blocking correctness of the taxonomy itself:

  • 🟡 Mediumsrc/remote-query-client.ts: the default submit now has a 5 s total timeout, and blowing it yields exit 5, the README's unrecoverable orphaned-job case.
  • 🟢 Lowsrc/read-remote-client-options.ts: trailing-slash normalization before parsing drops the base path for query/fragment-bearing base URLs.

Code review by Claude Code Opus (opus)

**Summary:** Reviewed the exit-code taxonomy, JSON error envelopes, and the non-blocking default. The failure classification is coherent end to end — `CliError` detail → `FailureReport` → exit code → envelope — and I verified the trickier claims hold: commander writes its own prose and `showHelpAfterError` output to stderr (so the "exactly one JSON document on stdout" contract survives usage errors), `--help`/`--version` throw `CommanderError` with `exitCode === 0` and correctly fall through to exit `0`, the argv `--json` scan matches `process.argv.slice(2)` under the bin wrapper, `Headers.set` does reject the interior CR/LF/NUL the api-key check relies on, and the api key never reaches any printed message (only `report.message` is emitted; causes are dropped). Docs and code agree on every exit code I traced. Two findings, neither blocking correctness of the taxonomy itself: - 🟡 **Medium** — `src/remote-query-client.ts`: the default submit now has a 5 s total timeout, and blowing it yields exit `5`, the README's unrecoverable orphaned-job case. - 🟢 **Low** — `src/read-remote-client-options.ts`: trailing-slash normalization before parsing drops the base path for query/fragment-bearing base URLs. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:8f124bf6-86f8-40b7-bc5e-e49c966a07ee -->
@ -15,0 +17,4 @@
}
// Trailing slash so request paths resolve under the base path instead of replacing it
const normalized = url.endsWith("/") ? url : `${url}/`;

🟢 Low: The trailing slash is appended to the raw string before parsing, so it lands in the last URL component rather than the path. For a base URL carrying a query or fragment the base path is then silently dropped:

new URL("https://h/api?x=1" + "/")        // pathname "/api", search "?x=1/"
new URL("v1/queries", that)               // https://h/v1/queries  — "/api" lost

Requests (with the bearer token) go to the wrong path with no error. Normalizing after parsing avoids it:

parsed = new URL(url);
if (!parsed.pathname.endsWith("/")) parsed.pathname += "/";

(The plain no-slash case, https://h/api, is handled correctly today — only query/fragment-bearing bases misroute.)

🟢 **Low:** The trailing slash is appended to the raw string before parsing, so it lands in the last URL component rather than the path. For a base URL carrying a query or fragment the base path is then silently dropped: ``` new URL("https://h/api?x=1" + "/") // pathname "/api", search "?x=1/" new URL("v1/queries", that) // https://h/v1/queries — "/api" lost ``` Requests (with the bearer token) go to the wrong path with no error. Normalizing after parsing avoids it: ```ts parsed = new URL(url); if (!parsed.pathname.endsWith("/")) parsed.pathname += "/"; ``` (The plain no-slash case, `https://h/api`, is handled correctly today — only query/fragment-bearing bases misroute.)
jercik marked this conversation as resolved
@ -22,2 +13,3 @@
input: { question: string; session_id: string | undefined; wait_ms: number },
): Promise<QuerySubmission> {
const timeoutMs = (input.wait_ms ?? 0) + requestTimeoutSlackMs;
const timeoutMs = input.wait_ms + requestTimeoutSlackMs;

🟡 Medium: With --wait-ms now defaulting to 0, this makes the total abort budget for a submit exactly requestTimeoutSlackMs = 5000 ms — down from 125 s before this change. The slack constant was sized as headroom on top of a server-side wait, not as a standalone request budget, so it now also has to cover DNS, TLS handshake, any cold start, and the server's own session/query/job insert.

When 5 s is exceeded on a submit that the server did accept, AbortSignal.timeout fires and the client reports exit 5 — which the README defines as the unrecoverable case ("Unknown — assume yes", "After losing a submit without an id, report and stop — do not resubmit"). That is precisely the orphaned-paid-job outcome this PR is trying to eliminate, reintroduced on a much shorter fuse. Verification against local throwaway servers wouldn't surface it, since loopback latency is ~0.

Suggest giving the non-blocking submit a floor rather than raw slack, e.g. const timeoutMs = Math.max(input.wait_ms + requestTimeoutSlackMs, defaultJobFetchTimeoutMs); — the 30 s already deemed acceptable for a poll. A synchronous --wait-ms submit is unaffected.

🟡 **Medium:** With `--wait-ms` now defaulting to `0`, this makes the total abort budget for a *submit* exactly `requestTimeoutSlackMs` = 5000 ms — down from 125 s before this change. The slack constant was sized as headroom on top of a server-side wait, not as a standalone request budget, so it now also has to cover DNS, TLS handshake, any cold start, and the server's own session/query/job insert. When 5 s is exceeded on a submit that the server did accept, `AbortSignal.timeout` fires and the client reports exit `5` — which the README defines as the unrecoverable case ("Unknown — assume yes", "After losing a submit without an id, report and stop — do not resubmit"). That is precisely the orphaned-paid-job outcome this PR is trying to eliminate, reintroduced on a much shorter fuse. Verification against local throwaway servers wouldn't surface it, since loopback latency is ~0. Suggest giving the non-blocking submit a floor rather than raw slack, e.g. `const timeoutMs = Math.max(input.wait_ms + requestTimeoutSlackMs, defaultJobFetchTimeoutMs);` — the 30 s already deemed acceptable for a poll. A synchronous `--wait-ms` submit is unaffected.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Reviewed the exit-code partition, JSON envelope contract, and request plumbing. The failure taxonomy holds up well — I ran the suite (88 tests pass) and exercised the built CLI against local servers covering 401, 500-with-JSON-body, non-JSON 200, schema-drift 200 (with and without a recoverable job.id), unreachable host, slow server, commander usage errors, --help/--version, and a crashing dist/cli.js; every observed exit code and stdout/stderr split matched the README.

Three actionable findings, all medium: the new --wait-ms 0 default collapses the submit budget to 5 s (a slow-but-successful create now aborts as exit 5, i.e. an orphan — the exact failure this PR targets); the trailing-slash normalization runs on the raw string, so a base URL carrying a query or fragment silently drops its base path; and the README's exit-6 orphan discriminator overstates recovery for readable JSON that carries no job.id.

Code review by Claude Code Opus (opus)

**Summary:** Reviewed the exit-code partition, JSON envelope contract, and request plumbing. The failure taxonomy holds up well — I ran the suite (88 tests pass) and exercised the built CLI against local servers covering 401, 500-with-JSON-body, non-JSON 200, schema-drift 200 (with and without a recoverable `job.id`), unreachable host, slow server, commander usage errors, `--help`/`--version`, and a crashing `dist/cli.js`; every observed exit code and stdout/stderr split matched the README. Three actionable findings, all medium: the new `--wait-ms 0` default collapses the submit budget to 5 s (a slow-but-successful create now aborts as exit `5`, i.e. an orphan — the exact failure this PR targets); the trailing-slash normalization runs on the raw string, so a base URL carrying a query or fragment silently drops its base path; and the README's exit-`6` orphan discriminator overstates recovery for readable JSON that carries no `job.id`. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:fe08df45-fd84-4c14-9d3f-47799fe1c4fd -->
README.md Outdated
@ -80,0 +106,4 @@
| `3` | Usage error — invalid flags/arguments/environment; no HTTP request was dispatched. | No | Nothing was sent — fix the input, resume polling the same id |
| `4` | HTTP error — non-2xx response; numeric status in message and envelope. | 4xx: no. 5xx: unknown (a proxy may mask an accepted request) — branch on `status` | The GET touched nothing: 401/403 → fix credential, keep polling; 404 → re-check the id (a confirmed-correct-id 404 ends the poll); 408/429/5xx → keep polling |
| `5` | Transport failure — dispatched, but no response completed (network failure, or the client abort after `wait_ms + 5000` ms / the fixed 30 s poll timeout — including an abort mid-body). | Unknown — assume yes | Job untouched — keep polling |
| `6` | Invalid response — 2xx received but the body was not a parseable submission envelope. | Yes — id recovered in `.error.job_id` when the body was readable JSON; lost only when it wasn't | Job untouched. A transient blip (truncated body, proxy garbling) recovers — retry once or twice. A repeated exit `6` with an identical message is deterministic schema drift, terminal for this client build: the job has likely already ended in a shape this client cannot render — report it, don't keep looping |

🟡 Medium: This row — and the matching parenthetical on line 120 ("a readable-JSON exit 6 recovers the id in the envelope and orphans nothing") — state that a readable-JSON body always yields .error.job_id. It does not: recoverJobId requires a job.id string specifically, so readable JSON without that shape orphans exactly like a non-JSON body.

Verified against a local server returning 200 {"foo":1}:

{ "error": { "kind": "invalid-response", "message": "tropkod response did not match the expected schema (HTTP 200)", "status": 200, "exit_code": 6 } }

Readable JSON, no job_id. That is a realistic drift shape (a renamed envelope key, a proxy-injected error document served as 200), and it is the case where the id is lost.

Since this section is the contract an agent branches on to decide "report and stop" versus "I have the id," the discriminator should be the field itself rather than the body's parseability — e.g. "id recovered in .error.job_id when the body carried a readable job.id; lost otherwise," and on line 120 "an exit 6 whose envelope carries .error.job_id orphans nothing; an exit 6 without it does."

🟡 **Medium:** This row — and the matching parenthetical on line 120 ("a readable-JSON exit `6` recovers the id in the envelope and orphans nothing") — state that a readable-JSON body always yields `.error.job_id`. It does not: `recoverJobId` requires a `job.id` string specifically, so readable JSON without that shape orphans exactly like a non-JSON body. Verified against a local server returning `200 {"foo":1}`: ```json { "error": { "kind": "invalid-response", "message": "tropkod response did not match the expected schema (HTTP 200)", "status": 200, "exit_code": 6 } } ``` Readable JSON, no `job_id`. That is a realistic drift shape (a renamed envelope key, a proxy-injected error document served as 200), and it is the case where the id is lost. Since this section is the contract an agent branches on to decide "report and stop" versus "I have the id," the discriminator should be the field itself rather than the body's parseability — e.g. "id recovered in `.error.job_id` when the body carried a readable `job.id`; lost otherwise," and on line 120 "an exit `6` whose envelope carries `.error.job_id` orphans nothing; an exit `6` without it does."
jercik marked this conversation as resolved
@ -15,0 +17,4 @@
}
// Trailing slash so request paths resolve under the base path instead of replacing it
const normalized = url.endsWith("/") ? url : `${url}/`;

🟡 Medium: The trailing slash is appended to the raw string before parsing, so when the URL carries a query or fragment the slash lands inside the search/hash instead of the path — and the base path is then silently discarded during relative resolution.

Observed against a local server:

--url request line
http://host/api POST /api/v1/queries
http://host/api?tenant=1 POST /v1/queries
http://host/api#frag POST /v1/queries

http://host/api?tenant=1 becomes http://host/api?tenant=1/, whose pathname is /api (no trailing slash) and whose search is ?tenant=1/; new URL("v1/queries", base) then replaces the last path segment and drops the search. The result is a request to an unintended path on the same host, with the bearer token attached, surfacing as a confusing 404 (exit 4) rather than a usage error.

Parse first, then normalize the parsed path — and drop the parts that base resolution cannot carry anyway:

let parsed: URL;
try {
  parsed = new URL(url);
} catch (error) {
  throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL is not a valid URL", error);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
  throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL must be an http(s) URL");
}
parsed.search = "";
parsed.hash = "";
if (!parsed.pathname.endsWith("/")) {
  parsed.pathname = `${parsed.pathname}/`;
}

(Rejecting a URL that carries a query or fragment as a usage error is the other defensible option — either way it should not fail silently.)

🟡 **Medium:** The trailing slash is appended to the raw string *before* parsing, so when the URL carries a query or fragment the slash lands inside the search/hash instead of the path — and the base path is then silently discarded during relative resolution. Observed against a local server: | `--url` | request line | | --- | --- | | `http://host/api` | `POST /api/v1/queries` ✅ | | `http://host/api?tenant=1` | `POST /v1/queries` ❌ | | `http://host/api#frag` | `POST /v1/queries` ❌ | `http://host/api?tenant=1` becomes `http://host/api?tenant=1/`, whose `pathname` is `/api` (no trailing slash) and whose `search` is `?tenant=1/`; `new URL("v1/queries", base)` then replaces the last path segment and drops the search. The result is a request to an unintended path on the same host, with the bearer token attached, surfacing as a confusing 404 (exit `4`) rather than a usage error. Parse first, then normalize the parsed path — and drop the parts that base resolution cannot carry anyway: ```ts let parsed: URL; try { parsed = new URL(url); } catch (error) { throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL is not a valid URL", error); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL must be an http(s) URL"); } parsed.search = ""; parsed.hash = ""; if (!parsed.pathname.endsWith("/")) { parsed.pathname = `${parsed.pathname}/`; } ``` (Rejecting a URL that carries a query or fragment as a usage error is the other defensible option — either way it should not fail silently.)
jercik marked this conversation as resolved
@ -22,2 +13,3 @@
input: { question: string; session_id: string | undefined; wait_ms: number },
): Promise<QuerySubmission> {
const timeoutMs = (input.wait_ms ?? 0) + requestTimeoutSlackMs;
const timeoutMs = input.wait_ms + requestTimeoutSlackMs;

🟡 Medium: With --wait-ms now defaulting to 0, this makes the total client budget for a submit 5 s — down from 125 s before this PR. requestTimeoutSlackMs was network slack layered on top of a server-side wait; it is now the entire budget for DNS + TCP + TLS + server accept.

Verified against a local server that delays 8 s:

{ "error": { "kind": "transport", "message": "tropkod request timed out after 5000ms", "exit_code": 5 } }
EXIT=5 after 5109ms

Per the README, exit 5 on a submit means "assume yes" for job-created and "report and stop" — so a create that merely took 6 s (cold start, TLS handshake on a fresh connection, brief queueing at the edge) becomes an unrecoverable orphaned paid analysis, which is the failure mode this PR sets out to eliminate.

A floor keeps the non-blocking promise (the timeout is only an upper bound; a healthy server still returns in milliseconds) while removing the cliff:

const minSubmitTimeoutMs = 30_000;
const timeoutMs = Math.max(input.wait_ms + requestTimeoutSlackMs, minSubmitTimeoutMs);

That also lines the submit budget up with defaultJobFetchTimeoutMs, which already grants a plain GET 30 s.

🟡 **Medium:** With `--wait-ms` now defaulting to `0`, this makes the total client budget for a submit **5 s** — down from 125 s before this PR. `requestTimeoutSlackMs` was network slack layered on top of a server-side wait; it is now the entire budget for DNS + TCP + TLS + server accept. Verified against a local server that delays 8 s: ``` { "error": { "kind": "transport", "message": "tropkod request timed out after 5000ms", "exit_code": 5 } } EXIT=5 after 5109ms ``` Per the README, exit `5` on a submit means "assume yes" for job-created and "report and stop" — so a create that merely took 6 s (cold start, TLS handshake on a fresh connection, brief queueing at the edge) becomes an unrecoverable orphaned paid analysis, which is the failure mode this PR sets out to eliminate. A floor keeps the non-blocking promise (the timeout is only an upper bound; a healthy server still returns in milliseconds) while removing the cliff: ```ts const minSubmitTimeoutMs = 30_000; const timeoutMs = Math.max(input.wait_ms + requestTimeoutSlackMs, minSubmitTimeoutMs); ``` That also lines the submit budget up with `defaultJobFetchTimeoutMs`, which already grants a plain GET 30 s.
jercik marked this conversation as resolved
fix: floor the submit timeout and parse-then-normalize the base URL
All checks were successful
commit-msg / commitlint (pull_request) Successful in 18s
Checks / quality-checks (pull_request) Successful in 33s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 3m0s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 9m17s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 14s
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 11m49s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 24s
6f80771095
The submit request budget is now max(wait_ms + 5000, 30000), sharing
one constant with the poll GET, so the default --wait-ms 0 no longer
aborts a slow-but-accepted create at 5 s and orphans the job. The base
URL is parsed before normalization; a query or fragment is rejected as
a usage error (exit 3) instead of silently corrupting the base path.
README exit-5 and exit-6 rows updated: the orphan discriminator is the
presence of .error.job_id, not body parseability.
Author
Owner

Round-2 findings are addressed in 6f80771 (all three reproduced before fixing; both code issues were flagged independently by both reviewers). Per conversation:

remote-query-client.ts — 5 s submit budget with --wait-ms 0 (31053, 31056): Confirmed against an 8 s-delay server — the default submit aborted at 5000 ms with exit 5, the exact orphaned-job case this PR exists to eliminate. Fixed with the suggested floor: Math.max(wait_ms + requestTimeoutSlackMs, baseRequestTimeoutMs), where baseRequestTimeoutMs is the renamed 30 s constant the poll GET already uses, so the submit floor and poll timeout share one source of truth. Verified: the 8 s server now returns the pending envelope (exit 2); a 45 s server aborts at exactly 30 s; --wait-ms 30000 aborts at 35 s. README exit-5 row documents max(wait_ms + 5000, 30000).

read-remote-client-options.ts — trailing slash appended pre-parse (31054, 31057): Confirmed — --url https://h/api?tenant=1 produced base https://h/api?tenant=1/ and requests hit the host root, silently dropping /api with the bearer token attached. Fixed by parsing first, then rejecting any base carrying a query or fragment as usage/exit 3 and normalizing parsed.pathname with the trailing slash. Rejection beats silent clearing: relative resolution can never honor a base query/fragment, so accepting one always discards caller intent — and the taxonomy has an exact pre-dispatch bucket for it. Tests cover no-slash, trailing-slash, query, and fragment cases; verified zero requests reach the server on rejection.

README — exit-6 row overstates recovery (31058): Confirmed — recoverJobId requires a job.id string, and a local 200 {"foo":1} yields an exit-6 envelope with no job_id: readable JSON, id lost. Both the exit-6 row and the line-120 parenthetical now discriminate on the envelope field itself — id recovered iff .error.job_id is present, lost otherwise — with the lead-in broadened since schema-drift JSON orphans identically to non-JSON.

Checks: 92/92 tests, lint/typecheck/build/knip/format green, 18/18 empirical spot-checks passed.

Round-2 findings are addressed in 6f80771 (all three reproduced before fixing; both code issues were flagged independently by both reviewers). Per conversation: **`remote-query-client.ts` — 5 s submit budget with `--wait-ms 0` (31053, 31056):** Confirmed against an 8 s-delay server — the default submit aborted at 5000 ms with exit 5, the exact orphaned-job case this PR exists to eliminate. Fixed with the suggested floor: `Math.max(wait_ms + requestTimeoutSlackMs, baseRequestTimeoutMs)`, where `baseRequestTimeoutMs` is the renamed 30 s constant the poll GET already uses, so the submit floor and poll timeout share one source of truth. Verified: the 8 s server now returns the pending envelope (exit 2); a 45 s server aborts at exactly 30 s; `--wait-ms 30000` aborts at 35 s. README exit-5 row documents `max(wait_ms + 5000, 30000)`. **`read-remote-client-options.ts` — trailing slash appended pre-parse (31054, 31057):** Confirmed — `--url https://h/api?tenant=1` produced base `https://h/api?tenant=1/` and requests hit the host root, silently dropping `/api` with the bearer token attached. Fixed by parsing first, then rejecting any base carrying a query or fragment as usage/exit 3 and normalizing `parsed.pathname` with the trailing slash. Rejection beats silent clearing: relative resolution can never honor a base query/fragment, so accepting one always discards caller intent — and the taxonomy has an exact pre-dispatch bucket for it. Tests cover no-slash, trailing-slash, query, and fragment cases; verified zero requests reach the server on rejection. **README — exit-6 row overstates recovery (31058):** Confirmed — `recoverJobId` requires a `job.id` string, and a local 200 `{"foo":1}` yields an exit-6 envelope with no `job_id`: readable JSON, id lost. Both the exit-6 row and the line-120 parenthetical now discriminate on the envelope field itself — id recovered iff `.error.job_id` is present, lost otherwise — with the lead-in broadened since schema-drift JSON orphans identically to non-JSON. Checks: 92/92 tests, lint/typecheck/build/knip/format green, 18/18 empirical spot-checks passed.
forgejo-actions left a comment

Summary: 1 low-severity defect and 1 observation. No critical, high, or medium issues found.

I exercised the head sources against throwaway local HTTP servers rather than relying on reading alone. Verified as documented: exit 2 on a pending submit (JSON and human output), 3 for every usage path (missing URL, URL with a query, header-invalid API key, empty stdin, empty --job, question+--job, commander's unknown-option and rejected --wait-ms), 4 with the status preserved on a 401 and on a 5xx whose body reads like a refusal, 5 on an unreachable host and on the 30 s abort while the body hung mid-stream, and 6 (with job_id recovered) on a schema-drifted 2xx. Also confirmed: commander prints its own prose to stderr before exitOverride() throws, --help/--version throw a CommanderError with exitCode === 0, program.opts() is populated when the action rejects, CommanderError from @commander-js/extra-typings is a runtime value and instanceof holds for errors thrown by commander itself, and AbortSignal.timeout does not keep the event loop alive after a fast response.

The usage-error-before-stdin ordering, the max(wait_ms + 5000, 30000) floor, the always-appended (HTTP <status>) suffix, and the stdout-only envelope for commander errors all behave as the README describes.

Code review by Claude Code Opus (opus)

**Summary:** 1 low-severity defect and 1 observation. No critical, high, or medium issues found. I exercised the head sources against throwaway local HTTP servers rather than relying on reading alone. Verified as documented: exit `2` on a pending submit (JSON and human output), `3` for every usage path (missing URL, URL with a query, header-invalid API key, empty stdin, empty `--job`, question+`--job`, commander's unknown-option and rejected `--wait-ms`), `4` with the status preserved on a 401 and on a 5xx whose body reads like a refusal, `5` on an unreachable host and on the 30 s abort while the body hung mid-stream, and `6` (with `job_id` recovered) on a schema-drifted 2xx. Also confirmed: commander prints its own prose to stderr before `exitOverride()` throws, `--help`/`--version` throw a `CommanderError` with `exitCode === 0`, `program.opts()` is populated when the action rejects, `CommanderError` from `@commander-js/extra-typings` is a runtime value and `instanceof` holds for errors thrown by commander itself, and `AbortSignal.timeout` does not keep the event loop alive after a fast response. The usage-error-before-stdin ordering, the `max(wait_ms + 5000, 30000)` floor, the always-appended `(HTTP <status>)` suffix, and the stdout-only envelope for commander errors all behave as the README describes. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:97a094a5-8a05-4123-98a7-357608768817 -->
README.md Outdated
@ -80,0 +109,4 @@
| `6` | Invalid response — 2xx received but the body was not a parseable submission envelope. | Yes — id recovered iff the envelope carries `.error.job_id` (the body held a readable `job.id`); lost otherwise | Job untouched. A transient blip (truncated body, proxy garbling) recovers — retry once or twice. A repeated exit `6` with an identical message is deterministic schema drift, terminal for this client build: the job has likely already ended in a shape this client cannot render — report it, don't keep looping |
| `7` | Unexpected client error (a bug in this CLI). | Unknown — assume yes | Job untouched — report the bug; polling the same id stays safe |
After a failed **submit**, resubmitting is safe iff `code == 3 || (code == 4 && status < 500)` — and only when every earlier submit in the task is accounted for. A failed **poll** never licenses a resubmit: `GET /v1/jobs/<id>` can neither create nor destroy a job — repair the input and keep polling.

ℹ️ Info: No action needed — recording a consequence of the taxonomy that is worth being deliberate about.

Exit 5 covers both "never reached the server" and "aborted after dispatch", and this rule plus the orphaned-jobs section then tells an agent to report and stop without resubmitting. A mistyped TROPKOD_URL lands here: I confirmed an unreachable host produces {"kind":"transport","exit_code":5} with message fetch failed: bad port, indistinguishable in the envelope from a mid-body abort even though no job can possibly exist. So after fixing the URL, an agent following the documented rule still refuses to resubmit.

If that trade is intentional (conservative beats a duplicate paid analysis), it reads fine as written. If not, the cheap version is surfacing the connect-time cause in .error.messagereadCauseChainMessage already yields ECONNREFUSED/ENOTFOUND/bad port — and documenting that those substrings prove nothing was dispatched, without adding an exit code.

ℹ️ **Info:** No action needed — recording a consequence of the taxonomy that is worth being deliberate about. Exit `5` covers both "never reached the server" and "aborted after dispatch", and this rule plus the orphaned-jobs section then tells an agent to report and stop without resubmitting. A mistyped `TROPKOD_URL` lands here: I confirmed an unreachable host produces `{"kind":"transport","exit_code":5}` with message `fetch failed: bad port`, indistinguishable in the envelope from a mid-body abort even though no job can possibly exist. So after fixing the URL, an agent following the documented rule still refuses to resubmit. If that trade is intentional (conservative beats a duplicate paid analysis), it reads fine as written. If not, the cheap version is surfacing the connect-time cause in `.error.message` — `readCauseChainMessage` already yields `ECONNREFUSED`/`ENOTFOUND`/`bad port` — and documenting that those substrings prove nothing was dispatched, without adding an exit code.
jercik marked this conversation as resolved
@ -25,2 +27,2 @@
if (questionParts.length > 0 || stdinQuestion.length > 0) {
throw new Error("pass either a question or --job, not both");
const jobId = options.job.trim();
if (jobId.length === 0) {

🟢 Low: --job is only checked for emptiness, so a job id consisting of dot segments silently rewrites the request path instead of being rejected.

fetchRemoteJob builds /v1/jobs/${encodeURIComponent(jobId)} and resolves it against the base URL. encodeURIComponent escapes /, ? and #, but leaves . and .. untouched, and the URL parser then collapses them. Verified against a local server: --job ".." issues GET /v1/ (the server logged exactly that), and because my stub answered with a valid envelope the client exited 2 for a job id that does not exist. Against the real service that request lands on some unrelated route, and a 2xx there yields exit 6 — which the README tells agents to read as "deterministic schema drift, terminal for this client build: report it", sending them after a phantom client bug.

A guard here keeps the id a single path segment, alongside the existing empty check:

if (jobId === "." || jobId === "..") {
  throw new CliError({ kind: "usage" }, "--job requires a job id");
}

Fixing it in remote-query-client.ts instead does not work: the URL pathname setter normalizes dot segments too, so the rejection belongs at this parse boundary.

🟢 **Low:** `--job` is only checked for emptiness, so a job id consisting of dot segments silently rewrites the request path instead of being rejected. `fetchRemoteJob` builds `/v1/jobs/${encodeURIComponent(jobId)}` and resolves it against the base URL. `encodeURIComponent` escapes `/`, `?` and `#`, but leaves `.` and `..` untouched, and the URL parser then collapses them. Verified against a local server: `--job ".."` issues `GET /v1/` (the server logged exactly that), and because my stub answered with a valid envelope the client exited `2` for a job id that does not exist. Against the real service that request lands on some unrelated route, and a 2xx there yields exit `6` — which the README tells agents to read as "deterministic schema drift, terminal for this client build: report it", sending them after a phantom client bug. A guard here keeps the id a single path segment, alongside the existing empty check: ```ts if (jobId === "." || jobId === "..") { throw new CliError({ kind: "usage" }, "--job requires a job id"); } ``` Fixing it in `remote-query-client.ts` instead does not work: the `URL` `pathname` setter normalizes dot segments too, so the rejection belongs at this parse boundary.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Found 1 low-severity issue. The exit-code taxonomy, JSON envelopes, and error classification hold up under empirical testing.

I reconstructed the CLI from the PR head in a scratch directory (real commander@15/zod@4, Node 26) and drove it against stub servers to verify the documented contract rather than reason about it. All 92 tests pass. Verified end to end:

  • exit 3: missing --url, unparseable/mailto:/htp: URL, URL with query or fragment, API key with a newline or a non-latin1 character, --job " ", question-plus---job, commander's unknown option and rejected --wait-ms (stdout carries exactly one envelope, commander's prose stays on stderr, (add --help for usage) is absent from message as the README states);
  • exit 4: 401 and 500 with a nested error body, 404 on a poll, status echoed in both message and status;
  • exit 5: refused connection, DNS failure, connection reset mid-body, and a real 30 s AbortSignal.timeout against a server that never responds (tropkod request timed out after 30000ms, process exits promptly — the timeout timer does not hold the loop open);
  • exit 6: HTML body on a 2xx, schema drift with a recoverable job.id (job_id in the envelope) and without;
  • exit 2/0: pending envelope in both JSON and text mode, base-path normalization (--url http://h:8746/proxyPOST /proxy/v1/queries), -- terminator correctly suppressing the argv --json scan;
  • the stdin reorder: with a pipe held open and no --url, the client exits 3 immediately instead of blocking, and a question passed as an argument never touches stdin.

The removal of parseIntegerOption's isSafeInteger/BigInt guard is safe in parseWaitMsOption: any digit-only string that survives the > 300000 check is exactly representable, and oversized inputs (99999999999999999999) are rejected by the range branch.

Code review by Claude Code Opus (opus)

**Summary:** Found 1 low-severity issue. The exit-code taxonomy, JSON envelopes, and error classification hold up under empirical testing. I reconstructed the CLI from the PR head in a scratch directory (real `commander@15`/`zod@4`, Node 26) and drove it against stub servers to verify the documented contract rather than reason about it. All 92 tests pass. Verified end to end: - exit `3`: missing `--url`, unparseable/`mailto:`/`htp:` URL, URL with query or fragment, API key with a newline or a non-latin1 character, `--job " "`, question-plus-`--job`, commander's unknown option and rejected `--wait-ms` (stdout carries exactly one envelope, commander's prose stays on stderr, `(add --help for usage)` is absent from `message` as the README states); - exit `4`: 401 and 500 with a nested error body, 404 on a poll, status echoed in both `message` and `status`; - exit `5`: refused connection, DNS failure, connection reset mid-body, and a real 30 s `AbortSignal.timeout` against a server that never responds (`tropkod request timed out after 30000ms`, process exits promptly — the timeout timer does not hold the loop open); - exit `6`: HTML body on a 2xx, schema drift with a recoverable `job.id` (`job_id` in the envelope) and without; - exit `2`/`0`: pending envelope in both JSON and text mode, base-path normalization (`--url http://h:8746/proxy` → `POST /proxy/v1/queries`), `--` terminator correctly suppressing the argv `--json` scan; - the stdin reorder: with a pipe held open and no `--url`, the client exits `3` immediately instead of blocking, and a question passed as an argument never touches stdin. The removal of `parseIntegerOption`'s `isSafeInteger`/`BigInt` guard is safe in `parseWaitMsOption`: any digit-only string that survives the `> 300000` check is exactly representable, and oversized inputs (`99999999999999999999`) are rejected by the range branch. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:1451dace-2283-4446-aa27-8db7348e02c8 -->
@ -51,3 +43,4 @@
headers["content-type"] = "application/json";
}
const requestUrl = new URL(request.path.slice(1), options.base_url);

🟢 Low: A --job id that is exactly . or .. silently retargets the request instead of failing. encodeURIComponent leaves dots unescaped, so new URL("v1/jobs/..", base) collapses the segment during relative resolution.

Reproduced against a stub server:

  • --job ".."GET /v1/
  • --job "."GET /v1/jobs/

Both carry the authorization header and neither is a job fetch. Ids containing a slash are safe (encodeURIComponent yields %2F, which URL does not normalize), so the blast radius is just these two degenerate values — but the failure is silent, and whatever the alternate route returns gets mapped into the exit-code taxonomy as if it were a verdict about that job: a 2xx with an unparseable body becomes a repeated exit 6, which the README tells agents to read as "deterministic schema drift, terminal for this client build".

A guard in resolveSubmissionRequest, next to the existing empty-id check, keeps the fix in the functional core:

if (jobId === "." || jobId === ".." || jobId.includes("/")) {
  throw new CliError({ kind: "usage" }, "--job requires a job id");
}

That preserves exit 3 ("nothing was sent — fix the input, resume polling the same id"), which is the honest verdict here.

🟢 **Low:** A `--job` id that is exactly `.` or `..` silently retargets the request instead of failing. `encodeURIComponent` leaves dots unescaped, so `new URL("v1/jobs/..", base)` collapses the segment during relative resolution. Reproduced against a stub server: - `--job ".."` → `GET /v1/` - `--job "."` → `GET /v1/jobs/` Both carry the `authorization` header and neither is a job fetch. Ids containing a slash are safe (`encodeURIComponent` yields `%2F`, which `URL` does not normalize), so the blast radius is just these two degenerate values — but the failure is silent, and whatever the alternate route returns gets mapped into the exit-code taxonomy as if it were a verdict about that job: a 2xx with an unparseable body becomes a repeated exit `6`, which the README tells agents to read as "deterministic schema drift, terminal for this client build". A guard in `resolveSubmissionRequest`, next to the existing empty-id check, keeps the fix in the functional core: ```ts if (jobId === "." || jobId === ".." || jobId.includes("/")) { throw new CliError({ kind: "usage" }, "--job requires a job id"); } ``` That preserves exit `3` ("nothing was sent — fix the input, resume polling the same id"), which is the honest verdict here.
jercik marked this conversation as resolved
fix: reject dot-segment and slash job ids as usage errors
All checks were successful
commit-msg / commitlint (pull_request) Successful in 26s
Checks / quality-checks (pull_request) Successful in 46s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 1m41s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 9m33s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 14s
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 10m8s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 14s
9ec02a681d
Author
Owner

Round-3 feedback processed at 9ec02a6:

  • Dot-segment --job ids (both inline comments on resolve-submission-request.ts and remote-query-client.ts) — fixed. The guard sits at the parse boundary next to the existing empty-id check, exactly as suggested: ., .., and any id containing / now throw a usage error (--job id must be a single path segment, exit 3 — "nothing was sent, fix the input, resume polling the same id"). Tests cover both dot segments, a slash-bearing id, and an id with interior dots (job.v2.42) that must stay accepted.
  • Exit-5 conflation of never-dispatched vs aborted-after-dispatch (README, info) — acknowledged; the trade is intentional. Conservative-beats-duplicate is the design: a false "report and stop" costs one wasted stop, a false "safe to resubmit" costs a paid analysis. The connect-time cause already surfaces in .error.message via the cause chain (ECONNREFUSED, ENOTFOUND, …), but we deliberately don't document those substrings as dispatch proofs — branching on stderr wording is exactly what the exit-code taxonomy exists to replace, and Node's cause chains aren't a stable contract.
Round-3 feedback processed at `9ec02a6`: - **Dot-segment `--job` ids (both inline comments on `resolve-submission-request.ts` and `remote-query-client.ts`)** — fixed. The guard sits at the parse boundary next to the existing empty-id check, exactly as suggested: `.`, `..`, and any id containing `/` now throw a usage error (`--job id must be a single path segment`, exit `3` — "nothing was sent, fix the input, resume polling the same id"). Tests cover both dot segments, a slash-bearing id, and an id with interior dots (`job.v2.42`) that must stay accepted. - **Exit-`5` conflation of never-dispatched vs aborted-after-dispatch (README, info)** — acknowledged; the trade is intentional. Conservative-beats-duplicate is the design: a false "report and stop" costs one wasted stop, a false "safe to resubmit" costs a paid analysis. The connect-time cause already surfaces in `.error.message` via the cause chain (`ECONNREFUSED`, `ENOTFOUND`, …), but we deliberately don't document those substrings as dispatch proofs — branching on stderr wording is exactly what the exit-code taxonomy exists to replace, and Node's cause chains aren't a stable contract.
forgejo-actions left a comment

Summary: Found 2 medium issues.

Code review by Codex GPT-5.6 SOL (gpt-5.6-sol)

**Summary:** Found 2 medium issues. _Code review by Codex GPT-5.6 SOL (gpt-5.6-sol)_ <!-- axrecipe-review:801ddd90-890a-4bdc-bc75-bf9746d455ec -->
@ -15,0 +22,4 @@
} catch (error) {
throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL is not a valid URL", error);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {

🟡 Medium: URLs with userinfo pass this validation, but Node Fetch rejects a URL such as https://user:password@host/ before dispatch with Request cannot be constructed from a URL that includes credentials. The catch in requestQuerySubmission then reports exit 5 (transport) even though no job could have been created, and its error message echoes the full URL—including the password—to stderr or the JSON envelope. Reject non-empty parsed.username or parsed.password here as a usage error with a constant message.

🟡 **Medium:** URLs with userinfo pass this validation, but Node Fetch rejects a URL such as `https://user:password@host/` before dispatch with `Request cannot be constructed from a URL that includes credentials`. The catch in `requestQuerySubmission` then reports exit 5 (`transport`) even though no job could have been created, and its error message echoes the full URL—including the password—to stderr or the JSON envelope. Reject non-empty `parsed.username` or `parsed.password` here as a `usage` error with a constant message.
jercik marked this conversation as resolved
@ -69,3 +67,2 @@
if (!response.ok) {
const text = await response.text();
throw new Error(readErrorMessage(text, response.status));
const text = await response.text().catch(() => "");

🟡 Medium: This expression converts every failure while consuming a non-2xx body into an empty body and still returns exit 4. With the request timeout signal, a 503 that sends headers and stalls mid-body makes response.text() reject with TimeoutError, so the documented exit 5 timeout path is never used for HTTP error bodies; connection resets are hidden the same way. Handle the body read with the same abort/transport classification as the 2xx response.json() path, or narrow the documented exit-code contract to match this behavior.

🟡 **Medium:** This expression converts every failure while consuming a non-2xx body into an empty body and still returns exit 4. With the request timeout signal, a 503 that sends headers and stalls mid-body makes `response.text()` reject with `TimeoutError`, so the documented exit 5 timeout path is never used for HTTP error bodies; connection resets are hidden the same way. Handle the body read with the same abort/transport classification as the 2xx `response.json()` path, or narrow the documented exit-code contract to match this behavior.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Reviewed the non-blocking-default / exit-code / JSON-envelope rework. I ran the branch end-to-end against throwaway local servers (Node 26, commander 15, zod 4) and the whole documented taxonomy reproduces exactly: --help/--version → 0, pending submit → 2 with the envelope, failed job → 1, usage/commander errors → 3 (one JSON doc on stdout, commander prose on stderr), 401/500 → 4 with status, unreachable host and mid-body termination → 5, non-JSON and schema-drift 200s → 6 (with job_id recovered when the body carried one). Base-path joining (/proxy/base + /v1/queries), job-id encoding (a b?c#d, %2e%2e, .., jobs/42), the Bearer header, and the pre-stdin URL/credential check (170 ms exit 3 against an open pipe) all behave as documented. The full suite passes (95 tests).

Two findings, both in read-remote-client-options.ts: a URL carrying userinfo escapes the new validation and is misclassified as exit 5 while echoing the password, and the header-validation cause captures the raw API key.

Code review by Claude Code Opus (opus)

**Summary:** Reviewed the non-blocking-default / exit-code / JSON-envelope rework. I ran the branch end-to-end against throwaway local servers (Node 26, commander 15, zod 4) and the whole documented taxonomy reproduces exactly: `--help`/`--version` → 0, pending submit → 2 with the envelope, failed job → 1, usage/commander errors → 3 (one JSON doc on stdout, commander prose on stderr), 401/500 → 4 with `status`, unreachable host and mid-body termination → 5, non-JSON and schema-drift 200s → 6 (with `job_id` recovered when the body carried one). Base-path joining (`/proxy/base` + `/v1/queries`), job-id encoding (`a b?c#d`, `%2e%2e`, `..`, `jobs/42`), the `Bearer` header, and the pre-stdin URL/credential check (170 ms exit 3 against an open pipe) all behave as documented. The full suite passes (95 tests). Two findings, both in `read-remote-client-options.ts`: a URL carrying userinfo escapes the new validation and is misclassified as exit 5 while echoing the password, and the header-validation `cause` captures the raw API key. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:0e12beba-76d1-4582-b796-0f69543204fa -->
@ -15,0 +25,4 @@
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL must be an http(s) URL");
}
if (parsed.search !== "" || parsed.hash !== "") {

🟡 Medium: This validation rejects a query/fragment but not userinfo, and a base URL with credentials escapes to the fetch layer where it is misclassified as a transport failure.

The Fetch Request constructor throws TypeError: Request cannot be constructed from a URL that includes credentials before dispatching anything. That TypeError lands in the catch around fetch in remote-query-client.ts, is not an abort error, and so becomes CliError({ kind: "transport" }) → exit 5. Reproduced on this branch:

$ TROPKOD_URL="http://user:pass@127.0.0.1:28591" TROPKOD_API_KEY=k tropkod-client --json --job j1
{
  "error": {
    "kind": "transport",
    "message": "Request cannot be constructed from a URL that includes credentials: http://user:pass@127.0.0.1:28591/v1/jobs/j1",
    "exit_code": 5
  }
}
EXIT=5

Two concrete consequences:

  1. Exit 5 is the one code the README defines as "dispatched, but no response completed… Unknown — assume yes", and the Orphaned jobs section instructs agents to "report and stop — do not resubmit the question". A purely local --url typo therefore makes a harness believe it may have created and paid for an analysis, and forbids the retry that would actually fix it. The provably-correct code here is 3 (usage, no request dispatched).
  2. The message echoes the full URL including the password onto stdout/stderr, so the secret lands in agent logs and transcripts.

Fix in the same guard, next to the query/fragment check:

if (parsed.username !== "" || parsed.password !== "") {
  throw new CliError(
    { kind: "usage" },
    "--url or TROPKOD_URL must not include credentials",
  );
}

That keeps the failure inside the provably-no-job exit 3 class and stops the password from being printed.

🟡 **Medium:** This validation rejects a query/fragment but not userinfo, and a base URL with credentials escapes to the fetch layer where it is misclassified as a transport failure. The Fetch `Request` constructor throws `TypeError: Request cannot be constructed from a URL that includes credentials` *before dispatching anything*. That TypeError lands in the `catch` around `fetch` in `remote-query-client.ts`, is not an abort error, and so becomes `CliError({ kind: "transport" })` → exit `5`. Reproduced on this branch: ``` $ TROPKOD_URL="http://user:pass@127.0.0.1:28591" TROPKOD_API_KEY=k tropkod-client --json --job j1 { "error": { "kind": "transport", "message": "Request cannot be constructed from a URL that includes credentials: http://user:pass@127.0.0.1:28591/v1/jobs/j1", "exit_code": 5 } } EXIT=5 ``` Two concrete consequences: 1. Exit `5` is the one code the README defines as "dispatched, but no response completed… Unknown — assume yes", and the Orphaned jobs section instructs agents to "report and stop — do not resubmit the question". A purely local `--url` typo therefore makes a harness believe it may have created and paid for an analysis, and forbids the retry that would actually fix it. The provably-correct code here is `3` (usage, no request dispatched). 2. The message echoes the full URL including the password onto stdout/stderr, so the secret lands in agent logs and transcripts. Fix in the same guard, next to the query/fragment check: ```ts if (parsed.username !== "" || parsed.password !== "") { throw new CliError( { kind: "usage" }, "--url or TROPKOD_URL must not include credentials", ); } ``` That keeps the failure inside the provably-no-job exit `3` class and stops the password from being printed.
jercik marked this conversation as resolved
@ -20,0 +48,4 @@
throw new CliError(
{ kind: "usage" },
"--api-key or TROPKOD_API_KEY contains characters not allowed in an HTTP header",
error,

🟢 Low: Passing this error as the cause embeds the raw API key in the thrown CliError. undici's message interpolates the rejected header value verbatim:

$ node -e 'try{new Headers().set("authorization","Bearer sk-live-SECRET\ndef")}catch(e){console.log(e.message)}'
Headers.set: "Bearer sk-live-SECRET
def" is an invalid header value.

Nothing prints it today — formatFailure renders only report.message, and the console.error(error) added in bin/tropkod-client only fires when the module import itself rejects — so this is latent rather than an active leak. But it is a credential sitting one console.error(error) (or one readCauseChainMessage reuse) away from stdout, and the cause carries no diagnostic value the CliError message doesn't already state. Dropping the third argument removes the exposure at no cost.

🟢 **Low:** Passing this `error` as the `cause` embeds the raw API key in the thrown `CliError`. undici's message interpolates the rejected header value verbatim: ``` $ node -e 'try{new Headers().set("authorization","Bearer sk-live-SECRET\ndef")}catch(e){console.log(e.message)}' Headers.set: "Bearer sk-live-SECRET def" is an invalid header value. ``` Nothing prints it today — `formatFailure` renders only `report.message`, and the `console.error(error)` added in `bin/tropkod-client` only fires when the module import itself rejects — so this is latent rather than an active leak. But it is a credential sitting one `console.error(error)` (or one `readCauseChainMessage` reuse) away from stdout, and the cause carries no diagnostic value the CliError message doesn't already state. Dropping the third argument removes the exposure at no cost.
jercik marked this conversation as resolved
fix: reject credential-bearing URLs, drop key-bearing cause, pin non-2xx body-read to exit 4
All checks were successful
commit-msg / commitlint (pull_request) Successful in 13s
Checks / quality-checks (pull_request) Successful in 31s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 1m43s
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 11m13s
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 11m19s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 13s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 15s
7e154286d7
Author
Owner

Round-4 feedback processed at 7e15428:

  • Credential-bearing base URL misclassified as exit 5 and echoing the password (both Medium inline comments on read-remote-client-options.ts) — fixed with exactly the suggested guard, next to the query/fragment check: non-empty parsed.username or parsed.password now throws a usage error with the constant message --url or TROPKOD_URL must not include credentials (exit 3, provably no request dispatched, retry licensed). Tests assert the message is constant, the detail is usage, and the thrown error carries no cause — so the password can never reach stdout, stderr, or the envelope. The flags table now lists credentials alongside query/fragment.
  • Non-2xx body-read failures swallowed into exit 4 (Medium, remote-query-client.ts) — resolved by the second remedy offered: the contract is narrowed to match the behavior, deliberately. On a non-2xx, the status line is the verdict and the body only decorates the message; reclassifying a stalled/reset error body as transport would lose information — worst case, a 4xx (provably no job created, resubmit safe) whose body stalls would become exit 5 ("assume a paid job exists, never resubmit"), strictly worse for the caller. The swallow now carries a why-comment in code, the exit-4 row states that the status alone decides, and the exit-5 row's "abort mid-body" is scoped to a 2xx response.
  • API key embedded in the Headers-probe cause (Low, read-remote-client-options.ts) — fixed; the third argument is dropped and a comment records why (undici interpolates the rejected header value verbatim). The existing interior-newline test now also asserts cause is undefined.
Round-4 feedback processed at `7e15428`: - **Credential-bearing base URL misclassified as exit `5` and echoing the password (both Medium inline comments on `read-remote-client-options.ts`)** — fixed with exactly the suggested guard, next to the query/fragment check: non-empty `parsed.username` or `parsed.password` now throws a usage error with the constant message `--url or TROPKOD_URL must not include credentials` (exit `3`, provably no request dispatched, retry licensed). Tests assert the message is constant, the detail is `usage`, and the thrown error carries no cause — so the password can never reach stdout, stderr, or the envelope. The flags table now lists credentials alongside query/fragment. - **Non-2xx body-read failures swallowed into exit `4` (Medium, `remote-query-client.ts`)** — resolved by the second remedy offered: the contract is narrowed to match the behavior, deliberately. On a non-2xx, the status line is the verdict and the body only decorates the message; reclassifying a stalled/reset error body as transport would *lose* information — worst case, a 4xx (provably no job created, resubmit safe) whose body stalls would become exit `5` ("assume a paid job exists, never resubmit"), strictly worse for the caller. The swallow now carries a why-comment in code, the exit-`4` row states that the status alone decides, and the exit-`5` row's "abort mid-body" is scoped to a 2xx response. - **API key embedded in the Headers-probe `cause` (Low, `read-remote-client-options.ts`)** — fixed; the third argument is dropped and a comment records why (undici interpolates the rejected header value verbatim). The existing interior-newline test now also asserts `cause` is `undefined`.
forgejo-actions left a comment

Summary: Found 1 medium issue. The exit-code taxonomy, JSON envelopes, and README claims otherwise hold up under direct testing.

I built the branch and verified the documented behavior empirically rather than by reading alone:

  • pnpm test (97 passed), tsc -b --noEmit, and tsc -p tsconfig.app.json all pass.
  • Exercised against throwaway local servers: 401 body -> exit 4 with status; 500 with a {error:{message}} body -> exit 4 (the (HTTP 500) suffix is preserved, so it can no longer masquerade as a 4xx); non-JSON 2xx and empty 2xx -> exit 6 invalid-response; a drifted 2xx carrying job.id -> exit 6 with job_id recovered in the envelope; pending/completed/failed -> 2/0/1.
  • Transport: refused/ENOTFOUND -> exit 5. Usage: missing URL, --wait-ms abc, --wait-ms 300001, unknown option -> exit 3 with exactly one JSON document on stdout, commander prose only on stderr. --help/--version -> exit 0 with human text.
  • Confirmed the ordering guarantee: with an open, never-closing stdin pipe and no TROPKOD_URL, the client exits 3 immediately; with the URL set it blocks until EOF, exactly as the new README paragraph states.
  • Confirmed URL/job-id handling on the wire: base path http://h/proxy -> GET /proxy/v1/jobs/job-42, and --job '%2e%2e' is double-encoded to /v1/jobs/%252e%252e (no path collapse). The --url/--api-key guards are complete for header injection: Headers.set rejects CR/LF/NUL and any code point above U+00FF before dispatch.
  • Confirmed the bin wrapper: a missing dist/cli.js yields exit 7 with empty stdout.

Code review by Claude Code Opus (opus)

**Summary:** Found 1 medium issue. The exit-code taxonomy, JSON envelopes, and README claims otherwise hold up under direct testing. I built the branch and verified the documented behavior empirically rather than by reading alone: - `pnpm test` (97 passed), `tsc -b --noEmit`, and `tsc -p tsconfig.app.json` all pass. - Exercised against throwaway local servers: 401 body -> exit `4` with `status`; 500 with a `{error:{message}}` body -> exit `4` (the `(HTTP 500)` suffix is preserved, so it can no longer masquerade as a 4xx); non-JSON 2xx and empty 2xx -> exit `6` `invalid-response`; a drifted 2xx carrying `job.id` -> exit `6` with `job_id` recovered in the envelope; pending/completed/failed -> `2`/`0`/`1`. - Transport: refused/ENOTFOUND -> exit `5`. Usage: missing URL, `--wait-ms abc`, `--wait-ms 300001`, unknown option -> exit `3` with exactly one JSON document on stdout, commander prose only on stderr. `--help`/`--version` -> exit `0` with human text. - Confirmed the ordering guarantee: with an open, never-closing stdin pipe and no `TROPKOD_URL`, the client exits `3` immediately; with the URL set it blocks until EOF, exactly as the new README paragraph states. - Confirmed URL/job-id handling on the wire: base path `http://h/proxy` -> `GET /proxy/v1/jobs/job-42`, and `--job '%2e%2e'` is double-encoded to `/v1/jobs/%252e%252e` (no path collapse). The `--url`/`--api-key` guards are complete for header injection: `Headers.set` rejects CR/LF/NUL and any code point above U+00FF before dispatch. - Confirmed the bin wrapper: a missing `dist/cli.js` yields exit `7` with empty stdout. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:d072f148-1401-4e90-9012-4bb5fe068525 -->
@ -15,0 +33,4 @@
}
// Fetch rejects credential-bearing URLs pre-dispatch; constant message so the password never prints
if (parsed.username !== "" || parsed.password !== "") {
throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL must not include credentials");

🟡 Medium: The pre-dispatch guards here cover credentials and header-invalid API keys, but not a port that fetch refuses to connect to. WHATWG fetch blocks a fixed list of ports (9, 22, 6000, 10080, ...), and it rejects before opening a socket. That rejection lands in the catch around fetch in remote-query-client.ts, is not an abort, and so becomes CliError({kind:"transport"}) -> exit 5.

Verified on the built branch:

$ TROPKOD_URL=http://127.0.0.1:22 TROPKOD_API_KEY=k tropkod-client --json "q"
{"error":{"kind":"transport","message":"fetch failed: bad port","exit_code":5}}  # exit=5

Per the README this is the worst possible verdict for a purely local config typo: exit 5 on a submit means "job created: unknown — assume yes", and the Orphaned jobs section instructs the caller to "report and stop — do not resubmit the question". So a mistyped port makes an agent abandon the task and warn about a paid analysis that was never queued, when the truthful verdict is exit 3 ("no HTTP request was dispatched").

Safe fix: reject the blocked ports here, alongside the existing scheme/query/credential checks, so the failure keeps the provably-no-job code:

// fetch refuses these ports pre-dispatch; catching it here keeps the no-job-created exit code
const blockedPorts = new Set(["9", "22", "6000", /* ...WHATWG bad port list... */]);
if (blockedPorts.has(parsed.port)) {
  throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL uses a port that HTTP clients refuse to connect to");
}

Sniffing the bad port cause string in remote-query-client.ts would also work but is fragile across runtimes; validating at the boundary matches how the credential and header-value cases are already handled.

🟡 **Medium:** The pre-dispatch guards here cover credentials and header-invalid API keys, but not a port that `fetch` refuses to connect to. WHATWG `fetch` blocks a fixed list of ports (9, 22, 6000, 10080, ...), and it rejects *before* opening a socket. That rejection lands in the `catch` around `fetch` in `remote-query-client.ts`, is not an abort, and so becomes `CliError({kind:"transport"})` -> exit `5`. Verified on the built branch: ``` $ TROPKOD_URL=http://127.0.0.1:22 TROPKOD_API_KEY=k tropkod-client --json "q" {"error":{"kind":"transport","message":"fetch failed: bad port","exit_code":5}} # exit=5 ``` Per the README this is the worst possible verdict for a purely local config typo: exit `5` on a submit means "job created: unknown — assume yes", and the *Orphaned jobs* section instructs the caller to "report and stop — do not resubmit the question". So a mistyped port makes an agent abandon the task and warn about a paid analysis that was never queued, when the truthful verdict is exit `3` ("no HTTP request was dispatched"). Safe fix: reject the blocked ports here, alongside the existing scheme/query/credential checks, so the failure keeps the provably-no-job code: ```ts // fetch refuses these ports pre-dispatch; catching it here keeps the no-job-created exit code const blockedPorts = new Set(["9", "22", "6000", /* ...WHATWG bad port list... */]); if (blockedPorts.has(parsed.port)) { throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL uses a port that HTTP clients refuse to connect to"); } ``` Sniffing the `bad port` cause string in `remote-query-client.ts` would also work but is fragile across runtimes; validating at the boundary matches how the credential and header-value cases are already handled.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Found 1 medium and 1 low issue. The exit-code taxonomy holds up under testing.

I ran the CLI (native TS execution, Node 26) against throwaway local servers and confirmed every documented code path: exit 2 on a pending submit, 3 for missing url / missing api-key / empty --job / --job .. / url-with-query / url-with-credentials, 4 for 401 and for a 500 whose body stalls (30 s, status preserved — not downgraded to transport), 5 for DNS failure, blocked port, no-response, and a stalled 2xx body, 6 for a non-JSON 2xx and for schema drift both with and without a recoverable .error.job_id. Commander's exitOverride behavior matches the README caveat exactly: --help/--version throw CommanderError with exitCode === 0 and exit 0; usage errors carry commander's message (including the (Did you mean …) suggestion newline) without the (add --help for usage) hint, print prose on stderr, and still emit exactly one JSON document on stdout under --json. Base-path joining (/proxy/proxy/v1/queries), encodeURIComponent on job ids, and the Headers.set probe for control characters in the api key all behave correctly, and the probe's constant message keeps the key out of the output. AbortSignal.timeout is unref'd, so the longer submit budget cannot hold the process open after a verdict. QuerySubmission still tolerates unknown server fields, so exit 6 really is reserved for drift the client cannot render. All 97 tests pass.

Code review by Claude Code Opus (opus)

**Summary:** Found 1 medium and 1 low issue. The exit-code taxonomy holds up under testing. I ran the CLI (native TS execution, Node 26) against throwaway local servers and confirmed every documented code path: exit `2` on a pending submit, `3` for missing url / missing api-key / empty `--job` / `--job ..` / url-with-query / url-with-credentials, `4` for 401 and for a 500 whose body stalls (30 s, status preserved — not downgraded to transport), `5` for DNS failure, blocked port, no-response, and a stalled 2xx body, `6` for a non-JSON 2xx and for schema drift both with and without a recoverable `.error.job_id`. Commander's `exitOverride` behavior matches the README caveat exactly: `--help`/`--version` throw `CommanderError` with `exitCode === 0` and exit `0`; usage errors carry commander's message (including the `(Did you mean …)` suggestion newline) without the `(add --help for usage)` hint, print prose on stderr, and still emit exactly one JSON document on stdout under `--json`. Base-path joining (`/proxy` → `/proxy/v1/queries`), `encodeURIComponent` on job ids, and the `Headers.set` probe for control characters in the api key all behave correctly, and the probe's constant message keeps the key out of the output. `AbortSignal.timeout` is unref'd, so the longer submit budget cannot hold the process open after a verdict. `QuerySubmission` still tolerates unknown server fields, so exit `6` really is reserved for drift the client cannot render. All 97 tests pass. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:c9553090-7df0-4e69-868a-e440679ca063 -->
README.md Outdated
@ -83,3 +122,3 @@
## Agent Rule
Reach for `tropkod-client` when you need to check a claim about a public npm or crates.io dependency ("does `fastify@5.6.0` expose `schemaCompiler`?") against the dependency's real source rather than guessing from memory or package metadata. Run `tropkod-client --help` before first use to confirm the current flags, then submit one inline-named question (`npm:<pkg>@<version>` or `crate:<name>@<version>`) and read `--json` output for a scriptable verdict. It needs `TROPKOD_URL` and `TROPKOD_API_KEY` in the environment.
Reach for `tropkod-client` when you need to check a claim about a public npm or crates.io dependency ("does `fastify@5.6.0` expose `schemaCompiler`?") against the dependency's real source rather than guessing from memory or package metadata. Run `tropkod-client --help` before first use to confirm the current flags, then submit one inline-named question (`npm:<pkg>@<version>` or `crate:<name>@<version>`) with `--json`. The default submit is non-blocking: it prints the job envelope and exits `2`; poll `--job <id> --json` while it exits `2`, branching per the exit-code table otherwise. Branch on exit codes `3``7` and on `.error.kind`/`.error.status` instead of stderr wording; on polls, only a stdout verdict, a confirmed-correct-id 404, or a repeated exit `6` with an identical message (deterministic schema drift — report, don't loop) ends the loop. It needs `TROPKOD_URL` and `TROPKOD_API_KEY` in the environment.

🟡 Medium: The poll protocol has no cadence and no bound. This Agent Rule says to "poll --job <id> --json while it exits 2", and the exit-code table adds "keep polling" for exit 5, and for exit 4 on 401/403/408/429/5xx — but nothing anywhere in the README states an interval between polls or a maximum number of attempts. An agent implementing this literally runs GET /v1/jobs/<id> in a tight loop for the full duration of an analysis, which is exactly the behavior that earns the 429 the table then instructs it to keep polling through. The termination conditions listed here (a stdout verdict, a confirmed-correct-id 404, a repeated identical exit 6) never fire for a service that is simply down, so an exit 4/5xx or exit 5 loop has no exit at all.

This is the one piece of the new non-blocking contract that isn't pinned down; every other branch in the table is precise enough to implement mechanically. Suggested fix: state a concrete interval (e.g. wait ~5 s between polls, backing off on 429/5xx and honoring Retry-After if present) and an overall bound after which the agent reports the job id and stops, rather than polling forever.

🟡 **Medium:** The poll protocol has no cadence and no bound. This Agent Rule says to "poll `--job <id> --json` while it exits `2`", and the exit-code table adds "keep polling" for exit `5`, and for exit `4` on 401/403/408/429/5xx — but nothing anywhere in the README states an interval between polls or a maximum number of attempts. An agent implementing this literally runs `GET /v1/jobs/<id>` in a tight loop for the full duration of an analysis, which is exactly the behavior that earns the 429 the table then instructs it to keep polling through. The termination conditions listed here (a stdout verdict, a confirmed-correct-id 404, a repeated identical exit `6`) never fire for a service that is simply down, so an exit `4`/5xx or exit `5` loop has no exit at all. This is the one piece of the new non-blocking contract that isn't pinned down; every other branch in the table is precise enough to implement mechanically. Suggested fix: state a concrete interval (e.g. wait ~5 s between polls, backing off on 429/5xx and honoring `Retry-After` if present) and an overall bound after which the agent reports the job id and stops, rather than polling forever.
jercik marked this conversation as resolved
@ -0,0 +7,4 @@
});
const FastifyErrorBody = z.object({
message: z.string(),

🟢 Low: An error body with an empty message produces a message that is just a leading space plus the status. Both schemas accept z.string(), so {"message":""} at 503 returns " (HTTP 503)" and {"error":{"message":""}} at 502 returns " (HTTP 502)" (verified by calling readErrorMessage directly). That lands in the JSON envelope as "message": " (HTTP 503)" and on stderr as a line beginning with a space — strictly less informative than the tropkod request failed (HTTP 503) fallback it displaces, and it makes .error.message awkward to log.

Use z.string().min(1) on both message fields (line 5 and line 10) so an empty or missing message falls through to the generic status-only text.

🟢 **Low:** An error body with an empty `message` produces a message that is just a leading space plus the status. Both schemas accept `z.string()`, so `{"message":""}` at 503 returns `" (HTTP 503)"` and `{"error":{"message":""}}` at 502 returns `" (HTTP 502)"` (verified by calling `readErrorMessage` directly). That lands in the JSON envelope as `"message": " (HTTP 503)"` and on stderr as a line beginning with a space — strictly less informative than the `tropkod request failed (HTTP 503)` fallback it displaces, and it makes `.error.message` awkward to log. Use `z.string().min(1)` on both `message` fields (line 5 and line 10) so an empty or missing message falls through to the generic status-only text.
jercik marked this conversation as resolved
fix: reject fetch-blocked ports, document poll cadence and bound, drop empty error messages
All checks were successful
commit-msg / commitlint (pull_request) Successful in 13s
Checks / quality-checks (pull_request) Successful in 32s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 1m41s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 9m41s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 16s
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 9m58s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 12s
6ab48b1d12
Author
Owner

Round-5 feedback processed at 6ab48b1:

  • Fetch-blocked ports misclassified as exit 5 (Medium, read-remote-client-options.ts) — fixed at the parse boundary as suggested. The guard carries the full WHATWG bad-port list, copied verbatim from undici 8.7.0's lib/web/fetch/constants.js — the exact fetch implementation bundled in the Node this client targets — rather than from memory or a partial sample. Verified the original repro end-to-end: TROPKOD_URL=http://127.0.0.1:22 … --json "q" now prints {"error":{"kind":"usage","message":"--url or TROPKOD_URL uses a port that HTTP clients refuse to connect to","exit_code":3}} and exits 3. On the structural alternative (classifying pre-dispatch fetch TypeErrors as usage): it can't close this family, and the repro shows why — undici reports a bad port as fetch failed: bad port, a network-error shape per the fetch spec's own wording ("return a network error"), structurally indistinguishable from a real connection failure without the cause-string sniffing the finding rightly calls fragile. The parse boundary is the only place this can be caught deterministically, and with credentials, header values, scheme, query/fragment, and now ports all guarded there, the remaining fetch failed causes are genuine post-dispatch network operations — honest exit-5 territory. If the runtime's list ever grows past our copy, the failure degrades to today's exit 5, never anything worse.
  • Poll protocol had no cadence and no bound (Medium, README) — fixed. The Agent Rule now states: wait about 30 seconds between polls (a tight loop earns the 429 the table then says to poll through), bound the whole loop at about 35 minutes (the server's own analysis budget is 30), and the loop-ending conditions now include the bound expiring — a job still pending past it, or a service answering only exit 5/5xx for that long, means stop and report the job id. The Usage section's poll sentence carries the cadence too. 30 s rather than the suggested ~5 s: polls are cheap but analyses take minutes, so a faster cadence buys nothing while multiplying requests sixfold.
  • Empty error-body message renders " (HTTP 503)" (Low, read-error-message.ts) — fixed exactly as suggested: z.string().min(1) on both message fields, so an empty message falls through to the tropkod request failed (HTTP <status>) fallback. Literal tests cover both body shapes.
Round-5 feedback processed at `6ab48b1`: - **Fetch-blocked ports misclassified as exit `5` (Medium, `read-remote-client-options.ts`)** — fixed at the parse boundary as suggested. The guard carries the full WHATWG bad-port list, copied verbatim from undici 8.7.0's `lib/web/fetch/constants.js` — the exact fetch implementation bundled in the Node this client targets — rather than from memory or a partial sample. Verified the original repro end-to-end: `TROPKOD_URL=http://127.0.0.1:22 … --json "q"` now prints `{"error":{"kind":"usage","message":"--url or TROPKOD_URL uses a port that HTTP clients refuse to connect to","exit_code":3}}` and exits `3`. On the structural alternative (classifying pre-dispatch fetch `TypeError`s as usage): it can't close this family, and the repro shows why — undici reports a bad port as `fetch failed: bad port`, a network-error shape per the fetch spec's own wording ("return a network error"), structurally indistinguishable from a real connection failure without the cause-string sniffing the finding rightly calls fragile. The parse boundary is the only place this can be caught deterministically, and with credentials, header values, scheme, query/fragment, and now ports all guarded there, the remaining `fetch failed` causes are genuine post-dispatch network operations — honest exit-`5` territory. If the runtime's list ever grows past our copy, the failure degrades to today's exit `5`, never anything worse. - **Poll protocol had no cadence and no bound (Medium, README)** — fixed. The Agent Rule now states: wait about 30 seconds between polls (a tight loop earns the 429 the table then says to poll through), bound the whole loop at about 35 minutes (the server's own analysis budget is 30), and the loop-ending conditions now include the bound expiring — a job still pending past it, or a service answering only exit `5`/5xx for that long, means stop and report the job id. The Usage section's poll sentence carries the cadence too. 30 s rather than the suggested ~5 s: polls are cheap but analyses take minutes, so a faster cadence buys nothing while multiplying requests sixfold. - **Empty error-body message renders `" (HTTP 503)"` (Low, `read-error-message.ts`)** — fixed exactly as suggested: `z.string().min(1)` on both message fields, so an empty message falls through to the `tropkod request failed (HTTP <status>)` fallback. Literal tests cover both body shapes.
forgejo-actions left a comment

Summary: No actionable issues found.

I verified the change rather than reading it alone: installed the runtime deps in a scratch copy, ran the suite (11 files / 101 tests pass), and exercised src/cli.ts on Node 26 against stub servers for each documented path.

Observed behavior matched the README's contract in every case I could reach:

  • submit → exit 2 with the envelope; --job poll → exit 2; stdin submit → exit 2
  • commander unknown-option and rejected --wait-ms → exit 3, exactly one JSON doc on stdout, commander's prose on stderr only
  • --help / --version → exit 0, human text (documented exception)
  • 401 and 500 → exit 4 with status and the (HTTP n) suffix preserved on both
  • non-JSON 2xx → exit 6 without job_id; schema drift carrying job.id → exit 6 with job_id: "job-XYZ" recovered
  • ECONNREFUSED → exit 5 (fetch failed: connect ECONNREFUSED …, the AggregateError descent works); headers-then-stall 2xx → exit 5 after 30.2 s with the timeout message, confirming the mid-body abort claim in the exit-5 row
  • blocked ports (:1, :9) → exit 3 pre-dispatch, so they never masquerade as transport failures; the port list matches the WHATWG bad-port list exactly
  • missing --url / --api-key with a never-closing stdin fifo → exit 3 immediately, confirming the pre-stdin ordering in src/cli.ts:53
  • --job ../../etc, --job a/b → exit 3; encodeURIComponent plus the dot/slash guard closes the path-traversal surface (%2e%2e re-encodes to %252e%252e)

Two security-relevant points I checked specifically and found sound: a cross-origin redirect drops the Authorization header (verified with a redirect + sink server — the sink saw null), and the credential/header-validation errors in read-remote-client-options.ts are raised with a constant message and no cause, so neither the URL password nor the API key can reach stderr.

The readJsonFlagFromArgv false positive is real but correctly bounded: --session --json q is accepted by commander with --json as the session value, the run succeeds, and program.opts() (not the scan) governs output, so the scan only ever over-prints on a commander error path — as its comment states.

Code review by Claude Code Opus (opus)

**Summary:** No actionable issues found. I verified the change rather than reading it alone: installed the runtime deps in a scratch copy, ran the suite (11 files / 101 tests pass), and exercised `src/cli.ts` on Node 26 against stub servers for each documented path. Observed behavior matched the README's contract in every case I could reach: - submit → exit `2` with the envelope; `--job` poll → exit `2`; stdin submit → exit `2` - commander unknown-option and rejected `--wait-ms` → exit `3`, exactly one JSON doc on stdout, commander's prose on stderr only - `--help` / `--version` → exit `0`, human text (documented exception) - 401 and 500 → exit `4` with `status` and the `(HTTP n)` suffix preserved on both - non-JSON 2xx → exit `6` without `job_id`; schema drift carrying `job.id` → exit `6` with `job_id: "job-XYZ"` recovered - ECONNREFUSED → exit `5` (`fetch failed: connect ECONNREFUSED …`, the AggregateError descent works); headers-then-stall 2xx → exit `5` after 30.2 s with the timeout message, confirming the mid-body abort claim in the exit-5 row - blocked ports (`:1`, `:9`) → exit `3` pre-dispatch, so they never masquerade as transport failures; the port list matches the WHATWG bad-port list exactly - missing `--url` / `--api-key` with a never-closing stdin fifo → exit `3` immediately, confirming the pre-stdin ordering in `src/cli.ts:53` - `--job ../../etc`, `--job a/b` → exit `3`; `encodeURIComponent` plus the dot/slash guard closes the path-traversal surface (`%2e%2e` re-encodes to `%252e%252e`) Two security-relevant points I checked specifically and found sound: a cross-origin redirect drops the `Authorization` header (verified with a redirect + sink server — the sink saw `null`), and the credential/header-validation errors in `read-remote-client-options.ts` are raised with a constant message and no `cause`, so neither the URL password nor the API key can reach stderr. The `readJsonFlagFromArgv` false positive is real but correctly bounded: `--session --json q` is accepted by commander with `--json` as the session value, the run succeeds, and `program.opts()` (not the scan) governs output, so the scan only ever over-prints on a commander error path — as its comment states. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:3d709b0a-0579-4fc0-bc2b-dadca6456355 -->
forgejo-actions left a comment

Summary: Found 1 medium issue.

Code review by Codex GPT-5.6 SOL (gpt-5.6-sol)

**Summary:** Found 1 medium issue. _Code review by Codex GPT-5.6 SOL (gpt-5.6-sol)_ <!-- axrecipe-review:fdfba411-da8b-4a4e-b3df-7fa2f2018a0e -->
@ -20,0 +140,4 @@
const authorization = `Bearer ${apiKey}`;
try {
new Headers().set("authorization", authorization);

🟡 Medium: Headers#set is weaker than the validation undici applies when serializing a request. It accepts API keys containing U+007F and several C0 controls (for example U+0001), but fetch then rejects them with invalid authorization header before opening a socket. That path is classified as exit 5 / transport, so automation is told to assume a job may exist even though no HTTP request was dispatched; correcting and resubmitting would actually be safe. Use node:http's validateHeaderValue here (while continuing to discard the key-bearing cause) or explicitly reject the additional controls so these inputs remain exit 3 usage errors.

🟡 **Medium:** `Headers#set` is weaker than the validation undici applies when serializing a request. It accepts API keys containing `U+007F` and several C0 controls (for example `U+0001`), but `fetch` then rejects them with `invalid authorization header` before opening a socket. That path is classified as exit `5` / `transport`, so automation is told to assume a job may exist even though no HTTP request was dispatched; correcting and resubmitting would actually be safe. Use `node:http`'s `validateHeaderValue` here (while continuing to discard the key-bearing cause) or explicitly reject the additional controls so these inputs remain exit `3` usage errors.
jercik marked this conversation as resolved
fix: match undici header validation and classify pre-dispatch rejections as usage
All checks were successful
commit-msg / commitlint (pull_request) Successful in 13s
Checks / quality-checks (pull_request) Successful in 37s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 2m1s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 10m35s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 13s
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 11m5s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 13s
6a7c270e11
Author
Owner

Round-6 feedback processed at 6a7c270 — this one closes the whole failure family, not just the instance:

  • Headers#set laxer than undici's dispatch-time validation (Medium, read-remote-client-options.ts) — fixed at both layers.
    • Boundary: the probe is now node:http's validateHeaderValue, as suggested — verified empirically to reject exactly what undici's field-value check rejects (C0 controls including U+0001, U+007F, CR/LF, NUL) while accepting TAB and obs-text, where Headers#set accepted U+0001/U+007F. Constant message, still no cause. Verified end-to-end: a key containing 0x01 now prints {"error":{"kind":"usage","message":"--api-key or TROPKOD_API_KEY contains characters not allowed in an HTTP header","exit_code":3}} and exits 3.
    • Structural backstop: this was the fourth round in a row where a pre-dispatch fetch rejection surfaced as exit 5, so the family is now closed at the classification layer too. Probing the real rejection shapes showed the discriminator the earlier rounds lacked: undici tags every client-side-invalid request with code: "UND_ERR_INVALID_ARG" on the cause chain (TypeError: fetch failedInvalidArgumentError: invalid authorization header, constant message, no value interpolation) and rejects before any connection attempt, while genuine network failures carry ECONNREFUSED/ENOTFOUND-class codes. isRejectedBeforeDispatch walks the cause chain for that code and classifies the rejection as usage/exit 3 (no cause on the thrown error, keeping whatever undici found invalid out of every detail). Any future input shape undici validates more strictly than the boundary does now degrades to a truthful exit 3, not a false "assume a paid job exists". The boundary guards stay for their better messages; the backstop is the safety net the enumeration approach was missing. Exit-code table updated accordingly (exit 3 now names requests the HTTP client itself refuses to send before connecting).
Round-6 feedback processed at `6a7c270` — this one closes the whole failure family, not just the instance: - **`Headers#set` laxer than undici's dispatch-time validation (Medium, `read-remote-client-options.ts`)** — fixed at both layers. - *Boundary:* the probe is now `node:http`'s `validateHeaderValue`, as suggested — verified empirically to reject exactly what undici's field-value check rejects (C0 controls including `U+0001`, `U+007F`, CR/LF, NUL) while accepting TAB and obs-text, where `Headers#set` accepted `U+0001`/`U+007F`. Constant message, still no cause. Verified end-to-end: a key containing `0x01` now prints `{"error":{"kind":"usage","message":"--api-key or TROPKOD_API_KEY contains characters not allowed in an HTTP header","exit_code":3}}` and exits `3`. - *Structural backstop:* this was the fourth round in a row where a pre-dispatch fetch rejection surfaced as exit `5`, so the family is now closed at the classification layer too. Probing the real rejection shapes showed the discriminator the earlier rounds lacked: undici tags every client-side-invalid request with `code: "UND_ERR_INVALID_ARG"` on the cause chain (`TypeError: fetch failed` → `InvalidArgumentError: invalid authorization header`, constant message, no value interpolation) and rejects before any connection attempt, while genuine network failures carry `ECONNREFUSED`/`ENOTFOUND`-class codes. `isRejectedBeforeDispatch` walks the cause chain for that code and classifies the rejection as `usage`/exit `3` (no cause on the thrown error, keeping whatever undici found invalid out of every detail). Any future input shape undici validates more strictly than the boundary does now degrades to a truthful exit `3`, not a false "assume a paid job exists". The boundary guards stay for their better messages; the backstop is the safety net the enumeration approach was missing. Exit-code table updated accordingly (exit `3` now names requests the HTTP client itself refuses to send before connecting).
forgejo-actions left a comment

Summary: Reviewed the non-blocking default, exit-code partition, and JSON error envelopes. The implementation matches the documented contract; I found no correctness or security defects, only 2 minor items (1 low, 1 info).

What I verified empirically on Node v26 (no node_modules present, so the suite itself was not run):

  • A blocked port rejects as TypeError: fetch failed with cause Error: bad port and no code, so isRejectedBeforeDispatch cannot see it — the client-side blockedPorts set is genuinely needed, and its entries match the WHATWG bad-port list exactly.
  • A credential-bearing URL rejects with a TypeError that echoes the password in its message; rejecting those in readRemoteClientOptions before dispatch is what keeps that string out of readCauseChainMessage.
  • A DEL character in a header value passes Headers#set but rejects at dispatch as InvalidArgumentError/UND_ERR_INVALID_ARG (message invalid authorization header, no value echoed) — the validateHeaderValue pre-check and the usage classification are both justified.
  • DOMException is an instanceof Error in Node, so isAbortError correctly catches the TimeoutError from AbortSignal.timeout; a mid-body abort that surfaces as TypeError: terminated instead still lands on the transport fallback, so exit 5 holds either way.
  • new URL("v1/jobs/x", base) resolves under the base path once pathname is slash-terminated, and --job ids that are ./../contain / are the only ones encodeURIComponent would let collapse the path — all three are rejected.

The commander integration also checks out: showHelpAfterError(string) writes the hint to stderr (not full help to stdout), so the "exactly one JSON document on stdout" contract survives usage errors, and --help/--version throw CommanderError with exitCode === 0 as the catch assumes.

Code review by Claude Code Opus (opus)

**Summary:** Reviewed the non-blocking default, exit-code partition, and JSON error envelopes. The implementation matches the documented contract; I found no correctness or security defects, only 2 minor items (1 low, 1 info). What I verified empirically on Node v26 (no `node_modules` present, so the suite itself was not run): - A blocked port rejects as `TypeError: fetch failed` with cause `Error: bad port` and **no** `code`, so `isRejectedBeforeDispatch` cannot see it — the client-side `blockedPorts` set is genuinely needed, and its entries match the WHATWG bad-port list exactly. - A credential-bearing URL rejects with a `TypeError` that **echoes the password in its message**; rejecting those in `readRemoteClientOptions` before dispatch is what keeps that string out of `readCauseChainMessage`. - A DEL character in a header value passes `Headers#set` but rejects at dispatch as `InvalidArgumentError`/`UND_ERR_INVALID_ARG` (message `invalid authorization header`, no value echoed) — the `validateHeaderValue` pre-check and the `usage` classification are both justified. - `DOMException` is an `instanceof Error` in Node, so `isAbortError` correctly catches the `TimeoutError` from `AbortSignal.timeout`; a mid-body abort that surfaces as `TypeError: terminated` instead still lands on the `transport` fallback, so exit `5` holds either way. - `new URL("v1/jobs/x", base)` resolves under the base path once `pathname` is slash-terminated, and `--job` ids that are `.`/`..`/contain `/` are the only ones `encodeURIComponent` would let collapse the path — all three are rejected. The commander integration also checks out: `showHelpAfterError(string)` writes the hint to stderr (not full help to stdout), so the "exactly one JSON document on stdout" contract survives usage errors, and `--help`/`--version` throw `CommanderError` with `exitCode === 0` as the catch assumes. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:aefd6dcd-079d-4fa0-b5c3-77eec80c8b09 -->
README.md Outdated
@ -41,3 +51,3 @@
### JSON output
`--json` prints the full submission envelope. Combine with `jq` for scripting:
`--json` makes every invocation that reaches a verdict print exactly one JSON document on stdout: `.job` present means the server's submission envelope; `.error` present means the invocation failed. Two exceptions: (1) `--help`/`--version` print human text and exit `0`; (2) a crashed client exits with empty stdout — exit `7` from the bin wrapper's catch, or the runtime's own exit `1` on a crash outside the handlers. Empty stdout is never a verdict.

ℹ️ Info: .job present is described as "the server's submission envelope", but what --json prints is JSON.stringify of the Zod-parsed value: z.object strips unknown keys, and analysis's .nullish().transform(v => v ?? undefined) drops the key entirely when the server sends null (JSON.stringify omits undefined-valued keys — confirmed).

So a server that adds a field (say job.progress) sees it silently vanish from --json output rather than pass through. That's pre-existing behavior and arguably the right call given the exit-6 schema-drift contract, but "the server's submission envelope" reads as pass-through to an agent building on this. Something like "the parsed submission envelope (fields this client build knows)" would set the expectation correctly.

ℹ️ **Info:** `.job` present is described as "the server's submission envelope", but what `--json` prints is `JSON.stringify` of the *Zod-parsed* value: `z.object` strips unknown keys, and `analysis`'s `.nullish().transform(v => v ?? undefined)` drops the key entirely when the server sends `null` (`JSON.stringify` omits undefined-valued keys — confirmed). So a server that adds a field (say `job.progress`) sees it silently vanish from `--json` output rather than pass through. That's pre-existing behavior and arguably the right call given the exit-`6` schema-drift contract, but "the server's submission envelope" reads as pass-through to an agent building on this. Something like "the parsed submission envelope (fields this client build knows)" would set the expectation correctly.
jercik marked this conversation as resolved
@ -15,0 +109,4 @@
try {
parsed = new URL(url);
} catch (error) {
throw new CliError({ kind: "usage" }, "--url or TROPKOD_URL is not a valid URL", error);

🟢 Low: This is the one CliError in the file that keeps a credential-bearing cause. new URL() throws a TypeError whose input property holds the raw string, so --url 'https://user:hunter2@' (unparseable host, so it fails here rather than at the credentials check 12 lines below) parks the password in CliError.cause.input.

Nothing currently prints causes — only bin/tropkod-client's console.error(error) would, and that path isn't reachable from here — so the impact is latent. But it contradicts the deliberate policy stated twice in this same file (// No cause: keep the raw key out of every error detail, and the constant message on the credentials branch). Dropping the third argument costs nothing, since the message is already constant and the cause is never surfaced.

🟢 **Low:** This is the one `CliError` in the file that keeps a credential-bearing cause. `new URL()` throws a `TypeError` whose `input` property holds the raw string, so `--url 'https://user:hunter2@'` (unparseable host, so it fails here rather than at the credentials check 12 lines below) parks the password in `CliError.cause.input`. Nothing currently prints causes — only `bin/tropkod-client`'s `console.error(error)` would, and that path isn't reachable from here — so the impact is latent. But it contradicts the deliberate policy stated twice in this same file (`// No cause: keep the raw key out of every error detail`, and the constant message on the credentials branch). Dropping the third argument costs nothing, since the message is already constant and the cause is never surfaced.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Found 1 low-severity documentation gap. No correctness, security, or error-handling defects.

I verified the behavioral claims empirically rather than by reading alone: built the branch with TypeScript 7.0.2 (tsc -b --noEmit and tsc -p tsconfig.app.json both clean), ran the suite (12 files / 107 tests pass), and exercised the built bin/tropkod-client plus src/cli.ts against throwaway local HTTP servers.

Every exit code in the new README table matched observed behavior:

  • 0 --help/--version; 2 pending submit (envelope with job.id on stdout)
  • 3 missing --url, unparseable URL, credential-bearing URL (password not echoed), query/fragment, WHATWG-blocked port, header-invalid API key, --job ""/".."/"a/b", and every commander-originated usage error (unknown option, --wait-ms -1/300001/1e5/"")
  • 4 401 and 500 with (HTTP <status>) appended and status in the envelope
  • 5 ECONNREFUSED ("fetch failed: connect ECONNREFUSED …")
  • 6 non-JSON 2xx, and schema drift both with job_id: "job-ABC" recovered and without
  • 7 from the bin wrapper's catch (verified by removing dist/)

Also confirmed: the stdout contract holds under --json for commander errors (exactly one JSON document on stdout, commander's prose only on stderr, and the envelope message is commander's wording minus the (add --help for usage) hint, exactly as the README's caveat says); base-path URL joining is correct (http://h/base/api + --job job-42GET /base/api/v1/jobs/job-42, Authorization: Bearer k); the submit body is {"question","session_id"?,"wait_ms"} with wait_ms: 0 by default; moving readRemoteClientOptions above the stdin read really does avoid the open-pipe hang (process exits in ~0.1 s against a FIFO held open for 25 s); and AbortSignal.timeout(305000) does not keep the event loop alive after a fast response.

The blocked-port list matches the WHATWG bad-port list exactly, and the --job dot-segment/slash rejection correctly covers what encodeURIComponent leaves unescaped.

Code review by Claude Code Opus (opus)

**Summary:** Found 1 low-severity documentation gap. No correctness, security, or error-handling defects. I verified the behavioral claims empirically rather than by reading alone: built the branch with TypeScript 7.0.2 (`tsc -b --noEmit` and `tsc -p tsconfig.app.json` both clean), ran the suite (12 files / 107 tests pass), and exercised the built `bin/tropkod-client` plus `src/cli.ts` against throwaway local HTTP servers. Every exit code in the new README table matched observed behavior: - `0` `--help`/`--version`; `2` pending submit (envelope with `job.id` on stdout) - `3` missing `--url`, unparseable URL, credential-bearing URL (password not echoed), query/fragment, WHATWG-blocked port, header-invalid API key, `--job ""`/`".."`/`"a/b"`, and every commander-originated usage error (unknown option, `--wait-ms -1`/`300001`/`1e5`/`""`) - `4` 401 and 500 with `(HTTP <status>)` appended and `status` in the envelope - `5` `ECONNREFUSED` (`"fetch failed: connect ECONNREFUSED …"`) - `6` non-JSON 2xx, and schema drift both with `job_id: "job-ABC"` recovered and without - `7` from the bin wrapper's catch (verified by removing `dist/`) Also confirmed: the stdout contract holds under `--json` for commander errors (exactly one JSON document on stdout, commander's prose only on stderr, and the envelope `message` is commander's wording minus the `(add --help for usage)` hint, exactly as the README's caveat says); base-path URL joining is correct (`http://h/base/api` + `--job job-42` → `GET /base/api/v1/jobs/job-42`, `Authorization: Bearer k`); the submit body is `{"question","session_id"?,"wait_ms"}` with `wait_ms: 0` by default; moving `readRemoteClientOptions` above the stdin read really does avoid the open-pipe hang (process exits in ~0.1 s against a FIFO held open for 25 s); and `AbortSignal.timeout(305000)` does not keep the event loop alive after a fast response. The blocked-port list matches the WHATWG bad-port list exactly, and the `--job` dot-segment/slash rejection correctly covers what `encodeURIComponent` leaves unescaped. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:dc2c4ed1-c7b4-4f03-9e7c-0142568babb4 -->
README.md Outdated
@ -69,1 +88,3 @@
| `--json` | — | `false` | Print the full JSON envelope |
| Flag | Env fallback | Default | Notes |
| ----------------- | ----------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | `TROPKOD_URL` | none (required) | tropkod service URL; the hosted deployment is `https://api.tropkod.ai`; must not include credentials, a query, or a fragment |

🟢 Low: This row enumerates the --url rejection rules ("must not include credentials, a query, or a fragment") but omits two more that readRemoteClientOptions also enforces with exit 3: a non-http(s) scheme (--url or TROPKOD_URL must be an http(s) URL) and any port on the WHATWG bad-port list (--url or TROPKOD_URL uses a port that HTTP clients refuse to connect to).

The port rule is the one that can surprise a self-hoster: --url http://host:6000 or :10080 — both plausible service ports — fail before any request with a message that has no counterpart in the docs. Since the README is written as the exhaustive agent-facing contract and this row already lists the other constraints inline, worth appending them, e.g. ; must be an http(s) URL with no credentials, query, or fragment, on a port fetch will connect to.

🟢 **Low:** This row enumerates the `--url` rejection rules ("must not include credentials, a query, or a fragment") but omits two more that `readRemoteClientOptions` also enforces with exit `3`: a non-`http(s)` scheme (`--url or TROPKOD_URL must be an http(s) URL`) and any port on the WHATWG bad-port list (`--url or TROPKOD_URL uses a port that HTTP clients refuse to connect to`). The port rule is the one that can surprise a self-hoster: `--url http://host:6000` or `:10080` — both plausible service ports — fail before any request with a message that has no counterpart in the docs. Since the README is written as the exhaustive agent-facing contract and this row already lists the other constraints inline, worth appending them, e.g. `; must be an http(s) URL with no credentials, query, or fragment, on a port fetch will connect to`.
jercik marked this conversation as resolved
fix: drop credential-bearing URL-parse cause, complete --url and .job docs
All checks were successful
commit-msg / commitlint (pull_request) Successful in 19s
Checks / quality-checks (pull_request) Successful in 56s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 2m40s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 12m19s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 13s
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 13m36s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 19s
f001f0187f
Author
Owner

Round-7 feedback processed at f001f01:

  • URL-parse CliError keeps a credential-bearing cause (Low, read-remote-client-options.ts) — fixed; the third argument is dropped, with a comment recording why (new URL()'s TypeError carries the raw string in its input property). A test now covers the exact scenario from the finding: https://user:hunter2@ throws the constant message with detail: usage and cause undefined. Every CliError in the file is now cause-free.
  • .job described as "the server's submission envelope" (Info, README) — reworded as suggested: the JSON-output section now says "the parsed submission envelope (the fields this client build knows — unknown server keys are stripped, and a null analysis is omitted)", so nobody builds on pass-through expectations.
  • --url row omits the scheme and bad-port rules (Low, README) — completed with the suggested inline shape: the row now reads "must be an http(s) URL with no credentials, query, or fragment, on a port fetch will connect to (the WHATWG bad-port list is rejected client-side)" — the port rule a self-hoster on :6000/:10080 would otherwise meet undocumented.
Round-7 feedback processed at `f001f01`: - **URL-parse `CliError` keeps a credential-bearing cause (Low, `read-remote-client-options.ts`)** — fixed; the third argument is dropped, with a comment recording why (`new URL()`'s `TypeError` carries the raw string in its `input` property). A test now covers the exact scenario from the finding: `https://user:hunter2@` throws the constant message with `detail: usage` and `cause` undefined. Every `CliError` in the file is now cause-free. - **`.job` described as "the server's submission envelope" (Info, README)** — reworded as suggested: the JSON-output section now says "the parsed submission envelope (the fields this client build knows — unknown server keys are stripped, and a `null` `analysis` is omitted)", so nobody builds on pass-through expectations. - **`--url` row omits the scheme and bad-port rules (Low, README)** — completed with the suggested inline shape: the row now reads "must be an `http(s)` URL with no credentials, query, or fragment, on a port fetch will connect to (the WHATWG bad-port list is rejected client-side)" — the port rule a self-hoster on `:6000`/`:10080` would otherwise meet undocumented.
forgejo-actions left a comment

Summary: Found 1 low-severity issue. The exit-code taxonomy, JSON envelopes, and README claims hold up under direct testing.

I ran the head revision against throwaway local servers (Node 26, commander 15, zod 4) and confirmed the documented contract end to end:

  • exit 0 for --help/--version (human text even under --json), exit 2 for a pending submit, exit 3 for missing --url, unknown options, --wait-ms 300001, blocked ports (:22), credential-bearing URLs, --job .., and a newline-bearing API key; exit 4 for 401/500/404 with status in the envelope; exit 5 for ECONNREFUSED, ENOTFOUND, and a mid-body stall on a 2xx (tropkod request timed out after 30000ms); exit 6 for a non-JSON 2xx and for schema drift, with job_id recovered from {"job":{"id":…}}.
  • Under --json, stdout carried exactly one document in every failing case, including commander-originated errors where commander's own prose went only to stderr — matching the README's caveat.
  • The exit-3 invariant ("no HTTP request was dispatched") holds at every { kind: "usage" } construction site: URL/key parsing, argument resolution, stdin collection, and the undici UND_ERR_INVALID_ARG pre-dispatch branch.
  • Base-path joining is correct: --url http://h/proxy --job "job 42" produced GET /proxy/v1/jobs/job%2042, and --url http://h/deep/base produced POST /deep/base/v1/queries. The ./..// job-id rejection is load-bearing, since encodeURIComponent leaves dots unescaped and new URL would collapse the path.
  • Reading --url/--api-key before stdin does prevent the hang: an open pipe with no TROPKOD_URL exited 3 immediately.
  • Closed or directory stdin does not escape the usage classification; Node substitutes a readable stream, and the invocation exits 3.
  • tsc -p tsconfig.app.json --noEmit is clean and the suite passes (108 tests, 12 files).

No credential leaks: every URL/key rejection uses a constant message and drops the cause, and the port list matches the current WHATWG bad-port set exactly.

Code review by Claude Code Opus (opus)

**Summary:** Found 1 low-severity issue. The exit-code taxonomy, JSON envelopes, and README claims hold up under direct testing. I ran the head revision against throwaway local servers (Node 26, commander 15, zod 4) and confirmed the documented contract end to end: - exit `0` for `--help`/`--version` (human text even under `--json`), exit `2` for a pending submit, exit `3` for missing `--url`, unknown options, `--wait-ms 300001`, blocked ports (`:22`), credential-bearing URLs, `--job ..`, and a newline-bearing API key; exit `4` for 401/500/404 with `status` in the envelope; exit `5` for ECONNREFUSED, ENOTFOUND, and a mid-body stall on a 2xx (`tropkod request timed out after 30000ms`); exit `6` for a non-JSON 2xx and for schema drift, with `job_id` recovered from `{"job":{"id":…}}`. - Under `--json`, stdout carried exactly one document in every failing case, including commander-originated errors where commander's own prose went only to stderr — matching the README's caveat. - The exit-`3` invariant ("no HTTP request was dispatched") holds at every `{ kind: "usage" }` construction site: URL/key parsing, argument resolution, stdin collection, and the undici `UND_ERR_INVALID_ARG` pre-dispatch branch. - Base-path joining is correct: `--url http://h/proxy --job "job 42"` produced `GET /proxy/v1/jobs/job%2042`, and `--url http://h/deep/base` produced `POST /deep/base/v1/queries`. The `.`/`..`/`/` job-id rejection is load-bearing, since `encodeURIComponent` leaves dots unescaped and `new URL` would collapse the path. - Reading `--url`/`--api-key` before stdin does prevent the hang: an open pipe with no `TROPKOD_URL` exited `3` immediately. - Closed or directory stdin does not escape the usage classification; Node substitutes a readable stream, and the invocation exits `3`. - `tsc -p tsconfig.app.json --noEmit` is clean and the suite passes (108 tests, 12 files). No credential leaks: every URL/key rejection uses a constant message and drops the `cause`, and the port list matches the current WHATWG bad-port set exactly. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:50cadb24-4b75-4b52-9fa7-35473f1dd82e -->
@ -0,0 +10,4 @@
const parsed = Number(value);
if (parsed > maxWaitMs) {
throw new InvalidArgumentError(`expected a value between 0 and 300000, received "${value}"`);

🟢 Low: The range message hard-codes 300000 while the bound itself lives in maxWaitMs on line 4, so the two can drift silently — a future cap change would keep telling users "between 0 and 300000" while rejecting at a different value. Interpolate the constant instead:

throw new InvalidArgumentError(
  `expected a value between 0 and ${String(maxWaitMs)}, received "${value}"`,
);

The wording stays byte-identical today, so the existing assertions in parse-wait-ms-option.test.ts keep passing.

🟢 **Low:** The range message hard-codes `300000` while the bound itself lives in `maxWaitMs` on line 4, so the two can drift silently — a future cap change would keep telling users "between 0 and 300000" while rejecting at a different value. Interpolate the constant instead: ```ts throw new InvalidArgumentError( `expected a value between 0 and ${String(maxWaitMs)}, received "${value}"`, ); ``` The wording stays byte-identical today, so the existing assertions in `parse-wait-ms-option.test.ts` keep passing.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Reviewed the exit-code/JSON-envelope rework end to end and exercised the built CLI against throwaway local servers. The taxonomy holds up: verified exit 0 (help/version), 2 (pending submit and poll), 3 (missing URL, blocked port, bad --job, commander usage errors), 4 (401 body, and a 503 whose body stalls — still exit 4 after the abort, as documented), 5 (ECONNREFUSED, ENOTFOUND, and an abort mid-body of a stalled 2xx), and 6 (non-JSON 2xx, schema drift with and without a recoverable job.id). The --json stdout contract, the argv --json scan on commander errors, base-path joining, unknown-key stripping, and the pre-stdin URL check (an open pipe still exits 3 immediately) all behave as the README states. I also swept all 65535 ports through fetch: the blockedPorts set matches the runtime's bad-port set exactly (82/82, no diff in either direction). Test suite passes (108 tests) and tsc -p tsconfig.app.json is clean.

Found 2 low-severity issues; nothing blocking.

Code review by Claude Code Opus (opus)

**Summary:** Reviewed the exit-code/JSON-envelope rework end to end and exercised the built CLI against throwaway local servers. The taxonomy holds up: verified exit `0` (help/version), `2` (pending submit and poll), `3` (missing URL, blocked port, bad `--job`, commander usage errors), `4` (401 body, and a 503 whose body stalls — still exit `4` after the abort, as documented), `5` (ECONNREFUSED, ENOTFOUND, and an abort mid-body of a stalled 2xx), and `6` (non-JSON 2xx, schema drift with and without a recoverable `job.id`). The `--json` stdout contract, the argv `--json` scan on commander errors, base-path joining, unknown-key stripping, and the pre-stdin URL check (an open pipe still exits `3` immediately) all behave as the README states. I also swept all 65535 ports through `fetch`: the `blockedPorts` set matches the runtime's bad-port set exactly (82/82, no diff in either direction). Test suite passes (108 tests) and `tsc -p tsconfig.app.json` is clean. Found 2 low-severity issues; nothing blocking. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:826d94eb-7b01-4300-bdc2-1b776c163b81 -->
@ -0,0 +30,4 @@
return { kind: "usage", message: error.message };
}
if (error instanceof Error) {
return { kind: "unexpected", message: error.message };

🟢 Low: An unexpected failure keeps only error.message, so the exit 7 path the README describes as "a bug in this CLI — report the bug" surfaces a bare one-liner with no stack and no cause chain. A TypeError: Cannot read properties of undefined on stderr gives whoever hits it nothing to file. The stdout --json envelope shape does not need to change; writing the original error (e.g. console.error(error), as bin/tropkod-client already does for a load failure) to stderr for this kind alone would keep the verdict contract intact while making the report actionable.

🟢 **Low:** An `unexpected` failure keeps only `error.message`, so the exit `7` path the README describes as "a bug in this CLI — report the bug" surfaces a bare one-liner with no stack and no cause chain. A `TypeError: Cannot read properties of undefined` on stderr gives whoever hits it nothing to file. The stdout `--json` envelope shape does not need to change; writing the original error (e.g. `console.error(error)`, as `bin/tropkod-client` already does for a load failure) to stderr for this kind alone would keep the verdict contract intact while making the report actionable.
jercik marked this conversation as resolved
@ -24,3 +26,2 @@
if (options.job !== undefined) {
if (questionParts.length > 0 || stdinQuestion.length > 0) {
throw new Error("pass either a question or --job, not both");
const jobId = options.job.trim();

🟢 Low: --job now gets trimmed and rejected when blank, but --session is still passed through untouched, so --session " " is serialized verbatim into the submit body. Confirmed against a local server: body={"question":"q","session_id":" ","wait_ms":0}. That spends a real request to earn an exit 4 (or, if the service is lenient, silently queues a paid analysis under a whitespace session id) where the same input on --job is caught client-side as exit 3. Trimming options.session and rejecting an empty result with a CliError({ kind: "usage" }) would make the two id-bearing flags consistent.

🟢 **Low:** `--job` now gets trimmed and rejected when blank, but `--session` is still passed through untouched, so `--session " "` is serialized verbatim into the submit body. Confirmed against a local server: `body={"question":"q","session_id":" ","wait_ms":0}`. That spends a real request to earn an exit `4` (or, if the service is lenient, silently queues a paid analysis under a whitespace session id) where the same input on `--job` is caught client-side as exit `3`. Trimming `options.session` and rejecting an empty result with a `CliError({ kind: "usage" })` would make the two id-bearing flags consistent.
jercik marked this conversation as resolved
fix: validate --session like --job, interpolate wait-ms cap, dump exit-7 errors to stderr
All checks were successful
commit-msg / commitlint (pull_request) Successful in 36s
Checks / quality-checks (pull_request) Successful in 1m3s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 2m41s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 9m55s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 14s
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 12m22s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 13s
3b0a8e3ffe
Author
Owner

Round-8 feedback processed at 3b0a8e3 — all three fixed:

  • --session " " serialized verbatim into the submit body (Low, resolve-submission-request.ts) — fixed as suggested: --session is now trimmed and a whitespace-only value throws --session requires a session id (usage, exit 3) before any request, making the two id-bearing flags consistent. The check lives in the question branch only, so the documented "--session is ignored when --job is given" behavior is preserved — a test pins all three cases (trimmed, rejected-blank, ignored-alongside-job).
  • Hard-coded 300000 in the range message (Low, parse-wait-ms-option.ts) — fixed with the exact suggested interpolation; wording is byte-identical today and the existing test assertions pass unchanged.
  • Exit-7 surfaces a bare one-liner (Low, classify-failure.ts) — fixed per the suggestion: after emitting the normal envelope/stderr lines, the CLI now console.errors the original error — stack and cause chain included — for the unexpected kind alone, in both modes. The stdout --json contract is untouched (still exactly one JSON document), and the README's JSON-output section documents the extra stderr output so it can't read as contract drift.
Round-8 feedback processed at `3b0a8e3` — all three fixed: - **`--session " "` serialized verbatim into the submit body (Low, `resolve-submission-request.ts`)** — fixed as suggested: `--session` is now trimmed and a whitespace-only value throws `--session requires a session id` (usage, exit `3`) before any request, making the two id-bearing flags consistent. The check lives in the question branch only, so the documented "`--session` is ignored when `--job` is given" behavior is preserved — a test pins all three cases (trimmed, rejected-blank, ignored-alongside-job). - **Hard-coded `300000` in the range message (Low, `parse-wait-ms-option.ts`)** — fixed with the exact suggested interpolation; wording is byte-identical today and the existing test assertions pass unchanged. - **Exit-`7` surfaces a bare one-liner (Low, `classify-failure.ts`)** — fixed per the suggestion: after emitting the normal envelope/stderr lines, the CLI now `console.error`s the original error — stack and cause chain included — for the `unexpected` kind alone, in both modes. The stdout `--json` contract is untouched (still exactly one JSON document), and the README's JSON-output section documents the extra stderr output so it can't read as contract drift.
forgejo-actions left a comment

Summary: No blocking defects found. Verified the new exit-code taxonomy and JSON envelope contract empirically against throwaway HTTP servers (401, 500 with a refusal-shaped body, non-JSON 2xx, schema-drift 2xx with a recoverable job id, a 2xx truncated mid-body, and a 503 whose error body never completes), plus commander paths (--help, -V, unknown option, rejected --wait-ms, --json=true), the missing---url/--api-key ordering ahead of the stdin read, base-path preservation (--url http://h/base -> POST /base/v1/queries), and --job segment validation. Every observed exit code and stdout shape matched the README's tables. The full suite passes (12 files, 111 tests).

Two low-severity notes below, both about the "could a job have been created?" claims rather than the implementation.

Code review by Claude Code Opus (opus)

**Summary:** No blocking defects found. Verified the new exit-code taxonomy and JSON envelope contract empirically against throwaway HTTP servers (401, 500 with a refusal-shaped body, non-JSON 2xx, schema-drift 2xx with a recoverable job id, a 2xx truncated mid-body, and a 503 whose error body never completes), plus commander paths (`--help`, `-V`, unknown option, rejected `--wait-ms`, `--json=true`), the missing-`--url`/`--api-key` ordering ahead of the stdin read, base-path preservation (`--url http://h/base` -> `POST /base/v1/queries`), and `--job` segment validation. Every observed exit code and stdout shape matched the README's tables. The full suite passes (12 files, 111 tests). Two low-severity notes below, both about the "could a job have been created?" claims rather than the implementation. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:d12ae2c9-5532-4c39-ad19-dca3942ea358 -->
README.md Outdated
@ -80,0 +109,4 @@
| `6` | Invalid response — 2xx received but the body was not a parseable submission envelope. | Yes — id recovered iff the envelope carries `.error.job_id` (the body held a readable `job.id`); lost otherwise | Job untouched. A transient blip (truncated body, proxy garbling) recovers — retry once or twice. A repeated exit `6` with an identical message is deterministic schema drift, terminal for this client build: the job has likely already ended in a shape this client cannot render — report it, don't keep looping |
| `7` | Unexpected client error (a bug in this CLI). | Unknown — assume yes | Job untouched — report the bug; polling the same id stays safe |
After a failed **submit**, resubmitting is safe iff `code == 3 || (code == 4 && status < 500)` — and only when every earlier submit in the task is accounted for. A failed **poll** never licenses a resubmit: `GET /v1/jobs/<id>` can neither create nor destroy a job — repair the input and keep polling.

🟢 Low: The resubmit predicate treats every 4xx as proof that no job was created, but the client cannot distinguish a 4xx minted by the tropkod service from one minted by an intermediary after it forwarded the POST. A reverse proxy or gateway that gives up on a slow upstream can answer 408 (and some edge networks synthesize other 4xx codes) once the request has already reached the service and created a paid job. The exit-code table already carves out this exact reasoning for 5xx ("a proxy may mask an accepted request") but applies the opposite assumption to the whole 4xx range, so code == 4 && status < 500 can license a duplicate paid analysis — the failure mode this PR exists to prevent.

Suggest narrowing the predicate to the statuses that are provably pre-dispatch verdicts, e.g. code == 3 || (code == 4 && status < 500 && status != 408), and mirroring the carve-out in the 4 row's "On a submit: job created?" cell.

🟢 **Low:** The resubmit predicate treats every 4xx as proof that no job was created, but the client cannot distinguish a 4xx minted by the tropkod service from one minted by an intermediary *after* it forwarded the POST. A reverse proxy or gateway that gives up on a slow upstream can answer `408` (and some edge networks synthesize other 4xx codes) once the request has already reached the service and created a paid job. The exit-code table already carves out this exact reasoning for 5xx ("a proxy may mask an accepted request") but applies the opposite assumption to the whole 4xx range, so `code == 4 && status < 500` can license a duplicate paid analysis — the failure mode this PR exists to prevent. Suggest narrowing the predicate to the statuses that are provably pre-dispatch verdicts, e.g. `code == 3 || (code == 4 && status < 500 && status != 408)`, and mirroring the carve-out in the `4` row's "On a submit: job created?" cell.
jercik marked this conversation as resolved
@ -0,0 +9,4 @@
let depth = 0;
while (current instanceof Error && depth < maxCauseDepth) {
if ((current as { code?: unknown }).code === invalidArgumentCode) {

ℹ️ Info: This maps any UND_ERR_INVALID_ARG found in the cause chain to usage/exit 3, which the README documents as provably no job created. That holds for the rejections this guards against, and readRemoteClientOptions already rejects the known triggers (bad port, credentials in the URL, header-invalid key) at parse time — so what actually reaches this branch is, by construction, an invalid-argument shape nobody enumerated. If one of those ever arises after bytes hit the wire (e.g. undici re-dispatching a redirect hop for a POST that the service already accepted), exit 3 tells the agent rule it is safe to resubmit a paid analysis. Downgrading the unrecognized residue to transport (exit 5, "assume a job exists") would fail safe instead; keeping exit 3 is defensible, just worth being a deliberate choice.

ℹ️ **Info:** This maps any `UND_ERR_INVALID_ARG` found in the cause chain to `usage`/exit `3`, which the README documents as *provably* no job created. That holds for the rejections this guards against, and `readRemoteClientOptions` already rejects the known triggers (bad port, credentials in the URL, header-invalid key) at parse time — so what actually reaches this branch is, by construction, an invalid-argument shape nobody enumerated. If one of those ever arises after bytes hit the wire (e.g. undici re-dispatching a redirect hop for a POST that the service already accepted), exit `3` tells the agent rule it is safe to resubmit a paid analysis. Downgrading the *unrecognized* residue to `transport` (exit `5`, "assume a job exists") would fail safe instead; keeping exit `3` is defensible, just worth being a deliberate choice.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: No correctness, security, or error-handling defects found. One low-severity documentation gap.

I verified the load-bearing runtime claims against Node 26.5 rather than taking them on trust:

  • Blocked-port list is exact. Scanned ports 1–10100 with fetch and diffed the results against the hardcoded set in read-remote-client-options.ts: 82 ports blocked by undici, 82 in the list, zero in either direction. The scan also confirmed the premise for the list — undici's bad-port rejection is TypeError: fetch failed with cause Error: bad port and no code property, so isRejectedBeforeDispatch genuinely cannot see it and the parse-time check is what keeps exit 3.
  • Abort classification. A timeout during headers rejects fetch with a DOMException named TimeoutError; a timeout firing mid-body rejects response.json() with the same shape (and DOMException extends Error in Node, so isAbortError matches both). The response.text().catch(() => "") on the non-2xx path therefore degrades to a status-only message and keeps exit 4, as documented.
  • Header pre-validation. validateHeaderValue rejects LF/CR/NUL/C0/DEL and accepts interior tab; since apiKey is trim()ed (which also strips NBSP), a leading/trailing space or tab — the case undici rejects but node:http allows — is unreachable. The stricter-check comment holds, and no error path can echo the key.
  • Base-URL joining. pathname += "/" plus new URL(path.slice(1), base) resolves under a base path instead of replacing it, and URL normalizes :0080/:022 before the string-keyed port lookup.
  • Job-id path safety. Because encodeURIComponent escapes %, the only inputs that can produce a WHATWG dot-segment are literal . and .., both rejected; %2e%2e encodes to %252e%252e and stays one segment.
  • Bin wrapper. Reproduced the extensionless-entry + dynamic-import case: a module-load throw is caught and the process exits 7.

Exit-code contract review: every exit-3 path (option parsing, readRemoteClientOptions, resolveSubmissionRequest, collectStdinText, commander via exitOverride, isRejectedBeforeDispatch) precedes dispatch, so "no HTTP request was sent" holds. Nothing reaches exit 7 from a server-caused condition — every network/parse failure is wrapped in CliError, and QuerySubmission/recoverJobId/readErrorMessage all use safeParse, so a ZodError cannot escape as unexpected. The commander caveat in the README is accurate: Command#error writes the showHelpAfterError hint to stderr separately and does not include it in the CommanderError message.

Code review by Claude Code Opus (opus)

**Summary:** No correctness, security, or error-handling defects found. One low-severity documentation gap. I verified the load-bearing runtime claims against Node 26.5 rather than taking them on trust: - **Blocked-port list is exact.** Scanned ports 1–10100 with `fetch` and diffed the results against the hardcoded set in `read-remote-client-options.ts`: 82 ports blocked by undici, 82 in the list, zero in either direction. The scan also confirmed the premise for the list — undici's bad-port rejection is `TypeError: fetch failed` with cause `Error: bad port` and **no `code` property**, so `isRejectedBeforeDispatch` genuinely cannot see it and the parse-time check is what keeps exit `3`. - **Abort classification.** A timeout during headers rejects `fetch` with a `DOMException` named `TimeoutError`; a timeout firing mid-body rejects `response.json()` with the same shape (and `DOMException extends Error` in Node, so `isAbortError` matches both). The `response.text().catch(() => "")` on the non-2xx path therefore degrades to a status-only message and keeps exit `4`, as documented. - **Header pre-validation.** `validateHeaderValue` rejects LF/CR/NUL/C0/DEL and accepts interior tab; since `apiKey` is `trim()`ed (which also strips NBSP), a leading/trailing space or tab — the case undici rejects but `node:http` allows — is unreachable. The stricter-check comment holds, and no error path can echo the key. - **Base-URL joining.** `pathname += "/"` plus `new URL(path.slice(1), base)` resolves under a base path instead of replacing it, and `URL` normalizes `:0080`/`:022` before the string-keyed port lookup. - **Job-id path safety.** Because `encodeURIComponent` escapes `%`, the only inputs that can produce a WHATWG dot-segment are literal `.` and `..`, both rejected; `%2e%2e` encodes to `%252e%252e` and stays one segment. - **Bin wrapper.** Reproduced the extensionless-entry + dynamic-import case: a module-load throw is caught and the process exits `7`. Exit-code contract review: every exit-`3` path (option parsing, `readRemoteClientOptions`, `resolveSubmissionRequest`, `collectStdinText`, commander via `exitOverride`, `isRejectedBeforeDispatch`) precedes dispatch, so "no HTTP request was sent" holds. Nothing reaches exit `7` from a server-caused condition — every network/parse failure is wrapped in `CliError`, and `QuerySubmission`/`recoverJobId`/`readErrorMessage` all use `safeParse`, so a ZodError cannot escape as `unexpected`. The commander caveat in the README is accurate: `Command#error` writes the `showHelpAfterError` hint to stderr separately and does not include it in the `CommanderError` message. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:7197d4e4-7ab4-4153-b69f-aabb739d925e -->
@ -18,2 +19,3 @@
tropkod-client --json "Does npm:fastify@5.6.0 expose schemaCompiler?"
# Read the question from stdin
# Poll while the exit code is 2; branch per the README's exit-code table otherwise

🟢 Low: This epilog points at "the README's exit-code table," but the help output never carries that table, and this PR is the one that makes exit codes the primary contract — the README's own Agent Rule tells an agent to run --help before first use and then to "branch on exit codes 37." An agent that discovers the tool through --help alone gets a dangling reference.

A compact list in the same addHelpText("after", ...) block closes the loop without duplicating the whole table, e.g.:

Exit codes:
  0 completed  1 job failed  2 pending/running  3 usage (nothing sent)
  4 HTTP error 5 transport   6 invalid response 7 client bug
🟢 **Low:** This epilog points at "the README's exit-code table," but the help output never carries that table, and this PR is the one that makes exit codes the primary contract — the README's own Agent Rule tells an agent to run `--help` before first use and then to "branch on exit codes `3`–`7`." An agent that discovers the tool through `--help` alone gets a dangling reference. A compact list in the same `addHelpText("after", ...)` block closes the loop without duplicating the whole table, e.g.: ``` Exit codes: 0 completed 1 job failed 2 pending/running 3 usage (nothing sent) 4 HTTP error 5 transport 6 invalid response 7 client bug ```
jercik marked this conversation as resolved
fix: 408 resubmit carve-out, fail-safe pre-dispatch residue, exit-code help
All checks were successful
commit-msg / commitlint (pull_request) Successful in 13s
Checks / quality-checks (pull_request) Successful in 34s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 2m36s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 12m10s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 17s
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 12m27s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 11s
4fe7d422c5
Author
Owner

Round-9 disposition (fixes in 4fe7d42):

  • 31659 (README resubmit predicate vs 408) — fixed as suggested. The predicate now reads code == 3 || (code == 4 && status < 500 && status != 408), the exit-4 submit cell says "4xx except 408: no. 408 and 5xx: unknown (an intermediary may mask an accepted request)", and a sentence explains the carve-out: an intermediary can mint a 408 after forwarding the POST.
  • 31660 (pre-dispatch backstop overclaims for unrecognized residue) — fixed, beyond the suggested acknowledgment: is-rejected-before-dispatch.ts and its tests are deleted. The reviewer's fail-safe argument is exactly the PR's own stated principle — a false "safe to resubmit" costs a duplicate paid analysis, a false "report and stop" costs one wasted stop — and the backstop traded the cardinal failure for the tolerable one at precisely the margin where neither is provable. Every known trigger of UND_ERR_INVALID_ARG (C0/DEL in the key) is already caught at parse time by validateHeaderValue, so the branch was unreachable for known inputs and only ever fired on residue we cannot prove pre-dispatch (e.g. rejections after a redirect hop). The fetch catch now classifies all unrecognized refusals as transport (exit 5, "assume a job exists"), with a comment naming that as the deliberate default.
  • 31677 (help lacks exit-code summary) — fixed with the suggested legend: --help now ends with a two-line exit-code table pointing at the README for the full version.

All three conversations resolved.

Round-9 disposition (fixes in 4fe7d42): - **31659 (README resubmit predicate vs 408)** — fixed as suggested. The predicate now reads `code == 3 || (code == 4 && status < 500 && status != 408)`, the exit-4 submit cell says "4xx except 408: no. 408 and 5xx: unknown (an intermediary may mask an accepted request)", and a sentence explains the carve-out: an intermediary can mint a 408 after forwarding the POST. - **31660 (pre-dispatch backstop overclaims for unrecognized residue)** — fixed, beyond the suggested acknowledgment: `is-rejected-before-dispatch.ts` and its tests are deleted. The reviewer's fail-safe argument is exactly the PR's own stated principle — a false "safe to resubmit" costs a duplicate paid analysis, a false "report and stop" costs one wasted stop — and the backstop traded the cardinal failure for the tolerable one at precisely the margin where neither is provable. Every known trigger of `UND_ERR_INVALID_ARG` (C0/DEL in the key) is already caught at parse time by `validateHeaderValue`, so the branch was unreachable for known inputs and only ever fired on residue we cannot prove pre-dispatch (e.g. rejections after a redirect hop). The fetch catch now classifies all unrecognized refusals as `transport` (exit 5, "assume a job exists"), with a comment naming that as the deliberate default. - **31677 (help lacks exit-code summary)** — fixed with the suggested legend: `--help` now ends with a two-line exit-code table pointing at the README for the full version. All three conversations resolved.
forgejo-actions left a comment

Summary: Found 1 medium issue (documentation self-contradiction). The implementation itself held up under verification.

What I verified (sandbox copy with deps installed; the repo working tree was left untouched):

  • tsc -p tsconfig.app.json --noEmit passes clean.
  • vitest run: 11 files, 106 tests, all passing.
  • End-to-end runs against throwaway local HTTP servers confirmed each documented exit code and envelope: missing --url3; unknown option / bad --wait-ms / over-cap --wait-ms3 with commander prose on stderr and the stdout envelope; pending submit → 2 with analysis omitted; 401/5004 with the (HTTP <status>) suffix preserved; non-JSON 2xx → 6; schema drift carrying job.id6 with .error.job_id recovered; body truncated mid-stream → 5; connection refused → 5 with the flattened cause chain; --version0.
  • --job path-segment rejection ("job 1/x"3) and base-URL joining (http://h/apihttp://h/api/v1/queries, .../v1/jobs/<encoded>) behave as intended.
  • The validateHeaderValue pre-dispatch guard does not over-reject: keys containing \t or U+00E9 pass locally and through real fetch, while DEL and astral code points are rejected pre-dispatch (exit 3), so no provably-no-job case is misrouted to exit 5.

Code review by Claude Code Opus (opus)

**Summary:** Found 1 medium issue (documentation self-contradiction). The implementation itself held up under verification. **What I verified** (sandbox copy with deps installed; the repo working tree was left untouched): - `tsc -p tsconfig.app.json --noEmit` passes clean. - `vitest run`: 11 files, 106 tests, all passing. - End-to-end runs against throwaway local HTTP servers confirmed each documented exit code and envelope: missing `--url` → `3`; unknown option / bad `--wait-ms` / over-cap `--wait-ms` → `3` with commander prose on stderr *and* the stdout envelope; pending submit → `2` with `analysis` omitted; `401`/`500` → `4` with the `(HTTP <status>)` suffix preserved; non-JSON 2xx → `6`; schema drift carrying `job.id` → `6` with `.error.job_id` recovered; body truncated mid-stream → `5`; connection refused → `5` with the flattened cause chain; `--version` → `0`. - `--job` path-segment rejection (`"job 1/x"` → `3`) and base-URL joining (`http://h/api` → `http://h/api/v1/queries`, `.../v1/jobs/<encoded>`) behave as intended. - The `validateHeaderValue` pre-dispatch guard does not over-reject: keys containing `\t` or U+00E9 pass locally *and* through real `fetch`, while DEL and astral code points are rejected pre-dispatch (exit `3`), so no provably-no-job case is misrouted to exit `5`. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:2d9ec1c4-ea1e-4217-abce-bd560b90a11d -->
README.md Outdated
@ -82,1 +117,4 @@
## Orphaned jobs
A submit that dies before printing its job id — a harness-killed process, a transport failure (exit `5`), an accepted response without a recoverable id (exit `6` with no `.error.job_id`; an exit `6` whose envelope carries `.error.job_id` orphans nothing), or a client crash (exit `7`, or a bare runtime exit `1` with empty stdout) — orphans the job, not the analysis: the server never cancels a job because the client went away, and runs the paid analysis to completion on its single processing slot. The client has exactly two routes, `POST /v1/queries` and `GET /v1/jobs/<id>`; there is no job-listing or lookup route, so an id that was never printed is unrecoverable. `--session` does not help: it is submit-only input, and reusing it queues a **new** paid analysis behind the invisible first one. After losing a submit without an id, report and stop — do not resubmit the question. Exit `3`, and exit `4` on a 4xx, are the codes that prove no job was created by the failed invocation.

🟡 Medium: This closing sentence contradicts the exit-code table two sections above and the resubmit rule on line 112, specifically for 408.

  • Line 107 (exit 4, "On a submit"): "4xx except 408: no. 408 and 5xx: unknown (an intermediary may mask an accepted request)"
  • Line 112: "resubmitting is safe iff code == 3 || (code == 4 && status < 500 && status != 408)"
  • Here: "Exit 3, and exit 4 on a 4xx, are the codes that prove no job was created by the failed invocation."

408 is a 4xx, so this sentence asserts a job was provably not created in exactly the case the other two passages call unknowable. The failure mode is the one this section exists to prevent: an agent that reads the "Orphaned jobs" section (which ends with "do not resubmit the question", making this the operative rule at that moment) will treat a 408 on a submit as proof of no job and resubmit, queueing a second paid analysis behind an invisible first one.

Suggested fix — align the wording with line 112:

Exit 3, and exit 4 on a 4xx other than 408, are the codes that prove no job was created by the failed invocation.

🟡 **Medium:** This closing sentence contradicts the exit-code table two sections above and the resubmit rule on line 112, specifically for `408`. - Line 107 (exit `4`, "On a submit"): *"4xx except 408: no. 408 and 5xx: unknown (an intermediary may mask an accepted request)"* - Line 112: *"resubmitting is safe iff `code == 3 || (code == 4 && status < 500 && status != 408)`"* - Here: *"Exit `3`, and exit `4` on a 4xx, are the codes that prove no job was created by the failed invocation."* `408` is a 4xx, so this sentence asserts a job was provably not created in exactly the case the other two passages call unknowable. The failure mode is the one this section exists to prevent: an agent that reads the "Orphaned jobs" section (which ends with *"do not resubmit the question"*, making this the operative rule at that moment) will treat a `408` on a submit as proof of no job and resubmit, queueing a second paid analysis behind an invisible first one. Suggested fix — align the wording with line 112: > Exit `3`, and exit `4` on a 4xx other than `408`, are the codes that prove no job was created by the failed invocation.
jercik marked this conversation as resolved
forgejo-actions left a comment

Summary: Found 1 medium documentation issue. The implementation itself held up under empirical verification.

I checked out the head, ran the suite in a scratch copy (106/106 pass), typechecked cleanly, and exercised the CLI end-to-end against throwaway local servers on Node 26. Every exit code in the new README table reproduced as documented:

  • submit → 2 with the envelope (analysis: null omitted); --job 401/500 → 4 with status; non-JSON 2xx body → 6; schema drift carrying job.id6 with .error.job_id; --job .. and blocked port 223; ECONNREFUSED5; stalled 2xx body → 5 after exactly 30 s; 500 with a truncated body → 4 (not reclassified as transport).
  • readCauseChainMessage produced fetch failed: connect ECONNREFUSED … as intended; response.json() really does reject with SyntaxError on a non-JSON body and a TimeoutError DOMException (which is instanceof Error in Node) on a mid-body abort, so both branches in requestQuerySubmission are reachable and correctly discriminated.
  • The commander caveat is accurate to the byte: CommanderError.message is "error: unknown option '--josn'\n(Did you mean --json?)" while stderr additionally carries (add --help for usage). --help/--version throw with exitCode === 0 and are correctly passed through as exit 0.
  • validateHeaderValue is genuinely aligned with undici here — I tested \u0001, \u007f, \n, \u0000, \t, Latin-1 and a >0xFF code point, and node's check accepts exactly the set fetch accepts, so the exit-3 pre-dispatch claim holds.
  • The bin wrapper's new catch works for both a load-time throw and a missing dist/cli.js, yielding exit 7 with empty stdout.
  • --job path-segment validation plus encodeURIComponent leaves no traversal route, and the blocked-port set matches the current WHATWG bad-port list exactly.

Code review by Claude Code Opus (opus)

**Summary:** Found 1 medium documentation issue. The implementation itself held up under empirical verification. I checked out the head, ran the suite in a scratch copy (106/106 pass), typechecked cleanly, and exercised the CLI end-to-end against throwaway local servers on Node 26. Every exit code in the new README table reproduced as documented: - submit → `2` with the envelope (`analysis: null` omitted); `--job` 401/500 → `4` with `status`; non-JSON 2xx body → `6`; schema drift carrying `job.id` → `6` with `.error.job_id`; `--job ..` and blocked port `22` → `3`; `ECONNREFUSED` → `5`; stalled 2xx body → `5` after exactly 30 s; 500 with a truncated body → `4` (not reclassified as transport). - `readCauseChainMessage` produced `fetch failed: connect ECONNREFUSED …` as intended; `response.json()` really does reject with `SyntaxError` on a non-JSON body and a `TimeoutError` `DOMException` (which *is* `instanceof Error` in Node) on a mid-body abort, so both branches in `requestQuerySubmission` are reachable and correctly discriminated. - The commander caveat is accurate to the byte: `CommanderError.message` is `"error: unknown option '--josn'\n(Did you mean --json?)"` while stderr additionally carries `(add --help for usage)`. `--help`/`--version` throw with `exitCode === 0` and are correctly passed through as exit `0`. - `validateHeaderValue` is genuinely aligned with undici here — I tested `\u0001`, `\u007f`, `\n`, `\u0000`, `\t`, Latin-1 and a >0xFF code point, and node's check accepts exactly the set fetch accepts, so the exit-3 pre-dispatch claim holds. - The bin wrapper's new catch works for both a load-time throw and a missing `dist/cli.js`, yielding exit `7` with empty stdout. - `--job` path-segment validation plus `encodeURIComponent` leaves no traversal route, and the blocked-port set matches the current WHATWG bad-port list exactly. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:fa671f08-5de7-412e-8bcd-7f42e4ad7a51 -->
README.md Outdated
@ -82,1 +117,4 @@
## Orphaned jobs
A submit that dies before printing its job id — a harness-killed process, a transport failure (exit `5`), an accepted response without a recoverable id (exit `6` with no `.error.job_id`; an exit `6` whose envelope carries `.error.job_id` orphans nothing), or a client crash (exit `7`, or a bare runtime exit `1` with empty stdout) — orphans the job, not the analysis: the server never cancels a job because the client went away, and runs the paid analysis to completion on its single processing slot. The client has exactly two routes, `POST /v1/queries` and `GET /v1/jobs/<id>`; there is no job-listing or lookup route, so an id that was never printed is unrecoverable. `--session` does not help: it is submit-only input, and reusing it queues a **new** paid analysis behind the invisible first one. After losing a submit without an id, report and stop — do not resubmit the question. Exit `3`, and exit `4` on a 4xx, are the codes that prove no job was created by the failed invocation.

🟡 Medium: The closing sentence contradicts the exit-code table and the resubmit rule for HTTP 408.

Here: "Exit 3, and exit 4 on a 4xx, are the codes that prove no job was created by the failed invocation."

But the table row for 4 says "4xx except 408: no. 408 and 5xx: unknown (an intermediary may mask an accepted request)", and the rule just above says resubmitting is safe iff code == 3 || (code == 4 && status < 500 && status != 408).

408 is a 4xx, so this section tells an agent it proves no job was created — the one conclusion the rest of the document is careful to deny, and the exact case that would queue a second paid analysis. Since this paragraph is the part an agent reads after losing a submit, the looser wording is the one that will be acted on.

Suggested fix: Exit 3, and exit 4on a 4xx other than408, are the codes that prove no job was created by the failed invocation.

🟡 **Medium:** The closing sentence contradicts the exit-code table and the resubmit rule for HTTP 408. Here: *"Exit `3`, and exit `4` on a 4xx, are the codes that prove no job was created by the failed invocation."* But the table row for `4` says *"4xx except 408: no. 408 and 5xx: unknown (an intermediary may mask an accepted request)"*, and the rule just above says resubmitting is safe iff `code == 3 || (code == 4 && status < 500 && status != 408)`. 408 is a 4xx, so this section tells an agent it proves no job was created — the one conclusion the rest of the document is careful to deny, and the exact case that would queue a second paid analysis. Since this paragraph is the part an agent reads *after* losing a submit, the looser wording is the one that will be acted on. Suggested fix: `Exit `3`, and exit `4` on a 4xx other than `408`, are the codes that prove no job was created by the failed invocation.`
jercik marked this conversation as resolved
docs: exclude 408 from the orphaned-jobs no-job-proof sentence
All checks were successful
commit-msg / commitlint (pull_request) Successful in 13s
Checks / quality-checks (pull_request) Successful in 31s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 1m42s
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Successful in 8m25s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Successful in 15s
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Successful in 11m54s
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Successful in 13s
c386d9bcce
Author
Owner

Round-10 disposition (fix in c386d9b):

  • 31742 / 31746 (orphaned-jobs closing sentence contradicts the 408 carve-out) — fixed with the suggested one-liner, identical in both reviews: "Exit 3, and exit 4 on a 4xx other than 408, are the codes that prove no job was created by the failed invocation." Both reviewers were right that this was the operative passage: it sits at the exact decision point an agent reaches after losing a submit, so the round-9 carve-out had landed in the table and the predicate but not in the sentence that actually governs the resubmit choice. The three passages (exit-4 table row, resubmit predicate, orphaned-jobs closing sentence) now state the same rule.

Both conversations resolved.

Round-10 disposition (fix in c386d9b): - **31742 / 31746 (orphaned-jobs closing sentence contradicts the 408 carve-out)** — fixed with the suggested one-liner, identical in both reviews: "Exit `3`, and exit `4` on a 4xx other than `408`, are the codes that prove no job was created by the failed invocation." Both reviewers were right that this was the operative passage: it sits at the exact decision point an agent reaches after losing a submit, so the round-9 carve-out had landed in the table and the predicate but not in the sentence that actually governs the resubmit choice. The three passages (exit-4 table row, resubmit predicate, orphaned-jobs closing sentence) now state the same rule. Both conversations resolved.
forgejo-actions left a comment

Summary: No actionable issues found.

Code review by Codex GPT-5.6 SOL (gpt-5.6-sol)

**Summary:** No actionable issues found. _Code review by Codex GPT-5.6 SOL (gpt-5.6-sol)_ <!-- axrecipe-review:e4caff1d-22f3-4622-8b22-d874d072992d -->
forgejo-actions left a comment

Summary: No actionable issues found.

I reviewed the full diff and verified the documented behavior empirically rather than by reading alone: I reconstructed the CLI in a throwaway sandbox (Node 26, commander 15, zod 4) against local HTTP servers and exercised every exit code in the README table.

Verified against real runs:

  • --help/--version exit 0 under exitOverride(); unknown option and a rejected --wait-ms exit 3 and print exactly one stdout envelope under --json while commander's own prose stays on stderr (the message field matched commander's wording minus the (add --help for usage) hint, as documented).
  • Pre-dispatch guards exit 3: missing/blank URL, unparseable URL, non-http(s) scheme, query/fragment, credentials (message carries no password, cause is dropped), blocked port (:1, :22, :10080), and a newline/control-bearing API key. The hard-coded bad-port set matches the current WHATWG fetch list.
  • 401 and a 5xx with a refusal-shaped body both exit 4 with the status suffixed, so a 5xx cannot masquerade as a 4xx.
  • 2xx with a non-JSON body and 2xx with schema drift exit 6; job_id appears iff recoverJobId finds job.id.
  • A stalled 2xx body aborts at the 30 s floor and exits 5 (tropkod request timed out after 30000ms), and a content-length truncation exits 5, while an EOF-delimited truncation exits 6 — both consistent with the table's retry guidance.
  • ECONNREFUSED/ENOTFOUND exit 5 with the cause-chain message; a pending envelope exits 2; completed exits 0; failed exits 1.
  • URL joining preserves a base path in both directions (/, no slash, /proxy, /proxy/) and encodeURIComponent plus the ./..// guard keeps --job to one path segment; the request body carries the trimmed session_id and wait_ms.
  • The stdin path reads to EOF and is correctly ordered after the URL/credential checks; --job and a positional question skip the read entirely.

Repo-hygiene checks in the sandbox: all 106 tests pass, tsc -p tsconfig.app.json and tsconfig.test.json are clean, and knip reports no unused exports for the new modules. The --json argv scan's only false positive (a --json consumed as another option's value on a commander-error path) is already noted in the code and costs at most one extra stdout envelope.

Code review by Claude Code Opus (opus)

**Summary:** No actionable issues found. I reviewed the full diff and verified the documented behavior empirically rather than by reading alone: I reconstructed the CLI in a throwaway sandbox (Node 26, commander 15, zod 4) against local HTTP servers and exercised every exit code in the README table. Verified against real runs: - `--help`/`--version` exit `0` under `exitOverride()`; unknown option and a rejected `--wait-ms` exit `3` and print exactly one stdout envelope under `--json` while commander's own prose stays on stderr (the `message` field matched commander's wording minus the `(add --help for usage)` hint, as documented). - Pre-dispatch guards exit `3`: missing/blank URL, unparseable URL, non-`http(s)` scheme, query/fragment, credentials (message carries no password, `cause` is dropped), blocked port (`:1`, `:22`, `:10080`), and a newline/control-bearing API key. The hard-coded bad-port set matches the current WHATWG fetch list. - `401` and a 5xx with a refusal-shaped body both exit `4` with the status suffixed, so a 5xx cannot masquerade as a 4xx. - 2xx with a non-JSON body and 2xx with schema drift exit `6`; `job_id` appears iff `recoverJobId` finds `job.id`. - A stalled 2xx body aborts at the 30 s floor and exits `5` (`tropkod request timed out after 30000ms`), and a content-length truncation exits `5`, while an EOF-delimited truncation exits `6` — both consistent with the table's retry guidance. - ECONNREFUSED/ENOTFOUND exit `5` with the cause-chain message; a pending envelope exits `2`; completed exits `0`; failed exits `1`. - URL joining preserves a base path in both directions (`/`, no slash, `/proxy`, `/proxy/`) and `encodeURIComponent` plus the `.`/`..`/`/` guard keeps `--job` to one path segment; the request body carries the trimmed `session_id` and `wait_ms`. - The stdin path reads to EOF and is correctly ordered after the URL/credential checks; `--job` and a positional question skip the read entirely. Repo-hygiene checks in the sandbox: all 106 tests pass, `tsc -p tsconfig.app.json` and `tsconfig.test.json` are clean, and knip reports no unused exports for the new modules. The `--json` argv scan's only false positive (a `--json` consumed as another option's value on a commander-error path) is already noted in the code and costs at most one extra stdout envelope. _Code review by Claude Code Opus (opus)_ <!-- axrecipe-review:7b0d7a2d-4982-403a-a0ef-877321744d62 -->
jercik scheduled this pull request to auto merge when all checks succeed 2026-07-31 11:21:32 +00:00
Merge branch 'main' into feat/agent-harness-ergonomics
Some checks failed
commit-msg / commitlint (pull_request) Successful in 19s
Checks / quality-checks (24.15.0) (pull_request) Successful in 36s
Checks / quality-checks (26.5.0) (pull_request) Successful in 36s
PR Review / Prepare immutable review tools (pull_request_target) Successful in 1m46s
PR Review / forgejo-review-approach-smart-2 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 generator (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-smart-2 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-approach-3 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 generator (pull_request_target) Failing after 23s
PR Review / forgejo-review-code-smart-1 generator (pull_request_target) Failing after 23s
PR Review / forgejo-review-code-smart-1 poster (pull_request_target) Has been skipped
PR Review / forgejo-review-code-smart-2 poster (pull_request_target) Has been skipped
e624d35210
jercik merged commit 66c889664a into main 2026-07-31 11:24:32 +00:00
jercik deleted branch feat/agent-harness-ergonomics 2026-07-31 11:24:33 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
j4k-oss/tropkod-client!3
No description provided.