linkgravity 1.6.1 → 1.7.1
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 +20 -0
- package/bin/completion.js +50 -0
- package/bin/completions/_lgy +33 -0
- package/bin/completions/lgy.bash +18 -0
- package/bin/completions/lgy.fish +32 -0
- package/bin/setup.js +17 -0
- package/hooks/hook.js +105 -0
- package/hooks/stop_hook.js +65 -0
- package/npm-scripts/register-hook.js +44 -16
- 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
|
@@ -40,6 +40,22 @@ function info(msg) {
|
|
|
40
40
|
console.log(`\n${color.cyan}▶${color.reset} ${msg}`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// `fresh`: npm has swapped the package on disk since require time.
|
|
44
|
+
function repairHookRegistration({ fresh = false } = {}) {
|
|
45
|
+
const modulePath = require.resolve('../npm-scripts/register-hook');
|
|
46
|
+
if (fresh) {
|
|
47
|
+
delete require.cache[modulePath];
|
|
48
|
+
delete require.cache[require.resolve('../npm-scripts/venv-paths')];
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
require(modulePath)({ allowFirstTimeCreate: false, quiet: true });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.log(
|
|
54
|
+
`${color.yellow}⚠${color.reset} Couldn't check the agy hook registration: ${err.message}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
function runPm2(args, silent = true) {
|
|
44
60
|
const stdioOpt = silent ? 'pipe' : 'inherit';
|
|
45
61
|
const result = spawnSync(process.execPath, [PM2_BIN, ...args], {
|
|
@@ -392,6 +408,8 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
392
408
|
require('../npm-scripts/ensure-env').ensureEnvironment();
|
|
393
409
|
}
|
|
394
410
|
|
|
411
|
+
repairHookRegistration();
|
|
412
|
+
|
|
395
413
|
info('Starting LinkGravity daemon...');
|
|
396
414
|
runPm2([
|
|
397
415
|
'start',
|
|
@@ -602,6 +620,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
602
620
|
const latestVersion = viewResult.stdout.toString().trim();
|
|
603
621
|
|
|
604
622
|
if (latestVersion === currentVersion) {
|
|
623
|
+
repairHookRegistration();
|
|
605
624
|
success(`Already up to date (v${currentVersion}).\n`);
|
|
606
625
|
process.exit(0);
|
|
607
626
|
}
|
|
@@ -624,6 +643,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
624
643
|
// replaced this package on disk, and the copy required at startup is the pre-update one.
|
|
625
644
|
delete require.cache[require.resolve('../npm-scripts/ensure-env')];
|
|
626
645
|
require('../npm-scripts/ensure-env').ensureEnvironment();
|
|
646
|
+
repairHookRegistration({ fresh: true });
|
|
627
647
|
|
|
628
648
|
if (!procBeforeUpdate) {
|
|
629
649
|
info("Daemon wasn't running - starting it fresh...");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// bash and fish read these directories lazily, on the first completion attempt, so neither needs
|
|
6
|
+
// an rc line. zsh's default fpath has no home-directory entry, so it gets three.
|
|
7
|
+
const TARGETS = {
|
|
8
|
+
bash: { src: 'lgy.bash', dest: ['.local', 'share', 'bash-completion', 'completions', 'lgy'] },
|
|
9
|
+
zsh: { src: '_lgy', dest: ['.local', 'share', 'zsh', 'site-functions', '_lgy'], rc: '.zshrc' },
|
|
10
|
+
fish: { src: 'lgy.fish', dest: ['.config', 'fish', 'completions', 'lgy.fish'] },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const MARKER = '# linkgravity completion';
|
|
14
|
+
|
|
15
|
+
function zshRcBlock(dir) {
|
|
16
|
+
return [
|
|
17
|
+
'',
|
|
18
|
+
MARKER,
|
|
19
|
+
`fpath+=("${dir}")`,
|
|
20
|
+
'autoload -Uz _lgy',
|
|
21
|
+
'whence compdef > /dev/null && compdef _lgy lgy linkgravity',
|
|
22
|
+
'',
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function installCompletion(shell = path.basename(process.env.SHELL || '')) {
|
|
27
|
+
const target = TARGETS[shell];
|
|
28
|
+
if (!target) return null;
|
|
29
|
+
|
|
30
|
+
const dest = path.join(os.homedir(), ...target.dest);
|
|
31
|
+
let rcUpdated = false;
|
|
32
|
+
try {
|
|
33
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
34
|
+
fs.copyFileSync(path.join(__dirname, 'completions', target.src), dest);
|
|
35
|
+
|
|
36
|
+
if (target.rc) {
|
|
37
|
+
const rc = path.join(os.homedir(), target.rc);
|
|
38
|
+
const existing = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
|
|
39
|
+
if (!existing.includes(MARKER)) {
|
|
40
|
+
fs.appendFileSync(rc, zshRcBlock(path.dirname(dest)));
|
|
41
|
+
rcUpdated = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return { shell, file: dest, rc: rcUpdated ? path.join(os.homedir(), target.rc) : null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { installCompletion };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#compdef lgy linkgravity
|
|
2
|
+
|
|
3
|
+
local -a commands
|
|
4
|
+
commands=(
|
|
5
|
+
'version:Print the installed version'
|
|
6
|
+
'start:Start bot in the background'
|
|
7
|
+
'stop:Stop the background bot'
|
|
8
|
+
'restart:Restart the background bot'
|
|
9
|
+
'logs:View bot logs'
|
|
10
|
+
'status:Show daemon status'
|
|
11
|
+
'enable:Start automatically on system boot'
|
|
12
|
+
'disable:Remove bot from system boot'
|
|
13
|
+
'setup:Run the configuration wizard'
|
|
14
|
+
'update:Install a newer version if one exists'
|
|
15
|
+
'help:Show the help message'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if (( CURRENT == 2 )); then
|
|
19
|
+
_describe 'command' commands
|
|
20
|
+
return
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
case "$words[2]" in
|
|
24
|
+
logs)
|
|
25
|
+
[[ "$words[CURRENT-1]" == (-n|--tail) ]] && return
|
|
26
|
+
_values 'flag' \
|
|
27
|
+
'-f[Follow the log output]' \
|
|
28
|
+
'-n[Number of lines to show]' \
|
|
29
|
+
'--tail[Number of lines to show]' \
|
|
30
|
+
'-t[Show timestamps]' \
|
|
31
|
+
'--timestamp[Show timestamps]'
|
|
32
|
+
;;
|
|
33
|
+
esac
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
_lgy_completion() {
|
|
2
|
+
local cur prev
|
|
3
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
4
|
+
prev="${COMP_WORDS[COMP_CWORD - 1]}"
|
|
5
|
+
|
|
6
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
7
|
+
COMPREPLY=($(compgen -W "version start stop restart logs status enable disable setup update help" -- "$cur"))
|
|
8
|
+
return
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
if [ "${COMP_WORDS[1]}" = "logs" ]; then
|
|
12
|
+
case "$prev" in
|
|
13
|
+
-n | --tail) return ;;
|
|
14
|
+
esac
|
|
15
|
+
COMPREPLY=($(compgen -W "-f -n --tail -t --timestamp" -- "$cur"))
|
|
16
|
+
fi
|
|
17
|
+
}
|
|
18
|
+
complete -F _lgy_completion lgy linkgravity
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
complete -c lgy -n __fish_use_subcommand -a version -d 'Print the installed version'
|
|
2
|
+
complete -c lgy -n __fish_use_subcommand -a start -d 'Start bot in the background'
|
|
3
|
+
complete -c lgy -n __fish_use_subcommand -a stop -d 'Stop the background bot'
|
|
4
|
+
complete -c lgy -n __fish_use_subcommand -a restart -d 'Restart the background bot'
|
|
5
|
+
complete -c lgy -n __fish_use_subcommand -a logs -d 'View bot logs'
|
|
6
|
+
complete -c lgy -n __fish_use_subcommand -a status -d 'Show daemon status'
|
|
7
|
+
complete -c lgy -n __fish_use_subcommand -a enable -d 'Start automatically on system boot'
|
|
8
|
+
complete -c lgy -n __fish_use_subcommand -a disable -d 'Remove bot from system boot'
|
|
9
|
+
complete -c lgy -n __fish_use_subcommand -a setup -d 'Run the configuration wizard'
|
|
10
|
+
complete -c lgy -n __fish_use_subcommand -a update -d 'Install a newer version if one exists'
|
|
11
|
+
complete -c lgy -n __fish_use_subcommand -a help -d 'Show the help message'
|
|
12
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -s f -d 'Follow the log output'
|
|
13
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -s n -d 'Number of lines to show'
|
|
14
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -l tail -d 'Number of lines to show'
|
|
15
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -s t -d 'Show timestamps'
|
|
16
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -l timestamp -d 'Show timestamps'
|
|
17
|
+
complete -c linkgravity -n __fish_use_subcommand -a version -d 'Print the installed version'
|
|
18
|
+
complete -c linkgravity -n __fish_use_subcommand -a start -d 'Start bot in the background'
|
|
19
|
+
complete -c linkgravity -n __fish_use_subcommand -a stop -d 'Stop the background bot'
|
|
20
|
+
complete -c linkgravity -n __fish_use_subcommand -a restart -d 'Restart the background bot'
|
|
21
|
+
complete -c linkgravity -n __fish_use_subcommand -a logs -d 'View bot logs'
|
|
22
|
+
complete -c linkgravity -n __fish_use_subcommand -a status -d 'Show daemon status'
|
|
23
|
+
complete -c linkgravity -n __fish_use_subcommand -a enable -d 'Start automatically on system boot'
|
|
24
|
+
complete -c linkgravity -n __fish_use_subcommand -a disable -d 'Remove bot from system boot'
|
|
25
|
+
complete -c linkgravity -n __fish_use_subcommand -a setup -d 'Run the configuration wizard'
|
|
26
|
+
complete -c linkgravity -n __fish_use_subcommand -a update -d 'Install a newer version if one exists'
|
|
27
|
+
complete -c linkgravity -n __fish_use_subcommand -a help -d 'Show the help message'
|
|
28
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -s f -d 'Follow the log output'
|
|
29
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -s n -d 'Number of lines to show'
|
|
30
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -l tail -d 'Number of lines to show'
|
|
31
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -s t -d 'Show timestamps'
|
|
32
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -l timestamp -d 'Show timestamps'
|
package/bin/setup.js
CHANGED
|
@@ -476,6 +476,8 @@ async function runSetup() {
|
|
|
476
476
|
process.exit(0);
|
|
477
477
|
}
|
|
478
478
|
if (consent) registerHook({ allowFirstTimeCreate: true });
|
|
479
|
+
} else {
|
|
480
|
+
registerHook({ allowFirstTimeCreate: false, quiet: true });
|
|
479
481
|
}
|
|
480
482
|
|
|
481
483
|
while (true) {
|
|
@@ -494,6 +496,21 @@ async function runSetup() {
|
|
|
494
496
|
await platformMenu(choice);
|
|
495
497
|
}
|
|
496
498
|
|
|
499
|
+
const { installCompletion } = require('./completion');
|
|
500
|
+
const installed = installCompletion();
|
|
501
|
+
if (installed) {
|
|
502
|
+
p.note(
|
|
503
|
+
[
|
|
504
|
+
`Installed for ${installed.shell} at ${installed.file}.`,
|
|
505
|
+
installed.rc ? `Added a line to ${installed.rc}.` : null,
|
|
506
|
+
'Open a new shell to use it.',
|
|
507
|
+
]
|
|
508
|
+
.filter(Boolean)
|
|
509
|
+
.join('\n'),
|
|
510
|
+
'Tab completion',
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
497
514
|
p.outro('Setup complete.');
|
|
498
515
|
}
|
|
499
516
|
|
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 } = 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() {
|
|
@@ -104,7 +129,7 @@ function removeRetiredHooks(config) {
|
|
|
104
129
|
return removedAny;
|
|
105
130
|
}
|
|
106
131
|
|
|
107
|
-
function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
132
|
+
function registerHook({ allowFirstTimeCreate = true, quiet = false } = {}) {
|
|
108
133
|
const config = loadHooksConfig();
|
|
109
134
|
config.hooks = config.hooks || {};
|
|
110
135
|
|
|
@@ -116,9 +141,11 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
116
141
|
});
|
|
117
142
|
|
|
118
143
|
if (isFirstTime && !allowFirstTimeCreate) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
144
|
+
if (!quiet) {
|
|
145
|
+
console.log(
|
|
146
|
+
"ℹ️ LinkGravity's Discord/Telegram/Slack approval hook isn't registered with agy yet - run `lgy setup` to enable it.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
122
149
|
return;
|
|
123
150
|
}
|
|
124
151
|
|
|
@@ -137,8 +164,11 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
137
164
|
wroteChange = true;
|
|
138
165
|
}
|
|
139
166
|
|
|
167
|
+
installHookScripts();
|
|
168
|
+
|
|
140
169
|
for (const reg of HOOK_REGISTRATIONS) {
|
|
141
|
-
const
|
|
170
|
+
const scriptPath = path.join(installedHooksDir, reg.fileName);
|
|
171
|
+
const command = `${NODE_CMD} "${scriptPath}"`;
|
|
142
172
|
const hookEntry = findHookEntry(config, reg.eventType, reg.name, reg.wrapInMatcher);
|
|
143
173
|
const isNew = !hookEntry.command;
|
|
144
174
|
|
|
@@ -146,9 +176,7 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
146
176
|
hookEntry.type = 'command';
|
|
147
177
|
hookEntry.timeout = reg.defaultTimeout;
|
|
148
178
|
hookEntry.command = command;
|
|
149
|
-
console.log(
|
|
150
|
-
`🔗 Registered agy ${reg.eventType} hook '${reg.name}' -> ${reg.scriptPath}`,
|
|
151
|
-
);
|
|
179
|
+
console.log(`🔗 Registered agy ${reg.eventType} hook '${reg.name}' -> ${scriptPath}`);
|
|
152
180
|
wroteChange = true;
|
|
153
181
|
} else if (hookEntry.command !== command) {
|
|
154
182
|
backupBeforeFirstChange();
|
|
@@ -159,9 +187,9 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
159
187
|
);
|
|
160
188
|
hookEntry.command = command;
|
|
161
189
|
wroteChange = true;
|
|
162
|
-
} else {
|
|
190
|
+
} else if (!quiet) {
|
|
163
191
|
console.log(
|
|
164
|
-
`🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${
|
|
192
|
+
`🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${scriptPath}`,
|
|
165
193
|
);
|
|
166
194
|
}
|
|
167
195
|
}
|
|
@@ -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()
|