@hasna/hooks 0.2.20 → 0.3.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.
Files changed (64) hide show
  1. package/README.md +45 -2
  2. package/bin/index.js +1157 -84
  3. package/dist/db/migrations/002_session_events.d.ts +10 -0
  4. package/dist/db/migrations/003_user_prompt_submit_event.d.ts +10 -0
  5. package/dist/db/pg-migrations.d.ts +1 -1
  6. package/dist/db/remote-storage.d.ts +9 -0
  7. package/dist/db/schema.d.ts +4 -3
  8. package/dist/db/storage-sync.d.ts +57 -0
  9. package/dist/index.d.ts +16 -3
  10. package/dist/index.js +1042 -58
  11. package/dist/lib/installer.d.ts +25 -5
  12. package/dist/lib/registry.d.ts +3 -1
  13. package/dist/storage.d.ts +4 -0
  14. package/dist/storage.js +696 -0
  15. package/hooks/codewith-native-common.test.ts +61 -0
  16. package/hooks/codewith-native-common.ts +478 -0
  17. package/hooks/hook-agent-rules-version-check/LICENSE +191 -0
  18. package/hooks/hook-agent-rules-version-check/README.md +50 -0
  19. package/hooks/hook-agent-rules-version-check/package.json +53 -0
  20. package/hooks/hook-agent-rules-version-check/src/hook.test.ts +104 -0
  21. package/hooks/hook-agent-rules-version-check/src/hook.ts +215 -0
  22. package/hooks/hook-agent-rules-version-check/tsconfig.json +25 -0
  23. package/hooks/hook-announce-start/README.md +7 -5
  24. package/hooks/hook-announce-start/package.json +2 -2
  25. package/hooks/hook-announce-start/src/hook.ts +33 -14
  26. package/hooks/hook-fleet-blockers-gate/LICENSE +191 -0
  27. package/hooks/hook-fleet-blockers-gate/README.md +39 -0
  28. package/hooks/hook-fleet-blockers-gate/package.json +52 -0
  29. package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +106 -0
  30. package/hooks/hook-fleet-blockers-gate/src/hook.ts +242 -0
  31. package/hooks/hook-fleet-blockers-gate/tsconfig.json +25 -0
  32. package/hooks/hook-fleet-catchup/LICENSE +191 -0
  33. package/hooks/hook-fleet-catchup/README.md +40 -0
  34. package/hooks/hook-fleet-catchup/package.json +52 -0
  35. package/hooks/hook-fleet-catchup/src/hook.test.ts +156 -0
  36. package/hooks/hook-fleet-catchup/src/hook.ts +283 -0
  37. package/hooks/hook-fleet-catchup/tsconfig.json +25 -0
  38. package/hooks/pre-bash/README.md +17 -0
  39. package/hooks/pre-bash/package.json +12 -0
  40. package/hooks/pre-bash/src/hook.ts +103 -0
  41. package/hooks/prompt-guard/README.md +17 -0
  42. package/hooks/prompt-guard/package.json +12 -0
  43. package/hooks/prompt-guard/src/hook.ts +76 -0
  44. package/hooks/session-start/README.md +17 -0
  45. package/hooks/session-start/package.json +12 -0
  46. package/hooks/session-start/src/hook.ts +150 -0
  47. package/hooks/stop-sync/README.md +17 -0
  48. package/hooks/stop-sync/package.json +12 -0
  49. package/hooks/stop-sync/src/hook.ts +69 -0
  50. package/hooks/worktree-guard/README.md +17 -0
  51. package/hooks/worktree-guard/package.json +12 -0
  52. package/hooks/worktree-guard/src/hook.ts +88 -0
  53. package/package.json +14 -6
  54. package/dist/cli/components/App.d.ts +0 -7
  55. package/dist/cli/components/CategorySelect.d.ts +0 -7
  56. package/dist/cli/components/DataTable.d.ts +0 -11
  57. package/dist/cli/components/Header.d.ts +0 -7
  58. package/dist/cli/components/HookSelect.d.ts +0 -11
  59. package/dist/cli/components/InstallProgress.d.ts +0 -9
  60. package/dist/cli/components/SearchView.d.ts +0 -9
  61. package/dist/cli/index.d.ts +0 -2
  62. package/dist/lib/db-writer.d.ts +0 -9
  63. package/dist/mcp/http.d.ts +0 -16
  64. package/dist/mcp/server.d.ts +0 -17
package/bin/index.js CHANGED
@@ -861,7 +861,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
861
861
  this._exitCallback = (err) => {
862
862
  if (err.code !== "commander.executeSubCommandAsync") {
863
863
  throw err;
864
- }
864
+ } else {}
865
865
  };
866
866
  }
867
867
  return this;
