linkgravity 1.8.0 → 1.9.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/hooks/hook.js CHANGED
@@ -9,7 +9,7 @@ const LGY_CONFIG_FILE = path.join(os.homedir(), '.gemini', 'linkgravity', 'lgy.j
9
9
  // Not "localhost" - the server binds 127.0.0.1 and node resolves localhost to ::1 first.
10
10
  const APPROVE_HOST = '127.0.0.1';
11
11
  const APPROVE_PORT = 18080;
12
- const TIMEOUT_MS = 3600 * 1000;
12
+ const TIMEOUT_MS = 86400 * 1000;
13
13
 
14
14
  function emit(payload) {
15
15
  // Exits explicitly: the keep-alive socket and its hour-long timer stay open after the
@@ -20,7 +20,7 @@ const HOOK_REGISTRATIONS = [
20
20
  eventType: 'PreToolUse',
21
21
  name: 'discord-approval',
22
22
  fileName: 'hook.js',
23
- defaultTimeout: 3600,
23
+ defaultTimeout: 86400,
24
24
  wrapInMatcher: true,
25
25
  },
26
26
  {
@@ -130,7 +130,7 @@ function removeRetiredHooks(config) {
130
130
  return removedAny;
131
131
  }
132
132
 
133
- function registerHook({ allowFirstTimeCreate = true, quiet = false } = {}) {
133
+ function registerHook({ allowFirstTimeCreate = true, quiet = false, resetTimeouts = false } = {}) {
134
134
  const config = loadHooksConfig();
135
135
  config.hooks = config.hooks || {};
136
136
 
@@ -180,19 +180,34 @@ function registerHook({ allowFirstTimeCreate = true, quiet = false } = {}) {
180
180
  hookEntry.command = command;
181
181
  console.log(`🔗 Registered agy ${reg.eventType} hook '${reg.name}' -> ${scriptPath}`);
182
182
  wroteChange = true;
183
- } else if (hookEntry.command !== command) {
184
- backupBeforeFirstChange();
185
- console.log(
186
- `🔗 Fixing agy ${reg.eventType} hook '${reg.name}' in ${hooksJsonPath}` +
187
- (backedUp ? ` (previous version backed up to ${hooksJsonPath}.bak)` : '') +
188
- `:\n was: ${hookEntry.command}\n now: ${command}`,
189
- );
190
- hookEntry.command = command;
191
- wroteChange = true;
192
- } else if (!quiet) {
193
- console.log(
194
- `🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${scriptPath}`,
195
- );
183
+ } else {
184
+ let changedThisEntry = false;
185
+ if (hookEntry.command !== command) {
186
+ backupBeforeFirstChange();
187
+ console.log(
188
+ `🔗 Fixing agy ${reg.eventType} hook '${reg.name}' in ${hooksJsonPath}` +
189
+ (backedUp ? ` (previous version backed up to ${hooksJsonPath}.bak)` : '') +
190
+ `:\n was: ${hookEntry.command}\n now: ${command}`,
191
+ );
192
+ hookEntry.command = command;
193
+ changedThisEntry = true;
194
+ }
195
+ // Only setup resets timeout - a passive `update` must not clobber a user-customized value.
196
+ if (resetTimeouts && hookEntry.timeout !== reg.defaultTimeout) {
197
+ backupBeforeFirstChange();
198
+ console.log(
199
+ `🔗 Resetting agy ${reg.eventType} hook '${reg.name}' timeout: ${hookEntry.timeout}s -> ${reg.defaultTimeout}s`,
200
+ );
201
+ hookEntry.timeout = reg.defaultTimeout;
202
+ changedThisEntry = true;
203
+ }
204
+ if (changedThisEntry) {
205
+ wroteChange = true;
206
+ } else if (!quiet) {
207
+ console.log(
208
+ `🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${scriptPath}`,
209
+ );
210
+ }
196
211
  }
197
212
  }
198
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.8.0",
3
+ "version": "1.9.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",
@@ -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:
@@ -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}"
@@ -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"})
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
 
@@ -208,13 +208,6 @@ async def run_agy(
208
208
  stdout_chunks.append(clean)
209
209
  if stream_queue is not None:
210
210
  await stream_queue.put((clean, False))
211
- if (
212
- "(Calls tool:" in clean
213
- or "Tool Output:" in clean
214
- or "Tool Execute:" in clean
215
- or clean.startswith("● ")
216
- ):
217
- await stream_queue.put(("__SPLIT__", True))
218
211
 
219
212
  async def read_stderr():
220
213
  while True:
@@ -229,7 +222,7 @@ async def run_agy(
229
222
  gather_task = asyncio.create_task(_gather_pipes())
230
223
  wait_task = asyncio.create_task(proc.wait())
231
224
 
232
- # Slices let the timeout pause during a pending tool approval (up to 3600s).
225
+ # Slices let the timeout pause while an approval is pending (hook.js waits up to 24h).
233
226
  from config import session_manager as _sm
234
227
 
235
228
  poll_slice = 5.0
@@ -348,7 +341,7 @@ async def agy_new_conversation(
348
341
  content: str, model: str = None, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
349
342
  ) -> tuple[str, str]:
350
343
  # --print consumes the next token as the prompt, so the flag must come first.
351
- args = ["--dangerously-skip-permissions", "--print", content]
344
+ args = ["--dangerously-skip-permissions", "--print", content, "--print-timeout", "24h"]
352
345
  if model:
353
346
  args.extend(["--model", clean_model_name(model)])
354
347
  result_text = await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
@@ -364,7 +357,7 @@ async def agy_send_message(
364
357
  thread_id: str = None,
365
358
  cwd: str = None,
366
359
  ) -> str:
367
- args = ["--dangerously-skip-permissions", "--print", content, "--conversation", conv_id]
360
+ args = ["--dangerously-skip-permissions", "--print", content, "--conversation", conv_id, "--print-timeout", "24h"]
368
361
  if model:
369
362
  args.extend(["--model", clean_model_name(model)])
370
363
  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
 
@@ -119,12 +122,19 @@ class SessionManager:
119
122
  self.active_tts_tasks.pop(str(thread_id), None)
120
123
 
121
124
  def set_pending_approval(
122
- 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,
123
131
  ):
124
132
  self.pending_approvals[approval_key] = future
125
133
  self.pending_approval_types[approval_key] = app_type
126
134
  if conv_id:
127
135
  self.active_approval_by_conv[conv_id] = approval_key
136
+ if thread_id:
137
+ self.active_approval_by_thread[thread_id] = approval_key
128
138
 
129
139
  def get_pending_approval_by_conv(self, conv_id: str) -> asyncio.Future | None:
130
140
  """Looks up whichever approval is CURRENTLY active for a given
@@ -136,18 +146,35 @@ class SessionManager:
136
146
  return None
137
147
  return self.pending_approvals.get(approval_key)
138
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
+
139
157
  def get_pending_approval_type_by_conv(self, conv_id: str) -> str:
140
158
  approval_key = self.active_approval_by_conv.get(conv_id)
141
159
  if not approval_key:
142
160
  return "tool"
143
161
  return self.pending_approval_types.get(approval_key, "tool")
144
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
+
145
169
  def clear_pending_approval(self, approval_key: str):
146
170
  self.pending_approvals.pop(approval_key, None)
147
171
  self.pending_approval_types.pop(approval_key, None)
148
172
  self.pending_approval_messages.pop(approval_key, None)
149
- # 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 -
150
174
  # a newer call may have already overwritten it.
151
175
  for conv, key in list(self.active_approval_by_conv.items()):
152
176
  if key == approval_key:
153
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
 
@@ -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}*"