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
## Phase 1: Foundation and Opt-in Mechanisms
- [ ] Task: Implement CLI flag/env-var to enable the hook system
- [ ] Sub-task: Write Tests
- [ ] Sub-task: Implement Feature
- [ ] Task: Set up lightweight local IPC server (e.g., standard library socket/HTTP) for receiving hook commands
- [ ] Sub-task: Write Tests
- [ ] Sub-task: Implement Feature
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Foundation and Opt-in Mechanisms' (Protocol in workflow.md)
## Phase 1: Foundation and Opt-in Mechanisms [checkpoint: 2bc7a3f]
- [x] Task: Implement CLI flag/env-var to enable the hook system [1306163]
- [x] Sub-task: Write Tests
- [x] Sub-task: Implement Feature
- [x] Task: Set up lightweight local IPC server (e.g., standard library socket/HTTP) for receiving hook commands [44c2585]
- [x] Sub-task: Write Tests
- [x] Sub-task: Implement Feature
- [x] Task: Conductor - User Manual Verification 'Phase 1: Foundation and Opt-in Mechanisms' (Protocol in workflow.md) [2bc7a3f]
## 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: 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: Implement Feature
- [ ] Task: Integrate aggressive logging for all hook invocations
+33
View File
@@ -14,6 +14,8 @@ import tomli_w
import threading
import time
import math
import sys
import os
from pathlib import Path
from tkinter import filedialog, Tk
import aggregate
@@ -23,6 +25,7 @@ import shell_runner
import session_logger
import project_manager
import theme
import api_hooks
CONFIG_PATH = Path("config.toml")
PROVIDERS = ["gemini", "anthropic"]
@@ -393,6 +396,8 @@ def _parse_history_entries(history: list[str], roles: list[str] | None = None) -
class App:
def __init__(self):
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 ----
ai_cfg = self.config.get("ai", {})
@@ -468,6 +473,10 @@ class App:
self._pending_history_adds: list[dict] = []
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
self._trigger_blink = False
self._is_blinking = False
@@ -2054,6 +2063,8 @@ class App:
self._rebuild_projects_list()
self._rebuild_discussion_selector()
self._fetch_models(self.current_provider)
self.hook_server.start()
while dpg.is_dearpygui_running():
# Show any pending confirmation dialog on the main thread safely
@@ -2078,6 +2089,27 @@ class App:
# Force scroll to bottom using a very large number
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
if self._trigger_script_blink:
self._trigger_script_blink = False
@@ -2181,6 +2213,7 @@ class App:
dpg.save_init_file("dpg_layout.ini")
session_logger.close_session()
ai_client.cleanup() # Destroy active API caches to stop billing
self.hook_server.stop()
dpg.destroy_context()
+21 -2
View File
@@ -40,6 +40,7 @@ _seq_lock = threading.Lock()
_comms_fh = None # file handle: logs/comms_<ts>.log
_tool_fh = None # file handle: logs/toolcalls_<ts>.log
_api_fh = None # file handle: logs/apihooks_<ts>.log
def _now_ts() -> str:
@@ -52,7 +53,7 @@ def open_session():
opens the two log files for this session. Idempotent - a second call is
ignored.
"""
global _ts, _comms_fh, _tool_fh, _seq
global _ts, _comms_fh, _tool_fh, _api_fh, _seq
if _comms_fh is not None:
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)
_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.flush()
@@ -72,13 +74,30 @@ def open_session():
def close_session():
"""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:
_comms_fh.close()
_comms_fh = None
if _tool_fh:
_tool_fh.close()
_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):
+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()