bind each Claude pane to its own session id (T-146)

A regression from T-137: every pane rendered the newest .jsonl in the
workspace dir, so concurrent sessions collided — a secondary tab showed
the primary's conversation. Each pane now spawns claude with its own
--session-id (a transcript is named <session-id>.jsonl), tails that
exact file via TranscriptReader's file: param, and uses a per-session
MessageBus channel so controllers don't cross-talk.

The primary's id is deterministic from its session name (stable → it
resumes across restarts, like /resume off the same history file);
secondaries get a fresh random id so a clean session is always available.
The reader now waits for the bound file to appear rather than throwing.

Migration: an existing tmux session created before this (no --session-id,
claude chose its own id) must be killed once (claude.kill-all-sessions)
so the next spawn binds the controlled id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 08:28:55 +02:00
co-authored by Claude Opus 4.7
parent 24f68e7aaa
commit e1cd4653c0
10 changed files with 222 additions and 4 deletions
+22 -3
View File
@@ -45,6 +45,7 @@ class _ClaudePaneState extends State<ClaudePane> {
TranscriptPublisher? _feed;
String? _paneId;
String? _sessionName;
String? _sessionId;
String? _error;
String _statusLine = 'attaching…';
@@ -143,6 +144,11 @@ class _ClaudePaneState extends State<ClaudePane> {
}
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!);
// Bind this pane to a specific Claude session id so concurrent
// sessions in one workspace don't collide on the newest transcript
// (T-146). Primary: deterministic → resumes across restarts.
// Secondary: fresh → always a clean session.
_sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId();
final tmuxConf = await _ensureTmuxConf();
const cols = _cols;
@@ -162,6 +168,8 @@ class _ClaudePaneState extends State<ClaudePane> {
'-y',
'$rows',
'claude',
'--session-id',
_sessionId!,
];
// CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode:
@@ -180,7 +188,7 @@ class _ClaudePaneState extends State<ClaudePane> {
});
if (!resp.ok) {
argv = ['claude'];
argv = ['claude', '--session-id', _sessionId!];
resp = await ipc.request('pane.spawn', args: {
'argv': argv,
'kind': PaneKind.claude.wire,
@@ -209,9 +217,20 @@ class _ClaudePaneState extends State<ClaudePane> {
// kernel MessageBus, which the view's controller subscribes to. The
// subscription is wired before the reader's first poll so the initial
// tail is never missed.
// Tail this session's own transcript (<munged-cwd>/<sessionId>.jsonl),
// not just the newest in the workspace — that's what kept secondaries
// showing the primary's conversation (T-146). Each pane gets its own
// bus channel so their controllers don't cross-talk.
final messages = _kernel()!.messages;
_feed = TranscriptPublisher(messages: messages, reader: TranscriptReader(repoRoot));
_conversation = ConversationController.fromBus(messages: messages);
final home = Platform.environment['HOME'] ?? '';
final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl';
final channel = ClaudeConversation.sessionChannel(_sessionId!);
_feed = TranscriptPublisher(
messages: messages,
reader: TranscriptReader(repoRoot, file: transcriptFile),
channel: channel,
);
_conversation = ConversationController.fromBus(messages: messages, channel: channel);
_subscribe();
setState(() {});
}
@@ -6,9 +6,18 @@
/// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp
///
/// Secondary sessions append `-N`.
///
/// Also derives the Claude `--session-id` for each pane (T-146): a pane's
/// transcript is named `<session-id>.jsonl`, so binding each pane to a
/// distinct UUID is how concurrent sessions in one workspace stay
/// independent. The primary's id is deterministic from its (stable)
/// session name so it resumes across restarts; secondaries get a fresh
/// random id each spawn so a clean session is always one click away.
library;
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:math';
/// Stable session name for the primary Claude pane of [repoRoot].
String primarySessionName(String repoRoot) {
@@ -49,3 +58,46 @@ String _hash(String s) {
}
return h.toRadixString(16).padLeft(8, '0');
}
// ---------------------------------------------------------------------------
// Claude session-id (UUID) derivation — T-146
// ---------------------------------------------------------------------------
/// Stable session id for the primary pane of [repoRoot]: a UUID
/// deterministically derived from the primary session name, so the same
/// workspace re-binds the same `<uuid>.jsonl` across restarts (resume).
String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot));
/// A fresh random session id for a secondary pane — secondaries are
/// always clean sessions, never resumed.
String freshSessionId() {
final r = Random.secure();
return _formatUuid(List<int>.generate(16, (_) => r.nextInt(256)));
}
/// Deterministic, valid-format UUID derived from [seed] (same seed →
/// same id). Expands an FNV-1a stream into 16 bytes.
String _deterministicUuid(String seed) {
final bytes = <int>[];
var h = 0xcbf29ce484222325;
const prime = 0x100000001b3;
for (var i = 0; i < 16; i++) {
for (final c in utf8.encode('$seed:$i')) {
h ^= c;
h = (h * prime) & 0xFFFFFFFFFFFFFFFF;
}
bytes.add(h & 0xff);
}
return _formatUuid(bytes);
}
/// Format 16 [bytes] as a canonical v4 UUID string (sets the version and
/// variant nibbles so it passes `--session-id`'s UUID validation).
String _formatUuid(List<int> bytes) {
final b = List<int>.of(bytes);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
final hex = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-${hex.substring(20)}';
}
@@ -22,6 +22,10 @@ abstract final class ClaudeConversation {
/// Channel for the lead (or single) Claude pane's conversation.
static const leadChannel = 'conversation';
/// Per-session channel, keyed by the Claude session id (T-146) so
/// concurrent panes in one workspace don't cross-talk.
static String sessionChannel(String sessionId) => 'conversation/$sessionId';
/// Channel for a teammate's conversation (team work, T-139/T-140).
static String teammateChannel(String agentId) => 'conversation/$agentId';
@@ -294,10 +294,13 @@ class TranscriptReader {
}
Future<void> _tick(StreamController<ConversationItem> controller) async {
// A teammate reader tails one fixed file; otherwise discover the
// A pane/teammate reader tails one fixed file; otherwise discover the
// newest session `.jsonl` in the munged dir.
final newest = _explicitFile ?? await _newestJsonl(_mungedDir());
if (newest == null) return;
// An explicit file may not exist yet (claude writes it shortly after
// spawn) — wait for it rather than throwing in the poll loop.
if (!await File(newest).exists()) return;
if (newest != _currentPath) {
// New session file. Start from the recent tail rather than byte 0: