Files
clide/lib/builtin/claude/src/session_naming.dart
T
jpmschweitzerandClaude e3c0b0146b fork a Claude conversation into a new pane
Adds fork-into-a-pane: /fork in the composer, a roster Fork button, and a
clide.agent.fork command all branch a session via
--resume <source> --fork-session, so the branch gets its own claude
session id and diverges without touching the original. SpawnSpec/
ManagedSession gain forkSourceSessionId; the orchestrator selects the
fork argv via a new forkSessionArgs helper; the session host opens the
fork as a new secondary pane.

The branch's real claude session-id (assigned by --fork-session, arriving
in the init event) is not yet captured back — tracked as T-185.

T-172.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-30 23:34:50 +02:00

118 lines
4.7 KiB
Dart

/// Claude session-id derivation for clide panes (D-77/T-146).
///
/// Session continuity is via `--resume <session-id>` (D-77); tmux session
/// names are no longer used. This module provides:
///
/// - [primarySessionId] — a stable UUID derived from the repo path, so the
/// primary pane resumes the same transcript across clide restarts.
/// - [freshSessionId] — a fresh random UUID for secondary panes, which always
/// start clean.
/// - [claudeLaunchArgs] — selects `--resume` vs `--session-id` depending on
/// whether the transcript already exists on disk (T-161).
library;
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:math';
// ---------------------------------------------------------------------------
// Internal seed derivation (private — tmux slug names are no longer public)
// ---------------------------------------------------------------------------
// Slug cap kept for backward-compat: the same path still produces the same
// deterministic UUID across upgrades because the seed string is unchanged.
const _maxSlugLen = 80;
/// Derive a stable, short seed from [repoRoot] to feed into the deterministic
/// UUID. This is the old tmux-session-name slug, kept internal and unchanged
/// so existing transcripts survive the tmux→--resume migration (the UUID is
/// derived from this seed, so the UUID must not change).
String _primarySessionSeed(String repoRoot) {
return 'clide-claude-${_slugify(repoRoot)}';
}
String _slugify(String path) {
final home = Platform.environment['HOME'] ?? '';
var p = path;
if (home.isNotEmpty && p.startsWith(home)) {
p = p.substring(home.length);
}
p = p.replaceAll('/', '-').replaceAll('.', '');
while (p.startsWith('-')) {
p = p.substring(1);
}
while (p.endsWith('-')) {
p = p.substring(0, p.length - 1);
}
if (p.isEmpty) p = 'root';
if (p.length > _maxSlugLen) return _hash(path);
return p;
}
String _hash(String s) {
var h = 2166136261;
for (var i = 0; i < s.length; i++) {
h ^= s.codeUnitAt(i);
h = (h * 16777619) & 0xffffffff;
}
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 repo path, so the same workspace
/// re-binds the same `<uuid>.jsonl` across restarts (`--resume`, D-77).
String primarySessionId(String repoRoot) => _deterministicUuid(_primarySessionSeed(repoRoot));
/// The session-selection args for launching [sessionId]: `--resume` an
/// existing session, or `--session-id` to create a new one. `--session-id`
/// REFUSES an id that already exists ("Session ID … is already in use") — so
/// resuming a pane whose transcript already exists must use `--resume`
/// (T-161). Appended after the stream-json flags by [ClaudeStreamJsonProcess].
List<String> claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['--resume', sessionId] : ['--session-id', sessionId];
/// The session-selection args for forking a session (T-172, D-77).
///
/// `--resume <sourceSessionId> --fork-session` resumes [sourceSessionId] but
/// creates a NEW claude session id so the branch diverges without touching the
/// original. No `--session-id` is passed — the fork gets its own id from the
/// `init` event.
List<String> forkSessionArgs(String sourceSessionId) => ['--resume', sourceSessionId, '--fork-session'];
/// 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)}';
}