expluro 0.3.0__tar.gz → 0.3.1__tar.gz

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 (30) hide show
  1. {expluro-0.3.0 → expluro-0.3.1}/PKG-INFO +1 -1
  2. {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/PKG-INFO +1 -1
  3. {expluro-0.3.0 → expluro-0.3.1}/pyproject.toml +1 -1
  4. {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/_branch_detection.py +47 -0
  5. {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/acp.py +171 -47
  6. {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/claude.py +55 -19
  7. expluro-0.3.1/topo/drivers/kiro_session_files.py +277 -0
  8. {expluro-0.3.0 → expluro-0.3.1}/topo/main.py +7 -2
  9. {expluro-0.3.0 → expluro-0.3.1}/topo/session.py +74 -24
  10. expluro-0.3.0/topo/drivers/kiro_session_files.py +0 -167
  11. {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/SOURCES.txt +0 -0
  12. {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/dependency_links.txt +0 -0
  13. {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/entry_points.txt +0 -0
  14. {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/requires.txt +0 -0
  15. {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/top_level.txt +0 -0
  16. {expluro-0.3.0 → expluro-0.3.1}/setup.cfg +0 -0
  17. {expluro-0.3.0 → expluro-0.3.1}/topo/__init__.py +0 -0
  18. {expluro-0.3.0 → expluro-0.3.1}/topo/auth.py +0 -0
  19. {expluro-0.3.0 → expluro-0.3.1}/topo/backend.py +0 -0
  20. {expluro-0.3.0 → expluro-0.3.1}/topo/capture.py +0 -0
  21. {expluro-0.3.0 → expluro-0.3.1}/topo/constants.py +0 -0
  22. {expluro-0.3.0 → expluro-0.3.1}/topo/display.py +0 -0
  23. {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/__init__.py +0 -0
  24. {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/_permissions.py +0 -0
  25. {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/base.py +0 -0
  26. {expluro-0.3.0 → expluro-0.3.1}/topo/picker.py +0 -0
  27. {expluro-0.3.0 → expluro-0.3.1}/topo/renderer.py +0 -0
  28. {expluro-0.3.0 → expluro-0.3.1}/topo/session_files.py +0 -0
  29. {expluro-0.3.0 → expluro-0.3.1}/topo/snapshot.py +0 -0
  30. {expluro-0.3.0 → expluro-0.3.1}/topo/update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Expluro CLI — graph layer on top of your AI agent
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: claude-agent-sdk>=0.0.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Expluro CLI — graph layer on top of your AI agent
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: claude-agent-sdk>=0.0.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "expluro"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Expluro CLI — graph layer on top of your AI agent"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -97,3 +97,50 @@ def parse_branch_flag(buffered_head: str) -> tuple[float | None, str]:
97
97
  except ValueError:
98
98
  return None, buffered_head[m.end():]
99
99
  return confidence, buffered_head[m.end():]
100
+
101
+
102
+ def strip_all_branch_tags(text: str) -> tuple[float | None, str]:
103
+ """Strip every leading BRANCH tag from `text` and return (confidence, rest).
104
+
105
+ Defensive against three model misbehaviours, in one place so every
106
+ driver inherits the same protection:
107
+
108
+ - **Multiple stacked tags** — e.g. `<!--BRANCH:0.0--><!--BRANCH:0.5-->Hi`.
109
+ We loop `parse_branch_flag` and strip them all; the *first*
110
+ confidence wins (subsequent tags are usually a model emitting
111
+ the marker more than once, not actually multiple decisions).
112
+
113
+ - **Partial tag at end** — e.g. `<!--BRANCH:0.5` with no closing
114
+ `-->`. Happens when a tool call interrupts a tag stream. We drop
115
+ the partial-tag bytes so they don't leak into rendered text.
116
+
117
+ - **Malformed digits** — handled inside `parse_branch_flag`
118
+ (the tag is consumed silently, no confidence reported).
119
+
120
+ Returns the first valid confidence found (or None if no parseable
121
+ tag was at the head), and the remaining text after all leading tags
122
+ are stripped (or empty string if the remaining content was a
123
+ partial-tag prefix).
124
+ """
125
+ first_confidence: float | None = None
126
+ buf = text
127
+ while True:
128
+ confidence, rest = parse_branch_flag(buf)
129
+ if confidence is None and rest == buf:
130
+ # No tag matched at all — buf is plain content.
131
+ break
132
+ # A tag was consumed (confidence may still be None if the tag's
133
+ # digits were malformed). Capture the first valid confidence.
134
+ if first_confidence is None and confidence is not None:
135
+ first_confidence = confidence
136
+ buf = rest
137
+ if not buf:
138
+ break
139
+
140
+ # If the buffer is now a partial tag prefix (no closing `-->`),
141
+ # the model started a tag we never saw the end of. Drop it
142
+ # rather than leak `<!--BRANCH:0.5` to the renderer.
143
+ if buf.startswith("<!--BRANCH:") and "-->" not in buf:
144
+ buf = ""
145
+
146
+ return first_confidence, buf
@@ -48,6 +48,7 @@ from ._branch_detection import (
48
48
  BRANCH_FLAG_START_RE,
49
49
  HEAD_BUFFER_MAX,
50
50
  parse_branch_flag,
51
+ strip_all_branch_tags,
51
52
  )
52
53
  from ._permissions import (
53
54
  PermissionDecision,
@@ -97,6 +98,27 @@ class ACPDriver(BaseDriver):
97
98
  self._event_queue: asyncio.Queue[DriverEvent | object] = asyncio.Queue()
98
99
  # Tracks tool calls in flight so we can fire pre/post hooks symmetrically.
99
100
  self._active_tool_calls: dict[str, ToolHookPayload] = {}
101
+ # Every tool_call_id we've ever surfaced as a tool_use event in this
102
+ # driver's lifetime. After an auto-branch the agent reconnects via
103
+ # load_session, and Kiro then re-emits ToolCallStart for tool calls
104
+ # it already ran in the parent — a "synchronization replay." Without
105
+ # deduping, those land in the new node as phantom tool calls (often
106
+ # stuck on "running…" because their Progress event is also a replay
107
+ # the browser can't reconcile). The set lives for the driver's full
108
+ # lifetime intentionally: replays after subsequent reconnects must
109
+ # also be filtered. tool_call_ids are random, so collision with a
110
+ # genuinely new call is effectively impossible.
111
+ self._seen_tool_call_ids: set[str] = set()
112
+ # Per-turn assistant text accumulator. ACP doesn't have a protocol-
113
+ # level "message complete" notification — it just streams chunks
114
+ # and finishes the prompt. Without an end-of-turn text_complete
115
+ # event the renderer's rich.Live never flushes (so the terminal
116
+ # stays blank) and capture's message_ai persistence falls back to
117
+ # raw chunks (so the browser shows orphan unselectable text outside
118
+ # any message bubble). We accumulate all text we emit during a turn
119
+ # and surface it as a single text_complete in _run_prompt's finalize
120
+ # block, matching the Claude driver's behavior.
121
+ self._turn_text_buffer: str = ""
100
122
  # Set by reset_context() before connect() to ask connect() to load
101
123
  # an existing session via session/load_session instead of creating a
102
124
  # fresh one. Mirrors ClaudeDriver's _resume_session_id pattern.
@@ -138,6 +160,15 @@ class ACPDriver(BaseDriver):
138
160
  logger.debug("ACPDriver: spawning agent: %s", " ".join(self._command))
139
161
  binary = self._command[0] if self._command else ""
140
162
  try:
163
+ # `limit` raises asyncio's per-line buffer for the StreamReader
164
+ # the SDK reads line-delimited JSON-RPC from. Default is 64 KB,
165
+ # which Kiro can blow past in a single AgentMessageChunk for
166
+ # long-form answers or large tool outputs. When that happens
167
+ # asyncio raises LimitOverrunError ("Separator is not found,
168
+ # and chunk exceed the limit"), the SDK reader dies, and the
169
+ # session hangs on the next prompt with no recovery path.
170
+ # 4 MB covers any reasonable agent message; we'd rather pay a
171
+ # few MB of buffer than break the session on a long answer.
141
172
  self._proc = await asyncio.create_subprocess_exec(
142
173
  *self._command,
143
174
  stdin=asyncio.subprocess.PIPE,
@@ -147,6 +178,7 @@ class ACPDriver(BaseDriver):
147
178
  cwd=cwd,
148
179
  env=os.environ.copy(),
149
180
  start_new_session=True,
181
+ limit=4 * 1024 * 1024,
150
182
  )
151
183
  except FileNotFoundError as exc:
152
184
  # The picker filters by shutil.which, but binaries can be
@@ -380,6 +412,7 @@ class ACPDriver(BaseDriver):
380
412
  self._head_buf = ""
381
413
  self._head_parsed = False
382
414
  self._branch_signal_fired_this_turn = False
415
+ self._turn_text_buffer = ""
383
416
 
384
417
  # Fire-and-forget the prompt as a background task. The connection's
385
418
  # `prompt()` returns a PromptResponse when the agent finishes. We
@@ -411,6 +444,22 @@ class ACPDriver(BaseDriver):
411
444
  # streaming and would otherwise be lost when the turn ends.
412
445
  assert self._session_id is not None
413
446
  await self._flush_head_buffer(self._session_id)
447
+ # Emit text_complete with the assembled assistant text. The
448
+ # renderer needs this to flush its rich.Live buffer (otherwise
449
+ # short responses stay invisible in the terminal); capture
450
+ # needs it to persist a clean message_ai event (otherwise the
451
+ # browser shows orphan unselectable text outside any message
452
+ # bubble). Note: this fires AFTER _flush_head_buffer above so
453
+ # any tail-of-turn text the head buffer was holding is included.
454
+ if self._turn_text_buffer:
455
+ await self._event_queue.put(
456
+ DriverEvent(
457
+ type="text_complete",
458
+ data={"text": self._turn_text_buffer, "session_id": self._session_id},
459
+ raw=None,
460
+ )
461
+ )
462
+ self._turn_text_buffer = ""
414
463
  # Emit a result event capturing usage if present, then end-of-turn.
415
464
  data: dict[str, Any] = {}
416
465
  if response is not None:
@@ -440,6 +489,16 @@ class ACPDriver(BaseDriver):
440
489
  # ------------------------------------------------------------------
441
490
 
442
491
  async def _emit(self, event: DriverEvent) -> None:
492
+ # Accumulate every text chunk we emit during this turn so the
493
+ # finalize block of _run_prompt can surface it as a text_complete
494
+ # event. The renderer relies on text_complete to flush its
495
+ # rich.Live; capture relies on it to persist message_ai. Without
496
+ # this, both surfaces show the assistant text incorrectly (blank
497
+ # terminal, orphan-text browser bubble).
498
+ if event.type == "text":
499
+ chunk = event.data.get("text", "")
500
+ if chunk:
501
+ self._turn_text_buffer += chunk
443
502
  await self._event_queue.put(event)
444
503
 
445
504
  async def _handle_text_chunk(self, text: str, session_id: str, raw: Any) -> None:
@@ -478,15 +537,9 @@ class ACPDriver(BaseDriver):
478
537
  short post-tool text response (e.g. "Your username is yab"
479
538
  with no newline) doesn't get stuck in the buffer and lost.
480
539
 
481
- Defensive against three model misbehaviours:
482
- - Multiple stacked tags (`<!--BRANCH:0.0--><!--BRANCH:0.5-->Hi`):
483
- strip ALL leading tags, not just the first, so none leak.
484
- - Partial tag at flush time (`<!--BRANCH:0.5` with no `-->`):
485
- happens if a tool call interrupts mid-tag-stream. Drop the
486
- partial-tag bytes rather than emitting them as visible text.
487
- - Malformed digits (`<!--BRANCH:.-->`): handled inside
488
- parse_branch_flag — the tag is consumed, no confidence
489
- recorded.
540
+ Multi-tag, partial-tag, and malformed-digit defenses live in
541
+ `strip_all_branch_tags` so the Claude driver gets the same
542
+ behaviour for free.
490
543
 
491
544
  Caller is responsible for resetting `_head_parsed` after this if
492
545
  appropriate. We deliberately do not reset state here so the
@@ -494,31 +547,9 @@ class ACPDriver(BaseDriver):
494
547
  """
495
548
  if not self._head_buf:
496
549
  return
497
- buf = self._head_buf
550
+ first_confidence, buf = strip_all_branch_tags(self._head_buf)
498
551
  self._head_buf = ""
499
552
 
500
- first_confidence: float | None = None
501
- # Strip every leading tag. Defensive against double-emission.
502
- while True:
503
- confidence, rest = parse_branch_flag(buf)
504
- if confidence is None and rest == buf:
505
- # No tag matched at all — buf is plain content.
506
- break
507
- # A tag was consumed (confidence may still be None if the
508
- # tag's digits were malformed). Capture the first valid
509
- # confidence we see.
510
- if first_confidence is None and confidence is not None:
511
- first_confidence = confidence
512
- buf = rest
513
- if not buf:
514
- break
515
-
516
- # If the buffer is now a partial tag prefix (no closing `-->`),
517
- # the model started a tag we never saw the end of. Drop it
518
- # rather than leak `<!--BRANCH:0.5` to the renderer.
519
- if buf.startswith("<!--BRANCH:") and "-->" not in buf:
520
- buf = ""
521
-
522
553
  if first_confidence is not None and not self._branch_signal_fired_this_turn:
523
554
  self._branch_signal_fired_this_turn = True
524
555
  await self._emit(
@@ -701,6 +732,34 @@ class _ACPClient(Client):
701
732
  tool_use_id = getattr(update, "tool_call_id", "") or getattr(update, "id", "")
702
733
  tool_kind = getattr(update, "kind", None)
703
734
  tool_input = _extract_tool_input(update)
735
+ # INFO so this is captured in cli.log at the default level.
736
+ # 1–3 entries per turn; the most useful diagnostic data we
737
+ # have for ACP-side bugs (duplicate tool calls, status
738
+ # mismatches, etc.).
739
+ logger.info(
740
+ "ACPDriver: ToolCallStart id=%s kind=%s status=%s title=%r",
741
+ tool_use_id,
742
+ tool_kind,
743
+ getattr(update, "status", None),
744
+ tool_name,
745
+ )
746
+ # Replay guard: after an auto-branch reconnects via load_session,
747
+ # Kiro re-emits ToolCallStart for tool calls it ran in the parent.
748
+ # We've already surfaced those as tool_use events on the parent
749
+ # node, so re-surfacing them on the child creates phantom entries
750
+ # (the title degrades to just `kind` — e.g. 'shell' — and the
751
+ # corresponding Progress also replays, leaving a tool_use with
752
+ # no real result the user can act on). Skip silently if we've
753
+ # already seen this id.
754
+ if tool_use_id and tool_use_id in self._driver._seen_tool_call_ids:
755
+ logger.info(
756
+ "ACPDriver: ToolCallStart REPLAY id=%s skipped "
757
+ "(already surfaced earlier in this driver lifetime)",
758
+ tool_use_id,
759
+ )
760
+ return
761
+ if tool_use_id:
762
+ self._driver._seen_tool_call_ids.add(tool_use_id)
704
763
  payload = ToolHookPayload(
705
764
  tool_name=tool_name,
706
765
  tool_input=tool_input,
@@ -752,6 +811,24 @@ class _ACPClient(Client):
752
811
  if isinstance(update, ToolCallProgress):
753
812
  tool_use_id = getattr(update, "tool_call_id", "") or getattr(update, "id", "")
754
813
  status = getattr(update, "status", None)
814
+ logger.info(
815
+ "ACPDriver: ToolCallProgress id=%s status=%s",
816
+ tool_use_id, status,
817
+ )
818
+ # Replay guard companion to the one in ToolCallStart: if the
819
+ # corresponding Start was skipped as a replay, _active_tool_calls
820
+ # has no entry for this id and emitting tool_result here would
821
+ # surface the phantom completion to the browser even though we
822
+ # never surfaced the start. Bail unless this id has an active
823
+ # in-flight payload that THIS driver registered.
824
+ if tool_use_id and tool_use_id not in self._driver._active_tool_calls:
825
+ logger.info(
826
+ "ACPDriver: ToolCallProgress REPLAY id=%s skipped "
827
+ "(no matching in-flight tool_use; either replay or "
828
+ "Progress arrived after pop)",
829
+ tool_use_id,
830
+ )
831
+ return
755
832
  # ToolCallStatus values include "in_progress", "completed", "failed".
756
833
  if status is not None and str(status).lower() in ("completed", "failed"):
757
834
  payload = self._driver._active_tool_calls.pop(tool_use_id, None)
@@ -801,26 +878,32 @@ class _ACPClient(Client):
801
878
  options = kwargs.get("options") or []
802
879
  tool_call = kwargs.get("tool_call")
803
880
 
804
- # Pull tool name + summary out of the ACP tool_call. Kiro fills `title`
805
- # with a human-readable description (e.g. "Run command: ls -la"); when
806
- # absent we fall back to `kind` ('execute', 'edit', etc.).
807
- tool_name = (
808
- getattr(tool_call, "title", None)
809
- or getattr(tool_call, "kind", None)
810
- or "tool"
811
- )
812
- # Kiro's title already encodes the action, so we leave summary blank
813
- # to avoid duplicating the same text twice in the picker header.
814
- summary = ""
815
-
816
- # Fast path: prior "always allow this tool" or "bypass all" decision.
881
+ # Use `kind` as the trust-keying identifier, not `title`. `title` is
882
+ # the per-invocation description ("Run command: cat /etc/hosts 2>&1")
883
+ # — keying always-allow on it means the user has to re-approve every
884
+ # unique command. `kind` is the category ("execute", "read", "edit"),
885
+ # so "Always allow execute" actually applies to the next shell
886
+ # command. Mirrors how the Claude driver keys on the Anthropic tool
887
+ # name ("Bash", "Read"). Closes #55.
888
+ #
889
+ # The verbose title moves into `summary`, which the picker shows
890
+ # underneath the kind label so the user still sees what's actually
891
+ # about to run before approving.
892
+ tool_kind = getattr(tool_call, "kind", None) or "tool"
893
+ title = getattr(tool_call, "title", "") or ""
894
+ raw_input = getattr(tool_call, "raw_input", None)
895
+ if not isinstance(raw_input, dict):
896
+ raw_input = {}
897
+ summary = _kiro_tool_summary(tool_kind, raw_input, title)
898
+
899
+ # Fast path: prior "always allow this kind" or "bypass all" decision.
817
900
  state = self._driver._permission_state
818
- if state.fast_allow(tool_name):
901
+ if state.fast_allow(tool_kind):
819
902
  return _select_first_allow(options) or _denied_response()
820
903
 
821
904
  # Show the picker.
822
905
  decision = await ask_user_for_permission(
823
- tool_name=tool_name,
906
+ tool_name=tool_kind,
824
907
  summary=summary,
825
908
  state=state,
826
909
  )
@@ -890,6 +973,47 @@ class _ACPClient(Client):
890
973
  logger.exception("ACPDriver: failed to emit write_text_file error event")
891
974
 
892
975
 
976
+ def _kiro_tool_summary(kind: str, raw_input: dict[str, Any], title: str) -> str:
977
+ """One-line summary for the permission picker, derived from the tool kind.
978
+
979
+ Mirrors `_tool_input_summary` in claude.py — show the action's most
980
+ relevant parameter (the command, the path, the query) underneath the
981
+ kind label so the user knows exactly what they're approving without
982
+ needing the verbose Kiro title.
983
+
984
+ Falls back to the title (with the noisy "Run command: " prefix Kiro
985
+ adds for shell commands stripped) when raw_input doesn't contain a
986
+ field we recognize. Truncates to 120 chars to keep the picker compact.
987
+ """
988
+ text: str = ""
989
+ if kind == "execute":
990
+ text = str(raw_input.get("command", "") or raw_input.get("input", "") or "")
991
+ elif kind in ("read", "edit", "delete", "move"):
992
+ text = str(raw_input.get("path", "") or raw_input.get("file_path", "") or "")
993
+ elif kind == "search":
994
+ text = str(raw_input.get("pattern", "") or raw_input.get("query", "") or "")
995
+ elif kind == "fetch":
996
+ text = str(raw_input.get("url", "") or "")
997
+
998
+ if not text:
999
+ # Fallback: try the title, stripping the noisy "Run command: "
1000
+ # prefix Kiro adds for shell commands so it reads like a normal
1001
+ # command line.
1002
+ cleaned_title = title
1003
+ if cleaned_title.startswith("Run command: "):
1004
+ cleaned_title = cleaned_title[len("Run command: "):]
1005
+ text = cleaned_title
1006
+
1007
+ if not text:
1008
+ # Last resort: first stringy value in raw_input.
1009
+ for v in raw_input.values():
1010
+ if isinstance(v, str) and v:
1011
+ text = v
1012
+ break
1013
+
1014
+ return text[:120] + ("…" if len(text) > 120 else "")
1015
+
1016
+
893
1017
  def _extract_tool_input(update: Any) -> dict[str, Any]:
894
1018
  """
895
1019
  Build a tool_input dict from an ACP ToolCall(Start|Update).
@@ -53,7 +53,8 @@ from ._branch_detection import (
53
53
  BRANCH_DETECTION_INSTRUCTION as _BRANCH_DETECTION_INSTRUCTION,
54
54
  BRANCH_FLAG_START_RE as _BRANCH_FLAG_START_RE,
55
55
  HEAD_BUFFER_MAX as _HEAD_BUFFER_MAX,
56
- parse_branch_flag as _parse_branch_flag, # noqa: F401 (kept for symmetry; existing logic uses regex directly)
56
+ parse_branch_flag as _parse_branch_flag, # noqa: F401 (kept for symmetry)
57
+ strip_all_branch_tags as _strip_all_branch_tags,
57
58
  )
58
59
 
59
60
 
@@ -75,6 +76,15 @@ class ClaudeDriver(BaseDriver):
75
76
  # Branch signal head buffer — accumulates first line to parse the flag before streaming
76
77
  self._head_buf: str = ""
77
78
  self._head_parsed: bool = False
79
+ # Defensive replay-guard, mirroring ACPDriver. The Claude Agent SDK
80
+ # likely doesn't re-emit ToolUseBlocks after --resume (subsequent
81
+ # receive_response() returns NEW messages, not historical), but
82
+ # tool_use_ids are unique enough that collision with a real new call
83
+ # is impossible — so adding the guard is free insurance against any
84
+ # future SDK change or edge case we haven't observed. If this set
85
+ # ever fires (logged at INFO), we'll know Claude has the same
86
+ # phantom-tool-call bug we fixed for ACP and can act accordingly.
87
+ self._seen_tool_call_ids: set[str] = set()
78
88
 
79
89
  # ------------------------------------------------------------------
80
90
  # Lifecycle
@@ -346,39 +356,37 @@ class ClaudeDriver(BaseDriver):
346
356
  if ready:
347
357
  self._head_parsed = True
348
358
  logger.debug("Head buffer resolved: %r", self._head_buf[:60])
349
- m = _BRANCH_FLAG_START_RE.match(self._head_buf)
350
- if m:
351
- confidence = float(m.group(1))
352
- rest = self._head_buf[m.end():]
359
+ # Strip every leading BRANCH tag (defensive against
360
+ # double-emission, partial tags from interrupted
361
+ # streams, and malformed digits) — same logic the
362
+ # ACP driver uses, factored into the shared helper.
363
+ confidence, rest = _strip_all_branch_tags(self._head_buf)
364
+ self._head_buf = ""
365
+ if confidence is not None:
353
366
  events.append(DriverEvent(
354
367
  type="branch_signal",
355
368
  data={"confidence": confidence, "session_id": msg.session_id},
356
369
  raw=msg,
357
370
  ))
358
- if rest:
359
- events.append(DriverEvent(
360
- type="text",
361
- data={"text": rest, "session_id": msg.session_id},
362
- raw=msg,
363
- ))
364
- else:
365
- # No flag — flush buffered head as regular text.
371
+ if rest:
366
372
  events.append(DriverEvent(
367
373
  type="text",
368
- data={"text": self._head_buf, "session_id": msg.session_id},
374
+ data={"text": rest, "session_id": msg.session_id},
369
375
  raw=msg,
370
376
  ))
371
- self._head_buf = ""
372
377
  return events
373
378
 
374
379
  if isinstance(msg, AssistantMessage):
375
380
  for block in msg.content:
376
381
  if isinstance(block, TextBlock):
377
382
  text = block.text
378
- # Strip flag from start for clean persistence — signal already
379
- # emitted during streaming so don't emit it again here.
380
- m = _BRANCH_FLAG_START_RE.match(text)
381
- clean_text = text[m.end():] if m else text
383
+ # Strip every leading BRANCH tag for clean persistence.
384
+ # Confidence is discarded here — the streaming path
385
+ # already emitted branch_signal during head-buffer
386
+ # resolution; emitting again would double-fire the
387
+ # auto-branch handler. Same multi-tag, partial-tag,
388
+ # malformed-digit defenses as the ACP driver.
389
+ _, clean_text = _strip_all_branch_tags(text)
382
390
  self._head_buf = ""
383
391
  self._head_parsed = False
384
392
  events.append(DriverEvent(
@@ -393,6 +401,30 @@ class ClaudeDriver(BaseDriver):
393
401
  raw=msg,
394
402
  ))
395
403
  elif isinstance(block, ToolUseBlock):
404
+ # INFO-level so cli.log captures the tool-call lifecycle
405
+ # at the default log level — matches ACPDriver. Cheap
406
+ # diagnostic data: 1–3 entries per turn, the most useful
407
+ # signal we have when a tool flow goes sideways.
408
+ logger.info(
409
+ "ClaudeDriver: ToolUse name=%s id=%s",
410
+ block.name, block.id,
411
+ )
412
+ # Replay-guard mirroring the ACP path. If the SDK ever
413
+ # re-emits a ToolUseBlock for an id we've already
414
+ # surfaced (e.g. after --resume on a forked session),
415
+ # we'd otherwise emit a phantom tool_use the browser
416
+ # can't reconcile. Skip the duplicate. If this branch
417
+ # ever fires (visible in cli.log), we'll know Claude
418
+ # has the same replay behaviour we patched for Kiro.
419
+ if block.id and block.id in self._seen_tool_call_ids:
420
+ logger.info(
421
+ "ClaudeDriver: ToolUse REPLAY id=%s skipped "
422
+ "(already surfaced earlier in this driver lifetime)",
423
+ block.id,
424
+ )
425
+ continue
426
+ if block.id:
427
+ self._seen_tool_call_ids.add(block.id)
396
428
  events.append(DriverEvent(
397
429
  type="tool_use",
398
430
  data={
@@ -404,6 +436,10 @@ class ClaudeDriver(BaseDriver):
404
436
  raw=msg,
405
437
  ))
406
438
  elif isinstance(block, ToolResultBlock):
439
+ logger.info(
440
+ "ClaudeDriver: ToolResult tool_use_id=%s",
441
+ block.tool_use_id,
442
+ )
407
443
  events.append(DriverEvent(
408
444
  type="tool_result",
409
445
  data={
@@ -0,0 +1,277 @@
1
+ """
2
+ Kiro session file forking.
3
+
4
+ Kiro's ACP server does not implement `session/fork_session` (advertised
5
+ fork capability is null; calling the method returns "Method not found").
6
+ But it does support `session/load_session`, and it accepts session files
7
+ written manually to ~/.kiro/sessions/cli/<uuid>.jsonl + .json.
8
+
9
+ So we fork the same way the Claude driver does: copy the parent's session
10
+ files to a new UUID, truncate to the branch point, ask Kiro to load_session
11
+ the new UUID. The agent treats it as a real session.
12
+
13
+ File layout (verified empirically):
14
+ - <uuid>.jsonl: one event per line. Two kinds: Prompt and AssistantMessage.
15
+ Each carries message_id, content blocks, optional meta.timestamp.
16
+ - <uuid>.json: metadata. Contains session_id, cwd, title, session_state with
17
+ per-turn telemetry (user_turn_metadatas). Kiro accepts copied/truncated
18
+ metadata even though some fields like input_token_count, metering_usage
19
+ reflect the parent's values.
20
+ - <uuid>.lock: process lock. Created by the agent process on open. We do
21
+ NOT plant a lock file; Kiro creates one when it opens the session.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import time
28
+ import uuid
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ KIRO_SESSIONS_DIR = Path.home() / ".kiro" / "sessions" / "cli"
35
+
36
+
37
+ def fork_kiro_session(
38
+ parent_session_id: str,
39
+ message_count: int,
40
+ cwd: str,
41
+ ) -> str | None:
42
+ """
43
+ Create a new Kiro session file by copying the parent's session files,
44
+ truncated to the first `message_count // 2` complete user turns.
45
+
46
+ `message_count` is interpreted the same way Claude's
47
+ `session_files.fork_at_message_count` does it: the number of {role,
48
+ content} pairs from `materialized_context` (user prompts + assistant
49
+ responses, 2 per exchange). We round up so a cut after a user prompt
50
+ but before its assistant response includes the response — leaving a
51
+ dangling user prompt in the session file makes Kiro panic with
52
+ "invalid conversation history received" on resume.
53
+
54
+ Why this differs from naive `lines[:message_count]`: a single
55
+ materialized_context exchange may span many JSONL entries when the
56
+ turn includes tool calls (Prompt → AssistantMessage → ToolResults →
57
+ AssistantMessage is one turn but four lines). Naive line truncation
58
+ cuts mid-turn and leaves either a dangling Prompt (panic) or a
59
+ truncated AssistantMessage (missing content). Mirrors Claude's
60
+ `_collect_turns` for the same reason.
61
+
62
+ Returns the new session UUID, or None on failure.
63
+
64
+ Why we don't use ACP fork_session: Kiro returns "Method not found".
65
+ Why we generate the UUID ourselves: matches Claude's approach. Lets us
66
+ return the new id synchronously and write predictable filenames.
67
+ """
68
+ parent_jsonl = KIRO_SESSIONS_DIR / f"{parent_session_id}.jsonl"
69
+ parent_json = KIRO_SESSIONS_DIR / f"{parent_session_id}.json"
70
+
71
+ if not parent_jsonl.exists() or not parent_json.exists():
72
+ logger.warning(
73
+ "kiro_session_files: parent session files not found for %s in %s",
74
+ parent_session_id, KIRO_SESSIONS_DIR,
75
+ )
76
+ return None
77
+
78
+ if message_count <= 0:
79
+ logger.warning(
80
+ "kiro_session_files: refusing to fork at message_count=%d (must be > 0)",
81
+ message_count,
82
+ )
83
+ return None
84
+
85
+ # materialized_context counts user prompts and assistant responses
86
+ # equally, so message_count // 2 is the number of real user prompts
87
+ # to collect. Round up on odd inputs (caller asked for a cut after a
88
+ # user prompt but before its response — better to include the whole
89
+ # turn than leave a dangling prompt).
90
+ user_turns_needed = (message_count + 1) // 2
91
+
92
+ # Defensive flush wait: Kiro's session writer is async, and the
93
+ # ACPDriver's close_session call should make it flush before we get
94
+ # here. But if the writer hasn't caught up, we'd fork an incomplete
95
+ # state. Wait up to 2s for the file to contain the requested number
96
+ # of COMPLETE turns (each having an AssistantMessage following the
97
+ # Prompt). Beats the previous strategy of waiting for raw line count
98
+ # because turn boundaries don't map 1:1 to line counts.
99
+ deadline = time.monotonic() + 2.0
100
+ new_lines: list[str] = []
101
+ turn_count: int = 0
102
+ while time.monotonic() < deadline:
103
+ try:
104
+ current_lines = parent_jsonl.read_text().splitlines()
105
+ except OSError:
106
+ current_lines = []
107
+ new_lines, turn_count = _collect_kiro_turns(current_lines, user_turns_needed)
108
+ if turn_count >= user_turns_needed:
109
+ break
110
+ time.sleep(0.05)
111
+
112
+ if turn_count < user_turns_needed:
113
+ # Re-read once more in case of last-instant flush, then proceed
114
+ # with whatever's there. Logging is loud so we can spot this
115
+ # in bug reports.
116
+ try:
117
+ final_lines = parent_jsonl.read_text().splitlines()
118
+ except OSError:
119
+ final_lines = []
120
+ new_lines, turn_count = _collect_kiro_turns(final_lines, user_turns_needed)
121
+ if turn_count < user_turns_needed:
122
+ logger.warning(
123
+ "kiro_session_files: parent file has %d complete turns but "
124
+ "expected %d after flush wait; forking with what we have "
125
+ "(the result may be missing the latest exchange)",
126
+ turn_count, user_turns_needed,
127
+ )
128
+
129
+ if not new_lines:
130
+ logger.warning(
131
+ "kiro_session_files: no complete turns collected from parent %s "
132
+ "(file may be empty or end with a dangling user prompt)",
133
+ parent_session_id,
134
+ )
135
+ return None
136
+
137
+ try:
138
+ metadata = json.loads(parent_json.read_text())
139
+ except (OSError, json.JSONDecodeError) as exc:
140
+ logger.warning("kiro_session_files: failed to read parent json metadata: %s", exc)
141
+ return None
142
+
143
+ new_uuid = str(uuid.uuid4())
144
+
145
+ # Truncate user_turn_metadatas to match the number of complete turns.
146
+ # Update session_id and conversation_id to the new UUID. Leave other
147
+ # metadata fields (cwd, title, permissions, model state) as-is. Kiro
148
+ # accepted this in our test runs.
149
+ metadata["session_id"] = new_uuid
150
+ conv_meta = (
151
+ metadata.setdefault("session_state", {})
152
+ .setdefault("conversation_metadata", {})
153
+ )
154
+ user_turn_metadatas = conv_meta.get("user_turn_metadatas", []) or []
155
+ conv_meta["user_turn_metadatas"] = user_turn_metadatas[:turn_count]
156
+ conv_meta["user_turn_start_request"] = None
157
+ conv_meta["last_request"] = None
158
+
159
+ rts_state = metadata.get("session_state", {}).get("rts_model_state")
160
+ if isinstance(rts_state, dict):
161
+ rts_state["conversation_id"] = new_uuid
162
+
163
+ new_jsonl_path = KIRO_SESSIONS_DIR / f"{new_uuid}.jsonl"
164
+ new_json_path = KIRO_SESSIONS_DIR / f"{new_uuid}.json"
165
+
166
+ try:
167
+ # Write JSONL and metadata atomically: write to .tmp then rename.
168
+ # Avoids leaving a half-written file if we crash mid-write.
169
+ tmp_jsonl = new_jsonl_path.with_suffix(".jsonl.tmp")
170
+ tmp_json = new_json_path.with_suffix(".json.tmp")
171
+ tmp_jsonl.write_text("\n".join(new_lines) + "\n")
172
+ tmp_json.write_text(json.dumps(metadata, indent=2))
173
+ tmp_jsonl.rename(new_jsonl_path)
174
+ tmp_json.rename(new_json_path)
175
+ except OSError as exc:
176
+ logger.warning("kiro_session_files: failed to write forked session files: %s", exc)
177
+ # Best-effort cleanup.
178
+ for p in (new_jsonl_path, new_json_path):
179
+ try:
180
+ p.unlink(missing_ok=True)
181
+ except OSError:
182
+ pass
183
+ return None
184
+
185
+ logger.info(
186
+ "kiro_session_files: forked %s -> %s (lines=%d, turns=%d)",
187
+ parent_session_id, new_uuid, len(new_lines), turn_count,
188
+ )
189
+ return new_uuid
190
+
191
+
192
+ def kiro_session_exists(session_id: str) -> bool:
193
+ """Check if a Kiro session file exists locally."""
194
+ return (KIRO_SESSIONS_DIR / f"{session_id}.jsonl").exists()
195
+
196
+
197
+ # ----------------------------------------------------------------------
198
+ # Internal: turn-aware truncation
199
+ # ----------------------------------------------------------------------
200
+
201
+ def _collect_kiro_turns(
202
+ raw_lines: list[str],
203
+ turns_needed: int,
204
+ ) -> tuple[list[str], int]:
205
+ """
206
+ Walk Kiro JSONL line-strings and collect all lines belonging to the
207
+ first `turns_needed` COMPLETE user turns.
208
+
209
+ A "complete turn" is a `kind: Prompt` entry followed by at least one
210
+ `kind: AssistantMessage` entry (with any number of `ToolResults` or
211
+ intermediate `AssistantMessage` entries between the Prompt and the
212
+ final AssistantMessage). Turns that end with a dangling Prompt — no
213
+ AssistantMessage yet — are EXCLUDED. Including them produces a
214
+ session file that Kiro rejects with "invalid conversation history
215
+ received" on resume.
216
+
217
+ Mirrors `session_files._collect_turns` for the Claude driver, adapted
218
+ to Kiro's schema (`kind: Prompt | AssistantMessage | ToolResults`
219
+ instead of Claude's `type: user | assistant | attachment`).
220
+
221
+ Returns (collected_lines, complete_turns_count). `collected_lines`
222
+ contains the original raw line strings (unmodified), in order. The
223
+ caller is responsible for writing them to a new session file.
224
+
225
+ Edge cases handled:
226
+ - Empty input: returns ([], 0).
227
+ - All turns incomplete (every Prompt has no AssistantMessage):
228
+ returns ([], 0). Caller bails out.
229
+ - File ends with dangling Prompt: that final turn is dropped.
230
+ - Bad JSON line: skipped, doesn't crash.
231
+ - Unknown `kind` values inside a turn: included (forward-compat
232
+ with future Kiro schema additions — better to over-include than
233
+ drop content the agent expects).
234
+ - Multiple consecutive AssistantMessage entries within one turn
235
+ (e.g. tool-use → tool-result → final response): all included.
236
+ """
237
+ result: list[str] = []
238
+ pending: list[str] = []
239
+ pending_has_assistant = False
240
+ turns_collected = 0
241
+
242
+ for raw in raw_lines:
243
+ stripped = raw.strip()
244
+ if not stripped:
245
+ continue
246
+ try:
247
+ entry = json.loads(stripped)
248
+ except json.JSONDecodeError:
249
+ continue
250
+ kind = entry.get("kind", "")
251
+
252
+ if kind == "Prompt":
253
+ # New turn boundary. Flush previous turn if it was complete;
254
+ # otherwise drop the previous (incomplete) turn entirely —
255
+ # leaving a dangling Prompt would make Kiro panic on resume.
256
+ if pending and pending_has_assistant:
257
+ result.extend(pending)
258
+ turns_collected += 1
259
+ if turns_collected >= turns_needed:
260
+ return result, turns_collected
261
+ pending = [raw]
262
+ pending_has_assistant = False
263
+ else:
264
+ # Inside an in-progress turn. Include if we've started one;
265
+ # otherwise drop (entries before the first Prompt are header/
266
+ # noise we don't want to carry forward).
267
+ if pending:
268
+ pending.append(raw)
269
+ if kind == "AssistantMessage":
270
+ pending_has_assistant = True
271
+
272
+ # End of file: flush the final turn if it was complete.
273
+ if pending and pending_has_assistant:
274
+ result.extend(pending)
275
+ turns_collected += 1
276
+
277
+ return result, turns_collected
@@ -29,8 +29,13 @@ _LOG_FILE = _LOG_DIR / "cli.log"
29
29
 
30
30
  def _setup_logging() -> Path | None:
31
31
  """Configure root logger. Returns the active log file path, or None on fallback."""
32
- level_name = os.environ.get("TOPO_LOG_LEVEL", "WARNING").upper()
33
- level = getattr(logging, level_name, logging.WARNING)
32
+ # Default INFO so the rotating file captures session lifecycle events
33
+ # (auto-branch fires, connect/disconnect, permission decisions, etc.)
34
+ # without users having to remember TOPO_LOG_LEVEL. WARNING was too
35
+ # quiet — bug reports came in with empty logs because everything
36
+ # interesting logs at INFO/DEBUG.
37
+ level_name = os.environ.get("TOPO_LOG_LEVEL", "INFO").upper()
38
+ level = getattr(logging, level_name, logging.INFO)
34
39
  fmt = logging.Formatter(
35
40
  "%(asctime)s %(levelname)s %(name)s: %(message)s"
36
41
  )
@@ -55,7 +55,12 @@ DRIVERS = {
55
55
  FRONTEND_URL = os.getenv("TOPO_FRONTEND_URL", "https://expluro.com")
56
56
  WS_BASE = os.getenv("TOPO_BASE_URL", "https://api.expluro.com").replace("https://", "wss://").replace("http://", "ws://")
57
57
 
58
- AUTO_BRANCH_THRESHOLD = 0.7 # confidence >= this triggers an automatic branch
58
+ AUTO_BRANCH_THRESHOLD = 0.85 # confidence >= this triggers an automatic branch.
59
+ # Aligned with the BRANCH_DETECTION_INSTRUCTION's own confidence bands —
60
+ # 0.85+ is the "clear fork: option chosen, or subject/direction changed
61
+ # substantially" range. Lower confidences (0.50–0.85) cover meaningful but
62
+ # ambiguous shifts, where false-firing destroys the user's active turn and
63
+ # is worse than missing one (the user can always /branch manually).
59
64
 
60
65
 
61
66
  def start_session(driver_name: str = "claude", cwd: str | None = None, no_browser: bool = False) -> None:
@@ -668,8 +673,25 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
668
673
  cc_session_id: str | None = None
669
674
  renderer = TerminalRenderer()
670
675
  renderer.start_turn()
676
+ # Buffer for events that arrive before the branch decision is
677
+ # resolved. We render them to the terminal immediately (UX
678
+ # stays live), but defer the durable record until we know
679
+ # which node owns this turn — parent if no branch fires,
680
+ # child if it does. Without this, an event that lands before
681
+ # branch_signal (e.g. a `thinking` block from extended
682
+ # thinking, which Claude emits as its own AssistantMessage
683
+ # ahead of the streamed text containing the BRANCH tag)
684
+ # would force the safety-net flush of pending_user_msg onto
685
+ # the parent, leaving the pivot message on the wrong node.
686
+ pre_branch_buffer: list = []
687
+ branch_decided = False
671
688
  try:
672
689
  async for event in driver.receive():
690
+ # Capture cc_session_id from any event regardless of
691
+ # buffering, so a turn that defers all events still
692
+ # records the session id for /resume.
693
+ if not cc_session_id and event.data.get("session_id"):
694
+ cc_session_id = event.data["session_id"]
673
695
  if event.type == "branch_signal":
674
696
  confidence = event.data.get("confidence", 0.0)
675
697
  logger.info("Branch signal received (confidence=%.2f, threshold=%.2f, fires=%s)", confidence, AUTO_BRANCH_THRESHOLD, auto_branch_enabled['value'] and confidence >= AUTO_BRANCH_THRESHOLD)
@@ -741,29 +763,50 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
741
763
  except Exception:
742
764
  logger.warning("Failed to persist deferred user message")
743
765
  pending_user_msg = None
766
+ # Replay any events that arrived before branch_signal
767
+ # (e.g. leading `thinking` block) onto whichever node
768
+ # we just resolved to. They were rendered live; only
769
+ # the durable record was deferred.
770
+ for buffered_evt in pre_branch_buffer:
771
+ try:
772
+ await capture.record_driver_event(buffered_evt)
773
+ except Exception:
774
+ logger.warning("Failed to persist buffered pre-branch event")
775
+ pre_branch_buffer = []
776
+ branch_decided = True
744
777
  continue # branch_signal is never persisted or rendered
745
- # Safety net: if the model omitted the BRANCH flag, no
746
- # branch_signal arrived and pending_user_msg was never flushed
747
- # there. Flush it now on the current node before any other
748
- # event lands, so it anchors as the first event on the turn.
749
- if pending_user_msg is not None:
750
- try:
751
- await capture.record_user_message(pending_user_msg)
752
- _label = pending_user_msg[:80]
753
- current_node_label['value'] = _label
754
- node_label_cache[capture.node_id] = _label
755
- except Exception:
756
- logger.warning("Failed to persist deferred user message (no branch_signal path)")
757
- pending_user_msg = None
758
778
  # Render to terminal first (local, instant) — the terminal is the
759
779
  # primary interaction surface and must not be paced by backend latency.
760
780
  # Persistence + browser broadcast runs second.
761
781
  renderer.render(event)
782
+ # Defer durable persistence until the branch decision is
783
+ # resolved. text/text_complete/result without a prior
784
+ # branch_signal proves the model omitted the BRANCH tag —
785
+ # commit to the current node now and replay buffer.
786
+ if not branch_decided and pending_user_msg is not None:
787
+ if event.type in ("text", "text_complete", "result"):
788
+ try:
789
+ await capture.record_user_message(pending_user_msg)
790
+ _label = pending_user_msg[:80]
791
+ current_node_label['value'] = _label
792
+ node_label_cache[capture.node_id] = _label
793
+ except Exception:
794
+ logger.warning("Failed to persist deferred user message (no branch_signal path)")
795
+ pending_user_msg = None
796
+ for buffered_evt in pre_branch_buffer:
797
+ try:
798
+ await capture.record_driver_event(buffered_evt)
799
+ except Exception:
800
+ logger.warning("Failed to persist buffered pre-branch event")
801
+ pre_branch_buffer = []
802
+ branch_decided = True
803
+ # Fall through to record current event on this node.
804
+ else:
805
+ # thinking, tool_use, tool_result, rate_limit, error, etc.
806
+ # Buffer for replay once the branch decision is made.
807
+ pre_branch_buffer.append(event)
808
+ continue
762
809
  await capture.record_driver_event(event)
763
- # Capture the Claude Code session ID from the first event that has it.
764
- # Every message type carries session_id in event.data.
765
- if not cc_session_id and event.data.get("session_id"):
766
- cc_session_id = event.data["session_id"]
767
810
  except KeyboardInterrupt:
768
811
  renderer.stop_spinner_if_active()
769
812
  console.print("\n[yellow]Interrupted.[/yellow]")
@@ -773,12 +816,12 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
773
816
  console.print(f"[red]Error: {exc}[/red]")
774
817
  finally:
775
818
  is_streaming['value'] = False
776
- # Last-chance safety net: if the stream errored before any
777
- # event arrived (so neither the branch_signal flush nor the
778
- # pre-event flush ran), persist the user message now on
779
- # whichever node capture is currently on. Avoids losing the
780
- # message from durable history if the response failed
781
- # catastrophically before any tokens streamed.
819
+ # Last-chance safety net: if the stream errored before the
820
+ # branch decision was resolved (so neither the branch_signal
821
+ # path nor the text/result fallback ran), persist the user
822
+ # message and any buffered pre-branch events now on whichever
823
+ # node capture is currently on. Avoids losing the message
824
+ # from durable history if the response failed catastrophically.
782
825
  if pending_user_msg is not None:
783
826
  try:
784
827
  await capture.record_user_message(pending_user_msg)
@@ -788,6 +831,13 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
788
831
  except Exception:
789
832
  logger.warning("Failed to persist user message in finally")
790
833
  pending_user_msg = None
834
+ if pre_branch_buffer:
835
+ for buffered_evt in pre_branch_buffer:
836
+ try:
837
+ await capture.record_driver_event(buffered_evt)
838
+ except Exception:
839
+ logger.warning("Failed to persist buffered pre-branch event in finally")
840
+ pre_branch_buffer = []
791
841
  # Persist the Claude Code session ID so checkout can --resume this node later.
792
842
  if cc_session_id:
793
843
  current_cc_session_id['value'] = cc_session_id
@@ -1,167 +0,0 @@
1
- """
2
- Kiro session file forking.
3
-
4
- Kiro's ACP server does not implement `session/fork_session` (advertised
5
- fork capability is null; calling the method returns "Method not found").
6
- But it does support `session/load_session`, and it accepts session files
7
- written manually to ~/.kiro/sessions/cli/<uuid>.jsonl + .json.
8
-
9
- So we fork the same way the Claude driver does: copy the parent's session
10
- files to a new UUID, truncate to the branch point, ask Kiro to load_session
11
- the new UUID. The agent treats it as a real session.
12
-
13
- File layout (verified empirically):
14
- - <uuid>.jsonl: one event per line. Two kinds: Prompt and AssistantMessage.
15
- Each carries message_id, content blocks, optional meta.timestamp.
16
- - <uuid>.json: metadata. Contains session_id, cwd, title, session_state with
17
- per-turn telemetry (user_turn_metadatas). Kiro accepts copied/truncated
18
- metadata even though some fields like input_token_count, metering_usage
19
- reflect the parent's values.
20
- - <uuid>.lock: process lock. Created by the agent process on open. We do
21
- NOT plant a lock file; Kiro creates one when it opens the session.
22
- """
23
- from __future__ import annotations
24
-
25
- import json
26
- import logging
27
- import time
28
- import uuid
29
- from pathlib import Path
30
- from typing import Any
31
-
32
- logger = logging.getLogger(__name__)
33
-
34
- KIRO_SESSIONS_DIR = Path.home() / ".kiro" / "sessions" / "cli"
35
-
36
-
37
- def fork_kiro_session(
38
- parent_session_id: str,
39
- message_count: int,
40
- cwd: str,
41
- ) -> str | None:
42
- """
43
- Create a new Kiro session file by copying the parent's session files
44
- and truncating to the first `message_count` JSONL events.
45
-
46
- Returns the new session UUID, or None on failure.
47
-
48
- Why we don't use ACP fork_session: Kiro returns "Method not found".
49
- Why we generate the UUID ourselves: matches Claude's approach. Lets us
50
- return the new id synchronously and write predictable filenames.
51
- """
52
- parent_jsonl = KIRO_SESSIONS_DIR / f"{parent_session_id}.jsonl"
53
- parent_json = KIRO_SESSIONS_DIR / f"{parent_session_id}.json"
54
-
55
- if not parent_jsonl.exists() or not parent_json.exists():
56
- logger.warning(
57
- "kiro_session_files: parent session files not found for %s in %s",
58
- parent_session_id, KIRO_SESSIONS_DIR,
59
- )
60
- return None
61
-
62
- # Defensive flush wait: Kiro's session writer is async, and the
63
- # ACPDriver's close_session call should make it flush before we get
64
- # here. But if for any reason the file is still short of expected
65
- # size, wait briefly for it to settle. Without this, we'd fork a
66
- # half-written file and Kiro would later panic on a dangling user
67
- # prompt. Caller passes message_count = expected line count.
68
- deadline = time.monotonic() + 2.0
69
- while time.monotonic() < deadline:
70
- try:
71
- current_lines = parent_jsonl.read_text().splitlines()
72
- except OSError:
73
- current_lines = []
74
- if len(current_lines) >= message_count:
75
- break
76
- time.sleep(0.05)
77
-
78
- try:
79
- lines = parent_jsonl.read_text().splitlines()
80
- except OSError as exc:
81
- logger.warning("kiro_session_files: failed to read parent jsonl: %s", exc)
82
- return None
83
- if len(lines) < message_count:
84
- logger.warning(
85
- "kiro_session_files: parent file has %d lines but expected %d after flush wait; "
86
- "forking what's there (the result may be missing the last assistant message)",
87
- len(lines), message_count,
88
- )
89
-
90
- if message_count <= 0:
91
- logger.warning(
92
- "kiro_session_files: refusing to fork at message_count=%d (must be > 0)",
93
- message_count,
94
- )
95
- return None
96
-
97
- # Cap to actual content length. If caller asks for more messages than the
98
- # parent has, we just take everything. Same behavior as Claude.
99
- new_lines = lines[: min(message_count, len(lines))]
100
- if not new_lines:
101
- logger.warning("kiro_session_files: parent jsonl was empty for %s", parent_session_id)
102
- return None
103
-
104
- # Count complete turns in the truncated JSONL. A complete turn ends with
105
- # an AssistantMessage. user_turn_metadatas has one entry per complete turn.
106
- # Robust to tool-call interleavings: we count AssistantMessage events
107
- # rather than divide by two.
108
- turn_count = sum(1 for line in new_lines if '"kind":"AssistantMessage"' in line)
109
-
110
- try:
111
- metadata = json.loads(parent_json.read_text())
112
- except (OSError, json.JSONDecodeError) as exc:
113
- logger.warning("kiro_session_files: failed to read parent json metadata: %s", exc)
114
- return None
115
-
116
- new_uuid = str(uuid.uuid4())
117
-
118
- # Truncate user_turn_metadatas to match the number of complete turns.
119
- # Update session_id and conversation_id to the new UUID. Leave other
120
- # metadata fields (cwd, title, permissions, model state) as-is. Kiro
121
- # accepted this in our test runs.
122
- metadata["session_id"] = new_uuid
123
- conv_meta = (
124
- metadata.setdefault("session_state", {})
125
- .setdefault("conversation_metadata", {})
126
- )
127
- user_turn_metadatas = conv_meta.get("user_turn_metadatas", []) or []
128
- conv_meta["user_turn_metadatas"] = user_turn_metadatas[:turn_count]
129
- conv_meta["user_turn_start_request"] = None
130
- conv_meta["last_request"] = None
131
-
132
- rts_state = metadata.get("session_state", {}).get("rts_model_state")
133
- if isinstance(rts_state, dict):
134
- rts_state["conversation_id"] = new_uuid
135
-
136
- new_jsonl_path = KIRO_SESSIONS_DIR / f"{new_uuid}.jsonl"
137
- new_json_path = KIRO_SESSIONS_DIR / f"{new_uuid}.json"
138
-
139
- try:
140
- # Write JSONL and metadata atomically: write to .tmp then rename.
141
- # Avoids leaving a half-written file if we crash mid-write.
142
- tmp_jsonl = new_jsonl_path.with_suffix(".jsonl.tmp")
143
- tmp_json = new_json_path.with_suffix(".json.tmp")
144
- tmp_jsonl.write_text("\n".join(new_lines) + "\n")
145
- tmp_json.write_text(json.dumps(metadata, indent=2))
146
- tmp_jsonl.rename(new_jsonl_path)
147
- tmp_json.rename(new_json_path)
148
- except OSError as exc:
149
- logger.warning("kiro_session_files: failed to write forked session files: %s", exc)
150
- # Best-effort cleanup.
151
- for p in (new_jsonl_path, new_json_path):
152
- try:
153
- p.unlink(missing_ok=True)
154
- except OSError:
155
- pass
156
- return None
157
-
158
- logger.info(
159
- "kiro_session_files: forked %s -> %s (lines=%d, turns=%d)",
160
- parent_session_id, new_uuid, len(new_lines), turn_count,
161
- )
162
- return new_uuid
163
-
164
-
165
- def kiro_session_exists(session_id: str) -> bool:
166
- """Check if a Kiro session file exists locally."""
167
- return (KIRO_SESSIONS_DIR / f"{session_id}.jsonl").exists()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes