ccx-native 0.0.1 → 0.2.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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,1041 @@
1
+ """Read and search worker histories as text: ``ccx history``.
2
+
3
+ A projection of the normalized conversation document
4
+ (:mod:`ccx.ops.conversation`), never a second parser: every message keeps the
5
+ document's turn ``sequence`` and item ``ordinal``, so an address ``#TURN.N``
6
+ means the same item in ``ccx history``, ``ccx logs`` and ``peek --since-turn``.
7
+
8
+ The projection maps normalized items to a small message model shared by both
9
+ engines: prompts (``U``), steers (``U+``), notifications and events
10
+ (``N[kind]``), assistant text (``A``, final ``A*``, a running turn's latest
11
+ ``A…``), tool calls (``T``), slash commands (``/``), interrupts (``!``) and
12
+ compactions (``══``). Reasoning is never shown. Everything here is read-only
13
+ text rendering over documents the caller already read.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import datetime as dt
18
+ import json
19
+ import os
20
+ import re
21
+ import shlex
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from typing import Callable, TextIO
25
+
26
+ from ccx.claude.transcript import _parse_task_notification
27
+ from ccx.errors import InvalidRequest
28
+
29
+ # --- message model -------------------------------------------------------------
30
+
31
+ ROLES = ("user", "notify", "assistant", "final", "tool", "output", "compact")
32
+ VIEW_ROLES = {"chat": {"user", "notify", "final"},
33
+ "trace": {"user", "notify", "assistant", "tool"}}
34
+ SEARCH_ROLES = {"user", "assistant", "notify", "tool"}
35
+ VIEWS = ("outline", "chat", "trace", "map")
36
+
37
+ DEFAULT_WINDOW = 30 # turns shown with no selector (one worker)
38
+ DEFAULT_MANY_WINDOW = 10 # per worker when several are named
39
+ DEFAULT_LIMIT = 20 # hit lines per worker
40
+ DEFAULT_MAX_CHARS = 3000
41
+ MAP_GAP_HOURS = 6.0
42
+
43
+
44
+ @dataclass
45
+ class Message:
46
+ turn: int
47
+ n: int
48
+ role: str # user steer notify assistant tool compact command interrupt
49
+ text: str = ""
50
+ ts: float | None = None # epoch seconds, when the source records it
51
+ kind: str | None = None # notify kind: bg monitor agent cron system error <namespace>
52
+ final: str | None = None # "final" (A*) or "latest" (A…, turn still running)
53
+ tool: str | None = None
54
+ cmd: str | None = None
55
+ desc: str | None = None
56
+ out: str = "" # tool output, notification body, compaction summary
57
+ out_cut: tuple[int, int] | None = None # (returned, original) bytes when the source bound cut it
58
+ err: bool = False
59
+ dur: float | None = None
60
+
61
+
62
+ @dataclass
63
+ class Turn:
64
+ seq: int
65
+ start: float | None
66
+ end: float | None
67
+ status: str | None
68
+ messages: list[Message] = field(default_factory=list)
69
+
70
+
71
+ @dataclass
72
+ class History:
73
+ engine: str
74
+ short: str
75
+ name: str | None
76
+ cwd: str | None
77
+ turns: list[Turn]
78
+ notes: list[str] = field(default_factory=list)
79
+
80
+ def messages(self) -> list[Message]:
81
+ return [m for t in self.turns for m in t.messages]
82
+
83
+
84
+ def _epoch_iso(value) -> float | None:
85
+ if not isinstance(value, str) or not value:
86
+ return None
87
+ try:
88
+ return dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(
89
+ tzinfo=dt.timezone.utc).timestamp()
90
+ except ValueError:
91
+ return None
92
+
93
+
94
+ def _epoch_ms(value) -> float | None:
95
+ return value / 1000.0 if isinstance(value, int) and not isinstance(value, bool) else None
96
+
97
+
98
+ _COMPACT_HEAD = re.compile(r"\AThis session is being continued from a previous conversation.*?\n\n"
99
+ r"(?:<system-reminder>.*?</system-reminder>\s*)?", re.S)
100
+ _COMPACT_TAIL = re.compile(r"\n+If you need specific details from before compaction.*\Z", re.S)
101
+
102
+
103
+ def strip_compact_wrapper(text: str) -> str:
104
+ """Drop Claude Code's resume boilerplate around a compaction summary."""
105
+ return _COMPACT_TAIL.sub("", _COMPACT_HEAD.sub("", text or "")).strip()
106
+
107
+
108
+ _XML_TAG = re.compile(r"<(result|event|note)>(.*?)</\1>", re.S)
109
+
110
+
111
+ def _notification(text: str) -> tuple[str, str, str] | None:
112
+ """``(kind, one-line summary, body)`` for a Claude ``<task-notification>``."""
113
+ parsed = _parse_task_notification(text)
114
+ if parsed is None:
115
+ return None
116
+ summary = (parsed.get("summary") or "").strip()
117
+ kind = ("monitor" if summary.startswith("Monitor") or parsed.get("event")
118
+ else "agent" if summary.startswith("Agent") else "bg")
119
+ body = "\n".join(v.strip() for _k, v in _XML_TAG.findall(text) if v.strip())
120
+ return kind, summary or (parsed.get("status") or "") or text[:200], body
121
+
122
+
123
+ _JSON_FIELD = re.compile(r'"(\w+)"\s*:\s*("(?:[^"\\]|\\.)*("|$)|-?\d+(?:\.\d+)?|true|false|null)')
124
+
125
+
126
+ def _json_fields(raw: str) -> dict:
127
+ """The fields of a JSON object, also from one cut short by a result bound.
128
+
129
+ A cut payload keeps its leading string and number fields; an unterminated
130
+ last string keeps what arrived."""
131
+ try:
132
+ body = json.loads(raw)
133
+ return body if isinstance(body, dict) else {}
134
+ except ValueError:
135
+ pass
136
+ fields: dict = {}
137
+ for key, value, closed in _JSON_FIELD.findall(raw or ""):
138
+ if key in fields:
139
+ continue
140
+ if value.startswith('"') and not closed:
141
+ value = value.rstrip("\\") + '"'
142
+ try:
143
+ fields[key] = json.loads(value)
144
+ except ValueError:
145
+ continue
146
+ return fields
147
+
148
+
149
+ def _injected_output(item: dict) -> str:
150
+ """The text of a Codex namespaced wake-up output: who, phase, status, report."""
151
+ raw = (item.get("result") or {}).get("content") or ""
152
+ if not isinstance(raw, str):
153
+ raw = json.dumps(raw)
154
+ body = _json_fields(raw)
155
+ if not body:
156
+ return raw
157
+ report = next((str(body[k]) for k in ("report", "message", "summary", "activity")
158
+ if isinstance(body.get(k), str) and body[k]), "")
159
+ who = next((str(body[k]) for k in ("worker", "sessionId", "work_id", "workId")
160
+ if isinstance(body.get(k), str) and body[k]), "")
161
+ status = body.get("status") or body.get("state")
162
+ head = " ".join(str(x) for x in (who, body.get("phase"), status) if x)
163
+ return f"{head}: {report}" if head else report
164
+
165
+
166
+ def _shell_command(item: dict) -> str:
167
+ """The command a shell call ran; Codex already unwraps ``bash -lc``."""
168
+ actions = ((item.get("data") or {}).get("commandActions")) or []
169
+ commands = [a.get("command") for a in actions
170
+ if isinstance(a, dict) and isinstance(a.get("command"), str) and a["command"]]
171
+ if commands:
172
+ return "\n".join(commands)
173
+ command = (item.get("arguments") or {}).get("command")
174
+ if isinstance(command, list):
175
+ command = command[-1] if len(command) == 3 else " ".join(map(str, command))
176
+ command = command if isinstance(command, str) else ""
177
+ m = re.match(r"^\S*(?:bash|sh|zsh) -l?c '(.*)'$", command, re.S)
178
+ return m.group(1).replace("'\\''", "'") if m else command
179
+
180
+
181
+ def _tool_message(item: dict, base: dict) -> Message:
182
+ name = item.get("name") or item.get("nativeType") or "tool"
183
+ summary = item.get("summary") if isinstance(item.get("summary"), dict) else {}
184
+ if name in ("Bash", "shell"):
185
+ cmd = _shell_command(item)
186
+ elif summary.get("target"):
187
+ cmd = summary["target"]
188
+ else:
189
+ args = item.get("arguments")
190
+ cmd = json.dumps(args, ensure_ascii=False)[:300] if args else ""
191
+ result = item.get("result") if isinstance(item.get("result"), dict) else {}
192
+ started, completed = item.get("startedAtMs"), item.get("completedAtMs")
193
+ dur = ((completed - started) / 1000.0 if isinstance(started, int)
194
+ and isinstance(completed, int) and completed >= started else None)
195
+ out = result.get("content")
196
+ out_cut = ((result.get("returnedByteCount") or 0, result.get("originalByteCount") or 0)
197
+ if result.get("truncated") else None)
198
+ return Message(**base, role="tool", tool=name, cmd=cmd, desc=summary.get("description"),
199
+ out=out if isinstance(out, str) else ("" if out is None else json.dumps(out)),
200
+ out_cut=out_cut,
201
+ err=bool(result.get("isError")) or item.get("status") in ("failed", "declined"),
202
+ dur=dur)
203
+
204
+
205
+ def _project_item(item: dict, base: dict, cron: dict | None) -> Message | None:
206
+ kind, native_type = item.get("kind"), item.get("nativeType")
207
+ text = item.get("text") if isinstance(item.get("text"), str) else ""
208
+ if kind == "user-text":
209
+ if cron is not None:
210
+ return Message(**base, role="notify", kind="cron", text=text)
211
+ if item.get("meta"):
212
+ if text.lstrip().startswith("<local-command-caveat>"):
213
+ return None
214
+ return Message(**base, role="notify", kind="system", text=text)
215
+ note = _notification(text)
216
+ if note is not None:
217
+ k, summary, body = note
218
+ return Message(**base, role="notify", kind=k, text=summary, out=body)
219
+ if text.startswith("[Request interrupted by user"):
220
+ return Message(**base, role="interrupt", text=text)
221
+ if text.lstrip().startswith(("<local-command-stdout>", "<local-command-stderr>")):
222
+ return None
223
+ return Message(**base, role="user", text=text)
224
+ if kind == "command":
225
+ args = (item.get("arguments") or {}).get("args")
226
+ return Message(**base, role="command",
227
+ text=f"{item.get('name') or ''}{' ' + args if args else ''}")
228
+ if kind in ("assistant-text", "assistant-plan"):
229
+ return Message(**base, role="assistant", text=text, kind=item.get("phase"))
230
+ if kind == "tool-result" and item.get("namespace"):
231
+ return Message(**base, role="notify", kind=item["namespace"], text=_injected_output(item))
232
+ if kind in ("tool-call", "tool-result", "file-change", "subagent-action",
233
+ "subagent-activity", "action"):
234
+ return _tool_message(item, base)
235
+ if kind == "system-text" and native_type in ("compact_boundary", "contextCompaction"):
236
+ data = item.get("data") if isinstance(item.get("data"), dict) else {}
237
+ bits = [b for b in (data.get("trigger"),
238
+ f"{data['preTokens'] // 1000}k tokens"
239
+ if isinstance(data.get("preTokens"), int) else None) if b]
240
+ summary = strip_compact_wrapper(data.get("summary") or "")
241
+ return Message(**base, role="compact", text=" · ".join(bits), out=summary)
242
+ if kind == "system-text":
243
+ return Message(**base, role="notify", kind="system", text=text)
244
+ if kind == "error":
245
+ return Message(**base, role="notify", kind="error", text=str(item.get("message") or ""))
246
+ if kind == "other" and native_type == "system":
247
+ native = item.get("native") if isinstance(item.get("native"), dict) else {}
248
+ if native.get("subtype") == "informational":
249
+ return Message(**base, role="notify", kind="system",
250
+ text=str(native.get("content") or ""))
251
+ return None # attachments, reasoning summaries, bookkeeping: not shown
252
+
253
+
254
+ def project(doc: dict, *, name: str | None = None) -> History:
255
+ """Map one normalized document to the history message model."""
256
+ worker = doc.get("worker") or {}
257
+ busy = doc.get("state") == "busy"
258
+ turns: list[Turn] = []
259
+ cron: dict | None = None
260
+ for t in doc.get("turns") or []:
261
+ seq = t.get("sequence")
262
+ turn = Turn(seq, _epoch_iso(t.get("startedAt")), _epoch_iso(t.get("completedAt")),
263
+ t.get("status"))
264
+ for item in sorted(t.get("input", []) + t.get("output", []),
265
+ key=lambda i: i.get("ordinal") or 0):
266
+ native = item.get("native") if isinstance(item.get("native"), dict) else {}
267
+ if item.get("kind") == "other" and native.get("subtype") == "scheduled_task_fire":
268
+ cron = native # its prompt is the next user row
269
+ continue
270
+ base = {"turn": seq, "n": item.get("ordinal"), "ts": _epoch_ms(item.get("startedAtMs"))}
271
+ message = _project_item(item, base, cron)
272
+ if item.get("kind") == "user-text":
273
+ cron = None
274
+ if message is not None:
275
+ turn.messages.append(message)
276
+ _label_turn(turn, worker.get("engine"))
277
+ turns.append(turn)
278
+ if turns and (busy or turns[-1].status == "in-progress"):
279
+ for m in reversed(turns[-1].messages):
280
+ if m.role == "assistant":
281
+ for other in turns[-1].messages:
282
+ if other.final:
283
+ other.final = None
284
+ m.final = "latest"
285
+ break
286
+ completeness = doc.get("completeness") or {}
287
+ notes = list(completeness.get("warnings") or []) if completeness.get("status") in (
288
+ "partial", "source-limited") else []
289
+ return History(engine=worker.get("engine") or "?", short=worker.get("short") or "?",
290
+ name=name, cwd=worker.get("cwd"), turns=turns, notes=notes)
291
+
292
+
293
+ def _label_turn(turn: Turn, engine: str | None) -> None:
294
+ """Prompt vs steer, and the final answer, from each turn's own messages."""
295
+ prompted = False
296
+ for m in turn.messages:
297
+ if m.role in ("user", "notify", "command"):
298
+ if prompted and m.role == "user":
299
+ m.role = "steer"
300
+ prompted = True
301
+ texts = [m for m in turn.messages if m.role == "assistant"]
302
+ if not texts:
303
+ return
304
+ if engine == "codex" and any(m.kind for m in texts):
305
+ finals = [m for m in texts if m.kind == "final_answer"]
306
+ if finals:
307
+ finals[-1].final = "final"
308
+ return
309
+ texts[-1].final = "final"
310
+
311
+
312
+ # --- shell command words -------------------------------------------------------
313
+
314
+ _HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n\s*\2\s*(?=\n|$)", re.S)
315
+ # wrapper -> its options that take a separate argument
316
+ _WRAPPERS = {
317
+ "sudo": {"-u", "-g", "-C", "-D"}, "env": {"-u", "-C", "-S", "--unset", "--chdir"},
318
+ "timeout": {"-s", "-k", "--signal", "--kill-after"}, "nice": {"-n"},
319
+ "xargs": {"-I", "-n", "-P", "-L", "-d", "-a", "-E", "-s"},
320
+ "uv": {"--with", "--python", "-p", "--project", "--directory", "--group", "--extra"},
321
+ "run": {"--with", "--python", "-p", "--project", "--directory", "--group", "--extra"},
322
+ "uvx": {"--from", "--with"}, "npx": {"-p", "--package"}, "ionice": {"-c", "-n"},
323
+ "time": set(), "nohup": set(), "exec": set(), "command": set(), "stdbuf": set(),
324
+ "caffeinate": set(),
325
+ # shell keywords that precede a command
326
+ "if": set(), "then": set(), "elif": set(), "else": set(), "while": set(),
327
+ "until": set(), "do": set(), "!": set(), "{": set(),
328
+ }
329
+ _KEYWORDS = {"fi", "done", "for", "in", "esac", "case", "function", "}", "]]", "[["}
330
+ _SHELLS = {"bash", "sh", "zsh"}
331
+
332
+
333
+ def _segments(cmd: str) -> list[str]:
334
+ """Split on unquoted ``; & | && || newline $( ` ( )``; heredoc bodies and
335
+ ``$(( ))`` arithmetic dropped, ``\\``-newline continuations joined, and
336
+ redirections such as ``2>&1`` kept whole."""
337
+ cmd = _HEREDOC.sub("<<HEREDOC", (cmd or "").replace("\\\n", " "))
338
+ segs, cur, quote, i = [], [], None, 0
339
+ while i < len(cmd):
340
+ c = cmd[i]
341
+ if quote:
342
+ if c == "\\" and quote == '"':
343
+ cur.append(cmd[i:i + 2])
344
+ i += 2
345
+ continue
346
+ if c == quote:
347
+ quote = None
348
+ cur.append(c)
349
+ elif c in "'\"":
350
+ quote = c
351
+ cur.append(c)
352
+ elif c == "\\":
353
+ cur.append(cmd[i:i + 2])
354
+ i += 2
355
+ continue
356
+ elif cmd.startswith("$((", i):
357
+ end = cmd.find("))", i + 3)
358
+ i = len(cmd) if end < 0 else end + 2
359
+ cur.append("0") # arithmetic is a value, not a command
360
+ continue
361
+ elif c == "&" and ((cur and cur[-1] in "<>") or cmd[i + 1:i + 2] == ">"):
362
+ cur.append(c) # 2>&1, >&2, &>file, &>>file
363
+ elif c == "|" and cur and cur[-1] == ">":
364
+ cur.append(c) # >| (noclobber override)
365
+ elif c in ";&|\n`()":
366
+ segs.append("".join(cur))
367
+ cur = []
368
+ elif c == "$" and cmd[i + 1:i + 2] == "(":
369
+ segs.append("".join(cur))
370
+ cur = []
371
+ i += 2
372
+ continue
373
+ elif c == "#" and (not cur or cur[-1] in " \t"):
374
+ while i < len(cmd) and cmd[i] != "\n":
375
+ i += 1
376
+ continue
377
+ else:
378
+ cur.append(c)
379
+ i += 1
380
+ segs.append("".join(cur))
381
+ return segs
382
+
383
+
384
+ def command_words(cmd: str, _depth: int = 0) -> list[str]:
385
+ """The command name of each pipeline/list segment, after env assignments,
386
+ wrappers (with their option arguments) and leading shell keywords;
387
+ ``bash -c '…'`` is unwrapped and ``python3.x`` reads as ``python``."""
388
+ words: list[str] = []
389
+ for seg in _segments(cmd):
390
+ toks = seg.strip().split()
391
+ wrapper = None
392
+ while toks:
393
+ t = toks[0]
394
+ if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", t):
395
+ toks.pop(0)
396
+ elif t in _WRAPPERS:
397
+ wrapper = t
398
+ toks.pop(0)
399
+ elif wrapper is not None and t.startswith("-"):
400
+ toks.pop(0)
401
+ if t in _WRAPPERS[wrapper] and toks:
402
+ toks.pop(0) # the option's argument
403
+ elif wrapper == "timeout" and re.match(r"^\d+(\.\d+)?[smhd]?$", t):
404
+ toks.pop(0) # the duration
405
+ else:
406
+ break
407
+ if not toks:
408
+ continue
409
+ word = os.path.basename(toks[0].strip("'\"{}"))
410
+ word = re.sub(r"^python3(\.\d+)?$", "python", word)
411
+ if not word or not re.match(r"^[\w.+-]+$", word) or word in _KEYWORDS:
412
+ continue
413
+ if word in _SHELLS and _depth < 2:
414
+ inner = _shell_c_argument(seg)
415
+ if inner is not None:
416
+ words.extend(command_words(inner, _depth + 1))
417
+ continue
418
+ words.append(word)
419
+ if word == "python":
420
+ module = _python_module(toks[1:])
421
+ if module:
422
+ words.append(module)
423
+ return words
424
+
425
+
426
+ def _python_module(args: list[str]) -> str | None:
427
+ """MODULE of ``python [options] -m MODULE``: ``python -m pytest`` runs pytest."""
428
+ i = 0
429
+ while i < len(args):
430
+ arg = args[i]
431
+ if arg == "-m":
432
+ module = args[i + 1].strip("'\"") if i + 1 < len(args) else ""
433
+ return module if re.fullmatch(r"[\w.]+", module) else None
434
+ if arg in ("-X", "-W"):
435
+ i += 2
436
+ continue
437
+ if not arg.startswith("-") or arg == "-c":
438
+ return None # a script, `-` (stdin) or -c code: no module
439
+ i += 1
440
+ return None
441
+
442
+
443
+ def _shell_c_argument(segment: str) -> str | None:
444
+ """The script of ``bash -c '…'`` / ``sh -lc "…"``, or None."""
445
+ try:
446
+ toks = shlex.split(segment)
447
+ except ValueError:
448
+ return None
449
+ for i, tok in enumerate(toks[1:], 1):
450
+ if re.fullmatch(r"-[a-z]*c[a-z]*", tok):
451
+ return toks[i + 1] if i + 1 < len(toks) else None
452
+ if not tok.startswith("-"):
453
+ return None
454
+ return None
455
+
456
+
457
+ # --- time ---------------------------------------------------------------------
458
+
459
+ _TIME_FORMS = "2026-11-02, 2026-11-02T14:00, '11-02 14:00', 14:00 (today), or 90m/6h/2d (ago)"
460
+
461
+
462
+ def parse_time(value: str | None, *, end: bool = False, now: float | None = None) -> float | None:
463
+ """Epoch seconds for ``--since``/``--until``; a bare ``--until`` date includes that day."""
464
+ if value is None:
465
+ return None
466
+ v = value.strip()
467
+ now_s = time.time() if now is None else now
468
+ m = re.fullmatch(r"(\d+)([mhd])", v)
469
+ if m:
470
+ return now_s - int(m.group(1)) * {"m": 60, "h": 3600, "d": 86400}[m.group(2)]
471
+ today = dt.datetime.fromtimestamp(now_s)
472
+ if re.fullmatch(r"\d{1,2}:\d{2}", v):
473
+ v = f"{today:%Y-%m-%d} {v}"
474
+ elif re.fullmatch(r"\d{2}-\d{2}( .*)?", v):
475
+ v = f"{today.year}-{v}"
476
+ try:
477
+ moment = dt.datetime.fromisoformat(v.replace(" ", "T"))
478
+ except ValueError:
479
+ raise InvalidRequest(f"cannot read time {value!r}; use {_TIME_FORMS}") from None
480
+ if moment.tzinfo is not None:
481
+ return moment.timestamp()
482
+ if end and re.fullmatch(r"\d{4}-\d{2}-\d{2}", v):
483
+ moment += dt.timedelta(days=1)
484
+ return moment.timestamp()
485
+
486
+
487
+ _YEAR = dt.datetime.now().year
488
+
489
+
490
+ def _clock(ts: float | None, full: bool = False) -> str:
491
+ if not ts:
492
+ return "--"
493
+ d = dt.datetime.fromtimestamp(ts)
494
+ if not full:
495
+ return d.strftime("%H:%M")
496
+ return d.strftime("%m-%d %H:%M") if d.year == _YEAR else d.strftime("%Y-%m-%d %H:%M")
497
+
498
+
499
+ def _msg_clock(ts: float | None, turn_ts: float | None) -> str:
500
+ """HH:MM inside a turn; the date too when the turn crossed midnight."""
501
+ if not ts:
502
+ return ""
503
+ if turn_ts and dt.date.fromtimestamp(ts) != dt.date.fromtimestamp(turn_ts):
504
+ return " " + _clock(ts, True)
505
+ return " " + _clock(ts)
506
+
507
+
508
+ def _dur(s: float | None) -> str:
509
+ if s is None:
510
+ return "?"
511
+ s = int(s)
512
+ if s < 60:
513
+ return f"{s}s"
514
+ if s < 3600:
515
+ return f"{s // 60}m{s % 60:02d}"
516
+ return f"{s // 3600}h{(s % 3600) // 60:02d}"
517
+
518
+
519
+ def zone_name() -> str:
520
+ tz = os.environ.get("TZ")
521
+ if tz:
522
+ return tz.lstrip(":")
523
+ try:
524
+ link = os.path.realpath("/etc/localtime")
525
+ except OSError:
526
+ link = ""
527
+ return link.split("zoneinfo/")[-1] if "zoneinfo/" in link else (time.tzname[0] or "local")
528
+
529
+
530
+ # --- selection ----------------------------------------------------------------
531
+
532
+ @dataclass
533
+ class Selection:
534
+ turns: set[int] | None = None # None: no --turns
535
+ messages: set[tuple[int, int]] = field(default_factory=set)
536
+
537
+
538
+ _TURN_PART = re.compile(r"\d+(\.\d+)?|\d+-\d*|-\d+")
539
+
540
+
541
+ def check_turns(spec: str | None) -> None:
542
+ for part in (spec or "").replace("#", "").split(","):
543
+ if spec is not None and not _TURN_PART.fullmatch(part.strip()):
544
+ raise InvalidRequest(
545
+ f"cannot read --turns part {part!r}; use 12, 10-14, 120-, -5 (the last five) "
546
+ "or 184.0 (one message), comma-separated")
547
+
548
+
549
+ def parse_turns(spec: str | None, last: int) -> Selection:
550
+ if spec is None:
551
+ return Selection()
552
+ check_turns(spec)
553
+ sel = Selection(turns=set())
554
+ for part in spec.replace("#", "").split(","):
555
+ part = part.strip()
556
+ if "." in part:
557
+ t, k = map(int, part.split("."))
558
+ sel.messages.add((t, k))
559
+ elif part.startswith("-"):
560
+ sel.turns |= set(range(max(1, last - int(part[1:]) + 1), last + 1))
561
+ elif "-" in part:
562
+ a, b = part.split("-")
563
+ sel.turns |= set(range(int(a), (int(b) if b else last) + 1))
564
+ else:
565
+ sel.turns.add(int(part))
566
+ return sel
567
+
568
+
569
+ def smart_regex(pattern: str | None, what: str = "--grep") -> re.Pattern | None:
570
+ """ripgrep-style smart case: an all-lowercase pattern is case-insensitive."""
571
+ if pattern is None:
572
+ return None
573
+ flags = 0 if re.search(r"[A-Z]", re.sub(r"\\.", "", pattern)) else re.I
574
+ try:
575
+ return re.compile(pattern, flags)
576
+ except re.error as error:
577
+ raise InvalidRequest(f"{what}: invalid regular expression: {error}") from None
578
+
579
+
580
+ def roles_of(m: Message) -> set[str]:
581
+ if m.role in ("user", "steer", "command", "interrupt"):
582
+ return {"user"}
583
+ if m.role == "assistant":
584
+ return {"assistant", "final"} if m.final else {"assistant"}
585
+ if m.role == "tool":
586
+ return {"tool", "output"}
587
+ return {m.role}
588
+
589
+
590
+ @dataclass
591
+ class Options:
592
+ view: str | None = None
593
+ turns: str | None = None
594
+ grep: str | None = None
595
+ cmd: str | None = None
596
+ errors: bool = False
597
+ roles: set[str] | None = None
598
+ since: float | None = None
599
+ until: float | None = None
600
+ context: int | None = None
601
+ limit: int = DEFAULT_LIMIT
602
+ max_chars: int | None = None
603
+ full: bool = False
604
+
605
+ @property
606
+ def search(self) -> bool:
607
+ return bool(self.grep or self.cmd or self.errors)
608
+
609
+ @property
610
+ def hits_mode(self) -> bool:
611
+ return self.search and self.view is None and self.context is None
612
+
613
+ def needs_full_results(self) -> bool:
614
+ """Read tool results unbounded when output is searched or shown, or a
615
+ message is read by address: a cut result would hide its real end."""
616
+ addressed = self.turns is not None and any(
617
+ "." in p for p in self.turns.replace("#", "").split(","))
618
+ return addressed or bool(self.roles and "output" in self.roles)
619
+
620
+
621
+ def options(*, view=None, turns=None, grep=None, cmd=None, errors=False, role=None,
622
+ since=None, until=None, context=None, limit=DEFAULT_LIMIT, max_chars=None,
623
+ full=False, now: float | None = None) -> Options:
624
+ """Validate CLI arguments before any worker is read."""
625
+ if view is not None and view not in VIEWS:
626
+ raise InvalidRequest(f"--view must be one of {', '.join(VIEWS)}")
627
+ roles = None
628
+ if role is not None:
629
+ roles = {r.strip() for r in role.split(",") if r.strip()}
630
+ bad = roles - set(ROLES)
631
+ if bad or not roles:
632
+ raise InvalidRequest(f"--role: unknown role {','.join(sorted(bad)) or '(none given)'} "
633
+ f"(use {','.join(ROLES)})")
634
+ check_turns(turns)
635
+ smart_regex(grep)
636
+ smart_regex(cmd, "--cmd")
637
+ for name, value in (("--limit", limit), ("--max-chars", max_chars)):
638
+ if value is not None and value < 1:
639
+ raise InvalidRequest(f"{name} must be at least 1")
640
+ if context is not None and context < 0:
641
+ raise InvalidRequest("-C must be zero or more")
642
+ return Options(view=view, turns=turns, grep=grep, cmd=cmd, errors=errors, roles=roles,
643
+ since=parse_time(since, now=now), until=parse_time(until, end=True, now=now),
644
+ context=context, limit=limit, max_chars=max_chars, full=full)
645
+
646
+
647
+ def matcher(opts: Options, turn_span: Callable[[Message], tuple]) -> Callable[[Message], bool] | None:
648
+ """Message predicate for the search and time filters, or None when unfiltered."""
649
+ if not (opts.search or opts.since or opts.until):
650
+ return None
651
+ rx = smart_regex(opts.grep)
652
+ cw = re.compile(opts.cmd, re.I) if opts.cmd else None
653
+ scopes = opts.roles or SEARCH_ROLES
654
+
655
+ def in_time(m: Message) -> bool:
656
+ if m.ts is not None:
657
+ return not ((opts.since and m.ts < opts.since) or (opts.until and m.ts >= opts.until))
658
+ start, end = turn_span(m) # no per-message time: the turn's span
659
+ if opts.since and (end or start or 0) < opts.since:
660
+ return False
661
+ return not (opts.until and (start or 0) >= opts.until)
662
+
663
+ def keep(m: Message) -> bool:
664
+ if opts.errors and not (m.role == "tool" and m.err):
665
+ return False
666
+ if (opts.since or opts.until) and not in_time(m):
667
+ return False
668
+ if cw is not None and (m.role != "tool" or not any(
669
+ cw.fullmatch(w) for w in command_words(m.cmd or ""))):
670
+ return False
671
+ if rx is None:
672
+ return True
673
+ fields: list[str] = []
674
+ if m.role in ("user", "steer", "command", "interrupt") and "user" in scopes:
675
+ fields.append(m.text)
676
+ if m.role == "notify" and "notify" in scopes:
677
+ fields += [m.text, m.out]
678
+ if m.role == "assistant" and ("assistant" in scopes or ("final" in scopes and m.final)):
679
+ fields.append(m.text)
680
+ if m.role == "tool":
681
+ if "tool" in scopes:
682
+ fields += [m.cmd or "", m.desc or ""]
683
+ if "output" in scopes:
684
+ fields.append(m.out)
685
+ if m.role == "compact" and "compact" in scopes:
686
+ fields.append(m.out)
687
+ return any(rx.search(f) for f in fields if f)
688
+
689
+ return keep
690
+
691
+
692
+ # --- rendering ----------------------------------------------------------------
693
+
694
+ _TAGS = {"user": "U", "steer": "U+", "notify": "N", "assistant": "A", "tool": "T",
695
+ "compact": "══", "command": "/", "interrupt": "!"}
696
+
697
+
698
+ def tag(m: Message) -> str:
699
+ if m.final == "final":
700
+ return "A*"
701
+ if m.final == "latest":
702
+ return "A…"
703
+ if m.role == "notify" and m.kind:
704
+ return f"N[{m.kind}]"
705
+ return _TAGS[m.role]
706
+
707
+
708
+ def _one(text: str, n: int) -> str:
709
+ text = " ".join((text or "").split())
710
+ return text if len(text) <= n else text[: n - 1] + "…"
711
+
712
+
713
+ def cut(text: str, n: int | None, *, keep_end: bool = False) -> str:
714
+ # Don't cut for a remainder shorter than the marker itself.
715
+ if n is None or len(text) <= n + 40:
716
+ return text
717
+ if keep_end: # compaction summaries end with current work / next step
718
+ return f"[… {len(text) - n} earlier chars]\n" + text[-n:]
719
+ return text[:n] + f"\n[… {len(text) - n} more chars]"
720
+
721
+
722
+ def tool_label(m: Message, commands: bool, limit: int | None = 160) -> str:
723
+ """One line; with ``limit=None`` the whole command, newlines kept."""
724
+ body = (m.cmd or "") if commands or not m.desc else m.desc
725
+ mark = " ✗" if m.err else ""
726
+ d = f" {_dur(m.dur)}" if m.dur and m.dur >= 5 else ""
727
+ flat = " ".join(body.split())
728
+ if limit is None:
729
+ text = body
730
+ elif len(flat) <= limit:
731
+ text = flat
732
+ else:
733
+ text = flat[:limit] + f" [+{len(flat) - limit} chars]"
734
+ return f"{m.tool}{mark}{d}: {text}"
735
+
736
+
737
+ def cut_note(m: Message) -> str:
738
+ """Say that a tool output is only the start of the real one."""
739
+ if not m.out_cut:
740
+ return ""
741
+ returned, original = m.out_cut
742
+ kb = lambda n: f"{max(1, round(n / 1024))} KB" # noqa: E731
743
+ return (f"[output cut at {kb(returned)} of {kb(original)}; "
744
+ f"--full or --turns {m.turn}.{m.n} for all]")
745
+
746
+
747
+ def compact_line(m: Message) -> str:
748
+ extra = f" · {len(m.out) // 1000}k-char summary" if m.out else ""
749
+ text = f" · {m.text}" if m.text else ""
750
+ return f"══ #{m.turn}.{m.n} compaction{text}{extra}"
751
+
752
+
753
+ _NOISE_WORDS = {"cd", "echo", "head", "tail", "sed", "wc", "sort", "ls", "cat", "true",
754
+ "printf", "sleep"}
755
+
756
+
757
+ def main_word(m: Message) -> str:
758
+ if m.tool not in ("Bash", "shell"):
759
+ return m.tool or "?"
760
+ words = command_words(m.cmd or "")
761
+ useful = [w for w in words if w not in _NOISE_WORDS] or words
762
+ return useful[0] if useful else "sh"
763
+
764
+
765
+ def tool_digest(turn: Turn) -> str:
766
+ tools = [m for m in turn.messages if m.role == "tool"]
767
+ if not tools:
768
+ return ""
769
+ counts: dict[str, int] = {}
770
+ for m in tools:
771
+ counts[main_word(m)] = counts.get(main_word(m), 0) + 1
772
+ top = sorted(counts.items(), key=lambda kv: -kv[1])[:5]
773
+ stamps = [t for m in tools for t in (m.ts, (m.ts or 0) + (m.dur or 0) if m.ts else None) if t]
774
+ span = f" · {_dur(max(stamps) - min(stamps))}" if len(stamps) > 1 else ""
775
+ errs = sum(1 for m in tools if m.err)
776
+ return (f"[{len(tools)} tools{span}" + (f" · {errs}✗" if errs else "") + " · "
777
+ + ", ".join(f"{k}×{v}" if v > 1 else k for k, v in top) + "]")
778
+
779
+
780
+ def idle_gaps(turns: list[Turn]) -> dict[int, float]:
781
+ """Idle time before each turn, on the whole history (not a selection)."""
782
+ gaps, last = {}, None
783
+ for t in turns:
784
+ if last and t.start:
785
+ gaps[t.seq] = t.start - last
786
+ last = t.end or t.start or last
787
+ return gaps
788
+
789
+
790
+ def render_turns(turns: list[Turn], view: str, out: TextIO, *, max_chars: int | None,
791
+ gaps: dict[int, float], roles: set[str] | None = None,
792
+ keep: Callable[[Message], bool] | None = None, commands: bool = False) -> None:
793
+ tool_limit = None if max_chars is None else 160
794
+ shown = roles or VIEW_ROLES.get(view, set())
795
+ for t in turns:
796
+ idle = gaps.get(t.seq, 0)
797
+ gap = f" (+{_dur(idle)} idle)" if idle > 3600 else ""
798
+ if view == "outline":
799
+ _render_outline_turn(t, gap, out)
800
+ continue
801
+ print(f"\n━━ #{t.seq} {_clock(t.start, True)}{gap}", file=out)
802
+ for m in t.messages:
803
+ if m.role == "compact":
804
+ print(f"[.{m.n}] {compact_line(m)}", file=out)
805
+ continue
806
+ if not roles_of(m) & shown:
807
+ continue
808
+ if m.role == "tool":
809
+ if keep is None or keep(m):
810
+ print(f" .{m.n}{_msg_clock(m.ts, t.start)} "
811
+ f"{tool_label(m, commands, tool_limit)}", file=out)
812
+ if "output" in shown and m.out:
813
+ text = cut(m.out, max_chars) + (f"\n{cut_note(m)}" if m.out_cut else "")
814
+ print(" " + text.replace("\n", "\n "), file=out)
815
+ continue
816
+ body = m.text + (f"\n{m.out}" if m.role == "notify" and m.out else "")
817
+ print(f"[.{m.n} {tag(m)}{_msg_clock(m.ts, t.start)}] {cut(body, max_chars)}", file=out)
818
+
819
+
820
+ def _render_outline_turn(t: Turn, gap: str, out: TextIO) -> None:
821
+ if not t.messages:
822
+ return # e.g. a turn of command output only: its number stays a gap
823
+ compacts = [m for m in t.messages if m.role == "compact"]
824
+ heads = [m for m in t.messages if m.role in ("user", "notify", "steer", "interrupt")
825
+ or (m.role == "command" and not (compacts and m.text.startswith("/compact")))]
826
+ final = next((m for m in reversed(t.messages) if m.final), None)
827
+ if heads:
828
+ h = heads[0]
829
+ print(f"#{t.seq}.{h.n} {_clock(t.start, True)}{gap} {tag(h)}: {_one(h.text, 150)}", file=out)
830
+ for m in heads[1:3]:
831
+ print(f" .{m.n} {tag(m)}: {_one(m.text, 120)}", file=out)
832
+ if len(heads) > 3:
833
+ print(f" (+{len(heads) - 3} more prompts)", file=out)
834
+ elif not compacts:
835
+ print(f"#{t.seq} {_clock(t.start, True)}{gap} (no prompt)", file=out)
836
+ if final:
837
+ print(f" .{final.n} {tag(final)}: {_one(final.text, 200)}", file=out)
838
+ digest = tool_digest(t)
839
+ if digest:
840
+ print(f" {digest}", file=out)
841
+ for c in compacts:
842
+ print(f" {compact_line(c)} · {_clock(c.ts or t.end, True)}", file=out)
843
+
844
+
845
+ def _section(summary: str, name: str, n: int) -> str:
846
+ m = re.search(rf"^\s*\d+\. {name}[^\n]*:\s*\n(.*?)(?=^\s*\d+\. [A-Z][^\n]*:\s*$|\Z)",
847
+ summary, re.S | re.M)
848
+ return _one(m.group(1), n) if m else ""
849
+
850
+
851
+ def render_map(turns: list[Turn], out: TextIO) -> None:
852
+ """Segments between compactions and long idle gaps; one title line each."""
853
+ segs, cur, last_ts, why = [], None, None, "start"
854
+ for t in turns:
855
+ if cur and t.start and last_ts and t.start - last_ts > MAP_GAP_HOURS * 3600:
856
+ segs.append((cur, None))
857
+ cur, why = None, f"idle {_dur(t.start - last_ts)}"
858
+ if cur is None:
859
+ cur = {"turns": [], "why": why}
860
+ cur["turns"].append(t)
861
+ last_ts = t.end or t.start or last_ts
862
+ compact = next((m for m in reversed(t.messages) if m.role == "compact"), None)
863
+ if compact is not None: # the compacting turn closes its segment
864
+ segs.append((cur, compact))
865
+ cur, why = None, f"after compaction #{compact.turn}.{compact.n}"
866
+ if cur:
867
+ segs.append((cur, None))
868
+ msgs = [m for t in turns for m in t.messages]
869
+ tools = [m for m in msgs if m.role == "tool"]
870
+ words: dict[str, int] = {}
871
+ for m in tools:
872
+ words[main_word(m)] = words.get(main_word(m), 0) + 1
873
+ starts = [t.start for t in turns if t.start]
874
+ ends = [t.end or t.start for t in turns if t.end or t.start]
875
+ print(f"{len(turns)} turns · {sum(1 for m in msgs if m.role in ('user', 'steer'))} user msgs · "
876
+ f"{sum(1 for m in msgs if m.role == 'notify')} notifications · "
877
+ f"{sum(1 for m in msgs if m.role == 'compact')} compactions · {len(tools)} tool calls "
878
+ f"({sum(1 for m in tools if m.err)}✗) · {_clock(min(starts) if starts else None, True)} → "
879
+ f"{_clock(max(ends) if ends else None, True)}", file=out)
880
+ if words:
881
+ print(" top: " + ", ".join(f"{k}×{v}" for k, v in
882
+ sorted(words.items(), key=lambda kv: -kv[1])[:12]), file=out)
883
+ for seg, closer in segs:
884
+ ts_ = seg["turns"]
885
+ sm = [m for t in ts_ for m in t.messages]
886
+ users = [m for m in sm if m.role in ("user", "steer")]
887
+ notes = sum(1 for t in ts_ if t.messages and t.messages[0].role == "notify")
888
+ st = [t.start for t in ts_ if t.start]
889
+ en = [t.end or t.start for t in ts_ if t.end or t.start]
890
+ print(f"── #{ts_[0].seq}-{ts_[-1].seq} {_clock(min(st) if st else None, True)} → "
891
+ f"{_clock(max(en) if en else None, True)} {seg['why']} · {len(users)} user · "
892
+ f"{notes} notify-led turns", file=out)
893
+ title = ""
894
+ if closer is not None and closer.out:
895
+ work = _section(closer.out, "Current Work", 170)
896
+ title = f"#{closer.turn}.{closer.n} compaction, current work: {work}" if work else ""
897
+ if not title and users:
898
+ title = f"#{users[0].turn}.{users[0].n} {tag(users[0])}: {_one(users[0].text, 160)}"
899
+ if not title:
900
+ fin = next((m for m in sm if m.final), None)
901
+ title = f"#{fin.turn}.{fin.n} {tag(fin)}: {_one(fin.text, 160)}" if fin else ""
902
+ if title:
903
+ print(f" ⤷ {title}", file=out)
904
+
905
+
906
+ def snippet(m: Message, rx: re.Pattern | None, width: int = 220) -> str:
907
+ if m.role == "tool" and rx is None:
908
+ return tool_label(m, True)
909
+ for text in (m.text, m.cmd or "", m.desc or "", m.out):
910
+ found = rx.search(text) if (rx and text) else None
911
+ if found:
912
+ a = max(0, found.start() - width // 2)
913
+ return ("…" if a else "") + _one(text[a: a + width], width)
914
+ return tool_label(m, True) if m.role == "tool" else _one(m.text, width)
915
+
916
+
917
+ def header(h: History, zone: str) -> str:
918
+ starts = [t.start for t in h.turns if t.start]
919
+ ends = [t.end or t.start for t in h.turns if t.end or t.start]
920
+ span = f"{_clock(min(starts), True)} → {_clock(max(ends), True)}" if starts else "no times"
921
+ compacts = [m for m in h.messages() if m.role == "compact"]
922
+ latest = f" · latest compaction #{compacts[-1].turn}.{compacts[-1].n}" if compacts else ""
923
+ cwd = (h.cwd or "").replace(os.path.expanduser("~"), "~", 1)
924
+ name = f" {h.name!r}" if h.name else ""
925
+ return (f"{h.engine}:{h.short}{name} {cwd} · {len(h.turns)} turns · {span}{latest} · "
926
+ f"local times ({zone})")
927
+
928
+
929
+ @dataclass
930
+ class Hint:
931
+ short: str
932
+ turn: int
933
+ n: int
934
+
935
+
936
+ def render_history(h: History, opts: Options, out: TextIO, *, many: bool,
937
+ zone: str) -> Hint | None:
938
+ """Print one worker's part of ``ccx history``; return a next-step hint."""
939
+ if many:
940
+ print(file=out)
941
+ print(header(h, zone), file=out)
942
+ for note in h.notes:
943
+ print(f" note: {note}", file=out)
944
+ last = h.turns[-1].seq if h.turns else 0
945
+ sel = parse_turns(opts.turns, last)
946
+ if sel.messages:
947
+ _render_addressed(h, sel, opts, out)
948
+ if not sel.turns:
949
+ return None
950
+ span = {t.seq: (t.start, t.end) for t in h.turns}
951
+ keep = matcher(opts, lambda m: span.get(m.turn, (None, None)))
952
+ view = opts.view or ("chat" if (opts.turns or opts.roles or opts.context is not None)
953
+ else "outline")
954
+ gaps = idle_gaps(h.turns)
955
+ turns = [t for t in h.turns if sel.turns is None or t.seq in sel.turns]
956
+ if view == "map":
957
+ if keep is not None:
958
+ turns = [t for t in turns if any(keep(m) for m in t.messages)]
959
+ render_map(turns, out)
960
+ return None
961
+ if opts.hits_mode:
962
+ return _render_hits(h, turns, keep, opts, out)
963
+ if keep is not None:
964
+ index = [i for i, t in enumerate(turns) if any(keep(m) for m in t.messages)
965
+ or (not opts.search and not t.messages and _turn_in_range(t, opts))]
966
+ ctx = opts.context or 0
967
+ show = {j for i in index for j in range(i - ctx, i + ctx + 1)}
968
+ turns = [t for i, t in enumerate(turns) if i in show]
969
+ elif sel.turns is None:
970
+ window = DEFAULT_MANY_WINDOW if many else DEFAULT_WINDOW
971
+ if len(turns) > window:
972
+ print(f"(last {window} of {len(turns)} turns; --turns 1- for all, "
973
+ "--view map for an overview)", file=out)
974
+ turns = turns[-window:]
975
+ if not turns:
976
+ print(" (no matching turns)" if opts.search else " (no turns in range)", file=out)
977
+ max_chars = None if opts.full else (opts.max_chars or DEFAULT_MAX_CHARS)
978
+ render_turns(turns, view, out, max_chars=max_chars, gaps=gaps, roles=opts.roles,
979
+ keep=keep if opts.search else None,
980
+ commands=bool(opts.cmd or (opts.roles and "tool" in opts.roles)))
981
+ return None
982
+
983
+
984
+ def _turn_in_range(t: Turn, opts: Options) -> bool:
985
+ start, end = t.start, t.end or t.start
986
+ return not ((opts.since and (end or 0) < opts.since) or (opts.until and (start or 0) >= opts.until))
987
+
988
+
989
+ def _render_addressed(h: History, sel: Selection, opts: Options, out: TextIO) -> None:
990
+ found = {(m.turn, m.n): m for m in h.messages()}
991
+ for address in sorted(sel.messages):
992
+ m = found.get(address)
993
+ if m is None:
994
+ print(f"(no message #{address[0]}.{address[1]}: it doesn't exist or isn't shown "
995
+ "by history, e.g. reasoning or bookkeeping)", file=out)
996
+ continue
997
+ if m.role == "tool":
998
+ body = (m.cmd or "") + ("\n--- output ---\n" + m.out if m.out else "")
999
+ body += f"\n{cut_note(m)}" if m.out_cut else ""
1000
+ elif m.role == "compact":
1001
+ body = m.out or ("(no summary stored locally: Codex encrypts compaction summaries)"
1002
+ if h.engine == "codex" else "(no summary text)")
1003
+ else:
1004
+ body = m.text + (f"\n{m.out}" if m.role == "notify" and m.out else "")
1005
+ turn_start = next((t.start for t in h.turns if t.seq == m.turn), None)
1006
+ print(f"#{m.turn}.{m.n} {tag(m)} {_clock(m.ts or turn_start, True)}"
1007
+ + (f" {m.tool}" if m.tool else ""), file=out)
1008
+ print(cut(body, None if opts.full else opts.max_chars, keep_end=m.role == "compact"),
1009
+ file=out)
1010
+
1011
+
1012
+ def _render_hits(h: History, turns: list[Turn], keep, opts: Options, out: TextIO) -> Hint | None:
1013
+ rx = smart_regex(opts.grep)
1014
+ starts = {t.seq: t.start for t in turns}
1015
+ hits = [m for t in turns for m in t.messages if keep(m)]
1016
+ shown = hits[-opts.limit:]
1017
+ if len(hits) > len(shown):
1018
+ older = f"--turns 1-{shown[0].turn - 1} or " if shown[0].turn > 1 else ""
1019
+ print(f" {len(hits)} hits, newest {len(shown)} shown (older: {older}--limit {len(hits)})",
1020
+ file=out)
1021
+ else:
1022
+ print(f" {len(hits)} hit{'s' * (len(hits) != 1)}", file=out)
1023
+ with_output = bool(opts.roles and "output" in opts.roles)
1024
+ for m in shown:
1025
+ print(f" #{m.turn}.{m.n} {_clock(m.ts or starts.get(m.turn), True)} {tag(m)}: "
1026
+ f"{snippet(m, rx)}", file=out)
1027
+ if with_output and m.role == "tool" and m.out:
1028
+ ending = cut_note(m) or _one(m.out[-200:], 200) # how it ended
1029
+ print(f" ⤷ {ending}", file=out)
1030
+ return Hint(h.short, shown[-1].turn, shown[-1].n) if shown else None
1031
+
1032
+
1033
+ def hint_line(hint: Hint) -> str:
1034
+ return (f"next: ccx history {hint.short} --turns {hint.turn}.{hint.n} prints that message "
1035
+ f"whole; --turns {max(1, hint.turn - 1)}-{hint.turn + 1} reads the exchange around it")
1036
+
1037
+
1038
+ __all__ = [
1039
+ "History", "Message", "Options", "Turn", "command_words", "hint_line", "options",
1040
+ "parse_time", "parse_turns", "project", "render_history", "zone_name",
1041
+ ]