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 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, python: venvPython } = require('./venv-paths');
6
+ const { repoRoot, workspaceDir } = require('./venv-paths');
7
7
 
8
8
  const hooksJsonPath = path.join(os.homedir(), '.gemini', 'config', 'hooks.json');
9
9
 
10
- // PreToolUse/Stop meanings are agy's own hook contract: Stop fires when agy is about to
11
- // end a turn, and if fullyIdle is false (an async run_command still in flight), stop_hook.py
12
- // tells agy to keep going instead of losing that result.
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
- scriptPath: path.join(repoRoot, 'hooks', 'hook.py'),
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
- scriptPath: path.join(repoRoot, 'hooks', 'stop_hook.py'),
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
- console.log(
120
- "ℹ️ LinkGravity's Discord/Telegram/Slack approval hook isn't registered with agy yet - run `lgy setup` to enable it.",
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 command = `"${venvPython}" "${reg.scriptPath}"`;
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 -> ${reg.scriptPath}`,
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.py, src/main.py, etc.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "start": "node npm-scripts/run-dev.js",
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()
@@ -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()