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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user