hey-axi 0.3.1 → 0.3.2

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/home.js CHANGED
@@ -68,15 +68,24 @@ export async function homeView(runHey, { execPath = process.argv[1], cwd = proce
68
68
  let threads = Array.isArray(data) ? data : data?.[found?.key];
69
69
  if (threads && threads.length > query.limit) threads = threads.slice(0, query.limit);
70
70
  size.shown = threads?.length ?? 0;
71
+ const where = query.label.split(" (")[0];
71
72
  if (!threads?.length && (size.more || size.total > 0)) {
72
73
  // An empty page while HEY reports more: say exactly that, not "nothing".
73
74
  out.count = countLine(size);
74
75
  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 {
76
+ } else if (!threads?.length && size.complete) {
77
+ // "Nothing" only when HEY says nothing is left (a total of 0, has_more: false, no next page).
78
+ out.count = countLine(size);
79
+ out.mail = `0 threads: nothing in ${where}`;
80
+ } else if (!threads?.length) {
81
+ // An empty first page with no paging signal either way: report the page, claim no more.
82
+ out.count = countLine(size);
83
+ out.mail = `0 threads on this page of ${where}; HEY reported no total and no further pages`;
84
+ } else {
77
85
  out.count = countLine(size);
78
86
  out.threads = threads;
79
87
  }
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" });
88
+ const unsure = !threads?.length && !size.more && !(size.total > 0) && !size.complete;
89
+ out.help = homeHelp({ base: query.base, carry: query.carry, more: size.more, total: size.total, truncated, drafts: previous?.drafts > 0, search: query.path === "search", unsure });
81
90
  return out;
82
91
  }
package/src/hooks.js CHANGED
@@ -124,7 +124,10 @@ function hasEndHook(path) {
124
124
  }
125
125
  }
126
126
 
127
- function openCodeEndPlugin(command) {
127
+ // One default export that both OpenCode generations load (the same shape axi-sdk-js
128
+ // 0.1.13 uses for the session-start plugin): OpenCode 2 checks `id` and runs `setup(ctx)`,
129
+ // subscribing to its event stream; OpenCode 1 runs `server(input)` and uses its `event` hook.
130
+ export function openCodeEndPlugin(command) {
128
131
  return `// ${END_PLUGIN_MARKER}
129
132
  // Generated by \`hey-axi setup hooks\`. \`hey-axi setup hooks --remove\` deletes it.
130
133
  import { spawn } from "node:child_process";
@@ -141,12 +144,36 @@ function capture(directory, sessionID) {
141
144
  });
142
145
  }
143
146
 
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);
147
+ // A session went idle or was deleted: record what hey-axi did in it.
148
+ async function onEvent(event, directory) {
149
+ if (!event || typeof event !== "object") return;
150
+ const properties = event.properties ?? {};
151
+ const idle = event.type === "session.idle" || (event.type === "session.status" && properties.status?.type === "idle");
152
+ if (idle || event.type === "session.deleted") {
153
+ await capture(properties.info?.directory ?? directory, properties.sessionID ?? properties.info?.id ?? event.sessionID);
154
+ }
155
+ }
156
+
157
+ export default {
158
+ id: "axi-hey-axi-session-end",
159
+ // OpenCode 2 entrypoint.
160
+ setup(ctx) {
161
+ const controller = new AbortController();
162
+ const directory = ctx.location?.directory;
163
+ void (async () => {
164
+ try {
165
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) await onEvent(event, directory);
166
+ } catch {
167
+ // the stream ended (plugin unloaded): nothing to record
168
+ }
169
+ })();
170
+ return () => controller.abort();
171
+ },
172
+ // OpenCode 1 entrypoint.
173
+ async server({ directory }) {
174
+ return { event: async ({ event }) => onEvent(event, directory) };
148
175
  },
149
- });
176
+ };
150
177
  `;
151
178
  }
152
179
 
package/src/manifest.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
- "source": "hey commands --json + hey <command> --help",
3
- "hey_version": "1.7.0",
4
- "hey_commit": "924703c5d7e8a24bb46acba0fe27bbc27f9c1de9",
2
+ "source": "hey commands --json + hey <command> --help (basecamp/hey-cli main at 9dfe00f, 2026-10-06; unreleased, built from source)",
3
+ "hey_version": "1.7.0+main.9dfe00f",
4
+ "hey_commit": "9dfe00fb6594a4da5272b8ed0692f494229f14d2",
5
+ "hey_commit_date": "2026-10-06",
5
6
  "commands": [
6
7
  {
7
8
  "name": "account",
@@ -199,7 +200,7 @@
199
200
  "path": "box",
200
201
  "short": "List HEY boxes and their email threads",
201
202
  "usage": "box <name|id>",
202
- "notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view <id>, and every thread with that sender via hey contact threads <contact-id>. --page continues from the next_page cursor of an earlier listing of the same box.",
203
+ "notes": "Accepts a box's short name (imbox, feed, papertrail, setaside, replylater, bubbleup), kind (feedbox, trailbox, …) or display name (including one the user gave the box) in any case, or a numeric ID. hey search --in and hey move --to take the same spellings, but not every box: search narrows only to imbox, feed and papertrail (plus trash) and refuses anything else as a usage error, and move takes a box ID or any box but bubbleup (use hey bubble up). An unknown name is not_found here and in move. Trash is not a box: search it with hey search --in trash and move threads there with hey trash. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view <id>, and every thread with that sender via hey contact threads <contact-id>. --page continues from the next_page cursor of an earlier listing of the same box.",
203
204
  "flags": [
204
205
  {
205
206
  "name": "all",
@@ -254,7 +255,7 @@
254
255
  "name": "view",
255
256
  "path": "box view",
256
257
  "short": "List email threads in a box",
257
- "notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view <id>, and every thread with that sender via hey contact threads <contact-id>. --page continues from the next_page cursor of an earlier listing of the same box.",
258
+ "notes": "Accepts a box's short name (imbox, feed, papertrail, setaside, replylater, bubbleup), kind (feedbox, trailbox, …) or display name (including one the user gave the box) in any case, or a numeric ID. hey search --in and hey move --to take the same spellings, but not every box: search narrows only to imbox, feed and papertrail (plus trash) and refuses anything else as a usage error, and move takes a box ID or any box but bubbleup (use hey bubble up). An unknown name is not_found here and in move. Trash is not a box: search it with hey search --in trash and move threads there with hey trash. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view <id>, and every thread with that sender via hey contact threads <contact-id>. --page continues from the next_page cursor of an earlier listing of the same box.",
258
259
  "flags": [
259
260
  {
260
261
  "name": "all",
@@ -281,8 +282,8 @@
281
282
  ],
282
283
  "examples": [
283
284
  "hey box view imbox",
284
- "hey box view imbox --limit 10",
285
- "hey box view imbox --page next-cursor"
285
+ "hey box view papertrail",
286
+ "hey box view imbox --limit 10"
286
287
  ]
287
288
  }
288
289
  ],
@@ -292,7 +293,7 @@
292
293
  "examples": [
293
294
  "hey box list",
294
295
  "hey box view imbox",
295
- "hey box view imbox --limit 10"
296
+ "hey box view papertrail"
296
297
  ]
297
298
  },
298
299
  {
@@ -331,7 +332,7 @@
331
332
  "name": "pop",
332
333
  "path": "bubble pop",
333
334
  "short": "Cancel bubble-ups",
334
- "notes": "Accepts one or more box item IDs from hey box view output. Cancels each thread's bubble-up.",
335
+ "notes": "Accepts one or more box item IDs from hey box view output. Cancels each thread's bubble-up; the thread moves to the Imbox and is marked seen.",
335
336
  "synopsis": [
336
337
  "hey bubble pop <box-item-id>... [flags]"
337
338
  ],
@@ -344,7 +345,7 @@
344
345
  "name": "up",
345
346
  "path": "bubble up",
346
347
  "short": "Bubble email threads up",
347
- "notes": "Accepts one or more box item IDs from hey box view output. Exactly one of --now, --on, --tomorrow, --weekend and --next-week is required. --on takes a YYYY-MM-DD date; HEY bubbles the threads up at its morning hour of that day, or at its evening hour (18:00) when the date is today. --tomorrow, --weekend and --next-week land at the morning hour of tomorrow, the coming Saturday, and next Monday.",
348
+ "notes": "Accepts one or more box item IDs from hey box view output. Exactly one of --now, --on, --tomorrow, --weekend and --next-week is required. --on takes a YYYY-MM-DD date; HEY bubbles the threads up at 08:00 UTC that day, or at 18:00 UTC when the date is today in UTC, HEY's clock for bubbling up, not the local one. --tomorrow, --weekend and --next-week land at 08:00 UTC tomorrow, the next Saturday, and next Monday.",
348
349
  "flags": [
349
350
  {
350
351
  "name": "next-week",
@@ -817,7 +818,7 @@
817
818
  "name": "compose",
818
819
  "path": "compose",
819
820
  "short": "Write and send a new email",
820
- "notes": "--from selects a configured sender email or ID from account senders; --account must agree. --from is only for new messages. Starts a new thread with --to (optionally --cc/--bcc), which requires --subject, or replies to an existing one with --thread-id, which does not. Repeatable --attach files are uploaded before sending and can be sent without body text. The body is Markdown; use --message-html to send raw HTML instead. --draft saves instead of sending — recipients become optional — and answers the draft ID for hey draft show/edit/send/delete. A new message ends with the sender's HEY name tag, as one composed in HEY does; --no-name-tag leaves it out.",
821
+ "notes": "--from selects a configured sender email or ID from account senders; --account must agree. --from is only for new messages. Starts a new thread with --to (optionally --cc/--bcc), which requires --subject, or replies to an existing one with --thread-id, which does not; --thread-id answers the thread's latest emailed message, never a note (kind \"comment\") or share notice (kind \"access_notice\"), and a thread with no emailed message is refused as not_found. Repeatable --attach files are uploaded before sending and can be sent without body text. The body is Markdown; use --message-html to send raw HTML instead. --draft saves instead of sending — recipients become optional — and answers the draft ID for hey draft show/edit/send/delete. A new message ends with the sender's HEY name tag, as one composed in HEY does; --no-name-tag leaves it out. A send answers the new message's id and the topic_id of its thread (for hey thread read), with subject and delayed (true while Undo Send holds it back) — only the fields HEY's answer carries, never guessed. While delayed is true, HEY leaves the message out of its thread, so hey thread read shows the thread without it (a thread it started reads as not found) until Undo Send releases it. A send HEY refuses — usually the account's sending limit — is kept as a draft and fails with not_delivered (exit 7), naming the draft in meta.draft_id; send it later with hey draft send <draft_id>, not by sending again, which only makes another draft.",
821
822
  "flags": [
822
823
  {
823
824
  "name": "attach",
@@ -883,7 +884,7 @@
883
884
  "name": "thread-id",
884
885
  "value": true,
885
886
  "type": "string",
886
- "desc": "Reply to this thread instead of starting a new one",
887
+ "desc": "Reply to this thread's latest emailed message instead of starting a new one",
887
888
  "default": ""
888
889
  },
889
890
  {
@@ -961,7 +962,7 @@
961
962
  "name": "contact",
962
963
  "path": "contact",
963
964
  "short": "Manage contacts",
964
- "notes": "Contact IDs come from `hey contact list`. Hiding and bundling are reversible. Private notes are managed under `hey contact note`.",
965
+ "notes": "Contact IDs come from `hey contact list`. Delivery is managed with `hey contact deliver`; hiding and bundling are reversible. Private notes are managed under `hey contact note`.",
965
966
  "subcommands": [
966
967
  {
967
968
  "name": "add",
@@ -1010,7 +1011,7 @@
1010
1011
  "name": "bundle",
1011
1012
  "path": "contact bundle",
1012
1013
  "short": "Bundle a contact's mail",
1013
- "notes": "Bundling is a per-contact preference. HEY applies it when the contact's delivery setting supports bundles. Reverse it with `hey contact unbundle <id>`.",
1014
+ "notes": "Bundling is a per-contact preference. HEY bundles only a contact with no box preference or one sent to the Paper Trail; for any other it answers success and changes nothing. Reverse it with `hey contact unbundle <id>`.",
1014
1015
  "synopsis": [
1015
1016
  "hey contact bundle <id> [flags]"
1016
1017
  ],
@@ -1018,6 +1019,28 @@
1018
1019
  "hey contact bundle 12345"
1019
1020
  ]
1020
1021
  },
1022
+ {
1023
+ "name": "deliver",
1024
+ "path": "contact deliver",
1025
+ "short": "Choose where a contact's email arrives",
1026
+ "notes": "Use a contact ID from `hey contact list`, not a clearance ID, box item ID, box ID, or email address. Pass the exact --to token: imbox, feed, papertrail, or screened-out.",
1027
+ "flags": [
1028
+ {
1029
+ "name": "to",
1030
+ "value": true,
1031
+ "type": "string",
1032
+ "desc": "Delivery destination: imbox, feed, papertrail, or screened-out",
1033
+ "default": ""
1034
+ }
1035
+ ],
1036
+ "synopsis": [
1037
+ "hey contact deliver <contact-id> [flags]"
1038
+ ],
1039
+ "examples": [
1040
+ "hey contact deliver 12345 --to feed",
1041
+ "hey contact deliver 12345 --to screened-out"
1042
+ ]
1043
+ },
1021
1044
  {
1022
1045
  "name": "hide",
1023
1046
  "path": "contact hide",
@@ -1062,7 +1085,7 @@
1062
1085
  "name": "note",
1063
1086
  "path": "contact note",
1064
1087
  "short": "Manage private contact notes",
1065
- "notes": "Private notes support show, set, and delete. Set accepts --note, positional content, stdin, or $EDITOR.",
1088
+ "notes": "Private notes support show, set, and delete. Show answers note_markdown, the form set takes, and note_markdown_lossless; when that is false, change note_html and set it with --note-html instead. Set replaces the whole note and accepts --note, positional content, stdin, or $EDITOR.",
1066
1089
  "subcommands": [
1067
1090
  {
1068
1091
  "name": "delete",
@@ -1079,7 +1102,7 @@
1079
1102
  "name": "set",
1080
1103
  "path": "contact note set",
1081
1104
  "short": "Write or edit a private contact note",
1082
- "notes": "Accepts --note, positional content, stdin, or opens $EDITOR with the existing note. The note is Markdown, or raw HTML via --note-html. Use the delete subcommand to clear a note.",
1105
+ "notes": "Replaces the whole note. Accepts --note, positional content, stdin, or opens $EDITOR with the existing note as Markdown, which is refused when that Markdown would drop part of the note. The note is Markdown, or raw HTML via --note-html. To add to a note, read note_markdown from hey contact note show, change it, and set all of it; when note_markdown_lossless is false, change note_html and set it with --note-html instead. Use the delete subcommand to clear a note.",
1083
1106
  "flags": [
1084
1107
  {
1085
1108
  "name": "note",
@@ -1102,19 +1125,22 @@
1102
1125
  ],
1103
1126
  "examples": [
1104
1127
  "hey contact note set 12345 \"Prefers email\"",
1105
- "hey contact note set 12345 --note \"Prefers email\""
1128
+ "hey contact note set 12345 --note \"Prefers email\"",
1129
+ "hey contact note set 12345 --note-html \"<p><strong>Prefers email</strong></p>\""
1106
1130
  ]
1107
1131
  },
1108
1132
  {
1109
1133
  "name": "show",
1110
1134
  "path": "contact note show",
1111
1135
  "short": "Read a private contact note",
1136
+ "notes": "JSON answers note (HEY's plain text, formatting dropped), note_html (as HEY serves it), note_markdown (the note as Markdown, the form hey contact note set takes) and note_markdown_lossless. When note_markdown_lossless is false the note holds an attachment or other markup Markdown cannot carry: change note_html and write it back with --note-html instead.",
1112
1137
  "synopsis": [
1113
1138
  "hey contact note show <id> [flags]"
1114
1139
  ],
1115
1140
  "examples": [
1116
1141
  "hey contact note show 12345",
1117
- "hey contact note show 12345 --json"
1142
+ "hey contact note show 12345 --json",
1143
+ "hey contact note show 12345 --jq '.data.note_markdown'"
1118
1144
  ]
1119
1145
  }
1120
1146
  ],
@@ -1126,7 +1152,7 @@
1126
1152
  "name": "show",
1127
1153
  "path": "contact show",
1128
1154
  "short": "View a contact",
1129
- "notes": "Returns contact details, aliases, screening status, and the private note. The embedded postings are one page of the contact's threads; hey contact threads <id> pages through all of them.",
1155
+ "notes": "Returns contact details, aliases, screening status, and the private note: note, note_html and note_markdown, empty for a contact with no note, and note_markdown_lossless, true then (note_markdown is the form hey contact note set takes when note_markdown_lossless is true). The embedded postings are one page of the contact's threads; hey contact threads <id> pages through all of them.",
1130
1156
  "synopsis": [
1131
1157
  "hey contact show <id> [flags]"
1132
1158
  ],
@@ -1367,7 +1393,7 @@
1367
1393
  "name": "send",
1368
1394
  "path": "draft send",
1369
1395
  "short": "Deliver a draft",
1370
- "notes": "Sends the draft as it stands — recipients are required, added with `hey draft edit --to`. Delivery goes through HEY's undo window. Scheduling a delivery is done in a HEY app for now; the API cannot yet name an exact instant.",
1396
+ "notes": "Sends the draft as it stands — recipients are required, added with `hey draft edit --to`. Delivery goes through HEY's undo window. The answer carries the delivered message's id and its thread's topic_id, with subject and delayed; when HEY's answer names no entry, the id stays the draft's and only the fields HEY served are added. While delayed is true, HEY leaves the message out of its thread, so hey thread read shows the thread without it (a thread it started reads as not found) until Undo Send releases it. Scheduling a delivery is done in a HEY app for now; the CLI has no flag for it, and HEY's API schedules only to a whole hour. A send HEY refuses — usually the account's sending limit — is kept as a draft and fails with not_delivered (exit 7), naming the draft in meta.draft_id; send it later with hey draft send <draft_id>, not by sending again, which only makes another draft.",
1371
1397
  "synopsis": [
1372
1398
  "hey draft send <draft-id> [flags]"
1373
1399
  ],
@@ -1397,7 +1423,7 @@
1397
1423
  "name": "event",
1398
1424
  "path": "event",
1399
1425
  "short": "Read and manage calendar events",
1400
- "notes": "Subcommands: list, day, week, add, edit, delete. \"What's on the schedule today?\" is answered by day, not list: day and week read the span as HEY draws it, with a repeating event expanded into the occurrences inside it, over the calendars switched on in HEY. list reads what calendars hold — every calendar unless --calendar names one — and a repeating event is one row, its series, on the day the series began. An edit is not a patch on HEY's side: it resends the notes, location, link, attached email, reminders and time zones the event already carries, so notes lose their formatting and a countdown is removed unless --countdown names one again. edit <series id> changes a whole series; one day of it is edit <series id> --occurrence <occurrence_id from day/week> --apply-to current|future, which keeps the countdown and refuses to flatten notes unless --allow-plain-notes or --notes is given. --apply-to future starts a new series and requires --repeat with its complete schedule; a preset alone means forever and custom copies an existing opaque schedule (a count-based rule can restart its full count). A virtual day of an opaque custom schedule is read from HEY's Day view and refused if that view no longer serves it. A realized custom day cannot be split safely; use a virtual occurrence, edit that day alone, or edit the whole series. A realized preset occurrence moved away from its series time must be moved back with --apply-to current before a future split. Read the day again afterward for the new series id.",
1426
+ "notes": "Subcommands: list, day, week, add, edit, delete. \"What's on the schedule today?\" is answered by day, not list: day and week read the span as HEY draws it, with a repeating event expanded into the occurrences inside it, over the calendars switched on in HEY; without a date they, and list's window, start from today in the HEY account's time zone. list reads what calendars hold — every calendar unless --calendar names one — and a repeating event is one row, its series, on the day the series began. An edit is not a patch on HEY's side: it resends the notes, location, link, attached email, reminders and time zones the event already carries, so notes lose their formatting and a countdown is removed unless --countdown names one again. edit <series id> changes a whole series; one day of it is edit <series id> --occurrence <occurrence_id from day/week> --apply-to current|future, which keeps the countdown and refuses to flatten notes unless --allow-plain-notes or --notes is given. --apply-to future starts a new series and requires --repeat with its complete schedule; a preset alone means forever and custom copies an existing opaque schedule (a count-based rule can restart its full count). A virtual day of an opaque custom schedule is read from HEY's Day view and refused if that view no longer serves it. A realized custom day cannot be split safely; use a virtual occurrence, edit that day alone, or edit the whole series. A realized preset occurrence moved away from its series time must be moved back with --apply-to current before a future split. Read the day again afterward for the new series id. delete <series id> deletes the whole series; one day of it is delete <series id> --occurrence <occurrence_id> --apply-to current|future, whether or not HEY has written that day out. delete refuses a written-out day's own id, because HEY would draw the day again from the series. add and edit read clock times, and today when --starts-on is left out, in the HEY account's time zone unless --time-zone names one, and refuse when they need it and the account has none; an edit keeps a zoned event's zone and leaves a zoneless event zoneless.",
1401
1427
  "subcommands": [
1402
1428
  {
1403
1429
  "name": "add",
@@ -1523,7 +1549,7 @@
1523
1549
  "name": "time-zone",
1524
1550
  "value": true,
1525
1551
  "type": "string",
1526
- "desc": "IANA zone the times are written in (defaults to this machine's)",
1552
+ "desc": "IANA zone the times are written in, such as America/New_York (defaults to your HEY account's)",
1527
1553
  "default": ""
1528
1554
  },
1529
1555
  {
@@ -1575,12 +1601,30 @@
1575
1601
  "name": "delete",
1576
1602
  "path": "event delete",
1577
1603
  "short": "Delete an event",
1578
- "notes": "Deletes the whole event, recurring series included. An event on a shared calendar is deleted for everybody on it.",
1604
+ "notes": "delete <id> deletes the whole event, recurring series included, for everybody on a shared calendar. One day of a series is delete <series id> --occurrence <occurrence_id from day/week> --apply-to current|future: current deletes that day alone; future deletes it and every day after, and from the series' first day that is the whole series. A day HEY wrote out on its own (own id in id/recording_id, series in parent_id) is refused by its own id, because deleting that id lets HEY draw the day again from the series; use the occurrence form. future is refused from a written-out day moved off its series time or on an opaque custom schedule.",
1605
+ "flags": [
1606
+ {
1607
+ "name": "apply-to",
1608
+ "value": true,
1609
+ "type": "string",
1610
+ "desc": "How much of the series an --occurrence delete reaches: current (that day alone) or future (that day and every one after it)",
1611
+ "default": ""
1612
+ },
1613
+ {
1614
+ "name": "occurrence",
1615
+ "value": true,
1616
+ "type": "string",
1617
+ "desc": "One day of a repeating event, by the occurrence_id 'hey event day' serves (<series id>_<YYYY-MM-DD>)",
1618
+ "default": ""
1619
+ }
1620
+ ],
1579
1621
  "synopsis": [
1580
1622
  "hey event delete <id> [flags]"
1581
1623
  ],
1582
1624
  "examples": [
1583
- "hey event delete 4821"
1625
+ "hey event delete 4821",
1626
+ "hey event delete 4821 --occurrence 4821_2026-09-15 --apply-to current",
1627
+ "hey event delete 4821 --occurrence 4821_2026-09-15 --apply-to future"
1584
1628
  ]
1585
1629
  },
1586
1630
  {
@@ -1609,7 +1653,7 @@
1609
1653
  "name": "calendar",
1610
1654
  "value": true,
1611
1655
  "type": "int",
1612
- "desc": "Calendar ID to file the event on",
1656
+ "desc": "Calendar ID to move the event to",
1613
1657
  "default": "0"
1614
1658
  },
1615
1659
  {
@@ -1726,7 +1770,7 @@
1726
1770
  "name": "time-zone",
1727
1771
  "value": true,
1728
1772
  "type": "string",
1729
- "desc": "IANA zone the times are written in (defaults to this machine's)",
1773
+ "desc": "IANA zone the times are written in, such as America/New_York (defaults to the event's own zone, else your HEY account's)",
1730
1774
  "default": ""
1731
1775
  },
1732
1776
  {
@@ -1831,7 +1875,7 @@
1831
1875
  "name": "forward",
1832
1876
  "path": "forward",
1833
1877
  "short": "Forward the latest message in a thread",
1834
- "notes": "Forwards the latest entry in a thread with HEY's quoted content. Accepts comma-separated recipients and an optional note via -m.",
1878
+ "notes": "Forwards the latest emailed message in a thread with HEY's quoted content — never an internal note or share notice posted after it. Accepts comma-separated recipients and an optional note via -m. The answer's thread_id and entry_id name what was forwarded; id and topic_id name the new message and the thread it started, as HEY's answer names them. While delayed is true, HEY leaves the message out of its thread, so hey thread read shows the thread without it (a thread it started reads as not found) until Undo Send releases it. A send HEY refuses — usually the account's sending limit — is kept as a draft and fails with not_delivered (exit 7), naming the draft in meta.draft_id; send it later with hey draft send <draft_id>, not by sending again, which only makes another draft.",
1835
1879
  "flags": [
1836
1880
  {
1837
1881
  "name": "bcc",
@@ -1882,7 +1926,7 @@
1882
1926
  "name": "habit",
1883
1927
  "path": "habit",
1884
1928
  "short": "Create and manage habits",
1885
- "notes": "Subcommands: list, create, edit, delete, complete, uncomplete. Use list --ids-only to pipe IDs to the rest. Days accept weekday names or 0 (Sunday) through 6 (Saturday).",
1929
+ "notes": "Subcommands: list, create, edit, delete, complete, uncomplete. Use list --ids-only to pipe IDs to the rest. Days accept weekday names or 0 (Sunday) through 6 (Saturday). Without --date, list, complete and uncomplete use today in the HEY account's time zone; complete and uncomplete refuse when the account has none, so pass --date.",
1886
1930
  "subcommands": [
1887
1931
  {
1888
1932
  "name": "complete",
@@ -2065,7 +2109,7 @@
2065
2109
  "name": "ignore",
2066
2110
  "path": "ignore",
2067
2111
  "short": "Ignore email threads",
2068
- "notes": "Accepts one or more box item IDs from hey box view output. Ignored threads remain in their box and can be restored with hey stop-ignoring.",
2112
+ "notes": "Accepts one or more box item IDs from hey box view output. Ignoring marks a thread seen and leaves it in its box; while it is ignored hey unseen has no effect. hey stop-ignoring resumes notifications but leaves it seen.",
2069
2113
  "synopsis": [
2070
2114
  "hey ignore <box-item-id>... [flags]"
2071
2115
  ],
@@ -2078,7 +2122,7 @@
2078
2122
  "name": "journal",
2079
2123
  "path": "journal",
2080
2124
  "short": "Read and write journal entries",
2081
- "notes": "Subcommands: list, read, write. Read defaults to today. Write accepts --content, stdin, or opens $EDITOR; content is Markdown, or raw HTML via --content-html.",
2125
+ "notes": "Subcommands: list, read, write. Read and write default to today in the HEY account's time zone; write refuses without one, so name the date. Read JSON answers content (HTML as HEY serves it), content_markdown (the form write takes) and content_markdown_lossless; when that is false, change content and write it with --content-html instead. Write replaces the whole entry and accepts --content, stdin, or opens $EDITOR (refused for an entry whose Markdown is not lossless); content is Markdown, or raw HTML via --content-html.",
2082
2126
  "subcommands": [
2083
2127
  {
2084
2128
  "name": "list",
@@ -2138,7 +2182,7 @@
2138
2182
  "examples": [
2139
2183
  "hey journal read",
2140
2184
  "hey journal read 2026-03-15",
2141
- "hey journal read --html"
2185
+ "hey journal read --html > entry.html"
2142
2186
  ]
2143
2187
  },
2144
2188
  {
@@ -2158,7 +2202,7 @@
2158
2202
  "name": "content-html",
2159
2203
  "value": true,
2160
2204
  "type": "string",
2161
- "desc": "Journal content as raw HTML instead of Markdown",
2205
+ "desc": "Journal content as raw HTML instead of Markdown; the trix-content wrapper HEY serves an entry in is taken off",
2162
2206
  "default": ""
2163
2207
  }
2164
2208
  ],
@@ -2426,7 +2470,7 @@
2426
2470
  "name": "reply",
2427
2471
  "path": "reply",
2428
2472
  "short": "Reply to a thread",
2429
- "notes": "Replies to the latest entry in a thread, addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Repeatable --to/--cc/--bcc flags merge explicit recipients into that prefill; --replace-recipients uses only the explicit lists. --dry-run is read-only, needs no message, and returns the resolved sender, recipients, subject, account, thread and entry. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft, carries the resolved recipients, and answers the draft ID for hey draft show/edit/send/delete.",
2473
+ "notes": "Replies to the latest emailed message in a thread — never a note (kind \"comment\") or share notice (kind \"access_notice\"), which are internal to the thread and never emailed; a thread with no emailed message is refused as not_found. The reply is addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Repeatable --to/--cc/--bcc flags merge explicit recipients into that prefill; --replace-recipients uses only the explicit lists. --dry-run is read-only, needs no message, and returns the resolved sender, recipients, subject, account, thread and entry. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft, carries the resolved recipients, and answers the draft ID for hey draft show/edit/send/delete. A send answers the reply's id and the topic_id of the thread it landed on — a new thread when HEY breaks the reply out of this one — as HEY's answer names them. While delayed is true, HEY leaves the message out of its thread, so hey thread read shows the thread without it (a thread it started reads as not found) until Undo Send releases it. A send HEY refuses — usually the account's sending limit — is kept as a draft and fails with not_delivered (exit 7), naming the draft in meta.draft_id; send it later with hey draft send <draft_id>, not by sending again, which only makes another draft.",
2430
2474
  "flags": [
2431
2475
  {
2432
2476
  "name": "attach",
@@ -2517,7 +2561,7 @@
2517
2561
  },
2518
2562
  {
2519
2563
  "name": "seen",
2520
- "desc": "Mark what they already sent as seen",
2564
+ "desc": "Mark what they already sent as seen (reliable only for a sender with one waiting thread)",
2521
2565
  "default": "false"
2522
2566
  }
2523
2567
  ],
@@ -2533,8 +2577,8 @@
2533
2577
  {
2534
2578
  "name": "clear",
2535
2579
  "path": "screener clear",
2536
- "short": "Empty the Screener without deciding",
2537
- "notes": "Clears the whole queue for the account, not one sender, and takes no confirmation. It decides nothing: senders reappear in The Screener on their next email, so what they already sent stays hidden until then. HEY queues the work, so `hey screener list` may still show the queue for a moment afterwards.",
2580
+ "short": "Trash everything waiting in the Screener",
2581
+ "notes": "Clears the whole queue for the account, not one sender, and takes no confirmation: everything waiting goes to Trash, or loses your access if it is a shared thread. It approves and denies no one, so senders reappear in The Screener on their next email. Confirm with the user first. HEY queues the work, so `hey screener list` may still show the queue for a moment afterwards.",
2538
2582
  "synopsis": [
2539
2583
  "hey screener clear [flags]"
2540
2584
  ],
@@ -2547,11 +2591,11 @@
2547
2591
  "name": "deny",
2548
2592
  "path": "screener deny",
2549
2593
  "short": "Turn a sender away",
2550
- "notes": "Clearance IDs come from `hey screener list`, not contact IDs. --spam also marks what they already sent as spam and trains HEY's filter on it, which is harder to undo than denying. Reverse with `hey screener approve <id>`.",
2594
+ "notes": "Clearance IDs come from `hey screener list`, not contact IDs. --spam also marks what they already sent as spam and, for a single ID, trains HEY's filter on it, which is harder to undo than denying; several IDs go through HEY's bulk endpoint, which does not train. Reverse with `hey screener approve <id>`.",
2551
2595
  "flags": [
2552
2596
  {
2553
2597
  "name": "spam",
2554
- "desc": "Also mark what they sent as spam and train the filter",
2598
+ "desc": "Also mark what they sent as spam (and train the filter, for a single ID)",
2555
2599
  "default": "false"
2556
2600
  }
2557
2601
  ],
@@ -2629,7 +2673,7 @@
2629
2673
  "name": "search",
2630
2674
  "path": "search",
2631
2675
  "short": "Search email threads and messages",
2632
- "notes": "Returns one result per thread. id is the box item ID for organization actions when the thread has an active box item, topic_id opens the thread, and messages contains the matching message summaries. Use `hey search filters` to discover box, date, label, and attachment values.",
2676
+ "notes": "Returns one result per thread. id is the box item ID for organization actions, omitted when you have no box item for the thread; topic_id opens the thread, and messages contains the matching message summaries. Use `hey search filters` to discover box, date, label, and attachment values.",
2633
2677
  "flags": [
2634
2678
  {
2635
2679
  "name": "all",
@@ -2675,7 +2719,7 @@
2675
2719
  "name": "in",
2676
2720
  "value": true,
2677
2721
  "type": "string",
2678
- "desc": "Box: imbox, feed, papertrail, or trash",
2722
+ "desc": "Box: imbox, feed, papertrail, or trash (or the kind or name of one of those three boxes)",
2679
2723
  "default": ""
2680
2724
  },
2681
2725
  {
@@ -3122,7 +3166,7 @@
3122
3166
  "name": "content-html",
3123
3167
  "value": true,
3124
3168
  "type": "string",
3125
- "desc": "Snippet content as raw HTML instead of Markdown",
3169
+ "desc": "Snippet content as raw HTML instead of Markdown; the trix-content wrapper HEY serves content_html in is taken off",
3126
3170
  "default": ""
3127
3171
  },
3128
3172
  {
@@ -3182,7 +3226,7 @@
3182
3226
  "name": "content-html",
3183
3227
  "value": true,
3184
3228
  "type": "string",
3185
- "desc": "New snippet content as raw HTML instead of Markdown",
3229
+ "desc": "New snippet content as raw HTML instead of Markdown; the trix-content wrapper HEY serves content_html in is taken off",
3186
3230
  "default": ""
3187
3231
  },
3188
3232
  {
@@ -3242,7 +3286,7 @@
3242
3286
  "name": "read",
3243
3287
  "path": "thread read",
3244
3288
  "short": "Read a thread",
3245
- "notes": "Returns a thread with all entries, oldest first. Entry bodies are Markdown; the creator is the account user, while a non-default send-as address is the optional sender on a hydrated entry. Each entry whose message was read carries recipients as to, cc and bcc contact lists. In JSON, inbound entries also carry received_via: the exact account delivery addresses HEY recorded, distinct from the visible recipients, with an optional resolved contact. --html writes an HTML document instead, one <article> per entry with a From/To/CC/BCC header and HEY's original body HTML. A thread that could only be read in part is refused unless --allow-partial is passed, in which case each entry's body_state says what was read. Use the topic ID with hey reply or hey forward.",
3289
+ "notes": "Returns a thread with all entries, oldest first. Entry bodies are Markdown; the creator is the entry's author (the external sender for inbound mail, your own contact for mail you sent), while a non-default send-as address you sent from is the optional sender on a hydrated entry. Each entry whose message was read carries recipients as to, cc and bcc contact lists. In JSON, inbound entries also carry received_via: the exact account delivery addresses HEY recorded, distinct from the visible recipients, with an optional resolved contact. --html writes an HTML document instead, one <article> per entry with a From/To/CC/BCC header and HEY's original body HTML. Each entry's kind says what it is: message for mail, while comment (a note) and access_notice (a share notice) are internal — visible to everyone with access to the thread, never emailed — and --markdown, styled and --html output head them as a note or share notice rather than with a From line. A thread that could only be read in part is refused unless --allow-partial is passed, in which case each entry's body_state says what was read. Use the topic ID with hey reply or hey forward.",
3246
3290
  "flags": [
3247
3291
  {
3248
3292
  "name": "allow-partial",
@@ -3498,7 +3542,7 @@
3498
3542
  "name": "todo",
3499
3543
  "path": "todo",
3500
3544
  "short": "Create and manage to-dos",
3501
- "notes": "Subcommands: list, add, complete, uncomplete, delete. Use list --ids-only to pipe IDs to complete/delete.",
3545
+ "notes": "Subcommands: list, add, complete, uncomplete, delete. Use list --ids-only to pipe IDs to complete/delete. add files on today in the HEY account's time zone without --date, and refuses when the account has none, so pass --date.",
3502
3546
  "subcommands": [
3503
3547
  {
3504
3548
  "name": "add",
@@ -3509,7 +3553,7 @@
3509
3553
  "name": "date",
3510
3554
  "value": true,
3511
3555
  "type": "string",
3512
- "desc": "Due date (YYYY-MM-DD)",
3556
+ "desc": "Due date (YYYY-MM-DD, defaults to today)",
3513
3557
  "default": ""
3514
3558
  },
3515
3559
  {
@@ -3725,7 +3769,7 @@
3725
3769
  "name": "watch",
3726
3770
  "path": "watch",
3727
3771
  "short": "Follow email threads and calendars as they change",
3728
- "notes": "Long-running. Writes one JSON object per changed thread to stdout (NDJSON), not the usual envelope. Use --exit-on-first to block until one change lands and then exit.",
3772
+ "notes": "Long-running. Piped or with --json, writes one JSON object per change to stdout (NDJSON), not the usual envelope, and does not apply --jq. Use --exit-on-first to block until one change lands and then exit; a ready line comes first. For new mail only, add --events new.",
3729
3773
  "flags": [
3730
3774
  {
3731
3775
  "name": "box",
package/src/policy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // What hey-axi will and won't run, beyond "is it a HEY command".
2
2
 
3
3
  // hey-axi's own flags. Stripped before anything is forwarded to HEY.
4
- export const AXI_FLAGS = ["--allow-send", "--allow-secret"];
4
+ export const AXI_FLAGS = ["--allow-send", "--allow-secret", "--allow-destructive"];
5
5
 
6
6
  // Commands that deliver email.
7
7
  // safe: HEY flags that already mean nothing is sent
@@ -20,6 +20,18 @@ export const DRAFT_NOTICE = "Saved as a DRAFT. Nothing was sent. Pass --allow-se
20
20
  // Commands that print a credential into the agent's context.
21
21
  const SECRET_COMMANDS = new Set(["auth token"]);
22
22
 
23
+ // Commands that destroy a whole set of things at once, with no confirmation from HEY and
24
+ // no single command that undoes them. Refused unless --allow-destructive (or
25
+ // HEY_AXI_ALLOW_DESTRUCTIVE=1).
26
+ // what: what the command would do, in the refusal
27
+ // instead: what to do first, in the refusal
28
+ const DESTRUCTIVE_COMMANDS = new Map([
29
+ ["screener clear", {
30
+ what: "moves everything waiting in the Screener to Trash (the whole queue for the account, every sender, not one; shared threads lose your access instead) without asking. It approves or denies no one, so those senders come back to the Screener on their next email",
31
+ instead: "check what is waiting with `hey-axi screener list`, and decide one sender at a time with `hey-axi screener deny <clearance-id>` (or `screener approve`); to really clear the whole queue, confirm with the user, then pass --allow-destructive (or set HEY_AXI_ALLOW_DESTRUCTIVE=1)",
32
+ }],
33
+ ]);
34
+
23
35
  // How a command runs. Anything not listed is "json": captured, parsed, rendered as TOON.
24
36
  // stream: long-running NDJSON, relayed event by event as it arrives (TOON, or NDJSON with --json)
25
37
  // raw: prints non-JSON (a script, a CSV); relayed untouched
@@ -157,14 +169,27 @@ const NAMED_CONTAINER = /\b(?:label|collection|group|workflow|box)\s+["'“]?([^
157
169
  export function noopFor(path, failure, positionals = [], values = {}) {
158
170
  const text = `${failure.error || ""} ${failure.hint || ""}`;
159
171
  const target = positionals.length ? ` ${positionals.join(" ")}` : "";
160
- if (DELETES.has(path) && failure.kind === "not_found" && (GENERIC_NOT_FOUND.test(failure.error || "") || DELETES.get(path).test(failure.error || ""))) {
172
+ // One day of a repeating event (--occurrence) is not "already gone" on a not-found:
173
+ // the day may simply not be in the series (a wrong date), so it stays an error.
174
+ const occurrence = path === "event delete" && values.occurrence;
175
+ if (DELETES.has(path) && !occurrence && failure.kind === "not_found" && (GENERIC_NOT_FOUND.test(failure.error || "") || DELETES.get(path).test(failure.error || ""))) {
161
176
  return { ok: true, noop: true, command: path, result: `nothing to delete:${target || " it"} is already gone (no-op)`, note: "if you expected it to exist, check the id with the matching list command" };
162
177
  }
163
178
  const endState = END_STATES[path];
164
- // Several targets: if HEY names some of them, it must name all of them.
179
+ if (!endState) return null;
180
+ // The ids asked for must be the ones HEY's message is about. Every number the message
181
+ // names has to be one of them (or the --to/--from container), and when it names any,
182
+ // it has to name them all: "1 already seen" is not about `seen 2`, and "5 already
183
+ // seen" is not about `seen 5 6`. A bare count is only accepted for the whole batch
184
+ // ("all 2 already seen"). A message naming no number at all ("Already seen") stands.
165
185
  const ids = positionals.filter((word) => /^\d+$/.test(word));
166
- if (ids.length > 1) {
167
- const named = ids.filter((id) => new RegExp(`\\b${id}\\b`).test(text));
186
+ if (ids.length) {
187
+ const allowed = new Set([...ids, ...["to", "from"].map((key) => values[key]).filter((value) => value !== undefined).map(String)]);
188
+ const numbers = [...text.matchAll(/(?<![\w.-])\d+(?![\w.-])/g)].map((match) => match[0]);
189
+ const strangers = numbers.filter((number) => !allowed.has(number));
190
+ const wholeBatch = strangers.length === 1 && Number(strangers[0]) === ids.length && new RegExp(`\\ball ${strangers[0]}\\b`, "i").test(text);
191
+ if (strangers.length && !wholeBatch) return null;
192
+ const named = ids.filter((id) => numbers.includes(id));
168
193
  if (named.length && named.length < ids.length) return null;
169
194
  }
170
195
  if (CONTAINER[path]) {
@@ -177,7 +202,7 @@ export function noopFor(path, failure, positionals = [], values = {}) {
177
202
  if (DESTINATION[path] && (!destination || !text.toLowerCase().includes(String(destination).toLowerCase()))) return null;
178
203
  // A failure that also reports a failed part ("1 already seen; 2 failed") is not a no-op.
179
204
  if (/\b(failed|cannot|can'?t|could ?n[o']t|unable|error)\b/i.test(failure.error || "")) return null;
180
- if (endState && failure.kind !== "auth" && failure.kind !== "forbidden" && failure.kind !== "not_found" && endState.test(text)) {
205
+ if (failure.kind !== "auth" && failure.kind !== "forbidden" && failure.kind !== "not_found" && endState.test(text)) {
181
206
  return { ok: true, noop: true, command: path, result: `already done${target ? ` for${target}` : ""} (no-op)`, detail: failure.error };
182
207
  }
183
208
  return null;
@@ -193,6 +218,14 @@ export function sendCommands() {
193
218
  return [...SEND_COMMANDS.keys()];
194
219
  }
195
220
 
221
+ export function destructiveCommands() {
222
+ return [...DESTRUCTIVE_COMMANDS.keys()];
223
+ }
224
+
225
+ function destructiveAllowed(flags, env) {
226
+ return flags.has("--allow-destructive") || truthy(env.HEY_AXI_ALLOW_DESTRUCTIVE);
227
+ }
228
+
196
229
  function sendAllowed(flags, env) {
197
230
  return flags.has("--allow-send") || truthy(env.HEY_AXI_ALLOW_SEND);
198
231
  }
@@ -230,5 +263,15 @@ export function checkPolicy(path, flags, env = process.env, io = { stdin: proces
230
263
  hint: "pass --allow-secret (or set HEY_AXI_ALLOW_SECRETS=1) if you really need it",
231
264
  };
232
265
  }
266
+
267
+ if (DESTRUCTIVE_COMMANDS.has(path) && !destructiveAllowed(flags, env)) {
268
+ const rule = DESTRUCTIVE_COMMANDS.get(path);
269
+ return {
270
+ error: "destructive command blocked",
271
+ command: path,
272
+ reason: `this command ${rule.what}. hey-axi did not run it, so nothing was changed`,
273
+ hint: rule.instead,
274
+ };
275
+ }
233
276
  return null;
234
277
  }