Skip to content

Commit ae9d8f6

Browse files
committed
fix(tmux): route panes by attached parent session
Static TMUX_PANE capture in the shared server process sent every child pane to the server's original TUI. Register each local TUI's active session and pane, forward parent session identity during spawn, and retain the startup pane as a bounded fallback.
1 parent dafee98 commit ae9d8f6

13 files changed

Lines changed: 537 additions & 61 deletions

docs/multiplexer-integration.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,19 @@ OpenCode pane (resolved from the parent pane's `ZELLIJ_PANE_ID` via
326326
If the parent pane cannot be resolved, the tab target is omitted and Zellij
327327
places the pane in whatever tab it has focused — no tab id is guessed.
328328

329+
### Tmux attached-session targeting
330+
331+
When multiple local TUI clients attach to one OpenCode server from different
332+
tmux sessions, each TUI records its active OpenCode session and `TMUX_PANE`.
333+
Child panes and layout updates target the tmux pane registered by their parent
334+
session, so each attached root session keeps its subagents beside itself.
335+
336+
Registrations are session-scoped, refreshed while the TUI is active, and expire
337+
after 30 seconds without a heartbeat. If no fresh registration exists,
338+
or tmux rejects a registered target, pane creation falls back to the server
339+
process's original `TMUX_PANE`. This preserves direct/local TUI behavior and
340+
avoids losing subagent visibility after an attached pane closes unexpectedly.
341+
329342
### Zellij details
330343

331344
The Zellij adapter requires **Zellij 0.44.1 or newer**. Older releases are

src/codemap.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,13 @@ OpenCode Core → Plugin Initialization (index.ts)
6363
3. **Config Validation**: Checks if current directory has valid plugin config
6464
4. **Snapshot Loading**: Reads agent models/variants from `tui-state.ts`
6565
5. **Live Updates**: Sets up interval to refresh snapshot every 1000ms
66-
6. **Sidebar Rendering**: Renders sidebar with:
66+
6. **Tmux registration**: Refreshes the active session-to-`TMUX_PANE`
67+
registration for parent-aware child-pane routing
68+
7. **Sidebar Rendering**: Renders sidebar with:
6769
- Plugin header (OMO-Slim + version)
6870
- Config status warning (if invalid)
6971
- Agent list with model/variant details
70-
7. **Lifecycle Management**: Cleans up interval on dispose
72+
8. **Lifecycle Management**: Cleans up interval and owned tmux registration on dispose
7173

7274
### State Persistence Flow (tui-state.ts)
7375

src/multiplexer/codemap.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ manage, and close panes for child OpenCode agent sessions.
1818
- `KittyMultiplexer`: kitty-specific implementation using `kitten @` CLI commands
1919
- `CmuxMultiplexer`: cmux UUID surface implementation using the cmux CLI
2020
- **Shared Utilities** (`shared.ts`): `quoteShellArg`, `buildOpencodeAttachCommand`, and `findBinary` — extracted from the three adapters to eliminate copy-paste duplication.
21+
- **Tmux pane registry** (`tmux-pane-registry.ts`): Session-scoped,
22+
expiring TUI pane registrations used to route child panes to the attached
23+
root session that created them.
2124
- **Session Manager** (`session-manager.ts`): Tracks child session lifecycle and coordinates pane operations via event-driven architecture.
2225
- **cmux lifecycle** (`cmux/session-lifecycle.ts`): Owns readiness, deferred
2326
spawning, stable-idle polling, activity generations, reliable close retries,
@@ -70,7 +73,7 @@ The session manager reacts to OpenCode session events:
7073
├─ Validates event properties (sessionId, parentId)
7174
├─ Checks if session is already tracked or spawning
7275
├─ Records session in knownSessions
73-
├─ Spawns pane via multiplexer.spawnPane()
76+
├─ Spawns pane via multiplexer.spawnPane(), forwarding the parent session
7477
│ ├─ Validates server is running
7578
│ ├─ Creates new pane with:
7679
│ │ ├─ Command: opencode attach --session-id <sessionId>
@@ -151,6 +154,11 @@ interface MultiplexerConfig {
151154
### Tmux Implementation
152155

153156
- Uses `tmux` CLI commands via `spawn()` utility
157+
- Resolves a fresh parent-session pane registration before splitting and
158+
falls back to the server process's startup pane if registration is absent or
159+
rejected by tmux
160+
- Debounces layout updates per parent pane so attached tmux sessions remain
161+
isolated during concurrent child creation
154162
- Creates panes with descriptive titles and working directories
155163
- Applies layouts using `tmux select-layout` and `tmux resize-pane`
156164
- Graceful shutdown: sends Ctrl+C before killing pane to allow clean process termination
@@ -182,6 +190,7 @@ interface MultiplexerConfig {
182190
| `index.ts` | Public API exports |
183191
| `types.ts` | Core interfaces and shared utilities |
184192
| `shared.ts` | Shared infrastructure (quoteShellArg, buildOpencodeAttachCommand, findBinary) |
193+
| `tmux-pane-registry.ts` | Attached TUI session-to-pane registration storage |
185194
| `factory.ts` | Multiplexer instance creation |
186195
| `session-manager.ts` | Session lifecycle management |
187196
| `tmux/index.ts` | tmux-specific implementation |

src/multiplexer/session-manager.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ describe('MultiplexerSessionManager', () => {
152152
'Test Worker',
153153
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
154154
'/test/directory',
155+
{ parentSessionId: 'parent-456' },
155156
);
156157
});
157158

@@ -199,6 +200,7 @@ describe('MultiplexerSessionManager', () => {
199200
'Nested Worker',
200201
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
201202
'/child/directory',
203+
{ parentSessionId: 'parent-456' },
202204
);
203205
});
204206

@@ -289,6 +291,7 @@ describe('MultiplexerSessionManager', () => {
289291
'Ready Worker',
290292
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
291293
'/test/directory',
294+
{ parentSessionId: 'parent-ready' },
292295
);
293296
});
294297

