@quantiya/codevibe-antigravity-plugin 2.0.63 → 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 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
  ```
@@ -6,6 +6,6 @@
6
6
  "implementation": {
7
7
  "architecture": "transcript+tmux+approval-heuristic",
8
8
  "schema_version": "v1.0",
9
- "note": "v1.0 ships without JSON hooks (agy backend gate `enable_json_hooks` is OFF for our account). Monitors `~/.gemini/antigravity-cli/brain/<UUID>/.system_generated/logs/transcript.jsonl` + mirrors the tmux pane for approval-UI detection. v1.1 will add hook handlers when the backend gate flips."
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
  }
@@ -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
+ });
@@ -34,25 +34,43 @@ __export(installer_cli_exports, {
34
34
  isInstallerVerbose: () => isInstallerVerbose
35
35
  });
36
36
  module.exports = __toCommonJS(installer_cli_exports);
37
- var path2 = __toESM(require("path"));
37
+ var path3 = __toESM(require("path"));
38
38
 
39
39
  // src/installer.ts
40
40
  var fs = __toESM(require("fs"));
41
- var path = __toESM(require("path"));
42
- var os = __toESM(require("os"));
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 ?? os.homedir();
68
+ const home = opts.homedir ?? os2.homedir();
51
69
  return {
52
- pluginJsonSourcePath: path.join(opts.pluginPkgDir, MANIFEST_FILENAME),
53
- installDir: path.join(home, ".gemini", "antigravity-cli", "plugins", "codevibe-antigravity"),
54
- mcpConfigPath: path.join(home, ".gemini", "config", "mcp_config.json"),
55
- serverScriptPath: path.join(opts.pluginPkgDir, "dist", "server.js")
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 = path.join(path.dirname(installDir), ".codevibe-install.lock");
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(path.dirname(installDir), { recursive: true });
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 = path.join(stagingDir, MANIFEST_FILENAME);
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 = path.join(stagingDir, SENTINEL_FILENAME);
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 = path.dirname(installDir);
163
- const baseName = path.basename(installDir);
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 = path.join(parent, entry.name);
174
- const sentinel = path.join(dir, SENTINEL_FILENAME);
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 = path.join(parent, entry.name);
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(path.dirname(mcpConfigPath), { recursive: true });
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: ${path.dirname(mcpConfigPath)}`
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(path.join(installDir, SENTINEL_FILENAME), "utf8");
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(path.dirname(lockPath), { recursive: true });
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 = path2.resolve(__dirname, "..");
632
+ const pkgDir = path3.resolve(__dirname, "..");
441
633
  const paths = resolveInstallerPaths({ pluginPkgDir: pkgDir });
442
- try {
443
- const result = await ensureInstalled({
444
- ...paths,
445
- logger: {
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();