openclaw-opencode-bridge 2.1.4 → 2.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,31 +4,41 @@
4
4
  [![license](https://img.shields.io/npm/l/openclaw-opencode-bridge)](LICENSE)
5
5
  [![node](https://img.shields.io/node/v/openclaw-opencode-bridge)](package.json)
6
6
 
7
- > Liked this project? Consider donating!
7
+ Language: [English](README_EN.md)
8
8
 
9
- > EVM Address: 0xe81c32383C8F21A14E6C2264939dA512e9F9bb42
9
+ Bridge untuk menghubungkan channel OpenClaw ke OpenCode lewat command prefix seperti `/cc` atau `@cc`.
10
10
 
11
- Bridge [OpenClaw](https://openclaw.ai) messaging channels to [OpenCode](https://opencode.ai) via persistent tmux sessions.
12
-
13
- Send `@cc` or `/cc` from any chat — your message is routed directly to OpenCode running in your terminal, bypassing the gateway LLM entirely. No separate API keys, no OAuth, no extra costs.
11
+ Pesan dari user akan langsung dieksekusi oleh OpenCode CLI (`opencode run`), lalu hasilnya dikirim balik lewat `openclaw message send`.
14
12
 
15
13
  <p>
16
14
  <img src="DEMO_1.png" alt="Telegram demo — sending a command" width="400" />
17
15
  <img src="DEMO_2.png" alt="Telegram demo — receiving a response" width="400" />
18
16
  </p>
19
17
 
20
- > **⚠️ Telegram only.** This plugin has been developed and tested exclusively with the Telegram channel. Other channels (Discord, Slack, etc.) may use different message formats or metadata wrapping that could break prefix detection or LLM suppression. Community contributions for additional channels are welcome — please open an issue if you encounter problems.
18
+ > ⚠️ Fokus pengujian saat ini adalah Telegram. Channel lain mungkin butuh penyesuaian format.
19
+
20
+ ## Donasi
21
+
22
+ Kalau project ini membantu, kamu bisa dukung lewat:
23
+
24
+ `0xe81c32383C8F21A14E6C2264939dA512e9F9bb42`
21
25
 
22
- ## How It Works
26
+ ## Fitur Utama
23
27
 
24
- <img alt="Architecture" src="https://mermaid.ink/img/Z3JhcGggVEQKICAgIEFbIkNoYXQiXSAtLT58IkBjYyBtZXNzYWdlInwgQlsiT3BlbkNsYXcgR2F0ZXdheSJdCiAgICBCIC0tPiBDWyJvcGVuY29kZS1icmlkZ2UgcGx1Z2luIl0KICAgIEMgLS0+fHN1cHByZXNzIExMTHwgQgogICAgQyAtLT58ZXhlY0ZpbGV8IERbIlNoZWxsIFNjcmlwdCJdCiAgICBEIC0tPnx0bXV4IHBhc3RlLWJ1ZmZlcnwgRVsiT3BlbkNvZGUgLyB0bXV4Il0KICAgIEUgLS0+fCJvcGVuY2xhdyBtZXNzYWdlIHNlbmQifCBB" />
28
+ - Prefix command: `@cc`, `/cc`, `@ccn`, `/ccn`, `@ccu`, `@ccm`, `@ccms`
29
+ - Reply OpenCode dikirim otomatis ke user lewat channel OpenClaw
30
+ - Output dibersihkan dari noise terminal/tool logs
31
+ - Timeout bersifat batas maksimum, bukan delay tetap
32
+ - Dukungan onboarding/uninstall otomatis
25
33
 
26
- 1. User sends a prefixed message (e.g. `@cc deploy the app`)
27
- 2. The plugin intercepts the message and suppresses the gateway LLM
28
- 3. A shell script forwards the message to OpenCode in a persistent tmux session
29
- 4. OpenCode replies back through the same channel via `openclaw message send`
34
+ ## Alur Kerja Singkat
30
35
 
31
- ## Prerequisites
36
+ 1. User kirim pesan ber-prefix, contoh: `/cc buat script python`.
37
+ 2. Plugin menangkap pesan dan menahan reply default gateway.
38
+ 3. Script bridge menjalankan `opencode run`.
39
+ 4. Hasil OpenCode dikirim balik ke user melalui `openclaw message send`.
40
+
41
+ ## Prasyarat
32
42
 
33
43
  | Dependency | Install |
34
44
  |---|---|
@@ -36,53 +46,63 @@ Send `@cc` or `/cc` from any chat — your message is routed directly to OpenCod
36
46
  | [OpenCode](https://opencode.ai) | `npm i -g opencode-ai` |
37
47
  | [tmux](https://github.com/tmux/tmux) | Auto-installed during onboard if missing |
38
48
 
39
- > **Note:** macOS and Linux only. Windows is not supported (tmux dependency).
49
+ > Sistem operasi yang didukung: Linux dan macOS.
40
50
 
41
- ## Quick Start
51
+ ## Instalasi Cepat
42
52
 
43
53
  ```bash
44
54
  npm i -g openclaw-opencode-bridge
45
55
  openclaw-opencode-bridge onboard
46
56
  ```
47
57
 
48
- The interactive wizard configures everything — plugin, shell scripts, OPENCODE.md, daemon, and channel settings.
58
+ Wizard onboarding akan mengatur plugin, script, AGENTS.md, daemon, dan konfigurasi channel.
49
59
 
50
- Verify the connection:
60
+ Tes awal:
51
61
 
52
- ```
53
- @cc hello
62
+ ```bash
63
+ /cc hello
54
64
  ```
55
65
 
56
- ## Commands
66
+ ## Daftar Perintah
57
67
 
58
- | Prefix | Description |
68
+ | Prefix | Fungsi |
59
69
  |---|---|
60
- | `@cc` · `/cc` | Send to the current session (retains conversation context) |
61
- | `@ccn` · `/ccn` | Start a fresh session (kills existing, creates new) |
62
- | `@ccu` · `/ccu` | Show OpenCode usage stats |
63
- | `@ccm` · `/ccm` | List OpenCode models |
64
- | `@ccms` · `/ccms` | Set OpenCode model (by number or model-id) |
70
+ | `@cc` · `/cc` | Lanjut ke sesi terbaru (`--continue`) |
71
+ | `@ccn` · `/ccn` | Jalankan sesi baru tanpa `--continue` (konteks fresh) |
72
+ | `@ccu` · `/ccu` | Tampilkan statistik pemakaian OpenCode |
73
+ | `@ccm` · `/ccm` | Tampilkan daftar model OpenCode |
74
+ | `@ccms` · `/ccms` | Ganti model OpenCode (nomor atau model-id) |
65
75
 
66
- Messages are sent as-is — no quoting needed:
76
+ Contoh:
67
77
 
78
+ ```bash
79
+ /cc refactor auth module dan tambah unit test
80
+ /ccn review PR ini: https://github.com/org/repo/pull/42
81
+ /ccu
68
82
  ```
69
- @cc refactor the auth module and add tests
70
- @ccn review this PR: https://github.com/org/repo/pull/42
71
- @ccu
72
- ```
73
83
 
74
- Multiline messages and special characters (`$`, `` ` ``, `\`, quotes) are preserved exactly as typed.
84
+ ## Perilaku Timeout
85
+
86
+ - `/cc` memakai timeout adaptif dengan base `300s` dan maksimum `600s`.
87
+ - `/ccn` memakai timeout adaptif dengan base `300s` dan maksimum `600s`.
88
+ - Timeout adalah batas maksimal proses. Kalau OpenCode selesai lebih cepat, reply langsung dikirim saat itu juga.
75
89
 
76
- ## Migration from v1
90
+ ## Catatan Sesi
77
91
 
78
- v2 replaces the legacy skill/hook system with a single OpenClaw plugin:
92
+ - `/ccn` tidak menghapus semua history OpenCode.
93
+ - `/cc` biasanya melanjutkan sesi terbaru.
94
+ - Data sesi OpenCode tersimpan di direktori data OpenCode user (contoh Linux: `~/.local/share/opencode`).
95
+
96
+ ## Migrasi dari Versi Lama
97
+
98
+ Versi 2+ menggantikan sistem skill/hook lama menjadi plugin OpenClaw tunggal:
79
99
 
80
100
  ```bash
81
101
  npm i -g openclaw-opencode-bridge
82
102
  openclaw-opencode-bridge onboard
83
103
  ```
84
104
 
85
- The wizard detects and removes legacy components automatically.
105
+ Komponen legacy akan dibersihkan otomatis saat onboarding.
86
106
 
87
107
  ## Uninstall
88
108
 
@@ -90,17 +110,21 @@ The wizard detects and removes legacy components automatically.
90
110
  openclaw-opencode-bridge uninstall
91
111
  ```
92
112
 
93
- Removes all installed components — plugin, shell scripts, OPENCODE.md additions, and daemon.
113
+ Ini akan menghapus plugin, script bridge, AGENTS.md hasil instalasi bridge, dan daemon.
94
114
 
95
115
  ## Troubleshooting
96
116
 
97
- | Symptom | Fix |
117
+ | Gejala | Solusi |
98
118
  |---|---|
99
- | LLM responds instead of delivery message | `openclaw gateway restart` |
100
- | Delivery confirmed but no reply | Check `tmux ls` session may have crashed |
101
- | Multiline sends only first line | Re-run `openclaw-opencode-bridge onboard` (v2.0.6+) |
119
+ | Gateway LLM tetap membalas | Jalankan `openclaw gateway restart` |
120
+ | Ada “OpenCode will reply shortly” tapi tidak ada balasan akhir | Cek log `/tmp/opencode-bridge-send.log` lalu ulangi `openclaw-opencode-bridge onboard` |
121
+ | Perintah lambat/timeout | Cek prompt terlalu berat, lihat log bridge, pastikan OpenCode CLI normal |
122
+ | Output aneh/berantakan | Ulangi onboarding agar script/plugin terbaru terpasang |
123
+
124
+ ## Attribution
102
125
 
103
- > Forked from [openclaw-claude-bridge](https://github.com/bettep-dev/openclaw-claude-bridge) by [@bettep-dev](https://github.com/bettep-dev) — modified to work with OpenCode instead of Claude CLI.
126
+ Proyek ini diadaptasi dari karya original:
127
+ `https://github.com/bettep-dev/openclaw-claude-bridge`
104
128
 
105
129
  ## License
106
130
 
package/README_EN.md ADDED
@@ -0,0 +1,131 @@
1
+ # openclaw-opencode-bridge
2
+
3
+ [![npm version](https://img.shields.io/npm/v/openclaw-opencode-bridge)](https://www.npmjs.com/package/openclaw-opencode-bridge)
4
+ [![license](https://img.shields.io/npm/l/openclaw-opencode-bridge)](LICENSE)
5
+ [![node](https://img.shields.io/node/v/openclaw-opencode-bridge)](package.json)
6
+
7
+ Bridge OpenClaw channels to OpenCode using command prefixes like `/cc` or `@cc`.
8
+
9
+ User messages are executed via OpenCode CLI (`opencode run`), then sent back through `openclaw message send`.
10
+
11
+ Language: [Bahasa Indonesia](README.md)
12
+
13
+ <p>
14
+ <img src="DEMO_1.png" alt="Telegram demo — sending a command" width="400" />
15
+ <img src="DEMO_2.png" alt="Telegram demo — receiving a response" width="400" />
16
+ </p>
17
+
18
+ > ⚠️ Current testing focus is Telegram. Other channels may need format-specific adjustments.
19
+
20
+ ## Key Features
21
+
22
+ - Prefix commands: `@cc`, `/cc`, `@ccn`, `/ccn`, `@ccu`, `@ccm`, `@ccms`
23
+ - OpenCode output is automatically sent back to users via OpenClaw
24
+ - Output is sanitized to remove terminal/tool noise
25
+ - Timeout acts as a maximum limit, not a fixed delay
26
+ - Automatic onboarding and uninstall workflow
27
+
28
+ ## How It Works
29
+
30
+ 1. User sends a prefixed message, for example: `/cc build a python script`.
31
+ 2. The plugin intercepts the message and suppresses the default gateway reply.
32
+ 3. Bridge scripts run `opencode run`.
33
+ 4. OpenCode output is delivered back to the same user/channel.
34
+
35
+ ## Prerequisites
36
+
37
+ | Dependency | Install |
38
+ |---|---|
39
+ | [OpenClaw](https://openclaw.ai) | `npm i -g openclaw` |
40
+ | [OpenCode](https://opencode.ai) | `npm i -g opencode-ai` |
41
+ | [tmux](https://github.com/tmux/tmux) | Auto-installed during onboard if missing |
42
+
43
+ > Supported OS: Linux and macOS.
44
+
45
+ ## Quick Start
46
+
47
+ ```bash
48
+ npm i -g openclaw-opencode-bridge
49
+ openclaw-opencode-bridge onboard
50
+ ```
51
+
52
+ Onboarding configures plugin entries, scripts, AGENTS.md, daemon, and channel settings.
53
+
54
+ Quick test:
55
+
56
+ ```bash
57
+ /cc hello
58
+ ```
59
+
60
+ ## Command List
61
+
62
+ | Prefix | Function |
63
+ |---|---|
64
+ | `@cc` · `/cc` | Continue the latest session (`--continue`) |
65
+ | `@ccn` · `/ccn` | Start a fresh run without `--continue` |
66
+ | `@ccu` · `/ccu` | Show OpenCode usage stats |
67
+ | `@ccm` · `/ccm` | List OpenCode models |
68
+ | `@ccms` · `/ccms` | Set OpenCode model (number or model-id) |
69
+
70
+ Examples:
71
+
72
+ ```bash
73
+ /cc refactor auth module and add tests
74
+ /ccn review this PR: https://github.com/org/repo/pull/42
75
+ /ccu
76
+ ```
77
+
78
+ ## Timeout Behavior
79
+
80
+ - `/cc` uses adaptive timeout with base `300s` and max `600s`.
81
+ - `/ccn` uses adaptive timeout with base `300s` and max `600s`.
82
+ - Timeout is a hard upper bound. If OpenCode finishes early, output is sent immediately.
83
+
84
+ ## Session Notes
85
+
86
+ - `/ccn` does not delete existing OpenCode history.
87
+ - `/cc` usually continues the latest session.
88
+ - OpenCode session data is stored in OpenCode user data directory (Linux example: `~/.local/share/opencode`).
89
+
90
+ ## Migration from Legacy Version
91
+
92
+ Version 2+ replaces the legacy skill/hook flow with a single OpenClaw plugin:
93
+
94
+ ```bash
95
+ npm i -g openclaw-opencode-bridge
96
+ openclaw-opencode-bridge onboard
97
+ ```
98
+
99
+ Legacy components are cleaned automatically during onboarding.
100
+
101
+ ## Uninstall
102
+
103
+ ```bash
104
+ openclaw-opencode-bridge uninstall
105
+ ```
106
+
107
+ This removes plugin entries, bridge scripts, bridge-managed AGENTS.md, and daemon registration.
108
+
109
+ ## Troubleshooting
110
+
111
+ | Symptom | Fix |
112
+ |---|---|
113
+ | Gateway LLM still responds | Run `openclaw gateway restart` |
114
+ | Delivery message appears but no final reply | Check `/tmp/opencode-bridge-send.log`, then rerun `openclaw-opencode-bridge onboard` |
115
+ | Slow response / timeout | Check prompt complexity and bridge logs; confirm OpenCode CLI is healthy |
116
+ | Messy output | Rerun onboarding to ensure latest scripts/plugin are installed |
117
+
118
+ ## Donation
119
+
120
+ If this project helps you, you can support it via:
121
+
122
+ `0xe81c32383C8F21A14E6C2264939dA512e9F9bb42`
123
+
124
+ ## Attribution
125
+
126
+ This project is adapted from the original work:
127
+ `https://github.com/bettep-dev/openclaw-claude-bridge`
128
+
129
+ ## License
130
+
131
+ [MIT](LICENSE)
package/lib/cli.js CHANGED
@@ -42,7 +42,7 @@ const program = new Command();
42
42
  program
43
43
  .name("openclaw-opencode-bridge")
44
44
  .description(
45
- "Bridge OpenClaw messaging channels to OpenCode via tmux persistent sessions",
45
+ "Bridge OpenClaw messaging channels to OpenCode via persistent OpenCode sessions",
46
46
  )
47
47
  .version(pkg.version);
48
48
 
package/lib/onboard.js CHANGED
@@ -347,11 +347,40 @@ function compactError(err) {
347
347
 
348
348
  async function runGatewayCommand(action, timeoutMs) {
349
349
  try {
350
- await execAsync(`openclaw gateway ${action}`, { timeout: timeoutMs });
351
- return { ok: true, action };
350
+ const { stdout = "", stderr = "" } = await execAsync(
351
+ `openclaw gateway ${action}`,
352
+ { timeout: timeoutMs },
353
+ );
354
+ return { ok: true, action, stdout, stderr };
352
355
  } catch (e) {
353
- return { ok: false, action, error: compactError(e) };
356
+ return {
357
+ ok: false,
358
+ action,
359
+ error: compactError(e),
360
+ stdout: e?.stdout || "",
361
+ stderr: e?.stderr || "",
362
+ };
363
+ }
364
+ }
365
+
366
+ async function isGatewayHealthy() {
367
+ const status = await runGatewayCommand("status", 20000);
368
+ if (!status.ok) return false;
369
+
370
+ const text = `${status.stdout || ""}\n${status.stderr || ""}`.toLowerCase();
371
+ return (
372
+ text.includes("runtime: running") ||
373
+ (text.includes("rpc probe: ok") && text.includes("listening:"))
374
+ );
375
+ }
376
+
377
+ function ensureGatewayHint(errorText) {
378
+ if (!errorText) return "gateway restart failed";
379
+ const text = String(errorText).trim();
380
+ if (/running|active|already|ok/i.test(text)) {
381
+ return `${text}; gateway appears to be running`;
354
382
  }
383
+ return text;
355
384
  }
356
385
 
357
386
  async function restartGatewayResilient() {
@@ -377,6 +406,17 @@ async function restartGatewayResilient() {
377
406
  return { ok: true, mode: "restart-retry" };
378
407
  }
379
408
 
409
+ // 4) If command failed but status is healthy, treat as success with warning.
410
+ if (await isGatewayHealthy()) {
411
+ return {
412
+ ok: true,
413
+ mode: "status-healthy",
414
+ warning: ensureGatewayHint(
415
+ retry.error || start.error || primary.error || stop.error || "",
416
+ ),
417
+ };
418
+ }
419
+
380
420
  return {
381
421
  ok: false,
382
422
  error: retry.error || start.error || primary.error || "gateway restart failed",
@@ -877,8 +917,11 @@ async function onboard() {
877
917
  const restartError = gatewayRestart.error || "";
878
918
 
879
919
  if (restarted) {
920
+ const warningSuffix = gatewayRestart.warning
921
+ ? ` ${chalk.yellow.dim(`(${gatewayRestart.warning})`)}`
922
+ : "";
880
923
  spinner5.stop(
881
- `Gateway ${chalk.dim("->")} restarted ${chalk.dim(`(${gatewayRestart.mode})`)}`,
924
+ `Gateway ${chalk.dim("->")} restarted ${chalk.dim(`(${gatewayRestart.mode})`)}${warningSuffix}`,
882
925
  );
883
926
  } else {
884
927
  spinner5.fail(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "opencode-bridge",
3
3
  "name": "OpenCode Bridge",
4
- "description": "Route @cc/@ccn/@ccu prefix messages to OpenCode via tmux, suppressing the default LLM response.",
4
+ "description": "Route @cc/@ccn/@ccu prefix messages to OpenCode CLI, suppressing the default LLM response.",
5
5
  "configSchema": {
6
6
  "type": "object",
7
7
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openclaw-opencode-bridge",
3
- "version": "2.1.4",
4
- "description": "Bridge OpenClaw messaging channels to OpenCode via tmux persistent sessions",
3
+ "version": "2.1.7",
4
+ "description": "Bridge OpenClaw messaging channels to OpenCode via persistent OpenCode sessions",
5
5
  "main": "./lib/onboard.js",
6
6
  "bin": {
7
7
  "openclaw-opencode-bridge": "./lib/cli.js"
package/plugin/index.ts CHANGED
@@ -13,7 +13,7 @@ const SCRIPT_MAP: Record<string, string> = {
13
13
 
14
14
  const REQUIRES_ARG = new Set(["cc", "ccn"]);
15
15
  const EXEC_TIMEOUT = 15_000;
16
- const SUPPRESSION_WINDOW = 15_000;
16
+ const DELIVERY_TTL_MS = 10_000;
17
17
 
18
18
  const DELIVERY_MSG = "🔗 OpenCode will reply shortly.";
19
19
 
@@ -24,15 +24,13 @@ const SILENT_PROMPT =
24
24
  "You MUST NOT call any tools or functions.\n" +
25
25
  `Output ONLY this exact text, nothing else: ${DELIVERY_MSG}`;
26
26
 
27
- // NOTE: Single-user assumption — concurrent users may see cross-suppression
28
- let bridgeSuppressUntil = 0;
29
-
30
27
  /**
31
28
  * Flag set by message_received (fires FIRST) and consumed by before_prompt_build (fires SECOND).
32
29
  * This bypasses the unreliable extractLastUserText approach entirely.
33
30
  * message_received gets event.content (raw user text) which always correctly detects @cc prefix.
34
31
  */
35
32
  let pendingBridgeCommand = false;
33
+ let pendingDeliveryUntil = 0;
36
34
 
37
35
  export default function register(api: OpenClawPluginApi) {
38
36
  const config = api.pluginConfig as {
@@ -67,9 +65,6 @@ export default function register(api: OpenClawPluginApi) {
67
65
 
68
66
  // Set flag for before_prompt_build to consume
69
67
  pendingBridgeCommand = true;
70
- // Keep suppression narrow to this turn to avoid cross-message blocking.
71
- bridgeSuppressUntil = Date.now() + SUPPRESSION_WINDOW;
72
-
73
68
  api.logger.debug?.(
74
69
  `[opencode-bridge] message_received: command=${command}, pendingBridgeCommand=true`,
75
70
  );
@@ -102,42 +97,40 @@ export default function register(api: OpenClawPluginApi) {
102
97
  // No longer relies on extractLastUserText for prefix detection.
103
98
  api.on("before_prompt_build", async (event, ctx) => {
104
99
  const shouldSuppress = pendingBridgeCommand;
100
+ const deliveryPending = Date.now() < pendingDeliveryUntil;
105
101
 
106
102
  api.logger.debug?.(
107
- `[opencode-bridge] before_prompt_build: pendingBridgeCommand=${pendingBridgeCommand}, bridgeSuppressUntil=${bridgeSuppressUntil > Date.now()}`,
103
+ `[opencode-bridge] before_prompt_build: pendingBridgeCommand=${pendingBridgeCommand}, deliveryPending=${deliveryPending}`,
108
104
  );
109
105
 
110
106
  if (shouldSuppress) {
111
107
  pendingBridgeCommand = false;
112
- bridgeSuppressUntil = Date.now() + SUPPRESSION_WINDOW;
108
+ pendingDeliveryUntil = Date.now() + DELIVERY_TTL_MS;
113
109
  return { systemPrompt: SILENT_PROMPT, prependContext: SILENT_PROMPT };
114
- } else {
115
- // Clear suppression for non-bridge messages
116
- bridgeSuppressUntil = 0;
117
110
  }
118
111
  });
119
112
 
120
113
  // --- Hook 3: message_sending (modifying) ---
121
- // Replace LLM output with delivery confirmation while bridge suppression is active
114
+ // Replace one outgoing LLM message with delivery confirmation
122
115
  api.on("message_sending", async (_event, _ctx) => {
123
- const suppressing = Date.now() < bridgeSuppressUntil;
116
+ const suppressing = Date.now() < pendingDeliveryUntil;
124
117
  api.logger.debug?.(
125
118
  `[opencode-bridge] message_sending: suppressing=${suppressing}`,
126
119
  );
127
120
 
128
121
  if (suppressing) {
129
122
  // One-shot override for the intercepted bridge message.
130
- bridgeSuppressUntil = 0;
123
+ pendingDeliveryUntil = 0;
131
124
  return { content: DELIVERY_MSG, cancel: false };
132
125
  }
133
126
  });
134
127
 
135
128
  // --- Hook 4: before_tool_call (modifying) ---
136
- // Block ALL tool calls while bridge suppression is active
129
+ // Block ALL tool calls while delivery override is pending
137
130
  api.on("before_tool_call", async (_event, _ctx) => {
138
- if (Date.now() < bridgeSuppressUntil) {
131
+ if (Date.now() < pendingDeliveryUntil) {
139
132
  api.logger.debug?.(
140
- `[opencode-bridge] before_tool_call: BLOCKED (suppression active)`,
133
+ `[opencode-bridge] before_tool_call: BLOCKED (delivery pending)`,
141
134
  );
142
135
  return { block: true, blockReason: "opencode-bridge: message intercepted, tools disabled" };
143
136
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "opencode-bridge",
3
3
  "name": "OpenCode Bridge",
4
- "description": "Route @cc/@ccn/@ccu prefix messages to OpenCode via tmux, suppressing the default LLM response.",
4
+ "description": "Route @cc/@ccn/@ccu prefix messages to OpenCode CLI, suppressing the default LLM response.",
5
5
  "configSchema": {
6
6
  "type": "object",
7
7
  "additionalProperties": false,
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "opencode-bridge",
3
- "version": "2.0.6",
3
+ "version": "2.1.12",
4
4
  "private": true,
5
- "description": "OpenClaw plugin: route prefix messages to OpenCode via tmux",
5
+ "description": "OpenClaw plugin: route prefix messages to OpenCode CLI",
6
6
  "type": "module",
7
7
  "openclaw": {
8
8
  "extensions": ["./index.ts"]
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # bridge-version: 10
2
+ # bridge-version: 17
3
3
  # Start fresh session asynchronously and send instruction
4
4
  MSG="$1"
5
5
  OPENCODE="{{OPENCODE_BIN}}"
@@ -7,9 +7,9 @@ CHANNEL="{{CHANNEL}}"
7
7
  TARGET="{{TARGET_ID}}"
8
8
  WORKSPACE="{{WORKSPACE}}"
9
9
  LOG_FILE="/tmp/opencode-bridge-send.log"
10
- BASE_TIMEOUT_SEC=45
11
- MAX_TIMEOUT_SEC=300
12
- LOCK_WAIT_SEC=600
10
+ BASE_TIMEOUT_SEC=300
11
+ MAX_TIMEOUT_SEC=600
12
+ LOCK_WAIT_SEC=3
13
13
 
14
14
  if [ -z "$MSG" ]; then
15
15
  echo "ERROR: No message provided"
@@ -30,20 +30,43 @@ normalize_text() {
30
30
  }
31
31
 
32
32
  acquire_bridge_lock() {
33
- local safe_channel safe_target lock_file
33
+ local safe_channel safe_target holder_pid waited
34
34
  safe_channel="$(printf '%s' "$CHANNEL" | sed -E 's/[^a-zA-Z0-9._-]/_/g')"
35
35
  safe_target="$(printf '%s' "$TARGET" | sed -E 's/[^a-zA-Z0-9._-]/_/g')"
36
- lock_file="/tmp/opencode-bridge-${safe_channel}-${safe_target}.lock"
36
+ LOCK_DIR="/tmp/opencode-bridge-${safe_channel}-${safe_target}.lockdir"
37
+ waited=0
37
38
 
38
- if command -v flock >/dev/null 2>&1; then
39
- exec 200>"$lock_file"
40
- flock -w "$LOCK_WAIT_SEC" 200
41
- return $?
42
- fi
39
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
40
+ if [ ! -s "$LOCK_DIR/pid" ]; then
41
+ rm -rf "$LOCK_DIR"
42
+ continue
43
+ fi
44
+ if [ -f "$LOCK_DIR/pid" ]; then
45
+ holder_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
46
+ if [ -n "$holder_pid" ] && ! kill -0 "$holder_pid" 2>/dev/null; then
47
+ rm -rf "$LOCK_DIR"
48
+ continue
49
+ fi
50
+ fi
43
51
 
52
+ if [ "$waited" -ge "$LOCK_WAIT_SEC" ]; then
53
+ return 1
54
+ fi
55
+ sleep 1
56
+ waited=$((waited + 1))
57
+ done
58
+
59
+ printf '%s\n' "${BASHPID:-$$}" > "$LOCK_DIR/pid"
60
+ trap 'release_bridge_lock' EXIT INT TERM
44
61
  return 0
45
62
  }
46
63
 
64
+ release_bridge_lock() {
65
+ if [ -n "${LOCK_DIR:-}" ] && [ -d "$LOCK_DIR" ]; then
66
+ rm -rf "$LOCK_DIR"
67
+ fi
68
+ }
69
+
47
70
  compute_timeout() {
48
71
  local msg="$1"
49
72
  local timeout="$BASE_TIMEOUT_SEC"
@@ -56,7 +79,7 @@ compute_timeout() {
56
79
  if [ "$chars" -gt 280 ]; then timeout=$((timeout + 20)); fi
57
80
  if [ "$words" -gt 25 ]; then timeout=$((timeout + 20)); fi
58
81
 
59
- if printf '%s' "$msg" | grep -Eqi '\b(create|build|write|implement|script|code|program|refactor|debug|fix|test|automation|deploy|migrate|api|database|project)\b'; then
82
+ if printf '%s' "$msg" | grep -Eqi '\b(create|build|write|implement|script|code|program|refactor|debug|fix|test|automation|deploy|migrate|api|database|project|search|news|weather|forecast|cuaca|berita)\b'; then
60
83
  timeout=$((timeout + 60))
61
84
  fi
62
85
 
@@ -78,11 +101,6 @@ is_trivial_echo() {
78
101
  [ -n "$message_norm" ] && [ "$output_norm" = "$message_norm" ]
79
102
  }
80
103
 
81
- has_external_delivery_success() {
82
- local raw="$1"
83
- printf '%s\n' "$raw" | grep -Eqi 'Sent via [A-Za-z]+|Message ID:[[:space:]]*[0-9]+'
84
- }
85
-
86
104
  extract_last_marked_block() {
87
105
  local raw="$1"
88
106
  if ! printf '%s' "$raw" | grep -q '🔗'; then
@@ -119,7 +137,7 @@ sanitize_output() {
119
137
  cleaned="$(printf '%s\n' "$cleaned" | sed -E 's/^[[:space:]]*>+[[:space:]]?//; /^```/d')"
120
138
 
121
139
  cleaned="$(printf '%s\n' "$cleaned" | grep -Eiv \
122
- '^[[:space:]]*$|^[[:space:]]*(build[[:space:]]*·|◇[[:space:]]+doctor warnings)[[:space:]]*$|^[[:space:]]*◇[[:space:]]+|^[[:space:]]*exa[[:space:]]+(web|code)[[:space:]]+search([[:space:]]+.*)?$|^[[:space:]]*[←→↳].*|^[[:space:]]*wrote file successfully\.?$|^[[:space:]]*(\$[[:space:]]*)?openclaw message send --channel[[:space:]]+|^[[:space:]]*error:[[:space:]]*too many arguments for '\''send'\''.*$|^[[:space:]]*sent via telegram|^[[:space:]]*\[(telegram|discord|slack|whatsapp|signal|irc|matrix|line|mattermost|teams)\]|autoselectfamily=|dnsresultorder=|^[[:space:]]*[│┌┐└┘├┤┬┴┼─═╭╮╰╯]+[[:space:]]*$')"
140
+ '^[[:space:]]*$|^[[:space:]]*(build[[:space:]]*·|◇[[:space:]]+doctor warnings)[[:space:]]*$|^[[:space:]]*◇[[:space:]]+|^[[:space:]]*exa[[:space:]]+(web|code)[[:space:]]+search([[:space:]]+.*)?$|^[[:space:]]*[←→↳].*|^[[:space:]]*wrote file successfully\.?$|^[[:space:]]*(\$[[:space:]]*)?openclaw message send --channel[[:space:]]+|^[[:space:]]*bridge-guard:[[:space:]]*blocked openclaw message send.*$|^[[:space:]]*error:[[:space:]]*too many arguments for '\''send'\''.*$|^[[:space:]]*sent via telegram|^[[:space:]]*\[(telegram|discord|slack|whatsapp|signal|irc|matrix|line|mattermost|teams)\]|autoselectfamily=|dnsresultorder=|^[[:space:]]*[│┌┐└┘├┤┬┴┼─═╭╮╰╯]+[[:space:]]*$')"
123
141
 
124
142
  marked="$(extract_last_marked_block "$cleaned")"
125
143
  if [ -n "$marked" ]; then
@@ -131,6 +149,14 @@ sanitize_output() {
131
149
  printf '%s' "$(trim_text "$cleaned")"
132
150
  }
133
151
 
152
+ extract_text_from_json_stream() {
153
+ local raw="$1"
154
+ local parsed
155
+
156
+ parsed="$(printf '%s\n' "$raw" | node -e "const fs=require('fs');const lines=fs.readFileSync(0,'utf8').split(/\r?\n/);let out='';for(const line of lines){const s=line.trim();if(!s)continue;try{const obj=JSON.parse(s);if(obj&&obj.type==='text'&&obj.part&&typeof obj.part.text==='string'){out+=obj.part.text;}}catch{}}process.stdout.write(out);" 2>/dev/null)"
157
+ printf '%s' "$(trim_text "$parsed")"
158
+ }
159
+
134
160
  sentence_case_first() {
135
161
  local text="$1"
136
162
  printf '%s' "$text" | awk '
@@ -166,38 +192,60 @@ apply_reply_style() {
166
192
  run_with_timeout() {
167
193
  local mode="$1"
168
194
  local prompt="$2"
169
- local output rc tmp pid watchdog
195
+ local output rc tmp pid watchdog guard_dir guarded_path real_openclaw
170
196
 
171
197
  tmp="$(mktemp /tmp/opencode-run.XXXXXX)"
172
- if [ -n "$mode" ]; then
173
- "$OPENCODE" run "$mode" "$prompt" >"$tmp" 2>&1 &
174
- else
175
- "$OPENCODE" run "$prompt" >"$tmp" 2>&1 &
198
+ guarded_path="$PATH"
199
+ real_openclaw="$(command -v openclaw 2>/dev/null || true)"
200
+ if [ -n "$real_openclaw" ]; then
201
+ guard_dir="$(mktemp -d /tmp/opencode-bridge-guard.XXXXXX)"
202
+ printf '#!/bin/sh\nif [ "$1" = "message" ] && [ "$2" = "send" ]; then\n echo "bridge-guard: blocked openclaw message send from opencode runtime" >&2\n exit 64\nfi\nexec "%s" "$@"\n' "$real_openclaw" > "${guard_dir}/openclaw"
203
+ chmod +x "${guard_dir}/openclaw"
204
+ guarded_path="${guard_dir}:${PATH}"
176
205
  fi
177
- pid=$!
178
-
179
- (
180
- sleep "$RUN_TIMEOUT_SEC"
181
- if kill -0 "$pid" 2>/dev/null; then
182
- touch "${tmp}.timeout"
183
- kill "$pid" 2>/dev/null
184
- sleep 2
185
- kill -9 "$pid" 2>/dev/null
186
- fi
187
- ) &
188
- watchdog=$!
189
206
 
190
- wait "$pid"
191
- rc=$?
192
- kill "$watchdog" 2>/dev/null
193
-
194
- if [ -f "${tmp}.timeout" ]; then
195
- rc=124
196
- rm -f "${tmp}.timeout"
207
+ # Timeout here is an upper bound only; command returns immediately when OpenCode finishes.
208
+ if command -v timeout >/dev/null 2>&1; then
209
+ if [ -n "$mode" ]; then
210
+ PATH="$guarded_path" timeout --signal=TERM --kill-after=2 "${RUN_TIMEOUT_SEC}s" \
211
+ "$OPENCODE" run "$mode" --no-fork --format json "$prompt" >"$tmp" 2>&1
212
+ else
213
+ PATH="$guarded_path" timeout --signal=TERM --kill-after=2 "${RUN_TIMEOUT_SEC}s" \
214
+ "$OPENCODE" run --no-fork --format json "$prompt" >"$tmp" 2>&1
215
+ fi
216
+ rc=$?
217
+ else
218
+ if [ -n "$mode" ]; then
219
+ PATH="$guarded_path" "$OPENCODE" run "$mode" --no-fork --format json "$prompt" >"$tmp" 2>&1 &
220
+ else
221
+ PATH="$guarded_path" "$OPENCODE" run --no-fork --format json "$prompt" >"$tmp" 2>&1 &
222
+ fi
223
+ pid=$!
224
+
225
+ (
226
+ sleep "$RUN_TIMEOUT_SEC"
227
+ if kill -0 "$pid" 2>/dev/null; then
228
+ touch "${tmp}.timeout"
229
+ kill "$pid" 2>/dev/null
230
+ sleep 2
231
+ kill -9 "$pid" 2>/dev/null
232
+ fi
233
+ ) &
234
+ watchdog=$!
235
+
236
+ wait "$pid"
237
+ rc=$?
238
+ kill "$watchdog" 2>/dev/null
239
+
240
+ if [ -f "${tmp}.timeout" ]; then
241
+ rc=124
242
+ rm -f "${tmp}.timeout"
243
+ fi
197
244
  fi
198
245
 
199
246
  output="$(cat "$tmp")"
200
247
  rm -f "$tmp"
248
+ [ -n "${guard_dir:-}" ] && rm -rf "$guard_dir"
201
249
 
202
250
  printf '%s\n' "$rc"
203
251
  printf '%s' "$output"
@@ -219,10 +267,17 @@ run_with_timeout() {
219
267
  run_result="$(run_with_timeout "" "$FULL_MSG")"
220
268
  rc="$(printf '%s' "$run_result" | head -n 1)"
221
269
  raw_output="$(printf '%s' "$run_result" | tail -n +2)"
222
- output="$(sanitize_output "$raw_output")"
270
+ output="$(extract_text_from_json_stream "$raw_output")"
271
+ if [ -z "$output" ]; then
272
+ output="$(sanitize_output "$raw_output")"
273
+ fi
223
274
 
224
275
  if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
225
- output="OpenCode timed out after ${RUN_TIMEOUT_SEC}s. Task may still be running. Try waiting a bit or send a follow-up."
276
+ if [ -n "$output" ] && ! is_trivial_echo "$output" "$MSG"; then
277
+ output="${output}\n\n(Stopped after ${RUN_TIMEOUT_SEC}s timeout.)"
278
+ else
279
+ output="OpenCode timed out after ${RUN_TIMEOUT_SEC}s. Try a narrower prompt or send a follow-up."
280
+ fi
226
281
  elif [ -z "$output" ]; then
227
282
  output="OpenCode finished, but returned an empty response."
228
283
  elif is_trivial_echo "$output" "$MSG"; then
@@ -231,11 +286,7 @@ run_with_timeout() {
231
286
 
232
287
  output="$(apply_reply_style "$output")"
233
288
 
234
- if has_external_delivery_success "$raw_output"; then
235
- printf '[%s] /ccn skipped bridge send (already sent by OpenCode)\n' "$(date '+%Y-%m-%d %H:%M:%S')"
236
- else
237
- openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$output"
238
- fi
289
+ openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$output"
239
290
 
240
291
  ended_at=$(date +%s)
241
292
  elapsed=$((ended_at - started_at))
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # bridge-version: 10
2
+ # bridge-version: 19
3
3
  # Dispatch instruction to OpenCode asynchronously and relay response
4
4
  MSG="$1"
5
5
  OPENCODE="{{OPENCODE_BIN}}"
@@ -7,9 +7,9 @@ CHANNEL="{{CHANNEL}}"
7
7
  TARGET="{{TARGET_ID}}"
8
8
  WORKSPACE="{{WORKSPACE}}"
9
9
  LOG_FILE="/tmp/opencode-bridge-send.log"
10
- BASE_TIMEOUT_SEC=45
11
- MAX_TIMEOUT_SEC=300
12
- LOCK_WAIT_SEC=600
10
+ BASE_TIMEOUT_SEC=300
11
+ MAX_TIMEOUT_SEC=600
12
+ LOCK_WAIT_SEC=3
13
13
 
14
14
  if [ -z "$MSG" ]; then
15
15
  echo "ERROR: No message provided"
@@ -30,20 +30,43 @@ normalize_text() {
30
30
  }
31
31
 
32
32
  acquire_bridge_lock() {
33
- local safe_channel safe_target lock_file
33
+ local safe_channel safe_target holder_pid waited
34
34
  safe_channel="$(printf '%s' "$CHANNEL" | sed -E 's/[^a-zA-Z0-9._-]/_/g')"
35
35
  safe_target="$(printf '%s' "$TARGET" | sed -E 's/[^a-zA-Z0-9._-]/_/g')"
36
- lock_file="/tmp/opencode-bridge-${safe_channel}-${safe_target}.lock"
36
+ LOCK_DIR="/tmp/opencode-bridge-${safe_channel}-${safe_target}.lockdir"
37
+ waited=0
37
38
 
38
- if command -v flock >/dev/null 2>&1; then
39
- exec 200>"$lock_file"
40
- flock -w "$LOCK_WAIT_SEC" 200
41
- return $?
42
- fi
39
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
40
+ if [ ! -s "$LOCK_DIR/pid" ]; then
41
+ rm -rf "$LOCK_DIR"
42
+ continue
43
+ fi
44
+ if [ -f "$LOCK_DIR/pid" ]; then
45
+ holder_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
46
+ if [ -n "$holder_pid" ] && ! kill -0 "$holder_pid" 2>/dev/null; then
47
+ rm -rf "$LOCK_DIR"
48
+ continue
49
+ fi
50
+ fi
43
51
 
52
+ if [ "$waited" -ge "$LOCK_WAIT_SEC" ]; then
53
+ return 1
54
+ fi
55
+ sleep 1
56
+ waited=$((waited + 1))
57
+ done
58
+
59
+ printf '%s\n' "${BASHPID:-$$}" > "$LOCK_DIR/pid"
60
+ trap 'release_bridge_lock' EXIT INT TERM
44
61
  return 0
45
62
  }
46
63
 
64
+ release_bridge_lock() {
65
+ if [ -n "${LOCK_DIR:-}" ] && [ -d "$LOCK_DIR" ]; then
66
+ rm -rf "$LOCK_DIR"
67
+ fi
68
+ }
69
+
47
70
  compute_timeout() {
48
71
  local msg="$1"
49
72
  local timeout="$BASE_TIMEOUT_SEC"
@@ -56,7 +79,7 @@ compute_timeout() {
56
79
  if [ "$chars" -gt 280 ]; then timeout=$((timeout + 20)); fi
57
80
  if [ "$words" -gt 25 ]; then timeout=$((timeout + 20)); fi
58
81
 
59
- if printf '%s' "$msg" | grep -Eqi '\b(create|build|write|implement|script|code|program|refactor|debug|fix|test|automation|deploy|migrate|api|database|project)\b'; then
82
+ if printf '%s' "$msg" | grep -Eqi '\b(create|build|write|implement|script|code|program|refactor|debug|fix|test|automation|deploy|migrate|api|database|project|search|news|weather|forecast|cuaca|berita)\b'; then
60
83
  timeout=$((timeout + 60))
61
84
  fi
62
85
 
@@ -78,11 +101,6 @@ is_trivial_echo() {
78
101
  [ -n "$message_norm" ] && [ "$output_norm" = "$message_norm" ]
79
102
  }
80
103
 
81
- has_external_delivery_success() {
82
- local raw="$1"
83
- printf '%s\n' "$raw" | grep -Eqi 'Sent via [A-Za-z]+|Message ID:[[:space:]]*[0-9]+'
84
- }
85
-
86
104
  extract_last_marked_block() {
87
105
  local raw="$1"
88
106
  if ! printf '%s' "$raw" | grep -q '🔗'; then
@@ -119,7 +137,7 @@ sanitize_output() {
119
137
  cleaned="$(printf '%s\n' "$cleaned" | sed -E 's/^[[:space:]]*>+[[:space:]]?//; /^```/d')"
120
138
 
121
139
  cleaned="$(printf '%s\n' "$cleaned" | grep -Eiv \
122
- '^[[:space:]]*$|^[[:space:]]*(build[[:space:]]*·|◇[[:space:]]+doctor warnings)[[:space:]]*$|^[[:space:]]*◇[[:space:]]+|^[[:space:]]*exa[[:space:]]+(web|code)[[:space:]]+search([[:space:]]+.*)?$|^[[:space:]]*[←→↳].*|^[[:space:]]*wrote file successfully\.?$|^[[:space:]]*(\$[[:space:]]*)?openclaw message send --channel[[:space:]]+|^[[:space:]]*error:[[:space:]]*too many arguments for '\''send'\''.*$|^[[:space:]]*sent via telegram|^[[:space:]]*\[(telegram|discord|slack|whatsapp|signal|irc|matrix|line|mattermost|teams)\]|autoselectfamily=|dnsresultorder=|^[[:space:]]*[│┌┐└┘├┤┬┴┼─═╭╮╰╯]+[[:space:]]*$')"
140
+ '^[[:space:]]*$|^[[:space:]]*(build[[:space:]]*·|◇[[:space:]]+doctor warnings)[[:space:]]*$|^[[:space:]]*◇[[:space:]]+|^[[:space:]]*exa[[:space:]]+(web|code)[[:space:]]+search([[:space:]]+.*)?$|^[[:space:]]*[←→↳].*|^[[:space:]]*wrote file successfully\.?$|^[[:space:]]*(\$[[:space:]]*)?openclaw message send --channel[[:space:]]+|^[[:space:]]*bridge-guard:[[:space:]]*blocked openclaw message send.*$|^[[:space:]]*error:[[:space:]]*too many arguments for '\''send'\''.*$|^[[:space:]]*sent via telegram|^[[:space:]]*\[(telegram|discord|slack|whatsapp|signal|irc|matrix|line|mattermost|teams)\]|autoselectfamily=|dnsresultorder=|^[[:space:]]*[│┌┐└┘├┤┬┴┼─═╭╮╰╯]+[[:space:]]*$')"
123
141
 
124
142
  marked="$(extract_last_marked_block "$cleaned")"
125
143
  if [ -n "$marked" ]; then
@@ -131,6 +149,14 @@ sanitize_output() {
131
149
  printf '%s' "$(trim_text "$cleaned")"
132
150
  }
133
151
 
152
+ extract_text_from_json_stream() {
153
+ local raw="$1"
154
+ local parsed
155
+
156
+ parsed="$(printf '%s\n' "$raw" | node -e "const fs=require('fs');const lines=fs.readFileSync(0,'utf8').split(/\r?\n/);let out='';for(const line of lines){const s=line.trim();if(!s)continue;try{const obj=JSON.parse(s);if(obj&&obj.type==='text'&&obj.part&&typeof obj.part.text==='string'){out+=obj.part.text;}}catch{}}process.stdout.write(out);" 2>/dev/null)"
157
+ printf '%s' "$(trim_text "$parsed")"
158
+ }
159
+
134
160
  sentence_case_first() {
135
161
  local text="$1"
136
162
  printf '%s' "$text" | awk '
@@ -166,34 +192,60 @@ apply_reply_style() {
166
192
  run_with_timeout() {
167
193
  local mode="$1"
168
194
  local prompt="$2"
169
- local output rc tmp pid watchdog
195
+ local output rc tmp pid watchdog guard_dir guarded_path real_openclaw
170
196
 
171
197
  tmp="$(mktemp /tmp/opencode-run.XXXXXX)"
172
- "$OPENCODE" run "$mode" "$prompt" >"$tmp" 2>&1 &
173
- pid=$!
174
-
175
- (
176
- sleep "$RUN_TIMEOUT_SEC"
177
- if kill -0 "$pid" 2>/dev/null; then
178
- touch "${tmp}.timeout"
179
- kill "$pid" 2>/dev/null
180
- sleep 2
181
- kill -9 "$pid" 2>/dev/null
182
- fi
183
- ) &
184
- watchdog=$!
185
-
186
- wait "$pid"
187
- rc=$?
188
- kill "$watchdog" 2>/dev/null
198
+ guarded_path="$PATH"
199
+ real_openclaw="$(command -v openclaw 2>/dev/null || true)"
200
+ if [ -n "$real_openclaw" ]; then
201
+ guard_dir="$(mktemp -d /tmp/opencode-bridge-guard.XXXXXX)"
202
+ printf '#!/bin/sh\nif [ "$1" = "message" ] && [ "$2" = "send" ]; then\n echo "bridge-guard: blocked openclaw message send from opencode runtime" >&2\n exit 64\nfi\nexec "%s" "$@"\n' "$real_openclaw" > "${guard_dir}/openclaw"
203
+ chmod +x "${guard_dir}/openclaw"
204
+ guarded_path="${guard_dir}:${PATH}"
205
+ fi
189
206
 
190
- if [ -f "${tmp}.timeout" ]; then
191
- rc=124
192
- rm -f "${tmp}.timeout"
207
+ # Timeout here is an upper bound only; command returns immediately when OpenCode finishes.
208
+ if command -v timeout >/dev/null 2>&1; then
209
+ if [ -n "$mode" ]; then
210
+ PATH="$guarded_path" timeout --signal=TERM --kill-after=2 "${RUN_TIMEOUT_SEC}s" \
211
+ "$OPENCODE" run "$mode" --no-fork --format json "$prompt" >"$tmp" 2>&1
212
+ else
213
+ PATH="$guarded_path" timeout --signal=TERM --kill-after=2 "${RUN_TIMEOUT_SEC}s" \
214
+ "$OPENCODE" run --no-fork --format json "$prompt" >"$tmp" 2>&1
215
+ fi
216
+ rc=$?
217
+ else
218
+ if [ -n "$mode" ]; then
219
+ PATH="$guarded_path" "$OPENCODE" run "$mode" --no-fork --format json "$prompt" >"$tmp" 2>&1 &
220
+ else
221
+ PATH="$guarded_path" "$OPENCODE" run --no-fork --format json "$prompt" >"$tmp" 2>&1 &
222
+ fi
223
+ pid=$!
224
+
225
+ (
226
+ sleep "$RUN_TIMEOUT_SEC"
227
+ if kill -0 "$pid" 2>/dev/null; then
228
+ touch "${tmp}.timeout"
229
+ kill "$pid" 2>/dev/null
230
+ sleep 2
231
+ kill -9 "$pid" 2>/dev/null
232
+ fi
233
+ ) &
234
+ watchdog=$!
235
+
236
+ wait "$pid"
237
+ rc=$?
238
+ kill "$watchdog" 2>/dev/null
239
+
240
+ if [ -f "${tmp}.timeout" ]; then
241
+ rc=124
242
+ rm -f "${tmp}.timeout"
243
+ fi
193
244
  fi
194
245
 
195
246
  output="$(cat "$tmp")"
196
247
  rm -f "$tmp"
248
+ [ -n "${guard_dir:-}" ] && rm -rf "$guard_dir"
197
249
 
198
250
  printf '%s\n' "$rc"
199
251
  printf '%s' "$output"
@@ -202,6 +254,7 @@ run_with_timeout() {
202
254
  (
203
255
  started_at=$(date +%s)
204
256
  RUN_TIMEOUT_SEC="$(compute_timeout "$MSG")"
257
+ RUN_MODE="--continue"
205
258
  cd "$WORKSPACE" || exit 1
206
259
  FULL_MSG="[${CHANNEL}:${TARGET}] $MSG"
207
260
 
@@ -211,13 +264,20 @@ run_with_timeout() {
211
264
  exit 0
212
265
  fi
213
266
 
214
- run_result="$(run_with_timeout --continue "$FULL_MSG")"
267
+ run_result="$(run_with_timeout "$RUN_MODE" "$FULL_MSG")"
215
268
  rc="$(printf '%s' "$run_result" | head -n 1)"
216
269
  raw_output="$(printf '%s' "$run_result" | tail -n +2)"
217
- output="$(sanitize_output "$raw_output")"
270
+ output="$(extract_text_from_json_stream "$raw_output")"
271
+ if [ -z "$output" ]; then
272
+ output="$(sanitize_output "$raw_output")"
273
+ fi
218
274
 
219
275
  if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
220
- output="OpenCode timed out after ${RUN_TIMEOUT_SEC}s. Task may still be running. Try waiting a bit or send a follow-up."
276
+ if [ -n "$output" ] && ! is_trivial_echo "$output" "$MSG"; then
277
+ output="${output}\n\n(Stopped after ${RUN_TIMEOUT_SEC}s timeout.)"
278
+ else
279
+ output="OpenCode timed out after ${RUN_TIMEOUT_SEC}s. Try a narrower prompt or send a follow-up."
280
+ fi
221
281
  elif [ -z "$output" ]; then
222
282
  output="OpenCode finished, but returned an empty response."
223
283
  elif is_trivial_echo "$output" "$MSG"; then
@@ -226,15 +286,12 @@ run_with_timeout() {
226
286
 
227
287
  output="$(apply_reply_style "$output")"
228
288
 
229
- if has_external_delivery_success "$raw_output"; then
230
- printf '[%s] /cc skipped bridge send (already sent by OpenCode)\n' "$(date '+%Y-%m-%d %H:%M:%S')"
231
- else
232
- openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$output"
233
- fi
289
+ openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$output"
234
290
 
235
291
  ended_at=$(date +%s)
236
292
  elapsed=$((ended_at - started_at))
237
- printf '[%s] /cc completed in %ss (exit=%s timeout=%ss)\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$elapsed" "$rc" "$RUN_TIMEOUT_SEC"
293
+ mode_label="${RUN_MODE:---new}"
294
+ printf '[%s] /cc completed in %ss (exit=%s timeout=%ss mode=%s)\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$elapsed" "$rc" "$RUN_TIMEOUT_SEC" "$mode_label"
238
295
  ) >>"$LOG_FILE" 2>&1 &
239
296
 
240
297
  echo "✅ OpenCode request queued."