supercode-interchange 0.4.16

Canonical, provider-neutral session interchange primitives for Supercode
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
//! Natively load — and continue — real Claude Code and Codex sessions.
//!
//! Both tools persist their conversations as JSONL on disk:
//!
//! - **Claude Code**: `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`,
//!   one line per event in the Anthropic message format, linked by
//!   `uuid`/`parentUuid`.
//! - **Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, where each line
//!   is a `{timestamp, type, payload}` envelope and the `response_item` lines
//!   form the canonical conversation.
//!
//! [`Session::load`] auto-detects the format and normalizes either one into a
//! provider-neutral [`Vec<ChatMessage>`] that can be handed straight back to a
//! model (via OpenRouter or any OpenAI-compatible endpoint) to continue.
//!
//! Provider-internal artifacts that don't replay across vendors — Anthropic
//! `thinking` blocks, Codex `reasoning` items — are dropped during
//! normalization.
//!
//! # Where this sits in supercode's priorities
//!
//! This module is the home of **feature 1 (translate between session formats)**
//! and half of **feature 2 (emulate-to-continue)** — the load/emit surface for
//! each harness ([`SessionFormat`], `from_*_str` loaders, `to_*_jsonl`
//! emitters). See [`AGENTS.md`](../../../AGENTS.md) for the three ranked
//! feature-priorities and the glue-tool positioning; the top priority is
//! **feature 3 (continue losslessly *with massive token reduction*)**, which
//! this fidelity work exists to make trustworthy. `opencode` + `pi` loaders
//! are built against the frozen `docs/interop/opencode-pi-spec.md` contract
//! — OpenCode additionally reads its native SQLite store (`opencode*.db`,
//! PARITY-3/PARITY-16) via `rusqlite`, reconstructing the same envelope form
//! [`Session::from_opencode_str`] already parses for the JSON-tree surfaces.

use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};

use rusqlite::Connection;
use serde_json::Value;

use crate::{
    ChatMessage, Fidelity, FunctionCall, InterchangeError as Error, Result, Role, ToolCall,
};

mod claude_code;
mod codex;
mod detect;
mod gemini;
mod goose;
mod grok;
mod helpers;
mod hermes;
mod native;
mod openclaw;
mod opencode;
mod pi;

// The per-harness files below are an internal file layout only: every item
// keeps its original `crate::session::…` path through these re-exports, whose
// visibility matches the most-visible item each module holds.
pub(crate) use claude_code::*;
use codex::*;
pub use detect::*;
use gemini::*;
use grok::*;
pub use helpers::*;
pub use hermes::*;
use native::*;
pub use openclaw::*;
pub use opencode::*;
pub use pi::*;

/// Which tool produced a session log.
///
/// This is **read-provenance**: a fact recovered when a log is loaded (stored
/// in [`SessionMeta::source`], filled in by auto-detection in
/// `detect_source`), describing which tool originally wrote the file on
/// disk. It answers "where did this session come from?" — e.g. for
/// `inspect`/`convert` display in the CLI.
///
/// It is deliberately distinct from [`SessionFormat`], even though the two
/// enums' variant lists currently coincide: [`SessionFormat`] selects a
/// serialization codec (what to parse/export *as*), while `SessionSource`
/// records history (what wrote the file). The pair is intentionally kept
/// separate rather than merged — a session loaded from one tool's log can
/// still be exported in the other tool's format, and the two concepts could
/// diverge further (e.g. a format that is readable but not attributable, or
/// multiple versioned formats sharing one source).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionSource {
    /// A `~/.claude/projects/.../<id>.jsonl` transcript.
    ClaudeCode,
    /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
    Codex,
    /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
    /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
    /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
    /// 1:1 per the frozen interop spec (§0).
    OpenCode,
    /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
    /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
    /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
    /// round-trip property.
    Pi,
    /// A Grok session transcript stored as
    /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
    Grok,
    /// A Gemini CLI transcript stored under
    /// `~/.gemini/tmp/<project>/chats/session-*.jsonl`.
    Gemini,
    /// A Goose session exported through `_goose/unstable/session/export`, or
    /// reconstructed from Goose's `sessions/sessions.db` native store.
    Goose,
    /// An OpenClaw agent session (`~/.openclaw/agents/<agentId>/sessions/
    /// <uuid>.jsonl`, openclaw >= 2026.7): pi session-format v3 with
    /// openclaw dialect divergences — `type:"leaf"` navigation-control
    /// entries that REDIRECT the active leaf (pi's last-entry anchor rule is
    /// wrong for them), `appendMode:"side"` entries that never anchor, and
    /// vendor-namespaced `__openclaw` message metadata. READ-ONLY provenance
    /// (UNI-16): there is deliberately no `SessionFormat::OpenClaw` — the
    /// write tier is a permanently skipped direct-DB/store path; loaded
    /// sessions translate OUT through the other formats.
    OpenClaw,
    /// A Hermes Agent session read from its single SQLite store
    /// (`~/.hermes/state.db`, `SCHEMA_VERSION = 22` at the 0.19.0 pin).
    /// READ-ONLY provenance (UNI-15): no `SessionFormat::Hermes` exists —
    /// writing into a live, shared, WAL, single-writer store stays gated by
    /// UNI-22 (not fired; the schema churned 19->22 in one release) — loaded
    /// sessions translate OUT through the other formats.
    Hermes,
    /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
    /// cosmetic"): a session that was never imported from ANY foreign
    /// tool's log at all — authored directly by supercode's own agent loop,
    /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
    /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
    /// natively-spawned `spawn_subagent` children) uses this — before this
    /// variant existed, that call site built its blank `Session` via
    /// `Session::from_claude_code_str("")` purely as an "empty parser to
    /// get a blank skeleton" trick, which left `meta.source ==
    /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
    /// was ever involved, mislabeling a native supercode spawn as an
    /// imported CC session on disk (and in any `inspect`/`convert` reading
    /// it back). Never produced by auto-detection (`detect_source`) or any
    /// `from_<tool>_str` loader — only by code that explicitly constructs
    /// a `SessionMeta` with this source, so no existing imported-session
    /// path can ever observe this variant appearing where it didn't before.
    Native,
}

