agenthub-multiagent-mcp 1.51.0 → 1.53.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/README.md +192 -192
- package/dist/captureHookInstall.d.ts +127 -0
- package/dist/captureHookInstall.d.ts.map +1 -0
- package/dist/captureHookInstall.js +256 -0
- package/dist/captureHookInstall.js.map +1 -0
- package/dist/captureHookInstall.test.d.ts +10 -0
- package/dist/captureHookInstall.test.d.ts.map +1 -0
- package/dist/captureHookInstall.test.js +325 -0
- package/dist/captureHookInstall.test.js.map +1 -0
- package/dist/channel.js +99 -3
- package/dist/channel.js.map +1 -1
- package/dist/client.d.ts +80 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +197 -4
- package/dist/client.js.map +1 -1
- package/dist/client.unit.test.js +50 -0
- package/dist/client.unit.test.js.map +1 -1
- package/dist/daemon.js +217 -3
- package/dist/daemon.js.map +1 -1
- package/dist/hooks/periodicReminder.js +9 -9
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/session-capture.sh +410 -0
- package/dist/sessionCapture.d.ts +163 -0
- package/dist/sessionCapture.d.ts.map +1 -0
- package/dist/sessionCapture.js +279 -0
- package/dist/sessionCapture.js.map +1 -0
- package/dist/sessionCapture.test.d.ts +9 -0
- package/dist/sessionCapture.test.d.ts.map +1 -0
- package/dist/sessionCapture.test.js +366 -0
- package/dist/sessionCapture.test.js.map +1 -0
- package/dist/sessionCaptureScript.test.d.ts +16 -0
- package/dist/sessionCaptureScript.test.d.ts.map +1 -0
- package/dist/sessionCaptureScript.test.js +92 -0
- package/dist/sessionCaptureScript.test.js.map +1 -0
- package/dist/setup.js +56 -56
- package/dist/steering.test.d.ts +2 -0
- package/dist/steering.test.d.ts.map +1 -0
- package/dist/steering.test.js +223 -0
- package/dist/steering.test.js.map +1 -0
- package/dist/streamingRuntimeLib.d.ts +391 -0
- package/dist/streamingRuntimeLib.d.ts.map +1 -0
- package/dist/streamingRuntimeLib.js +628 -0
- package/dist/streamingRuntimeLib.js.map +1 -0
- package/dist/streamingRuntimeLib.test.d.ts +2 -0
- package/dist/streamingRuntimeLib.test.d.ts.map +1 -0
- package/dist/streamingRuntimeLib.test.js +233 -0
- package/dist/streamingRuntimeLib.test.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +149 -13
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/openspec-viz/diagram-generator.js +54 -54
- package/dist/tools/openspec-viz/html-generator.js +503 -503
- package/dist/tools/sessionControl.d.ts +53 -0
- package/dist/tools/sessionControl.d.ts.map +1 -0
- package/dist/tools/sessionControl.js +265 -0
- package/dist/tools/sessionControl.js.map +1 -0
- package/dist/tools/sessionControl.test.d.ts +14 -0
- package/dist/tools/sessionControl.test.d.ts.map +1 -0
- package/dist/tools/sessionControl.test.js +298 -0
- package/dist/tools/sessionControl.test.js.map +1 -0
- package/dist/tools/tools.test.js +5 -2
- package/dist/tools/tools.test.js.map +1 -1
- package/dist/tools/zohoAttachments.test.d.ts +10 -0
- package/dist/tools/zohoAttachments.test.d.ts.map +1 -0
- package/dist/tools/zohoAttachments.test.js +204 -0
- package/dist/tools/zohoAttachments.test.js.map +1 -0
- package/dist/worker.js +10 -10
- package/native/agenthub-memvid/agenthub-memvid.linux-x64-gnu.node +0 -0
- package/native/agenthub-memvid/index.d.ts +58 -58
- package/native/agenthub-memvid/index.js +319 -319
- package/package.json +77 -77
- package/skills/catalog.json +76 -76
- package/skills/commands/close-session.md +144 -144
- package/skills/manifest.json +50 -50
- package/skills/skills/code-brain-hook/SKILL.md +61 -61
- package/skills/skills/file-bug/SKILL.md +75 -75
- package/skills/skills/multiagent-brainstorm/SKILL.md +142 -142
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# AgentHub Rich Session Capture Hook
|
|
3
|
+
#
|
|
4
|
+
# Captures full tool_input + tool_response from PostToolUse hook,
|
|
5
|
+
# plus assistant/user messages from the transcript file.
|
|
6
|
+
# Posts structured events to /api/sessions/{id}/events.
|
|
7
|
+
#
|
|
8
|
+
# Two callers, two ways of learning which session to record into:
|
|
9
|
+
#
|
|
10
|
+
# 1. Agent/daemon shifts (add-multiplayer-sessions §1.5). The daemon opens the
|
|
11
|
+
# session and stamps AGENTHUB_SESSION_ID into the child environment. This
|
|
12
|
+
# path is unchanged.
|
|
13
|
+
#
|
|
14
|
+
# 2. Human interactive sessions (§3.2). There is no daemon, so nothing stamps
|
|
15
|
+
# AGENTHUB_SESSION_ID — the hook has to obtain one itself. It does that by
|
|
16
|
+
# asking the server to resolve a session for Claude Code's own session id,
|
|
17
|
+
# and the server refuses unless the person has consented.
|
|
18
|
+
#
|
|
19
|
+
# The interactive path is off unless a consent grant file exists locally, which
|
|
20
|
+
# the MCP writes only after the server confirms that *this user* consented. With
|
|
21
|
+
# no grant file the hook makes no network call at all and exits — so a person
|
|
22
|
+
# who has not consented pays nothing and is recorded nowhere.
|
|
23
|
+
|
|
24
|
+
INPUT=$(cat)
|
|
25
|
+
|
|
26
|
+
SESSION_ID="${AGENTHUB_SESSION_ID:-}"
|
|
27
|
+
API_KEY="${AGENTHUB_API_KEY:-}"
|
|
28
|
+
API_URL="${AGENTHUB_URL:-https://agenthub.contetial.com}"
|
|
29
|
+
CONNECT_TOKEN=""
|
|
30
|
+
|
|
31
|
+
# Resolve a python interpreter. Debian/Ubuntu (the employee-daemon hosts) ship
|
|
32
|
+
# `python3` only — the unqualified `python` this used to call does not exist
|
|
33
|
+
# there, and because the whole substitution is 2>/dev/null the hook produced
|
|
34
|
+
# nothing, silently. `python` is tried first so Windows/Git-Bash checkouts keep
|
|
35
|
+
# using the interpreter that is actually on PATH there.
|
|
36
|
+
PY=$(command -v python || command -v python3)
|
|
37
|
+
[ -z "$PY" ] && exit 0
|
|
38
|
+
|
|
39
|
+
# --- Interactive (human) path: resolve a session id, consent permitting -------
|
|
40
|
+
#
|
|
41
|
+
# Emits four lines: session id, api url, connect token, and the notice JSON to
|
|
42
|
+
# print (empty when it is not time to re-announce). Prints nothing at all when
|
|
43
|
+
# capture is not authorized, which is the common case.
|
|
44
|
+
if [ -z "$SESSION_ID" ]; then
|
|
45
|
+
BOOTSTRAP=$(echo "$INPUT" | "$PY" -c "
|
|
46
|
+
import sys, json, os, time
|
|
47
|
+
|
|
48
|
+
# The grant file is written by the AgentHub MCP *only* after the server confirms
|
|
49
|
+
# this user's capture consent, and removed when consent is withdrawn. Its absence
|
|
50
|
+
# is the fast, offline, fail-closed answer: no consent on record here, so do
|
|
51
|
+
# nothing and talk to nobody.
|
|
52
|
+
grant_path = os.environ.get('AGENTHUB_CAPTURE_GRANT') or os.path.join(
|
|
53
|
+
os.path.expanduser('~'), '.claude', 'agenthub-capture.json')
|
|
54
|
+
try:
|
|
55
|
+
with open(grant_path, 'r') as f:
|
|
56
|
+
grant = json.load(f)
|
|
57
|
+
except Exception:
|
|
58
|
+
sys.exit(0)
|
|
59
|
+
|
|
60
|
+
api_url = (grant.get('api_url') or '').rstrip('/')
|
|
61
|
+
token = grant.get('connect_token') or os.environ.get('AGENTHUB_CONNECT_TOKEN') or ''
|
|
62
|
+
if not api_url or not token:
|
|
63
|
+
sys.exit(0)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
data = json.load(sys.stdin)
|
|
67
|
+
except Exception:
|
|
68
|
+
sys.exit(0)
|
|
69
|
+
|
|
70
|
+
claude_sid = data.get('session_id') or ''
|
|
71
|
+
if not claude_sid:
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
# Per-Claude-session cache, kept beside the grant rather than in /tmp so it
|
|
75
|
+
# behaves the same on the Windows workstations this path exists to serve.
|
|
76
|
+
state_dir = os.path.join(os.path.dirname(grant_path), 'agenthub-capture-state')
|
|
77
|
+
state_file = os.path.join(state_dir, claude_sid.replace(os.sep, '_') + '.json')
|
|
78
|
+
state = {}
|
|
79
|
+
try:
|
|
80
|
+
with open(state_file, 'r') as f:
|
|
81
|
+
state = json.load(f)
|
|
82
|
+
except Exception:
|
|
83
|
+
state = {}
|
|
84
|
+
|
|
85
|
+
# A refusal is remembered for the rest of the session. Without this the hook
|
|
86
|
+
# would re-ask the server on every single tool use of a session it has already
|
|
87
|
+
# been told it may not record.
|
|
88
|
+
if state.get('denied'):
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
|
|
91
|
+
notice_text = state.get('notice_text') or ''
|
|
92
|
+
session_id = state.get('session_id') or ''
|
|
93
|
+
created = False
|
|
94
|
+
|
|
95
|
+
if not session_id:
|
|
96
|
+
import urllib.request, urllib.error
|
|
97
|
+
body = json.dumps({
|
|
98
|
+
'claude_session_id': claude_sid,
|
|
99
|
+
'title': (data.get('cwd') or '').replace('\\\\', '/').rstrip('/').split('/')[-1] or 'interactive session',
|
|
100
|
+
'working_directory': data.get('cwd') or '',
|
|
101
|
+
}).encode()
|
|
102
|
+
req = urllib.request.Request(
|
|
103
|
+
api_url + '/api/session-capture/sessions',
|
|
104
|
+
data=body,
|
|
105
|
+
headers={'Content-Type': 'application/json', 'X-Connect-Token': token},
|
|
106
|
+
method='POST')
|
|
107
|
+
try:
|
|
108
|
+
with urllib.request.urlopen(req, timeout=4) as resp:
|
|
109
|
+
payload = json.loads(resp.read().decode())
|
|
110
|
+
session_id = payload.get('session_id') or ''
|
|
111
|
+
notice_text = payload.get('capture_notice') or ''
|
|
112
|
+
created = True
|
|
113
|
+
except urllib.error.HTTPError:
|
|
114
|
+
# The server declined: consent absent or withdrawn, streaming off, or
|
|
115
|
+
# the credential does not identify a person. Remember it and stay quiet.
|
|
116
|
+
try:
|
|
117
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
118
|
+
with open(state_file, 'w') as f:
|
|
119
|
+
json.dump({'denied': True}, f)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
sys.exit(0)
|
|
123
|
+
except Exception:
|
|
124
|
+
# Network trouble is transient — do not record a denial for it, and do
|
|
125
|
+
# not capture this tool call either.
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
|
|
128
|
+
if not session_id:
|
|
129
|
+
sys.exit(0)
|
|
130
|
+
|
|
131
|
+
# --- Visible capture indicator (§3.3) ---------------------------------------
|
|
132
|
+
# A person must never be recorded without an obvious signal that it is
|
|
133
|
+
# happening. The notice is printed when capture starts and re-printed on an
|
|
134
|
+
# interval, so it stays visible in a long session rather than scrolling away
|
|
135
|
+
# once. The wording comes from the server so a client cannot soften or drop it.
|
|
136
|
+
try:
|
|
137
|
+
interval = int(grant.get('notice_interval_sec') or 900)
|
|
138
|
+
except Exception:
|
|
139
|
+
interval = 900
|
|
140
|
+
# Clamped, not merely defaulted. The interval is read from a local file, and an
|
|
141
|
+
# indicator that a local setting can push out to 'never' is not an indicator.
|
|
142
|
+
# The floor keeps a long session from being all banner.
|
|
143
|
+
interval = max(60, min(interval, 1800))
|
|
144
|
+
now = int(time.time())
|
|
145
|
+
last = int(state.get('last_notice') or 0)
|
|
146
|
+
show = created or (now - last) >= interval
|
|
147
|
+
|
|
148
|
+
state_out = {'session_id': session_id, 'notice_text': notice_text,
|
|
149
|
+
'last_notice': now if show else last}
|
|
150
|
+
try:
|
|
151
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
152
|
+
with open(state_file, 'w') as f:
|
|
153
|
+
json.dump(state_out, f)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
notice_json = ''
|
|
158
|
+
if show:
|
|
159
|
+
msg = ('● RECORDING - AgentHub session capture is ON for this session (' + session_id + ') '
|
|
160
|
+
'and is sending it to ' + api_url + '. ' + notice_text +
|
|
161
|
+
' Turn it off any time: set capture consent to off in AgentHub.')
|
|
162
|
+
notice_json = json.dumps({'systemMessage': msg})
|
|
163
|
+
|
|
164
|
+
# Write through the binary buffer, not print(). On Windows, text-mode stdout
|
|
165
|
+
# translates \n to \r\n, and the shell read strips only the \n — the \r would
|
|
166
|
+
# survive into the URL and the session id, producing a request to a host that
|
|
167
|
+
# does not exist. Nothing reports an error; capture just silently never posts.
|
|
168
|
+
#
|
|
169
|
+
# NOTE for anyone editing this file: this Python source is a bash double-quoted
|
|
170
|
+
# string, so a backtick anywhere in it (even inside a comment) is a command
|
|
171
|
+
# substitution that bash runs before Python ever sees the script. One in a
|
|
172
|
+
# comment here consumed the hook's stdin and made capture silently do nothing.
|
|
173
|
+
# Never use a backtick in these blocks; the same goes for a bare dollar sign.
|
|
174
|
+
# sessionCaptureScript.test.ts enforces both.
|
|
175
|
+
out = '\n'.join([session_id, api_url, token, notice_json]) + '\n'
|
|
176
|
+
sys.stdout.buffer.write(out.encode('utf-8'))
|
|
177
|
+
" 2>/dev/null)
|
|
178
|
+
|
|
179
|
+
{ read -r BS_SESSION; read -r BS_URL; read -r BS_TOKEN; read -r BS_NOTICE; } <<EOF
|
|
180
|
+
$BOOTSTRAP
|
|
181
|
+
EOF
|
|
182
|
+
|
|
183
|
+
# Belt and braces against the \r above: a stray carriage return in the URL or
|
|
184
|
+
# the session id is invisible in logs and turns every subsequent POST into a
|
|
185
|
+
# request to a nonexistent host, so strip it here too rather than trusting the
|
|
186
|
+
# producer to have got its newlines right.
|
|
187
|
+
BS_SESSION="${BS_SESSION%$'\r'}"
|
|
188
|
+
BS_URL="${BS_URL%$'\r'}"
|
|
189
|
+
BS_TOKEN="${BS_TOKEN%$'\r'}"
|
|
190
|
+
BS_NOTICE="${BS_NOTICE%$'\r'}"
|
|
191
|
+
|
|
192
|
+
SESSION_ID="$BS_SESSION"
|
|
193
|
+
[ -n "$BS_URL" ] && API_URL="$BS_URL"
|
|
194
|
+
CONNECT_TOKEN="$BS_TOKEN"
|
|
195
|
+
|
|
196
|
+
# The indicator is the only thing this hook ever writes to stdout. Claude Code
|
|
197
|
+
# surfaces `systemMessage` to the person in the session.
|
|
198
|
+
[ -n "$BS_NOTICE" ] && echo "$BS_NOTICE"
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
# No session to record into (unset on the daemon path, or consent absent on the
|
|
202
|
+
# interactive one), or no credential — either way, capture nothing.
|
|
203
|
+
[ -z "$SESSION_ID" ] && exit 0
|
|
204
|
+
[ -z "$API_KEY" ] && [ -z "$CONNECT_TOKEN" ] && exit 0
|
|
205
|
+
|
|
206
|
+
# Re-export so the event-building block below sees the resolved values on both
|
|
207
|
+
# paths. On the daemon path these are already what they were.
|
|
208
|
+
export AGENTHUB_SESSION_ID="$SESSION_ID"
|
|
209
|
+
export AGENTHUB_URL="$API_URL"
|
|
210
|
+
export AGENTHUB_CAPTURE_CONNECT_TOKEN="$CONNECT_TOKEN"
|
|
211
|
+
|
|
212
|
+
# Use python to parse JSON and build rich event payload
|
|
213
|
+
EVENTS_JSON=$(echo "$INPUT" | "$PY" -c "
|
|
214
|
+
import sys, json, os, re
|
|
215
|
+
|
|
216
|
+
TRUNC = 8000 # 8KB max per field (captures full assistant responses)
|
|
217
|
+
TRUNC_SHORT = 1000
|
|
218
|
+
|
|
219
|
+
# Secret patterns to redact
|
|
220
|
+
SECRET_RE = re.compile(r'(api[_-]?key|token|password|secret|authorization|credential)[=:\s]*[^\s,}\"]{8,}', re.IGNORECASE)
|
|
221
|
+
|
|
222
|
+
def redact(s):
|
|
223
|
+
if not s: return s
|
|
224
|
+
return SECRET_RE.sub(lambda m: m.group().split('=')[0] + '=[REDACTED]' if '=' in m.group() else m.group().split(':')[0] + ':[REDACTED]', str(s))
|
|
225
|
+
|
|
226
|
+
def trunc(s, limit=TRUNC):
|
|
227
|
+
s = str(s) if s else ''
|
|
228
|
+
return s[:limit] + '...' if len(s) > limit else s
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
data = json.load(sys.stdin)
|
|
232
|
+
except:
|
|
233
|
+
sys.exit(0)
|
|
234
|
+
|
|
235
|
+
tool_name = data.get('tool_name', '')
|
|
236
|
+
tool_input = data.get('tool_input', {})
|
|
237
|
+
tool_response = data.get('tool_response', {})
|
|
238
|
+
transcript_path = data.get('transcript_path', '')
|
|
239
|
+
session_id = data.get('session_id', '')
|
|
240
|
+
|
|
241
|
+
if not tool_name:
|
|
242
|
+
sys.exit(0)
|
|
243
|
+
|
|
244
|
+
events = []
|
|
245
|
+
|
|
246
|
+
# --- Report Claude's session_id to server (once per session) ---
|
|
247
|
+
claude_id_file = '/tmp/agenthub-claude-sid-' + os.environ.get('AGENTHUB_SESSION_ID', 'none')
|
|
248
|
+
if session_id and not os.path.exists(claude_id_file):
|
|
249
|
+
try:
|
|
250
|
+
import urllib.request
|
|
251
|
+
req = urllib.request.Request(
|
|
252
|
+
os.environ.get('AGENTHUB_URL', 'https://agenthub.contetial.com') + '/api/sessions/' + os.environ['AGENTHUB_SESSION_ID'] + '/claude-session-id',
|
|
253
|
+
data=json.dumps({'claude_session_id': session_id}).encode(),
|
|
254
|
+
headers={'Content-Type': 'application/json',
|
|
255
|
+
'X-API-Key': os.environ.get('AGENTHUB_API_KEY', ''),
|
|
256
|
+
'X-Connect-Token': os.environ.get('AGENTHUB_CAPTURE_CONNECT_TOKEN', '')},
|
|
257
|
+
method='PATCH'
|
|
258
|
+
)
|
|
259
|
+
urllib.request.urlopen(req, timeout=2)
|
|
260
|
+
with open(claude_id_file, 'w') as f:
|
|
261
|
+
f.write(session_id)
|
|
262
|
+
except:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# --- Read transcript for new assistant/user messages ---
|
|
266
|
+
offset_file = '/tmp/agenthub-transcript-offset-' + os.environ.get('AGENTHUB_SESSION_ID', 'none')
|
|
267
|
+
last_offset = 0
|
|
268
|
+
try:
|
|
269
|
+
with open(offset_file, 'r') as f:
|
|
270
|
+
last_offset = int(f.read().strip())
|
|
271
|
+
except:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
if transcript_path and os.path.exists(transcript_path):
|
|
275
|
+
try:
|
|
276
|
+
with open(transcript_path, 'r') as f:
|
|
277
|
+
f.seek(last_offset)
|
|
278
|
+
new_lines = f.readlines()
|
|
279
|
+
new_offset = f.tell()
|
|
280
|
+
|
|
281
|
+
for line in new_lines:
|
|
282
|
+
line = line.strip()
|
|
283
|
+
if not line:
|
|
284
|
+
continue
|
|
285
|
+
try:
|
|
286
|
+
entry = json.loads(line)
|
|
287
|
+
entry_type = entry.get('type', '')
|
|
288
|
+
|
|
289
|
+
# Skip non-message entries (progress, queue-operation, etc.)
|
|
290
|
+
if entry_type not in ('user', 'assistant'):
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
# Content is nested: entry.message.role and entry.message.content
|
|
294
|
+
msg = entry.get('message', {})
|
|
295
|
+
role = msg.get('role', entry_type)
|
|
296
|
+
raw_content = msg.get('content', entry.get('content', ''))
|
|
297
|
+
content = ''
|
|
298
|
+
|
|
299
|
+
# Extract text content from various formats
|
|
300
|
+
if isinstance(raw_content, str):
|
|
301
|
+
content = raw_content
|
|
302
|
+
elif isinstance(raw_content, list):
|
|
303
|
+
parts = []
|
|
304
|
+
for block in raw_content:
|
|
305
|
+
if isinstance(block, dict) and block.get('type') == 'text':
|
|
306
|
+
parts.append(block.get('text', ''))
|
|
307
|
+
content = '\n'.join(parts)
|
|
308
|
+
|
|
309
|
+
if not content or len(content.strip()) < 2:
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
if role == 'assistant':
|
|
313
|
+
events.append({
|
|
314
|
+
'event_type': 'assistant_message',
|
|
315
|
+
'payload': json.dumps({'content': redact(trunc(content))}),
|
|
316
|
+
'token_count': 0
|
|
317
|
+
})
|
|
318
|
+
elif role == 'user':
|
|
319
|
+
events.append({
|
|
320
|
+
'event_type': 'user_message',
|
|
321
|
+
'payload': json.dumps({'content': redact(trunc(content)), 'source': 'prompt'}),
|
|
322
|
+
'token_count': 0
|
|
323
|
+
})
|
|
324
|
+
except:
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# Save offset
|
|
328
|
+
with open(offset_file, 'w') as f:
|
|
329
|
+
f.write(str(new_offset))
|
|
330
|
+
except:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
# --- Build tool-specific event ---
|
|
334
|
+
tn = tool_name.lower()
|
|
335
|
+
ti = tool_input if isinstance(tool_input, dict) else {}
|
|
336
|
+
tr = tool_response if isinstance(tool_response, dict) else {}
|
|
337
|
+
|
|
338
|
+
if tn == 'bash':
|
|
339
|
+
payload = {
|
|
340
|
+
'tool_name': 'Bash',
|
|
341
|
+
'command': redact(trunc(ti.get('command', ''), TRUNC_SHORT)),
|
|
342
|
+
'exit_code': tr.get('exit_code', tr.get('exitCode')),
|
|
343
|
+
'stdout': redact(trunc(tr.get('stdout', tr.get('output', str(tr)[:TRUNC])))),
|
|
344
|
+
}
|
|
345
|
+
events.append({'event_type': 'command_exec', 'payload': json.dumps(payload), 'token_count': 0})
|
|
346
|
+
|
|
347
|
+
elif tn == 'read':
|
|
348
|
+
payload = {
|
|
349
|
+
'tool_name': 'Read',
|
|
350
|
+
'file_path': ti.get('file_path', ''),
|
|
351
|
+
'lines_read': ti.get('limit', 0),
|
|
352
|
+
'content_preview': redact(trunc(str(tr)[:TRUNC_SHORT], TRUNC_SHORT)),
|
|
353
|
+
}
|
|
354
|
+
events.append({'event_type': 'file_read', 'payload': json.dumps(payload), 'token_count': 0})
|
|
355
|
+
|
|
356
|
+
elif tn == 'edit':
|
|
357
|
+
payload = {
|
|
358
|
+
'tool_name': 'Edit',
|
|
359
|
+
'file_path': ti.get('file_path', ''),
|
|
360
|
+
'old_string': redact(trunc(ti.get('old_string', ''), TRUNC_SHORT)),
|
|
361
|
+
'new_string': redact(trunc(ti.get('new_string', ''), TRUNC_SHORT)),
|
|
362
|
+
}
|
|
363
|
+
events.append({'event_type': 'file_edit', 'payload': json.dumps(payload), 'token_count': 0})
|
|
364
|
+
|
|
365
|
+
elif tn == 'write':
|
|
366
|
+
content = ti.get('content', '')
|
|
367
|
+
payload = {
|
|
368
|
+
'tool_name': 'Write',
|
|
369
|
+
'file_path': ti.get('file_path', ''),
|
|
370
|
+
'content_preview': redact(trunc(content, TRUNC_SHORT)),
|
|
371
|
+
}
|
|
372
|
+
events.append({'event_type': 'file_write', 'payload': json.dumps(payload), 'token_count': 0})
|
|
373
|
+
|
|
374
|
+
elif tn in ('glob', 'grep'):
|
|
375
|
+
payload = {
|
|
376
|
+
'tool_name': tool_name,
|
|
377
|
+
'pattern': ti.get('pattern', ''),
|
|
378
|
+
'path': ti.get('path', ''),
|
|
379
|
+
'result_summary': redact(trunc(str(tr), TRUNC_SHORT)),
|
|
380
|
+
}
|
|
381
|
+
events.append({'event_type': 'tool_call', 'payload': json.dumps(payload), 'token_count': 0})
|
|
382
|
+
|
|
383
|
+
else:
|
|
384
|
+
# Generic tool call
|
|
385
|
+
payload = {
|
|
386
|
+
'tool_name': tool_name,
|
|
387
|
+
'input_summary': redact(trunc(json.dumps(ti), TRUNC_SHORT)),
|
|
388
|
+
'result_summary': redact(trunc(str(tr), TRUNC_SHORT)),
|
|
389
|
+
}
|
|
390
|
+
events.append({'event_type': 'tool_call', 'payload': json.dumps(payload), 'token_count': 0})
|
|
391
|
+
|
|
392
|
+
# Output JSON for curl
|
|
393
|
+
if events:
|
|
394
|
+
print(json.dumps({'events': events}))
|
|
395
|
+
" 2>/dev/null)
|
|
396
|
+
|
|
397
|
+
# POST events if any. Both credential headers are sent; the empty one reads as
|
|
398
|
+
# absent server-side, so one curl serves the daemon path (API key) and the
|
|
399
|
+
# interactive path (connect token) without branching.
|
|
400
|
+
if [ -n "$EVENTS_JSON" ] && [ "$EVENTS_JSON" != "null" ]; then
|
|
401
|
+
curl -s --max-time 3 \
|
|
402
|
+
-X POST \
|
|
403
|
+
-H "Content-Type: application/json" \
|
|
404
|
+
-H "X-API-Key: $API_KEY" \
|
|
405
|
+
-H "X-Connect-Token: $CONNECT_TOKEN" \
|
|
406
|
+
-d "$EVENTS_JSON" \
|
|
407
|
+
"${API_URL}/api/sessions/${SESSION_ID}/events" > /dev/null 2>&1 &
|
|
408
|
+
fi
|
|
409
|
+
|
|
410
|
+
exit 0
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-capture wiring for daemon shifts (add-multiplayer-sessions §1.5).
|
|
3
|
+
*
|
|
4
|
+
* The capture hook (`session-capture.sh`) has always existed but has never
|
|
5
|
+
* produced anything for the live fleet, because two halves were missing:
|
|
6
|
+
*
|
|
7
|
+
* 1. Nothing created a coding session, so `AGENTHUB_SESSION_ID` was never set
|
|
8
|
+
* and the hook exited at its first guard (`session-capture.sh:14`).
|
|
9
|
+
* 2. Nothing registered the hook for the spawned Claude process — the MCP
|
|
10
|
+
* installers cover only the inbox and activity hooks.
|
|
11
|
+
*
|
|
12
|
+
* This module supplies both, and is the single place the daemon touches for
|
|
13
|
+
* capture concerns. Everything here is **fail-soft**: a capture failure logs and
|
|
14
|
+
* returns a null/empty result so the shift still runs. A shift must never die
|
|
15
|
+
* for an observability reason.
|
|
16
|
+
*
|
|
17
|
+
* Gated on `AGENTHUB_SESSION_STREAMING_ENABLED` (default off, matching the
|
|
18
|
+
* server-side flag added in §1.2). With the flag off, `beginShiftCapture`
|
|
19
|
+
* performs no network call, no filesystem access, and returns `null`, and
|
|
20
|
+
* `applyCaptureEnv` leaves the child env untouched — the shift is byte-for-byte
|
|
21
|
+
* what it is today.
|
|
22
|
+
*/
|
|
23
|
+
/** Minimal structural view of the parts of `fetch` this module uses. */
|
|
24
|
+
export interface CaptureResponse {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
status: number;
|
|
27
|
+
json(): Promise<unknown>;
|
|
28
|
+
}
|
|
29
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<CaptureResponse>;
|
|
30
|
+
/** Default bound on every capture HTTP call. Shift startup must not block on it. */
|
|
31
|
+
export declare const CAPTURE_TIMEOUT_MS = 5000;
|
|
32
|
+
/**
|
|
33
|
+
* Identifies our PostToolUse entry inside a settings object so repeated merges
|
|
34
|
+
* never duplicate it (same marker discipline as activityHookInstall.ts /
|
|
35
|
+
* inboxHookInstall.ts, which key off the CLI filename in the command string).
|
|
36
|
+
*/
|
|
37
|
+
export declare const CAPTURE_HOOK_MARKER = "session-capture.sh";
|
|
38
|
+
/** Reason recorded on the session when a shift ends. */
|
|
39
|
+
export type ShiftOutcome = "completed" | "failed" | "spawn_error" | "daemon_shutdown";
|
|
40
|
+
/**
|
|
41
|
+
* Live session streaming kill switch. Default **off** — an unset, empty, or
|
|
42
|
+
* unrecognised value means disabled, so the producer stays silent unless an
|
|
43
|
+
* operator deliberately turns it on.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sessionStreamingEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Merge the capture PostToolUse hook into a settings object **without**
|
|
48
|
+
* discarding anything already there: other hook events, other PostToolUse
|
|
49
|
+
* entries, and every non-`hooks` key are preserved. Idempotent — a settings
|
|
50
|
+
* object that already carries the capture hook is returned unchanged.
|
|
51
|
+
*
|
|
52
|
+
* Returns a new object; the input is never mutated.
|
|
53
|
+
*/
|
|
54
|
+
export declare function mergeCaptureHook(base: Record<string, unknown>, command: string, timeoutSeconds?: number): Record<string, unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Locate `session-capture.sh` relative to the compiled module directory.
|
|
57
|
+
* Ordered most-specific first:
|
|
58
|
+
* 1. `dist/session-capture.sh` — the published npm package (build copies it)
|
|
59
|
+
* 2. `mcp-server/session-capture.sh` — running from a source checkout's dist/
|
|
60
|
+
* 3. `<repo>/.claude/hooks/session-capture.sh` — repo checkout fallback
|
|
61
|
+
* Returns `null` when the script cannot be found; the caller then runs the shift
|
|
62
|
+
* without capture rather than inventing a degraded inline copy.
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolveCaptureHookScript(moduleDir: string, exists?: (p: string) => boolean): string | null;
|
|
65
|
+
/** Shell command Claude Code runs for the PostToolUse capture hook. */
|
|
66
|
+
export declare function captureHookCommand(scriptPath: string): string;
|
|
67
|
+
export interface CaptureSettingsDeps {
|
|
68
|
+
exists?: (p: string) => boolean;
|
|
69
|
+
writeFile?: (p: string, data: string) => void;
|
|
70
|
+
log?: (msg: string) => void;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build a memoised factory that materialises the per-daemon-process settings
|
|
74
|
+
* file registering the capture hook, and returns its path ("" when the hook
|
|
75
|
+
* script is unavailable or the write fails).
|
|
76
|
+
*
|
|
77
|
+
* **Why a dedicated file rather than editing `~/.claude/settings.json`:** the
|
|
78
|
+
* daemon already passes a settings file to `run-claude.sh` (arg 4 → `claude
|
|
79
|
+
* --settings`). Writing a fresh file scoped to the spawned process means no
|
|
80
|
+
* user-owned or workspace-owned settings file is ever rewritten, so there is
|
|
81
|
+
* nothing to clobber — a strictly stronger guarantee than merging in place. The
|
|
82
|
+
* file contains only our hook, so hook entries from the user's own settings
|
|
83
|
+
* sources are neither dropped nor double-registered. `mergeCaptureHook` is used
|
|
84
|
+
* (rather than a literal) so the same non-destructive merge is exercised here
|
|
85
|
+
* and reusable by the in-place installer Phase 3 will need for human sessions.
|
|
86
|
+
*/
|
|
87
|
+
export declare function createCaptureSettingsFactory(moduleDir: string, settingsPath: string, deps?: CaptureSettingsDeps): () => string;
|
|
88
|
+
export interface SessionApiOptions {
|
|
89
|
+
apiUrl: string;
|
|
90
|
+
apiKey: string;
|
|
91
|
+
timeoutMs?: number;
|
|
92
|
+
fetchImpl?: FetchLike;
|
|
93
|
+
log?: (msg: string) => void;
|
|
94
|
+
}
|
|
95
|
+
export interface OpenSessionOptions extends SessionApiOptions {
|
|
96
|
+
agentId: string;
|
|
97
|
+
title?: string;
|
|
98
|
+
workingDirectory?: string;
|
|
99
|
+
projectId?: string;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Create the coding session that a shift will be captured under.
|
|
103
|
+
*
|
|
104
|
+
* Uses `POST /api/sessions` (`handleCreateSession`) — the plain record-creating
|
|
105
|
+
* endpoint. Deliberately **not** `POST /api/sessions/start`: that one also calls
|
|
106
|
+
* `Orchestrator().DispatchSession`, i.e. it asks a daemon to start a shift. The
|
|
107
|
+
* daemon calling it would dispatch a second shift for work it is already doing.
|
|
108
|
+
*
|
|
109
|
+
* Never throws and never blocks beyond `timeoutMs`. Returns the session id, or
|
|
110
|
+
* `null` when the session could not be created — the caller then runs the shift
|
|
111
|
+
* uncaptured.
|
|
112
|
+
*/
|
|
113
|
+
export declare function openCaptureSession(opts: OpenSessionOptions): Promise<string | null>;
|
|
114
|
+
/**
|
|
115
|
+
* Close a captured session. Uses `POST /api/sessions/{id}/terminate`, the only
|
|
116
|
+
* endpoint that moves a session to a terminal state and stamps a reason.
|
|
117
|
+
* Never throws — a shift that already finished must not fail on cleanup.
|
|
118
|
+
*/
|
|
119
|
+
export declare function closeCaptureSession(sessionId: string, reason: ShiftOutcome, opts: SessionApiOptions): Promise<boolean>;
|
|
120
|
+
/**
|
|
121
|
+
* Remove the hook's per-session scratch markers.
|
|
122
|
+
*
|
|
123
|
+
* `session-capture.sh` keys both of its markers by `AGENTHUB_SESSION_ID`
|
|
124
|
+
* (`/tmp/agenthub-transcript-offset-<id>`, `/tmp/agenthub-claude-sid-<id>`), so
|
|
125
|
+
* concurrent shifts on one host never collide — each shift gets its own session
|
|
126
|
+
* id. They are, however, never cleaned up by the script itself, so the daemon
|
|
127
|
+
* removes them when the session closes. Best-effort: absent files are fine.
|
|
128
|
+
*/
|
|
129
|
+
export declare function cleanupCaptureMarkers(sessionId: string, tmpDir?: string, removeFile?: (p: string) => void): void;
|
|
130
|
+
/** A shift's live capture wiring. `settingsFile` is "" when no hook was registered. */
|
|
131
|
+
export interface ShiftCapture {
|
|
132
|
+
sessionId: string;
|
|
133
|
+
settingsFile: string;
|
|
134
|
+
}
|
|
135
|
+
export interface BeginShiftCaptureOptions extends OpenSessionOptions {
|
|
136
|
+
env?: NodeJS.ProcessEnv;
|
|
137
|
+
/** Memoised factory from `createCaptureSettingsFactory`. */
|
|
138
|
+
ensureSettingsFile: () => string;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Open a session and register the capture hook for one shift.
|
|
142
|
+
*
|
|
143
|
+
* Returns `null` — meaning "run the shift uncaptured" — when streaming is
|
|
144
|
+
* disabled or the session could not be created. With streaming disabled this
|
|
145
|
+
* returns before any I/O of any kind.
|
|
146
|
+
*/
|
|
147
|
+
export declare function beginShiftCapture(opts: BeginShiftCaptureOptions): Promise<ShiftCapture | null>;
|
|
148
|
+
/** Close a shift's session and clean up its hook markers. Never throws. */
|
|
149
|
+
export declare function endShiftCapture(capture: ShiftCapture | null, outcome: ShiftOutcome, opts: SessionApiOptions & {
|
|
150
|
+
tmpDir?: string;
|
|
151
|
+
}): Promise<void>;
|
|
152
|
+
/**
|
|
153
|
+
* Stamp the capture variables onto a shift's child environment.
|
|
154
|
+
*
|
|
155
|
+
* `AGENTHUB_SESSION_ID` is what un-gates `session-capture.sh`; the URL and key
|
|
156
|
+
* are re-asserted because the hook reads them from its own environment and
|
|
157
|
+
* would otherwise silently fall back to the compiled-in default host.
|
|
158
|
+
*
|
|
159
|
+
* With `capture === null` the env object is returned untouched — this is the
|
|
160
|
+
* off-path guarantee, expressed as a single early return.
|
|
161
|
+
*/
|
|
162
|
+
export declare function applyCaptureEnv(env: Record<string, string>, capture: ShiftCapture | null, apiUrl: string, apiKey: string): Record<string, string>;
|
|
163
|
+
//# sourceMappingURL=sessionCapture.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sessionCapture.d.ts","sourceRoot":"","sources":["../src/sessionCapture.ts"],"names":[],"mappings":"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"}
|