callwalkietalkie 0.8.0

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.
@@ -0,0 +1,985 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ cmux adapter — thin read/write chat over cmux's Unix socket.
4
+
5
+ Opinionated: cmux is the host. We pick a workspace/surface, send text into the
6
+ terminal, and parse scrollback into chat + command blocks for the phone UI.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import os
14
+ import re
15
+ import socket
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Optional
20
+
21
+
22
+ def _socket_candidates() -> list[str]:
23
+ # Re-read env each call — empty strings are not valid paths
24
+ # (Path("").exists() is True on macOS, which would falsely "find" cwd).
25
+ out: list[str] = []
26
+ for path in (
27
+ os.environ.get("CMUX_SOCKET_PATH") or "",
28
+ os.environ.get("CMUX_SOCKET") or "",
29
+ str(Path.home() / ".local/state/cmux/cmux.sock"),
30
+ "/tmp/cmux.sock",
31
+ "/tmp/cmux-debug.sock",
32
+ ):
33
+ path = path.strip()
34
+ if path and path not in out:
35
+ out.append(path)
36
+ return out
37
+
38
+
39
+ class CmuxError(RuntimeError):
40
+ pass
41
+
42
+
43
+ def find_socket() -> str:
44
+ for path in _socket_candidates():
45
+ try:
46
+ if Path(path).is_socket() or Path(path).exists():
47
+ # Prefer real sockets; skip directories / empty-path weirdness.
48
+ if Path(path).is_dir():
49
+ continue
50
+ return path
51
+ except OSError:
52
+ continue
53
+ raise CmuxError(
54
+ "cmux is not running (no socket).\n"
55
+ "Open cmux, then re-run. Socket expected at ~/.local/state/cmux/cmux.sock"
56
+ )
57
+
58
+
59
+ def _recv_json_line(sock: socket.socket) -> dict:
60
+ """Read newline-delimited JSON, skipping blank lines / partial frames."""
61
+ buf = b""
62
+ while True:
63
+ chunk = sock.recv(1 << 20)
64
+ if not chunk:
65
+ break
66
+ buf += chunk
67
+ while b"\n" in buf:
68
+ raw_line, buf = buf.split(b"\n", 1)
69
+ line = raw_line.strip()
70
+ if not line:
71
+ continue
72
+ try:
73
+ return json.loads(line)
74
+ except json.JSONDecodeError:
75
+ # Incomplete JSON that happened to contain a newline — keep reading.
76
+ buf = raw_line + b"\n" + buf
77
+ break
78
+ # Fast path: whole buffer is one JSON object (no trailing newline yet).
79
+ stripped = buf.strip()
80
+ if stripped.startswith(b"{") and stripped.endswith(b"}"):
81
+ try:
82
+ return json.loads(stripped)
83
+ except json.JSONDecodeError:
84
+ pass
85
+ preview = buf[:120].decode("utf-8", "replace")
86
+ if "Access denied" in preview:
87
+ raise CmuxError(
88
+ "cmux blocked this process (socket is cmux-only).\n"
89
+ "In ~/.config/cmux/cmux.json set automation.socketControlMode to "
90
+ '"automation", then Quit and reopen cmux.\n'
91
+ "npx callwalkietalkie has to run outside a cmux terminal, so the default "
92
+ "cmuxOnly mode rejects it."
93
+ )
94
+ raise CmuxError(f"empty/partial response from cmux ({preview!r})")
95
+
96
+
97
+ def _rpc_socket(method: str, params: Optional[dict], path: str, timeout: float) -> Any:
98
+ payload = {
99
+ "id": str(uuid.uuid4()),
100
+ "method": method,
101
+ "params": params or {},
102
+ }
103
+ raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
104
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
105
+ sock.settimeout(timeout)
106
+ sock.connect(path)
107
+ sock.sendall(raw)
108
+ return _recv_json_line(sock)
109
+
110
+
111
+ def _rpc_cli(method: str, params: Optional[dict], path: str, timeout: float) -> Any:
112
+ """Fallback via `cmux rpc` when the raw socket returns junk."""
113
+ import subprocess
114
+
115
+ cli = (
116
+ os.environ.get("CMUX_BUNDLED_CLI_PATH")
117
+ or "/Applications/cmux.app/Contents/Resources/bin/cmux"
118
+ )
119
+ if not Path(cli).exists():
120
+ cli = "cmux"
121
+ env = os.environ.copy()
122
+ env["CMUX_SOCKET_PATH"] = path
123
+ # Don't pass a blank CMUX_SOCKET through — it confuses some builds.
124
+ if not (env.get("CMUX_SOCKET") or "").strip():
125
+ env.pop("CMUX_SOCKET", None)
126
+ proc = subprocess.run(
127
+ [cli, "--socket", path, "rpc", method, json.dumps(params or {}), "--json"],
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=timeout,
131
+ env=env,
132
+ )
133
+ out = (proc.stdout or "").strip()
134
+ if not out:
135
+ err = (proc.stderr or "").strip() or f"cmux rpc exited {proc.returncode}"
136
+ raise CmuxError(err)
137
+ # CLI may print a deprecation line before JSON — take the last JSON object.
138
+ data = None
139
+ for line in out.splitlines():
140
+ line = line.strip()
141
+ if line.startswith("{"):
142
+ try:
143
+ data = json.loads(line)
144
+ except json.JSONDecodeError:
145
+ continue
146
+ if data is None:
147
+ try:
148
+ data = json.loads(out)
149
+ except json.JSONDecodeError as e:
150
+ raise CmuxError(f"bad cmux CLI JSON: {e}") from e
151
+ return data
152
+
153
+
154
+ def rpc(
155
+ method: str,
156
+ params: Optional[dict] = None,
157
+ *,
158
+ socket_path: Optional[str] = None,
159
+ timeout: float = 8.0,
160
+ ) -> Any:
161
+ path = socket_path or find_socket()
162
+ last_err: Optional[Exception] = None
163
+ data: Any = None
164
+ for attempt in range(3):
165
+ try:
166
+ data = _rpc_socket(method, params, path, timeout)
167
+ break
168
+ except (FileNotFoundError, ConnectionRefusedError, TimeoutError, OSError, CmuxError) as e:
169
+ last_err = e
170
+ if attempt == 1:
171
+ # Midway: try CLI once.
172
+ try:
173
+ data = _rpc_cli(method, params, path, timeout)
174
+ break
175
+ except Exception as cli_err:
176
+ last_err = cli_err
177
+ continue
178
+ if data is None:
179
+ raise CmuxError(str(last_err) if last_err else "cmux rpc failed")
180
+
181
+ if not isinstance(data, dict):
182
+ return data
183
+
184
+ # Error envelope (socket or CLI)
185
+ if data.get("ok") is False or (
186
+ data.get("error") and "result" not in data and "pong" not in data
187
+ ):
188
+ err = data.get("error") or {}
189
+ if isinstance(err, dict):
190
+ msg = err.get("message") or err.get("code") or str(err)
191
+ else:
192
+ msg = str(err)
193
+ raise CmuxError(msg or "cmux error")
194
+
195
+ if "result" in data:
196
+ return data.get("result")
197
+ return data
198
+
199
+
200
+ @dataclass
201
+ class Target:
202
+ workspace_id: str
203
+ workspace_ref: str
204
+ surface_id: str
205
+ surface_ref: str
206
+ window_id: Optional[str] = None
207
+ window_ref: Optional[str] = None
208
+ title: str = "cmux"
209
+ cwd: Optional[str] = None
210
+
211
+
212
+ @dataclass
213
+ class ChatMessage:
214
+ role: str # user | assistant | system
215
+ text: str
216
+ kind: str = "message" # message | command | system
217
+
218
+
219
+ @dataclass
220
+ class Session:
221
+ """Stateful bridge onto one cmux surface."""
222
+
223
+ target: Target
224
+ socket_path: str
225
+ _sent: list[str] = field(default_factory=list)
226
+ _last_text: str = ""
227
+
228
+ def refresh_target_meta(self) -> None:
229
+ # Prefer surface-level title from inventory. Workspace.list title is shared
230
+ # across tabs in the same workspace and would make every session look identical.
231
+ try:
232
+ for item in inventory(socket_path=self.socket_path):
233
+ if self.target.surface_ref in (
234
+ item.get("surface"),
235
+ item.get("surface_id"),
236
+ ) or self.target.surface_id in (
237
+ item.get("surface"),
238
+ item.get("surface_id"),
239
+ ):
240
+ if item.get("title"):
241
+ self.target.title = item["title"]
242
+ if item.get("cwd"):
243
+ self.target.cwd = item["cwd"]
244
+ if item.get("window"):
245
+ self.target.window_ref = item.get("window")
246
+ return
247
+ except CmuxError:
248
+ pass
249
+ workspaces = list_workspaces(socket_path=self.socket_path)
250
+ for ws in workspaces:
251
+ if ws["id"] == self.target.workspace_id or ws.get("ref") == self.target.workspace_ref:
252
+ if not self.target.title or self.target.title == "cmux":
253
+ self.target.title = (
254
+ ws.get("custom_title")
255
+ or ws.get("title")
256
+ or self.target.title
257
+ )
258
+ self.target.cwd = ws.get("current_directory") or self.target.cwd
259
+ break
260
+
261
+ def read_text(self, *, lines: int = 800, scrollback: bool = True) -> str:
262
+ result = rpc(
263
+ "surface.read_text",
264
+ {
265
+ "workspace_id": self.target.workspace_ref,
266
+ "surface_id": self.target.surface_ref,
267
+ "scrollback": scrollback,
268
+ "lines": lines,
269
+ },
270
+ socket_path=self.socket_path,
271
+ )
272
+ text = result.get("text")
273
+ if not text and result.get("base64"):
274
+ text = base64.b64decode(result["base64"]).decode("utf-8", "replace")
275
+ return text or ""
276
+
277
+ def interrupt(self) -> None:
278
+ """Stop a running agent / command in the selected surface.
279
+
280
+ Agents disagree on the cancel key (Ctrl+C vs Escape), and some cmux
281
+ builds only honor one of send_key / send_text — so we fire all of them.
282
+ """
283
+ self.focus()
284
+ base = {
285
+ "workspace_id": self.target.workspace_ref,
286
+ "surface_id": self.target.surface_ref,
287
+ }
288
+ attempts = [
289
+ ("surface.send_key", {**base, "key": "c", "ctrl": True}),
290
+ ("surface.send_text", {**base, "text": "\x03"}),
291
+ ("surface.send_key", {**base, "key": "Escape"}),
292
+ ]
293
+ ok = False
294
+ last_err: CmuxError | None = None
295
+ for method, params in attempts:
296
+ try:
297
+ rpc(method, params, socket_path=self.socket_path)
298
+ ok = True
299
+ except CmuxError as e:
300
+ last_err = e
301
+ if not ok and last_err is not None:
302
+ raise last_err
303
+
304
+ def focus(self) -> None:
305
+ """Bring this surface to front so send/interrupt land in the visible tab."""
306
+ try:
307
+ rpc(
308
+ "surface.focus",
309
+ {
310
+ "workspace_id": self.target.workspace_ref,
311
+ "surface_id": self.target.surface_ref,
312
+ },
313
+ socket_path=self.socket_path,
314
+ )
315
+ except CmuxError:
316
+ pass
317
+
318
+ def send(self, text: str) -> None:
319
+ text = text.rstrip("\n")
320
+ if not text:
321
+ raise CmuxError("empty message")
322
+ self.focus()
323
+ # Agents expect a submit. Always end with Enter.
324
+ payload = text + "\n"
325
+ rpc(
326
+ "surface.send_text",
327
+ {
328
+ "workspace_id": self.target.workspace_ref,
329
+ "surface_id": self.target.surface_ref,
330
+ "text": payload,
331
+ },
332
+ socket_path=self.socket_path,
333
+ )
334
+ self._sent.append(text)
335
+
336
+ def transcript(self) -> list[ChatMessage]:
337
+ screen = self.read_text()
338
+ self._last_text = screen
339
+ parsed = parse_screen(screen)
340
+ # Ensure phone-originated sends show up even if the agent UI ate the echo.
341
+ return merge_sent(parsed, self._sent)
342
+
343
+ def busy(self) -> bool:
344
+ return is_busy(self._last_text or self.read_text())
345
+
346
+ def notify_linked(self) -> None:
347
+ try:
348
+ rpc(
349
+ "notification.create",
350
+ {
351
+ "title": "Call Walkie Talkie",
352
+ "body": "Phone linked — chatting into this workspace",
353
+ "workspace_id": self.target.workspace_ref,
354
+ "surface_id": self.target.surface_ref,
355
+ },
356
+ socket_path=self.socket_path,
357
+ )
358
+ except CmuxError:
359
+ pass
360
+ try:
361
+ # Best-effort sidebar presence. Method names vary across cmux builds.
362
+ rpc(
363
+ "notification.create",
364
+ {
365
+ "title": "Call Walkie Talkie · live",
366
+ "body": self.target.title,
367
+ },
368
+ socket_path=self.socket_path,
369
+ )
370
+ except CmuxError:
371
+ pass
372
+
373
+
374
+ # ---------------------------------------------------------------- discovery
375
+
376
+
377
+ def ping(socket_path: Optional[str] = None) -> dict:
378
+ path = socket_path or find_socket()
379
+ result = rpc("system.ping", socket_path=path)
380
+ return {"ok": True, "socket_path": path, "pong": bool((result or {}).get("pong", True))}
381
+
382
+
383
+ def identify(socket_path: Optional[str] = None) -> dict:
384
+ return rpc("system.identify", socket_path=socket_path or find_socket())
385
+
386
+
387
+ def list_workspaces(socket_path: Optional[str] = None) -> list[dict]:
388
+ result = rpc("workspace.list", socket_path=socket_path or find_socket())
389
+ return list((result or {}).get("workspaces") or [])
390
+
391
+
392
+ def list_surfaces(workspace_ref: str, socket_path: Optional[str] = None) -> list[dict]:
393
+ result = rpc(
394
+ "surface.list",
395
+ {"workspace_id": workspace_ref},
396
+ socket_path=socket_path or find_socket(),
397
+ )
398
+ return list((result or {}).get("surfaces") or [])
399
+
400
+
401
+ def inventory(socket_path: Optional[str] = None) -> list[dict]:
402
+ """Flat list of pickable terminal sessions across windows/workspaces."""
403
+ path = socket_path or find_socket()
404
+ items: list[dict] = []
405
+ tree = None
406
+ try:
407
+ tree = rpc("system.tree", socket_path=path)
408
+ except CmuxError:
409
+ try:
410
+ tree = rpc("tree", socket_path=path)
411
+ except CmuxError:
412
+ tree = None
413
+
414
+ if isinstance(tree, dict) and tree.get("windows"):
415
+ for win in tree.get("windows") or []:
416
+ win_ref = win.get("ref") or win.get("window_ref")
417
+ for ws in win.get("workspaces") or []:
418
+ ws_ref = ws.get("ref") or (f"workspace:{ws['index']}" if "index" in ws else None)
419
+ ws_id = ws.get("id")
420
+ ws_title = ws.get("custom_title") or ws.get("title") or ws_ref
421
+ ws_cwd = ws.get("current_directory")
422
+ for pane in ws.get("panes") or []:
423
+ for surf in pane.get("surfaces") or []:
424
+ if (surf.get("type") or "terminal") != "terminal":
425
+ continue
426
+ items.append(
427
+ {
428
+ "window": win_ref,
429
+ "workspace": ws_ref or ws_id,
430
+ "workspace_id": ws_id,
431
+ "surface": surf.get("ref") or surf.get("id"),
432
+ "surface_id": surf.get("id"),
433
+ "title": surf.get("title") or ws_title or "terminal",
434
+ "cwd": ws_cwd,
435
+ "active": bool(surf.get("active") or surf.get("focused")),
436
+ }
437
+ )
438
+ if items:
439
+ return items
440
+
441
+ # Fallback: workspace.list + surface.list
442
+ for ws in list_workspaces(socket_path=path):
443
+ ws_ref = ws.get("ref") or ws.get("id")
444
+ for surf in list_surfaces(ws_ref, socket_path=path):
445
+ if (surf.get("type") or "terminal") != "terminal":
446
+ continue
447
+ items.append(
448
+ {
449
+ "window": None,
450
+ "workspace": ws_ref,
451
+ "workspace_id": ws.get("id"),
452
+ "surface": surf.get("ref") or surf.get("id"),
453
+ "surface_id": surf.get("id"),
454
+ "title": surf.get("title")
455
+ or ws.get("custom_title")
456
+ or ws.get("title")
457
+ or "terminal",
458
+ "cwd": ws.get("current_directory"),
459
+ "active": bool(surf.get("focused") or surf.get("selected_in_pane")),
460
+ }
461
+ )
462
+ return items
463
+
464
+
465
+ def resolve_target(
466
+ *,
467
+ workspace: Optional[str] = None,
468
+ surface: Optional[str] = None,
469
+ window: Optional[str] = None,
470
+ socket_path: Optional[str] = None,
471
+ ) -> Target:
472
+ path = socket_path or find_socket()
473
+ inv = inventory(socket_path=path)
474
+ ident = identify(socket_path=path)
475
+ focused = (ident or {}).get("focused") or {}
476
+
477
+ pick = None
478
+ if surface or workspace:
479
+ for item in inv:
480
+ if surface and surface not in (
481
+ item.get("surface"),
482
+ item.get("surface_id"),
483
+ ):
484
+ continue
485
+ if workspace and workspace not in (
486
+ item.get("workspace"),
487
+ item.get("workspace_id"),
488
+ ):
489
+ continue
490
+ if window and window not in (item.get("window"),):
491
+ continue
492
+ pick = item
493
+ break
494
+ if pick is None:
495
+ for item in inv:
496
+ if item.get("active"):
497
+ pick = item
498
+ break
499
+ if pick is None and inv:
500
+ pick = inv[0]
501
+ if pick is None:
502
+ # Last resort: focused refs from identify
503
+ if not focused.get("surface_ref") and not focused.get("surface_id"):
504
+ raise CmuxError("No cmux terminal open. Create one in cmux, then retry.")
505
+ pick = {
506
+ "workspace": focused.get("workspace_ref") or focused.get("workspace_id"),
507
+ "workspace_id": focused.get("workspace_id"),
508
+ "surface": focused.get("surface_ref") or focused.get("surface_id"),
509
+ "surface_id": focused.get("surface_id"),
510
+ "window": focused.get("window_ref"),
511
+ "title": "cmux",
512
+ "cwd": None,
513
+ }
514
+
515
+ # Enrich title/cwd from workspace.list when tree omitted them
516
+ if not pick.get("cwd") or pick.get("title") in (None, "terminal", "cmux"):
517
+ for ws in list_workspaces(socket_path=path):
518
+ if pick.get("workspace") in (ws.get("ref"), ws.get("id")):
519
+ pick["cwd"] = pick.get("cwd") or ws.get("current_directory")
520
+ if pick.get("title") in (None, "terminal", "cmux"):
521
+ pick["title"] = ws.get("custom_title") or ws.get("title") or pick.get("title")
522
+ break
523
+
524
+ return Target(
525
+ workspace_id=pick.get("workspace_id") or pick.get("workspace"),
526
+ workspace_ref=pick.get("workspace"),
527
+ surface_id=pick.get("surface_id") or pick.get("surface"),
528
+ surface_ref=pick.get("surface"),
529
+ window_id=focused.get("window_id"),
530
+ window_ref=pick.get("window") or focused.get("window_ref"),
531
+ title=pick.get("title") or "cmux",
532
+ cwd=pick.get("cwd"),
533
+ )
534
+
535
+
536
+ def open_session(
537
+ *,
538
+ workspace: Optional[str] = None,
539
+ surface: Optional[str] = None,
540
+ window: Optional[str] = None,
541
+ ) -> Session:
542
+ path = find_socket()
543
+ target = resolve_target(
544
+ workspace=workspace, surface=surface, window=window, socket_path=path
545
+ )
546
+ return Session(target=target, socket_path=path)
547
+
548
+
549
+ # ---------------------------------------------------------------- screen → messages
550
+
551
+
552
+ _BOXY = re.compile(r"[╭╮╯╰│─┌┐└┘▄▀━┃╌╍╎╏╔╗╚╝║═]")
553
+ _CMD_START = re.compile(r"^\s*\$\s+(.*)$")
554
+ _SHELL_PROMPT = re.compile(
555
+ r"^[^\n]*[@%][^\n]*[ %#❯] |^[^\n]*[ %#❯]\s*$"
556
+ )
557
+ _HIDDEN = re.compile(r"^\s*…\s+\d+.*(hidden|expand)", re.I)
558
+ _STATUSY = re.compile(
559
+ r"(Running|Waiting|tokens|Goal active|Run Everything|ctrl\+[co]|Auto ·)",
560
+ re.I,
561
+ )
562
+ _BRAILLE = re.compile(r"[⠁-⣿⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]")
563
+
564
+
565
+ def is_busy(screen: str) -> bool:
566
+ """True when the agent UI looks mid-run (stoppable with Ctrl+C / Esc)."""
567
+ lines = [ln.strip() for ln in (screen or "").splitlines() if ln.strip()]
568
+ for s in lines[-40:]:
569
+ low = s.lower()
570
+ # Shell / CLI echo — not an agent run (e.g. "Already running.").
571
+ if low.startswith("already running"):
572
+ continue
573
+ # Explicit stop affordance from agent UIs.
574
+ if "ctrl+c" in low and (
575
+ "stop" in low or s.lstrip().startswith("→") or "press" in low
576
+ ):
577
+ return True
578
+ # Progress lines: "Running 2.83k tokens" / spinner + running|waiting|…
579
+ if re.search(r"\brunning\b", low) and (
580
+ "token" in low or _BRAILLE.search(s) or "…" in s or "..." in s
581
+ ):
582
+ return True
583
+ if _BRAILLE.search(s) and re.search(
584
+ r"\b(running|waiting|thinking|generating)\b", low
585
+ ):
586
+ return True
587
+ if re.search(r"\bwaiting\b", low) and (
588
+ "token" in low or "model" in low or "response" in low or "tool" in low
589
+ ):
590
+ return True
591
+ return False
592
+
593
+
594
+ def _is_chrome(line: str) -> bool:
595
+ s = line.strip()
596
+ if not s:
597
+ return True
598
+ if _BOXY.search(s) and len(_BOXY.findall(s)) >= 3:
599
+ return True
600
+ if s.startswith(("→ ", "→")) and ("ctrl+" in s.lower() or "stop" in s.lower()):
601
+ return True
602
+ if s.startswith("Auto ·") or "Run Everything" in s:
603
+ return True
604
+ if _STATUSY.search(s) and len(s) < 140:
605
+ # Status footers / progress — skip as chat content.
606
+ if "tokens" in s.lower() or "goal active" in s.lower() or s.startswith(("⠠", "⠰", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")):
607
+ return True
608
+ if re.match(r"^[⠁-⣿\s]+", s):
609
+ return True
610
+ if s in {"1 task", "Press Ctrl+C again to exit"}:
611
+ return True
612
+ if s.startswith("Tip:") and len(s) < 100:
613
+ return True
614
+ if re.match(r"^~?/.*·\s*(main|master|develop)", s):
615
+ return True
616
+ return False
617
+
618
+
619
+ def _is_shell_echo(line: str) -> bool:
620
+ # Classic zsh/bash prompt lines the user typed locally — treat as command.
621
+ if re.search(r"%\s+\S+", line) and ("@" in line or "Documents" in line or "Projects" in line or "~" in line):
622
+ # `user@host dir % cmd`
623
+ if re.search(r"[%$#]\s+\S+", line):
624
+ return True
625
+ return False
626
+
627
+
628
+ _USER_META = re.compile(
629
+ r"^(Read\b|Grepped\b|Edited\b|Monitored\b|Ran\b|Waited\b|Finished\b|Found \d|SyntaxError|File \"|<stdin>)",
630
+ re.I,
631
+ )
632
+ _TOOL_START = re.compile(
633
+ r"^(Read\b|Read,|Grepped\b|Edited\b|Monitored\b|Ran\b|Waited\b|Finished\b)",
634
+ re.I,
635
+ )
636
+
637
+
638
+ def _tool_line_text(line: str) -> str:
639
+ if line.startswith(" ") and not line.startswith(" "):
640
+ return _strip_assistant_line(line)
641
+ return line.rstrip()
642
+
643
+
644
+ def _is_tool_start_line(line: str) -> bool:
645
+ if not line.strip() or _CMD_START.match(line):
646
+ return False
647
+ return bool(_TOOL_START.match(_tool_line_text(line).strip()))
648
+
649
+
650
+ def _is_tool_block_continuation(line: str) -> bool:
651
+ if not line.strip():
652
+ return True
653
+ if _HIDDEN.match(line):
654
+ return True
655
+ if line.startswith(" ") or line.startswith("\t"):
656
+ return True
657
+ stripped = line.strip()
658
+ if stripped.startswith("▎"):
659
+ return True
660
+ if _is_tool_start_line(line):
661
+ return True
662
+ if re.match(r"^Found \d+\s+match", stripped, re.I):
663
+ return True
664
+ if line.startswith(" ") and not line.startswith(" "):
665
+ inner = _strip_assistant_line(line)
666
+ if _TOOL_START.match(inner):
667
+ return True
668
+ if inner.startswith("…"):
669
+ return True
670
+ if re.search(r"\.(html|py|js|tsx|ts|json|md|mjs|css|txt)\b", inner, re.I):
671
+ return True
672
+ return False
673
+
674
+
675
+ def _split_message_tools(text: str) -> list[ChatMessage]:
676
+ """Pull Read/Grep/Edit/Monitored blocks out of assistant prose."""
677
+ lines = text.splitlines()
678
+ out: list[ChatMessage] = []
679
+ abuf: list[str] = []
680
+ i = 0
681
+ while i < len(lines):
682
+ line = lines[i]
683
+ if _is_tool_start_line(line):
684
+ if abuf:
685
+ body = "\n".join(abuf).strip()
686
+ abuf = []
687
+ if body:
688
+ out.append(ChatMessage(role="assistant", text=body, kind="message"))
689
+ block = [_tool_line_text(line)]
690
+ i += 1
691
+ while i < len(lines) and _is_tool_block_continuation(lines[i]):
692
+ if lines[i].strip():
693
+ block.append(lines[i].rstrip())
694
+ i += 1
695
+ body = "\n".join(block).strip()
696
+ if body:
697
+ out.append(ChatMessage(role="assistant", text=body, kind="command"))
698
+ continue
699
+ if line.strip():
700
+ abuf.append(_strip_assistant_line(line))
701
+ i += 1
702
+ if abuf:
703
+ body = "\n".join(abuf).strip()
704
+ if body:
705
+ out.append(ChatMessage(role="assistant", text=body, kind="message"))
706
+ return out
707
+
708
+
709
+ def _split_tool_messages(msgs: list[ChatMessage]) -> list[ChatMessage]:
710
+ out: list[ChatMessage] = []
711
+ for m in msgs:
712
+ if m.role != "assistant" or m.kind != "message":
713
+ out.append(m)
714
+ continue
715
+ out.extend(_split_message_tools(m.text))
716
+ return out
717
+
718
+
719
+ def _norm_chat_text(text: str) -> str:
720
+ return re.sub(r"\s+", " ", (text or "").strip())
721
+
722
+
723
+ def _is_user_padding_only(line: str) -> bool:
724
+ return bool(line.strip() == "" and len(line) >= 20)
725
+
726
+
727
+ def _is_user_input(line: str) -> bool:
728
+ """cmux agent UI right-aligns user turns with heavy trailing padding."""
729
+ if not line.strip() or _CMD_START.match(line) or _is_chrome(line):
730
+ return False
731
+ stripped = line.strip()
732
+ if stripped.startswith("▎") or "ctrl+o" in stripped or "ctrl+r" in stripped:
733
+ return False
734
+ if _HIDDEN.match(line) or _USER_META.match(stripped):
735
+ return False
736
+ lead = len(line) - len(line.lstrip(" "))
737
+ trail = len(line) - len(line.rstrip(" "))
738
+ pad = lead + trail
739
+ if lead > 4:
740
+ return False
741
+ if trail >= 10:
742
+ return True
743
+ # Wrapped user turns still sit in the ~54-col right-aligned slot.
744
+ if len(line) >= 48 and pad >= 18 and len(stripped) <= 120:
745
+ return True
746
+ return False
747
+
748
+
749
+ def _is_user_continuation(line: str) -> bool:
750
+ """Next line of a wrapped / pasted user turn (paths, lowercase, etc.)."""
751
+ if not line.strip() or _CMD_START.match(line) or _is_chrome(line):
752
+ return False
753
+ stripped = line.strip()
754
+ if stripped.startswith("▎") or _HIDDEN.match(line) or _USER_META.match(stripped):
755
+ return False
756
+ lead = len(line) - len(line.lstrip(" "))
757
+ if lead > 4:
758
+ return False
759
+ if _is_user_input(line):
760
+ return True
761
+ if stripped.startswith(("/", ".", "~")) or "\\" in stripped or "/" in stripped:
762
+ return True
763
+ if any(tok in stripped for tok in (".png", ".jpg", "Screenshot", "TemporaryItems")):
764
+ return True
765
+ if stripped and stripped[0].islower():
766
+ return True
767
+ trail = len(line) - len(line.rstrip(" "))
768
+ if trail >= 8:
769
+ return True
770
+ return False
771
+
772
+
773
+ def _strip_assistant_line(line: str) -> str:
774
+ if line.startswith(" ") and not line.startswith(" "):
775
+ return line[2:].rstrip()
776
+ return line.rstrip()
777
+
778
+
779
+ def parse_screen(text: str) -> list[ChatMessage]:
780
+ """Turn a cmux terminal dump into chat + command blocks."""
781
+ out: list[ChatMessage] = []
782
+ abuf: list[str] = []
783
+ ubuf: list[str] = []
784
+
785
+ def flush_assistant():
786
+ nonlocal abuf
787
+ body = "\n".join(abuf).strip()
788
+ abuf = []
789
+ if body:
790
+ out.append(ChatMessage(role="assistant", text=body, kind="message"))
791
+
792
+ def flush_user():
793
+ nonlocal ubuf
794
+ body = " ".join(p.strip() for p in ubuf if p.strip()).strip()
795
+ ubuf = []
796
+ if body:
797
+ out.append(ChatMessage(role="user", text=body, kind="message"))
798
+
799
+ def flush_all():
800
+ flush_user()
801
+ flush_assistant()
802
+
803
+ def flush_command(lines: list[str]):
804
+ body = "\n".join(lines).strip()
805
+ if body:
806
+ out.append(ChatMessage(role="assistant", text=body, kind="command"))
807
+
808
+ lines = text.splitlines()
809
+ i = 0
810
+ while i < len(lines):
811
+ line = lines[i]
812
+ if _is_chrome(line):
813
+ flush_all()
814
+ i += 1
815
+ continue
816
+
817
+ m = _CMD_START.match(line)
818
+ if m:
819
+ flush_all()
820
+ cmd = [m.group(1).rstrip()]
821
+ i += 1
822
+ while i < len(lines):
823
+ nxt = lines[i]
824
+ if _CMD_START.match(nxt) or _is_chrome(nxt):
825
+ break
826
+ if _HIDDEN.match(nxt):
827
+ i += 1
828
+ continue
829
+ # Indented continuation / tool output belongs to the command.
830
+ if nxt.startswith(" ") or nxt.startswith("\t") or not nxt.strip():
831
+ if nxt.strip():
832
+ cmd.append(nxt.rstrip())
833
+ i += 1
834
+ continue
835
+ # Bare output still part of command until a prose break.
836
+ if nxt.startswith(" ") and not nxt.startswith(" $"):
837
+ cmd.append(nxt.rstrip())
838
+ i += 1
839
+ continue
840
+ break
841
+ flush_command(cmd)
842
+ continue
843
+
844
+ if _is_shell_echo(line):
845
+ flush_all()
846
+ # Take the part after the final prompt marker.
847
+ parts = re.split(r"[%$#❯]\s+", line, maxsplit=1)
848
+ cmd_text = parts[-1].strip() if len(parts) > 1 else line.strip()
849
+ if cmd_text:
850
+ flush_command([cmd_text])
851
+ i += 1
852
+ continue
853
+
854
+ if _HIDDEN.match(line):
855
+ i += 1
856
+ continue
857
+
858
+ if _is_user_padding_only(line):
859
+ flush_user()
860
+ i += 1
861
+ continue
862
+
863
+ if ubuf and _is_user_continuation(line):
864
+ ubuf.append(line.strip())
865
+ i += 1
866
+ continue
867
+
868
+ if _is_user_input(line):
869
+ flush_assistant()
870
+ ubuf.append(line.strip())
871
+ i += 1
872
+ continue
873
+
874
+ if _is_tool_start_line(line):
875
+ flush_user()
876
+ flush_assistant()
877
+ tool = [_tool_line_text(line)]
878
+ i += 1
879
+ while i < len(lines) and _is_tool_block_continuation(lines[i]):
880
+ if lines[i].strip():
881
+ tool.append(lines[i].rstrip())
882
+ i += 1
883
+ flush_command(tool)
884
+ continue
885
+
886
+ flush_user()
887
+ abuf.append(_strip_assistant_line(line))
888
+ i += 1
889
+
890
+ flush_all()
891
+ return _split_tool_messages(_resplit_assistant_blobs(_coalesce(out)))
892
+
893
+
894
+ def _coalesce(msgs: list[ChatMessage]) -> list[ChatMessage]:
895
+ if not msgs:
896
+ return msgs
897
+ merged: list[ChatMessage] = [msgs[0]]
898
+ for m in msgs[1:]:
899
+ prev = merged[-1]
900
+ if prev.kind == m.kind == "message" and prev.role == m.role:
901
+ prev.text = (prev.text + "\n" + m.text).strip()
902
+ else:
903
+ merged.append(m)
904
+ # Drop tiny noise crumbs
905
+ return [m for m in merged if len(m.text.strip()) >= 2]
906
+
907
+
908
+ def _resplit_assistant_blobs(msgs: list[ChatMessage]) -> list[ChatMessage]:
909
+ """Recover user turns that were previously merged into assistant prose."""
910
+ out: list[ChatMessage] = []
911
+ for m in msgs:
912
+ if m.role != "assistant" or m.kind != "message" or "\n" not in m.text:
913
+ out.append(m)
914
+ continue
915
+ abuf: list[str] = []
916
+ ubuf: list[str] = []
917
+
918
+ def flush_a():
919
+ nonlocal abuf
920
+ body = "\n".join(abuf).strip()
921
+ abuf = []
922
+ if body:
923
+ out.append(ChatMessage(role="assistant", text=body, kind="message"))
924
+
925
+ def flush_u():
926
+ nonlocal ubuf
927
+ body = " ".join(p.strip() for p in ubuf if p.strip()).strip()
928
+ ubuf = []
929
+ if body:
930
+ out.append(ChatMessage(role="user", text=body, kind="message"))
931
+
932
+ for line in m.text.splitlines():
933
+ if _is_user_padding_only(line):
934
+ flush_u()
935
+ continue
936
+ if ubuf and _is_user_continuation(line):
937
+ ubuf.append(line.strip())
938
+ continue
939
+ if _is_user_input(line):
940
+ flush_a()
941
+ ubuf.append(line.strip())
942
+ continue
943
+ flush_u()
944
+ abuf.append(_strip_assistant_line(line))
945
+ flush_u()
946
+ flush_a()
947
+ return out or msgs
948
+
949
+
950
+ def _dedupe_user_messages(msgs: list[ChatMessage]) -> list[ChatMessage]:
951
+ drop: set[int] = set()
952
+ user_idxs = [i for i, m in enumerate(msgs) if m.role == "user"]
953
+ norms = [_norm_chat_text(msgs[i].text) for i in user_idxs]
954
+ for ai, ni in enumerate(norms):
955
+ for bi, nj in enumerate(norms):
956
+ if ai == bi or not ni or not nj or ni == nj:
957
+ continue
958
+ if len(ni) < len(nj) and ni in nj:
959
+ drop.add(user_idxs[ai])
960
+ elif len(nj) < len(ni) and nj in ni:
961
+ drop.add(user_idxs[bi])
962
+ return [m for i, m in enumerate(msgs) if i not in drop]
963
+
964
+
965
+ def merge_sent(parsed: list[ChatMessage], sent: list[str]) -> list[ChatMessage]:
966
+ """Append phone sends; drop fragment crumbs the screen parser split out."""
967
+ if not sent:
968
+ return _dedupe_user_messages(parsed)
969
+ sent_norms = [_norm_chat_text(t) for t in sent if _norm_chat_text(t)]
970
+ sent_set = set(sent_norms)
971
+
972
+ def is_user_crumb(text: str) -> bool:
973
+ n = _norm_chat_text(text)
974
+ if not n or n in sent_set:
975
+ return False
976
+ return any(len(n) >= 3 and n in sn for sn in sent_norms)
977
+
978
+ base = [m for m in parsed if not (m.role == "user" and is_user_crumb(m.text))]
979
+ existing = {_norm_chat_text(m.text) for m in base if m.role == "user"}
980
+ extras = [
981
+ ChatMessage(role="user", text=t, kind="message")
982
+ for t in sent
983
+ if _norm_chat_text(t) not in existing
984
+ ]
985
+ return _dedupe_user_messages(base + extras)