From 4e78e52144a0660e0b5e7b67fec0251604914cbd Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 4 Jun 2026 09:18:41 -0400 Subject: [PATCH 1/5] ci(publish): publish @agent-relay/harnesses on release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @agent-relay/harnesses was set up as a public package (no private flag, publishConfig.access public, versioned in lockstep) but was never wired into the publish workflow, so it never reached npm. External SDK consumers (e.g. relayflows) need it for the prebuilt PTY harnesses and the definePtyHarness/createHuman author helpers. Add a publish-harnesses job to the package=all path. It runs after publish-packages — where its exact-version workspace deps (@agent-relay/sdk, @agent-relay/harness-driver) land on the registry — so an external `npm install @agent-relay/harnesses@` can always resolve its dependencies. Mirrors the existing provenance + skip-if-exists publish pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 49 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 50 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c5698cd5a..596284f75 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -964,6 +964,55 @@ jobs: fi npm publish --access public --provenance --tag ${{ github.event.inputs.tag }} --ignore-scripts + # Publish @agent-relay/harnesses after the publish-packages matrix, which is + # where its exact-version workspace deps (@agent-relay/sdk and + # @agent-relay/harness-driver) land on the registry. Publishing harnesses + # before those exist would leave a window where + # `npm install @agent-relay/harnesses@` cannot resolve its dependencies — + # the same install race the broker/sdk ordering above is built to avoid. + publish-harnesses: + name: Publish @agent-relay/harnesses + needs: [build, publish-packages] + runs-on: ubuntu-latest + if: github.event.inputs.package == 'all' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.14.0' + registry-url: 'https://registry.npmjs.org' + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-output + path: . + + - name: Update npm for OIDC support + run: npm install -g npm@latest + + - name: Dry run check + if: github.event.inputs.dry_run == 'true' + working-directory: packages/harnesses + run: npm publish --dry-run --access public --tag ${{ github.event.inputs.tag }} --ignore-scripts + + - name: Publish to NPM + if: github.event.inputs.dry_run != 'true' + working-directory: packages/harnesses + run: | + set -euo pipefail + PKG_NAME=$(node -p "require('./package.json').name") + PKG_VERSION=$(node -p "require('./package.json').version") + if npm view "${PKG_NAME}@${PKG_VERSION}" version >/dev/null 2>&1; then + echo "${PKG_NAME}@${PKG_VERSION} already exists on npm; skipping publish" + exit 0 + fi + npm publish --access public --provenance --tag ${{ github.event.inputs.tag }} --ignore-scripts + # package=main publishes only the root `agent-relay` tarball, but that # tarball pins several @agent-relay/* runtime dependencies to the freshly # bumped version. Publish those direct deps first so a main-only release diff --git a/CHANGELOG.md b/CHANGELOG.md index ec20c9775..277c10305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `@agent-relay/harnesses` is now published to npm, so external SDK consumers can install the prebuilt PTY harnesses (`claude`, `codex`, `gemini`, …) and the `definePtyHarness`/`defineHarness`/`createHuman` author helpers. - `agent-relay drive` and `agent-relay passthrough` add adaptive predictive echo so typing stays responsive when driving a high-latency or remote agent, and stays invisible on fast local links. - `@agent-relay/harness-driver` exports a reusable `PredictiveEchoEngine` so other attach UIs (CLI, Electron, browser) can share one predictive-echo implementation. - `@agent-relay/sdk` `relay.addListener(...)` on a workspace client now receives all workspace-visible events: `events.connect()` opens the relaycast 2.5 workspace stream when no agent client is present, so the documented `relay.addListener('message.created', ...)` quickstart path streams without registering an agent. From 4da8efc7bcfc684e3e3cbc87cb427dbdc0e5d09b Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 4 Jun 2026 09:36:44 -0400 Subject: [PATCH 2/5] ci(publish): gate release on publish-harnesses; trim changelog Address PR review: publish-harnesses ran outside the release gate, so a tag/release could be cut even if harness publishing failed. - create-release now needs publish-harnesses and its `if` requires the job to not have failed. It tolerates `skipped` so package=main releases (where publish-harnesses does not run) are not blocked. - summary job lists the harness publish result. - Trim the changelog bullet to impact-first per the repo changelog rule. Leaving the new job's actions on @v4 tags to match the rest of the workflow (the repo uses tag refs throughout; SHA-pinning would be inconsistent and is not the enforced policy here). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 9 ++++++++- CHANGELOG.md | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 596284f75..e33d4cd34 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1577,13 +1577,18 @@ jobs: # Create git tag and release create-release: name: Create Release - needs: [build, build-broker, build-standalone, verify-binaries, publish-main] + needs: [build, build-broker, build-standalone, verify-binaries, publish-main, publish-harnesses] runs-on: ubuntu-latest + # publish-harnesses only runs for package=all; for a package=main release it + # is skipped, which must not block the tag. Gate on "not failed" rather than + # "succeeded" so a real harness publish failure stops the release but a + # skipped one does not. if: | always() && github.event.inputs.package != 'cli-prerelease' && github.event.inputs.dry_run != 'true' && needs.publish-main.result == 'success' && + (needs.publish-harnesses.result == 'success' || needs.publish-harnesses.result == 'skipped') && needs.publish-main.outputs.published == 'true' steps: @@ -2060,6 +2065,7 @@ jobs: publish-sdk-internal-deps, publish-broker-packages, publish-packages, + publish-harnesses, publish-brand-only, publish-sdk-py, publish-main, @@ -2102,6 +2108,7 @@ jobs: echo "| Publish SDK Internal Deps | ${{ needs.publish-sdk-internal-deps.result == 'success' && '✅' || (needs.publish-sdk-internal-deps.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-sdk-internal-deps.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Publish Broker Packages | ${{ needs.publish-broker-packages.result == 'success' && '✅' || (needs.publish-broker-packages.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-broker-packages.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Publish Packages | ${{ needs.publish-packages.result == 'success' && '✅' || (needs.publish-packages.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-packages.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Publish Harnesses | ${{ needs.publish-harnesses.result == 'success' && '✅' || (needs.publish-harnesses.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-harnesses.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Publish Brand | ${{ needs.publish-brand-only.result == 'success' && '✅' || (needs.publish-brand-only.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-brand-only.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Publish Python SDK | ${{ needs.publish-sdk-py.result == 'success' && '✅' || (needs.publish-sdk-py.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-sdk-py.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Publish Main | ${{ needs.publish-main.result == 'success' && '✅' || (needs.publish-main.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-main.result }} |" >> $GITHUB_STEP_SUMMARY diff --git a/CHANGELOG.md b/CHANGELOG.md index 277c10305..f26466668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `@agent-relay/harnesses` is now published to npm, so external SDK consumers can install the prebuilt PTY harnesses (`claude`, `codex`, `gemini`, …) and the `definePtyHarness`/`defineHarness`/`createHuman` author helpers. +- `@agent-relay/harnesses` is now published to npm, so SDK consumers can install the prebuilt PTY harnesses and harness-authoring helpers. - `agent-relay drive` and `agent-relay passthrough` add adaptive predictive echo so typing stays responsive when driving a high-latency or remote agent, and stays invisible on fast local links. - `@agent-relay/harness-driver` exports a reusable `PredictiveEchoEngine` so other attach UIs (CLI, Electron, browser) can share one predictive-echo implementation. - `@agent-relay/sdk` `relay.addListener(...)` on a workspace client now receives all workspace-visible events: `events.connect()` opens the relaycast 2.5 workspace stream when no agent client is present, so the documented `relay.addListener('message.created', ...)` quickstart path streams without registering an agent. From b0924259c50c0c925fb3a4199069781ccf29fa68 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 4 Jun 2026 10:46:07 -0400 Subject: [PATCH 3/5] feat(harness-driver): add lifecycle-aware SpawnedAgentHandle `spawnPty`/`spawnCli`/`spawnHeadless` now return a `SpawnedAgentHandle` instead of a bare `SpawnAgentResult`. The handle is a structural superset (still carries name/runtime/sessionId/pid) so existing callers are unaffected, and it adds the promise-based lifecycle operations consumers previously had to reconstruct from the raw broker event stream: - waitForExit() -> { reason, code, signal } - waitForIdle() -> { reason, idleSecs } - exit / exitCode / exitSignal (synchronous view of a prior exit) - release() All operations are backed by the client's broker event stream and its event history, so they are replay-correct: awaiting after the agent has already exited resolves immediately from history rather than hanging. Typechecks clean; harness-driver tests pass; dependents (harnesses, sdk) unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/harness-driver/src/agent-handle.ts | 150 ++++++++++++++++++++ packages/harness-driver/src/client.ts | 17 +-- packages/harness-driver/src/index.ts | 1 + 3 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 packages/harness-driver/src/agent-handle.ts diff --git a/packages/harness-driver/src/agent-handle.ts b/packages/harness-driver/src/agent-handle.ts new file mode 100644 index 000000000..2a1b1990b --- /dev/null +++ b/packages/harness-driver/src/agent-handle.ts @@ -0,0 +1,150 @@ +/** + * Lifecycle-aware handle for a spawned agent. + * + * `HarnessDriverClient.spawnPty()` / `spawnCli()` / `spawnHeadless()` return one + * of these instead of a bare {@link SpawnAgentResult}. It is a structural + * superset of `SpawnAgentResult` (it still carries `name` / `runtime` / + * `sessionId` / `pid`), so existing callers are unaffected, and it adds the + * promise-based lifecycle operations consumers previously had to reconstruct + * from the raw broker event stream: + * + * - `waitForExit()` — resolve when the agent exits, with `code` / `signal`. + * - `waitForIdle()` — resolve on the next idle signal (or on exit). + * - `exit` / `exitCode` / `exitSignal` — synchronous view of a prior exit. + * - `release()` — release the agent via the broker. + * + * All operations are backed by the client's broker event stream and its event + * history, so they are replay-correct: calling `waitForExit()` after the agent + * has already exited resolves immediately from history rather than hanging. + */ +import type { HarnessDriverClient } from './client.js'; +import type { AgentRuntime, BrokerEvent } from './protocol.js'; +import type { SpawnAgentResult } from './types.js'; + +export interface AgentExitInfo { + /** `'exited'` when the agent exited; `'timeout'` when the wait elapsed first. */ + reason: 'exited' | 'timeout'; + /** Process exit code, when the broker reported one. */ + code?: number; + /** Terminating signal, when the agent was killed by one. */ + signal?: string; +} + +export interface AgentIdleInfo { + /** `'idle'` on an idle signal, `'exited'` if the agent exited first, `'timeout'` otherwise. */ + reason: 'idle' | 'exited' | 'timeout'; + /** Seconds the agent has been idle, when `reason === 'idle'`. */ + idleSecs?: number; + /** Exit details, when `reason === 'exited'`. */ + exit?: AgentExitInfo; +} + +export class SpawnedAgentHandle implements SpawnAgentResult { + readonly name: string; + readonly runtime: AgentRuntime; + readonly sessionId?: string; + readonly pid?: number; + + constructor( + result: SpawnAgentResult, + private readonly client: HarnessDriverClient + ) { + this.name = result.name; + this.runtime = result.runtime; + this.sessionId = result.sessionId; + this.pid = result.pid; + } + + /** Exit info if the agent has already exited (from broker event history), else `undefined`. */ + get exit(): AgentExitInfo | undefined { + const exited = this.client.getLastEvent('agent_exited', this.name); + if (exited && exited.kind === 'agent_exited') { + return { reason: 'exited', code: exited.code, signal: exited.signal }; + } + const exit = this.client.getLastEvent('agent_exit', this.name); + if (exit && exit.kind === 'agent_exit') { + return { reason: 'exited' }; + } + return undefined; + } + + get exitCode(): number | undefined { + return this.exit?.code; + } + + get exitSignal(): string | undefined { + return this.exit?.signal; + } + + /** + * Resolve when the agent exits (with `code` / `signal` when the broker reports + * them), or with `{ reason: 'timeout' }` if `timeoutMs` elapses first. Replays + * a prior exit from broker history, so it is safe to call after the fact. + */ + waitForExit(timeoutMs?: number): Promise { + const already = this.exit; + if (already) return Promise.resolve(already); + + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const settle = (info: AgentExitInfo) => { + if (timer) clearTimeout(timer); + unsub(); + resolve(info); + }; + const unsub = this.client.onEvent((event: BrokerEvent) => { + const exit = matchExit(event, this.name); + if (exit) settle(exit); + }); + if (timeoutMs !== undefined) { + timer = setTimeout(() => settle({ reason: 'timeout' }), timeoutMs); + } + }); + } + + /** + * Resolve on the next idle signal for this agent (edge-triggered: a fresh + * signal after the call, matching how runners poll-then-nudge). Also resolves + * if the agent exits first, or with `{ reason: 'timeout' }` after `timeoutMs`. + */ + waitForIdle(timeoutMs?: number): Promise { + const already = this.exit; + if (already) return Promise.resolve({ reason: 'exited', exit: already }); + + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const settle = (info: AgentIdleInfo) => { + if (timer) clearTimeout(timer); + unsubIdle(); + unsubExit(); + resolve(info); + }; + const unsubIdle = this.client.addListener('agentIdle', (payload) => { + if (payload.name === this.name) settle({ reason: 'idle', idleSecs: payload.idleSecs }); + }); + const unsubExit = this.client.onEvent((event: BrokerEvent) => { + const exit = matchExit(event, this.name); + if (exit) settle({ reason: 'exited', exit }); + }); + if (timeoutMs !== undefined) { + timer = setTimeout(() => settle({ reason: 'timeout' }), timeoutMs); + } + }); + } + + /** Release the agent via the broker. */ + release(reason?: string): Promise<{ name: string }> { + return this.client.release(this.name, reason); + } +} + +/** Match an exit `BrokerEvent` for `name`, normalising the two exit kinds. */ +function matchExit(event: BrokerEvent, name: string): AgentExitInfo | undefined { + if (event.kind === 'agent_exited' && event.name === name) { + return { reason: 'exited', code: event.code, signal: event.signal }; + } + if (event.kind === 'agent_exit' && event.name === name) { + return { reason: 'exited' }; + } + return undefined; +} diff --git a/packages/harness-driver/src/client.ts b/packages/harness-driver/src/client.ts index b4b3321fc..5e6ffa3f5 100644 --- a/packages/harness-driver/src/client.ts +++ b/packages/harness-driver/src/client.ts @@ -43,6 +43,7 @@ import type { ListAgent, } from './types.js'; import { EventBus } from './event-bus.js'; +import { SpawnedAgentHandle } from './agent-handle.js'; import type { AfterAgentReleaseContext, AfterAgentSpawnContext, @@ -621,7 +622,7 @@ export class HarnessDriverClient { // ── Agent lifecycle ──────────────────────────────────────────────── - async spawnPty(input: SpawnPtyInput): Promise { + async spawnPty(input: SpawnPtyInput): Promise { const beforeCtx: BeforeAgentSpawnContext = { kind: 'pty', input, @@ -638,14 +639,14 @@ export class HarnessDriverClient { }); const result = SpawnAgentResultSchema.parse(rawResult); await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, result, undefined); - return result; + return new SpawnedAgentHandle(result, this); } catch (err) { await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, undefined, err); throw err; } } - async spawnCli(input: SpawnCliInput): Promise { + async spawnCli(input: SpawnCliInput): Promise { const beforeCtx: BeforeAgentSpawnContext = { kind: 'cli', input, @@ -659,7 +660,7 @@ export class HarnessDriverClient { private async spawnCliWithContext( beforeCtx: BeforeAgentSpawnContext, input: SpawnCliInput - ): Promise { + ): Promise { const t0 = Date.now(); const resolvedInput = await this.runBeforeSpawn(beforeCtx); const transport = resolveSpawnTransport(resolvedInput); @@ -680,14 +681,14 @@ export class HarnessDriverClient { }); const result = SpawnAgentResultSchema.parse(rawResult); await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, result, undefined); - return result; + return new SpawnedAgentHandle(result, this); } catch (err) { await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, undefined, err); throw err; } } - async spawnHeadless(input: SpawnHeadlessInput): Promise { + async spawnHeadless(input: SpawnHeadlessInput): Promise { const cliInput: SpawnCliInput = { ...input, transport: 'headless' }; const beforeCtx: BeforeAgentSpawnContext = { kind: 'headless', @@ -699,11 +700,11 @@ export class HarnessDriverClient { return this.spawnCliWithContext(beforeCtx, cliInput); } - async spawnClaude(input: Omit): Promise { + async spawnClaude(input: Omit): Promise { return this.spawnCli({ ...input, cli: 'claude' }); } - async spawnOpencode(input: Omit): Promise { + async spawnOpencode(input: Omit): Promise { return this.spawnCli({ ...input, cli: 'opencode' }); } diff --git a/packages/harness-driver/src/index.ts b/packages/harness-driver/src/index.ts index 68c7cb4e7..7faef08e1 100644 --- a/packages/harness-driver/src/index.ts +++ b/packages/harness-driver/src/index.ts @@ -1,4 +1,5 @@ export * from './driver-types.js'; +export * from './agent-handle.js'; export * from './broker-driver.js'; export * from './actions.js'; export * from './client.js'; From 639e8a9969acb5c747d5b5509564e189686a7ab7 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 4 Jun 2026 10:56:24 -0400 Subject: [PATCH 4/5] fix(harness-driver): source waitForIdle from broker event stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client `eventBus` only carries call-site hook events; broker events like `agentIdle` are never emitted onto it in direct-client usage, so `addListener('agentIdle', ...)` never fired and `waitForIdle()` could hang. Match the `agent_idle` BrokerEvent on the transport stream via `onEvent` instead — consistent with how `waitForExit()` already works. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/harness-driver/src/agent-handle.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/harness-driver/src/agent-handle.ts b/packages/harness-driver/src/agent-handle.ts index 2a1b1990b..6227e15ba 100644 --- a/packages/harness-driver/src/agent-handle.ts +++ b/packages/harness-driver/src/agent-handle.ts @@ -115,14 +115,17 @@ export class SpawnedAgentHandle implements SpawnAgentResult { let timer: ReturnType | undefined; const settle = (info: AgentIdleInfo) => { if (timer) clearTimeout(timer); - unsubIdle(); - unsubExit(); + unsub(); resolve(info); }; - const unsubIdle = this.client.addListener('agentIdle', (payload) => { - if (payload.name === this.name) settle({ reason: 'idle', idleSecs: payload.idleSecs }); - }); - const unsubExit = this.client.onEvent((event: BrokerEvent) => { + // Idle and exit both arrive on the broker event stream. The named + // `agentIdle` event bus is only populated by call-site hooks (not broker + // events) in direct-client usage, so it must not be used here. + const unsub = this.client.onEvent((event: BrokerEvent) => { + if (event.kind === 'agent_idle' && event.name === this.name) { + settle({ reason: 'idle', idleSecs: event.idle_secs }); + return; + } const exit = matchExit(event, this.name); if (exit) settle({ reason: 'exited', exit }); }); From 991086ded45b05b353981e51a1e2b3dc89691ef4 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 4 Jun 2026 10:59:21 -0400 Subject: [PATCH 5/5] fix(harness-driver): ensure broker event stream is live in handle waits `transport.onEvent()` only registers a listener; events arrive only once the stream is connected. Call the idempotent `connectEvents()` at the start of `waitForExit`/`waitForIdle` so they receive events even if the consumer never connected the stream, instead of hanging until timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/harness-driver/src/agent-handle.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/harness-driver/src/agent-handle.ts b/packages/harness-driver/src/agent-handle.ts index 6227e15ba..1a6f37dc9 100644 --- a/packages/harness-driver/src/agent-handle.ts +++ b/packages/harness-driver/src/agent-handle.ts @@ -82,6 +82,10 @@ export class SpawnedAgentHandle implements SpawnAgentResult { * a prior exit from broker history, so it is safe to call after the fact. */ waitForExit(timeoutMs?: number): Promise { + // Ensure the broker event stream is live — `onEvent` only registers a + // listener; without an active connection no events ever arrive. Idempotent. + this.client.connectEvents(); + const already = this.exit; if (already) return Promise.resolve(already); @@ -108,6 +112,9 @@ export class SpawnedAgentHandle implements SpawnAgentResult { * if the agent exits first, or with `{ reason: 'timeout' }` after `timeoutMs`. */ waitForIdle(timeoutMs?: number): Promise { + // Ensure the broker event stream is live (see waitForExit). Idempotent. + this.client.connectEvents(); + const already = this.exit; if (already) return Promise.resolve({ reason: 'exited', exit: already });