@@ -1942,6 +1942,16 @@ var init_registry = __esm(() => {
1942
1942
  matcher: "Write|Edit|NotebookEdit",
1943
1943
  tags: ["git", "snapshot", "rollback", "backup"]
1944
1944
  },
1945
+ {
1946
+ name: "worktree-guard",
1947
+ displayName: "Worktree Guard",
1948
+ description: "Warns on feature work outside managed repos worktrees and blocks commit/push from shared checkouts",
1949
+ version: "0.1.0",
1950
+ category: "Git Safety",
1951
+ event: "PreToolUse",
1952
+ matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit",
1953
+ tags: ["git", "worktree", "repos", "multi-agent", "safety"]
1954
+ },
1945
1955
  {
1946
1956
  name: "checktests",
1947
1957
  displayName: "Check Tests",
@@ -2022,6 +2032,16 @@ var init_registry = __esm(() => {
2022
2032
  matcher: "Bash",
2023
2033
  tags: ["npm", "packages", "typosquatting", "supply-chain"]
2024
2034
  },
2035
+ {
2036
+ name: "pre-bash",
2037
+ displayName: "Pre Bash",
2038
+ description: "Codewith Bash gate for staged secrets scans before commit/push and comms rechecks before risky operations",
2039
+ version: "0.1.0",
2040
+ category: "Security",
2041
+ event: "PreToolUse",
2042
+ matcher: "Bash",
2043
+ tags: ["codewith", "bash", "secrets", "gitleaks", "risky-ops"]
2044
+ },
2025
2045
  {
2026
2046
  name: "phonenotify",
2027
2047
  displayName: "Phone Notify",
@@ -2132,6 +2152,16 @@ var init_registry = __esm(() => {
2132
2152
  matcher: "",
2133
2153
  tags: ["prompt", "injection", "security", "validation", "guard"]
2134
2154
  },
2155
+ {
2156
+ name: "prompt-guard",
2157
+ displayName: "Prompt Guard (Codewith)",
2158
+ description: "Codewith UserPromptSubmit guard for obvious pasted prompt-injection and fake policy/freeze instructions",
2159
+ version: "0.1.0",
2160
+ category: "Permissions",
2161
+ event: "UserPromptSubmit",
2162
+ matcher: "",
2163
+ tags: ["codewith", "prompt", "injection", "policy", "freeze"]
2164
+ },
2135
2165
  {
2136
2166
  name: "desktopnotify",
2137
2167
  displayName: "Desktop Notify",
@@ -2222,6 +2252,26 @@ var init_registry = __esm(() => {
2222
2252
  matcher: "",
2223
2253
  tags: ["tasks", "completion", "gate", "quality", "agent-teams"]
2224
2254
  },
2255
+ {
2256
+ name: "session-start",
2257
+ displayName: "Session Start",
2258
+ description: "Codewith SessionStart digest for blockers, announcements, identity registration, and heartbeat context",
2259
+ version: "0.1.0",
2260
+ category: "Agent Teams",
2261
+ event: "SessionStart",
2262
+ matcher: "",
2263
+ tags: ["codewith", "session", "context", "conversations", "heartbeat"]
2264
+ },
2265
+ {
2266
+ name: "stop-sync",
2267
+ displayName: "Stop Sync",
2268
+ description: "Codewith Stop turn-end heartbeat and best-effort task evidence helper",
2269
+ version: "0.1.0",
2270
+ category: "Agent Teams",
2271
+ event: "Stop",
2272
+ matcher: "",
2273
+ tags: ["codewith", "stop", "heartbeat", "todos", "turn-end"]
2274
+ },
2225
2275
  {
2226
2276
  name: "typecheck-gate",
2227
2277
  displayName: "Typecheck Gate",
@@ -2275,10 +2325,10 @@ var init_registry = __esm(() => {
2275
2325
  {
2276
2326
  name: "announce-start",
2277
2327
  displayName: "Announce Start",
2278
- description: "On session start, auto-registers agent, reads messages, and announces to space",
2279
- version: "0.1.0",
2328
+ description: "On SessionStart, auto-registers agent, reads messages, and announces to space",
2329
+ version: "0.2.0",
2280
2330
  category: "Agent Teams",
2281
- event: "Notification",
2331
+ event: "SessionStart",
2282
2332
  matcher: "",
2283
2333
  tags: ["announcement", "start", "register", "messages", "agent-teams"]
2284
2334
  },
@@ -2301,6 +2351,36 @@ var init_registry = __esm(() => {
2301
2351
  event: "Notification",
2302
2352
  matcher: "",
2303
2353
  tags: ["messages", "dm", "context", "inject", "agent-teams"]
2354
+ },
2355
+ {
2356
+ name: "fleet-catchup",
2357
+ displayName: "Fleet Catchup",
2358
+ description: "On SessionStart, injects unread blockers, channel notifications, and the announcements digest into context (deterministic CLI reads, fail-open)",
2359
+ version: "0.1.0",
2360
+ category: "Agent Teams",
2361
+ event: "SessionStart",
2362
+ matcher: "",
2363
+ tags: ["fleet", "catchup", "blockers", "announcements", "context", "agent-teams"]
2364
+ },
2365
+ {
2366
+ name: "agent-rules-version-check",
2367
+ displayName: "Agent Rules Version Check",
2368
+ description: "On SessionStart, compares the rendered hasna:agent-operating-rules sentinel version against configs state and warns on mismatch",
2369
+ version: "0.1.0",
2370
+ category: "Agent Teams",
2371
+ event: "SessionStart",
2372
+ matcher: "",
2373
+ tags: ["fleet", "rules", "version", "sentinel", "configs", "drift", "agent-teams"]
2374
+ },
2375
+ {
2376
+ name: "fleet-blockers-gate",
2377
+ displayName: "Fleet Blockers Gate",
2378
+ description: "Before tool calls, blocks mutating tools while an unread [FREEZE] blocker is active (TTL-cached conversations check, fail-open <500ms)",
2379
+ version: "0.1.0",
2380
+ category: "Agent Teams",
2381
+ event: "PreToolUse",
2382
+ matcher: "",
2383
+ tags: ["fleet", "freeze", "blockers", "gate", "safety", "agent-teams"]
2304
2384
  }
2305
2385
  ];
2306
2386
  });
@@ -3954,28 +4034,39 @@ function normalizeHookName(name) {
3954
4034
  return name.startsWith("hook-") ? name : `hook-${name}`;
3955
4035
  }
3956
4036
  function shortHookName(name) {
3957
- return normalizeHookName(name).replace("hook-", "");
4037
+ return name.startsWith("hook-") ? name.slice("hook-".length) : name;
3958
4038
  }
3959
4039
  function removeHookEntriesByName(entries, hookName) {
3960
4040
  return entries.filter((entry) => !entry.hooks?.some((h) => {
3961
- const match = h.command?.match(/^hooks run (\w+)/);
4041
+ const match = h.command?.match(/^hooks run ([\w-]+)/);
3962
4042
  return match && match[1] === hookName;
3963
4043
  }));
3964
4044
  }
3965
4045
  function getTargetSettingsDir(target) {
4046
+ if (target === "codewith")
4047
+ return ".codewith";
3966
4048
  if (target === "gemini")
3967
4049
  return ".gemini";
3968
4050
  return ".claude";
3969
4051
  }
3970
- function getSettingsPath(scope = "global", target = "claude") {
4052
+ function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
4053
+ if (target === "codewith" && codewithConfigPath)
4054
+ return codewithConfigPath;
4055
+ if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
4056
+ return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
4057
+ }
3971
4058
  const dir = getTargetSettingsDir(target);
3972
4059
  if (scope === "project") {
3973
- return join2(process.cwd(), dir, "settings.json");
4060
+ return target === "codewith" ? join2(process.cwd(), dir, "config.toml") : join2(process.cwd(), dir, "settings.json");
3974
4061
  }
3975
- return join2(homedir2(), dir, "settings.json");
4062
+ return target === "codewith" ? join2(homedir2(), dir, "config.toml") : join2(homedir2(), dir, "settings.json");
3976
4063
  }
3977
4064
  function getHookPath(name) {
3978
- return join2(HOOKS_DIR, normalizeHookName(name));
4065
+ const shortName = shortHookName(name);
4066
+ const direct = join2(HOOKS_DIR, shortName);
4067
+ if (existsSync2(direct))
4068
+ return direct;
4069
+ return join2(HOOKS_DIR, normalizeHookName(shortName));
3979
4070
  }
3980
4071
  function hookExists(name) {
3981
4072
  return existsSync2(getHookPath(name));
@@ -4001,7 +4092,94 @@ function writeSettings(settings, scope = "global", target = "claude") {
4001
4092
  `);
4002
4093
  }
4003
4094
  function getTargetEventName(internalEvent, target) {
4004
- return EVENT_MAP[target]?.[internalEvent] || internalEvent;
4095
+ return EVENT_MAP[target]?.[internalEvent] ?? null;
4096
+ }
4097
+ function isEventSupported(internalEvent, target) {
4098
+ return getTargetEventName(internalEvent, target) !== null;
4099
+ }
4100
+ function tomlString(value) {
4101
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4102
+ }
4103
+ function codewithMatcher(matcher) {
4104
+ if (!matcher)
4105
+ return;
4106
+ if (matcher.startsWith("^"))
4107
+ return matcher;
4108
+ if (matcher.includes("|"))
4109
+ return `^(${matcher})$`;
4110
+ return `^${matcher}$`;
4111
+ }
4112
+ function codewithTimeout(name) {
4113
+ switch (name) {
4114
+ case "session-start":
4115
+ return 8;
4116
+ case "pre-bash":
4117
+ return 20;
4118
+ case "prompt-guard":
4119
+ return 3;
4120
+ case "worktree-guard":
4121
+ case "stop-sync":
4122
+ return 5;
4123
+ default:
4124
+ return 10;
4125
+ }
4126
+ }
4127
+ function codewithStatusMessage(name) {
4128
+ switch (name) {
4129
+ case "session-start":
4130
+ return "Checking Hasna session context";
4131
+ case "pre-bash":
4132
+ return "Checking Bash safety";
4133
+ case "prompt-guard":
4134
+ return "Checking prompt safety";
4135
+ case "worktree-guard":
4136
+ return "Checking worktree safety";
4137
+ case "stop-sync":
4138
+ return "Syncing turn-end heartbeat";
4139
+ default:
4140
+ return `Running ${name}`;
4141
+ }
4142
+ }
4143
+ function buildCodewithTomlFragment(name, profile) {
4144
+ const shortName = shortHookName(name);
4145
+ const meta = getHook(shortName);
4146
+ if (!meta)
4147
+ throw new Error(`Hook '${shortName}' not found`);
4148
+ const eventKey = getTargetEventName(meta.event, "codewith");
4149
+ if (!eventKey) {
4150
+ throw new Error(`Hook '${shortName}' uses event '${meta.event}', which is not supported by the Codewith target`);
4151
+ }
4152
+ const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
4153
+ const matcher = codewithMatcher(meta.matcher);
4154
+ const lines = [
4155
+ `[[hooks.${eventKey}]]`
4156
+ ];
4157
+ if (matcher)
4158
+ lines.push(`matcher = ${tomlString(matcher)}`);
4159
+ lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
4160
+ return `${lines.join(`
4161
+ `)}
4162
+ `;
4163
+ }
4164
+ function readCodewithConfig(scope, configPath) {
4165
+ const path = getSettingsPath(scope, "codewith", configPath);
4166
+ try {
4167
+ return existsSync2(path) ? readFileSync(path, "utf-8") : "";
4168
+ } catch {
4169
+ return "";
4170
+ }
4171
+ }
4172
+ function appendCodewithFragment(fragment, scope, configPath) {
4173
+ const path = getSettingsPath(scope, "codewith", configPath);
4174
+ const dir = dirname(path);
4175
+ if (!existsSync2(dir))
4176
+ mkdirSync(dir, { recursive: true });
4177
+ const existing = existsSync2(path) ? readFileSync(path, "utf-8") : "";
4178
+ const sep = existing.trim() ? `
4179
+
4180
+ ` : "";
4181
+ writeFileSync(path, `${existing.replace(/\s*$/, "")}${sep}${fragment}`);
4182
+ return path;
4005
4183
  }
4006
4184
  function detectConflict(name, scope, target) {
4007
4185
  const meta = getHook(name);
@@ -4022,11 +4200,60 @@ function detectConflict(name, scope, target) {
4022
4200
  }
4023
4201
  return;
4024
4202
  }
4025
- function installForTarget(name, scope, overwrite, target, profile) {
4203
+ function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
4026
4204
  const shortName = shortHookName(name);
4027
4205
  if (!hookExists(shortName)) {
4028
4206
  return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
4029
4207
  }
4208
+ if (target === "codewith") {
4209
+ try {
4210
+ const fragment = buildCodewithTomlFragment(shortName, profile);
4211
+ if (codewithMode !== "write") {
4212
+ return {
4213
+ hook: shortName,
4214
+ success: true,
4215
+ scope,
4216
+ target,
4217
+ fragment,
4218
+ applied: false,
4219
+ note: "Codewith install is fragment-only by default; open-configs should own applying this TOML."
4220
+ };
4221
+ }
4222
+ if (!codewithConfigPath) {
4223
+ return {
4224
+ hook: shortName,
4225
+ success: false,
4226
+ error: "Direct Codewith writes require an explicit --codewith-config path; refusing to write default ~/.codewith/config.toml.",
4227
+ scope,
4228
+ target,
4229
+ fragment,
4230
+ applied: false
4231
+ };
4232
+ }
4233
+ const existing = readCodewithConfig(scope, codewithConfigPath);
4234
+ if (!overwrite && new RegExp(`command\\s*=\\s*["']hooks run ${shortName}(?:\\s|["'])`).test(existing)) {
4235
+ return { hook: shortName, success: false, error: "Already installed. Use --overwrite to append another fragment.", scope, target };
4236
+ }
4237
+ const path = appendCodewithFragment(fragment, scope, codewithConfigPath);
4238
+ return {
4239
+ hook: shortName,
4240
+ success: true,
4241
+ scope,
4242
+ target,
4243
+ fragment,
4244
+ applied: true,
4245
+ configPath: path,
4246
+ note: "Direct Codewith config write was explicitly requested; prefer open-configs for managed machines."
4247
+ };
4248
+ } catch (error) {
4249
+ return {
4250
+ hook: shortName,
4251
+ success: false,
4252
+ error: error instanceof Error ? error.message : "Unknown error",
4253
+ target
4254
+ };
4255
+ }
4256
+ }
4030
4257
  const registered = getRegisteredHooksForTarget(scope, target);
4031
4258
  if (registered.includes(shortName) && !overwrite) {
4032
4259
  return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
@@ -4045,25 +4272,63 @@ function installForTarget(name, scope, overwrite, target, profile) {
4045
4272
  }
4046
4273
  }
4047
4274
  function installHook(name, options = {}) {
4048
- const { scope = "global", overwrite = false, target = "claude", profile } = options;
4275
+ const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath } = options;
4049
4276
  if (target === "all") {
4277
+ const shortName = shortHookName(name);
4278
+ const meta = getHook(shortName);
4279
+ if (meta) {
4280
+ const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => !isEventSupported(meta.event, agentTarget));
4281
+ if (unsupportedTargets.length > 0) {
4282
+ return {
4283
+ hook: shortName,
4284
+ success: false,
4285
+ error: `Event '${meta.event}' is not supported by target(s): ${unsupportedTargets.join(", ")}`,
4286
+ target: "all"
4287
+ };
4288
+ }
4289
+ }
4050
4290
  const claudeResult = installForTarget(name, scope, overwrite, "claude", profile);
4051
- installForTarget(name, scope, overwrite, "gemini", profile);
4291
+ if (!claudeResult.success) {
4292
+ return {
4293
+ ...claudeResult,
4294
+ error: `Failed for target 'claude': ${claudeResult.error}`,
4295
+ target: "all"
4296
+ };
4297
+ }
4298
+ const geminiResult = installForTarget(name, scope, overwrite, "gemini", profile);
4299
+ if (!geminiResult.success) {
4300
+ return {
4301
+ ...geminiResult,
4302
+ error: `Failed for target 'gemini': ${geminiResult.error}`,
4303
+ target: "all"
4304
+ };
4305
+ }
4306
+ const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
4307
+ if (!codewithResult.success) {
4308
+ return {
4309
+ ...codewithResult,
4310
+ error: `Failed for target 'codewith': ${codewithResult.error}`,
4311
+ target: "all"
4312
+ };
4313
+ }
4052
4314
  return { ...claudeResult, target: "all" };
4053
4315
  }
4054
- return installForTarget(name, scope, overwrite, target, profile);
4316
+ return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath);
4055
4317
  }
4056
4318
  function registerHook(name, scope = "global", target = "claude", profile) {
4057
4319
  const meta = getHook(name);
4058
4320
  if (!meta)
4059
4321
  return;
4322
+ const eventKey = getTargetEventName(meta.event, target);
4323
+ if (eventKey === null) {
4324
+ throw new Error(`Event '${meta.event}' is not supported by target '${target}'`);
4325
+ }
4060
4326
  const settings = readSettings(scope, target);
4061
4327
  if (!settings.hooks)
4062
4328
  settings.hooks = {};
4063
- const eventKey = getTargetEventName(meta.event, target);
4329
+ removeHookFromAllEvents(settings, name);
4064
4330
  if (!settings.hooks[eventKey])
4065
4331
  settings.hooks[eventKey] = [];
4066
- settings.hooks[eventKey] = removeHookEntriesByName(settings.hooks[eventKey], name);
4067
4332
  const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
4068
4333
  const entry = {
4069
4334
  hooks: [{ type: "command", command: hookCommand }]
@@ -4074,6 +4339,16 @@ function registerHook(name, scope = "global", target = "claude", profile) {
4074
4339
  settings.hooks[eventKey].push(entry);
4075
4340
  writeSettings(settings, scope, target);
4076
4341
  }
4342
+ function removeHookFromAllEvents(settings, name) {
4343
+ if (!settings.hooks)
4344
+ return;
4345
+ for (const key of Object.keys(settings.hooks)) {
4346
+ settings.hooks[key] = removeHookEntriesByName(settings.hooks[key], name);
4347
+ if (settings.hooks[key].length === 0) {
4348
+ delete settings.hooks[key];
4349
+ }
4350
+ }
4351
+ }
4077
4352
  function unregisterHook(name, scope = "global", target = "claude") {
4078
4353
  const meta = getHook(name);
4079
4354
  if (!meta)
@@ -4081,19 +4356,23 @@ function unregisterHook(name, scope = "global", target = "claude") {
4081
4356
  const settings = readSettings(scope, target);
4082
4357
  if (!settings.hooks)
4083
4358
  return;
4084
- const eventKey = getTargetEventName(meta.event, target);
4085
- if (!settings.hooks[eventKey])
4086
- return;
4087
- settings.hooks[eventKey] = removeHookEntriesByName(settings.hooks[eventKey], name);
4088
- if (settings.hooks[eventKey].length === 0) {
4089
- delete settings.hooks[eventKey];
4090
- }
4359
+ removeHookFromAllEvents(settings, name);
4091
4360
  if (Object.keys(settings.hooks).length === 0) {
4092
4361
  delete settings.hooks;
4093
4362
  }
4094
4363
  writeSettings(settings, scope, target);
4095
4364
  }
4096
4365
  function getRegisteredHooksForTarget(scope = "global", target = "claude") {
4366
+ if (target === "codewith") {
4367
+ const config = readCodewithConfig(scope);
4368
+ const registered2 = [];
4369
+ const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
4370
+ let match;
4371
+ while (match = re.exec(config)) {
4372
+ registered2.push(match[1]);
4373
+ }
4374
+ return [...new Set(registered2)];
4375
+ }
4097
4376
  const settings = readSettings(scope, target);
4098
4377
  if (!settings.hooks)
4099
4378
  return [];
@@ -4101,8 +4380,8 @@ function getRegisteredHooksForTarget(scope = "global", target = "claude") {
4101
4380
  for (const eventKey of Object.keys(settings.hooks)) {
4102
4381
  for (const entry of settings.hooks[eventKey]) {
4103
4382
  for (const hook of entry.hooks || []) {
4104
- const newMatch = hook.command?.match(/^hooks run (\w+)(?:\s+--profile\s+\w+)?$/);
4105
- const oldMatch = hook.command?.match(/^hook-(\w+)$/);
4383
+ const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
4384
+ const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
4106
4385
  const match = newMatch || oldMatch;
4107
4386
  if (match) {
4108
4387
  registered.push(match[1]);
@@ -4122,6 +4401,9 @@ function removeHook(name, scope = "global", target = "claude") {
4122
4401
  const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
4123
4402
  return claudeRemoved || geminiRemoved;
4124
4403
  }
4404
+ if (target === "codewith") {
4405
+ return false;
4406
+ }
4125
4407
  return removeHookForTarget(shortName, scope, target);
4126
4408
  }
4127
4409
  function removeHookForTarget(name, scope, target) {
@@ -4142,13 +4424,28 @@ var init_installer = __esm(() => {
4142
4424
  PreToolUse: "PreToolUse",
4143
4425
  PostToolUse: "PostToolUse",
4144
4426
  Stop: "Stop",
4145
- Notification: "Notification"
4427
+ Notification: "Notification",
4428
+ SessionStart: "SessionStart",
4429
+ SessionEnd: "SessionEnd",
4430
+ UserPromptSubmit: null
4146
4431
  },
4147
4432
  gemini: {
4148
4433
  PreToolUse: "BeforeTool",
4149
4434
  PostToolUse: "AfterTool",
4150
4435
  Stop: "AfterAgent",
4151
- Notification: "Notification"
4436
+ Notification: "Notification",
4437
+ SessionStart: null,
4438
+ SessionEnd: null,
4439
+ UserPromptSubmit: null
4440
+ },
4441
+ codewith: {
4442
+ PreToolUse: "PreToolUse",
4443
+ PostToolUse: "PostToolUse",
4444
+ Stop: "Stop",
4445
+ Notification: null,
4446
+ SessionStart: "SessionStart",
4447
+ SessionEnd: null,
4448
+ UserPromptSubmit: "UserPromptSubmit"
4152
4449
  }
4153
4450
  };
4154
4451
  getInstalledHooks = getRegisteredHooks;
@@ -4266,7 +4563,7 @@ var CREATE_HOOK_EVENTS_TABLE = `
4266
4563
  timestamp TEXT NOT NULL,
4267
4564
  session_id TEXT NOT NULL,
4268
4565
  hook_name TEXT NOT NULL,
4269
- event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification')),
4566
+ event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
4270
4567
  tool_name TEXT,
4271
4568
  tool_input TEXT,
4272
4569
  result TEXT CHECK (result IN ('continue', 'block', NULL)),
@@ -4297,6 +4594,64 @@ var init_001_initial = __esm(() => {
4297
4594
  init_schema();
4298
4595
  });
4299
4596
 
4597
+ // src/db/migrations/002_session_events.ts
4598
+ function up2(db) {
4599
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
4600
+ if (!row?.sql)
4601
+ return;
4602
+ if (row.sql.includes("SessionStart"))
4603
+ return;
4604
+ db.exec("BEGIN");
4605
+ try {
4606
+ db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
4607
+ db.exec(CREATE_HOOK_EVENTS_TABLE);
4608
+ db.exec(`INSERT INTO hook_events
4609
+ (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
4610
+ SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
4611
+ FROM hook_events_old`);
4612
+ db.exec("DROP TABLE hook_events_old");
4613
+ for (const idx of CREATE_INDEXES) {
4614
+ db.exec(idx);
4615
+ }
4616
+ db.exec("COMMIT");
4617
+ } catch (err) {
4618
+ db.exec("ROLLBACK");
4619
+ throw err;
4620
+ }
4621
+ }
4622
+ var init_002_session_events = __esm(() => {
4623
+ init_schema();
4624
+ });
4625
+
4626
+ // src/db/migrations/003_user_prompt_submit_event.ts
4627
+ function up3(db) {
4628
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
4629
+ if (!row?.sql)
4630
+ return;
4631
+ if (row.sql.includes("UserPromptSubmit"))
4632
+ return;
4633
+ db.exec("BEGIN");
4634
+ try {
4635
+ db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
4636
+ db.exec(CREATE_HOOK_EVENTS_TABLE);
4637
+ db.exec(`INSERT INTO hook_events
4638
+ (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
4639
+ SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
4640
+ FROM hook_events_old`);
4641
+ db.exec("DROP TABLE hook_events_old");
4642
+ for (const idx of CREATE_INDEXES) {
4643
+ db.exec(idx);
4644
+ }
4645
+ db.exec("COMMIT");
4646
+ } catch (err) {
4647
+ db.exec("ROLLBACK");
4648
+ throw err;
4649
+ }
4650
+ }
4651
+ var init_003_user_prompt_submit_event = __esm(() => {
4652
+ init_schema();
4653
+ });
4654
+
4300
4655
  // src/db/migrations/index.ts
4301
4656
  function ensureMigrationsTable(db) {
4302
4657
  db.exec(`
@@ -4326,7 +4681,13 @@ function runMigrations(db) {
4326
4681
  var MIGRATIONS;
4327
4682
  var init_migrations = __esm(() => {
4328
4683
  init_001_initial();
4329
- MIGRATIONS = [{ version: "001_initial", up }];
4684
+ init_002_session_events();
4685
+ init_003_user_prompt_submit_event();
4686
+ MIGRATIONS = [
4687
+ { version: "001_initial", up },
4688
+ { version: "002_session_events", up: up2 },
4689
+ { version: "003_user_prompt_submit_event", up: up3 }
4690
+ ];
4330
4691
  });
4331
4692
 
4332
4693
  // src/db/legacy-import.ts
@@ -4538,6 +4899,396 @@ var init_db = __esm(() => {
4538
4899
  init_legacy_import();
4539
4900
  });
4540
4901
 
4902
+ // src/db/pg-migrations.ts
4903
+ var PG_MIGRATIONS;
4904
+ var init_pg_migrations = __esm(() => {
4905
+ PG_MIGRATIONS = [
4906
+ `CREATE TABLE IF NOT EXISTS hook_events (
4907
+ id TEXT PRIMARY KEY,
4908
+ timestamp TEXT NOT NULL,
4909
+ session_id TEXT NOT NULL,
4910
+ hook_name TEXT NOT NULL,
4911
+ event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
4912
+ tool_name TEXT,
4913
+ tool_input TEXT,
4914
+ result TEXT CHECK (result IN ('continue', 'block', NULL)),
4915
+ error TEXT,
4916
+ duration_ms INTEGER,
4917
+ project_dir TEXT,
4918
+ metadata TEXT
4919
+ )`,
4920
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
4921
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
4922
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
4923
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
4924
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`,
4925
+ `CREATE TABLE IF NOT EXISTS schema_migrations (
4926
+ version TEXT PRIMARY KEY,
4927
+ applied_at TEXT NOT NULL
4928
+ )`,
4929
+ `CREATE TABLE IF NOT EXISTS _meta (
4930
+ key TEXT PRIMARY KEY,
4931
+ value TEXT NOT NULL
4932
+ )`,
4933
+ `CREATE TABLE IF NOT EXISTS feedback (
4934
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
4935
+ message TEXT NOT NULL,
4936
+ email TEXT,
4937
+ category TEXT DEFAULT 'general',
4938
+ version TEXT,
4939
+ machine_id TEXT,
4940
+ created_at TEXT NOT NULL DEFAULT NOW()::text
4941
+ )`,
4942
+ `ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
4943
+ `ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
4944
+ CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`
4945
+ ];
4946
+ });
4947
+
4948
+ // src/db/remote-storage.ts
4949
+ import pg from "pg";
4950
+ function translatePlaceholders(sql) {
4951
+ let index = 0;
4952
+ return sql.replace(/\?/g, () => `$${++index}`);
4953
+ }
4954
+ function normalizeParams(params) {
4955
+ const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
4956
+ return flat.map((value) => value === undefined ? null : value);
4957
+ }
4958
+ function sslConfigFor(connectionString) {
4959
+ return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
4960
+ }
4961
+
4962
+ class PgAdapterAsync {
4963
+ pool;
4964
+ constructor(connectionString) {
4965
+ this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
4966
+ }
4967
+ async run(sql, ...params) {
4968
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
4969
+ return { changes: result.rowCount ?? 0 };
4970
+ }
4971
+ async all(sql, ...params) {
4972
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
4973
+ return result.rows;
4974
+ }
4975
+ async close() {
4976
+ await this.pool.end();
4977
+ }
4978
+ }
4979
+ var init_remote_storage = () => {};
4980
+
4981
+ // src/db/storage-sync.ts
4982
+ function readEnv(name) {
4983
+ const value = process.env[name]?.trim();
4984
+ return value || undefined;
4985
+ }
4986
+ function normalizeStorageMode(value) {
4987
+ const normalized = value?.trim().toLowerCase();
4988
+ if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
4989
+ return normalized;
4990
+ return;
4991
+ }
4992
+ function getStorageDatabaseEnvName() {
4993
+ for (const name of STORAGE_DATABASE_ENV) {
4994
+ if (readEnv(name))
4995
+ return name;
4996
+ }
4997
+ return null;
4998
+ }
4999
+ function getStorageDatabaseEnv() {
5000
+ const name = getStorageDatabaseEnvName();
5001
+ return name ? { name } : null;
5002
+ }
5003
+ function getStorageDatabaseUrl() {
5004
+ const env = getStorageDatabaseEnv();
5005
+ return env ? readEnv(env.name) ?? null : null;
5006
+ }
5007
+ function getStorageMode() {
5008
+ const mode = normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_FALLBACK_ENV));
5009
+ if (mode)
5010
+ return mode;
5011
+ return getStorageDatabaseUrl() ? "hybrid" : "local";
5012
+ }
5013
+ async function getStoragePg() {
5014
+ const url = getStorageDatabaseUrl();
5015
+ if (!url) {
5016
+ throw new Error("Missing HASNA_HOOKS_DATABASE_URL or HOOKS_DATABASE_URL");
5017
+ }
5018
+ return new PgAdapterAsync(url);
5019
+ }
5020
+ async function runStorageMigrations(remote) {
5021
+ await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
5022
+ for (const sql of PG_MIGRATIONS)
5023
+ await remote.run(sql);
5024
+ }
5025
+ async function storagePush(options) {
5026
+ const remote = await getStoragePg();
5027
+ const db = getDb();
5028
+ try {
5029
+ await runStorageMigrations(remote);
5030
+ const results = [];
5031
+ for (const table of resolveTables(options?.tables)) {
5032
+ results.push(await pushTable(db, remote, table));
5033
+ }
5034
+ recordSyncMeta(db, "push", results);
5035
+ return results;
5036
+ } finally {
5037
+ await remote.close();
5038
+ }
5039
+ }
5040
+ async function storagePull(options) {
5041
+ const remote = await getStoragePg();
5042
+ const db = getDb();
5043
+ try {
5044
+ await runStorageMigrations(remote);
5045
+ const results = [];
5046
+ for (const table of resolveTables(options?.tables)) {
5047
+ results.push(await pullTable(remote, db, table));
5048
+ }
5049
+ recordSyncMeta(db, "pull", results);
5050
+ return results;
5051
+ } finally {
5052
+ await remote.close();
5053
+ }
5054
+ }
5055
+ async function storageSync(options) {
5056
+ const pull = await storagePull(options);
5057
+ const push = await storagePush(options);
5058
+ return { pull, push };
5059
+ }
5060
+ function getSyncMetaAll() {
5061
+ const db = getDb();
5062
+ ensureSyncMetaTable(db);
5063
+ return db.query("SELECT table_name, last_synced_at, direction FROM _hooks_sync_meta ORDER BY table_name, direction").all();
5064
+ }
5065
+ function getStorageStatus() {
5066
+ const activeEnv = getStorageDatabaseEnv();
5067
+ return {
5068
+ configured: Boolean(activeEnv),
5069
+ mode: getStorageMode(),
5070
+ env: STORAGE_DATABASE_ENV,
5071
+ activeEnv: activeEnv?.name ?? null,
5072
+ service: "hooks",
5073
+ tables: STORAGE_TABLES,
5074
+ sync: getSyncMetaAll()
5075
+ };
5076
+ }
5077
+ function resolveTables(tables) {
5078
+ if (!tables || tables.length === 0)
5079
+ return [...STORAGE_TABLES];
5080
+ const allowed = new Set(STORAGE_TABLES);
5081
+ const requested = tables.map((table) => table.trim()).filter(Boolean);
5082
+ const invalid = requested.filter((table) => !allowed.has(table));
5083
+ if (invalid.length > 0)
5084
+ throw new Error(`Unknown hooks sync table(s): ${invalid.join(", ")}`);
5085
+ return requested;
5086
+ }
5087
+ function parseStorageTables(value) {
5088
+ if (!value)
5089
+ return;
5090
+ return resolveTables(Array.isArray(value) ? value : value.split(","));
5091
+ }
5092
+ async function pushTable(db, remote, table) {
5093
+ const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
5094
+ try {
5095
+ if (!tableExists(db, table))
5096
+ return result;
5097
+ const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
5098
+ result.rowsRead = rows.length;
5099
+ if (rows.length === 0)
5100
+ return result;
5101
+ const remoteColumns = await getRemoteColumns(remote, table);
5102
+ const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
5103
+ result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
5104
+ } catch (error) {
5105
+ result.errors.push(error instanceof Error ? error.message : String(error));
5106
+ }
5107
+ return result;
5108
+ }
5109
+ async function pullTable(remote, db, table) {
5110
+ const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
5111
+ try {
5112
+ if (!tableExists(db, table))
5113
+ return result;
5114
+ const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
5115
+ result.rowsRead = rows.length;
5116
+ if (rows.length === 0)
5117
+ return result;
5118
+ const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
5119
+ result.rowsWritten = upsertSqlite(db, table, columns, rows);
5120
+ } catch (error) {
5121
+ result.errors.push(error instanceof Error ? error.message : String(error));
5122
+ }
5123
+ return result;
5124
+ }
5125
+ async function getRemoteColumns(remote, table) {
5126
+ const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
5127
+ return new Map(rows.map((row) => [row.column_name, row.data_type]));
5128
+ }
5129
+ function filterRemoteColumns(remoteColumns, columns) {
5130
+ if (remoteColumns.size === 0)
5131
+ return columns;
5132
+ return columns.filter((column) => remoteColumns.has(column));
5133
+ }
5134
+ function filterLocalColumns(db, table, columns) {
5135
+ const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
5136
+ const allowed = new Set(rows.map((row) => row.name));
5137
+ return columns.filter((column) => allowed.has(column));
5138
+ }
5139
+ async function upsertPg(remote, table, columns, rows, remoteColumns) {
5140
+ if (columns.length === 0)
5141
+ return 0;
5142
+ const primaryKeys = PRIMARY_KEYS[table];
5143
+ const columnList = columns.map(quoteIdent).join(", ");
5144
+ const placeholders = columns.map(() => "?").join(", ");
5145
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
5146
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
5147
+ const fallbackKey = primaryKeys[0];
5148
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
5149
+ for (const row of rows) {
5150
+ await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
5151
+ ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column], remoteColumns.get(column))));
5152
+ }
5153
+ return rows.length;
5154
+ }
5155
+ function upsertSqlite(db, table, columns, rows) {
5156
+ if (columns.length === 0)
5157
+ return 0;
5158
+ const primaryKeys = PRIMARY_KEYS[table];
5159
+ const columnList = columns.map(quoteIdent).join(", ");
5160
+ const placeholders = columns.map(() => "?").join(", ");
5161
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
5162
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
5163
+ const fallbackKey = primaryKeys[0];
5164
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
5165
+ const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
5166
+ ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
5167
+ const insert = db.transaction((batch) => {
5168
+ for (const row of batch)
5169
+ statement.run(...columns.map((column) => coerceForSqlite(row[column])));
5170
+ });
5171
+ insert(rows);
5172
+ return rows.length;
5173
+ }
5174
+ function recordSyncMeta(db, direction, results) {
5175
+ ensureSyncMetaTable(db);
5176
+ const now2 = new Date().toISOString();
5177
+ const statement = db.query(`
5178
+ INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction)
5179
+ VALUES (?, ?, ?)
5180
+ ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
5181
+ `);
5182
+ for (const result of results) {
5183
+ if (result.errors.length > 0)
5184
+ continue;
5185
+ statement.run(result.table, now2, direction);
5186
+ }
5187
+ }
5188
+ function ensureSyncMetaTable(db) {
5189
+ db.exec(`
5190
+ CREATE TABLE IF NOT EXISTS _hooks_sync_meta (
5191
+ table_name TEXT NOT NULL,
5192
+ last_synced_at TEXT,
5193
+ direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
5194
+ PRIMARY KEY (table_name, direction)
5195
+ )
5196
+ `);
5197
+ }
5198
+ function tableExists(db, table) {
5199
+ const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
5200
+ return Boolean(row);
5201
+ }
5202
+ function quoteIdent(identifier) {
5203
+ return `"${identifier.replace(/"/g, '""')}"`;
5204
+ }
5205
+ function coerceForPg(value, dataType) {
5206
+ if (value === undefined || value === null)
5207
+ return null;
5208
+ if (dataType === "boolean") {
5209
+ if (typeof value === "boolean")
5210
+ return value;
5211
+ if (typeof value === "number")
5212
+ return value !== 0;
5213
+ if (typeof value === "string")
5214
+ return value === "1" || value.toLowerCase() === "true";
5215
+ }
5216
+ if (value instanceof Date)
5217
+ return value.toISOString();
5218
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
5219
+ return value;
5220
+ if (typeof value === "object")
5221
+ return JSON.stringify(value);
5222
+ return value;
5223
+ }
5224
+ function coerceForSqlite(value) {
5225
+ if (value === undefined || value === null)
5226
+ return null;
5227
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
5228
+ return value;
5229
+ if (value instanceof Date)
5230
+ return value.toISOString();
5231
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
5232
+ return value;
5233
+ if (typeof value === "object")
5234
+ return JSON.stringify(value);
5235
+ return String(value);
5236
+ }
5237
+ var STORAGE_TABLES, HOOKS_STORAGE_TABLES, HOOKS_STORAGE_ENV = "HASNA_HOOKS_DATABASE_URL", HOOKS_STORAGE_FALLBACK_ENV = "HOOKS_DATABASE_URL", HOOKS_STORAGE_MODE_ENV = "HASNA_HOOKS_STORAGE_MODE", HOOKS_STORAGE_MODE_FALLBACK_ENV = "HOOKS_STORAGE_MODE", STORAGE_DATABASE_ENV, STORAGE_MODE_ENV, PRIMARY_KEYS;
5238
+ var init_storage_sync = __esm(() => {
5239
+ init_db();
5240
+ init_pg_migrations();
5241
+ init_remote_storage();
5242
+ STORAGE_TABLES = [
5243
+ "hook_events",
5244
+ "schema_migrations",
5245
+ "_meta",
5246
+ "feedback"
5247
+ ];
5248
+ HOOKS_STORAGE_TABLES = STORAGE_TABLES;
5249
+ STORAGE_DATABASE_ENV = [HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV];
5250
+ STORAGE_MODE_ENV = [HOOKS_STORAGE_MODE_ENV, HOOKS_STORAGE_MODE_FALLBACK_ENV];
5251
+ PRIMARY_KEYS = {
5252
+ hook_events: ["id"],
5253
+ schema_migrations: ["version"],
5254
+ _meta: ["key"],
5255
+ feedback: ["id"]
5256
+ };
5257
+ });
5258
+
5259
+ // src/storage.ts
5260
+ var exports_storage = {};
5261
+ __export(exports_storage, {
5262
+ storageSync: () => storageSync,
5263
+ storagePush: () => storagePush,
5264
+ storagePull: () => storagePull,
5265
+ runStorageMigrations: () => runStorageMigrations,
5266
+ resolveTables: () => resolveTables,
5267
+ parseStorageTables: () => parseStorageTables,
5268
+ getSyncMetaAll: () => getSyncMetaAll,
5269
+ getStorageStatus: () => getStorageStatus,
5270
+ getStoragePg: () => getStoragePg,
5271
+ getStorageMode: () => getStorageMode,
5272
+ getStorageDatabaseUrl: () => getStorageDatabaseUrl,
5273
+ getStorageDatabaseEnvName: () => getStorageDatabaseEnvName,
5274
+ getStorageDatabaseEnv: () => getStorageDatabaseEnv,
5275
+ STORAGE_TABLES: () => STORAGE_TABLES,
5276
+ STORAGE_MODE_ENV: () => STORAGE_MODE_ENV,
5277
+ STORAGE_DATABASE_ENV: () => STORAGE_DATABASE_ENV,
5278
+ PgAdapterAsync: () => PgAdapterAsync,
5279
+ PG_MIGRATIONS: () => PG_MIGRATIONS,
5280
+ HOOKS_STORAGE_TABLES: () => HOOKS_STORAGE_TABLES,
5281
+ HOOKS_STORAGE_MODE_FALLBACK_ENV: () => HOOKS_STORAGE_MODE_FALLBACK_ENV,
5282
+ HOOKS_STORAGE_MODE_ENV: () => HOOKS_STORAGE_MODE_ENV,
5283
+ HOOKS_STORAGE_FALLBACK_ENV: () => HOOKS_STORAGE_FALLBACK_ENV,
5284
+ HOOKS_STORAGE_ENV: () => HOOKS_STORAGE_ENV
5285
+ });
5286
+ var init_storage = __esm(() => {
5287
+ init_storage_sync();
5288
+ init_remote_storage();
5289
+ init_pg_migrations();
5290
+ });
5291
+
4541
5292
  // src/mcp/server.ts
4542
5293
  var exports_server = {};
4543
5294
  __export(exports_server, {
@@ -4671,7 +5422,7 @@ function createHooksServer() {
4671
5422
  const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
4672
5423
  const eventHooks = settings.hooks?.[meta.event] || [];
4673
5424
  const found = eventHooks.some((entry) => entry.hooks?.some((h) => {
4674
- const match = h.command?.match(/^hooks run (\w+)/);
5425
+ const match = h.command?.match(/^hooks run ([\w-]+)/);
4675
5426
  return match && match[1] === name;
4676
5427
  }));
4677
5428
  if (!found) {
@@ -4714,8 +5465,10 @@ function createHooksServer() {
4714
5465
  events: {
4715
5466
  PreToolUse: "Fires before a tool executes. Can block the operation.",
4716
5467
  PostToolUse: "Fires after a tool executes. Runs asynchronously.",
4717
- Stop: "Fires when a session ends. Useful for notifications.",
4718
- Notification: "Fires on notification events like context compaction."
5468
+ Stop: "Fires when the agent finishes responding. Useful for notifications.",
5469
+ Notification: "Fires on notification events like context compaction.",
5470
+ SessionStart: "Fires when a session starts or resumes. Can inject context.",
5471
+ SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements."
4719
5472
  },
4720
5473
  commands: {
4721
5474
  install: "hooks install <name>",
@@ -5129,6 +5882,10 @@ function createHooksServer() {
5129
5882
  }]
5130
5883
  };
5131
5884
  });
5885
+ server.tool("storage_status", "Show hooks storage sync configuration and local sync history.", {}, async () => ({ content: [{ type: "text", text: JSON.stringify(getStorageStatus()) }] }));
5886
+ server.tool("storage_push", "Push local hook data to storage PostgreSQL.", { tables: z.array(z.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storagePush(params.tables ? { tables: params.tables } : undefined)) }] }));
5887
+ server.tool("storage_pull", "Pull hook data from storage PostgreSQL to local SQLite.", { tables: z.array(z.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storagePull(params.tables ? { tables: params.tables } : undefined)) }] }));
5888
+ server.tool("storage_sync", "Bidirectional hooks storage sync: pull then push.", { tables: z.array(z.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storageSync(params.tables ? { tables: params.tables } : undefined)) }] }));
5132
5889
  server.tool("send_feedback", "Send feedback about this service", {
5133
5890
  message: z.string(),
5134
5891
  email: z.string().optional(),
@@ -5236,6 +5993,7 @@ var init_server = __esm(() => {
5236
5993
  init_registry();
5237
5994
  init_installer();
5238
5995
  init_profiles();
5996
+ init_storage();
5239
5997
  __dirname3 = dirname2(fileURLToPath2(import.meta.url));
5240
5998
  pkg = { name: "@hasna/hooks", version: "0.0.0" };
5241
5999
  try {
@@ -5326,29 +6084,83 @@ function getPathValue(input, path) {
5326
6084
  return;
5327
6085
  }, input);
5328
6086
  }
5329
- function wildcardToRegExp(pattern) {
5330
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
5331
- return new RegExp(`^${escaped}$`);
6087
+ function getFieldValues(input, path) {
6088
+ const values = [];
6089
+ const push = (value) => {
6090
+ if (!values.some((item) => Object.is(item, value)))
6091
+ values.push(value);
6092
+ };
6093
+ if (path.includes(".") && path in input)
6094
+ push(input[path]);
6095
+ const nestedValue = getPathValue(input, path);
6096
+ if (nestedValue !== undefined || !path.includes("."))
6097
+ push(nestedValue);
6098
+ return values;
5332
6099
  }
5333
- function matchString(value, matcher) {
6100
+ function wildcardToRegExp(pattern, options = {}) {
6101
+ let body = "";
6102
+ for (let index = 0;index < pattern.length; index += 1) {
6103
+ const char = pattern[index];
6104
+ if (char === "*") {
6105
+ if (pattern[index + 1] === "*") {
6106
+ body += ".*";
6107
+ index += 1;
6108
+ } else {
6109
+ body += options.segmentSafe ? "[^/]*" : ".*";
6110
+ }
6111
+ } else {
6112
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
6113
+ }
6114
+ }
6115
+ return new RegExp(`^${body}$`);
6116
+ }
6117
+ function matchString(value, matcher, options = {}) {
5334
6118
  if (matcher === undefined)
5335
6119
  return true;
5336
6120
  if (value === undefined)
5337
6121
  return false;
5338
6122
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
5339
- return matchers.some((item) => wildcardToRegExp(item).test(value));
6123
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
5340
6124
  }
5341
6125
  function matchRecord(input, matcher) {
5342
6126
  if (!matcher)
5343
6127
  return true;
5344
6128
  return Object.entries(matcher).every(([path, expected]) => {
5345
- const actual = getPathValue(input, path);
5346
- if (typeof expected === "string" || Array.isArray(expected)) {
5347
- return matchString(actual === undefined ? undefined : String(actual), expected);
5348
- }
5349
- return actual === expected;
6129
+ const actualValues = getFieldValues(input, path);
6130
+ return matchField(actualValues, expected, path);
5350
6131
  });
5351
6132
  }
6133
+ function matchField(actualValues, expected, path) {
6134
+ if (isNegativeMatcher(expected)) {
6135
+ return !actualValues.some((actual) => matchPositiveField(actual, expected.not, path));
6136
+ }
6137
+ return actualValues.some((actual) => matchPositiveField(actual, expected, path));
6138
+ }
6139
+ function matchPositiveField(actual, expected, path) {
6140
+ if (typeof expected === "string" || Array.isArray(expected)) {
6141
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
6142
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
6143
+ }));
6144
+ }
6145
+ if (Array.isArray(actual)) {
6146
+ return actual.some((item) => item === expected);
6147
+ }
6148
+ return actual === expected;
6149
+ }
6150
+ function stringCandidates(actual) {
6151
+ if (actual === undefined)
6152
+ return [];
6153
+ if (Array.isArray(actual)) {
6154
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
6155
+ }
6156
+ return [String(actual)];
6157
+ }
6158
+ function isPrimitiveFieldValue(value) {
6159
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
6160
+ }
6161
+ function isNegativeMatcher(value) {
6162
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
6163
+ }
5352
6164
  function eventMatchesFilter(event, filter) {
5353
6165
  return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
5354
6166
  }
@@ -5364,6 +6176,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
5364
6176
  function getEventsDataDir(override) {
5365
6177
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
5366
6178
  }
6179
+ function getActiveEventsDirEnv() {
6180
+ if (process.env[HASNA_EVENTS_DIR_ENV])
6181
+ return HASNA_EVENTS_DIR_ENV;
6182
+ if (process.env[HASNA_EVENTS_HOME_ENV])
6183
+ return HASNA_EVENTS_HOME_ENV;
6184
+ return null;
6185
+ }
5367
6186
 
5368
6187
  class JsonEventsStore {
5369
6188
  dataDir;
@@ -5476,6 +6295,52 @@ class JsonEventsStore {
5476
6295
  });
5477
6296
  }
5478
6297
  }
6298
+ async function getEventsStatus(dataDir) {
6299
+ const store = new JsonEventsStore(dataDir);
6300
+ await store.init();
6301
+ const [channels, events, deliveries] = await Promise.all([
6302
+ store.listChannels(),
6303
+ store.listEvents(),
6304
+ store.listDeliveries()
6305
+ ]);
6306
+ const transports = channels.reduce((counts, channel) => {
6307
+ counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
6308
+ return counts;
6309
+ }, {});
6310
+ return {
6311
+ service: "events",
6312
+ schemaVersion: "1.0",
6313
+ dataDir: store.dataDir,
6314
+ env: {
6315
+ primary: HASNA_EVENTS_DIR_ENV,
6316
+ fallback: HASNA_EVENTS_HOME_ENV,
6317
+ active: getActiveEventsDirEnv()
6318
+ },
6319
+ files: {
6320
+ channels: statusFile(store.dataDir, "channels.json", channels.length),
6321
+ events: statusFile(store.dataDir, "events.json", events.length),
6322
+ deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
6323
+ },
6324
+ counts: {
6325
+ channels: channels.length,
6326
+ enabledChannels: channels.filter((channel) => channel.enabled).length,
6327
+ disabledChannels: channels.filter((channel) => !channel.enabled).length,
6328
+ events: events.length,
6329
+ deliveries: deliveries.length
6330
+ },
6331
+ transports,
6332
+ safety: {
6333
+ includesEventPayloads: false,
6334
+ includesWebhookSecrets: false,
6335
+ listOutputsRedactSecrets: true,
6336
+ statusOutputIsMetadataOnly: true
6337
+ }
6338
+ };
6339
+ }
6340
+ function statusFile(dataDir, fileName, records) {
6341
+ const path = join(dataDir, fileName);
6342
+ return { path, exists: existsSync(path), records };
6343
+ }
5479
6344
  var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
5480
6345
  function buildSignatureBase(timestamp, body) {
5481
6346
  return `${timestamp}.${body}`;
@@ -5701,7 +6566,7 @@ class EventsClient {
5701
6566
  }
5702
6567
  return deliveries;
5703
6568
  }
5704
- async testChannel(id, input = {}) {
6569
+ async matchChannel(id, input = {}) {
5705
6570
  const channel = await this.store.getChannel(id);
5706
6571
  if (!channel)
5707
6572
  throw new Error(`Channel not found: ${id}`);
@@ -5718,6 +6583,34 @@ class EventsClient {
5718
6583
  time: input.time,
5719
6584
  id: input.id
5720
6585
  });
6586
+ const matched = channelMatchesEvent(channel, event);
6587
+ return {
6588
+ channelId: channel.id,
6589
+ matched,
6590
+ event,
6591
+ filters: channel.filters,
6592
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
6593
+ };
6594
+ }
6595
+ async testChannel(id, input = {}, options = {}) {
6596
+ const channel = await this.store.getChannel(id);
6597
+ if (!channel)
6598
+ throw new Error(`Channel not found: ${id}`);
6599
+ const match = await this.matchChannel(id, input);
6600
+ const event = match.event;
6601
+ if (options.honorFilters && !match.matched) {
6602
+ const timestamp = new Date().toISOString();
6603
+ const result2 = createDeliveryResult(event, channel, [{
6604
+ attempt: 1,
6605
+ status: "skipped",
6606
+ startedAt: timestamp,
6607
+ completedAt: timestamp,
6608
+ error: match.reason
6609
+ }]);
6610
+ result2.metadata = { reason: "filter_mismatch" };
6611
+ await this.store.appendDelivery(result2);
6612
+ return result2;
6613
+ }
5721
6614
  const eventForChannel = await this.applyRedaction(event, channel);
5722
6615
  const result = await this.deliverWithRetry(eventForChannel, channel);
5723
6616
  await this.store.appendDelivery(result);
@@ -5828,6 +6721,76 @@ function normalizeRetryPolicy(policy) {
5828
6721
  multiplier: Math.max(1, policy?.multiplier ?? 2)
5829
6722
  };
5830
6723
  }
6724
+ function parseFieldMatchers(values, label, typed = false) {
6725
+ if (!values?.length)
6726
+ return;
6727
+ const result = {};
6728
+ for (const value of values) {
6729
+ const parsed = parseMatcherExpression(value, label);
6730
+ const path = parsed.path;
6731
+ if (path in result)
6732
+ throw new Error(`Duplicate ${label} filter path: ${path}`);
6733
+ const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
6734
+ result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
6735
+ }
6736
+ return result;
6737
+ }
6738
+ function parseFilterOptions(options) {
6739
+ const filter2 = {};
6740
+ if (options.source)
6741
+ filter2.source = options.source;
6742
+ if (options.type)
6743
+ filter2.type = options.type;
6744
+ if (options.subject)
6745
+ filter2.subject = options.subject;
6746
+ if (options.severity)
6747
+ filter2.severity = options.severity;
6748
+ const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
6749
+ const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
6750
+ if (Object.keys(data).length > 0)
6751
+ filter2.data = data;
6752
+ if (Object.keys(metadata).length > 0)
6753
+ filter2.metadata = metadata;
6754
+ return Object.keys(filter2).length > 0 ? [filter2] : undefined;
6755
+ }
6756
+ function mergeMatchers(...records) {
6757
+ const result = {};
6758
+ for (const record of records) {
6759
+ if (!record)
6760
+ continue;
6761
+ for (const [path, value] of Object.entries(record)) {
6762
+ if (path in result)
6763
+ throw new Error(`Duplicate filter path: ${path}`);
6764
+ result[path] = value;
6765
+ }
6766
+ }
6767
+ return result;
6768
+ }
6769
+ function parseTypedMatcherValue(value, label) {
6770
+ const parsed = JSON.parse(value);
6771
+ if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
6772
+ return parsed;
6773
+ }
6774
+ throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
6775
+ }
6776
+ function parseMatcherExpression(value, label) {
6777
+ const negativeSeparator = value.indexOf("!=");
6778
+ if (negativeSeparator > 0) {
6779
+ return {
6780
+ path: value.slice(0, negativeSeparator),
6781
+ rawValue: value.slice(negativeSeparator + 2),
6782
+ negated: true
6783
+ };
6784
+ }
6785
+ const separator = value.indexOf("=");
6786
+ if (separator <= 0)
6787
+ throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
6788
+ return {
6789
+ path: value.slice(0, separator),
6790
+ rawValue: value.slice(separator + 1),
6791
+ negated: false
6792
+ };
6793
+ }
5831
6794
  function parseJsonObject(value, fallback) {
5832
6795
  if (!value)
5833
6796
  return fallback;
@@ -5849,18 +6812,6 @@ function parseHeaders(values) {
5849
6812
  }
5850
6813
  return headers;
5851
6814
  }
5852
- function parseFilter(options) {
5853
- const filter2 = {};
5854
- if (options.source)
5855
- filter2.source = options.source;
5856
- if (options.type)
5857
- filter2.type = options.type;
5858
- if (options.subject)
5859
- filter2.subject = options.subject;
5860
- if (options.severity)
5861
- filter2.severity = options.severity;
5862
- return Object.keys(filter2).length > 0 ? [filter2] : undefined;
5863
- }
5864
6815
  function createClient(options) {
5865
6816
  if (options.createClient)
5866
6817
  return options.createClient();
@@ -5878,16 +6829,16 @@ function hasJsonOption(options) {
5878
6829
  function wantsJson(actionOptions, command) {
5879
6830
  return hasJsonOption(actionOptions) || hasJsonOption(command);
5880
6831
  }
5881
- function registerWebhookCommands(program, options) {
5882
- const webhooks = program.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
5883
- webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
6832
+ function registerChannelCommands(program, options) {
6833
+ const channels = program.command(options.channelsCommandName ?? "channels").description("Manage Hasna event channels");
6834
+ channels.command("add").description("Add or replace a channel").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--data <path=value...>", "Event data field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--metadata <path=value...>", "Event metadata field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--data-json <path=json...>", "Event data field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--metadata-json <path=json...>", "Event metadata field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
5884
6835
  const timestamp = new Date().toISOString();
5885
6836
  const channel = {
5886
6837
  id: actionOptions.id,
5887
6838
  name: actionOptions.name,
5888
6839
  enabled: !actionOptions.disabled,
5889
6840
  transport: actionOptions.transport,
5890
- filters: parseFilter(actionOptions),
6841
+ filters: parseFilterOptions(actionOptions),
5891
6842
  retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
5892
6843
  redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
5893
6844
  createdAt: timestamp,
@@ -5903,35 +6854,51 @@ function registerWebhookCommands(program, options) {
5903
6854
  const saved = await createClient(options).addChannel(channel);
5904
6855
  print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
5905
6856
  });
5906
- webhooks.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
5907
- const channels = await createClient(options).listChannels();
6857
+ channels.command("list").description("List configured channels").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
6858
+ const channels2 = await createClient(options).listChannels();
5908
6859
  if (wantsJson(actionOptions, command)) {
5909
- console.log(JSON.stringify(sanitizeChannelsForOutput(channels), null, 2));
6860
+ console.log(JSON.stringify(sanitizeChannelsForOutput(channels2), null, 2));
5910
6861
  return;
5911
6862
  }
5912
- if (!channels.length) {
6863
+ if (!channels2.length) {
5913
6864
  console.log("No channels configured.");
5914
6865
  return;
5915
6866
  }
5916
- for (const channel of channels) {
6867
+ for (const channel of channels2) {
5917
6868
  console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
5918
6869
  }
5919
6870
  });
5920
- webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
6871
+ channels.command("status").description("Show events channel storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
6872
+ const status = await getEventsStatus(options.dataDir);
6873
+ print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
6874
+ });
6875
+ channels.command("remove").description("Remove a channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
5921
6876
  const removed = await createClient(options).removeChannel(id);
5922
6877
  print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
5923
6878
  });
5924
- webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
6879
+ channels.command("test").description("Send a test event to one channel").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--honor-filters", "Skip delivery when the sample event does not match channel filters", false).option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
5925
6880
  const result = await createClient(options).testChannel(id, {
5926
- source: options.source,
6881
+ source: actionOptions.source ?? options.source,
5927
6882
  type: actionOptions.type,
5928
6883
  subject: actionOptions.subject ?? id,
5929
6884
  message: actionOptions.message,
5930
- data: parseJsonObject(actionOptions.data, { test: true })
5931
- });
6885
+ data: parseJsonObject(actionOptions.data, { test: true }),
6886
+ metadata: parseJsonObject(actionOptions.metadata, {})
6887
+ }, { honorFilters: actionOptions.honorFilters });
5932
6888
  print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
5933
6889
  });