/// An on-disk session format supercode can both read and write.
///
/// Like an image editor that opens and exports several file formats, supercode
/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
/// supported format on the edges.
///
/// This is a **write-target** / codec selector: a caller's request, passed to
/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
/// choosing which on-disk dialect to parse or emit. It answers "what format
/// should I read/write?" — as opposed to [`SessionSource`], which records the
/// provenance fact of what actually produced a loaded file. The two enums are
/// intentionally kept separate (provenance fact vs. serialization choice) and
/// should not be unified, even though their variants currently match
/// one-to-one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionFormat {
    /// Claude Code transcript JSONL.
    ClaudeCode,
    /// Codex rollout JSONL.
    Codex,
    /// OpenCode export-document / envelope JSONL (wave B; see
    /// [`SessionSource::OpenCode`]).
    OpenCode,
    /// Pi session JSONL (see [`SessionSource::Pi`]).
    Pi,
    /// Grok `chat_history.jsonl` transcript.
    Grok,
    /// Gemini CLI session JSONL.
    Gemini,
    /// Goose native session-export JSON.
    Goose,
}

impl SessionFormat {
    /// The [`SessionSource`] a file of this format reports.
    ///
    /// This is the deliberate one-way bridge between the two concepts: a file
    /// saved in this format will, when reloaded, report this provenance (see
    /// `crates/harness/tests/session_saving.rs`), making the relationship
    /// discoverable from the method itself.
    pub fn source(self) -> SessionSource {
        match self {
            SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
            SessionFormat::Codex => SessionSource::Codex,
            SessionFormat::OpenCode => SessionSource::OpenCode,
            SessionFormat::Pi => SessionSource::Pi,
            SessionFormat::Grok => SessionSource::Grok,
            SessionFormat::Gemini => SessionSource::Gemini,
            SessionFormat::Goose => SessionSource::Goose,
        }
    }
}

/// Metadata recovered from a session log.
pub use crate::ontology::surface::{
    CrossSurface, Recurrence, SurfaceKey, Trigger, WorkspaceKind, WorkspaceRef,
};

/// ORCH-6: the ORCH-3 conversation nouns as one additive wire block, carried
/// by `harness.v1.sessions.discover` / `sessions.load` rows and by
/// [`crate::catalog::SessionDescriptor`]. Every field is optional so an older
/// client sees exactly the shape it already knows.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrchestrationNouns {
    /// Why the session exists.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trigger: Option<Trigger>,
    /// Where the conversation is reached.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub surface: Option<SurfaceKey>,
    /// Routed config home (Hermes profile / OpenClaw agent).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile: Option<String>,
    /// The job a recurring session belongs to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub recurrence: Option<Recurrence>,
    /// Moved-to-another-surface state.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cross_surface: Option<CrossSurface>,
    /// Typed workspace (the D2 precedence result).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workspace: Option<WorkspaceRef>,
}

impl OrchestrationNouns {
    /// Read the nouns off a loaded session's metadata. `trigger` and
    /// `workspace` always resolve — through [`SessionMeta::trigger_or_default`]
    /// and [`SessionMeta::workspace`], never through a second derivation.
    pub fn from_meta(meta: &SessionMeta) -> Self {
        Self {
            trigger: Some(meta.trigger_or_default()),
            surface: meta.surface.clone(),
            profile: meta.profile.clone(),
            recurrence: meta.recurrence.clone(),
            cross_surface: meta.cross_surface.clone(),
            workspace: Some(meta.workspace_ref()),
        }
    }
}

#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SessionMeta {
    /// The tool that wrote the log.
    pub source: SessionSource,
    /// The session/rollout id.
    pub session_id: Option<String>,
    /// The model the session was running.
    pub model: Option<String>,
    /// The working directory the session ran in.
    pub cwd: Option<PathBuf>,
    /// The system / base-instructions prompt, when the log records it.
    pub system_prompt: Option<String>,
    /// Verbatim source-format header records (the Codex `session_meta` /
    /// `turn_context` lines), preserved so re-export can replay the exact header
    /// the original tool expects rather than guessing its required fields.
    pub codex_headers: Vec<Value>,
    /// Exact source lines for Codex execution/provenance records that affect
    /// continuation semantics but must not be replayed as active events after
    /// a foreign-format hop. Each entry records its original physical-line
    /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
    /// list in a namespaced extension; a later Codex export restores headers
    /// from it while keeping compaction/rollback/review records non-operative,
    /// avoiding a second rollback or compaction of the already-normalized view.
    pub codex_provenance: Vec<Value>,
    /// PARITY-23: generalized source-native residue records for NON-codex
    /// sources — `{record_index, kind, raw}` entries captured at load time
    /// (or restored from a portable v2 envelope) so cross-format hops can
    /// return them exactly. Codex keeps its original dedicated store above.
    pub native_residue: Vec<Value>,
    /// The source format `native_residue` belongs to (e.g. `claude_code`).
    pub native_residue_source: Option<String>,
    /// The OpenCode analogue of [`Self::codex_headers`]
    /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
    /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
    /// absent), plus any captured `session_diff`/`todo` side-records — each
    /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
    /// shape `raw` uses, so a consumer can tell which storage key a header
    /// record belongs to. These replay only via the direct-write fallback
    /// (`Session::to_opencode_direct_write`); `opencode import` has no
    /// ingestion path for `session_diff`/`todo` (S5).
    pub opencode_headers: Vec<Value>,
    /// Goose's native session-export object with `conversation` removed.
    /// Goose stores sessions in SQLite but defines this JSON object as its
    /// official import/export boundary. Keeping the shell lets an unchanged
    /// direct round-trip remain byte exact while appended turns are spliced
    /// into a stock-importable artifact without guessing native metadata.
    pub goose_header: Option<Value>,
    /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
    /// stem). `None` for top-level sessions.
    pub agent_id: Option<String>,
    /// For a subagent session: the `tool_use_id` of the parent `Task` call that
    /// spawned it, recovered from the parent transcript's tool result. Best
    /// effort — `None` if the link could not be established.
    pub parent_tool_use_id: Option<String>,
    /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
    /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
    /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
    /// `depth`). Empty for a plain top-level session. Used by
    /// [`Session::reconstruct_tree`] to nest children under their parents.
    pub lineage: std::collections::BTreeMap<String, String>,
    /// ORCH-3: why the session exists, when the source says.
    pub trigger: Option<Trigger>,
    /// ORCH-3: the conversation's surface identity, when it has one.
    pub surface: Option<SurfaceKey>,
    /// ORCH-3: routed config home (Hermes profile / OpenClaw agent / Codex profile).
    pub profile: Option<String>,
    /// ORCH-3: the job a recurring session belongs to.
    pub recurrence: Option<Recurrence>,
    /// ORCH-3: moved-to-another-surface state.
    pub cross_surface: Option<CrossSurface>,
}

