overcode 0.3.3__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {overcode-0.3.3/src/overcode.egg-info → overcode-0.3.4}/PKG-INFO +1 -1
  2. {overcode-0.3.3 → overcode-0.3.4}/pyproject.toml +1 -1
  3. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/bundled_skills.py +26 -0
  4. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/config.py +2 -2
  5. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/monitoring.py +1 -1
  6. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/split.py +50 -8
  7. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/config.py +28 -7
  8. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/dependency_check.py +33 -1
  9. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/hook_handler.py +4 -4
  10. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/hook_status_detector.py +18 -0
  11. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/monitor_daemon.py +26 -4
  12. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/monitor_daemon_state.py +1 -1
  13. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/session_manager.py +14 -2
  14. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/settings.py +17 -0
  15. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/sister_controller.py +22 -4
  16. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/sister_poller.py +26 -5
  17. overcode-0.3.4/src/overcode/ssh_provisioner.py +234 -0
  18. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/status_detector_factory.py +6 -0
  19. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/summary_columns.py +74 -7
  20. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/summary_groups.py +1 -1
  21. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/time_context.py +21 -16
  22. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tmux_manager.py +83 -0
  23. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui.py +268 -15
  24. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui.tcss +11 -0
  25. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_actions/daemon.py +9 -0
  26. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_actions/navigation.py +3 -0
  27. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_actions/session.py +13 -13
  28. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_actions/view.py +28 -16
  29. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/__init__.py +2 -0
  30. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/command_bar.py +17 -0
  31. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/help_overlay.py +37 -22
  32. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/session_summary.py +7 -2
  33. overcode-0.3.4/src/overcode/tui_widgets/tui_log_panel.py +145 -0
  34. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web_api.py +13 -1
  35. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web_control_api.py +42 -11
  36. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web_server.py +4 -1
  37. {overcode-0.3.3 → overcode-0.3.4/src/overcode.egg-info}/PKG-INFO +1 -1
  38. {overcode-0.3.3 → overcode-0.3.4}/src/overcode.egg-info/SOURCES.txt +2 -0
  39. {overcode-0.3.3 → overcode-0.3.4}/LICENSE +0 -0
  40. {overcode-0.3.3 → overcode-0.3.4}/MANIFEST.in +0 -0
  41. {overcode-0.3.3 → overcode-0.3.4}/README.md +0 -0
  42. {overcode-0.3.3 → overcode-0.3.4}/setup.cfg +0 -0
  43. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/__init__.py +0 -0
  44. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/agent_scanner.py +0 -0
  45. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/claude_config.py +0 -0
  46. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/claude_pid.py +0 -0
  47. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/__init__.py +0 -0
  48. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/__main__.py +0 -0
  49. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/_shared.py +0 -0
  50. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/agent.py +0 -0
  51. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/budget.py +0 -0
  52. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/daemon.py +0 -0
  53. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/hooks.py +0 -0
  54. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/jobs.py +0 -0
  55. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/perms.py +0 -0
  56. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/sister.py +0 -0
  57. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/cli/skills.py +0 -0
  58. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/daemon_claude_skill.md +0 -0
  59. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/daemon_logging.py +0 -0
  60. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/daemon_utils.py +0 -0
  61. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/data_export.py +0 -0
  62. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/duration.py +0 -0
  63. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/exceptions.py +0 -0
  64. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/follow_mode.py +0 -0
  65. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/history_reader.py +0 -0
  66. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/implementations.py +0 -0
  67. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/interfaces.py +0 -0
  68. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/job_launcher.py +0 -0
  69. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/job_manager.py +0 -0
  70. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/launcher.py +0 -0
  71. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/logging_config.py +0 -0
  72. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/mocks.py +0 -0
  73. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/monitor_daemon_core.py +0 -0
  74. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/notifier.py +0 -0
  75. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/pid_utils.py +0 -0
  76. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/presence_logger.py +0 -0
  77. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/protocols.py +0 -0
  78. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/standing_instructions.py +0 -0
  79. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/status_constants.py +0 -0
  80. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/status_detector.py +0 -0
  81. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/status_history.py +0 -0
  82. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/status_patterns.py +0 -0
  83. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/summarizer_client.py +0 -0
  84. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/summarizer_component.py +0 -0
  85. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/supervisor_daemon.py +0 -0
  86. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/supervisor_daemon_core.py +0 -0
  87. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/supervisor_layout.sh +0 -0
  88. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/testing/__init__.py +0 -0
  89. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/testing/renderer.py +0 -0
  90. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/testing/tmux_driver.py +0 -0
  91. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/testing/tui_eye.py +0 -0
  92. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/testing/tui_eye_skill.md +0 -0
  93. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tmux_utils.py +0 -0
  94. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_actions/__init__.py +0 -0
  95. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_actions/input.py +0 -0
  96. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_helpers.py +0 -0
  97. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_logic.py +0 -0
  98. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_render.py +0 -0
  99. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
  100. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/daemon_panel.py +0 -0
  101. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
  102. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  103. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
  104. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/job_summary.py +0 -0
  105. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
  106. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/preview_pane.py +0 -0
  107. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
  108. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/status_timeline.py +0 -0
  109. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  110. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/usage_monitor.py +0 -0
  111. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web/__init__.py +0 -0
  112. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web/templates/analytics.html +0 -0
  113. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web/templates/dashboard.html +0 -0
  114. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web_chartjs.py +0 -0
  115. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web_server_runner.py +0 -0
  116. {overcode-0.3.3 → overcode-0.3.4}/src/overcode/web_templates.py +0 -0
  117. {overcode-0.3.3 → overcode-0.3.4}/src/overcode.egg-info/dependency_links.txt +0 -0
  118. {overcode-0.3.3 → overcode-0.3.4}/src/overcode.egg-info/entry_points.txt +0 -0
  119. {overcode-0.3.3 → overcode-0.3.4}/src/overcode.egg-info/requires.txt +0 -0
  120. {overcode-0.3.3 → overcode-0.3.4}/src/overcode.egg-info/top_level.txt +0 -0
  121. {overcode-0.3.3 → overcode-0.3.4}/tests/test_e2e_multi_agent_jokes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overcode
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: A supervisor for managing multiple Claude Code instances in tmux
5
5
  Author: Mike Bond