5934
- return webhooks;
6890
+ channels.command("match").description("Check whether a sample event matches one channel without delivering").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events match preview").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
6891
+ const result = await createClient(options).matchChannel(id, {
6892
+ source: actionOptions.source ?? options.source,
6893
+ type: actionOptions.type,
6894
+ subject: actionOptions.subject ?? id,
6895
+ message: actionOptions.message,
6896
+ data: parseJsonObject(actionOptions.data, { test: true }),
6897
+ metadata: parseJsonObject(actionOptions.metadata, {})
6898
+ });
6899
+ print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
6900
+ });
6901
+ return channels;
5935
6902
  }
5936
6903
  function registerEventCommands(program, options) {
5937
6904
  const events = program.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
@@ -5979,7 +6946,7 @@ function registerEventCommands(program, options) {
5979
6946
  return events;
5980
6947
  }
5981
6948
  function registerEventsCommands(program, options) {
5982
- registerWebhookCommands(program, options);
6949
+ registerChannelCommands(program, options);
5983
6950
  registerEventCommands(program, options);
5984
6951
  }
5985
6952
  function parseNumber(value) {
@@ -7204,6 +8171,8 @@ function resolveScope(options) {
7204
8171
  function resolveTarget(options) {
7205
8172
  if (options.target === "gemini")
7206
8173
  return "gemini";
8174
+ if (options.target === "codewith")
8175
+ return "codewith";
7207
8176
  if (options.target === "all")
7208
8177
  return "all";
7209
8178
  return "claude";
@@ -7302,7 +8271,7 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
7302
8271
  process.stderr.write(stderr);
7303
8272
  process.exit(exitCode);
7304
8273
  });
7305
- program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).option("-a, --all", "Install all available hooks", false).option("-c, --category <category>", "Install all hooks in a category").option("-g, --global", "Install globally (~/.claude/settings.json)", false).option("-p, --project", "Install for current project (.claude/settings.json)", false).option("-t, --target <target>", "Agent target: claude, gemini, all (default: claude)", "claude").option("--profile <id>", "Agent profile ID to scope hooks to").option("--dry-run", "Preview what would be installed without writing to settings", false).option("-j, --json", "Output as JSON", false).description("Install one or more hooks").action((hooks, options) => {
8274
+ program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).option("-a, --all", "Install all available hooks", false).option("-c, --category <category>", "Install all hooks in a category").option("-g, --global", "Install globally (~/.claude/settings.json)", false).option("-p, --project", "Install for current project (.claude/settings.json)", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith, all (default: claude)", "claude").option("--profile <id>", "Agent profile ID to scope hooks to").option("--dry-run", "Preview what would be installed without writing to settings", false).option("--apply-codewith", "Explicitly append Codewith TOML to a config file (prefer open-configs for managed configs)", false).option("--codewith-config <path>", "Explicit Codewith config path required with --apply-codewith").option("-j, --json", "Output as JSON", false).description("Install one or more hooks").action((hooks, options) => {
7306
8275
  const scope = resolveScope(options);
7307
8276
  const target = resolveTarget(options);
7308
8277
  let toInstall = hooks;
@@ -7325,11 +8294,20 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
7325
8294
  render(/* @__PURE__ */ jsxDEV8(App, {}, undefined, false, undefined, this));
7326
8295
  return;
7327
8296
  }
8297
+ if (options.applyCodewith && (target === "codewith" || target === "all") && !options.codewithConfig) {
8298
+ const message = "--apply-codewith requires --codewith-config <path>; refusing to write default ~/.codewith/config.toml.";
8299
+ if (options.json) {
8300
+ console.log(JSON.stringify({ error: message, scope, target, applied: false }));
8301
+ } else {
8302
+ console.log(chalk2.red(message));
8303
+ }
8304
+ return;
8305
+ }
7328
8306
  if (options.dryRun) {
7329
8307
  const known = toInstall.filter((n) => getHook(n));
7330
8308
  const unknown = toInstall.filter((n) => !getHook(n));
7331
8309
  if (options.json) {
7332
- console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target }));
8310
+ console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target, mode: target === "codewith" ? "fragment" : "write" }));
7333
8311
  return;
