hey-axi 0.2.0 → 0.3.1

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/src/hooks.js CHANGED
@@ -1,32 +1,40 @@
1
- // `hey-axi setup hooks`: register the no-args home view as session-start context for
2
- // Claude Code, Codex and OpenCode (AXI principle 7), using axi-sdk-js's hook installer.
3
- // Only this explicit command writes agent config; nothing else ever does.
1
+ // `hey-axi setup hooks`: register hey-axi in the agent session lifecycle for Claude Code,
2
+ // Codex and OpenCode (AXI principle 7). Only this explicit command writes agent config;
3
+ // nothing else ever does.
4
+ // - session start: the no-args home view (via axi-sdk-js's hook installer)
5
+ // - session end: `hey-axi hook session-end`, which folds what hey-axi did this
6
+ // session into the summary the next home view shows (src/activity.js)
4
7
  //
5
8
  // hey-axi setup hooks install or repair (user scope: ~/.claude, ~/.codex, ~/.config/opencode)
6
9
  // hey-axi setup hooks --project the same, for the current directory (.claude/, .codex/, .opencode/)
7
10
  // hey-axi setup hooks --status report only, no writes
8
11
  // hey-axi setup hooks --remove remove hey-axi's managed hooks (other hooks are left alone)
9
12
 
10
- import { existsSync, readFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
14
  import { homedir } from "node:os";
12
- import { collapse } from "./home.js";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { collapse } from "./home-path.js";
17
+ import { disableCapture, enableCapture } from "./activity.js";
13
18
 
14
19
  const MARKER = "hey-axi";
15
20
  export const HOOK_FLAGS = ["--project", "--status", "--remove", "--help", "-h"];
21
+ const END_ARGS = "hook session-end";
22
+ const END_PLUGIN_MARKER = "hey-axi managed opencode session-end plugin";
16
23
 
17
24
  export const HOOKS_HELP = [
18
- "hey-axi setup hooks — show the hey-axi home view (Imbox summary + next commands) at the start of every agent session",
25
+ "hey-axi setup hooks — show the hey-axi home view at the start of every agent session, and remember what each session did",
19
26
  "",
20
27
  "usage: hey-axi setup hooks [--project] [--status | --remove]",
21
28
  "",
22
29
  "flags:",
23
- " --project target this directory (.claude/settings.json, .codex/hooks.json, .opencode/plugins/) instead of your user config",
24
- " --status report what is installed; writes nothing",
25
- " --remove remove hey-axi's managed hooks; other hooks are left alone",
30
+ " --project target this directory (.claude/settings.json, .codex/hooks.json, .opencode/plugins/) instead of your user config (default: user config)",
31
+ " --status report what is installed; writes nothing (default false)",
32
+ " --remove remove hey-axi's managed hooks and its session journal; other hooks are left alone (default false: install or repair)",
26
33
  "",
27
- "agents: Claude Code (SessionStart hook), Codex (SessionStart hook; also sets [features].hooks = true in ~/.codex/config.toml), OpenCode (managed plugin)",
34
+ "agents: Claude Code (SessionStart + SessionEnd hooks), Codex (SessionStart + SessionEnd hooks; also sets [features].hooks = true in ~/.codex/config.toml), OpenCode (managed plugins)",
35
+ "Session end: hey-axi keeps a local journal of what it did (command names and numeric ids only) and the next session's home view summarizes it (e.g. drafts waiting to be reviewed).",
28
36
  "Re-running is a no-op when nothing changed, and repairs the command path after hey-axi moves.",
29
- "Note: the hook puts Imbox senders and subjects into every session's context. Use --project to limit it to one directory.",
37
+ "Note: the start hook puts subjects and senders from the directory's scope into every session's context. `hey-axi setup scope` narrows it; --project limits it to one directory.",
30
38
  "",
31
39
  "examples:",
32
40
  " hey-axi setup hooks",
@@ -38,16 +46,144 @@ function snapshot(paths) {
38
46
  return paths.map((path) => (existsSync(path) ? readFileSync(path, "utf8") : null));
39
47
  }
40
48
 
41
- function describe(status) {
49
+ function readJson(path) {
50
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
51
+ }
52
+
53
+ const isMine = (hook) => typeof hook?.command === "string" && hook.command.includes(MARKER) && hook.command.endsWith(END_ARGS);
54
+
55
+ function endTargets(scope, root, home) {
56
+ return {
57
+ claude: join(root, ".claude", "settings.json"),
58
+ codex: join(root, ".codex", "hooks.json"),
59
+ opencode: join(scope === "project" ? join(root, ".opencode", "plugins") : join(home, ".config", "opencode", "plugins"), "axi-hey-axi-session-end.js"),
60
+ };
61
+ }
62
+
63
+ // The command the start hook runs (portable name or absolute path, chosen by axi-sdk-js).
64
+ function startCommand(paths) {
65
+ for (const path of paths) {
66
+ try {
67
+ for (const group of readJson(path).hooks?.SessionStart || []) {
68
+ for (const hook of group.hooks || []) if (typeof hook.command === "string" && hook.command.includes(MARKER)) return hook.command;
69
+ }
70
+ } catch {
71
+ // unreadable config: try the next one
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function setEndHook(path, command, onError) {
78
+ try {
79
+ const settings = readJson(path);
80
+ settings.hooks ||= {};
81
+ const groups = Array.isArray(settings.hooks.SessionEnd) ? settings.hooks.SessionEnd : [];
82
+ const wanted = { type: "command", command, timeout: 5 };
83
+ let found = false;
84
+ for (const group of groups) {
85
+ for (const hook of group.hooks || []) {
86
+ if (!isMine(hook)) continue;
87
+ found = true;
88
+ Object.assign(hook, wanted);
89
+ }
90
+ }
91
+ if (!found) groups.push({ hooks: [wanted] });
92
+ settings.hooks.SessionEnd = groups;
93
+ const next = `${JSON.stringify(settings, null, 2)}\n`;
94
+ if (!existsSync(path) || readFileSync(path, "utf8") !== next) {
95
+ mkdirSync(dirname(path), { recursive: true });
96
+ writeFileSync(path, next);
97
+ }
98
+ } catch (error) {
99
+ onError(`${path}: ${error.message}`);
100
+ }
101
+ }
102
+
103
+ function removeEndHook(path, onError) {
104
+ try {
105
+ if (!existsSync(path)) return;
106
+ const settings = readJson(path);
107
+ const groups = settings.hooks?.SessionEnd;
108
+ if (!Array.isArray(groups)) return;
109
+ const kept = groups.map((group) => ({ ...group, hooks: (group.hooks || []).filter((hook) => !isMine(hook)) })).filter((group) => group.hooks.length);
110
+ if (kept.length === groups.length && kept.every((group, index) => group.hooks.length === groups[index].hooks.length)) return;
111
+ if (kept.length) settings.hooks.SessionEnd = kept;
112
+ else delete settings.hooks.SessionEnd;
113
+ writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`);
114
+ } catch (error) {
115
+ onError(`${path}: ${error.message}`);
116
+ }
117
+ }
118
+
119
+ function hasEndHook(path) {
120
+ try {
121
+ return (readJson(path).hooks?.SessionEnd || []).some((group) => (group.hooks || []).some(isMine));
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function openCodeEndPlugin(command) {
128
+ return `// ${END_PLUGIN_MARKER}
129
+ // Generated by \`hey-axi setup hooks\`. \`hey-axi setup hooks --remove\` deletes it.
130
+ import { spawn } from "node:child_process";
131
+
132
+ const command = ${JSON.stringify(command)};
133
+
134
+ function capture(directory, sessionID) {
135
+ return new Promise((resolve) => {
136
+ const child = spawn(command, ["hook", "session-end"], { cwd: directory || process.cwd(), shell: false, stdio: ["pipe", "ignore", "ignore"] });
137
+ const timer = setTimeout(() => child.kill("SIGTERM"), 5000);
138
+ child.on("error", () => { clearTimeout(timer); resolve(); });
139
+ child.on("close", () => { clearTimeout(timer); resolve(); });
140
+ child.stdin.end(JSON.stringify({ session_id: sessionID, cwd: directory }));
141
+ });
142
+ }
143
+
144
+ export const HeyAxiSessionEndPlugin = async ({ directory }) => ({
145
+ event: async ({ event }) => {
146
+ const idle = event.type === "session.idle" || (event.type === "session.status" && event.properties?.status?.type === "idle");
147
+ if (idle || event.type === "session.deleted") await capture(directory, event.properties?.sessionID ?? event.properties?.info?.id);
148
+ },
149
+ });
150
+ `;
151
+ }
152
+
153
+ function setEndPlugin(path, command, onError) {
154
+ try {
155
+ const next = openCodeEndPlugin(command);
156
+ if (existsSync(path)) {
157
+ const current = readFileSync(path, "utf8");
158
+ if (!current.includes(END_PLUGIN_MARKER)) return onError(`${path}: refusing to overwrite an unmanaged OpenCode plugin`);
159
+ if (current === next) return;
160
+ }
161
+ mkdirSync(dirname(path), { recursive: true });
162
+ writeFileSync(path, next);
163
+ } catch (error) {
164
+ onError(`${path}: ${error.message}`);
165
+ }
166
+ }
167
+
168
+ function removeEndPlugin(path, onError) {
169
+ try {
170
+ if (existsSync(path) && readFileSync(path, "utf8").includes(END_PLUGIN_MARKER)) rmSync(path);
171
+ } catch (error) {
172
+ onError(`${path}: ${error.message}`);
173
+ }
174
+ }
175
+
176
+ function describe(status, end) {
177
+ const state = (start, finish) => (start ? `installed ${finish ? "with" : "without"} session end` : finish ? "not installed (session end only)" : "not installed");
42
178
  return {
43
- claude: `${status.claude.installed ? "installed" : "not installed"} (${collapse(status.claude.path)})`,
44
- codex: `${status.codex.installed ? "installed" : "not installed"} (${collapse(status.codex.path)}; hooks feature ${status.codex.userFeatureEnabled ? "on" : "off"} in ${collapse(status.codex.userFeaturePath)})`,
45
- opencode: `${status.opencode.installed ? "installed" : "not installed"} (${collapse(status.opencode.path)})`,
179
+ claude: `${state(status.claude.installed, hasEndHook(end.claude))} (${collapse(status.claude.path)})`,
180
+ codex: `${state(status.codex.installed, hasEndHook(end.codex))} (${collapse(status.codex.path)}; hooks feature ${status.codex.userFeatureEnabled ? "on" : "off"} in ${collapse(status.codex.userFeaturePath)})`,
181
+ opencode: `${state(status.opencode.installed, existsSync(end.opencode))} (${collapse(status.opencode.path)})`,
46
182
  };
47
183
  }
48
184
 
49
185
  // Returns { output, code }.
50
- export async function setupHooks(flags, { execPath = process.argv[1] } = {}) {
186
+ export async function setupHooks(flags, { execPath = process.argv[1], cwd = process.cwd(), home = homedir() } = {}) {
51
187
  const unknown = [...flags].filter((flag) => !HOOK_FLAGS.includes(flag));
52
188
  if (unknown.length) {
53
189
  return { code: 2, output: { ok: false, error: `unknown flag ${unknown.join(", ")} for \`setup hooks\``, help: "valid flags for `setup hooks`: --project, --status, --remove" } };
@@ -57,19 +193,27 @@ export async function setupHooks(flags, { execPath = process.argv[1] } = {}) {
57
193
  }
58
194
  const sdk = await import("axi-sdk-js");
59
195
  const scope = flags.has("--project") ? "project" : "user";
60
- const options = { marker: MARKER, scope, projectDir: process.cwd(), homeDir: homedir() };
196
+ const root = scope === "project" ? resolve(cwd) : home;
197
+ const options = { marker: MARKER, scope, projectDir: cwd, homeDir: home };
198
+ const end = endTargets(scope, root, home);
61
199
  const before = sdk.sessionStartHookStatus(options);
62
- const files = [before.claude.path, before.codex.path, before.codex.userFeaturePath, before.opencode.path];
200
+ const files = [before.claude.path, before.codex.path, before.codex.userFeaturePath, before.opencode.path, end.opencode];
63
201
  const errors = [];
64
202
  const onError = (message) => errors.push(message);
65
203
 
66
204
  if (flags.has("--status")) {
67
- return { code: 0, output: { hooks: `status (${scope} scope)`, ...describe(before) } };
205
+ return { code: 0, output: { hooks: `status (${scope} scope)`, ...describe(before, end) } };
68
206
  }
69
207
 
70
208
  const was = snapshot(files);
209
+ let captureChanged = false;
71
210
  if (flags.has("--remove")) {
72
211
  sdk.uninstallSessionStartHooks({ ...options, onError });
212
+ removeEndHook(end.claude, onError);
213
+ removeEndHook(end.codex, onError);
214
+ removeEndPlugin(end.opencode, onError);
215
+ const otherScope = sdk.sessionStartHookStatus({ ...options, scope: scope === "project" ? "user" : "project" });
216
+ if (!otherScope.claude.installed && !otherScope.codex.installed && !otherScope.opencode.installed) captureChanged = disableCapture();
73
217
  } else {
74
218
  sdk.installSessionStartHooks({
75
219
  ...options,
@@ -78,16 +222,25 @@ export async function setupHooks(flags, { execPath = process.argv[1] } = {}) {
78
222
  distEntrypoints: ["src/hey-axi.js"],
79
223
  onError,
80
224
  });
225
+ const command = startCommand([before.claude.path, before.codex.path]);
226
+ if (command) {
227
+ // Claude Code and Codex run hook commands through a shell: quote a path with spaces.
228
+ const shellCommand = /\s/.test(command) && existsSync(command) ? `'${command.replace(/'/g, "'\\''")}'` : command;
229
+ setEndHook(end.claude, `${shellCommand} ${END_ARGS}`, onError);
230
+ setEndHook(end.codex, `${shellCommand} ${END_ARGS}`, onError);
231
+ setEndPlugin(end.opencode, command, onError);
232
+ captureChanged = enableCapture();
233
+ }
81
234
  }
82
235
  const after = sdk.sessionStartHookStatus(options);
83
- const changed = snapshot(files).some((content, index) => content !== was[index]);
84
- const verb = flags.has("--remove") ? (changed ? "removed" : "nothing to remove") : changed ? "installed or updated" : "already up to date (no changes)";
85
- const output = { hooks: `${verb} (${scope} scope)`, ...describe(after) };
236
+ const changed = captureChanged || snapshot(files).some((content, index) => content !== was[index]);
237
+ const verb = flags.has("--remove") ? (changed ? "removed" : "nothing to remove (no-op)") : changed ? "installed or updated" : "already up to date (no changes)";
238
+ const output = { hooks: `${verb} (${scope} scope)`, ...describe(after, end) };
86
239
  if (!flags.has("--remove") && !after.claude.installed && !after.codex.installed && !after.opencode.installed) {
87
240
  output.ok = false;
88
241
  output.error = "no hook was installed";
89
242
  }
90
243
  if (errors.length) output.errors = errors;
91
- if (!flags.has("--remove")) output.help = ["Start a new agent session to see the home view", "Run `hey-axi setup hooks --remove` to undo"];
244
+ if (!flags.has("--remove")) output.help = ["Start a new agent session to see the home view", "Run `hey-axi setup scope --label <name>` to focus this directory", "Run `hey-axi setup hooks --remove` to undo"];
92
245
  return { code: errors.length || output.ok === false ? 1 : 0, output };
93
246
  }