Skip to content

feat(cli): drive relayfile integration ops over the control-plane socket (no shell-out)#1215

Merged
khaliqgant merged 9 commits into
mainfrom
feat/relayfile-control-plane-client
Jun 29, 2026
Merged

feat(cli): drive relayfile integration ops over the control-plane socket (no shell-out)#1215
khaliqgant merged 9 commits into
mainfrom
feat/relayfile-control-plane-client

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jun 29, 2026

Copy link
Copy Markdown
Member

What

Replaces agent-relay's stringly-typed spawn('relayfile', …) + parse-stdout bridge with a typed client over relayfile's control-plane unix socket (relayfile control-plane serve). Integration ops (connect/bind/list/unbind/resolve/writeback) now go over a versioned, schema-checked wire instead of argv-as-request / stdout-as-response.

This is the relay half of the control-plane RFC. The relayfile half (daemon + OpenAPI + @relayfile/client package + 0.10.17) is a paired PR on the relayfile repo.

Why

The CLI-as-RPC contract produced a recurring class of compile-time-preventable bugs that only surfaced at runtime (e.g. flag provided but not defined: -json, pathGlob vs resource, native-resource passed as a VFS glob). The socket contract is version-negotiated via /v1/hello and the request/response types are generated from the OpenAPI, so field drift is a compile error, not a runtime surprise.

Changes

  • relayfile-client.ts — typed control-plane client: HTTP/JSON over http.request({ socketPath }) (zero deps), X-Relayfile-API-Version handshake, daemon auto-start (one lifecycle launch) + RELAYFILE_REQUIRE_DAEMON=1 strict never-spawn mode, typed methods aliasing the generated schemas.
  • defaultRelayfileBridge swapped onto the client; runRelayfile/spawn deleted. The RelayfileBridge interface is unchanged — subscribe/unsubscribe callers are untouched.
  • subscribe/unsubscribe canonicalize the native --resource to relayfile's stored path-glob via resolve-path before keying find/unbind (fixes the silent native-vs-glob mismatch).
  • Types generated from a vendored copy of relayfile's openapi/relayfile-control-plane-v1.openapi.yaml (npm run codegen:relayfile, reproducible). *.gen.ts lint-ignored.
  • Tests: real-daemon contract test (boots the daemon, drives the bridge over the socket) + client lifecycle units (require-daemon error, version-incompat gate, no-cache-on-failure, auto-start).

Verification

  • 447 cli tests pass (real-daemon contract tests run when RELAYFILE_BIN is set, skip otherwise), tsc + eslint clean, codegen reproducible.
  • Verified end-to-end against a real relayfile control-plane serve daemon built from the paired relayfile branch.

⚠️ Merge ordering / follow-up

  • Requires relayfile ≥ 0.10.17 (carries the control-plane + --version). The relayfile PR must publish first; the compat gate intentionally blocks older daemons.
  • The vendored spec + in-repo client is the interim. Before merge I'll push a commit swapping the import to the published @relayfile/client package and deleting the vendored spec/codegen (the package is built + green on the relayfile side; @worker owns its publish).
  • Follow-up: CI step to build/install relayfile and set RELAYFILE_BIN so the contract tests run in CI (deferred until 0.10.17 is on npm).

🤖 Generated with Claude Code

Review in cubic

…ket (no shell-out)

