oh-my-claude-sisyphus 3.2.5 → 3.3.1

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 (56) hide show
  1. package/README.md +37 -2
  2. package/agents/scientist-high.md +1003 -0
  3. package/agents/scientist-low.md +232 -0
  4. package/agents/scientist.md +1180 -0
  5. package/bridge/__pycache__/gyoshu_bridge.cpython-310.pyc +0 -0
  6. package/bridge/gyoshu_bridge.py +846 -0
  7. package/commands/research.md +511 -0
  8. package/dist/agents/definitions.d.ts +9 -0
  9. package/dist/agents/definitions.d.ts.map +1 -1
  10. package/dist/agents/definitions.js +25 -0
  11. package/dist/agents/definitions.js.map +1 -1
  12. package/dist/agents/index.d.ts +2 -1
  13. package/dist/agents/index.d.ts.map +1 -1
  14. package/dist/agents/index.js +2 -1
  15. package/dist/agents/index.js.map +1 -1
  16. package/dist/agents/scientist.d.ts +16 -0
  17. package/dist/agents/scientist.d.ts.map +1 -0
  18. package/dist/agents/scientist.js +370 -0
  19. package/dist/agents/scientist.js.map +1 -0
  20. package/dist/lib/atomic-write.d.ts +29 -0
  21. package/dist/lib/atomic-write.d.ts.map +1 -0
  22. package/dist/lib/atomic-write.js +111 -0
  23. package/dist/lib/atomic-write.js.map +1 -0
  24. package/dist/tools/index.d.ts +1 -0
  25. package/dist/tools/index.d.ts.map +1 -1
  26. package/dist/tools/index.js +4 -1
  27. package/dist/tools/index.js.map +1 -1
  28. package/dist/tools/python-repl/bridge-manager.d.ts +65 -0
  29. package/dist/tools/python-repl/bridge-manager.d.ts.map +1 -0
  30. package/dist/tools/python-repl/bridge-manager.js +478 -0
  31. package/dist/tools/python-repl/bridge-manager.js.map +1 -0
  32. package/dist/tools/python-repl/index.d.ts +40 -0
  33. package/dist/tools/python-repl/index.d.ts.map +1 -0
  34. package/dist/tools/python-repl/index.js +36 -0
  35. package/dist/tools/python-repl/index.js.map +1 -0
  36. package/dist/tools/python-repl/paths.d.ts +84 -0
  37. package/dist/tools/python-repl/paths.d.ts.map +1 -0
  38. package/dist/tools/python-repl/paths.js +213 -0
  39. package/dist/tools/python-repl/paths.js.map +1 -0
  40. package/dist/tools/python-repl/session-lock.d.ts +111 -0
  41. package/dist/tools/python-repl/session-lock.d.ts.map +1 -0
  42. package/dist/tools/python-repl/session-lock.js +510 -0
  43. package/dist/tools/python-repl/session-lock.js.map +1 -0
  44. package/dist/tools/python-repl/socket-client.d.ts +42 -0
  45. package/dist/tools/python-repl/socket-client.d.ts.map +1 -0
  46. package/dist/tools/python-repl/socket-client.js +157 -0
  47. package/dist/tools/python-repl/socket-client.js.map +1 -0
  48. package/dist/tools/python-repl/tool.d.ts +100 -0
  49. package/dist/tools/python-repl/tool.d.ts.map +1 -0
  50. package/dist/tools/python-repl/tool.js +575 -0
  51. package/dist/tools/python-repl/tool.js.map +1 -0
  52. package/dist/tools/python-repl/types.d.ts +95 -0
  53. package/dist/tools/python-repl/types.d.ts.map +1 -0
  54. package/dist/tools/python-repl/types.js +2 -0
  55. package/dist/tools/python-repl/types.js.map +1 -0
  56. package/package.json +2 -1
