@agent-flow-board/bridge 0.0.0-stage → 0.2.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/cli.js ADDED
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ // agent-flow-board mcp --server <url> [--org <slug>] forward MCP over stdio to the server (to /mcp/<slug> with --org)
3
+ // agent-flow-board login --server <url> [--org <slug>] sign in by device code
4
+ // agent-flow-board login --json --server <url> --org <slug> for an agent: line 1 holds userCode and verifier, it approves through MCP
5
+ // agent-flow-board setup --org <slug> --server <url> [--dir <repo>] [--shared] write the Claude hooks into the repo's .claude/settings.local.json
6
+ // agent-flow-board logout --server <url> [--org <slug>]
7
+ // agent-flow-board queue list | retry <id> | drop <id>
8
+ // agent-flow-board hook claude|antigravity|gemini [event] one hook event from stdin, one JSON object to stdout
9
+ // agent-flow-board hooks print claude|antigravity|gemini [--org <slug>] the settings to paste into the tool
10
+ // agent-flow-board git-hook install|uninstall|check the warn-only post-commit hook
11
+ // agent-flow-board check connect|run|pull|bind|watch|status|doctor|clear|setup|disconnect browser checks on this machine (see check usage)
12
+ // agent-flow-board mockup push <file.html> --server <url> --org <slug> --item <ref> upload one mockup file (never queued)
13
+ // agent-flow-board mockup kit <file.html> --server <url> --org <slug> --project <key> upload the project's kit (colours, fonts, icons)
14
+ // -- DEP-MODULES -- //
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { runBridge } from './bridge.js';
18
+ import { ADAPTERS, runHook } from './hooks/adapters.js';
19
+ import { callHook } from './hooks/call.js';
20
+ import { checkCommit, installGitHook, lastCommitMessage, uninstallGitHook } from './hooks/gitHook.js';
21
+ import { hookSettings } from './hooks/settings.js';
22
+ import { runSetup } from './hooks/setup.js';
23
+ import { applyStatusFile } from './hooks/statusFile.js';
24
+ import { deviceLogin } from './device.js';
25
+ import { pushKit, pushMockup } from './mockup.js';
26
+ import { Queue } from './queue.js';
27
+ import { accountKey, configDir, pickStore } from './tokenStore.js';
28
+ // -- FUNCTIONS -- //
29
+ const args = process.argv.slice(2);
30
+ const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
31
+ const cmd = args[0];
32
+ const log = (s) => process.stderr.write(s + '\n');
33
+ function serverUrl() {
34
+ const s = flag('--server') ?? process.env.AFB_SERVER;
35
+ if (!s) {
36
+ log('Say which server to use: --server https://board.example.com (or set AFB_SERVER).');
37
+ process.exit(2);
38
+ }
39
+ return s.replace(/\/$/, '');
40
+ }
41
+ /** An organisation slug as the app writes it. Anything else is ignored by a hook and refused by the other commands. */
42
+ const ORG_SLUG = /^[a-z0-9][a-z0-9-]{1,38}$/;
43
+ /** The organisation this run is for (--org), or undefined for the plain server sign in as before. */
44
+ function orgFlag(strict = true) {
45
+ const o = flag('--org');
46
+ if (o === undefined)
47
+ return undefined;
48
+ if (ORG_SLUG.test(o))
49
+ return o;
50
+ if (strict) {
51
+ log('--org takes an organisation slug: lowercase letters, digits and dashes.');
52
+ process.exit(2);
53
+ }
54
+ return undefined;
55
+ }
56
+ const store = pickStore();
57
+ /**
58
+ * The saved sign in for a server and organisation. Only the MCP bridge (legacy true) also tries an older sign in saved under the plain
59
+ * host, because /mcp/<slug> answers 403 if it is the wrong organisation. Hooks have no such check, so they never borrow it.
60
+ */
61
+ const tokenFor = (server, org, legacy = false) => process.env.AFB_TOKEN ??
62
+ store.get(accountKey(server, org)) ??
63
+ (org && legacy ? store.get(accountKey(server)) : null);
64
+ const queue = new Queue(join(configDir(), 'queue.json'));
65
+ const fetchImpl = (input, init) => fetch(input, init);
66
+ async function readStdin() {
67
+ const chunks = [];
68
+ for await (const c of process.stdin)
69
+ chunks.push(c);
70
+ const text = Buffer.concat(chunks).toString('utf8').trim();
71
+ try {
72
+ return text ? JSON.parse(text) : {};
73
+ }
74
+ catch {
75
+ return {};
76
+ }
77
+ }
78
+ /** Asks the organisation's own address whether this sign in belongs to it. Only a 403 means another organisation. */
79
+ async function belongsToOrg(server, org, token) {
80
+ try {
81
+ const res = await fetchImpl(`${server}/mcp/${org}`, {
82
+ method: 'POST',
83
+ headers: {
84
+ 'content-type': 'application/json',
85
+ accept: 'application/json, text/event-stream',
86
+ authorization: `Bearer ${token}`
87
+ },
88
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
89
+ });
90
+ // Only a refusal means another organisation, or no valid sign in
91
+ return res.status !== 403 && res.status !== 401;
92
+ }
93
+ catch {
94
+ // The board cannot be reached right now: keep the sign in, the first call checks again
95
+ return true;
96
+ }
97
+ }
98
+ async function main() {
99
+ if (cmd === 'check') {
100
+ // Loaded only here, so the hooks and the MCP forwarder never load the check code or Playwright
101
+ const { checkMain } = await import('./checks/command.js');
102
+ const code = await checkMain({
103
+ args: args.slice(1),
104
+ store,
105
+ fetch: fetchImpl,
106
+ personalToken: (server, org) => tokenFor(server.replace(/\/$/, ''), org, true),
107
+ log,
108
+ out: (line) => process.stdout.write(line + '\n')
109
+ });
110
+ // A browser or a preview may still hold a handle open: the exit code is what the caller reads, so end now
111
+ process.exitCode = code;
112
+ setTimeout(() => process.exit(code), 300).unref();
113
+ return;
114
+ }
115
+ if (cmd === 'hook') {
116
+ // A hook prints exactly one JSON object and always exits 0, so it can never break the tool that runs it
117
+ let out = {};
118
+ try {
119
+ const adapter = ADAPTERS[args[1] ?? ''];
120
+ const server = (flag('--server') ?? process.env.AFB_SERVER ?? '').replace(/\/$/, '');
121
+ if (adapter && server) {
122
+ const token = tokenFor(server, orgFlag(false));
123
+ out = await runHook(adapter, args[2] ?? '', await readStdin(), async (event) => {
124
+ const asked = Date.now();
125
+ const answer = await callHook({ server, token, fetch: fetchImpl }, event);
126
+ // The status goes to this conversation's file, never to the tool. A failed write never fails the hook.
127
+ applyStatusFile(join(configDir(), 'status'), event, answer, asked);
128
+ return answer;
129
+ });
130
+ }
131
+ }
132
+ catch {
133
+ out = {};
134
+ }
135
+ process.stdout.write(JSON.stringify(out));
136
+ return;
137
+ }
138
+ if (cmd === 'hooks' && args[1] === 'print') {
139
+ const text = hookSettings(args[2] ?? '', serverUrl());
140
+ if (!text) {
141
+ log('Say which tool: claude, antigravity or gemini.');
142
+ process.exit(2);
143
+ }
144
+ process.stdout.write(text + '\n');
145
+ return;
146
+ }
147
+ if (cmd === 'git-hook') {
148
+ const repo = flag('--dir') ?? process.cwd();
149
+ if (args[1] === 'install') {
150
+ const r = installGitHook(repo);
151
+ log(r.created ? `Installed ${r.path}.` : `Already installed in ${r.path}.`);
152
+ }
153
+ else if (args[1] === 'uninstall') {
154
+ log(uninstallGitHook(repo) ? 'Removed.' : 'Nothing to remove.');
155
+ }
156
+ else if (args[1] === 'check') {
157
+ // Warn only: print the warning to the terminal and never fail the commit
158
+ const warning = checkCommit(lastCommitMessage(repo));
159
+ if (warning)
160
+ log(warning);
161
+ }
162
+ else {
163
+ log('Usage: agent-flow-board git-hook install|uninstall|check [--dir <repo>]');
164
+ process.exit(2);
165
+ }
166
+ return;
167
+ }
168
+ if (cmd === 'mockup') {
169
+ // One upload over /api/v1 with this machine's own sign in. It is never queued, so a failure is said out loud and run again
170
+ if (!['push', 'kit'].includes(args[1] ?? '') || !args[2] || args[2].startsWith('--')) {
171
+ log('Usage: agent-flow-board mockup push <file.html> --server <url> --org <slug> (--item <ref> | --project <key> | --mockup <id>) [--another] [--title <text>] [--keep <text> | --keep-file <path>] [--free <text> | --free-file <path>] [--screenshot <file.png>]\n agent-flow-board mockup kit <file.html> --server <url> --org <slug> --project <key> [--commit <hash>] [--summary <text>]');
172
+ process.exit(2);
173
+ }
174
+ const server = serverUrl();
175
+ const org = orgFlag();
176
+ if (!org) {
177
+ log('Say which organisation: --org <slug>.');
178
+ process.exit(2);
179
+ }
180
+ if (args[1] === 'kit') {
181
+ const project = flag('--project');
182
+ if (!project) {
183
+ log('Say which project: --project <key>.');
184
+ process.exit(2);
185
+ }
186
+ const kit = await pushKit({
187
+ server,
188
+ org,
189
+ token: tokenFor(server, org, true) ?? '',
190
+ fetch: fetchImpl,
191
+ file: args[2],
192
+ project,
193
+ commit: flag('--commit'),
194
+ summary: flag('--summary'),
195
+ log
196
+ });
197
+ process.stdout.write(JSON.stringify(kit) + '\n');
198
+ return;
199
+ }
200
+ const text = (inline, file) => {
201
+ const path = flag(file);
202
+ return path ? readFileSync(path, 'utf8') : flag(inline);
203
+ };
204
+ const result = await pushMockup({
205
+ server,
206
+ org,
207
+ token: tokenFor(server, org, true) ?? '',
208
+ fetch: fetchImpl,
209
+ file: args[2],
210
+ item: flag('--item'),
211
+ project: flag('--project'),
212
+ mockup: flag('--mockup'),
213
+ another: args.includes('--another'),
214
+ title: flag('--title'),
215
+ keepNotes: text('--keep', '--keep-file'),
216
+ freeNotes: text('--free', '--free-file'),
217
+ screenshot: flag('--screenshot'),
218
+ log
219
+ });
220
+ process.stdout.write(JSON.stringify(result) + '\n');
221
+ return;
222
+ }
223
+ if (cmd === 'queue') {
224
+ const sub = args[1];
225
+ if (sub === 'retry' && args[2])
226
+ log(queue.retry(args[2]) ? 'Queued again.' : 'No such queued write.');
227
+ else if (sub === 'drop' && args[2])
228
+ log(queue.drop(args[2]) ? 'Dropped.' : 'No such queued write.');
229
+ else
230
+ for (const e of queue.list())
231
+ log(`${e.id} ${e.state.padEnd(6)} ${e.name} ${e.key}${e.reason ? ` held: ${e.reason}` : ''}`);
232
+ return;
233
+ }
234
+ if (cmd === 'logout') {
235
+ const server = serverUrl();
236
+ const org = orgFlag();
237
+ store.delete(accountKey(server, org));
238
+ // An older sign in under the plain host may be what this organisation was using, so it goes too
239
+ if (org)
240
+ store.delete(accountKey(server));
241
+ log('Signed out.');
242
+ return;
243
+ }
244
+ if (cmd === 'setup') {
245
+ const server = serverUrl();
246
+ const org = orgFlag();
247
+ if (!org) {
248
+ log('setup needs --org <slug>: the organisation the hooks send to.');
249
+ process.exit(2);
250
+ }
251
+ const token = tokenFor(server, org);
252
+ if (!token) {
253
+ log(`Not signed in to ${org} on ${server}. Run: agent-flow-board login --json --server ${server} --org ${org}`);
254
+ process.exit(1);
255
+ }
256
+ if (!(await belongsToOrg(server, org, token))) {
257
+ log(`The saved sign in is not for ${org}. Run the login for ${org} again.`);
258
+ process.exit(1);
259
+ }
260
+ const result = runSetup({
261
+ repo: flag('--dir') ?? process.cwd(),
262
+ server,
263
+ org,
264
+ shared: args.includes('--shared'),
265
+ configDir: configDir()
266
+ });
267
+ process.stdout.write(JSON.stringify(result) + '\n');
268
+ if (!result.ok) {
269
+ log(`Setup failed: ${result.error}`);
270
+ process.exit(1);
271
+ }
272
+ log(result.changed
273
+ ? `Wrote ${result.file}: ${result.added} hook(s) added, ${result.replaced} replaced, ${result.kept} other hook(s) kept.${result.backup ? ` The old file is backed up at ${result.backup}.` : ''}`
274
+ : `${result.file} already has these hooks. Nothing was changed.`);
275
+ return;
276
+ }
277
+ if (cmd !== 'mcp' && cmd !== 'login') {
278
+ log('Usage: agent-flow-board mcp|login|logout --server <url>, setup --org <slug>, queue list|retry <id>|drop <id>, mockup push <file.html>, or check connect|run|watch|status');
279
+ process.exit(2);
280
+ }
281
+ const server = serverUrl();
282
+ const org = orgFlag();
283
+ const account = accountKey(server, org);
284
+ // login --json is for an agent: the first line on stdout holds the code and the verifier it needs to approve the sign in through MCP
285
+ const asJson = cmd === 'login' && args.includes('--json');
286
+ if (asJson && !org) {
287
+ log('login --json needs --org <slug>: the agent approves the sign in for that organisation.');
288
+ process.exit(2);
289
+ }
290
+ const login = async () => {
291
+ const r = await deviceLogin(server, fetchImpl, log, asJson
292
+ ? {
293
+ pkce: true,
294
+ onCode: (c) => process.stdout.write(JSON.stringify(c) + '\n')
295
+ }
296
+ : {});
297
+ // The device approval lets the person choose the organisation, so it is checked against the one asked for before it is kept
298
+ if (org && !(await belongsToOrg(server, org, r.token))) {
299
+ throw new Error(`That sign in is for a different organisation than ${org}. It was not saved. Run the sign in again and choose ${org} when the approval page asks.`);
300
+ }
301
+ store.set(account, r.token);
302
+ log('Signed in.');
303
+ return r.token;
304
+ };
305
+ if (cmd === 'login') {
306
+ await login();
307
+ // The second line says it worked, with no token
308
+ if (asJson)
309
+ process.stdout.write(JSON.stringify({ signedIn: true, server, org }) + '\n');
310
+ return;
311
+ }
312
+ await runBridge({
313
+ server,
314
+ org,
315
+ fetch: fetchImpl,
316
+ getToken: () => tokenFor(server, org, true),
317
+ login,
318
+ queue,
319
+ stdin: process.stdin,
320
+ stdout: process.stdout,
321
+ log
322
+ });
323
+ // A background sign in may still be polling when the tool closes the bridge, and it must not keep this process alive
324
+ setTimeout(() => process.exit(0), 500).unref();
325
+ }
326
+ main().catch((e) => {
327
+ log(String(e instanceof Error ? e.message : e));
328
+ process.exit(1);
329
+ });
package/dist/device.js ADDED
@@ -0,0 +1,61 @@
1
+ // DEVICE CODE SIGN IN FOR THE BRIDGE (RFC 8628). THE PERSON TYPES THE CODE ON THE SERVER'S /device PAGE; NOTHING IS PRE FILLED.
2
+ // -- DEP-MODULES -- //
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ // -- FUNCTIONS -- //
5
+ /** How long one sign in call may take. The wait between polls is separate. */
6
+ export const DEVICE_CALL_TIMEOUT_MS = 30_000;
7
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ export async function deviceLogin(server, fetchImpl, log, options = {}) {
9
+ const wait = options.wait ?? sleep;
10
+ const verifier = options.pkce ? randomBytes(32).toString('base64url') : null;
11
+ const start = await fetchImpl(`${server}/oauth/device/code`, {
12
+ method: 'POST',
13
+ headers: { 'content-type': 'application/json' },
14
+ body: JSON.stringify({
15
+ client_name: options.name ?? 'agent-flow-board bridge',
16
+ ...(verifier
17
+ ? {
18
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
19
+ code_challenge_method: 'S256'
20
+ }
21
+ : {})
22
+ }),
23
+ signal: AbortSignal.timeout(DEVICE_CALL_TIMEOUT_MS)
24
+ });
25
+ if (!start.ok)
26
+ throw new Error(`The server refused the sign in (${start.status}).`);
27
+ const d = (await start.json());
28
+ options.onCode?.({
29
+ userCode: d.user_code,
30
+ verifier,
31
+ verificationUri: d.verification_uri,
32
+ expiresInSeconds: d.expires_in
33
+ });
34
+ log(`To sign in, open ${d.verification_uri} and type this code: ${d.user_code}`);
35
+ log(`The code works for ${Math.floor(d.expires_in / 60)} minutes. Waiting for you to approve it...`);
36
+ let interval = d.interval * 1000;
37
+ const deadline = Date.now() + d.expires_in * 1000;
38
+ while (Date.now() < deadline) {
39
+ await wait(interval);
40
+ const res = await fetchImpl(`${server}/oauth/token`, {
41
+ method: 'POST',
42
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
43
+ body: new URLSearchParams({
44
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
45
+ device_code: d.device_code
46
+ }).toString(),
47
+ signal: AbortSignal.timeout(DEVICE_CALL_TIMEOUT_MS)
48
+ });
49
+ const body = (await res.json());
50
+ if (body.access_token)
51
+ return { token: body.access_token, expiresInSeconds: body.expires_in ?? 0 };
52
+ if (body.error === 'authorization_pending')
53
+ continue;
54
+ if (body.error === 'slow_down') {
55
+ interval += 5000;
56
+ continue;
57
+ }
58
+ throw new Error(body.error_description ?? body.error ?? 'The sign in failed.');
59
+ }
60
+ throw new Error('The code expired before it was approved. Run the command again.');
61
+ }
@@ -0,0 +1,218 @@
1
+ // THE THREE HOOK ADAPTERS: EACH TOOL'S OWN PAYLOAD TO ONE SMALL EVENT, AND THE SERVER'S ANSWER BACK IN THE TOOL'S OWN SHAPE (SPEC 9.3)
2
+ // Claude Code and Antigravity payloads follow what was captured on real runs. The Gemini CLI mapping follows its documented hook
3
+ // protocol and has not been captured on a real install. Every adapter prints one JSON object, `{}` when there is nothing to say.
4
+ // -- CONSTANTS -- //
5
+ // The board under its older name, and under a per organisation connection name (afb-<org-slug>)
6
+ const BOARD_SERVER = /agent[-_]flow[-_]board|^(?:mcp_+)?afb-[a-z0-9]/i;
7
+ // -- FUNCTIONS -- //
8
+ const text = (v) => (typeof v === 'string' ? v : '');
9
+ const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
10
+ /** The item a close call names, when the call is a close at all. */
11
+ function closingFrom(args) {
12
+ const status = text(args.status).toUpperCase();
13
+ const item = text(args.item) || text(args.id);
14
+ return item && (status === 'COMPLETED' || status === 'REMOVED') && args.dryRun !== true
15
+ ? [{ item, status }]
16
+ : [];
17
+ }
18
+ // -- CLAUDE CODE -- //
19
+ export const claude = {
20
+ tool: 'claude-code',
21
+ toEvent(_name, p) {
22
+ const sessionId = text(p.session_id);
23
+ if (!sessionId)
24
+ return null;
25
+ const base = {
26
+ tool: 'claude-code',
27
+ externalId: sessionId,
28
+ cwd: text(p.cwd) || undefined
29
+ };
30
+ const hook = text(p.hook_event_name);
31
+ // The editor mod reads the status file, so the events that can change what a conversation works on ask for the status:
32
+ // the start, each prompt, the stop, and a call of the board's own tools (a claim, a step, a release). Plain edits and reads do not.
33
+ if (hook === 'SessionStart')
34
+ return { ...base, event: 'start', status: true };
35
+ if (hook === 'UserPromptSubmit')
36
+ return { ...base, event: 'prompt', status: true };
37
+ // The conversation ended: the server lets go of this session's claim
38
+ if (hook === 'SessionEnd')
39
+ return { ...base, event: 'end' };
40
+ if (hook === 'Stop')
41
+ return { ...base, event: 'stop', stopActive: p.stop_hook_active === true, status: true };
42
+ const tool = text(p.tool_name);
43
+ const input = obj(p.tool_input);
44
+ if (hook === 'PreToolUse') {
45
+ if (/^mcp__.*__(item_status|flow_set_status)$/.test(tool) && BOARD_SERVER.test(tool)) {
46
+ const closing = closingFrom(input);
47
+ return closing.length ? { ...base, event: 'closing', closing } : null;
48
+ }
49
+ return null;
50
+ }
51
+ if (hook !== 'PostToolUse')
52
+ return null;
53
+ const subagent = Boolean(p.agent_id);
54
+ let action = { kind: 'read' };
55
+ if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool))
56
+ action = { kind: 'edit', file: text(input.file_path) || text(input.notebook_path) };
57
+ else if (/^(Bash|PowerShell)$/.test(tool))
58
+ action = { kind: 'shell', command: text(input.command) };
59
+ else if (tool.startsWith('mcp__') && BOARD_SERVER.test(tool))
60
+ action = { kind: 'board', name: tool.split('__').pop() ?? '' };
61
+ return {
62
+ ...base,
63
+ event: 'tool',
64
+ subagent,
65
+ action,
66
+ ...(action.kind === 'board' ? { status: true } : {})
67
+ };
68
+ },
69
+ toOutput(event, a) {
70
+ if (!a)
71
+ return {};
72
+ if (event === 'start')
73
+ return {
74
+ hookSpecificOutput: {
75
+ hookEventName: 'SessionStart',
76
+ additionalContext: a.context ?? '',
77
+ ...(a.title ? { sessionTitle: a.title } : {})
78
+ },
79
+ // The one line for the person (an older editor status mod), shown once as the conversation starts
80
+ ...(a.notice ? { systemMessage: a.notice } : {})
81
+ };
82
+ if (event === 'prompt' && a.title)
83
+ return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', sessionTitle: a.title } };
84
+ // The nudge rides back from PostToolUse as context the agent reads before its next step
85
+ if (event === 'tool' && a.nudge)
86
+ return { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: a.nudge } };
87
+ if (event === 'closing' && a.ask)
88
+ return {
89
+ hookSpecificOutput: {
90
+ hookEventName: 'PreToolUse',
91
+ permissionDecision: 'ask',
92
+ permissionDecisionReason: a.ask
93
+ }
94
+ };
95
+ if (event === 'stop' && a.block)
96
+ return { decision: 'block', reason: a.block };
97
+ return {};
98
+ }
99
+ };
100
+ // -- ANTIGRAVITY AND agy -- //
101
+ const AGY_EDIT = /^(write_to_file|replace_file_content|multi_replace_file_content|edit_file|create_file|delete_file)$/;
102
+ export const antigravity = {
103
+ tool: 'antigravity',
104
+ toEvent(name, p) {
105
+ const conversation = text(p.conversationId);
106
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(conversation))
107
+ return null;
108
+ const base = {
109
+ tool: 'antigravity',
110
+ // The prefix keeps a conversation id apart from the ids other tools use
111
+ externalId: `agy-${conversation}`,
112
+ cwd: (Array.isArray(p.workspacePaths) ? text(p.workspacePaths[0]) : '') || undefined
113
+ };
114
+ if (name === 'PreInvocation')
115
+ return Number(p.invocationNum ?? 0) === 0 ? { ...base, event: 'start' } : null;
116
+ if (name === 'Stop')
117
+ return { ...base, event: 'stop' };
118
+ if (name !== 'PostToolUse')
119
+ return null;
120
+ const call = obj(p.toolCall);
121
+ const args = obj(call.args);
122
+ const failed = text(p.error).trim() !== '';
123
+ const tool = text(call.name);
124
+ let action = { kind: 'read' };
125
+ if (AGY_EDIT.test(tool) || typeof args.TargetFile === 'string')
126
+ action = { kind: 'edit', file: text(args.TargetFile) || text(args.AbsolutePath) };
127
+ else if (tool === 'run_command')
128
+ action = { kind: 'shell', command: text(args.CommandLine) };
129
+ else if (tool === 'call_mcp_tool' && BOARD_SERVER.test(text(args.ServerName)))
130
+ action = { kind: 'board', name: text(args.ToolName) };
131
+ return { ...base, event: 'tool', failed, action };
132
+ },
133
+ toOutput(event, a) {
134
+ if (!a)
135
+ return {};
136
+ // Antigravity has no hook output that names a conversation, so the board label carries the title instead
137
+ if (event === 'start' && a.context)
138
+ return { injectSteps: [{ ephemeralMessage: a.context }] };
139
+ // GAP: no PostToolUse output was captured for Antigravity, so the nudge is not sent here. Its sessions still hear the
140
+ // progress line in every board reply and the Stop reminder, which name the steps that are not recorded.
141
+ // A Stop "continue" re-enters the agent loop. The server sends at most one per distinct code change.
142
+ if (event === 'stop' && a.block)
143
+ return { decision: 'continue', reason: a.block };
144
+ return {};
145
+ }
146
+ };
147
+ // -- GEMINI CLI -- //
148
+ export const gemini = {
149
+ tool: 'gemini',
150
+ toEvent(_name, p) {
151
+ const sessionId = text(p.session_id);
152
+ if (!sessionId)
153
+ return null;
154
+ const base = { tool: 'gemini', externalId: sessionId, cwd: text(p.cwd) || undefined };
155
+ const hook = text(p.hook_event_name);
156
+ if (hook === 'SessionStart')
157
+ return { ...base, event: 'start' };
158
+ if (hook === 'BeforeAgent')
159
+ return { ...base, event: 'prompt' };
160
+ if (hook === 'AfterAgent')
161
+ return { ...base, event: 'stop', stopActive: p.stop_hook_active === true };
162
+ const tool = text(p.tool_name);
163
+ const input = obj(p.tool_input);
164
+ if (hook === 'BeforeTool') {
165
+ if (/^mcp_.*(item_status|flow_set_status)$/.test(tool) && BOARD_SERVER.test(tool)) {
166
+ const closing = closingFrom(input);
167
+ return closing.length ? { ...base, event: 'closing', closing } : null;
168
+ }
169
+ return null;
170
+ }
171
+ if (hook !== 'AfterTool')
172
+ return null;
173
+ let action = { kind: 'read' };
174
+ if (/^(write_file|replace|edit)$/.test(tool))
175
+ action = { kind: 'edit', file: text(input.file_path) || text(input.path) };
176
+ else if (tool === 'run_shell_command')
177
+ action = { kind: 'shell', command: text(input.command) };
178
+ else if (tool.startsWith('mcp_') && BOARD_SERVER.test(tool))
179
+ action = { kind: 'board', name: tool.split('_').pop() ?? '' };
180
+ return { ...base, event: 'tool', action };
181
+ },
182
+ toOutput(event, a) {
183
+ if (!a)
184
+ return {};
185
+ if (event === 'start')
186
+ return {
187
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: a.context ?? '' }
188
+ };
189
+ // Not captured on a real install: AfterTool takes additionalContext in the documented protocol
190
+ if (event === 'tool' && a.nudge)
191
+ return { hookSpecificOutput: { hookEventName: 'AfterTool', additionalContext: a.nudge } };
192
+ // Gemini cannot ask before a tool call, so the close question is a message, never a block
193
+ if (event === 'closing' && a.ask)
194
+ return { systemMessage: a.ask };
195
+ if (event === 'stop' && a.block)
196
+ return { decision: 'deny', reason: a.block };
197
+ return {};
198
+ }
199
+ };
200
+ export const ADAPTERS = {
201
+ claude,
202
+ 'claude-code': claude,
203
+ antigravity,
204
+ agy: antigravity,
205
+ gemini
206
+ };
207
+ /** Runs one hook: payload in, one JSON object out. Never throws, never blocks. */
208
+ export async function runHook(adapter, name, payload, call) {
209
+ try {
210
+ const event = adapter.toEvent(name, payload);
211
+ if (!event)
212
+ return {};
213
+ return adapter.toOutput(event.event, await call(event));
214
+ }
215
+ catch {
216
+ return {};
217
+ }
218
+ }