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 +1 -1
- package/npm-scripts/register-hook.js +30 -15
- package/package.json +1 -1
- package/src/api/ui_routes.py +10 -0
- package/src/config.py +1 -1
- package/src/core/agy_runner.py +3 -10
- package/src/core/session_manager.py +29 -2
- package/src/handlers/thread_reply.py +18 -6
- package/src/messengers/slack_adapter.py +9 -0
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 =
|
|
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:
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
package/src/api/ui_routes.py
CHANGED
|
@@ -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 =
|
|
151
|
+
APPROVAL_TIMEOUT_SEC = 86400
|
|
152
152
|
PERSISTENT_FILE = DATA_DIR / "persistent_tools.json"
|
|
153
153
|
SESSION_FILE = DATA_DIR / "sessions.json"
|
|
154
154
|
|
package/src/core/agy_runner.py
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
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}*"
|