Skip to content

feat: add cc-relay extension for Claude Code CLI bridge#58448

Closed
haoziqi778 wants to merge 5 commits into
openclaw:mainfrom
haoziqi778:feat/cc-relay
Closed

feat: add cc-relay extension for Claude Code CLI bridge#58448
haoziqi778 wants to merge 5 commits into
openclaw:mainfrom
haoziqi778:feat/cc-relay

Conversation

@haoziqi778

Copy link
Copy Markdown

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 the before_prompt_build hook — 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: The cc_dispatch tool 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)

  • Final results are extracted from Claude Code's native ~/.claude/projects/*/*.jsonl session files, not from noisy terminal stdout
  • Uses last-prompt markers to isolate only the current task's output from a shared session
  • Automatically detects and skips context compaction events to avoid sending Claude's internal summaries as results
  • Progress monitoring uses byte-offset incremental reads for efficiency — no re-parsing of already-seen entries

Real-time progress reporting

  • Background reporter polls the JSONL at configurable intervals (default 60s)
  • Extracts only human-readable content: assistant text + meaningful tool calls (Write, Edit, Bash, Agent)
  • Filters out noise: Read/Glob/Grep tool calls are excluded from progress messages
  • Detects session file rotation when Claude starts a new session mid-task

Workspace file change detection

  • Takes mtime-based snapshots of the workspace before and after CC execution
  • Detects new and modified files by comparing snapshots
  • Automatically sends changed files as attachments to the originating channel
  • Configurable limits: max 10 attachments, max 10MB per file (both adjustable)
  • When result text exceeds maxResultChars, the full output is sent as a .md file attachment

Process management and security

  • Serial task queue prevents Claude session conflicts (max 50 queued tasks)
  • Optional runuser user isolation — runs Claude CLI as a dedicated OS user (e.g. ccuser), separating privileges from the OpenClaw process
  • runAsUser input validated with character whitelist (alphanumeric, _, - only)
  • Shell arguments protected by shellQuote to prevent command injection
  • Two-stage timeout: SIGTERM first, escalates to SIGKILL after 5 seconds
  • Supports both --continue (resume last session) and --fresh (new session) modes

Channel-agnostic delivery

  • Results and attachments delivered through OpenClaw's native outbound adapter system
  • Works with all channels: Feishu, Discord, Slack, Telegram, Matrix, etc.
  • Falls back to text-only delivery when a channel doesn't support media

11 configurable parameters with Control UI support

  • mode, claudeBin, workdir, runAsUser, permissionMode, model, timeoutSeconds, progressIntervalSeconds, maxResultChars, maxAttachments, maxAttachmentBytes
  • Full uiHints in openclaw.plugin.json for the settings UI
  • All parameters have sensible defaults — the plugin works out of the box

Reference templates included

  • templates/SOUL-relay.md — example SOUL for relay agents (optional, for reference)
  • templates/exec-approvals.json — example exec approval allowlist

Architecture

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

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>

@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: 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".

Comment thread extensions/cc-relay/index.ts Outdated
Comment thread extensions/cc-relay/index.ts
Comment thread extensions/cc-relay/src/worker.ts
@greptile-apps

greptile-apps Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds the extensions/cc-relay/ plugin — a new workspace extension that bridges OpenClaw agents to a local Claude Code CLI process. The architecture (serial task queue, JSONL session parsing, byte-offset incremental reads, runuser isolation) is well-thought-out and the three operating modes (relay, hybrid, tool-only) are cleanly separated. Security hardening (shellQuote, runAsUser whitelist, two-stage SIGTERM/SIGKILL timeout) is present.

Issues found:

  • TypeScript error in test fixture (dispatcher.test.ts): baseConfig is typed as CcRelayConfig but omits the required mode field. This will fail pnpm tsgo.
  • Logic bug — skipAfterCompaction never resets (session-parser.ts): Once a compaction event is detected, the flag stays true for the rest of the loop, silently dropping all subsequent assistant messages — including the actual final result. Long-running tasks that trigger mid-execution context compaction will return an empty or truncated result.
  • --permission-mode default always injected (worker.ts): Because permissionMode defaults to "default" (a truthy string), buildClaudeArgs unconditionally appends --permission-mode default to every CLI invocation. If the CLI rejects "default" as a value, all default-config executions fail.
  • Temp file leak on attachment send failure (dispatcher.ts): The full-output .md temp file is only cleaned up if sendFile succeeds; a rejection leaves the file behind where it will be detected as a changed file on the next run.
  • @sinclair/typebox undeclared in extension package.json: Works via workspace hoisting today but violates repo policy and breaks standalone installs.

Confidence Score: 4/5

Safe to merge once the two P1 issues (missing mode in test fixture, skipAfterCompaction reset bug) are addressed.

Two P1-level issues require fixes before merge: a TypeScript error in the test fixture that breaks pnpm tsgo, and a logic bug that silently drops final results for any long task that triggers context compaction. The remaining three findings are P2 quality/hygiene items that do not block core functionality.

extensions/cc-relay/src/session-parser.ts (compaction reset bug) and extensions/cc-relay/src/dispatcher.test.ts (missing mode field) need the most attention before landing.

Important Files Changed

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

Comment thread extensions/cc-relay/src/dispatcher.test.ts
Comment thread extensions/cc-relay/src/session-parser.ts
Comment thread extensions/cc-relay/src/worker.ts Outdated
Comment thread extensions/cc-relay/src/dispatcher.ts
Comment thread extensions/cc-relay/package.json
- 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>

@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: 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".

Comment thread extensions/cc-relay/src/session-parser.ts Outdated
Comment thread extensions/cc-relay/src/progress-reporter.ts
haoziqi778 and others added 2 commits April 1, 2026 00:08
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>

@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: 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".

Comment thread extensions/cc-relay/src/session-parser.ts
Comment thread extensions/cc-relay/src/worker.ts
Comment thread extensions/cc-relay/src/dispatcher.ts

@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: 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".

Comment on lines +114 to +115
const { entries, newOffset } = parseNewEntries(this.sessionFile, this.byteOffset);
if (entries.length === 0) return;

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 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}`,

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 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 👍 / 👎.

@steipete

steipete commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

We have native claude-cli use, this feels duplicative. You can totally publish this to clawhub though if you see value.

@steipete steipete closed this Apr 5, 2026
@vincentkoc

Copy link
Copy Markdown
Member

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 ~/.claude/projects/*. That is a worse fit for core now that we have a first-class Claude CLI backend/auth path and have been hardening that native path directly.

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.

@vincentkoc

Copy link
Copy Markdown
Member

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 --setting-sources user, malformed permission-flag fail-safe handling, and Claude CLI image/KV-stability coverage.

we did not take the relay/session-scraping architecture from this PR into core, but the runAsUser isolation idea is still a valid future hardening direction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants