Skip to content

Commit b50eb24

Browse files
authored
Merge pull request #1047 from ermanhavuc/fix/1016-tmux-attached-session-target
fix(tmux): route subagent panes to attached parent session
2 parents dafee98 + ae9d8f6 commit b50eb24

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)