11 Commits

5 changed files with 249 additions and 12 deletions
+102
View File
@@ -0,0 +1,102 @@
import json
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
import logging
import session_logger
class HookServerInstance(HTTPServer):
def __init__(self, server_address, RequestHandlerClass, app):
super().__init__(server_address, RequestHandlerClass)
self.app = app
class HookHandler(BaseHTTPRequestHandler):
def do_GET(self):
app = self.server.app
session_logger.log_api_hook("GET", self.path, "")
if self.path == '/status':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
elif self.path == '/api/project':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'project': app.project}).encode('utf-8'))
elif self.path == '/api/session':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'session': {'entries': app.disc_entries}}).encode('utf-8'))
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
app = self.server.app
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
body_str = body.decode('utf-8') if body else ""
session_logger.log_api_hook("POST", self.path, body_str)
try:
data = json.loads(body_str) if body_str else {}
if self.path == '/api/project':
app.project = data.get('project', app.project)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'status': 'updated'}).encode('utf-8'))
elif self.path == '/api/session':
app.disc_entries = data.get('session', {}).get('entries', app.disc_entries)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'status': 'updated'}).encode('utf-8'))
elif self.path == '/api/gui':
if not hasattr(app, '_pending_gui_tasks'):
app._pending_gui_tasks = []
if not hasattr(app, '_pending_gui_tasks_lock'):
app._pending_gui_tasks_lock = threading.Lock()
with app._pending_gui_tasks_lock:
app._pending_gui_tasks.append(data)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'status': 'queued'}).encode('utf-8'))
else:
self.send_response(404)
self.end_headers()
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
def log_message(self, format, *args):
logging.info("Hook API: " + format % args)
class HookServer:
def __init__(self, app, port=8999):
self.app = app
self.port = port
self.server = None
self.thread = None
def start(self):
if not getattr(self.app, 'test_hooks_enabled', False):
return
self.server = HookServerInstance(('127.0.0.1', self.port), HookHandler, self.app)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
logging.info(f"Hook server started on port {self.port}")
def stop(self):
if self.server:
self.server.shutdown()
self.server.server_close()
if self.thread:
self.thread.join()
logging.info("Hook server stopped")
+10 -10
View File
@@ -1,19 +1,19 @@
# Implementation Plan # Implementation Plan
## Phase 1: Foundation and Opt-in Mechanisms ## Phase 1: Foundation and Opt-in Mechanisms [checkpoint: 2bc7a3f]
- [ ] Task: Implement CLI flag/env-var to enable the hook system - [x] Task: Implement CLI flag/env-var to enable the hook system [1306163]
- [ ] Sub-task: Write Tests - [x] Sub-task: Write Tests
- [ ] Sub-task: Implement Feature - [x] Sub-task: Implement Feature
- [ ] Task: Set up lightweight local IPC server (e.g., standard library socket/HTTP) for receiving hook commands - [x] Task: Set up lightweight local IPC server (e.g., standard library socket/HTTP) for receiving hook commands [44c2585]
- [ ] Sub-task: Write Tests - [x] Sub-task: Write Tests
- [ ] Sub-task: Implement Feature - [x] Sub-task: Implement Feature
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Foundation and Opt-in Mechanisms' (Protocol in workflow.md) - [x] Task: Conductor - User Manual Verification 'Phase 1: Foundation and Opt-in Mechanisms' (Protocol in workflow.md) [2bc7a3f]
## Phase 2: Hook Implementations and Logging ## Phase 2: Hook Implementations and Logging
- [ ] Task: Implement project and AI session state manipulation hooks - [x] Task: Implement project and AI session state manipulation hooks [d9d056c]
- [ ] Sub-task: Write Tests - [ ] Sub-task: Write Tests
- [ ] Sub-task: Implement Feature - [ ] Sub-task: Implement Feature
- [ ] Task: Implement GUI state manipulation hooks with thread-safe queueing - [x] Task: Implement GUI state manipulation hooks with thread-safe queueing [5f9bc19]
- [ ] Sub-task: Write Tests - [ ] Sub-task: Write Tests
- [ ] Sub-task: Implement Feature - [ ] Sub-task: Implement Feature
- [ ] Task: Integrate aggressive logging for all hook invocations - [ ] Task: Integrate aggressive logging for all hook invocations
+33
View File
@@ -14,6 +14,8 @@ import tomli_w
import threading import threading
import time import time
import math import math
import sys
import os
from pathlib import Path from pathlib import Path
from tkinter import filedialog, Tk from tkinter import filedialog, Tk
import aggregate import aggregate
@@ -23,6 +25,7 @@ import shell_runner
import session_logger import session_logger
import project_manager import project_manager
import theme import theme
import api_hooks
CONFIG_PATH = Path("config.toml") CONFIG_PATH = Path("config.toml")
PROVIDERS = ["gemini", "anthropic"] PROVIDERS = ["gemini", "anthropic"]
@@ -393,6 +396,8 @@ def _parse_history_entries(history: list[str], roles: list[str] | None = None) -
class App: class App:
def __init__(self): def __init__(self):
self.config = load_config() self.config = load_config()
self.test_hooks_enabled = '--enable-test-hooks' in sys.argv or os.environ.get('SLOP_TEST_HOOKS') == '1'
self.hook_server = api_hooks.HookServer(self)
# ---- global settings from config.toml ---- # ---- global settings from config.toml ----
ai_cfg = self.config.get("ai", {}) ai_cfg = self.config.get("ai", {})
@@ -468,6 +473,10 @@ class App:
self._pending_history_adds: list[dict] = [] self._pending_history_adds: list[dict] = []
self._pending_history_adds_lock = threading.Lock() self._pending_history_adds_lock = threading.Lock()
# API GUI Hooks Queue
self._pending_gui_tasks: list[dict] = []
self._pending_gui_tasks_lock = threading.Lock()
# Blink state # Blink state
self._trigger_blink = False self._trigger_blink = False
self._is_blinking = False self._is_blinking = False
@@ -2055,6 +2064,8 @@ class App:
self._rebuild_discussion_selector() self._rebuild_discussion_selector()
self._fetch_models(self.current_provider) self._fetch_models(self.current_provider)
self.hook_server.start()
while dpg.is_dearpygui_running(): while dpg.is_dearpygui_running():
# Show any pending confirmation dialog on the main thread safely # Show any pending confirmation dialog on the main thread safely
with self._pending_dialog_lock: with self._pending_dialog_lock:
@@ -2078,6 +2089,27 @@ class App:
# Force scroll to bottom using a very large number # Force scroll to bottom using a very large number
dpg.set_y_scroll("disc_scroll", 99999) dpg.set_y_scroll("disc_scroll", 99999)
# Process queued API GUI tasks
with self._pending_gui_tasks_lock:
gui_tasks = self._pending_gui_tasks[:]
self._pending_gui_tasks.clear()
for task in gui_tasks:
try:
action = task.get("action")
if action == "set_value":
item = task.get("item")
val = task.get("value")
if item and dpg.does_item_exist(item):
dpg.set_value(item, val)
elif action == "click":
item = task.get("item")
if item and dpg.does_item_exist(item):
cb = dpg.get_item_callback(item)
if cb:
cb()
except Exception as e:
print(f"Error executing GUI hook task: {e}")
# Handle retro arcade blinking effect # Handle retro arcade blinking effect
if self._trigger_script_blink: if self._trigger_script_blink:
self._trigger_script_blink = False self._trigger_script_blink = False
@@ -2181,6 +2213,7 @@ class App:
dpg.save_init_file("dpg_layout.ini") dpg.save_init_file("dpg_layout.ini")
session_logger.close_session() session_logger.close_session()
ai_client.cleanup() # Destroy active API caches to stop billing ai_client.cleanup() # Destroy active API caches to stop billing
self.hook_server.stop()
dpg.destroy_context() dpg.destroy_context()
+21 -2
View File
@@ -40,6 +40,7 @@ _seq_lock = threading.Lock()
_comms_fh = None # file handle: logs/comms_<ts>.log _comms_fh = None # file handle: logs/comms_<ts>.log
_tool_fh = None # file handle: logs/toolcalls_<ts>.log _tool_fh = None # file handle: logs/toolcalls_<ts>.log
_api_fh = None # file handle: logs/apihooks_<ts>.log
def _now_ts() -> str: def _now_ts() -> str:
@@ -52,7 +53,7 @@ def open_session():
opens the two log files for this session. Idempotent - a second call is opens the two log files for this session. Idempotent - a second call is
ignored. ignored.
""" """
global _ts, _comms_fh, _tool_fh, _seq global _ts, _comms_fh, _tool_fh, _api_fh, _seq
if _comms_fh is not None: if _comms_fh is not None:
return # already open return # already open
@@ -65,6 +66,7 @@ def open_session():
_comms_fh = open(_LOG_DIR / f"comms_{_ts}.log", "w", encoding="utf-8", buffering=1) _comms_fh = open(_LOG_DIR / f"comms_{_ts}.log", "w", encoding="utf-8", buffering=1)
_tool_fh = open(_LOG_DIR / f"toolcalls_{_ts}.log", "w", encoding="utf-8", buffering=1) _tool_fh = open(_LOG_DIR / f"toolcalls_{_ts}.log", "w", encoding="utf-8", buffering=1)
_api_fh = open(_LOG_DIR / f"apihooks_{_ts}.log", "w", encoding="utf-8", buffering=1)
_tool_fh.write(f"# Tool-call log — session {_ts}\n\n") _tool_fh.write(f"# Tool-call log — session {_ts}\n\n")
_tool_fh.flush() _tool_fh.flush()
@@ -72,13 +74,30 @@ def open_session():
def close_session(): def close_session():
"""Flush and close both log files. Called on clean exit (optional).""" """Flush and close both log files. Called on clean exit (optional)."""
global _comms_fh, _tool_fh global _comms_fh, _tool_fh, _api_fh
if _comms_fh: if _comms_fh:
_comms_fh.close() _comms_fh.close()
_comms_fh = None _comms_fh = None
if _tool_fh: if _tool_fh:
_tool_fh.close() _tool_fh.close()
_tool_fh = None _tool_fh = None
if _api_fh:
_api_fh.close()
_api_fh = None
def log_api_hook(method: str, path: str, payload: str):
"""
Log an API hook invocation.
"""
if _api_fh is None:
return
ts_entry = datetime.datetime.now().strftime("%H:%M:%S")
try:
_api_fh.write(f"[{ts_entry}] {method} {path} - Payload: {payload}\n")
_api_fh.flush()
except Exception:
pass
def log_comms(entry: dict): def log_comms(entry: dict):
+83
View File
@@ -0,0 +1,83 @@
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import pytest
from unittest.mock import patch
import gui
import api_hooks
import urllib.request
import json
import threading
import time
def test_hooks_enabled_via_cli():
with patch.object(sys, 'argv', ['gui.py', '--enable-test-hooks']):
app = gui.App()
assert app.test_hooks_enabled is True
def test_hooks_disabled_by_default():
with patch.object(sys, 'argv', ['gui.py']):
if 'SLOP_TEST_HOOKS' in os.environ:
del os.environ['SLOP_TEST_HOOKS']
app = gui.App()
assert getattr(app, 'test_hooks_enabled', False) is False
def test_hooks_enabled_via_env():
with patch.object(sys, 'argv', ['gui.py']):
with patch.dict(os.environ, {'SLOP_TEST_HOOKS': '1'}):
app = gui.App()
assert app.test_hooks_enabled is True
def test_ipc_server_starts_and_responds():
app_mock = gui.App()
app_mock.test_hooks_enabled = True
server = api_hooks.HookServer(app_mock, port=8999)
server.start()
# Wait for server to start
time.sleep(0.5)
try:
req = urllib.request.Request("http://127.0.0.1:8999/status")
with urllib.request.urlopen(req) as response:
assert response.status == 200
data = json.loads(response.read().decode())
assert data.get("status") == "ok"
# Test project GET
req = urllib.request.Request("http://127.0.0.1:8999/api/project")
with urllib.request.urlopen(req) as response:
assert response.status == 200
data = json.loads(response.read().decode())
assert "project" in data
# Test session GET
req = urllib.request.Request("http://127.0.0.1:8999/api/session")
with urllib.request.urlopen(req) as response:
assert response.status == 200
data = json.loads(response.read().decode())
assert "session" in data
# Test project POST
req = urllib.request.Request("http://127.0.0.1:8999/api/project", method="POST", data=json.dumps({"project": {"foo": "bar"}}).encode("utf-8"), headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as response:
assert response.status == 200
assert app_mock.project == {"foo": "bar"}
# Test session POST
req = urllib.request.Request("http://127.0.0.1:8999/api/session", method="POST", data=json.dumps({"session": {"entries": [{"role": "User", "content": "hi"}]}}).encode("utf-8"), headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as response:
assert response.status == 200
assert app_mock.disc_entries == [{"role": "User", "content": "hi"}]
# Test GUI queue hook
req = urllib.request.Request("http://127.0.0.1:8999/api/gui", method="POST", data=json.dumps({"action": "set_value", "item": "test_item", "value": "test_value"}).encode("utf-8"), headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as response:
assert response.status == 200
# Instead of checking DPG (since we aren't running the real main loop in tests),
# check if it got queued in app_mock
assert hasattr(app_mock, '_pending_gui_tasks')
assert len(app_mock._pending_gui_tasks) == 1
assert app_mock._pending_gui_tasks[0] == {"action": "set_value", "item": "test_item", "value": "test_value"}
finally:
server.stop()