impl SessionMeta {
    pub(crate) fn new(source: SessionSource) -> Self {
        SessionMeta {
            source,
            session_id: None,
            model: None,
            cwd: None,
            system_prompt: None,
            codex_headers: Vec::new(),
            codex_provenance: Vec::new(),
            native_residue: Vec::new(),
            native_residue_source: None,
            opencode_headers: Vec::new(),
            goose_header: None,
            agent_id: None,
            parent_tool_use_id: None,
            lineage: std::collections::BTreeMap::new(),
            trigger: None,
            surface: None,
            profile: None,
            recurrence: None,
            cross_surface: None,
        }
    }

    /// The trigger, defaulting from what the loaders already know: a spawned
    /// child (`agent_id` / a delegate lineage) is `Parent`; otherwise `Human`.
    pub fn trigger_or_default(&self) -> Trigger {
        if let Some(t) = self.trigger {
            return t;
        }
        let delegate = self
            .lineage
            .get("hermes_lineage_kind")
            .map(|k| k == "delegate")
            .unwrap_or(false);
        if self.agent_id.is_some() || self.parent_tool_use_id.is_some() || delegate {
            Trigger::Parent
        } else {
            Trigger::Human
        }
    }

    /// UNI-9 workspace with the D2 precedence: `repo` when a cwd exists, else
    /// `channel` when the surface is a channel, else `none`. Derived, never stored.
    pub fn workspace(&self) -> (WorkspaceKind, Option<String>) {
        if let Some(cwd) = &self.cwd {
            return (
                WorkspaceKind::Repo,
                Some(cwd.to_string_lossy().into_owned()),
            );
        }
        if let Some(surface) = self.surface.as_ref().filter(|s| s.is_channel()) {
            let label = match (&surface.platform, &surface.chat_id) {
                (Some(p), Some(c)) => format!("{p}:{c}"),
                (Some(p), None) => p.clone(),
                _ => String::new(),
            };
            return (WorkspaceKind::Channel, Some(label));
        }
        (WorkspaceKind::None, None)
    }

    /// [`Self::workspace`] as the wire value. Naming only — the precedence
    /// stays in `workspace()`.
    pub fn workspace_ref(&self) -> WorkspaceRef {
        let (kind, value) = self.workspace();
        WorkspaceRef { kind, value }
    }
}

/// A normalized, replayable conversation loaded from a tool's session log.
#[derive(Debug, Clone)]
pub struct Session {
    /// Recovered metadata.
    pub meta: SessionMeta,
    /// The conversation, normalized to the OpenAI chat-completions shape.
    pub messages: Vec<ChatMessage>,
    /// Subagent (Task) sub-conversations. Claude Code stores these as separate
    /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
    /// discovers and attaches them here (each is a full [`Session`] whose
    /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
    pub subagents: Vec<Session>,
    /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
    /// captured via `split_lines_verbatim`, not the blank-skipping/trimming
    /// `non_empty_lines` parse view, so a blank line, a CRLF (`\r\n`)
    /// terminator, or trailing whitespace on a line all survive bit-for-bit
    /// rather than being dropped/normalized away. Normalization into
    /// `messages` is still lossy by design (it targets the OpenAI replay
    /// shape), but these raw lines retain *everything* — including records
    /// with no canonical representation (e.g. Claude `file-history-snapshot`)
    /// — so a round-trip through the supercode-native format
    /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
    /// formats (Claude Code/Codex/Pi), for ANY input (see
    /// [`Self::raw_trailing_newline`] for the one piece of information a line
    /// list alone can't carry).
    pub raw: Vec<String>,
    /// Whether the source text `raw` was captured from ended with a trailing
    /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
    /// trailing newline from one that doesn't (both split into the same
    /// lines) — this flag carries that fact out-of-band so
    /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
    /// original source bytes exactly, including the presence/absence of a
    /// final newline. `true` for a `Session` whose `raw` isn't captured
    /// verbatim from real source text (e.g. OpenCode's re-synthesized
    /// export-document `raw`, or a `Session` assembled programmatically) —
    /// matching the historical always-terminated-by-newline behavior for
    /// those cases.
    pub raw_trailing_newline: bool,
    /// How many of `messages` (and, symmetrically, of `raw` — see below) came
    /// from parsing the imported log, as opposed to being appended after
    /// import. Set once, at the end of [`Self::from_claude_code_str`] /
    /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
    /// before [`Self::from_native_str`]'s subsequent loop reattaches any
    /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
    /// That loop pushes exactly one `raw` line and one message per appended
    /// turn, so the two lists grow in lockstep from here on: the raw-prefix
    /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
    /// as `raw.len() - (messages.len() - imported_message_count)`, without a
    /// second counter. `None` only when a `Session` is constructed some other
    /// way than through those two loaders — splicing then has no boundary to
    /// honor and treats every message as imported (equivalent to
    /// `Some(messages.len())`).
    /// A bounded display-history projection uses this field for the total
    /// number of normalized messages observed before its in-memory window was
    /// applied. Such a semantic view is never a continuation source, and all
    /// splice callers clamp the value to `messages.len()`.
    pub imported_message_count: Option<usize>,
    /// Whether `raw` was captured strict-verbatim from real source text
    /// (`true`) or re-synthesized by this crate (`false`) — the fact
    /// [`Self::raw_verbatim`]'s callers need to know before claiming a
    /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
    /// `true` for every line-oriented loader (`from_claude_code_str`,
    /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
    /// surface (`from_opencode_str`'s per-line loop) — each of those splits
    /// `raw` directly out of the source text via `split_lines_verbatim`, so
    /// replaying it reproduces the original bytes exactly. `false` for
    /// OpenCode's EXPORT-DOCUMENT read surface
    /// (`Session::from_opencode_export_doc`): a pretty-printed
    /// `{info, messages:[...]}` document has no per-line envelope structure
    /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
    /// record — faithful in value, but not the original document's bytes.
    /// A `Session` assembled programmatically (not through a `from_*_str`
    /// loader) also defaults to `false` — no real source text was captured
    /// at all.
    pub raw_is_verbatim: bool,
    /// PARITY-15: how many non-empty lines of the source text FAILED to
    /// deserialize at all (a genuinely malformed/truncated JSON line — not
    /// a well-formed-but-unmodeled record type, which is a normal,
    /// intentional "skip", tracked separately by `crate::audit`). Every
    /// line-oriented loader tolerates a stray corrupt line rather than
    /// hard-failing the whole load (a single bad line must not make an
    /// otherwise-healthy multi-thousand-line session unloadable) — but that
    /// tolerance used to be completely invisible: `Session::load` returned
    /// `Ok` either way, with no signal that anything was skipped. This
    /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
    /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
    /// and for a `Session` assembled programmatically.
    pub parse_error_lines: usize,
    /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
    /// failing — the same "say exactly what was given up" residue list
    /// `harness.v1.sessions.export` already reports for artifacts.
    ///
    /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
    /// transcript it cannot reconstruct exactly, which is what keeps
    /// continuation/transfer/export guarantees intact. A non-empty list means
    /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
    /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
    pub load_residue: Vec<String>,
}

