Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@<v>` 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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -2011,6 +2065,7 @@ jobs:
publish-sdk-internal-deps,
publish-broker-packages,
publish-packages,
publish-harnesses,
publish-brand-only,
publish-sdk-py,
publish-main,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
160 changes: 160 additions & 0 deletions packages/harness-driver/src/agent-handle.ts
Original file line number Diff line number Diff line change
@@ -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' };
}
Comment on lines +64 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: agent_exit is an exit-request signal (not a confirmed process termination), but this code treats it as a completed exit. That can make waitForExit() and exit resolve too early, before the process has actually terminated and before a real exit code/signal is known. Only resolve exit completion from the confirmed agent_exited event. [api mismatch]

Severity Level: Critical 🚨
- ❌ Callers may treat agents as terminated while still running.
- ⚠️ Exit codes and signals may never be observed or recorded.
- ⚠️ Workflow runners may start dependent steps prematurely.
Steps of Reproduction ✅
1. In a scenario where the broker protocol distinguishes between an `agent_exit` request
and a confirmed `agent_exited` event (definitions live in
`packages/harness-driver/src/protocol.ts` and are consumed via `BrokerEvent` imported at
line 21 in `agent-handle.ts`), configure or observe an agent where `agent_exit` is emitted
before the underlying process has actually terminated.

2. Spawn this agent via `HarnessDriverClient.spawnPty()` or related APIs (implemented in
`packages/harness-driver/src/client.ts`), which now return a `SpawnedAgentHandle` that
uses the `exit` getter at `packages/harness-driver/src/agent-handle.ts:59-68` to derive
its lifecycle state.

3. Call `await handle.waitForExit()` shortly after the exit is requested; inside
`waitForExit()` at lines 84-103, `const already = this.exit;` runs the `exit` getter,
which first checks `getLastEvent('agent_exited', this.name)` and then falls back to
`getLastEvent('agent_exit', this.name)`.

4. Because `agent_exit` has been recorded but `agent_exited` has not yet occurred,
`this.client.getLastEvent('agent_exit', this.name)` returns the pending exit-request
event, the `exit` getter returns `{ reason: 'exited' }`, and `waitForExit()` resolves as
if the process has fully exited even though the real termination (and exit code/signal)
has not yet been observed.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/harness-driver/src/agent-handle.ts
**Line:** 64:67
**Comment:**
	*Api Mismatch: `agent_exit` is an exit-request signal (not a confirmed process termination), but this code treats it as a completed exit. That can make `waitForExit()` and `exit` resolve too early, before the process has actually terminated and before a real exit code/signal is known. Only resolve exit completion from the confirmed `agent_exited` event.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

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<AgentExitInfo> {
// 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<AgentExitInfo>((resolve) => {
let timer: ReturnType<typeof setTimeout> | 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<AgentIdleInfo> {
// 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<AgentIdleInfo>((resolve) => {
let timer: ReturnType<typeof setTimeout> | 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);
}
});
Comment on lines +84 to +142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Start the broker event stream before waiting.

waitForExit() and waitForIdle() subscribe to future lifecycle events, but neither method ensures the client's WS stream is connected. HarnessDriverClient leaves that as a separate step (connectEvents() at packages/harness-driver/src/client.ts, Lines 572-574), and subscribeWorkerStream() has to opt in explicitly before listening (Lines 843-845). On a client created via new HarnessDriverClient(...) or connect(), these waits will otherwise only ever resolve from already-cached history and can hang indefinitely for new exits/idles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/harness-driver/src/agent-handle.ts` around lines 84 - 132, Both
waitForExit and waitForIdle currently subscribe to future events but don't
ensure the client's event stream or per-worker subscription are active; make
each method async, and before creating the new Promise call await
this.client.connectEvents() and await
this.client.subscribeWorkerStream(this.name) (or the equivalent
ensure-subscription API on HarnessDriverClient) so the WS/event stream and
worker-specific stream are started; update the signatures of waitForExit and
waitForIdle to async Promise<AgentExitInfo> / async Promise<AgentIdleInfo> so
callers still get a promise.

}