@@ -360,6 +363,7 @@ describe('MultiplexerSessionManager', () => {
360363
'Recover Worker',
361364
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
362365
'/test/directory',
366+
{ parentSessionId: 'parent-recover-timeout' },
363367
);
364368
});
365369

@@ -410,6 +414,7 @@ describe('MultiplexerSessionManager', () => {
410414
'Busy During Wait',
411415
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
412416
'/test/directory',
417+
{ parentSessionId: 'parent-busy-during-wait' },
413418
);
414419
});
415420

@@ -467,6 +472,7 @@ describe('MultiplexerSessionManager', () => {
467472
'Respawn Worker',
468473
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
469474
'/test/directory',
475+
{ parentSessionId: 'parent-respawn' },
470476
);
471477
});
472478

@@ -768,6 +774,7 @@ describe('MultiplexerSessionManager', () => {
768774
'Resumed Worker',
769775
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
770776
'/resumed/dir',
777+
{ parentSessionId: 'parent' },
771778
);
772779
});
773780

@@ -1694,6 +1701,7 @@ describe('MultiplexerSessionManager', () => {
16941701
'Worker',
16951702
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
16961703
'/task/dir',
1704+
{ parentSessionId: 'parent-789' },
16971705
);
16981706
expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p-1');
16991707
expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
@@ -1761,6 +1769,7 @@ describe('MultiplexerSessionManager', () => {
17611769
'Worker',
17621770
`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
17631771
'/test/directory',
1772+
{ parentSessionId: 'parent-close-race' },
17641773
);
17651774
});
17661775

src/multiplexer/session-manager.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,15 @@ export class MultiplexerSessionManager {
346346
return;
347347

348348
const paneResult = await this.multiplexer
349-
.spawnPane(sessionId, title, serverUrl, directory)
349+
.spawnPane(
350+
sessionId,
351+
title,
352+
serverUrl,
353+
directory,
354+
this.multiplexer.type === 'tmux'
355+
? { parentSessionId: parentId }
356+
: undefined,
357+
)
350358
.catch((err) => {
351359
log('[multiplexer-session-manager] failed to spawn pane', {
352360
instanceId: this.instanceId,
@@ -940,7 +948,15 @@ export class MultiplexerSessionManager {
940948
return;
941949

942950
const paneResult = await this.multiplexer
943-
.spawnPane(sessionId, known.title, serverUrl, known.directory)
951+
.spawnPane(
952+
sessionId,
953+
known.title,
954+
serverUrl,
955+
known.directory,
956+
this.multiplexer.type === 'tmux'
957+
? { parentSessionId: known.parentId }
958+
: undefined,
959+
)
944960
.catch((err) => {
945961
log('[multiplexer-session-manager] failed to respawn pane', {
946962
instanceId: this.instanceId,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2+
import * as fs from 'node:fs';
3+
import * as os from 'node:os';
4+
import * as path from 'node:path';
5+
import {
6+
getTmuxPaneRegistrationPath,
7+
readTmuxPane,
8+
recordTmuxPane,
9+
removeTmuxPane,
10+
TMUX_PANE_REGISTRATION_TTL_MS,
11+
} from './tmux-pane-registry';
12+
13+
describe('tmux pane registry', () => {
14+
const originalXdgDataHome = process.env.XDG_DATA_HOME;
15+
let stateDirectory: string;
16+
17+
beforeEach(() => {
18+
stateDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tmux-state-'));
19+
process.env.XDG_DATA_HOME = stateDirectory;
20+
});
21+
22+
afterEach(() => {
23+
fs.rmSync(stateDirectory, { recursive: true, force: true });
24+
if (originalXdgDataHome === undefined) {
25+
delete process.env.XDG_DATA_HOME;
26+
} else {
27+
process.env.XDG_DATA_HOME = originalXdgDataHome;
28+
}
29+
});
30+
31+
test('resolves a fresh pane registration for one session', () => {
32+
expect(recordTmuxPane('root-a', '%42', 100)).toBe(true);
33+
34+
expect(readTmuxPane('root-a')).toBe('%42');
35+
expect(readTmuxPane('root-b')).toBeUndefined();
36+
});
37+
38+
test('ignores expired registrations', () => {
39+
recordTmuxPane('root', '%42', 100);
40+
const filePath = getTmuxPaneRegistrationPath('root');
41+
const registration = JSON.parse(fs.readFileSync(filePath, 'utf8'));
42+
43+
expect(
44+
readTmuxPane(
45+
'root',
46+
registration.updatedAt + TMUX_PANE_REGISTRATION_TTL_MS + 1,
47+
),
48+
).toBeUndefined();
49+
});
50+
51+
test('only removes a registration still owned by the disposing TUI', () => {
52+
recordTmuxPane('root', '%42', 100);
53+
removeTmuxPane('root', '%42', 200);
54+
expect(readTmuxPane('root')).toBe('%42');
55+
56+
removeTmuxPane('root', '%42', 100);
57+
expect(readTmuxPane('root')).toBeUndefined();
58+
});
59+
60+
test('rejects invalid tmux pane identifiers', () => {
61+
expect(recordTmuxPane('root', '../pane', 100)).toBe(false);
62+
expect(readTmuxPane('root')).toBeUndefined();
63+
});
64+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { createHash, randomUUID } from 'node:crypto';
2+
import * as fs from 'node:fs';
3+
import * as os from 'node:os';
4+
import * as path from 'node:path';
5+
6+
interface TmuxPaneRegistration {
7+
version: 1;
8+
sessionId: string;
9+
paneId: string;
10+
ownerPid: number;
11+
updatedAt: number;
12+
}
13+
14+
export const TMUX_PANE_REGISTRATION_TTL_MS = 30_000;
15+
16+
function dataDir(): string {
17+
return (
18+
process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share')
19+
);
20+
}
21+
22+
function sessionScope(sessionId: string): string {
23+
return createHash('sha256').update(sessionId).digest('hex').slice(0, 24);
24+
}
25+
26+
export function getTmuxPaneRegistrationPath(sessionId: string): string {
27+
return path.join(
28+
dataDir(),
29+
'opencode',
30+
'storage',
31+
'oh-my-opencode-slim',
32+
'tmux-panes',
33+
`${sessionScope(sessionId)}.json`,
34+
);
35+
}
36+
37+
function isPaneId(value: unknown): value is string {
38+
return typeof value === 'string' && /^%\d+$/.test(value);
39+
}
40+
41+
export function recordTmuxPane(
42+
sessionId: string,
43+
paneId: string,
44+
ownerPid = process.pid,
45+
): boolean {
46+
if (!sessionId || !isPaneId(paneId)) return false;
47+
48+
const registration: TmuxPaneRegistration = {
49+
version: 1,
50+
sessionId,
51+
paneId,
52+
ownerPid,
53+
updatedAt: Date.now(),
54+
};
55+
56+
try {
57+
const filePath = getTmuxPaneRegistrationPath(sessionId);
58+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
59+
const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
60+
try {
61+
fs.writeFileSync(tmpPath, `${JSON.stringify(registration)}\n`);
62+
fs.renameSync(tmpPath, filePath);
63+
return true;
64+
} finally {
65+
try {
66+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
67+
} catch {
68+
// Best-effort state cleanup.
69+
}
70+
}
71+
} catch {
72+
return false;
73+
}
74+
}
75+
76+
export function readTmuxPane(
77+
sessionId: string,
78+
now = Date.now(),
79+
): string | undefined {
80+
try {
81+
const parsed = JSON.parse(
82+
fs.readFileSync(getTmuxPaneRegistrationPath(sessionId), 'utf8'),
83+
) as Partial<TmuxPaneRegistration>;
84+
if (
85+
parsed.version !== 1 ||
86+
parsed.sessionId !== sessionId ||
87+
!isPaneId(parsed.paneId) ||
88+
typeof parsed.updatedAt !== 'number' ||
89+
now - parsed.updatedAt > TMUX_PANE_REGISTRATION_TTL_MS
90+
) {
91+
return undefined;
92+
}
93+
return parsed.paneId;
94+
} catch {
95+
return undefined;
96+
}
97+
}
98+
99+
export function removeTmuxPane(
100+
sessionId: string,
101+
paneId: string,
102+
ownerPid = process.pid,
103+
): void {
104+
try {
105+
const filePath = getTmuxPaneRegistrationPath(sessionId);
106+
const parsed = JSON.parse(
107+
fs.readFileSync(filePath, 'utf8'),
108+
) as Partial<TmuxPaneRegistration>;
109+
if (parsed.paneId === paneId && parsed.ownerPid === ownerPid) {
110+
fs.unlinkSync(filePath);
111+
}
112+
} catch {
113+
// Registration may already be gone or replaced by another TUI.
114+
}
115+
}

0 commit comments

Comments
 (0)