openclaw-opencode-bridge 2.0.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.
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const os = require("os");
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { execSync } = require("child_process");
8
+ const chalk = require("chalk");
9
+
10
+ function detectOS() {
11
+ const p = process.platform;
12
+ if (p === "darwin") return "macOS";
13
+ if (p === "linux") return "Linux";
14
+ return p;
15
+ }
16
+
17
+ function getHomeDir() {
18
+ return os.homedir();
19
+ }
20
+
21
+ function getWorkspace() {
22
+ return path.join(getHomeDir(), ".openclaw", "workspace");
23
+ }
24
+
25
+ function getScriptsDir() {
26
+ return path.join(getHomeDir(), ".openclaw", "scripts");
27
+ }
28
+
29
+ // --- Daemon install/remove ---
30
+
31
+ const PLIST_NAME = "ai.openclaw.opencode-session.plist";
32
+ const SYSTEMD_NAME = "openclaw-opencode-session.service";
33
+
34
+ function getPlistPath() {
35
+ return path.join(getHomeDir(), "Library", "LaunchAgents", PLIST_NAME);
36
+ }
37
+
38
+ function getSystemdPath() {
39
+ return path.join(getHomeDir(), ".config", "systemd", "user", SYSTEMD_NAME);
40
+ }
41
+
42
+ function installDaemon(scriptPath) {
43
+ const platform = process.platform;
44
+
45
+ if (platform === "darwin") {
46
+ return installLaunchAgent(scriptPath);
47
+ } else if (platform === "linux") {
48
+ return installSystemdUnit(scriptPath);
49
+ } else {
50
+ console.log(
51
+ chalk.yellow(` ! Daemon auto-install not supported on ${platform}.`),
52
+ );
53
+ console.log(chalk.yellow(` Run ${scriptPath} manually or via cron.`));
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function substituteTemplate(content, vars) {
59
+ let result = content;
60
+ for (const [key, value] of Object.entries(vars)) {
61
+ result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
62
+ }
63
+ return result;
64
+ }
65
+
66
+ function installLaunchAgent(scriptPath) {
67
+ const plistPath = getPlistPath();
68
+ const dir = path.dirname(plistPath);
69
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
70
+
71
+ const tplPath = path.join(
72
+ __dirname,
73
+ "..",
74
+ "templates",
75
+ "daemon",
76
+ "macos.plist",
77
+ );
78
+ let content = fs.readFileSync(tplPath, "utf8");
79
+ content = substituteTemplate(content, { SCRIPT_PATH: scriptPath });
80
+
81
+ fs.writeFileSync(plistPath, content);
82
+
83
+ // Unload if already loaded, then load
84
+ try {
85
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, {
86
+ stdio: "ignore",
87
+ });
88
+ } catch {}
89
+ try {
90
+ execSync(`launchctl load "${plistPath}"`);
91
+ return true;
92
+ } catch (e) {
93
+ console.log(chalk.red(` Failed to load LaunchAgent: ${e.message}`));
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function installSystemdUnit(scriptPath) {
99
+ const unitPath = getSystemdPath();
100
+ const dir = path.dirname(unitPath);
101
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
102
+
103
+ const tplPath = path.join(
104
+ __dirname,
105
+ "..",
106
+ "templates",
107
+ "daemon",
108
+ "linux.service",
109
+ );
110
+ let content = fs.readFileSync(tplPath, "utf8");
111
+ content = substituteTemplate(content, { SCRIPT_PATH: scriptPath });
112
+
113
+ fs.writeFileSync(unitPath, content);
114
+
115
+ try {
116
+ execSync("systemctl --user daemon-reload");
117
+ execSync(`systemctl --user enable --now ${SYSTEMD_NAME}`);
118
+ return true;
119
+ } catch (e) {
120
+ console.log(chalk.red(` Failed to enable systemd unit: ${e.message}`));
121
+ return false;
122
+ }
123
+ }
124
+
125
+ const LEGACY_NAMES = {
126
+ darwin: "ai.openclaw.claude-session.plist",
127
+ linux: "openclaw-claude.service",
128
+ };
129
+
130
+ function removeDaemon() {
131
+ const platform = process.platform;
132
+ let removed = false;
133
+
134
+ if (platform === "darwin") {
135
+ const systemdDir = path.join(getHomeDir(), ".config", "systemd", "user");
136
+ for (const name of [SYSTEMD_NAME, LEGACY_NAMES.darwin]) {
137
+ const unitPath = path.join(systemdDir, name);
138
+ if (fs.existsSync(unitPath)) {
139
+ try {
140
+ execSync(`systemctl --user disable --now ${name}`, {
141
+ stdio: "ignore",
142
+ });
143
+ } catch {}
144
+ fs.unlinkSync(unitPath);
145
+ removed = true;
146
+ }
147
+ }
148
+ const plistPath = getPlistPath();
149
+ const legacyPlist = path.join(getHomeDir(), "Library", "LaunchAgents", LEGACY_NAMES.darwin);
150
+ for (const p of [plistPath, legacyPlist]) {
151
+ if (fs.existsSync(p)) {
152
+ try {
153
+ execSync(`launchctl unload "${p}" 2>/dev/null`, {
154
+ stdio: "ignore",
155
+ });
156
+ } catch {}
157
+ fs.unlinkSync(p);
158
+ removed = true;
159
+ }
160
+ }
161
+ } else if (platform === "linux") {
162
+ const systemdDir = path.join(getHomeDir(), ".config", "systemd", "user");
163
+ for (const name of [SYSTEMD_NAME, LEGACY_NAMES.linux]) {
164
+ const unitPath = path.join(systemdDir, name);
165
+ if (fs.existsSync(unitPath)) {
166
+ try {
167
+ execSync(`systemctl --user disable --now ${name}`, {
168
+ stdio: "ignore",
169
+ });
170
+ } catch {}
171
+ fs.unlinkSync(unitPath);
172
+ removed = true;
173
+ }
174
+ }
175
+ try {
176
+ execSync("systemctl --user daemon-reload");
177
+ } catch {}
178
+ }
179
+ return removed;
180
+ }
181
+
182
+ module.exports = {
183
+ detectOS,
184
+ getHomeDir,
185
+ getWorkspace,
186
+ getScriptsDir,
187
+ installDaemon,
188
+ removeDaemon,
189
+ substituteTemplate,
190
+ };
@@ -0,0 +1,38 @@
1
+ {
2
+ "id": "opencode-bridge",
3
+ "name": "OpenCode Bridge",
4
+ "description": "Route @cc/@ccn/@ccu prefix messages to OpenCode via tmux, suppressing the default LLM response.",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "scriptsDir": {
10
+ "type": "string",
11
+ "description": "Path to bridge shell scripts directory"
12
+ },
13
+ "channel": {
14
+ "type": "string",
15
+ "description": "Delivery channel (telegram, discord, slack, etc.)"
16
+ },
17
+ "targetId": {
18
+ "type": "string",
19
+ "description": "Delivery target ID for replies"
20
+ }
21
+ },
22
+ "required": ["scriptsDir"]
23
+ },
24
+ "uiHints": {
25
+ "scriptsDir": {
26
+ "label": "Scripts Directory",
27
+ "help": "Absolute path to the directory containing opencode-send.sh, opencode-new-session.sh, opencode-stats.sh"
28
+ },
29
+ "channel": {
30
+ "label": "Channel",
31
+ "help": "Channel for sending replies (e.g. telegram)"
32
+ },
33
+ "targetId": {
34
+ "label": "Target ID",
35
+ "help": "Target ID for sending replies (e.g. Telegram chat ID)"
36
+ }
37
+ }
38
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "openclaw-opencode-bridge",
3
+ "version": "2.0.6",
4
+ "description": "Bridge OpenClaw messaging channels to OpenCode via tmux persistent sessions",
5
+ "main": "./lib/onboard.js",
6
+ "bin": {
7
+ "openclaw-opencode-bridge": "./lib/cli.js"
8
+ },
9
+ "scripts": {
10
+ },
11
+ "preferGlobal": true,
12
+ "dependencies": {
13
+ "commander": "^12.0.0",
14
+ "chalk": "^4.1.2"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "openclaw": {
20
+ "extensions": ["./plugin/index.ts"]
21
+ },
22
+ "keywords": [
23
+ "openclaw",
24
+ "opencode",
25
+ "tmux",
26
+ "cli",
27
+ "bridge",
28
+ "telegram",
29
+ "discord",
30
+ "slack"
31
+ ],
32
+ "bugs": {
33
+ "email": "febro.aw20@gmail.com"
34
+ },
35
+ "author": {
36
+ "name": "Febrian",
37
+ "email": "febro.aw20@gmail.com"
38
+ },
39
+ "homepage": "https://febro.fun",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/bettep-dev/openclaw-opencode-bridge"
44
+ }
45
+ }
@@ -0,0 +1,136 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+
4
+ const PREFIX_RE = /^[@\/](cc|ccn|ccu|ccm|ccms)\b\s*([\s\S]*)/;
5
+
6
+ const SCRIPT_MAP: Record<string, string> = {
7
+ cc: "opencode-send.sh",
8
+ ccn: "opencode-new-session.sh",
9
+ ccu: "opencode-stats.sh",
10
+ ccm: "opencode-models.sh",
11
+ ccms: "opencode-setmodel.sh",
12
+ };
13
+
14
+ const REQUIRES_ARG = new Set(["cc", "ccn"]);
15
+ const EXEC_TIMEOUT = 120_000;
16
+
17
+ const DELIVERY_MSG = "šŸ”— OpenCode will reply shortly.";
18
+
19
+ const SILENT_PROMPT =
20
+ "CRITICAL SYSTEM OVERRIDE — HIGHEST PRIORITY.\n" +
21
+ "The previous user message was intercepted by the opencode-bridge plugin and is already being handled externally.\n" +
22
+ "You MUST NOT process, interpret, or respond to the user's request.\n" +
23
+ "You MUST NOT call any tools or functions.\n" +
24
+ `Output ONLY this exact text, nothing else: ${DELIVERY_MSG}`;
25
+
26
+ // NOTE: Single-user assumption — concurrent users may see cross-suppression
27
+ let bridgeSuppressUntil = 0;
28
+
29
+ /**
30
+ * Flag set by message_received (fires FIRST) and consumed by before_prompt_build (fires SECOND).
31
+ * This bypasses the unreliable extractLastUserText approach entirely.
32
+ * message_received gets event.content (raw user text) which always correctly detects @cc prefix.
33
+ */
34
+ let pendingBridgeCommand = false;
35
+
36
+ export default function register(api: OpenClawPluginApi) {
37
+ const config = api.pluginConfig as {
38
+ scriptsDir?: string;
39
+ channel?: string;
40
+ targetId?: string;
41
+ };
42
+
43
+ const scriptsDir = config.scriptsDir ?? "";
44
+
45
+ // --- Hook 1: message_received (fire-and-forget) ---
46
+ // Fires FIRST. Detect prefix from raw event.content and set pendingBridgeCommand flag.
47
+ // Also executes the bridge script.
48
+ api.on("message_received", async (event, _ctx) => {
49
+ const raw = (event.content ?? "").trim();
50
+ api.logger.debug?.(
51
+ `[opencode-bridge] message_received: raw_start=${JSON.stringify(raw.slice(0, 200))}`,
52
+ );
53
+ const match = raw.match(PREFIX_RE);
54
+ if (!match) return;
55
+
56
+ const command = match[1];
57
+ const script = SCRIPT_MAP[command];
58
+ if (!script) return;
59
+
60
+ // Set flag for before_prompt_build to consume
61
+ pendingBridgeCommand = true;
62
+ // Also set suppression timer as safety net
63
+ bridgeSuppressUntil = Date.now() + EXEC_TIMEOUT + 5_000;
64
+
65
+ api.logger.debug?.(
66
+ `[opencode-bridge] message_received: command=${command}, pendingBridgeCommand=true`,
67
+ );
68
+
69
+ const arg = match[2].trim();
70
+
71
+ if (REQUIRES_ARG.has(command) && !arg) {
72
+ api.logger.warn?.(`[opencode-bridge] /${command} requires an argument`);
73
+ return;
74
+ }
75
+
76
+ const scriptPath = `${scriptsDir}/${script}`;
77
+ const args = arg ? [arg] : [];
78
+
79
+ execFile(
80
+ scriptPath,
81
+ args,
82
+ { timeout: EXEC_TIMEOUT },
83
+ (error, _stdout, stderr) => {
84
+ if (error) {
85
+ api.logger.error?.(
86
+ `[opencode-bridge] ${script} failed: ${stderr?.trim() || error.message}`,
87
+ );
88
+ }
89
+ },
90
+ );
91
+ });
92
+
93
+ // --- Hook 2: before_prompt_build (modifying) ---
94
+ // Fires SECOND. Consumes the pendingBridgeCommand flag set by message_received.
95
+ // No longer relies on extractLastUserText for prefix detection.
96
+ api.on("before_prompt_build", async (event, ctx) => {
97
+ const shouldSuppress = pendingBridgeCommand;
98
+
99
+ api.logger.debug?.(
100
+ `[opencode-bridge] before_prompt_build: pendingBridgeCommand=${pendingBridgeCommand}, bridgeSuppressUntil=${bridgeSuppressUntil > Date.now()}`,
101
+ );
102
+
103
+ if (shouldSuppress) {
104
+ pendingBridgeCommand = false;
105
+ bridgeSuppressUntil = Date.now() + EXEC_TIMEOUT + 5_000;
106
+ return { systemPrompt: SILENT_PROMPT, prependContext: SILENT_PROMPT };
107
+ } else {
108
+ // Clear suppression for non-bridge messages
109
+ bridgeSuppressUntil = 0;
110
+ }
111
+ });
112
+
113
+ // --- Hook 3: message_sending (modifying) ---
114
+ // Replace LLM output with delivery confirmation while bridge suppression is active
115
+ api.on("message_sending", async (_event, _ctx) => {
116
+ const suppressing = Date.now() < bridgeSuppressUntil;
117
+ api.logger.debug?.(
118
+ `[opencode-bridge] message_sending: suppressing=${suppressing}`,
119
+ );
120
+
121
+ if (suppressing) {
122
+ return { content: DELIVERY_MSG, cancel: false };
123
+ }
124
+ });
125
+
126
+ // --- Hook 4: before_tool_call (modifying) ---
127
+ // Block ALL tool calls while bridge suppression is active
128
+ api.on("before_tool_call", async (_event, _ctx) => {
129
+ if (Date.now() < bridgeSuppressUntil) {
130
+ api.logger.debug?.(
131
+ `[opencode-bridge] before_tool_call: BLOCKED (suppression active)`,
132
+ );
133
+ return { block: true, blockReason: "opencode-bridge: message intercepted, tools disabled" };
134
+ }
135
+ });
136
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "id": "opencode-bridge",
3
+ "name": "OpenCode Bridge",
4
+ "description": "Route @cc/@ccn/@ccu prefix messages to OpenCode via tmux, suppressing the default LLM response.",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "scriptsDir": {
10
+ "type": "string",
11
+ "description": "Path to bridge shell scripts directory"
12
+ },
13
+ "channel": {
14
+ "type": "string",
15
+ "description": "Delivery channel (telegram, discord, slack, etc.)"
16
+ },
17
+ "targetId": {
18
+ "type": "string",
19
+ "description": "Delivery target ID for replies"
20
+ }
21
+ },
22
+ "required": ["scriptsDir"]
23
+ },
24
+ "uiHints": {
25
+ "scriptsDir": {
26
+ "label": "Scripts Directory",
27
+ "help": "Absolute path to the directory containing opencode-send.sh, opencode-new-session.sh, opencode-stats.sh"
28
+ },
29
+ "channel": {
30
+ "label": "Channel",
31
+ "help": "Channel for sending replies (e.g. telegram)"
32
+ },
33
+ "targetId": {
34
+ "label": "Target ID",
35
+ "help": "Target ID for sending replies (e.g. Telegram chat ID)"
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "opencode-bridge",
3
+ "version": "2.0.6",
4
+ "private": true,
5
+ "description": "OpenClaw plugin: route prefix messages to OpenCode via tmux",
6
+ "type": "module",
7
+ "openclaw": {
8
+ "extensions": ["./index.ts"]
9
+ }
10
+ }
@@ -0,0 +1,13 @@
1
+ [Unit]
2
+ Description=OpenClaw OpenCode Session Keepalive
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ ExecStart=/bin/bash {{SCRIPT_PATH}}
8
+ Restart=on-failure
9
+ RestartSec=30
10
+ TimeoutStartSec=10
11
+
12
+ [Install]
13
+ WantedBy=default.target
@@ -0,0 +1,23 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>ai.openclaw.opencode-session</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>/bin/bash</string>
10
+ <string>{{SCRIPT_PATH}}</string>
11
+ </array>
12
+ <key>StartInterval</key>
13
+ <integer>30</integer>
14
+ <key>RunAtLoad</key>
15
+ <true/>
16
+ <key>StandardOutPath</key>
17
+ <string>/tmp/openclaw-opencode-session.log</string>
18
+ <key>StandardErrorPath</key>
19
+ <string>/tmp/openclaw-opencode-session.log</string>
20
+ <key>ProcessType</key>
21
+ <string>Background</string>
22
+ </dict>
23
+ </plist>
@@ -0,0 +1,41 @@
1
+ #!/bin/bash
2
+ # bridge-version: 1
3
+ # List FREE OpenCode models (filtered from opencode models output)
4
+ TMUX="{{TMUX_BIN}}"
5
+ OPENCODE="{{OPENCODE_BIN}}"
6
+ CHANNEL="{{CHANNEL}}"
7
+ TARGET="{{TARGET_ID}}"
8
+ USE_SESSION="opencode-models-tmp"
9
+
10
+ cleanup() {
11
+ "$TMUX" kill-session -t "$USE_SESSION" 2>/dev/null
12
+ }
13
+ trap cleanup EXIT
14
+
15
+ "$TMUX" kill-session -t "$USE_SESSION" 2>/dev/null
16
+ sleep 1
17
+ "$TMUX" new-session -d -s "$USE_SESSION"
18
+ "$TMUX" set-option -t "$USE_SESSION" history-limit 15000
19
+ "$TMUX" send-keys -t "$USE_SESSION" "$OPENCODE models --refresh" Enter
20
+
21
+ sleep 10
22
+
23
+ PANE=$("$TMUX" capture-pane -t "$USE_SESSION" -p)
24
+
25
+ COUNT=0
26
+ OUTPUT="šŸ”“ **FREE Models:**\n\n"
27
+
28
+ while IFS= read -r line; do
29
+ if echo "$line" | grep -qi "free"; then
30
+ COUNT=$((COUNT + 1))
31
+ OUTPUT="$OUTPUT\`[$COUNT]\` $line\n"
32
+ fi
33
+ done <<< "$PANE"
34
+
35
+ if [ "$COUNT" -gt 0 ]; then
36
+ OUTPUT="$OUTPUT\nšŸ“ Usage: \`@ccms <number>\` or \`@ccms <model-id>\`"
37
+ openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "$OUTPUT" 2>/dev/null
38
+ echo "$OUTPUT"
39
+ else
40
+ openclaw message send --channel "$CHANNEL" --target "$TARGET" -m "āŒ No FREE models found.\n\nRun \`opencode models --refresh\` to update the list." 2>/dev/null
41
+ fi
@@ -0,0 +1,46 @@
1
+ #!/bin/bash
2
+ # bridge-version: 1
3
+ # Kill existing session -> create new session -> send instruction
4
+ MSG="$1"
5
+ TMUX="{{TMUX_BIN}}"
6
+ OPENCODE="{{OPENCODE_BIN}}"
7
+ WORKSPACE="{{WORKSPACE}}"
8
+ CHANNEL="{{CHANNEL}}"
9
+ TARGET="{{TARGET_ID}}"
10
+ SESSION="{{SESSION_NAME}}"
11
+
12
+ if [ -z "$MSG" ]; then
13
+ echo "ERROR: No message provided"
14
+ exit 1
15
+ fi
16
+
17
+ # Kill existing session
18
+ if "$TMUX" has-session -t "$SESSION" 2>/dev/null; then
19
+ "$TMUX" kill-session -t "$SESSION" 2>/dev/null
20
+ sleep 1
21
+ fi
22
+
23
+ # Create new session
24
+ "$TMUX" new-session -d -s "$SESSION"
25
+ "$TMUX" set-option -t "$SESSION" history-limit 10000
26
+ "$TMUX" send-keys -t "$SESSION" "cd $WORKSPACE && $OPENCODE" Enter
27
+
28
+ # Wait for OpenCode prompt
29
+ WAIT=0
30
+ while [ $WAIT -lt 60 ]; do
31
+ PANE=$("$TMUX" capture-pane -t "$SESSION" -p)
32
+ if echo "$PANE" | grep -qE "āÆ|>|opencode"; then
33
+ break
34
+ fi
35
+ sleep 2
36
+ WAIT=$((WAIT + 2))
37
+ done
38
+
39
+ # Send instruction
40
+ sleep 1
41
+ printf '%s' "[${CHANNEL}:${TARGET}] $MSG" | "$TMUX" load-buffer -
42
+ "$TMUX" paste-buffer -t "$SESSION" -d -p
43
+ sleep 0.3
44
+ "$TMUX" send-keys -t "$SESSION" Enter
45
+
46
+ echo "āœ… New session started. Reply will arrive shortly."
@@ -0,0 +1,31 @@
1
+ #!/bin/bash
2
+ # bridge-version: 1
3
+ # Send instruction to existing opencode-daemon tmux session
4
+ MSG="$1"
5
+ TMUX="{{TMUX_BIN}}"
6
+ CHANNEL="{{CHANNEL}}"
7
+ TARGET="{{TARGET_ID}}"
8
+ SESSION="{{SESSION_NAME}}"
9
+
10
+ if [ -z "$MSG" ]; then
11
+ echo "ERROR: No message provided"
12
+ exit 1
13
+ fi
14
+
15
+ # Check session exists
16
+ if ! "$TMUX" has-session -t "$SESSION" 2>/dev/null; then
17
+ echo "ERROR: $SESSION session not found. It will be auto-created within 30 seconds."
18
+ exit 1
19
+ fi
20
+
21
+ # Clear input line, then send message with channel prefix
22
+ "$TMUX" send-keys -t "$SESSION" C-c
23
+ sleep 0.5
24
+ "$TMUX" send-keys -t "$SESSION" C-u
25
+ sleep 0.3
26
+ printf '%s' "[${CHANNEL}:${TARGET}] $MSG" | "$TMUX" load-buffer -
27
+ "$TMUX" paste-buffer -t "$SESSION" -d -p
28
+ sleep 0.3
29
+ "$TMUX" send-keys -t "$SESSION" Enter
30
+
31
+ echo "āœ… Delivered to OpenCode. Reply will arrive shortly."
@@ -0,0 +1,37 @@
1
+ #!/bin/bash
2
+ # bridge-version: 3
3
+ # Keep opencode-daemon tmux session alive (daemon runs every 30s)
4
+ # Safe: idempotent, flock-protected (with fallback), short timeout
5
+
6
+ TMUX="{{TMUX_BIN}}"
7
+ OPENCODE="{{OPENCODE_BIN}}"
8
+ WORKSPACE="{{WORKSPACE}}"
9
+ SESSION="{{SESSION_NAME}}"
10
+ LOCK_FILE="/tmp/opencode-session.lock"
11
+ TIMEOUT=10
12
+
13
+ # Use flock for lock protection if available
14
+ if command -v flock &> /dev/null; then
15
+ exec 200>"$LOCK_FILE"
16
+ flock -w "$TIMEOUT" 200 || exit 0
17
+ else
18
+ # Fallback: simple PID lock (less robust but works without flock)
19
+ if [ -f "$LOCK_FILE" ]; then
20
+ OLD_PID=$(cat "$LOCK_FILE" 2>/dev/null)
21
+ if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
22
+ exit 0
23
+ fi
24
+ fi
25
+ echo $$ > "$LOCK_FILE"
26
+ fi
27
+
28
+ # Idempotent: exit if session already exists
29
+ if "$TMUX" has-session -t "$SESSION" 2>/dev/null; then
30
+ exit 0
31
+ fi
32
+
33
+ # Create new session + start OpenCode
34
+ "$TMUX" new-session -d -s "$SESSION"
35
+ "$TMUX" set-option -t "$SESSION" history-limit 10000
36
+ "$TMUX" send-keys -t "$SESSION" \
37
+ "cd $WORKSPACE && $OPENCODE --continue" Enter