@@ -0,0 +1,846 @@
1
+ #!/usr/bin/env python3
2
+ """Gyoshu Python Bridge - JSON-RPC 2.0 over Unix Socket.
3
+
4
+ This bridge provides a protocol-based interface for executing Python code
5
+ from the Scientist agent. Communication happens over Unix socket using
6
+ Newline-Delimited JSON (NDJSON) with JSON-RPC 2.0 message format.
7
+
8
+ Protocol Format (JSON-RPC 2.0):
9
+ Request: {"jsonrpc": "2.0", "id": "req_001", "method": "execute", "params": {...}}
10
+ Response: {"jsonrpc": "2.0", "id": "req_001", "result": {...}}
11
+ Error: {"jsonrpc": "2.0", "id": "req_001", "error": {"code": -32600, "message": "..."}}
12
+
13
+ Methods:
14
+ - execute(code, timeout) - Execute Python code in persistent namespace
15
+ - interrupt() - Set interrupt flag for running execution
16
+ - reset() - Clear execution namespace
17
+ - get_state() - Get memory and variable info
18
+ - ping() - Health check
19
+ """
20
+
21
+ import sys
22
+ import os
23
+ import json
24
+ import time
25
+ import io
26
+ import re
27
+ import signal
28
+ import contextlib
29
+ import traceback
30
+ import threading
31
+ import gc
32
+ import argparse
33
+ import socket as socket_module
34
+ import stat
35
+ from datetime import datetime, timezone
36
+ from typing import Any, Dict, List, Optional, Callable, Tuple
37
+
38
+ # =============================================================================
39
+ # JSON-RPC 2.0 PROTOCOL
40
+ # =============================================================================
41
+
42
+ JSON_RPC_VERSION = "2.0"
43
+
44
+ # JSON-RPC 2.0 Error Codes
45
+ ERROR_PARSE = -32700 # Invalid JSON
46
+ ERROR_INVALID_REQUEST = -32600 # Not a valid Request object
47
+ ERROR_METHOD_NOT_FOUND = -32601 # Method does not exist
48
+ ERROR_INVALID_PARAMS = -32602 # Invalid method parameters
49
+ ERROR_INTERNAL = -32603 # Internal JSON-RPC error
50
+ ERROR_EXECUTION = -32000 # Application-specific: execution error
51
+ ERROR_TIMEOUT = -32001 # Application-specific: timeout
52
+
53
+ # Global protocol output stream (set per-connection in socket mode)
54
+ _protocol_out: Optional[io.TextIOWrapper] = None
55
+
56
+
57
+ def _send_protocol(data: dict) -> None:
58
+ """Write NDJSON message to protocol channel."""
59
+ global _protocol_out
60
+ if _protocol_out:
61
+ _protocol_out.write(
62
+ json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
63
+ )
64
+ _protocol_out.flush()
65
+
66
+
67
+ def send_response(
68
+ id: Optional[str], result: Optional[Dict] = None, error: Optional[Dict] = None
69
+ ) -> None:
70
+ """Send JSON-RPC 2.0 response via protocol channel."""
71
+ response: Dict[str, Any] = {
72
+ "jsonrpc": JSON_RPC_VERSION,
73
+ "id": id,
74
+ }
75
+
76
+ if error is not None:
77
+ response["error"] = error
78
+ else:
79
+ response["result"] = result
80
+
81
+ _send_protocol(response)
82
+
83
+
84
+ def make_error(code: int, message: str, data: Optional[Any] = None) -> Dict:
85
+ """Create a JSON-RPC 2.0 error object."""
86
+ error = {"code": code, "message": message}
87
+ if data is not None:
88
+ error["data"] = data
89
+ return error
90
+
91
+
92
+ # =============================================================================
93
+ # MARKER PARSING
94
+ # =============================================================================
95
+
96
+ # Marker pattern for structured output
97
+ # Examples:
98
+ # [OBJECTIVE] Loading data...
99
+ # [STAT:mean] 0.95
100
+ # [DATA] Shape: (100, 5)
101
+ MARKER_REGEX = re.compile(
102
+ r"^\s*\[([A-Z][A-Z0-9_-]*)(?::([^\]]+))?\]\s*(.*)$", re.MULTILINE
103
+ )
104
+
105
+ # Scientific marker taxonomy
106
+ MARKER_CATEGORIES = {
107
+ # Research Process
108
+ "OBJECTIVE": "research_process",
109
+ "HYPOTHESIS": "research_process",
110
+ "EXPERIMENT": "research_process",
111
+ "OBSERVATION": "research_process",
112
+ "ANALYSIS": "research_process",
113
+ "CONCLUSION": "research_process",
114
+ # Data Operations
115
+ "DATA": "data_operations",
116
+ "SHAPE": "data_operations",
117
+ "DTYPE": "data_operations",
118
+ "RANGE": "data_operations",
119
+ "MISSING": "data_operations",
120
+ "MEMORY": "data_operations",
121
+ # Calculations
122
+ "CALC": "calculations",
123
+ "METRIC": "calculations",
124
+ "STAT": "calculations",
125
+ "CORR": "calculations",
126
+ # Artifacts
127
+ "PLOT": "artifacts",
128
+ "ARTIFACT": "artifacts",
129
+ "TABLE": "artifacts",
130
+ "FIGURE": "artifacts",
131
+ # Insights
132
+ "FINDING": "insights",
133
+ "INSIGHT": "insights",
134
+ "PATTERN": "insights",
135
+ # Workflow
136
+ "STEP": "workflow",
137
+ "STAGE": "workflow",
138
+ "CHECKPOINT": "workflow",
139
+ "CHECK": "workflow",
140
+ "INFO": "workflow",
141
+ "WARNING": "workflow",
142
+ "ERROR": "workflow",
143
+ "DEBUG": "workflow",
144
+ # Scientific
145
+ "CITATION": "scientific",
146
+ "LIMITATION": "scientific",
147
+ "NEXT_STEP": "scientific",
148
+ "DECISION": "scientific",
149
+ }
150
+
151
+
152
+ def parse_markers(text: str) -> List[Dict[str, Any]]:
153
+ """Extract markers from output text.
154
+
155
+ Args:
156
+ text: Raw output text potentially containing markers
157
+
158
+ Returns:
159
+ List of marker dicts with type, subtype, content, line_number, category, valid
160
+ """
161
+ markers = []
162
+
163
+ for match in MARKER_REGEX.finditer(text):
164
+ raw_type = match.group(1)
165
+ marker_type = raw_type.replace("-", "_")
166
+ subtype_str = match.group(2) # May be None
167
+ content = match.group(3).strip()
168
+
169
+ # Calculate line number (1-indexed)
170
+ line_number = text[: match.start()].count("\n") + 1
171
+
172
+ # Classify marker and check validity
173
+ category = MARKER_CATEGORIES.get(marker_type, "unknown")
174
+ valid = marker_type in MARKER_CATEGORIES
175
+
176
+ markers.append(
177
+ {
178
+ "type": marker_type,
179
+ "subtype": subtype_str,
180
+ "content": content,
181
+ "line_number": line_number,
182
+ "category": category,
183
+ "valid": valid,
184
+ }
185
+ )
186
+
187
+ return markers
188
+
189
+
190
+ # =============================================================================
191
+ # BOUNDED STRING IO
192
+ # =============================================================================
193
+
194
+ MAX_CAPTURE_CHARS = 1048576 # 1MB default
195
+
196
+
197
+ class BoundedStringIO:
198
+ """StringIO wrapper that caps capture size to prevent memory exhaustion."""
199
+
200
+ def __init__(self, max_size: int = MAX_CAPTURE_CHARS):
201
+ self._buffer: List[str] = []
202
+ self._size = 0
203
+ self._max_size = max_size
204
+ self._truncated = False
205
+
206
+ def write(self, s: str) -> int:
207
+ if self._truncated:
208
+ return len(s)
209
+ new_size = self._size + len(s)
210
+ if new_size > self._max_size:
211
+ remaining = self._max_size - self._size
212
+ if remaining > 0:
213
+ self._buffer.append(s[:remaining])
214
+ self._truncated = True
215
+ else:
216
+ self._buffer.append(s)
217
+ self._size = new_size
218
+ return len(s)
219
+
220
+ def getvalue(self) -> str:
221
+ result = "".join(self._buffer)
222
+ if self._truncated:
223
+ result += "\n[OUTPUT TRUNCATED - exceeded 1MB limit]"
224
+ return result
225
+
226
+ @property
227
+ def truncated(self) -> bool:
228
+ return self._truncated
229
+
230
+ def flush(self) -> None:
231
+ """No-op for compatibility with sys.stdout interface."""
232
+ pass
233
+
234
+
235
+ # =============================================================================
236
+ # MEMORY UTILITIES
237
+ # =============================================================================
238
+
239
+
240
+ def get_memory_usage() -> Dict[str, float]:
241
+ """Get current process memory usage in MB.
242
+
243
+ Returns:
244
+ Dict with rss_mb (resident set size) and vms_mb (virtual memory size)
245
+ """
246
+ try:
247
+ import psutil
248
+
249
+ process = psutil.Process()
250
+ mem = process.memory_info()
251
+ return {
252
+ "rss_mb": round(mem.rss / (1024 * 1024), 2),
253
+ "vms_mb": round(mem.vms / (1024 * 1024), 2),
254
+ }
255
+ except ImportError:
256
+ # Fallback: use resource module
257
+ try:
258
+ import resource
259
+
260
+ usage = resource.getrusage(resource.RUSAGE_SELF)
261
+ # maxrss is in KB on Linux, bytes on macOS
262
+ rss_kb = usage.ru_maxrss
263
+ if sys.platform == "darwin":
264
+ rss_kb = rss_kb / 1024 # Convert bytes to KB on macOS
265
+ return {
266
+ "rss_mb": round(rss_kb / 1024, 2),
267
+ "vms_mb": 0.0, # Not available via resource
268
+ }
269
+ except ImportError:
270
+ # Final fallback: read from /proc on Linux
271
+ try:
272
+ with open(f"/proc/{os.getpid()}/status", "r") as f:
273
+ status = f.read()
274
+
275
+ rss = 0.0
276
+ vms = 0.0
277
+ for line in status.split("\n"):
278
+ if line.startswith("VmRSS:"):
279
+ rss = int(line.split()[1]) / 1024 # kB to MB
280
+ elif line.startswith("VmSize:"):
281
+ vms = int(line.split()[1]) / 1024
282
+
283
+ return {"rss_mb": round(rss, 2), "vms_mb": round(vms, 2)}
284
+ except Exception:
285
+ return {"rss_mb": 0.0, "vms_mb": 0.0}
286
+
287
+
288
+ def clean_memory() -> Dict[str, float]:
289
+ """Run garbage collection and return memory after cleanup."""
290
+ gc.collect()
291
+ return get_memory_usage()
292
+
293
+
294
+ # =============================================================================
295
+ # EXECUTION STATE
296
+ # =============================================================================
297
+
298
+
299
+ class ExecutionState:
300
+ """Manages persistent execution namespace and interrupt handling."""
301
+
302
+ def __init__(self):
303
+ self._namespace: Dict[str, Any] = {}
304
+ self._interrupt_flag = threading.Event()
305
+ self._execution_lock = threading.Lock()
306
+
307
+ # Initialize with common imports available
308
+ self._initialize_namespace()
309
+
310
+ def _initialize_namespace(self):
311
+ """Set up default namespace with helper functions."""
312
+ self._namespace = {
313
+ "__name__": "__gyoshu__",
314
+ "__doc__": "Gyoshu execution namespace",
315
+ # Provide helper functions
316
+ "clean_memory": clean_memory,
317
+ "get_memory": get_memory_usage,
318
+ }
319
+
320
+ def reset(self) -> Dict[str, Any]:
321
+ """Clear namespace and reset state."""
322
+ with self._execution_lock:
323
+ self._namespace.clear()
324
+ self._initialize_namespace()
325
+ self._interrupt_flag.clear()
326
+ gc.collect()
327
+
328
+ return {
329
+ "status": "reset",
330
+ "memory": get_memory_usage(),
331
+ }
332
+
333
+ def get_state(self) -> Dict[str, Any]:
334
+ """Return current state information."""
335
+ # Get user-defined variables (exclude dunder and builtins)
336
+ user_vars = [
337
+ k
338
+ for k in self._namespace.keys()
339
+ if not k.startswith("_") and k not in ("clean_memory", "get_memory")
340
+ ]
341
+
342
+ return {
343
+ "memory": get_memory_usage(),
344
+ "variables": user_vars,
345
+ "variable_count": len(user_vars),
346
+ }
347
+
348
+ def interrupt(self) -> Dict[str, Any]:
349
+ """Set interrupt flag to stop execution."""
350
+ self._interrupt_flag.set()
351
+ return {"status": "interrupt_requested"}
352
+
353
+ @property
354
+ def namespace(self) -> Dict[str, Any]:
355
+ return self._namespace
356
+
357
+ @property
358
+ def interrupt_flag(self) -> threading.Event:
359
+ return self._interrupt_flag
360
+
361
+
362
+ # Global execution state
363
+ _state = ExecutionState()
364
+
365
+
366
+ # =============================================================================
367
+ # CODE EXECUTION
368
+ # =============================================================================
369
+
370
+
371
+ class ExecutionTimeoutError(Exception):
372
+ """Raised when code execution exceeds timeout."""
373
+
374
+ pass
375
+
376
+
377
+ def _timeout_handler(signum, frame):
378
+ """Signal handler for execution timeout."""
379
+ raise ExecutionTimeoutError("Code execution timed out")
380
+
381
+
382
+ def execute_code(
383
+ code: str,
384
+ namespace: Dict[str, Any],
385
+ timeout: Optional[float] = None,
386
+ interrupt_flag: Optional[threading.Event] = None,
387
+ ) -> Dict[str, Any]:
388
+ """Execute Python code and capture output.
389
+
390
+ Args:
391
+ code: Python code to execute
392
+ namespace: Execution namespace (modified in place)
393
+ timeout: Maximum execution time in seconds (None = no limit)
394
+ interrupt_flag: Event to check for interrupt requests
395
+
396
+ Returns:
397
+ Dict with success, stdout, stderr, exception info
398
+ """
399
+ stdout_capture = BoundedStringIO()
400
+ stderr_capture = BoundedStringIO()
401
+
402
+ result = {
403
+ "success": False,
404
+ "stdout": "",
405
+ "stderr": "",
406
+ "stdout_truncated": False,
407
+ "stderr_truncated": False,
408
+ "exception": None,
409
+ "exception_type": None,
410
+ "traceback": None,
411
+ }
412
+
413
+ # Set up timeout (Unix only - uses SIGALRM)
414
+ old_handler = None
415
+ if timeout and hasattr(signal, "SIGALRM"):
416
+ old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
417
+ signal.alarm(int(timeout))
418
+
419
+ try:
420
+ # Redirect stdout/stderr for user code
421
+ with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(
422
+ stderr_capture
423
+ ):
424
+ # Compile code for better error messages
425
+ compiled = compile(code, "<gyoshu>", "exec")
426
+
427
+ # Execute in provided namespace
428
+ exec(compiled, namespace)
429
+
430
+ result["success"] = True
431
+
432
+ except ExecutionTimeoutError as e:
433
+ result["exception"] = str(e)
434
+ result["exception_type"] = "TimeoutError"
435
+ result["traceback"] = "Execution timed out"
436
+
437
+ except KeyboardInterrupt:
438
+ result["exception"] = "Execution interrupted"
439
+ result["exception_type"] = "KeyboardInterrupt"
440
+ result["traceback"] = "Interrupted by user"
441
+
442
+ except SyntaxError as e:
443
+ result["exception"] = str(e)
444
+ result["exception_type"] = "SyntaxError"
445
+ result["traceback"] = "".join(
446
+ traceback.format_exception(type(e), e, e.__traceback__)
447
+ )
448
+
449
+ except Exception as e:
450
+ result["exception"] = str(e)
451
+ result["exception_type"] = type(e).__name__
452
+ result["traceback"] = "".join(
453
+ traceback.format_exception(type(e), e, e.__traceback__)
454
+ )
455
+
456
+ finally:
457
+ if timeout and hasattr(signal, "SIGALRM"):
458
+ signal.alarm(0)
459
+ if old_handler is not None:
460
+ signal.signal(signal.SIGALRM, old_handler)
461
+
462
+ result["stdout"] = stdout_capture.getvalue()
463
+ result["stderr"] = stderr_capture.getvalue()
464
+ result["stdout_truncated"] = stdout_capture.truncated
465
+ result["stderr_truncated"] = stderr_capture.truncated
466
+
467
+ return result
468
+
469
+
470
+ # =============================================================================
471
+ # REQUEST HANDLERS
472
+ # =============================================================================
473
+
474
+
475
+ def handle_execute(id: str, params: Dict[str, Any]) -> None:
476
+ """Handle 'execute' method - run Python code.
477
+
478
+ Params:
479
+ code (str): Python code to execute
480
+ timeout (float, optional): Timeout in seconds (default: 300)
481
+ """
482
+ code = params.get("code")
483
+ if not code:
484
+ send_response(
485
+ id,
486
+ error=make_error(ERROR_INVALID_PARAMS, "Missing required parameter: code"),
487
+ )
488
+ return
489
+
490
+ if not isinstance(code, str):
491
+ send_response(
492
+ id,
493
+ error=make_error(ERROR_INVALID_PARAMS, "Parameter 'code' must be a string"),
494
+ )
495
+ return
496
+
497
+ timeout = params.get("timeout", 300) # Default 5 minutes
498
+ if not isinstance(timeout, (int, float)) or timeout <= 0:
499
+ timeout = 300
500
+
501
+ # Clear interrupt flag before execution
502
+ _state.interrupt_flag.clear()
503
+
504
+ # Record start time
505
+ start_time = time.time()
506
+ started_at = datetime.now(timezone.utc).isoformat()
507
+
508
+ # Execute the code
509
+ exec_result = execute_code(
510
+ code=code,
511
+ namespace=_state.namespace,
512
+ timeout=timeout,
513
+ interrupt_flag=_state.interrupt_flag,
514
+ )
515
+
516
+ # Calculate duration
517
+ duration_ms = round((time.time() - start_time) * 1000, 2)
518
+
519
+ # Parse markers from stdout
520
+ markers = parse_markers(exec_result["stdout"])
521
+
522
+ # Build response
523
+ response = {
524
+ "success": exec_result["success"],
525
+ "stdout": exec_result["stdout"],
526
+ "stderr": exec_result["stderr"],
527
+ "stdout_truncated": exec_result.get("stdout_truncated", False),
528
+ "stderr_truncated": exec_result.get("stderr_truncated", False),
529
+ "markers": markers,
530
+ "timing": {
531
+ "started_at": started_at,
532
+ "duration_ms": duration_ms,
533
+ },
534
+ "memory": get_memory_usage(),
535
+ }
536
+
537
+ # Add error info if failed
538
+ if not exec_result["success"]:
539
+ response["error"] = {
540
+ "type": exec_result["exception_type"],
541
+ "message": exec_result["exception"],
542
+ "traceback": exec_result["traceback"],
543
+ }
544
+
545
+ send_response(id, result=response)
546
+
547
+
548
+ def handle_interrupt(id: str, params: Dict[str, Any]) -> None:
549
+ """Handle 'interrupt' method - signal interrupt to running code."""
550
+ result = _state.interrupt()
551
+ send_response(id, result=result)
552
+
553
+
554
+ def handle_reset(id: str, params: Dict[str, Any]) -> None:
555
+ """Handle 'reset' method - clear namespace and state."""
556
+ result = _state.reset()
557
+ send_response(id, result=result)
558
+
559
+
560
+ def handle_get_state(id: str, params: Dict[str, Any]) -> None:
561
+ """Handle 'get_state' method - return current state info."""
562
+ result = _state.get_state()
563
+ send_response(id, result=result)
564
+
565
+
566
+ def handle_ping(id: str, params: Dict[str, Any]) -> None:
567
+ """Handle 'ping' method - health check."""
568
+ send_response(
569
+ id,
570
+ result={
571
+ "status": "ok",
572
+ "timestamp": datetime.now(timezone.utc).isoformat(),
573
+ },
574
+ )
575
+
576
+
577
+ # Method registry
578
+ HANDLERS: Dict[str, Callable[[str, Dict[str, Any]], None]] = {
579
+ "execute": handle_execute,
580
+ "interrupt": handle_interrupt,
581
+ "reset": handle_reset,
582
+ "get_state": handle_get_state,
583
+ "ping": handle_ping,
584
+ }
585
+
586
+
587
+ # =============================================================================
588
+ # REQUEST PROCESSING
589
+ # =============================================================================
590
+
591
+ # Cap JSON-RPC request line size to prevent DoS (10MB)
592
+ MAX_REQUEST_LINE_BYTES = 10 * 1024 * 1024
593
+
594
+
595
+ def read_bounded_line(stream, max_bytes: int) -> Tuple[Optional[bytes], bool]:
596
+ """Read a line with bounded byte count.
597
+
598
+ Returns:
599
+ Tuple of (line_bytes or None if EOF, was_oversized)
600
+ - If EOF with no data: (None, False)
601
+ - If line fits in limit: (bytes, False)
602
+ - If line exceeded limit: (truncated_bytes, True)
603
+ """
604
+ data = bytearray()
605
+ while len(data) < max_bytes:
606
+ char = stream.read(1)
607
+ if not char:
608
+ # EOF - return what we have
609
+ return (bytes(data) if data else None, False)
610
+ if char == b"\n":
611
+ # Normal line termination
612
+ return (bytes(data), False)
613
+ data.extend(char)
614
+
615
+ # Limit exceeded - drain rest of line
616
+ while True:
617
+ char = stream.read(1)
618
+ if not char or char == b"\n":
619
+ break
620
+ return (bytes(data[:max_bytes]), True)
621
+
622
+
623
+ def process_request(line: str) -> None:
624
+ """Parse and handle a single JSON-RPC request."""
625
+ request_id: Optional[str] = None
626
+
627
+ try:
628
+ # Parse JSON
629
+ try:
630
+ request = json.loads(line)
631
+ except json.JSONDecodeError as e:
632
+ send_response(None, error=make_error(ERROR_PARSE, f"Parse error: {e}"))
633
+ return
634
+
635
+ # Validate request structure
636
+ if not isinstance(request, dict):
637
+ send_response(
638
+ None,
639
+ error=make_error(
640
+ ERROR_INVALID_REQUEST, "Request must be a JSON object"
641
+ ),
642
+ )
643
+ return
644
+
645
+ # Extract id (may be null for notifications, but we require it)
646
+ request_id = request.get("id")
647
+
648
+ # Check jsonrpc version
649
+ if request.get("jsonrpc") != JSON_RPC_VERSION:
650
+ send_response(
651
+ request_id,
652
+ error=make_error(
653
+ ERROR_INVALID_REQUEST,
654
+ f"Invalid jsonrpc version, expected '{JSON_RPC_VERSION}'",
655
+ ),
656
+ )
657
+ return
658
+
659
+ # Extract method
660
+ method = request.get("method")
661
+ if not method or not isinstance(method, str):
662
+ send_response(
663
+ request_id,
664
+ error=make_error(ERROR_INVALID_REQUEST, "Missing or invalid 'method'"),
665
+ )
666
+ return
667
+
668
+ # Extract params (optional, default to empty dict)
669
+ params = request.get("params", {})
670
+ if not isinstance(params, dict):
671
+ send_response(
672
+ request_id,
673
+ error=make_error(
674
+ ERROR_INVALID_PARAMS, "Parameter 'params' must be an object"
675
+ ),
676
+ )
677
+ return
678
+
679
+ # Find handler
680
+ handler = HANDLERS.get(method)
681
+ if not handler:
682
+ send_response(
683
+ request_id,
684
+ error=make_error(ERROR_METHOD_NOT_FOUND, f"Method not found: {method}"),
685
+ )
686
+ return
687
+
688
+ # Execute handler
689
+ handler(request_id, params)
690
+
691
+ except Exception as e:
692
+ # Catch-all for unexpected errors
693
+ send_response(
694
+ request_id,
695
+ error=make_error(
696
+ ERROR_INTERNAL, f"Internal error: {e}", data=traceback.format_exc()
697
+ ),
698
+ )
699
+
700
+
701
+ # =============================================================================
702
+ # SOCKET SERVER
703
+ # =============================================================================
704
+
705
+
706
+ def safe_unlink_socket(socket_path: str) -> None:
707
+ """Safely unlink a socket file, handling races and verifying type."""
708
+ try:
709
+ st = os.lstat(socket_path)
710
+ if stat.S_ISSOCK(st.st_mode):
711
+ os.unlink(socket_path)
712
+ except FileNotFoundError:
713
+ pass # Already removed
714
+ except OSError:
715
+ pass # Best effort
716
+
717
+
718
+ def run_socket_server(socket_path: str) -> None:
719
+ """Run the JSON-RPC server over Unix socket."""
720
+ global _protocol_out
721
+
722
+ # Safely remove existing socket
723
+ safe_unlink_socket(socket_path)
724
+
725
+ server = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM)
726
+
727
+ # Set umask to ensure socket mode 0600 (owner only)
728
+ old_umask = os.umask(0o177)
729
+ try:
730
+ server.bind(socket_path)
731
+
732
+ # Post-bind verification: ensure socket has expected ownership and mode
733
+ try:
734
+ st = os.lstat(socket_path)
735
+ if not stat.S_ISSOCK(st.st_mode):
736
+ raise RuntimeError(
737
+ f"Post-bind check failed: {socket_path} is not a socket"
738
+ )
739
+ if st.st_uid != os.getuid():
740
+ raise RuntimeError(
741
+ f"Post-bind check failed: {socket_path} not owned by us"
742
+ )
743
+ mode = st.st_mode & 0o777
744
+ if mode != 0o600:
745
+ raise RuntimeError(
746
+ f"Post-bind check failed: {socket_path} has mode {oct(mode)}, expected 0o600"
747
+ )
748
+ except Exception:
749
+ server.close()
750
+ raise
751
+ finally:
752
+ os.umask(old_umask)
753
+
754
+ server.listen(1)
755
+
756
+ print(
757
+ f"[gyoshu_bridge] Socket server started at {socket_path}, PID={os.getpid()}",
758
+ file=sys.stderr,
759
+ )
760
+ sys.stderr.flush()
761
+
762
+ def shutdown_handler(signum, frame):
763
+ print("[gyoshu_bridge] Shutdown signal received", file=sys.stderr)
764
+ sys.stderr.flush()
765
+ server.close()
766
+ safe_unlink_socket(socket_path)
767
+ sys.exit(0)
768
+
769
+ signal.signal(signal.SIGTERM, shutdown_handler)
770
+ signal.signal(signal.SIGINT, shutdown_handler)
771
+
772
+ try:
773
+ while True:
774
+ conn, addr = server.accept()
775
+ handle_socket_connection(conn)
776
+ except Exception as e:
777
+ print(f"[gyoshu_bridge] Server error: {e}", file=sys.stderr)
778
+ traceback.print_exc(file=sys.stderr)
779
+ finally:
780
+ server.close()
781
+ safe_unlink_socket(socket_path)
782
+
783
+
784
+ def handle_socket_connection(conn: socket_module.socket) -> None:
785
+ """Handle a single client connection."""
786
+ global _protocol_out
787
+
788
+ try:
789
+ _protocol_out = conn.makefile("w", buffering=1, encoding="utf-8")
790
+
791
+ reader = conn.makefile("rb")
792
+ while True:
793
+ line_bytes, was_oversized = read_bounded_line(
794
+ reader, MAX_REQUEST_LINE_BYTES
795
+ )
796
+ if line_bytes is None:
797
+ break
798
+ if was_oversized:
799
+ send_response(
800
+ None, error=make_error(ERROR_INVALID_REQUEST, "Request too large")
801
+ )
802
+ continue
803
+ line = line_bytes.decode("utf-8", errors="replace").strip()
804
+ if not line:
805
+ continue
806
+ process_request(line)
807
+ except Exception as e:
808
+ print(f"[gyoshu_bridge] Connection error: {e}", file=sys.stderr)
809
+ traceback.print_exc(file=sys.stderr)
810
+ finally:
811
+ try:
812
+ conn.close()
813
+ except Exception:
814
+ pass
815
+
816
+
817
+ # =============================================================================
818
+ # MAIN
819
+ # =============================================================================
820
+
821
+
822
+ def parse_args() -> argparse.Namespace:
823
+ parser = argparse.ArgumentParser(
824
+ description="Gyoshu Python Bridge - JSON-RPC 2.0 over Unix Socket"
825
+ )
826
+ parser.add_argument(
827
+ "socket_path",
828
+ nargs="?",
829
+ help="Unix socket path (required)",
830
+ )
831
+ return parser.parse_args()
832
+
833
+
834
+ def main() -> None:
835
+ args = parse_args()
836
+
837
+ if not args.socket_path:
838
+ print("Usage: gyoshu_bridge.py <socket_path>", file=sys.stderr)
839
+ print("Example: gyoshu_bridge.py /tmp/gyoshu.sock", file=sys.stderr)
840
+ sys.exit(1)
841
+
842
+ run_socket_server(args.socket_path)
843
+
844
+
845
+ if __name__ == "__main__":
846
+ main()