diff --git a/.changeset/repin-c2ffe5ec5.md b/.changeset/repin-c2ffe5ec5.md new file mode 100644 index 00000000..e1e9ed83 --- /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). (#83) diff --git a/README.md b/README.md index 4b0330d2..74a082c5 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 claude\|codex\|cursor\|portable] [--port N] [--no-open]` | 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` (through `agent-bundle serve-app`), call `hauler_status` once so it opens populated, and stay in the foreground until Ctrl-C. A checkout command: it needs the built `artifact/` beside the CLI and `agent-bundle` under `node_modules` (`pnpm install && pnpm build`); the npm package ships no runtime dependencies and an installed host pack has no artifact, so both report what is missing. 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,9 @@ 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` (from the +plugin checkout) 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 +719,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 +735,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/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: 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/cli/dashboard.ts b/src/cli/dashboard.ts new file mode 100644 index 00000000..0d944054 --- /dev/null +++ b/src/cli/dashboard.ts @@ -0,0 +1,181 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +/** + * `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. A + * plain command, not a rendered one: an open browser tab has no render budget. + * + * The framework is spawned, never imported: a route that imported + * `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 has it under `node_modules`; the npm package + * (no runtime dependencies, #82) and an installed host pack do not, and the + * command says so. + */ +export const config = { + description: + 'Open the cargo-hauler dashboard in a browser: serve the MCP App standalone against the plugin\'s own hauler server (from the plugin checkout) and stay in the foreground until Ctrl-C.', + exitCode: 'result', +} satisfies CliRouteConfig; + +export const inputSchema = z + .object({ + noOpen: z.boolean().optional().describe('Print the URL without opening the default browser'), + port: z.number().int().min(0).max(65_535).optional().describe('Loopback port for the host page (default 0: ephemeral)'), + target: z + .enum(['claude', 'codex', 'cursor', 'portable']) + .optional() + .describe('The built host pack whose hauler server to bind (default portable)'), + }) + .strict(); + +export const resultSchema = z + .object({ + exitCode: z.number().int().min(0).max(255), + message: z.string(), + operation: z.literal('dashboard'), + /** The served host page, once `agent-bundle serve-app` printed it. */ + url: z.string().nullable(), + }) + .strict(); + +type DashboardResult = z.infer; + +/** + * Where this CLI lives decides what it can serve: `dist/bin/cargo-hauler.js` + * sits two levels under the project root (a checkout or the npm package), + * whose `artifact/` holds every built host pack; `artifact//bin/ + * cargo-hauler.mjs` sits two levels under that artifact root. An installed + * host pack is neither and has no artifact to serve from. + */ +const locateProject = (): { 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 bin that carries this route. + */ +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; + } + } +}; + +const failure = (message: string): DashboardResult => ({ exitCode: 1, message, operation: 'dashboard', url: null }); + +const inHost = 'In an MCP host, call hauler_status instead — the dashboard App is attached to its result.'; + +/** `agent-bundle serve-app` prints `MCP App at (…)` once the host listens. */ +const servedUrl = (line: string): string | undefined => /\bat (https?:\/\/\S+)/u.exec(line)?.[1]; + +/** + * Runs the framework CLI in the foreground. The routed CLI owns stdout for the + * result document, so the child's stdout — the URL line — is relayed to + * stderr, the operator's channel; its stderr is inherited. The request signal + * (Ctrl-C reaching the routed CLI) becomes the child's SIGTERM. + */ +const serve = ( + argv: readonly string[], + signal: AbortSignal, +): Promise<{ readonly exitCode: number; readonly url: string | null }> => + new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, argv, { stdio: ['ignore', 'pipe', 'inherit'] }); + let url: string | null = null; + let buffered = ''; + child.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8'); + process.stderr.write(text); + buffered += text; + for (const line of buffered.split('\n').slice(0, -1)) { + url ??= servedUrl(line) ?? null; + } + buffered = buffered.slice(buffered.lastIndexOf('\n') + 1); + }); + const stop = (): void => { + child.kill('SIGTERM'); + }; + signal.addEventListener('abort', stop, { once: true }); + child.once('error', reject); + child.once('exit', (code, exitSignal) => { + signal.removeEventListener('abort', stop); + resolvePromise({ exitCode: code ?? (exitSignal === null ? 1 : 128), url }); + }); + }); + +export default async function Dashboard({ input, signal }: CliRouteProps): Promise { + const project = locateProject(); + if (project === undefined) { + return failure( + `hauler dashboard runs from the plugin checkout, where the built artifact sits beside the CLI; an installed host pack has none. ${inHost}`, + ); + } + const cli = frameworkCliPath(project.root); + if (cli === undefined) { + return failure( + `hauler dashboard needs agent-bundle resolvable from ${project.root} (\`pnpm install\` in the checkout; the npm package ships no runtime dependencies). ${inHost}`, + ); + } + const served = await serve( + [ + cli, + 'serve-app', + 'hauler/dashboard', + '--root', + project.root, + '--artifact', + project.artifact, + '--target', + input.target ?? 'portable', + '--tool', + 'hauler_status', + '--allow', + 'call-tool', + input.noOpen === true ? '--no-open' : '--open', + ...(input.port === undefined ? [] : ['--port', String(input.port)]), + ], + signal, + ); + return { + exitCode: served.exitCode, + message: + served.exitCode === 0 + ? 'dashboard closed' + : `agent-bundle serve-app exited with ${String(served.exitCode)}; see its diagnostics above`, + operation: 'dashboard', + url: served.url, + }; +} 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..5798ae2e 100644 --- a/src/scripts/hauler.ts +++ b/src/scripts/hauler.ts @@ -3,6 +3,7 @@ import { existsSync, realpathSync } from 'node:fs'; import { 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,13 +37,15 @@ 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 - status | log | last | await | result | request [--after TICKET] -- + status | log | last | await | result | request [--after TICKET] -- | dashboard Routed commands; run \`cargo-hauler --help\` for options `; 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 +152,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) => @@ -220,26 +224,42 @@ const routedCliPath = (): string | null => { return null; }; -const forwardToRoutedCli = (argv: readonly string[], options: ScriptOptions): Promise => +/** + * Runs `node