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
@@ -52,4 +52,28 @@ void main() {
expect(primarySessionName(long), primarySessionName(long));
});
});
group('claude session ids (T-146)', () {
final uuidRe = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
test('primary id is a valid v4-format UUID', () {
expect(primarySessionId('/home/me/clide'), matches(uuidRe));
});
test('primary id is deterministic per repo (resumes across restarts)', () {
expect(primarySessionId('/home/me/clide'), primarySessionId('/home/me/clide'));
});
test('different repos get different primary ids', () {
expect(primarySessionId('/home/me/clide'), isNot(primarySessionId('/home/me/other')));
});
test('fresh ids are valid UUIDs and unique per call (clean secondaries)', () {
final a = freshSessionId();
final b = freshSessionId();
expect(a, matches(uuidRe));
expect(b, matches(uuidRe));
expect(a, isNot(b));
});
});
}
@@ -743,6 +743,35 @@ void main() {
expect(collected.whereType<AssistantTextMessage>().single.text, 'from the explicit file');
});
test('explicit file: waits without error until the file appears', () async {
// claude writes <session-id>.jsonl shortly after spawn (T-146); the
// reader must poll without throwing until it exists, then stream it.
final dir = await Directory.systemTemp.createTemp('late_file_');
addTearDown(() => dir.delete(recursive: true));
final target = File('${dir.path}/agent-late.jsonl');
final reader = TranscriptReader(
'/unused',
projectsBase: '/nonexistent',
pollInterval: const Duration(milliseconds: 20),
file: target.path,
);
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
// Let it poll a few times against the missing file — must not throw.
await Future<void>.delayed(const Duration(milliseconds: 80));
expect(collected, isEmpty);
// Now the file appears.
writeLines(target, [assistantText('a1', 'arrived late')]);
await pumpUntil(() => collected.isNotEmpty);
await sub.cancel();
await reader.dispose();
expect(collected.whereType<AssistantTextMessage>().single.text, 'arrived late');
});
});
}