From 2b7ed0ddd1c09ce6043c088a2544a4bb7bddea96 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 20:58:34 +0000 Subject: [PATCH 1/4] chore: re-pin agent-bundle and @agent-bundle/runtime to preview c2ffe5ec5 The pkg.pr.new preview of agent-bundle main c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a (#538): config.render.maxElapsedMs (#526), request.plugin (#532), operator .env for installed packs (#538), canonical event payloads (#545), request.terminal (#534), serve-app / serveApp (#537), request.lineage.tree (#544), provider identity handles (#552), notice axes (#539/#541), agent-bundle/meta in rendered skills (#527), re-exported default components (#524), loadRouteModule (#499), MCP tasks (#550), HookHandler (#533), Claude contract 2.1.260 (#542). --- package.json | 4 ++-- pnpm-lock.yaml | 29 +++++++++++++++-------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 54121872..1d6924b8 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "release:version": "changeset version" }, "devDependencies": { - "@agent-bundle/runtime": "https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141", + "@agent-bundle/runtime": "https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a", "@changesets/cli": "3.0.1", "@effect/atom-react": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", @@ -69,7 +69,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "@typescript/native-preview": "^7.0.0-dev.20260707.2", - "agent-bundle": "https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@42539ff5fbafdff0656317b841a50100ac0fc141", + "agent-bundle": "https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a", "bashjsast": "github:woolkingx/bashjsast#131f4b677d1906774fc49cfceab0ccb7b370b2e6", "effect": "4.0.0-rc.112", "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 418da680..1df512fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@agent-bundle/runtime': - specifier: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141 - version: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a + version: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@changesets/cli': specifier: 3.0.1 version: 3.0.1 @@ -45,8 +45,8 @@ importers: specifier: ^7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 agent-bundle: - specifier: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@42539ff5fbafdff0656317b841a50100ac0fc141 - version: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@42539ff5fbafdff0656317b841a50100ac0fc141(@agent-bundle/runtime@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@rspack/core@2.2.2(@swc/helpers@0.5.23))(@rstest/core@0.11.11)(@typescript/native-preview@7.0.0-dev.20260707.2)(hono@4.13.5)(react@19.2.8)(typescript@7.0.2) + specifier: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a + version: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a(@agent-bundle/runtime@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@rspack/core@2.2.2(@swc/helpers@0.5.23))(@rstest/core@0.11.11)(@typescript/native-preview@7.0.0-dev.20260707.2)(hono@4.13.5)(react@19.2.8)(typescript@7.0.2) bashjsast: specifier: github:woolkingx/bashjsast#131f4b677d1906774fc49cfceab0ccb7b370b2e6 version: https://codeload.github.com/woolkingx/bashjsast/tar.gz/131f4b677d1906774fc49cfceab0ccb7b370b2e6 @@ -77,9 +77,9 @@ importers: packages: - '@agent-bundle/runtime@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141': - resolution: {integrity: sha512-hn+gqiXPy3dQvC6WCf6XQeXqRTZ1aFtrEnYehUN9iGw+Qn8qIojJollwDpG1Y1237NGz3g+0/rinhynlJykaDw==, tarball: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141} - version: 0.0.0-preview-42539ff + '@agent-bundle/runtime@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a': + resolution: {integrity: sha512-qJgOW8WQHpLnHMpc2bw0uJJ27ASdwtrUxpcZkm1zE+/zsOus9Y6m9tkADVfmkxDmEIBG+KnI4oGP8jzRX5NRng==, tarball: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a} + version: 0.0.0-preview-c2ffe5e engines: {node: '>=22.19.0'} peerDependencies: '@rspack/core': ^2.2.0-0 @@ -955,13 +955,13 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - agent-bundle@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@42539ff5fbafdff0656317b841a50100ac0fc141: - resolution: {integrity: sha512-f0ApoqxhrYX1z+YgbC1RCHrv/Ro4Y8OEzq7xsh3VMMuJjFokfTZXC0QMieFR9rZbWjxdKUIBRUSOBnvEGUuAuw==, tarball: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@42539ff5fbafdff0656317b841a50100ac0fc141} - version: 0.0.0-preview-42539ff + agent-bundle@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a: + resolution: {integrity: sha512-WFevlX5/xcU1FSyA50WOutNmQFq7iAJykV60l2kpvyLHnsZTj5rdVOT4Gvz6aMYwnPdDvQ4v7AxW4mdmLFtUWw==, tarball: https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a} + version: 0.0.0-preview-c2ffe5e engines: {node: '>=22.19.0'} hasBin: true peerDependencies: - '@agent-bundle/runtime': 0.0.0-preview-42539ff + '@agent-bundle/runtime': 0.0.0-preview-c2ffe5e '@rstest/core': ^0.11.10 react: 19.2.8 peerDependenciesMeta: @@ -1672,7 +1672,7 @@ packages: snapshots: - '@agent-bundle/runtime@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@agent-bundle/runtime@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@modelcontextprotocol/sdk': 1.30.0(zod@4.5.4) '@modelcontextprotocol/server': 2.0.0 @@ -2416,8 +2416,9 @@ snapshots: acorn@8.18.0: {} - agent-bundle@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@42539ff5fbafdff0656317b841a50100ac0fc141(@agent-bundle/runtime@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@rspack/core@2.2.2(@swc/helpers@0.5.23))(@rstest/core@0.11.11)(@typescript/native-preview@7.0.0-dev.20260707.2)(hono@4.13.5)(react@19.2.8)(typescript@7.0.2): + agent-bundle@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a(@agent-bundle/runtime@https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@rspack/core@2.2.2(@swc/helpers@0.5.23))(@rstest/core@0.11.11)(@typescript/native-preview@7.0.0-dev.20260707.2)(hono@4.13.5)(react@19.2.8)(typescript@7.0.2): dependencies: + '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) '@modelcontextprotocol/client': 2.0.0 '@modelcontextprotocol/node': 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.5) '@modelcontextprotocol/server': 2.0.0 @@ -2440,7 +2441,7 @@ snapshots: ws: 8.21.3 yaml: 2.9.0 optionalDependencies: - '@agent-bundle/runtime': https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@42539ff5fbafdff0656317b841a50100ac0fc141(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@agent-bundle/runtime': https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a(@rspack/core@2.2.2(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@rstest/core': 0.11.11 react: 19.2.8 transitivePeerDependencies: From dadfe525ed6336389e8be35933ed8161fb35adb8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 20:59:01 +0000 Subject: [PATCH 2/4] feat: adopt the c2ffe5ec5 framework surface and delete the workarounds it retires - await: hauler await / hauler_await declare config.render.maxElapsedMs (7 260 000 ms = awaitCeilingMs + transport headroom) and wait up to the daemon ceiling in one call; awaitMaxWaitMs (55 s), renderBoundedWaitMs, and awaitTransportSlackMs are gone; guidance, README, and SKILL.md name the 2 h ceiling and Codex tool_timeout_sec. - events: tool/before, tool/after, stop read canonical.payload; the native-key parsers in src/lib/event-support.ts (beforeShellEventFrom, afterShellEventFrom, stopHoldEventFrom, hookContextFrom) are replaced by one shellEventFrom(payload); HookContext.nativeInput (never read) is removed; tests build props with createEventRouteInput. - terminal: hauler exec takes RunExecOptions.terminal from the envelope (main(argv, { terminal })); process.stdout.isTTY probes, colorEnabled, and sharesOutputTarget are deleted. - lineage: ticketLineageSchema.resolution admits transcript. - skills: SKILL.tsx imports version from agent-bundle/meta again (#440); workbench-surface moves into the route-unit pool (#441). - dashboard: hauler dashboard [--target HOST] [--port N] [--no-open] runs agent-bundle serve-app hauler/dashboard against the built artifact with hauler_status as the opening tool and call-tool auto-approved. A routed command importing agent-bundle/api does not build (the bundler inlines the framework, then fails on its runtime-relative imports; an external bare import is AB6005), so the process entry spawns the framework CLI instead. --- .changeset/repin-c2ffe5ec5.md | 5 + README.md | 29 ++- rstest.config.ts | 5 +- src/cli/await.tsx | 26 +-- src/client/exec.ts | 79 +++---- src/client/shared-output.ts | 19 -- src/components/documents.tsx | 5 +- src/components/ticket-guidance.tsx | 5 +- src/events/stop.tsx | 11 +- src/events/tool/after.tsx | 7 +- src/events/tool/before.tsx | 7 +- src/hooks/after-shell.ts | 3 +- src/hooks/before-shell.ts | 3 +- src/hooks/shared.ts | 1 - src/lib/ansi.ts | 33 --- src/lib/event-support.ts | 108 +-------- src/lib/protocol-schemas.ts | 28 +-- src/lib/tickets.ts | 14 -- src/mcp/hauler/tools/hauler_await.tsx | 19 +- src/scripts/hauler.ts | 209 ++++++++++++++++-- src/skills/cargo-hauler/SKILL.md | 10 +- src/skills/hauler-dashboard/SKILL.tsx | 19 +- tests/ansi.test.ts | 25 +-- tests/await-budget.test.ts | 51 +++-- tests/client-exec.test.ts | 37 +++- tests/events.test.ts | 35 +-- tests/hooks-simulate.test.ts | 57 ++--- tests/route-unit/events.test.ts | 33 +-- .../workbench-surface.test.ts | 7 +- tests/schema-compat.test.ts | 29 +-- tests/script.test.ts | 31 +++ tests/shared-output.test.ts | 40 ---- 32 files changed, 486 insertions(+), 504 deletions(-) create mode 100644 .changeset/repin-c2ffe5ec5.md delete mode 100644 src/client/shared-output.ts rename tests/{ => route-unit}/workbench-surface.test.ts (87%) delete mode 100644 tests/shared-output.test.ts diff --git a/.changeset/repin-c2ffe5ec5.md b/.changeset/repin-c2ffe5ec5.md new file mode 100644 index 00000000..fe72755d --- /dev/null +++ b/.changeset/repin-c2ffe5ec5.md @@ -0,0 +1,5 @@ +--- +'cargo-hauler': patch +--- + +Re-pin `agent-bundle` and `@agent-bundle/runtime` to the `c2ffe5ec5` preview and drop the workarounds it retires. `hauler await` and `hauler_await` wait up to the daemon's 2 h ceiling in one call (`maxWaitMs` ≤ 7200000): the routes declare `config.render.maxElapsedMs` instead of clamping every wait to 55 s, and the await guidance names that ceiling; a host with its own per-call deadline (Codex `tool_timeout_sec`) still bounds one call. The `tool/before`, `tool/after`, and `stop` hook routes read the framework's canonical event payload (`sessionId`, `toolInput`, `toolResponse`, `reentry`) instead of parsing Claude, Codex, and Cursor keys themselves; the never-prompt semantics are unchanged — `allow` only for a fully hauler-governed rewrite, `continue` otherwise, never `ask`. `hauler exec` shapes color, `2>&1` merging, and the redirected-stdout notice from the terminal the executable envelope hands `main`, no longer probing `process.stdout` itself. Ticket lineage accepts the runtime's `transcript` resolution. The rendered `hauler-dashboard` Skill names the release version again. New: `hauler dashboard [--target HOST] [--port N] [--no-open]` serves the dashboard App in a plain browser tab against the running daemon (through `agent-bundle serve-app`, from the checkout or the npm package). (#PR) diff --git a/README.md b/README.md index 4b0330d2..a6a183e1 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,13 @@ checkout? See [Install](#install). | `hauler status [--limit N] [--cwd DIR] [--session ID] [--lane KEY] [--ticket ID …] [--status S …] [--command-contains TEXT]` | Queue, active runs, lanes, admission, kache, optionally filtered. | | `hauler log [--limit N]` | Recent requests from the ledger. | | `hauler last` | The most recent request. | -| `hauler await [--max-wait-ms N]` | Long-poll until the ticket finishes or the wait expires (default 30 s, ceiling 55 s per call — the rendered-route budget; call again to keep waiting). | +| `hauler await [--max-wait-ms N]` | Long-poll until the ticket finishes or the wait expires (default 30 s, ceiling 2 h per call — the daemon's await ceiling; call again to keep waiting). A host with its own per-call deadline still bounds one call: Codex stops a tool call at `tool_timeout_sec` (60 s unless raised). | | `hauler result [--full]` | A stored ticket; running tickets include a live output tail. The document names the full on-disk output log (`Full output: (size)`) and `--json` carries it as `request.outputPath`; `--full` prints that whole log instead of the tail (the last ~768 KiB when it does not fit, with the path for the rest). | | `hauler kill ` | Stop a ticket: drop it from the queue or SIGTERM (then SIGKILL) its cargo process group, freeing the lane. Riders return to their lane or fail with it. | | `hauler request [--session ID] [--host HOST] [--cwd DIR] [--after TICKET …] -- ` | Submit a background request and return its ticket, with where it landed in its lane (`queued behind cc-3281 (~13m)`, `waiting for cc-3281`, or `attached to cc-3281`). `--after` works as for `exec`. | | `hauler daemon ` | Manage the daemon lifecycle. `restart` sends the graceful stop, waits up to 5 s for the old pid to exit, then starts a daemon from this install and prints both (`restarted: pid 741314 (0.4.1) → pid 742001 (0.4.4)`); a daemon that has not exited by then is reported, not killed, and nothing is started (exit `1`). Tickets in flight at the restart are not handed over: the new daemon marks them `killed` with the error `orphaned by daemon restart`. | | `hauler install-shim [--dir DIR] [--real-cargo PATH] [--force]` | Install the optional PATH shim. | +| `hauler dashboard [--target HOST] [--port N] [--open false] [--artifact DIR]` | Open the dashboard in a plain browser tab: serve the MCP App standalone against the plugin's own `hauler` server on `127.0.0.1`, call `hauler_status` once so it opens populated, and stay in the foreground until Ctrl-C. Runs from the checkout or the npm package (it needs the built `artifact/` and a resolvable `agent-bundle`); in an MCP host, call `hauler_status` instead. | The `hauler` MCP server projects the same operations as `hauler_status`, `hauler_log`, `hauler_last`, `hauler_await`, `hauler_result`, `hauler_kill`, @@ -94,7 +95,8 @@ The dashboard is an MCP App (`ui://cargo-hauler/dashboard.html`) attached to `hauler_status`. It shows contention and admission, in-flight and queued work with live output per ticket, metrics over one-hour, 24-hour, and all-time windows, per-command timings, optional kache data, lanes, and history. It -refreshes itself while open. +refreshes itself while open. Outside an MCP host, `hauler dashboard` serves +the same App in a plain browser tab against the running daemon. ![cargo-hauler metrics for one-hour, 24-hour, and all-time windows](docs/media/dashboard-metrics.png) @@ -716,7 +718,7 @@ artifact build, at the harness proof levels: | script-dispatch | `script-dispatch` | the `hauler` entry through its `main` envelope as its own process | | mcp-in-memory | `mcp-surface`, `layout` | tool names, `outputSchema`, the dashboard resource link, `_meta.hauler`, and a live fixture broker over the in-memory transport | | packed-stdio | `packed-contract` | the built `artifact/cursor` server as a real process against a live broker, every tool through the wire-contract matrix | -| workbench-surface | `tests/workbench-surface.test.ts` | what `agent-bundle dev` would show: catalog, provider, lifecycles per host, counts | +| workbench-surface | `workbench-surface` | what `agent-bundle dev` would show: catalog, provider, lifecycles per host, counts | Daemon-backed cases run a real broker in-process with a fake `cargo` (`tests/harness.ts`) and reach it either through the `haulerDaemon` provider @@ -732,24 +734,19 @@ pnpm run doctor # installed copies versus the artifact pnpm run check # the gate ``` -To see the dashboard outside an MCP host, run `pnpm run dev` and open the -Workbench's MCP page: it binds a session to the generated `hauler` server and -previews the `ui://cargo-hauler/dashboard.html` App over that session, so the -data is the daemon's own. The repository ships no preview harness of its own. +To see the dashboard outside an MCP host, run `node dist/bin/hauler.js +dashboard` after a build: it serves the `ui://cargo-hauler/dashboard.html` App +standalone against the generated `hauler` server (`serveApp` from +`agent-bundle/api`, the Workbench's own host stack), so the data is the +daemon's own. `pnpm run dev` and the Workbench's MCP page preview the same App +with live rebuilds. The repository ships no preview harness of its own. agent-bundle does not yet have an npm release; this repository pins the [pkg.pr.new](https://pkg.pr.new) preview of main commit -[`42539ff5f`](https://github.com/ScriptedAlchemy/agent-bundle/commit/42539ff5fbafdff0656317b841a50100ac0fc141) +[`c2ffe5ec5`](https://github.com/ScriptedAlchemy/agent-bundle/commit/c2ffe5ec55f2360fdc5c387e5b57e08f5282b84a) for both `agent-bundle` and `@agent-bundle/runtime`. `inspect` reports the `agent` component kind as unavailable on every host (agent-bundle G5 -deferral); this plugin defines no agents. Two framework limitations observed -while building this app are tracked upstream: a rendered `SKILL.tsx` cannot -import `agent-bundle/meta` -([agent-bundle#440](https://github.com/ScriptedAlchemy/agent-bundle/issues/440)), -and `inspectWorkbenchSurface` fails on a project with a rendered skill when -run under the `react-server` condition of the route-unit pool -([agent-bundle#441](https://github.com/ScriptedAlchemy/agent-bundle/issues/441); -which is why `tests/workbench-surface.test.ts` lives in the plain pool). +deferral); this plugin defines no agents. `repos/effect` is a read-only subtree containing the Effect v4 source pinned to `effect@4.0.0-rc.112`; see `AGENTS.md` before working with Effect code in this diff --git a/rstest.config.ts b/rstest.config.ts index 4cea63ab..16192250 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -9,9 +9,8 @@ import { agentBundleRstest } from 'agent-bundle/rstest'; * with the package identity instead of raising `AB4760`. * * Only that alias is adopted. The preset's `react-server` pool conditions are - * for the route-unit level (`rstest.route-unit.config.ts`); this pool also - * runs the compiler in-process (`tests/workbench-surface.test.ts`), whose - * rendered-skill pass needs React's client build. + * for the route-unit level (`rstest.route-unit.config.ts`), which also hosts + * the workbench-surface proof. */ const { resolve } = await agentBundleRstest(); diff --git a/src/cli/await.tsx b/src/cli/await.tsx index 826c1d28..5188acc8 100644 --- a/src/cli/await.tsx +++ b/src/cli/await.tsx @@ -5,19 +5,19 @@ import { z } from 'zod'; import { AwaitStream } from '../components/streaming.js'; import { cliSurface } from '../components/surface.js'; -import { awaitMaxWaitMs, awaitResultSchema } from '../lib/protocol-schemas.js'; +import { awaitCeilingMs } from '../daemon/protocol.js'; +import { awaitMaxWaitMessage, awaitResultSchema } from '../lib/protocol-schemas.js'; import { requestDaemonConfig } from '../lib/request-config.js'; -import { - awaitTicketResult, - defaultAwaitMs, - fetchTicketResult, - progressMessage, - renderBoundedWaitMs, -} from '../lib/tickets.js'; +import { awaitTicketResult, defaultAwaitMs, fetchTicketResult, progressMessage } from '../lib/tickets.js'; export const config = { description: 'Long-poll a ticket until it finishes or the wait expires.', positionals: ['ticket'], + // The daemon's 2 h await ceiling (`awaitCeilingMs`) plus a minute for the + // snapshot fetch before the wait and the socket round trip after it — a + // literal, as route config is read statically; `tests/await-budget.test.ts` + // holds the two together. The host's own tool-call deadline still applies. + render: { maxElapsedMs: 7_260_000 }, } satisfies CliRouteConfig; export const inputSchema = z.object({ @@ -26,12 +26,10 @@ export const inputSchema = z.object({ .number() .int() .min(0) - .max(awaitMaxWaitMs, { - message: 'maxWaitMs is capped at 55000 ms (55 s) per call — the rendered-route budget; run await again to keep waiting', - }) + .max(awaitCeilingMs, { message: awaitMaxWaitMessage }) .optional() .describe( - 'Give up after this many milliseconds (default 30000, ceiling 55000 — one rendered call); call again to keep waiting', + 'Give up after this many milliseconds (default 30000, ceiling 7200000 — the daemon\'s 2 h await ceiling); call again to keep waiting', ), }); @@ -40,11 +38,9 @@ export const resultSchema = awaitResultSchema; export default async function Await({ input, signal }: CliRouteProps) { const context = await agent(); const daemonConfig = requestDaemonConfig(context); - const requestedWaitMs = input.maxWaitMs ?? defaultAwaitMs; + const maxWaitMs = input.maxWaitMs ?? defaultAwaitMs; const startedAt = Date.now(); const snapshot = await fetchTicketResult(input, { config: daemonConfig, signal }); - // What the render session can still afford after the snapshot fetch. - const maxWaitMs = renderBoundedWaitMs(requestedWaitMs, Date.now() - startedAt); const awaited = awaitTicketResult({ ...input, maxWaitMs }, { config: daemonConfig, onProgress: ({ line }) => { diff --git a/src/client/exec.ts b/src/client/exec.ts index 4721b3bb..783bb9fd 100644 --- a/src/client/exec.ts +++ b/src/client/exec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import * as NodeServices from '@effect/platform-node/NodeServices'; import * as NodeSocket from '@effect/platform-node/NodeSocket'; +import type { AgentTerminal } from 'agent-bundle'; import * as Cause from 'effect/Cause'; import * as Deferred from 'effect/Deferred'; import * as Effect from 'effect/Effect'; @@ -35,7 +36,7 @@ import type { ServerMessage, } from '../daemon/protocol.js'; -import { AnsiStreamStripper, colorEnabled } from '../lib/ansi.js'; +import { AnsiStreamStripper } from '../lib/ansi.js'; import { shortId } from '../lib/id.js'; import { ensureDaemonRunning, type EnsureDaemonError } from './ensure-daemon.js'; @@ -47,7 +48,6 @@ import { } from './host-cap.js'; import { localQueryReason } from './local-invocation.js'; import { formatProgressLine } from './progress.js'; -import { sharesOutputTarget } from './shared-output.js'; export interface ExecIo { readonly writeStderr: (data: string | Uint8Array) => void; @@ -67,39 +67,33 @@ export interface RunExecOptions { readonly heartbeatMs?: number; readonly host?: string; readonly io: ExecIo; - /** - * Ask the daemon to run cargo with stderr merged into stdout. Defaults to - * whether this process's fd 1 and fd 2 are the same open file (`2>&1`), in - * which case direct cargo would have preserved write order across them. - */ - readonly mergeStderr?: boolean; readonly session?: string; readonly silenceThresholdMs?: number; /** - * Whether the consumer of stdout renders ANSI color. Only consulted for a - * merged stream (`mergeStderr`), where cargo's captured `always` color - * would otherwise reach a pipe or file that direct cargo (`auto`) would - * have left uncolored. Defaults like `stderrColor`, against stdout's TTY. - */ - readonly stdoutColor?: boolean; - /** - * Whether the consumer of stderr renders ANSI color. When false, cargo - * output chunks (demux-rendered diagnostics keep their color on the wire) - * are stripped before reaching `io`, so a pipe/capture never sees escape - * bytes. Defaults to this process's stderr TTY-ness combined with the - * NO_COLOR/FORCE_COLOR/CLICOLOR/TERM conventions. + * The process's terminal as the generated executable envelope probed it + * (agent-bundle#511, `main(argv, { terminal })`) — the client probes + * nothing itself. `stdout.kind` decides whether an auto-background notice + * must say where a redirected stdout's output went; `sharesTarget` (fd 1 + * and fd 2 name one open file: `2>&1`, `| tee`, a shared terminal) asks the + * daemon for one merged pipe so write order survives as it would under + * direct cargo; each channel's `color` decides whether cargo's captured + * `always` color reaches `io` or is stripped first, so a pipe or capture + * never sees escape bytes (demux-rendered diagnostics keep theirs on the + * wire). Absent — a caller outside the envelope — reads as two separate + * colorless pipes. */ - readonly stderrColor?: boolean; - /** - * Whether this process's stdout is a terminal. When it is not (the caller - * redirected it to a file or pipe), an auto-background notice adds where - * the command's output can actually be read. Defaults to - * `process.stdout.isTTY`. - */ - readonly stdoutIsTty?: boolean; + readonly terminal?: AgentTerminal; readonly workspaceRoot?: string; } +/** `RunExecOptions` once `runExecClient` has read the terminal: what the client's internals consume. */ +interface ExecOptions extends Omit { + /** Run cargo with stderr merged into stdout: the caller's two channels share one target. */ + readonly mergeStderr: boolean; + /** Stdout is a terminal, so an auto-background notice need not say where output went. */ + readonly stdoutIsTty: boolean; +} + export interface RunExecResult { readonly exitCode: number; readonly mode: 'brokered' | 'passthrough'; @@ -200,7 +194,7 @@ export interface PassthroughMode { } const passthrough = ( - options: RunExecOptions, + options: ExecOptions, config: DaemonConfigShape, mode: PassthroughMode, ): Effect.Effect => @@ -224,7 +218,7 @@ const passthrough = ( cwd: options.cwd, env: options.env, killSignal, - mergeStderr: options.mergeStderr === true, + mergeStderr: options.mergeStderr, onOutput: (channel, data) => Effect.sync(() => writeChannel(options.io, channel, data)), tailBytes: 0, }); @@ -319,7 +313,7 @@ const describeExit = (message: ExitMessage): string => { }; const handleServerMessage = ( - options: RunExecOptions, + options: ExecOptions, message: ServerMessage, state: StreamState, ): Effect.Effect => @@ -372,7 +366,7 @@ const handleServerMessage = ( auto: { capMs: hostShellCapMs(capHost), host: capHost, - stdoutRedirected: options.stdoutIsTty !== true, + stdoutRedirected: !options.stdoutIsTty, }, } : {}), @@ -463,7 +457,7 @@ const handleServerMessage = ( }); const streamBrokered = ( - options: RunExecOptions, + options: ExecOptions, config: DaemonConfigShape, ): Effect.Effect< RunExecResult, @@ -616,7 +610,7 @@ const streamBrokered = ( ...(options.session === undefined ? {} : { session: options.session }), ...(options.workspaceRoot === undefined ? {} : { workspaceRoot: options.workspaceRoot }), ...(options.background === true ? { background: true } : {}), - ...(options.mergeStderr === true ? { mergeStderr: true } : {}), + ...(options.mergeStderr ? { mergeStderr: true } : {}), ...(options.after === undefined || options.after.length === 0 ? {} : { after: [...options.after] }), @@ -673,7 +667,7 @@ const streamBrokered = ( }); const brokeredOrUnreachable = ( - options: RunExecOptions, + options: ExecOptions, config: DaemonConfigShape, ): Effect.Effect => Effect.scoped(streamBrokered(options, config)).pipe( @@ -723,20 +717,19 @@ const brokeredOrUnreachable = ( export const runExecClient = ( rawOptions: RunExecOptions, ): Effect.Effect => { - const stderrColor = - rawOptions.stderrColor ?? colorEnabled(process.env, process.stderr.isTTY === true); - const mergeStderr = rawOptions.mergeStderr ?? sharesOutputTarget(1, 2); - const stdoutColor = - rawOptions.stdoutColor ?? colorEnabled(process.env, process.stdout.isTTY === true); + const { terminal, ...rest } = rawOptions; + const stderrColor = terminal !== undefined && terminal.stderr.color !== 'none'; + const stdoutColor = terminal !== undefined && terminal.stdout.color !== 'none'; + const mergeStderr = terminal?.sharesTarget === true; const keep = (io: ExecIo): ExecIo => io; const stripStderr = stderrColor ? keep : (io: ExecIo) => withStrippedChannel(io, 'stderr'); // A merged stream is text from both channels, so the binary-stdout caveat no longer applies. const stripStdout = mergeStderr && !stdoutColor ? (io: ExecIo) => withStrippedChannel(io, 'stdout') : keep; - const options: RunExecOptions = { - ...rawOptions, + const options: ExecOptions = { + ...rest, io: stripStdout(stripStderr(rawOptions.io)), mergeStderr, - stdoutIsTty: rawOptions.stdoutIsTty ?? process.stdout.isTTY === true, + stdoutIsTty: terminal?.stdout.kind === 'tty', }; const config = options.config ?? resolveDaemonConfig(); // Help/version and other non-compiling queries never take a ticket: a diff --git a/src/client/shared-output.ts b/src/client/shared-output.ts deleted file mode 100644 index 204d8788..00000000 --- a/src/client/shared-output.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { fstatSync } from 'node:fs'; - -/** - * Whether two descriptors name the same open file (dev+inode), which is what - * `cargo run 2>&1`, `| tee`, and a shared terminal look like from inside the - * process. When they do, the caller cannot tell the channels apart and direct - * cargo would have preserved the program's write order across them; the - * broker is asked to run the child with one merged pipe so it can too (#38). - * Any descriptor that cannot be inspected keeps the channels separate. - */ -export const sharesOutputTarget = (stdoutFd: number, stderrFd: number): boolean => { - try { - const out = fstatSync(stdoutFd); - const err = fstatSync(stderrFd); - return out.dev === err.dev && out.ino === err.ino; - } catch { - return false; - } -}; diff --git a/src/components/documents.tsx b/src/components/documents.tsx index 77b8ece6..369a53d7 100644 --- a/src/components/documents.tsx +++ b/src/components/documents.tsx @@ -1,10 +1,9 @@ import { Agent } from '@agent-bundle/runtime'; import React from 'react'; -import { orphanedByRestartError, type RequestRecord } from '../daemon/protocol.js'; +import { awaitCeilingMs, orphanedByRestartError, type RequestRecord } from '../daemon/protocol.js'; import { formatMs } from '../lib/format.js'; import { documentValue } from '../lib/json.js'; -import { awaitMaxWaitMs } from '../lib/protocol-schemas.js'; import type { AwaitResult, KillResult, @@ -164,7 +163,7 @@ export const AwaitDocument = ({ {result.request === null ? null : } {result.timedOut ? ( - {`The ${formatMs(maxWaitMs)} wait expired before ${result.ticket} finished. Call ${names.await} again (each call waits up to ${formatMs(awaitMaxWaitMs)}) rather than polling ${names.result} in a tight loop.`} + {`The ${formatMs(maxWaitMs)} wait expired before ${result.ticket} finished. Call ${names.await} again (each call waits up to ${formatMs(awaitCeilingMs)}) rather than polling ${names.result} in a tight loop.`} ) : result.request === null ? ( diff --git a/src/components/ticket-guidance.tsx b/src/components/ticket-guidance.tsx index 63b8633c..fe5f6998 100644 --- a/src/components/ticket-guidance.tsx +++ b/src/components/ticket-guidance.tsx @@ -1,9 +1,8 @@ import { Agent } from '@agent-bundle/runtime'; import React from 'react'; -import { isOrphanedByRestart, type RequestRecord, type RequestStatus } from '../daemon/protocol.js'; +import { awaitCeilingMs, isOrphanedByRestart, type RequestRecord, type RequestStatus } from '../daemon/protocol.js'; import { formatMs } from '../lib/format.js'; -import { awaitMaxWaitMs } from '../lib/protocol-schemas.js'; import type { SurfaceNames } from './surface.js'; @@ -22,7 +21,7 @@ type GuidanceComponent = (props: TicketGuidanceProps) => React.JSX.Element; const PendingGuidance: GuidanceComponent = ({ names, record }) => ( - {`${record.ticket} is still ${record.status}. Do not re-run the same cargo command; call ${names.await} with ticket ${record.ticket} (each call waits up to ${formatMs(awaitMaxWaitMs)}; call again to keep waiting) or check ${names.result} later.`} + {`${record.ticket} is still ${record.status}. Do not re-run the same cargo command; call ${names.await} with ticket ${record.ticket} (each call waits up to ${formatMs(awaitCeilingMs)}; call again to keep waiting) or check ${names.result} later.`} ); diff --git a/src/events/stop.tsx b/src/events/stop.tsx index d99f5bc9..ae05f266 100644 --- a/src/events/stop.tsx +++ b/src/events/stop.tsx @@ -3,7 +3,7 @@ import type { AgentEventRouteConfig, AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; import { handleStopHold } from '../hooks/stop-hold.js'; -import { decisionValue, hookContextFrom, stopHoldEventFrom } from '../lib/event-support.js'; +import { decisionValue } from '../lib/event-support.js'; // Standalone: stop-hold may block for its bounded wait and must not occupy // the shared MCP runtime. Budget mirrors the former 900 s stop hook. @@ -14,8 +14,13 @@ export const config = { timeoutMs: 900_000, } satisfies AgentEventRouteConfig; -export default async function StopHold({ canonical, native }: AgentEventRouteProps) { - const result = await handleStopHold(stopHoldEventFrom(native), {}, hookContextFrom(canonical, native)); +export default async function StopHold({ canonical }: AgentEventRouteProps<'stop'>) { + // `reentry` is the canonical reading of Claude/Codex `stop_hook_active` and + // Cursor `loop_count > 0` (agent-bundle#466). + const result = await handleStopHold({ + sessionId: canonical.payload.sessionId?.value, + stopHookActive: canonical.payload.reentry?.value, + }); // Stop routes ignore Context nodes; the deny reason is the only channel. return ; } diff --git a/src/events/tool/after.tsx b/src/events/tool/after.tsx index 7a7c06ee..e9403408 100644 --- a/src/events/tool/after.tsx +++ b/src/events/tool/after.tsx @@ -3,7 +3,7 @@ import type { AgentEventRouteConfig, AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; import { handleAfterShell } from '../../hooks/after-shell.js'; -import { afterShellEventFrom, decisionValue, hookContextFrom } from '../../lib/event-support.js'; +import { decisionValue, shellEventFrom } from '../../lib/event-support.js'; export const config = { // The portable playground target defines no hooks; events ship with the plugin hosts. @@ -14,8 +14,9 @@ export const config = { tools: ['shell'], } satisfies AgentEventRouteConfig; -export default async function AfterShellTool({ canonical, native }: AgentEventRouteProps) { - const result = await handleAfterShell(afterShellEventFrom(native), hookContextFrom(canonical, native)); +export default async function AfterShellTool({ canonical }: AgentEventRouteProps<'tool/after'>) { + const { host, nativeEvent } = canonical.provenance; + const result = await handleAfterShell(shellEventFrom(canonical.payload), { nativeEvent, target: host }); return ( {result.additionalContext === undefined ? null : {result.additionalContext}} diff --git a/src/events/tool/before.tsx b/src/events/tool/before.tsx index a031b820..3d4d52f3 100644 --- a/src/events/tool/before.tsx +++ b/src/events/tool/before.tsx @@ -3,7 +3,7 @@ import type { AgentEventRouteConfig, AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; import { handleBeforeShell } from '../../hooks/before-shell.js'; -import { beforeShellEventFrom, decisionValue, hookContextFrom } from '../../lib/event-support.js'; +import { decisionValue, shellEventFrom } from '../../lib/event-support.js'; export const config = { // The portable playground target defines no hooks; events ship with the plugin hosts. @@ -14,8 +14,9 @@ export const config = { tools: ['shell'], } satisfies AgentEventRouteConfig; -export default async function BeforeShellTool({ canonical, native }: AgentEventRouteProps) { - const result = await handleBeforeShell(beforeShellEventFrom(native), hookContextFrom(canonical, native)); +export default async function BeforeShellTool({ canonical }: AgentEventRouteProps<'tool/before'>) { + const { host, nativeEvent } = canonical.provenance; + const result = await handleBeforeShell(shellEventFrom(canonical.payload), { nativeEvent, target: host }); return ( {result.additionalContext === undefined ? null : {result.additionalContext}} diff --git a/src/hooks/after-shell.ts b/src/hooks/after-shell.ts index 58ae34db..ab5e9693 100644 --- a/src/hooks/after-shell.ts +++ b/src/hooks/after-shell.ts @@ -14,7 +14,8 @@ import { export interface AfterShellEvent { readonly cwd?: string; readonly sessionId?: string; - readonly toolInput?: Readonly>; + /** The completed call's input as the host sent it: an object on Claude and Cursor, any JSON on Codex. */ + readonly toolInput?: unknown; readonly toolName?: string; readonly toolResponse?: unknown; readonly toolUseId?: string; diff --git a/src/hooks/before-shell.ts b/src/hooks/before-shell.ts index b4f6c986..cc100baf 100644 --- a/src/hooks/before-shell.ts +++ b/src/hooks/before-shell.ts @@ -16,7 +16,8 @@ export type { HookContext, HookServices }; export interface BeforeShellEvent { readonly cwd?: string; readonly sessionId?: string; - readonly toolInput?: Readonly>; + /** The pending call's input as the host sent it: an object on Claude and Cursor, any JSON on Codex. */ + readonly toolInput?: unknown; readonly toolName?: string; readonly toolUseId?: string; } diff --git a/src/hooks/shared.ts b/src/hooks/shared.ts index 1e841a47..babe990a 100644 --- a/src/hooks/shared.ts +++ b/src/hooks/shared.ts @@ -10,7 +10,6 @@ export { isRecord, countWord }; export interface HookContext { readonly nativeEvent?: string; - readonly nativeInput?: Readonly>; readonly target?: string; } diff --git a/src/lib/ansi.ts b/src/lib/ansi.ts index 46644517..97ef1e3d 100644 --- a/src/lib/ansi.ts +++ b/src/lib/ansi.ts @@ -82,36 +82,3 @@ export class AnsiStreamStripper { return Buffer.from(stripAnsi(held), 'latin1'); } } - -const isNonZero = (value: string): boolean => value !== '0' && value.toLowerCase() !== 'false'; - -/** - * Whether a consumer stream should receive color, following the common - * precedence: FORCE_COLOR decides outright when set, CLICOLOR_FORCE forces - * color on, NO_COLOR (any non-empty value) and CLICOLOR=0 force it off, - * TERM=dumb cannot render color, otherwise color iff the stream is a TTY. - */ -export const colorEnabled = ( - env: Readonly>, - isTty: boolean, -): boolean => { - const forceColor = env.FORCE_COLOR; - if (forceColor !== undefined && forceColor !== '') { - return isNonZero(forceColor); - } - const clicolorForce = env.CLICOLOR_FORCE; - if (clicolorForce !== undefined && isNonZero(clicolorForce)) { - return true; - } - const noColor = env.NO_COLOR; - if (noColor !== undefined && noColor !== '') { - return false; - } - if (env.CLICOLOR === '0') { - return false; - } - if (env.TERM === 'dumb') { - return false; - } - return isTty; -}; diff --git a/src/lib/event-support.ts b/src/lib/event-support.ts index 67d62840..74ba7424 100644 --- a/src/lib/event-support.ts +++ b/src/lib/event-support.ts @@ -1,109 +1,25 @@ import type { JsonValue } from '@agent-bundle/runtime'; -import type { AgentEventCanonicalIdentity, AgentEventNativePayload } from 'agent-bundle'; +import type { AgentEventPayload } from 'agent-bundle'; import type { AfterShellEvent } from '../hooks/after-shell.js'; -import type { BeforeShellEvent } from '../hooks/before-shell.js'; -import type { HookContext } from '../hooks/shared.js'; -import type { StopHoldEvent } from '../hooks/stop-hold.js'; -import { isRecord } from './guards.js'; import { documentValue } from './json.js'; /** - * Bridges host-native event envelopes (Claude/Codex `snake_case`, Cursor - * `conversation_id` + string `tool_output`) onto the canonical event shapes - * the hook libraries in `src/hooks/*` already accept. + * The hook libraries' shell event read from the framework's canonical payload + * (agent-bundle#466): one cross-host reading of the envelope, each field absent + * when the host did not send it. A `tool/before` payload is the same reading + * without `toolResponse`, so `handleBeforeShell` takes the result as is. */ - -const nativeString = (native: AgentEventNativePayload, ...keys: readonly string[]): string | undefined => { - for (const key of keys) { - const value = native[key]; - if (typeof value === 'string' && value.length > 0) { - return value; - } - } - return undefined; -}; - -const nativeRecord = ( - native: AgentEventNativePayload, - key: string, -): Readonly> | undefined => { - const value = native[key]; - return isRecord(value) ? value : undefined; -}; - -const parseJsonRecord = (text: string): Readonly> | undefined => { - try { - const parsed: unknown = JSON.parse(text); - return isRecord(parsed) ? parsed : undefined; - } catch { - return undefined; - } -}; - -export const hookContextFrom = ( - canonical: AgentEventCanonicalIdentity, - native: AgentEventNativePayload, -): HookContext => ({ - nativeEvent: canonical.provenance.nativeEvent, - nativeInput: native, - target: canonical.provenance.host, +export const shellEventFrom = (payload: AgentEventPayload<'tool/before' | 'tool/after'>): AfterShellEvent => ({ + cwd: payload.cwd?.value, + sessionId: payload.sessionId?.value, + toolInput: payload.toolInput?.value, + toolName: payload.toolName?.value, + toolResponse: payload.toolResponse?.value, + toolUseId: payload.toolUseId?.value, }); -export const beforeShellEventFrom = (native: AgentEventNativePayload): BeforeShellEvent => { - const cwd = nativeString(native, 'cwd'); - const sessionId = nativeString(native, 'session_id', 'conversation_id'); - const toolInput = nativeRecord(native, 'tool_input'); - const toolName = nativeString(native, 'tool_name'); - const toolUseId = nativeString(native, 'tool_use_id'); - return { - ...(cwd === undefined ? {} : { cwd }), - ...(sessionId === undefined ? {} : { sessionId }), - ...(toolInput === undefined ? {} : { toolInput }), - ...(toolName === undefined ? {} : { toolName }), - ...(toolUseId === undefined ? {} : { toolUseId }), - }; -}; - -/** Claude/Codex send `tool_response` as an object; Cursor sends `tool_output` as a string. */ -const toolResponseFrom = (native: AgentEventNativePayload): Readonly> | undefined => { - const response = nativeRecord(native, 'tool_response'); - if (response !== undefined) { - return response; - } - const output = native.tool_output; - return typeof output === 'string' ? parseJsonRecord(output) : undefined; -}; - -export const afterShellEventFrom = (native: AgentEventNativePayload): AfterShellEvent => { - const toolResponse = toolResponseFrom(native); - return { - ...beforeShellEventFrom(native), - ...(toolResponse === undefined ? {} : { toolResponse }), - }; -}; - -/** Cursor reports re-entry as `loop_count`; Claude/Codex as `stop_hook_active`. */ -const stopHookActiveFrom = (native: AgentEventNativePayload): boolean | undefined => { - if (typeof native.stop_hook_active === 'boolean') { - return native.stop_hook_active; - } - if (typeof native.loop_count === 'number') { - return native.loop_count > 0; - } - return undefined; -}; - -export const stopHoldEventFrom = (native: AgentEventNativePayload): StopHoldEvent => { - const sessionId = nativeString(native, 'session_id', 'conversation_id'); - const stopHookActive = stopHookActiveFrom(native); - return { - ...(sessionId === undefined ? {} : { sessionId }), - ...(stopHookActive === undefined ? {} : { stopHookActive }), - }; -}; - /** * `continue` is the no-decision answer (the host's own flow applies), `allow` * is the explicit `tool/before` approval for a hauler-governed rewrite, and diff --git a/src/lib/protocol-schemas.ts b/src/lib/protocol-schemas.ts index dfad75b5..a485e930 100644 --- a/src/lib/protocol-schemas.ts +++ b/src/lib/protocol-schemas.ts @@ -373,28 +373,17 @@ export const daemonResultSchema = z .strict() satisfies z.ZodType; /** - * Ceiling for one rendered `await` call (CLI or MCP tool). Every rendered - * route runs inside the framework's 60 s render session - * (`DEFAULT_AGENT_RENDER_LIMITS.maxElapsedMs`); a longer wait throws - * `elapsed-time-exceeded` at emit and the caller loses the result it waited - * for (#32). The margin covers the snapshot fetch before the wait and the - * final emit after it. Callers wanting longer wait in a loop of calls; the - * daemon-side `awaitCeilingMs` (2 h) still governs the wire and the hook - * stop-hold, which do not render. + * One `await` call waits up to the daemon's own ceiling (`awaitCeilingMs`, + * 2 h): the rendered routes declare a matching `config.render.maxElapsedMs` + * (agent-bundle#454), so the wire is the only bound. Callers wanting longer + * call again. */ -export const awaitMaxWaitMs = 55_000; +export const awaitMaxWaitMessage = `maxWaitMs is capped at ${awaitCeilingMs} ms (2 h) per call — the daemon's await ceiling; call await again to keep waiting`; export const ticketInputSchema = z .object({ ticket: z.string().min(1), - maxWaitMs: z - .number() - .int() - .min(0) - .max(awaitMaxWaitMs, { - message: `maxWaitMs is capped at ${awaitMaxWaitMs} ms (55 s) per call — the rendered-route budget; call await again to keep waiting`, - }) - .optional(), + maxWaitMs: z.number().int().min(0).max(awaitCeilingMs, { message: awaitMaxWaitMessage }).optional(), }) .strict(); @@ -474,8 +463,9 @@ export const ticketLineageSchema = z depth: z.number().int().nonnegative(), parent: z.string().optional(), // Mirrors the runtime's AgentLineageResolution; `confirmed` is a registry - // edge the host itself named (Claude's Agent PostToolUse, agent-bundle#486). - resolution: z.enum(['native', 'registry', 'confirmed', 'inferred']), + // edge the host itself named (Claude's Agent PostToolUse, agent-bundle#486), + // `transcript` one read back from the host's transcript (agent-bundle#457). + resolution: z.enum(['native', 'registry', 'confirmed', 'transcript', 'inferred']), root: z.string(), }) .strict(); diff --git a/src/lib/tickets.ts b/src/lib/tickets.ts index 04dfe8b8..6c914cb5 100644 --- a/src/lib/tickets.ts +++ b/src/lib/tickets.ts @@ -13,7 +13,6 @@ import { describeRequestRecord, displayRequestRecord } from '../query.js'; import type { TicketRequestContext } from './attribution.js'; import { enrichTicketRequest, ticketAttribution } from './attribution.js'; import { - awaitMaxWaitMs, type AwaitResult, type KillResult, type RequestInput, @@ -38,19 +37,6 @@ export interface AwaitOptions extends TicketOptions { export const defaultAwaitMs = 30_000; -/** Socket accept + response timeouts the client adds on top of the daemon wait (`client/tickets.ts`). */ -export const awaitTransportSlackMs = 4_000; - -/** - * The daemon wait one rendered `await` may still afford. The route clock has - * already spent `elapsedMs` (fetching the ticket snapshot), and the socket - * round trip adds `awaitTransportSlackMs`; the remainder of the 55 s ceiling - * is what is left before the framework's 60 s render session would expire - * with the result in hand but undelivered (#32). - */ -export const renderBoundedWaitMs = (requestedMs: number, elapsedMs: number): number => - Math.max(0, Math.min(requestedMs, awaitMaxWaitMs - elapsedMs - awaitTransportSlackMs)); - /** A heartbeat line without the `[cargo-hauler]` prefix, for progress channels that label the source themselves. */ export const progressMessage = (line: string): string => line.replace(/^\[cargo-hauler\]\s*/u, '').trimEnd(); diff --git a/src/mcp/hauler/tools/hauler_await.tsx b/src/mcp/hauler/tools/hauler_await.tsx index 85a84289..ecd3e15c 100644 --- a/src/mcp/hauler/tools/hauler_await.tsx +++ b/src/mcp/hauler/tools/hauler_await.tsx @@ -6,18 +6,17 @@ import { AwaitStream } from '../../../components/streaming.js'; import { mcpSurface } from '../../../components/surface.js'; import { awaitResultSchema, ticketInputSchema } from '../../../lib/protocol-schemas.js'; import { requestDaemonConfig } from '../../../lib/request-config.js'; -import { - awaitTicketResult, - defaultAwaitMs, - fetchTicketResult, - progressMessage, - renderBoundedWaitMs, -} from '../../../lib/tickets.js'; +import { awaitTicketResult, defaultAwaitMs, fetchTicketResult, progressMessage } from '../../../lib/tickets.js'; export const config = { annotations: { readOnlyHint: true }, description: - 'Long-poll a cargo-hauler ticket until it finishes or the wait expires (maxWaitMs default 30000, ceiling 55000; call again to keep waiting). The document streams: the live ticket card first, then the settled result; progress notifications carry queue position, elapsed time, and the cost estimate while waiting.', + 'Long-poll a cargo-hauler ticket until it finishes or the wait expires (maxWaitMs default 30000, ceiling 7200000 — the daemon\'s 2 h await ceiling; call again to keep waiting; a host with its own per-call deadline, such as Codex\'s tool_timeout_sec, still bounds one call). The document streams: the live ticket card first, then the settled result; progress notifications carry queue position, elapsed time, and the cost estimate while waiting.', + // The daemon's 2 h await ceiling (`awaitCeilingMs`) plus a minute for the + // snapshot fetch before the wait and the socket round trip after it — a + // literal, as route config is read statically; `tests/await-budget.test.ts` + // holds the two together. The host's own tool-call deadline still applies. + render: { maxElapsedMs: 7_260_000 }, title: 'Await hauler ticket', } satisfies ToolConfig; @@ -27,12 +26,10 @@ export const resultSchema = awaitResultSchema; export default async function HaulerAwait({ input, signal }: ToolRouteProps) { const context = await agent(); const daemonConfig = requestDaemonConfig(context); - const requestedWaitMs = input.maxWaitMs ?? defaultAwaitMs; + const maxWaitMs = input.maxWaitMs ?? defaultAwaitMs; const startedAt = Date.now(); // The shell frame: the ticket as it is right now, before the wait blocks. const snapshot = await fetchTicketResult(input, { config: daemonConfig, signal }); - // What the render session can still afford after the snapshot fetch. - const maxWaitMs = renderBoundedWaitMs(requestedWaitMs, Date.now() - startedAt); const awaited = awaitTicketResult({ ...input, maxWaitMs }, { config: daemonConfig, // Heartbeats become MCP progress notifications. Progress is best-effort: diff --git a/src/scripts/hauler.ts b/src/scripts/hauler.ts index 0391fffe..3945580a 100644 --- a/src/scripts/hauler.ts +++ b/src/scripts/hauler.ts @@ -1,8 +1,9 @@ import { spawn } from 'node:child_process'; -import { existsSync, realpathSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { AgentTerminal, ExecutableMainContext } from 'agent-bundle'; import * as Cause from 'effect/Cause'; import * as Effect from 'effect/Effect'; @@ -36,6 +37,9 @@ Commands: an older install (in-flight tickets end as "orphaned by daemon restart") install-shim [--dir DIR] [--real-cargo PATH] [--force] Install an optional PATH cargo shim + dashboard [--target HOST] [--port N] [--no-open] + Serve the dashboard App in a plain browser tab against the running + daemon (from the plugin checkout or the npm package) until Ctrl-C status | log | last | await | result | request [--after TICKET] -- Routed commands; run \`cargo-hauler --help\` for options `; @@ -43,6 +47,8 @@ Commands: export interface ScriptOptions { readonly runExec?: (options: RunExecOptions) => Effect.Effect; readonly signal?: AbortSignal; + /** The process's terminal as the executable envelope probed it; `exec` shapes its output by it. */ + readonly terminal?: AgentTerminal; readonly write?: (value: string) => void; readonly writeStderr?: (data: string | Uint8Array) => void; readonly writeStdout?: (data: Uint8Array) => void; @@ -149,6 +155,7 @@ const runExecCommand = async (argv: readonly string[], options: ScriptOptions): ...(parsed.background ? { background: true } : {}), ...(parsed.after === undefined ? {} : { after: parsed.after }), ...(session === undefined ? {} : { session }), + ...(options.terminal === undefined ? {} : { terminal: options.terminal }), }).pipe( Effect.map((result) => result.exitCode), Effect.catchCause((cause) => @@ -205,6 +212,156 @@ const runDaemonCommand = async ( return daemonExitCode(result); }; +const dashboardUsage = 'Usage: hauler dashboard [--target claude|codex|cursor|portable] [--port N] [--no-open]\n'; + +const dashboardTargets = ['claude', 'codex', 'cursor', 'portable'] as const; + +type DashboardTarget = (typeof dashboardTargets)[number]; + +const isDashboardTarget = (value: string): value is DashboardTarget => + (dashboardTargets as readonly string[]).includes(value); + +interface DashboardArgv { + readonly open: boolean; + readonly port?: number; + readonly target: DashboardTarget; +} + +const parseDashboardArgv = (rest: readonly string[]): DashboardArgv | undefined => { + let open = true; + let port: number | undefined; + let target: DashboardTarget = 'portable'; + for (let index = 0; index < rest.length; index += 1) { + const argument = rest[index]; + switch (argument) { + case '--no-open': + open = false; + break; + case '--port': { + const value = Number(rest[index + 1]); + if (!Number.isInteger(value) || value < 0 || value > 65_535) { + return undefined; + } + port = value; + index += 1; + break; + } + case '--target': { + const value = rest[index + 1]; + if (value === undefined || !isDashboardTarget(value)) { + return undefined; + } + target = value; + index += 1; + break; + } + default: + return undefined; + } + } + return { open, ...(port === undefined ? {} : { port }), target }; +}; + +/** + * Where this entry lives decides what it can serve: `dist/bin/hauler.js` sits + * two levels under the project root (a checkout or the npm package), whose + * `artifact/` holds every built host pack; `artifact//scripts/hauler.mjs` + * sits two levels under that artifact root. An installed host pack is neither + * and has no artifact to serve from. + */ +const locateDashboardProject = (): { readonly artifact: string; readonly root: string } | undefined => { + const here = fileURLToPath(new URL('../../', import.meta.url)); + if (existsSync(join(here, 'agent-bundle.manifest.json'))) { + return { artifact: here, root: dirname(here) }; + } + if (existsSync(join(here, 'artifact', 'agent-bundle.manifest.json'))) { + return { artifact: join(here, 'artifact'), root: here }; + } + return undefined; +}; + +/** + * The framework's own CLI as the project resolves it: the `agent-bundle` + * package under the nearest `node_modules` at or above the root, read from its + * manifest's `bin`. Looked up by path, not `require.resolve`: the package's + * `exports` declare no `require` condition, and an `import()` of it here + * would drag the framework into every bundle that carries this entry. + */ +const frameworkCliPath = (root: string): string | undefined => { + for (let directory = root; ; directory = dirname(directory)) { + const packageDir = join(directory, 'node_modules', 'agent-bundle'); + const manifestPath = join(packageDir, 'package.json'); + if (existsSync(manifestPath)) { + const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf8')); + const bin = typeof manifest === 'object' && manifest !== null && 'bin' in manifest ? manifest.bin : undefined; + const relative = + typeof bin === 'string' ? bin : typeof bin === 'object' && bin !== null ? Reflect.get(bin, 'agent-bundle') : undefined; + return typeof relative === 'string' ? resolve(packageDir, relative) : undefined; + } + if (directory === dirname(directory)) { + return undefined; + } + } +}; + +/** + * `hauler dashboard`: the MCP App in a plain browser tab, outside any MCP + * host. It runs the framework's own `agent-bundle serve-app` (agent-bundle#514) + * against this install's built artifact: the packed `hauler` server launches + * exactly as `mcp run` launches it, the App is bound to it through the + * Workbench's host stack on a loopback origin, `hauler_status` is called once + * so it opens populated, and `call-tool` is approved so the panels may poll; + * the command stays in the foreground until Ctrl-C or the server exits. + * + * The framework is spawned, never imported: a routed command importing + * `agent-bundle/api` would either inline the framework into every host pack's + * bin or leave a bare import the artifact validator rejects (`AB6005`). The + * packs stay self-contained, and `agent-bundle` is resolved from the project + * at run time — the checkout (or an install that adds it) has it; an + * installed host pack does not, and says so. + */ +const runDashboard = (rest: readonly string[], options: ScriptOptions): Promise => { + const write = options.write ?? defaultWrite; + const writeStderr = options.writeStderr ?? defaultWriteStderr; + const argv = parseDashboardArgv(rest); + if (argv === undefined) { + write(dashboardUsage); + return Promise.resolve(2); + } + const project = locateDashboardProject(); + if (project === undefined) { + writeStderr( + 'hauler dashboard runs from the plugin checkout or the npm package, where the built artifact sits beside the CLI; an installed host pack has none. In an MCP host, call hauler_status instead — the dashboard App is attached to its result.\n', + ); + return Promise.resolve(1); + } + const cli = frameworkCliPath(project.root); + if (cli === undefined) { + writeStderr( + `hauler dashboard needs agent-bundle resolvable from ${project.root} (\`pnpm install\` in the checkout). In an MCP host, call hauler_status instead — the dashboard App is attached to its result.\n`, + ); + return Promise.resolve(1); + } + const serveArgv = [ + cli, + 'serve-app', + 'hauler/dashboard', + '--root', + project.root, + '--artifact', + project.artifact, + '--target', + argv.target, + '--tool', + 'hauler_status', + '--allow', + 'call-tool', + argv.open ? '--open' : '--no-open', + ...(argv.port === undefined ? [] : ['--port', String(argv.port)]), + ]; + return forwardToProcess(serveArgv, options); +}; + /** * The generated routed CLI: `dist/bin/cargo-hauler.js` beside this script in * the npm package, or `bin/cargo-hauler.mjs` one level up in a host artifact @@ -220,26 +377,42 @@ const routedCliPath = (): string | null => { return null; }; -const forwardToRoutedCli = (argv: readonly string[], options: ScriptOptions): Promise => +/** + * Runs `node