impl Session {
    /// The fidelity this reconstruction actually achieved.
    ///
    /// Same rule the export path applies to an artifact: named residue means
    /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
    /// [`Fidelity::ByteLossless`] and a re-synthesized one is
    /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
    /// session's: the whole reconstruction is only as faithful as its least
    /// faithful part, and each child still reports its own residue where it
    /// was measured.
    pub fn load_fidelity(&self) -> Fidelity {
        let own = if !self.load_residue.is_empty() {
            Fidelity::Semantic
        } else if self.raw_is_verbatim {
            Fidelity::ByteLossless
        } else {
            Fidelity::ValueLossless
        };
        if own != Fidelity::Semantic
            && self
                .subagents
                .iter()
                .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
        {
            return Fidelity::Semantic;
        }
        own
    }

    /// Assemble a session from supercode's own flat store transcript (one
    /// [`ChatMessage`] per JSONL line). These files are the native working
    /// format written by Supercode's native session store, not a foreign
    /// harness log, so routing them through format auto-detection would
    /// misclassify them as an empty Claude Code session.
    pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
        Session {
            meta: SessionMeta::new(SessionSource::Native),
            messages,
            subagents: Vec::new(),
            raw: Vec::new(),
            raw_trailing_newline: true,
            imported_message_count: None,
            raw_is_verbatim: false,
            parse_error_lines: 0,
            load_residue: Vec::new(),
        }
    }

    /// Load a session, auto-detecting whether it's a Claude Code or Codex log
    /// — or, when `path` looks like a SQLite database, a real OpenCode
    /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
    /// UTF-8 text read, so a binary `.db` file is routed to
    /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
    /// did not contain valid UTF-8" error (the confirmed footgun these items
    /// close — see [`looks_like_sqlite`] and the UTF-8 diagnostic reader).
    ///
    /// A DIRECTORY is also accepted directly: `path` is probed with
    /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
    /// checks below (both of which assume a file and would otherwise surface
    /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
    /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
    /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
    /// `audit --format opencode` already does. A resolved `Sqlite` surface
    /// loads exactly like pointing `load` at that `opencode*.db` file
    /// directly (most-recently-updated top-level session). The legacy
    /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
    /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
    /// that case returns a clear error naming the `.db` file / `audit` as the
    /// way in, rather than silently doing nothing or crashing.
    pub fn load(path: impl AsRef<Path>) -> Result<Session> {
        Self::load_with_fidelity(path, Fidelity::ByteLossless)
    }

    /// Load a session at a declared [`Fidelity`].
    ///
    /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
    /// record graph cannot be reconstructed exactly (the everyday case for a
    /// Claude Code session that has been compacted or resumed across files,
    /// where a live record's `parentUuid` names a record that was pruned)
    /// still loads, stitched best-effort in transcript order, and names what
    /// it gave up in [`Session::load_residue`]. Every stricter level keeps
    /// the historical behavior — refuse loudly — because a continuation,
    /// transfer or export built on a guessed graph is exactly the loss
    /// supercode exists to prevent. Callers that go on to RESUME a session
    /// must therefore use [`Session::load`].
    pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
        Self::load_with_fidelity_and_subagents(path, fidelity, true)
    }

    /// Load only the selected session's own transcript at a declared fidelity.
    ///
    /// This is the read-only frontend path: Claude Code can place hundreds of
    /// child transcripts beside a parent, but a chat viewport displaying the
    /// parent must not eagerly parse and transport that entire child tree.
    /// Translation, continuation, export, and the ordinary [`Self::load`]
    /// path keep attaching every subagent unchanged.
    #[doc(hidden)]
    pub fn load_parent_with_fidelity(
        path: impl AsRef<Path>,
        fidelity: Fidelity,
    ) -> Result<Session> {
        Self::load_with_fidelity_and_subagents(path, fidelity, false)
    }

    /// Load a bounded, parent-only transcript for human display.
    ///
    /// Unlike the continuation loader, Codex compaction records do not erase
    /// earlier visible assistant turns here: the native rollout still holds
    /// those records, and a scrollback view should show what the human saw,
    /// not only the compacted context the next model call will receive.
    #[doc(hidden)]
    pub fn load_display_view(
        path: impl AsRef<Path>,
        fidelity: Fidelity,
        message_limit: usize,
    ) -> Result<Session> {
        let path = path.as_ref();
        if path.is_dir() || looks_like_sqlite(path) {
            let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
            truncate_session_messages(&mut session, message_limit);
            return Ok(session);
        }
        let mut read_limit = message_limit.max(1);
        let mut previous_window_len = 0usize;
        let (mut session, omitted_prefix) = loop {
            let (source, text, omitted_prefix) = read_display_jsonl(path, read_limit)?;
            let mut candidate = match source {
                Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
                Some(SessionSource::Gemini) => {
                    let mut session = Self::from_gemini_str(&text)?;
                    session.raw_is_verbatim = false;
                    session.load_residue.push(
                        "display history is a bounded native-record projection, not a complete Gemini artifact"
                            .to_string(),
                    );
                    session
                }
                Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
                Some(SessionSource::Grok) => {
                    let mut session = Self::from_grok_str(&text)?;
                    session.capture_grok_path_metadata(path);
                    session
                }
                Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
                _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
            };
            let observed_messages = candidate
                .imported_message_count
                .unwrap_or(candidate.messages.len())
                .max(candidate.messages.len());
            let human_turns = candidate
                .messages
                .iter()
                .filter(|message| message.role == Role::User)
                .count();
            let window_len = text.len();
            let sufficient =
                !omitted_prefix || (observed_messages > message_limit.max(1) && human_turns >= 2);
            // 16 KiB/message with a 64 MiB ceiling means 4096 is the first
            // read limit that cannot grow the native byte window further.
            // Smaller repeated lengths can be the intentional 4 MiB floor;
            // keep doubling through that plateau instead of declaring a
            // false pagination end.
            let byte_window_exhausted = window_len <= previous_window_len && read_limit >= 4096;
            if sufficient || byte_window_exhausted {
                if omitted_prefix {
                    // The prefix is known to contain more native history even
                    // when this bounded window cannot cheaply normalize its
                    // exact size. Never turn that into a false end-of-history.
                    candidate.imported_message_count =
                        Some(observed_messages.max(message_limit.max(1).saturating_add(1)));
                }
                break (candidate, omitted_prefix);
            }
            previous_window_len = window_len;
            read_limit = read_limit.saturating_mul(2);
        };
        if omitted_prefix {
            session.load_residue.push(
                "older native records remain outside this bounded display window".to_string(),
            );
        }
        truncate_session_messages(&mut session, message_limit);
        Ok(session)
    }

    fn load_with_fidelity_and_subagents(
        path: impl AsRef<Path>,
        fidelity: Fidelity,
        include_subagents: bool,
    ) -> Result<Session> {
        let path = path.as_ref();
        if path.is_dir() {
            return match detect_opencode_storage_surface(path) {
                Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
                    Self::from_opencode_sqlite(&db_path, None)
                }
                Some((
                    OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
                    _,
                )) => Err(crate::Error::Other(format!(
                    "{} is an OpenCode data root using a legacy JSON storage tree, which \
                         supercode does not load directly — point `inspect`/`convert`/`resume` \
                         at the store's `opencode*.db` SQLite file if this install has one, or \
                         use `audit --format opencode {}` instead",
                    path.display(),
                    path.display()
                ))),
                None => Err(crate::Error::Other(format!(
                    "{} is a directory, but no session file or OpenCode store was found in it \
                     (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
                     tree)",
                    path.display()
                ))),
            };
        }
        if looks_like_sqlite(path) {
            // Two SQLite-backed stores exist: OpenCode's (schema_meta-free
            // key/value envelope db) and Hermes's `state.db` (UNI-15). The
            // fingerprint check is cheap and read-only.
            if let Ok(conn) = Connection::open_with_flags(
                path,
                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
                    | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
            ) {
                if hermes_sqlite_fingerprint(&conn) {
                    drop(conn);
                    return Self::from_hermes_sqlite(path, None);
                }
            }
            return Self::from_opencode_sqlite(path, None);
        }
        let text = read_utf8_or_diagnose(path)?;
        match detect_source(&text) {
            Some(SessionSource::Codex) => Self::from_codex_str(&text),
            Some(SessionSource::Pi) => Self::from_pi_str(&text),
            Some(SessionSource::OpenClaw) => {
                let mut session = Self::from_openclaw_str(&text)?;
                if session.meta.profile.is_none() {
                    session.meta.profile = openclaw_agent_id_from_path(path);
                }
                Ok(session)
            }
            Some(SessionSource::Grok) => {
                let mut session = Self::from_grok_str(&text)?;
                session.capture_grok_path_metadata(path);
                Ok(session)
            }
            Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
            Some(SessionSource::Goose) => Self::from_goose_str(&text),
            // IX-3: a detected OpenCode session must route to its own
            // loader, not the Claude Code fallback below
            // (`docs/interop/build-followups.md`).
            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
            _ => {
                let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
                if include_subagents {
                    session.attach_claude_subagents(path, &text, fidelity)?;
                }
                Ok(session)
            }
        }
    }

    /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
    ///
    /// Codex stores subagents as separate rollout files linked to their parent
    /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
    /// collection of sessions, this nests each child into its parent's
    /// [`Session::subagents`] and returns only the roots. Children whose parent
    /// isn't in the set are returned as roots themselves (best effort).
    pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
        use std::collections::HashMap;
        // Index each session's position by its session_id.
        let mut idx: HashMap<String, usize> = HashMap::new();
        for (i, s) in sessions.iter().enumerate() {
            if let Some(id) = &s.meta.session_id {
                idx.insert(id.clone(), i);
            }
        }
        // Determine each session's parent (by index), if present in the set.
        let parent_of: Vec<Option<usize>> = sessions
            .iter()
            .map(|s| {
                s.meta
                    .lineage
                    .get("parent_thread_id")
                    .and_then(|p| idx.get(p).copied())
            })
            .collect();

        // Move children into parents, deepest-first so chains nest correctly.
        let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
        let mut order: Vec<usize> = (0..slots.len()).collect();
        order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
        for i in order {
            if let Some(p) = parent_of[i] {
                if p != i {
                    if let Some(child) = slots[i].take() {
                        if let Some(parent) = slots[p].as_mut() {
                            parent.subagents.push(child);
                        } else {
                            slots[i] = Some(child); // parent already moved; keep as root
                        }
                    }
                }
            }
        }
        slots.into_iter().flatten().collect()
    }

    /// Parse a session of a known format from an in-memory JSONL string.
    pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
        match format {
            SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
            SessionFormat::Codex => Self::from_codex_str(jsonl),
            SessionFormat::Pi => Self::from_pi_str(jsonl),
            SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
            SessionFormat::Grok => Self::from_grok_str(jsonl),
            SessionFormat::Gemini => Self::from_gemini_str(jsonl),
            SessionFormat::Goose => Self::from_goose_str(jsonl),
        }
    }

    /// Serialize this session to JSONL in the given format.
    ///
    /// The conversation is synthesized from the canonical messages, so this
    /// works for sessions loaded from *either* tool as well as ones supercode
    /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
    /// "export": format-specific framing that has no slot in the target may be
    /// dropped, but the user/assistant/tool conversation is preserved.
    pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
        match format {
            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
            SessionFormat::Codex => Ok(self.to_codex_jsonl()),
            SessionFormat::Pi => Ok(self.to_pi_jsonl()),
            SessionFormat::OpenCode => self.to_opencode_jsonl(),
            SessionFormat::Grok => Ok(self.to_grok_jsonl()),
            SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
            SessionFormat::Goose => Ok(self.to_goose_json()),
        }
    }

    /// Export back to `format`, replaying the imported `raw` prefix
    /// **verbatim** — original uuids/ids, real timestamps, and
    /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
    /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
    /// is the session's own origin (`format.source() == self.meta.source`,
    /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
    /// Only messages appended *after* import (tracked by
    /// [`Self::imported_message_count`]) are synthesized, chained onto the
    /// last original record found in the raw prefix.
    ///
    /// `session_id` of `Some(new)` rewrites the session id on every emitted
    /// line, raw and synthesized alike (`sessionId` for Claude Code,
    /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
    ///
    /// Cross-format export (no verbatim prefix exists in the target dialect,
    /// by definition) and a session with no `raw` lines both fall back
    /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
    /// today. A12 (SPEC.md §6): this turns "export back to origin" from
    /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
    /// cross-format stays at the documented semantic tier.
    pub fn to_jsonl_spliced(
        &self,
        format: SessionFormat,
        session_id: Option<&str>,
    ) -> Result<String> {
        if self.parse_error_lines > 0
            || self
                .subagents
                .iter()
                .any(|subagent| subagent.parse_error_lines > 0)
        {
            return Err(Error::InvalidSession(
                "refusing spliced export because the loaded session contains parse loss"
                    .to_string(),
            ));
        }
        if self.raw.is_empty() || format.source() != self.meta.source {
            if let Some(session_id) = session_id {
                let mut rewritten = self.clone();
                rewritten.meta.session_id = Some(session_id.to_string());
                return rewritten.to_jsonl(format);
            }
            return self.to_jsonl(format);
        }
        match format {
            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
            SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
            SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
            SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
            SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
            SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
            SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
        }
    }

    /// Write this session to `path` in the given format.
    pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
        std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
        Ok(())
    }

    /// Reconstruct the exact source bytes this `Session` was loaded from,
    /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
    /// inverse of the strict-verbatim capture those two fields record — see
    /// `join_lines_verbatim`).
    ///
    /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
    /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
    /// original text, so this reproduces the original file byte-for-byte —
    /// the P008/P009 diagonal-convert fix (`convert <file> --to
    /// <same-format>` is byte-identical to `<file>`) is built on exactly
    /// this. The one documented exception is an OpenCode **export-document**
    /// source (a single pretty-printed JSON value, not JSONL): `raw` there
    /// is RE-SYNTHESIZED as one envelope line per record (see
    /// `from_opencode_export_doc`'s contract), so this returns a
    /// verbatim reproduction of THAT captured representation rather than the
    /// original pretty-printed document — a known, narrow residue, not a
    /// silent loss (the same records are all still present).
    pub fn raw_verbatim(&self) -> String {
        join_lines_verbatim(&self.raw, self.raw_trailing_newline)
    }

    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
    /// core.session(tree-addressable transcript)"): materialize this
    /// session's linear [`Self::messages`] into a native in-place
    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
    /// FIRST time it wants to run a tree operation (rewind/branch/label)
    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
    /// synthesized node (see
    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
    /// why a single timestamp is used: the source linear messages carry no
    /// per-turn timestamp of their own here).
    ///
    /// This does not mutate `self` or persist anything — see
    /// the composition layer's session-store tree writer for persistence, and
    /// [`Self::apply_session_tree`] for the inverse bridge.
    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
    }

    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
    /// existing linear consumer — the agent loop, exporters — working
    /// unchanged after a tree operation runs). Nothing else on `self`
    /// (`meta`, `raw`, ...) is touched.
    ///
    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
    /// `Err` rather than applying anything — a structurally-corrupt tree
    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
    /// `self` is left untouched on `Err` (the assignment only happens after
    /// the projection has already succeeded).
    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
        self.messages = tree.linear_projection()?;
        Ok(())
    }
}

