linkgravity 1.5.10 → 1.5.12

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,17 +32,27 @@ 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');
38
+
35
39
  function info(msg) {
36
40
  console.log(`\n${color.cyan}▶${color.reset} ${msg}`);
37
41
  }
38
42
 
39
43
  function runPm2(args, silent = true) {
40
44
  const stdioOpt = silent ? 'pipe' : 'inherit';
41
- const result = spawnSync('npx', ['-y', 'pm2', ...args], {
45
+ const result = spawnSync(process.execPath, [PM2_BIN, ...args], {
42
46
  stdio: stdioOpt,
43
47
  cwd: path.join(__dirname, '..'),
44
- // pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
45
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
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
+ // Version managers (fnm, nvm) put node on PATH from a shell hook the daemon never runs,
53
+ // so the bot's own `node` lookup for voice-service would fail without this.
54
+ PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`,
55
+ },
46
56
  });
47
57
 
48
58
  if (result.error) {
@@ -119,7 +129,7 @@ function colorizeLevel(line) {
119
129
  }
120
130
 
121
131
  function runPm2LogsStream(args, printLine) {
122
- const cp = spawn('npx', ['-y', 'pm2', ...args], { cwd: path.join(__dirname, '..') });
132
+ const cp = spawn(process.execPath, [PM2_BIN, ...args], { cwd: path.join(__dirname, '..') });
123
133
 
124
134
  const isNoise = (line) =>
125
135
  line.trim().length === 0 ||
@@ -160,6 +170,8 @@ function runPm2LogsStream(args, printLine) {
160
170
  stdoutHandler.flush();
161
171
  stderrHandler.flush();
162
172
  });
173
+ // Ctrl-C reaches pm2 too (same process group), but an unhandled parent exit would leave it tailing.
174
+ process.on('exit', () => cp.kill());
163
175
  }
164
176
 
165
177
  function runPm2LogsClean(args, showStamps = false) {
@@ -175,10 +187,7 @@ function verifyStartup() {
175
187
  `${color.cyan}▶${color.reset} Verifying startup status (waiting for bot to come online)...`,
176
188
  );
177
189
 
178
- // Spawned directly instead of through npx: npx wraps pm2 in "npm exec" + "sh -c", so cp.kill()
179
- // reaps only the wrapper and leaves the real pm2 logs process orphaned onto init.
180
- const pm2Bin = require.resolve('pm2/bin/pm2');
181
- let cp = spawn(process.execPath, [pm2Bin, 'logs', LGY_PM2_NAME, '--raw', '--lines', '0'], {
190
+ let cp = spawn(process.execPath, [PM2_BIN, 'logs', LGY_PM2_NAME, '--raw', '--lines', '0'], {
182
191
  cwd: path.join(__dirname, '..'),
183
192
  });
184
193
 
@@ -282,7 +291,7 @@ function findAgyBin() {
282
291
  }
283
292
 
284
293
  function getPm2Proc() {
285
- const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
294
+ const jlist = spawnSync(process.execPath, [PM2_BIN, 'jlist'], { stdio: 'pipe' });
286
295
  if (jlist.status !== 0) return null;
287
296
 
288
297
  // pm2 can print noise before the real JSON (version banners, ANSI escapes, daemon-spawn logs) that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.10",
3
+ "version": "1.5.12",
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",
@@ -13,6 +13,10 @@ _intentionally_stopped = set()
13
13
 
14
14
  _STDOUT_BUFFER_SIZE = 65536
15
15
 
16
+ # Returned as ordinary output, not raised - callers that need to distinguish failure from a
17
+ # real answer have to compare against this.
18
+ TIMEOUT_MESSAGE = "🛑 AI Task timed out."
19
+
16
20
 
17
21
  @functools.lru_cache(maxsize=1)
18
22
  def _find_preload_lib() -> tuple[str, str] | None:
@@ -315,7 +319,7 @@ async def run_agy(
315
319
  logger.warning("[AGY RETRY] Global timeout. Retrying...")
316
320
  await asyncio.sleep(2.0)
317
321
  continue
318
- msg = "🛑 AI Task timed out."
322
+ msg = TIMEOUT_MESSAGE
319
323
  if stream_queue is not None:
320
324
  await stream_queue.put(("\n\n" + msg, True))
321
325
  return msg
@@ -388,7 +392,7 @@ async def generate_thread_title(user_input: str, response: str) -> str:
388
392
  )
389
393
  title = await run_agy("--print", prompt, timeout=30)
390
394
  title = title.strip().strip('"').strip("'")
391
- if not title or len(title) > 80:
395
+ if not title or len(title) > 80 or title == TIMEOUT_MESSAGE:
392
396
  return fallback
393
397
  return title
394
398
  except Exception as e:
@@ -73,13 +73,9 @@ async def handle_pending_session(
73
73
  await queue.put(("__END__", True))
74
74
  await stream_task
75
75
 
76
- response_text = result_text
77
- if adapter.should_auto_title(thread):
78
- new_title = await generate_thread_title(content, response_text)
79
- await adapter.rename_conversation(thread, new_title)
80
- await update_agy_conversation_title(new_conv_id, new_title)
76
+ needs_title = adapter.should_auto_title(thread)
81
77
 
82
- response_text = await render_thought_process(new_conv_id, ctx, response_text, thread)
78
+ response_text = await render_thought_process(new_conv_id, ctx, result_text, thread)
83
79
 
84
80
  session["conversation_id"] = new_conv_id
85
81
  session["created_at"] = datetime.now().isoformat()
@@ -87,6 +83,13 @@ async def handle_pending_session(
87
83
  session_manager.save_sessions()
88
84
 
89
85
  await send_agy_response(thread, response_text, session, ctx, start_time, new_conv_id)
86
+
87
+ # Outside the typing indicator: the answer is already on screen, and leaving "typing"
88
+ # up during the extra title call reads as if more output is still coming.
89
+ if needs_title:
90
+ new_title = await generate_thread_title(content, result_text)
91
+ await adapter.rename_conversation(thread, new_title)
92
+ await update_agy_conversation_title(new_conv_id, new_title)
90
93
  finally:
91
94
  cleanup_images(image_paths)
92
95