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 CHANGED
@@ -32,30 +32,39 @@ function success(msg) {
32
32
  console.log(`${color.green}✔${color.reset} ${msg}`);
33
33
  }
34
34
 
35
- // Resolved rather than run through npx: pm2 is a direct dependency, and npx wraps it in
36
- // "npm exec" + "sh -c", which leaves the real process orphaned when we try to kill it.
37
- const PM2_BIN = require.resolve('pm2/bin/pm2');
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: path.join(__dirname, '..'),
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: path.join(__dirname, '..') });
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: path.join(__dirname, '..'),
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 = spawnSync('npm', ['view', 'linkgravity', 'version'], {
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 = spawnSync('npm', ['view', 'linkgravity', 'version'], { stdio: 'pipe' });
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 = spawnSync('npm', ['install', '-g', 'linkgravity@latest'], {
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
- spawnSync('npx', ['-y', 'pm2', 'delete', pm2Name], { stdio: 'pipe' });
192
+ runPm2(['delete', pm2Name]);
184
193
 
185
- const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
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 \`npx pm2 delete ${pm2Name}\` manually and check \`npx pm2 list\`.`,
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 = spawnSync('npx', ['-y', 'pm2', 'restart', pm2Name, '--update-env'], {
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 = spawnSync(
218
- 'npx',
219
- ['-y', 'pm2', 'start', scriptPath, '--interpreter', pythonExe, '--name', pm2Name],
220
- { stdio: 'pipe', env: { ...process.env, PYTHONUNBUFFERED: '1' } },
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, python: venvPython } = require('./venv-paths');
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
- // 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() {
@@ -34,12 +59,13 @@ function loadHooksConfig() {
34
59
  return { hooks: {} };
35
60
  }
36
61
  try {
37
- return JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
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 - backed up to ${backupPath} and starting fresh.`,
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
- console.log(
120
- "ℹ️ LinkGravity's Discord/Telegram/Slack approval hook isn't registered with agy yet - run `lgy setup` to enable it.",
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 command = `"${venvPython}" "${reg.scriptPath}"`;
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 -> ${reg.scriptPath}`,
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.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.7.0",
3
+ "version": "1.7.2",
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()