slaunt 0.1.17 → 0.1.19
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 +17 -122
- package/bin/slaunt-hooks.js +99 -0
- package/package.json +9 -59
- package/release/SlauntKillBar.zip +0 -0
- package/release/manifest.json +7 -0
- package/scripts/killbar-portability-check.js +67 -0
- package/scripts/package-killbar-macos.sh +38 -0
- package/src/approval-manager.js +53 -0
- package/src/cli-auth.js +230 -0
- package/src/compact-wordmark.js +13 -0
- package/src/desktop/broker-daemon.js +18 -0
- package/src/desktop/broker-grants.js +37 -0
- package/src/desktop/broker-hook-command.js +23 -0
- package/src/desktop/broker-keychain.js +29 -0
- package/src/desktop/broker-launch-agent.js +34 -0
- package/src/desktop/broker-service.js +40 -0
- package/src/desktop/chat-capture-command.js +7 -0
- package/src/desktop/chat-hook-capture.js +445 -0
- package/src/desktop/claude-lifecycle.js +143 -0
- package/src/desktop/client-identity.js +19 -0
- package/src/desktop/native-claude-broker.js +34 -0
- package/src/desktop/native-claude-connection.js +42 -0
- package/src/desktop/tool-guard-command.js +13 -0
- package/src/desktop/tool-guard.js +225 -0
- package/src/discovery/catalog.js +734 -0
- package/src/full-screen.js +86 -0
- package/src/helper-release.js +27 -0
- package/src/hidden-oauth.js +70 -0
- package/src/independent-grants.js +38 -0
- package/src/install-flow.js +71 -0
- package/src/killbar-background-install.js +47 -0
- package/src/killbar-enrollment.js +74 -0
- package/src/killbar-launch-agent.js +10 -0
- package/src/mcp-identity.js +6 -0
- package/src/native-config.js +150 -0
- package/src/native-oauth.js +33 -0
- package/src/oauth-cohort.js +44 -0
- package/src/pty-wrapper.py +57 -0
- package/src/read-json.js +8 -0
- package/src/setup/client-installer.js +93 -0
- package/src/setup/codex-guard-supervisor.js +34 -0
- package/src/setup/hook-installer.js +496 -0
- package/src/setup/managed-policy.js +26 -0
- package/src/setup/mcp-config-installer.js +204 -0
- package/src/setup-flow.js +66 -0
- package/src/tui.js +205 -0
- package/test/approval-manager.test.js +27 -0
- package/test/broker-grants.test.js +14 -0
- package/test/broker-keychain.test.js +14 -0
- package/test/broker-launch-agent.test.js +15 -0
- package/test/broker-service.test.js +42 -0
- package/test/cli-auth-recovery.test.js +15 -0
- package/test/codex-guard-supervisor.test.js +25 -0
- package/test/full-screen.test.js +49 -0
- package/test/helper-release.test.js +22 -0
- package/test/hidden-oauth.test.js +56 -0
- package/test/hook-installer.test.js +232 -0
- package/test/independent-grants.test.js +16 -0
- package/test/install-flow.test.js +24 -0
- package/test/killbar-background-install.test.js +15 -0
- package/test/killbar-enrollment.test.js +115 -0
- package/test/killbar-launch-agent.test.js +12 -0
- package/test/killbar-portability.test.js +17 -0
- package/test/managed-policy.test.js +18 -0
- package/test/native-claude-broker.test.js +19 -0
- package/test/native-claude-connection.test.js +17 -0
- package/test/native-claude-hooks.test.js +9 -0
- package/test/native-config.test.js +3 -0
- package/test/native-integration.cjs +29 -0
- package/test/native-oauth.test.js +23 -0
- package/test/oauth-cohort.test.js +26 -0
- package/test/tui.test.js +100 -0
- package/LICENSE +0 -201
- package/dist/audit/risk-engine.d.ts +0 -4
- package/dist/audit/risk-engine.d.ts.map +0 -1
- package/dist/audit/risk-engine.js +0 -174
- package/dist/audit/risk-engine.js.map +0 -1
- package/dist/audit/run.d.ts +0 -18
- package/dist/audit/run.d.ts.map +0 -1
- package/dist/audit/run.js +0 -68
- package/dist/audit/run.js.map +0 -1
- package/dist/classification/builtin-catalog.d.ts +0 -3
- package/dist/classification/builtin-catalog.d.ts.map +0 -1
- package/dist/classification/builtin-catalog.js +0 -283
- package/dist/classification/builtin-catalog.js.map +0 -1
- package/dist/classification/catalog.d.ts +0 -10
- package/dist/classification/catalog.d.ts.map +0 -1
- package/dist/classification/catalog.js +0 -194
- package/dist/classification/catalog.js.map +0 -1
- package/dist/classification/classifier.d.ts +0 -3
- package/dist/classification/classifier.d.ts.map +0 -1
- package/dist/classification/classifier.js +0 -64
- package/dist/classification/classifier.js.map +0 -1
- package/dist/classification/public-catalog-config.d.ts +0 -3
- package/dist/classification/public-catalog-config.d.ts.map +0 -1
- package/dist/classification/public-catalog-config.js +0 -5
- package/dist/classification/public-catalog-config.js.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -239
- package/dist/cli.js.map +0 -1
- package/dist/config/discover.d.ts +0 -8
- package/dist/config/discover.d.ts.map +0 -1
- package/dist/config/discover.js +0 -163
- package/dist/config/discover.js.map +0 -1
- package/dist/config/install.d.ts +0 -10
- package/dist/config/install.d.ts.map +0 -1
- package/dist/config/install.js +0 -143
- package/dist/config/install.js.map +0 -1
- package/dist/config/paths.d.ts +0 -24
- package/dist/config/paths.d.ts.map +0 -1
- package/dist/config/paths.js +0 -90
- package/dist/config/paths.js.map +0 -1
- package/dist/demo.d.ts +0 -4
- package/dist/demo.d.ts.map +0 -1
- package/dist/demo.js +0 -99
- package/dist/demo.js.map +0 -1
- package/dist/explanation/local.d.ts +0 -24
- package/dist/explanation/local.d.ts.map +0 -1
- package/dist/explanation/local.js +0 -696
- package/dist/explanation/local.js.map +0 -1
- package/dist/mcp/introspect.d.ts +0 -18
- package/dist/mcp/introspect.d.ts.map +0 -1
- package/dist/mcp/introspect.js +0 -236
- package/dist/mcp/introspect.js.map +0 -1
- package/dist/mcp/oauth.d.ts +0 -14
- package/dist/mcp/oauth.d.ts.map +0 -1
- package/dist/mcp/oauth.js +0 -257
- package/dist/mcp/oauth.js.map +0 -1
- package/dist/presentation/plain-language.d.ts +0 -12
- package/dist/presentation/plain-language.d.ts.map +0 -1
- package/dist/presentation/plain-language.js +0 -120
- package/dist/presentation/plain-language.js.map +0 -1
- package/dist/privacy/redaction.d.ts +0 -2
- package/dist/privacy/redaction.d.ts.map +0 -1
- package/dist/privacy/redaction.js +0 -50
- package/dist/privacy/redaction.js.map +0 -1
- package/dist/report/html.d.ts +0 -4
- package/dist/report/html.d.ts.map +0 -1
- package/dist/report/html.js +0 -101
- package/dist/report/html.js.map +0 -1
- package/dist/tui/analysis-path.d.ts +0 -17
- package/dist/tui/analysis-path.d.ts.map +0 -1
- package/dist/tui/analysis-path.js +0 -112
- package/dist/tui/analysis-path.js.map +0 -1
- package/dist/tui/render.d.ts +0 -4
- package/dist/tui/render.d.ts.map +0 -1
- package/dist/tui/render.js +0 -262
- package/dist/tui/render.js.map +0 -1
- package/dist/tui/splash.d.ts +0 -15
- package/dist/tui/splash.d.ts.map +0 -1
- package/dist/tui/splash.js +0 -130
- package/dist/tui/splash.js.map +0 -1
- package/dist/tui/stream.d.ts +0 -6
- package/dist/tui/stream.d.ts.map +0 -1
- package/dist/tui/stream.js +0 -56
- package/dist/tui/stream.js.map +0 -1
- package/dist/types.d.ts +0 -132
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js +0 -8
- package/dist/version.js.map +0 -1
- package/models/APACHE-2.0.txt +0 -202
- package/models/MODEL.json +0 -29
- package/models/NOTICE.md +0 -23
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Codex JSONL is an unstable local format. Only this explicit, completed visible
|
|
4
|
+
// message shape is understood. Never recursively search arbitrary envelopes.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const net = require('node:net');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { createHash } = require('node:crypto');
|
|
10
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
11
|
+
const EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt']);
|
|
12
|
+
const PHASES = new Set(['commentary', 'final_answer']);
|
|
13
|
+
const MAX_IPC_BYTES = 128 * 1024;
|
|
14
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
15
|
+
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256
|
|
17
|
+
&& !/[\x00-\x1f\x7f]/.test(value);
|
|
18
|
+
const rootPath = () => {
|
|
19
|
+
if (!process.getuid || process.platform === 'win32') throw new Error('capture-platform-unsupported');
|
|
20
|
+
return `/tmp/slaunt-capture-${process.getuid()}`;
|
|
21
|
+
};
|
|
22
|
+
function ensureRoot(root) {
|
|
23
|
+
try { fs.mkdirSync(root, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
24
|
+
const stat = fs.lstatSync(root);
|
|
25
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o077)) {
|
|
26
|
+
throw new Error('unsafe-capture-directory');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function socketPath(root, sessionId, turnId) {
|
|
30
|
+
if (!validId(sessionId) || !validId(turnId)) throw new Error('invalid-capture-binding');
|
|
31
|
+
const address = path.join(root, `${hash(JSON.stringify([sessionId, turnId])).slice(0, 40)}.sock`);
|
|
32
|
+
if (Buffer.byteLength(address) > 100) throw new Error('capture-socket-path-too-long');
|
|
33
|
+
return address;
|
|
34
|
+
}
|
|
35
|
+
function requestFromHook(input) {
|
|
36
|
+
const event = input?.hook_event_name ?? input?.event_name;
|
|
37
|
+
if (!EVENTS.has(event) || !validId(input?.session_id) || !validId(input?.turn_id)
|
|
38
|
+
|| typeof input.transcript_path !== 'string' || !path.isAbsolute(input.transcript_path)
|
|
39
|
+
|| input.transcript_path.length > 4096 || /[\x00-\x1f\x7f]/.test(input.transcript_path)) {
|
|
40
|
+
throw new Error('invalid-capture-event');
|
|
41
|
+
}
|
|
42
|
+
// The native hook may contain prompts, tool arguments and results. None of
|
|
43
|
+
// those are retained; the short-lived command reads only the allowlisted shape.
|
|
44
|
+
return { event_name: event, session_id: input.session_id, turn_id: input.turn_id,
|
|
45
|
+
transcript_path: input.transcript_path };
|
|
46
|
+
}
|
|
47
|
+
function parseVisibleRecord(record, state) {
|
|
48
|
+
if (record?.type === 'session_meta') {
|
|
49
|
+
if (state.sawSession || record.payload?.id !== state.threadId) throw new Error('capture-transcript-identity-mismatch');
|
|
50
|
+
state.sawSession = true;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (!state.sawSession) throw new Error('capture-transcript-identity-missing');
|
|
54
|
+
if (record?.type === 'turn_context') {
|
|
55
|
+
state.currentTurn = validId(record.payload?.turn_id) ? record.payload.turn_id : null;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const message = record?.payload;
|
|
59
|
+
if ((!state.catchUp && state.currentTurn !== state.turnId) || record.type !== 'response_item'
|
|
60
|
+
|| message?.type !== 'message' || message.role !== 'assistant') return null;
|
|
61
|
+
// Missing phase on older visible-message records is safe to keep unknown.
|
|
62
|
+
// Explicit unknown/private channels or phases are never reclassified.
|
|
63
|
+
const channels = { commentary: 'commentary', final: 'final_answer', final_answer: 'final_answer' };
|
|
64
|
+
const channelPhase = Object.hasOwn(channels, message.channel) ? channels[message.channel] : null;
|
|
65
|
+
if ((message.phase != null && !PHASES.has(message.phase))
|
|
66
|
+
|| (message.channel != null && !channelPhase)
|
|
67
|
+
|| (message.phase != null && channelPhase && message.phase !== channelPhase)
|
|
68
|
+
|| (message.recipient !== undefined && message.recipient !== 'all')
|
|
69
|
+
|| message.status !== 'completed' || !validId(message.id)
|
|
70
|
+
|| !Array.isArray(message.content) || !message.content.length
|
|
71
|
+
|| message.content.some(part => part?.type !== 'output_text' || typeof part.text !== 'string')) return null;
|
|
72
|
+
const content = message.content.map(part => part.text).join('');
|
|
73
|
+
if (!content || Buffer.byteLength(JSON.stringify(content)) > 120 * 1024) throw new Error('capture-message-too-large');
|
|
74
|
+
return {
|
|
75
|
+
event_id: `codex-visible:${hash(JSON.stringify([state.threadId, state.currentTurn, message.id]))}`,
|
|
76
|
+
role: 'assistant', content,
|
|
77
|
+
...((message.phase ?? channelPhase) ? { phase: message.phase ?? channelPhase } : {}),
|
|
78
|
+
...(typeof record.timestamp === 'string' && Number.isFinite(Date.parse(record.timestamp))
|
|
79
|
+
? { occurred_at: new Date(record.timestamp).toISOString() } : {})
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// All path components below the configured home, including sessionsRoot, must
|
|
84
|
+
// remain owned directories without symlinks. O_NOFOLLOW + fstat binds the open
|
|
85
|
+
// regular file to the inode validated before opening it.
|
|
86
|
+
function openTranscript(file, sessionsRoot, homeDir) {
|
|
87
|
+
const root = path.resolve(sessionsRoot);
|
|
88
|
+
const home = path.resolve(homeDir);
|
|
89
|
+
const resolved = path.resolve(file);
|
|
90
|
+
const relative = path.relative(root, resolved);
|
|
91
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || path.extname(resolved) !== '.jsonl'
|
|
92
|
+
|| (root !== home && (!root.startsWith(`${home}${path.sep}`)))) throw new Error('unsafe-capture-transcript');
|
|
93
|
+
let current = home;
|
|
94
|
+
for (const part of ['', ...path.relative(home, path.dirname(resolved)).split(path.sep)]) {
|
|
95
|
+
current = part ? path.join(current, part) : current;
|
|
96
|
+
const stat = fs.lstatSync(current);
|
|
97
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o022)) {
|
|
98
|
+
throw new Error('unsafe-capture-transcript');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const expected = fs.lstatSync(resolved);
|
|
102
|
+
if (!expected.isFile() || expected.isSymbolicLink() || expected.uid !== process.getuid()
|
|
103
|
+
|| (expected.mode & 0o022) || expected.nlink !== 1 || expected.size > 1024 * 1024 * 1024) {
|
|
104
|
+
throw new Error('unsafe-capture-transcript');
|
|
105
|
+
}
|
|
106
|
+
const fd = fs.openSync(resolved, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
107
|
+
try {
|
|
108
|
+
const actual = fs.fstatSync(fd);
|
|
109
|
+
if (!actual.isFile() || actual.ino !== expected.ino || actual.dev !== expected.dev || actual.uid !== process.getuid()
|
|
110
|
+
|| (actual.mode & 0o022) || actual.nlink !== 1) {
|
|
111
|
+
throw new Error('unsafe-capture-transcript');
|
|
112
|
+
}
|
|
113
|
+
// Recheck directory ancestry after opening to reject concurrent replacement.
|
|
114
|
+
if (fs.realpathSync(resolved) !== resolved) throw new Error('unsafe-capture-transcript');
|
|
115
|
+
return { fd, stat: actual };
|
|
116
|
+
} catch (error) { fs.closeSync(fd); throw error; }
|
|
117
|
+
}
|
|
118
|
+
function acknowledged(response) {
|
|
119
|
+
return !response?.isError && response?.structuredContent?.ok === true
|
|
120
|
+
&& response.structuredContent.status === 'captured_or_duplicate';
|
|
121
|
+
}
|
|
122
|
+
function createTranscriptReader({ client, threadId, turnId, sessionsRoot, homeDir, cursor = null, onCheckpoint = async () => {} }) {
|
|
123
|
+
const state = { offset: 0, sawSession: false, currentTurn: null, skipping: false, inode: null,
|
|
124
|
+
...(cursor ?? {}), threadId, turnId, catchUp: Boolean(cursor) };
|
|
125
|
+
let file = null;
|
|
126
|
+
return {
|
|
127
|
+
get offset() { return state.offset; },
|
|
128
|
+
snapshot() { return Object.fromEntries(['offset', 'sawSession', 'currentTurn', 'skipping', 'inode'].map(key => [key, state[key]])); },
|
|
129
|
+
async scan(transcriptPath, isCurrent = () => true) {
|
|
130
|
+
if (file && file !== transcriptPath) throw new Error('capture-transcript-path-changed');
|
|
131
|
+
file = transcriptPath;
|
|
132
|
+
const { fd, stat } = openTranscript(file, sessionsRoot, homeDir);
|
|
133
|
+
try {
|
|
134
|
+
const inode = `${stat.dev}:${stat.ino}`;
|
|
135
|
+
if ((state.inode && state.inode !== inode) || stat.size < state.offset) throw new Error('capture-transcript-replaced');
|
|
136
|
+
state.inode = inode;
|
|
137
|
+
const initialOffset = state.offset;
|
|
138
|
+
const buffer = Buffer.alloc(Math.min(MAX_SCAN_BYTES, stat.size - state.offset));
|
|
139
|
+
const length = fs.readSync(fd, buffer, 0, buffer.length, state.offset);
|
|
140
|
+
let position = 0;
|
|
141
|
+
let captured = 0;
|
|
142
|
+
while (position < length && captured < 32 && isCurrent()) {
|
|
143
|
+
const newline = buffer.indexOf(10, position);
|
|
144
|
+
if (newline < 0) {
|
|
145
|
+
if (length - position >= MAX_LINE_BYTES || state.skipping) {
|
|
146
|
+
state.currentTurn = null;
|
|
147
|
+
state.skipping = true;
|
|
148
|
+
state.offset += length - position;
|
|
149
|
+
}
|
|
150
|
+
break; // A partial JSONL append remains unacknowledged.
|
|
151
|
+
}
|
|
152
|
+
const bytes = newline + 1 - position;
|
|
153
|
+
if (state.skipping || bytes > MAX_LINE_BYTES) {
|
|
154
|
+
state.currentTurn = null;
|
|
155
|
+
state.skipping = false;
|
|
156
|
+
position = newline + 1;
|
|
157
|
+
state.offset += bytes;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const line = buffer.subarray(position, newline).toString('utf8');
|
|
161
|
+
let record;
|
|
162
|
+
try { record = JSON.parse(line); }
|
|
163
|
+
catch { throw new Error('capture-transcript-format-unsupported'); }
|
|
164
|
+
const event = parseVisibleRecord(record, state);
|
|
165
|
+
if (event) {
|
|
166
|
+
// This selector comes from the native MCP lifecycle request, never
|
|
167
|
+
// from transcript text, the hook command, or the last active chat.
|
|
168
|
+
const response = await client.callTool({ name: 'chat_capture', arguments: event,
|
|
169
|
+
_meta: { threadId } }, undefined, { timeout: 5000 });
|
|
170
|
+
if (!isCurrent()) throw new Error('capture-binding-stale');
|
|
171
|
+
if (!acknowledged(response)) throw new Error('capture-upstream-unavailable');
|
|
172
|
+
captured += 1;
|
|
173
|
+
}
|
|
174
|
+
// Never commit the source cursor until capture has been acknowledged.
|
|
175
|
+
state.offset += bytes;
|
|
176
|
+
position = newline + 1;
|
|
177
|
+
if (event) await onCheckpoint();
|
|
178
|
+
}
|
|
179
|
+
return { captured, pending: state.offset < stat.size,
|
|
180
|
+
backlog: buffer.length === MAX_SCAN_BYTES && state.offset > initialOffset && state.offset < stat.size };
|
|
181
|
+
} finally { fs.closeSync(fd); }
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const SAFE_ERRORS = new Set(['capture-transcript-identity-mismatch', 'capture-transcript-identity-missing',
|
|
187
|
+
'unsafe-capture-transcript', 'capture-transcript-path-changed', 'capture-transcript-replaced',
|
|
188
|
+
'capture-transcript-format-unsupported', 'capture-message-too-large', 'capture-upstream-unavailable',
|
|
189
|
+
'capture-binding-stale', 'capture-busy', 'capture-backlog-remains', 'capture-broker-unavailable']);
|
|
190
|
+
function safeError(error) { return SAFE_ERRORS.has(error?.message) ? error.message : 'capture-unavailable'; }
|
|
191
|
+
function validCursor(cursor, previous) {
|
|
192
|
+
return cursor && Object.keys(cursor).sort().join(',') === 'currentTurn,inode,offset,sawSession,skipping'
|
|
193
|
+
&& Number.isSafeInteger(cursor.offset) && cursor.offset >= (previous?.offset ?? 0)
|
|
194
|
+
&& cursor.offset <= 1024 * 1024 * 1024
|
|
195
|
+
&& typeof cursor.sawSession === 'boolean' && typeof cursor.skipping === 'boolean'
|
|
196
|
+
&& (cursor.currentTurn === null || validId(cursor.currentTurn))
|
|
197
|
+
&& (cursor.inode === null || /^[0-9]+:[0-9]+$/.test(cursor.inode))
|
|
198
|
+
&& (!previous?.inode || previous.inode === cursor.inode);
|
|
199
|
+
}
|
|
200
|
+
function validUpload(event) {
|
|
201
|
+
return event && Object.keys(event).every(key => ['event_id', 'role', 'phase', 'content', 'occurred_at'].includes(key))
|
|
202
|
+
&& /^codex-visible:[a-f0-9]{64}$/.test(event.event_id) && event.role === 'assistant'
|
|
203
|
+
&& typeof event.content === 'string' && event.content.length > 0
|
|
204
|
+
&& Buffer.byteLength(JSON.stringify(event.content)) <= 120 * 1024
|
|
205
|
+
&& (event.phase === undefined || PHASES.has(event.phase))
|
|
206
|
+
&& (event.occurred_at === undefined || (typeof event.occurred_at === 'string' && Number.isFinite(Date.parse(event.occurred_at))));
|
|
207
|
+
}
|
|
208
|
+
async function clearStaleSocket(address) {
|
|
209
|
+
let previous;
|
|
210
|
+
try { previous = fs.lstatSync(address); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
211
|
+
if (!previous.isSocket() || previous.uid !== process.getuid()) throw new Error('unsafe-capture-socket');
|
|
212
|
+
await new Promise((resolve, reject) => {
|
|
213
|
+
const probe = net.createConnection(address);
|
|
214
|
+
probe.setTimeout(250, () => { probe.destroy(); reject(new Error('capture-broker-busy')); });
|
|
215
|
+
probe.once('connect', () => { probe.destroy(); reject(new Error('capture-broker-busy')); });
|
|
216
|
+
probe.once('error', error => {
|
|
217
|
+
if (['ECONNREFUSED', 'ENOENT'].includes(error.code)) resolve(); else reject(new Error('capture-broker-busy'));
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
const current = fs.lstatSync(address);
|
|
221
|
+
if (current.ino !== previous.ino || current.dev !== previous.dev) throw new Error('unsafe-capture-socket');
|
|
222
|
+
fs.unlinkSync(address);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// This is only an authenticated upload route on the existing MCP broker. It
|
|
226
|
+
// never opens transcripts, scans files, schedules capture, or polls for text.
|
|
227
|
+
function createHookCaptureServer({ client, root = rootPath() }) {
|
|
228
|
+
const bindings = new Map();
|
|
229
|
+
const pendingBindings = new Map();
|
|
230
|
+
const threads = new Map();
|
|
231
|
+
const sockets = new Set();
|
|
232
|
+
let closed = false;
|
|
233
|
+
function dispose(record) {
|
|
234
|
+
record.closed = true;
|
|
235
|
+
record.server.close();
|
|
236
|
+
try { if (fs.lstatSync(record.address).ino === record.inode) fs.unlinkSync(record.address); } catch {}
|
|
237
|
+
bindings.delete(record.address);
|
|
238
|
+
if (![...bindings.values()].some(binding => binding.threadId === record.threadId)) threads.delete(record.threadId);
|
|
239
|
+
}
|
|
240
|
+
async function bindInternal(sessionId, { turnId, threadId }) {
|
|
241
|
+
if (closed || !validId(threadId)) throw new Error('capture-binding-stale');
|
|
242
|
+
const address = socketPath(root, sessionId, turnId);
|
|
243
|
+
const existing = bindings.get(address);
|
|
244
|
+
if (existing) {
|
|
245
|
+
if (existing.threadId !== threadId) throw new Error('capture-binding-stale');
|
|
246
|
+
return existing.ready;
|
|
247
|
+
}
|
|
248
|
+
if (bindings.size >= 128) {
|
|
249
|
+
const oldest = [...bindings.values()].find(record => !threads.get(record.threadId)?.busy);
|
|
250
|
+
if (!oldest) throw new Error('capture-busy');
|
|
251
|
+
dispose(oldest);
|
|
252
|
+
}
|
|
253
|
+
ensureRoot(root);
|
|
254
|
+
await clearStaleSocket(address);
|
|
255
|
+
const bucket = threads.get(threadId) ?? { cursor: null, pathHash: null, busy: null };
|
|
256
|
+
threads.set(threadId, bucket);
|
|
257
|
+
const record = { threadId, address, closed: false, inode: null, server: null };
|
|
258
|
+
const server = net.createServer(socket => {
|
|
259
|
+
sockets.add(socket);
|
|
260
|
+
let acquired = false;
|
|
261
|
+
let data = '';
|
|
262
|
+
let chain = Promise.resolve();
|
|
263
|
+
let queuedFrames = 0;
|
|
264
|
+
const send = value => { if (!socket.destroyed && !socket.writableEnded) socket.write(JSON.stringify(value) + '\n'); };
|
|
265
|
+
socket.on('error', () => {});
|
|
266
|
+
socket.setTimeout(10000, () => socket.destroy());
|
|
267
|
+
socket.once('close', () => {
|
|
268
|
+
sockets.delete(socket);
|
|
269
|
+
// Do not release serialization while an acknowledged upload is still
|
|
270
|
+
// in flight: a following hook must see its committed cursor or retry ID.
|
|
271
|
+
void chain.finally(() => { if (bucket.busy === socket) bucket.busy = null; });
|
|
272
|
+
});
|
|
273
|
+
async function handle(message) {
|
|
274
|
+
if (closed || record.closed || socket.destroyed || socket.writableEnded) throw new Error('capture-binding-stale');
|
|
275
|
+
if (!acquired) {
|
|
276
|
+
if (message.op !== 'open' || message.session_id !== sessionId || message.turn_id !== turnId
|
|
277
|
+
|| !/^[a-f0-9]{64}$/.test(message.path_hash)
|
|
278
|
+
|| Object.keys(message).some(key => !['op', 'session_id', 'turn_id', 'path_hash'].includes(key))) throw new Error('capture-binding-stale');
|
|
279
|
+
if (bucket.busy) throw new Error('capture-busy');
|
|
280
|
+
if (bucket.pathHash && bucket.pathHash !== message.path_hash) throw new Error('capture-transcript-path-changed');
|
|
281
|
+
bucket.pathHash = message.path_hash;
|
|
282
|
+
bucket.busy = socket; acquired = true;
|
|
283
|
+
return send({ ok: true, type: 'ready', threadId, cursor: bucket.cursor });
|
|
284
|
+
}
|
|
285
|
+
if (message.op === 'capture' && Object.keys(message).every(key => ['op', 'event'].includes(key)) && validUpload(message.event)) {
|
|
286
|
+
const response = await client.callTool({ name: 'chat_capture', arguments: message.event, _meta: { threadId } }, undefined, { timeout: 5000 });
|
|
287
|
+
if (!acknowledged(response)) throw new Error('capture-upstream-unavailable');
|
|
288
|
+
return send({ ok: true, type: 'captured' });
|
|
289
|
+
}
|
|
290
|
+
if (message.op === 'commit' && Object.keys(message).every(key => ['op', 'cursor'].includes(key)) && validCursor(message.cursor, bucket.cursor)) {
|
|
291
|
+
bucket.cursor = { ...message.cursor };
|
|
292
|
+
return send({ ok: true, type: 'committed' });
|
|
293
|
+
}
|
|
294
|
+
throw new Error('capture-binding-stale');
|
|
295
|
+
}
|
|
296
|
+
socket.on('data', chunk => {
|
|
297
|
+
data += chunk.toString('utf8');
|
|
298
|
+
if (Buffer.byteLength(data) > MAX_IPC_BYTES) { socket.destroy(); return; }
|
|
299
|
+
let newline;
|
|
300
|
+
while ((newline = data.indexOf('\n')) >= 0) {
|
|
301
|
+
const line = data.slice(0, newline); data = data.slice(newline + 1);
|
|
302
|
+
if (++queuedFrames > 4) { socket.destroy(); return; }
|
|
303
|
+
chain = chain.then(() => handle(JSON.parse(line))).catch(error => {
|
|
304
|
+
send({ ok: false, code: safeError(error) }); socket.end();
|
|
305
|
+
}).finally(() => { queuedFrames -= 1; });
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
server.maxConnections = 16; record.server = server;
|
|
310
|
+
record.ready = new Promise((resolve, reject) => {
|
|
311
|
+
server.once('error', () => reject(new Error('capture-broker-unavailable')));
|
|
312
|
+
server.listen(address, () => {
|
|
313
|
+
try {
|
|
314
|
+
if (closed || record.closed) throw new Error('capture-binding-stale');
|
|
315
|
+
fs.chmodSync(address, 0o600); record.inode = fs.lstatSync(address).ino; resolve();
|
|
316
|
+
} catch { reject(new Error('capture-broker-unavailable')); }
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
bindings.set(address, record);
|
|
320
|
+
try { await record.ready; } catch (error) { dispose(record); throw error; }
|
|
321
|
+
}
|
|
322
|
+
async function bind(sessionId, options = {}) {
|
|
323
|
+
if (!validId(options.turnId)) return;
|
|
324
|
+
const address = socketPath(root, sessionId, options.turnId);
|
|
325
|
+
const pending = pendingBindings.get(address);
|
|
326
|
+
if (pending) {
|
|
327
|
+
if (pending.threadId !== options.threadId) throw new Error('capture-binding-stale');
|
|
328
|
+
return pending.promise;
|
|
329
|
+
}
|
|
330
|
+
const promise = bindInternal(sessionId, options);
|
|
331
|
+
pendingBindings.set(address, { threadId: options.threadId, promise });
|
|
332
|
+
try { await promise; } finally { pendingBindings.delete(address); }
|
|
333
|
+
}
|
|
334
|
+
return { bind, close() {
|
|
335
|
+
closed = true;
|
|
336
|
+
for (const socket of sockets) socket.destroy();
|
|
337
|
+
for (const record of bindings.values()) dispose(record);
|
|
338
|
+
threads.clear();
|
|
339
|
+
} };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function connectHook(root, input, timeoutMs) {
|
|
343
|
+
ensureRoot(root);
|
|
344
|
+
const address = socketPath(root, input.session_id, input.turn_id);
|
|
345
|
+
const stat = fs.lstatSync(address);
|
|
346
|
+
if (!stat.isSocket() || stat.uid !== process.getuid() || (stat.mode & 0o077)) throw new Error('capture-broker-unavailable');
|
|
347
|
+
const socket = net.createConnection(address);
|
|
348
|
+
let buffer = ''; let waiting = null;
|
|
349
|
+
const finish = (error, response) => {
|
|
350
|
+
if (!waiting) return;
|
|
351
|
+
const current = waiting; waiting = null; clearTimeout(current.timer);
|
|
352
|
+
if (error) current.reject(error); else current.resolve(response);
|
|
353
|
+
};
|
|
354
|
+
socket.on('error', () => finish(new Error('capture-broker-unavailable')));
|
|
355
|
+
socket.on('close', () => finish(new Error('capture-broker-unavailable')));
|
|
356
|
+
socket.on('data', chunk => {
|
|
357
|
+
buffer += chunk.toString('utf8');
|
|
358
|
+
if (Buffer.byteLength(buffer) > 8192) { socket.destroy(); return; }
|
|
359
|
+
const newline = buffer.indexOf('\n');
|
|
360
|
+
if (newline < 0) return;
|
|
361
|
+
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
362
|
+
try {
|
|
363
|
+
const result = JSON.parse(line);
|
|
364
|
+
if (!result.ok) finish(new Error(SAFE_ERRORS.has(result.code) ? result.code : 'capture-broker-unavailable'));
|
|
365
|
+
else finish(null, result);
|
|
366
|
+
} catch { finish(new Error('capture-broker-unavailable')); }
|
|
367
|
+
});
|
|
368
|
+
const request = message => new Promise((resolve, reject) => {
|
|
369
|
+
if (waiting || socket.destroyed) return reject(new Error('capture-broker-unavailable'));
|
|
370
|
+
waiting = { resolve, reject, timer: setTimeout(() => {
|
|
371
|
+
finish(new Error('capture-broker-unavailable')); socket.destroy();
|
|
372
|
+
}, timeoutMs) };
|
|
373
|
+
socket.write(JSON.stringify(message) + '\n');
|
|
374
|
+
});
|
|
375
|
+
try {
|
|
376
|
+
const ready = await request({ op: 'open', session_id: input.session_id, turn_id: input.turn_id, path_hash: hash(input.transcript_path) });
|
|
377
|
+
if (ready.type !== 'ready' || !validId(ready.threadId)
|
|
378
|
+
|| (ready.cursor !== null && !validCursor(ready.cursor, null))) throw new Error('capture-binding-stale');
|
|
379
|
+
return { ready, request, close: () => socket.destroy() };
|
|
380
|
+
} catch (error) { socket.destroy(); throw error; }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// One invocation, one bounded flush, then exit. There are no background scans
|
|
384
|
+
// or late-append watchers; the next native hook drains subsequent appends.
|
|
385
|
+
async function captureHook({ input, root = rootPath(), homeDir = os.homedir(),
|
|
386
|
+
sessionsRoot = path.join(homeDir, '.codex', 'sessions'), timeoutMs = 7000 } = {}) {
|
|
387
|
+
const request = requestFromHook(input);
|
|
388
|
+
const deadline = Date.now() + timeoutMs;
|
|
389
|
+
let connection;
|
|
390
|
+
while (!connection && Date.now() < deadline) {
|
|
391
|
+
try { connection = await connectHook(root, request, Math.min(5000, deadline - Date.now())); }
|
|
392
|
+
catch (error) {
|
|
393
|
+
if (error.message !== 'capture-busy' && error.message !== 'capture-broker-unavailable' && error.code !== 'ENOENT') throw error;
|
|
394
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (!connection) throw new Error('capture-broker-unavailable');
|
|
398
|
+
try {
|
|
399
|
+
const reader = createTranscriptReader({ threadId: connection.ready.threadId, turnId: request.turn_id,
|
|
400
|
+
sessionsRoot, homeDir, cursor: connection.ready.cursor,
|
|
401
|
+
onCheckpoint: async () => {
|
|
402
|
+
const result = await connection.request({ op: 'commit', cursor: reader.snapshot() });
|
|
403
|
+
if (result.type !== 'committed') throw new Error('capture-upstream-unavailable');
|
|
404
|
+
},
|
|
405
|
+
client: { async callTool(call) {
|
|
406
|
+
if (Date.now() >= deadline) throw new Error('capture-backlog-remains');
|
|
407
|
+
const response = await connection.request({ op: 'capture', event: call.arguments });
|
|
408
|
+
if (response.type !== 'captured') throw new Error('capture-upstream-unavailable');
|
|
409
|
+
return { structuredContent: { ok: true, status: 'captured_or_duplicate' } };
|
|
410
|
+
} } });
|
|
411
|
+
let result; let captured = 0;
|
|
412
|
+
for (let pass = 0; pass < 4 && Date.now() < deadline; pass += 1) {
|
|
413
|
+
const before = reader.offset;
|
|
414
|
+
result = await reader.scan(request.transcript_path, () => Date.now() < deadline);
|
|
415
|
+
captured += result.captured;
|
|
416
|
+
const committed = await connection.request({ op: 'commit', cursor: reader.snapshot() });
|
|
417
|
+
if (committed.type !== 'committed') throw new Error('capture-upstream-unavailable');
|
|
418
|
+
if (!result.pending || reader.offset === before) break;
|
|
419
|
+
}
|
|
420
|
+
return { ok: true, captured, pending: Boolean(result?.pending) };
|
|
421
|
+
} finally { connection.close(); }
|
|
422
|
+
}
|
|
423
|
+
async function runCaptureCommand({ stdin = process.stdin, stdout = process.stdout, stderr = process.stderr,
|
|
424
|
+
timeoutMs = 9000, capture = captureHook } = {}) {
|
|
425
|
+
return new Promise(resolve => {
|
|
426
|
+
let done = false; let data = '';
|
|
427
|
+
function finish(error, result) {
|
|
428
|
+
if (done) return;
|
|
429
|
+
done = true; clearTimeout(timer); stdin.pause();
|
|
430
|
+
if (error || result?.pending) stderr.write(`Slaunt visible chat capture: ${error ? safeError(error) : 'capture-backlog-remains'}\n`);
|
|
431
|
+
stdout.write('{}\n', resolve);
|
|
432
|
+
}
|
|
433
|
+
const timer = setTimeout(() => finish(new Error('capture-broker-unavailable')), timeoutMs);
|
|
434
|
+
stdin.on('error', () => finish(new Error('capture-broker-unavailable')));
|
|
435
|
+
stdin.on('data', chunk => { data += chunk.toString('utf8'); if (Buffer.byteLength(data) > 1024 * 1024) finish(new Error('capture-message-too-large')); });
|
|
436
|
+
stdin.on('end', () => {
|
|
437
|
+
if (done) return;
|
|
438
|
+
try { void capture({ input: requestFromHook(JSON.parse(data)) }).then(result => finish(null, result), finish); }
|
|
439
|
+
catch (error) { finish(error); }
|
|
440
|
+
});
|
|
441
|
+
stdin.resume();
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
module.exports = { parseVisibleRecord, createTranscriptReader, createHookCaptureServer,
|
|
445
|
+
requestFromHook, captureHook, runCaptureCommand };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'SubagentStart', 'SubagentStop', 'PreCompact', 'PostCompact', 'Stop'];
|
|
6
|
+
const FIELDS = ['session_id', 'tool_name', 'tool_use_id', 'permission_mode', 'agent_type', 'agent_id', 'trigger', 'prompt_id', 'prompt', 'last_assistant_message'];
|
|
7
|
+
const MAX_CAPTURE_BYTES = 120 * 1024;
|
|
8
|
+
const CLAUDE_HOOK_TOOL = Object.freeze({
|
|
9
|
+
name: 'slaunt_hook_event',
|
|
10
|
+
description: 'Maintain Claude Code lifecycle state and capture submitted prompts and final visible assistant messages.',
|
|
11
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['event_name', 'session_id'],
|
|
12
|
+
properties: {
|
|
13
|
+
event_name: { type: 'string', enum: EVENTS },
|
|
14
|
+
...Object.fromEntries(FIELDS.map(key => [key, { type: 'string', maxLength: ['prompt', 'last_assistant_message'].includes(key) ? 524288 : 256 }]))
|
|
15
|
+
} }
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function validId(value) {
|
|
19
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function captureMeta(meta) {
|
|
23
|
+
return validId(meta?.threadId) ? { _meta: { threadId: meta.threadId } } : {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isRateLimited(response) {
|
|
27
|
+
return response?.isError === true && (response.content || []).some(block =>
|
|
28
|
+
block?.type === 'text' && /http_rate_limited|rate limit(?:ed)?|\b429\b/i.test(block.text || ''));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateInput(input) {
|
|
32
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || !EVENTS.includes(input.event_name)
|
|
33
|
+
|| !validId(input.session_id)
|
|
34
|
+
|| Object.keys(input).some(key => key !== 'event_name' && !FIELDS.includes(key))) {
|
|
35
|
+
throw new Error('invalid-hook-metadata');
|
|
36
|
+
}
|
|
37
|
+
for (const [key, value] of Object.entries(input)) {
|
|
38
|
+
if (key === 'event_name') continue;
|
|
39
|
+
if (typeof value !== 'string' || value.length > (['prompt', 'last_assistant_message'].includes(key) ? 524288 : 256)
|
|
40
|
+
|| (!['prompt', 'last_assistant_message'].includes(key) && /[\x00-\x1f\x7f]/.test(value))) {
|
|
41
|
+
throw new Error('invalid-hook-metadata');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (input.event_name === 'UserPromptSubmit' && input.prompt !== undefined && !validId(input.prompt_id)) {
|
|
45
|
+
throw new Error('invalid-hook-metadata');
|
|
46
|
+
}
|
|
47
|
+
if (input.event_name === 'Stop' && input.last_assistant_message && !validId(input.prompt_id)) {
|
|
48
|
+
throw new Error('invalid-hook-metadata');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createClaudeLifecycle(client) {
|
|
53
|
+
let boundSession = null;
|
|
54
|
+
let runActive = false;
|
|
55
|
+
let toolCallCount = 0;
|
|
56
|
+
let queue = Promise.resolve();
|
|
57
|
+
|
|
58
|
+
async function call(name, args) {
|
|
59
|
+
const response = await client.callTool({ name, arguments: args });
|
|
60
|
+
if (response.isError) throw new Error('slaunt-lifecycle-unavailable');
|
|
61
|
+
return response;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function captureVisible(input, eventName, content, meta) {
|
|
65
|
+
if (!validId(input.prompt_id) || typeof content !== 'string' || content.length === 0) return;
|
|
66
|
+
const args = { hook_event_name: eventName, session_id: input.session_id, turn_id: input.prompt_id };
|
|
67
|
+
if (eventName === 'UserPromptSubmit') args.prompt = content;
|
|
68
|
+
else args.last_assistant_message = content;
|
|
69
|
+
if (Buffer.byteLength(JSON.stringify(args)) > MAX_CAPTURE_BYTES) {
|
|
70
|
+
process.stderr.write('Slaunt visible chat capture: capture-message-too-large\n');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
75
|
+
const response = await client.callTool({ name: 'chat_hook_event', arguments: args, ...captureMeta(meta) }, undefined, { timeout: 5000 });
|
|
76
|
+
if (!response.isError) return;
|
|
77
|
+
if (attempt === 0 && isRateLimited(response)) {
|
|
78
|
+
// The backend deduplicates this event by session, event, and turn ID.
|
|
79
|
+
// Retry one explicit 429 once without repeating lifecycle writes.
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
process.stderr.write('Slaunt visible chat capture: capture-upstream-unavailable\n');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Chat capture is best-effort and must not block the user's Claude turn.
|
|
88
|
+
process.stderr.write('Slaunt visible chat capture: capture-upstream-unavailable\n');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handle(input, meta) {
|
|
93
|
+
validateInput(input);
|
|
94
|
+
// Claude can fire UserPromptSubmit while a background subagent reports to
|
|
95
|
+
// its parent. That internal prompt is not part of the visible user chat.
|
|
96
|
+
if (input.event_name === 'UserPromptSubmit' && input.agent_id) return { content: [] };
|
|
97
|
+
|
|
98
|
+
if (boundSession !== input.session_id) {
|
|
99
|
+
if (boundSession) await call('run_end', {
|
|
100
|
+
outcome: 'interrupted', summary: `Claude Code session changed after ${toolCallCount} tool calls`
|
|
101
|
+
});
|
|
102
|
+
runActive = false;
|
|
103
|
+
toolCallCount = 0;
|
|
104
|
+
await call('thread_begin', { request_id: randomUUID(), ...(boundSession ? { new_thread: true } : {}), label: 'Claude Code' });
|
|
105
|
+
boundSession = input.session_id;
|
|
106
|
+
}
|
|
107
|
+
if (input.event_name === 'UserPromptSubmit') {
|
|
108
|
+
if (runActive) await call('run_end', {
|
|
109
|
+
outcome: 'interrupted', summary: `Previous Claude Code turn interrupted after ${toolCallCount} tool calls`
|
|
110
|
+
});
|
|
111
|
+
await captureVisible(input, 'UserPromptSubmit', input.prompt, meta);
|
|
112
|
+
await call('run_begin', { task: 'Claude Code turn' });
|
|
113
|
+
runActive = true;
|
|
114
|
+
toolCallCount = 0;
|
|
115
|
+
return call('aki_workspace_context', { include_agents: true, recent_limit: 5 });
|
|
116
|
+
}
|
|
117
|
+
if (input.event_name === 'SessionStart') {
|
|
118
|
+
return call('aki_workspace_context', { include_agents: true, recent_limit: 5 });
|
|
119
|
+
}
|
|
120
|
+
if (input.event_name === 'Stop') {
|
|
121
|
+
await captureVisible(input, 'Stop', input.last_assistant_message, meta);
|
|
122
|
+
runActive = false;
|
|
123
|
+
const completedToolCalls = toolCallCount;
|
|
124
|
+
toolCallCount = 0;
|
|
125
|
+
return call('run_end', { outcome: 'completed', summary: `Claude Code turn ended after ${completedToolCalls} tool calls` });
|
|
126
|
+
}
|
|
127
|
+
// Keep Claude's event adapter installed, but aggregate tool activity into
|
|
128
|
+
// run_end instead of issuing a remote telemetry write for every tool.
|
|
129
|
+
if (input.event_name === 'PostToolUse') {
|
|
130
|
+
toolCallCount = Math.min(toolCallCount + 1, 10_000);
|
|
131
|
+
return { content: [] };
|
|
132
|
+
}
|
|
133
|
+
const metadata = Object.fromEntries(FIELDS.filter(key => key !== 'session_id' && key !== 'prompt' && key !== 'last_assistant_message' && input[key]).map(key => [key, input[key]]));
|
|
134
|
+
return call('run_step', { activity: `Claude Code ${input.event_name}`, metadata });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (input, meta) => {
|
|
138
|
+
const next = queue.then(() => handle(input, meta));
|
|
139
|
+
queue = next.catch(() => {});
|
|
140
|
+
return next;
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
module.exports = { CLAUDE_HOOK_TOOL, createClaudeLifecycle };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Central's canonical runtime keys, distinct from onboarding catalog IDs.
|
|
4
|
+
const CLIENT_IDENTITIES = Object.freeze({
|
|
5
|
+
'claude-code': Object.freeze({ key: 'claude-code', name: 'Claude Code' }),
|
|
6
|
+
'codex-cli': Object.freeze({ key: 'codex', name: 'Codex' })
|
|
7
|
+
});
|
|
8
|
+
function brokerArgsForClient(broker, clientId) {
|
|
9
|
+
if (!CLIENT_IDENTITIES[clientId]) throw new Error('unsupported-client-identity');
|
|
10
|
+
return [...broker.args, `--slaunt-client=${clientId}`];
|
|
11
|
+
}
|
|
12
|
+
function resolveBrokerIdentity(argv) {
|
|
13
|
+
const markers = argv.filter(value => value.startsWith('--slaunt-client='));
|
|
14
|
+
if (markers.length !== 1) throw new Error('client-identity-required');
|
|
15
|
+
const identity = CLIENT_IDENTITIES[markers[0].slice('--slaunt-client='.length)];
|
|
16
|
+
if (!identity) throw new Error('unsupported-client-identity');
|
|
17
|
+
return identity;
|
|
18
|
+
}
|
|
19
|
+
module.exports = { CLIENT_IDENTITIES, brokerArgsForClient, resolveBrokerIdentity };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {createClaudeLifecycle}=require('./claude-lifecycle');
|
|
3
|
+
const {createToolGuardServer}=require('./tool-guard');
|
|
4
|
+
|
|
5
|
+
// The caller owns and authenticates the MCP connection. This broker must not
|
|
6
|
+
// accept a token or account identity from hook input, and cannot use a client
|
|
7
|
+
// that has not already connected to Slaunt.
|
|
8
|
+
function createNativeClaudeBroker({client,root,timeoutMs=6000}={}) {
|
|
9
|
+
if(!client || typeof client.callTool!=='function')throw new Error('broker-connection-required');
|
|
10
|
+
const lifecycle=createClaudeLifecycle(client);
|
|
11
|
+
const guard=createToolGuardServer({client,clientKey:'claude-code',...(root?{root}:{}),timeoutMs});
|
|
12
|
+
let closed=false;
|
|
13
|
+
let generation=0,queue=Promise.resolve();
|
|
14
|
+
function event(input,meta) {
|
|
15
|
+
if(closed)throw new Error('broker-closed');
|
|
16
|
+
const name=input?.event_name;
|
|
17
|
+
const bind=name==='SessionStart'||(name==='UserPromptSubmit' && !input?.agent_id);
|
|
18
|
+
// Invalidate before the lifecycle transition so an old session never
|
|
19
|
+
// continues authorizing while a new run is being created.
|
|
20
|
+
if(bind || name==='Stop'){generation++;guard.invalidate();}
|
|
21
|
+
const current=generation;
|
|
22
|
+
const next=queue.then(async()=>{
|
|
23
|
+
if(closed)throw new Error('broker-closed');
|
|
24
|
+
const result=await lifecycle(input,meta);
|
|
25
|
+
if(bind && !input.agent_id && !closed && current===generation)await guard.bind(input.session_id);
|
|
26
|
+
return result;
|
|
27
|
+
});
|
|
28
|
+
queue=next.catch(()=>{});
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
function close(){if(closed)return;closed=true;generation++;guard.close();}
|
|
32
|
+
return {event,close};
|
|
33
|
+
}
|
|
34
|
+
module.exports={createNativeClaudeBroker};
|