hey-axi 0.2.0 → 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.
package/src/errors.js CHANGED
@@ -1,28 +1,91 @@
1
- // Turn a failed HEY run into hey-axi's error shape, keeping HEY's own error envelope
2
- // ({ok:false,error,code,hint,meta}, written to stderr in --json mode) when there is one.
3
- // HEY may print plain warning lines (e.g. about the keyring) before the envelope; those
4
- // are kept as `warning`. Suggestions name hey-axi commands, and when HEY gives no hint
5
- // a `help` line for the exit code says what to do next.
1
+ // Turn a failed HEY run into hey-axi's error shape (AXI principle 6):
2
+ // - HEY's own error envelope ({ok:false,error,code,hint,meta}) is kept, with hints
3
+ // rewritten to name hey-axi commands
4
+ // - anything else is translated: one cleaned line of HEY's text (no warnings, stack
5
+ // frames or terminal escapes) under a hey-axi message for the failure kind
6
+ // - plain warning lines HEY prints first (e.g. about the keyring) go to stderr, not stdout
7
+ // - exit codes follow AXI: 2 for usage errors, 1 for everything else that failed
8
+ // A `help` line always says what to do next.
6
9
 
7
10
  import { heyToAxi } from "./text.js";
8
11
 
9
- // HEY's exit codes (`hey help exit-codes`) → the next step for an agent.
10
- const NEXT_STEP = {
11
- 1: "Run `hey-axi <command> --help` to check the arguments",
12
- 2: "Check the id; list commands such as `hey-axi box view imbox` show valid ids (threads use topic_id)",
13
- 3: "Ask the user to run `hey auth login` in their terminal, then `hey-axi auth status`",
14
- 4: "This account can't do that; check `hey-axi account list` and --account",
15
- 5: "Rate limited by HEY: wait a minute, then retry",
16
- 6: "Network problem reaching HEY: check the connection, then retry",
17
- 7: "HEY's API returned an error: retry later, or run `hey-axi doctor`",
18
- 8: "More than one match: pass an exact id instead of a name",
19
- 127: "Install the HEY CLI (curl -fsSL https://hey.com/install-cli | bash) or set HEY_BIN",
12
+ // HEY's exit codes (`hey help exit-codes`) → a kind, a message and the next step.
13
+ const KINDS = {
14
+ 1: { kind: "command_error", message: "the command failed", help: "Run `hey-axi <command> --help` to check the arguments" },
15
+ 2: { kind: "not_found", message: "not found", help: "Check the id; list commands such as `hey-axi box view imbox` show valid ids (threads use topic_id)" },
16
+ 3: { kind: "auth", message: "not signed in to HEY", help: "Run `hey-axi auth login --token <token>` if you have a token; otherwise ask the user to run `hey-axi auth login --interactive` in their terminal. Check with `hey-axi auth status`" },
17
+ 4: { kind: "forbidden", message: "this account can't do that", help: "Check `hey-axi account list` and --account" },
18
+ 5: { kind: "rate_limited", message: "rate limited by HEY", help: "Wait a minute, then retry" },
19
+ 6: { kind: "network", message: "couldn't reach HEY", help: "Check the network connection, then retry" },
20
+ 7: { kind: "api_error", message: "HEY's API returned an error", help: "Retry later, or run `hey-axi doctor`" },
21
+ 8: { kind: "ambiguous", message: "more than one match", help: "Pass an exact id instead of a name" },
22
+ 127: { kind: "hey_missing", message: "HEY CLI not found", help: "Install the HEY CLI (curl -fsSL https://hey.com/install-cli | bash) or set HEY_BIN" },
20
23
  };
21
24
 
22
- function withHelp(failure) {
23
- if (failure.hint) failure.hint = heyToAxi(failure.hint);
24
- else if (NEXT_STEP[failure.exit_code]) failure.help = NEXT_STEP[failure.exit_code];
25
- return failure;
25
+ const USAGE_CODES = new Set(["usage", "invalid_argument", "invalid_flag", "validation"]);
26
+
27
+ // AXI exit code for a failed HEY run.
28
+ export function axiExitCode(status, code) {
29
+ if (status === 0) return 0;
30
+ if (USAGE_CODES.has(code)) return 2;
31
+ return 1;
32
+ }
33
+
34
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
35
+ const NOISE = /^(warning:|panic:|fatal error:|\[signal |runtime error|\s+at |goroutine \d|\s*[\w./-]+\.go:\d+|exit status \d+$|\[\w+\]\s)/i;
36
+
37
+ // The one line of HEY's plain-text output worth showing an agent.
38
+ // Programming-language errors from inside HEY or its libraries say nothing an agent can
39
+ // act on: they are dropped, and the failure's own message (by kind) stands alone.
40
+ const INTERNAL = /^(Uncaught )?(TypeError|ReferenceError|SyntaxError|RangeError|InternalError|AssertionError|panic|fatal|runtime error|invalid memory address|nil pointer|unexpected end of JSON|json: |SIGSEGV)\b/i;
41
+ // Low-level network codes become plain words.
42
+ const NETWORK = [
43
+ [/\bECONNRESET\b/, "the connection was reset"], [/\bECONNREFUSED\b/, "the connection was refused"],
44
+ [/\bETIMEDOUT\b|\bi\/o timeout\b|context deadline exceeded/i, "the request timed out"], [/\bENOTFOUND\b|no such host/i, "the server name could not be resolved"],
45
+ [/\bEAI_AGAIN\b/, "DNS lookup failed"], [/\bcertificate\b|\bx509\b|\bTLS\b/i, "a TLS/certificate problem"],
46
+ ];
47
+
48
+ export function cleanLine(text) {
49
+ const lines = String(text || "").replace(ANSI, "").split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim() && !NOISE.test(line));
50
+ if (!lines.length) return "";
51
+ let line = lines[0].trim().replace(/^(error|Error|ERROR):\s*/, "");
52
+ const network = NETWORK.find(([pattern]) => pattern.test(line));
53
+ if (network) return network[1];
54
+ if (INTERNAL.test(line)) return "";
55
+ // Library prefixes ("axios:", "fetch failed:", "Get \"https://…\":") and raw URLs add nothing.
56
+ line = line.replace(/^(axios|undici|fetch failed|request failed|(get|post|put|patch|delete) "[^"]*"):?\s*/i, "").replace(/\bhttps?:\/\/\S+/g, "the HEY server").trim();
57
+ if (!line) return "";
58
+ return heyToAxi(line.length > 200 ? `${line.slice(0, 200)}…` : line);
59
+ }
60
+
61
+ // HEY's stderr after a successful run, minus terminal escapes and debug noise (stack
62
+ // frames, panics): warnings and notices such as `next_page: …` stay, on stderr.
63
+ export function warningLines(text) {
64
+ return String(text || "").replace(ANSI, "").split(/\r?\n/).map((line) => line.trimEnd())
65
+ .filter((line) => line.trim() && (/^warning:/i.test(line.trim()) || !NOISE.test(line)))
66
+ .map((line) => heyToAxi(line));
67
+ }
68
+
69
+ // Keys in HEY's error meta that only carry debugging detail.
70
+ const DEBUG_KEYS = /stack|trace|backtrace|panic|goroutine|debug|caller|frames?$/i;
71
+
72
+ // HEY's error meta without debugging detail: stack-like keys are dropped and
73
+ // multi-line strings are cut to their one meaningful line.
74
+ export function cleanMeta(value, depth = 0) {
75
+ if (typeof value === "string") return /[\r\n\u001b]/.test(value) || value.length > 200 ? cleanLine(value) : value;
76
+ if (!value || typeof value !== "object" || depth > 3) return depth > 3 ? undefined : value;
77
+ if (Array.isArray(value)) return value.slice(0, 20).map((item) => cleanMeta(item, depth + 1)).filter((item) => item !== undefined && item !== "");
78
+ const out = {};
79
+ for (const [key, item] of Object.entries(value)) {
80
+ if (DEBUG_KEYS.test(key)) continue;
81
+ const cleaned = cleanMeta(item, depth + 1);
82
+ if (cleaned !== undefined && cleaned !== "") out[key] = cleaned;
83
+ }
84
+ return Object.keys(out).length ? out : undefined;
85
+ }
86
+
87
+ function warnings(text) {
88
+ return String(text || "").replace(ANSI, "").split(/\r?\n/).filter((line) => /^warning:/i.test(line.trim())).map((line) => line.trim());
26
89
  }
27
90
 
28
91
  function findEnvelope(text) {
@@ -43,19 +106,36 @@ function findEnvelope(text) {
43
106
  return null;
44
107
  }
45
108
 
46
- export function heyFailure(result) {
47
- if (result.error) return withHelp({ ok: false, error: result.error, exit_code: result.status });
109
+ // Returns { failure, exitCode, warnings }: the object to print, hey-axi's exit code,
110
+ // and diagnostic lines for stderr.
111
+ export function translateFailure(result, { path } = {}) {
112
+ const raw = KINDS[result.status] || KINDS[1];
113
+ const info = path ? { ...raw, help: raw.help.replace("<command>", path) } : raw;
114
+ if (result.error) {
115
+ return { failure: { ok: false, error: result.error, kind: info.kind, help: info.help }, exitCode: 1, warnings: [] };
116
+ }
48
117
  for (const text of [result.stderr, result.stdout]) {
49
118
  const found = findEnvelope(text);
50
119
  if (!found) continue;
51
- const failure = { ok: false, error: found.envelope.error };
52
- for (const key of ["code", "hint", "meta"]) {
53
- if (found.envelope[key] !== undefined && found.envelope[key] !== "") failure[key] = found.envelope[key];
54
- }
55
- if (found.prefix) failure.warning = found.prefix;
56
- failure.exit_code = result.status;
57
- return withHelp(failure);
120
+ const { envelope } = found;
121
+ // Even HEY's own envelope is translated: one clean line, no stack frames or escapes.
122
+ const failure = { ok: false, error: cleanLine(String(envelope.error)) || info.message, kind: info.kind };
123
+ if (envelope.code !== undefined && envelope.code !== "" && envelope.code !== "unknown") failure.code = String(envelope.code);
124
+ const meta = envelope.meta === undefined || envelope.meta === "" ? undefined : cleanMeta(envelope.meta);
125
+ if (meta !== undefined) failure.meta = meta;
126
+ const usage = USAGE_CODES.has(envelope.code) || /^usage: /i.test(String(envelope.error));
127
+ if (usage) failure.kind = "usage";
128
+ const hint = envelope.hint ? cleanLine(String(envelope.hint)) : "";
129
+ if (hint && !/^Run 'hey(-axi)? --help'/.test(hint)) failure.hint = hint;
130
+ failure.help = usage ? `Run \`hey-axi ${path || "<command>"} --help\` for its arguments and flags` : info.help;
131
+ return { failure, exitCode: usage ? 2 : axiExitCode(result.status, envelope.code), warnings: warnings(found.prefix) };
58
132
  }
59
- const message = (result.stderr || "").trim() || (result.stdout || "").trim() || "hey command failed";
60
- return withHelp({ ok: false, error: message, exit_code: result.status });
133
+ const detail = cleanLine(result.stderr) || cleanLine(result.stdout);
134
+ const failure = { ok: false, error: detail ? `${info.message}: ${detail}` : info.message, kind: info.kind, help: info.help };
135
+ return { failure, exitCode: axiExitCode(result.status), warnings: [...warnings(result.stderr), ...warnings(result.stdout)] };
136
+ }
137
+
138
+ // Kept for callers that only need the object (the home view).
139
+ export function heyFailure(result) {
140
+ return translateFailure(result).failure;
61
141
  }
@@ -0,0 +1,57 @@
1
+ // Per-command help details (AXI principle 10): every flag's default, and two or three
2
+ // usage examples for every command. HEY's own examples come first; commands HEY gives
3
+ // fewer than two for get examples built from their USAGE line and flags.
4
+
5
+ import { heyToAxi } from "./text.js";
6
+ import { patternsFor } from "./arity.js";
7
+ import { LIST_FIELDS, parseField } from "./shape.js";
8
+ import { userOnlyPaths } from "./policy.js";
9
+
10
+ // "(default …)" text for one flag, including zero, false and empty defaults.
11
+ export function flagDefault(flag) {
12
+ const value = flag.default;
13
+ if (!flag.value) return `default ${value === "true" ? "true" : "false"}`;
14
+ if (value === undefined || value === "" || value === "[]") return "default: none";
15
+ if (flag.name === "limit" && value === "0") return "default 0: no limit set, HEY returns its first page";
16
+ if (value === "0s") return "default 0s: no timeout";
17
+ return `default ${value}`;
18
+ }
19
+
20
+ const placeholder = (flag) => {
21
+ if (flag.type === "int") return "10";
22
+ if (flag.type === "duration") return "10m";
23
+ return `<${flag.name}>`;
24
+ };
25
+
26
+ function synthesized(node) {
27
+ const pattern = patternsFor(node)[0];
28
+ const args = (pattern?.args || []).filter((arg) => arg.required).map((arg) => `<${arg.name}>`);
29
+ const oneOf = pattern?.oneOf?.[0]?.[0];
30
+ const fromHey = (node.examples || [])[0];
31
+ const base = fromHey ? heyToAxi(fromHey) : ["hey-axi", node.path, ...args, ...(oneOf ? [oneOf] : [])].join(" ");
32
+ const out = [base];
33
+ const own = (node.flags || []).filter((flag) => !(pattern?.oneOf || []).flat().includes(`--${flag.name}`));
34
+ const valued = own.find((flag) => flag.value && !/\(required\)$/.test(flag.desc || ""));
35
+ const required = own.filter((flag) => /\(required\)$/.test(flag.desc || ""));
36
+ if (required.length && !fromHey) {
37
+ out[0] = `${base} ${required.map((flag) => `--${flag.name} ${placeholder(flag)}`).join(" ")}`;
38
+ }
39
+ if (valued && !out[0].includes(`--${valued.name}`)) out.push(`${out[0]} --${valued.name} ${placeholder(valued)}`);
40
+ const list = LIST_FIELDS[node.path];
41
+ if (list) out.push(`${out[0]} --fields ${list.slice(0, 2).map((spec) => parseField(spec).key).join(",")}`);
42
+ out.push(`${out[0]} --json`);
43
+ out.push(`${out[0]} --account <id>`);
44
+ return out;
45
+ }
46
+
47
+ // Two or three examples, HEY's own first.
48
+ export function examplesFor(node) {
49
+ const examples = (node.examples || []).map(heyToAxi);
50
+ for (const example of synthesized(node)) {
51
+ if (examples.length >= 3 || (examples.length >= 2 && (node.examples || []).length >= 2)) break;
52
+ if (!examples.includes(example)) examples.push(example);
53
+ }
54
+ // Person-only commands run only with --interactive, so their examples say so.
55
+ const person = userOnlyPaths().includes(node.path);
56
+ return examples.slice(0, 3).map((example) => (person && !/--(interactive|token|cookie)\b/.test(example) ? `${example} --interactive` : example));
57
+ }
package/src/guide.js CHANGED
@@ -1,9 +1,11 @@
1
1
  // Static guidance shared by the no-args home view and the generated part of
2
2
  // skills/hey-axi/SKILL.md (scripts/gen-skill.js), so the two never drift.
3
3
 
4
+ import { withSelectors } from "./shape.js";
5
+
4
6
  export const DESCRIPTION = "HEY email, calendars and todos for agents: compact TOON over Basecamp's HEY CLI; outgoing mail is saved as a draft unless --allow-send";
5
7
 
6
- // How many Imbox threads the home view (and the session hook) shows.
8
+ // How many threads the home view (and the session hook) shows by default.
7
9
  export const HOME_LIMIT = 10;
8
10
 
9
11
  export const HOME_HELP = [
@@ -15,13 +17,30 @@ export const HOME_HELP = [
15
17
  "Run `hey-axi --help` for all commands; lists take --fields a,b / --fields all, long text --full",
16
18
  ];
17
19
 
20
+ // The live home view's help: the static lines, pointed at the scope's command, with the
21
+ // scope's --account carried into every suggestion, plus "see the rest" / --full /
22
+ // review-drafts hints when they apply.
23
+ export function homeHelp({ base = "hey-axi box view imbox", carry = [], more = false, total, truncated = false, drafts = false, search = false } = {}) {
24
+ const lines = HOME_HELP.map((line) => {
25
+ if (line.includes("box view imbox --limit 50")) {
26
+ if (!more) return `Run \`${withSelectors("hey-axi box list", carry)}\` for other boxes (feed, papertrail, aside, later)`;
27
+ const count = total !== undefined ? `all ${total}` : "more";
28
+ return search ? `Run \`${base}\` for ${count}` : `Run \`${base} --all\` for ${count} (or --limit <n>)`;
29
+ }
30
+ return line.replace(/`(hey-axi [^`]*)`/, (_, command) => (command === "hey-axi --help" ? `\`${command}\`` : `\`${withSelectors(command, carry)}\``));
31
+ }).filter(Boolean);
32
+ if (truncated) lines.push(`Run \`${base} --full\` to see complete content`);
33
+ if (drafts) lines.unshift(`Run \`${withSelectors("hey-axi draft list", carry)}\` to review drafts saved last session (not sent)`);
34
+ return lines;
35
+ }
36
+
18
37
  export const SETUP_HELP = {
19
38
  missing: [
20
39
  "Install the HEY CLI: curl -fsSL https://hey.com/install-cli | bash (or brew install --cask basecamp/tap/hey)",
21
40
  "If it is installed outside PATH, set HEY_BIN=/path/to/hey",
22
41
  ],
23
42
  auth: [
24
- "Ask the user to run `hey auth login` in their own terminal (it opens a browser)",
43
+ "Ask the user to run `hey-axi auth login --interactive` in their own terminal (it opens a browser), or run `hey-axi auth login --token <token>`",
25
44
  "Run `hey-axi auth status` to check afterwards",
26
45
  ],
27
46
  other: ["Run `hey-axi doctor` to find login and configuration problems"],
@@ -0,0 +1,12 @@
1
+ // The absolute path of a file, with the user's home directory collapsed to `~`, for
2
+ // paths shown to agents.
3
+ import { homedir } from "node:os";
4
+ import { resolve, sep } from "node:path";
5
+
6
+ export function collapse(path, home = homedir()) {
7
+ if (!path) return path;
8
+ const absolute = resolve(path);
9
+ if (!home) return absolute;
10
+ if (absolute === home) return "~";
11
+ return absolute.startsWith(home.endsWith(sep) ? home : home + sep) ? `~${absolute.slice(home.replace(/[\\/]$/, "").length)}` : absolute;
12
+ }
package/src/home.js CHANGED
@@ -1,21 +1,36 @@
1
- // The no-args home view (AXI principles 8 and 10): who this is, then live Imbox state,
2
- // then the next commands. Also what the session hook prints. It never fails hard:
1
+ // The no-args home view (AXI principles 7, 8 and 10): who this is, the directory scope,
2
+ // live mail for that scope with its count, what the last session in this directory did,
3
+ // then the next commands. It is also what the session hook prints. It never fails hard:
3
4
  // a missing or signed-out HEY is reported as `status` with the fix (exit 0), so a
4
5
  // session hook still gives the agent something useful.
5
6
 
6
- import { homedir } from "node:os";
7
- import { DESCRIPTION, HOME_HELP, HOME_LIMIT, SETUP_HELP } from "./guide.js";
8
- import { shapeData } from "./shape.js";
7
+ import { DESCRIPTION, SETUP_HELP, homeHelp } from "./guide.js";
8
+ import { carrySelectors, countLine, listSize, shapeData, findList } from "./shape.js";
9
9
  import { heyFailure } from "./errors.js";
10
10
  import { heyToAxi } from "./text.js";
11
+ import { ScopeError, findScope, scopeQuery } from "./scope.js";
12
+ import { lastSession } from "./activity.js";
13
+ import { collapse } from "./home-path.js";
11
14
 
12
- export function collapse(path, home = homedir()) {
13
- return path && home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
14
- }
15
+ export { collapse };
15
16
 
16
- export async function homeView(runHey, { execPath = process.argv[1] } = {}) {
17
+ export async function homeView(runHey, { execPath = process.argv[1], cwd = process.cwd() } = {}) {
17
18
  const out = { bin: collapse(execPath), description: DESCRIPTION };
18
- const result = await runHey(["box", "view", "imbox", "--limit", String(HOME_LIMIT)], ["--json"]);
19
+ let scope = null;
20
+ try {
21
+ scope = findScope(cwd);
22
+ } catch (error) {
23
+ if (!(error instanceof ScopeError)) throw error;
24
+ out.status = error.message;
25
+ out.help = ["Fix or delete that file, then run `hey-axi` again", "Run `hey-axi setup scope --help` for the format"];
26
+ return out;
27
+ }
28
+ const query = scopeQuery(scope);
29
+ out.scope = query.label;
30
+ const previous = lastSession(scope?.dir || cwd);
31
+ if (previous) out.last_session = previous.line;
32
+
33
+ const result = await runHey(query.argv, ["--json"]);
19
34
  if (result.status === 127) {
20
35
  out.status = result.error || "HEY CLI not found";
21
36
  out.help = SETUP_HELP.missing;
@@ -30,6 +45,7 @@ export async function homeView(runHey, { execPath = process.argv[1] } = {}) {
30
45
  out.status = `HEY error: ${heyToAxi(failure.error)}`;
31
46
  out.help = [...(failure.hint ? [heyToAxi(failure.hint)] : []), ...SETUP_HELP.other];
32
47
  }
48
+ out.help = out.help.map((line) => carrySelectors(line, query.carry || []));
33
49
  return out;
34
50
  }
35
51
  let envelope;
@@ -40,11 +56,27 @@ export async function homeView(runHey, { execPath = process.argv[1] } = {}) {
40
56
  out.help = SETUP_HELP.other;
41
57
  return out;
42
58
  }
43
- const { data, empty } = shapeData(envelope.data, { path: "box view" });
44
- const threads = Array.isArray(data) ? data : data?.postings;
45
- out.imbox = envelope.summary || `${threads?.length ?? 0} threads`;
46
- if (empty || !threads?.length) out.imbox = "0 threads: the Imbox is empty";
47
- else out.threads = threads;
48
- out.help = HOME_HELP;
59
+ const body = envelope && typeof envelope === "object" && "data" in envelope ? envelope.data : envelope;
60
+ const found = findList(body);
61
+ const size = listSize(envelope, found ? found.list.length : 0, found?.container);
62
+ // search has no --limit: keep the first `limit` rows here.
63
+ if (found && found.list.length > query.limit) {
64
+ // More rows than shown, but no total unless HEY gave one.
65
+ size.more = true;
66
+ }
67
+ const { data, truncated } = shapeData(body, { path: query.path });
68
+ let threads = Array.isArray(data) ? data : data?.[found?.key];
69
+ if (threads && threads.length > query.limit) threads = threads.slice(0, query.limit);
70
+ size.shown = threads?.length ?? 0;
71
+ if (!threads?.length && (size.more || size.total > 0)) {
72
+ // An empty page while HEY reports more: say exactly that, not "nothing".
73
+ out.count = countLine(size);
74
+ out.mail = `0 threads on this page; HEY reports ${size.total > 0 ? `${size.total} in total` : "more"}`;
75
+ } else if (!threads?.length) out.mail = `0 threads: nothing in ${query.label.split(" (")[0]}`;
76
+ else {
77
+ out.count = countLine(size);
78
+ out.threads = threads;
79
+ }
80
+ out.help = homeHelp({ base: query.base, carry: query.carry, more: size.more, total: size.total, truncated, drafts: previous?.drafts > 0, search: query.path === "search" });
49
81
  return out;
50
82
  }
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
  }