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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @sessionpipe/conformance
1
+ # sessionpipe-conformance
2
2
 
3
3
  Runs the sessionpipe conformance fixtures against a receiver URL or an adapter.
4
4
 
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // sessionpipe-conformance 0.1.0 · Apache-2.0 · https://sessionpipe.org
2
+ // sessionpipe-conformance 0.2.0 · Apache-2.0 · https://sessionpipe.org
3
3
 
4
4
  // src/cli.ts
5
5
  import { readdirSync as readdirSync5, statSync as statSync4 } from "node:fs";
@@ -9,7 +9,16 @@ import path7 from "node:path";
9
9
  import { readFileSync as readFileSync6 } from "node:fs";
10
10
 
11
11
  // ../core/src/adapters/antigravity.ts
12
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, realpathSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
12
+ import {
13
+ existsSync as existsSync2,
14
+ mkdirSync as mkdirSync2,
15
+ readdirSync as readdirSync2,
16
+ readFileSync as readFileSync2,
17
+ realpathSync,
18
+ statSync as statSync2,
19
+ unlinkSync as unlinkSync2,
20
+ writeFileSync as writeFileSync2
21
+ } from "node:fs";
13
22
  import path3 from "node:path";
14
23
 
15
24
  // ../core/src/paths.ts
@@ -217,28 +226,67 @@ function readAntigravity(lines, from) {
217
226
  }
218
227
 
219
228
  // ../core/src/adapters/claude-shaped.ts
220
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
229
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
221
230
  import path2 from "node:path";
