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.
- package/README.md +59 -4
- package/bin/agy-porter.mjs +2 -2
- package/lib/consent.mjs +122 -0
- package/lib/detector.mjs +2 -1
- package/lib/hook-compiler.mjs +54 -18
- package/lib/index.mjs +3 -0
- package/lib/manifest-sanitizer.mjs +46 -10
- package/lib/mod-compiler.mjs +210 -0
- package/lib/mod-lexer.mjs +143 -0
- package/lib/mod-scan.mjs +169 -0
- package/lib/mod-transform.mjs +84 -0
- package/lib/mode-compiler.mjs +14 -4
- package/lib/rules-compiler.mjs +71 -10
- package/lib/skill-compiler.mjs +4 -1
- package/lib/source.mjs +25 -2
- package/lib/static-auditor.mjs +47 -8
- package/lib/tool-map.mjs +0 -1
- package/lib/transpiler-engine.mjs +50 -18
- package/lib/yaml-lite.mjs +36 -4
- package/package.json +14 -3
- package/scripts/hook-shim.mjs +198 -24
- package/scripts/mod-runtime/claude-code.mjs +20 -0
- package/scripts/mod-runtime/elements.mjs +35 -0
- package/scripts/mod-runtime/engine.mjs +129 -0
- package/scripts/mod-runtime/hook.mjs +24 -0
- package/scripts/mod-runtime/instance.mjs +250 -0
- package/scripts/mod-runtime/lifecycle.mjs +45 -0
- package/scripts/mod-runtime/locate.mjs +48 -0
- package/scripts/mod-runtime/mcp-entry.mjs +55 -0
- package/scripts/mod-runtime/server.mjs +178 -0
- package/scripts/mod-runtime/tool-calls.mjs +83 -0
- package/scripts/mod-runtime/ui-draw.js +122 -0
- package/scripts/mod-runtime/ui.html +61 -0
- package/scripts/mod-runtime/ui.js +149 -0
package/scripts/hook-shim.mjs
CHANGED
|
@@ -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
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
107
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
342
|
+
const timer = setTimeout(() => { killProcessTree(child); finish({ timedOut: true }); }, timeoutMs);
|
|
186
343
|
try {
|
|
187
|
-
|
|
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
|
|
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, ...
|
|
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
|
-
|
|
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
|
+
}
|