6
6
  Project-URL: Homepage, https://github.com/mkb23/overcode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "overcode"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -248,6 +248,32 @@ Jobs are visible in the TUI jobs view (press `J`) and auto-clean after 24h (conf
248
248
  }
249
249
 
250
250
 
251
+ def get_available_skills(project_dir: str | None = None) -> list[str]:
252
+ """Scan for installed skill directories (user-level + project-level).
253
+
254
+ Returns sorted list of skill names found in ~/.claude/skills/
255
+ and optionally .claude/skills/ relative to project_dir.
256
+ """
257
+ skills: set[str] = set()
258
+
259
+ # User-level skills
260
+ user_skills = Path.home() / ".claude" / "skills"
261
+ if user_skills.is_dir():
262
+ for d in user_skills.iterdir():
263
+ if d.is_dir() and (d / "SKILL.md").exists():
264
+ skills.add(d.name)
265
+
266
+ # Project-level skills
267
+ if project_dir:
268
+ proj_skills = Path(project_dir) / ".claude" / "skills"
269
+ if proj_skills.is_dir():
270
+ for d in proj_skills.iterdir():
271
+ if d.is_dir() and (d / "SKILL.md").exists():
272
+ skills.add(d.name)
273
+
274
+ return sorted(skills)
275
+
276
+
251
277
  def any_skills_stale() -> bool:
252
278
  """Check if any installed skills are outdated vs bundled versions."""
253
279
  base = Path.home() / ".claude" / "skills"
@@ -44,8 +44,8 @@ CONFIG_TEMPLATE = """\
44
44
  # start: "09:00"
45
45
  # end: "17:00"
46
46
 
47
- # Time context hook settings (for 'overcode time-context')
48
- # time_context:
47
+ # Enhanced context hook settings (agent identity, clock, presence, uptime)
48
+ # enhanced_context:
49
49
  # office_start: 9
50
50
  # office_end: 17
51
51
  # heartbeat_interval_minutes: 15 # omit to disable
@@ -18,7 +18,7 @@ def hook_handler_cmd():
18
18
 
19
19
  Called by Claude Code hooks, not by users directly.
20
20
  Reads event JSON from stdin, writes state for status detection,
21
- and outputs time-context for UserPromptSubmit events.
21
+ and outputs enhanced context for UserPromptSubmit events.
22
22
  """
23
23
  from ..hook_handler import handle_hook_event
24
24
 
@@ -263,20 +263,51 @@ def _setup_keybindings(linked_session: str = "", toggle_key: str = "") -> None:
263
263
  # --- Scrollback for the nested tmux in the bottom pane ---
264
264
  # The bottom pane runs a nested tmux client. The outer tmux