222
231
  var OURS = /sessionpipe[^"\n]*[\\/]hook\.js"?\s+[a-z][a-z-]*\s+[A-Za-z]+\s*$/;
223
232
  var isOurs = (h) => Array.isArray(h?.hooks) && h.hooks.some((x) => typeof x.command === "string" && OURS.test(x.command));
224
- function readJson(file) {
233
+ function readJsonFile(file) {
234
+ let raw;
225
235
  try {
226
- return JSON.parse(readFileSync(file, "utf8"));
236
+ raw = readFileSync(file, "utf8");
227
237
  } catch {
228
- return {};
238
+ return { ok: true, existed: false, value: {}, raw: "", indent: " ", eol: "\n", finalNewline: true };
229
239
  }
240
+ let value;
241
+ try {
242
+ value = JSON.parse(raw);
243
+ } catch {
244
+ const hint = /^\s*\/\/|\/\*/m.test(raw) ? "not plain JSON (it has comments)" : "not plain JSON";
245
+ return { ok: false, existed: true, reason: hint };
246
+ }
247
+ if (!value || typeof value !== "object" || Array.isArray(value))
248
+ return { ok: false, existed: true, reason: "not a JSON object" };
249
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
250
+ const m = /^( +|\t+)\S/m.exec(raw);
251
+ const indent = m?.[1] ?? " ";
252
+ return {
253
+ ok: true,
254
+ existed: true,
255
+ value,
256
+ raw,
257
+ indent,
258
+ eol,
259
+ finalNewline: /\r?\n$/.test(raw)
260
+ };
261
+ }
262
+ function formatJson(value, f) {
263
+ const text = JSON.stringify(value, null, f.indent).replace(/\n/g, f.eol);
264
+ return f.finalNewline ? text + f.eol : text;
230
265
  }
231
266
  function entryFor(event, harness, cmd, o) {
232
267
  const matcher = o.matcherFor(event);
233
268
  return {
234
269
  ...matcher !== void 0 ? { matcher } : {},
235
- hooks: [{ type: "command", command: cmd([harness, event]).command, timeout: o.timeout }]
270
+ hooks: [{ type: "command", command: cmd([harness, event]).command, timeout: o.timeoutFor?.(event) ?? o.timeout }]
236
271
  };
237
272
  }
238
273
  function installClaudeShaped(file, harness, events, cmd, o) {
239
- const settings2 = readJson(file);
274
+ const f = readJsonFile(file);
275
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
276
+ const settings2 = f.value;
240
277
  const hooks = settings2.hooks && typeof settings2.hooks === "object" ? settings2.hooks : {};
241
278
  let changed = false;
279
+ const want = new Set(events);
280
+ for (const ev of Object.keys(hooks)) {
281
+ if (want.has(ev)) continue;
282
+ const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
283
+ const kept = list.filter((h) => !isOurs(h));
284
+ if (kept.length !== list.length) {
285
+ changed = true;
286
+ if (kept.length) hooks[ev] = kept;
287
+ else delete hooks[ev];
288
+ }
289
+ }
242
290
  for (const ev of events) {
243
291
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
244
292
  const entry2 = entryFor(ev, harness, cmd, o);
@@ -257,19 +305,19 @@ function installClaudeShaped(file, harness, events, cmd, o) {
257
305
  if (changed) {
258
306
  settings2.hooks = hooks;
259
307
  mkdirSync(path2.dirname(file), { recursive: true });
260
- writeFileSync(file, `${JSON.stringify(settings2, null, 2)}
261
- `);
308
+ writeFileSync(file, formatJson(settings2, f));
262
309
  }
263
- return { file, changed };
310
+ return { file, changed, created: !f.existed && changed };
264
311
  }
265
- function uninstallClaudeShaped(file, events) {
312
+ function uninstallClaudeShaped(file, events, opts = {}) {
266
313
  if (!existsSync(file)) return { file, changed: false };
267
- const raw = readFileSync(file, "utf8");
268
- const settings2 = readJson(file);
314
+ const f = readJsonFile(file);
315
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
316
+ const settings2 = f.value;
269
317
  const hooks = settings2.hooks;
270
318
  if (!hooks || typeof hooks !== "object") return { file, changed: false };
271
319
  let changed = false;
272
- for (const ev of events) {
320
+ for (const ev of [...events, ...Object.keys(hooks)]) {
273
321
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
274
322
  const kept = list.filter((h) => !isOurs(h));
275
323
  if (kept.length !== list.length) {
@@ -280,13 +328,17 @@ function uninstallClaudeShaped(file, events) {
280
328
  }
281
329
  if (!changed) return { file, changed: false };
282
330
  if (!Object.keys(hooks).length) delete settings2.hooks;
283
- const next = `${JSON.stringify(settings2, null, 2)}
284
- `;
285
- if (next !== raw) writeFileSync(file, next);
331
+ if (opts.created && !Object.keys(settings2).length) {
332
+ unlinkSync(file);
333
+ return { file, changed: true, note: "removed (sessionpipe created it)" };
334
+ }
335
+ const next = formatJson(settings2, f);
336
+ if (next !== f.raw) writeFileSync(file, next);
286
337
  return { file, changed: true };
287
338
  }
288
339
  function installedClaudeShaped(file, harness, events, cmd, o) {
289
- const hooks = readJson(file).hooks ?? {};
340
+ const f = readJsonFile(file);
341
+ const hooks = f.ok ? f.value.hooks ?? {} : {};
290
342
  let present = 0;
291
343
  let current = 0;
292
344
  for (const ev of events) {
@@ -295,6 +347,9 @@ function installedClaudeShaped(file, harness, events, cmd, o) {
295
347
  if (mine.length) present++;
296
348
  if (mine.length === 1 && JSON.stringify(mine[0]) === JSON.stringify(entryFor(ev, harness, cmd, o))) current++;
297
349
  }
350
+ for (const ev of Object.keys(hooks))
351
+ if (!events.includes(ev) && (Array.isArray(hooks[ev]) ? hooks[ev] : []).some((h) => isOurs(h)))
352
+ return "stale";
298
353
  if (!present) return "missing";
299
354
  return current === events.length ? "current" : "stale";
300
355
  }
@@ -309,10 +364,13 @@ var projectsDir = (env) => path3.join(gemini(env), "config", "projects");
309
364
  var dataDirs = (env) => ["antigravity", "antigravity-cli", "antigravity-ide"].map((d) => path3.join(gemini(env), d));
310
365
  var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
311
366
  var validTool = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
312
- function entry(cmd) {
367
+ function entry(cmd, lean) {
313
368
  const handler = (e) => ({ type: "command", command: cmd([NAME, e]).command, timeout: 5 });
314
369
  const out = { enabled: true };
315
- for (const e of AG_EVENTS) out[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
370
+ for (const e of AG_EVENTS) {
371
+ if (lean && e === "PreToolUse") continue;
372
+ out[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
373
+ }
316
374
  return out;
317
375
  }
318
376
  var antigravity = {
@@ -320,34 +378,49 @@ var antigravity = {
320
378
  events: AG_EVENTS,
321
379
  detect: (env = process.env) => existsSync2(path3.join(gemini(env), "config")) || dataDirs(env).some((d) => existsSync2(d)),
322
380
  configFiles: (env = process.env) => [hooksFile(env)],
323
- install(cmd, env = process.env) {
381
+ install(cmd, env = process.env, opts = {}) {
324
382
  const file = hooksFile(env);
325
- const hooks = readJson(file);
326
- const e = entry(cmd);
383
+ const f = readJsonFile(file);
384
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
385
+ const hooks = f.value;
386
+ const e = entry(cmd, opts.lean);
327
387
  if (JSON.stringify(hooks[HOOK_NAME]) === JSON.stringify(e)) return [{ file, changed: false }];
328
388
  hooks[HOOK_NAME] = e;
329
389
  mkdirSync2(path3.dirname(file), { recursive: true });
330
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
331
- `);
390
+ writeFileSync2(file, formatJson(hooks, f));
332
391
  return [
333
- { file, changed: true, note: "hooks.json is read when a conversation starts; open ones report after a restart" }
392
+ {
393
+ file,
394
+ changed: true,
395
+ created: !f.existed,
396
+ note: "hooks.json is read when a conversation starts; open ones report after a restart"
397
+ }
334
398
  ];
335
399
  },
336
- uninstall(env = process.env) {
400
+ uninstall(env = process.env, opts = {}) {
337
401
  const file = hooksFile(env);
338
402
  if (!existsSync2(file)) return [{ file, changed: false }];
339
- const hooks = readJson(file);
403
+ const f = readJsonFile(file);
404
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
405
+ const hooks = f.value;
340
406
  if (!(HOOK_NAME in hooks)) return [{ file, changed: false }];
341
407
  delete hooks[HOOK_NAME];
342
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
343
- `);
408
+ if (opts.created?.includes(file) && !Object.keys(hooks).length) {
409
+ unlinkSync2(file);
410
+ return [{ file, changed: true, note: "removed (sessionpipe created it)" }];
411
+ }
412
+ writeFileSync2(file, formatJson(hooks, f));
344
413
  return [{ file, changed: true }];
345
414
  },
346
- installed(cmd, env = process.env) {
415
+ installed(cmd, env = process.env, opts = {}) {
347
416
  const file = hooksFile(env);
348
- const have = readJson(file)[HOOK_NAME];
417
+ const f = readJsonFile(file);
418
+ const have = f.ok ? f.value[HOOK_NAME] : void 0;
349
419
  return [
350
- { file, state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd)) ? "current" : "stale" }
420
+ {
421
+ file,
422
+ state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd, opts.lean)) ? "current" : "stale"
423
+ }
351
424
  ];
352
425
  },
353
426
  fromHook(input) {
@@ -613,9 +686,18 @@ var CLAUDE_EVENTS = [
613
686
  "SubagentStop",
614
687
  "PreCompact",
615
688
  "PostCompact",
616
- "SessionEnd"
689
+ "SessionEnd",
690
+ "StopFailure"
617
691
  ];
618
- var OPTS = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
692
+ var LEAN_SKIP = /* @__PURE__ */ new Set(["PreToolUse", "PostToolUseFailure"]);
693
+ var eventsFor = (lean) => lean ? CLAUDE_EVENTS.filter((e) => !LEAN_SKIP.has(e)) : [...CLAUDE_EVENTS];
694
+ var OPTS = {
695
+ matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0,
696
+ timeout: 5,
697
+ // The hook waits up to 125 s for a verified control answer, and only when control
698
+ // is paired on this machine; everywhere else it exits in milliseconds as before.
699
+ timeoutFor: (e) => e === "PermissionRequest" ? 130 : void 0
700
+ };
619
701
  var DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/;
620
702
  function claudeDirs(env = process.env, home = HOME) {
621
703
  const out = [];
@@ -646,11 +728,13 @@ var claudeCode = {
646
728
  events: CLAUDE_EVENTS,
647
729
  detect: (env = process.env) => claudeDirs(env).some((d) => existsSync3(d)),
648
730
  configFiles: (env = process.env) => claudeDirs(env).map(settingsIn),
649
- install: (cmd, env = process.env) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd, OPTS)),
650
- uninstall: (env = process.env) => claudeDirs(env).map((d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS)),
651
- installed: (cmd, env = process.env) => claudeDirs(env).map((d) => ({
731
+ install: (cmd, env = process.env, opts = {}) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd, OPTS)),
732
+ uninstall: (env = process.env, opts = {}) => claudeDirs(env).map(
733
+ (d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS, { created: opts.created?.includes(settingsIn(d)) })
734
+ ),
735
+ installed: (cmd, env = process.env, opts = {}) => claudeDirs(env).map((d) => ({
652
736
  file: settingsIn(d),
653
- state: installedClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd, OPTS)
737
+ state: installedClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd, OPTS)
654
738
  })),
655
739
  fromHook(input) {
656
740
  const event = input.argv[1] ?? "";
@@ -741,6 +825,15 @@ var claudeCode = {
741
825
  case "Stop":
742
826
  events.push(ev("turn.ended", { turn_id, reason: "stop" }));
743
827
  break;
828
+ case "StopFailure":
829
+ events.push(
830
+ ev("turn.ended", {
831
+ turn_id,
832
+ reason: "error",
833
+ error: typeof s.error === "string" ? s.error.slice(0, 200) : void 0
834
+ })
835
+ );
836
+ break;
744
837
  case "SubagentStart":
745
838
  events.push(
746
839
  ev("subagent.started", {
@@ -1252,6 +1345,7 @@ var CODEX_EVENTS = [
1252
1345
  "SessionEnd"
1253
1346
  ];
1254
1347
  var OPTS2 = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
1348
+ var eventsFor2 = (lean) => lean ? CODEX_EVENTS.filter((e) => e !== "PreToolUse") : [...CODEX_EVENTS];
1255
1349
  var codexHome = (env) => env.CODEX_HOME || path5.join(HOME, ".codex");
1256
1350
  var hooksFile2 = (env) => path5.join(codexHome(env), "hooks.json");
1257
1351
  var configFile = (env) => path5.join(codexHome(env), "config.toml");
@@ -1300,7 +1394,8 @@ function installNotify(cmd, env) {
1300
1394
  return {
1301
1395
  file,
1302
1396
  changed: false,
1303
- note: `left alone: notify runs ${n.raw.slice(0, 80)} (hooks.json carries the events; the notify fallback is off)`
1397
+ skipped: true,
1398
+ 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`
1304
1399
  };
1305
1400
  }
1306
1401
  write(withNotify(text, line));
@@ -1357,14 +1452,21 @@ var codex = {
1357
1452
  events: CODEX_EVENTS,
1358
1453
  detect: (env = process.env) => existsSync4(codexHome(env)),
1359
1454
  configFiles: (env = process.env) => [hooksFile2(env), configFile(env)],
1360
- install: (cmd, env = process.env) => [
1361
- installClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd, OPTS2),
1455
+ install: (cmd, env = process.env, opts = {}) => [
1456
+ installClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd, OPTS2),
1362
1457
  installNotify(cmd, env)
1363
1458
  ],
1364
- uninstall: (env = process.env) => [uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS), uninstallNotify(env)],
1365
- installed: (cmd, env = process.env) => {
1459
+ uninstall: (env = process.env, opts = {}) => [
1460
+ uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS, { created: opts.created?.includes(hooksFile2(env)) }),
1461
+ uninstallNotify(env)
1462
+ ],
1463
+ installed: (cmd, env = process.env, opts = {}) => {
1366
1464
  const out = [
1367
- { file: hooksFile2(env), state: installedClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd, OPTS2) }
1465
+ {
1466
+ file: hooksFile2(env),
1467
+ state: installedClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd, OPTS2),
1468
+ note: "Codex runs non-managed hooks only after the review in `codex` (/hooks); until then nothing is reported unless the notify fallback is ours"
1469
+ }
1368
1470
  ];
1369
1471
  let text = "";
1370
1472
  try {
@@ -1552,6 +1654,7 @@ function codexFacts(file) {
1552
1654
  }
1553
1655
 
1554
1656
  // ../core/src/adapters/gemini-cli.ts
1657
+ import { execFileSync as execFileSync3 } from "node:child_process";
1555
1658
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
1556
1659
  import path6 from "node:path";
1557
1660
  var NAME4 = "gemini-cli";
@@ -1569,17 +1672,61 @@ var OPTS3 = { matcherFor: (e) => /Tool/.test(e) ? ".*" : void 0, timeout: 5e3 };
1569
1672
  var geminiDir = (env) => env.GEMINI_CLI_HOME || path6.join(HOME, ".gemini");
1570
1673
  var settings = (env) => path6.join(geminiDir(env), "settings.json");
1571
1674
  var validTool4 = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
1675
+ var eventsFor3 = (lean) => lean ? GEMINI_EVENTS.filter((e) => e !== "BeforeTool") : [...GEMINI_EVENTS];
1676
+ function geminiHooksActive(settingsFile) {
1677
+ let tools;
1678
+ try {
1679
+ tools = JSON.parse(readFileSync5(settingsFile, "utf8")).tools;
1680
+ } catch {
1681
+ }
1682
+ if (tools?.enableHooks === true) return { active: true };
1683
+ if (tools?.enableHooks === false)
1684
+ return { active: false, note: "inactive: tools.enableHooks is false in settings.json" };
1685
+ let version = "";
1686
+ try {
1687
+ version = execFileSync3("gemini", ["--version"], {
1688
+ encoding: "utf8",
1689
+ timeout: 4e3,
1690
+ stdio: ["ignore", "pipe", "ignore"]
1691
+ }).trim();
1692
+ } catch {
1693
+ return {
1694
+ active: true,
1695
+ note: "could not run `gemini --version`; hooks need tools.enableHooks on versions before 0.46"
1696
+ };
1697
+ }
1698
+ const m = /(\d+)\.(\d+)/.exec(version);
1699
+ if (m && (Number(m[1]) > 0 || Number(m[2]) >= 46)) return { active: true };
1700
+ return {
1701
+ active: false,
1702
+ note: `inactive: Gemini CLI ${version || "?"} runs hooks only with "tools": {"enableHooks": true} in settings.json`
1703
+ };
1704
+ }
1572
1705
  var geminiCli = {
1573
1706
  name: NAME4,
1574
1707
  events: GEMINI_EVENTS,
1575
1708
  // ~/.gemini also belongs to Antigravity; Gemini CLI is here when its settings or its chat logs exist.
1576
1709
  detect: (env = process.env) => existsSync5(settings(env)) || existsSync5(path6.join(geminiDir(env), "tmp")),
1577
1710
  configFiles: (env = process.env) => [settings(env)],
1578
- install: (cmd, env = process.env) => [installClaudeShaped(settings(env), NAME4, GEMINI_EVENTS, cmd, OPTS3)],
1579
- uninstall: (env = process.env) => [uninstallClaudeShaped(settings(env), GEMINI_EVENTS)],
1580
- installed: (cmd, env = process.env) => [
1581
- { file: settings(env), state: installedClaudeShaped(settings(env), NAME4, GEMINI_EVENTS, cmd, OPTS3) }
1711
+ install: (cmd, env = process.env, opts = {}) => {
1712
+ const r2 = installClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd, OPTS3);
1713
+ const a = geminiHooksActive(settings(env));
1714
+ return [a.note && !r2.skipped ? { ...r2, note: [r2.note, a.note].filter(Boolean).join("; ") } : r2];
1715
+ },
1716
+ uninstall: (env = process.env, opts = {}) => [
1717
+ uninstallClaudeShaped(settings(env), GEMINI_EVENTS, { created: opts.created?.includes(settings(env)) })
1582
1718
  ],
1719
+ installed: (cmd, env = process.env, opts = {}) => {
1720
+ const state = installedClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd, OPTS3);
1721
+ const a = geminiHooksActive(settings(env));
1722
+ return [
1723
+ {
1724
+ file: settings(env),
1725
+ state: state === "current" && !a.active ? "inactive" : state,
1726
+ ...a.note ? { note: a.note } : {}
1727
+ }
1728
+ ];
1729
+ },
1583
1730
  fromHook(input) {
1584
1731
  const event = input.argv[1] ?? "";
1585
1732
  let s = {};