/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
/// accept there. Binary SQLite input never reaches this function: callers
/// check [`looks_like_sqlite`] first and route to
/// [`Session::from_opencode_sqlite`] instead.
pub(super) fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
    let bytes = std::fs::read(path)?;
    String::from_utf8(bytes).map_err(|_| {
        crate::Error::Other(format!(
            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
            path.display()
        ))
    })
}

/// Read only the portion of a JSONL transcript a bounded scrollback can use.
///
/// The first record carries durable session metadata (especially for Codex),
/// while the trailing window carries the messages the viewport will render.
/// Full lossless loaders intentionally continue to read every byte.
pub(super) fn read_display_jsonl(
    path: &Path,
    message_limit: usize,
) -> Result<(Option<SessionSource>, String, bool)> {
    const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
    const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
    const BYTES_PER_MESSAGE: u64 = 16 * 1024;

    let mut first = String::new();
    BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
    let source = detect_source(&first);
    if !matches!(
        source,
        Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
    ) {
        let text = read_utf8_or_diagnose(path)?;
        return Ok((detect_source(&text), text, false));
    }

    let mut file = std::fs::File::open(path)?;
    let file_len = file.metadata()?.len();
    let requested = (message_limit.max(1) as u64)
        .saturating_mul(BYTES_PER_MESSAGE)
        .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
    if file_len <= requested {
        let text = read_utf8_or_diagnose(path)?;
        return Ok((source, text, false));
    }

    let start = file_len - requested;
    file.seek(SeekFrom::Start(start))?;
    let mut bytes = Vec::with_capacity(requested as usize);
    file.read_to_end(&mut bytes)?;
    // The window normally starts in the middle of a JSON record. Discard that
    // partial prefix so every line passed to the existing parsers is valid.
    if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
        bytes.drain(..=newline);
    }
    let mut tail = String::from_utf8(bytes).map_err(|_| {
        crate::Error::Other(format!(
            "{} contains non-UTF-8 data in its display window",
            path.display()
        ))
    })?;
    if start > 0 {
        // Always recover the human boundary immediately before the byte
        // window, even when the window already contains newer prompts. A
        // long run of large tool records can otherwise make the numeric tail
        // begin in one old turn while its only retained users belong to much
        // newer turns. The display projector then (correctly) hides the
        // orphaned activity, making pagination appear inert.
        //
        // Search backward independently of the render window and retain only
        // two complete human JSONL records. The search grows geometrically but
        // never reads more than the same 64 MiB hard ceiling as the display
        // window, and none of the intervening tool bytes are normalized or
        // sent over RPC.
        let max_search_bytes = start.min(MAX_TAIL_BYTES);
        let mut search_bytes = requested.min(max_search_bytes);
        let anchors = loop {
            let search_start = start - search_bytes;
            file.seek(SeekFrom::Start(search_start))?;
            let mut search = Vec::with_capacity(search_bytes as usize);
            (&mut file).take(search_bytes).read_to_end(&mut search)?;
            if search_start > 0 {
                if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
                    search.drain(..=newline);
                } else {
                    search.clear();
                }
            }
            // `start` normally cuts the record whose remainder the tail
            // reader discarded. Exclude its incomplete prefix here too.
            if let Some(newline) = search.iter().rposition(|byte| *byte == b'\n') {
                search.truncate(newline + 1);
            } else {
                search.clear();
            }
            let anchors = std::str::from_utf8(&search)
                .ok()
                .map(|search| {
                    let mut found = search
                        .lines()
                        .rev()
                        .filter(|line| native_display_human_line(line, source))
                        .take(2)
                        .map(str::to_string)
                        .collect::<Vec<_>>();
                    found.reverse();
                    found
                })
                .unwrap_or_default();
            if anchors.len() >= 2 || search_start == 0 || search_bytes == max_search_bytes {
                break anchors;
            }
            search_bytes = search_bytes.saturating_mul(2).min(max_search_bytes);
        };
        if !anchors.is_empty() {
            tail = format!("{}\n{tail}", anchors.join("\n"));
        }
    }
    let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
        format!("{first}{tail}")
    } else {
        tail
    };
    Ok((source, text, true))
}

