overcode 0.1.3__py3-none-any.whl → 0.1.4__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 (41) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +7 -2
  3. overcode/implementations.py +74 -8
  4. overcode/monitor_daemon.py +60 -65
  5. overcode/monitor_daemon_core.py +261 -0
  6. overcode/monitor_daemon_state.py +7 -0
  7. overcode/session_manager.py +1 -0
  8. overcode/settings.py +22 -0
  9. overcode/supervisor_daemon.py +48 -47
  10. overcode/supervisor_daemon_core.py +210 -0
  11. overcode/testing/__init__.py +6 -0
  12. overcode/testing/renderer.py +268 -0
  13. overcode/testing/tmux_driver.py +223 -0
  14. overcode/testing/tui_eye.py +185 -0
  15. overcode/testing/tui_eye_skill.md +187 -0
  16. overcode/tmux_manager.py +17 -3
  17. overcode/tui.py +196 -2462
  18. overcode/tui_actions/__init__.py +20 -0
  19. overcode/tui_actions/daemon.py +201 -0
  20. overcode/tui_actions/input.py +128 -0
  21. overcode/tui_actions/navigation.py +117 -0
  22. overcode/tui_actions/session.py +428 -0
  23. overcode/tui_actions/view.py +357 -0
  24. overcode/tui_helpers.py +41 -9
  25. overcode/tui_logic.py +347 -0
  26. overcode/tui_render.py +414 -0
  27. overcode/tui_widgets/__init__.py +24 -0
  28. overcode/tui_widgets/command_bar.py +399 -0
  29. overcode/tui_widgets/daemon_panel.py +153 -0
  30. overcode/tui_widgets/daemon_status_bar.py +245 -0
  31. overcode/tui_widgets/help_overlay.py +71 -0
  32. overcode/tui_widgets/preview_pane.py +69 -0
  33. overcode/tui_widgets/session_summary.py +514 -0
  34. overcode/tui_widgets/status_timeline.py +253 -0
  35. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
  36. overcode-0.1.4.dist-info/RECORD +68 -0
  37. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  38. overcode-0.1.3.dist-info/RECORD +0 -45
  39. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
  40. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  41. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,428 @@
