nexo-brain 5.4.8 → 5.5.0
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +6 -2
- package/src/agent_runner.py +82 -9
- package/src/enforcement_engine.py +348 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `5.
|
|
21
|
+
Version `5.5.0` is the current packaged-runtime line: headless Protocol Enforcer — all crons get enforcement rules automatically.
|
|
22
22
|
|
|
23
23
|
Previously in `5.4.6`: runtime dependency management in `nexo update` + daily auto-update cron.
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
@@ -61,7 +61,11 @@
|
|
|
61
61
|
"postinstall": "node bin/postinstall.js"
|
|
62
62
|
},
|
|
63
63
|
"runtimeDependencies": [
|
|
64
|
-
{
|
|
64
|
+
{
|
|
65
|
+
"name": "@anthropic-ai/claude-code",
|
|
66
|
+
"type": "npm-global",
|
|
67
|
+
"optional": false
|
|
68
|
+
}
|
|
65
69
|
],
|
|
66
70
|
"engines": {
|
|
67
71
|
"node": ">=18"
|
package/src/agent_runner.py
CHANGED
|
@@ -56,7 +56,6 @@ class TerminalClientUnavailableError(AgentRunnerError):
|
|
|
56
56
|
class AutomationBackendUnavailableError(AgentRunnerError):
|
|
57
57
|
"""Raised when the configured automation backend is unavailable."""
|
|
58
58
|
|
|
59
|
-
|
|
60
59
|
def _canonical_pricing_model(model: str) -> str:
|
|
61
60
|
lowered = str(model or "").strip().lower()
|
|
62
61
|
lowered = lowered.split("[", 1)[0]
|
|
@@ -533,6 +532,63 @@ def _build_codex_prompt(
|
|
|
533
532
|
return prompt
|
|
534
533
|
|
|
535
534
|
|
|
535
|
+
def _build_enforcement_system_prompt() -> str:
|
|
536
|
+
"""Build a system prompt fragment from tool-enforcement-map.json for headless sessions.
|
|
537
|
+
|
|
538
|
+
Reads the map and generates concise enforcement rules that the model must follow.
|
|
539
|
+
Only includes 'must' and 'should' level tools — 'none' tools are omitted.
|
|
540
|
+
"""
|
|
541
|
+
map_path = NEXO_HOME / "tool-enforcement-map.json"
|
|
542
|
+
if not map_path.exists():
|
|
543
|
+
for candidate in [
|
|
544
|
+
Path(__file__).parent.parent / "tool-enforcement-map.json",
|
|
545
|
+
]:
|
|
546
|
+
if candidate.exists():
|
|
547
|
+
map_path = candidate
|
|
548
|
+
break
|
|
549
|
+
else:
|
|
550
|
+
return ""
|
|
551
|
+
try:
|
|
552
|
+
data = json.loads(map_path.read_text())
|
|
553
|
+
except Exception:
|
|
554
|
+
return ""
|
|
555
|
+
|
|
556
|
+
lines = ["[PROTOCOL ENFORCEMENT — these rules are mandatory]"]
|
|
557
|
+
must_rules = []
|
|
558
|
+
should_rules = []
|
|
559
|
+
|
|
560
|
+
for tool_name, tool in data.get("tools", {}).items():
|
|
561
|
+
enf = tool.get("enforcement", {})
|
|
562
|
+
level = enf.get("level", "none")
|
|
563
|
+
if level == "none":
|
|
564
|
+
continue
|
|
565
|
+
rules = enf.get("rules", [])
|
|
566
|
+
rule_descs = [r.get("description", "") for r in rules if r.get("description")]
|
|
567
|
+
notes = enf.get("notes", "")
|
|
568
|
+
if rule_descs:
|
|
569
|
+
desc = f"{tool_name}: {'; '.join(rule_descs)}"
|
|
570
|
+
elif notes:
|
|
571
|
+
desc = f"{tool_name}: {notes[:120]}"
|
|
572
|
+
else:
|
|
573
|
+
rule_types = [r.get("type", "") for r in rules]
|
|
574
|
+
desc = f"{tool_name} ({', '.join(rule_types)})" if rule_types else tool_name
|
|
575
|
+
if level == "must":
|
|
576
|
+
must_rules.append(desc)
|
|
577
|
+
elif level == "should":
|
|
578
|
+
should_rules.append(desc)
|
|
579
|
+
|
|
580
|
+
if must_rules:
|
|
581
|
+
lines.append("MUST (violation creates protocol debt):")
|
|
582
|
+
for r in must_rules:
|
|
583
|
+
lines.append(f" - {r}")
|
|
584
|
+
if should_rules:
|
|
585
|
+
lines.append("SHOULD (recommended, check if relevant):")
|
|
586
|
+
for r in should_rules:
|
|
587
|
+
lines.append(f" - {r}")
|
|
588
|
+
|
|
589
|
+
return "\n".join(lines) if (must_rules or should_rules) else ""
|
|
590
|
+
|
|
591
|
+
|
|
536
592
|
def run_automation_prompt(
|
|
537
593
|
prompt: str,
|
|
538
594
|
*,
|
|
@@ -562,6 +618,13 @@ def run_automation_prompt(
|
|
|
562
618
|
reasoning_effort = profile["reasoning_effort"]
|
|
563
619
|
selected_backend = _resolve_available_backend(selected_backend, preferences=prefs)
|
|
564
620
|
|
|
621
|
+
enforcement_fragment = _build_enforcement_system_prompt()
|
|
622
|
+
if enforcement_fragment:
|
|
623
|
+
if append_system_prompt:
|
|
624
|
+
append_system_prompt = append_system_prompt + "\n\n" + enforcement_fragment
|
|
625
|
+
else:
|
|
626
|
+
append_system_prompt = enforcement_fragment
|
|
627
|
+
|
|
565
628
|
cwd_path = Path(cwd).expanduser().resolve() if cwd else Path.cwd()
|
|
566
629
|
run_env = _headless_env(env)
|
|
567
630
|
extra_args = list(extra_args or [])
|
|
@@ -599,14 +662,24 @@ def run_automation_prompt(
|
|
|
599
662
|
if allowed_tools:
|
|
600
663
|
cmd.extend(["--allowedTools", allowed_tools])
|
|
601
664
|
cmd.extend(extra_args)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
665
|
+
try:
|
|
666
|
+
from enforcement_engine import run_with_enforcement
|
|
667
|
+
result = run_with_enforcement(
|
|
668
|
+
cmd,
|
|
669
|
+
prompt=prompt,
|
|
670
|
+
cwd=str(cwd_path),
|
|
671
|
+
env=run_env,
|
|
672
|
+
timeout=timeout,
|
|
673
|
+
)
|
|
674
|
+
except ImportError:
|
|
675
|
+
result = subprocess.run(
|
|
676
|
+
cmd,
|
|
677
|
+
cwd=str(cwd_path),
|
|
678
|
+
capture_output=True,
|
|
679
|
+
text=True,
|
|
680
|
+
timeout=timeout,
|
|
681
|
+
env=run_env,
|
|
682
|
+
)
|
|
610
683
|
final_stdout, telemetry = _extract_claude_telemetry(
|
|
611
684
|
result.stdout or "",
|
|
612
685
|
requested_output_format=requested_output_format,
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Headless Protocol Enforcement Engine for NEXO Brain.
|
|
2
|
+
|
|
3
|
+
Wraps a Claude Code subprocess with stream-json I/O, monitors tool calls,
|
|
4
|
+
and injects enforcement prompts when rules from tool-enforcement-map.json
|
|
5
|
+
are violated. Python equivalent of Desktop's enforcement-engine.js.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
18
|
+
MAP_FILENAME = "tool-enforcement-map.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_map() -> dict | None:
|
|
22
|
+
for candidate in [
|
|
23
|
+
NEXO_HOME / MAP_FILENAME,
|
|
24
|
+
NEXO_HOME / "brain" / MAP_FILENAME,
|
|
25
|
+
Path(__file__).parent.parent / MAP_FILENAME,
|
|
26
|
+
]:
|
|
27
|
+
if candidate.exists():
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(candidate.read_text(encoding="utf-8"))
|
|
30
|
+
except Exception:
|
|
31
|
+
continue
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize(name: str) -> str:
|
|
36
|
+
return name.replace("mcp__nexo__", "")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HeadlessEnforcer:
|
|
40
|
+
"""Monitor a Claude Code stream-json process and enforce protocol rules."""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.map = _load_map()
|
|
44
|
+
self.tools_called: set[str] = set()
|
|
45
|
+
self.tool_call_count = 0
|
|
46
|
+
self.user_message_count = 0
|
|
47
|
+
self.tool_timestamps: dict[str, float] = {}
|
|
48
|
+
self.msg_since_tool: dict[str, int] = {}
|
|
49
|
+
self.injection_queue: list[dict] = []
|
|
50
|
+
self._started_at = time.time()
|
|
51
|
+
|
|
52
|
+
# Build indexes from map
|
|
53
|
+
self._on_start: list[dict] = []
|
|
54
|
+
self._on_end: list[dict] = []
|
|
55
|
+
self._periodic_msg: list[dict] = []
|
|
56
|
+
self._periodic_time: list[dict] = []
|
|
57
|
+
self._after_tool: dict[str, list[dict]] = {}
|
|
58
|
+
|
|
59
|
+
if self.map:
|
|
60
|
+
self._build_indexes()
|
|
61
|
+
|
|
62
|
+
def _build_indexes(self):
|
|
63
|
+
for tool_name, tool_def in self.map.get("tools", {}).items():
|
|
64
|
+
enf = tool_def.get("enforcement")
|
|
65
|
+
if not enf or enf.get("level") == "none":
|
|
66
|
+
continue
|
|
67
|
+
for rule in enf.get("rules", []):
|
|
68
|
+
rtype = rule.get("type", "")
|
|
69
|
+
entry = {"tool": tool_name, "rule": rule, "enf": enf}
|
|
70
|
+
if rtype == "on_session_start":
|
|
71
|
+
self._on_start.append(entry)
|
|
72
|
+
elif rtype == "on_session_end":
|
|
73
|
+
self._on_end.append(entry)
|
|
74
|
+
elif rtype == "periodic_by_messages":
|
|
75
|
+
self._periodic_msg.append(entry)
|
|
76
|
+
elif rtype == "periodic_by_time":
|
|
77
|
+
self._periodic_time.append(entry)
|
|
78
|
+
elif rtype == "after_tool":
|
|
79
|
+
for wt in rule.get("watch_tools", []):
|
|
80
|
+
self._after_tool.setdefault(wt, []).append(entry)
|
|
81
|
+
|
|
82
|
+
# triggers_after chains
|
|
83
|
+
for triggered in enf.get("triggers_after", []):
|
|
84
|
+
self._after_tool.setdefault(tool_name, []).append({
|
|
85
|
+
"tool": triggered,
|
|
86
|
+
"rule": {"type": "after_tool"},
|
|
87
|
+
"enf": self.map["tools"].get(triggered, {}).get("enforcement", {}),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
def on_tool_call(self, raw_name: str):
|
|
91
|
+
name = _normalize(raw_name)
|
|
92
|
+
self.tool_call_count += 1
|
|
93
|
+
self.tools_called.add(name)
|
|
94
|
+
self.tool_timestamps[name] = time.time()
|
|
95
|
+
self.msg_since_tool[name] = 0
|
|
96
|
+
|
|
97
|
+
# Check after_tool rules
|
|
98
|
+
for entry in self._after_tool.get(name, []):
|
|
99
|
+
target = entry["tool"]
|
|
100
|
+
if target not in self.tools_called:
|
|
101
|
+
prompt = entry["enf"].get("inject_prompt", "")
|
|
102
|
+
if prompt:
|
|
103
|
+
self._enqueue(prompt, f"after:{name}->{target}")
|
|
104
|
+
|
|
105
|
+
def check_periodic(self):
|
|
106
|
+
"""Called periodically to check time-based and message-based rules."""
|
|
107
|
+
# on_session_start
|
|
108
|
+
for entry in self._on_start:
|
|
109
|
+
tool = entry["tool"]
|
|
110
|
+
threshold = entry["rule"].get("threshold", 2)
|
|
111
|
+
if tool not in self.tools_called and self.tool_call_count >= threshold:
|
|
112
|
+
prompt = entry["enf"].get("inject_prompt", "")
|
|
113
|
+
if prompt:
|
|
114
|
+
self._enqueue(prompt, f"start:{tool}")
|
|
115
|
+
|
|
116
|
+
# periodic_by_messages
|
|
117
|
+
for entry in self._periodic_msg:
|
|
118
|
+
tool = entry["tool"]
|
|
119
|
+
threshold = entry["rule"].get("threshold", 3)
|
|
120
|
+
count = self.msg_since_tool.get(tool, self.user_message_count)
|
|
121
|
+
if count >= threshold:
|
|
122
|
+
prompt = entry["enf"].get("inject_prompt", "")
|
|
123
|
+
if prompt:
|
|
124
|
+
self._enqueue(prompt, f"periodic_msg:{tool}")
|
|
125
|
+
|
|
126
|
+
# periodic_by_time
|
|
127
|
+
for entry in self._periodic_time:
|
|
128
|
+
tool = entry["tool"]
|
|
129
|
+
threshold_min = entry["rule"].get("threshold", 15)
|
|
130
|
+
last = self.tool_timestamps.get(tool, self._started_at)
|
|
131
|
+
elapsed_min = (time.time() - last) / 60
|
|
132
|
+
if elapsed_min >= threshold_min:
|
|
133
|
+
prompt = entry["enf"].get("inject_prompt", "")
|
|
134
|
+
if prompt:
|
|
135
|
+
self._enqueue(prompt, f"periodic_time:{tool}")
|
|
136
|
+
|
|
137
|
+
def get_end_prompts(self) -> list[str]:
|
|
138
|
+
prompts = []
|
|
139
|
+
for entry in self._on_end:
|
|
140
|
+
if entry["enf"].get("level") == "must":
|
|
141
|
+
p = entry["enf"].get("session_end_inject_prompt") or entry["enf"].get("inject_prompt", "")
|
|
142
|
+
if p:
|
|
143
|
+
prompts.append(p)
|
|
144
|
+
return prompts
|
|
145
|
+
|
|
146
|
+
def flush(self) -> dict | None:
|
|
147
|
+
if not self.injection_queue:
|
|
148
|
+
return None
|
|
149
|
+
return self.injection_queue.pop(0)
|
|
150
|
+
|
|
151
|
+
def _enqueue(self, prompt: str, tag: str):
|
|
152
|
+
if any(q["tag"] == tag for q in self.injection_queue):
|
|
153
|
+
return
|
|
154
|
+
tool = tag.split(":")[-1].split("->")[-1]
|
|
155
|
+
if tool in self.tools_called and not tag.startswith("periodic_"):
|
|
156
|
+
return
|
|
157
|
+
self.injection_queue.append({"prompt": prompt, "tag": tag, "at": time.time()})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_with_enforcement(
|
|
161
|
+
cmd: list[str],
|
|
162
|
+
*,
|
|
163
|
+
prompt: str,
|
|
164
|
+
cwd: str = "",
|
|
165
|
+
env: dict | None = None,
|
|
166
|
+
timeout: int = 300,
|
|
167
|
+
) -> subprocess.CompletedProcess:
|
|
168
|
+
"""Run a Claude Code process with real-time enforcement monitoring.
|
|
169
|
+
|
|
170
|
+
Uses stream-json mode instead of -p mode. Monitors stdout for tool calls,
|
|
171
|
+
injects enforcement prompts via stdin when rules are violated.
|
|
172
|
+
"""
|
|
173
|
+
enforcer = HeadlessEnforcer()
|
|
174
|
+
if not enforcer.map:
|
|
175
|
+
# No map available — fall back to simple subprocess.run
|
|
176
|
+
return subprocess.run(cmd, cwd=cwd or None, capture_output=True, text=True,
|
|
177
|
+
timeout=timeout, env=env)
|
|
178
|
+
|
|
179
|
+
# Replace -p with stream-json mode
|
|
180
|
+
stream_cmd = []
|
|
181
|
+
skip_next = False
|
|
182
|
+
for i, arg in enumerate(cmd):
|
|
183
|
+
if skip_next:
|
|
184
|
+
skip_next = False
|
|
185
|
+
continue
|
|
186
|
+
if arg == "-p":
|
|
187
|
+
skip_next = True # Skip the prompt argument
|
|
188
|
+
continue
|
|
189
|
+
if arg == "--output-format":
|
|
190
|
+
skip_next = True # Skip output format
|
|
191
|
+
continue
|
|
192
|
+
stream_cmd.append(arg)
|
|
193
|
+
|
|
194
|
+
stream_cmd.extend([
|
|
195
|
+
"--print",
|
|
196
|
+
"--input-format", "stream-json",
|
|
197
|
+
"--output-format", "stream-json",
|
|
198
|
+
"--verbose",
|
|
199
|
+
])
|
|
200
|
+
|
|
201
|
+
proc = subprocess.Popen(
|
|
202
|
+
stream_cmd,
|
|
203
|
+
stdin=subprocess.PIPE,
|
|
204
|
+
stdout=subprocess.PIPE,
|
|
205
|
+
stderr=subprocess.PIPE,
|
|
206
|
+
cwd=cwd or None,
|
|
207
|
+
env=env,
|
|
208
|
+
text=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Send initial user message
|
|
212
|
+
initial_msg = json.dumps({
|
|
213
|
+
"type": "user",
|
|
214
|
+
"message": {"role": "user", "content": [{"type": "text", "text": prompt}]}
|
|
215
|
+
})
|
|
216
|
+
proc.stdin.write(initial_msg + "\n")
|
|
217
|
+
proc.stdin.flush()
|
|
218
|
+
|
|
219
|
+
collected_text = []
|
|
220
|
+
stderr_lines = []
|
|
221
|
+
start_time = time.time()
|
|
222
|
+
waiting_for_injection_response = False
|
|
223
|
+
|
|
224
|
+
def _inject(text: str):
|
|
225
|
+
nonlocal waiting_for_injection_response
|
|
226
|
+
msg = json.dumps({
|
|
227
|
+
"type": "user",
|
|
228
|
+
"message": {"role": "user", "content": [{"type": "text", "text": text}]}
|
|
229
|
+
})
|
|
230
|
+
try:
|
|
231
|
+
proc.stdin.write(msg + "\n")
|
|
232
|
+
proc.stdin.flush()
|
|
233
|
+
waiting_for_injection_response = True
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
def _read_stderr():
|
|
238
|
+
try:
|
|
239
|
+
for line in proc.stderr:
|
|
240
|
+
stderr_lines.append(line)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
|
|
245
|
+
stderr_thread.start()
|
|
246
|
+
|
|
247
|
+
# Periodic check timer
|
|
248
|
+
last_periodic_check = time.time()
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
for raw_line in proc.stdout:
|
|
252
|
+
line = raw_line.strip()
|
|
253
|
+
if not line:
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
# Check timeout
|
|
257
|
+
if time.time() - start_time > timeout:
|
|
258
|
+
proc.kill()
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
event = json.loads(line)
|
|
263
|
+
except json.JSONDecodeError:
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
event_type = event.get("type", "")
|
|
267
|
+
|
|
268
|
+
# Detect tool use
|
|
269
|
+
if event_type == "assistant" and event.get("message", {}).get("content"):
|
|
270
|
+
for block in event["message"]["content"]:
|
|
271
|
+
if block.get("type") == "tool_use":
|
|
272
|
+
enforcer.on_tool_call(block.get("name", ""))
|
|
273
|
+
elif event_type == "content_block_start":
|
|
274
|
+
cb = event.get("content_block", {})
|
|
275
|
+
if cb.get("type") == "tool_use":
|
|
276
|
+
enforcer.on_tool_call(cb.get("name", ""))
|
|
277
|
+
|
|
278
|
+
# Collect assistant text
|
|
279
|
+
if event_type == "assistant" and not waiting_for_injection_response:
|
|
280
|
+
msg = event.get("message", {})
|
|
281
|
+
for block in msg.get("content", []):
|
|
282
|
+
if block.get("type") == "text":
|
|
283
|
+
collected_text.append(block["text"])
|
|
284
|
+
|
|
285
|
+
# Detect turn end (result event with stop_reason)
|
|
286
|
+
if event_type == "result":
|
|
287
|
+
if waiting_for_injection_response:
|
|
288
|
+
waiting_for_injection_response = False
|
|
289
|
+
# After injection response, check for more injections
|
|
290
|
+
item = enforcer.flush()
|
|
291
|
+
if item:
|
|
292
|
+
_inject(item["prompt"])
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# Normal turn end — check for pending enforcements
|
|
296
|
+
enforcer.check_periodic()
|
|
297
|
+
item = enforcer.flush()
|
|
298
|
+
if item:
|
|
299
|
+
_inject(item["prompt"])
|
|
300
|
+
else:
|
|
301
|
+
break # No more injections, we're done
|
|
302
|
+
|
|
303
|
+
# Periodic check every 30 seconds
|
|
304
|
+
if time.time() - last_periodic_check > 30:
|
|
305
|
+
enforcer.check_periodic()
|
|
306
|
+
last_periodic_check = time.time()
|
|
307
|
+
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
finally:
|
|
311
|
+
# End-of-session enforcement: inject diary + stop
|
|
312
|
+
end_prompts = enforcer.get_end_prompts()
|
|
313
|
+
for ep in end_prompts:
|
|
314
|
+
try:
|
|
315
|
+
_inject(ep)
|
|
316
|
+
# Wait briefly for response
|
|
317
|
+
deadline = time.time() + 15
|
|
318
|
+
for raw_line in proc.stdout:
|
|
319
|
+
if time.time() > deadline:
|
|
320
|
+
break
|
|
321
|
+
line = raw_line.strip()
|
|
322
|
+
if not line:
|
|
323
|
+
continue
|
|
324
|
+
try:
|
|
325
|
+
event = json.loads(line)
|
|
326
|
+
if event.get("type") == "result":
|
|
327
|
+
break
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
continue
|
|
330
|
+
except Exception:
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
proc.stdin.close()
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
try:
|
|
338
|
+
proc.wait(timeout=10)
|
|
339
|
+
except subprocess.TimeoutExpired:
|
|
340
|
+
proc.kill()
|
|
341
|
+
|
|
342
|
+
stderr_thread.join(timeout=2)
|
|
343
|
+
final_text = "\n".join(collected_text)
|
|
344
|
+
final_stderr = "".join(stderr_lines)
|
|
345
|
+
|
|
346
|
+
return subprocess.CompletedProcess(
|
|
347
|
+
stream_cmd, proc.returncode or 0, final_text, final_stderr
|
|
348
|
+
)
|