#[cfg(test)]
mod orchestration_noun_tests {
    use super::*;

    #[test]
    fn hermes_key_parses_profile_and_surface() {
        let (s, p) = parse_hermes_session_key("agent:coder:telegram:group:-100777:55:u9").unwrap();
        assert_eq!(p.as_deref(), Some("coder"));
        assert_eq!(s.platform.as_deref(), Some("telegram"));
        assert_eq!(s.kind.as_deref(), Some("group"));
        assert_eq!(s.chat_id.as_deref(), Some("-100777"));
        assert_eq!(s.thread_id.as_deref(), Some("55"));
        assert_eq!(s.participant_id.as_deref(), Some("u9"));
        let (_, p) = parse_hermes_session_key("agent:main:telegram:dm:1").unwrap();
        assert!(p.is_none());
        assert!(parse_hermes_session_key("cron:abc").is_none());
    }

    #[test]
    fn openclaw_keys_parse_every_documented_shape() {
        let (a, s, t, r) =
            parse_openclaw_session_key("agent:design:slack:channel:C1:thread:T2").unwrap();
        assert_eq!(a.as_deref(), Some("design"));
        assert_eq!(s.platform.as_deref(), Some("slack"));
        assert_eq!(s.chat_id.as_deref(), Some("C1"));
        assert_eq!(s.thread_id.as_deref(), Some("T2"));
        assert_eq!(t, Trigger::Channel);
        assert!(r.is_none());
        let (a, s, t, _) = parse_openclaw_session_key("agent:main:main").unwrap();
        assert_eq!(a.as_deref(), Some("main"));
        assert_eq!(s.kind.as_deref(), Some("main"));
        assert_eq!(t, Trigger::Unknown);
        let (_, _, t, r) = parse_openclaw_session_key("cron:job-7").unwrap();
        assert_eq!(t, Trigger::Cron);
        assert_eq!(r.unwrap().job_id, "job-7");
        assert_eq!(
            parse_openclaw_session_key("hook:gmail:m1").unwrap().2,
            Trigger::Webhook
        );
        assert_eq!(
            parse_openclaw_session_key("acp-bridge:u").unwrap().2,
            Trigger::Api
        );
        assert!(parse_openclaw_session_key("garbage").is_none());
    }