7334
8312
  }
7335
8313
  console.log(chalk2.bold(`
@@ -7356,21 +8334,30 @@ Dry run \u2014 would install (${scope}, ${target}):
7356
8334
  results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` });
7357
8335
  continue;
7358
8336
  }
7359
- const result = installHook(name, { scope, overwrite: options.overwrite, target, profile: options.profile });
8337
+ const result = installHook(name, {
8338
+ scope,
8339
+ overwrite: options.overwrite,
8340
+ target,
8341
+ profile: options.profile,
8342
+ codewithMode: options.applyCodewith ? "write" : "fragment",
8343
+ codewithConfigPath: options.codewithConfig
8344
+ });
7360
8345
  results.push(result);
7361
8346
  }
7362
8347
  if (options.json) {
7363
8348
  console.log(JSON.stringify({
7364
8349
  installed: results.filter((r) => r.success).map((r) => r.hook),
7365
8350
  failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })),
8351
+ fragments: results.filter((r) => r.success && r.fragment).map((r) => ({ hook: r.hook, fragment: r.fragment, applied: r.applied, configPath: r.configPath, note: r.note })),
7366
8352
  total: results.length,
7367
8353
  success: results.filter((r) => r.success).length,
7368
8354
  scope,
7369
- target
8355
+ target,
8356
+ applied: results.some((r) => r.applied)
7370
8357
  }));
