linkgravity 1.7.4 → 1.8.0

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/README.md CHANGED
@@ -60,7 +60,7 @@ Slack has more moving parts than the others - two separate tokens, and a few set
60
60
  - It's easy to grab the wrong token here - the page also shows a **User OAuth Token** (`xoxp-...`) further down, which is a different thing and won't work for this bot.
61
61
  5. **App Home** (left sidebar) > under **Show Tabs**, turn on **Messages Tab**, then check **Allow users to send Slash commands and messages from the messages tab** - this is what lets you DM the bot at all. (If this section looks greyed out, it's because step 3 hasn't been saved/installed yet - go back and do that first.)
62
62
  6. **Event Subscriptions** (left sidebar) > toggle **Enable Events** on > under **Subscribe to bot events**, add `message.channels`, `message.groups`, `message.im`, and `message.mpim` > **Save Changes**.
63
- 7. **Slash Commands** (left sidebar) > **Create New Command**, four times, for `/new`, `/model`, `/credit`, and `/permissions` (any description/hint text is fine - only the command name matters).
63
+ 7. **Slash Commands** (left sidebar) > **Create New Command**, five times, for `/new`, `/model`, `/credit`, `/permissions`, and `/automode` (any description/hint text is fine - only the command name matters).
64
64
  8. Back on **OAuth & Permissions**, since scopes/events changed after the initial install, click **Reinstall to Workspace** to push those changes live. Any time you change scopes or events later, you'll need to repeat this step.
65
65
  9. In Slack itself, for any **channel** (not DM) you want the bot usable in, run `/invite @<your bot's name>` there first - the bot can't post in a channel it hasn't been added to.
66
66
 
package/bin/cli.js CHANGED
@@ -59,6 +59,20 @@ function repairHookRegistration({ fresh = false } = {}) {
59
59
  }
60
60
  }
61
61
 