    #[test]
    fn hermes_source_and_cron_ids_classify() {
        assert_eq!(hermes_trigger_for_source("telegram"), Trigger::Channel);
        assert_eq!(hermes_trigger_for_source("cli"), Trigger::Human);
        assert_eq!(hermes_trigger_for_source("acp"), Trigger::Human);
        assert_eq!(hermes_trigger_for_source("api_server"), Trigger::Api);
        assert_eq!(hermes_trigger_for_source("cron"), Trigger::Cron);
        assert_eq!(hermes_trigger_for_source(""), Trigger::Unknown);
        assert_eq!(
            hermes_cron_job_id("cron_job42_20260902_120000").as_deref(),
            Some("job42")
        );
        assert_eq!(
            hermes_cron_job_id("cron_a_b_20260902_120000").as_deref(),
            Some("a_b")
        );
        assert!(hermes_cron_job_id("cron_job42_2026_1200").is_none());
        assert!(hermes_cron_job_id("adf8a015").is_none());
    }

    #[test]
    fn workspace_precedence_repo_over_channel_over_none() {
        let mut meta = SessionMeta::new(SessionSource::Hermes);
        assert_eq!(meta.workspace().0, WorkspaceKind::None);
        meta.surface = Some(SurfaceKey {
            platform: Some("telegram".into()),
            chat_id: Some("1".into()),
            ..Default::default()
        });
        assert_eq!(
            meta.workspace(),
            (WorkspaceKind::Channel, Some("telegram:1".into()))
        );
        meta.cwd = Some(PathBuf::from("/w"));
        assert_eq!(meta.workspace().0, WorkspaceKind::Repo);
        assert_eq!(meta.trigger_or_default(), Trigger::Human);
        meta.agent_id = Some("a".into());
        assert_eq!(meta.trigger_or_default(), Trigger::Parent);
    }

