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.
- {expluro-0.3.0 → expluro-0.3.1}/PKG-INFO +1 -1
- {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/PKG-INFO +1 -1
- {expluro-0.3.0 → expluro-0.3.1}/pyproject.toml +1 -1
- {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/_branch_detection.py +47 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/acp.py +171 -47
- {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/claude.py +55 -19
- expluro-0.3.1/topo/drivers/kiro_session_files.py +277 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/main.py +7 -2
- {expluro-0.3.0 → expluro-0.3.1}/topo/session.py +74 -24
- expluro-0.3.0/topo/drivers/kiro_session_files.py +0 -167
- {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/SOURCES.txt +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/dependency_links.txt +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/entry_points.txt +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/requires.txt +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/expluro.egg-info/top_level.txt +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/setup.cfg +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/__init__.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/auth.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/backend.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/capture.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/constants.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/display.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/__init__.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/_permissions.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/drivers/base.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/picker.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/renderer.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/session_files.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/snapshot.py +0 -0
- {expluro-0.3.0 → expluro-0.3.1}/topo/update_check.py +0 -0
|
@@ -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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
#
|
|
805
|
-
#
|
|
806
|
-
#
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
#
|
|
813
|
-
#
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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(
|
|
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=
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
379
|
-
#
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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.
|
|
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
|
|
777
|
-
#
|
|
778
|
-
#
|
|
779
|
-
#
|
|
780
|
-
#
|
|
781
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|