@argszero/cordis-plugin-inspect-client-guard
v0.1.0
Published
A platform:"client" cordis_inspect_query with no live page to answer it must not hang the asking agent forever. The shared Host registry (`CordisInspectRegistryService.queryClient`, packages/extensions/cordis-host-runner/src/inspect-registry.ts) settles i
Maintainers
Readme
@argszero/cordis-plugin-inspect-client-guard
A platform: "client" cordis_inspect_query that no live page answers must not
hang the asking agent forever.
The two model-facing inspect tools route through one shared Host registry,
CordisInspectRegistryService
(packages/extensions/cordis-host-runner/src/inspect-registry.ts). Its Client
half, queryClient(), settles on exactly two paths:
- a page answer that survives
resolveClientQuery(), or - the tool call's
AbortSignal.
There is no deadline. A session with no live page to answer — a subagent, a
teammate, a headless or ACP run, a desktop window with no page open — leaves the
Promise pending until something outside cancels the tool call. The tool's own
description advertises that behaviour to the model
("remains pending until a page answers or the Tool is cancelled"), so from the
model's side a hang is indistinguishable from a slow answer. The report behind
this plugin measured one call pending 351 s before an external stop turned it
into err: true.
Every other way the answer path can fail also leaves the pending entry in place, which is worth stating plainly because it is what makes a fix on the answer path insufficient:
| path | shipped behaviour |
|------|-------------------|
| no page answers | pending forever, until an external cancel |
| answer arrives, Session→Agent lookup yields undefined | TypeError: Cannot read properties of undefined (reading 'id') thrown inside the remote handler — the answer is lost and the entry survives, so the asking agent still hangs |
| answer is ok: false | { accepted: false }, entry survives → still hangs |
| answer fails validateOutput() | { accepted: false }, entry survives → still hangs |
What this plugin does
It installs on the live service instance — the object cordis's service proxy
keeps under Symbol.for('cordis.original'), which is the object tool-cordis
reaches through its own ctx.cordisInspect.query(...) read — and changes two
things:
query(): aplatform: "client"call is raced against a deadline (default 15 s). On expiry the request's pending entry is dropped, the page is told the query is no longer answerable (the samecordis/inspect-query-resolvedevent the registry's own abort path emits), and the call rejects with an actionable message:Client inspect query Theme.listTokens got no answer from any page within 15000ms.Client Inspect Providers only run inside a live browser page, so a session with no page open — a subagent, a teammate, a headless or ACP run — has nobody to answer it. Do not retry platform:"client" queries here: read the source, or use a platform:"host" provider from cordis_inspect_list instead.The error carries
code: 'INSPECT_CLIENT_DEADLINE'. The wording is deliberate: the model is told what to do instead, so it falls back to reading source rather than retrying.resolveClientQuery(): an unboundagentis refused with{ accepted: false }instead of throwing. That guard does not restore liveness on its own — only the deadline does — but it keeps the refusal on the path the owner already understands, and turns a silently-dropped answer into a visible refusal.
Healthy answers, cancellation, and platform: "host" queries are untouched: an
answer or abort that wins the race is propagated as it came, and the deadline
timer is cleared on every exit.
What it deliberately does not do
- It keeps the deadline timer
ref'd. The report's suggested patch carriesdeadline.unref?.(). Measured on Node v26.5.0, with the query as the only pending work: aref'd timer fires (REJECTED: DEADLINE fired), anunref'd one lets the process exit first —exit=13, 0.029 s,Warning: Detected unsettled top-level await. A bound that only fires while some other work keeps the loop alive is the same as no bound, so this plugin does notunref. - It cannot settle the owner's inner Promise, so a query it times out is
abandoned rather than answered. It removes the pending entry, so nothing
accumulates and a late page answer is refused by the owner's own lookup
(measured:
{ accepted: false }, no double settle). - It cannot tell "no page is connected" from "a page is slow". The mirrored Client manifest is not cleared when a page goes away, so this is a deadline, not a liveness probe. Set the deadline accordingly.
- It is the mitigation, not the fix. The durable fix belongs in the registry:
a deadline on the wait, and — as the report asks — surfacing
accepted: falseto the answering page, whose Remote call today checks only the transport envelope (packages/extensions/cordis-client-runner/src/client/index.ts:195-198), so a refused answer looks identical to an accepted one.
Install
npm install @argszero/cordis-plugin-inspect-client-guardMount it into a profile (cordis.patch.yml in a bundle, or your own config):
- insert:
- id: inspect-client-guard
name: '@argszero/cordis-plugin-inspect-client-guard'
config:
deadlineMs: 15000 # optional; must be a positive integerThe plugin injects cordisInspect, so mount it wherever the Cordis host runner
is mounted. To stop guarding, disable the row:
- set:
- id: inspect-client-guard
disabled: trueAPI
import { installInspectClientGuard, originalOf } from '@argszero/cordis-plugin-inspect-client-guard/guard'
const dispose = installInspectClientGuard(registry, {
deadlineMs: 15000,
onReport: report => console.log(report.kind, report.requestId),
})
dispose()installInspectClientGuard(registry, options) accepts either the registry or any
cordis read of it (it unwraps Symbol.for('cordis.original') itself), is
idempotent, and returns a disposer that restores the previous methods by marker —
a service method read is not identity-stable, so identity comparison would never
restore. onReport receives { kind: 'deadline' | 'unbound-agent', requestId,
providerId, methodName, deadlineMs }.
Tests
npm test runs the arms against the real published class
(@deepseek-ai/dsh-cordis-host-runner) and against a real cordis Context —
nothing mocks the code under test:
- the control arm asserts the shipped class still hangs with no page;
- the end-to-end arms mount the real service in a real
Context, apply this plugin throughctx.plugin, and assert the consumer's ownctx.get('cordisInspect').query(...)read reaches the wrapper; - one arm runs a child process whose only pending work is the abandoned query, because a deadline that only works while other work is pending is not a deadline.
License
MIT