1
+ """
2
+ Session action methods for TUI.
3
+
4
+ Handles agent/session operations like kill, new, sleep, command bar focus.
5
+ """
6
+
7
+ import time
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, List
10
+
11
+ from textual.css.query import NoMatches
12
+ from textual.widgets import Input
13
+
14
+ if TYPE_CHECKING:
15
+ from ..tui_widgets import SessionSummary, CommandBar, PreviewPane
16
+ from ..session_manager import Session
17
+
18
+
19
+ class SessionActionsMixin:
20
+ """Mixin providing session/agent actions for SupervisorTUI."""
21
+
22
+ def action_toggle_focused(self) -> None:
23
+ """Toggle expansion of focused session (only in tree mode)."""
24
+ from ..tui_widgets import SessionSummary
25
+ if self.view_mode == "list_preview":
26
+ return # Don't toggle in list mode
27
+ focused = self.focused
28
+ if isinstance(focused, SessionSummary):
29
+ focused.expanded = not focused.expanded
30
+
31
+ def action_toggle_sleep(self) -> None:
32
+ """Toggle sleep mode for the focused agent.
33
+
34
+ Sleep mode marks an agent as 'asleep' (human doesn't want it to do anything).
35
+ Sleeping agents are excluded from stats calculations.
36
+ Press z again to wake the agent.
37
+
38
+ Note: Cannot put a running agent to sleep (#158).
39
+ """
40
+ from ..tui_widgets import SessionSummary
41
+ from ..status_constants import STATUS_RUNNING
42
+ focused = self.focused
43
+ if not isinstance(focused, SessionSummary):
44
+ self.notify("No agent focused", severity="warning")
45
+ return
46
+
47
+ session = focused.session
48
+ new_asleep_state = not session.is_asleep
49
+
50
+ # Prevent putting a running agent to sleep (#158)
51
+ if new_asleep_state and focused.detected_status == STATUS_RUNNING:
52
+ self.notify("Cannot put a running agent to sleep", severity="warning")
53
+ return
54
+
55
+ # Update the session in the session manager
56
+ self.session_manager.update_session(session.id, is_asleep=new_asleep_state)
57
+
58
+ # Update the local session object
59
+ session.is_asleep = new_asleep_state
60
+
61
+ # Update the widget's display status if sleeping
62
+ if new_asleep_state:
63
+ focused.detected_status = "asleep"
64
+ self.notify(f"Agent '{session.name}' is now asleep (excluded from stats)", severity="information")
65
+ else:
66
+ # Wake up - status will be refreshed on next update cycle
67
+ self.notify(f"Agent '{session.name}' is now awake", severity="information")
68
+
69
+ # Force a refresh
70
+ focused.refresh()
71
+
72
+ def action_kill_focused(self) -> None:
73
+ """Kill the currently focused agent (requires confirmation)."""
74
+ from ..tui_widgets import SessionSummary
75
+ focused = self.focused
76
+ if not isinstance(focused, SessionSummary):
77
+ self.notify("No agent focused", severity="warning")
78
+ return
79
+
80
+ session_name = focused.session.name
81
+ session_id = focused.session.id
82
+ now = time.time()
83
+
84
+ # Check if this is a confirmation of a pending kill
85
+ if self._pending_kill:
86
+ pending_name, pending_time = self._pending_kill
87
+ # Confirm if same session and within 3 second window
88
+ if pending_name == session_name and (now - pending_time) < 3.0:
89
+ self._pending_kill = None # Clear pending state
90
+ self._execute_kill(focused, session_name, session_id)
91
+ return
92
+ else:
93
+ # Different session or expired - start new confirmation
94
+ self._pending_kill = None
95
+
96
+ # First press - request confirmation
97
+ self._pending_kill = (session_name, now)
98
+ self.notify(
99
+ f"Press x again to kill '{session_name}'",
100
+ severity="warning",
101
+ timeout=3
102
+ )
103
+
104
+ def action_restart_focused(self) -> None:
105
+ """Restart the currently focused agent (requires confirmation).
106
+
107
+ Sends Ctrl-C to kill the current Claude process, then restarts it
108
+ with the same configuration (directory, permissions).
109
+ """
110
+ from ..tui_widgets import SessionSummary
111
+ focused = self.focused
112
+ if not isinstance(focused, SessionSummary):
113
+ self.notify("No agent focused", severity="warning")
114
+ return
115
+
116
+ session_name = focused.session.name
117
+ now = time.time()
118
+
119
+ # Check if this is a confirmation of a pending restart
120
+ if self._pending_restart:
121
+ pending_name, pending_time = self._pending_restart
122
+ # Confirm if same session and within 3 second window
123
+ if pending_name == session_name and (now - pending_time) < 3.0:
124
+ self._pending_restart = None # Clear pending state
125
+ self._execute_restart(focused)
126
+ return
127
+ else:
128
+ # Different session or expired - start new confirmation
129
+ self._pending_restart = None
130
+
131
+ # First press - request confirmation
132
+ self._pending_restart = (session_name, now)
133
+ self.notify(
134
+ f"Press R again to restart '{session_name}'",
135
+ severity="warning",
136
+ timeout=3
137
+ )
138
+
139
+ def action_sync_to_main_and_clear(self) -> None:
140
+ """Switch to main branch, pull, and clear agent context (requires confirmation).
141
+
142
+ This action:
143
+ 1. Runs git checkout main && git pull via Claude's bash command
144
+ 2. Sends /clear to reset the conversation context
145
+ """
146
+ from ..tui_widgets import SessionSummary
147
+
148
+ focused = self.focused
149
+ if not isinstance(focused, SessionSummary):
150
+ self.notify("No agent focused", severity="warning")
151
+ return
152
+
153
+ session_name = focused.session.name
154
+ now = time.time()
155
+
156
+ # Check if this is a confirmation of a pending sync
157
+ if self._pending_sync:
158
+ pending_name, pending_time = self._pending_sync
159
+ # Confirm if same session and within 3 second window
160
+ if pending_name == session_name and (now - pending_time) < 3.0:
161
+ self._pending_sync = None # Clear pending state
162
+ self._execute_sync(focused)
163
+ return
164
+ else:
165
+ # Different session or expired - start new confirmation
166
+ self._pending_sync = None
167
+
168
+ # First press - request confirmation
169
+ self._pending_sync = (session_name, now)
170
+ self.notify(
171
+ f"Press c again to sync '{session_name}' to main",
172
+ severity="warning",
173
+ timeout=3
174
+ )
175
+
176
+ def _execute_sync(self, widget: "SessionSummary") -> None:
177
+ """Execute the actual sync operation after confirmation."""
178
+ from ..tmux_manager import TmuxManager
179
+
180
+ session = widget.session
181
+ session_name = session.name
182
+ tmux = TmuxManager(self.tmux_session)
183
+
184
+ self.notify(f"Syncing '{session_name}' to main...", severity="information")
185
+
186
+ # Send git commands - Claude will execute and return to prompt
187
+ git_commands = "!git checkout main && git pull"
188
+ if not tmux.send_keys(session.tmux_window, git_commands, enter=True):
189
+ self.notify(f"Failed to send git commands to '{session_name}'", severity="error")
190
+ return
191
+
192
+ # Send /clear - tmux queues this, Claude processes after git completes
193
+ if tmux.send_keys(session.tmux_window, "/clear", enter=True):
194
+ self.notify(f"Synced '{session_name}' to main with fresh context", severity="information")
195
+ # Reset session stats for fresh start
196
+ self.session_manager.update_stats(
197
+ session.id,
198
+ current_task="Synced to main"
199
+ )
200
+ else:
201
+ self.notify(f"Failed to send /clear to '{session_name}'", severity="error")
202
+
203
+ def action_new_agent(self) -> None:
204
+ """Prompt for directory and name to create a new agent.
205
+
206
+ Two-step flow:
207
+ 1. Enter working directory (or press Enter for current directory)
208
+ 2. Enter agent name (defaults to directory basename)
209
+ """
210
+ from ..tui_widgets import CommandBar
211
+
212
+ try:
213
+ command_bar = self.query_one("#command-bar", CommandBar)
214
+ command_bar.add_class("visible") # Must show the command bar first
215
+ command_bar.set_mode("new_agent_dir")
216
+ # Pre-fill with current working directory
217
+ input_widget = command_bar.query_one("#cmd-input", Input)
218
+ input_widget.value = str(Path.cwd())
219
+ command_bar.focus_input()
220
+ except NoMatches:
221
+ self.notify("Command bar not found", severity="error")
222
+
223
+ def action_focus_command_bar(self) -> None:
224
+ """Focus the command bar for input."""
225
+ from ..tui_widgets import SessionSummary, CommandBar
226
+
227
+ try:
228
+ cmd_bar = self.query_one("#command-bar", CommandBar)
229
+
230
+ # Show the command bar
231
+ cmd_bar.add_class("visible")
232
+
233
+ # Get the currently focused session (if any)
234
+ focused = self.focused
235
+ if isinstance(focused, SessionSummary):
236
+ cmd_bar.set_target(focused.session.name)
237
+ elif not cmd_bar.target_session and self.sessions:
238
+ # Default to first session if none focused
239
+ cmd_bar.set_target(self.sessions[0].name)
240
+
241
+ # Enable and focus the input
242
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
243
+ cmd_input.disabled = False
244
+ cmd_input.focus()
245
+ except NoMatches:
246
+ pass
247
+
248
+ def action_focus_standing_orders(self) -> None:
249
+ """Focus the command bar for editing standing orders."""
250
+ from ..tui_widgets import SessionSummary, CommandBar
251
+
252
+ try:
253
+ cmd_bar = self.query_one("#command-bar", CommandBar)
254
+
255
+ # Show the command bar
256
+ cmd_bar.add_class("visible")
257
+
258
+ # Get the currently focused session (if any)
259
+ focused = self.focused
260
+ if isinstance(focused, SessionSummary):
261
+ cmd_bar.set_target(focused.session.name)
262
+ # Pre-fill with existing standing orders
263
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
264
+ cmd_input.value = focused.session.standing_instructions or ""
265
+ elif not cmd_bar.target_session and self.sessions:
266
+ # Default to first session if none focused
267
+ cmd_bar.set_target(self.sessions[0].name)
268
+
269
+ # Set mode to standing_orders
270
+ cmd_bar.set_mode("standing_orders")
271
+
272
+ # Enable and focus the input
273
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
274
+ cmd_input.disabled = False
275
+ cmd_input.focus()
276
+ except NoMatches:
277
+ pass
278
+
279
+ def action_focus_human_annotation(self) -> None:
280
+ """Focus input for editing human annotation (#74)."""
281
+ from ..tui_widgets import SessionSummary, CommandBar
282
+
283
+ try:
284
+ cmd_bar = self.query_one("#command-bar", CommandBar)
285
+
286
+ # Show the command bar
287
+ cmd_bar.add_class("visible")
288
+
289
+ # Get the currently focused session (if any)
290
+ focused = self.focused
291
+ if isinstance(focused, SessionSummary):
292
+ cmd_bar.set_target(focused.session.name)
293
+ # Pre-fill with existing annotation
294
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
295
+ cmd_input.value = focused.session.human_annotation or ""
296
+ elif not cmd_bar.target_session and self.sessions:
297
+ # Default to first session if none focused
298
+ cmd_bar.set_target(self.sessions[0].name)
299
+
300
+ # Set mode to annotation editing
301
+ cmd_bar.set_mode("annotation")
302
+
303
+ # Enable and focus the input
304
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
305
+ cmd_input.disabled = False
306
+ cmd_input.focus()
307
+ except NoMatches:
308
+ pass
309
+
310
+ def action_edit_agent_value(self) -> None:
311
+ """Focus the command bar for editing agent value (#61)."""
312
+ from ..tui_widgets import SessionSummary, CommandBar
313
+
314
+ try:
315
+ cmd_bar = self.query_one("#command-bar", CommandBar)
316
+
317
+ # Show the command bar
318
+ cmd_bar.add_class("visible")
319
+
320
+ # Get the currently focused session (if any)
321
+ focused = self.focused
322
+ if isinstance(focused, SessionSummary):
323
+ cmd_bar.set_target(focused.session.name)
324
+ # Pre-fill with existing value
325
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
326
+ cmd_input.value = str(focused.session.agent_value)
327
+ elif not cmd_bar.target_session and self.sessions:
328
+ # Default to first session if none focused
329
+ cmd_bar.set_target(self.sessions[0].name)
330
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
331
+ cmd_input.value = "1000"
332
+
333
+ # Set mode to value
334
+ cmd_bar.set_mode("value")
335
+
336
+ # Enable and focus the input
337
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
338
+ cmd_input.disabled = False
339
+ cmd_input.focus()
340
+ except NoMatches:
341
+ pass
342
+
343
+ def action_transport_all(self) -> None:
344
+ """Prepare all sessions for transport/handover (requires double-press confirmation).
345
+
346
+ Sends instructions to all active (non-sleeping) agents to:
347
+ - Create a new branch if on main/master
348
+ - Commit their current changes
349
+ - Push to their branch
350
+ - Create a draft PR if none exists
351
+ - Post handover summary as a PR comment
352
+
353
+ Sleeping agents are excluded from handover.
354
+ """
355
+ now = time.time()
356
+
357
+ # Get active sessions (exclude terminated and sleeping)
358
+ active_sessions = [
359
+ s for s in self.sessions
360
+ if s.status != "terminated" and not s.is_asleep
361
+ ]
362
+ if not active_sessions:
363
+ self.notify("No active sessions to prepare (sleeping sessions excluded)", severity="warning")
364
+ return
365
+
366
+ # Check if this is a confirmation of a pending transport
367
+ if self._pending_transport is not None:
368
+ if (now - self._pending_transport) < 3.0:
369
+ self._pending_transport = None # Clear pending state
370
+ self._execute_transport_all(active_sessions)
371
+ return
372
+ else:
373
+ # Expired - start new confirmation
374
+ self._pending_transport = None
375
+
376
+ # First press - request confirmation
377
+ self._pending_transport = now
378
+ count = len(active_sessions)
379
+ self.notify(
380
+ f"Press H again to send handover instructions to {count} agent(s)",
381
+ severity="warning",
382
+ timeout=3
383
+ )
384
+
385
+ def _execute_transport_all(self, sessions: List["Session"]) -> None:
386
+ """Execute transport/handover instructions to all sessions."""
387
+ from ..launcher import ClaudeLauncher
388
+
389
+ # The handover instruction to send to each agent
390
+ handover_instruction = (
391
+ "Please prepare for handover. Follow these steps in order:\n\n"
392
+ "1. Check your current branch with `git branch --show-current`\n"
393
+ " - If on main or master, create and switch to a new branch:\n"
394
+ " `git checkout -b handover/<brief-task-description>`\n"
395
+ " - Never push directly to main/master\n\n"
396
+ "2. Commit all your current changes with a descriptive commit message\n\n"
397
+ "3. Push to your branch: `git push -u origin <branch-name>`\n\n"
398
+ "4. Check if a PR exists: `gh pr list --head $(git branch --show-current)`\n"
399
+ " - If no PR exists, create a draft PR:\n"
400
+ " `gh pr create --draft --title '<brief title>' --body 'WIP'`\n\n"
401
+ "5. Post a handover comment on the PR using `gh pr comment` with:\n"
402
+ " - What you've accomplished\n"
403
+ " - Current state of the work\n"
404
+ " - Any pending tasks or next steps\n"
405
+ " - Known issues or blockers"
406
+ )
407
+
408
+ launcher = ClaudeLauncher(
409
+ tmux_session=self.tmux_session,
410
+ session_manager=self.session_manager
411
+ )
412
+
413
+ success_count = 0
414
+ for session in sessions:
415
+ if launcher.send_to_session(session.name, handover_instruction):
416
+ success_count += 1
417
+
418
+ if success_count == len(sessions):
419
+ self.notify(
420
+ f"Sent handover instructions to {success_count} agent(s)",
421
+ severity="information"
422
+ )
423
+ else:
424
+ failed = len(sessions) - success_count
425
+ self.notify(
426
+ f"Sent to {success_count}, failed {failed} agent(s)",
427
+ severity="warning"
428
+ )