agy-plugin-porter 0.1.0 → 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.
@@ -11,9 +11,9 @@
11
11
  // the script decided something.
12
12
  // - SessionStart / Stop / PostToolUse: stdout must be a JSON object.
13
13
  import { spawn } from 'node:child_process';
14
- import { readFileSync } from 'node:fs';
15
- import { sep } from 'node:path';
16
- import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
15
+ import { isAbsolute, resolve, sep } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
17
 
18
18
  // agy tool name -> source tool name and arg renames. Arg names are the agy CLI's
19
19
  // as seen live; the IDE-named tools reuse them unverified. Unknown args pass through.
@@ -56,7 +56,35 @@ const renameKeys = (obj, renames) => Object.fromEntries(
56
56
  Object.entries(obj ?? {}).map(([k, v]) => [renames[k] ?? k, v]));
57
57
  const invert = (renames) => Object.fromEntries(Object.entries(renames).map(([k, v]) => [v, k]));
58
58
 
59
- export function toSourceTool(toolCall, dialect) {
59
+ export function toSourceTool(toolCall, dialect, matcher) {
60
+ if ((toolCall?.name === 'call_mcp_tool' || toolCall?.name === 'mcp_tool') && toolCall?.args) {
61
+ const server = toolCall.args.ServerName ?? toolCall.args.server_name ?? '';
62
+ const tool = toolCall.args.ToolName ?? toolCall.args.tool_name ?? '';
63
+ const rawArgs = toolCall.args.Arguments ?? toolCall.args.arguments ?? {};
64
+ return {
65
+ tool_name: server && tool ? `mcp__${server}__${tool}` : toolCall.name,
66
+ tool_input: typeof rawArgs === 'string' ? (parseJson(rawArgs.trim()) ?? {}) : (rawArgs ?? {}),
67
+ };
68
+ }
69
+ if (dialect !== 'gemini' && typeof toolCall?.name === 'string' && toolCall.name.startsWith('mcp_') && toolCall.name !== 'mcp_tool') {
70
+ const rest = toolCall.name.slice(4);
71
+ if (typeof matcher === 'string') {
72
+ for (const m of matcher.matchAll(/mcp__([A-Za-z0-9._-]+)__/g)) {
73
+ const srv = m[1];
74
+ const normSrv = srv.replace(/-/g, '_');
75
+ if (rest.startsWith(`${srv}_`)) {
76
+ return { tool_name: `mcp__${srv}__${rest.slice(srv.length + 1)}`, tool_input: toolCall.args ?? {} };
77
+ }
78
+ if (rest.startsWith(`${normSrv}_`)) {
79
+ return { tool_name: `mcp__${srv}__${rest.slice(normSrv.length + 1)}`, tool_input: toolCall.args ?? {} };
80
+ }
81
+ }
82
+ }
83
+ const idx = rest.indexOf('_');
84
+ if (idx > 0) {
85
+ return { tool_name: `mcp__${rest.slice(0, idx)}__${rest.slice(idx + 1)}`, tool_input: toolCall.args ?? {} };
86
+ }
87
+ }
60
88
  const [name, renames] = REVERSE[dialect]?.[toolCall?.name] ?? [toolCall?.name ?? '', {}];
61
89
  return { tool_name: name, tool_input: renameKeys(toolCall?.args, renames) };
62
90
  }
@@ -73,10 +101,10 @@ export function toSourcePayload(entry, payload) {
73
101
  else base.permission_mode = 'default';
74
102
  switch (entry.event) {
75
103
  case 'PreToolUse':
76
- return { ...base, ...toSourceTool(payload.toolCall, entry.dialect) };
104
+ return { ...base, ...toSourceTool(payload.toolCall, entry.dialect, entry.matcher) };
77
105
  case 'PostToolUse':
78
- if (entry.sourceEvent === 'PostToolUseFailure') return { ...base, ...toSourceTool(payload.toolCall, entry.dialect), error: payload.error ?? '' };
79
- return { ...base, ...toSourceTool(payload.toolCall, entry.dialect), tool_response: payload.error ? { error: payload.error } : {} };
106
+ if (entry.sourceEvent === 'PostToolUseFailure') return { ...base, ...toSourceTool(payload.toolCall, entry.dialect, entry.matcher), error: payload.error ?? '' };
107
+ return { ...base, ...toSourceTool(payload.toolCall, entry.dialect, entry.matcher), tool_response: payload.error ? { error: payload.error } : {} };
80
108
  case 'SessionStart':
81
109
  return { ...base, source: 'startup' };
82
110
  case 'Stop':
@@ -88,23 +116,89 @@ export function toSourcePayload(entry, payload) {
88
116
 
89
117
  // Expands the plugin-path variables Claude Code and Gemini CLI provide, so the
90
118
  // command works under cmd.exe too. Unknown variables are left to the shell.
91
- export function expandCommand(command, vars) {
92
- const values = { ...vars, '/': sep };
93
- return command.replace(/\$\{([A-Za-z_]\w*|\/)\}|\$([A-Za-z_]\w*)/g, (whole, braced, bare) => {
94
- const name = braced ?? bare;
95
- return Object.hasOwn(values, name) ? values[name] : whole;
119
+ export function expandCommand(command, vars, { posix = false } = {}) {
120
+ const slash = posix ? '/' : sep;
121
+ const norm = (v) => (posix && typeof v === 'string' ? v.replace(/\\/g, '/') : v);
122
+ const values = { ...Object.fromEntries(Object.entries(vars ?? {}).map(([k, v]) => [k, norm(v)])), '/': slash };
123
+ const VAR_RE = /\$\{([A-Za-z_]\w*|\/)\}|\$([A-Za-z_]\w*)/g;
124
+ return command.replace(/"[^"]*"|'[^']*'|\S+/g, (token) => {
125
+ let expandedAny = false;
126
+ const expanded = token.replace(VAR_RE, (whole, braced, bare) => {
127
+ const name = braced ?? bare;
128
+ if (Object.hasOwn(values, name)) {
129
+ if (name !== '/') expandedAny = true;
130
+ return values[name];
131
+ }
132
+ return whole;
133
+ });
134
+ const isQuoted = (token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"));
135
+ if (expandedAny && !isQuoted && !token.includes('"') && /\s/.test(expanded)) {
136
+ return `"${expanded}"`;
137
+ }
138
+ return expanded;
96
139
  });
97
140
  }
98
141
 
99
142
  const NO_OPINION = { PreToolUse: '' };
100
143
  export const noOpinion = (event) => NO_OPINION[event] ?? '{}';
101
144
 
145
+ function splitTopLevelAlternatives(raw) {
146
+ const out = [];
147
+ let bracket = false;
148
+ let paren = 0;
149
+ let start = 0;
150
+ for (let i = 0; i < raw.length; i++) {
151
+ const ch = raw[i];
152
+ if (ch === '\\') { i++; continue; }
153
+ if (ch === '[' && !bracket) bracket = true;
154
+ else if (ch === ']' && bracket) bracket = false;
155
+ else if (!bracket && ch === '(') paren++;
156
+ else if (!bracket && ch === ')') paren = Math.max(0, paren - 1);
157
+ else if (!bracket && paren === 0 && ch === '|') {
158
+ out.push(raw.slice(start, i).trim());
159
+ start = i + 1;
160
+ }
161
+ }
162
+ out.push(raw.slice(start).trim());
163
+ return out.filter(Boolean);
164
+ }
165
+
166
+ function matchesSourceMatcher(matcher, toolName, rawToolName) {
167
+ if (!matcher || matcher === '*' || matcher === '.*') return true;
168
+ const effectiveRaw = (rawToolName === 'call_mcp_tool' || rawToolName === 'mcp_tool') && toolName !== rawToolName
169
+ ? ''
170
+ : (rawToolName ?? '');
171
+ if (matcher === toolName || (effectiveRaw && matcher === effectiveRaw)) return true;
172
+ try {
173
+ for (const alt of splitTopLevelAlternatives(matcher)) {
174
+ if (alt === '*' || alt === '.*' || alt === toolName || (effectiveRaw && alt === effectiveRaw)) return true;
175
+ const hasAnchor = /^\(*\^/.test(alt) || /\$\)*$/.test(alt);
176
+ const isMcpPrefix = /^\^?mcp__.*__$/.test(alt);
177
+ const re = hasAnchor
178
+ ? new RegExp(alt)
179
+ : isMcpPrefix
180
+ ? new RegExp(`^(?:${alt})`)
181
+ : new RegExp(`^(?:${alt})$`);
182
+ if (re.test(toolName) || (effectiveRaw && re.test(effectiveRaw))) return true;
183
+ }
184
+ return false;
185
+ } catch {
186
+ return true;
187
+ }
188
+ }
189
+
102
190
  // agy has one PostToolUse with `error` set when the tool failed (empty on
103
191
  // success, live-tested). Claude splits it: PostToolUse runs on success only,
104
192
  // PostToolUseFailure on failure only.
105
193
  export function shouldRun(entry, payload) {
106
- if (entry.event !== 'PostToolUse' || entry.dialect !== 'claude') return true;
107
- return Boolean(payload.error) === (entry.sourceEvent === 'PostToolUseFailure');
194
+ if (entry.event === 'PostToolUse' && entry.dialect === 'claude') {
195
+ if (Boolean(payload.error) !== (entry.sourceEvent === 'PostToolUseFailure')) return false;
196
+ }
197
+ if ((entry.event === 'PreToolUse' || entry.event === 'PostToolUse') && entry.matcher) {
198
+ const { tool_name } = toSourceTool(payload?.toolCall, entry.dialect, entry.matcher);
199
+ if (!matchesSourceMatcher(entry.matcher, tool_name, payload?.toolCall?.name)) return false;
200
+ }
201
+ return true;
108
202
  }
109
203
 
110
204
  function parseJson(text) {
@@ -132,10 +226,10 @@ export function translateResult(entry, result) {
132
226
  const reasonOr = (r, fallback) => (typeof r === 'string' && r.trim() ? r.trim() : fallback);
133
227
  if (blocked) return { stdout: JSON.stringify({ decision: 'deny', reason: reasonOr(stderr, 'Blocked by hook') }) };
134
228
  if (!json) return { stdout: '' };
135
- let decision = ['allow', 'deny', 'ask'].includes(specific.permissionDecision) ? specific.permissionDecision : null;
229
+ let decision = ['allow', 'deny', 'ask', 'force_ask'].includes(specific.permissionDecision) ? specific.permissionDecision : null;
136
230
  let reason = specific.permissionDecisionReason;
137
231
  if (!decision && typeof json.decision === 'string') {
138
- decision = { block: 'deny', deny: 'deny', approve: 'allow', allow: 'allow', ask: 'ask' }[json.decision] ?? null;
232
+ decision = { block: 'deny', deny: 'deny', approve: 'allow', allow: 'allow', ask: 'ask', force_ask: 'force_ask' }[json.decision] ?? null;
139
233
  reason = json.reason;
140
234
  }
141
235
  if (!decision && json.continue === false) {
@@ -147,8 +241,18 @@ export function translateResult(entry, result) {
147
241
  if (decision === 'deny') out.reason = reasonOr(reason, 'Blocked by hook');
148
242
  else if (typeof reason === 'string' && reason) out.reason = reason;
149
243
  if (decision !== 'deny' && specific.updatedInput && typeof specific.updatedInput === 'object') {
150
- const renames = REVERSE[entry.dialect]?.[entry.toolName]?.[1] ?? {};
151
- out.overwrite = renameKeys(specific.updatedInput, invert(renames));
244
+ if (entry.toolName === 'call_mcp_tool' || entry.toolName === 'mcp_tool') {
245
+ const rawArgs = entry.toolArgs && typeof entry.toolArgs === 'object' ? entry.toolArgs : {};
246
+ const argKey = 'Arguments' in rawArgs ? 'Arguments' : ('arguments' in rawArgs ? 'arguments' : 'Arguments');
247
+ const wasString = typeof rawArgs[argKey] === 'string';
248
+ out.overwrite = {
249
+ ...rawArgs,
250
+ [argKey]: wasString ? JSON.stringify(specific.updatedInput) : specific.updatedInput,
251
+ };
252
+ } else {
253
+ const renames = REVERSE[entry.dialect]?.[entry.toolName]?.[1] ?? {};
254
+ out.overwrite = renameKeys(specific.updatedInput, invert(renames));
255
+ }
152
256
  }
153
257
  return { stdout: JSON.stringify(out) };
154
258
  }
@@ -170,6 +274,59 @@ export function translateResult(entry, result) {
170
274
  return { stdout: '{}', ...(feedback ? { warning: `PostToolUse feedback dropped (agy has no channel for it): ${feedback}` } : {}) };
171
275
  }
172
276
 
277
+ const POSIX_SHELL_RE = /\b(?:bash|sh)\s+|\.sh\b|\$\(|\/dev\/null\b|\bexport\s+[A-Za-z_]\w*=|^\s*\[[\s!]/;
278
+
279
+ function hasPosixShebang(command, cwd) {
280
+ const m = /^\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command ?? '');
281
+ const token = m?.[1] ?? m?.[2] ?? m?.[3];
282
+ if (!token || /\.(?:exe|cmd|bat|ps1)$/i.test(token)) return false;
283
+ const candidate = isAbsolute(token) ? token : (cwd ? resolve(cwd, token) : token);
284
+ try {
285
+ if (!existsSync(candidate)) return false;
286
+ const head = readFileSync(candidate, 'utf8').slice(0, 128);
287
+ return /^#!\s*\//m.test(head);
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ function findGitBash() {
294
+ const candidates = [
295
+ process.env.AGY_GIT_BASH,
296
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
297
+ 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
298
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
299
+ process.env.LOCALAPPDATA && resolve(process.env.LOCALAPPDATA, 'Programs\\Git\\bin\\bash.exe'),
300
+ ].filter(Boolean);
301
+ return candidates.find((p) => existsSync(p)) ?? null;
302
+ }
303
+
304
+ export function resolveHookSpawn(command, platform = process.platform, cwd = process.cwd()) {
305
+ if (platform === 'win32' && (POSIX_SHELL_RE.test(command) || hasPosixShebang(command, cwd))) {
306
+ const gitBash = findGitBash();
307
+ if (gitBash) {
308
+ return { file: gitBash, args: ['-c', command], shell: false };
309
+ }
310
+ }
311
+ return { file: command, args: [], shell: true };
312
+ }
313
+
314
+ function killProcessTree(child) {
315
+ if (!child?.pid) return;
316
+ try { child.stdout?.destroy(); } catch {}
317
+ try { child.stderr?.destroy(); } catch {}
318
+ try { child.stdin?.destroy(); } catch {}
319
+ if (process.platform === 'win32') {
320
+ try {
321
+ const tk = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
322
+ tk.on('error', () => { try { child.kill(); } catch {} });
323
+ tk.unref();
324
+ return;
325
+ } catch {}
326
+ }
327
+ try { child.kill(); } catch {}
328
+ }
329
+
173
330
  export function runCommand(command, { cwd, env, input, timeoutMs }) {
174
331
  return new Promise((resolve) => {
175
332
  let stdout = '';
@@ -182,9 +339,12 @@ export function runCommand(command, { cwd, env, input, timeoutMs }) {
182
339
  clearTimeout(timer);
183
340
  resolve({ stdout, stderr, ...extra });
184
341
  };
185
- const timer = setTimeout(() => { child?.kill(); finish({ timedOut: true }); }, timeoutMs);
342
+ const timer = setTimeout(() => { killProcessTree(child); finish({ timedOut: true }); }, timeoutMs);
186
343
  try {
187
- child = spawn(command, { cwd, env, shell: true, windowsHide: true });
344
+ const spec = resolveHookSpawn(command, process.platform, cwd);
345
+ child = spec.shell
346
+ ? spawn(spec.file, { cwd, env, shell: true, windowsHide: true })
347
+ : spawn(spec.file, spec.args, { cwd, env, windowsHide: true });
188
348
  } catch (err) {
189
349
  finish({ spawnError: err.message });
190
350
  return;
@@ -226,13 +386,16 @@ async function main() {
226
386
  const vars = entry.dialect === 'gemini'
227
387
  ? { extensionPath: pluginRoot, GEMINI_PROJECT_DIR: projectDir, CLAUDE_PROJECT_DIR: projectDir }
228
388
  : { CLAUDE_PLUGIN_ROOT: pluginRoot, CLAUDE_PROJECT_DIR: projectDir };
229
- const result = await runCommand(expandCommand(entry.command, vars), {
389
+ const probeSpec = resolveHookSpawn(expandCommand(entry.command, vars, { posix: true }), process.platform, pluginRoot);
390
+ const usePosix = process.platform === 'win32' && !probeSpec.shell;
391
+ const envVars = usePosix ? Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, v.replace(/\\/g, '/')])) : vars;
392
+ const result = await runCommand(expandCommand(entry.command, vars, { posix: usePosix }), {
230
393
  cwd: pluginRoot,
231
- env: { ...process.env, ...vars },
394
+ env: { ...process.env, ...envVars },
232
395
  input: JSON.stringify(toSourcePayload(entry, payload)),
233
396
  timeoutMs: (entry.timeout ?? 60) * 1000,
234
397
  });
235
- const translated = translateResult({ ...entry, toolName: payload.toolCall?.name }, result);
398
+ const translated = translateResult({ ...entry, toolName: payload.toolCall?.name, toolArgs: payload.toolCall?.args }, result);
236
399
  out = translated.stdout;
237
400
  if (translated.warning) warn(translated.warning);
238
401
  } catch (err) {
@@ -241,4 +404,15 @@ async function main() {
241
404
  process.stdout.write(out, () => process.exit(0));
242
405
  }
243
406
 
244
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
407
+ // Node resolves symlinks in the main module's URL but not in argv[1], so compare
408
+ // real paths. Otherwise a plugin reached through a symlink (macOS's /var, a linked
409
+ // home or .gemini) never runs its hooks.
410
+ function isMain() {
411
+ try {
412
+ return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
413
+ } catch {
414
+ return false;
415
+ }
416
+ }
417
+
418
+ if (isMain()) main();
@@ -0,0 +1,20 @@
1
+ // Stand-in for the 'claude-code' module a TSX mod imports. Atoms live in the
2
+ // conversation's $.state (engine.mjs), so they are per conversation, like Claude
3
+ // Code's per-session state. derive and memberOf aren't used by the spike's mods.
4
+ const key = (ref) => `${ref.plugin}/${ref.key}${ref.id === undefined ? '' : `#${ref.id}`}`;
5
+
6
+ export const atom = (ref, initial, options) => Object.freeze({ ref: Object.freeze({ ...ref }), initial, shape: options?.shape });
7
+
8
+ export async function read($, a) {
9
+ const value = await $.state.get(key(a.ref));
10
+ return value === undefined ? a.initial : value;
11
+ }
12
+
13
+ export async function update($, a, fn) {
14
+ const next = fn(await read($, a));
15
+ await $.state.set(key(a.ref), next);
16
+ return next;
17
+ }
18
+
19
+ export function derive() { throw new Error('derive is not available in the Antigravity port yet'); }
20
+ export function memberOf() { throw new Error('memberOf is not available in the Antigravity port yet'); }
@@ -0,0 +1,35 @@
1
+ // Element trees for a ported Claude Code mod. Mods compile against bare `h` and
2
+ // `Fragment` (esbuild's classic JSX runtime), and get components from $.ui.resolve(e).
3
+ // The view (ui-draw.js) decides how each element name is drawn.
4
+ const normalize = (c) => (c == null || typeof c === 'boolean' ? [] : Array.isArray(c) ? c.flatMap(normalize)
5
+ : typeof c === 'number' ? [String(c)] : [c]);
6
+
7
+ export const element = (type, props) => {
8
+ const { children, ...rest } = props ?? {};
9
+ return { type, props: rest, children: normalize(children) };
10
+ };
11
+
12
+ export function installJsxGlobals() {
13
+ globalThis.h = (tag, props, ...children) => {
14
+ const all = { ...(props ?? {}) };
15
+ if (children.length) all.children = children;
16
+ return typeof tag === 'function' ? tag(all) : element(String(tag), all);
17
+ };
18
+ globalThis.Fragment = (props) => element('Box', { flexDirection: 'column', ...props });
19
+ }
20
+
21
+ // $.ui.resolve(e): every element name is a constructor.
22
+ export const ELEMENTS = new Proxy({}, {
23
+ get: (_, name) => (typeof name === 'string' && name !== 'then' ? (props) => element(name, props) : undefined),
24
+ });
25
+
26
+ // A tree as JSON for the view. Functions (onPress, onSubmit, ...) become {$cb: id}.
27
+ export function serialize(node, cb) {
28
+ if (node == null || typeof node === 'boolean') return null;
29
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
30
+ const kids = (list) => list.map((c) => serialize(c, cb)).filter((c) => c !== null);
31
+ if (Array.isArray(node)) return { type: 'Box', props: { flexDirection: 'column' }, children: kids(node) };
32
+ const props = {};
33
+ for (const [k, v] of Object.entries(node.props ?? {})) props[k] = typeof v === 'function' ? { $cb: cb(v) } : v;
34
+ return { type: node.type, props, children: kids(node.children ?? []) };
35
+ }
@@ -0,0 +1,129 @@
1
+ // Hosts a ported Claude Code mod for every Antigravity conversation: one Instance
2
+ // per conversation, agy hook events mapped to the mod's events (plan §5.4), and the
3
+ // MCP side (the mod's $.tool.register tools plus one slash_<name> tool per command).
4
+ import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ import { installJsxGlobals } from './elements.mjs';
8
+ import { Instance } from './instance.mjs';
9
+
10
+ installJsxGlobals();
11
+
12
+ export class Engine {
13
+ constructor({ mod, modUrl, dataDir, emit, visibleViews, log, preToolWaitMs = 1500, pendingMaxMs = 15 * 60_000 }) {
14
+ Object.assign(this, { mod, modUrl, emit, visibleViews, log, preToolWaitMs, pendingMaxMs });
15
+ this.options = mod.options ?? {};
16
+ this.instances = new Map();
17
+ const file = join(dataDir, 'store.json');
18
+ let data = {};
19
+ try { data = JSON.parse(readFileSync(file, 'utf8')); } catch {}
20
+ this.store = {
21
+ data: Object.assign(Object.create(null), data),
22
+ save() {
23
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
24
+ const payload = JSON.stringify(this.data, null, 2);
25
+ writeFileSync(tmp, payload);
26
+ try {
27
+ renameSync(tmp, file);
28
+ } catch {
29
+ try { writeFileSync(file, payload); } catch {}
30
+ try { rmSync(tmp, { force: true }); } catch {}
31
+ }
32
+ },
33
+ };
34
+ }
35
+
36
+ // The conversation's instance, loaded on first use. A cwd learned later reruns
37
+ // session.start, as a Claude Code reload does (live, Phase 2.5A).
38
+ async get(conv, cwd) {
39
+ // Hooks report C:\..., the view's workspace URI gives C:/...: use one spelling.
40
+ if (cwd && /^[A-Za-z]:[\\/]/.test(cwd)) cwd = cwd.replace(/\//g, '\\');
41
+ let entry = this.instances.get(conv);
42
+ if (!entry) {
43
+ const inst = new Instance(this, conv, cwd);
44
+ entry = { inst, ready: inst.load() };
45
+ this.instances.set(conv, entry);
46
+ this.log('instance', { conv, cwd: cwd ?? '' });
47
+ }
48
+ await entry.ready;
49
+ const { inst } = entry;
50
+ if (cwd && !inst.cwd) {
51
+ inst.cwd = cwd;
52
+ entry.ready = inst.dispatch('session.start', { cwd, source: 'resume' }, () => ({}));
53
+ await entry.ready;
54
+ inst.scheduleRender();
55
+ }
56
+ return inst;
57
+ }
58
+
59
+ // agy hook events, forwarded by hook.mjs. Returns what the hook prints.
60
+ async hook(event, payload) {
61
+ const inst = await this.get(typeof payload?.conversationId === 'string' ? payload.conversationId : '', payload?.workspacePaths?.[0]);
62
+ switch (event) {
63
+ case 'PreInvocation': {
64
+ // PreInvocation runs before every model call: only the first one of a turn
65
+ // starts it, and only that one adds prompt.context.
66
+ if (inst.inTurn) return { stdout: '{}' };
67
+ inst.inTurn = true;
68
+ await inst.dispatch('turn.start', {}, () => undefined);
69
+ return { stdout: await this.promptContext(inst) };
70
+ }
71
+ case 'PreToolUse': {
72
+ const { decision, path } = await inst.preToolUse(payload);
73
+ return { stdout: decision ? JSON.stringify(decision) : '', path };
74
+ }
75
+ case 'PostToolUse':
76
+ return { stdout: '{}', path: inst.postToolUse(payload) };
77
+ case 'Stop':
78
+ inst.dropPending('no PostToolUse before Stop');
79
+ await new Promise((r) => setImmediate(r));
80
+ inst.inTurn = false;
81
+ await inst.dispatch('turn.complete', { agentId: undefined }, () => undefined);
82
+ return { stdout: '{}' };
83
+ default:
84
+ return { stdout: '{}' };
85
+ }
86
+ }
87
+
88
+ // Only the systemMessage step is proven live. A step type agy doesn't know ends
89
+ // the whole agent turn (live: hookEphemeralMessage), so nothing else is emitted.
90
+ async promptContext(inst) {
91
+ if (!inst.has('prompt.context')) return '{}';
92
+ const r = await inst.dispatch('prompt.context', { blocks: [] }, (x) => ({ blocks: x.blocks }));
93
+ const text = (Array.isArray(r?.blocks) ? r.blocks : []).map((b) => b?.text).filter((t) => typeof t === 'string' && t).join('\n\n');
94
+ return text ? JSON.stringify({ injectSteps: [{ systemMessage: { systemMessage: text } }] }) : '{}';
95
+ }
96
+
97
+ async listTools() {
98
+ const inst = await this.get('');
99
+ const tools = [];
100
+ for (const t of inst.tools.values()) {
101
+ const full = `mcp__${this.mod.mcpServer}__${t.name}`;
102
+ const d = await inst.dispatch('tool.describe', { tool: full, description: t.description ?? '', inputSchema: t.inputSchema },
103
+ (x) => ({ description: x.description, inputSchema: x.inputSchema }));
104
+ tools.push({ name: t.name, description: d?.description ?? t.description ?? '', inputSchema: d?.inputSchema ?? t.inputSchema ?? { type: 'object', properties: {} } });
105
+ }
106
+ for (const c of inst.commands.values()) tools.push(commandTool(c));
107
+ return tools;
108
+ }
109
+
110
+ async callTool(conv, name, args) {
111
+ const inst = await this.get(conv ?? '');
112
+ if (name.startsWith('slash_')) return this.runCommand(inst, name.slice(6), args?.args ?? '', 'mcp');
113
+ const full = `mcp__${this.mod.mcpServer}__${name}`;
114
+ const r = await inst.dispatch('tool.call', { tool: full, ...args }, () => ({ deny: `${name} has no handler in this mod` }));
115
+ if (r?.deny) return { isError: true, text: String(r.deny) };
116
+ return { isError: false, text: typeof r?.result === 'string' ? r.result : JSON.stringify(r?.result ?? null) };
117
+ }
118
+
119
+ async runCommand(inst, command, args, from) {
120
+ const r = await inst.dispatch('command.run', { command, args: String(args ?? ''), from }, () => ({ text: `Unknown command /${command}` }));
121
+ return { isError: false, text: typeof r?.text === 'string' ? r.text : '' };
122
+ }
123
+ }
124
+
125
+ export const commandTool = (c) => ({
126
+ name: `slash_${c.name}`,
127
+ description: `Runs the /${c.name} command: ${c.description ?? ''}`.trim(),
128
+ inputSchema: { type: 'object', properties: { args: { type: 'string', description: `What follows /${c.name}${c.argumentHint ? ` (${c.argumentHint})` : ''}; empty for none.` } } },
129
+ });
@@ -0,0 +1,24 @@
1
+ // agy hook -> sidecar: `agy-node sidecars/<id>/hook.mjs <Event>`, cwd = plugin root
2
+ // (live, Phase 2.5A). Prints what the sidecar answers. Fails open: if the sidecar is
3
+ // down, slow or broken, it prints "no opinion" (nothing for PreToolUse, `{}`
4
+ // otherwise) and exits 0, since agy fails a tool call on any nonzero PreToolUse exit
5
+ // and reads `{}` from PreToolUse as a deny (live, agy-baseline.md).
6
+ import { callSidecar } from './locate.mjs';
7
+
8
+ const event = process.argv[2] || 'PreToolUse';
9
+ const noOpinion = event === 'PreToolUse' ? '' : '{}';
10
+ const finish = (out) => process.stdout.write(out, () => process.exit(0));
11
+ process.on('uncaughtException', () => finish(noOpinion));
12
+ setTimeout(() => finish(noOpinion), 4000).unref();
13
+
14
+ let raw = '';
15
+ for await (const chunk of process.stdin) raw += chunk;
16
+ let payload = {};
17
+ try { payload = JSON.parse(raw.replace(/^/, '') || '{}'); } catch {}
18
+ try {
19
+ const r = await callSidecar('/api/hook', { event, payload });
20
+ finish(typeof r.stdout === 'string' ? r.stdout : noOpinion);
21
+ } catch (err) {
22
+ process.stderr.write(`agy-porter mod hook: ${err.message}; not blocking\n`);
23
+ finish(noOpinion);
24
+ }