Replace the stringly-typed `spawn('relayfile', …)` + parse-stdout bridge with a
typed client over relayfile's control-plane unix socket (`relayfile
control-plane serve`). The contract is now version-negotiated via `/v1/hello`,
and request/response types are generated from relayfile's OpenAPI — so field
drift is a compile error, not a runtime surprise.

- New `relayfile-client.ts`: HTTP/JSON over `http.request({ socketPath })`,
  `X-Relayfile-API-Version` handshake, daemon auto-start (+ RELAYFILE_REQUIRE_DAEMON
  strict mode), typed methods aliasing the generated schemas.
- `defaultRelayfileBridge` swapped onto the client; `runRelayfile`/`spawn`
  deleted. The `RelayfileBridge` interface is unchanged, so subscribe/unsubscribe
  callers are untouched.
- subscribe/unsubscribe now canonicalize the native `--resource` to relayfile's
  stored path-glob via `resolve-path` before keying find/unbind (fixes the
  silent native-vs-glob mismatch).
- Types generated from a vendored copy of relayfile's
  openapi/relayfile-control-plane-v1.openapi.yaml (`npm run codegen:relayfile`).
  This is the interim home; it will be swapped to the published `@relayfile/client`
  package before merge once relayfile 0.10.16 ships.
- Tests: real-daemon contract test (boots the daemon, drives the bridge over the
  socket) + client lifecycle units. Requires relayfile >= 0.10.16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@khaliqgant khaliqgant requested a review from willwashburn as a code owner June 29, 2026 22:55
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ee96fbb1-39fc-44cf-a747-47a58794bb73

📥 Commits

Reviewing files that changed from the base of the PR and between 0922dfa and 05d586b.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (5)
  • CHANGELOG.md
  • packages/cli/package.json
  • packages/cli/src/cli/commands/integration-relayfile-contract.test.ts
  • packages/cli/src/cli/commands/integration-subscribe.test.ts
  • packages/cli/src/cli/commands/integration.ts
📝 Walkthrough

Walkthrough

Adds a Relayfile control-plane OpenAPI contract and generated types, implements a typed unix-socket client, and updates integration subscribe/unsubscribe to resolve resources to canonical pathGlob values before binding operations.

Changes

Relayfile control-plane client and integration update

Layer / File(s) Summary
OpenAPI contract and generated types
packages/cli/src/cli/lib/relayfile-contract/relayfile-control-plane-v1.openapi.yaml, packages/cli/src/cli/lib/relayfile-contract/relayfile-control-plane-v1.gen.ts, package.json, packages/cli/.eslintrc.cjs, .prettierignore
Adds the Relayfile control-plane API spec, generated TypeScript contract, and the package/config updates needed to generate and ignore the output.
Relayfile control-plane client
packages/cli/src/cli/lib/relayfile-client.ts
Implements the unix-socket client, version negotiation, request handling, daemon startup, and typed endpoint methods for the control-plane API.
Integration bridge and subscribe/unsubscribe flow
packages/cli/src/cli/commands/integration.ts, CHANGELOG.md
Replaces subprocess relayfile calls with the shared client and updates subscribe/unsubscribe to use pathGlob for binding lookup, webhook naming, bind/unbind, and logging.
Mock updates and contract coverage
packages/cli/src/cli/commands/integration-subscribe.test.ts, packages/cli/src/cli/commands/relaycast-groups.test.ts, packages/cli/src/cli/commands/integration-relayfile-contract.test.ts, packages/cli/src/cli/lib/relayfile-client.test.ts
Extends relayfile mocks and adds tests for version checks, daemon readiness, resource resolution, and bind/list/unbind behavior against the control-plane daemon.

Sequence Diagram(s)

sequenceDiagram
  participant integration.ts
  participant RelayfileControlPlaneClient
  participant relayfile control-plane

  integration.ts->>RelayfileControlPlaneClient: ensureCompatible()
  RelayfileControlPlaneClient->>relayfile control-plane: GET /v1/hello
  relayfile control-plane-->>RelayfileControlPlaneClient: daemonVersion, supportedApiVersions
  RelayfileControlPlaneClient->>RelayfileControlPlaneClient: assertRelayfileVersion()

  integration.ts->>RelayfileControlPlaneClient: resolvePath(provider, resource)
  RelayfileControlPlaneClient->>relayfile control-plane: POST /v1/integrations/resolve-path
  relayfile control-plane-->>RelayfileControlPlaneClient: pathGlob, warning?

  integration.ts->>RelayfileControlPlaneClient: bind / listBindings / unbind
  RelayfileControlPlaneClient->>relayfile control-plane: HTTP over unix socket
  relayfile control-plane-->>RelayfileControlPlaneClient: typed response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • willwashburn

Poem

🐇 I hopped through sockets, swift and bright,
With pathGlob guiding bindings right.
No shell-out dust, just typed control,
And version checks keep every roll.
The relayfile trail feels neat —
A bunny hop, precise and sweet.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description is detailed, but it doesn't follow the repo template and omits the required Summary/Test Plan checkbox format and Screenshots section. Rewrite it with ## Summary, ## Test Plan using the two checkboxes, and ## Screenshots; keep the existing content under those headings and note screenshots as N/A if not applicable.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: moving relayfile integration operations to the control-plane socket instead of shelling out.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/relayfile-control-plane-client

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request replaces the legacy spawn('relayfile') and stdout-parsing approach with a typed client (RelayfileControlPlaneClient) that communicates with the relayfile control-plane daemon over a local Unix domain socket. It introduces generated TypeScript types from the OpenAPI specification, updates CLI commands to use the new client, and adds comprehensive contract and lifecycle tests. The review feedback highlights three important reliability improvements in relayfile-client.ts: handling asynchronous 'error' events on the spawned daemon process to prevent CLI crashes, providing default values in compareSemver to handle partial semver strings robustly, and listening to 'error' events on the HTTP response stream.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +258 to +288
private async startDaemonAndConnect(): Promise<HelloResponse> {
let child;
try {
child = spawn(this.binary, ['control-plane', 'serve', '--sock', this.socketPath], {
detached: true,
stdio: 'ignore',
});
} catch (err) {
throw new RelayfileControlPlaneError(
'DAEMON_UNAVAILABLE',
`failed to start relayfile control-plane (${err instanceof Error ? err.message : String(err)}). ` +
`Install relayfile or set RELAYFILE_BIN.`
);
}
child.unref?.();
const deadline = Date.now() + this.startTimeoutMs;
let lastErr: unknown;
while (Date.now() < deadline) {
await sleep(100);
try {
return await this.hello();
} catch (err) {
lastErr = err;
}
}
throw new RelayfileControlPlaneError(
'DAEMON_UNAVAILABLE',
`relayfile control-plane did not become ready within ${this.startTimeoutMs}ms ` +
`(${lastErr instanceof Error ? lastErr.message : String(lastErr)}).`
);
}

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.

high

When spawning the daemon process via spawn, if the binary is missing or fails to execute, Node.js will emit an 'error' event asynchronously on the returned ChildProcess object. Because there is no 'error' event listener registered on child, this will trigger an uncaught exception and crash the entire CLI process.

Adding an 'error' event listener to the spawned child process prevents this crash and allows the client to fail fast with a descriptive error instead of waiting for the connection timeout.

  private async startDaemonAndConnect(): Promise<HelloResponse> {
    let child;
    let spawnError: Error | undefined;
    try {
      child = spawn(this.binary, ['control-plane', 'serve', '--sock', this.socketPath], {
        detached: true,
        stdio: 'ignore',
      });
      child.on('error', (err) => {
        spawnError = err;
      });
    } catch (err) {
      throw new RelayfileControlPlaneError(
        'DAEMON_UNAVAILABLE',
        `failed to start relayfile control-plane (${err instanceof Error ? err.message : String(err)}). ` +
          `Install relayfile or set RELAYFILE_BIN.`
      );
    }
    child.unref?.();
    const deadline = Date.now() + this.startTimeoutMs;
    let lastErr: unknown;
    while (Date.now() < deadline) {
      if (spawnError) {
        throw new RelayfileControlPlaneError(
          'DAEMON_UNAVAILABLE',
          `failed to start relayfile control-plane (${spawnError.message}). ` +
            `Install relayfile or set RELAYFILE_BIN.`
        );
      }
      await sleep(100);
      try {
        return await this.hello();
      } catch (err) {
        lastErr = err;
      }
    }
    throw new RelayfileControlPlaneError(
      'DAEMON_UNAVAILABLE',
      `relayfile control-plane did not become ready within ${this.startTimeoutMs}ms ` +
        `(${lastErr instanceof Error ? lastErr.message : String(lastErr)}).`
    );
  }

Comment on lines +43 to +45
const [a0, a1, a2] = core(a);
const [b0, b1, b2] = core(b);
return (a0! - b0!) || (a1! - b1!) || (a2! - b2!);

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.

medium

In compareSemver, if either version string a or b is missing minor or patch components (e.g., "0.10"), destructuring core(a) will result in undefined for a2. Subtracting undefined from a number results in NaN, causing the function to return NaN instead of a valid comparison integer.

Using default values during destructuring resolves this issue and ensures robust comparison even for partial semver strings.

Suggested change
const [a0, a1, a2] = core(a);
const [b0, b1, b2] = core(b);
return (a0! - b0!) || (a1! - b1!) || (a2! - b2!);
const [a0 = 0, a1 = 0, a2 = 0] = core(a);
const [b0 = 0, b1 = 0, b2 = 0] = core(b);
return (a0 - b0) || (a1 - b1) || (a2 - b2);

Comment on lines +169 to +170
(res) => {
const chunks: Buffer[] = [];

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.

medium

The IncomingMessage object (res) is a readable stream and can emit an 'error' event. If an error occurs on the response stream and there is no listener, it can lead to unhandled rejections or crashes. Registering a listener that rejects the promise ensures robust error handling.

Suggested change
(res) => {
const chunks: Buffer[] = [];
(res) => {
res.on('error', reject);
const chunks: Buffer[] = [];

@khaliqgant

Copy link
Copy Markdown
Member Author

Depends on / pairs with AgentWorkforce/relayfile#344 (control-plane daemon + OpenAPI + @relayfile/client@0.10.16 + CI codegen drift check + publish workflow).

Merge order: relayfile#344 merges and publishes 0.10.16 first → then a commit lands on this PR swapping the vendored interim client to the published @relayfile/client dependency → then this PR merges. The compat gate here intentionally requires relayfile ≥ 0.10.16.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0f089e8ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +261 to +264
child = spawn(this.binary, ['control-plane', 'serve', '--sock', this.socketPath], {
detached: true,
stdio: 'ignore',
});

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 Handle auto-start spawn errors

When the socket is absent and relayfile is not installed (or RELAYFILE_BIN points to a bad path), spawn() reports ENOENT asynchronously on the returned child rather than throwing here. Because this child has no error listener, the process gets an unhandled error event instead of rejecting with the intended DAEMON_UNAVAILABLE message, so fresh installs/bad binary configs crash during any integration command that auto-starts the daemon.

Useful? React with 👍 / 👎.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 3 potential issues.

Open in Devin Review

Comment on lines +55 to +57
if (!parsed || compareSemver(parsed, MIN_RELAYFILE_VERSION) < 0) {
throw new Error(
`relayfile >= ${MIN_RELAYFILE_VERSION} is required; found ${

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.

🟡 Version-too-old error silently swallowed during writeback secret resolution

The daemon version gate throws a plain Error (throw new Error(...) at packages/cli/src/cli/lib/relayfile-client.ts:56), but the writeback handler only re-throws RelayfileControlPlaneError, so the version failure is silently swallowed and the caller receives undefined instead of an actionable error.

Impact: A too-old daemon's version error could be masked, causing a misleading "Ensure relayfile is logged in" message instead of the intended "update relayfile" prompt.

Error type mismatch between assertRelayfileVersion and resolveWritebackBinding catch clause

In connectAndNegotiate (relayfile-client.ts:232-256), two version checks run:

  1. Line 247–253: API version check → throws new RelayfileControlPlaneError('VERSION_INCOMPATIBLE', ...)
  2. Line 255: assertRelayfileVersion(hello.daemonVersion) → throws plain new Error(...)

In resolveWritebackBinding (integration.ts:219-224), the catch clause is:

if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') throw err;
return undefined;

The plain Error from check #2 fails the instanceof test and is swallowed, returning undefined. The comment on lines 220-221 explicitly states "a daemon/version failure is not [a soft miss]", but this version failure IS silently swallowed.

In the current flow, ensureCompatible() is always called before resolveWritebackBinding (integration.ts:584, integration.ts:691), and ensureReady() caches its result, so the version check in connectAndNegotiate runs only once — meaning this path is unreachable with current callers. However, the error type inconsistency violates the code's stated contract.

Suggested change
if (!parsed || compareSemver(parsed, MIN_RELAYFILE_VERSION) < 0) {
throw new Error(
`relayfile >= ${MIN_RELAYFILE_VERSION} is required; found ${
if (!parsed || compareSemver(parsed, MIN_RELAYFILE_VERSION) < 0) {
throw new RelayfileControlPlaneError(
'VERSION_INCOMPATIBLE',
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

* under RELAYFILE_REQUIRE_DAEMON=1). The wire contract is version-negotiated, so
* field/method drift is a typed error, not a silent runtime surprise.
*/
export function defaultRelayfileBridge(options?: RelayfileClientOptions): RelayfileBridge {

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.

🟡 Changelog not updated for user-visible control-plane migration

No CHANGELOG.md entry was added for this PR's user-visible changes (the [Unreleased] section is unchanged), violating the AGENTS.md rule "Curate [Unreleased] in CHANGELOG.md as you land PRs."

Impact: The release narrative omits significant behavior changes (daemon auto-start, version negotiation, resource path resolution) that users and operators need to know about.

Missing changelog entries for control-plane socket migration

AGENTS.md requires: "Curate [Unreleased] in CHANGELOG.md as you land PRs. The root changelog is the cross-package, user-facing release narrative for Relay."

This PR introduces several user-visible changes:

  • integration subscribe and unsubscribe now talk to a control-plane unix socket daemon instead of spawning relayfile per operation — changing error messages, startup behavior (daemon auto-start), and adding a version-negotiation handshake.
  • Resource identifiers (e.g. owner/repo, #channel) are now automatically resolved to canonical VFS path globs before binding.
  • A new compatibility check (ensureCompatible) gates operations on daemon version, producing actionable "update relayfile" errors.

None of these are documented in the [Unreleased] section of CHANGELOG.md.

Prompt for agents
The AGENTS.md rule requires updating the [Unreleased] section in CHANGELOG.md when landing PRs. This PR introduces several user-visible changes that need changelog entries:

1. integration subscribe/unsubscribe now uses a control-plane unix socket (relayfile control-plane serve) instead of spawning relayfile per operation, with auto-start and version negotiation.
2. Resource identifiers (e.g. owner/repo, #channel) are now resolved to canonical VFS path globs via the daemon's resolve-path endpoint before binding/matching.
3. A compatibility check (ensureCompatible) gates operations on daemon version, producing actionable upgrade errors.

Add one or two concise bullets under Changed in the [Unreleased] section of CHANGELOG.md, following the existing style: name the command touched and the practical effect. For example under Changed: integration subscribe/unsubscribe now communicates with the relayfile daemon over a local control-plane socket (auto-started on first use) instead of spawning one process per operation, and canonicalizes provider-native resource identifiers to the VFS glob relayfile stores bindings under.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 169 to +173
async connect(provider) {
await runRelayfile(['integration', 'connect', provider], { inherit: true });
// OAuth runs in the daemon; on a local daemon the browser still opens
// (noOpen defaults false). The returned output carries any connect URL.
const { output } = await client.connect({ provider });
if (output?.trim()) process.stderr.write(output.endsWith('\n') ? output : `${output}\n`);

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.

🚩 Behavioral change: connect output is buffered instead of streamed

The old connect method used spawn('relayfile', ..., { inherit: true }) which pipes daemon stdout/stderr to the terminal in real time. The new implementation (packages/cli/src/cli/commands/integration.ts:172-173) captures the response body's output field and writes it to stderr only after the HTTP response completes. For OAuth flows that take several seconds (waiting for browser callback), the user sees no progress output until the flow finishes. This is a UX regression if the daemon's connect endpoint produces incremental status messages, but acceptable if the daemon simply opens a browser and waits silently.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@agent-relay-code

Copy link
Copy Markdown
Contributor

Review Summary

PR #1215 replaces the legacy spawn('relayfile') + parse-stdout shell-out with a typed control-plane HTTP/JSON client over the relayfile unix socket. New OpenAPI contract + generated types, a RelayfileControlPlaneClient, version negotiation via /v1/hello, and native→glob resource resolution before bind/find/unbind. The refactor is internally consistent and the design is sound.

Verification performed (the way CI runs it)

  • Generated artifact in sync: npm run codegen:relayfile reproduces the committed *.gen.ts byte-for-byte from the YAML — both before and after my formatting edits.
  • Typecheck: full npm run typecheck (all package builds + cd packages/cli && tsc --noEmit) — passes, 0 errors.
  • Lint: npm run lint — 0 errors (only pre-existing complexity/naming warnings in unrelated files; *.gen.ts correctly eslint-ignored).
  • Tests: the 4 affected test files (relayfile-client, integration-relayfile-contract, integration-subscribe, relaycast-groups) — 39 passed, 5 skipped (opt-in real-daemon tests gated on RELAYFILE_BIN).
  • Bridge consumers traced: the new required RelayfileBridge methods (resolveResourcePath, ensureCompatible) are implemented in the default bridge and added to all mocks; no untouched consumer breaks (cloud-package matches were unrelated substrings).

Mechanical fixes auto-applied (non-semantic)

  • Prettier formatting on integration.ts, relayfile-client.ts, integration-relayfile-contract.test.ts, and the *.openapi.yaml (line-wrapping + YAML quote style). CI's prettier --check . would have failed on these. YAML reformatting is cosmetic — confirmed the regenerated contract is unchanged.
  • .prettierignore: added the generated relayfile-control-plane-v1.gen.ts. CI's prettier --check . flagged it (openapi-typescript output disagrees with prettier), and --write-ing it would drift from the generator. Adding it to .prettierignore matches the repo's existing convention (cli-registry.generated.ts is handled the same way). The PR added *.gen.ts to eslint-ignore but missed the prettier ignore — this would have been a red format:check.

Not changed (left as advisory / human judgment)

  • The connect() behavior change (interactive inherit:true OAuth → socket call writing daemon output to stderr) and the isConnected provider-status semantics are intentional, the point of the PR; not auto-edited.
  • No test was added or weakened.

Advisory Notes

  • CHANGELOG: CLAUDE.md asks landing PRs to curate [Unreleased]. This user-visible change (relayfile integration ops now run over the control-plane socket instead of shelling out) has no entry yet. Adding one is an editorial decision for the author, so I left it unchanged. Suggested Changed bullet: "agent-relay integration commands now drive relayfile over its local control-plane socket (relayfile control-plane serve) via a version-negotiated typed client instead of shelling out to relayfile; the daemon auto-starts on first use (or set RELAYFILE_REQUIRE_DAEMON=1 to require it)."

Addressed comments

  • No bot or human review comments were present in the provided PR context (.workforce/context.json carries no review metadata, and no comments file was supplied), so there were none to reconcile.

Note: CI status could not be observed from this sandbox (no gh/git per instructions); the statements above reflect local reproduction of the build/typecheck/lint/format/test steps, not the live PR checks.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

🧹 Nitpick comments (1)
packages/cli/src/cli/commands/integration-subscribe.test.ts (1)

82-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add one non-identity resolve-path case here.

This stub always returns pathGlob === resource, so these tests would still pass if runSubscribe()/runUnsubscribe() accidentally kept using the native string after resolution. One case that maps owner/repo to /github/repos/owner/repo/** and asserts bind/unbind use the glob would lock in the main regression this PR is fixing.

🤖 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/cli/src/cli/commands/integration-subscribe.test.ts` around lines 82
- 85, Add a non-identity resolve-path test case in integration-subscribe.test.ts
so the mock for resolveResourcePath no longer always returns the input
unchanged. Extend the runSubscribe/runUnsubscribe coverage to include a
provider/resource pair like owner/repo that resolves to
/github/repos/owner/repo/**, and assert the bind/unbind calls use the resolved
pathGlob rather than the original string. Use the existing runSubscribe,
runUnsubscribe, bind, unbind, and resolveResourcePath symbols to place the new
assertion alongside the current stubbed setup.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/cli/src/cli/commands/integration.ts`:
- Around line 214-223: The catch in resolveWritebackBinding is swallowing daemon
outages by returning undefined for DAEMON_UNAVAILABLE and
non-RelayfileControlPlaneError failures, which makes resolveWriteback() report
the wrong remediation. Update the error handling in resolveWritebackBinding to
only treat true missing/disconnected writeback secrets as a soft miss, while
re-throwing daemon/control-plane outage errors from
client.writebackSecret(channel), alongside VERSION_INCOMPATIBLE; keep the
undefined fallback only for expected absent-secret cases.

In `@packages/cli/src/cli/lib/relayfile-client.test.ts`:
- Around line 103-106: The teardown in the relayfile client test uses
spawn('pkill', ...) without handling the case where pkill is missing, which can
crash the suite on some runners. Update the afterEach cleanup in
relayfile-client.test.ts to either use spawnSync for the pkill call, or add an
error handler/platform guard around the existing spawn invocation so missing
pkill does not produce an unhandled child process error. Keep the rest of the
cleanup logic for strays unchanged.

In `@packages/cli/src/cli/lib/relayfile-client.ts`:
- Around line 155-216: Add an explicit timeout to the Unix-socket request flow
in relayfile-client’s request helper so a silent daemon cannot leave the call
hanging indefinitely. Update the http.request-based logic to enforce a
per-request timeout for the path used by startDaemonAndConnect()/v1/hello, and
make sure the timeout rejects with RelayfileControlPlaneError using a clear
daemon-unavailable message. Keep the existing error handling in request() for
response parsing and socket errors, but ensure the new timeout is wired into the
same promise lifecycle.
- Around line 258-272: The daemon startup flow in startDaemonAndConnect() is not
handling spawn failures correctly, and rawRequest() has no timeout. Move the
DAEMON_UNAVAILABLE mapping to the child process’s error handling for spawn() so
ENOENT/bad RELAYFILE_BIN failures from the child’s error event are caught and
surfaced instead of crashing the CLI, and add a timeout or abort path around the
/v1/hello request so a hung hello call cannot wait forever. Use the existing
startDaemonAndConnect(), rawRequest(), and RelayfileControlPlaneError symbols to
wire the fix in the same control-plane client flow.

In
`@packages/cli/src/cli/lib/relayfile-contract/relayfile-control-plane-v1.openapi.yaml`:
- Around line 18-19: The /v1/hello operation is modeled with query/body
versioning only, but the client in relayfile-client.ts actually negotiates via
X-Relayfile-API-Version. Update the hello operation in
relayfile-control-plane-v1.openapi.yaml to include ApiVersionHeader alongside
the existing version parameter setup, and only keep ApiVersionQuery/body if the
daemon truly supports those shapes. Use the existing ApiVersionHeader and
ApiVersionQuery symbols to locate the affected operation and make the contract
match the real handshake.

---

Nitpick comments:
In `@packages/cli/src/cli/commands/integration-subscribe.test.ts`:
- Around line 82-85: Add a non-identity resolve-path test case in
integration-subscribe.test.ts so the mock for resolveResourcePath no longer
always returns the input unchanged. Extend the runSubscribe/runUnsubscribe
coverage to include a provider/resource pair like owner/repo that resolves to
/github/repos/owner/repo/**, and assert the bind/unbind calls use the resolved
pathGlob rather than the original string. Use the existing runSubscribe,
runUnsubscribe, bind, unbind, and resolveResourcePath symbols to place the new
assertion alongside the current stubbed setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d1a0ddff-252f-41fd-95c7-5ee4e1d68e0c

📥 Commits

Reviewing files that changed from the base of the PR and between b1a05af and f0f089e.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (10)
  • package.json
  • packages/cli/.eslintrc.cjs
  • packages/cli/src/cli/commands/integration-relayfile-contract.test.ts
  • packages/cli/src/cli/commands/integration-subscribe.test.ts
  • packages/cli/src/cli/commands/integration.ts
  • packages/cli/src/cli/commands/relaycast-groups.test.ts
  • packages/cli/src/cli/lib/relayfile-client.test.ts
  • packages/cli/src/cli/lib/relayfile-client.ts
  • packages/cli/src/cli/lib/relayfile-contract/relayfile-control-plane-v1.gen.ts
  • packages/cli/src/cli/lib/relayfile-contract/relayfile-control-plane-v1.openapi.yaml

Comment on lines 214 to 223
async resolveWritebackBinding(channel) {
try {
const output = await runRelayfile([
'integration',
'writeback-secret',
'--channel',
channel,
'--json',
]);
const parsed = JSON.parse(output) as { url?: unknown; secret?: unknown };
const url = typeof parsed.url === 'string' ? parsed.url.trim() : '';
const secret = typeof parsed.secret === 'string' ? parsed.secret.trim() : '';
if (!url || !secret) {
return undefined;
}
return { url, secret };
} catch {
const { url, secret } = await client.writebackSecret(channel);
if (!url?.trim() || !secret?.trim()) return undefined;
return { url: url.trim(), secret: secret.trim() };
} catch (err) {
// A missing/disconnected writeback secret is a soft miss (caller falls
// back to --bridge-url/--bridge-secret); a daemon/version failure is not.
if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') throw err;
return undefined;

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Re-throw daemon outages instead of treating them like a missing secret.

This catch currently converts DAEMON_UNAVAILABLE and any non-RelayfileControlPlaneError into undefined, so resolveWriteback() reports a login/flag remediation even when the control-plane died mid-command. That hides the real failure mode and contradicts the comment above this block.

Suggested fix
       } catch (err) {
         // A missing/disconnected writeback secret is a soft miss (caller falls
         // back to --bridge-url/--bridge-secret); a daemon/version failure is not.
-        if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') throw err;
+        if (!(err instanceof RelayfileControlPlaneError)) throw err;
+        if (err.code === 'VERSION_INCOMPATIBLE' || err.code === 'DAEMON_UNAVAILABLE') throw err;
         return undefined;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async resolveWritebackBinding(channel) {
try {
const output = await runRelayfile([
'integration',
'writeback-secret',
'--channel',
channel,
'--json',
]);
const parsed = JSON.parse(output) as { url?: unknown; secret?: unknown };
const url = typeof parsed.url === 'string' ? parsed.url.trim() : '';
const secret = typeof parsed.secret === 'string' ? parsed.secret.trim() : '';
if (!url || !secret) {
return undefined;
}
return { url, secret };
} catch {
const { url, secret } = await client.writebackSecret(channel);
if (!url?.trim() || !secret?.trim()) return undefined;
return { url: url.trim(), secret: secret.trim() };
} catch (err) {
// A missing/disconnected writeback secret is a soft miss (caller falls
// back to --bridge-url/--bridge-secret); a daemon/version failure is not.
if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') throw err;
return undefined;
async resolveWritebackBinding(channel) {
try {
const { url, secret } = await client.writebackSecret(channel);
if (!url?.trim() || !secret?.trim()) return undefined;
return { url: url.trim(), secret: secret.trim() };
} catch (err) {
// A missing/disconnected writeback secret is a soft miss (caller falls
// back to --bridge-url/--bridge-secret); a daemon/version failure is not.
if (!(err instanceof RelayfileControlPlaneError)) throw err;
if (err.code === 'VERSION_INCOMPATIBLE' || err.code === 'DAEMON_UNAVAILABLE') throw err;
return undefined;
}
🤖 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/cli/src/cli/commands/integration.ts` around lines 214 - 223, The
catch in resolveWritebackBinding is swallowing daemon outages by returning
undefined for DAEMON_UNAVAILABLE and non-RelayfileControlPlaneError failures,
which makes resolveWriteback() report the wrong remediation. Update the error
handling in resolveWritebackBinding to only treat true missing/disconnected
writeback secrets as a soft miss, while re-throwing daemon/control-plane outage
errors from client.writebackSecret(channel), alongside VERSION_INCOMPATIBLE;
keep the undefined fallback only for expected absent-secret cases.

Comment thread packages/cli/src/cli/lib/relayfile-client.test.ts Outdated
Comment thread packages/cli/src/cli/lib/relayfile-client.ts Outdated
Comment thread packages/cli/src/cli/lib/relayfile-client.ts Outdated
@agent-relay-code

Copy link
Copy Markdown
Contributor

Working tree is clean — I made no edits (no mechanical fixes were needed). All verification was non-destructive.

Review of PR #1215feat/relayfile-control-plane-client

Summary

This PR replaces the legacy spawn('relayfile', …) + stdout-parsing shell-out with a typed control-plane client (RelayfileControlPlaneClient) that talks HTTP/JSON over a unix domain socket. Request/response types are derived from a committed OpenAPI contract (relayfile-control-plane-v1.openapi.yaml.gen.ts via openapi-typescript). The RelayfileBridge gains two methods (resolveResourcePath, ensureCompatible); runSubscribe/runUnsubscribe now canonicalize the native resource to the VFS pathGlob before matching/binding/unbinding.

Verification (ran the way CI does)

  • npm ci — clean install, lockfile consistent.
  • npm run codegen:relayfile then diff vs committed .gen.tsidentical (generated artifact is in sync with the openapi.yaml source).
  • CLI tsc --noEmitpasses (confirms all RelayfileBridge mocks satisfy the two new required interface methods).
  • npm run lint0 errors (17 pre-existing warnings, none in changed files; .gen.ts correctly added to eslint ignorePatterns).
  • npx vitest run packages/cli (full suite) — 442 passed, 10 skipped. The 5 newly-added skips are opt-in real-daemon contract tests gated on RELAYFILE_BIN.
  • prettier --check on changed sources — clean.

Correctness notes (verified, no action needed)

  • hello() issues rawRequest directly, while public endpoints go through request()ensureReady(). No recursion; readiness is negotiated once and a failed probe is not cached (covered by a test).
  • isConnected passes [entry] (a ProviderStatus) into readProviderConnected, which reads record.provider/record.status — both present in the schema. Consistent.
  • resolveWritebackBinding now re-throws only VERSION_INCOMPATIBLE and otherwise returns undefined. This is not a fail-open regression: a soft miss still falls back to --bridge-url/--bridge-secret, and a version mismatch surfaces loudly instead of being silently swallowed as "no secret". ensureCompatible() already runs at the top of subscribe/unsubscribe, so the version gate fires before this path.
  • No lifecycle/reaper/dispatch/broker-ownership code is touched.

Addressed comments

No bot or human review comments were provided in .workforce/ (only pr.diff, changed-files.txt, context.json). The prior commit chore: apply pr-reviewer fixes for #1215 indicates an earlier review pass already landed; nothing further was actionable in the current checkout. Nothing to address.

Advisory Notes

  • None. The change is self-contained to the relayfile integration path and stays within the PR's purpose.

CI status

I cannot observe GitHub's live check runs or mergeability from this sandbox; I verified the build/typecheck/lint/test steps locally and they pass with the tree as-is. Because required-check completion and merge-conflict status are post-harness facts I cannot confirm here, I am not asserting they are green.

No files were modified — no mechanical fixes were required, and all logic is sound, so there was nothing to auto-edit or leave as a fix.

Addresses bot review on #1215:
- spawn(): listen for the child's async 'error' event so a missing binary /
  bad RELAYFILE_BIN maps to DAEMON_UNAVAILABLE instead of crashing the CLI.
- rawRequest(): add a per-request timeout so a hung socket rejects with
  DAEMON_UNAVAILABLE instead of blocking startDaemonAndConnect forever.
- listen for 'error' on the response stream.
- compareSemver(): default partial-semver components to 0 (avoid NaN).
- assertRelayfileVersion(): throw a typed RelayfileControlPlaneError
  ('VERSION_INCOMPATIBLE') so callers re-throwing daemon/version failures by
  code don't silently swallow it.
- resolveWritebackBinding(): re-throw DAEMON_UNAVAILABLE / VERSION_INCOMPATIBLE
  rather than masking a daemon outage as a missing secret.
- test: auto-start with a missing binary fails fast with DAEMON_UNAVAILABLE.
- CHANGELOG: [Unreleased] entry for the control-plane socket migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/cli/src/cli/lib/relayfile-client.ts (1)

322-345: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Poll requests can overrun startTimeoutMs.

The default requestTimeoutMs (10000) is larger than the default startTimeoutMs (5000). During auto-start polling, each hello() carries the full per-request timeout, so once the socket exists but the daemon wedges mid-handshake, a single hello() can block ~10s and blow past the 5s start budget that startTimeoutMs documents ("how long to wait for an auto-started daemon to answer /v1/hello"). Before the socket exists this is harmless (fast ENOENT/ECONNREFUSED), but a slow-initializing daemon makes the budget non-binding.

Consider bounding each poll by the remaining deadline.

🤖 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/cli/src/cli/lib/relayfile-client.ts` around lines 322 - 345, The
auto-start polling in relayfile-client’s startup path can exceed the documented
start budget because hello() uses the full request timeout on each retry. Update
the polling loop in the startup/auto-start logic around hello() so each attempt
is bounded by the remaining time until the deadline, rather than
requestTimeoutMs, and keep the existing RelayfileControlPlaneError handling for
spawnError and timeout failure.
🤖 Prompt for all review comments with 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.

Outside diff comments:
In `@packages/cli/src/cli/lib/relayfile-client.ts`:
- Around line 322-345: The auto-start polling in relayfile-client’s startup path
can exceed the documented start budget because hello() uses the full request
timeout on each retry. Update the polling loop in the startup/auto-start logic
around hello() so each attempt is bounded by the remaining time until the
deadline, rather than requestTimeoutMs, and keep the existing
RelayfileControlPlaneError handling for spawnError and timeout failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: af647b9f-c49a-4a33-b3f5-8ddcd5332107

📥 Commits

Reviewing files that changed from the base of the PR and between 6f2c37a and 104ec08.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/cli/src/cli/commands/integration.ts
  • packages/cli/src/cli/lib/relayfile-client.test.ts
  • packages/cli/src/cli/lib/relayfile-client.ts
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli/src/cli/commands/integration.ts

relayfile already released 0.10.16 for the native-resource bind fix, so the
control-plane + @relayfile/client release target moves to 0.10.17. Align the
relay compat gate and version-gate tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@khaliqgant

Copy link
Copy Markdown
Member Author

Review feedback addressed (pushed):

  • 🔴 spawn() daemon auto-start now registers a child 'error' listener → a missing binary / bad RELAYFILE_BIN fails fast with DAEMON_UNAVAILABLE instead of crashing the CLI (+ a regression test).
  • 🔴 rawRequest() now sets a per-request timeout (req.on('timeout')req.destroy) so a hung /v1/hello rejects instead of blocking startDaemonAndConnect forever.
  • Response stream 'error' is now handled (res.on('error', …)).
  • compareSemver() defaults partial-semver components to 0 (no NaN).
  • assertRelayfileVersion() throws a typed RelayfileControlPlaneError('VERSION_INCOMPATIBLE'); resolveWritebackBinding() now re-throws DAEMON_UNAVAILABLE/VERSION_INCOMPATIBLE instead of masking a daemon outage as a missing secret.
  • CHANGELOG [Unreleased] entry added.
  • MIN_RELAYFILE_VERSION bumped to 0.10.17 (relayfile already shipped 0.10.16 for the native-resource bind fix; the control-plane + @relayfile/client target 0.10.17).

On the connect output-buffering note: the flow already logs "Opening browser to connect …" before the call for immediate feedback; real-time streaming of the OAuth transcript needs daemon-side structured ConnectProvider events (tracked as a follow-up on the relayfile side). The OpenAPI /v1/hello header-modeling note is being fixed in the authoritative spec on relayfile#344; the relay copy is regenerated from it.

448 tests green, tsc + eslint clean, codegen reproducible.

Proactive Runtime Bot and others added 3 commits June 29, 2026 16:29
…l-plane-client

# Conflicts:
#	packages/cli/src/cli/commands/integration.ts
Picks up the /v1/hello X-Relayfile-API-Version header docs added on the
relayfile side, regenerates the client types, and prettier-ignores the vendored
spec so it stays byte-identical to the authoritative source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
it('rejects a version older than the minimum with an actionable message', () => {
expect(() => assertRelayfileVersion('0.10.15')).toThrow(
new RegExp(
`relayfile >= ${MIN_RELAYFILE_VERSION.replace(/\./g, '\\.')} is required; found "0\\.10\\.15"`
@relayfile/client@0.10.17 is published, so swap the integration bridge onto the
published package and delete the in-repo interim client + vendored OpenAPI +
local codegen (openapi-typescript devDep, codegen:relayfile script). The client's
lifecycle/version tests now live in the package; relay keeps the bridge-level
real-daemon contract test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@khaliqgant

Copy link
Copy Markdown
Member Author

Final swap landed ✅ — @relayfile/client@0.10.17 is published to npm, so this PR now consumes the published package directly:

  • integration.ts imports from @relayfile/client (was the in-repo interim client).
  • Deleted the vendored interim: relayfile-client.ts, relayfile-contract/ (spec + generated types), the codegen:relayfile script, and the openapi-typescript devDep.
  • The client's version/lifecycle tests now live in @relayfile/client; this repo keeps the bridge-level real-daemon contract test.

So the PR is no longer publish-gated — it's the end state: agent-relay drives integration ops through the published, version-negotiated @relayfile/client over the control-plane socket. 440 tests green, tsc + eslint + prettier clean.

Pick up the latest published @relayfile/client. API version (1) and the daemon
min (0.10.17) are unchanged, so the wire contract and version gate are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@khaliqgant khaliqgant merged commit 06c8a9f into main Jun 29, 2026
45 checks passed
@khaliqgant khaliqgant deleted the feat/relayfile-control-plane-client branch June 29, 2026 23:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants