linkgravity 1.7.0 → 1.7.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/bin/cli.js +20 -0
- package/bin/setup.js +2 -0
- package/hooks/hook.js +105 -0
- package/hooks/stop_hook.js +65 -0
- package/npm-scripts/register-hook.js +44 -16
- package/npm-scripts/venv-paths.js +1 -1
- package/package.json +1 -1
- package/hooks/hook.py +0 -72
- package/hooks/stop_hook.py +0 -64
package/bin/cli.js
CHANGED
|
@@ -40,6 +40,22 @@ function info(msg) {
|
|
|
40
40
|
console.log(`\n${color.cyan}▶${color.reset} ${msg}`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// `fresh`: npm has swapped the package on disk since require time.
|
|
44
|
+
function repairHookRegistration({ fresh = false } = {}) {
|
|
45
|
+
const modulePath = require.resolve('../npm-scripts/register-hook');
|
|
46
|
+
if (fresh) {
|
|
47
|
+
delete require.cache[modulePath];
|
|
48
|
+
delete require.cache[require.resolve('../npm-scripts/venv-paths')];
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
require(modulePath)({ allowFirstTimeCreate: false, quiet: true });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.log(
|
|
54
|
+
`${color.yellow}⚠${color.reset} Couldn't check the agy hook registration: ${err.message}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
function runPm2(args, silent = true) {
|
|
44
60
|
const stdioOpt = silent ? 'pipe' : 'inherit';
|
|
45
61
|
const result = spawnSync(process.execPath, [PM2_BIN, ...args], {
|
|
@@ -392,6 +408,8 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
392
408
|
require('../npm-scripts/ensure-env').ensureEnvironment();
|
|
393
409
|
}
|
|
394
410
|
|
|
411
|
+
repairHookRegistration();
|
|
412
|
+
|
|
395
413
|
info('Starting LinkGravity daemon...');
|
|
396
414
|
runPm2([
|
|
397
415
|
'start',
|
|
@@ -602,6 +620,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
602
620
|
const latestVersion = viewResult.stdout.toString().trim();
|
|
603
621
|
|
|
604
622
|
if (latestVersion === currentVersion) {
|
|
623
|
+
repairHookRegistration();
|
|
605
624
|
success(`Already up to date (v${currentVersion}).\n`);
|
|
606
625
|
process.exit(0);
|
|
607
626
|
}
|
|
@@ -624,6 +643,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
624
643
|
// replaced this package on disk, and the copy required at startup is the pre-update one.
|
|
625
644
|
delete require.cache[require.resolve('../npm-scripts/ensure-env')];
|
|
626
645
|
require('../npm-scripts/ensure-env').ensureEnvironment();
|
|
646
|
+
repairHookRegistration({ fresh: true });
|
|
627
647
|
|
|
628
648
|
if (!procBeforeUpdate) {
|
|
629
649
|
info("Daemon wasn't running - starting it fresh...");
|
package/bin/setup.js
CHANGED
package/hooks/hook.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const LGY_CONFIG_FILE = path.join(os.homedir(), '.gemini', 'linkgravity', 'lgy.json');
|
|
9
|
+
// Not "localhost" - the server binds 127.0.0.1 and node resolves localhost to ::1 first.
|
|
10
|
+
const APPROVE_HOST = '127.0.0.1';
|
|
11
|
+
const APPROVE_PORT = 18080;
|
|
12
|
+
const TIMEOUT_MS = 3600 * 1000;
|
|
13
|
+
|
|
14
|
+
function emit(payload) {
|
|
15
|
+
process.stdout.write(JSON.stringify(payload));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadApproveToken() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(LGY_CONFIG_FILE, 'utf8')).approve_token || '';
|
|
21
|
+
} catch {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readStdin() {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
29
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requestDecision(body) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const req = http.request(
|
|
35
|
+
{
|
|
36
|
+
host: APPROVE_HOST,
|
|
37
|
+
port: APPROVE_PORT,
|
|
38
|
+
path: '/approve',
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'Content-Length': body.length,
|
|
43
|
+
'X-LGY-Token': loadApproveToken(),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
(res) => {
|
|
47
|
+
const parts = [];
|
|
48
|
+
res.on('data', (chunk) => parts.push(chunk));
|
|
49
|
+
res.on('end', () => {
|
|
50
|
+
try {
|
|
51
|
+
resolve(JSON.parse(Buffer.concat(parts).toString('utf8')));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
reject(err);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
req.on('error', reject);
|
|
59
|
+
req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error('timed out waiting for approval')));
|
|
60
|
+
req.end(body);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
if (process.env.LGY_APPROVAL_HOOK !== '1') {
|
|
66
|
+
emit({ decision: 'allow' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let hookInput;
|
|
71
|
+
try {
|
|
72
|
+
hookInput = JSON.parse(await readStdin());
|
|
73
|
+
} catch {
|
|
74
|
+
emit({ decision: 'deny', reason: 'Failed to parse hook input.' });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const toolCall = hookInput.toolCall || {};
|
|
79
|
+
const body = Buffer.from(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
conversation_id: hookInput.conversationId || 'unknown',
|
|
82
|
+
tool_name: toolCall.name || 'unknown_tool',
|
|
83
|
+
tool_input: toolCall.args || {},
|
|
84
|
+
thread_id: process.env.LGY_THREAD_ID || null,
|
|
85
|
+
}),
|
|
86
|
+
'utf8',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const result = await requestDecision(body);
|
|
91
|
+
if ((result.decision || 'allow') === 'allow') {
|
|
92
|
+
const out = { decision: 'allow' };
|
|
93
|
+
// Print mode requires a matching allow rule even when this hook says "allow", or it soft-denies anyway.
|
|
94
|
+
if (result.permissionOverrides) out.permissionOverrides = result.permissionOverrides;
|
|
95
|
+
emit(out);
|
|
96
|
+
} else {
|
|
97
|
+
emit({ decision: 'deny', reason: result.reason || 'User rejected the action.' });
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
process.stderr.write(`Hook error: ${err.message}\n`);
|
|
101
|
+
emit({ decision: 'deny', reason: `Connection to webhook failed: ${err.message}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
main();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Stop-event hook (https://antigravity.google/docs/hooks).
|
|
4
|
+
// `fullyIdle: false` means a run_command that detached to async is still in flight; "continue"
|
|
5
|
+
// keeps the turn alive so agy picks that result up instead of losing it.
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const MAX_CONTINUE_ATTEMPTS = 20;
|
|
11
|
+
// Its own process, so it never reaches `lgy logs`.
|
|
12
|
+
const DEBUG_LOG = path.join(os.homedir(), '.gemini', 'linkgravity', 'logs', 'stop_hook_debug.log');
|
|
13
|
+
|
|
14
|
+
function log(line) {
|
|
15
|
+
try {
|
|
16
|
+
fs.mkdirSync(path.dirname(DEBUG_LOG), { recursive: true });
|
|
17
|
+
fs.appendFileSync(DEBUG_LOG, `${new Date().toISOString()} ${line}\n`);
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function emit(payload) {
|
|
22
|
+
process.stdout.write(JSON.stringify(payload));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readStdin() {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
28
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
const raw = await readStdin();
|
|
33
|
+
let hookInput;
|
|
34
|
+
try {
|
|
35
|
+
hookInput = JSON.parse(raw);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
log(`[PARSE ERROR] ${err.message} raw=${JSON.stringify(raw)}`);
|
|
38
|
+
emit({});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fullyIdle = hookInput.fullyIdle ?? true;
|
|
43
|
+
const executionNum = hookInput.executionNum ?? 0;
|
|
44
|
+
log(
|
|
45
|
+
`[STOP HOOK] fullyIdle=${fullyIdle} executionNum=${executionNum} ` +
|
|
46
|
+
`terminationReason=${JSON.stringify(hookInput.terminationReason)} ` +
|
|
47
|
+
`conv=${JSON.stringify(hookInput.conversationId)}`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (!fullyIdle && executionNum < MAX_CONTINUE_ATTEMPTS) {
|
|
51
|
+
const response = {
|
|
52
|
+
decision: 'continue',
|
|
53
|
+
reason:
|
|
54
|
+
'A background command is still running. Wait for it to finish, ' +
|
|
55
|
+
'then report its actual result to the user before ending your turn.',
|
|
56
|
+
};
|
|
57
|
+
log(`[STOP HOOK] -> continue: ${JSON.stringify(response)}`);
|
|
58
|
+
emit(response);
|
|
59
|
+
} else {
|
|
60
|
+
log('[STOP HOOK] -> {} (fullyIdle true or attempt cap reached)');
|
|
61
|
+
emit({});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
main();
|
|
@@ -3,30 +3,55 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
|
-
const { repoRoot,
|
|
6
|
+
const { repoRoot, workspaceDir } = require('./venv-paths');
|
|
7
7
|
|
|
8
8
|
const hooksJsonPath = path.join(os.homedir(), '.gemini', 'config', 'hooks.json');
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
// Not process.execPath: this runs on every agy tool call machine-wide, and an absolute
|
|
11
|
+
// interpreter path dies the moment a version manager moves it.
|
|
12
|
+
const NODE_CMD = 'node';
|
|
13
|
+
|
|
14
|
+
// A copy, never this package: npm has had no uninstall lifecycle since v7, so an entry pointing
|
|
15
|
+
// into node_modules would 127 every agy tool call once linkgravity is removed.
|
|
16
|
+
const installedHooksDir = path.join(workspaceDir, 'hooks');
|
|
17
|
+
|
|
13
18
|
const HOOK_REGISTRATIONS = [
|
|
14
19
|
{
|
|
15
20
|
eventType: 'PreToolUse',
|
|
16
21
|
name: 'discord-approval',
|
|
17
|
-
|
|
22
|
+
fileName: 'hook.js',
|
|
18
23
|
defaultTimeout: 3600,
|
|
19
24
|
wrapInMatcher: true,
|
|
20
25
|
},
|
|
21
26
|
{
|
|
22
27
|
eventType: 'Stop',
|
|
23
28
|
name: 'discord-approval-stop',
|
|
24
|
-
|
|
29
|
+
fileName: 'stop_hook.js',
|
|
25
30
|
defaultTimeout: 30,
|
|
26
31
|
wrapInMatcher: false,
|
|
27
32
|
},
|
|
28
33
|
];
|
|
29
34
|
|
|
35
|
+
function installHookScripts() {
|
|
36
|
+
for (const reg of HOOK_REGISTRATIONS) {
|
|
37
|
+
const source = fs.readFileSync(path.join(repoRoot, 'hooks', reg.fileName), 'utf8');
|
|
38
|
+
const installedPath = path.join(installedHooksDir, reg.fileName);
|
|
39
|
+
let installed = null;
|
|
40
|
+
try {
|
|
41
|
+
installed = fs.readFileSync(installedPath, 'utf8');
|
|
42
|
+
} catch {}
|
|
43
|
+
if (installed === source) continue;
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(installedHooksDir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(installedPath, source);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// A stale copy still answers agy, so only a missing one is fatal.
|
|
49
|
+
if (installed === null) throw err;
|
|
50
|
+
console.log(`⚠️ Couldn't refresh ${installedPath}: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
const RETIRED_HOOKS = [{ eventType: 'PreInvocation', name: 'wait-ms-before-async-reminder' }];
|
|
31
56
|
|
|
32
57
|
function loadHooksConfig() {
|
|
@@ -104,7 +129,7 @@ function removeRetiredHooks(config) {
|
|
|
104
129
|
return removedAny;
|
|
105
130
|
}
|
|
106
131
|
|
|
107
|
-
function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
132
|
+
function registerHook({ allowFirstTimeCreate = true, quiet = false } = {}) {
|
|
108
133
|
const config = loadHooksConfig();
|
|
109
134
|
config.hooks = config.hooks || {};
|
|
110
135
|
|
|
@@ -116,9 +141,11 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
116
141
|
});
|
|
117
142
|
|
|
118
143
|
if (isFirstTime && !allowFirstTimeCreate) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
144
|
+
if (!quiet) {
|
|
145
|
+
console.log(
|
|
146
|
+
"ℹ️ LinkGravity's Discord/Telegram/Slack approval hook isn't registered with agy yet - run `lgy setup` to enable it.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
122
149
|
return;
|
|
123
150
|
}
|
|
124
151
|
|
|
@@ -137,8 +164,11 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
137
164
|
wroteChange = true;
|
|
138
165
|
}
|
|
139
166
|
|
|
167
|
+
installHookScripts();
|
|
168
|
+
|
|
140
169
|
for (const reg of HOOK_REGISTRATIONS) {
|
|
141
|
-
const
|
|
170
|
+
const scriptPath = path.join(installedHooksDir, reg.fileName);
|
|
171
|
+
const command = `${NODE_CMD} "${scriptPath}"`;
|
|
142
172
|
const hookEntry = findHookEntry(config, reg.eventType, reg.name, reg.wrapInMatcher);
|
|
143
173
|
const isNew = !hookEntry.command;
|
|
144
174
|
|
|
@@ -146,9 +176,7 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
146
176
|
hookEntry.type = 'command';
|
|
147
177
|
hookEntry.timeout = reg.defaultTimeout;
|
|
148
178
|
hookEntry.command = command;
|
|
149
|
-
console.log(
|
|
150
|
-
`🔗 Registered agy ${reg.eventType} hook '${reg.name}' -> ${reg.scriptPath}`,
|
|
151
|
-
);
|
|
179
|
+
console.log(`🔗 Registered agy ${reg.eventType} hook '${reg.name}' -> ${scriptPath}`);
|
|
152
180
|
wroteChange = true;
|
|
153
181
|
} else if (hookEntry.command !== command) {
|
|
154
182
|
backupBeforeFirstChange();
|
|
@@ -159,9 +187,9 @@ function registerHook({ allowFirstTimeCreate = true } = {}) {
|
|
|
159
187
|
);
|
|
160
188
|
hookEntry.command = command;
|
|
161
189
|
wroteChange = true;
|
|
162
|
-
} else {
|
|
190
|
+
} else if (!quiet) {
|
|
163
191
|
console.log(
|
|
164
|
-
`🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${
|
|
192
|
+
`🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${scriptPath}`,
|
|
165
193
|
);
|
|
166
194
|
}
|
|
167
195
|
}
|
|
@@ -35,7 +35,7 @@ function venvBin(name) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
module.exports = {
|
|
38
|
-
repoRoot, // where the CODE lives (this checkout/install) - hooks/hook.
|
|
38
|
+
repoRoot, // where the CODE lives (this checkout/install) - hooks/hook.js, src/main.py, etc.
|
|
39
39
|
workspaceDir, // where generated/user DATA lives (venv, logs, lgy.json, wake_refs, ...)
|
|
40
40
|
isWin,
|
|
41
41
|
venvBinDir,
|
package/package.json
CHANGED
package/hooks/hook.py
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import json
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
import urllib.error
|
|
6
|
-
import urllib.request
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
LGY_CONFIG_FILE = Path.home() / ".gemini" / "linkgravity" / "lgy.json"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _load_approve_token():
|
|
13
|
-
try:
|
|
14
|
-
with open(LGY_CONFIG_FILE, encoding="utf-8") as f:
|
|
15
|
-
return json.load(f).get("approve_token", "")
|
|
16
|
-
except Exception:
|
|
17
|
-
return ""
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def main():
|
|
21
|
-
if os.environ.get("LGY_APPROVAL_HOOK") != "1":
|
|
22
|
-
print(json.dumps({"decision": "allow"}))
|
|
23
|
-
return
|
|
24
|
-
|
|
25
|
-
try:
|
|
26
|
-
raw_input = sys.stdin.read()
|
|
27
|
-
hook_input = json.loads(raw_input)
|
|
28
|
-
except Exception:
|
|
29
|
-
print(json.dumps({"decision": "deny", "reason": "Failed to parse hook input."}))
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
tool_call = hook_input.get("toolCall", {})
|
|
33
|
-
tool_name = tool_call.get("name", "unknown_tool")
|
|
34
|
-
|
|
35
|
-
tool_input_data = tool_call.get("args", {})
|
|
36
|
-
conv_id = hook_input.get("conversationId", "unknown")
|
|
37
|
-
|
|
38
|
-
payload = json.dumps(
|
|
39
|
-
{
|
|
40
|
-
"conversation_id": conv_id,
|
|
41
|
-
"tool_name": tool_name,
|
|
42
|
-
"tool_input": tool_input_data,
|
|
43
|
-
"thread_id": os.environ.get("LGY_THREAD_ID"),
|
|
44
|
-
}
|
|
45
|
-
).encode("utf-8")
|
|
46
|
-
|
|
47
|
-
req = urllib.request.Request(
|
|
48
|
-
"http://localhost:18080/approve",
|
|
49
|
-
data=payload,
|
|
50
|
-
headers={"Content-Type": "application/json", "X-LGY-Token": _load_approve_token()},
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
try:
|
|
54
|
-
with urllib.request.urlopen(req, timeout=3600) as response:
|
|
55
|
-
res_data = json.loads(response.read().decode("utf-8"))
|
|
56
|
-
decision = res_data.get("decision", "allow")
|
|
57
|
-
if decision == "allow":
|
|
58
|
-
out = {"decision": "allow"}
|
|
59
|
-
# Print mode requires a matching allow rule even when this hook says "allow", or it soft-denies anyway.
|
|
60
|
-
if res_data.get("permissionOverrides"):
|
|
61
|
-
out["permissionOverrides"] = res_data["permissionOverrides"]
|
|
62
|
-
print(json.dumps(out))
|
|
63
|
-
else:
|
|
64
|
-
reason = res_data.get("reason", "User rejected the action.")
|
|
65
|
-
print(json.dumps({"decision": "deny", "reason": reason}))
|
|
66
|
-
except Exception as e:
|
|
67
|
-
sys.stderr.write(f"Hook error: {e}\n")
|
|
68
|
-
print(json.dumps({"decision": "deny", "reason": f"Connection to webhook failed: {e}"}))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if __name__ == "__main__":
|
|
72
|
-
main()
|
package/hooks/stop_hook.py
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Stop-event hook (https://antigravity.google/docs/hooks#stop).
|
|
3
|
-
|
|
4
|
-
`fullyIdle: false` means a run_command call detached to async (its
|
|
5
|
-
WaitMsBeforeAsync budget ran out) is still in flight. Returning
|
|
6
|
-
{"decision": "continue"} keeps the turn alive so agy can pick up that
|
|
7
|
-
result instead of ending the turn with it lost. Capped by executionNum
|
|
8
|
-
so a genuinely stuck command doesn't loop forever.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import sys
|
|
13
|
-
from datetime import datetime
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
MAX_CONTINUE_ATTEMPTS = 20
|
|
17
|
-
# This runs as its own process, invoked directly by agy - not visible in
|
|
18
|
-
# `lgy logs`. Plain-file logging is the only way to inspect it.
|
|
19
|
-
DEBUG_LOG = Path.home() / ".gemini" / "linkgravity" / "logs" / "stop_hook_debug.log"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def log(line: str):
|
|
23
|
-
try:
|
|
24
|
-
DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
-
with open(DEBUG_LOG, "a", encoding="utf-8") as f:
|
|
26
|
-
f.write(f"{datetime.now().isoformat()} {line}\n")
|
|
27
|
-
except Exception:
|
|
28
|
-
pass
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def main():
|
|
32
|
-
try:
|
|
33
|
-
raw = sys.stdin.read()
|
|
34
|
-
hook_input = json.loads(raw)
|
|
35
|
-
except Exception as e:
|
|
36
|
-
log(f"[PARSE ERROR] {e} raw={raw!r}")
|
|
37
|
-
print(json.dumps({}))
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
fully_idle = hook_input.get("fullyIdle", True)
|
|
41
|
-
execution_num = hook_input.get("executionNum", 0)
|
|
42
|
-
termination_reason = hook_input.get("terminationReason")
|
|
43
|
-
log(
|
|
44
|
-
f"[STOP HOOK] fullyIdle={fully_idle!r} executionNum={execution_num!r} "
|
|
45
|
-
f"terminationReason={termination_reason!r} conv={hook_input.get('conversationId')!r}"
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
if not fully_idle and execution_num < MAX_CONTINUE_ATTEMPTS:
|
|
49
|
-
response = {
|
|
50
|
-
"decision": "continue",
|
|
51
|
-
"reason": (
|
|
52
|
-
"A background command is still running. Wait for it to finish, "
|
|
53
|
-
"then report its actual result to the user before ending your turn."
|
|
54
|
-
),
|
|
55
|
-
}
|
|
56
|
-
log(f"[STOP HOOK] -> continue: {response}")
|
|
57
|
-
print(json.dumps(response))
|
|
58
|
-
else:
|
|
59
|
-
log("[STOP HOOK] -> {} (fullyIdle true or attempt cap reached)")
|
|
60
|
-
print(json.dumps({}))
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if __name__ == "__main__":
|
|
64
|
-
main()
|