@goodnesshq/opencode-notification 0.1.5 → 0.1.7

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.
@@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir, copyFile, stat } from "node:fs/promises";
3
3
  import { join, basename, dirname } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createInterface } from "node:readline/promises";
6
+ import * as readline from "node:readline";
6
7
  import { fileURLToPath } from "node:url";
7
8
 
8
9
  const REPO_CONFIG_PATH = ".opencode/oc-notify.json";
@@ -19,7 +20,7 @@ const SCHEMA_SOURCE_PATH = join(SCRIPT_DIR, "oc-notify.schema.json");
19
20
  const DEFAULTS = {
20
21
  enabled: true,
21
22
  title: "",
22
- tier: "standard",
23
+ tier: "focus",
23
24
  detailLevel: "full",
24
25
  responseComplete: {
25
26
  enabled: true,
@@ -127,6 +128,246 @@ async function copySchema(cwd) {
127
128
  } catch {}
128
129
  }
129
130
 
131
+ function isInteractive() {
132
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
133
+ if (process.env.CI) return false;
134
+ return true;
135
+ }
136
+
137
+ async function promptInput(message) {
138
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
139
+ const answer = await rl.question(message);
140
+ await rl.close();
141
+ return answer;
142
+ }
143
+
144
+ function renderPrompt(lines, state) {
145
+ if (state.linesRendered > 0) {
146
+ readline.moveCursor(process.stdout, 0, -(state.linesRendered - 1));
147
+ readline.cursorTo(process.stdout, 0);
148
+ readline.clearScreenDown(process.stdout);
149
+ }
150
+ process.stdout.write(lines.join("\n"));
151
+ state.linesRendered = lines.length;
152
+ }
153
+
154
+ async function withRawInput(handler) {
155
+ const stdin = process.stdin;
156
+ readline.emitKeypressEvents(stdin);
157
+ const wasRaw = !!stdin.isRaw;
158
+ const wasPaused = stdin.isPaused();
159
+ if (stdin.isTTY) stdin.setRawMode(true);
160
+ if (wasPaused) stdin.resume();
161
+ try {
162
+ return await handler();
163
+ } finally {
164
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
165
+ if (wasPaused) stdin.pause();
166
+ }
167
+ }
168
+
169
+ async function promptSelect({ message, choices, defaultValue }) {
170
+ return withRawInput(
171
+ () =>
172
+ new Promise((resolve) => {
173
+ const state = { linesRendered: 0 };
174
+ let cursor = choices.findIndex((choice) => choice.value === defaultValue);
175
+ if (cursor < 0) cursor = 0;
176
+
177
+ const render = () => {
178
+ const lines = [];
179
+ lines.push(message);
180
+ for (let index = 0; index < choices.length; index += 1) {
181
+ const choice = choices[index];
182
+ const pointer = index === cursor ? ">" : " ";
183
+ lines.push(` ${pointer} ${choice.name}`);
184
+ }
185
+ renderPrompt(lines, state);
186
+ };
187
+
188
+ const finish = () => {
189
+ const choice = choices[cursor];
190
+ const label = choice?.name ?? choice?.value ?? "";
191
+ renderPrompt([`${message} ${label}`], state);
192
+ process.stdout.write("\n");
193
+ cleanup();
194
+ resolve(choice?.value);
195
+ };
196
+
197
+ const onKey = (_char, key) => {
198
+ if (!key) return;
199
+ if (key.ctrl && key.name === "c") {
200
+ cleanup();
201
+ process.stdout.write("\n");
202
+ process.exit(1);
203
+ }
204
+ if (key.name === "up") {
205
+ cursor = Math.max(0, cursor - 1);
206
+ render();
207
+ return;
208
+ }
209
+ if (key.name === "down") {
210
+ cursor = Math.min(choices.length - 1, cursor + 1);
211
+ render();
212
+ return;
213
+ }
214
+ if (key.name === "return" || key.name === "enter") {
215
+ finish();
216
+ }
217
+ };
218
+
219
+ const cleanup = () => {
220
+ process.stdin.removeListener("keypress", onKey);
221
+ };
222
+
223
+ process.stdin.on("keypress", onKey);
224
+ render();
225
+ }),
226
+ );
227
+ }
228
+
229
+ async function promptConfirm(message, defaultValue) {
230
+ return promptSelect({
231
+ message,
232
+ choices: [
233
+ { name: "Yes", value: true },
234
+ { name: "No", value: false },
235
+ ],
236
+ defaultValue: defaultValue ? true : false,
237
+ });
238
+ }
239
+
240
+ async function promptMultiSelect({
241
+ message,
242
+ choices,
243
+ defaultValues = [],
244
+ pageSize = 12,
245
+ }) {
246
+ return withRawInput(
247
+ () =>
248
+ new Promise((resolve) => {
249
+ const state = { linesRendered: 0 };
250
+ let filter = "";
251
+ let cursor = 0;
252
+ const selected = new Set(defaultValues);
253
+
254
+ const getFiltered = () => {
255
+ if (!filter.trim()) return choices;
256
+ const term = filter.toLowerCase();
257
+ return choices.filter(
258
+ (choice) =>
259
+ choice.name.toLowerCase().includes(term) ||
260
+ choice.value.toLowerCase().includes(term),
261
+ );
262
+ };
263
+
264
+ const render = () => {
265
+ const filtered = getFiltered();
266
+ if (cursor > Math.max(0, filtered.length - 1)) cursor = 0;
267
+
268
+ const lines = [];
269
+ lines.push(message);
270
+ lines.push(
271
+ `Filter: ${filter || "(type to filter)"} (${selected.size} selected)`,
272
+ );
273
+
274
+ if (filtered.length === 0) {
275
+ lines.push(" No matches");
276
+ renderPrompt(lines, state);
277
+ return;
278
+ }
279
+
280
+ const startIndex = Math.max(
281
+ 0,
282
+ Math.min(
283
+ cursor - Math.floor(pageSize / 2),
284
+ Math.max(0, filtered.length - pageSize),
285
+ ),
286
+ );
287
+ const endIndex = Math.min(startIndex + pageSize, filtered.length);
288
+ const visible = filtered.slice(startIndex, endIndex);
289
+
290
+ for (let index = 0; index < visible.length; index += 1) {
291
+ const actualIndex = startIndex + index;
292
+ const choice = visible[index];
293
+ const pointer = actualIndex === cursor ? ">" : " ";
294
+ const checked = selected.has(choice.value) ? "[x]" : "[ ]";
295
+ lines.push(` ${pointer} ${checked} ${choice.name}`);
296
+ }
297
+
298
+ renderPrompt(lines, state);
299
+ };
300
+
301
+ const finish = () => {
302
+ const ordered = choices
303
+ .filter((choice) => selected.has(choice.value))
304
+ .map((choice) => choice.value);
305
+ const summary = ordered.length > 0 ? ordered.join(", ") : "(none)";
306
+ renderPrompt([`${message} ${summary}`], state);
307
+ process.stdout.write("\n");
308
+ cleanup();
309
+ resolve(ordered);
310
+ };
311
+
312
+ const onKey = (char, key) => {
313
+ if (!key) return;
314
+ if (key.ctrl && key.name === "c") {
315
+ cleanup();
316
+ process.stdout.write("\n");
317
+ process.exit(1);
318
+ }
319
+ if (key.name === "up") {
320
+ cursor = Math.max(0, cursor - 1);
321
+ render();
322
+ return;
323
+ }
324
+ if (key.name === "down") {
325
+ cursor = Math.min(getFiltered().length - 1, cursor + 1);
326
+ render();
327
+ return;
328
+ }
329
+ if (key.name === "space") {
330
+ const filtered = getFiltered();
331
+ const choice = filtered[cursor];
332
+ if (choice) {
333
+ if (selected.has(choice.value)) {
334
+ selected.delete(choice.value);
335
+ } else {
336
+ selected.add(choice.value);
337
+ }
338
+ render();
339
+ }
340
+ return;
341
+ }
342
+ if (key.name === "backspace") {
343
+ if (filter.length > 0) {
344
+ filter = filter.slice(0, -1);
345
+ cursor = 0;
346
+ render();
347
+ }
348
+ return;
349
+ }
350
+ if (key.name === "return" || key.name === "enter") {
351
+ finish();
352
+ return;
353
+ }
354
+ if (char && char.length === 1 && !key.ctrl && !key.meta) {
355
+ filter += char;
356
+ cursor = 0;
357
+ render();
358
+ }
359
+ };
360
+
361
+ const cleanup = () => {
362
+ process.stdin.removeListener("keypress", onKey);
363
+ };
364
+
365
+ process.stdin.on("keypress", onKey);
366
+ render();
367
+ }),
368
+ );
369
+ }
370
+
130
371
 
131
372
  async function sendTestNotification(config, repoName) {
132
373
  const title = config.title || `${repoName}@test`;
@@ -202,121 +443,184 @@ async function main() {
202
443
  console.log("Installed .opencode/notify-init.mjs and schema in this repo.");
203
444
  return;
204
445
  }
205
- const rl = createInterface({ input: process.stdin, output: process.stdout });
446
+ const interactive = isInteractive();
447
+ const rl = interactive ? null : createInterface({ input: process.stdin, output: process.stdout });
206
448
 
207
449
  console.log("OpenCode Notify — per-repo setup\n");
208
450
 
209
- const enableInput = await rl.question(
210
- "Enable notifications for this repo? (Y/n) [Y] ",
211
- );
212
- const enabled = yesNo(enableInput, true);
451
+ const enabled = interactive
452
+ ? await promptConfirm("Enable notifications for this repo?", true)
453
+ : yesNo(await rl.question("Enable notifications for this repo? (Y/n) [Y] "), true);
213
454
  if (!enabled) {
214
- await rl.close();
455
+ if (rl) await rl.close();
215
456
  return;
216
457
  }
217
458
 
218
- const titleInput = await rl.question(
219
- `Notification title (blank = "${repoName}@<branch>"): `,
220
- );
459
+ const titleInput = interactive
460
+ ? await promptInput(`Notification title (blank = "${repoName}@<branch>"): `)
461
+ : await rl.question(`Notification title (blank = "${repoName}@<branch>"): `);
221
462
  const title = titleInput.trim();
222
463
 
223
- console.log(
224
- "\nSelect a tier:\n 1) Minimal\n 2) Standard\n 3) Verbose\n 4) Custom",
225
- );
226
- const tierInput = await rl.question("> [2] ");
227
- const tier =
228
- tierInput.trim() === "1"
229
- ? "minimal"
230
- : tierInput.trim() === "3"
231
- ? "verbose"
232
- : tierInput.trim() === "4"
233
- ? "custom"
234
- : "standard";
235
-
236
- console.log(
237
- "\nResponse-complete trigger:\n 1) session.idle (coarse, low noise)\n 2) message.updated (precise)\n 3) message.part.updated (very precise, more noise)",
238
- );
239
- const triggerInput = await rl.question("> [2] ");
240
- const trigger =
241
- triggerInput.trim() === "1"
242
- ? "session.idle"
243
- : triggerInput.trim() === "3"
244
- ? "message.part.updated"
245
- : "message.updated";
246
-
247
- const macInput = await rl.question(
248
- "\nEnable macOS Notification Center? (Y/n) [Y] ",
249
- );
250
- const macEnabled = yesNo(macInput, true);
464
+ const tier = interactive
465
+ ? await promptSelect({
466
+ message: "Select a tier:",
467
+ choices: [
468
+ { name: "Focus (requests + completion)", value: "focus" },
469
+ { name: "Full (standard + failures + summary)", value: "full" },
470
+ { name: "Custom", value: "custom" },
471
+ ],
472
+ defaultValue: "focus",
473
+ })
474
+ : await (async () => {
475
+ console.log("\nSelect a tier:\n 1) Focus (requests + completion)\n 2) Full (standard + failures + summary)\n 3) Custom");
476
+ const tierInput = await rl.question("> [1] ");
477
+ return tierInput.trim() === "2"
478
+ ? "full"
479
+ : tierInput.trim() === "3"
480
+ ? "custom"
481
+ : "focus";
482
+ })();
483
+
484
+ const trigger = interactive
485
+ ? await promptSelect({
486
+ message: "Response-complete trigger:",
487
+ choices: [
488
+ { name: "session.idle (coarse, low noise)", value: "session.idle" },
489
+ { name: "message.updated (precise)", value: "message.updated" },
490
+ {
491
+ name: "message.part.updated (very precise, more noise)",
492
+ value: "message.part.updated",
493
+ },
494
+ ],
495
+ defaultValue: "message.updated",
496
+ })
497
+ : await (async () => {
498
+ console.log(
499
+ "\nResponse-complete trigger:\n 1) session.idle (coarse, low noise)\n 2) message.updated (precise)\n 3) message.part.updated (very precise, more noise)",
500
+ );
501
+ const triggerInput = await rl.question("> [2] ");
502
+ return triggerInput.trim() === "1"
503
+ ? "session.idle"
504
+ : triggerInput.trim() === "3"
505
+ ? "message.part.updated"
506
+ : "message.updated";
507
+ })();
508
+
509
+ const macEnabled = interactive
510
+ ? await promptConfirm("Enable macOS Notification Center?", true)
511
+ : yesNo(
512
+ await rl.question(
513
+ "\nEnable macOS Notification Center? (Y/n) [Y] ",
514
+ ),
515
+ true,
516
+ );
251
517
 
252
- const ntfyInput = await rl.question("Enable ntfy notifications? (Y/n) [Y] ");
253
- const ntfyEnabled = yesNo(ntfyInput, true);
518
+ const ntfyEnabled = interactive
519
+ ? await promptConfirm("Enable ntfy notifications?", true)
520
+ : yesNo(await rl.question("Enable ntfy notifications? (Y/n) [Y] "), true);
254
521
 
255
522
  let globalConfig = await readJson(GLOBAL_CONFIG_PATH);
256
523
  if (!globalConfig) globalConfig = {};
257
524
  if (!globalConfig.ntfy) globalConfig.ntfy = {};
258
525
 
259
526
  if (ntfyEnabled) {
260
- const serverInput = await rl.question(
261
- `ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
262
- );
527
+ const serverInput = interactive
528
+ ? await promptInput(
529
+ `ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
530
+ )
531
+ : await rl.question(
532
+ `ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
533
+ );
263
534
  const topicDefault = globalConfig.ntfy.topic || DEFAULTS.channels.ntfy.topic;
264
535
  const topicLabel = topicDefault || "<your-topic>";
265
- const topicInput = await rl.question(
266
- `ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
267
- );
536
+ const topicInput = interactive
537
+ ? await promptInput(
538
+ `ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
539
+ )
540
+ : await rl.question(
541
+ `ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
542
+ );
268
543
  if (serverInput.trim()) globalConfig.ntfy.server = serverInput.trim();
269
544
  if (topicInput.trim()) globalConfig.ntfy.topic = topicInput.trim();
270
545
  }
271
546
 
272
- const detailInput = await rl.question(
273
- "Detail level (1=full, 2=title-only) [1] ",
274
- );
275
- const detailLevel = detailInput.trim() === "2" ? "title-only" : "full";
276
-
277
- const adjustGroups = yesNo(
278
- await rl.question("\nAdjust event groups? (y/N) [N] "),
279
- false,
280
- );
547
+ const detailLevel = interactive
548
+ ? await promptSelect({
549
+ message: "Detail level:",
550
+ choices: [
551
+ { name: "full", value: "full" },
552
+ { name: "title-only", value: "title-only" },
553
+ ],
554
+ defaultValue: "full",
555
+ })
556
+ : (await rl.question("Detail level (1=full, 2=title-only) [1] ")).trim() === "2"
557
+ ? "title-only"
558
+ : "full";
559
+
560
+ const adjustGroups = interactive
561
+ ? await promptConfirm("Adjust event groups?", false)
562
+ : yesNo(await rl.question("\nAdjust event groups? (y/N) [N] "), false);
281
563
  let includeGroups = [];
282
564
  let excludeGroups = [];
283
565
  if (adjustGroups) {
284
- const advanced = yesNo(
285
- await rl.question("Show advanced groups? (y/N) [N] "),
286
- false,
287
- );
566
+ const advanced = interactive
567
+ ? await promptConfirm("Show advanced groups?", false)
568
+ : yesNo(await rl.question("Show advanced groups? (y/N) [N] "), false);
288
569
  const allowed = advanced
289
570
  ? GROUPS_BASIC.concat(GROUPS_ADVANCED)
290
571
  : GROUPS_BASIC;
291
- console.log(`Available groups: ${allowed.join(", ")}`);
292
- const includeInput = await rl.question(
293
- "Include groups (comma-separated, blank to skip): ",
294
- );
295
- const excludeInput = await rl.question(
296
- "Exclude groups (comma-separated, blank to skip): ",
297
- );
298
- includeGroups = parseList(includeInput, allowed);
299
- excludeGroups = parseList(excludeInput, allowed);
572
+ if (interactive) {
573
+ const choices = allowed.map((group) => ({ name: group, value: group }));
574
+ includeGroups = await promptMultiSelect({
575
+ message: "Include groups:",
576
+ choices,
577
+ defaultValues: [],
578
+ });
579
+ excludeGroups = await promptMultiSelect({
580
+ message: "Exclude groups:",
581
+ choices,
582
+ defaultValues: [],
583
+ });
584
+ } else {
585
+ console.log(`Available groups: ${allowed.join(", ")}`);
586
+ const includeInput = await rl.question(
587
+ "Include groups (comma-separated, blank to skip): ",
588
+ );
589
+ const excludeInput = await rl.question(
590
+ "Exclude groups (comma-separated, blank to skip): ",
591
+ );
592
+ includeGroups = parseList(includeInput, allowed);
593
+ excludeGroups = parseList(excludeInput, allowed);
594
+ }
300
595
  }
301
596
 
302
- const installTN = yesNo(
303
- await rl.question("\nUse terminal-notifier if available? (Y/n) [Y] "),
304
- true,
305
- );
597
+ const installTN = interactive
598
+ ? await promptConfirm("Use terminal-notifier if available?", true)
599
+ : yesNo(
600
+ await rl.question(
601
+ "\nUse terminal-notifier if available? (Y/n) [Y] ",
602
+ ),
603
+ true,
604
+ );
306
605
 
307
606
  if (installTN) {
308
607
  const hasTN = await commandExists("terminal-notifier");
309
608
  const hasBrew = await commandExists("brew");
310
609
  if (!hasTN && hasBrew) {
311
- const installNow = yesNo(
312
- await rl.question(
313
- "terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
314
- ),
315
- false,
316
- );
317
- if (installNow) {
318
- try {
319
- const { execSync } = await import("node:child_process");
610
+ const installNow = interactive
611
+ ? await promptConfirm(
612
+ "terminal-notifier not found. Install with Homebrew now?",
613
+ false,
614
+ )
615
+ : yesNo(
616
+ await rl.question(
617
+ "terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
618
+ ),
619
+ false,
620
+ );
621
+ if (installNow) {
622
+ try {
623
+ const { execSync } = await import("node:child_process");
320
624
  execSync("brew install terminal-notifier", { stdio: "inherit" });
321
625
  } catch {
322
626
  console.log("Homebrew install failed. Falling back to AppleScript.");
@@ -352,25 +656,28 @@ async function main() {
352
656
  },
353
657
  };
354
658
 
355
- const writeInput = await rl.question(
356
- "\nWrite config to .opencode/oc-notify.json? (Y/n) [Y] ",
357
- );
358
- const shouldWrite = yesNo(writeInput, true);
659
+ const shouldWrite = interactive
660
+ ? await promptConfirm("Write config to .opencode/oc-notify.json?", true)
661
+ : yesNo(
662
+ await rl.question(
663
+ "\nWrite config to .opencode/oc-notify.json? (Y/n) [Y] ",
664
+ ),
665
+ true,
666
+ );
359
667
  if (shouldWrite) {
360
668
  await ensureOpencodeDir(cwd);
361
669
  await writeJson(join(cwd, REPO_CONFIG_PATH), config);
362
670
  await copySchema(cwd);
363
671
  }
364
672
 
365
- const testInput = await rl.question(
366
- "Send a test notification now? (Y/n) [Y] ",
367
- );
368
- const sendTest = yesNo(testInput, true);
673
+ const sendTest = interactive
674
+ ? await promptConfirm("Send a test notification now?", true)
675
+ : yesNo(await rl.question("Send a test notification now? (Y/n) [Y] "), true);
369
676
  if (sendTest) {
370
677
  await sendTestNotification(config, repoName);
371
678
  }
372
679
 
373
- await rl.close();
680
+ if (rl) await rl.close();
374
681
  }
375
682
 
376
683
  await main();
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "tier": {
18
18
  "type": "string",
19
- "enum": ["minimal", "standard", "verbose", "custom"]
19
+ "enum": ["focus", "full", "custom"]
20
20
  },
21
21
  "detailLevel": {
22
22
  "type": "string",
package/README.md CHANGED
@@ -24,19 +24,7 @@ npx @goodnesshq/opencode-notification install
24
24
  This only installs repo assets and prints next steps for manual setup.
25
25
 
26
26
  Before running the installer, create a unique ntfy topic and set up mobile notifications. See `docs/ntfy-topic.md`.
27
-
28
- ### Update guidance
29
-
30
- - One-off: `npx @goodnesshq/opencode-notification@latest setup`
31
- - Global: `npm update -g @goodnesshq/opencode-notification`
32
-
33
- ## Troubleshooting
34
-
35
- **OpenCode fails to start after enabling the plugin**
36
-
37
- - Ensure every entry in `plugin` points to an existing file.
38
- - Remove stale plugin paths from other repos.
39
- - Use absolute paths (no `~`).
27
+ The installer uses an interactive, arrow-key prompt flow (with a non-interactive fallback in non-TTY environments).
40
28
 
41
29
  ## Config Schema
42
30
 
@@ -44,7 +32,7 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
44
32
 
45
33
  - `enabled`: master switch
46
34
  - `title`: override or template (supports `{repo}` and `{branch}`)
47
- - `tier`: `minimal` | `standard` | `verbose` | `custom`
35
+ - `tier`: `focus` | `full` | `custom`
48
36
  - `detailLevel`: `full` | `title-only`
49
37
  - `responseComplete`: trigger for completion notifications
50
38
  - `channels`: `mac` and `ntfy` settings
@@ -53,17 +41,16 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
53
41
 
54
42
  ## Tier Defaults
55
43
 
56
- - **minimal**: `action_required`, `failures`
57
- - **standard**: minimal + `change_summary`, `session_lifecycle`
58
- - **verbose**: standard + `files`, `todos`, `vcs_worktree`, `pty`, `responses`
44
+ - **focus**: `action_required`
45
+ - **full**: `action_required`, `failures`, `change_summary`, `session_lifecycle`
59
46
 
60
47
  ## Example Configs
61
48
 
62
- ### Minimal
49
+ ### Focus
63
50
  ```json
64
51
  {
65
52
  "enabled": true,
66
- "tier": "minimal",
53
+ "tier": "focus",
67
54
  "responseComplete": {
68
55
  "enabled": true,
69
56
  "trigger": "session.idle"
@@ -75,11 +62,11 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
75
62
  }
76
63
  ```
77
64
 
78
- ### Standard
65
+ ### Full
79
66
  ```json
80
67
  {
81
68
  "enabled": true,
82
- "tier": "standard",
69
+ "tier": "full",
83
70
  "detailLevel": "full",
84
71
  "responseComplete": {
85
72
  "enabled": true,
@@ -88,23 +75,34 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
88
75
  }
89
76
  ```
90
77
 
91
- ### Verbose
78
+ ### Custom (formerly verbose-style)
92
79
  ```json
93
80
  {
94
81
  "enabled": true,
95
- "tier": "verbose",
82
+ "tier": "custom",
96
83
  "responseComplete": {
97
84
  "enabled": true,
98
85
  "trigger": "message.part.updated"
99
86
  },
100
87
  "overrides": {
101
- "includeGroups": ["commands_tools"],
88
+ "includeGroups": [
89
+ "action_required",
90
+ "failures",
91
+ "change_summary",
92
+ "session_lifecycle",
93
+ "files",
94
+ "todos",
95
+ "vcs_worktree",
96
+ "pty",
97
+ "responses",
98
+ "commands_tools"
99
+ ],
102
100
  "excludeGroups": []
103
101
  }
104
102
  }
105
103
  ```
106
104
 
107
- ### Custom
105
+ ### Custom (curated)
108
106
  ```json
109
107
  {
110
108
  "enabled": true,
@@ -114,4 +112,12 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
114
112
  "excludeGroups": ["session_lifecycle"]
115
113
  }
116
114
  }
115
+
116
+ ## Migration from legacy tiers
117
+
118
+ Legacy tiers `minimal`, `standard`, and `verbose` are no longer supported. Update existing configs before notifications resume:
119
+
120
+ - `minimal` -> `custom` with includeGroups: `action_required`, `failures`
121
+ - `standard` -> `full`
122
+ - `verbose` -> `custom` with includeGroups: `action_required`, `failures`, `change_summary`, `session_lifecycle`, `files`, `todos`, `vcs_worktree`, `pty`, `responses`
117
123
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodnesshq/opencode-notification",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Per-repo notification plugin for OpenCode with macOS and ntfy delivery.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@ const DEFAULTS = {
10
10
  version: 1,
11
11
  enabled: true,
12
12
  title: "",
13
- tier: "standard",
13
+ tier: "focus",
14
14
  detailLevel: "full",
15
15
  responseComplete: {
16
16
  enabled: true,
@@ -71,19 +71,8 @@ const GROUPS = {
71
71
  };
72
72
 
73
73
  const TIER_GROUPS = {
74
- minimal: ["action_required", "failures"],
75
- standard: ["action_required", "failures", "change_summary", "session_lifecycle"],
76
- verbose: [
77
- "action_required",
78
- "failures",
79
- "change_summary",
80
- "session_lifecycle",
81
- "files",
82
- "todos",
83
- "vcs_worktree",
84
- "pty",
85
- "responses",
86
- ],
74
+ focus: ["action_required"],
75
+ full: ["action_required", "failures", "change_summary", "session_lifecycle"],
87
76
  custom: [],
88
77
  };
89
78
 
@@ -118,8 +107,11 @@ function normalizeArray(value) {
118
107
  }
119
108
 
120
109
  function normalizeTier(value) {
121
- if (typeof value !== "string") return DEFAULTS.tier;
122
- return TIER_GROUPS[value] ? value : DEFAULTS.tier;
110
+ if (value === undefined || value === null) {
111
+ return { value: DEFAULTS.tier, valid: true };
112
+ }
113
+ if (typeof value !== "string") return { value, valid: false };
114
+ return TIER_GROUPS[value] ? { value, valid: true } : { value, valid: false };
123
115
  }
124
116
 
125
117
  function normalizeTrigger(value) {
@@ -147,11 +139,13 @@ function normalizeConfig(raw, globalConfig) {
147
139
  globalConfig && typeof globalConfig === "object" && !Array.isArray(globalConfig)
148
140
  ? globalConfig
149
141
  : {};
142
+ const tierInfo = normalizeTier(source.tier);
150
143
  const config = {
151
144
  version: DEFAULTS.version,
152
145
  enabled: normalizeBool(source.enabled, DEFAULTS.enabled),
153
146
  title: normalizeString(source.title, DEFAULTS.title),
154
- tier: normalizeTier(source.tier),
147
+ tier: tierInfo.value,
148
+ tierValid: tierInfo.valid,
155
149
  detailLevel: source.detailLevel === "title-only" ? "title-only" : "full",
156
150
  responseComplete: {
157
151
  enabled: normalizeBool(
@@ -196,7 +190,7 @@ function normalizeConfig(raw, globalConfig) {
196
190
  }
197
191
 
198
192
  function buildGroupSet(config) {
199
- const baseGroups = TIER_GROUPS[config.tier] ?? TIER_GROUPS.standard;
193
+ const baseGroups = TIER_GROUPS[config.tier] ?? [];
200
194
  const groupSet = new Set(baseGroups);
201
195
  for (const group of config.overrides.includeGroups) {
202
196
  if (GROUPS[group]) groupSet.add(group);
@@ -655,6 +649,7 @@ export default async function opencodeNotify(input) {
655
649
  console.warn("[opencode-notifications] invalid config detected", repoConfig.errors);
656
650
  }
657
651
  if (!config.enabled) return;
652
+ if (!config.tierValid) return;
658
653
 
659
654
  if (payload?.type === "vcs.branch.updated" && payload?.properties?.branch) {
660
655
  cachedBranch = payload.properties.branch;