@agentdeck/hooks 1.6.0 → 1.8.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.
@@ -257,11 +257,14 @@ export const AgentDeckObserver = async ({ directory, client }) => {
257
257
  if (status === "completed" || status === "error") {
258
258
  if (phase !== "end") {
259
259
  toolPhase.set(part.callID, "end");
260
- post("opencode_tool_end", { session_id: sessionID, tool_name: part.tool || "tool", cwd });
260
+ post("opencode_tool_end", { session_id: sessionID, tool_name: part.tool || "tool", tool_use_id: part.callID, is_error: status === "error", cwd });
261
261
  }
262
- } else if (!phase) {
262
+ } else if (!phase && status !== "pending") {
263
263
  toolPhase.set(part.callID, "start");
264
- post("opencode_tool_start", { session_id: sessionID, tool_name: part.tool || "tool", cwd });
264
+ const command = part.state?.input?.command;
265
+ const tool_input = part.tool === "bash" && typeof command === "string" && command.length <= 4096
266
+ ? { command } : undefined;
267
+ post("opencode_tool_start", { session_id: sessionID, tool_name: part.tool || "tool", tool_use_id: part.callID, tool_input, cwd });
265
268
  }
266
269
  if (toolPhase.size > 512) toolPhase.clear();
267
270
  }
