xmux-bridge 1.0.39

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,12 @@
1
+ 'use strict';
2
+
3
+ class MailboxError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'MailboxError';
7
+ }
8
+ }
9
+
10
+ module.exports = {
11
+ MailboxError,
12
+ };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const { MailboxError } = require('./errors');
8
+
9
+ function isPlainObject(value) {
10
+ return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]';
11
+ }
12
+
13
+ function sortKeys(value) {
14
+ if (Array.isArray(value)) {
15
+ return value.map(sortKeys);
16
+ }
17
+ if (isPlainObject(value)) {
18
+ const out = {};
19
+ for (const key of Object.keys(value).sort()) {
20
+ out[key] = sortKeys(value[key]);
21
+ }
22
+ return out;
23
+ }
24
+ return value;
25
+ }
26
+
27
+ function ensureAscii(text) {
28
+ return text.replace(/[^\x00-\x7f]/g, (ch) => {
29
+ const code = ch.charCodeAt(0);
30
+ return `\\u${code.toString(16).padStart(4, '0')}`;
31
+ });
32
+ }
33
+
34
+ function toJsonString(data, pretty = false) {
35
+ const normalized = sortKeys(data);
36
+ const json = JSON.stringify(normalized, null, pretty ? 2 : 0);
37
+ return ensureAscii(json);
38
+ }
39
+
40
+ function readJson(filePath, defaultValue) {
41
+ try {
42
+ const raw = fs.readFileSync(filePath, 'utf8');
43
+ return JSON.parse(raw);
44
+ } catch (err) {
45
+ if (err && err.code === 'ENOENT') {
46
+ return defaultValue;
47
+ }
48
+ if (err instanceof SyntaxError) {
49
+ throw new MailboxError(`invalid JSON in ${filePath}: ${err.message}`);
50
+ }
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ function atomicWriteJson(filePath, data) {
56
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
57
+ const tmpPath = path.join(
58
+ path.dirname(filePath),
59
+ `.tmp-${crypto.randomBytes(6).toString('hex')}.json`,
60
+ );
61
+ fs.writeFileSync(tmpPath, `${toJsonString(data, true)}\n`, 'utf8');
62
+ fs.renameSync(tmpPath, filePath);
63
+ }
64
+
65
+ function printJson(data) {
66
+ process.stdout.write(`${toJsonString(data, false)}\n`);
67
+ }
68
+
69
+ module.exports = {
70
+ atomicWriteJson,
71
+ isPlainObject,
72
+ printJson,
73
+ readJson,
74
+ toJsonString,
75
+ };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { sleepMs } = require('./time');
7
+
8
+ function withFileLock(targetPath, fn, attempts = 200, sleepDelayMs = 25) {
9
+ const lockDir = `${targetPath}.lock.d`;
10
+ const parentDir = path.dirname(lockDir) || '.';
11
+
12
+ try {
13
+ fs.mkdirSync(parentDir, { recursive: true });
14
+ } catch (_) {
15
+ // Parent creation can fail for permission/race reasons; mkdir for lock
16
+ // will surface the actionable error.
17
+ }
18
+
19
+ let acquired = false;
20
+ for (let i = 0; i < attempts; i += 1) {
21
+ try {
22
+ fs.mkdirSync(lockDir);
23
+ acquired = true;
24
+ break;
25
+ } catch (err) {
26
+ if (err && err.code === 'EEXIST') {
27
+ sleepMs(sleepDelayMs);
28
+ continue;
29
+ }
30
+ if (err && err.code === 'ENOENT') {
31
+ break;
32
+ }
33
+ throw err;
34
+ }
35
+ }
36
+
37
+ if (!acquired) {
38
+ throw new Error(`could not acquire lock on ${targetPath}`);
39
+ }
40
+
41
+ try {
42
+ return fn();
43
+ } finally {
44
+ try {
45
+ fs.rmdirSync(lockDir);
46
+ } catch (err) {
47
+ if (!err || err.code !== 'ENOENT') {
48
+ // Ignore lock cleanup races; stale lock handling is best-effort.
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ module.exports = {
55
+ withFileLock,
56
+ };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ function nowTs() {
4
+ return new Date().toISOString();
5
+ }
6
+
7
+ function sleepMs(ms) {
8
+ const waitMs = Math.max(0, Number(ms) || 0);
9
+ if (waitMs <= 0) {
10
+ return;
11
+ }
12
+ try {
13
+ const buffer = new SharedArrayBuffer(4);
14
+ const view = new Int32Array(buffer);
15
+ Atomics.wait(view, 0, 0, waitMs);
16
+ } catch (_) {
17
+ const end = Date.now() + waitMs;
18
+ while (Date.now() < end) {
19
+ // busy wait fallback
20
+ }
21
+ }
22
+ }
23
+
24
+ module.exports = {
25
+ nowTs,
26
+ sleepMs,
27
+ };
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env zsh
2
+ # xmux-bridge.zsh
3
+ # Provider-neutral XMux inbox relay. It polls a teammate inbox under
4
+ # <project>/.codex/xmux/teams/<team>/inboxes/<agent>.json and pastes unread
5
+ # messages into the target tmux pane.
6
+
7
+ set -uo pipefail
8
+
9
+ if [[ -n "${XMUX_BRIDGE_PATH:-}" ]]; then
10
+ PATH="$XMUX_BRIDGE_PATH"
11
+ else
12
+ PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
13
+ fi
14
+ _XMUX_BRIDGE_SOURCED_DIR="${${(%):-%x}:A:h}"
15
+ TMUX_BIN="${XMUX_TMUX_BIN:-tmux}"
16
+ NODE_BIN="${XMUX_NODE_BIN:-node}"
17
+
18
+ _xmux_tmux() {
19
+ command "$TMUX_BIN" "$@"
20
+ }
21
+
22
+ if [[ -n "${XMUX_INSTALL_DIR:-}" ]]; then
23
+ XMUX_INSTALL_DIR="${XMUX_INSTALL_DIR:A}"
24
+ else
25
+ XMUX_INSTALL_DIR="$_XMUX_BRIDGE_SOURCED_DIR"
26
+ fi
27
+ export XMUX_INSTALL_DIR
28
+ XMUX_MAILBOX_NODE_CLI="$XMUX_INSTALL_DIR/dist/bin/xmux-mailbox.js"
29
+
30
+ _xmux_node() {
31
+ command "$NODE_BIN" "$@"
32
+ }
33
+
34
+ _xmux_mailbox_cli() {
35
+ [[ -f "$XMUX_MAILBOX_NODE_CLI" ]] || return 127
36
+ _xmux_node "$XMUX_MAILBOX_NODE_CLI" "$@"
37
+ }
38
+
39
+ _xmux_bridge_project_root() {
40
+ local dir="${1:-$PWD}"
41
+ dir="${dir:A}"
42
+ while [[ "$dir" != "/" && -n "$dir" ]]; do
43
+ if [[ -e "$dir/.git" ]]; then
44
+ print -r -- "$dir"
45
+ return
46
+ fi
47
+ dir="${dir:h}"
48
+ done
49
+ print -r -- "${1:-$PWD}"
50
+ }
51
+
52
+ if [[ -n "${XMUX_PROJECT_DIR:-}" ]]; then
53
+ XMUX_PROJECT_DIR="${XMUX_PROJECT_DIR:A}"
54
+ else
55
+ XMUX_PROJECT_DIR="$(_xmux_bridge_project_root "$PWD")"
56
+ fi
57
+ export XMUX_PROJECT_DIR
58
+
59
+ if [[ -n "${XMUX_STATE_DIR:-}" ]]; then
60
+ XMUX_STATE_DIR="${XMUX_STATE_DIR:A}"
61
+ else
62
+ XMUX_STATE_DIR="$XMUX_PROJECT_DIR/.codex/xmux"
63
+ fi
64
+ export XMUX_STATE_DIR
65
+ unset XMUX_DIR XMUX_HOME 2>/dev/null || true
66
+
67
+ XMUX_LEAD_AGENT="${XMUX_LEAD_AGENT:-codex-lead}"
68
+
69
+ PANE_ID=""
70
+ TEAM_NAME=""
71
+ AGENT_NAME=""
72
+ PROVIDER=""
73
+ INBOX=""
74
+ TIMEOUT=60
75
+ IDLE_PATTERN=""
76
+ POLL_INTERVAL=0.5
77
+ SUBMIT_DELAY="${XMUX_SUBMIT_DELAY:-0.2}"
78
+
79
+ while getopts "p:T:a:i:x:w:d:P:" opt; do
80
+ case "$opt" in
81
+ p) PANE_ID="$OPTARG" ;;
82
+ T) TEAM_NAME="$OPTARG" ;;
83
+ a) AGENT_NAME="$OPTARG" ;;
84
+ P) PROVIDER="$OPTARG" ;;
85
+ i) INBOX="$OPTARG" ;;
86
+ x) TIMEOUT="$OPTARG" ;;
87
+ w) IDLE_PATTERN="$OPTARG" ;;
88
+ d) SUBMIT_DELAY="$OPTARG" ;;
89
+ *) echo "Usage: $0 -p <pane_id> -T <team> -a <agent> [-P <provider>] [-i <inbox>] [-x <timeout>] [-w <idle_pattern>] [-d <submit_delay>]" >&2; exit 1 ;;
90
+ esac
91
+ done
92
+
93
+ [[ -n "$PANE_ID" ]] || { echo "error: -p <pane_id> required" >&2; exit 1; }
94
+ [[ -n "$TEAM_NAME" ]] || { echo "error: -T <team> required" >&2; exit 1; }
95
+ [[ -n "$AGENT_NAME" ]] || { echo "error: -a <agent> required" >&2; exit 1; }
96
+
97
+ TEAM_DIR="$XMUX_STATE_DIR/teams/$TEAM_NAME"
98
+ INBOX_DIR="$TEAM_DIR/inboxes"
99
+ BRIDGE_PID_FILE="$TEAM_DIR/.${AGENT_NAME}-bridge.pid"
100
+ BRIDGE_ENV_FILE="$TEAM_DIR/.bridge-${AGENT_NAME}.env"
101
+ [[ -n "$INBOX" ]] || INBOX="$INBOX_DIR/$AGENT_NAME.json"
102
+ OUTBOX="$INBOX_DIR/$XMUX_LEAD_AGENT.json"
103
+
104
+ mkdir -p "$INBOX_DIR"
105
+ [[ -f "$INBOX" ]] || print -r -- '[]' > "$INBOX"
106
+ [[ -f "$OUTBOX" ]] || print -r -- '[]' > "$OUTBOX"
107
+
108
+ wait_for_idle() {
109
+ [[ -z "$IDLE_PATTERN" ]] && return 0
110
+ local elapsed=0
111
+ while (( elapsed < TIMEOUT )); do
112
+ if _xmux_tmux capture-pane -t "$PANE_ID" -p 2>/dev/null | grep -v '^[[:space:]]*$' | tail -8 | grep -qE "$IDLE_PATTERN"; then
113
+ return 0
114
+ fi
115
+ sleep 1
116
+ (( elapsed++ ))
117
+ done
118
+ echo "[xmux-bridge] warning: idle timeout after ${TIMEOUT}s for $AGENT_NAME" >&2
119
+ return 1
120
+ }
121
+
122
+ read_unread() {
123
+ _xmux_node - "$INBOX" <<'NODE'
124
+ const fs = require('fs');
125
+
126
+ const inboxPath = process.argv[2];
127
+ let raw = '';
128
+ try {
129
+ raw = fs.readFileSync(inboxPath, 'utf8');
130
+ } catch (error) {
131
+ if (error && error.code === 'ENOENT') {
132
+ process.stdout.write('');
133
+ process.exit(0);
134
+ }
135
+ throw error;
136
+ }
137
+
138
+ let messages;
139
+ try {
140
+ messages = JSON.parse(raw);
141
+ } catch (error) {
142
+ const detail = error && error.message ? error.message : String(error);
143
+ console.error(`read_unread: JSON parse error in ${inboxPath}: ${detail}`);
144
+ process.exit(1);
145
+ }
146
+
147
+ if (!Array.isArray(messages)) {
148
+ process.stdout.write('');
149
+ process.exit(0);
150
+ }
151
+
152
+ for (const message of messages) {
153
+ if (message && typeof message === 'object' && !message.read) {
154
+ process.stdout.write(JSON.stringify(message));
155
+ break;
156
+ }
157
+ }
158
+ NODE
159
+ }
160
+
161
+ parse_message() {
162
+ _xmux_node - "$1" <<'NODE'
163
+ const message = JSON.parse(process.argv[2] || '{}');
164
+
165
+ const hasText = Object.prototype.hasOwnProperty.call(message, 'text');
166
+ const hasMessage = Object.prototype.hasOwnProperty.call(message, 'message');
167
+ const rawText = hasText ? message.text : (hasMessage ? message.message : '');
168
+ let nested = null;
169
+ let text = '';
170
+
171
+ if (rawText && typeof rawText === 'object') {
172
+ text = JSON.stringify(rawText);
173
+ if (!Array.isArray(rawText)) nested = rawText;
174
+ } else if (typeof rawText === 'string') {
175
+ text = rawText;
176
+ try {
177
+ const candidate = JSON.parse(rawText);
178
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
179
+ nested = candidate;
180
+ }
181
+ } catch (_) {
182
+ nested = null;
183
+ }
184
+ } else {
185
+ text = String(rawText ?? '');
186
+ }
187
+
188
+ const requestId =
189
+ message.request_id ||
190
+ message.requestId ||
191
+ ((nested || {}).request_id || (nested || {}).requestId) ||
192
+ '';
193
+ const messageType = message.type || (nested || {}).type || '';
194
+ const fields = [
195
+ Buffer.from(text, 'utf8').toString('base64'),
196
+ String(message.timestamp ?? ''),
197
+ String(message.from ?? 'lead'),
198
+ String(requestId),
199
+ String(messageType),
200
+ ];
201
+ process.stdout.write(fields.join('\n'));
202
+ NODE
203
+ }
204
+
205
+ decode_b64() {
206
+ _xmux_node - "$1" <<'NODE'
207
+ const encoded = process.argv[2] || '';
208
+ if (!encoded) process.exit(0);
209
+ process.stdout.write(Buffer.from(encoded, 'base64').toString('utf8'));
210
+ NODE
211
+ }
212
+
213
+ mark_read_inline() {
214
+ _xmux_node - "$INBOX" "$1" "$2" <<'NODE'
215
+ const fs = require('fs');
216
+ const path = require('path');
217
+
218
+ const inboxPath = process.argv[2];
219
+ const timestamp = process.argv[3] || '';
220
+ const requestId = process.argv[4] || '';
221
+
222
+ function entryRequestId(entry) {
223
+ const direct = entry.request_id || entry.requestId || '';
224
+ const rawText = Object.prototype.hasOwnProperty.call(entry, 'text')
225
+ ? entry.text
226
+ : (Object.prototype.hasOwnProperty.call(entry, 'message') ? entry.message : '');
227
+ if (direct || typeof rawText !== 'string') return direct;
228
+ try {
229
+ const nested = JSON.parse(rawText);
230
+ if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
231
+ return nested.request_id || nested.requestId || '';
232
+ }
233
+ } catch (_) {
234
+ return '';
235
+ }
236
+ return '';
237
+ }
238
+
239
+ let messages;
240
+ try {
241
+ messages = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
242
+ } catch (error) {
243
+ if (error && error.code === 'ENOENT') process.exit(0);
244
+ throw error;
245
+ }
246
+
247
+ if (!Array.isArray(messages)) process.exit(0);
248
+
249
+ for (const message of messages) {
250
+ if (!message || typeof message !== 'object' || message.read) continue;
251
+ if (timestamp && message.timestamp === timestamp) {
252
+ message.read = true;
253
+ break;
254
+ }
255
+ if (requestId && entryRequestId(message) === requestId) {
256
+ message.read = true;
257
+ break;
258
+ }
259
+ if (!timestamp && !requestId) {
260
+ message.read = true;
261
+ break;
262
+ }
263
+ }
264
+
265
+ const resolved = path.resolve(inboxPath);
266
+ const dirName = path.dirname(resolved);
267
+ fs.mkdirSync(dirName, { recursive: true });
268
+ const tmpPath = path.join(
269
+ dirName,
270
+ `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
271
+ );
272
+ fs.writeFileSync(tmpPath, `${JSON.stringify(messages, null, 2)}\n`, 'utf8');
273
+ fs.renameSync(tmpPath, resolved);
274
+ NODE
275
+ }
276
+
277
+ mark_read() {
278
+ local timestamp="$1" request_id="$2"
279
+ _xmux_mailbox_cli mark-read "$TEAM_NAME" "$AGENT_NAME" --timestamp "$timestamp" --request-id "$request_id" >/dev/null 2>&1 && return 0
280
+ mark_read_inline "$timestamp" "$request_id"
281
+ }
282
+
283
+ append_to_lead_inline() {
284
+ _xmux_node - "$OUTBOX" "$AGENT_NAME" "$1" "$2" <<'NODE'
285
+ const fs = require('fs');
286
+ const path = require('path');
287
+
288
+ const outboxPath = process.argv[2];
289
+ const agent = process.argv[3];
290
+ const text = process.argv[4];
291
+ const requestId = process.argv[5];
292
+ let messages = [];
293
+
294
+ try {
295
+ messages = JSON.parse(fs.readFileSync(outboxPath, 'utf8'));
296
+ } catch (_) {
297
+ messages = [];
298
+ }
299
+ if (!Array.isArray(messages)) messages = [];
300
+
301
+ const entry = {
302
+ from: agent,
303
+ text,
304
+ timestamp: new Date().toISOString(),
305
+ read: false,
306
+ };
307
+ if (requestId) entry.request_id = requestId;
308
+ messages.push(entry);
309
+
310
+ const resolved = path.resolve(outboxPath);
311
+ const dirName = path.dirname(resolved);
312
+ fs.mkdirSync(dirName, { recursive: true });
313
+ const tmpPath = path.join(
314
+ dirName,
315
+ `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
316
+ );
317
+ fs.writeFileSync(tmpPath, `${JSON.stringify(messages, null, 2)}\n`, 'utf8');
318
+ fs.renameSync(tmpPath, resolved);
319
+ NODE
320
+ }
321
+
322
+ append_to_lead() {
323
+ local text="$1" request_id="$2"
324
+ if [[ -n "$request_id" ]]; then
325
+ _xmux_mailbox_cli write-response "$TEAM_NAME" --from "$AGENT_NAME" --text "$text" --request-id "$request_id" >/dev/null 2>&1 && return 0
326
+ else
327
+ _xmux_mailbox_cli write-response "$TEAM_NAME" --from "$AGENT_NAME" --text "$text" >/dev/null 2>&1 && return 0
328
+ fi
329
+ append_to_lead_inline "$text" "$request_id"
330
+ }
331
+
332
+ focus_target_pane() {
333
+ [[ "$PROVIDER" == "copilot" ]] || return 0
334
+ _xmux_tmux send-keys -t "$PANE_ID" Escape '[' I 2>/dev/null || return 1
335
+ sleep 0.05
336
+ }
337
+
338
+ mark_member_inactive() {
339
+ _xmux_mailbox_cli update-member "$TEAM_NAME" "$AGENT_NAME" --active false >/dev/null 2>&1 && return 0
340
+ _xmux_node - "$TEAM_DIR/team.json" "$AGENT_NAME" <<'NODE'
341
+ const fs = require('fs');
342
+
343
+ const teamPath = process.argv[2];
344
+ const agentName = process.argv[3];
345
+ let config;
346
+ try {
347
+ config = JSON.parse(fs.readFileSync(teamPath, 'utf8'));
348
+ } catch (_) {
349
+ process.exit(0);
350
+ }
351
+
352
+ const members = config.members;
353
+ if (!members || typeof members !== 'object' || !members[agentName] || typeof members[agentName] !== 'object') {
354
+ process.exit(0);
355
+ }
356
+
357
+ members[agentName].active = false;
358
+ members[agentName].updated_at = new Date().toISOString();
359
+ const tmpPath = `${teamPath}.tmp`;
360
+ fs.writeFileSync(tmpPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
361
+ fs.renameSync(tmpPath, teamPath);
362
+ NODE
363
+ }
364
+
365
+ paste_text() {
366
+ local text="$1"
367
+ local text_len=${#text}
368
+ local pos=0
369
+ local chunk_size=300
370
+ local chunk buf
371
+
372
+ focus_target_pane || return 1
373
+ _xmux_tmux send-keys -t "$PANE_ID" C-u 2>/dev/null || return 1
374
+ sleep 0.05
375
+
376
+ while (( pos < text_len )); do
377
+ chunk="${text:$pos:$chunk_size}"
378
+ buf="xmux-${$}-${RANDOM}"
379
+ if ! printf '%s' "$chunk" | _xmux_tmux load-buffer -b "$buf" - 2>/dev/null; then
380
+ echo "[xmux-bridge] error: load-buffer failed for $PANE_ID" >&2
381
+ return 1
382
+ fi
383
+ if ! _xmux_tmux paste-buffer -d -p -b "$buf" -t "$PANE_ID" 2>/dev/null; then
384
+ _xmux_tmux delete-buffer -b "$buf" 2>/dev/null
385
+ echo "[xmux-bridge] error: paste-buffer failed for $PANE_ID" >&2
386
+ return 1
387
+ fi
388
+ (( pos += chunk_size ))
389
+ sleep 0.05
390
+ done
391
+
392
+ sleep "$SUBMIT_DELAY"
393
+ focus_target_pane || return 1
394
+ buf="xmux-${$}-${RANDOM}"
395
+ if ! printf '\r' | _xmux_tmux load-buffer -b "$buf" - 2>/dev/null; then
396
+ echo "[xmux-bridge] error: load-buffer failed for submit on $PANE_ID" >&2
397
+ return 1
398
+ fi
399
+ if ! _xmux_tmux paste-buffer -d -b "$buf" -t "$PANE_ID" 2>/dev/null; then
400
+ _xmux_tmux delete-buffer -b "$buf" 2>/dev/null
401
+ echo "[xmux-bridge] error: paste-buffer submit failed for $PANE_ID" >&2
402
+ return 1
403
+ fi
404
+ }
405
+
406
+ cleanup() {
407
+ local recorded_pid=""
408
+ [[ -f "$BRIDGE_PID_FILE" ]] && recorded_pid="$(< "$BRIDGE_PID_FILE")"
409
+ if [[ "$recorded_pid" == "$$" ]]; then
410
+ rm -f "$BRIDGE_PID_FILE"
411
+ rm -f "$BRIDGE_ENV_FILE"
412
+ mark_member_inactive
413
+ fi
414
+ }
415
+ trap 'cleanup; exit 0' INT TERM EXIT
416
+
417
+ echo "[xmux-bridge] started - pane:$PANE_ID agent:$AGENT_NAME team:$TEAM_NAME"
418
+
419
+ defer_count=0
420
+ while true; do
421
+ if ! _xmux_tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qx -- "$PANE_ID"; then
422
+ append_to_lead "$AGENT_NAME pane exited." ""
423
+ exit 0
424
+ fi
425
+
426
+ msg="$(read_unread || true)"
427
+ if [[ -n "$msg" ]]; then
428
+ parsed="$(parse_message "$msg" 2>/dev/null || true)"
429
+ if [[ -z "$parsed" ]]; then
430
+ echo "[xmux-bridge] warning: failed to parse unread message for $AGENT_NAME" >&2
431
+ sleep 2
432
+ continue
433
+ fi
434
+
435
+ fields=("${(@f)parsed}")
436
+ text="$(decode_b64 "${fields[1]:-}")"
437
+ timestamp="${fields[2]:-}"
438
+ from_agent="${fields[3]:-lead}"
439
+ request_id="${fields[4]:-}"
440
+ msg_type="${fields[5]:-}"
441
+
442
+ if [[ "$msg_type" == "shutdown_request" ]]; then
443
+ mark_read "$timestamp" "$request_id"
444
+ if [[ -n "$request_id" ]]; then
445
+ append_to_lead "{\"type\":\"shutdown_approved\",\"from\":\"$AGENT_NAME\",\"request_id\":\"$request_id\"}" "$request_id"
446
+ else
447
+ append_to_lead "{\"type\":\"shutdown_approved\",\"from\":\"$AGENT_NAME\"}" ""
448
+ fi
449
+ _xmux_tmux kill-pane -t "$PANE_ID" 2>/dev/null
450
+ exit 0
451
+ fi
452
+
453
+ if [[ -n "$request_id" ]]; then
454
+ text="[request_id: $request_id]
455
+ $text"
456
+ fi
457
+
458
+ if (( ${#text} > 120 )); then
459
+ echo "[xmux-bridge] delivering from '$from_agent' to '$AGENT_NAME' (${#text} chars)"
460
+ else
461
+ echo "[xmux-bridge] delivering from '$from_agent' to '$AGENT_NAME': $text"
462
+ fi
463
+
464
+ if ! wait_for_idle; then
465
+ (( defer_count++ ))
466
+ echo "[xmux-bridge] warning: $AGENT_NAME not idle, deferring (${defer_count})" >&2
467
+ sleep 3
468
+ continue
469
+ fi
470
+ defer_count=0
471
+
472
+ if paste_text "$text"; then
473
+ mark_read "$timestamp" "$request_id"
474
+ else
475
+ sleep 2
476
+ continue
477
+ fi
478
+ fi
479
+
480
+ sleep "$POLL_INTERVAL"
481
+ done