openclaw-opencode-bridge 2.1.3 → 2.1.6

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 `60s` dan maksimum `300s`.
87
+ - `/ccn` memakai timeout adaptif dengan base `90s` dan maksimum `420s`.
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,16 @@ 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+) |
102
-
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.
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 |
104
123
 
105
124
  ## License
106
125
 
package/README_EN.md ADDED
@@ -0,0 +1,126 @@
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 `60s` and max `300s`.
81
+ - `/ccn` uses adaptive timeout with base `90s` and max `420s`.
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
+ ## License
125
+
126
+ [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.3",
4
- "description": "Bridge OpenClaw messaging channels to OpenCode via tmux persistent sessions",
3
+ "version": "2.1.6",
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: 9
2
+ # bridge-version: 16
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=90
11
+ MAX_TIMEOUT_SEC=420
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
@@ -116,8 +134,10 @@ sanitize_output() {
116
134
 
117
135
  cleaned="$(printf '%s\n' "$cleaned" | sed -E 's/\[[0-9]{1,3}m//g')"
118
136
 
119
- cleaned="$(printf '%s\n' "$cleaned" | grep -Ev \
120
- '^[[:space:]]*$|^[[:space:]]*(build[[:space:]]*·|◇[[:space:]]+Doctor warnings)[[:space:]]*$|^[[:space:]]*◇[[: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:]]*$')"
137
+ cleaned="$(printf '%s\n' "$cleaned" | sed -E 's/^[[:space:]]*>+[[:space:]]?//; /^```/d')"
138
+
139
+ cleaned="$(printf '%s\n' "$cleaned" | grep -Eiv \
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:]]*$')"
121
141
 
122
142
  marked="$(extract_last_marked_block "$cleaned")"
123
143
  if [ -n "$marked" ]; then
@@ -129,6 +149,14 @@ sanitize_output() {
129
149
  printf '%s' "$(trim_text "$cleaned")"
130
150
  }
131
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
+
132
160
  sentence_case_first() {
133
161
  local text="$1"
134
162
  printf '%s' "$text" | awk '
@@ -164,38 +192,60 @@ apply_reply_style() {
164
192
  run_with_timeout() {
165
193
  local mode="$1"
166
194
  local prompt="$2"
167
- local output rc tmp pid watchdog
195
+ local output rc tmp pid watchdog guard_dir guarded_path real_openclaw
168
196
 
169
197
  tmp="$(mktemp /tmp/opencode-run.XXXXXX)"
170
- if [ -n "$mode" ]; then
171
- "$OPENCODE" run "$mode" "$prompt" >"$tmp" 2>&1 &
172
- else
173
- "$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}"
174
205
  fi
175
- pid=$!
176
-
177
- (
178
- sleep "$RUN_TIMEOUT_SEC"
179
- if kill -0 "$pid" 2>/dev/null; then
180
- touch "${tmp}.timeout"
181
- kill "$pid" 2>/dev/null
182
- sleep 2
183
- kill -9 "$pid" 2>/dev/null
184
- fi
185
- ) &
186
- watchdog=$!
187
206
 
188
- wait "$pid"
189
- rc=$?
190
- kill "$watchdog" 2>/dev/null
191
-
192
- if [ -f "${tmp}.timeout" ]; then
193
- rc=124
194
- 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
195
244
  fi
196
245
 
197
246
  output="$(cat "$tmp")"
198
247
  rm -f "$tmp"
248
+ [ -n "${guard_dir:-}" ] && rm -rf "$guard_dir"
199
249
 
200
250
  printf '%s\n' "$rc"
201
251
  printf '%s' "$output"
@@ -217,10 +267,17 @@ run_with_timeout() {
217
267
  run_result="$(run_with_timeout "" "$FULL_MSG")"
218
268
  rc="$(printf '%s' "$run_result" | head -n 1)"
219
269
  raw_output="$(printf '%s' "$run_result" | tail -n +2)"
220
- 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
221
274
 
222
275
  if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
223
- 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
224
281
  elif [ -z "$output" ]; then
225
282
  output="OpenCode finished, but returned an empty response."
226
283
  elif is_trivial_echo "$output" "$MSG"; then
@@ -229,11 +286,7 @@ run_with_timeout() {
229
286
 
230
287
  output="$(apply_reply_style "$output")"
231
288
 
232
- if has_external_delivery_success "$raw_output"; then
233
- printf '[%s] /ccn skipped bridge send (already sent by OpenCode)\n' "$(date '+%Y-%m-%d %H:%M:%S')"
234
- else
235
- openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$output"
236
- fi
289
+ openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$output"
237
290
 
238
291
  ended_at=$(date +%s)
239
292
  elapsed=$((ended_at - started_at))
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # bridge-version: 9
2
+ # bridge-version: 18
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
10
+ BASE_TIMEOUT_SEC=60
11
11
  MAX_TIMEOUT_SEC=300
12
- LOCK_WAIT_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
@@ -116,8 +134,10 @@ sanitize_output() {
116
134
 
117
135
  cleaned="$(printf '%s\n' "$cleaned" | sed -E 's/\[[0-9]{1,3}m//g')"
118
136
 
119
- cleaned="$(printf '%s\n' "$cleaned" | grep -Ev \
120
- '^[[:space:]]*$|^[[:space:]]*(build[[:space:]]*·|◇[[:space:]]+Doctor warnings)[[:space:]]*$|^[[:space:]]*◇[[: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:]]*$')"
137
+ cleaned="$(printf '%s\n' "$cleaned" | sed -E 's/^[[:space:]]*>+[[:space:]]?//; /^```/d')"
138
+
139
+ cleaned="$(printf '%s\n' "$cleaned" | grep -Eiv \
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:]]*$')"
121
141
 
122
142
  marked="$(extract_last_marked_block "$cleaned")"
123
143
  if [ -n "$marked" ]; then
@@ -129,6 +149,14 @@ sanitize_output() {
129
149
  printf '%s' "$(trim_text "$cleaned")"
130
150
  }
131
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
+
132
160
  sentence_case_first() {
133
161
  local text="$1"
134
162
  printf '%s' "$text" | awk '
@@ -164,34 +192,60 @@ apply_reply_style() {
164
192
  run_with_timeout() {
165
193
  local mode="$1"
166
194
  local prompt="$2"
167
- local output rc tmp pid watchdog
195
+ local output rc tmp pid watchdog guard_dir guarded_path real_openclaw
168
196
 
169
197
  tmp="$(mktemp /tmp/opencode-run.XXXXXX)"
170
- "$OPENCODE" run "$mode" "$prompt" >"$tmp" 2>&1 &
171
- pid=$!
172
-
173
- (
174
- sleep "$RUN_TIMEOUT_SEC"
175
- if kill -0 "$pid" 2>/dev/null; then
176
- touch "${tmp}.timeout"
177
- kill "$pid" 2>/dev/null
178
- sleep 2
179
- kill -9 "$pid" 2>/dev/null
180
- fi
181
- ) &
182
- watchdog=$!
183
-
184
- wait "$pid"
185
- rc=$?
186
- 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
187
206
 
188
- if [ -f "${tmp}.timeout" ]; then
189
- rc=124
190
- 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
191
244
  fi
192
245
 
193
246
  output="$(cat "$tmp")"
194
247
  rm -f "$tmp"
248
+ [ -n "${guard_dir:-}" ] && rm -rf "$guard_dir"
195
249
 
196
250
  printf '%s\n' "$rc"
197
251
  printf '%s' "$output"
@@ -200,6 +254,7 @@ run_with_timeout() {
200
254
  (
201
255
  started_at=$(date +%s)
202
256
  RUN_TIMEOUT_SEC="$(compute_timeout "$MSG")"
257
+ RUN_MODE="--continue"
203
258
  cd "$WORKSPACE" || exit 1
204
259
  FULL_MSG="[${CHANNEL}:${TARGET}] $MSG"
205
260
 
@@ -209,13 +264,20 @@ run_with_timeout() {
209
264
  exit 0
210
265
  fi
211
266
 
212
- run_result="$(run_with_timeout --continue "$FULL_MSG")"
267
+ run_result="$(run_with_timeout "$RUN_MODE" "$FULL_MSG")"
213
268
  rc="$(printf '%s' "$run_result" | head -n 1)"
214
269
  raw_output="$(printf '%s' "$run_result" | tail -n +2)"
215
- 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
216
274
 
217
275
  if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
218
- 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
219
281
  elif [ -z "$output" ]; then
220
282
  output="OpenCode finished, but returned an empty response."
221
283
  elif is_trivial_echo "$output" "$MSG"; then
@@ -224,15 +286,12 @@ run_with_timeout() {
224
286
 
225
287
  output="$(apply_reply_style "$output")"
226
288
 
227
- if has_external_delivery_success "$raw_output"; then
228
- printf '[%s] /cc skipped bridge send (already sent by OpenCode)\n' "$(date '+%Y-%m-%d %H:%M:%S')"
229
- else
230
- openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$output"
231
- fi
289
+ openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$output"
232
290
 
233
291
  ended_at=$(date +%s)
234
292
  elapsed=$((ended_at - started_at))
235
- 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"
236
295
  ) >>"$LOG_FILE" 2>&1 &
237
296
 
238
297
  echo "✅ OpenCode request queued."