dulus 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. webchat_server.py +1761 -0
checkpoint/store.py ADDED
@@ -0,0 +1,314 @@
1
+ """Checkpoint store: file-level backup + snapshot persistence.
2
+
3
+ Directory layout:
4
+ ~/.dulus/checkpoints/<session_id>/
5
+ snapshots.json # list of Snapshot metadata
6
+ backups/
7
+ <hash>@v<N> # actual file copies
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import shutil
15
+ import time
16
+ from datetime import datetime, timedelta
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from .types import FileBackup, Snapshot, MAX_SNAPSHOTS
21
+
22
+ # Max file size to back up (1 MB)
23
+ _MAX_FILE_SIZE = 1 * 1024 * 1024
24
+
25
+ # Per-file version counters (reset per session)
26
+ _file_versions: dict[str, int] = {}
27
+
28
+
29
+ def _checkpoints_root() -> Path:
30
+ return Path.home() / ".dulus" / "checkpoints"
31
+
32
+
33
+ def _session_dir(session_id: str) -> Path:
34
+ return _checkpoints_root() / session_id
35
+
36
+
37
+ def _backups_dir(session_id: str) -> Path:
38
+ d = _session_dir(session_id) / "backups"
39
+ d.mkdir(parents=True, exist_ok=True)
40
+ return d
41
+
42
+
43
+ def _snapshots_file(session_id: str) -> Path:
44
+ d = _session_dir(session_id)
45
+ d.mkdir(parents=True, exist_ok=True)
46
+ return d / "snapshots.json"
47
+
48
+
49
+ def _path_hash(file_path: str) -> str:
50
+ """Deterministic short hash from file path (not content)."""
51
+ return hashlib.sha256(file_path.encode()).hexdigest()[:16]
52
+
53
+
54
+ def _next_version(file_path: str) -> int:
55
+ v = _file_versions.get(file_path, 0) + 1
56
+ _file_versions[file_path] = v
57
+ return v
58
+
59
+
60
+ # ── Load / save snapshots JSON ──────────────────────────────────────────────
61
+
62
+ def _load_snapshots(session_id: str) -> list[Snapshot]:
63
+ f = _snapshots_file(session_id)
64
+ if not f.exists():
65
+ return []
66
+ try:
67
+ data = json.loads(f.read_text(encoding="utf-8"))
68
+ return [Snapshot.from_dict(s) for s in data]
69
+ except Exception:
70
+ return []
71
+
72
+
73
+ def _save_snapshots(session_id: str, snapshots: list[Snapshot]) -> None:
74
+ f = _snapshots_file(session_id)
75
+ f.parent.mkdir(parents=True, exist_ok=True)
76
+ data = [s.to_dict() for s in snapshots]
77
+ f.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
78
+
79
+
80
+ # ── Public API ───────────────────────────────────────────────────────────────
81
+
82
+ def track_file_edit(session_id: str, file_path: str) -> str | None:
83
+ """Back up a file before it is edited (first-write-wins per snapshot interval).
84
+
85
+ Returns the backup filename, or None if the file doesn't exist yet.
86
+ """
87
+ p = Path(file_path)
88
+ bdir = _backups_dir(session_id)
89
+
90
+ if not p.exists():
91
+ # File doesn't exist — record that so restore can delete it
92
+ return None
93
+
94
+ # Size guard
95
+ try:
96
+ size = p.stat().st_size
97
+ except OSError:
98
+ return None
99
+ if size > _MAX_FILE_SIZE:
100
+ print(f"[checkpoint] skipping large file ({size} bytes): {file_path}")
101
+ return None
102
+
103
+ # Copy file to backups/
104
+ version = _next_version(file_path)
105
+ backup_name = f"{_path_hash(file_path)}@v{version}"
106
+ backup_path = bdir / backup_name
107
+ try:
108
+ shutil.copy2(str(p), str(backup_path))
109
+ except Exception as e:
110
+ print(f"[checkpoint] backup failed for {file_path}: {e}")
111
+ return None
112
+
113
+ return backup_name
114
+
115
+
116
+ def make_snapshot(
117
+ session_id: str,
118
+ state: Any,
119
+ config: dict,
120
+ user_prompt: str,
121
+ tracked_edits: dict[str, str | None] | None = None,
122
+ ) -> Snapshot | None:
123
+ """Create a snapshot after a user prompt has been processed.
124
+
125
+ tracked_edits: dict mapping file_path → backup_filename (or None if new file).
126
+ Populated by hooks.py during the turn.
127
+ """
128
+ snapshots = _load_snapshots(session_id)
129
+
130
+ # Build file_backups: merge previous snapshot's backups with new edits
131
+ prev_backups: dict[str, FileBackup] = {}
132
+ if snapshots:
133
+ prev_backups = dict(snapshots[-1].file_backups)
134
+
135
+ now = datetime.now().isoformat()
136
+ new_backups: dict[str, FileBackup] = {}
137
+
138
+ # Carry forward unchanged files from previous snapshot
139
+ for path, fb in prev_backups.items():
140
+ new_backups[path] = fb
141
+
142
+ # Add/update files that were edited this turn — back up their CURRENT state
143
+ if tracked_edits:
144
+ for path in tracked_edits:
145
+ p = Path(path)
146
+ if p.exists():
147
+ try:
148
+ size = p.stat().st_size
149
+ except OSError:
150
+ continue
151
+ if size > _MAX_FILE_SIZE:
152
+ continue
153
+ version = _next_version(path)
154
+ backup_name = f"{_path_hash(path)}@v{version}"
155
+ bdir = _backups_dir(session_id)
156
+ try:
157
+ shutil.copy2(str(p), str(bdir / backup_name))
158
+ except Exception:
159
+ continue
160
+ new_backups[path] = FileBackup(
161
+ backup_filename=backup_name,
162
+ version=version,
163
+ backup_time=now,
164
+ )
165
+ else:
166
+ # File was deleted during the turn (unlikely but possible)
167
+ new_backups[path] = FileBackup(
168
+ backup_filename=None,
169
+ version=_file_versions.get(path, 0),
170
+ backup_time=now,
171
+ )
172
+
173
+ next_id = (snapshots[-1].id + 1) if snapshots else 1
174
+
175
+ snapshot = Snapshot(
176
+ id=next_id,
177
+ session_id=session_id,
178
+ created_at=now,
179
+ turn_count=getattr(state, "turn_count", 0),
180
+ message_index=len(getattr(state, "messages", [])),
181
+ user_prompt_preview=user_prompt[:80] if user_prompt else "",
182
+ token_snapshot={
183
+ "input": getattr(state, "total_input_tokens", 0),
184
+ "output": getattr(state, "total_output_tokens", 0),
185
+ },
186
+ file_backups=new_backups,
187
+ )
188
+
189
+ snapshots.append(snapshot)
190
+
191
+ # Sliding window: keep only the last MAX_SNAPSHOTS
192
+ if len(snapshots) > MAX_SNAPSHOTS:
193
+ snapshots = snapshots[-MAX_SNAPSHOTS:]
194
+
195
+ _save_snapshots(session_id, snapshots)
196
+ return snapshot
197
+
198
+
199
+ def list_snapshots(session_id: str) -> list[dict]:
200
+ """Return lightweight summaries of all snapshots."""
201
+ snapshots = _load_snapshots(session_id)
202
+ result = []
203
+ for s in snapshots:
204
+ result.append({
205
+ "id": s.id,
206
+ "turn_count": s.turn_count,
207
+ "message_index": s.message_index,
208
+ "created_at": s.created_at,
209
+ "user_prompt_preview": s.user_prompt_preview,
210
+ "file_count": len(s.file_backups),
211
+ })
212
+ return result
213
+
214
+
215
+ def get_snapshot(session_id: str, snapshot_id: int) -> Snapshot | None:
216
+ snapshots = _load_snapshots(session_id)
217
+ for s in snapshots:
218
+ if s.id == snapshot_id:
219
+ return s
220
+ return None
221
+
222
+
223
+ def rewind_files(session_id: str, snapshot_id: int) -> list[str]:
224
+ """Restore files to their state at the given snapshot.
225
+
226
+ Returns list of restored/deleted file paths.
227
+ """
228
+ snapshot = get_snapshot(session_id, snapshot_id)
229
+ if snapshot is None:
230
+ return []
231
+
232
+ bdir = _backups_dir(session_id)
233
+ restored: list[str] = []
234
+
235
+ for file_path, fb in snapshot.file_backups.items():
236
+ try:
237
+ if fb.backup_filename is None:
238
+ # File didn't exist at snapshot time → delete it
239
+ p = Path(file_path)
240
+ if p.exists():
241
+ p.unlink()
242
+ restored.append(f"deleted: {file_path}")
243
+ else:
244
+ # Restore from backup
245
+ backup_path = bdir / fb.backup_filename
246
+ if backup_path.exists():
247
+ p = Path(file_path)
248
+ p.parent.mkdir(parents=True, exist_ok=True)
249
+ shutil.copy2(str(backup_path), str(p))
250
+ restored.append(f"restored: {file_path}")
251
+ else:
252
+ restored.append(f"backup missing: {file_path}")
253
+ except Exception as e:
254
+ restored.append(f"error restoring {file_path}: {e}")
255
+
256
+ return restored
257
+
258
+
259
+ def files_changed_since(session_id: str, snapshot_id: int) -> list[str]:
260
+ """List files that have been changed in snapshots after the given one."""
261
+ snapshots = _load_snapshots(session_id)
262
+ target = None
263
+ for s in snapshots:
264
+ if s.id == snapshot_id:
265
+ target = s
266
+ break
267
+ if target is None:
268
+ return []
269
+
270
+ changed: set[str] = set()
271
+ for s in snapshots:
272
+ if s.id <= snapshot_id:
273
+ continue
274
+ for path in s.file_backups:
275
+ if path not in target.file_backups or \
276
+ s.file_backups[path].version != target.file_backups[path].version:
277
+ changed.add(path)
278
+ return sorted(changed)
279
+
280
+
281
+ def delete_session_checkpoints(session_id: str) -> bool:
282
+ """Delete all checkpoints for a session."""
283
+ d = _session_dir(session_id)
284
+ if d.exists():
285
+ shutil.rmtree(str(d), ignore_errors=True)
286
+ return True
287
+ return False
288
+
289
+
290
+ def cleanup_old_sessions(max_age_days: int = 30) -> int:
291
+ """Remove checkpoint sessions older than max_age_days. Returns count removed."""
292
+ root = _checkpoints_root()
293
+ if not root.exists():
294
+ return 0
295
+ cutoff = time.time() - (max_age_days * 86400)
296
+ removed = 0
297
+ try:
298
+ for d in root.iterdir():
299
+ if d.is_dir():
300
+ try:
301
+ mtime = d.stat().st_mtime
302
+ if mtime < cutoff:
303
+ shutil.rmtree(str(d), ignore_errors=True)
304
+ removed += 1
305
+ except OSError:
306
+ pass
307
+ except OSError:
308
+ pass
309
+ return removed
310
+
311
+
312
+ def reset_file_versions() -> None:
313
+ """Reset per-file version counters (for testing)."""
314
+ _file_versions.clear()
checkpoint/types.py ADDED
@@ -0,0 +1,80 @@
1
+ """Checkpoint system types: FileBackup and Snapshot dataclasses."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ MAX_SNAPSHOTS = 100
9
+
10
+
11
+ @dataclass
12
+ class FileBackup:
13
+ """A single file's backup reference within a snapshot.
14
+
15
+ backup_filename: hash@vN name in the backups/ dir, or None if the file
16
+ did not exist before (meaning restore = delete).
17
+ version: monotonically increasing per-file version counter.
18
+ backup_time: ISO timestamp of when the backup was created.
19
+ """
20
+ backup_filename: str | None
21
+ version: int
22
+ backup_time: str
23
+
24
+ def to_dict(self) -> dict:
25
+ return {
26
+ "backup_filename": self.backup_filename,
27
+ "version": self.version,
28
+ "backup_time": self.backup_time,
29
+ }
30
+
31
+ @classmethod
32
+ def from_dict(cls, data: dict) -> FileBackup:
33
+ return cls(
34
+ backup_filename=data.get("backup_filename"),
35
+ version=data.get("version", 0),
36
+ backup_time=data.get("backup_time", ""),
37
+ )
38
+
39
+
40
+ @dataclass
41
+ class Snapshot:
42
+ """A checkpoint snapshot — metadata about conversation + file state."""
43
+ id: int
44
+ session_id: str
45
+ created_at: str
46
+ turn_count: int
47
+ message_index: int # len(state.messages) at snapshot time
48
+ user_prompt_preview: str # first 80 chars of the triggering prompt
49
+ token_snapshot: dict[str, int] # {"input": N, "output": N}
50
+ file_backups: dict[str, FileBackup] = field(default_factory=dict)
51
+
52
+ def to_dict(self) -> dict:
53
+ return {
54
+ "id": self.id,
55
+ "session_id": self.session_id,
56
+ "created_at": self.created_at,
57
+ "turn_count": self.turn_count,
58
+ "message_index": self.message_index,
59
+ "user_prompt_preview": self.user_prompt_preview,
60
+ "token_snapshot": self.token_snapshot,
61
+ "file_backups": {
62
+ path: fb.to_dict() for path, fb in self.file_backups.items()
63
+ },
64
+ }
65
+
66
+ @classmethod
67
+ def from_dict(cls, data: dict) -> Snapshot:
68
+ backups = {}
69
+ for path, fb_data in data.get("file_backups", {}).items():
70
+ backups[path] = FileBackup.from_dict(fb_data)
71
+ return cls(
72
+ id=data["id"],
73
+ session_id=data.get("session_id", ""),
74
+ created_at=data.get("created_at", ""),
75
+ turn_count=data.get("turn_count", 0),
76
+ message_index=data.get("message_index", 0),
77
+ user_prompt_preview=data.get("user_prompt_preview", ""),
78
+ token_snapshot=data.get("token_snapshot", {}),
79
+ file_backups=backups,
80
+ )
claude_code_watcher.py ADDED
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ claude_code_watcher.py
4
+
5
+ Watches a Claude Code session JSONL file and extracts assistant responses
6
+ in real time. Can print to stdout or POST to a Dulus/webhook endpoint.
7
+
8
+ v2: Groups multi-part assistant turns (text + tool_use + text) into one
9
+ complete message before sending. Fixes the bug where text after a
10
+ tool call was sent as a separate/missing message.
11
+
12
+ Usage:
13
+ python claude_code_watcher.py
14
+ python claude_code_watcher.py --session <path_to.jsonl>
15
+ python claude_code_watcher.py --post http://localhost:5000/claude_code_response
16
+ """
17
+
18
+ import json
19
+ import sys
20
+ import time
21
+ import os
22
+ import argparse
23
+ from pathlib import Path
24
+
25
+
26
+ SESSION_DIR = Path.home() / ".claude" / "projects" / "C--Users-Admin-Desktop-DULUSV2"
27
+
28
+ # How long to wait (seconds) with no new assistant entries before flushing
29
+ # the accumulated turn as complete.
30
+ FLUSH_TIMEOUT = 2.5
31
+
32
+
33
+ def find_latest_session() -> Path | None:
34
+ """Find the most recently modified JSONL session file."""
35
+ files = list(SESSION_DIR.glob("*.jsonl"))
36
+ if not files:
37
+ return None
38
+ return max(files, key=lambda f: f.stat().st_mtime)
39
+
40
+
41
+ def extract_text_blocks(entry: dict) -> list[str]:
42
+ """Return all text strings from an assistant entry's content blocks."""
43
+ msg = entry.get("message", {})
44
+ if msg.get("role") != "assistant":
45
+ return []
46
+ content = msg.get("content", "")
47
+ if isinstance(content, str):
48
+ t = content.strip()
49
+ return [t] if t else []
50
+ if isinstance(content, list):
51
+ parts = []
52
+ for block in content:
53
+ if isinstance(block, dict) and block.get("type") == "text":
54
+ t = block.get("text", "").strip()
55
+ if t:
56
+ parts.append(t)
57
+ return parts
58
+ return []
59
+
60
+
61
+ def has_tool_use(entry: dict) -> bool:
62
+ """True if this entry contains a tool_use block (mid-turn, more may follow)."""
63
+ msg = entry.get("message", {})
64
+ if msg.get("role") != "assistant":
65
+ return False
66
+ content = msg.get("content", "")
67
+ if isinstance(content, list):
68
+ return any(b.get("type") == "tool_use" for b in content if isinstance(b, dict))
69
+ return False
70
+
71
+
72
+ def is_assistant(entry: dict) -> bool:
73
+ return entry.get("message", {}).get("role") == "assistant"
74
+
75
+
76
+ def post_message(text: str, post_url: str):
77
+ try:
78
+ import urllib.request
79
+ payload = json.dumps({
80
+ "role": "assistant",
81
+ "source": "claude-code",
82
+ "text": text,
83
+ }).encode("utf-8")
84
+ req = urllib.request.Request(
85
+ post_url,
86
+ data=payload,
87
+ headers={"Content-Type": "application/json"},
88
+ method="POST",
89
+ )
90
+ urllib.request.urlopen(req, timeout=5)
91
+ except Exception as e:
92
+ print(f"[watcher] POST failed: {e}", flush=True)
93
+
94
+
95
+ def watch(session_path: Path, post_url: str | None = None, poll_interval: float = 0.5):
96
+ """Tail the JSONL file and emit complete assistant turns."""
97
+ print(f"[watcher] Watching: {session_path}", flush=True)
98
+ print(f"[watcher] Post URL: {post_url or 'stdout only'}", flush=True)
99
+ print(f"[watcher] Flush timeout: {FLUSH_TIMEOUT}s after last assistant entry", flush=True)
100
+
101
+ seen_uuids: set = set()
102
+
103
+ # Seed existing entries
104
+ try:
105
+ with open(session_path, "r", encoding="utf-8", errors="ignore") as f:
106
+ for line in f:
107
+ line = line.strip()
108
+ if not line:
109
+ continue
110
+ try:
111
+ entry = json.loads(line)
112
+ uid = entry.get("uuid") or entry.get("id")
113
+ if uid:
114
+ seen_uuids.add(uid)
115
+ except Exception:
116
+ pass
117
+ print(f"[watcher] Seeded {len(seen_uuids)} existing entries.", flush=True)
118
+ except Exception as e:
119
+ print(f"[watcher] Could not seed: {e}", flush=True)
120
+
121
+ # Accumulator for the current in-progress assistant turn
122
+ pending_texts: list[str] = []
123
+ pending_has_tool: bool = False
124
+ last_assistant_time: float = 0.0
125
+
126
+ while True:
127
+ try:
128
+ with open(session_path, "r", encoding="utf-8", errors="ignore") as f:
129
+ for line in f:
130
+ line = line.strip()
131
+ if not line:
132
+ continue
133
+ try:
134
+ entry = json.loads(line)
135
+ uid = entry.get("uuid") or entry.get("id")
136
+ if uid in seen_uuids:
137
+ continue
138
+ seen_uuids.add(uid)
139
+
140
+ if not is_assistant(entry):
141
+ # Non-assistant entry (user / tool_result) — if we have
142
+ # pending text that ended with a tool_use, keep accumulating.
143
+ # We'll flush on timeout or when the next text-only turn arrives.
144
+ continue
145
+
146
+ texts = extract_text_blocks(entry)
147
+ tool = has_tool_use(entry)
148
+
149
+ if texts:
150
+ pending_texts.extend(texts)
151
+ last_assistant_time = time.time()
152
+
153
+ if tool:
154
+ pending_has_tool = True
155
+ last_assistant_time = time.time()
156
+
157
+ # If this entry has ONLY tool_use (no text) it means we're
158
+ # mid-turn — keep accumulating.
159
+ # If this entry has text AND no tool_use, it MIGHT be the
160
+ # final piece of the turn. We'll let the timeout decide.
161
+
162
+ except Exception:
163
+ pass
164
+
165
+ except Exception as e:
166
+ print(f"[watcher] Read error: {e}", flush=True)
167
+
168
+ # Flush if we have accumulated text and the turn has been quiet for FLUSH_TIMEOUT
169
+ if pending_texts and last_assistant_time > 0:
170
+ elapsed = time.time() - last_assistant_time
171
+ if elapsed >= FLUSH_TIMEOUT:
172
+ full_text = "\n\n".join(pending_texts)
173
+ print(f"\n[CLAUDE-CODE] {full_text[:300]}{'...' if len(full_text) > 300 else ''}", flush=True)
174
+
175
+ if post_url:
176
+ post_message(full_text, post_url)
177
+
178
+ # Reset accumulator
179
+ pending_texts = []
180
+ pending_has_tool = False
181
+ last_assistant_time = 0.0
182
+
183
+ time.sleep(poll_interval)
184
+
185
+
186
+ def main():
187
+ parser = argparse.ArgumentParser(description="Watch Claude Code session for new assistant messages.")
188
+ parser.add_argument("--session", type=str, default=None, help="Path to .jsonl session file")
189
+ parser.add_argument("--post", type=str, default=None, help="POST new messages to this URL")
190
+ parser.add_argument("--interval", type=float, default=0.5, help="Poll interval in seconds (default 0.5)")
191
+ parser.add_argument("--flush-timeout", type=float, default=FLUSH_TIMEOUT,
192
+ help=f"Seconds of silence before flushing turn (default {FLUSH_TIMEOUT})")
193
+ args = parser.parse_args()
194
+
195
+ global FLUSH_TIMEOUT
196
+ FLUSH_TIMEOUT = args.flush_timeout
197
+
198
+ if args.session:
199
+ session_path = Path(args.session)
200
+ else:
201
+ session_path = find_latest_session()
202
+
203
+ if not session_path or not session_path.exists():
204
+ print(f"[watcher] Session file not found. Pass --session <path>")
205
+ sys.exit(1)
206
+
207
+ try:
208
+ watch(session_path, post_url=args.post, poll_interval=args.interval)
209
+ except KeyboardInterrupt:
210
+ print("\n[watcher] Stopped.")
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()