265
265
  # intercepts the prefix key, so copy-mode can't be entered
266
- # normally. These bindings use `copy-mode -t` to directly enter
267
- # copy mode in the inner session via the tmux API.
266
+ # normally.
267
+ #
268
+ # For local agents: use `copy-mode -t` to directly enter copy mode
269
+ # in the inner session via the tmux API (the linked session shares
270
+ # the actual agent panes, so scrollback is real).
271
+ #
272
+ # For SSH proxy windows (name starts with "ssh:"): the linked
273
+ # session pane is an SSH+tmux-attach client whose local scrollback
274
+ # is just rendering frames. Instead, forward PageUp/scroll through
275
+ # to the remote tmux which has the actual agent scrollback.
268
276
  if linked_session:
269
277
  # PageUp: enter copy mode + scroll up, but only when in the
270
278
  # bottom pane (pane_index != base) of the split window.
271
279
  # Top pane (Textual TUI) and other windows get normal PageUp.
280
+ #
281
+ # For SSH proxy windows: forward PageUp through the SSH pane
282
+ # so the *remote* tmux enters copy mode (the local proxy pane
283
+ # has no real scrollback — just rendered inner-tmux frames).
284
+ # For local agents: enter copy mode directly on the linked
285
+ # session which shares the real agent pane.
286
+ #
287
+ # Detection uses the @is_ssh_proxy window option (set by
288
+ # create_ssh_proxy_window) via a shell-based if-shell — this
289
+ # avoids tmux format expansion issues in stored bindings.
290
+ _ssh_check = (
291
+ f"tmux show-window-option -t {linked_session} -v @is_ssh_proxy "
292
+ "2>/dev/null | grep -q on"
293
+ )
272
294
  _tmux(
273
295
  "bind-key", "-n", "PPage",
274
296
  "if-shell", "-F",
275
297
  f"#{{&&:#{{==:#{{window_name}},{SPLIT_WINDOW_NAME}}},#{{!=:#{{pane_index}},{_base}}}}}",
276
- f"copy-mode -t {linked_session} -u",
298
+ # SSH proxy: send prefix + PPage so the REMOTE tmux enters
299
+ # copy mode (the remote's root-table PPage is overridden by
300
+ # overcode, but prefix PPage still maps to copy-mode -u).
301
+ # Local: enter copy mode directly on the linked session.
302
+ f'if-shell "{_ssh_check}" '
303
+ f'"send-keys -t {linked_session} C-b ; '
304
+ f'send-keys -t {linked_session} PPage" '
305
+ f'"copy-mode -t {linked_session} -u"',
277
306
  "send-keys PPage",
278
307
  )
279
308
  # PageDown in the inner session's copy mode
