linkgravity 1.7.5 → 1.8.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/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], {
@@ -714,6 +728,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
714
728
 
715
729
  if (latestVersion === currentVersion) {
716
730
  repairHookRegistration();
731
+ repairShellCompletion();
717
732
  success(`Already up to date (v${currentVersion}).\n`);
718
733
  process.exit(0);
719
734
  }
@@ -737,6 +752,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
737
752
  delete require.cache[require.resolve('../npm-scripts/ensure-env')];
738
753
  require('../npm-scripts/ensure-env').ensureEnvironment();
739
754
  repairHookRegistration({ fresh: true });
755
+ repairShellCompletion();
740
756
 
741
757
  if (!procBeforeUpdate) {
742
758
  const blocker = launchBlocker();
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.5",
3
+ "version": "1.8.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",
@@ -78,6 +78,8 @@ def _clean_inline(text: str) -> str:
78
78
  async def handle_approve_request(request):
79
79
  # Tracks approval_keys registered this request so the exception handler can clean them up.
80
80
  registered_approval_keys = []
81
+ # Tracks prompts sent this request so a later exception can still finalize (disable) them.
82
+ sent_prompts = []
81
83
  try:
82
84
  data = await request.json()
83
85
  conv_id = data.get("conversation_id")
@@ -151,6 +153,7 @@ async def handle_approve_request(request):
151
153
  prompt = adapter.create_question_prompt(
152
154
  future, question_text, options, multi_select=is_multi_select, allow_write_in=True
153
155
  )
156
+ sent_prompts.append(prompt)
154
157
  await send_ordered(target_thread_id, lambda: prompt.send(target_thread))
155
158
 
156
159
  try:
@@ -188,8 +191,8 @@ async def handle_approve_request(request):
188
191
  if not sub_cmd:
189
192
  continue
190
193
 
191
- is_auto_allowed = False
192
- if "\n" not in sub_cmd and "|" not in sub_cmd:
194
+ is_auto_allowed = session_manager.is_auto_mode()
195
+ if not is_auto_allowed and "\n" not in sub_cmd and "|" not in sub_cmd:
193
196
  try:
194
197
  tokens = shlex.split(sub_cmd)
195
198
  for scope in session_manager.persistent_allowed.get("commands", []):
@@ -234,6 +237,7 @@ async def handle_approve_request(request):
234
237
  prompt = adapter.create_tool_approval_prompt(
235
238
  future, "⚠️ Tool Execution Approval Required", prompt_desc, scope_options
236
239
  )
240
+ sent_prompts.append(prompt)
237
241
 
238
242
  sub_cmd_display, sub_cmd_desc, _ = format_bash_display(sub_cmd)
239
243
  sub_cmd_formatted = f"```text\n{sub_cmd_display}\n```{sub_cmd_desc}"
@@ -270,7 +274,7 @@ async def handle_approve_request(request):
270
274
  return allow_response(tool_name, tool_input)
271
275
 
272
276
  else:
273
- if is_tool_allowed(tool_name, tool_input):
277
+ if session_manager.is_auto_mode() or is_tool_allowed(tool_name, tool_input):
274
278
  if target_thread and tool_msg_text:
275
279
  await send_ordered(
276
280
  target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted)
@@ -287,6 +291,7 @@ async def handle_approve_request(request):
287
291
  prompt = adapter.create_tool_approval_prompt(
288
292
  future, "⚠️ Tool Execution Approval Required", tool_msg_formatted, scope_options
289
293
  )
294
+ sent_prompts.append(prompt)
290
295
 
291
296
  async def _send_prompt():
292
297
  await _send_chunked(adapter, target_thread, tool_msg_formatted)
@@ -316,4 +321,9 @@ async def handle_approve_request(request):
316
321
  logger.exception(f"Error in handle_approve_request: {e}")
317
322
  for key in registered_approval_keys:
318
323
  session_manager.clear_pending_approval(key)
324
+ for prompt in sent_prompts:
325
+ try:
326
+ await prompt.finalize()
327
+ except Exception:
328
+ pass
319
329
  return web.json_response({"decision": "allow"})
@@ -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
  )
package/src/config.py CHANGED
@@ -148,7 +148,7 @@ TMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
148
148
 
149
149
  MAX_EMBED_LEN = 1900
150
150
  STREAM_RATE_LIMIT_SEC = 0.5
151
- APPROVAL_TIMEOUT_SEC = 1800
151
+ APPROVAL_TIMEOUT_SEC = 86400
152
152
  PERSISTENT_FILE = DATA_DIR / "persistent_tools.json"
153
153
  SESSION_FILE = DATA_DIR / "sessions.json"
154
154
 
@@ -229,7 +229,7 @@ async def run_agy(
229
229
  gather_task = asyncio.create_task(_gather_pipes())
230
230
  wait_task = asyncio.create_task(proc.wait())
231
231
 
232
- # Slices let the timeout pause during a pending tool approval (up to 3600s).
232
+ # Slices let the timeout pause indefinitely while any approval is pending (hooks/hook.js waits up to 24h).
233
233
  from config import session_manager as _sm
234
234
 
235
235
  poll_slice = 5.0
@@ -348,7 +348,7 @@ async def agy_new_conversation(
348
348
  content: str, model: str = None, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
349
349
  ) -> tuple[str, str]:
350
350
  # --print consumes the next token as the prompt, so the flag must come first.
351
- args = ["--dangerously-skip-permissions", "--print", content]
351
+ args = ["--dangerously-skip-permissions", "--print", content, "--print-timeout", "24h"]
352
352
  if model:
353
353
  args.extend(["--model", clean_model_name(model)])
354
354
  result_text = await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
@@ -364,7 +364,7 @@ async def agy_send_message(
364
364
  thread_id: str = None,
365
365
  cwd: str = None,
366
366
  ) -> str:
367
- args = ["--dangerously-skip-permissions", "--print", content, "--conversation", conv_id]
367
+ args = ["--dangerously-skip-permissions", "--print", content, "--conversation", conv_id, "--print-timeout", "24h"]
368
368
  if model:
369
369
  args.extend(["--model", clean_model_name(model)])
370
370
  return await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
@@ -20,6 +20,9 @@ class SessionManager:
20
20
  # conv_id -> current approval_key. Keyed by approval_key (not conv_id)
21
21
  # so a 2nd call can't overwrite the 1st's still-pending Future.
22
22
  self.active_approval_by_conv: dict[str, str] = {}
23
+ # Same, but keyed by the platform session id - needed because a brand-new session's
24
+ # conv_id isn't known to us until its first turn fully finishes (see set_pending_approval).
25
+ self.active_approval_by_thread: dict[str, str] = {}
23
26
 
24
27
  self.persistent_allowed: dict = self._load_persistent()
25
28
 
@@ -54,14 +57,22 @@ class SessionManager:
54
57
  def _load_persistent(self) -> dict:
55
58
  from config import logger
56
59
 
57
- data = safe_load_json(self.persistent_file, {"tools": [], "commands": []}, logger=logger)
60
+ data = safe_load_json(self.persistent_file, {"tools": [], "commands": [], "auto_mode": False}, logger=logger)
58
61
  if isinstance(data, list):
59
- return {"tools": data, "commands": []}
62
+ return {"tools": data, "commands": [], "auto_mode": False}
63
+ data.setdefault("auto_mode", False)
60
64
  return data
61
65
 
62
66
  def save_persistent(self):
63
67
  atomic_write_json(self.persistent_file, self.persistent_allowed)
64
68
 
69
+ def is_auto_mode(self) -> bool:
70
+ return bool(self.persistent_allowed.get("auto_mode"))
71
+
72
+ def set_auto_mode(self, enabled: bool):
73
+ self.persistent_allowed["auto_mode"] = enabled
74
+ self.save_persistent()
75
+
65
76
  def register_queue(self, thread_id: str, queue: asyncio.Queue):
66
77
  self.active_queues[str(thread_id)] = queue
67
78
 
@@ -111,12 +122,19 @@ class SessionManager:
111
122
  self.active_tts_tasks.pop(str(thread_id), None)
112
123
 
113
124
  def set_pending_approval(
114
- self, approval_key: str, future: asyncio.Future, app_type: str = "tool", conv_id: str | None = None
125
+ self,
126
+ approval_key: str,
127
+ future: asyncio.Future,
128
+ app_type: str = "tool",
129
+ conv_id: str | None = None,
130
+ thread_id: str | None = None,
115
131
  ):
116
132
  self.pending_approvals[approval_key] = future
117
133
  self.pending_approval_types[approval_key] = app_type
118
134
  if conv_id:
119
135
  self.active_approval_by_conv[conv_id] = approval_key
136
+ if thread_id:
137
+ self.active_approval_by_thread[thread_id] = approval_key
120
138
 
121
139
  def get_pending_approval_by_conv(self, conv_id: str) -> asyncio.Future | None:
122
140
  """Looks up whichever approval is CURRENTLY active for a given
@@ -128,18 +146,35 @@ class SessionManager:
128
146
  return None
129
147
  return self.pending_approvals.get(approval_key)
130
148
 
149
+ def get_pending_approval_by_thread(self, thread_id: str) -> asyncio.Future | None:
150
+ """Same as get_pending_approval_by_conv, but keyed by the platform session id - use this
151
+ for a session that might still be "pending" (conv_id not assigned yet)."""
152
+ approval_key = self.active_approval_by_thread.get(thread_id)
153
+ if not approval_key:
154
+ return None
155
+ return self.pending_approvals.get(approval_key)
156
+
131
157
  def get_pending_approval_type_by_conv(self, conv_id: str) -> str:
132
158
  approval_key = self.active_approval_by_conv.get(conv_id)
133
159
  if not approval_key:
134
160
  return "tool"
135
161
  return self.pending_approval_types.get(approval_key, "tool")
136
162
 
163
+ def get_pending_approval_type_by_thread(self, thread_id: str) -> str:
164
+ approval_key = self.active_approval_by_thread.get(thread_id)
165
+ if not approval_key:
166
+ return "tool"
167
+ return self.pending_approval_types.get(approval_key, "tool")
168
+
137
169
  def clear_pending_approval(self, approval_key: str):
138
170
  self.pending_approvals.pop(approval_key, None)
139
171
  self.pending_approval_types.pop(approval_key, None)
140
172
  self.pending_approval_messages.pop(approval_key, None)
141
- # Only remove the conv_id pointer if it still points at THIS key -
173
+ # Only remove the conv_id/thread_id pointer if it still points at THIS key -
142
174
  # a newer call may have already overwritten it.
143
175
  for conv, key in list(self.active_approval_by_conv.items()):
144
176
  if key == approval_key:
145
177
  del self.active_approval_by_conv[conv]
178
+ for thread, key in list(self.active_approval_by_thread.items()):
179
+ if key == approval_key:
180
+ del self.active_approval_by_thread[thread]
@@ -22,8 +22,15 @@ from utils.utils import (
22
22
  async def handle_approval_reply(incoming: IncomingMessage, session: dict, content: str, pa) -> bool:
23
23
  adapter = get_adapter_for_platform(incoming.platform)
24
24
  thread = incoming.conversation_ref
25
-
26
- if session_manager.get_pending_approval_type_by_conv(incoming.conversation_id) == "ask_question":
25
+ conv_id = session.get("conversation_id")
26
+ # Brand-new session: conv_id isn't assigned yet, so fall back to the platform-keyed lookup.
27
+ approval_type = (
28
+ session_manager.get_pending_approval_type_by_conv(conv_id)
29
+ if conv_id
30
+ else session_manager.get_pending_approval_type_by_thread(incoming.conversation_id)
31
+ )
32
+
33
+ if approval_type == "ask_question":
27
34
  pa.set_result(content)
28
35
  await adapter.send_message(thread, f'✅ *Answer Received (Write in): "{content}"*')
29
36
  return True
@@ -166,14 +173,19 @@ async def handle_thread_reply(bot, incoming: IncomingMessage):
166
173
 
167
174
  agy_content = build_content_with_images(content, image_paths)
168
175
  conv_id = session.get("conversation_id")
169
- pa = session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
170
-
171
- if conv_id and pa and not pa.done():
176
+ # Brand-new session: conv_id isn't assigned until the first turn fully finishes, so fall back to platform id.
177
+ pa = (
178
+ session_manager.get_pending_approval_by_conv(conv_id)
179
+ if conv_id
180
+ else session_manager.get_pending_approval_by_thread(incoming.conversation_id)
181
+ )
182
+
183
+ if pa and not pa.done():
172
184
  handled = await handle_approval_reply(incoming, session, content, pa)
173
185
  if handled:
174
186
  return
175
187
 
176
- has_pending_approval = bool(conv_id and pa and not pa.done())
188
+ has_pending_approval = bool(pa and not pa.done())
177
189
  if session_manager.get_queue(incoming.conversation_id) is not None and not has_pending_approval:
178
190
  from core.agy_runner import stop_active_process
179
191
 
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)
@@ -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)
@@ -164,6 +164,7 @@ class _SlackPromptHandle(PromptHandle):
164
164
  self.channel: str | None = None
165
165
  self.ts: str | None = None
166
166
  self.outcome: ToolApprovalOutcome | None = None
167
+ self.resolved = False
167
168
 
168
169
  async def send(self, conversation_ref: SlackConversationRef) -> dict:
169
170
  try:
@@ -185,6 +186,12 @@ class _SlackPromptHandle(PromptHandle):
185
186
  self._cleanup()
186
187
  if self.ts is None:
187
188
  return
189
+ if not self.resolved:
190
+ # No button click happened - resolve() would have already rewritten text/blocks otherwise.
191
+ self.blocks = [b for b in self.blocks if b.get("type") != "actions"]
192
+ self.blocks.append(
193
+ {"type": "section", "text": {"type": "mrkdwn", "text": "⏰ *Expired - no response in time*"}}
194
+ )
188
195
  try:
189
196
  await self.client.chat_update(channel=self.channel, ts=self.ts, text=self.text, blocks=self.blocks)
190
197
  except SlackApiError as e:
@@ -390,6 +397,7 @@ class SlackAdapter(MessengerAdapter):
390
397
  elements = []
391
398
 
392
399
  async def resolve(decision: str, scope: ScopeOption | None, resp_body: dict, client: AsyncWebClient):
400
+ handle.resolved = True
393
401
  handle.outcome = ToolApprovalOutcome(decision=decision, scope=scope)
394
402
  if not decision_future.done():
395
403
  decision_future.set_result(decision)
@@ -463,6 +471,7 @@ class SlackAdapter(MessengerAdapter):
463
471
  keys: list[str] = []
464
472
 
465
473
  async def resolve(chosen_text: str, note: str, body: dict, client: AsyncWebClient):
474
+ handle.resolved = True
466
475
  if not answer_future.done():
467
476
  answer_future.set_result(chosen_text)
468
477
  new_text = f"✅ *{note}: {chosen_text}*"
@@ -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 [