sessionpipe-conformance 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,10 +1,19 @@
1
- // sessionpipe-conformance 0.1.0 · Apache-2.0 · https://sessionpipe.org
1
+ // sessionpipe-conformance 0.2.0 · Apache-2.0 · https://sessionpipe.org
2
2
 
3
3
  // src/index.ts
4
4
  import { readFileSync as readFileSync6 } from "node:fs";
5
5
 
6
6
  // ../core/src/adapters/antigravity.ts
7
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, realpathSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
7
+ import {
8
+ existsSync as existsSync2,
9
+ mkdirSync as mkdirSync2,
10
+ readdirSync as readdirSync2,
11
+ readFileSync as readFileSync2,
12
+ realpathSync,
13
+ statSync as statSync2,
14
+ unlinkSync as unlinkSync2,
15
+ writeFileSync as writeFileSync2
16
+ } from "node:fs";
8
17
  import path3 from "node:path";
9
18
 
10
19
  // ../core/src/paths.ts
@@ -212,28 +221,67 @@ function readAntigravity(lines, from) {
212
221
  }
213
222
 
214
223
  // ../core/src/adapters/claude-shaped.ts
215
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
224
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
216
225
  import path2 from "node:path";
217
226
  var OURS = /sessionpipe[^"\n]*[\\/]hook\.js"?\s+[a-z][a-z-]*\s+[A-Za-z]+\s*$/;
218
227
  var isOurs = (h) => Array.isArray(h?.hooks) && h.hooks.some((x) => typeof x.command === "string" && OURS.test(x.command));
219
- function readJson(file) {
228
+ function readJsonFile(file) {
229
+ let raw;
220
230
  try {
221
- return JSON.parse(readFileSync(file, "utf8"));
231
+ raw = readFileSync(file, "utf8");
222
232
  } catch {
223
- return {};
233
+ return { ok: true, existed: false, value: {}, raw: "", indent: " ", eol: "\n", finalNewline: true };
224
234
  }
235
+ let value;
236
+ try {
237
+ value = JSON.parse(raw);
238
+ } catch {
239
+ const hint = /^\s*\/\/|\/\*/m.test(raw) ? "not plain JSON (it has comments)" : "not plain JSON";
240
+ return { ok: false, existed: true, reason: hint };
241
+ }
242
+ if (!value || typeof value !== "object" || Array.isArray(value))
243
+ return { ok: false, existed: true, reason: "not a JSON object" };
244
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
245
+ const m = /^( +|\t+)\S/m.exec(raw);
246
+ const indent = m?.[1] ?? " ";
247
+ return {
248
+ ok: true,
249
+ existed: true,
250
+ value,
251
+ raw,
252
+ indent,
253
+ eol,
254
+ finalNewline: /\r?\n$/.test(raw)
255
+ };
256
+ }
257
+ function formatJson(value, f) {
258
+ const text = JSON.stringify(value, null, f.indent).replace(/\n/g, f.eol);
259
+ return f.finalNewline ? text + f.eol : text;
225
260
  }
226
261
  function entryFor(event, harness, cmd, o) {
227
262
  const matcher = o.matcherFor(event);
228
263
  return {
229
264
  ...matcher !== void 0 ? { matcher } : {},
230
- hooks: [{ type: "command", command: cmd([harness, event]).command, timeout: o.timeout }]
265
+ hooks: [{ type: "command", command: cmd([harness, event]).command, timeout: o.timeoutFor?.(event) ?? o.timeout }]
231
266
  };
232
267
  }
233
268
  function installClaudeShaped(file, harness, events, cmd, o) {
234
- const settings2 = readJson(file);
269
+ const f = readJsonFile(file);
270
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
271
+ const settings2 = f.value;
235
272
  const hooks = settings2.hooks && typeof settings2.hooks === "object" ? settings2.hooks : {};
236
273
  let changed = false;
274
+ const want = new Set(events);
275
+ for (const ev of Object.keys(hooks)) {
276
+ if (want.has(ev)) continue;
277
+ const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
278
+ const kept = list.filter((h) => !isOurs(h));
279
+ if (kept.length !== list.length) {
280
+ changed = true;
281
+ if (kept.length) hooks[ev] = kept;
282
+ else delete hooks[ev];
283
+ }
284
+ }
237
285
  for (const ev of events) {
238
286
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
239
287
  const entry2 = entryFor(ev, harness, cmd, o);
@@ -252,19 +300,19 @@ function installClaudeShaped(file, harness, events, cmd, o) {
252
300
  if (changed) {
253
301
  settings2.hooks = hooks;
254
302
  mkdirSync(path2.dirname(file), { recursive: true });
255
- writeFileSync(file, `${JSON.stringify(settings2, null, 2)}
256
- `);
303
+ writeFileSync(file, formatJson(settings2, f));
257
304
  }
258
- return { file, changed };
305
+ return { file, changed, created: !f.existed && changed };
259
306
  }
260
- function uninstallClaudeShaped(file, events) {
307
+ function uninstallClaudeShaped(file, events, opts = {}) {
261
308
  if (!existsSync(file)) return { file, changed: false };
262
- const raw = readFileSync(file, "utf8");
263
- const settings2 = readJson(file);
309
+ const f = readJsonFile(file);
310
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
311
+ const settings2 = f.value;
264
312
  const hooks = settings2.hooks;
265
313
  if (!hooks || typeof hooks !== "object") return { file, changed: false };
266
314
  let changed = false;
267
- for (const ev of events) {
315
+ for (const ev of [...events, ...Object.keys(hooks)]) {
268
316
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
269
317
  const kept = list.filter((h) => !isOurs(h));
270
318
  if (kept.length !== list.length) {
@@ -275,13 +323,17 @@ function uninstallClaudeShaped(file, events) {
275
323
  }
276
324
  if (!changed) return { file, changed: false };
277
325
  if (!Object.keys(hooks).length) delete settings2.hooks;
278
- const next = `${JSON.stringify(settings2, null, 2)}
279
- `;
280
- if (next !== raw) writeFileSync(file, next);
326
+ if (opts.created && !Object.keys(settings2).length) {
327
+ unlinkSync(file);
328
+ return { file, changed: true, note: "removed (sessionpipe created it)" };
329
+ }
330
+ const next = formatJson(settings2, f);
331
+ if (next !== f.raw) writeFileSync(file, next);
281
332
  return { file, changed: true };
282
333
  }
283
334
  function installedClaudeShaped(file, harness, events, cmd, o) {
284
- const hooks = readJson(file).hooks ?? {};
335
+ const f = readJsonFile(file);
336
+ const hooks = f.ok ? f.value.hooks ?? {} : {};
285
337
  let present = 0;
286
338
  let current = 0;
287
339
  for (const ev of events) {
@@ -290,6 +342,9 @@ function installedClaudeShaped(file, harness, events, cmd, o) {
290
342
  if (mine.length) present++;
291
343
  if (mine.length === 1 && JSON.stringify(mine[0]) === JSON.stringify(entryFor(ev, harness, cmd, o))) current++;
292
344
  }
345
+ for (const ev of Object.keys(hooks))
346
+ if (!events.includes(ev) && (Array.isArray(hooks[ev]) ? hooks[ev] : []).some((h) => isOurs(h)))
347
+ return "stale";
293
348
  if (!present) return "missing";
294
349
  return current === events.length ? "current" : "stale";
295
350
  }
@@ -304,10 +359,13 @@ var projectsDir = (env) => path3.join(gemini(env), "config", "projects");
304
359
  var dataDirs = (env) => ["antigravity", "antigravity-cli", "antigravity-ide"].map((d) => path3.join(gemini(env), d));
305
360
  var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
306
361
  var validTool = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
307
- function entry(cmd) {
362
+ function entry(cmd, lean) {
308
363
  const handler = (e) => ({ type: "command", command: cmd([NAME, e]).command, timeout: 5 });
309
364
  const out = { enabled: true };
310
- for (const e of AG_EVENTS) out[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
365
+ for (const e of AG_EVENTS) {
366
+ if (lean && e === "PreToolUse") continue;
367
+ out[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
368
+ }
311
369
  return out;
312
370
  }
313
371
  var antigravity = {
@@ -315,34 +373,49 @@ var antigravity = {
315
373
  events: AG_EVENTS,
316
374
  detect: (env = process.env) => existsSync2(path3.join(gemini(env), "config")) || dataDirs(env).some((d) => existsSync2(d)),
317
375
  configFiles: (env = process.env) => [hooksFile(env)],
318
- install(cmd, env = process.env) {
376
+ install(cmd, env = process.env, opts = {}) {
319
377
  const file = hooksFile(env);
320
- const hooks = readJson(file);
321
- const e = entry(cmd);
378
+ const f = readJsonFile(file);
379
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
380
+ const hooks = f.value;
381
+ const e = entry(cmd, opts.lean);
322
382
  if (JSON.stringify(hooks[HOOK_NAME]) === JSON.stringify(e)) return [{ file, changed: false }];
323
383
  hooks[HOOK_NAME] = e;
324
384
  mkdirSync2(path3.dirname(file), { recursive: true });
325
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
326
- `);
385
+ writeFileSync2(file, formatJson(hooks, f));
327
386
  return [
328
- { file, changed: true, note: "hooks.json is read when a conversation starts; open ones report after a restart" }
387
+ {
388
+ file,
389
+ changed: true,
390
+ created: !f.existed,
391
+ note: "hooks.json is read when a conversation starts; open ones report after a restart"
392
+ }
329
393
  ];
330
394
  },
331
- uninstall(env = process.env) {
395
+ uninstall(env = process.env, opts = {}) {
332
396
  const file = hooksFile(env);
333
397
  if (!existsSync2(file)) return [{ file, changed: false }];
334
- const hooks = readJson(file);
398
+ const f = readJsonFile(file);
399
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
400
+ const hooks = f.value;
335
401
  if (!(HOOK_NAME in hooks)) return [{ file, changed: false }];
336
402
  delete hooks[HOOK_NAME];
337
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
338
- `);
403
+ if (opts.created?.includes(file) && !Object.keys(hooks).length) {
404
+ unlinkSync2(file);
405
+ return [{ file, changed: true, note: "removed (sessionpipe created it)" }];
406
+ }
407
+ writeFileSync2(file, formatJson(hooks, f));
339
408
  return [{ file, changed: true }];
340
409
  },
341
- installed(cmd, env = process.env) {
410
+ installed(cmd, env = process.env, opts = {}) {
342
411
  const file = hooksFile(env);
343
- const have = readJson(file)[HOOK_NAME];
412
+ const f = readJsonFile(file);
413
+ const have = f.ok ? f.value[HOOK_NAME] : void 0;
344
414
  return [
345
- { file, state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd)) ? "current" : "stale" }
415
+ {
416
+ file,
417
+ state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd, opts.lean)) ? "current" : "stale"
418
+ }
346
419
  ];
347
420
  },
348
421
  fromHook(input) {
@@ -608,9 +681,18 @@ var CLAUDE_EVENTS = [
608
681
  "SubagentStop",
609
682
  "PreCompact",
610
683
  "PostCompact",
611
- "SessionEnd"
684
+ "SessionEnd",
685
+ "StopFailure"
612
686
  ];
613
- var OPTS = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
687
+ var LEAN_SKIP = /* @__PURE__ */ new Set(["PreToolUse", "PostToolUseFailure"]);
688
+ var eventsFor = (lean) => lean ? CLAUDE_EVENTS.filter((e) => !LEAN_SKIP.has(e)) : [...CLAUDE_EVENTS];
689
+ var OPTS = {
690
+ matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0,
691
+ timeout: 5,
692
+ // The hook waits up to 125 s for a verified control answer, and only when control
693
+ // is paired on this machine; everywhere else it exits in milliseconds as before.
694
+ timeoutFor: (e) => e === "PermissionRequest" ? 130 : void 0
695
+ };
614
696
  var DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/;
615
697
  function claudeDirs(env = process.env, home = HOME) {
616
698
  const out = [];
@@ -641,11 +723,13 @@ var claudeCode = {
641
723
  events: CLAUDE_EVENTS,
642
724
  detect: (env = process.env) => claudeDirs(env).some((d) => existsSync3(d)),
643
725
  configFiles: (env = process.env) => claudeDirs(env).map(settingsIn),
644
- install: (cmd, env = process.env) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd, OPTS)),
645
- uninstall: (env = process.env) => claudeDirs(env).map((d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS)),
646
- installed: (cmd, env = process.env) => claudeDirs(env).map((d) => ({
726
+ install: (cmd, env = process.env, opts = {}) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd, OPTS)),
727
+ uninstall: (env = process.env, opts = {}) => claudeDirs(env).map(
728
+ (d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS, { created: opts.created?.includes(settingsIn(d)) })
729
+ ),
730
+ installed: (cmd, env = process.env, opts = {}) => claudeDirs(env).map((d) => ({
647
731
  file: settingsIn(d),
648
- state: installedClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd, OPTS)
732
+ state: installedClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd, OPTS)
649
733
  })),
650
734
  fromHook(input) {
651
735
  const event = input.argv[1] ?? "";
@@ -736,6 +820,15 @@ var claudeCode = {
736
820
  case "Stop":
737
821
  events.push(ev("turn.ended", { turn_id, reason: "stop" }));
738
822
  break;
823
+ case "StopFailure":
824
+ events.push(
825
+ ev("turn.ended", {
826
+ turn_id,
827
+ reason: "error",
828
+ error: typeof s.error === "string" ? s.error.slice(0, 200) : void 0
829
+ })
830
+ );
831
+ break;
739
832
  case "SubagentStart":
740
833
  events.push(
741
834
  ev("subagent.started", {
@@ -1247,6 +1340,7 @@ var CODEX_EVENTS = [
1247
1340
  "SessionEnd"
1248
1341
  ];
1249
1342
  var OPTS2 = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
1343
+ var eventsFor2 = (lean) => lean ? CODEX_EVENTS.filter((e) => e !== "PreToolUse") : [...CODEX_EVENTS];
1250
1344
  var codexHome = (env) => env.CODEX_HOME || path5.join(HOME, ".codex");
1251
1345
  var hooksFile2 = (env) => path5.join(codexHome(env), "hooks.json");
1252
1346
  var configFile = (env) => path5.join(codexHome(env), "config.toml");
@@ -1295,7 +1389,8 @@ function installNotify(cmd, env) {
1295
1389
  return {
1296
1390
  file,
1297
1391
  changed: false,
1298
- note: `left alone: notify runs ${n.raw.slice(0, 80)} (hooks.json carries the events; the notify fallback is off)`
1392
+ skipped: true,
1393
+ note: `notify belongs to ${(n.argv?.[0] ?? n.raw).replace(/^.*\//, "").slice(0, 40)}; left alone. Codex will report nothing until you approve the hooks: start \`codex\` and accept the hook review`
1299
1394
  };
1300
1395
  }
1301
1396
  write(withNotify(text, line));
@@ -1352,14 +1447,21 @@ var codex = {
1352
1447
  events: CODEX_EVENTS,
1353
1448
  detect: (env = process.env) => existsSync4(codexHome(env)),
1354
1449
  configFiles: (env = process.env) => [hooksFile2(env), configFile(env)],
1355
- install: (cmd, env = process.env) => [
1356
- installClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd, OPTS2),
1450
+ install: (cmd, env = process.env, opts = {}) => [
1451
+ installClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd, OPTS2),
1357
1452
  installNotify(cmd, env)
1358
1453
  ],
1359
- uninstall: (env = process.env) => [uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS), uninstallNotify(env)],
1360
- installed: (cmd, env = process.env) => {
1454
+ uninstall: (env = process.env, opts = {}) => [
1455
+ uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS, { created: opts.created?.includes(hooksFile2(env)) }),
1456
+ uninstallNotify(env)
1457
+ ],
1458
+ installed: (cmd, env = process.env, opts = {}) => {
1361
1459
  const out = [
1362
- { file: hooksFile2(env), state: installedClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd, OPTS2) }
1460
+ {
1461
+ file: hooksFile2(env),
1462
+ state: installedClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd, OPTS2),
1463
+ note: "Codex runs non-managed hooks only after the review in `codex` (/hooks); until then nothing is reported unless the notify fallback is ours"
1464
+ }
1363
1465
  ];
1364
1466
  let text = "";
1365
1467
  try {
@@ -1547,6 +1649,7 @@ function codexFacts(file) {
1547
1649
  }
1548
1650
 
1549
1651
  // ../core/src/adapters/gemini-cli.ts
1652
+ import { execFileSync as execFileSync3 } from "node:child_process";
1550
1653
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
1551
1654
  import path6 from "node:path";
1552
1655
  var NAME4 = "gemini-cli";
@@ -1564,17 +1667,61 @@ var OPTS3 = { matcherFor: (e) => /Tool/.test(e) ? ".*" : void 0, timeout: 5e3 };
1564
1667
  var geminiDir = (env) => env.GEMINI_CLI_HOME || path6.join(HOME, ".gemini");
1565
1668
  var settings = (env) => path6.join(geminiDir(env), "settings.json");
1566
1669
  var validTool4 = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
1670
+ var eventsFor3 = (lean) => lean ? GEMINI_EVENTS.filter((e) => e !== "BeforeTool") : [...GEMINI_EVENTS];
1671
+ function geminiHooksActive(settingsFile) {
1672
+ let tools;
1673
+ try {
1674
+ tools = JSON.parse(readFileSync5(settingsFile, "utf8")).tools;
1675
+ } catch {
1676
+ }
1677
+ if (tools?.enableHooks === true) return { active: true };
1678
+ if (tools?.enableHooks === false)
1679
+ return { active: false, note: "inactive: tools.enableHooks is false in settings.json" };
1680
+ let version = "";
1681
+ try {
1682
+ version = execFileSync3("gemini", ["--version"], {
1683
+ encoding: "utf8",
1684
+ timeout: 4e3,
1685
+ stdio: ["ignore", "pipe", "ignore"]
1686
+ }).trim();
1687
+ } catch {
1688
+ return {
1689
+ active: true,
1690
+ note: "could not run `gemini --version`; hooks need tools.enableHooks on versions before 0.46"
1691
+ };
1692
+ }
1693
+ const m = /(\d+)\.(\d+)/.exec(version);
1694
+ if (m && (Number(m[1]) > 0 || Number(m[2]) >= 46)) return { active: true };
1695
+ return {
1696
+ active: false,
1697
+ note: `inactive: Gemini CLI ${version || "?"} runs hooks only with "tools": {"enableHooks": true} in settings.json`
1698
+ };
1699
+ }
1567
1700
  var geminiCli = {
1568
1701
  name: NAME4,
1569
1702
  events: GEMINI_EVENTS,
1570
1703
  // ~/.gemini also belongs to Antigravity; Gemini CLI is here when its settings or its chat logs exist.
1571
1704
  detect: (env = process.env) => existsSync5(settings(env)) || existsSync5(path6.join(geminiDir(env), "tmp")),
1572
1705
  configFiles: (env = process.env) => [settings(env)],
1573
- install: (cmd, env = process.env) => [installClaudeShaped(settings(env), NAME4, GEMINI_EVENTS, cmd, OPTS3)],
1574
- uninstall: (env = process.env) => [uninstallClaudeShaped(settings(env), GEMINI_EVENTS)],
1575
- installed: (cmd, env = process.env) => [
1576
- { file: settings(env), state: installedClaudeShaped(settings(env), NAME4, GEMINI_EVENTS, cmd, OPTS3) }
1706
+ install: (cmd, env = process.env, opts = {}) => {
1707
+ const r = installClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd, OPTS3);
1708
+ const a = geminiHooksActive(settings(env));
1709
+ return [a.note && !r.skipped ? { ...r, note: [r.note, a.note].filter(Boolean).join("; ") } : r];
1710
+ },
1711
+ uninstall: (env = process.env, opts = {}) => [
1712
+ uninstallClaudeShaped(settings(env), GEMINI_EVENTS, { created: opts.created?.includes(settings(env)) })
1577
1713
  ],
1714
+ installed: (cmd, env = process.env, opts = {}) => {
1715
+ const state = installedClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd, OPTS3);
1716
+ const a = geminiHooksActive(settings(env));
1717
+ return [
1718
+ {
1719
+ file: settings(env),
1720
+ state: state === "current" && !a.active ? "inactive" : state,
1721
+ ...a.note ? { note: a.note } : {}
1722
+ }
1723
+ ];
1724
+ },
1578
1725
  fromHook(input) {
1579
1726
  const event = input.argv[1] ?? "";
1580
1727
  let s = {};