309
+ # (send-keys works for both local and SSH proxy — for local it
310
+ # controls copy mode, for SSH proxy it forwards to remote tmux)
280
311
  _tmux(
281
312
  "bind-key", "-n", "NPage",
282
313
  "if-shell", "-F",
@@ -288,8 +319,9 @@ def _setup_keybindings(linked_session: str = "", toggle_key: str = "") -> None:
288
319
  # Without this, scrolling enters copy mode in the outer pane
289
320
  # which has no scrollback (just rendered inner tmux frames).
290
321
  #
291
- # Strategy: enter copy mode in the inner session (no-op if
292
- # already active), then send scroll-up/down commands to it.
322
+ # For local agents: enter copy mode in the inner session then
323
+ # send scroll commands. For SSH proxies: forward mouse events
324
+ # to the remote tmux through the pane.
293
325
  _in_bottom = (
294
326
  f"#{{&&:#{{==:#{{window_name}},{SPLIT_WINDOW_NAME}}},"
295
327
  f"#{{!=:#{{pane_index}},{_base}}}}}"
@@ -297,8 +329,14 @@ def _setup_keybindings(linked_session: str = "", toggle_key: str = "") -> None:
297
329
  _tmux(
298
330
  "bind-key", "-n", "WheelUpPane",
299
331
  "if-shell", "-F", _in_bottom,
300
- f"copy-mode -t {linked_session} -e ; "
301
- f"send-keys -t {linked_session} -X -N 3 scroll-up",
332
+ # SSH proxy: send prefix+PPage to enter copy mode on remote
333
+ # (no-op if already in copy mode, then PPage scrolls up).
334
+ # Local: enter copy mode + scroll up directly.
335
+ f'if-shell "{_ssh_check}" '
336
+ f'"send-keys -t {linked_session} C-b ; '
337
+ f'send-keys -t {linked_session} PPage" '
338
+ f'"copy-mode -t {linked_session} -e ; '
339
+ f'send-keys -t {linked_session} -X -N 3 scroll-up"',
302
340
  # Default behaviour for other contexts
303
341
  "if-shell -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' "
304
342
  "'send-keys -M' 'copy-mode -e'",
@@ -306,7 +344,11 @@ def _setup_keybindings(linked_session: str = "", toggle_key: str = "") -> None:
306
344
  _tmux(
307
345
  "bind-key", "-n", "WheelDownPane",
308
346
  "if-shell", "-F", _in_bottom,
309
- f"send-keys -t {linked_session} -X -N 3 scroll-down",
347
+ # SSH proxy: send NPage to remote (works in copy mode).
348
+ # Local: send scroll-down in local copy mode.
349
+ f'if-shell "{_ssh_check}" '
350
+ f'"send-keys -t {linked_session} NPage" '
351
+ f'"send-keys -t {linked_session} -X -N 3 scroll-down"',
310
352
  "send-keys -M",
311
353
  )
312
354
 
@@ -197,23 +197,35 @@ def get_web_time_presets() -> list:
197
197
  ]
198
198
 
199
199
 
200
- def get_time_context_config() -> dict:
201
- """Get time context configuration for the time-context hook.
200
+ def get_enhanced_context_config() -> dict:
201
+ """Get enhanced context configuration for the enhanced-context hook.
202
202
 
203
203
  Config format in ~/.overcode/config.yaml:
204
- time_context:
204
+ enhanced_context:
205
205
  office_start: 9
206
206
  office_end: 17
207
207
  heartbeat_interval_minutes: 15 # omit to disable
208
208
 
209
+ Falls back to legacy 'time_context' key for backwards compatibility.
210
+
209
211
  Returns:
210
212
  Dict with office_start (int), office_end (int),
211
213
  heartbeat_interval_minutes (Optional[int])
212
214
  """
215
+ # Try new key first, fall back to legacy key
216
+ office_start = _get_config_value("enhanced_context.office_start")
217
+ if office_start is None:
218
+ office_start = _get_config_value("time_context.office_start", 9)
219
+ office_end = _get_config_value("enhanced_context.office_end")
220
+ if office_end is None:
221
+ office_end = _get_config_value("time_context.office_end", 17)
222
+ hb_interval = _get_config_value("enhanced_context.heartbeat_interval_minutes")
223
+ if hb_interval is None:
224
+ hb_interval = _get_config_value("time_context.heartbeat_interval_minutes")
213
225
  return {
214
- "office_start": _get_config_value("time_context.office_start", 9),
215
- "office_end": _get_config_value("time_context.office_end", 17),
216
- "heartbeat_interval_minutes": _get_config_value("time_context.heartbeat_interval_minutes"),
226
+ "office_start": office_start,
227
+ "office_end": office_end,
228
+ "heartbeat_interval_minutes": hb_interval,
217
229
  }
218
230
 
219
231
 
@@ -365,9 +377,11 @@ def get_sisters_config() -> List[dict]:
365
377
  - name: "desktop"
366
378
  url: "http://localhost:25337"
367
379
  api_key: "secret"
380
+ ssh: "user@desktop" # optional: enables tmux attach + auto-provisioning
381
+ tmux_session: "agents" # optional: remote tmux session name (default: "agents")
368
382
 
369
383
  Returns:
370
- List of dicts with name, url, and optional api_key. Empty list if unconfigured.
384
+ List of dicts with name, url, and optional api_key/ssh/tmux_session. Empty list if unconfigured.
371
385
  """
372
386
  sisters = _get_config_value("sisters", [])
373
387
  if not isinstance(sisters, list):
@@ -385,6 +399,13 @@ def get_sisters_config() -> List[dict]:
385
399
  api_key = s.get("api_key")
386
400
  if api_key:
387
401
  entry["api_key"] = api_key
402
+ # SSH connectivity (optional)
403
+ ssh = s.get("ssh")
404
+ if ssh:
405
+ entry["ssh"] = ssh
406
+ tmux_session = s.get("tmux_session")
407
+ if tmux_session:
408
+ entry["tmux_session"] = tmux_session
388
409
  result.append(entry)
389
410
 
390
411
  return result
@@ -15,13 +15,45 @@ from .exceptions import TmuxNotFoundError, ClaudeNotFoundError
15
15
  def find_executable(name: str) -> Optional[str]:
16
16
  """Find the path to an executable.
17
17
 
18
+ Checks PATH first, then common install locations that may not be on PATH
19
+ in non-login shells (e.g., web server subprocesses, SSH non-interactive).
20
+
18
21
  Args:
19
22
  name: Name of the executable
20
23
 
21
24
  Returns:
22
25
  Full path to executable, or None if not found
23
26
  """
24
- return shutil.which(name)
27
+ import os
28
+ from pathlib import Path
29
+
30
+ path = shutil.which(name)
31
+ if path:
32
+ return path
33
+
34
+ # Check common locations not always on PATH in non-login shells
35
+ return _find_in_fallback_dirs(name)
36
+
37
+
38
+ def _find_in_fallback_dirs(name: str) -> Optional[str]:
39
+ """Check common install directories for an executable."""
40
+ import os
41
+ from pathlib import Path
42
+
43
+ home = Path.home()
44
+ fallback_dirs = [
45
+ home / ".local" / "bin", # pip/pipx, claude CLI
46
+ home / ".npm-global" / "bin", # npm global
47
+ home / ".nvm" / "current" / "bin", # nvm
48
+ Path("/usr/local/bin"),
49
+ Path("/opt/homebrew/bin"), # macOS ARM homebrew
50
+ ]
51
+ for d in fallback_dirs:
52
+ candidate = d / name
53
+ if candidate.is_file() and os.access(candidate, os.X_OK):
54
+ return str(candidate)
55
+
56
+ return None
25
57
 
26
58
 
27
59
  def _check_executable(
@@ -2,7 +2,7 @@
2
2
 
3
3
  A single command (`overcode hook-handler`) handles all hook events.
4
4
  It reads stdin JSON from Claude Code, writes state files for hook-based
5
- status detection, and outputs time-context for UserPromptSubmit events.
5
+ status detection, and outputs enhanced context for UserPromptSubmit events.
6
6
 
7
7
  Hook registrations (all use the same command):
8
8
  UserPromptSubmit -> overcode hook-handler
@@ -110,7 +110,7 @@ def handle_hook_event() -> None:
110
110
  # Write state file for status detection
111
111
  write_hook_state(event, tmux_session, session_name, tool_name=tool_name, tool_input=tool_input)
112
112
 
113
- # For UserPromptSubmit, check budget and output time-context
113
+ # For UserPromptSubmit, check budget and output enhanced context
114
114
  if event == "UserPromptSubmit":
115
115
  from .time_context import _load_daemon_state, _find_session_in_state
116
116
 
@@ -127,8 +127,8 @@ def handle_hook_event() -> None:
127
127
  )
128
128
  sys.exit(2)
129
129
 
130
- from .time_context import generate_time_context
130
+ from .time_context import generate_enhanced_context
131
131
 
132
- line = generate_time_context(tmux_session, session_name)
132
+ line = generate_enhanced_context(tmux_session, session_name)
133
133
  if line:
134
134
  print(line)
@@ -95,6 +95,8 @@ class HookStatusDetector:
95
95
  # Diagnostic phase tracking (same interface as PollingStatusDetector)
96
96
  self._last_detect_phase: Dict[str, str] = {}
97
97
  self._content_changed: Dict[str, bool] = {}
98
+ # Skills observed via Skill tool_use events, keyed by session name (#252)
99
+ self._loaded_skills: Dict[str, set] = {}
98
100
 
99
101
  # Resolve state directory — must match hook_handler._get_hook_state_path()
100
102
  if state_dir is not None:
@@ -180,6 +182,17 @@ class HookStatusDetector:
180
182
  self._last_detect_phase[session.id] = "hook:no_state"
181
183
  return STATUS_WAITING_USER, "Waiting for first hook event", pane_content
182
184
 
185
+ # Track Skill tool_use events (#252)
186
+ tool_name = hook_state.get("tool_name")
187
+ if tool_name == "Skill":
188
+ tool_input = hook_state.get("tool_input")
189
+ if isinstance(tool_input, dict):
190
+ skill_name = tool_input.get("skill", "")
191
+ if skill_name:
192
+ if session.name not in self._loaded_skills:
193
+ self._loaded_skills[session.name] = set()
194
+ self._loaded_skills[session.name].add(skill_name)
195
+
183
196
  # Hook state exists — use it for status
184
197
  event = hook_state.get("event", "")
185
198
 
@@ -308,3 +321,8 @@ class HookStatusDetector:
308
321
  return "Claude exited"
309
322
 
310
323
  return "Unknown state"
324
+
325
+ def get_loaded_skills(self, session_name: str) -> list[str]:
326
+ """Return skills observed via Skill tool_use for a session (#252)."""
327
+ skills = self._loaded_skills.get(session_name, set())
328
+ return sorted(skills)
@@ -312,6 +312,10 @@ class MonitorDaemon:
312
312
  self._last_session_id_sync: Optional[datetime] = None
313
313
  self._session_id_sync_interval = 10 # seconds
314
314
 
315
+ # Available skills sync (#252) — infrequent, skills rarely change
316
+ self._last_skills_sync: Optional[datetime] = None
317
+ self._skills_sync_interval = 60 # seconds
318
+
315
319
  # Relay configuration (for pushing state to cloud)
316
320
  self._relay_config = get_relay_config()
317
321
  self._last_relay_push = datetime.min
@@ -452,7 +456,7 @@ class MonitorDaemon:
452
456
  permissiveness_mode=session.permissiveness_mode,
453
457
  start_directory=session.start_directory,
454
458
  is_asleep=session.is_asleep,
455
- time_context_enabled=session.time_context_enabled,
459
+ enhanced_context_enabled=session.enhanced_context_enabled,
456
460
  agent_value=session.agent_value,
457
461
  # Heartbeat state (#171)
458
462
  heartbeat_enabled=session.heartbeat_enabled,
@@ -899,6 +903,7 @@ class MonitorDaemon:
899
903
  self._legacy_windows_migrated = True
900
904
  self._sync_session_ids(sessions, now)
901
905
  self._sync_session_stats(sessions, now)
906
+ self._sync_available_skills(sessions, now)
902
907
  self._dispatch_heartbeats(sessions)
903
908
  session_states, all_waiting = self._detect_and_enrich(sessions, now)
904
909
  self._cleanup_stale(sessions)
@@ -924,6 +929,16 @@ class MonitorDaemon:
924
929
  self.sync_claude_code_stats(session)
925
930
  self._last_stats_sync = now
926
931
 
932
+ def _sync_available_skills(self, sessions: list, now: datetime) -> None:
933
+ """Scan installed skill directories every 60s (#252)."""
934
+ if should_sync_stats(self._last_skills_sync, now, self._skills_sync_interval):
935
+ from .bundled_skills import get_available_skills
936
+ for session in sessions:
937
+ available = get_available_skills(session.start_directory)
938
+ if available != session.available_skills:
939
+ self.session_manager.update_session(session.id, available_skills=available)
940
+ self._last_skills_sync = now
941
+
927
942
  def _dispatch_heartbeats(self, sessions: list) -> None:
928
943
  """Send heartbeats before status detection (#171)."""
929
944
  self._heartbeat_triggered_sessions = self.check_and_send_heartbeats(sessions)
@@ -952,6 +967,12 @@ class MonitorDaemon:
952
967
  # Log hook events when they change (diagnostic visibility)
953
968
  self._log_hook_event(session, status, activity)
954
969
 
970
+ # Track loaded skills from hook events (#252)
971
+ if hasattr(self.detector, 'get_loaded_skills'):
972
+ new_skills = self.detector.get_loaded_skills(session.name)
973
+ if new_skills and sorted(new_skills) != sorted(session.loaded_skills):
974
+ self.session_manager.update_session(session.id, loaded_skills=new_skills)
975
+
955
976
  # Extract PR number from pane content
956
977
  if pane_content:
957
978
  pr = extract_pr_number(pane_content)
@@ -985,9 +1006,10 @@ class MonitorDaemon:
985
1006
  continue
986
1007
 
987
1008
  # Track stats and build state
988
- # Use "asleep" status if session is marked as sleeping (#68)
989
- # Use "running_heartbeat" if running due to heartbeat trigger (#171)
990
- if session.is_asleep:
1009
+ # Precedence: terminated > asleep > heartbeat variants > default (#399, #68, #171)
1010
+ if status == STATUS_TERMINATED:
1011
+ effective_status = STATUS_TERMINATED
1012
+ elif session.is_asleep:
991
1013
  effective_status = STATUS_ASLEEP
992
1014
  elif status == STATUS_RUNNING and session.id in self._sessions_running_from_heartbeat:
993
1015
  if session.id in self._heartbeat_start_pending:
@@ -76,7 +76,7 @@ class SessionDaemonState:
76
76
  permissiveness_mode: str = "normal" # normal, permissive, bypass
77
77
  start_directory: Optional[str] = None # For git diff stats
78
78
  is_asleep: bool = False # Agent is paused and excluded from stats (#70)
79
- time_context_enabled: bool = False # Per-agent time awareness toggle
79
+ enhanced_context_enabled: bool = False # Per-agent enhanced context toggle
80
80
 
81
81
  # Agent priority value (#61)
82
82
  agent_value: int = 1000 # Default 1000, higher = more important
@@ -97,8 +97,8 @@ class Session:
97
97
  # Sleep mode - agent is paused and excluded from stats
98
98
  is_asleep: bool = False
99
99
 
100
- # Time context hook - per-agent toggle for time awareness injection
101
- time_context_enabled: bool = False
100
+ # Enhanced context hook - per-agent toggle for enhanced context injection
101
+ enhanced_context_enabled: bool = False
102
102
 
103
103
  # Agent value - priority indicator for sorting/attention (#61)
104
104
  # Default 1000, higher = more important
@@ -129,6 +129,10 @@ class Session:
129
129
  # Hook-based status detection - per-agent toggle (#5)
130
130
  hook_status_detection: bool = True
131
131
 
132
+ # Skills loaded during this session (#252)
133
+ loaded_skills: List[str] = field(default_factory=list)
134
+ available_skills: List[str] = field(default_factory=list)
135
+
132
136
  # Claude CLI flag passthrough (#290)
133
137
  allowed_tools: Optional[str] = None # Comma-separated tool list for --allowedTools
134
138
  extra_claude_args: List[str] = field(default_factory=list) # Extra CLI flags via --claude-arg
@@ -158,6 +162,10 @@ class Session:
158
162
  remote_activity_summary_context: str = "" # AI context summary from remote summarizer
159
163
  remote_daemon_state: Optional[dict] = None # Raw daemon state dict from sister API (for generic forwarding)
160
164
 
165
+ # SSH connectivity for remote agents
166
+ source_ssh: str = "" # SSH target (e.g., "user@host") for tmux attach
167
+ source_tmux_session: str = "" # Remote tmux session name (default: "agents")
168
+
161
169
  def to_dict(self) -> dict:
162
170
  # asdict() recursively converts nested dataclasses (stats)
163
171
  return asdict(self)
@@ -193,6 +201,10 @@ class Session:
193
201
  if 'tmux_window' in filtered and isinstance(filtered['tmux_window'], int):
194
202
  filtered['tmux_window'] = str(filtered['tmux_window'])
195
203
 
204
+ # Backward compat: migrate time_context_enabled → enhanced_context_enabled (#378)
205
+ if 'enhanced_context_enabled' not in filtered and 'time_context_enabled' in data:
206
+ filtered['enhanced_context_enabled'] = data['time_context_enabled']
207
+
196
208
  try:
197
209
  return cls(**filtered)
198
210
  except TypeError:
@@ -262,6 +262,9 @@ class UserConfig:
262
262
  # Per-model pricing overrides (loaded from config.yaml model_pricing section)
263
263
  model_pricing: dict = field(default_factory=dict)
264
264
 
265
+ # Skill emoji overrides (loaded from config.yaml skill_emoji section) (#252)
266
+ skill_emoji: dict = field(default_factory=dict)
267
+
265
268
  @classmethod
266
269
  def load(cls) -> "UserConfig":
267
270
  """Load configuration from config file."""
@@ -292,6 +295,13 @@ class UserConfig:
292
295
  cache_read=vals.get("cache_read", 0.30),
293
296
  )
294
297
 
298
+ # Parse skill emoji overrides (#252)
299
+ skill_emoji_raw = data.get("skill_emoji", {})
300
+ skill_emoji_parsed = (
301
+ {str(k): str(v) for k, v in skill_emoji_raw.items()}
302
+ if isinstance(skill_emoji_raw, dict) else {}
303
+ )
304
+
295
305
  return cls(
296
306
  default_standing_instructions=data.get(
297
307
  "default_standing_instructions", ""
@@ -302,6 +312,7 @@ class UserConfig:
302
312
  price_cache_write=pricing.get("cache_write", 3.75),
303
313
  price_cache_read=pricing.get("cache_read", 0.30),
304
314
  model_pricing=model_pricing_parsed,
315
+ skill_emoji=skill_emoji_parsed,
305
316
  )
306
317
  except (yaml.YAMLError, IOError):
307
318
  return cls()
@@ -439,6 +450,11 @@ def resolve_detection_mode(session: str) -> str:
439
450
  return "hooks" if ClaudeConfigEditor.are_overcode_hooks_installed() else "polling"
440
451
 
441
452
 
453
+ def get_tui_log_path(session: str) -> Path:
454
+ """Get the TUI diagnostic log file path for a specific session."""
455
+ return get_session_dir(session) / "tui.log"
456
+
457
+
442
458
  def get_diagnostics_dir(session: str) -> Path:
443
459
  """Get the diagnostics directory for a specific session."""
444
460
  return get_session_dir(session) / "diagnostics"
@@ -521,6 +537,7 @@ class TUIPreferences:
521
537
  summary_detail: str = "full" # low, med, full
522
538
  timeline_visible: bool = True
523
539
  daemon_panel_visible: bool = False
540
+ tui_log_panel_visible: bool = False
524
541
  preview_visible: bool = False # preview pane visibility
525
542
  tmux_sync: bool = False # sync navigation to external tmux pane
526
543
  show_terminated: bool = False # keep killed sessions visible in timeline
@@ -6,11 +6,14 @@ request to the sister's web API and returns a Result.
6
6
  """
7
7
 
8
8
  import json
9
+ import logging
9
10
  from dataclasses import dataclass
10
11
  from typing import Optional
11
12
  from urllib.error import HTTPError, URLError
12
13
  from urllib.request import Request, urlopen
13
14
 
15
+ _log = logging.getLogger("overcode.sister")
16
+
14
17
 
15
18
  @dataclass
16
19
  class ControlResult:
@@ -40,6 +43,7 @@ class SisterController:
40
43
  ) -> ControlResult:
41
44
  """Send an HTTP request to a sister's control API."""
42
45
  url = f"{sister_url.rstrip('/')}{path}"
46
+ _log.debug("HTTP %s %s body=%s", method, url, body)
43
47
 
44
48
  data = json.dumps(body or {}).encode("utf-8")
45
49
  req = Request(url, data=data, method=method)
@@ -50,14 +54,18 @@ class SisterController:
50
54
  try:
51
55
  with urlopen(req, timeout=self.timeout) as resp:
52
56
  result = json.loads(resp.read().decode("utf-8"))
57
+ _log.debug("HTTP %s %s -> ok=%s", method, url, result.get("ok", True))
53
58
  return ControlResult(ok=result.get("ok", True), data=result)
54
59
  except HTTPError as e:
55
60
  try:
56
61
  error_body = json.loads(e.read().decode("utf-8"))
57
- return ControlResult(ok=False, error=error_body.get("error", str(e)))
62
+ error_msg = error_body.get("error", str(e))
58
63
  except (json.JSONDecodeError, UnicodeDecodeError):
59
- return ControlResult(ok=False, error=f"HTTP {e.code}: {e.reason}")
64
+ error_msg = f"HTTP {e.code}: {e.reason}"
65
+ _log.error("HTTP %s %s -> HTTPError %s: %s", method, url, e.code, error_msg)
66
+ return ControlResult(ok=False, error=error_msg)
60
67
  except (URLError, OSError) as e:
68
+ _log.error("HTTP %s %s -> ConnectionError: %s", method, url, e)
61
69
  return ControlResult(ok=False, error=f"Connection error: {e}")
62
70
 
63
71
  # --- Agent Interaction ---
@@ -122,6 +130,16 @@ class SisterController:
122
130
  {"name": fork_name, "prompt": prompt},
123
131
  )
124
132
 
133
+ def resize_agent(
134
+ self, sister_url: str, api_key: str, agent_name: str,
135
+ width: int, height: int,
136
+ ) -> ControlResult:
137
+ return self._request(
138
+ "POST", sister_url, api_key,
139
+ f"/api/agents/{agent_name}/resize",
140
+ {"width": width, "height": height},
141
+ )
142
+
125
143
  # --- Agent Configuration ---
126
144
 
127
145
  def set_standing_orders(
@@ -220,12 +238,12 @@ class SisterController:
220
238
 
221
239
  # --- Feature Toggles ---
222
240
 
223
- def set_time_context(
241
+ def set_enhanced_context(
224
242
  self, sister_url: str, api_key: str, agent_name: str, enabled: bool,
225
243
  ) -> ControlResult:
226
244
  return self._request(
227
245
  "PUT", sister_url, api_key,
228
- f"/api/agents/{agent_name}/time-context",
246
+ f"/api/agents/{agent_name}/enhanced-context",
229
247
  {"enabled": enabled},
230
248
  )
231
249