62
+ function repairShellCompletion() {
63
+ // Runs on every update too (not just the rarely-rerun setup) so existing installs get cleaned up.
64
+ try {
65
+ const result = require('./completion').installCompletion();
66
+ if (result?.cleaned) {
67
+ console.log(
68
+ `${color.dim}Cleaned up a stale completion line in your shell rc file.${color.reset}`,
69
+ );
70
+ }
71
+ } catch {
72
+ // Best-effort - a broken shell rc file shouldn't fail the update.
73
+ }
74
+ }
75
+
62
76
  function runPm2(args, silent = true) {
63
77
  const stdioOpt = silent ? 'pipe' : 'inherit';
64
78
  const result = spawnSync(process.execPath, [PM2_BIN, ...args], {
@@ -342,7 +356,10 @@ function launchBlocker() {
342
356
  return null;
343
357
  }
344
358
 
345
- function getPm2Proc() {
359
+ // pm2 keys an app by name AND script path, so an install that moved (node version switch, npm prefix
360
+ // change, npm link) registers a second app under the same name instead of replacing the first, and
361
+ // every name-based pm2 command then acts on both at once.
362
+ function getPm2Procs() {
346
363
  const jlist = spawnSync(process.execPath, [PM2_BIN, 'jlist'], { stdio: 'pipe' });
347
364
  if (jlist.status !== 0) return null;
348
365
 
@@ -353,7 +370,7 @@ function getPm2Proc() {
353
370
  if (out[i] !== '[') continue;
354
371
  try {
355
372
  const procs = JSON.parse(out.slice(i));
356
- if (Array.isArray(procs)) return procs.find((p) => p.name === LGY_PM2_NAME) || null;
373
+ if (Array.isArray(procs)) return procs.filter((p) => p.name === LGY_PM2_NAME);
357
374
  } catch (e) {}
358
375
  }
359
376
 
@@ -363,6 +380,71 @@ function getPm2Proc() {
363
380
  return null;
364
381
  }
365
382
 
383
+ function isOurRegistration(proc) {
384
+ const registered = proc.pm2_env.pm_exec_path || '';
385
+ if (!isWin) return registered === LGY_SCRIPT_PATH;
386
+ return registered.toLowerCase() === LGY_SCRIPT_PATH.toLowerCase();
387
+ }
388
+
389
+ function printRegistrations(procs) {
390
+ for (const proc of procs) {
391
+ const mine = isOurRegistration(proc) ? ' <- this install' : '';
392
+ console.log(
393
+ ` ${String(proc.pm2_env.status).padEnd(8)} ${proc.pm2_env.pm_exec_path}${mine}`,
394
+ );
395
+ }
396
+ }
397
+
398
+ let duplicatesReported = false;
399
+ function pickPm2Proc(procs) {
400
+ if (!procs || procs.length === 0) return null;
401
+
402
+ if (procs.length > 1 && !duplicatesReported) {
403
+ duplicatesReported = true;
404
+ console.log(
405
+ `\n${color.yellow}⚠${color.reset} pm2 has ${procs.length} apps registered as '${LGY_PM2_NAME}' - only this install's should be:`,
406
+ );
407
+ printRegistrations(procs);
408
+ console.log(
409
+ ` Everything below reports on one of them. Run ${color.cyan}lgy start${color.reset} to drop the stale ones.\n`,
410
+ );
411
+ }
412
+
413
+ const isOnline = (proc) => proc.pm2_env.status === 'online';
414
+ return (
415
+ procs.find((proc) => isOnline(proc) && isOurRegistration(proc)) ||
416
+ procs.find(isOnline) ||
417
+ procs.find(isOurRegistration) ||
418
+ procs[0]
419
+ );
420
+ }
421
+
422
+ function getPm2Proc() {
423
+ return pickPm2Proc(getPm2Procs());
424
+ }
425
+
426
+ function startDaemon() {
427
+ runPm2([
428
+ 'start',
429
+ LGY_SCRIPT_PATH,
430
+ '--interpreter',
431
+ daemonPython,
432
+ '--name',
433
+ LGY_PM2_NAME,
434
+ '--update-env',
435
+ ]);
436
+ }
437
+
438
+ // The saved autostart list keeps the removed paths until pm2 save runs, so a reboot restores them.
439
+ function clearRegistrations(procs) {
440
+ console.log(
441
+ `\n${color.yellow}⚠${color.reset} pm2 has ${procs.length} app(s) registered as '${LGY_PM2_NAME}', not all from this install:`,
442
+ );
443
+ printRegistrations(procs);
444
+ console.log(' Removing all of them and registering this install alone.\n');
445
+ runPm2(['delete', LGY_PM2_NAME]);
446
+ }
447
+
366
448
  // Best-effort: pm2 has no API for "is this registered to start on boot", so this checks the OS directly and returns null (unknown) if that check itself isn't available.
367
449
  function isAutostartEnabled() {
368
450
  if (isWin) return null;
@@ -403,8 +485,11 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
403
485
  const pkg = require('../package.json');
404
486
  console.log(`linkgravity v${pkg.version}`);
405
487
  } else if (cmd === 'start') {
406
- const existing = getPm2Proc();
407
- if (existing && existing.pm2_env.status === 'online') {
488
+ const registered = getPm2Procs() || [];
489
+ const stale = registered.filter((proc) => !isOurRegistration(proc));
490
+ const existing = registered.find(isOurRegistration);
491
+
492
+ if (!stale.length && existing && existing.pm2_env.status === 'online') {
408
493
  console.log(
409
494
  `\n${color.yellow}⚠${color.reset} LinkGravity is already running. ` +
410
495
  `Use ${color.cyan}lgy restart${color.reset} to apply changes, or ${color.cyan}lgy stop${color.reset} first.\n`,
@@ -425,16 +510,11 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
425
510
 
426
511
  repairHookRegistration();
427
512
 
513
+ if (stale.length) clearRegistrations(registered);
514
+
428
515
  info('Starting LinkGravity daemon...');
429
- runPm2([
430
- 'start',
431
- LGY_SCRIPT_PATH,
432
- '--interpreter',
433
- daemonPython,
434
- '--name',
435
- LGY_PM2_NAME,
436
- '--update-env',
437
- ]);
516
+ startDaemon();
517
+ if (stale.length) runPm2(['save']);
438
518
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
439
519
  } else if (cmd === 'stop') {
440
520
  info('Stopping LinkGravity daemon...');
@@ -442,6 +522,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
442
522
  runPm2(['reset', LGY_PM2_NAME]);
443
523
  success('Daemon stopped successfully.\n');
444
524
  } else if (cmd === 'restart') {
525
+ const registered = getPm2Procs() || [];
526
+ if (registered.some((proc) => !isOurRegistration(proc))) {
527
+ console.log(
528
+ `\n${color.yellow}⚠${color.reset} pm2 has ${registered.length} app(s) registered as '${LGY_PM2_NAME}', and a restart would start every one of them:`,
529
+ );
530
+ printRegistrations(registered);
531
+ console.log(
532
+ ` Run ${color.cyan}lgy start${color.reset} instead - it drops the stale ones first.\n`,
533
+ );
534
+ process.exit(1);
535
+ }
536
+
445
537
  info('Restarting LinkGravity daemon...');
446
538
  runPm2(['restart', LGY_PM2_NAME, '--update-env']);
447
539
  runPm2(['reset', LGY_PM2_NAME]);
@@ -636,12 +728,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
636
728
 
637
729
  if (latestVersion === currentVersion) {
638
730
  repairHookRegistration();
731
+ repairShellCompletion();
639
732
  success(`Already up to date (v${currentVersion}).\n`);
640
733
  process.exit(0);
641
734
  }
642
735
 
643
- const procBeforeUpdate = getPm2Proc();
736
+ const registeredBeforeUpdate = getPm2Procs() || [];
737
+ const procBeforeUpdate = pickPm2Proc(registeredBeforeUpdate);
644
738
  const wasOnline = !!procBeforeUpdate && procBeforeUpdate.pm2_env.status === 'online';
739
+ const hadStale = registeredBeforeUpdate.some((proc) => !isOurRegistration(proc));
645
740
 
646
741
  info(`Updating: v${currentVersion} -> v${latestVersion}...`);
647
742
  const installResult = runNpm(['install', '-g', 'linkgravity@latest'], { stdio: 'inherit' });
@@ -657,6 +752,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
657
752
  delete require.cache[require.resolve('../npm-scripts/ensure-env')];
658
753
  require('../npm-scripts/ensure-env').ensureEnvironment();
659
754
  repairHookRegistration({ fresh: true });
755
+ repairShellCompletion();
660
756
 
661
757
  if (!procBeforeUpdate) {
662
758
  const blocker = launchBlocker();
@@ -666,15 +762,20 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
666
762
  process.exit(0);
667
763
  }
668
764
  info("Daemon wasn't running - starting it fresh...");
669
- runPm2([
670
- 'start',
671
- LGY_SCRIPT_PATH,
672
- '--interpreter',
673
- daemonPython,
674
- '--name',
675
- LGY_PM2_NAME,
676
- '--update-env',
677
- ]);
765
+ startDaemon();
766
+ verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
767
+ } else if (hadStale) {
768
+ clearRegistrations(registeredBeforeUpdate);
769
+ if (!wasOnline) {
770
+ runPm2(['save']);
771
+ success(
772
+ `Daemon was stopped - leaving it stopped. Run 'lgy start' when you're ready.\n`,
773
+ );
774
+ process.exit(0);
775
+ }
776
+ info('Starting the daemon from this install...');
777
+ startDaemon();
778
+ runPm2(['save']);
678
779
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
679
780
  } else if (wasOnline) {
680
781
  info('Restarting daemon to apply the update...');
@@ -753,7 +854,9 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
753
854
  spawnSync(process.argv[0], [process.argv[1], action], { stdio: 'inherit' });
754
855
  })();
755
856
  } else {
756
- console.log(
857
+ // Not stdout: an eval "$(lgy ...)" line in a shell rc would run this message as commands.
858
+ console.error(
757
859
  `\n❌ Unknown command: ${cmd || 'none'}\n💡 Run 'lgy help' to see available commands.`,
758
860
  );
861
+ process.exit(1);
759
862
  }
package/bin/completion.js CHANGED
@@ -29,13 +29,21 @@ function installCompletion(shell = path.basename(process.env.SHELL || '')) {
29
29
 
30
30
  const dest = path.join(os.homedir(), ...target.dest);
31
31
  let rcUpdated = false;
32
+ let cleaned = false;
32
33
  try {
33
34
  fs.mkdirSync(path.dirname(dest), { recursive: true });
34
35
  fs.copyFileSync(path.join(__dirname, 'completions', target.src), dest);
35
36
 
36
37
  if (target.rc) {
37
38
  const rc = path.join(os.homedir(), target.rc);
38
- const existing = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
39
+ let existing = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
40
+ // Strip any pre-marker `eval "$(lgy completion ...)"` line - lgy never had that subcommand.
41
+ const stale = /^\s*eval "\$\(lgy completion[^)]*\)"\s*$/gm;
42
+ if (stale.test(existing)) {
43
+ fs.writeFileSync(rc, existing.replace(stale, '').replace(/\n{3,}/g, '\n\n'));
44
+ existing = fs.readFileSync(rc, 'utf8');
45
+ cleaned = true;
46
+ }
39
47
  if (!existing.includes(MARKER)) {
40
48
  fs.appendFileSync(rc, zshRcBlock(path.dirname(dest)));
41
49
  rcUpdated = true;
@@ -44,7 +52,12 @@ function installCompletion(shell = path.basename(process.env.SHELL || '')) {
44
52
  } catch {
45
53
  return null;
46
54
  }
47
- return { shell, file: dest, rc: rcUpdated ? path.join(os.homedir(), target.rc) : null };
55
+ return {
56
+ shell,
57
+ file: dest,
58
+ rc: rcUpdated ? path.join(os.homedir(), target.rc) : null,
59
+ cleaned,
60
+ };
48
61
  }
49
62
 
50
63
  module.exports = { installCompletion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
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",
@@ -188,8 +188,8 @@ async def handle_approve_request(request):
188
188
  if not sub_cmd:
189
189
  continue
190
190
 
191
- is_auto_allowed = False
192
- if "\n" not in sub_cmd and "|" not in sub_cmd:
191
+ is_auto_allowed = session_manager.is_auto_mode()
192
+ if not is_auto_allowed and "\n" not in sub_cmd and "|" not in sub_cmd:
193
193
  try:
194
194
  tokens = shlex.split(sub_cmd)
195
195
  for scope in session_manager.persistent_allowed.get("commands", []):
@@ -270,7 +270,7 @@ async def handle_approve_request(request):
270
270
  return allow_response(tool_name, tool_input)
271
271
 
272
272
  else:
273
- if is_tool_allowed(tool_name, tool_input):
273
+ if session_manager.is_auto_mode() or is_tool_allowed(tool_name, tool_input):
274
274
  if target_thread and tool_msg_text:
275
275
  await send_ordered(
276
276
  target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted)
@@ -275,6 +275,21 @@ class GeneralCog(commands.Cog):
275
275
  await handle.send(interaction.channel)
276
276
  await interaction.response.send_message("🔐 Permission list posted above.", ephemeral=True)
277
277
 
278
+ @app_commands.command(
279
+ name="automode", description="Auto-allow every approval globally, across all sessions (on/off)"
280
+ )
281
+ @app_commands.describe(state="on or off")
282
+ async def cmd_automode(self, interaction: discord.Interaction, state: str):
283
+ if not allowed(interaction.user.id):
284
+ return await interaction.response.send_message("❌ Denied", ephemeral=True)
285
+ if state.lower() not in ("on", "off"):
286
+ return await interaction.response.send_message("Use `on` or `off`.", ephemeral=True)
287
+
288
+ from services import permissions
289
+
290
+ msg = permissions.set_auto_mode(state.lower() == "on")
291
+ await interaction.response.send_message(msg)
292
+
278
293
  @app_commands.command(
279
294
  name="stop", description="Stop the currently generating response or task (Equivalent to ESC in CLI)"
280
295
  )
@@ -583,7 +583,7 @@ class VoiceCog(commands.Cog):
583
583
  wake_syllables = len(re.sub(r"[^\w가-힣]", "", matched_wake_word or ""))
584
584
  min_prefix_similarity = 0.55 if wake_syllables <= 2 else 0.35
585
585
  if is_waking_up and prefix_similarity is not None and prefix_similarity < min_prefix_similarity:
586
- self.logger.info(
586
+ self.logger.debug(
587
587
  f"STT: ignoring wake - '{text}' doesn't resemble '{matched_wake_word}' "
588
588
  f"(prefix similarity {prefix_similarity:.2f}, needed {min_prefix_similarity:.2f})"
589
589
  )
@@ -54,14 +54,22 @@ class SessionManager:
54
54
  def _load_persistent(self) -> dict:
55
55
  from config import logger
56
56
 
57
- data = safe_load_json(self.persistent_file, {"tools": [], "commands": []}, logger=logger)
57
+ data = safe_load_json(self.persistent_file, {"tools": [], "commands": [], "auto_mode": False}, logger=logger)
58
58
  if isinstance(data, list):
59
- return {"tools": data, "commands": []}
59
+ return {"tools": data, "commands": [], "auto_mode": False}
60
+ data.setdefault("auto_mode", False)
60
61
  return data
61
62
 
62
63
  def save_persistent(self):
63
64
  atomic_write_json(self.persistent_file, self.persistent_allowed)
64
65
 
66
+ def is_auto_mode(self) -> bool:
67
+ return bool(self.persistent_allowed.get("auto_mode"))
68
+
69
+ def set_auto_mode(self, enabled: bool):
70
+ self.persistent_allowed["auto_mode"] = enabled
71
+ self.save_persistent()
72
+
65
73
  def register_queue(self, thread_id: str, queue: asyncio.Queue):
66
74
  self.active_queues[str(thread_id)] = queue
67
75
 
package/src/main_slack.py CHANGED
@@ -83,9 +83,14 @@ async def cmd_model(ack, body, respond, context) -> None:
83
83
  return
84
84
  conversation_id, session = found
85
85
 
86
- from cogs.general_cog import load_cached_models
86
+ import time
87
87
 
88
- cached_models = load_cached_models()
88
+ import cogs.general_cog as general_cog
89
+
90
+ if time.time() - general_cog.last_models_fetch > 3600 and not general_cog.fetching_models:
91
+ general_cog.fetching_models = True
92
+ await general_cog.fetch_models_background()
93
+ cached_models = general_cog.cached_models
89
94
  current_model = session.get("model") or bot_settings.get("default_model")
90
95
 
91
96
  def _apply_model(final_model: str) -> str:
@@ -150,6 +155,24 @@ async def cmd_permissions(ack, body, respond, context) -> None:
150
155
  await handle.send(SlackConversationRef(channel=channel, thread_ts=None))
151
156
 
152
157
 
158
+ async def cmd_automode(ack, body, respond, context) -> None:
159
+ await ack()
160
+ user_id = body["user_id"]
161
+
162
+ if not allowed(user_id, "slack"):
163
+ await respond("❌ Denied")
164
+ return
165
+
166
+ state = (body.get("text") or "").strip().lower()
167
+ if state not in ("on", "off"):
168
+ await respond("Usage: /automode on|off")
169
+ return
170
+
171
+ from services import permissions
172
+
173
+ await respond(permissions.set_auto_mode(state == "on"))
174
+
175
+
153
176
  async def cmd_credit(ack, body, respond, context) -> None:
154
177
  await ack()
155
178
  adapter: SlackAdapter = context["adapter"]
@@ -234,6 +257,7 @@ def build_app() -> tuple[AsyncApp, SlackAdapter]:
234
257
  app.command("/model")(cmd_model)
235
258
  app.command("/credit")(cmd_credit)
236
259
  app.command("/permissions")(cmd_permissions)
260
+ app.command("/automode")(cmd_automode)
237
261
  app.event("message")(on_message)
238
262
  app.action(re.compile(".*"))(on_action)
239
263
  app.view(re.compile(".*"))(on_view_submission)
@@ -258,7 +282,7 @@ async def run_slack(stop_event: asyncio.Event) -> None:
258
282
 
259
283
  app, adapter = build_app()
260
284
  try:
261
- await adapter.resolve_bot_user_id()
285
+ bot_user_id = await adapter.resolve_bot_user_id()
262
286
  except SlackApiError as e:
263
287
  logger.critical(f"Slack auth_test failed - check slack_bot_token: {e}")
264
288
  return
@@ -266,6 +290,9 @@ async def run_slack(stop_event: asyncio.Event) -> None:
266
290
  handler = AsyncSocketModeHandler(app, SLACK_APP_TOKEN)
267
291
  logger.info("✅ Slack bot starting (Socket Mode)...")
268
292
  await handler.connect_async()
293
+ # cli.js's verifyStartup() waits for this exact sentence - without it a Slack-only install
294
+ # never reports a successful startup and every lgy start/restart/update times out.
295
+ logger.info(f"✅ Bot is fully online and ready! Logged in as {bot_user_id}")
269
296
  platform_health.set_status("slack", "running")
270
297
  try:
271
298
  await stop_event.wait()
@@ -95,9 +95,14 @@ async def cmd_model(update: Update, context) -> None:
95
95
  await update.message.reply_text("⚠️ No active session here. Start one with /new first.")
96
96
  return
97
97
 
98
- from cogs.general_cog import load_cached_models
98
+ import time
99
99
 
100
- cached_models = load_cached_models()
100
+ import cogs.general_cog as general_cog
101
+
102
+ if time.time() - general_cog.last_models_fetch > 3600 and not general_cog.fetching_models:
103
+ general_cog.fetching_models = True
104
+ await general_cog.fetch_models_background()
105
+ cached_models = general_cog.cached_models
101
106
  current_model = session.get("model") or bot_settings.get("default_model")
102
107
 
103
108
  def _apply_model(final_model: str) -> str:
@@ -157,6 +162,22 @@ async def cmd_permissions(update: Update, context) -> None:
157
162
  await handle.send(update.effective_chat.id)
158
163
 
159
164
 
165
+ async def cmd_automode(update: Update, context) -> None:
166
+ user = update.effective_user
167
+ if not allowed(user.id, "telegram"):
168
+ await update.message.reply_text("❌ Denied")
169
+ return
170
+
171
+ state = (context.args[0] if context.args else "").lower()
172
+ if state not in ("on", "off"):
173
+ await update.message.reply_text("Usage: /automode on|off")
174
+ return
175
+
176
+ from services import permissions
177
+
178
+ await update.message.reply_text(permissions.set_auto_mode(state == "on"))
179
+
180
+
160
181
  async def cmd_credit(update: Update, context) -> None:
161
182
  user = update.effective_user
162
183
  adapter: TelegramAdapter = context.bot_data["adapter"]
@@ -230,6 +251,7 @@ async def on_ready(app: Application) -> None:
230
251
  BotCommand("model", "Change the AI model for this session"),
231
252
  BotCommand("credit", "Turn AI Credits on/off"),
232
253
  BotCommand("permissions", "View and remove allowed tools and commands"),
254
+ BotCommand("automode", "Auto-allow every approval globally (on/off)"),
233
255
  ]
234
256
  )
235
257
  logger.info(f"✅ Bot is fully online and ready! Logged in as @{app.bot.username}")
@@ -245,6 +267,7 @@ def build_application() -> Application:
245
267
  app.add_handler(CommandHandler("model", cmd_model))
246
268
  app.add_handler(CommandHandler("credit", cmd_credit))
247
269
  app.add_handler(CommandHandler("permissions", cmd_permissions))
270
+ app.add_handler(CommandHandler("automode", cmd_automode))
248
271
  app.add_handler(CallbackQueryHandler(adapter.handle_callback_query))
249
272
  app.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, on_message))
250
273
  app.add_error_handler(on_error)
@@ -5,6 +5,17 @@ from messengers.base import PermissionEntry
5
5
  PAGE_SIZE = 20
6
6
 
7
7
 
8
+ def set_auto_mode(enabled: bool) -> str:
9
+ session_manager.set_auto_mode(enabled)
10
+ if enabled:
11
+ return (
12
+ "⚠️ **Auto-mode ON** — every tool/command approval will be auto-allowed globally, "
13
+ "across all sessions and platforms, until you run `/automode off`. Protected paths "
14
+ "are still blocked regardless."
15
+ )
16
+ return "🔒 Auto-mode OFF — approvals are back to normal."
17
+
18
+
8
19
  def list_entries() -> list[PermissionEntry]:
9
20
  allowed = session_manager.persistent_allowed
10
21
  return [