linkgravity 1.7.0 → 1.7.2
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/bin/cli.js +33 -25
- package/bin/pm2.js +22 -0
- package/bin/setup.js +23 -12
- package/hooks/hook.js +105 -0
- package/hooks/stop_hook.js +65 -0
- package/npm-scripts/register-hook.js +48 -18
- package/npm-scripts/venv-paths.js +1 -1
- package/package.json +1 -1
- package/hooks/hook.py +0 -72
- package/hooks/stop_hook.py +0 -64
package/bin/cli.js
CHANGED
|
@@ -32,30 +32,39 @@ function success(msg) {
|
|
|
32
32
|
console.log(`${color.green}✔${color.reset} ${msg}`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const { PM2_BIN, PM2_CWD, pm2Env } = require('./pm2');
|
|
36
|
+
|
|
37
|
+
// `npm` is npm.cmd on Windows, and node refuses to spawn a .cmd without a shell.
|
|
38
|
+
function runNpm(args, options = {}) {
|
|
39
|
+
return spawnSync('npm', args, { shell: isWin, ...options });
|
|
40
|
+
}
|
|
38
41
|
|
|
39
42
|
function info(msg) {
|
|
40
43
|
console.log(`\n${color.cyan}▶${color.reset} ${msg}`);
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
// `fresh`: npm has swapped the package on disk since require time.
|
|
47
|
+
function repairHookRegistration({ fresh = false } = {}) {
|
|
48
|
+
const modulePath = require.resolve('../npm-scripts/register-hook');
|
|
49
|
+
if (fresh) {
|
|
50
|
+
delete require.cache[modulePath];
|
|
51
|
+
delete require.cache[require.resolve('../npm-scripts/venv-paths')];
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
require(modulePath)({ allowFirstTimeCreate: false, quiet: true });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.log(
|
|
57
|
+
`${color.yellow}⚠${color.reset} Couldn't check the agy hook registration: ${err.message}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
function runPm2(args, silent = true) {
|
|
44
63
|
const stdioOpt = silent ? 'pipe' : 'inherit';
|
|
45
64
|
const result = spawnSync(process.execPath, [PM2_BIN, ...args], {
|
|
46
65
|
stdio: stdioOpt,
|
|
47
|
-
cwd:
|
|
48
|
-
env:
|
|
49
|
-
...process.env,
|
|
50
|
-
// pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
|
|
51
|
-
PYTHONUNBUFFERED: '1',
|
|
52
|
-
// pm2 merges --update-env rather than replacing, so a LOG_LEVEL from an earlier run
|
|
53
|
-
// survives unless a value is passed every time.
|
|
54
|
-
LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
|
|
55
|
-
// Version managers (fnm, nvm) put node on PATH from a shell hook the daemon never runs,
|
|
56
|
-
// so the bot's own `node` lookup for voice-service would fail without this.
|
|
57
|
-
PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`,
|
|
58
|
-
},
|
|
66
|
+
cwd: PM2_CWD,
|
|
67
|
+
env: pm2Env(),
|
|
59
68
|
});
|
|
60
69
|
|
|
61
70
|
if (result.error) {
|
|
@@ -132,7 +141,7 @@ function colorizeLevel(line) {
|
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
function runPm2LogsStream(args, printLine) {
|
|
135
|
-
const cp = spawn(process.execPath, [PM2_BIN, ...args], { cwd:
|
|
144
|
+
const cp = spawn(process.execPath, [PM2_BIN, ...args], { cwd: PM2_CWD });
|
|
136
145
|
|
|
137
146
|
const isNoise = (line) =>
|
|
138
147
|
line.trim().length === 0 ||
|
|
@@ -191,7 +200,7 @@ function verifyStartup() {
|
|
|
191
200
|
);
|
|
192
201
|
|
|
193
202
|
let cp = spawn(process.execPath, [PM2_BIN, 'logs', LGY_PM2_NAME, '--raw', '--lines', '0'], {
|
|
194
|
-
cwd:
|
|
203
|
+
cwd: PM2_CWD,
|
|
195
204
|
});
|
|
196
205
|
|
|
197
206
|
let settled = false;
|
|
@@ -343,10 +352,7 @@ function isAutostartEnabled() {
|
|
|
343
352
|
}
|
|
344
353
|
|
|
345
354
|
function checkLatestVersionFast(currentVersion) {
|
|
346
|
-
const view =
|
|
347
|
-
stdio: 'pipe',
|
|
348
|
-
timeout: 3000,
|
|
349
|
-
});
|
|
355
|
+
const view = runNpm(['view', 'linkgravity', 'version'], { stdio: 'pipe', timeout: 3000 });
|
|
350
356
|
if (view.error || view.status !== 0) return null;
|
|
351
357
|
const latest = view.stdout.toString().trim();
|
|
352
358
|
if (!latest) return null;
|
|
@@ -392,6 +398,8 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
392
398
|
require('../npm-scripts/ensure-env').ensureEnvironment();
|
|
393
399
|
}
|
|
394
400
|
|
|
401
|
+
repairHookRegistration();
|
|
402
|
+
|
|
395
403
|
info('Starting LinkGravity daemon...');
|
|
396
404
|
runPm2([
|
|
397
405
|
'start',
|
|
@@ -591,7 +599,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
591
599
|
const currentVersion = pkg.version;
|
|
592
600
|
|
|
593
601
|
info('Checking npm for the latest version...');
|
|
594
|
-
const viewResult =
|
|
602
|
+
const viewResult = runNpm(['view', 'linkgravity', 'version'], { stdio: 'pipe' });
|
|
595
603
|
if (viewResult.error || viewResult.status !== 0) {
|
|
596
604
|
console.error(
|
|
597
605
|
(viewResult.stderr || '').toString().trim() ||
|
|
@@ -602,6 +610,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
602
610
|
const latestVersion = viewResult.stdout.toString().trim();
|
|
603
611
|
|
|
604
612
|
if (latestVersion === currentVersion) {
|
|
613
|
+
repairHookRegistration();
|
|
605
614
|
success(`Already up to date (v${currentVersion}).\n`);
|
|
606
615
|
process.exit(0);
|
|
607
616
|
}
|
|
@@ -610,9 +619,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
610
619
|
const wasOnline = !!procBeforeUpdate && procBeforeUpdate.pm2_env.status === 'online';
|
|
611
620
|
|
|
612
621
|
info(`Updating: v${currentVersion} -> v${latestVersion}...`);
|
|
613
|
-
const installResult =
|
|
614
|
-
stdio: 'inherit',
|
|
615
|
-
});
|
|
622
|
+
const installResult = runNpm(['install', '-g', 'linkgravity@latest'], { stdio: 'inherit' });
|
|
616
623
|
if (installResult.status !== 0) {
|
|
617
624
|
console.error('npm install failed - update aborted, still on the old version.');
|
|
618
625
|
process.exit(1);
|
|
@@ -624,6 +631,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
624
631
|
// replaced this package on disk, and the copy required at startup is the pre-update one.
|
|
625
632
|
delete require.cache[require.resolve('../npm-scripts/ensure-env')];
|
|
626
633
|
require('../npm-scripts/ensure-env').ensureEnvironment();
|
|
634
|
+
repairHookRegistration({ fresh: true });
|
|
627
635
|
|
|
628
636
|
if (!procBeforeUpdate) {
|
|
629
637
|
info("Daemon wasn't running - starting it fresh...");
|
package/bin/pm2.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
// Not `npx pm2`: its `npm exec` + `sh -c` wrapping survives cp.kill() as a zombie, and LPM
|
|
4
|
+
// Firewall reads it as a runtime package install.
|
|
5
|
+
const PM2_BIN = require.resolve('pm2/bin/pm2');
|
|
6
|
+
const PM2_CWD = path.join(__dirname, '..');
|
|
7
|
+
|
|
8
|
+
function pm2Env() {
|
|
9
|
+
return {
|
|
10
|
+
...process.env,
|
|
11
|
+
// pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
|
|
12
|
+
PYTHONUNBUFFERED: '1',
|
|
13
|
+
// pm2 merges --update-env rather than replacing, so a LOG_LEVEL from an earlier run
|
|
14
|
+
// survives unless a value is passed every time.
|
|
15
|
+
LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
|
|
16
|
+
// Version managers (fnm, nvm) put node on PATH from a shell hook the daemon never runs,
|
|
17
|
+
// so the bot's own `node` lookup for voice-service would fail without this.
|
|
18
|
+
PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { PM2_BIN, PM2_CWD, pm2Env };
|
package/bin/setup.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const p = require('@clack/prompts');
|
|
2
2
|
const { spawnSync } = require('child_process');
|
|
3
3
|
const { python: pythonExe } = require('../npm-scripts/venv-paths');
|
|
4
|
+
const { PM2_BIN, PM2_CWD, pm2Env } = require('./pm2');
|
|
4
5
|
const {
|
|
5
6
|
getSettings,
|
|
6
7
|
updateSettings,
|
|
@@ -178,11 +179,19 @@ async function collectUserIds(existingIds, platformLabel, required = false) {
|
|
|
178
179
|
return ids;
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
function runPm2(args) {
|
|
183
|
+
return spawnSync(process.execPath, [PM2_BIN, ...args], {
|
|
184
|
+
stdio: 'pipe',
|
|
185
|
+
cwd: PM2_CWD,
|
|
186
|
+
env: pm2Env(),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
181
190
|
function stopDaemon(pm2Name, label) {
|
|
182
191
|
console.log(`${color.cyan}▶${color.reset} Stopping ${label} daemon...`);
|
|
183
|
-
|
|
192
|
+
runPm2(['delete', pm2Name]);
|
|
184
193
|
|
|
185
|
-
const jlist =
|
|
194
|
+
const jlist = runPm2(['jlist']);
|
|
186
195
|
let stillRunning = false;
|
|
187
196
|
if (jlist.status === 0) {
|
|
188
197
|
try {
|
|
@@ -194,17 +203,14 @@ function stopDaemon(pm2Name, label) {
|
|
|
194
203
|
p.outro(`${label} daemon stopped.`);
|
|
195
204
|
} else {
|
|
196
205
|
p.outro(
|
|
197
|
-
`${color.yellow}⚠${color.reset} ${label} daemon is still running - run \`
|
|
206
|
+
`${color.yellow}⚠${color.reset} ${label} daemon is still running - run \`lgy stop\` manually and check \`lgy status\`.`,
|
|
198
207
|
);
|
|
199
208
|
}
|
|
200
209
|
}
|
|
201
210
|
|
|
202
211
|
function startOrRestartDaemon(pm2Name, scriptPath, label) {
|
|
203
212
|
console.log(`${color.cyan}▶${color.reset} Restarting ${label} daemon to apply changes...`);
|
|
204
|
-
const restartResult =
|
|
205
|
-
stdio: 'pipe',
|
|
206
|
-
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
207
|
-
});
|
|
213
|
+
const restartResult = runPm2(['restart', pm2Name, '--update-env']);
|
|
208
214
|
|
|
209
215
|
if (restartResult.status === 0) {
|
|
210
216
|
p.outro(`${label} daemon restarted.`);
|
|
@@ -214,11 +220,14 @@ function startOrRestartDaemon(pm2Name, scriptPath, label) {
|
|
|
214
220
|
const stderr = (restartResult.stderr || '').toString();
|
|
215
221
|
if (stderr.includes('not found')) {
|
|
216
222
|
// Nothing to restart yet - start it instead of a false "restarted".
|
|
217
|
-
const startResult =
|
|
218
|
-
'
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
223
|
+
const startResult = runPm2([
|
|
224
|
+
'start',
|
|
225
|
+
scriptPath,
|
|
226
|
+
'--interpreter',
|
|
227
|
+
pythonExe,
|
|
228
|
+
'--name',
|
|
229
|
+
pm2Name,
|
|
230
|
+
]);
|
|
222
231
|
if (startResult.status === 0) {
|
|
223
232
|
p.outro(`${label} daemon wasn't running yet - started it fresh instead.`);
|
|
224
233
|
} else {
|
|
@@ -476,6 +485,8 @@ async function runSetup() {
|
|
|
476
485
|
process.exit(0);
|
|
477
486
|
}
|
|
478
487
|
if (consent) registerHook({ allowFirstTimeCreate: true });
|
|
488
|
+
} else {
|
|
489
|
+
registerHook({ allowFirstTimeCreate: false, quiet: true });
|
|
479
490
|
}
|
|
480
491
|
|
|
481
492
|
while (true) {
|
package/hooks/hook.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const LGY_CONFIG_FILE = path.join(os.homedir(), '.gemini', 'linkgravity', 'lgy.json');
|
|
9
|
+
// Not "localhost" - the server binds 127.0.0.1 and node resolves localhost to ::1 first.
|
|
10
|
+
const APPROVE_HOST = '127.0.0.1';
|
|
11
|
+
const APPROVE_PORT = 18080;
|
|
12
|
+
const TIMEOUT_MS = 3600 * 1000;
|
|
13
|
+
|
|
14
|
+
function emit(payload) {
|
|
15
|
+
process.stdout.write(JSON.stringify(payload));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadApproveToken() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(LGY_CONFIG_FILE, 'utf8')).approve_token || '';
|
|
21
|
+
} catch {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readStdin() {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
29
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requestDecision(body) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const req = http.request(
|
|
35
|
+
{
|
|
36
|
+
host: APPROVE_HOST,
|
|
37
|
+
port: APPROVE_PORT,
|
|
38
|
+
path: '/approve',
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'Content-Length': body.length,
|
|
43
|
+
'X-LGY-Token': loadApproveToken(),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
(res) => {
|
|
47
|
+
const parts = [];
|
|
48
|
+
res.on('data', (chunk) => parts.push(chunk));
|
|
49
|
+
res.on('end', () => {
|
|
50
|
+
try {
|
|
51
|
+
resolve(JSON.parse(Buffer.concat(parts).toString('utf8')));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
reject(err);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
req.on('error', reject);
|
|
59
|
+
req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error('timed out waiting for approval')));
|
|
60
|
+
req.end(body);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
if (process.env.LGY_APPROVAL_HOOK !== '1') {
|
|
66
|
+
emit({ decision: 'allow' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let hookInput;
|
|
71
|
+
try {
|
|
72
|
+
hookInput = JSON.parse(await readStdin());
|
|
73
|
+
} catch {
|
|
74
|
+
emit({ decision: 'deny', reason: 'Failed to parse hook input.' });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const toolCall = hookInput.toolCall || {};
|
|
79
|
+
const body = Buffer.from(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
conversation_id: hookInput.conversationId || 'unknown',
|
|
82
|
+
tool_name: toolCall.name || 'unknown_tool',
|
|
83
|
+
tool_input: toolCall.args || {},
|
|
84
|
+
thread_id: process.env.LGY_THREAD_ID || null,
|
|
85
|
+
}),
|
|
86
|
+
'utf8',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const result = await requestDecision(body);
|
|
91
|
+
if ((result.decision || 'allow') === 'allow') {
|
|
92
|
+
const out = { decision: 'allow' };
|
|
93
|
+
// Print mode requires a matching allow rule even when this hook says "allow", or it soft-denies anyway.
|
|
94
|
+
if (result.permissionOverrides) out.permissionOverrides = result.permissionOverrides;
|
|
95
|
+
emit(out);
|
|
96
|
+
} else {
|
|
97
|
+
emit({ decision: 'deny', reason: result.reason || 'User rejected the action.' });
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
process.stderr.write(`Hook error: ${err.message}\n`);
|
|
101
|
+
emit({ decision: 'deny', reason: `Connection to webhook failed: ${err.message}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
main();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Stop-event hook (https://antigravity.google/docs/hooks).
|
|
4
|
+
// `fullyIdle: false` means a run_command that detached to async is still in flight; "continue"
|
|
5
|
+
// keeps the turn alive so agy picks that result up instead of losing it.
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const MAX_CONTINUE_ATTEMPTS = 20;
|
|
11
|
+
// Its own process, so it never reaches `lgy logs`.
|
|
12
|
+
const DEBUG_LOG = path.join(os.homedir(), '.gemini', 'linkgravity', 'logs', 'stop_hook_debug.log');
|
|
13
|
+
|
|
14
|
+
function log(line) {
|
|
15
|
+
try {
|
|
16
|
+
fs.mkdirSync(path.dirname(DEBUG_LOG), { recursive: true });
|
|
17
|
+
fs.appendFileSync(DEBUG_LOG, `${new Date().toISOString()} ${line}\n`);
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function emit(payload) {
|
|
22
|
+
process.stdout.write(JSON.stringify(payload));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readStdin() {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
28
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
const raw = await readStdin();
|
|
33
|
+
let hookInput;
|
|
34
|
+
try {
|
|
35
|
+
hookInput = JSON.parse(raw);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
log(`[PARSE ERROR] ${err.message} raw=${JSON.stringify(raw)}`);
|
|
38
|
+
emit({});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fullyIdle = hookInput.fullyIdle ?? true;
|
|
43
|
+
const executionNum = hookInput.executionNum ?? 0;
|
|
44
|
+
log(
|
|
45
|
+
`[STOP HOOK] fullyIdle=${fullyIdle} executionNum=${executionNum} ` +
|
|
46
|
+
`terminationReason=${JSON.stringify(hookInput.terminationReason)} ` +
|
|
47
|
+
`conv=${JSON.stringify(hookInput.conversationId)}`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (!fullyIdle && executionNum < MAX_CONTINUE_ATTEMPTS) {
|
|
51
|
+
const response = {
|
|
52
|
+
decision: 'continue',
|
|
53
|
+
reason:
|
|
54
|
+
'A background command is still running. Wait for it to finish, ' +
|
|
55
|
+
'then report its actual result to the user before ending your turn.',
|
|
56
|
+
};
|
|
57
|
+
log(`[STOP HOOK] -> continue: ${JSON.stringify(response)}`);
|
|
58
|
+
emit(response);
|
|
59
|
+
} else {
|
|
60
|
+
log('[STOP HOOK] -> {} (fullyIdle true or attempt cap reached)');
|
|
61
|
+
emit({});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
main();
|
|
@@ -3,30 +3,55 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
|
-
const { repoRoot,
|
|
6
|
+
const { repoRoot, workspaceDir, isWin } = require('./venv-paths');
|
|
7
7
|
|
|
8
8
|
const hooksJsonPath = path.join(os.homedir(), '.gemini', 'config', 'hooks.json');
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
// Not process.execPath: this runs on every agy tool call machine-wide, and an absolute
|
|
11
|
+
// interpreter path dies the moment a version manager moves it.
|
|
12
|
+
const NODE_CMD = 'node';
|
|
13
|
+
|
|
14
|
+
// A copy, never this package: npm has had no uninstall lifecycle since v7, so an entry pointing
|
|
15
|
+
// into node_modules would 127 every agy tool call once linkgravity is removed.
|
|
16
|
+
const installedHooksDir = path.join(workspaceDir, 'hooks');
|
|
17
|
+
|
|
13
18
|
const HOOK_REGISTRATIONS = [
|
|
14
19
|
{
|
|
15
20
|
eventType: 'PreToolUse',
|
|
16
21
|
name: 'discord-approval',
|
|
17
|
-
|
|
22
|
+
fileName: 'hook.js',
|
|
18
23
|
defaultTimeout: 3600,
|
|
19
24
|
wrapInMatcher: true,
|
|
20
25
|
},
|
|
21
26
|
{
|
|
22
27
|
eventType: 'Stop',
|
|
23
28
|
name: 'discord-approval-stop',
|
|
24
|
-
|
|
29
|
+
fileName: 'stop_hook.js',
|
|
25
30
|
defaultTimeout: 30,
|
|
26
31
|
wrapInMatcher: false,
|
|
27
32
|
},
|
|
28
33
|
];
|
|
29
34
|
|
|
35
|
+
function installHookScripts() {
|
|
36
|
+
for (const reg of HOOK_REGISTRATIONS) {
|
|
37
|
+
const source = fs.readFileSync(path.join(repoRoot, 'hooks', reg.fileName), 'utf8');
|
|
38
|
+
const installedPath = path.join(installedHooksDir, reg.fileName);
|
|
39
|
+
let installed = null;
|
|
40
|
+
try {
|
|
41
|
+
installed = fs.readFileSync(installedPath, 'utf8');
|
|
42
|
+
} catch {}
|
|
43
|
+
if (installed === source) continue;
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(installedHooksDir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(installedPath, source);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// A stale copy still answers agy, so only a missing one is fatal.
|
|
49
|
+
if (installed === null) throw err;
|
|
50
|
+
console.log(`⚠️ Couldn't refresh ${installedPath}: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
const RETIRED_HOOKS = [{ eventType: 'PreInvocation', name: 'wait-ms-before-async-reminder' }];
|
|
31
56
|
|
|
32
57
|
function loadHooksConfig() {
|
|
@@ -34,12 +59,13 @@ function loadHooksConfig() {
|
|
|
34
59
|
return { hooks: {} };
|
|
35
60
|
}
|
|
36
61
|
try {
|
|
37
|
-
|
|
62
|
+
// Windows tools write a BOM, which JSON.parse rejects.
|
|
63
|
+
return JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
38
64
|
} catch (err) {
|
|
39
65
|
const backupPath = `${hooksJsonPath}.corrupted-${Date.now()}`;
|
|
40
66
|
fs.copyFileSync(hooksJsonPath, backupPath);
|
|
41
67
|
console.warn(
|
|
42
|
-
`⚠️ ${hooksJsonPath} was invalid JSON -
|
|
68
|
+
`⚠️ ${hooksJsonPath} was invalid JSON - copied to ${backupPath}. Any hooks it held, including other tools', are about to be replaced.`,
|
|
43
69
|
);
|
|
44
70
|
return { hooks: {} };
|
|
45
71
|
}
|
|
@@ -104,7 +130,7 @@ function removeRetiredHooks(config) {
|
|
|
104
130
|
return removedAny;
|
|
105
131
|
}
|
|
106
132
|
|
|
107
|
-
function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
133
|
+
function registerHook({ allowFirstTimeCreate = true, quiet = false } = {}) {
|
|
108
134
|
const config = loadHooksConfig();
|
|
109
135
|
config.hooks = config.hooks || {};
|
|
110
136
|
|
|
@@ -116,9 +142,11 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
116
142
|
});
|
|
117
143
|
|
|
118
144
|
if (isFirstTime && !allowFirstTimeCreate) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
if (!quiet) {
|
|
146
|
+
console.log(
|
|
147
|
+
"ℹ️ LinkGravity's Discord/Telegram/Slack approval hook isn't registered with agy yet - run `lgy setup` to enable it.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
122
150
|
return;
|
|
123
151
|
}
|
|
124
152
|
|
|
@@ -137,8 +165,12 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
137
165
|
wroteChange = true;
|
|
138
166
|
}
|
|
139
167
|
|
|
168
|
+
installHookScripts();
|
|
169
|
+
|
|
140
170
|
for (const reg of HOOK_REGISTRATIONS) {
|
|
141
|
-
const
|
|
171
|
+
const scriptPath = path.join(installedHooksDir, reg.fileName);
|
|
172
|
+
// Windows agy takes everything after the first token as one argument; POSIX agy uses a shell.
|
|
173
|
+
const command = isWin ? `${NODE_CMD} ${scriptPath}` : `${NODE_CMD} "${scriptPath}"`;
|
|
142
174
|
const hookEntry = findHookEntry(config, reg.eventType, reg.name, reg.wrapInMatcher);
|
|
143
175
|
const isNew = !hookEntry.command;
|
|
144
176
|
|
|
@@ -146,9 +178,7 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
146
178
|
hookEntry.type = 'command';
|
|
147
179
|
hookEntry.timeout = reg.defaultTimeout;
|
|
148
180
|
hookEntry.command = command;
|
|
149
|
-
console.log(
|
|
150
|
-
`🔗 Registered agy ${reg.eventType} hook '${reg.name}' -> ${reg.scriptPath}`,
|
|
151
|
-
);
|
|
181
|
+
console.log(`🔗 Registered agy ${reg.eventType} hook '${reg.name}' -> ${scriptPath}`);
|
|
152
182
|
wroteChange = true;
|
|
153
183
|
} else if (hookEntry.command !== command) {
|
|
154
184
|
backupBeforeFirstChange();
|
|
@@ -159,9 +189,9 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
159
189
|
);
|
|
160
190
|
hookEntry.command = command;
|
|
161
191
|
wroteChange = true;
|
|
162
|
-
} else {
|
|
192
|
+
} else if (!quiet) {
|
|
163
193
|
console.log(
|
|
164
|
-
`🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${
|
|
194
|
+
`🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${scriptPath}`,
|
|
165
195
|
);
|
|
166
196
|
}
|
|
167
197
|
}
|
|
@@ -35,7 +35,7 @@ function venvBin(name) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
module.exports = {
|
|
38
|
-
repoRoot, // where the CODE lives (this checkout/install) - hooks/hook.
|
|
38
|
+
repoRoot, // where the CODE lives (this checkout/install) - hooks/hook.js, src/main.py, etc.
|
|
39
39
|
workspaceDir, // where generated/user DATA lives (venv, logs, lgy.json, wake_refs, ...)
|
|
40
40
|
isWin,
|
|
41
41
|
venvBinDir,
|
package/package.json
CHANGED
package/hooks/hook.py
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import json
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
import urllib.error
|
|
6
|
-
import urllib.request
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
LGY_CONFIG_FILE = Path.home() / ".gemini" / "linkgravity" / "lgy.json"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _load_approve_token():
|
|
13
|
-
try:
|
|
14
|
-
with open(LGY_CONFIG_FILE, encoding="utf-8") as f:
|
|
15
|
-
return json.load(f).get("approve_token", "")
|
|
16
|
-
except Exception:
|
|
17
|
-
return ""
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def main():
|
|
21
|
-
if os.environ.get("LGY_APPROVAL_HOOK") != "1":
|
|
22
|
-
print(json.dumps({"decision": "allow"}))
|
|
23
|
-
return
|
|
24
|
-
|
|
25
|
-
try:
|
|
26
|
-
raw_input = sys.stdin.read()
|
|
27
|
-
hook_input = json.loads(raw_input)
|
|
28
|
-
except Exception:
|
|
29
|
-
print(json.dumps({"decision": "deny", "reason": "Failed to parse hook input."}))
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
tool_call = hook_input.get("toolCall", {})
|
|
33
|
-
tool_name = tool_call.get("name", "unknown_tool")
|
|
34
|
-
|
|
35
|
-
tool_input_data = tool_call.get("args", {})
|
|
36
|
-
conv_id = hook_input.get("conversationId", "unknown")
|
|
37
|
-
|
|
38
|
-
payload = json.dumps(
|
|
39
|
-
{
|
|
40
|
-
"conversation_id": conv_id,
|
|
41
|
-
"tool_name": tool_name,
|
|
42
|
-
"tool_input": tool_input_data,
|
|
43
|
-
"thread_id": os.environ.get("LGY_THREAD_ID"),
|
|
44
|
-
}
|
|
45
|
-
).encode("utf-8")
|
|
46
|
-
|
|
47
|
-
req = urllib.request.Request(
|
|
48
|
-
"http://localhost:18080/approve",
|
|
49
|
-
data=payload,
|
|
50
|
-
headers={"Content-Type": "application/json", "X-LGY-Token": _load_approve_token()},
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
try:
|
|
54
|
-
with urllib.request.urlopen(req, timeout=3600) as response:
|
|
55
|
-
res_data = json.loads(response.read().decode("utf-8"))
|
|
56
|
-
decision = res_data.get("decision", "allow")
|
|
57
|
-
if decision == "allow":
|
|
58
|
-
out = {"decision": "allow"}
|
|
59
|
-
# Print mode requires a matching allow rule even when this hook says "allow", or it soft-denies anyway.
|
|
60
|
-
if res_data.get("permissionOverrides"):
|
|
61
|
-
out["permissionOverrides"] = res_data["permissionOverrides"]
|
|
62
|
-
print(json.dumps(out))
|
|
63
|
-
else:
|
|
64
|
-
reason = res_data.get("reason", "User rejected the action.")
|
|
65
|
-
print(json.dumps({"decision": "deny", "reason": reason}))
|
|
66
|
-
except Exception as e:
|
|
67
|
-
sys.stderr.write(f"Hook error: {e}\n")
|
|
68
|
-
print(json.dumps({"decision": "deny", "reason": f"Connection to webhook failed: {e}"}))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if __name__ == "__main__":
|
|
72
|
-
main()
|
package/hooks/stop_hook.py
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Stop-event hook (https://antigravity.google/docs/hooks#stop).
|
|
3
|
-
|
|
4
|
-
`fullyIdle: false` means a run_command call detached to async (its
|
|
5
|
-
WaitMsBeforeAsync budget ran out) is still in flight. Returning
|
|
6
|
-
{"decision": "continue"} keeps the turn alive so agy can pick up that
|
|
7
|
-
result instead of ending the turn with it lost. Capped by executionNum
|
|
8
|
-
so a genuinely stuck command doesn't loop forever.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import sys
|
|
13
|
-
from datetime import datetime
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
MAX_CONTINUE_ATTEMPTS = 20
|
|
17
|
-
# This runs as its own process, invoked directly by agy - not visible in
|
|
18
|
-
# `lgy logs`. Plain-file logging is the only way to inspect it.
|
|
19
|
-
DEBUG_LOG = Path.home() / ".gemini" / "linkgravity" / "logs" / "stop_hook_debug.log"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def log(line: str):
|
|
23
|
-
try:
|
|
24
|
-
DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
-
with open(DEBUG_LOG, "a", encoding="utf-8") as f:
|
|
26
|
-
f.write(f"{datetime.now().isoformat()} {line}\n")
|
|
27
|
-
except Exception:
|
|
28
|
-
pass
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def main():
|
|
32
|
-
try:
|
|
33
|
-
raw = sys.stdin.read()
|
|
34
|
-
hook_input = json.loads(raw)
|
|
35
|
-
except Exception as e:
|
|
36
|
-
log(f"[PARSE ERROR] {e} raw={raw!r}")
|
|
37
|
-
print(json.dumps({}))
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
fully_idle = hook_input.get("fullyIdle", True)
|
|
41
|
-
execution_num = hook_input.get("executionNum", 0)
|
|
42
|
-
termination_reason = hook_input.get("terminationReason")
|
|
43
|
-
log(
|
|
44
|
-
f"[STOP HOOK] fullyIdle={fully_idle!r} executionNum={execution_num!r} "
|
|
45
|
-
f"terminationReason={termination_reason!r} conv={hook_input.get('conversationId')!r}"
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
if not fully_idle and execution_num < MAX_CONTINUE_ATTEMPTS:
|
|
49
|
-
response = {
|
|
50
|
-
"decision": "continue",
|
|
51
|
-
"reason": (
|
|
52
|
-
"A background command is still running. Wait for it to finish, "
|
|
53
|
-
"then report its actual result to the user before ending your turn."
|
|
54
|
-
),
|
|
55
|
-
}
|
|
56
|
-
log(f"[STOP HOOK] -> continue: {response}")
|
|
57
|
-
print(json.dumps(response))
|
|
58
|
-
else:
|
|
59
|
-
log("[STOP HOOK] -> {} (fullyIdle true or attempt cap reached)")
|
|
60
|
-
print(json.dumps({}))
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if __name__ == "__main__":
|
|
64
|
-
main()
|