@jayyuen66/dsh-ocr-review
English · 中文
What it does
- Wraps the external
ocr(open-code-review) CLI into 5 model tools plus 1 web settings card for dsh; the host validates tool arguments not at all, so every shape and type gate lives in this package. - The host half (
host.ts) declares 6.volatile()settings fields — as of 0.1.7 the namespace is implicit: the host projects the form from the profile entry idocr-reviewand the plugin calls nosettings.register. - It also registers 5 tools, one systemPrompt routing section (
ocr-review-routing, order 1555) and 4/_dsh/ocr-review/*endpoints. - The client half (
src/client-entry.ts→client.js) is the settings card, taking provider/model from thellm-pi-ainamespace of the settings service. - It writes provider/model into the external ocr CLI's own
~/.opencodereview/config.json— as anapi_key_cmdcommand, never as a plaintext key.
Prerequisites (external CLI)
- This package imports no OCR library, it only spawns processes:
ocr review/ocr scan/ocr delegate preview/ocr delegate rule/ocr session/ocr llm test. - Commands are built against the contract verified line-by-line on the v1.12.x source (header comments of
lib/cli.tsandlib/parse.ts), shaped likeocr review --audience agent --format json --effort medium --output <temp file>. - Multi-value
--exclude/--pathare merged into one comma-separated flag, and--formatonly accepts json/text/sarif. - Command resolution order: the launcher bundled with the package first -
@alibaba-group/open-code-review(anoptionalDependenciesentry, whose own optionalDependencies pick the platform binary) resolved to the absolute path of itsbin/ocr.js- and only when that does not resolve does it fall back to a bareocron PATH. Both installation shapes keep working, so installing this plugin is enough to get a working tool without a separate brew / npm i -g step. Resolution is lazy and memoized once per process (the same shape dsh core uses to resolve@vscode/ripgrep); a failed resolve never throws at load time, which would take the whole plugin offline over one optional binary. - With neither path available the child exits 127 and the tool reports "ocr command not found", offering
brew install open-code-reviewornpm i -g @alibaba-group/open-code-review. - Platforms: macOS and Linux are the targets. Process reaping (the reaper guard around
ocr_review/ocr_scan) depends on a POSIX toolchain - bash traps and job control plus the process-group semantics ofps/pgrep/kill. None of that exists on Windows, so the package runs the bare command there (guarded byprocess.platform): no reaping, but it still works. The cost is that a hard host exit can leave an orphaned OCR process. - The provider list and the key status come from the official host channels
ctx.settings.describe()(thevalue.providersof thellm-pi-aientry) andctx.credentials. - When either is missing the card shows the degradation reason while tools and settings keep working.
- The host must be
>=0.2.0-rc.2: declared inpeerDependencies(the host checks it on plugin install from 0.1.7-rc; alpha.1 has no such gate yet) and inengines.dsh(same value, read by nothing).
Installation
dsh plugin --profile web add @jayyuen66/dsh-ocr-review
- The packages are on the public npm registry, so installation needs no credentials.
- The published files are only
host.js,client.js,cordis.patch.ymlandscripts(prepackrebuilds both bundles); source repository: seerepository.urlin package.json. - Runtime value dependencies are
@jayyuen66/dsh-plugin-shared,@deepseek-ai/schemastery(the host's fork of schemastery — only it implements the 0.1.7.volatile()resolution) andjs-yaml(the last one used only byscripts/get-cred.mjs).
pnpm blocks the dependency script on install (ERR_PNPM_IGNORED_BUILDS)
This package keeps @alibaba-group/open-code-review in optionalDependencies, and upstream ships a postinstall (scripts/install.js). pnpm 10+ does not run dependency build scripts unless allowlisted, so the install ends like this:
Error: ERR_PNPM_IGNORED_BUILDS
× installing dependencies
╰─▶ Ignored build scripts: @alibaba-group/open-code-review@1.12.11
Allowlisting is a one-time step, and only this package triggers it — no other plugin in this family depends on anything with a build script.
The straightforward fix is to allowlist it in the profile's pnpm-workspace.yaml (under <dsh data dir>/profiles/<profile>/):
allowBuilds:
'@alibaba-group/open-code-review': true
Then re-run pnpm i. The approval persists in that profile under the exact package name and survives later failed installs.
The host also ships an approval flow of its own: a failed install surfaces "allow these scripts and retry" on the web plugin page, and an Agent can grant it for you through install_bundle's approvedBuilds once you have said so in the conversation. The host validates only the pending package names, not the conversational consent, so you must approve it explicitly.
About that postinstall: on the normal path it does nothing. The platform binary comes from upstream's optionalDependencies (@alibaba-group/ocr-<os>-<arch>, each carrying os / cpu fields so pnpm installs only the one matching your platform); install.js detects that, prints Binary provided by platform package, skipping download. and returns. Measured, ocr runs identically whether or not the script executes — the only difference is whether that code runs during install, and it only downloads when the platform package is missing, which under pnpm never happens.
If you would rather run nothing at install time, write '@alibaba-group/open-code-review': false instead: ocr still works (the launcher reads the binary straight out of the platform package directory), but the host's readPendingBuilds() only recognises entries whose value is set this to true or false, so once it is false the approval flow can no longer grant it and you maintain that entry by hand.
Enabling it in dsh
- The in-package
cordis.patch.ymldeclares- id: ocr-review/name: "@jayyuen66/dsh-ocr-review"and is pointed at bydsh.bundle.patchinpackage.json. dsh plugin --profile web add/removeregisters and removes it, then restart dsh.- The card needs a web profile (
dsh.client.platform: web,immediately: true). - The four
/_dsh/ocr-review/*endpoints hang off aninject(["webServer"])child fiber: with no webServer (a TUI host) the child never activates, so the endpoints simply do not exist while tools and settings stay available.- On the real host webServer arrives about a second after this entry, which is why it has to be a dependency rather than a one-off
ctx.getin apply.
- On the real host webServer arrives about a second after this entry, which is why it has to be a dependency rather than a one-off
- Deployment defaults can go on the profile's
config:line (cordis validates and fills defaults through the exportedConfigschema); precedence: runtime settings value > line config > built-in default.
Tools exposed to the model
| Tool | Arguments and constraints |
|---|---|
ocr_review |
repo (absolute path, default = current session workspace), scope workspace/commit/branch (branch needs both from+to and is mutually exclusive with commit), effort, background, exclude (at most 50 entries), provider/model/resume (resume only with commit/branch — workspace reviews cannot be resumed), wait |
Numeric group (arguments of ocr_review) |
concurrency/timeoutMinutes/maxTools/maxTokens/maxTokensBudget (mapped to --concurrency --timeout --max-tools --max-tokens --max-tokens-budget; non-integers or values below the floor are rejected; note that --max-tools values 1-49 are raised to 50 by OCR — its help text says min 50) |
ocr_scan |
repo, path (at most 100 entries), exclude (50), batch none/by-language/by-directory, provider/model (native shared flags of scan_cmd, matching review), the same numeric group, wait; no git diff required |
ocr_delegate_preview / ocr_delegate_rule |
The former: repo, scope/commit/from/to, exclude 50, background. The latter: repo, paths required, 1–200 entries, positional arguments after --, each escaped. Zero LLM cost on the OCR side, they return only the file list and the rule groups |
ocr_session |
repo, action list/show/comments (whitelist, anything else errors), id, limit (clamped to 1–100, default 10); show/comments without an id falls back to list |
| Background and reaper | wait: false (background start, no host deadline, reclaimed by a 2-hour backstop, no --output, poll results with ocr_session) and the host reaper guard apply only to these two minute-scale commands, review and scan - that subprocess is also an official job (see the job-surface section below); that change left the tool's JSON untouched (a later output-contract rework switched it to the direct canonical object, see below) |
Settings
| Field | Values, defaults and use |
|---|---|
effort |
low/medium/high, built-in default medium; used when ocr_review is called without an explicit effort |
language |
中文/English, default 中文; written as the top-level language key of the OCR config when a selection is applied |
autoVerify |
default true; when true, a successful /select also runs ocr llm test --color never (capped at 90 seconds) |
maxComments |
default 12, the number of comments kept in the summary; 0 = no truncation, non-integers and negatives fall back to 12 |
timeoutMinutes |
default 0 = request the host cap (the host clamps to min(request, shell.maxTimeoutMs)), >0 is a number of minutes |
ocrConfigPath |
where the external ocr CLI config lives; left empty it derives os.homedir()/.opencodereview/config.json, ~ and ~/ prefixes are accepted, relative paths error out. A custom value must follow the <X>/.opencodereview/config.json layout: OCR has no file-level path override (it only ever looks at <HOME>/.opencodereview/config.json, config_cmd.go:92-99), so the plugin makes it effective by injecting HOME=<X> into the ocr subprocess (config and sessions are redirected together, and api_key_cmd carries the host-resolved data directory as get-cred's explicit first-tier locator); any path outside that layout is rejected outright at the setting layer rather than silently mismatching (the HOME injection is a POSIX assignment prefix; Windows has no such channel, so custom paths only take effect on macOS/Linux) |
Three deployment-level tuning keys are deliberately not on the settings card — they are plain (non-volatile) fields of the exported Config, settable only on the profile's config: line and taking effect on restart:
| Key | Default | Use |
|---|---|---|
llmTestTimeoutMs |
90000 |
Wall clock for ocr llm test (shared by autoVerify and the card's "test connection"); raise it on slow machines |
ocrBackgroundMaxMs |
7200000 |
The 2-hour backstop window for background reviews (jobs.wait to the deadline, then kill) |
stdoutMaxBytes |
400000 |
Executor stdout buffer cap, shared by foreground and background runs |
Credentials and config files
| Aspect | Fact |
|---|---|
| Credential form in the config | What lands in the config is a command, not a secret: api_key_cmd = node '<path of scripts/get-cred.mjs in this package>' '<REF>' '<dsh data dir>' (all three parts single-quote escaped; the third is the host's current resolveDshHome() result, so get-cred's fallback layers stay correct when HOME is redirected or the dsh home is set outside the env), and the REF name must match ^[A-Z0-9_]+$ or the command is refused |
| How writes happen | Atomic replacements via the official @deepseek-ai/dsh-atomic-write: a random-suffixed .<hex>.tmp sibling created with wx, then rename, with the new inode carrying 0600 (parent directories 0700). The .bak backup is copied by this package before the rename and chmod-ed to 0600 (the official helper has no backup semantics). The whole read→decide→write span is serialized by the official withFileLock, so a short-lived config.json.lock appears in that directory during a write and is removed in finally; a lock left by a crashed holder is taken over via a pid liveness probe. Known inconsistency: contention beyond the official default 2s wait makes the write throw atomic-write: timed out waiting for the writer lock at …, and the endpoint answers 400 with that English text verbatim — it comes from the official package and bypasses lib/messages.ts, unlike every other user-visible error here. Before this swap the read-modify-write shared one event-loop tick, so this failure mode did not exist; mapping it would need an injectable wait budget plus a timeout test, so this package documents it instead of silently widening the surface. An existing config that is invalid JSON or whose top level is not an object aborts the write instead of "treating it as empty and overwriting" |
POST migrate |
Replaces the plaintext api_key of custom provider entries with api_key_cmd and deletes the plaintext; entries whose apiKeyEnv is not found in settings are left untouched and listed in skippedUnknown; nothing is written when nothing was migrated |
scripts/get-cred.mjs |
Spawned by the external ocr CLI itself and the only place in this package still reading host credential files. The data directory is resolved as explicit third argument > $DSH_HOME > ~/.dsh, and values are looked up in inherited process environment > refs.<REF> of <dsh data dir>/.credentials.yaml > <dsh data dir>/.env. The project .env of the reviewed repository is deliberately not read; when all three layers miss it exits non-zero and names every location it checked |
Public endpoints
| Route | Method | Input | Success payload |
|---|---|---|---|
/_dsh/ocr-review/providers |
GET | — | {providers (each with a hasKey boolean), current, getCredReady, source, csrf}, never containing a key |
/_dsh/ocr-review/select |
POST | body {provider, model}, both must hit the list returned by describe() |
{ok, applied, llmTest} (llmTest is null when autoVerify is off) |
/_dsh/ocr-review/migrate |
POST | — | {ok, migrated, skippedUnknown} |
/_dsh/ocr-review/test |
POST | — | {ok, output} (output is redacted first, then cut to 8000 characters) |
| Front guard of the three write endpoints | — | — | The first statement of each handler is guardTrust (Host authority -> the sec-fetch-site allowlist -> a verbatim origin compare; failing it gives 403 with {ok:false,error:"untrusted host authority" | "cross-origin request rejected"}); 405 for non-POST carrying Allow: POST and {ok:false,error:"POST only"}; 403 for a missing or wrong x-ocr-csrf header; 413 above the 8KB body cap; 400 for an aborted stream. The token is regenerated on every plugin apply and the old one stops working |
These four routes live and die with the plugin's injected child fiber. The official webServer.register hands back a disposer the caller must invoke, and it throws on a duplicate path outright (installed dsh-host-webserver/lib/index.js:177-184), while the route table belongs to the host-provided webServer instance and does not die when this plugin's fiber does. This package attaches all four disposers to the effect of the inject(["webServer"]) child fiber, so a hot reload (old fiber disposes, then a new apply runs) cannot collide on the path and kill the whole settings card, and disabling or uninstalling the plugin makes these endpoints unreachable again.
The background job surface (official ctx.jobs)
The external ocr subprocess started by wait: false is now also registered as an unowned job in the host's ctx.jobs (kind: "ocr-review", id ocr-review-N). What changed is who can see and stop it, not what the model receives:
| Aspect | Fact |
|---|---|
| Model observation surface | Three more tools: the official job_list shows the review, job_output reads its stdout/stderr (the registry pulls from proc.observed on its own cadence - non-consuming, so it never steals bytes from the readOutput() cursor), and job_kill stops it. Results still come from the ocr_session record; the payload content is unchanged since the job-surface swap, and a later output-contract rework returns the canonical object directly (no more stringify-ing the receipt into a { text } wrapper) |
| Broken executor reader | When the executor's reader throws, the official pump only writes one line to the host log and afterwards reads that channel as empty (measured) ⇒ this package writes one "this section is incomplete" note into the ring, exactly once, and stops knocking on the broken reader |
| Controller lifetime | Attached only for the instant of start: the registry refuses start when no controller serves the owner, and on the web surface the host's own tool-jobs row is disabled at the host plane (each preset realm mounts its own), so an unowned job needs a controller of our own. We attach it and detach it immediately (measured: detaching restores the refusal, and read/kill/settlement never need it again) - a resident token would pry that gate open for a composition that deliberately has no job tools |
| Three cancellation paths | An aborted tool call, a plugin unload, and the 2-hour backstop all call jobs.kill(id, undefined, reason) instead of killing the process around it, so the record moves to stopping and the reason lands in detail (measured: bypassing it left the row stuck at running forever). They may overlap - killing a stopping job still returns requested and proc.kill() is idempotent. Reasons are model-facing, so they come from the bilingual catalog |
| Nothing there may throw | An error raised inside an abort listener is rethrown by Node via process.nextTick ⇒ uncaught ⇒ the whole host process exits (reproduced locally: Error: unknown job ... followed by EXIT=1). The official package does not cover for producers either - killJob calls job.cancel() bare (installed dsh-jobs-local:611-622, and only the teardown arm wraps each call in try/catch), and a reloaded registry throws for an unfamiliar id (same file :554-558). So two layers each catch once: cancel swallows the "process is already gone" family through killProc, stop catches the registry arm and falls back to killing our own process. One test per layer - deleting either layer turns it red |
| The 2-hour backstop | We still give the external CLI no host timeoutMs (a long review would be cut off), but "no end at all" gained a victim with this adoption - the 10-slot unowned bucket is shared host-wide (measured: running and stopping both count, overflow refuses new starts), so one hung CLI parks a slot forever. The backstop uses the registry's own wait(id, ms) + kill, not a host timer and not a bare setTimeout |
| Unowned exposure | Unowned means visible to every session - any caller can list/read/kill it. Known and accepted for this wave (owned jobs would need the dsh-agent registry, measured unavailable); rolling the adoption back is the only way to close it |
| Settled-record window | The registry never recycles settled records on its own, and it outlives this plugin - so before each registration this package prunes the oldest settled ocr-review records down to 9 and only then starts, so the roster never holds more than 10 (each ring is capped at 256 KiB). At unload both jobs are done: live ones are killed through the registry, settled ones are removed - disabling this plugin means there is no "next apply" left to prune the window. Live and stopping records are never pruned: the registry answers overflow by refusing start (measured: the unowned bucket holds 10), not by killing somebody's review |
| No registry, no background | When the host has not loaded dsh-jobs-local, wait: false throws an error naming the missing piece instead of starting a subprocess nobody can observe. Foreground runs (wait: true) are unaffected |
Data and privacy
| Aspect | Fact |
|---|---|
| Process boundary | The reviewed repository's paths and diff content go into the ocr child process, and OCR sends them to the LLM endpoint it has in its own config.json; that egress is the review feature itself and does not pass through the dsh host. Plaintext keys never appear in tool results or in /_dsh/*, and the value the host gets from resolve() is used only to confirm "it resolves right now" and is discarded on the spot |
| Redaction of raw output | Raw OCR stdout/stderr (failure details, llm test, parse-failure echoes, session passthrough) all go through redactKeyMaterial() before leaving the host: bearer and assignment forms are redacted strictly, while the standalone-key form additionally demands random-string features so that file paths handed to the model are not erased as well |
| Places written | This package writes to only two places: the external ocr CLI's config.json (plus its random-suffixed .tmp, the .bak backup, and the short-lived config.json.lock held only for the duration of one read-modify-write) and ocr-review-*/ocr-scan-* in the system temp directory (removed right after reading). Plaintext that predates a migration may still sit in the .bak produced by that same write (mode 0600); removing it for good means deleting that backup yourself |
FAQ
| Symptom | Verdict |
|---|---|
| Exit 0 taken for a successful review | Exit 0 is not a successful review: any status outside success/complete/partial/completed_with_warnings/completed_with_errors/skipped is reported as failure ({"error": …} is of that kind; partial is one of the manifest terminal states — some files failed but coverage remains, and the comments still stand), and a parse failure echoes the first 500 redacted UTF-16 code units of the raw output (an emoji takes 2) |
| Whether the summary covered everything | It covers everything OCR produced only when droppedCount and invalidCommentCount are both 0, otherwise raise maxComments (or set 0) and re-check; stdout has its own 400000-byte cap, which is why review results always go through the --output temp file |
| Background processes when dsh exits | When dsh exits in any way (including kill -9 followed by launchd adoption), the OCR process tree of review/scan is taken down by the reaper guard inside the command with TERM→1s→KILL; cancelling the tool call terminates the corresponding background process too |
Development
npm run checkis the single gate to run before publishing: typecheck (both tsconfigs) +oxlint+ rebuild both bundles +npm test(vitest with coverage) +oxfmt --check.npm testruns with--coverageunder a 100% global threshold (lines/statements/branches/functions) — any line or branch not pinned by a test turns the suite red by design.npm run build/npm run build:clientproduce the publishedhost.js/client.js. After editinghost.ts,lib/*orsrc/*they must be rebuilt (test/build-host.test.tspins bundle freshness against the sources, so a stale bundle fails the suite). Never hand-edit the bundles.- The plugin source is TypeScript loaded directly by the host's cordis loader (Node ≥ 22.18 type stripping); the bundles exist for the published npm surface.
License
MIT — see LICENSE.
No comments yet. Be the first to write one.