agent-session-bridge 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. agent_session_bridge-0.2.0.dist-info/METADATA +288 -0
  2. agent_session_bridge-0.2.0.dist-info/RECORD +37 -0
  3. agent_session_bridge-0.2.0.dist-info/WHEEL +5 -0
  4. agent_session_bridge-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_session_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. agent_session_bridge-0.2.0.dist-info/top_level.txt +1 -0
  7. session_bridge/__init__.py +0 -0
  8. session_bridge/_ids.py +43 -0
  9. session_bridge/cli.py +473 -0
  10. session_bridge/convert.py +130 -0
  11. session_bridge/handshake.py +175 -0
  12. session_bridge/ir.py +246 -0
  13. session_bridge/place.py +98 -0
  14. session_bridge/readers/__init__.py +0 -0
  15. session_bridge/readers/_content.py +42 -0
  16. session_bridge/readers/_jsonl.py +50 -0
  17. session_bridge/readers/_pending.py +63 -0
  18. session_bridge/readers/claude_code.py +226 -0
  19. session_bridge/readers/codex.py +203 -0
  20. session_bridge/readers/hermes.py +167 -0
  21. session_bridge/skill_install.py +134 -0
  22. session_bridge/skills/session-handoff/SKILL.md +119 -0
  23. session_bridge/tui/__init__.py +0 -0
  24. session_bridge/tui/actions.py +123 -0
  25. session_bridge/tui/app.py +65 -0
  26. session_bridge/tui/discovery.py +208 -0
  27. session_bridge/tui/options.py +86 -0
  28. session_bridge/tui/register.py +475 -0
  29. session_bridge/tui/screens.py +710 -0
  30. session_bridge/tui/summary.py +41 -0
  31. session_bridge/writers/__init__.py +0 -0
  32. session_bridge/writers/_common.py +288 -0
  33. session_bridge/writers/claude_code.py +152 -0
  34. session_bridge/writers/codex.py +154 -0
  35. session_bridge/writers/codex_db.py +302 -0
  36. session_bridge/writers/hermes.py +142 -0
  37. session_bridge/writers/hermes_db.py +294 -0