7371
8358
  return;
7372
8359
  }
7373
- const settingsFile = scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
8360
+ const settingsFile = target === "codewith" ? options.applyCodewith ? options.codewithConfig : "TOML fragment only (open-configs should apply)" : scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
7374
8361
  console.log(chalk2.bold(`
7375
8362
  Installing hooks (${scope}, ${target})...
7376
8363
  `));
@@ -7384,6 +8371,14 @@ Installing hooks (${scope}, ${target})...
7384
8371
  if (result.conflict) {
7385
8372
  console.log(chalk2.yellow(` \u26A0 Warning: ${result.conflict}`));
7386
8373
  }
8374
+ if (result.fragment && target === "codewith") {
8375
+ console.log(chalk2.dim(" Codewith TOML fragment:"));
8376
+ console.log(chalk2.cyan(result.fragment.trimEnd().split(`
8377
+ `).map((line) => ` ${line}`).join(`
8378
+ `)));
8379
+ if (result.note)
8380
+ console.log(chalk2.yellow(` \u26A0 ${result.note}`));
8381
+ }
7387
8382
  } else {
7388
8383
  console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
7389
8384
  }
@@ -7391,10 +8386,10 @@ Installing hooks (${scope}, ${target})...
7391
8386
  console.log(chalk2.dim(`
7392
8387
  Registered in ${settingsFile}`));
