feat: add cc-relay extension for Claude Code CLI bridge#58448
feat: add cc-relay extension for Claude Code CLI bridge#58448haoziqi778 wants to merge 5 commits into
Conversation
Adds a new plugin that bridges OpenClaw agents to a local Claude Code CLI process, enabling an "OpenClaw shell + Claude Code brain" architecture. Key features: - Three modes: relay (forward all), hybrid (agent decides), tool-only - System prompt injection via before_prompt_build hook (no SOUL.md changes) - Background task queue with serial execution and progress reporting - Automatic file change detection and attachment delivery - Works with all channels via outbound adapters Inspired by the T800 bridge architecture. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ec5ecb29a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Greptile SummaryThis PR adds the Issues found:
Confidence Score: 4/5Safe to merge once the two P1 issues (missing Two P1-level issues require fixes before merge: a TypeScript error in the test fixture that breaks
|
| Filename | Overview |
|---|---|
| extensions/cc-relay/src/dispatcher.test.ts | Test fixture baseConfig is missing the required mode field from CcRelayConfig, causing a TypeScript type error caught by pnpm tsgo. |
| extensions/cc-relay/src/session-parser.ts | Core result-extraction has a logic bug: skipAfterCompaction is never reset, so all assistant entries after a compaction event — including the actual final result — are silently dropped. |
| extensions/cc-relay/src/worker.ts | Two-stage timeout and shell-quoting look correct. The --permission-mode default always-passed issue may break the Claude CLI when using default config. |
| extensions/cc-relay/src/dispatcher.ts | Serial queue design is sound. Temp file for oversized results can leak if sendFile throws before unlinkSync. |
| extensions/cc-relay/src/progress-reporter.ts | Incremental byte-offset polling and session-rotation detection look correct. Interval and wait-handle cleanup in stop() is properly guarded. |
| extensions/cc-relay/index.ts | Lazy dispatcher initialization via tool factory, before_prompt_build hook injection, and service lifecycle callbacks look correct. |
| extensions/cc-relay/package.json | Missing dependencies section — @sinclair/typebox is used at runtime but not declared, relying on workspace hoisting. |
| extensions/cc-relay/src/config.ts | runAsUser whitelist validation and default fallbacks are well-implemented. |
| extensions/cc-relay/src/cc-dispatch-tool.ts | TypeBox schema is well-formed; dispatcher lazy getter and tool return shape look correct. |
| extensions/cc-relay/src/prompt-directives.ts | Three-mode directive text is clear and correctly gated by a switch with no fall-through. |
| extensions/cc-relay/src/session-parser.test.ts | Good coverage of byte-offset reads and last-prompt boundaries. The compaction test only covers the case where the summary is the final entry, leaving the reset bug uncaught. |
| extensions/cc-relay/src/types.ts | Type definitions are clear and well-scoped. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/cc-relay/src/dispatcher.test.ts
Line: 16-27
Comment:
**`mode` field missing from `CcRelayConfig` fixture**
`CcRelayConfig` declares `mode: CcRelayMode` as a required field, but `baseConfig` omits it. This is a TypeScript type error that will be caught by `pnpm tsgo`. All tests in this file pass the partial object to `createDispatcher`, so every test silently uses `undefined` for `mode`.
```suggestion
const baseConfig: CcRelayConfig = {
mode: "hybrid",
claudeBin: "claude",
workdir: "/tmp/test-workspace",
runAsUser: "",
permissionMode: "default",
model: "claude-opus-4-6",
timeoutSeconds: 60,
progressIntervalSeconds: 0, // Disable progress for tests
maxResultChars: 4000,
maxAttachments: 10,
maxAttachmentBytes: 10 * 1024 * 1024,
};
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/cc-relay/src/session-parser.ts
Line: 120-143
Comment:
**`skipAfterCompaction` is never reset — final results lost after mid-task compaction**
Once `skipAfterCompaction` is set to `true`, every subsequent `assistant` entry in the rest of the loop is skipped — including the actual final result that Claude produces after the compaction event finishes. For any long-running task that triggers context compaction mid-execution, `extractFinalResult` will return only the pre-compaction text, or an empty string if compaction happens early.
The existing test only covers the case where the compaction summary is the very last entry. It does not exercise the case where real results follow the compaction event, so this path goes undetected.
The safest fix is to only suppress the single `assistant` message that immediately follows the large user-context block (the "compaction summary"), then allow subsequent entries through again:
```ts
let skipNextAssistant = false;
for (let i = promptIdx; i < entries.length; i++) {
const entry = entries[i]!;
if (entry.type === "user") {
const content = entry.message?.content;
if (Array.isArray(content) && content.length >= 50) {
skipNextAssistant = true;
}
} else if (entry.type === "assistant") {
if (skipNextAssistant) {
skipNextAssistant = false;
continue;
}
// extract text blocks
}
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/cc-relay/src/worker.ts
Line: 80-91
Comment:
**`--permission-mode default` always injected even when using default settings**
`buildClaudeArgs` gates the flag on `job.permissionMode || cfg.permissionMode`. However, `job.permissionMode` is set from `cfg.permissionMode` in the dispatcher (which defaults to `"default"`), so the condition is always truthy with the default configuration. This means `--permission-mode default` is passed to every Claude CLI invocation.
If the Claude CLI does not accept `"default"` as a valid value for `--permission-mode`, every invocation with the out-of-the-box configuration will fail. Consider only passing the flag when the value is a non-default override:
```suggestion
if ((job.permissionMode || cfg.permissionMode) && (job.permissionMode || cfg.permissionMode) !== "default") {
args.push("--permission-mode", job.permissionMode || cfg.permissionMode);
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/cc-relay/src/dispatcher.ts
Line: 155-165
Comment:
**Temp file not cleaned up when `sendFile` throws**
The `try` block covers `writeFileSync`, `sendFile`, and `unlinkSync` together. If `sendFile` rejects, `unlinkSync` is never reached and the temporary `.cc-relay-output-*.md` file is left in `job.workdir` permanently. On subsequent runs the workspace snapshot will detect it as a "new file" and attempt to send it as an attachment.
Split the cleanup into a `finally` block so the file is always removed:
```ts
const tmpPath = path.join(job.workdir, `.cc-relay-output-${job.id.slice(0, 8)}.md`);
try {
fs.writeFileSync(tmpPath, result.resultText, "utf-8");
await this.callbacks.sendFile(job.channel, job.target, tmpPath, "full-output.md");
} catch {
/* best-effort */
} finally {
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/cc-relay/package.json
Line: 1-12
Comment:
**`@sinclair/typebox` runtime dependency not declared in extension `package.json`**
`cc-dispatch-tool.ts` imports `{ Type }` from `@sinclair/typebox` at runtime, but the extension's `package.json` has no `dependencies` section. The repo guidelines require runtime deps to live in the extension's own `dependencies` so that `npm install --omit=dev` in the plugin dir succeeds.
It works today only because `@sinclair/typebox` is hoisted from the root workspace, but that is fragile — a future root version bump or deduplication could break the plugin for standalone installs.
Add a `dependencies` field:
```json
"dependencies": {
"@sinclair/typebox": "0.34.49"
}
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "feat: add cc-relay extension for Claude ..." | Re-trigger Greptile
- Fix DeliveryContext.target → DeliveryContext.to - Fix sendMedia signature (mediaUrl + mediaLocalRoots) - Add missing mode field in test config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 663bed817a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The processQueue microtask may execute before the assertion, so the job status can be either queued or running. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix UUID session filename regex (accept hyphens) - Reset skipAfterCompaction on normal user messages - Drain child stdout/stderr to prevent pipe deadlock - Skip redundant --permission-mode default flag - Clean up temp file in finally block - Scope dispatcher per session key (not singleton) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 32b9fb5739
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 771cd49d16
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const { entries, newOffset } = parseNewEntries(this.sessionFile, this.byteOffset); | ||
| if (entries.length === 0) return; |
There was a problem hiding this comment.
Advance byte offset even when no progress entries are emitted
reportOnce returns early when entries.length === 0, but parseNewEntries still returns a newer byte offset for data that was already consumed. When Claude outputs only filtered/non-reportable events (for example Read/Glob/Grep-heavy stretches), this keeps byteOffset stale and re-parses the same growing tail every interval, causing avoidable O(n²)-style polling overhead and delayed progress handling on long runs.
Useful? React with 👍 / 👎.
| cfg: api.config, | ||
| to: target, | ||
| text: fileName, | ||
| mediaUrl: `file://${filePath}`, |
There was a problem hiding this comment.
Build attachment media URLs with proper file URL encoding
Using file://${filePath} does not escape reserved URL characters from real paths, so filenames containing # or ? are interpreted as fragment/query instead of part of the path when outbound media loading parses the URL. In those cases the wrong file path is resolved (or the file is not found), so attachments silently fail to deliver even though the file exists.
Useful? React with 👍 / 👎.
|
We have native claude-cli use, this feels duplicative. You can totally publish this to clawhub though if you see value. |
|
Thanks for your contribution. We reviewed this closely, and we’re going to keep the core Claude CLI path in OpenClaw native rather than land a second relay architecture in core. The main user need here is valid, but this implementation duplicates Claude CLI integration with a separate dispatcher/session-parser layer built around Claude transcript files under The native path work now covers the important bits we actually want to carry forward here: tighter auth/config migration behavior, non-interactive backend hardening, stricter env scrubbing, and protection against repo-local Claude settings/hooks being pulled into host-managed runs. One idea from this PR is still worth keeping as a future hardening track: optional OS-user isolation for Claude CLI runs. If we pursue that, we’ll credit this PR for surfacing the approach. So we’re keeping this closed rather than reviving the relay architecture in core. Once the native follow-up is landed, we can add the landed PR link back here as the canonical path forward. |
|
thanks for your contribution. follow-up landed in #61276 via 3b84884. that native Claude CLI path now covers the core hardening and regression work we wanted in core: Anthropic CLI auth/default-model migration fixes, provider-owned auth patch replacement, fallback migration, inherited Claude env/config/plugin-root scrubbing, forced we did not take the relay/session-scraping architecture from this PR into core, but the |
Summary
Adds
extensions/cc-relay/— a plugin that bridges OpenClaw agents to a local Claude Code CLI process, enabling an "OpenClaw shell + Claude Code brain" architecture. Inspired by the production-tested T800 bridge.Three operating modes (config-driven, no SOUL.md changes needed)
relay: Agent forwards ALL user requests to CC. A behavioral directive is injected via thebefore_prompt_buildhook — the agent's SOUL personality, memory, and identity are fully preserved.hybrid(default): Agent autonomously decides when to delegate to CC vs answer directly. A softer hint is injected suggesting CC for complex tasks.tool-only: Thecc_dispatchtool is registered with zero prompt modification. The agent uses CC only if instructed by its SOUL or the user.Core capabilities
Structured JSONL session parsing (not raw terminal output)
~/.claude/projects/*/*.jsonlsession files, not from noisy terminal stdoutlast-promptmarkers to isolate only the current task's output from a shared sessionReal-time progress reporting
Workspace file change detection
maxResultChars, the full output is sent as a.mdfile attachmentProcess management and security
runuseruser isolation — runs Claude CLI as a dedicated OS user (e.g.ccuser), separating privileges from the OpenClaw processrunAsUserinput validated with character whitelist (alphanumeric,_,-only)shellQuoteto prevent command injection--continue(resume last session) and--fresh(new session) modesChannel-agnostic delivery
11 configurable parameters with Control UI support
mode,claudeBin,workdir,runAsUser,permissionMode,model,timeoutSeconds,progressIntervalSeconds,maxResultChars,maxAttachments,maxAttachmentBytesuiHintsinopenclaw.plugin.jsonfor the settings UIReference templates included
templates/SOUL-relay.md— example SOUL for relay agents (optional, for reference)templates/exec-approvals.json— example exec approval allowlistArchitecture
User (any channel)
→ OpenClaw agent (SOUL personality + memory intact)
→ before_prompt_build hook (appends mode directive)
→ cc_dispatch tool (TypeBox schema, registered via factory)
→ CcRelayDispatcher (serial queue, max 50)
→ worker.ts (spawn CC CLI, optional runuser isolation)
→ progress-reporter.ts (JSONL byte-offset monitoring)
→ session-parser.ts (last-prompt boundary + compaction skip)
→ outbound adapter (sendText / sendMedia)
← Results + file attachments → originating channel
Usage
{ "plugins": { "entries": { "cc-relay": { "enabled": true, "config": { "mode": "relay", "model": "claude-opus-4-6", "permissionMode": "bypassPermissions", "progressIntervalSeconds": 60 } } } } } Test plan - Unit tests pass: pnpm test:extension cc-relay - Plugin loads with mode: "relay" — system prompt contains relay directive - Plugin loads with mode: "hybrid" — system prompt contains hybrid hint - Plugin loads with mode: "tool-only" — system prompt unmodified - cc_dispatch tool appears in agent tool list when enabled - No import boundary violations (pnpm check) - Session parser correctly handles last-prompt boundaries - Session parser skips compaction events - Progress reporter filters Read/Glob/Grep noise - Queue rejects tasks when full (50 limit) - Invalid runAsUser values are rejected at config load AI-assisted PR - Built with Claude Code (AI-assisted) - Degree of testing: unit tests included, integration testing pending - Based on production-tested T800 bridge architecture - Reviewed through 2 rounds of simulated PMC review 🤖 Generated with haoziqi