Skip to content
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/node-embedded` starts and controls the real `relay node up` broker lifecycle inside long-lived Node.js hosts without exiting the host process, installing global signal handlers, or taking over console output.
- `AgentRelayBrokerSDK` (Swift) reaches broker-control/observability parity with the TypeScript harness driver: `listAgents`, `sendInput`, `resizePty`, `flushPending`, `snapshot`, full-payload `sendMessage` (with `mode`), `setModel`, `subscribeChannels`/`unsubscribeChannels`, `getStatus`, `getMetrics`, `getCrashInsights`, `preflight`, and `renewLease` on `AgentRelayBrokerClient`, plus the `Codable` response types (`ListAgent`, `BrokerStatus`, `PtySnapshot`, `MetricsResponse`, `CrashInsightsResponse`, and related).

### Fixed
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ Node workflow runs use Relayflows for YAML, TypeScript, and Python workflow file

Hosted equivalents live under `agent-relay cloud …`.

## Embedded node lifecycle

Long-lived Node.js hosts can start the same foreground broker lifecycle without
letting the CLI own `process.exit`, global signal handlers, `process.env`, or
console output:

```ts
import { startEmbeddedNode } from 'agent-relay/node-embedded';

const started = await startEmbeddedNode(
{ config: '/absolute/path/to/agent-relay.mjs' },
{ onOutput: ({ level, message }) => hostLogger[level](message) }
);
if (!started.ok) {
throw new Error(started.message);
}

process.once('SIGTERM', () => void started.handle.stop('SIGTERM'));
await started.handle.completion;
```

The host owns signal forwarding and calls the idempotent `handle.stop()` when
it wants Relay to shut down. Embedded startup is foreground-only;
`background: true` returns a structured code-2 failure because detached mode
belongs to the process-oriented CLI. Use `downEmbeddedNode` and
`statusEmbeddedNode` when controlling a broker through its persisted state.

## Packages

- `@agent-relay/sdk`: messaging, delivery contracts, and actions.
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-relay",
"version": "10.2.0",
"version": "10.3.0",
"description": "Real-time agent-to-agent communication system",
"type": "module",
"main": "dist/index.cjs",
Expand All @@ -22,6 +22,10 @@
"types": "./dist/cli/agent-relay-mcp.d.ts",
"import": "./dist/cli/agent-relay-mcp.js"
},
"./node-embedded": {
"types": "./dist/node-embedded.d.ts",
"import": "./dist/node-embedded.js"
},
"./package.json": "./package.json"
},
"bin": {
Expand Down
38 changes: 33 additions & 5 deletions packages/cli/src/cli/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ export interface CoreDependencies {
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
/** Stdio ownership for the optional Python node-provider child. */
pythonProviderStdio?: 'inherit' | 'ignore';
/** Optional structured logger factory for long-lived node capability providers. */
createNodeLogger?: (component: string) => {
debug: (message: string, extra?: Record<string, unknown>) => void;
info: (message: string, extra?: Record<string, unknown>) => void;
warn: (message: string, extra?: Record<string, unknown>) => void;
error: (message: string, extra?: Record<string, unknown>) => void;
};
exit: ExitFn;
}

Expand Down Expand Up @@ -142,30 +151,49 @@ function resolveCliVersion(fileSystem: CoreFileSystem): string {
}
}

async function createDefaultRelay(
export interface CoreRelayRuntimeOptions {
/** Environment inherited by the broker process. Defaults to process.env for the CLI. */
env?: NodeJS.ProcessEnv;
/** Human-readable broker startup step sink used by embedders. */
onStep?: (message: string) => void;
/** Broker stderr sink used by embedders. */
onStderr?: (line: string) => void;
}

/**
* Construct the production relay client used by `up`.
*
* The optional runtime overrides let an in-process host isolate environment
* and output ownership. The normal CLI deliberately omits them and retains
* its existing process.env / console behavior.
*/
export async function createDefaultRelay(
cwd: string,
apiPort = 0,
brokerName?: string,
verbose = false
verbose = false,
runtime: CoreRelayRuntimeOptions = {}
): Promise<CoreRelay> {
const binaryArgs: BrokerInitArgs = {};
if (apiPort > 0) {
binaryArgs.persist = true;
binaryArgs.apiPort = apiPort;
}
const stateDir = process.env.AGENT_RELAY_STATE_DIR;
const env = runtime.env ?? process.env;
const stateDir = env.AGENT_RELAY_STATE_DIR;
if (stateDir) {
binaryArgs.stateDir = stateDir;
}
const client = await createRuntimeClient({
cwd,
binaryArgs,
brokerName,
env,
preferConnect: apiPort > 0,
...(verbose
? {
onStep: (message: string) => console.error(`[agent-relay][verbose] ${message}`),
onStderr: (line: string) => console.error(`[broker] ${line}`),
onStep: runtime.onStep ?? ((message: string) => console.error(`[agent-relay][verbose] ${message}`)),
onStderr: runtime.onStderr ?? ((line: string) => console.error(`[broker] ${line}`)),
}
: {}),
});
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/cli/lib/broker-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
} from './node-definition-loader.js';
import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js';

type UpOptions = {
export type UpOptions = {
spawn?: boolean;
background?: boolean;
verbose?: boolean;
Expand All @@ -41,7 +41,7 @@
logJson?: boolean;
};

type DownOptions = {
export type DownOptions = {
force?: boolean;
all?: boolean;
timeout?: string;
Expand All @@ -62,6 +62,8 @@
// RELAY_NODE_TOKEN.
const NODE_TOKEN_WAIT_MS = 15_000;

export type StatusOptions = { stateDir?: string; waitFor?: string };

export interface BrokerConnection {
url: string;
port: number;
Expand Down Expand Up @@ -504,7 +506,7 @@
// flag, keep the prior behavior: the registration summary via log, warnings
// via warn.
...(nodeLoggingEnabled(options)
? { logger: createLogger('fleet') }
? { logger: deps.createNodeLogger?.('fleet') ?? createLogger('fleet') }
: { warn: (message) => deps.warn(message), log: (message) => deps.log(message) }),
})
);
Expand Down Expand Up @@ -565,7 +567,10 @@
...(credentials.baseUrl ? { RELAY_BASE_URL: credentials.baseUrl } : {}),
};
try {
const child = deps.spawnProcess(python, [configPath], { stdio: 'inherit', env });
const child = deps.spawnProcess(python, [configPath], {
stdio: deps.pythonProviderStdio ?? 'inherit',
env,
});
deps.log(
`Serving Python node provider: ${python} ${path.basename(configPath)} (pid: ${child.pid ?? 'unknown'}).`
);
Expand All @@ -581,7 +586,7 @@
}

function extractBrokerLockDir(message: string): string | null {
const match = message.match(/another broker instance is already running in this directory \(([^)]+)\)/i);

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with 'another broker instance is already running in this directory (' and with many repetitions of 'another broker instance is already running in this directory (('.
return match?.[1] ?? null;
}

Expand Down Expand Up @@ -1452,10 +1457,7 @@
}
}

export async function runStatusCommand(
deps: CoreDependencies,
options?: { stateDir?: string; waitFor?: string }
): Promise<void> {
export async function runStatusCommand(deps: CoreDependencies, options?: StatusOptions): Promise<void> {
const paths = deps.getProjectPaths();
if (options?.stateDir) {
paths.dataDir = path.resolve(options.stateDir);
Expand Down
Loading
Loading