@quantiya/codevibe-antigravity-plugin 2.0.62 → 2.0.64
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 +15 -0
- package/antigravity-plugin.json +1 -1
- package/dist/agy-hook.js +247 -0
- package/dist/installer-cli.js +227 -35
- package/dist/server.js +1256 -84
- package/libexec/agy-hook.sh +27 -0
- package/libexec/companion-launcher +29 -8
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -57,6 +57,18 @@ Approval prompts and multi-choice questions aren't in the transcript at the mome
|
|
|
57
57
|
|
|
58
58
|
A single launch session covers the entire wrapper lifetime, so `/resume` commands continue under the same backend session and your phone sees one continuous conversation.
|
|
59
59
|
|
|
60
|
+
### Several agy sessions at once
|
|
61
|
+
|
|
62
|
+
All agy sessions on a computer write to the same folders. CodeVibe tells them apart with two agy hooks:
|
|
63
|
+
- **`PreInvocation`** reports which conversation a session is running.
|
|
64
|
+
- **`PostToolUse`** reports which tool just ran. When that tool was waiting on an approval you answered at the desktop, the prompt on your phone closes.
|
|
65
|
+
|
|
66
|
+
On each launch, `codevibe --agent agy` registers one named entry, `codevibe-antigravity`, in `~/.gemini/config/hooks.json`, and keeps the hook scripts in `~/.codevibe/codevibe-agy/hooks/`:
|
|
67
|
+
- Other entries in that file are left as they are.
|
|
68
|
+
- A file that is not valid JSON, or is a symlink, is never changed. CodeVibe then falls back to agy's log files, which cannot tell concurrent sessions apart.
|
|
69
|
+
- The hook does nothing for agy sessions that CodeVibe did not start.
|
|
70
|
+
- To switch it off, add `"enabled": false` to the `codevibe-antigravity` entry. CodeVibe leaves a switched-off entry alone. Deleting the entry only lasts until the next `codevibe --agent agy` launch, which writes it again. After uninstalling CodeVibe you can delete the entry; see INSTALLATION.md.
|
|
71
|
+
|
|
60
72
|
## What gets synced
|
|
61
73
|
|
|
62
74
|
| Direction | What |
|
|
@@ -86,6 +98,9 @@ ls -la ~/.gemini/antigravity-cli/brain/
|
|
|
86
98
|
# Verify tmux session
|
|
87
99
|
tmux list-sessions | grep codevibe-agy
|
|
88
100
|
|
|
101
|
+
# Verify the session hooks are registered (needed to keep concurrent sessions apart)
|
|
102
|
+
grep -c codevibe-antigravity ~/.gemini/config/hooks.json
|
|
103
|
+
|
|
89
104
|
# Reinstall if needed
|
|
90
105
|
codevibe update
|
|
91
106
|
```
|
package/antigravity-plugin.json
CHANGED
|
@@ -6,6 +6,6 @@
|
|
|
6
6
|
"implementation": {
|
|
7
7
|
"architecture": "transcript+tmux+approval-heuristic",
|
|
8
8
|
"schema_version": "v1.0",
|
|
9
|
-
"note": "
|
|
9
|
+
"note": "Monitors ~/.gemini/antigravity-cli/brain/<UUID>/.system_generated/logs/transcript.jsonl and the tmux pane. Where agy JSON hooks fire (agy 1.3.1+, hook gate on), the companion launcher registers a `codevibe-antigravity` entry in ~/.gemini/config/hooks.json (PreInvocation + PostToolUse) so each session routes only its own conversations; otherwise it falls back to agy's CLI log."
|
|
10
10
|
}
|
|
11
11
|
}
|
package/dist/agy-hook.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/agy-hook.ts
|
|
31
|
+
var agy_hook_exports = {};
|
|
32
|
+
__export(agy_hook_exports, {
|
|
33
|
+
FORWARDED_TOOL_ARG_KEYS: () => FORWARDED_TOOL_ARG_KEYS,
|
|
34
|
+
HARD_DEADLINE_MS: () => HARD_DEADLINE_MS,
|
|
35
|
+
REQUEST_TIMEOUT_MS: () => REQUEST_TIMEOUT_MS,
|
|
36
|
+
buildBody: () => buildBody,
|
|
37
|
+
isPidAlive: () => isPidAlive,
|
|
38
|
+
postHook: () => postHook,
|
|
39
|
+
readConn: () => readConn,
|
|
40
|
+
readStdin: () => readStdin,
|
|
41
|
+
runHook: () => runHook
|
|
42
|
+
});
|
|
43
|
+
module.exports = __toCommonJS(agy_hook_exports);
|
|
44
|
+
var fs = __toESM(require("fs"));
|
|
45
|
+
var http = __toESM(require("http"));
|
|
46
|
+
var path2 = __toESM(require("path"));
|
|
47
|
+
|
|
48
|
+
// src/agy-hook-paths.ts
|
|
49
|
+
var os = __toESM(require("os"));
|
|
50
|
+
var path = __toESM(require("path"));
|
|
51
|
+
var HOOK_ROUTE_PREFIX = "/agy-hook/v1/";
|
|
52
|
+
var HOOK_EVENTS = ["PreInvocation", "PostToolUse"];
|
|
53
|
+
var HOOK_OWNER_ENV = "CODEVIBE_AGY_HOOK_OWNER";
|
|
54
|
+
function isHookEvent(value) {
|
|
55
|
+
return typeof value === "string" && HOOK_EVENTS.includes(value);
|
|
56
|
+
}
|
|
57
|
+
function codevibeAgyDir(home = os.homedir()) {
|
|
58
|
+
return path.join(home, ".codevibe", "codevibe-agy");
|
|
59
|
+
}
|
|
60
|
+
function runtimeBaseDir(home = os.homedir()) {
|
|
61
|
+
return path.join(codevibeAgyDir(home), "runtime");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/agy-hook.ts
|
|
65
|
+
var HARD_DEADLINE_MS = 2500;
|
|
66
|
+
var REQUEST_TIMEOUT_MS = 1500;
|
|
67
|
+
var MAX_STDIN_BYTES = 1024 * 1024;
|
|
68
|
+
var MAX_CONN_FILE_BYTES = 4096;
|
|
69
|
+
var MAX_TOOL_ARG_CHARS = 4096;
|
|
70
|
+
var FORWARDED_TOOL_ARG_KEYS = [
|
|
71
|
+
"CommandLine",
|
|
72
|
+
"Command",
|
|
73
|
+
"command",
|
|
74
|
+
"AbsolutePath",
|
|
75
|
+
"TargetFile",
|
|
76
|
+
"FilePath",
|
|
77
|
+
"file_path",
|
|
78
|
+
"filePath",
|
|
79
|
+
"path",
|
|
80
|
+
"DirectoryPath"
|
|
81
|
+
];
|
|
82
|
+
function buildBody(raw) {
|
|
83
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
84
|
+
const payload = raw;
|
|
85
|
+
if (typeof payload.conversationId !== "string" || payload.conversationId.length === 0) return null;
|
|
86
|
+
const body = { conversationId: payload.conversationId };
|
|
87
|
+
if (Number.isInteger(payload.invocationNum)) body.invocationNum = payload.invocationNum;
|
|
88
|
+
if (Number.isInteger(payload.initialNumSteps)) body.initialNumSteps = payload.initialNumSteps;
|
|
89
|
+
if (Number.isInteger(payload.stepIdx)) body.stepIdx = payload.stepIdx;
|
|
90
|
+
const toolCall = payload.toolCall;
|
|
91
|
+
if (toolCall && typeof toolCall === "object" && !Array.isArray(toolCall)) {
|
|
92
|
+
const call = toolCall;
|
|
93
|
+
if (typeof call.name === "string") body.toolName = call.name.slice(0, 128);
|
|
94
|
+
const args = call.args;
|
|
95
|
+
if (args && typeof args === "object" && !Array.isArray(args)) {
|
|
96
|
+
const source = args;
|
|
97
|
+
const kept = {};
|
|
98
|
+
for (const key of FORWARDED_TOOL_ARG_KEYS) {
|
|
99
|
+
const value = source[key];
|
|
100
|
+
if (typeof value === "string") kept[key] = value.slice(0, MAX_TOOL_ARG_CHARS);
|
|
101
|
+
}
|
|
102
|
+
body.toolArgs = kept;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if ("error" in payload) {
|
|
106
|
+
const error = payload.error;
|
|
107
|
+
body.hasError = !(error === null || error === void 0 || error === "" || error === false);
|
|
108
|
+
}
|
|
109
|
+
return body;
|
|
110
|
+
}
|
|
111
|
+
function readConn(runtimeBase, owner, uid = process.getuid?.()) {
|
|
112
|
+
const file = path2.join(runtimeBase, owner, "conn.json");
|
|
113
|
+
let stat;
|
|
114
|
+
try {
|
|
115
|
+
stat = fs.lstatSync(file);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (!stat.isFile() || stat.size > MAX_CONN_FILE_BYTES) return null;
|
|
120
|
+
if (uid !== void 0 && stat.uid !== uid) return null;
|
|
121
|
+
let parsed;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
128
|
+
const conn = parsed;
|
|
129
|
+
const port = conn.port;
|
|
130
|
+
const token = conn.token;
|
|
131
|
+
const pid = conn.pid;
|
|
132
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
133
|
+
if (typeof token !== "string" || token.length < 16) return null;
|
|
134
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
135
|
+
return { port, token, pid };
|
|
136
|
+
}
|
|
137
|
+
function isPidAlive(pid) {
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, 0);
|
|
140
|
+
return true;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return error.code === "EPERM";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function postHook(conn, event, body) {
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const payload = JSON.stringify(body);
|
|
148
|
+
let settled = false;
|
|
149
|
+
const finish = (ok) => {
|
|
150
|
+
if (settled) return;
|
|
151
|
+
settled = true;
|
|
152
|
+
resolve(ok);
|
|
153
|
+
};
|
|
154
|
+
try {
|
|
155
|
+
const request2 = http.request(
|
|
156
|
+
{
|
|
157
|
+
host: "127.0.0.1",
|
|
158
|
+
port: conn.port,
|
|
159
|
+
method: "POST",
|
|
160
|
+
path: `${HOOK_ROUTE_PREFIX}${event}`,
|
|
161
|
+
headers: {
|
|
162
|
+
Authorization: `Bearer ${conn.token}`,
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
165
|
+
},
|
|
166
|
+
timeout: REQUEST_TIMEOUT_MS
|
|
167
|
+
},
|
|
168
|
+
(response) => {
|
|
169
|
+
response.resume();
|
|
170
|
+
response.on("end", () => finish((response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300));
|
|
171
|
+
response.on("error", () => finish(false));
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
request2.on("timeout", () => {
|
|
175
|
+
request2.destroy();
|
|
176
|
+
finish(false);
|
|
177
|
+
});
|
|
178
|
+
request2.on("error", () => finish(false));
|
|
179
|
+
request2.end(payload);
|
|
180
|
+
} catch {
|
|
181
|
+
finish(false);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function readStdin(stream) {
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
const chunks = [];
|
|
188
|
+
let size = 0;
|
|
189
|
+
let done = false;
|
|
190
|
+
const finish = (value) => {
|
|
191
|
+
if (done) return;
|
|
192
|
+
done = true;
|
|
193
|
+
resolve(value);
|
|
194
|
+
};
|
|
195
|
+
stream.on("data", (chunk) => {
|
|
196
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
197
|
+
size += buffer.length;
|
|
198
|
+
if (size > MAX_STDIN_BYTES) {
|
|
199
|
+
finish(null);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
chunks.push(buffer);
|
|
203
|
+
});
|
|
204
|
+
stream.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
205
|
+
stream.on("error", () => finish(null));
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async function runHook(options) {
|
|
209
|
+
const owner = options.env[HOOK_OWNER_ENV] ?? "";
|
|
210
|
+
const raw = await readStdin(options.stdin);
|
|
211
|
+
if (!/^[0-9]{1,10}$/.test(owner) || !isHookEvent(options.event) || raw === null) return "skipped";
|
|
212
|
+
const conn = readConn(options.runtimeBase ?? runtimeBaseDir(), owner);
|
|
213
|
+
if (!conn || !(options.isPidAlive ?? isPidAlive)(conn.pid)) return "skipped";
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(raw);
|
|
217
|
+
} catch {
|
|
218
|
+
return "skipped";
|
|
219
|
+
}
|
|
220
|
+
const body = buildBody(parsed);
|
|
221
|
+
if (!body) return "skipped";
|
|
222
|
+
const ok = await (options.post ?? postHook)(conn, options.event, body);
|
|
223
|
+
return ok ? "sent" : "failed";
|
|
224
|
+
}
|
|
225
|
+
function finishProcess() {
|
|
226
|
+
try {
|
|
227
|
+
fs.writeSync(1, "{}");
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
process.exit(0);
|
|
231
|
+
}
|
|
232
|
+
if (require.main === module) {
|
|
233
|
+
setTimeout(finishProcess, HARD_DEADLINE_MS);
|
|
234
|
+
runHook({ event: process.argv[2], env: process.env, stdin: process.stdin }).then(finishProcess, finishProcess);
|
|
235
|
+
}
|
|
236
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
237
|
+
0 && (module.exports = {
|
|
238
|
+
FORWARDED_TOOL_ARG_KEYS,
|
|
239
|
+
HARD_DEADLINE_MS,
|
|
240
|
+
REQUEST_TIMEOUT_MS,
|
|
241
|
+
buildBody,
|
|
242
|
+
isPidAlive,
|
|
243
|
+
postHook,
|
|
244
|
+
readConn,
|
|
245
|
+
readStdin,
|
|
246
|
+
runHook
|
|
247
|
+
});
|
package/dist/installer-cli.js
CHANGED
|
@@ -34,25 +34,43 @@ __export(installer_cli_exports, {
|
|
|
34
34
|
isInstallerVerbose: () => isInstallerVerbose
|
|
35
35
|
});
|
|
36
36
|
module.exports = __toCommonJS(installer_cli_exports);
|
|
37
|
-
var
|
|
37
|
+
var path3 = __toESM(require("path"));
|
|
38
38
|
|
|
39
39
|
// src/installer.ts
|
|
40
40
|
var fs = __toESM(require("fs"));
|
|
41
|
-
var
|
|
42
|
-
var
|
|
41
|
+
var path2 = __toESM(require("path"));
|
|
42
|
+
var os2 = __toESM(require("os"));
|
|
43
43
|
var crypto = __toESM(require("crypto"));
|
|
44
|
+
|
|
45
|
+
// src/agy-hook-paths.ts
|
|
46
|
+
var os = __toESM(require("os"));
|
|
47
|
+
var path = __toESM(require("path"));
|
|
48
|
+
var HOOK_ENTRY_NAME = "codevibe-antigravity";
|
|
49
|
+
var HOOK_SHELL_BASENAME = "agy-hook.sh";
|
|
50
|
+
var HOOK_SCRIPT_BASENAME = "agy-hook.js";
|
|
51
|
+
function codevibeAgyDir(home = os.homedir()) {
|
|
52
|
+
return path.join(home, ".codevibe", "codevibe-agy");
|
|
53
|
+
}
|
|
54
|
+
function hooksDir(home = os.homedir()) {
|
|
55
|
+
return path.join(codevibeAgyDir(home), "hooks");
|
|
56
|
+
}
|
|
57
|
+
function globalHooksConfigPath(home = os.homedir()) {
|
|
58
|
+
return path.join(home, ".gemini", "config", "hooks.json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/installer.ts
|
|
44
62
|
var MANIFEST_FILENAME = "antigravity-plugin.json";
|
|
45
63
|
var SENTINEL_FILENAME = ".codevibe-manifest-hash";
|
|
46
64
|
var MCP_SERVERS_KEY = "codevibe-antigravity";
|
|
47
65
|
var LOCK_ACQUIRE_TIMEOUT_MS = 5e3;
|
|
48
66
|
var LOCK_RETRY_INTERVAL_MS = 50;
|
|
49
67
|
function resolveInstallerPaths(opts) {
|
|
50
|
-
const home = opts.homedir ??
|
|
68
|
+
const home = opts.homedir ?? os2.homedir();
|
|
51
69
|
return {
|
|
52
|
-
pluginJsonSourcePath:
|
|
53
|
-
installDir:
|
|
54
|
-
mcpConfigPath:
|
|
55
|
-
serverScriptPath:
|
|
70
|
+
pluginJsonSourcePath: path2.join(opts.pluginPkgDir, MANIFEST_FILENAME),
|
|
71
|
+
installDir: path2.join(home, ".gemini", "antigravity-cli", "plugins", "codevibe-antigravity"),
|
|
72
|
+
mcpConfigPath: path2.join(home, ".gemini", "config", "mcp_config.json"),
|
|
73
|
+
serverScriptPath: path2.join(opts.pluginPkgDir, "dist", "server.js")
|
|
56
74
|
};
|
|
57
75
|
}
|
|
58
76
|
async function ensureInstalled(options) {
|
|
@@ -88,7 +106,7 @@ async function ensureInstalled(options) {
|
|
|
88
106
|
}
|
|
89
107
|
}
|
|
90
108
|
const expectedManifestHash = sha256Hex(manifestContent);
|
|
91
|
-
const lockPath =
|
|
109
|
+
const lockPath = path2.join(path2.dirname(installDir), ".codevibe-install.lock");
|
|
92
110
|
const releaseLock = await acquireLock(lockPath, LOCK_ACQUIRE_TIMEOUT_MS, logger, "install");
|
|
93
111
|
try {
|
|
94
112
|
await crashRecoverySweep(installDir, logger);
|
|
@@ -124,14 +142,14 @@ async function stageAndInstall(args) {
|
|
|
124
142
|
const { installDir, manifestContent, expectedManifestHash, pid } = args;
|
|
125
143
|
const stagingDir = `${installDir}.new.${pid}`;
|
|
126
144
|
const oldDir = `${installDir}.old.${pid}`;
|
|
127
|
-
await fs.promises.mkdir(
|
|
145
|
+
await fs.promises.mkdir(path2.dirname(installDir), { recursive: true });
|
|
128
146
|
await rmRecursive(stagingDir);
|
|
129
147
|
await fs.promises.mkdir(stagingDir, { recursive: true, mode: 493 });
|
|
130
|
-
const manifestDest =
|
|
148
|
+
const manifestDest = path2.join(stagingDir, MANIFEST_FILENAME);
|
|
131
149
|
const manifestTmp = `${manifestDest}.partial`;
|
|
132
150
|
await fs.promises.writeFile(manifestTmp, manifestContent, { mode: 420 });
|
|
133
151
|
await fs.promises.rename(manifestTmp, manifestDest);
|
|
134
|
-
const sentinelDest =
|
|
152
|
+
const sentinelDest = path2.join(stagingDir, SENTINEL_FILENAME);
|
|
135
153
|
await fs.promises.writeFile(sentinelDest, expectedManifestHash + "\n", { mode: 420 });
|
|
136
154
|
const installExists = await lstatExists(installDir);
|
|
137
155
|
if (installExists) {
|
|
@@ -159,8 +177,8 @@ async function stageAndInstall(args) {
|
|
|
159
177
|
}
|
|
160
178
|
}
|
|
161
179
|
async function crashRecoverySweep(installDir, logger) {
|
|
162
|
-
const parent =
|
|
163
|
-
const baseName =
|
|
180
|
+
const parent = path2.dirname(installDir);
|
|
181
|
+
const baseName = path2.basename(installDir);
|
|
164
182
|
let entries;
|
|
165
183
|
try {
|
|
166
184
|
entries = await fs.promises.readdir(parent, { withFileTypes: true });
|
|
@@ -170,8 +188,8 @@ async function crashRecoverySweep(installDir, logger) {
|
|
|
170
188
|
for (const entry of entries) {
|
|
171
189
|
if (!entry.isDirectory()) continue;
|
|
172
190
|
if (!entry.name.startsWith(`${baseName}.new.`)) continue;
|
|
173
|
-
const dir =
|
|
174
|
-
const sentinel =
|
|
191
|
+
const dir = path2.join(parent, entry.name);
|
|
192
|
+
const sentinel = path2.join(dir, SENTINEL_FILENAME);
|
|
175
193
|
if (await pathExists(sentinel)) {
|
|
176
194
|
if (!await lstatExists(installDir)) {
|
|
177
195
|
try {
|
|
@@ -195,19 +213,19 @@ async function crashRecoverySweep(installDir, logger) {
|
|
|
195
213
|
for (const entry of entries) {
|
|
196
214
|
if (!entry.isDirectory()) continue;
|
|
197
215
|
if (!entry.name.startsWith(`${baseName}.old.`)) continue;
|
|
198
|
-
const dir =
|
|
216
|
+
const dir = path2.join(parent, entry.name);
|
|
199
217
|
logger.info("Crash-recovery: deleting orphan .old dir", { dir });
|
|
200
218
|
await rmRecursive(dir);
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
221
|
async function ensureMcpConfigCourtesy(mcpConfigPath, logger) {
|
|
204
222
|
try {
|
|
205
|
-
await fs.promises.mkdir(
|
|
223
|
+
await fs.promises.mkdir(path2.dirname(mcpConfigPath), { recursive: true });
|
|
206
224
|
} catch (err) {
|
|
207
225
|
const code = err.code;
|
|
208
226
|
if (code === "ENOTDIR" || code === "EEXIST") {
|
|
209
227
|
throw new Error(
|
|
210
|
-
`mcp_config.json parent path exists as a file, expected a directory: ${
|
|
228
|
+
`mcp_config.json parent path exists as a file, expected a directory: ${path2.dirname(mcpConfigPath)}`
|
|
211
229
|
);
|
|
212
230
|
}
|
|
213
231
|
throw err;
|
|
@@ -291,12 +309,186 @@ async function doEnsureMcpConfigCourtesy(mcpConfigPath, logger) {
|
|
|
291
309
|
{ mcpConfigPath, removedStaleEntry: hadStaleEntry }
|
|
292
310
|
);
|
|
293
311
|
}
|
|
312
|
+
var HOOK_TIMEOUT_SECONDS = 5;
|
|
313
|
+
function resolveHookInstallPaths(opts) {
|
|
314
|
+
const home = opts.homedir ?? os2.homedir();
|
|
315
|
+
return {
|
|
316
|
+
hookShellSourcePath: path2.join(opts.pluginPkgDir, "libexec", HOOK_SHELL_BASENAME),
|
|
317
|
+
hookScriptSourcePath: path2.join(opts.pluginPkgDir, "dist", HOOK_SCRIPT_BASENAME),
|
|
318
|
+
hooksDir: hooksDir(home),
|
|
319
|
+
hooksConfigPath: globalHooksConfigPath(home)
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function shellQuote(value) {
|
|
323
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
324
|
+
}
|
|
325
|
+
function buildHookEntry(hooksFolder) {
|
|
326
|
+
const shell = shellQuote(path2.join(hooksFolder, HOOK_SHELL_BASENAME));
|
|
327
|
+
return {
|
|
328
|
+
PreInvocation: [
|
|
329
|
+
{ type: "command", command: `${shell} PreInvocation`, timeout: HOOK_TIMEOUT_SECONDS }
|
|
330
|
+
],
|
|
331
|
+
PostToolUse: [
|
|
332
|
+
{
|
|
333
|
+
matcher: "*",
|
|
334
|
+
hooks: [
|
|
335
|
+
{ type: "command", command: `${shell} PostToolUse`, timeout: HOOK_TIMEOUT_SECONDS }
|
|
336
|
+
]
|
|
337
|
+
}
|
|
338
|
+
]
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function readHooksConfigSync(hooksConfigPath) {
|
|
342
|
+
let stat;
|
|
343
|
+
try {
|
|
344
|
+
stat = fs.lstatSync(hooksConfigPath);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (err.code === "ENOENT") return { kind: "absent" };
|
|
347
|
+
return { kind: "unsupported", reason: `cannot stat: ${err.message}` };
|
|
348
|
+
}
|
|
349
|
+
if (stat.isSymbolicLink()) return { kind: "unsupported", reason: "is a symlink" };
|
|
350
|
+
if (!stat.isFile()) return { kind: "unsupported", reason: "is not a regular file" };
|
|
351
|
+
let raw;
|
|
352
|
+
try {
|
|
353
|
+
raw = fs.readFileSync(hooksConfigPath, "utf8");
|
|
354
|
+
} catch (err) {
|
|
355
|
+
return { kind: "unsupported", reason: `cannot read: ${err.message}` };
|
|
356
|
+
}
|
|
357
|
+
if (raw.trim().length === 0) return { kind: "object", config: {}, mode: stat.mode & 511 };
|
|
358
|
+
let parsed;
|
|
359
|
+
try {
|
|
360
|
+
parsed = JSON.parse(raw);
|
|
361
|
+
} catch {
|
|
362
|
+
return { kind: "unsupported", reason: "is not valid JSON" };
|
|
363
|
+
}
|
|
364
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
365
|
+
return { kind: "unsupported", reason: "is not a JSON object" };
|
|
366
|
+
}
|
|
367
|
+
return { kind: "object", config: parsed, mode: stat.mode & 511 };
|
|
368
|
+
}
|
|
369
|
+
function sameJson(a, b) {
|
|
370
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
371
|
+
}
|
|
372
|
+
function isUserDisabled(entry) {
|
|
373
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
374
|
+
const named = entry;
|
|
375
|
+
if (named.enabled === false) return true;
|
|
376
|
+
const visit = (value) => {
|
|
377
|
+
if (Array.isArray(value)) return value.some(visit);
|
|
378
|
+
if (!value || typeof value !== "object") return false;
|
|
379
|
+
const obj = value;
|
|
380
|
+
return obj.enabled === false || visit(obj.hooks);
|
|
381
|
+
};
|
|
382
|
+
return Object.entries(named).some(([key, value]) => key !== "enabled" && visit(value));
|
|
383
|
+
}
|
|
384
|
+
async function installHookFile(source, dest, mode) {
|
|
385
|
+
const wanted = await fs.promises.readFile(source);
|
|
386
|
+
try {
|
|
387
|
+
const current = await fs.promises.readFile(dest);
|
|
388
|
+
const currentMode = (await fs.promises.stat(dest)).mode & 511;
|
|
389
|
+
if (current.equals(wanted) && currentMode === mode) return;
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
const tmp = `${dest}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}`;
|
|
393
|
+
const handle = await fs.promises.open(tmp, "wx", mode);
|
|
394
|
+
try {
|
|
395
|
+
await handle.chmod(mode);
|
|
396
|
+
await handle.writeFile(wanted);
|
|
397
|
+
await handle.close();
|
|
398
|
+
} catch (err) {
|
|
399
|
+
try {
|
|
400
|
+
await handle.close();
|
|
401
|
+
} catch {
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
await fs.promises.unlink(tmp);
|
|
405
|
+
} catch {
|
|
406
|
+
}
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
await fs.promises.rename(tmp, dest);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
try {
|
|
413
|
+
await fs.promises.unlink(tmp);
|
|
414
|
+
} catch {
|
|
415
|
+
}
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function ensureHookRegistration(paths, logger = defaultLogger()) {
|
|
420
|
+
try {
|
|
421
|
+
await fs.promises.mkdir(paths.hooksDir, { recursive: true, mode: 448 });
|
|
422
|
+
await fs.promises.mkdir(path2.dirname(paths.hooksConfigPath), { recursive: true });
|
|
423
|
+
const releaseLock = await acquireLock(
|
|
424
|
+
`${paths.hooksConfigPath}.lock`,
|
|
425
|
+
LOCK_ACQUIRE_TIMEOUT_MS,
|
|
426
|
+
logger,
|
|
427
|
+
"hooks-config"
|
|
428
|
+
);
|
|
429
|
+
try {
|
|
430
|
+
await installHookFile(paths.hookScriptSourcePath, path2.join(paths.hooksDir, HOOK_SCRIPT_BASENAME), 420);
|
|
431
|
+
await installHookFile(paths.hookShellSourcePath, path2.join(paths.hooksDir, HOOK_SHELL_BASENAME), 493);
|
|
432
|
+
const read = readHooksConfigSync(paths.hooksConfigPath);
|
|
433
|
+
if (read.kind === "unsupported") {
|
|
434
|
+
const reason = `${paths.hooksConfigPath} ${read.reason}; left untouched`;
|
|
435
|
+
logger.warn(`agy session hooks not registered: ${reason}`);
|
|
436
|
+
return { status: "unsupported", reason };
|
|
437
|
+
}
|
|
438
|
+
const config = read.kind === "object" ? read.config : {};
|
|
439
|
+
if (isUserDisabled(config[HOOK_ENTRY_NAME])) {
|
|
440
|
+
logger.info('agy session hooks are switched off in hooks.json ("enabled": false); leaving them off', {
|
|
441
|
+
hooksConfigPath: paths.hooksConfigPath
|
|
442
|
+
});
|
|
443
|
+
return { status: "disabled" };
|
|
444
|
+
}
|
|
445
|
+
const desired = buildHookEntry(paths.hooksDir);
|
|
446
|
+
if (sameJson(config[HOOK_ENTRY_NAME], desired)) return { status: "current" };
|
|
447
|
+
config[HOOK_ENTRY_NAME] = desired;
|
|
448
|
+
const existingMode = read.kind === "object" ? read.mode : null;
|
|
449
|
+
const tmp = `${paths.hooksConfigPath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}`;
|
|
450
|
+
const handle = await fs.promises.open(tmp, "wx", existingMode ?? 420);
|
|
451
|
+
try {
|
|
452
|
+
if (existingMode !== null) await handle.chmod(existingMode);
|
|
453
|
+
await handle.writeFile(JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
454
|
+
await handle.close();
|
|
455
|
+
} catch (err) {
|
|
456
|
+
try {
|
|
457
|
+
await handle.close();
|
|
458
|
+
} catch {
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
await fs.promises.unlink(tmp);
|
|
462
|
+
} catch {
|
|
463
|
+
}
|
|
464
|
+
throw err;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
await fs.promises.rename(tmp, paths.hooksConfigPath);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
try {
|
|
470
|
+
await fs.promises.unlink(tmp);
|
|
471
|
+
} catch {
|
|
472
|
+
}
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
logger.info("Registered the CodeVibe agy session hooks", { hooksConfigPath: paths.hooksConfigPath });
|
|
476
|
+
return { status: "written" };
|
|
477
|
+
} finally {
|
|
478
|
+
await releaseLock();
|
|
479
|
+
}
|
|
480
|
+
} catch (err) {
|
|
481
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
482
|
+
logger.warn(`agy session hooks not registered: ${reason}`);
|
|
483
|
+
return { status: "failed", reason };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
294
486
|
function sha256Hex(s) {
|
|
295
487
|
return crypto.createHash("sha256").update(s, "utf8").digest("hex");
|
|
296
488
|
}
|
|
297
489
|
async function tryReadCurrentHash(installDir) {
|
|
298
490
|
try {
|
|
299
|
-
const sentinel = await fs.promises.readFile(
|
|
491
|
+
const sentinel = await fs.promises.readFile(path2.join(installDir, SENTINEL_FILENAME), "utf8");
|
|
300
492
|
return sentinel.trim();
|
|
301
493
|
} catch {
|
|
302
494
|
return null;
|
|
@@ -319,7 +511,7 @@ async function lstatExists(p) {
|
|
|
319
511
|
}
|
|
320
512
|
}
|
|
321
513
|
async function acquireLock(lockPath, timeoutMs, logger, label = "install") {
|
|
322
|
-
await fs.promises.mkdir(
|
|
514
|
+
await fs.promises.mkdir(path2.dirname(lockPath), { recursive: true });
|
|
323
515
|
const myPidLine = `${process.pid}
|
|
324
516
|
`;
|
|
325
517
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -437,21 +629,19 @@ function isInstallerVerbose(env = process.env, argv = process.argv) {
|
|
|
437
629
|
}
|
|
438
630
|
async function main() {
|
|
439
631
|
const isVerbose = isInstallerVerbose();
|
|
440
|
-
const pkgDir =
|
|
632
|
+
const pkgDir = path3.resolve(__dirname, "..");
|
|
441
633
|
const paths = resolveInstallerPaths({ pluginPkgDir: pkgDir });
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
info: (msg, meta) => {
|
|
447
|
-
if (isVerbose) {
|
|
448
|
-
console.log(`[installer] ${msg}`, meta ?? "");
|
|
449
|
-
}
|
|
450
|
-
},
|
|
451
|
-
warn: (msg, meta) => console.warn(`[installer] WARN: ${msg}`, meta ?? ""),
|
|
452
|
-
error: (msg, meta) => console.error(`[installer] ERROR: ${msg}`, meta ?? "")
|
|
634
|
+
const logger = {
|
|
635
|
+
info: (msg, meta) => {
|
|
636
|
+
if (isVerbose) {
|
|
637
|
+
console.log(`[installer] ${msg}`, meta ?? "");
|
|
453
638
|
}
|
|
454
|
-
}
|
|
639
|
+
},
|
|
640
|
+
warn: (msg, meta) => console.warn(`[installer] WARN: ${msg}`, meta ?? ""),
|
|
641
|
+
error: (msg, meta) => console.error(`[installer] ERROR: ${msg}`, meta ?? "")
|
|
642
|
+
};
|
|
643
|
+
try {
|
|
644
|
+
const result = await ensureInstalled({ ...paths, logger });
|
|
455
645
|
if (isVerbose) {
|
|
456
646
|
if (result.installed) {
|
|
457
647
|
console.log(`[installer] Installed fresh manifest hash=${result.manifestHash}`);
|
|
@@ -459,12 +649,14 @@ async function main() {
|
|
|
459
649
|
console.log(`[installer] Up to date (manifest hash=${result.manifestHash})`);
|
|
460
650
|
}
|
|
461
651
|
}
|
|
462
|
-
process.exit(0);
|
|
463
652
|
} catch (err) {
|
|
464
653
|
const msg = err instanceof Error ? err.message : String(err);
|
|
465
654
|
console.error(`[installer] FATAL: ${msg}`);
|
|
466
655
|
process.exit(1);
|
|
467
656
|
}
|
|
657
|
+
const hooks = await ensureHookRegistration(resolveHookInstallPaths({ pluginPkgDir: pkgDir }), logger);
|
|
658
|
+
if (isVerbose) console.log(`[installer] agy session hooks: ${hooks.status}`);
|
|
659
|
+
process.exit(0);
|
|
468
660
|
}
|
|
469
661
|
if (require.main === module) {
|
|
470
662
|
void main();
|