@@ -270,27 +273,42 @@ export const AgentDeckObserver = async ({ directory, client }) => {
270
273
  // no prediction needed, zero false-positive risk. Forward it so
271
274
  // devices can render Allow/Deny; the daemon replies through the
272
275
  // steering queue as a permission_respond command.
273
- const perm = props.permission || props.info || props || {};
276
+ const perm = (props.permission && typeof props.permission === "object") ? props.permission : (props.info || props);
274
277
  const psid = perm.sessionID;
275
278
  if (psid && perm.id) {
276
279
  announce(psid);
277
280
  post("opencode_permission_asked", {
278
281
  session_id: psid,
279
282
  permission_id: perm.id,
280
- title: typeof perm.title === "string" ? perm.title : "",
283
+ title: typeof perm.title === "string" ? perm.title : (typeof perm.permission === "string" ? perm.permission : "Permission requested"),
281
284
  cwd,
282
285
  });
283
286
  }
284
287
  } else if (type === "permission.replied") {
285
- const perm = props.permission || props.info || props || {};
288
+ const perm = (props.permission && typeof props.permission === "object") ? props.permission : (props.info || props);
286
289
  const psid = perm.sessionID;
287
290
  if (psid) {
288
291
  post("opencode_permission_replied", {
289
292
  session_id: psid,
290
- permission_id: perm.id || "",
293
+ permission_id: perm.requestID || perm.permissionID || perm.id || "",
291
294
  cwd,
292
295
  });
293
296
  }
297
+ } else if (type === "question.asked") {
298
+ if (props.sessionID && props.id) {
299
+ announce(props.sessionID);
300
+ post("opencode_question_asked", {
301
+ session_id: props.sessionID, question_id: props.id,
302
+ title: (Array.isArray(props.questions) ? props.questions : []).map(q => q.question || q.header || "").filter(Boolean).join(" / "),
303
+ cwd,
304
+ });
305
+ }
306
+ } else if (type === "question.replied" || type === "question.rejected") {
307
+ if (props.sessionID && props.requestID) {
308
+ post(type === "question.replied" ? "opencode_question_replied" : "opencode_question_rejected", {
309
+ session_id: props.sessionID, question_id: props.requestID, cwd,
310
+ });
311
+ }
294
312
  } else if (type === "session.idle") {
295
313
  const sessionID = props.sessionID;
296
314
  announce(sessionID);
@@ -1 +1 @@
1
- {"version":3,"file":"opencode-install.js","sourceRoot":"","sources":["../src/opencode-install.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"opencode-install.js","sourceRoot":"","sources":["../src/opencode-install.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,308 @@
1
+ """AgentDeck Hermes observer v1. No hook changes Hermes execution.
2
+
3
+ Contract: NousResearch/hermes-agent@0a374d167, hermes.observer.v1.
4
+ Network work is serialized by one bounded daemon worker, never in a callback.
5
+ Only an AgentDeck daemon advertising hermesObserver is eligible (older/native
6
+ receivers must not misclassify these hooks as Claude). No default-port probing.
7
+ """
8
+ import atexit
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import queue
14
+ import re
15
+ import shlex
16
+ import threading
17
+ import time
18
+ import urllib.request
19
+ from collections import OrderedDict
20
+ from hermes_constants import get_hermes_home
21
+
22
+ _QUEUE = queue.Queue(maxsize=128)
23
+ _LOCK = threading.RLock()
24
+ _WORKER = None
25
+ _CHILDREN = OrderedDict()
26
+ _TURNS = OrderedDict()
27
+ _CONTEXT = OrderedDict()
28
+ _MAX_TRACKED = 512
29
+ _CI_RULES = json.loads((Path(__file__).parent / "ci-wait-rules.json").read_text())
30
+ # Ignore proxy env vars and redirects: observation must stay on loopback.
31
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
32
+ def redirect_request(self, *args, **kwargs):
33
+ return None
34
+
35
+ _HTTP = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
36
+
37
+
38
+ def _identity(sid):
39
+ if not isinstance(sid, str) or not sid.strip():
40
+ return None
41
+ home = str(get_hermes_home().expanduser().resolve())
42
+ return "hermes-" + hashlib.sha256((home + "\0" + sid).encode()).hexdigest()[:32]
43
+
44
+
45
+ def _remember(mapping, key, value):
46
+ mapping[key] = value
47
+ mapping.move_to_end(key)
48
+ while len(mapping) > _MAX_TRACKED:
49
+ mapping.popitem(last=False)
50
+
51
+
52
+ def _text(value, limit=8192):
53
+ return value[:limit] if isinstance(value, str) else ""
54
+
55
+
56
+ def _payload(kwargs, sid=None):
57
+ if kwargs.get("parent_session_id"):
58
+ return None
59
+ identity = _identity(sid or kwargs.get("session_id"))
60
+ if not identity or identity in _CHILDREN:
61
+ return None
62
+ # Real Gateway tool callbacks omit platform. Missing context must not
63
+ # relabel a Gateway conversation as CLI or leak its host's working directory.
64
+ platform = _text(kwargs.get("platform"), 40)
65
+ if platform:
66
+ _remember(_CONTEXT, identity, {
67
+ "cwd": os.getcwd() if platform.lower() == "cli" else "",
68
+ # ASCII only: small board fonts lack U+00B7.
69
+ "project_name": "Hermes (" + platform + ")",
70
+ "platform": platform.lower(),
71
+ })
72
+ return {
73
+ "session_id": identity,
74
+ **_CONTEXT.get(identity, {}),
75
+ "model": _text(kwargs.get("model"), 200),
76
+ # Lets the daemon close the row when this process is gone. One-shot
77
+ # mode (`hermes -z`) hard-exits through os._exit without firing
78
+ # on_session_finalize or atexit, so no event can carry that end.
79
+ "pid": os.getpid(),
80
+ }
81
+
82
+
83
+ def _tool_id(sid, value):
84
+ # Upstream ids can be model-controlled. Never export the original string.
85
+ if not isinstance(value, str) or not value or len(value) > _CI_RULES["maxCommandChars"]:
86
+ return None
87
+ return "hermes-tool-" + hashlib.sha256((sid + "\0" + value).encode()).hexdigest()[:32]
88
+
89
+
90
+ def _ci_intent(tool, args):
91
+ # Deliberately narrower than the daemon shell grammar: one explicit gh
92
+ # watch only. Shell chains, substitutions, env wrappers and polling loops
93
+ # are not forwarded. Everything is classified locally and then discarded.
94
+ if tool != "terminal" or not isinstance(args, dict):
95
+ return None
96
+ command = args.get("command")
97
+ if not isinstance(command, str) or len(command) > _CI_RULES["maxCommandChars"]:
98
+ return None
99
+ if any(c in command for c in _CI_RULES["forbidden"] + ";|&<>\n\r"):
100
+ return None
101
+ try:
102
+ argv = shlex.split(command, comments=True)
103
+ except ValueError:
104
+ return None
105
+ if not argv or argv.pop(0).split("/")[-1] != "gh":
106
+ return None
107
+ intent = {"kind": "ci", "provider": "github-actions", "mode": "watch"}
108
+ def repository(value):
109
+ return isinstance(value, str) and len(value) <= _CI_RULES["maxIdentityChars"] and re.fullmatch(_CI_RULES["repoPattern"], value)
110
+ def number(value):
111
+ return isinstance(value, str) and re.fullmatch(_CI_RULES["numberPattern"], value) and len(value) <= 16 and int(value) <= _CI_RULES["maxId"]
112
+ def take_repo():
113
+ if argv and argv[0] in ("-R", "--repo"):
114
+ argv.pop(0)
115
+ value = argv.pop(0) if argv else None
116
+ elif argv and argv[0].startswith("--repo="):
117
+ value = argv.pop(0)[7:]
118
+ else:
119
+ return False
120
+ if not repository(value):
121
+ raise ValueError("invalid repository")
122
+ intent["repo"] = value
123
+ return True
124
+ try:
125
+ while take_repo():
126
+ pass
127
+ if len(argv) < 2:
128
+ return None
129
+ group, action = argv.pop(0), argv.pop(0)
130
+ if (group, action) not in (("run", "watch"), ("pr", "checks")):
131
+ return None
132
+ grammar = _CI_RULES["runWatch" if group == "run" else "prChecks"]
133
+ watch = group == "run"
134
+ identity = None
135
+ while argv:
136
+ if take_repo():
137
+ continue
138
+ arg = argv.pop(0)
139
+ if arg == "--watch" and group == "pr":
140
+ watch = True
141
+ continue
142
+ option, equal, value = arg.partition("=")
143
+ if option in grammar["values"]:
144
+ value = value if equal else (argv.pop(0) if argv else None)
145
+ if not value:
146
+ return None
147
+ elif arg in grammar["switches"]:
148
+ continue
149
+ elif arg.startswith("-") or identity is not None:
150
+ return None
151
+ else:
152
+ identity = arg
153
+ if not watch:
154
+ return None
155
+ if group == "run":
156
+ if not number(identity):
157
+ return None
158
+ intent["runId"] = int(identity)
159
+ elif identity is not None:
160
+ url = re.fullmatch(_CI_RULES["urlPattern"], identity)
161
+ if url:
162
+ if not repository(url[1]) or not number(url[2]) or intent.get("repo", url[1]) != url[1]:
163
+ return None
164
+ intent.update(repo=url[1], pr=int(url[2]))
165
+ elif number(identity):
166
+ intent["pr"] = int(identity)
167
+ elif (re.fullmatch(_CI_RULES["digitsPattern"], identity) or len(identity) > _CI_RULES["maxIdentityChars"] or
168
+ not re.fullmatch(_CI_RULES["branchPattern"], identity)):
169
+ return None
170
+ else:
171
+ intent["ref"] = identity
172
+ return intent
173
+ except ValueError:
174
+ return None
175
+
176
+
177
+ def _deliver(event, payload):
178
+ # Resolve every delivery: a daemon restart can move the port.
179
+ data_dir = os.environ.get("AGENTDECK_DATA_DIR")
180
+ registry = (Path(data_dir).expanduser() if data_dir else Path.home() / ".agentdeck") / "daemon.json"
181
+ info = json.loads(registry.read_text())
182
+ port = info.get("httpPort") or info.get("port")
183
+ if type(port) is not int or not 1 <= port <= 65535:
184
+ return
185
+ base = "http://127.0.0.1:" + str(port)
186
+ with _HTTP.open(base + "/health", timeout=0.2) as response:
187
+ health = json.loads(response.read(65536))
188
+ if health.get("mode") != "daemon" or health.get("hermesObserver") != 1:
189
+ return
190
+ request = urllib.request.Request(
191
+ base + "/hooks/" + event,
192
+ data=json.dumps(payload).encode(),
193
+ headers={"Content-Type": "application/json"}, method="POST",
194
+ )
195
+ with _HTTP.open(request, timeout=0.8) as response:
196
+ response.read(1024)
197
+
198
+
199
+ def _run():
200
+ while True:
201
+ created, event, payload = _QUEUE.get()
202
+ try:
203
+ # A congested/dead daemon must not replay old working states later.
204
+ if time.monotonic() - created < 5:
205
+ _deliver(event, payload)
206
+ except Exception:
207
+ pass # Observation never breaks Hermes or retries an old turn.
208
+ finally:
209
+ _QUEUE.task_done()
210
+
211
+
212
+ def _flush():
213
+ # CLI teardown may exit immediately after finalize. Give the worker one
214
+ # bounded second; never keep a broken daemon holding Hermes open.
215
+ deadline = time.monotonic() + 1.0
216
+ while _QUEUE.unfinished_tasks and time.monotonic() < deadline:
217
+ time.sleep(0.01)
218
+
219
+
220
+ def _post(event, payload):
221
+ global _WORKER
222
+ if payload is None or os.environ.get("AGENTDECK_NO_HERMES_HOOKS") == "1":
223
+ return
224
+ with _LOCK:
225
+ if _WORKER is None or not _WORKER.is_alive():
226
+ _WORKER = threading.Thread(target=_run, name="agentdeck-observer", daemon=True)
227
+ _WORKER.start()
228
+ atexit.register(_flush)
229
+ try:
230
+ _QUEUE.put_nowait((time.monotonic(), event, payload))
231
+ except queue.Full:
232
+ pass # Bounded best effort; daemon expires abandoned state.
233
+
234
+
235
+ def _handle(event, **kwargs):
236
+ # Serialize concurrent callbacks too. A callback has no network I/O and
237
+ # always returns None (pre_llm/pre_tool return values can change behavior).
238
+ with _LOCK:
239
+ if event == "subagent_start":
240
+ child = _identity(kwargs.get("child_session_id"))
241
+ if child:
242
+ _remember(_CHILDREN, child, True)
243
+ return
244
+ if event == "on_session_reset":
245
+ # Gateway reset carries NEW session_id; only old_session_id may
246
+ # close a row here. CLI already emits finalize for the old one.
247
+ old = kwargs.get("old_session_id")
248
+ if old:
249
+ _post("hermes_session_end", _payload(kwargs, old))
250
+ _CONTEXT.pop(_identity(old), None)
251
+ return
252
+ payload = _payload(kwargs)
253
+ if payload is None:
254
+ return
255
+ sid = payload["session_id"]
256
+ if event == "on_session_start":
257
+ _post("hermes_session_start", payload)
258
+ elif event == "pre_llm_call":
259
+ _remember(_TURNS, sid, {"response": ""})
260
+ payload["prompt"] = _text(kwargs.get("user_message"))
261
+ _post("hermes_user_prompt_submit", payload)
262
+ elif event in ("pre_tool_call", "post_tool_call"):
263
+ payload["tool_name"] = _text(kwargs.get("tool_name"), 120)
264
+ call_id = _tool_id(sid, kwargs.get("tool_call_id"))
265
+ if call_id:
266
+ payload["tool_call_id"] = call_id
267
+ if event == "pre_tool_call":
268
+ intent = _ci_intent(kwargs.get("tool_name"), kwargs.get("args"))
269
+ if intent:
270
+ payload["ci_wait_intent"] = intent
271
+ payload["ci_wait_background"] = kwargs["args"].get("background") is True
272
+ else:
273
+ # Pinned upstream emits an authoritative terminal status.
274
+ # A tool error clears the wait, never invents a CI failure.
275
+ payload["is_error"] = kwargs.get("status") in ("error", "blocked", "cancelled", "timeout")
276
+ _post("hermes_tool_start" if event == "pre_tool_call" else "hermes_tool_end", payload)
277
+ elif event == "post_llm_call":
278
+ # Final output also fires on some abnormal exits. Wait for the
279
+ # authoritative run outcome before claiming a successful Stop.
280
+ _remember(_TURNS, sid, {"response": _text(kwargs.get("assistant_response"))})
281
+ elif event == "on_session_end":
282
+ # RUN scoped, never session teardown. Exactly one Stop with the
283
+ # actual final response AND authoritative interrupted/failed flag.
284
+ turn = _TURNS.pop(sid, None)
285
+ if turn is not None:
286
+ payload["last_assistant_message"] = turn["response"]
287
+ payload["interrupted"] = bool(kwargs.get("interrupted"))
288
+ payload["aborted"] = bool(kwargs.get("failed")) or (not kwargs.get("completed", False) and not payload["interrupted"])
289
+ _post("hermes_stop", payload)
290
+ elif event == "on_session_finalize":
291
+ _TURNS.pop(sid, None)
292
+ _post("hermes_session_end", payload)
293
+ _CONTEXT.pop(sid, None)
294
+
295
+
296
+ def register(ctx):
297
+ for event in (
298
+ "on_session_start", "pre_llm_call", "post_llm_call", "on_session_end",
299
+ "on_session_finalize", "on_session_reset", "pre_tool_call", "post_tool_call",
300
+ "subagent_start",
301
+ ):
302
+ def callback(_event=event, **kwargs):
303
+ try:
304
+ _handle(_event, **kwargs)
305
+ except Exception:
306
+ pass
307
+ return None
308
+ ctx.register_hook(event, callback)
@@ -0,0 +1,66 @@
1
+ {
2
+ "maxCommandChars": 16384,
3
+ "maxIdentityChars": 255,
4
+ "maxId": 9007199254740991,
5
+ "whitespace": "\t\n\u000b\f\r              

   ",
6
+ "forbidden": "\u0000`$(){}",
7
+ "numberPattern": "^[1-9][0-9]*$",
8
+ "digitsPattern": "^[0-9]+$",
9
+ "repoPattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
10
+ "branchPattern": "^[A-Za-z0-9_./-]+$",
11
+ "assignmentPattern": "^[A-Za-z_][A-Za-z0-9_]*=",
12
+ "urlPattern": "^https://github\\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/pull/([1-9][0-9]*)/?$",
13
+ "runWatch": {
14
+ "values": [
15
+ "--interval",
16
+ "-i"
17
+ ],
18
+ "switches": [
19
+ "--exit-status",
20
+ "--compact"
21
+ ]
22
+ },
23
+ "prChecks": {
24
+ "values": [
25
+ "--interval",
26
+ "-i",
27
+ "--json",
28
+ "--jq",
29
+ "-q",
30
+ "--template",
31
+ "-t"
32
+ ],
33
+ "switches": [
34
+ "--fail-fast",
35
+ "--required"
36
+ ]
37
+ },
38
+ "runList": {
39
+ "values": [
40
+ "--branch",
41
+ "-b",
42
+ "--json",
43
+ "--jq",
44
+ "-q",
45
+ "--template",
46
+ "-t",
47
+ "--limit",
48
+ "-L",
49
+ "--workflow",
50
+ "-w",
51
+ "--status",
52
+ "-s",
53
+ "--event",
54
+ "-e",
55
+ "--user",
56
+ "-u",
57
+ "--commit",
58
+ "-c",
59
+ "--created"
60
+ ],
61
+ "switches": [
62
+ "--all",
63
+ "-a"
64
+ ]
65
+ }
66
+ }
@@ -0,0 +1,13 @@
1
+ name: agentdeck-observer
2
+ version: 0.1.0
3
+ description: Read-only local AgentDeck session observation
4
+ provides_hooks:
5
+ - on_session_start
6
+ - pre_llm_call
7
+ - post_llm_call
8
+ - on_session_end
9
+ - on_session_finalize
10
+ - on_session_reset
11
+ - pre_tool_call
12
+ - post_tool_call
13
+ - subagent_start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentdeck/hooks",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "engines": {
5
5
  "node": "22.x || 24.x || 26.x"
6
6
  },
@@ -17,7 +17,9 @@
17
17
  "bugs": "https://github.com/puritysb/AgentDeck/issues",
18
18
  "files": [
19
19
  "dist",
20
- "!dist/__tests__"
20
+ "!dist/__tests__",
21
+ "hermes-agentdeck",
22
+ "!hermes-agentdeck/__pycache__"
21
23
  ],
22
24
  "main": "dist/install.js",
23
25
  "types": "dist/install.d.ts",