@tiens.nguyen/gu-cli 1.0.739 → 1.0.741

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/gu_hooks.py ADDED
@@ -0,0 +1,231 @@
1
+ """Hooks for the gu agent (task #270): commands gu runs at fixed moments of a turn.
2
+
3
+ onStart the first turn of a gu session (gu started, or /reset)
4
+ onAsk every question, before the agent starts on it
5
+ onToolDone after a tool the matcher names (gu's tool names: edit_file, grep_repo, mcp__…)
6
+ onTurnEnd after the answer has been sent
7
+
8
+ gu's own names and gu's own tool names, deliberately not Claude Code's. The worker hands the agent
9
+ the hooks that apply (hooks-config.mjs: yours, plus a project's only once approved) and the
10
+ session this turn belongs to.
11
+
12
+ A HOOK IS A COMMAND. It gets one JSON object on stdin and may answer on stdout:
13
+ {"additionalContext": "..."} added for the model (onStart, onAsk; onToolDone: after the result)
14
+ plain text the same as additionalContext
15
+ exit code 2 stop: onAsk stops the turn, onToolDone tells the model; stderr is why
16
+ A hook that fails any other way, or runs past its timeout, is a warning — never a failed turn.
17
+
18
+ WHAT A HOOK SEES: its own env on a short whitelist of this process's (the worker's env carries gu's
19
+ credentials), plus GU_PROJECT_DIR and GU_HOOK_EVENT. It runs in the project folder.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import time
28
+ from typing import Any, Callable
29
+
30
+ EVENTS = ("onStart", "onAsk", "onToolDone", "onTurnEnd")
31
+ DEFAULT_TIMEOUT_S = 30
32
+ CONTEXT_CAP_CHARS = 6000
33
+ _ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP", "SHELL", "USER",
34
+ "USERNAME", "LOGNAME", "TERM", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "USERPROFILE",
35
+ "PROGRAMDATA", "PATHEXT", "COMSPEC", "VIRTUAL_ENV", "PYTHONIOENCODING")
36
+ _PLAIN_MATCHER = re.compile(r"^[A-Za-z0-9_\-| ,]*$")
37
+
38
+
39
+ def matches(matcher: str | None, value: str) -> bool:
40
+ """Empty or "*" = everything. Letters, digits, _ - and | = an exact list. Anything else = a regex."""
41
+ m = (matcher or "").strip()
42
+ if m in ("", "*"):
43
+ return True
44
+ if _PLAIN_MATCHER.match(m):
45
+ return value in {x.strip() for x in re.split(r"[|,]", m) if x.strip()}
46
+ try:
47
+ return re.search(m, value) is not None
48
+ except re.error:
49
+ return False
50
+
51
+
52
+ class HookResult:
53
+ def __init__(self):
54
+ self.context: list[str] = []
55
+ self.blocked: str | None = None
56
+ self.warnings: list[str] = []
57
+ self.ran = 0
58
+
59
+ @property
60
+ def text(self) -> str:
61
+ t = "\n\n".join(c for c in self.context if c.strip())
62
+ return t[:CONTEXT_CAP_CHARS] + ("\n… [hook output cut]" if len(t) > CONTEXT_CAP_CHARS else "")
63
+
64
+
65
+ class HookRunner:
66
+ def __init__(self, hooks: dict, cwd: str | None, session_id: str,
67
+ log: Callable[[str], None] = lambda s: None):
68
+ self.hooks = hooks if isinstance(hooks, dict) else {}
69
+ self.cwd = cwd if cwd and os.path.isdir(cwd) else None
70
+ self.session_id = session_id or "unknown"
71
+ self.log = log
72
+
73
+ def has(self, event: str) -> bool:
74
+ return bool(self.hooks.get(event))
75
+
76
+ def matcher_union(self, event: str) -> list[str]:
77
+ return [g.get("matcher", "") for g in self.hooks.get(event) or [] if isinstance(g, dict)]
78
+
79
+ def _env(self, event: str, extra: dict | None) -> dict:
80
+ env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
81
+ env.setdefault("PYTHONIOENCODING", "utf-8")
82
+ env["GU_PROJECT_DIR"] = self.cwd or ""
83
+ env["GU_HOOK_EVENT"] = event
84
+ for k, v in (extra or {}).items():
85
+ env[str(k)] = str(v)
86
+ return env
87
+
88
+ def run(self, event: str, payload: dict, match_value: str = "") -> HookResult:
89
+ res = HookResult()
90
+ if event not in EVENTS:
91
+ return res
92
+ body = {"event": event, "session_id": self.session_id, "cwd": self.cwd or "", **payload}
93
+ data = json.dumps(body, ensure_ascii=False, default=str)
94
+ for group in self.hooks.get(event) or []:
95
+ if not isinstance(group, dict) or not matches(group.get("matcher"), match_value):
96
+ continue
97
+ for h in group.get("hooks") or []:
98
+ if not isinstance(h, dict) or (h.get("type") or "command") != "command" or not h.get("command"):
99
+ continue
100
+ self._one(event, h, data, res)
101
+ return res
102
+
103
+ def _one(self, event: str, h: dict, data: str, res: HookResult) -> None:
104
+ cmd = str(h["command"])
105
+ args = [str(a) for a in (h.get("args") or [])]
106
+ timeout = float(h.get("timeout") or DEFAULT_TIMEOUT_S)
107
+ label = os.path.basename(args[-1] if args else cmd.split()[0])
108
+ t0 = time.time()
109
+ res.ran += 1
110
+ try:
111
+ p = subprocess.run([cmd, *args] if args else cmd, shell=not args, input=data,
112
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
113
+ timeout=timeout, cwd=self.cwd, env=self._env(event, h.get("env")))
114
+ except subprocess.TimeoutExpired:
115
+ res.warnings.append(f"{event} hook {label} took longer than {timeout:.0f}s and was stopped")
116
+ self.log(f"hook {event} {label}: timeout {timeout:.0f}s")
117
+ return
118
+ except OSError as e:
119
+ res.warnings.append(f"{event} hook {label} could not start: {e.strerror or e}")
120
+ self.log(f"hook {event} {label}: could not start")
121
+ return
122
+ ms = int((time.time() - t0) * 1000)
123
+ out = (p.stdout or "").strip()
124
+ err = (p.stderr or "").strip()
125
+ # Sizes only: a hook's output is conversation content, and the log is content-free (#249).
126
+ self.log(f"hook {event} {label}: exit {p.returncode} in {ms}ms, {len(out)} chars out")
127
+ if p.returncode == 2:
128
+ res.blocked = err or f"the {event} hook {label} said no"
129
+ return
130
+ if p.returncode != 0:
131
+ tail = err.splitlines()[-1][:200] if err else ""
132
+ res.warnings.append(f"{event} hook {label} failed (exit {p.returncode})" + (f": {tail}" if tail else ""))
133
+ return
134
+ if not out:
135
+ return
136
+ try:
137
+ obj = json.loads(out)
138
+ except ValueError:
139
+ res.context.append(out) # plain text = context
140
+ return
141
+ if isinstance(obj, dict):
142
+ if str(obj.get("decision", "")).lower() == "block":
143
+ res.blocked = str(obj.get("reason") or err or f"the {event} hook {label} said no")
144
+ return
145
+ ctx = obj.get("additionalContext")
146
+ if isinstance(ctx, str) and ctx.strip():
147
+ res.context.append(ctx)
148
+
149
+
150
+ # ---- onStart: once per session ---------------------------------------------------------------
151
+
152
+ def _state_path() -> str:
153
+ return os.path.join(os.path.expanduser("~"), ".gonext", "hooks-state.json")
154
+
155
+
156
+ def is_new_session(cwd: str, session_id: str, path: str | None = None) -> bool:
157
+ """True the first time this session is seen for this folder — and records it."""
158
+ path = path or _state_path()
159
+ key = os.path.realpath(cwd or "")
160
+ try:
161
+ with open(path, encoding="utf-8") as fh:
162
+ state = json.load(fh)
163
+ if not isinstance(state, dict):
164
+ state = {}
165
+ except (OSError, ValueError):
166
+ state = {}
167
+ if state.get(key) == session_id:
168
+ return False
169
+ state[key] = session_id
170
+ try:
171
+ os.makedirs(os.path.dirname(path), exist_ok=True)
172
+ tmp = f"{path}.{os.getpid()}.tmp"
173
+ with open(tmp, "w", encoding="utf-8") as fh:
174
+ json.dump(state, fh, indent=2)
175
+ os.replace(tmp, path)
176
+ except OSError:
177
+ pass
178
+ return True
179
+
180
+
181
+ # ---- onToolDone: wrapping the agent's tools ---------------------------------------------------
182
+
183
+ def wrap_tools(tools: list, runner: HookRunner, clip: int = 4000) -> int:
184
+ """Run onToolDone after every tool a matcher names. Returns how many tools were wrapped. Hook
185
+ context is appended to the tool's result; a block (exit 2) is appended as a message."""
186
+ if not runner.has("onToolDone"):
187
+ return 0
188
+ matchers = runner.matcher_union("onToolDone")
189
+ n = 0
190
+ for t in tools:
191
+ name = getattr(t, "name", "")
192
+ if not name or name == "final_answer" or not any(matches(m, name) for m in matchers):
193
+ continue
194
+ orig = t.forward
195
+ order = list((getattr(t, "inputs", None) or {}).keys())
196
+
197
+ def forward(*pos, _orig=orig, _name=name, _order=order, **kw):
198
+ result = _orig(*pos, **kw)
199
+ args = dict(kw)
200
+ for k, v in zip(_order, pos):
201
+ args.setdefault(k, v)
202
+ text = str(result)
203
+ r = runner.run("onToolDone", {"tool_name": _name, "tool_input": args,
204
+ "tool_result": text[:clip]}, match_value=_name)
205
+ extra = []
206
+ if r.blocked:
207
+ extra.append(f"[onToolDone hook] {r.blocked}")
208
+ if r.text:
209
+ extra.append(f"[onToolDone hook]\n{r.text}")
210
+ return f"{result}\n\n" + "\n".join(extra) if extra and isinstance(result, str) else result
211
+
212
+ t.forward = forward
213
+ n += 1
214
+ return n
215
+
216
+
217
+ def from_cfg(cfg: dict, log: Callable[[str], None]) -> HookRunner | None:
218
+ hooks = cfg.get("hooks")
219
+ if not isinstance(hooks, dict) or not any(hooks.get(e) for e in EVENTS):
220
+ return None
221
+ sess = cfg.get("hookSession") if isinstance(cfg.get("hookSession"), dict) else {}
222
+ cwd = (cfg.get("activeWorkspace") or "").strip() or None
223
+ if sess.get("id"):
224
+ return HookRunner(hooks, cwd, str(sess["id"]), log)
225
+ # No terminal session (the web, or a terminal older than hooks). The job id would be a NEW
226
+ # "session" every turn — onStart on every question — so onStart is skipped, and the other
227
+ # events share one id per day, which keeps the memory hook's steps together.
228
+ import datetime
229
+ runner = HookRunner(hooks, cwd, "gu-" + datetime.date.today().isoformat(), log)
230
+ runner.hooks = {k: v for k, v in runner.hooks.items() if k != "onStart"}
231
+ return runner