/** 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' };
Comment on lines +156 to +157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop treating agent_exit as process exit

For PTY/headless agents, the broker emits agent_exit when the worker reports an exit/close reason, and then maintenance emits the real agent_exited event with code/signal; the Python/Swift SDKs also model agent_exit separately as an exit-requested event. Resolving waitForExit() on agent_exit means callers waiting before shutdown commonly get { reason: 'exited' } before the process has actually reached agent_exited, losing the exit code/signal this API promises and potentially continuing cleanup too early.

Useful? React with 👍 / 👎.

}
return undefined;
}
17 changes: 9 additions & 8 deletions packages/harness-driver/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -621,7 +622,7 @@ export class HarnessDriverClient {

// ── Agent lifecycle ────────────────────────────────────────────────

async spawnPty(input: SpawnPtyInput): Promise<SpawnAgentResult> {
async spawnPty(input: SpawnPtyInput): Promise<SpawnedAgentHandle> {
const beforeCtx: BeforeAgentSpawnContext<SpawnPtyInput> = {
kind: 'pty',
input,
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This now returns a class instance that carries an internal client reference, so callers that spread or serialize the spawn result can unintentionally expose deep internal state (including transport credentials) instead of just agent metadata. Keep internals non-enumerable/opaque (for example with true private fields and controlled serialization) before returning this object shape publicly. [security]

Severity Level: Critical 🚨
- ❌ JSON serializing SpawnedAgentHandle leaks BrokerTransport.apiKey credential.
- ⚠️ Logs may contain workspace_key and broker base URL.
Steps of Reproduction ✅
1. `HarnessDriverClient.spawnPty()` in `packages/harness-driver/src/client.ts:46-63`
parses the broker response into `result` using `SpawnAgentResultSchema` and now returns
`new SpawnedAgentHandle(result, this)` (line 63), instead of returning the plain `result`
object.

2. `SpawnedAgentHandle` in `packages/harness-driver/src/agent-handle.ts:42-56` stores the
passed client reference as a `private readonly client: HarnessDriverClient` constructor
parameter (lines 48-51); in the emitted JavaScript this becomes a normal instance property
(not an ES `#private` field), so it is an own, enumerable property of the handle object at
runtime.

3. The `HarnessDriverClient` instance stored on the handle holds a `BrokerTransport` with
an `apiKey` field (`packages/harness-driver/src/transport.ts:132-151`) and a
`workspaceKey` field on the client itself
(`packages/harness-driver/src/client.ts:139-144`), both of which are normal object
properties; nothing marks them as non-enumerable or excludes them from serialization.

4. When a downstream consumer obtains a handle from `spawnPty()` (for example, by adapting
the existing CLI entry `spawnAgentWithClient` in
`packages/cli/src/cli/lib/client-factory.ts:10-14` to `const handle = await
client.spawnPty(options);`) and then logs or transmits it via `JSON.stringify(handle)` or
`{ ...handle }`, JSON serialization walks enumerable own properties and includes the
nested `client` object. That nested object in turn contains the `BrokerTransport.apiKey`
and any `workspaceKey`, so the serialized spawn result now leaks internal connection
credentials and driver state instead of just the intended metadata (`name`, `runtime`,
`sessionId`, `pid`).

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/harness-driver/src/client.ts
**Line:** 642:642
**Comment:**
	*Security: This now returns a class instance that carries an internal `client` reference, so callers that spread or serialize the spawn result can unintentionally expose deep internal state (including transport credentials) instead of just agent metadata. Keep internals non-enumerable/opaque (for example with true private fields and controlled serialization) before returning this object shape publicly.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

} catch (err) {
await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, undefined, err);
throw err;
}
}

async spawnCli(input: SpawnCliInput): Promise<SpawnAgentResult> {
async spawnCli(input: SpawnCliInput): Promise<SpawnedAgentHandle> {
const beforeCtx: BeforeAgentSpawnContext<SpawnCliInput> = {
kind: 'cli',
input,
Expand All @@ -659,7 +660,7 @@ export class HarnessDriverClient {
private async spawnCliWithContext(
beforeCtx: BeforeAgentSpawnContext<SpawnCliInput>,
input: SpawnCliInput
): Promise<SpawnAgentResult> {
): Promise<SpawnedAgentHandle> {
const t0 = Date.now();
const resolvedInput = await this.runBeforeSpawn(beforeCtx);
const transport = resolveSpawnTransport(resolvedInput);
Expand All @@ -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<SpawnAgentResult> {
async spawnHeadless(input: SpawnHeadlessInput): Promise<SpawnedAgentHandle> {
const cliInput: SpawnCliInput = { ...input, transport: 'headless' };
const beforeCtx: BeforeAgentSpawnContext<SpawnCliInput> = {
kind: 'headless',
Expand All @@ -699,11 +700,11 @@ export class HarnessDriverClient {
return this.spawnCliWithContext(beforeCtx, cliInput);
}

async spawnClaude(input: Omit<SpawnCliInput, 'cli'>): Promise<SpawnAgentResult> {
async spawnClaude(input: Omit<SpawnCliInput, 'cli'>): Promise<SpawnedAgentHandle> {
return this.spawnCli({ ...input, cli: 'claude' });
}

async spawnOpencode(input: Omit<SpawnCliInput, 'cli'>): Promise<SpawnAgentResult> {
async spawnOpencode(input: Omit<SpawnCliInput, 'cli'>): Promise<SpawnedAgentHandle> {
return this.spawnCli({ ...input, cli: 'opencode' });
}

Expand Down
1 change: 1 addition & 0 deletions packages/harness-driver/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading