@goodnesshq/opencode-notification 0.1.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/.opencode/notify-init.mjs +374 -0
- package/.opencode/oc-notify.schema.json +101 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/bin/ocn.mjs +81 -0
- package/package.json +24 -0
- package/plugins/opencode-notifications.mjs +690 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
const REPO_CONFIG_PATH = ".opencode/oc-notify.json";
|
|
6
|
+
const REPO_SCHEMA_PATH = ".opencode/oc-notify.schema.json";
|
|
7
|
+
const GLOBAL_CONFIG_PATH = join(homedir(), ".config", "opencode", "oc-notify.json");
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
version: 1,
|
|
11
|
+
enabled: true,
|
|
12
|
+
title: "",
|
|
13
|
+
tier: "standard",
|
|
14
|
+
detailLevel: "full",
|
|
15
|
+
responseComplete: {
|
|
16
|
+
enabled: true,
|
|
17
|
+
trigger: "message.updated",
|
|
18
|
+
},
|
|
19
|
+
channels: {
|
|
20
|
+
mac: {
|
|
21
|
+
enabled: true,
|
|
22
|
+
method: "auto",
|
|
23
|
+
},
|
|
24
|
+
ntfy: {
|
|
25
|
+
enabled: true,
|
|
26
|
+
server: "https://ntfy.sh",
|
|
27
|
+
topic: "",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
overrides: {
|
|
31
|
+
includeGroups: [],
|
|
32
|
+
excludeGroups: [],
|
|
33
|
+
},
|
|
34
|
+
dedupe: {
|
|
35
|
+
ttlSeconds: 60,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const RESPONSE_TRIGGERS = new Set([
|
|
40
|
+
"session.idle",
|
|
41
|
+
"message.updated",
|
|
42
|
+
"message.part.updated",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const MAC_METHODS = new Set(["auto", "applescript", "terminal-notifier"]);
|
|
46
|
+
|
|
47
|
+
const GROUPS = {
|
|
48
|
+
action_required: ["permission.asked", "question.asked"],
|
|
49
|
+
responses: ["permission.replied", "question.replied", "question.rejected"],
|
|
50
|
+
failures: ["session.error", "worktree.failed", "mcp.browser.open.failed", "pty.exited"],
|
|
51
|
+
change_summary: ["session.diff"],
|
|
52
|
+
session_lifecycle: ["session.created", "session.compacted"],
|
|
53
|
+
session_noise: ["session.updated", "session.status"],
|
|
54
|
+
message_lifecycle: ["message.updated", "message.removed"],
|
|
55
|
+
message_parts: ["message.part.updated", "message.part.delta", "message.part.removed"],
|
|
56
|
+
files: ["file.edited", "file.watcher.updated"],
|
|
57
|
+
todos: ["todo.updated"],
|
|
58
|
+
vcs_worktree: ["vcs.branch.updated", "worktree.ready"],
|
|
59
|
+
pty: ["pty.created", "pty.updated", "pty.deleted"],
|
|
60
|
+
commands_tools: ["command.executed", "mcp.tools.changed"],
|
|
61
|
+
system: [
|
|
62
|
+
"installation.update-available",
|
|
63
|
+
"installation.updated",
|
|
64
|
+
"project.updated",
|
|
65
|
+
"server.connected",
|
|
66
|
+
"server.instance.disposed",
|
|
67
|
+
"global.disposed",
|
|
68
|
+
],
|
|
69
|
+
lsp: ["lsp.client.diagnostics", "lsp.updated"],
|
|
70
|
+
tui: ["tui.prompt.append", "tui.command.execute", "tui.toast.show", "tui.session.select"],
|
|
71
|
+
};
|
|
72
|
+
|
|
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
|
+
],
|
|
87
|
+
custom: [],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const LEVELS = {
|
|
91
|
+
error: new Set(["session.error", "worktree.failed", "mcp.browser.open.failed", "pty.exited"]),
|
|
92
|
+
warning: new Set(["permission.asked", "question.asked"]),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const TITLE_LIMIT = 60;
|
|
96
|
+
const SUBTITLE_LIMIT = 80;
|
|
97
|
+
const BODY_LIMIT = 200;
|
|
98
|
+
|
|
99
|
+
const DEFAULT_TOPIC = "oc-goodness-attn-1407537";
|
|
100
|
+
|
|
101
|
+
function truncate(value, limit) {
|
|
102
|
+
if (!value) return "";
|
|
103
|
+
if (value.length <= limit) return value;
|
|
104
|
+
return value.slice(0, limit - 1) + "...";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeString(value, fallback = "") {
|
|
108
|
+
return typeof value === "string" ? value : fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeBool(value, fallback) {
|
|
112
|
+
if (typeof value === "boolean") return value;
|
|
113
|
+
return fallback;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeArray(value) {
|
|
117
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeTier(value) {
|
|
121
|
+
if (typeof value !== "string") return DEFAULTS.tier;
|
|
122
|
+
return TIER_GROUPS[value] ? value : DEFAULTS.tier;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeTrigger(value) {
|
|
126
|
+
if (typeof value !== "string") return DEFAULTS.responseComplete.trigger;
|
|
127
|
+
return RESPONSE_TRIGGERS.has(value) ? value : DEFAULTS.responseComplete.trigger;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeMacMethod(value) {
|
|
131
|
+
if (typeof value !== "string") return DEFAULTS.channels.mac.method;
|
|
132
|
+
return MAC_METHODS.has(value) ? value : DEFAULTS.channels.mac.method;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sanitizeGroups(groups) {
|
|
136
|
+
const allowed = new Set(Object.keys(GROUPS));
|
|
137
|
+
const unique = new Set();
|
|
138
|
+
for (const group of groups) {
|
|
139
|
+
if (allowed.has(group)) unique.add(group);
|
|
140
|
+
}
|
|
141
|
+
return Array.from(unique);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeConfig(raw, globalConfig) {
|
|
145
|
+
const source = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
146
|
+
const globalSource =
|
|
147
|
+
globalConfig && typeof globalConfig === "object" && !Array.isArray(globalConfig)
|
|
148
|
+
? globalConfig
|
|
149
|
+
: {};
|
|
150
|
+
const config = {
|
|
151
|
+
version: DEFAULTS.version,
|
|
152
|
+
enabled: normalizeBool(source.enabled, DEFAULTS.enabled),
|
|
153
|
+
title: normalizeString(source.title, DEFAULTS.title),
|
|
154
|
+
tier: normalizeTier(source.tier),
|
|
155
|
+
detailLevel: source.detailLevel === "title-only" ? "title-only" : "full",
|
|
156
|
+
responseComplete: {
|
|
157
|
+
enabled: normalizeBool(
|
|
158
|
+
source.responseComplete?.enabled,
|
|
159
|
+
DEFAULTS.responseComplete.enabled
|
|
160
|
+
),
|
|
161
|
+
trigger: normalizeTrigger(source.responseComplete?.trigger ?? source.responseComplete),
|
|
162
|
+
},
|
|
163
|
+
channels: {
|
|
164
|
+
mac: {
|
|
165
|
+
enabled: normalizeBool(source.channels?.mac?.enabled, DEFAULTS.channels.mac.enabled),
|
|
166
|
+
method: normalizeMacMethod(source.channels?.mac?.method),
|
|
167
|
+
},
|
|
168
|
+
ntfy: {
|
|
169
|
+
enabled: normalizeBool(source.channels?.ntfy?.enabled, DEFAULTS.channels.ntfy.enabled),
|
|
170
|
+
server: normalizeString(
|
|
171
|
+
source.channels?.ntfy?.server,
|
|
172
|
+
normalizeString(globalSource.ntfy?.server, DEFAULTS.channels.ntfy.server)
|
|
173
|
+
),
|
|
174
|
+
topic: normalizeString(
|
|
175
|
+
source.channels?.ntfy?.topic,
|
|
176
|
+
normalizeString(globalSource.ntfy?.topic, "")
|
|
177
|
+
),
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
overrides: {
|
|
181
|
+
includeGroups: sanitizeGroups(normalizeArray(source.overrides?.includeGroups)),
|
|
182
|
+
excludeGroups: sanitizeGroups(normalizeArray(source.overrides?.excludeGroups)),
|
|
183
|
+
},
|
|
184
|
+
dedupe: {
|
|
185
|
+
ttlSeconds: Number.isFinite(source.dedupe?.ttlSeconds)
|
|
186
|
+
? Math.max(0, source.dedupe.ttlSeconds)
|
|
187
|
+
: DEFAULTS.dedupe.ttlSeconds,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (!config.channels.ntfy.topic && config.channels.ntfy.enabled) {
|
|
192
|
+
config.channels.ntfy.topic = DEFAULT_TOPIC;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return config;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildGroupSet(config) {
|
|
199
|
+
const baseGroups = TIER_GROUPS[config.tier] ?? TIER_GROUPS.standard;
|
|
200
|
+
const groupSet = new Set(baseGroups);
|
|
201
|
+
for (const group of config.overrides.includeGroups) {
|
|
202
|
+
if (GROUPS[group]) groupSet.add(group);
|
|
203
|
+
}
|
|
204
|
+
for (const group of config.overrides.excludeGroups) {
|
|
205
|
+
groupSet.delete(group);
|
|
206
|
+
}
|
|
207
|
+
return groupSet;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildEventSet(groupSet) {
|
|
211
|
+
const events = new Set();
|
|
212
|
+
for (const group of groupSet) {
|
|
213
|
+
const groupEvents = GROUPS[group] || [];
|
|
214
|
+
for (const event of groupEvents) events.add(event);
|
|
215
|
+
}
|
|
216
|
+
return events;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveLevel(type) {
|
|
220
|
+
if (LEVELS.error.has(type)) return "error";
|
|
221
|
+
if (LEVELS.warning.has(type)) return "warning";
|
|
222
|
+
return "info";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function extractSessionID(event) {
|
|
226
|
+
return (
|
|
227
|
+
event?.properties?.sessionID ||
|
|
228
|
+
event?.properties?.info?.id ||
|
|
229
|
+
event?.properties?.info?.sessionID ||
|
|
230
|
+
event?.properties?.part?.sessionID ||
|
|
231
|
+
event?.properties?.requestID ||
|
|
232
|
+
event?.properties?.tool?.sessionID ||
|
|
233
|
+
""
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function extractSessionTitle(event) {
|
|
238
|
+
return normalizeString(event?.properties?.info?.title, "");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isAssistantMessageComplete(info) {
|
|
242
|
+
return info?.role === "assistant" && Boolean(info?.time?.completed);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isStepFinishPart(part) {
|
|
246
|
+
return part?.type === "step-finish";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function shouldNotifyForEvent(type, event, eventSet, responseConfig) {
|
|
250
|
+
if (type === "pty.exited" && event?.properties?.exitCode === 0) return false;
|
|
251
|
+
if (eventSet.has(type)) return true;
|
|
252
|
+
if (!responseConfig?.enabled) return false;
|
|
253
|
+
if (responseConfig.trigger === "session.idle" && type === "session.idle") return true;
|
|
254
|
+
if (responseConfig.trigger === "message.updated" && type === "message.updated") {
|
|
255
|
+
return isAssistantMessageComplete(event?.properties?.info);
|
|
256
|
+
}
|
|
257
|
+
if (responseConfig.trigger === "message.part.updated" && type === "message.part.updated") {
|
|
258
|
+
return isStepFinishPart(event?.properties?.part);
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function dedupeKey(type, event, responseConfig) {
|
|
264
|
+
if (type === "permission.asked") return `permission:${event?.properties?.id || ""}`;
|
|
265
|
+
if (type === "question.asked") return `question:${event?.properties?.id || ""}`;
|
|
266
|
+
if (type === "session.idle") return `session.idle:${event?.properties?.sessionID || ""}`;
|
|
267
|
+
if (type === "session.diff") return `session.diff:${event?.properties?.sessionID || ""}`;
|
|
268
|
+
if (type === "message.updated") {
|
|
269
|
+
return `message.updated:${event?.properties?.info?.id || ""}`;
|
|
270
|
+
}
|
|
271
|
+
if (type === "message.part.updated") {
|
|
272
|
+
return `message.part.updated:${event?.properties?.part?.id || ""}`;
|
|
273
|
+
}
|
|
274
|
+
if (type === "pty.exited") return `pty.exited:${event?.properties?.id || ""}`;
|
|
275
|
+
if (responseConfig?.trigger === "message.updated" && type === "message.updated") {
|
|
276
|
+
return `response:${event?.properties?.info?.id || ""}`;
|
|
277
|
+
}
|
|
278
|
+
if (responseConfig?.trigger === "message.part.updated" && type === "message.part.updated") {
|
|
279
|
+
return `response:${event?.properties?.part?.id || ""}`;
|
|
280
|
+
}
|
|
281
|
+
if (responseConfig?.trigger === "session.idle" && type === "session.idle") {
|
|
282
|
+
return `response:${event?.properties?.sessionID || ""}`;
|
|
283
|
+
}
|
|
284
|
+
return "";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function buildBody(type, event) {
|
|
288
|
+
const props = event?.properties || {};
|
|
289
|
+
switch (type) {
|
|
290
|
+
case "permission.asked":
|
|
291
|
+
return `Permission requested: ${props.permission || ""}${props.patterns?.length ? ` for ${props.patterns.join(", ")}` : ""}`.trim();
|
|
292
|
+
case "question.asked":
|
|
293
|
+
return props.questions?.[0]?.header ? `Question: ${props.questions[0].header}` : "Question asked";
|
|
294
|
+
case "permission.replied":
|
|
295
|
+
return `Permission ${props.reply || "replied"}`;
|
|
296
|
+
case "question.replied":
|
|
297
|
+
return "Question answered";
|
|
298
|
+
case "question.rejected":
|
|
299
|
+
return "Question dismissed";
|
|
300
|
+
case "session.idle":
|
|
301
|
+
return "Assistant finished";
|
|
302
|
+
case "message.updated":
|
|
303
|
+
return "Response complete";
|
|
304
|
+
case "message.part.updated":
|
|
305
|
+
return "Step finished";
|
|
306
|
+
case "session.diff": {
|
|
307
|
+
const diff = props.diff || [];
|
|
308
|
+
const files = diff.map((item) => item.file).filter(Boolean);
|
|
309
|
+
const additions = diff.reduce((sum, item) => sum + (item.additions || 0), 0);
|
|
310
|
+
const deletions = diff.reduce((sum, item) => sum + (item.deletions || 0), 0);
|
|
311
|
+
const top = files.slice(0, 2).join(", ");
|
|
312
|
+
return `Changed ${files.length} files (+${additions}/-${deletions}).${top ? ` Top: ${top}` : ""}`;
|
|
313
|
+
}
|
|
314
|
+
case "session.error": {
|
|
315
|
+
const error = props.error || {};
|
|
316
|
+
const name = error.name || "Error";
|
|
317
|
+
const message = error.data?.message || "";
|
|
318
|
+
return message ? `${name}: ${message}` : `${name}`;
|
|
319
|
+
}
|
|
320
|
+
case "worktree.failed":
|
|
321
|
+
return `Worktree failed: ${props.message || ""}`.trim();
|
|
322
|
+
case "mcp.browser.open.failed":
|
|
323
|
+
return `Browser open failed: ${props.mcpName || ""}`.trim();
|
|
324
|
+
case "pty.exited":
|
|
325
|
+
return `Command failed (exit ${props.exitCode})`;
|
|
326
|
+
case "vcs.branch.updated":
|
|
327
|
+
return `Branch: ${props.branch || ""}`.trim();
|
|
328
|
+
case "worktree.ready":
|
|
329
|
+
return `Worktree ready: ${props.name || ""}${props.branch ? ` on ${props.branch}` : ""}`.trim();
|
|
330
|
+
case "file.edited":
|
|
331
|
+
return `Edited: ${props.file || ""}`.trim();
|
|
332
|
+
case "file.watcher.updated":
|
|
333
|
+
return `File ${props.event || ""}: ${props.file || ""}`.trim();
|
|
334
|
+
case "todo.updated":
|
|
335
|
+
return `Todos updated: ${(props.todos || []).length} items`;
|
|
336
|
+
case "command.executed":
|
|
337
|
+
return `Command executed: ${props.name || ""}`.trim();
|
|
338
|
+
case "mcp.tools.changed":
|
|
339
|
+
return `MCP tools changed: ${props.server || ""}`.trim();
|
|
340
|
+
case "session.created":
|
|
341
|
+
return "Session started";
|
|
342
|
+
case "session.compacted":
|
|
343
|
+
return "Session compacted";
|
|
344
|
+
case "session.updated":
|
|
345
|
+
return "Session updated";
|
|
346
|
+
case "message.removed":
|
|
347
|
+
return "Message removed";
|
|
348
|
+
case "message.part.delta":
|
|
349
|
+
return "Message part updated";
|
|
350
|
+
case "message.part.removed":
|
|
351
|
+
return "Message part removed";
|
|
352
|
+
default:
|
|
353
|
+
return `OpenCode event: ${type}`;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function loadJson(path) {
|
|
358
|
+
try {
|
|
359
|
+
const content = await readFile(path, "utf8");
|
|
360
|
+
return JSON.parse(content);
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export default async function opencodeNotify(input) {
|
|
367
|
+
const { directory, worktree, project, $ } = input;
|
|
368
|
+
let repoRoot = worktree || directory || project?.worktree || "";
|
|
369
|
+
const repoNameFallback = project?.name || basename(repoRoot || "");
|
|
370
|
+
let cachedRepoName = repoNameFallback || "";
|
|
371
|
+
let cachedBranch = "";
|
|
372
|
+
let terminalNotifierAvailable = null;
|
|
373
|
+
let globalConfigCache = { mtime: 0, data: null };
|
|
374
|
+
let repoConfigCache = { mtime: 0, data: null, errors: [], path: "" };
|
|
375
|
+
let schemaCache = { mtime: 0, data: null, root: "" };
|
|
376
|
+
let validationWarned = false;
|
|
377
|
+
const sessionTitles = new Map();
|
|
378
|
+
const dedupeStore = new Map();
|
|
379
|
+
|
|
380
|
+
async function detectTerminalNotifier() {
|
|
381
|
+
if (terminalNotifierAvailable !== null) return terminalNotifierAvailable;
|
|
382
|
+
try {
|
|
383
|
+
const result = await $`command -v terminal-notifier`.quiet();
|
|
384
|
+
terminalNotifierAvailable = Boolean(result.stdout.toString().trim());
|
|
385
|
+
} catch {
|
|
386
|
+
terminalNotifierAvailable = false;
|
|
387
|
+
}
|
|
388
|
+
return terminalNotifierAvailable;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function loadGlobalConfig() {
|
|
392
|
+
try {
|
|
393
|
+
const info = await stat(GLOBAL_CONFIG_PATH);
|
|
394
|
+
if (info.mtimeMs === globalConfigCache.mtime && globalConfigCache.data) {
|
|
395
|
+
return globalConfigCache.data;
|
|
396
|
+
}
|
|
397
|
+
const data = await loadJson(GLOBAL_CONFIG_PATH);
|
|
398
|
+
globalConfigCache = { mtime: info.mtimeMs, data };
|
|
399
|
+
return data;
|
|
400
|
+
} catch {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function loadSchema(root) {
|
|
406
|
+
if (!root) return null;
|
|
407
|
+
const path = join(root, REPO_SCHEMA_PATH);
|
|
408
|
+
try {
|
|
409
|
+
const info = await stat(path);
|
|
410
|
+
if (info.mtimeMs === schemaCache.mtime && schemaCache.data && schemaCache.root === root) {
|
|
411
|
+
return schemaCache.data;
|
|
412
|
+
}
|
|
413
|
+
const data = await loadJson(path);
|
|
414
|
+
schemaCache = { mtime: info.mtimeMs, data, root };
|
|
415
|
+
return data;
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function fileExists(path) {
|
|
422
|
+
try {
|
|
423
|
+
await stat(path);
|
|
424
|
+
return true;
|
|
425
|
+
} catch {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function resolveRepoRoot() {
|
|
431
|
+
if (!repoRoot) return "";
|
|
432
|
+
const direct = join(repoRoot, REPO_CONFIG_PATH);
|
|
433
|
+
if (await fileExists(direct)) return repoRoot;
|
|
434
|
+
try {
|
|
435
|
+
const gitRoot = (await $`git -C ${repoRoot} rev-parse --show-toplevel`.quiet()).stdout
|
|
436
|
+
.toString()
|
|
437
|
+
.trim();
|
|
438
|
+
if (gitRoot && (await fileExists(join(gitRoot, REPO_CONFIG_PATH)))) {
|
|
439
|
+
repoRoot = gitRoot;
|
|
440
|
+
return gitRoot;
|
|
441
|
+
}
|
|
442
|
+
} catch {}
|
|
443
|
+
return "";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function validateSchema(schema, value, path = "$") {
|
|
447
|
+
const errors = [];
|
|
448
|
+
if (!schema || typeof schema !== "object") return errors;
|
|
449
|
+
|
|
450
|
+
if (schema.type === "object") {
|
|
451
|
+
const isObject = value && typeof value === "object" && !Array.isArray(value);
|
|
452
|
+
if (!isObject) {
|
|
453
|
+
errors.push(`${path} should be an object`);
|
|
454
|
+
return errors;
|
|
455
|
+
}
|
|
456
|
+
const props = schema.properties || {};
|
|
457
|
+
if (schema.additionalProperties === false) {
|
|
458
|
+
for (const key of Object.keys(value)) {
|
|
459
|
+
if (!props[key]) {
|
|
460
|
+
errors.push(`${path} has unknown property '${key}'`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
for (const [key, childSchema] of Object.entries(props)) {
|
|
465
|
+
if (value[key] !== undefined) {
|
|
466
|
+
errors.push(...validateSchema(childSchema, value[key], `${path}.${key}`));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (schema.type === "array") {
|
|
472
|
+
if (!Array.isArray(value)) {
|
|
473
|
+
errors.push(`${path} should be an array`);
|
|
474
|
+
return errors;
|
|
475
|
+
}
|
|
476
|
+
if (schema.items) {
|
|
477
|
+
value.forEach((item, index) => {
|
|
478
|
+
errors.push(...validateSchema(schema.items, item, `${path}[${index}]`));
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (schema.type === "string") {
|
|
484
|
+
if (typeof value !== "string") errors.push(`${path} should be a string`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (schema.type === "number") {
|
|
488
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
489
|
+
errors.push(`${path} should be a number`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (schema.type === "integer") {
|
|
494
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
495
|
+
errors.push(`${path} should be an integer`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
500
|
+
errors.push(`${path} should be one of: ${schema.enum.join(", ")}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return errors;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function loadRepoConfig() {
|
|
507
|
+
const root = await resolveRepoRoot();
|
|
508
|
+
if (!root) return null;
|
|
509
|
+
const path = join(root, REPO_CONFIG_PATH);
|
|
510
|
+
try {
|
|
511
|
+
const info = await stat(path);
|
|
512
|
+
if (
|
|
513
|
+
info.mtimeMs === repoConfigCache.mtime &&
|
|
514
|
+
repoConfigCache.data &&
|
|
515
|
+
repoConfigCache.path === path
|
|
516
|
+
) {
|
|
517
|
+
return repoConfigCache;
|
|
518
|
+
}
|
|
519
|
+
const data = await loadJson(path);
|
|
520
|
+
const schema = await loadSchema(root);
|
|
521
|
+
const errors = schema ? validateSchema(schema, data) : [];
|
|
522
|
+
repoConfigCache = { mtime: info.mtimeMs, data, errors, path };
|
|
523
|
+
return repoConfigCache;
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function ensureRepoInfo() {
|
|
530
|
+
if (cachedRepoName && cachedBranch) return;
|
|
531
|
+
try {
|
|
532
|
+
const top = (await $`git -C ${repoRoot} rev-parse --show-toplevel`.quiet()).stdout
|
|
533
|
+
.toString()
|
|
534
|
+
.trim();
|
|
535
|
+
if (top) cachedRepoName = basename(top);
|
|
536
|
+
} catch {}
|
|
537
|
+
if (!cachedBranch) {
|
|
538
|
+
try {
|
|
539
|
+
cachedBranch = (await $`git -C ${repoRoot} rev-parse --abbrev-ref HEAD`.quiet()).stdout
|
|
540
|
+
.toString()
|
|
541
|
+
.trim();
|
|
542
|
+
} catch {}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function resolveTitle(config) {
|
|
547
|
+
const repo = cachedRepoName || repoNameFallback;
|
|
548
|
+
const branch = cachedBranch;
|
|
549
|
+
const rawTitle = config.title?.trim();
|
|
550
|
+
let title;
|
|
551
|
+
if (!rawTitle) {
|
|
552
|
+
title = branch ? `${repo}@${branch}` : repo;
|
|
553
|
+
} else if (rawTitle.includes("{repo}") || rawTitle.includes("{branch}")) {
|
|
554
|
+
title = rawTitle.replaceAll("{repo}", repo).replaceAll("{branch}", branch || "");
|
|
555
|
+
title = title.replace(/@\s*$/, "").trim();
|
|
556
|
+
} else {
|
|
557
|
+
title = rawTitle;
|
|
558
|
+
}
|
|
559
|
+
return truncate(title, TITLE_LIMIT);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function resolveSubtitle(event) {
|
|
563
|
+
const sessionID = extractSessionID(event);
|
|
564
|
+
const title = extractSessionTitle(event);
|
|
565
|
+
if (title) return truncate(title, SUBTITLE_LIMIT);
|
|
566
|
+
if (sessionID && sessionTitles.has(sessionID)) {
|
|
567
|
+
return truncate(sessionTitles.get(sessionID), SUBTITLE_LIMIT);
|
|
568
|
+
}
|
|
569
|
+
return truncate(sessionID || "", SUBTITLE_LIMIT);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function buildNotification(type, event, config) {
|
|
573
|
+
const title = resolveTitle(config);
|
|
574
|
+
const subtitle = resolveSubtitle(event);
|
|
575
|
+
const level = resolveLevel(type);
|
|
576
|
+
let body = buildBody(type, event);
|
|
577
|
+
if (config.detailLevel === "title-only") {
|
|
578
|
+
body = "";
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
title,
|
|
582
|
+
subtitle,
|
|
583
|
+
level,
|
|
584
|
+
body: truncate(body, BODY_LIMIT),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function shouldDedupe(key, ttlSeconds) {
|
|
589
|
+
if (!key) return false;
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
const ttlMs = ttlSeconds * 1000;
|
|
592
|
+
const last = dedupeStore.get(key);
|
|
593
|
+
if (last && now - last < ttlMs) return true;
|
|
594
|
+
dedupeStore.set(key, now);
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function notifyMac(notification, config) {
|
|
599
|
+
if (!config.channels.mac.enabled) return;
|
|
600
|
+
try {
|
|
601
|
+
const method = config.channels.mac.method || "auto";
|
|
602
|
+
const useTerminalNotifier =
|
|
603
|
+
method === "terminal-notifier" ||
|
|
604
|
+
(method === "auto" && (await detectTerminalNotifier()));
|
|
605
|
+
const message = notification.body || notification.subtitle || "";
|
|
606
|
+
if (useTerminalNotifier) {
|
|
607
|
+
await $`terminal-notifier -title ${notification.title} -message ${message} -subtitle ${notification.subtitle}`.quiet();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const script = [
|
|
611
|
+
"on run argv",
|
|
612
|
+
"set theMessage to item 1 of argv",
|
|
613
|
+
"set theTitle to item 2 of argv",
|
|
614
|
+
"set theSubtitle to item 3 of argv",
|
|
615
|
+
"if theSubtitle is \"\" then",
|
|
616
|
+
" display notification theMessage with title theTitle",
|
|
617
|
+
"else",
|
|
618
|
+
" display notification theMessage with title theTitle subtitle theSubtitle",
|
|
619
|
+
"end if",
|
|
620
|
+
"end run",
|
|
621
|
+
].join("\n");
|
|
622
|
+
await $`osascript -e ${script} ${message} ${notification.title} ${notification.subtitle}`.quiet();
|
|
623
|
+
} catch {}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function notifyNtfy(notification, config) {
|
|
627
|
+
if (!config.channels.ntfy.enabled) return;
|
|
628
|
+
if (!config.channels.ntfy.topic) return;
|
|
629
|
+
try {
|
|
630
|
+
const server = config.channels.ntfy.server.replace(/\/$/, "");
|
|
631
|
+
const topic = config.channels.ntfy.topic;
|
|
632
|
+
const url = `${server}/${topic}`;
|
|
633
|
+
const message = notification.body
|
|
634
|
+
? `${notification.subtitle ? `${notification.subtitle}\n` : ""}${notification.body}`
|
|
635
|
+
: notification.subtitle || notification.title;
|
|
636
|
+
const tags = ["opencode", notification.level].filter(Boolean).join(",");
|
|
637
|
+
const priority =
|
|
638
|
+
notification.level === "error" ? "5" : notification.level === "warning" ? "4" : "3";
|
|
639
|
+
await $`curl -sS -H ${"Title: " + notification.title} -H ${"Priority: " + priority} -H ${"Tags: " + tags} -d ${message} ${url}`.quiet();
|
|
640
|
+
} catch {}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
event: async ({ event }) => {
|
|
645
|
+
const payload = event?.payload ?? event;
|
|
646
|
+
const eventDirectory = event?.directory;
|
|
647
|
+
if (eventDirectory) repoRoot = eventDirectory;
|
|
648
|
+
|
|
649
|
+
const repoConfig = await loadRepoConfig();
|
|
650
|
+
if (!repoConfig?.data) return;
|
|
651
|
+
const globalConfig = await loadGlobalConfig();
|
|
652
|
+
const config = normalizeConfig(repoConfig.data, globalConfig);
|
|
653
|
+
if (repoConfig.errors?.length && !validationWarned) {
|
|
654
|
+
validationWarned = true;
|
|
655
|
+
console.warn("[opencode-notifications] invalid config detected", repoConfig.errors);
|
|
656
|
+
}
|
|
657
|
+
if (!config.enabled) return;
|
|
658
|
+
|
|
659
|
+
if (payload?.type === "vcs.branch.updated" && payload?.properties?.branch) {
|
|
660
|
+
cachedBranch = payload.properties.branch;
|
|
661
|
+
}
|
|
662
|
+
if (payload?.type === "session.created" || payload?.type === "session.updated") {
|
|
663
|
+
const sessionID = extractSessionID(payload);
|
|
664
|
+
const title = extractSessionTitle(payload);
|
|
665
|
+
if (sessionID && title) sessionTitles.set(sessionID, title);
|
|
666
|
+
}
|
|
667
|
+
if (payload?.type === "session.deleted") {
|
|
668
|
+
const sessionID = extractSessionID(payload);
|
|
669
|
+
if (sessionID) sessionTitles.delete(sessionID);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
await ensureRepoInfo();
|
|
673
|
+
|
|
674
|
+
const groupSet = buildGroupSet(config);
|
|
675
|
+
const eventSet = buildEventSet(groupSet);
|
|
676
|
+
const type = payload?.type || "unknown";
|
|
677
|
+
if (!shouldNotifyForEvent(type, payload, eventSet, config.responseComplete)) return;
|
|
678
|
+
|
|
679
|
+
const key = dedupeKey(type, payload, config.responseComplete);
|
|
680
|
+
if (shouldDedupe(key, config.dedupe.ttlSeconds)) return;
|
|
681
|
+
|
|
682
|
+
const notification = buildNotification(type, payload, config);
|
|
683
|
+
|
|
684
|
+
await notifyMac(notification, config);
|
|
685
|
+
await notifyNtfy(notification, config);
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export const OpencodeNotify = opencodeNotify;
|