7393
8388
  });
7394
- program2.command("list").alias("ls").option("-c, --category <category>", "Filter by category").option("-a, --all", "Show all available hooks", false).option("-i, --installed", "Show only installed hooks", false).option("-r, --registered", "Show registered hooks", false).option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-t, --target <target>", "Agent target: claude, gemini (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("List available or installed hooks").action((options) => {
8389
+ program2.command("list").alias("ls").option("-c, --category <category>", "Filter by category").option("-a, --all", "Show all available hooks", false).option("-i, --installed", "Show only installed hooks", false).option("-r, --registered", "Show registered hooks", false).option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("List available or installed hooks").action((options) => {
7395
8390
  const scope = resolveScope(options);
7396
8391
  if (options.registered || options.installed) {
7397
- const target = options.target === "gemini" ? "gemini" : "claude";
8392
+ const target = options.target === "gemini" ? "gemini" : options.target === "codewith" ? "codewith" : "claude";
7398
8393
  const registered = getRegisteredHooksForTarget(scope, target);
7399
8394
  if (options.json) {
7400
8395
  console.log(JSON.stringify(registered.map((name) => {
@@ -7478,7 +8473,7 @@ Found ${results.length} hook(s):
7478
8473
  console.log(` ${h.description}`);
7479
8474
  }
7480
8475
  });
7481
- program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-g, --global", "Remove from global settings", false).option("-p, --project", "Remove from project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
8476
+ program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-g, --global", "Remove from global settings", false).option("-p, --project", "Remove from project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
7482
8477
  const scope = resolveScope(options);
7483
8478
  const target = resolveTarget(options);
7484
8479
  if (!getHook(hook)) {
@@ -7589,7 +8584,7 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
7589
8584
  const settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
7590
8585
  const eventHooks = settings.hooks?.[meta.event] || [];
7591
8586
  const found = eventHooks.some((entry) => entry.hooks?.some((h) => {
7592
- const match = h.command?.match(/^hooks run (\w+)/);
8587
+ const match = h.command?.match(/^hooks run ([\w-]+)/);
7593
8588
  return match && match[1] === name;
7594
8589
  }));
7595
8590
  if (!found) {
@@ -7720,14 +8715,18 @@ ${meta.displayName} v${meta.version}
7720
8715
  const generalDocs = {
7721
8716
  overview: "Hooks are scripts that run at specific points in an AI coding agent session. Install @hasna/hooks globally, then register hooks \u2014 no files are copied to your project.",
7722
8717
  events: {
8718
+ SessionStart: "Fires when a session starts or resumes. Codewith can inject context via hookSpecificOutput.additionalContext.",
8719
+ UserPromptSubmit: "Codewith-native event when a user prompt is submitted; can block obvious injection attempts.",
7723
8720
  PreToolUse: 'Fires before a tool executes. Can block the operation by returning { "decision": "block" }.',
7724
8721
  PostToolUse: "Fires after a tool executes. Runs asynchronously, cannot block.",
7725
- Stop: "Fires when a session ends. Useful for notifications and cleanup.",
7726
- Notification: "Fires on notification events like context compaction."
8722
+ Stop: "Fires at turn end in Codewith and when other agents finish responding. Useful for notifications and cleanup.",
8723
+ Notification: "Fires on notification events like context compaction.",
8724
+ SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements."
7727
8725
  },
7728
8726
  installation: {
7729
8727
  global: "hooks install gitguard",
7730
8728
  project: "hooks install gitguard --project",
8729
+ codewith: "hooks install session-start --target codewith # emits TOML for open-configs to apply",
7731
8730
  category: 'hooks install --category "Git Safety"',
7732
8731
  all: "hooks install --all"
7733
8732
  },
@@ -7743,7 +8742,7 @@ ${meta.displayName} v${meta.version}
7743
8742
  },
7744
8743
  howItWorks: {
7745
8744
  install: "bun install -g @hasna/hooks",
7746
- register: "hooks install gitguard \u2192 writes to ~/.claude/settings.json",
8745
+ register: "hooks install gitguard \u2192 writes to ~/.claude/settings.json; hooks install session-start --target codewith emits a TOML fragment",
7747
8746
  execution: "Agent runs 'hooks run gitguard' \u2192 executes hook from global package",
7748
8747
  noFileCopy: "No files are copied to your project. Hooks run from the global @hasna/hooks package."
7749
8748
  }
@@ -8050,6 +9049,80 @@ logCmd.command("clear").description("Delete hook event logs").option("--hook <na
8050
9049
  }
8051
9050
  console.log(chalk2.green(`\u2713 Cleared ${count} event(s).`));
8052
9051
  });
9052
+ var storageCmd = program2.command("storage").description("Sync local hook data with storage PostgreSQL");
9053
+ storageCmd.command("status").description("Show storage sync status").option("-j, --json", "Output as JSON", false).action(async (options) => {
9054
+ const { getStorageStatus: getStorageStatus2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
9055
+ const status = getStorageStatus2();
9056
+ if (options.json) {
9057
+ console.log(JSON.stringify(status, null, 2));
9058
+ return;
9059
+ }
9060
+ console.log(chalk2.bold(`
9061
+ Storage Status
9062
+ `));
9063
+ console.log(` Configured: ${status.configured ? chalk2.green(`yes (${status.activeEnv})`) : chalk2.red("no")}`);
9064
+ console.log(` Mode: ${status.mode}`);
9065
+ console.log(` Tables: ${status.tables.join(", ")}`);
9066
+ console.log(` Sync rows: ${status.sync.length}`);
9067
+ });
9068
+ storageCmd.command("push").description("Push local hook data to storage PostgreSQL").option("-t, --tables <tables>", "Comma-separated table names").option("-j, --json", "Output as JSON", false).action(async (options) => {
9069
+ try {
9070
+ const { parseStorageTables: parseStorageTables2, storagePush: storagePush2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
9071
+ const results = await storagePush2({ tables: parseStorageTables2(options.tables) });
9072
+ if (options.json) {
9073
+ console.log(JSON.stringify(results, null, 2));
9074
+ return;
9075
+ }
9076
+ const written = results.reduce((sum, result) => sum + result.rowsWritten, 0);
9077
+ console.log(chalk2.green(`\u2713 Pushed ${written} row(s)`));
9078
+ } catch (error) {
9079
+ const message = error instanceof Error ? error.message : String(error);
9080
+ if (options.json)
9081
+ console.log(JSON.stringify({ error: message }));
9082
+ else
9083
+ console.error(chalk2.red(`\u2717 ${message}`));
9084
+ process.exitCode = 1;
9085
+ }
9086
+ });
9087
+ storageCmd.command("pull").description("Pull hook data from storage PostgreSQL to local SQLite").option("-t, --tables <tables>", "Comma-separated table names").option("-j, --json", "Output as JSON", false).action(async (options) => {
9088
+ try {
9089
+ const { parseStorageTables: parseStorageTables2, storagePull: storagePull2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
9090
+ const results = await storagePull2({ tables: parseStorageTables2(options.tables) });
9091
+ if (options.json) {
9092
+ console.log(JSON.stringify(results, null, 2));
9093
+ return;
9094
+ }
9095
+ const written = results.reduce((sum, result) => sum + result.rowsWritten, 0);
9096
+ console.log(chalk2.green(`\u2713 Pulled ${written} row(s)`));
9097
+ } catch (error) {
9098
+ const message = error instanceof Error ? error.message : String(error);
9099
+ if (options.json)
9100
+ console.log(JSON.stringify({ error: message }));
9101
+ else
9102
+ console.error(chalk2.red(`\u2717 ${message}`));
9103
+ process.exitCode = 1;
9104
+ }
9105
+ });
9106
+ storageCmd.command("sync").description("Bidirectional storage sync: pull then push").option("-t, --tables <tables>", "Comma-separated table names").option("-j, --json", "Output as JSON", false).action(async (options) => {
9107
+ try {
9108
+ const { parseStorageTables: parseStorageTables2, storageSync: storageSync2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
9109
+ const result = await storageSync2({ tables: parseStorageTables2(options.tables) });
9110
+ if (options.json) {
9111
+ console.log(JSON.stringify(result, null, 2));
9112
+ return;
9113
+ }
9114
+ const pulled = result.pull.reduce((sum, entry) => sum + entry.rowsWritten, 0);
9115
+ const pushed = result.push.reduce((sum, entry) => sum + entry.rowsWritten, 0);
9116
+ console.log(chalk2.green(`\u2713 Synced ${pulled} pulled row(s), ${pushed} pushed row(s)`));
9117
+ } catch (error) {
9118
+ const message = error instanceof Error ? error.message : String(error);
9119
+ if (options.json)
9120
+ console.log(JSON.stringify({ error: message }));
9121
+ else
9122
+ console.error(chalk2.red(`\u2717 ${message}`));
9123
+ process.exitCode = 1;
9124
+ }
9125
+ });
8053
9126
  program2.command("mcp").option("-s, --stdio", "Use stdio transport (one process per agent)", false).option("--sse", "Use legacy SSE transport (port 39427)", false).option("--http", "Use Streamable HTTP transport (explicit; this is also the default)", false).option("-p, --port <port>", "Port for HTTP/SSE transport (defaults to 8847 for HTTP, 39427 for SSE)").description("Start MCP server for AI agent integration (default: shared Streamable HTTP)").action(async (options) => {
8054
9127
  if (options.stdio) {
8055
9128
  const { startStdioServer: startStdioServer2 } = await Promise.resolve().then(() => (init_server(), exports_server));