diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c5698cd5a..e33d4cd34 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 @@ -1528,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: @@ -2011,6 +2065,7 @@ jobs: publish-sdk-internal-deps, publish-broker-packages, publish-packages, + publish-harnesses, publish-brand-only, publish-sdk-py, publish-main, @@ -2053,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 ec20c9775..f26466668 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 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. diff --git a/packages/harness-driver/src/agent-handle.ts b/packages/harness-driver/src/agent-handle.ts new file mode 100644 index 000000000..1a6f37dc9 --- /dev/null +++ b/packages/harness-driver/src/agent-handle.ts @@ -0,0 +1,160 @@ +/** + * 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 { + // 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); + + 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 { + // 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 }); + + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const settle = (info: AgentIdleInfo) => { + if (timer) clearTimeout(timer); + unsub(); + resolve(info); + }; + // 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 }); + }); + 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';