@@ -0,0 +1,710 @@
1
+ """Textual screens for the session-bridge TUI convert wizard.
2
+
3
+ Linear flow: PickerScreen -> SummaryScreen -> OptionsScreen -> DryRunScreen ->
4
+ ResultScreen. Screens pass data forward via constructor args; ``escape`` pops
5
+ back. All parsing/conversion/writing runs in thread workers so the UI never
6
+ blocks, and every failure renders as a panel — a traceback escaping textual
7
+ would corrupt the terminal.
8
+
9
+ This module is the only one (besides app.py) that imports textual; all logic
10
+ lives in the textual-free sibling modules (discovery/options/summary/actions).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import datetime
16
+
17
+ # Session content (ids, cwds, previews, error text) is untrusted and must never
18
+ # reach a markup-parsing sink unescaped: "[red]" in a transcript would otherwise
19
+ # raise MarkupError and take down the whole app.
20
+ from rich.markup import escape
21
+ from textual import work
22
+ from textual.app import ComposeResult
23
+ from textual.containers import Horizontal, Vertical, VerticalScroll
24
+ from textual.screen import Screen
25
+ from textual.widgets import (
26
+ Button,
27
+ DataTable,
28
+ Footer,
29
+ Header,
30
+ Input,
31
+ Label,
32
+ Select,
33
+ Static,
34
+ Switch,
35
+ )
36
+
37
+ from ..convert import HARNESSES, ConversionResult, default_output_name, read_session
38
+ from ..ir import Session
39
+ from .actions import execute_writes, run_conversion
40
+ from .discovery import SessionEntry, discover_sessions
41
+ from .options import ConvertOptions, build_cli_command, resolve_output, validate_options
42
+ from .summary import summarize_session
43
+
44
+
45
+ def _human_size(n: int) -> str:
46
+ for unit in ("B", "KB", "MB"):
47
+ if n < 1024:
48
+ return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
49
+ n /= 1024
50
+ return f"{n:.1f}GB"
51
+
52
+
53
+ def _one_line(text: str, limit: int = 80) -> str:
54
+ flat = " ".join(text.split())
55
+ return flat if len(flat) <= limit else flat[: limit - 1] + "…"
56
+
57
+
58
+ class PickerScreen(Screen):
59
+ """Discovered sessions across all three harness stores, newest first."""
60
+
61
+ BINDINGS = [
62
+ ("r", "rescan", "Rescan"),
63
+ ("q", "quit", "Quit"),
64
+ ]
65
+
66
+ def __init__(self) -> None:
67
+ super().__init__()
68
+ self.entries: list[SessionEntry] = []
69
+ self._by_key: dict[str, SessionEntry] = {}
70
+
71
+ def compose(self) -> ComposeResult:
72
+ yield Header()
73
+ yield Static("scanning session stores…", id="picker-status")
74
+ yield DataTable(id="sessions")
75
+ yield Footer()
76
+
77
+ def on_mount(self) -> None:
78
+ table = self.query_one(DataTable)
79
+ table.cursor_type = "row"
80
+ table.add_columns("harness", "session id", "cwd", "modified", "size", "preview")
81
+ self._load()
82
+
83
+ @work(thread=True, exclusive=True)
84
+ def _load(self) -> None:
85
+ app = self.app
86
+ entries = discover_sessions(
87
+ claude_home=getattr(app, "claude_home", None),
88
+ codex_home=getattr(app, "codex_home", None),
89
+ hermes_home=getattr(app, "hermes_home", None),
90
+ )
91
+ app.call_from_thread(self._populate, entries)
92
+
93
+ def _populate(self, entries: list[SessionEntry]) -> None:
94
+ self.entries = entries
95
+ self._by_key = {str(e.path): e for e in entries}
96
+ status = self.query_one("#picker-status", Static)
97
+ table = self.query_one(DataTable)
98
+ table.clear()
99
+ for e in entries:
100
+ table.add_row(
101
+ e.harness,
102
+ escape(_one_line(e.session_id or "?", 40)),
103
+ escape(_one_line(e.cwd or "-", 40)),
104
+ datetime.fromtimestamp(e.mtime).strftime("%Y-%m-%d %H:%M"),
105
+ _human_size(e.size),
106
+ escape(_one_line(e.preview or "")),
107
+ key=str(e.path),
108
+ )
109
+ status.update(
110
+ f"{len(entries)} session(s) found — enter to select, r to rescan, q to quit"
111
+ if entries
112
+ else "no sessions found in ~/.claude, ~/.codex, or ~/.hermes — r to rescan"
113
+ )
114
+
115
+ def action_rescan(self) -> None:
116
+ self.query_one("#picker-status", Static).update("rescanning…")
117
+ self._load()
118
+
119
+ def action_quit(self) -> None:
120
+ self.app.exit()
121
+
122
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
123
+ # Resolve by row key, not cursor index: a rescan can repopulate the
124
+ # table between the selection event being posted and handled.
125
+ entry = self._by_key.get(event.row_key.value or "")
126
+ if entry is not None:
127
+ self.app.push_screen(SummaryScreen(entry))
128
+
129
+
130
+ class SummaryScreen(Screen):
131
+ """Full parse of the chosen session (inspect-equivalent), kept for later screens."""
132
+
133
+ BINDINGS = [
134
+ ("c", "convert", "Convert"),
135
+ ("g", "register", "Register"),
136
+ ("escape", "app.pop_screen", "Back"),
137
+ ]
138
+
139
+ def __init__(self, entry: SessionEntry) -> None:
140
+ super().__init__()
141
+ self.entry = entry
142
+ self.session: Session | None = None
143
+
144
+ def compose(self) -> ComposeResult:
145
+ yield Header()
146
+ with VerticalScroll():
147
+ yield Static(f"parsing {escape(str(self.entry.path))}…", id="summary-body")
148
+ yield Footer()
149
+
150
+ def on_mount(self) -> None:
151
+ self._parse()
152
+
153
+ @work(thread=True, exclusive=True)
154
+ def _parse(self) -> None:
155
+ try:
156
+ session = read_session(self.entry.harness, self.entry.path)
157
+ except Exception as exc: # any parse failure -> panel, never a crash
158
+ self.app.call_from_thread(self._show_error, exc)
159
+ return
160
+ self.app.call_from_thread(self._render_summary, session)
161
+
162
+ def _show_error(self, exc: Exception) -> None:
163
+ self.query_one("#summary-body", Static).update(
164
+ f"[b red]could not parse this session[/]\n\n{escape(str(exc))}\n\nescape to go back"
165
+ )
166
+
167
+ # NB: not `_render` — that name is textual's internal Widget._render hook.
168
+ def _render_summary(self, session: Session) -> None:
169
+ self.session = session
170
+ rows = summarize_session(session)
171
+ width = max(len(label) for label, _ in rows)
172
+ body = "\n".join(
173
+ f"[b]{label:<{width}}[/] {escape(value)}" for label, value in rows
174
+ )
175
+ self.query_one("#summary-body", Static).update(
176
+ body + "\n\n[dim]c to configure a conversion, escape to go back[/]"
177
+ )
178
+
179
+ def action_convert(self) -> None:
180
+ if self.session is not None:
181
+ self.app.push_screen(OptionsScreen(self.entry, self.session))
182
+
183
+ def action_register(self) -> None:
184
+ if self.session is not None:
185
+ self.app.push_screen(RegisterFormScreen(self.entry))
186
+
187
+
188
+ class OptionsScreen(Screen):
189
+ """Convert options form; placement fields only exist for a claude-code target."""
190
+
191
+ BINDINGS = [("escape", "app.pop_screen", "Back")]
192
+
193
+ def __init__(self, entry: SessionEntry, session: Session) -> None:
194
+ super().__init__()
195
+ self.entry = entry
196
+ self.session = session
197
+ # Default to converting *away* from the source harness.
198
+ self.initial_target = next(h for h in HARNESSES if h != entry.harness)
199
+ self._auto_output = default_output_name(entry.path, self.initial_target)
200
+
201
+ def compose(self) -> ComposeResult:
202
+ yield Header()
203
+ with VerticalScroll():
204
+ yield Label("Target harness")
205
+ yield Select(
206
+ [(h, h) for h in HARNESSES],
207
+ value=self.initial_target,
208
+ allow_blank=False,
209
+ id="target",
210
+ )
211
+ yield Label("Output path")
212
+ yield Input(
213
+ value=default_output_name(self.entry.path, self.initial_target),
214
+ id="output",
215
+ )
216
+ with Horizontal(classes="switch-row"):
217
+ yield Switch(value=True, id="handshake")
218
+ yield Label("prepend resume handshake")
219
+ with Horizontal(classes="switch-row"):
220
+ yield Switch(value=False, id="stub")
221
+ yield Label("stub open tool calls (synthetic interrupted results)")
222
+ yield Label("Also write handshake markdown to (optional)")
223
+ yield Input(placeholder="e.g. resume.md", id="handshake-out")
224
+ with Vertical(id="place-group"):
225
+ with Horizontal(classes="switch-row"):
226
+ yield Switch(value=False, id="place")
227
+ yield Label("place under ~/.claude/projects so `claude --resume` finds it")
228
+ yield Label("Project cwd for placement")
229
+ yield Input(value=self.entry.cwd or "", id="place-cwd")
230
+ yield Label("Session id (blank: fresh uuid)")
231
+ yield Input(id="session-id")
232
+ with Horizontal(classes="switch-row"):
233
+ yield Switch(value=False, id="force")
234
+ yield Label("overwrite an existing transcript at that id")
235
+ yield Static("", id="form-errors")
236
+ yield Button("Continue to dry run", variant="primary", id="continue")
237
+ yield Footer()
238
+
239
+ def on_mount(self) -> None:
240
+ self._sync_target(self.initial_target)
241
+
242
+ def on_select_changed(self, event: Select.Changed) -> None:
243
+ if event.select.id == "target":
244
+ self._sync_target(str(event.value))
245
+
246
+ def _sync_target(self, target: str) -> None:
247
+ # Placement is claude-code-only: hide (not just disable) the group so a
248
+ # target flip can't smuggle stale place options into the built command.
249
+ self.query_one("#place-group").display = target == "claude-code"
250
+ # Refresh the output default on target flips, but never clobber a path
251
+ # the user has edited by hand.
252
+ out = self.query_one("#output", Input)
253
+ new_default = default_output_name(self.entry.path, target)
254
+ if out.value == self._auto_output:
255
+ out.value = new_default
256
+ self._auto_output = new_default
257
+
258
+ def _build_options(self) -> ConvertOptions:
259
+ target = str(self.query_one("#target", Select).value)
260
+ placing = (
261
+ target == "claude-code" and self.query_one("#place", Switch).value
262
+ )
263
+ output = self.query_one("#output", Input).value.strip()
264
+ handshake_out = self.query_one("#handshake-out", Input).value.strip()
265
+ session_id = self.query_one("#session-id", Input).value.strip()
266
+ return ConvertOptions(
267
+ source=self.entry.harness,
268
+ target=target,
269
+ path=str(self.entry.path),
270
+ output=output or None,
271
+ no_handshake=not self.query_one("#handshake", Switch).value,
272
+ stub_open_calls=self.query_one("#stub", Switch).value,
273
+ handshake_out=handshake_out or None,
274
+ place_claude_cwd=(
275
+ self.query_one("#place-cwd", Input).value.strip() or None
276
+ )
277
+ if placing
278
+ else None,
279
+ session_id=(session_id or None) if placing else None,
280
+ force=self.query_one("#force", Switch).value if placing else False,
281
+ )
282
+
283
+ def on_input_submitted(self, event: Input.Submitted) -> None:
284
+ # Enter in any form field submits, like a regular form.
285
+ self._continue()
286
+
287
+ def on_button_pressed(self, event: Button.Pressed) -> None:
288
+ if event.button.id == "continue":
289
+ self._continue()
290
+
291
+ def _continue(self) -> None:
292
+ opts = self._build_options()
293
+ errors = validate_options(opts)
294
+ # ConvertOptions can't represent "placement wanted but no cwd" (the CLI
295
+ # drives placement purely off the --place-claude-cwd value), so catch
296
+ # that state here where the switch is visible: silently dropping an
297
+ # explicit placement request would violate the never-silent invariant.
298
+ if (
299
+ self.query_one("#place-group").display
300
+ and self.query_one("#place", Switch).value
301
+ and not opts.place_claude_cwd
302
+ ):
303
+ errors.append(
304
+ "placement is enabled but no project cwd was given — fill in "
305
+ "the cwd or turn the placement switch off"
306
+ )
307
+ if errors:
308
+ self.query_one("#form-errors", Static).update(
309
+ "[b red]" + "\n".join(errors) + "[/]"
310
+ )
311
+ return
312
+ self.app.push_screen(DryRunScreen(opts))
313
+
314
+
315
+ class DryRunScreen(Screen):
316
+ """Run the pure conversion and show everything BEFORE any file is written."""
317
+
318
+ BINDINGS = [("escape", "back", "Back")]
319
+
320
+ def __init__(self, opts: ConvertOptions) -> None:
321
+ super().__init__()
322
+ self.opts = opts
323
+ self.result: ConversionResult | None = None
324
+ self._writing = False
325
+
326
+ def action_back(self) -> None:
327
+ # Popping mid-write would discard the outcome (including any error);
328
+ # the write worker pushes ResultScreen when it finishes.
329
+ if not self._writing:
330
+ self.app.pop_screen()
331
+
332
+ def compose(self) -> ComposeResult:
333
+ yield Header()
334
+ with VerticalScroll():
335
+ yield Static("converting (nothing written yet)…", id="dryrun-body")
336
+ with Horizontal(id="dryrun-buttons"):
337
+ yield Button("Write files", variant="success", id="write", disabled=True)
338
+ yield Button("Cancel", id="cancel")
339
+ yield Footer()
340
+
341
+ def on_mount(self) -> None:
342
+ self._convert()
343
+
344
+ @work(thread=True, exclusive=True)
345
+ def _convert(self) -> None:
346
+ try:
347
+ result = run_conversion(self.opts)
348
+ except Exception as exc:
349
+ self.app.call_from_thread(self._show_error, exc)
350
+ return
351
+ self.app.call_from_thread(self._render_dryrun, result)
352
+
353
+ def _show_error(self, exc: Exception) -> None:
354
+ self.query_one("#dryrun-body", Static).update(
355
+ f"[b red]conversion failed[/]\n\n{escape(str(exc))}\n\nescape to go back"
356
+ )
357
+
358
+ def _render_dryrun(self, result: ConversionResult) -> None:
359
+ self.result = result
360
+ opts = self.opts
361
+ out = resolve_output(opts)
362
+ lines = [
363
+ f"[b]will write[/] {len(result.records)} records -> {escape(out)}",
364
+ ]
365
+ if opts.handshake_out:
366
+ lines.append(f"[b]handshake copy[/] -> {escape(opts.handshake_out)}")
367
+ if opts.place_claude_cwd:
368
+ lines.append(
369
+ f"[b]placement[/] under ~/.claude/projects for cwd "
370
+ f"{escape(opts.place_claude_cwd)}"
371
+ )
372
+ if result.report.warnings:
373
+ lines.append(f"\n[b]{len(result.report.warnings)} conversion note(s):[/]")
374
+ lines.extend(f" [yellow]- {escape(w)}[/]" for w in result.report.warnings)
375
+ else:
376
+ lines.append("\n[green]lossless conversion (no warnings).[/]")
377
+ lines.append("\n[b]equivalent CLI command:[/]")
378
+ lines.append(f" [dim]{escape(build_cli_command(opts))}[/]")
379
+ self.query_one("#dryrun-body", Static).update("\n".join(lines))
380
+ write = self.query_one("#write", Button)
381
+ write.disabled = False
382
+ # While Write was disabled, focus defaulted to Cancel — move it so
383
+ # plain Enter confirms rather than silently cancelling.
384
+ write.focus()
385
+
386
+ def on_button_pressed(self, event: Button.Pressed) -> None:
387
+ if event.button.id == "cancel":
388
+ self.action_back()
389
+ elif event.button.id == "write" and self.result is not None and not self._writing:
390
+ self._writing = True
391
+ self.query_one("#write", Button).disabled = True
392
+ self.query_one("#cancel", Button).disabled = True
393
+ self._write()
394
+
395
+ @work(thread=True, exclusive=True)
396
+ def _write(self) -> None:
397
+ outcome = execute_writes(
398
+ self.result,
399
+ self.opts,
400
+ claude_home=getattr(self.app, "claude_home", None),
401
+ )
402
+ self.app.call_from_thread(self.app.push_screen, ResultScreen(outcome))
403
+
404
+
405
+ class ResultScreen(Screen):
406
+ """Outcome of the writes: paths, resume hint, or a rendered error."""
407
+
408
+ BINDINGS = [
409
+ ("n", "new_conversion", "Another conversion"),
410
+ ("q", "quit", "Quit"),
411
+ ]
412
+
413
+ def __init__(self, outcome) -> None:
414
+ super().__init__()
415
+ self.outcome = outcome
416
+
417
+ def compose(self) -> ComposeResult:
418
+ yield Header()
419
+ with VerticalScroll():
420
+ yield Static(self._body(), id="result-body")
421
+ yield Footer()
422
+
423
+ def _body(self) -> str:
424
+ o = self.outcome
425
+ lines = []
426
+ if o.output_path:
427
+ lines.append(
428
+ f"[green]wrote {o.record_count} records -> {escape(str(o.output_path))}[/]"
429
+ )
430
+ if o.handshake_path:
431
+ lines.append(f"wrote resume handshake -> {escape(str(o.handshake_path))}")
432
+ if o.placed_path:
433
+ lines.append(f"placed resumable session -> {escape(str(o.placed_path))}")
434
+ if o.resume_hint:
435
+ lines.append(f"\n[b]resume with:[/] {escape(o.resume_hint)}")
436
+ if o.error:
437
+ lines.append(f"\n[b red]error:[/] {escape(o.error)}")
438
+ lines.append("\n[dim]n for another conversion, q to quit[/]")
439
+ return "\n".join(lines)
440
+
441
+ def action_new_conversion(self) -> None:
442
+ while not isinstance(self.app.screen, PickerScreen):
443
+ self.app.pop_screen()
444
+
445
+ def action_quit(self) -> None:
446
+ self.app.exit()
447
+
448
+
449
+ class RegisterFormScreen(Screen):
450
+ """Registration options: write the session into a harness's SQLite store."""
451
+
452
+ BINDINGS = [("escape", "app.pop_screen", "Back")]
453
+
454
+ def __init__(self, entry: SessionEntry) -> None:
455
+ super().__init__()
456
+ self.entry = entry
457
+
458
+ def compose(self) -> ComposeResult:
459
+ app_codex_home = getattr(self.app, "codex_home", None)
460
+ yield Header()
461
+ with VerticalScroll():
462
+ yield Label("Target store")
463
+ yield Select(
464
+ [("Hermes (state.db)", "hermes"), ("Codex (state_5.sqlite)", "codex")],
465
+ value="hermes",
466
+ allow_blank=False,
467
+ id="store",
468
+ )
469
+ yield Label("Title (optional)")
470
+ yield Input(placeholder=f"resumed from {self.entry.harness}", id="reg-title")
471
+ yield Label("Session id (blank: generated)")
472
+ yield Input(id="reg-session-id")
473
+ with Horizontal(classes="switch-row"):
474
+ yield Switch(value=False, id="reg-stub")
475
+ yield Label("stub open tool calls (synthetic interrupted results)")
476
+ with Horizontal(classes="switch-row"):
477
+ yield Switch(value=True, id="reg-backup")
478
+ yield Label("back up the store before writing (recommended)")
479
+ with Vertical(id="hermes-group"):
480
+ yield Label("Hermes state.db path (blank: ~/.hermes/state.db)")
481
+ yield Input(id="hermes-db")
482
+ yield Label("Model to store (blank: keep source id — may not route)")
483
+ yield Input(id="hermes-model")
484
+ with Vertical(id="codex-group"):
485
+ yield Label("Project cwd Codex resumes from (required)")
486
+ yield Input(value=self.entry.cwd or "", id="codex-cwd")
487
+ yield Label("Codex home (blank: ~/.codex)")
488
+ yield Input(value=str(app_codex_home) if app_codex_home else "", id="codex-home")
489
+ yield Label("Model (blank: infer most recently used)")
490
+ yield Input(id="codex-model")
491
+ yield Label("Model provider")
492
+ yield Input(value="openai", id="codex-provider")
493
+ yield Static("", id="reg-errors")
494
+ yield Button("Continue to plan", variant="primary", id="reg-continue")
495
+ yield Footer()
496
+
497
+ def on_mount(self) -> None:
498
+ self._sync_store("hermes")
499
+
500
+ def on_select_changed(self, event: Select.Changed) -> None:
501
+ if event.select.id == "store":
502
+ self._sync_store(str(event.value))
503
+
504
+ def _sync_store(self, store: str) -> None:
505
+ self.query_one("#hermes-group").display = store == "hermes"
506
+ self.query_one("#codex-group").display = store == "codex"
507
+
508
+ def on_input_submitted(self, event: Input.Submitted) -> None:
509
+ # Enter in any form field submits, mirroring OptionsScreen.
510
+ self._continue()
511
+
512
+ def on_button_pressed(self, event: Button.Pressed) -> None:
513
+ if event.button.id == "reg-continue":
514
+ self._continue()
515
+
516
+ def _continue(self) -> None:
517
+ from .register import CodexRegisterOptions, HermesRegisterOptions
518
+
519
+ store = str(self.query_one("#store", Select).value)
520
+ title = self.query_one("#reg-title", Input).value.strip() or None
521
+ session_id = self.query_one("#reg-session-id", Input).value.strip() or None
522
+ stub = self.query_one("#reg-stub", Switch).value
523
+ no_backup = not self.query_one("#reg-backup", Switch).value
524
+ if store == "hermes":
525
+ opts = HermesRegisterOptions(
526
+ source=self.entry.harness,
527
+ path=str(self.entry.path),
528
+ db=self.query_one("#hermes-db", Input).value.strip() or None,
529
+ model=self.query_one("#hermes-model", Input).value.strip() or None,
530
+ title=title,
531
+ session_id=session_id,
532
+ no_backup=no_backup,
533
+ stub_open_calls=stub,
534
+ )
535
+ else:
536
+ cwd = self.query_one("#codex-cwd", Input).value.strip()
537
+ if not cwd:
538
+ self.query_one("#reg-errors", Static).update(
539
+ "[b red]Codex registration needs the project cwd it will "
540
+ "resume from[/]"
541
+ )
542
+ return
543
+ opts = CodexRegisterOptions(
544
+ source=self.entry.harness,
545
+ path=str(self.entry.path),
546
+ cwd=cwd,
547
+ codex_home=self.query_one("#codex-home", Input).value.strip() or None,
548
+ title=title,
549
+ model=self.query_one("#codex-model", Input).value.strip() or None,
550
+ model_provider=self.query_one("#codex-provider", Input).value.strip()
551
+ or "openai",
552
+ session_id=session_id,
553
+ no_backup=no_backup,
554
+ stub_open_calls=stub,
555
+ )
556
+ self.app.push_screen(RegisterPlanScreen(store, opts))
557
+
558
+
559
+ class RegisterPlanScreen(Screen):
560
+ """Everything the registration will do, shown BEFORE the SQLite mutation."""
561
+
562
+ BINDINGS = [("escape", "back", "Back")]
563
+
564
+ def __init__(self, store: str, opts) -> None:
565
+ super().__init__()
566
+ self.store = store
567
+ self.opts = opts
568
+ self.plan = None
569
+ self._writing = False
570
+
571
+ def action_back(self) -> None:
572
+ if not self._writing:
573
+ self.app.pop_screen()
574
+
575
+ def compose(self) -> ComposeResult:
576
+ yield Header()
577
+ with VerticalScroll():
578
+ yield Static("planning registration (nothing written yet)…", id="plan-body")
579
+ with Horizontal(id="plan-buttons"):
580
+ yield Button("Register", variant="success", id="register", disabled=True)
581
+ yield Button("Cancel", id="plan-cancel")
582
+ yield Footer()
583
+
584
+ def on_mount(self) -> None:
585
+ self._prepare()
586
+
587
+ @work(thread=True, exclusive=True)
588
+ def _prepare(self) -> None:
589
+ from .register import RegisterPlan, prepare_codex_register, prepare_hermes_register
590
+
591
+ try:
592
+ if self.store == "hermes":
593
+ plan = prepare_hermes_register(
594
+ self.opts, hermes_home=getattr(self.app, "hermes_home", None)
595
+ )
596
+ else:
597
+ plan = prepare_codex_register(
598
+ self.opts, codex_home=getattr(self.app, "codex_home", None)
599
+ )
600
+ except Exception as exc:
601
+ # prepare_* promise never to raise; if that regresses, render the
602
+ # failure rather than crash the terminal.
603
+ plan = RegisterPlan(
604
+ store=self.store, session=None, warnings=[], notes=[],
605
+ session_id=None, db_path=None, model=None, cli_command="",
606
+ opts=self.opts, error=str(exc),
607
+ )
608
+ self.app.call_from_thread(self._render_plan, plan)
609
+
610
+ def _render_plan(self, plan) -> None:
611
+ self.plan = plan
612
+ body = self.query_one("#plan-body", Static)
613
+ lines = []
614
+ if plan.error:
615
+ # Error plans still carry the computed loss disclosure — show it,
616
+ # like the CLI prints its conversion notes before failing.
617
+ lines.append(f"[b red]cannot register[/]\n\n{escape(plan.error)}\n")
618
+ else:
619
+ lines += [
620
+ f"[b]store[/] {self.store} -> {escape(str(plan.db_path))}",
621
+ f"[b]session id[/] {escape(str(plan.session_id))}",
622
+ f"[b]model[/] {escape(plan.model or '(source model id)')}",
623
+ "[b]backup[/] "
624
+ + ("taken before writing" if not self.opts.no_backup else "[yellow]DISABLED[/]"),
625
+ ]
626
+ if plan.warnings:
627
+ lines.append(f"\n[b]{len(plan.warnings)} conversion note(s):[/]")
628
+ lines.extend(f" [yellow]- {escape(w)}[/]" for w in plan.warnings)
629
+ elif not plan.error:
630
+ lines.append("\n[green]lossless registration (no warnings).[/]")
631
+ for note in plan.notes:
632
+ lines.append(f"[dim]note: {escape(note)}[/]")
633
+ if plan.cli_command:
634
+ lines.append("\n[b]equivalent CLI command:[/]")
635
+ lines.append(f" [dim]{escape(plan.cli_command)}[/]")
636
+ if plan.error:
637
+ lines.append("\n[dim]escape to go back[/]")
638
+ body.update("\n".join(lines))
639
+ if not plan.error:
640
+ register = self.query_one("#register", Button)
641
+ register.disabled = False
642
+ # Same focus handoff as DryRunScreen: Enter should confirm, not
643
+ # hit the Cancel button that had default focus while disabled.
644
+ register.focus()
645
+
646
+ def on_button_pressed(self, event: Button.Pressed) -> None:
647
+ if event.button.id == "plan-cancel":
648
+ self.action_back()
649
+ elif (
650
+ event.button.id == "register"
651
+ and self.plan is not None
652
+ and self.plan.error is None
653
+ and not self._writing
654
+ ):
655
+ self._writing = True
656
+ self.query_one("#register", Button).disabled = True
657
+ self.query_one("#plan-cancel", Button).disabled = True
658
+ self._execute()
659
+
660
+ @work(thread=True, exclusive=True)
661
+ def _execute(self) -> None:
662
+ from .register import execute_register
663
+
664
+ outcome = execute_register(self.plan)
665
+ self.app.call_from_thread(self.app.push_screen, RegisterResultScreen(outcome))
666
+
667
+
668
+ class RegisterResultScreen(Screen):
669
+ """Outcome of the registration: rows written, backup path, resume hint."""
670
+
671
+ BINDINGS = [
672
+ ("n", "new_conversion", "Another session"),
673
+ ("q", "quit", "Quit"),
674
+ ]
675
+
676
+ def __init__(self, outcome) -> None:
677
+ super().__init__()
678
+ self.outcome = outcome
679
+
680
+ def compose(self) -> ComposeResult:
681
+ yield Header()
682
+ with VerticalScroll():
683
+ yield Static(self._body(), id="reg-result-body")
684
+ yield Footer()
685
+
686
+ def _body(self) -> str:
687
+ o = self.outcome
688
+ lines = []
689
+ if o.backup_path:
690
+ lines.append(f"backed up store -> {escape(str(o.backup_path))}")
691
+ if o.error:
692
+ lines.append(f"\n[b red]registration failed:[/] {escape(o.error)}")
693
+ else:
694
+ lines.append(
695
+ f"[green]registered session {escape(str(o.session_id))} "
696
+ f"into {escape(str(o.db_path))}[/]"
697
+ )
698
+ if o.rollout_path:
699
+ lines.append(f"wrote Codex rollout -> {escape(str(o.rollout_path))}")
700
+ if o.resume_hint:
701
+ lines.append(f"\n[b]resume with:[/] {escape(o.resume_hint)}")
702
+ lines.append("\n[dim]n for another session, q to quit[/]")
703
+ return "\n".join(lines)
704
+
705
+ def action_new_conversion(self) -> None:
706
+ while not isinstance(self.app.screen, PickerScreen):
707
+ self.app.pop_screen()
708
+
709
+ def action_quit(self) -> None:
710
+ self.app.exit()