linkgravity 1.7.2 → 1.7.4

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
@@ -14,7 +14,7 @@ const {
14
14
  LGY_SCRIPT_PATH,
15
15
  } = require('./platforms');
16
16
 
17
- const { python: pythonExe, isWin } = require('../npm-scripts/venv-paths');
17
+ const { daemonPython, isWin } = require('../npm-scripts/venv-paths');
18
18
  const { isEnvironmentReady } = require('../npm-scripts/ensure-env');
19
19
 
20
20
  const cmd = process.argv[2];
@@ -203,15 +203,34 @@ function verifyStartup() {
203
203
  cwd: PM2_CWD,
204
204
  });
205
205
 
206
+ const baseline = getPm2Proc()?.pm2_env?.restart_time ?? 0;
207
+
206
208
  let settled = false;
207
209
  const finish = (ok) => {
208
210
  if (settled) return;
209
211
  settled = true;
210
212
  clearTimeout(timer);
213
+ clearInterval(watchdog);
211
214
  cp.kill();
212
215
  resolve(ok);
213
216
  };
214
217
 
218
+ // A process that exits on startup never logs anything for the stream below to match, so
219
+ // pm2's own counters are the only signal that it is dying and being restarted.
220
+ const watchdog = setInterval(() => {
221
+ const proc = getPm2Proc();
222
+ if (!proc) return;
223
+ const { status, restart_time: restarts = 0 } = proc.pm2_env;
224
+ if (status === 'errored' || restarts > baseline) {
225
+ console.log(
226
+ `\n\n${color.yellow}❌ The daemon keeps exiting - pm2 has restarted it ` +
227
+ `${restarts - baseline} time(s) (status: ${status}).${color.reset}`,
228
+ );
229
+ console.log(` Run ${color.cyan}lgy logs${color.reset} to see why.\n`);
230
+ finish(false);
231
+ }
232
+ }, 2000);
233
+
215
234
  let timer = setTimeout(() => {
216
235
  console.log(
217
236
  `\n\n${color.yellow}⏳ Startup verification timed out. Run 'lgy logs' to check status manually.${color.reset}`,
@@ -302,6 +321,27 @@ function findAgyBin() {
302
321
  return null;
303
322
  }
304
323
 
324
+ // Returns null when the daemon can be started, or the reason it can't.
325
+ function launchBlocker() {
326
+ const settings = getSettings();
327
+ const anyConfigured = Object.keys(PLATFORMS).some(
328
+ (key) => platformState(key, settings).configured,
329
+ );
330
+ if (!anyConfigured) {
331
+ return (
332
+ 'No messenger is configured yet - set up at least one of Discord, Telegram, or ' +
333
+ `Slack first: ${color.cyan}lgy setup${color.reset}`
334
+ );
335
+ }
336
+ if (!findAgyBin()) {
337
+ return (
338
+ "Couldn't find the agy CLI (checked $AGY_BIN_PATH, ~/.local/bin/agy, and PATH). " +
339
+ 'Install/configure agy first, or set the AGY_BIN_PATH environment variable to its location.'
340
+ );
341
+ }
342
+ return null;
343
+ }
344
+
305
345
  function getPm2Proc() {
306
346
  const jlist = spawnSync(process.execPath, [PM2_BIN, 'jlist'], { stdio: 'pipe' });
307
347
  if (jlist.status !== 0) return null;
@@ -372,24 +412,9 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
372
412
  process.exit(1);
373
413
  }
374
414
 
375
- const settings = getSettings();
376
- const anyConfigured = Object.keys(PLATFORMS).some(
377
- (key) => platformState(key, settings).configured,
378
- );
379
- if (!anyConfigured) {
380
- console.log(
381
- `\n${color.yellow}⚠${color.reset} No messenger is configured yet - ` +
382
- `set up at least one of Discord, Telegram, or Slack first: ${color.cyan}lgy setup${color.reset}\n`,
383
- );
384
- process.exit(1);
385
- }
386
-
387
- if (!findAgyBin()) {
388
- console.log(
389
- `\n${color.yellow}⚠${color.reset} Couldn't find the agy CLI ` +
390
- `(checked $AGY_BIN_PATH, ~/.local/bin/agy, and PATH). Install/configure agy first, ` +
391
- `or set the AGY_BIN_PATH environment variable to its location.\n`,
392
- );
415
+ const blocker = launchBlocker();
416
+ if (blocker) {
417
+ console.log(`\n${color.yellow}⚠${color.reset} ${blocker}\n`);
393
418
  process.exit(1);
394
419
  }
395
420
 
@@ -405,7 +430,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
405
430
  'start',
406
431
  LGY_SCRIPT_PATH,
407
432
  '--interpreter',
408
- pythonExe,
433
+ daemonPython,
409
434
  '--name',
410
435
  LGY_PM2_NAME,
411
436
  '--update-env',
@@ -634,12 +659,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
634
659
  repairHookRegistration({ fresh: true });
635
660
 
636
661
  if (!procBeforeUpdate) {
662
+ const blocker = launchBlocker();
663
+ if (blocker) {
664
+ console.log(`\n${color.yellow}⚠${color.reset} ${blocker}\n`);
665
+ success('Update finished - the daemon was left stopped.\n');
666
+ process.exit(0);
667
+ }
637
668
  info("Daemon wasn't running - starting it fresh...");
638
669
  runPm2([
639
670
  'start',
640
671
  LGY_SCRIPT_PATH,
641
672
  '--interpreter',
642
- pythonExe,
673
+ daemonPython,
643
674
  '--name',
644
675
  LGY_PM2_NAME,
645
676
  '--update-env',
package/bin/pm2.js CHANGED
@@ -10,6 +10,9 @@ function pm2Env() {
10
10
  ...process.env,
11
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
12
  PYTHONUNBUFFERED: '1',
13
+ // Without this Python inherits the console codepage (cp949 on Korean Windows) and loguru
14
+ // drops every line it can't encode - including the one verifyStartup waits for.
15
+ PYTHONIOENCODING: 'utf-8',
13
16
  // pm2 merges --update-env rather than replacing, so a LOG_LEVEL from an earlier run
14
17
  // survives unless a value is passed every time.
15
18
  LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
package/bin/setup.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const p = require('@clack/prompts');
2
2
  const { spawnSync } = require('child_process');
3
- const { python: pythonExe } = require('../npm-scripts/venv-paths');
3
+ const { daemonPython } = require('../npm-scripts/venv-paths');
4
4
  const { PM2_BIN, PM2_CWD, pm2Env } = require('./pm2');
5
5
  const {
6
6
  getSettings,
@@ -224,7 +224,7 @@ function startOrRestartDaemon(pm2Name, scriptPath, label) {
224
224
  'start',
225
225
  scriptPath,
226
226
  '--interpreter',
227
- pythonExe,
227
+ daemonPython,
228
228
  '--name',
229
229
  pm2Name,
230
230
  ]);
package/hooks/hook.js CHANGED
@@ -12,7 +12,9 @@ const APPROVE_PORT = 18080;
12
12
  const TIMEOUT_MS = 3600 * 1000;
13
13
 
14
14
  function emit(payload) {
15
- process.stdout.write(JSON.stringify(payload));
15
+ // Exits explicitly: the keep-alive socket and its hour-long timer stay open after the
16
+ // response arrives, and agy SIGABRTs the process instead of waiting for them to expire.
17
+ process.stdout.write(JSON.stringify(payload), () => process.exit(0));
16
18
  }
17
19
 
18
20
  function loadApproveToken() {
@@ -19,7 +19,7 @@ function log(line) {
19
19
  }
20
20
 
21
21
  function emit(payload) {
22
- process.stdout.write(JSON.stringify(payload));
22
+ process.stdout.write(JSON.stringify(payload), () => process.exit(0));
23
23
  }
24
24
 
25
25
  async function readStdin() {
@@ -40,6 +40,10 @@ module.exports = {
40
40
  isWin,
41
41
  venvBinDir,
42
42
  python: venvBin('python'),
43
+ // Only for pm2's --interpreter: pm2 spawns with detached:true, which on Windows forces a
44
+ // console window and ignores its own windowsHide option. pythonw is GUI-subsystem so no
45
+ // console is ever allocated, and pm2 pipes stdio anyway so no output is lost.
46
+ daemonPython: venvBin(isWin ? 'pythonw' : 'python'),
43
47
  pip: venvBin('pip'),
44
48
  preCommit: venvBin('pre-commit'),
45
49
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
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",
@@ -51,7 +51,7 @@ async def fetch_models_background():
51
51
  import time
52
52
 
53
53
  try:
54
- from config import AGY_BIN
54
+ from config import AGY_BIN, CREATION_FLAGS
55
55
 
56
56
  logger.debug(f"Starting fetch_models_background using AGY_BIN: {AGY_BIN}")
57
57
  env = os.environ.copy()
@@ -63,6 +63,7 @@ async def fetch_models_background():
63
63
  stderr=asyncio.subprocess.PIPE,
64
64
  stdin=asyncio.subprocess.DEVNULL,
65
65
  env=env,
66
+ creationflags=CREATION_FLAGS,
66
67
  )
67
68
  try:
68
69
  stdout, stderr = await asyncio.wait_for(p.communicate(), timeout=30.0)
package/src/config.py CHANGED
@@ -1,5 +1,7 @@
1
1
  import os
2
2
  import secrets
3
+ import shutil
4
+ import subprocess
3
5
  from pathlib import Path
4
6
 
5
7
  from core.atomic_io import atomic_write_json, safe_load_json
@@ -158,7 +160,22 @@ MODEL_CHOICES = {
158
160
  "pro": "Gemini 3.1 Pro",
159
161
  }
160
162
 
161
- AGY_BIN = os.getenv("AGY_BIN_PATH", str(Path.home() / ".local/bin/agy"))
163
+
164
+ def _resolve_agy_bin() -> str:
165
+ fallback = str(Path.home() / ".local/bin/agy")
166
+ for candidate in (os.getenv("AGY_BIN_PATH"), fallback):
167
+ if candidate and Path(candidate).is_file():
168
+ return candidate
169
+ # Windows installs agy under AppData and adds it to PATH instead.
170
+ return shutil.which("agy") or fallback
171
+
172
+
173
+ # Must stay in sync with findAgyBin() in bin/cli.js.
174
+ AGY_BIN = _resolve_agy_bin()
175
+
176
+ # pm2 spawns the daemon with detached:true, so on Windows it owns no console; without this
177
+ # flag every child it launches gets a console window of its own.
178
+ CREATION_FLAGS = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
162
179
 
163
180
  session_manager = SessionManager(DATA_DIR)
164
181
 
@@ -163,7 +163,7 @@ async def run_agy(
163
163
  if os.name == "nt":
164
164
  import subprocess
165
165
 
166
- kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
166
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
167
167
  elif hasattr(os, "setsid"):
168
168
  kwargs["preexec_fn"] = os.setsid
169
169
 
@@ -11,7 +11,7 @@ from functools import partial
11
11
  import discord
12
12
  from discord.ext import commands
13
13
 
14
- from config import DISCORD_TOKEN, logger, session_manager
14
+ from config import CREATION_FLAGS, DISCORD_TOKEN, logger, session_manager
15
15
  from handlers.message_router import handle_message
16
16
  from messengers.discord_adapter import DiscordAdapter
17
17
  from messengers.registry import register_adapter
@@ -128,7 +128,7 @@ async def status_updater_task():
128
128
 
129
129
 
130
130
  def _spawn_voice_process(voice_dir: str) -> subprocess.Popen:
131
- return subprocess.Popen(["node", "index.js"], cwd=voice_dir)
131
+ return subprocess.Popen(["node", "index.js"], cwd=voice_dir, creationflags=CREATION_FLAGS)
132
132
 
133
133
 
134
134
  async def _supervise_voice_process(voice_dir: str):
@@ -1,7 +1,7 @@
1
1
  from pathlib import Path
2
2
  from typing import Any
3
3
 
4
- from config import MAX_EMBED_LEN, MODEL_CHOICES, session_manager
4
+ from config import MAX_EMBED_LEN, bot_settings, session_manager
5
5
  from messengers.registry import get_adapter_for_platform
6
6
  from services.discord_helpers import split_message
7
7
  from utils.utils import get_current_model
@@ -18,8 +18,8 @@ async def send_agy_response(
18
18
  adapter = get_adapter_for_platform(session.get("platform", "discord"))
19
19
  session_manager.save_sessions()
20
20
 
21
- session_model = session.get("model")
22
- model_display = MODEL_CHOICES.get(session_model, session_model) if session_model else get_current_model()
21
+ # Same precedence as the /model autocomplete in general_cog.
22
+ model_display = session.get("model") or bot_settings.get("default_model") or get_current_model()
23
23
 
24
24
  parts = split_message(response_text, MAX_EMBED_LEN)
25
25
  for idx, part in enumerate(parts):