@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.
- package/dist/codex-install.d.ts +1 -0
- package/dist/codex-install.d.ts.map +1 -1
- package/dist/codex-install.js +95 -29
- package/dist/codex-install.js.map +1 -1
- package/dist/codex-mini-toml.d.ts +5 -1
- package/dist/codex-mini-toml.d.ts.map +1 -1
- package/dist/codex-mini-toml.js +194 -68
- package/dist/codex-mini-toml.js.map +1 -1
- package/dist/hermes-install.d.ts +5 -0
- package/dist/hermes-install.d.ts.map +1 -0
- package/dist/hermes-install.js +21 -0
- package/dist/hermes-install.js.map +1 -0
- package/dist/install.d.ts +1 -0
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +13 -3
- package/dist/install.js.map +1 -1
- package/dist/opencode-install.d.ts.map +1 -1
- package/dist/opencode-install.js +25 -7
- package/dist/opencode-install.js.map +1 -1
- package/hermes-agentdeck/__init__.py +308 -0
- package/hermes-agentdeck/ci-wait-rules.json +66 -0
- package/hermes-agentdeck/plugin.yaml +13 -0
- package/package.json +4 -2
package/dist/opencode-install.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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":"AAAA,sEAAsE;AACtE,uEAAuE;AACvE,sEAAsE;AACtE,EAAE;AACF,qEAAqE;AACrE,uEAAuE;AACvE,sEAAsE;AACtE,sEAAsE;AACtE,wEAAwE;AACxE,yEAAyE;AACzE,kEAAkE;AAClE,EAAE;AACF,yEAAyE;AACzE,sEAAsE;AACtE,iEAAiE;AACjE,sEAAsE;AACtE,yEAAyE;AACzE,2BAA2B;AAC3B,EAAE;AACF,yBAAyB;AACzB,uEAAuE;AACvE,2EAA2E;AAC3E,yEAAyE;AACzE,uEAAuE;AACvE,uEAAuE;AAEvE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAChG,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,IAAI,CAAC;AAE7B,MAAM,CAAC,MAAM,wBAAwB,GAAG,cAAc,CAAC;AAEvD;;wEAEwE;AACxE,MAAM,UAAU,kBAAkB;IAChC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;IACxC,MAAM,UAAU,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,SAAS,CAAC,CAAC;IACxE,OAAO,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,wBAAwB,CAAC,CAAC;AAC3E,CAAC;AAaD;qDACqD;AACrD,SAAS,SAAS;IAChB,OAAO,OAAO,CAAC,GAAG,CAAC,2BAA2B,KAAK,GAAG,CAAC;AACzD,CAAC;AAED,MAAM,cAAc,GAAG,CAAC,CAAC;AAEzB;;0CAE0C;AAC1C,MAAM,UAAU,oBAAoB;IAClC,OAAO,iCAAiC,cAAc
|
|
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.
|
|
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",
|