    #[test]
    fn openclaw_agent_id_comes_from_the_agents_directory() {
        let p = std::path::Path::new("/home/u/.openclaw/agents/design/sessions/x.jsonl");
        assert_eq!(openclaw_agent_id_from_path(p).as_deref(), Some("design"));
        assert!(openclaw_agent_id_from_path(std::path::Path::new("/tmp/x.jsonl")).is_none());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_new_user_message_slides_the_display_tail_without_becoming_its_only_anchor() {
        let mut messages = vec![
            ChatMessage::user("original prompt"),
            ChatMessage::assistant("one"),
            ChatMessage::assistant("two"),
            ChatMessage::assistant("three"),
            ChatMessage::assistant("four"),
            ChatMessage::assistant("five"),
            ChatMessage::user("new prompt"),
        ];

        truncate_messages_with_anchor(&mut messages, 4, Vec::new());

        assert_eq!(messages.len(), 4);
        assert_eq!(messages[0].content.as_deref(), Some("original prompt"));
        assert_eq!(messages[3].content.as_deref(), Some("new prompt"));
    }

    #[test]
    fn a_tool_heavy_current_turn_keeps_the_previous_and_current_user_anchors() {
        let mut messages = vec![
            ChatMessage::user("previous prompt"),
            ChatMessage::assistant("previous answer"),
            ChatMessage::user("current prompt"),
            ChatMessage::assistant("tool one"),
            ChatMessage::assistant("tool two"),
            ChatMessage::assistant("tool three"),
            ChatMessage::assistant("tool four"),
            ChatMessage::assistant("tool five"),
        ];

        truncate_messages_with_anchor(&mut messages, 4, Vec::new());

        assert_eq!(messages.len(), 4);
        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
        assert_eq!(messages[3].content.as_deref(), Some("tool five"));
    }

    #[test]
    fn a_preceding_user_anchor_is_restored_when_dedup_shrinks_below_the_limit() {
        let mut messages = vec![
            ChatMessage::user("current prompt"),
            ChatMessage::assistant("tool one"),
            ChatMessage::assistant("tool two"),
        ];

        truncate_messages_with_anchor(&mut messages, 4, vec![ChatMessage::user("previous prompt")]);

        assert_eq!(messages.len(), 4);
        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
    }

    #[test]
    fn a_tool_heavy_turn_restores_two_users_that_both_left_the_retention_buffer() {
        let mut messages = vec![
            ChatMessage::assistant("tool one"),
            ChatMessage::assistant("tool two"),
            ChatMessage::assistant("tool three"),
            ChatMessage::assistant("tool four"),
        ];

        truncate_messages_with_anchor(
            &mut messages,
            4,
            vec![
                ChatMessage::user("previous prompt"),
                ChatMessage::user("current prompt"),
            ],
        );

        assert_eq!(messages.len(), 4);
        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
        assert_eq!(messages[3].content.as_deref(), Some("tool four"));
    }

    #[test]
    fn a_loaded_boundary_anchor_survives_several_newer_user_turns() {
        let mut messages = vec![
            ChatMessage::assistant("older tool one"),
            ChatMessage::assistant("older tool two"),
            ChatMessage::user("recent prompt one"),
            ChatMessage::assistant("recent answer one"),
            ChatMessage::user("recent prompt two"),
            ChatMessage::assistant("recent answer two"),
            ChatMessage::user("current prompt"),
            ChatMessage::assistant("current tool"),
        ];

        truncate_messages_with_anchor(
            &mut messages,
            6,
            vec![ChatMessage::user("loaded earlier boundary")],
        );

        assert_eq!(messages.len(), 6);
        assert_eq!(
            messages[0].content.as_deref(),
            Some("loaded earlier boundary"),
            "newer user prompts must not replace the prompt that owns the retained activity",
        );
        assert_eq!(messages[4].content.as_deref(), Some("current prompt"));
        assert_eq!(messages[5].content.as_deref(), Some("current tool"));
    }

    #[test]
    fn a_bounded_byte_window_recovers_preceding_users_even_when_its_tail_has_users() {
        let nonce = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "supercode-display-boundary-{}-{nonce}.jsonl",
            std::process::id()
        ));
        let user = |text: &str| {
            format!(
                r#"{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#,
            )
        };
        let lines = [
            r#"{"type":"session_meta","payload":{"id":"session-1"}}"#.to_string(),
            user("preceding boundary"),
            format!(
                r#"{{"type":"event_msg","payload":{{"type":"token_count","noise":"{}"}}}}"#,
                "x".repeat(5 * 1024 * 1024)
            ),
            user("newer prompt one"),
            user("newer prompt two"),
        ];
        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();

        let (_, text, omitted_prefix) = read_display_jsonl(&path, 120).unwrap();
        std::fs::remove_file(&path).unwrap();

        assert!(omitted_prefix);
        assert!(text.contains("preceding boundary"));
        assert!(text.contains("newer prompt one"));
        assert!(text.contains("newer prompt two"));
    }
}