@erica-s/ai-agent-notify 2.1.5

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.
@@ -0,0 +1,314 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+
5
+ const SIDECAR_STATE_DIR = path.join(os.tmpdir(), "ai-agent-notify", "codex-mcp-sidecar");
6
+ const STALE_UNRESOLVED_RECORD_MAX_AGE_MS = 24 * 60 * 60 * 1000;
7
+ const STALE_RESOLVED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
8
+
9
+ function getSidecarStateDir() {
10
+ return SIDECAR_STATE_DIR;
11
+ }
12
+
13
+ function writeSidecarRecord(record) {
14
+ const normalized = normalizeRecord(record);
15
+ fs.mkdirSync(SIDECAR_STATE_DIR, { recursive: true });
16
+
17
+ const recordPath = getSidecarRecordPath(normalized.recordId);
18
+ const tempPath = `${recordPath}.tmp-${process.pid}`;
19
+
20
+ fs.writeFileSync(tempPath, JSON.stringify(normalized, null, 2), "utf8");
21
+ fs.renameSync(tempPath, recordPath);
22
+
23
+ return normalized;
24
+ }
25
+
26
+ function deleteSidecarRecord(recordId) {
27
+ const recordPath = getSidecarRecordPath(recordId);
28
+ if (!fs.existsSync(recordPath)) {
29
+ return;
30
+ }
31
+
32
+ try {
33
+ fs.unlinkSync(recordPath);
34
+ } catch {}
35
+ }
36
+
37
+ function readAllSidecarRecords(log) {
38
+ if (!fs.existsSync(SIDECAR_STATE_DIR)) {
39
+ return [];
40
+ }
41
+
42
+ let entries = [];
43
+ try {
44
+ entries = fs.readdirSync(SIDECAR_STATE_DIR, { withFileTypes: true });
45
+ } catch (error) {
46
+ if (typeof log === "function") {
47
+ log(`sidecar state readdir failed dir=${SIDECAR_STATE_DIR} error=${error.message}`);
48
+ }
49
+ return [];
50
+ }
51
+
52
+ const records = [];
53
+ entries.forEach((entry) => {
54
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".json")) {
55
+ return;
56
+ }
57
+
58
+ const recordPath = path.join(SIDECAR_STATE_DIR, entry.name);
59
+ try {
60
+ const raw = fs.readFileSync(recordPath, "utf8");
61
+ records.push(normalizeRecord(JSON.parse(raw)));
62
+ } catch (error) {
63
+ if (typeof log === "function") {
64
+ log(`sidecar state parse failed file=${recordPath} error=${error.message}`);
65
+ }
66
+ }
67
+ });
68
+
69
+ return records;
70
+ }
71
+
72
+ function pruneStaleSidecarRecords(log) {
73
+ const now = Date.now();
74
+ readAllSidecarRecords(log).forEach((record) => {
75
+ const referenceTimeMs = parseTime(
76
+ record.lastMatchedAt || record.updatedAt || record.resolvedAt || record.startedAt
77
+ );
78
+ const maxAgeMs = record.sessionId
79
+ ? STALE_RESOLVED_RECORD_MAX_AGE_MS
80
+ : STALE_UNRESOLVED_RECORD_MAX_AGE_MS;
81
+ const isTooOld = !referenceTimeMs || now - referenceTimeMs > maxAgeMs;
82
+ if (isTooOld) {
83
+ deleteSidecarRecord(record.recordId);
84
+ }
85
+ });
86
+ }
87
+
88
+ function findSidecarTerminalContextForSession(sessionId, log) {
89
+ if (!sessionId) {
90
+ return null;
91
+ }
92
+
93
+ pruneStaleSidecarRecords(log);
94
+
95
+ const matches = readAllSidecarRecords(log)
96
+ .filter((record) => record.sessionId === sessionId)
97
+ .sort(compareRecordsByFreshness);
98
+
99
+ if (matches.length === 0) {
100
+ return null;
101
+ }
102
+
103
+ const match = writeSidecarRecord({
104
+ ...matches[0],
105
+ lastMatchedAt: new Date().toISOString(),
106
+ });
107
+ return {
108
+ recordId: match.recordId,
109
+ cwd: match.cwd || "",
110
+ hwnd: parsePositiveInteger(match.hwnd),
111
+ shellPid: parsePositiveInteger(match.shellPid),
112
+ isWindowsTerminal: match.isWindowsTerminal === true,
113
+ sessionId: match.sessionId,
114
+ };
115
+ }
116
+
117
+ function findSidecarTerminalContextForProjectDir(projectDir, log) {
118
+ if (!projectDir) {
119
+ return null;
120
+ }
121
+
122
+ pruneStaleSidecarRecords(log);
123
+
124
+ const matches = readAllSidecarRecords(log)
125
+ .map((record) => ({
126
+ record,
127
+ match: describeProjectDirMatch(record.cwd, projectDir),
128
+ }))
129
+ .filter(
130
+ (entry) =>
131
+ entry.match &&
132
+ !entry.record.sessionId &&
133
+ parsePositiveInteger(entry.record.hwnd) &&
134
+ isProcessAlive(entry.record.pid)
135
+ )
136
+ .sort(compareProjectDirFallbackCandidates);
137
+
138
+ if (matches.length === 0) {
139
+ return null;
140
+ }
141
+
142
+ const best = matches[0];
143
+ const second = matches[1];
144
+ if (
145
+ second &&
146
+ best.match.relationPriority === second.match.relationPriority &&
147
+ best.match.distance === second.match.distance &&
148
+ best.match.commonSegments === second.match.commonSegments
149
+ ) {
150
+ return null;
151
+ }
152
+
153
+ if (typeof log === "function") {
154
+ log(
155
+ `sidecar cwd fallback matched projectDir=${projectDir} recordCwd=${best.record.cwd || ""} hwnd=${best.record.hwnd || ""} pid=${best.record.pid || ""}`
156
+ );
157
+ }
158
+
159
+ return {
160
+ recordId: best.record.recordId,
161
+ cwd: best.record.cwd || "",
162
+ hwnd: parsePositiveInteger(best.record.hwnd),
163
+ shellPid: null,
164
+ isWindowsTerminal: false,
165
+ sessionId: "",
166
+ };
167
+ }
168
+
169
+ function getSidecarRecordPath(recordId) {
170
+ const safeId = String(recordId || "unknown").replace(/[^A-Za-z0-9._-]/g, "_");
171
+ return path.join(SIDECAR_STATE_DIR, `${safeId}.json`);
172
+ }
173
+
174
+ function normalizeRecord(record) {
175
+ const now = new Date().toISOString();
176
+ const normalized = record && typeof record === "object" ? { ...record } : {};
177
+
178
+ normalized.recordId = String(normalized.recordId || `${process.pid}`);
179
+ normalized.kind = "codex-mcp-sidecar";
180
+ normalized.pid = parsePositiveInteger(normalized.pid);
181
+ normalized.parentPid = parsePositiveInteger(normalized.parentPid);
182
+ normalized.hwnd = parsePositiveInteger(normalized.hwnd);
183
+ normalized.shellPid = parsePositiveInteger(normalized.shellPid);
184
+ normalized.isWindowsTerminal = normalized.isWindowsTerminal === true;
185
+ normalized.cwd = typeof normalized.cwd === "string" ? normalized.cwd : "";
186
+ normalized.sessionId = typeof normalized.sessionId === "string" ? normalized.sessionId : "";
187
+ normalized.startedAt = typeof normalized.startedAt === "string" ? normalized.startedAt : now;
188
+ normalized.updatedAt = now;
189
+ normalized.resolvedAt =
190
+ typeof normalized.resolvedAt === "string" ? normalized.resolvedAt : "";
191
+ normalized.lastMatchedAt =
192
+ typeof normalized.lastMatchedAt === "string" ? normalized.lastMatchedAt : "";
193
+
194
+ return normalized;
195
+ }
196
+
197
+ function compareRecordsByFreshness(left, right) {
198
+ return (
199
+ parseTime(right.resolvedAt) - parseTime(left.resolvedAt) ||
200
+ parseTime(right.updatedAt) - parseTime(left.updatedAt) ||
201
+ parseTime(right.startedAt) - parseTime(left.startedAt)
202
+ );
203
+ }
204
+
205
+ function parsePositiveInteger(value) {
206
+ const parsed = parseInt(value, 10);
207
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
208
+ }
209
+
210
+ function parseTime(value) {
211
+ const parsed = Date.parse(value || "");
212
+ return Number.isFinite(parsed) ? parsed : 0;
213
+ }
214
+
215
+ function isProcessAlive(pid) {
216
+ const normalizedPid = parsePositiveInteger(pid);
217
+ if (!normalizedPid) {
218
+ return false;
219
+ }
220
+
221
+ try {
222
+ process.kill(normalizedPid, 0);
223
+ return true;
224
+ } catch (error) {
225
+ return error && error.code === "EPERM";
226
+ }
227
+ }
228
+
229
+ function describeProjectDirMatch(recordCwd, projectDir) {
230
+ const normalizedRecord = normalizeWindowsPath(recordCwd);
231
+ const normalizedProject = normalizeWindowsPath(projectDir);
232
+ if (!normalizedRecord || !normalizedProject) {
233
+ return null;
234
+ }
235
+
236
+ const recordSegments = splitWindowsPath(normalizedRecord);
237
+ const projectSegments = splitWindowsPath(normalizedProject);
238
+ const commonSegments = countCommonSegments(recordSegments, projectSegments);
239
+
240
+ if (normalizedRecord === normalizedProject) {
241
+ return {
242
+ relation: "exact",
243
+ relationPriority: 3,
244
+ distance: 0,
245
+ commonSegments,
246
+ };
247
+ }
248
+
249
+ if (normalizedRecord.startsWith(`${normalizedProject}\\`)) {
250
+ return {
251
+ relation: "record_inside_project",
252
+ relationPriority: 2,
253
+ distance: Math.max(0, recordSegments.length - projectSegments.length),
254
+ commonSegments,
255
+ };
256
+ }
257
+
258
+ if (normalizedProject.startsWith(`${normalizedRecord}\\`)) {
259
+ return {
260
+ relation: "project_inside_record",
261
+ relationPriority: 1,
262
+ distance: Math.max(0, projectSegments.length - recordSegments.length),
263
+ commonSegments,
264
+ };
265
+ }
266
+
267
+ return null;
268
+ }
269
+
270
+ function compareProjectDirFallbackCandidates(left, right) {
271
+ return (
272
+ right.match.relationPriority - left.match.relationPriority ||
273
+ left.match.distance - right.match.distance ||
274
+ right.match.commonSegments - left.match.commonSegments ||
275
+ parseTime(right.record.updatedAt) - parseTime(left.record.updatedAt) ||
276
+ parseTime(right.record.startedAt) - parseTime(left.record.startedAt)
277
+ );
278
+ }
279
+
280
+ function normalizeWindowsPath(value) {
281
+ try {
282
+ return path.resolve(value || "").replace(/\//g, "\\").toLowerCase();
283
+ } catch {
284
+ return String(value || "")
285
+ .replace(/\//g, "\\")
286
+ .toLowerCase();
287
+ }
288
+ }
289
+
290
+ function splitWindowsPath(value) {
291
+ return String(value || "")
292
+ .split("\\")
293
+ .filter(Boolean);
294
+ }
295
+
296
+ function countCommonSegments(left, right) {
297
+ const max = Math.min(left.length, right.length);
298
+ let count = 0;
299
+
300
+ while (count < max && left[count] === right[count]) {
301
+ count += 1;
302
+ }
303
+
304
+ return count;
305
+ }
306
+
307
+ module.exports = {
308
+ deleteSidecarRecord,
309
+ findSidecarTerminalContextForProjectDir,
310
+ findSidecarTerminalContextForSession,
311
+ getSidecarStateDir,
312
+ pruneStaleSidecarRecords,
313
+ writeSidecarRecord,
314
+ };
@@ -0,0 +1,411 @@
1
+ "use strict";
2
+
3
+ const CODEX_EVENT_NAME_BY_TYPE = {
4
+ "agent-turn-complete": "Stop",
5
+ "approval-requested": "PermissionRequest",
6
+ "exec-approval-request": "PermissionRequest",
7
+ "request-permissions": "PermissionRequest",
8
+ "apply-patch-approval-request": "PermissionRequest",
9
+ };
10
+ const ENV_PAYLOAD_KEYS = ["AI_AGENT_NOTIFY_PAYLOAD"];
11
+
12
+ function normalizeIncomingNotification({ argv = [], stdinData = "", env = {} } = {}) {
13
+ const candidates = getIncomingPayloadCandidates(argv, stdinData, env);
14
+ const explicitOverrides = getExplicitDisplayOverrides(env);
15
+
16
+ for (const candidate of candidates) {
17
+ const normalized =
18
+ normalizeClaudeHookPayload(candidate) ||
19
+ normalizeCodexLegacyNotifyPayload(candidate) ||
20
+ normalizeGenericJsonPayload(candidate);
21
+
22
+ if (normalized) {
23
+ return applyExplicitDisplayOverrides(normalized, explicitOverrides);
24
+ }
25
+ }
26
+
27
+ return applyExplicitDisplayOverrides(
28
+ createNotificationSpec({
29
+ sourceId: "unknown",
30
+ transport: candidates.length > 0 ? candidates[0].transport : "none",
31
+ sessionId: "unknown",
32
+ payloadKeys: [],
33
+ debugSummary:
34
+ candidates.length > 0 ? buildCandidateSummary(candidates[0]) : "payload transport=none",
35
+ }),
36
+ explicitOverrides
37
+ );
38
+ }
39
+
40
+ function getIncomingPayloadCandidates(argv, stdinData, env) {
41
+ const candidates = [];
42
+
43
+ pushPayloadCandidate(candidates, {
44
+ transport: "stdin",
45
+ raw: stdinData,
46
+ acceptNonJson: true,
47
+ });
48
+
49
+ for (const envKey of ENV_PAYLOAD_KEYS) {
50
+ pushPayloadCandidate(candidates, {
51
+ transport: `env:${envKey}`,
52
+ raw: env && typeof env === "object" ? env[envKey] : "",
53
+ acceptNonJson: false,
54
+ });
55
+ }
56
+
57
+ for (let index = argv.length - 1; index >= 0; index -= 1) {
58
+ pushPayloadCandidate(candidates, {
59
+ transport: `argv[${index}]`,
60
+ raw: argv[index],
61
+ acceptNonJson: false,
62
+ });
63
+ }
64
+
65
+ return dedupePayloadCandidates(candidates);
66
+ }
67
+
68
+ function pushPayloadCandidate(candidates, { transport, raw, acceptNonJson }) {
69
+ const trimmed = normalizePayloadString(raw);
70
+ if (!trimmed) {
71
+ return;
72
+ }
73
+
74
+ if (!acceptNonJson && !looksLikeJson(trimmed)) {
75
+ return;
76
+ }
77
+
78
+ const parsed = parseJsonMaybe(trimmed);
79
+ candidates.push({
80
+ transport,
81
+ raw: trimmed,
82
+ parsed,
83
+ parseState: describeParsedValue(parsed),
84
+ });
85
+ }
86
+
87
+ function dedupePayloadCandidates(candidates) {
88
+ const seen = new Set();
89
+ return candidates.filter((candidate) => {
90
+ const key = `${candidate.transport}:${candidate.raw}`;
91
+ if (seen.has(key)) {
92
+ return false;
93
+ }
94
+ seen.add(key);
95
+ return true;
96
+ });
97
+ }
98
+
99
+ function normalizeClaudeHookPayload(candidate) {
100
+ const payload = candidate.parsed;
101
+ if (!isPlainObject(payload)) {
102
+ return null;
103
+ }
104
+
105
+ if (!hasAnyKey(payload, ["hook_event_name", "session_id", "title", "message", "source"])) {
106
+ return null;
107
+ }
108
+
109
+ return createNotificationSpec({
110
+ sourceId: "claude-hook",
111
+ source: getStringField(payload, ["source"]),
112
+ transport: candidate.transport,
113
+ sessionId: getStringField(payload, ["session_id"]) || "unknown",
114
+ eventName: getStringField(payload, ["hook_event_name"]),
115
+ title: getStringField(payload, ["title"]),
116
+ message: getStringField(payload, ["message"]),
117
+ rawEventType: getStringField(payload, ["hook_event_name"]),
118
+ payloadKeys: Object.keys(payload).sort(),
119
+ debugSummary: buildCandidateSummary(candidate),
120
+ });
121
+ }
122
+
123
+ function normalizeCodexLegacyNotifyPayload(candidate) {
124
+ const payload = candidate.parsed;
125
+ if (!isPlainObject(payload)) {
126
+ return null;
127
+ }
128
+
129
+ const rawEventType = getStringField(payload, ["type"]);
130
+ const sessionId =
131
+ getStringField(payload, ["thread-id", "thread_id", "threadId"]) ||
132
+ getStringField(payload, ["turn-id", "turn_id", "turnId"]) ||
133
+ "unknown";
134
+ const turnId = getStringField(payload, ["turn-id", "turn_id", "turnId"]);
135
+ const client = getStringField(payload, ["client"]);
136
+ const projectDir = getStringField(payload, ["cwd", "project-dir", "project_dir", "projectDir"]);
137
+ const hasCodexShape =
138
+ !!rawEventType &&
139
+ (client.startsWith("codex") ||
140
+ hasAnyKey(payload, [
141
+ "thread-id",
142
+ "thread_id",
143
+ "threadId",
144
+ "turn-id",
145
+ "turn_id",
146
+ "turnId",
147
+ "cwd",
148
+ "input-messages",
149
+ "input_messages",
150
+ "last-assistant-message",
151
+ "last_assistant_message",
152
+ ]));
153
+
154
+ if (!hasCodexShape) {
155
+ return null;
156
+ }
157
+
158
+ return createNotificationSpec({
159
+ sourceId: "codex-legacy-notify",
160
+ source: getStringField(payload, ["source"]),
161
+ transport: candidate.transport,
162
+ sessionId,
163
+ turnId,
164
+ eventName: CODEX_EVENT_NAME_BY_TYPE[rawEventType] || "",
165
+ title: getStringField(payload, ["title"]),
166
+ message: getStringField(payload, ["message"]),
167
+ projectDir,
168
+ rawEventType,
169
+ client,
170
+ payloadKeys: Object.keys(payload).sort(),
171
+ debugSummary: buildCandidateSummary(candidate),
172
+ });
173
+ }
174
+
175
+ function normalizeGenericJsonPayload(candidate) {
176
+ const payload = candidate.parsed;
177
+ if (!isPlainObject(payload)) {
178
+ return null;
179
+ }
180
+
181
+ return createNotificationSpec({
182
+ sourceId: inferSourceId(payload),
183
+ source: getStringField(payload, ["source"]),
184
+ transport: candidate.transport,
185
+ sessionId:
186
+ getStringField(payload, ["session_id", "thread-id", "thread_id", "threadId"]) || "unknown",
187
+ eventName: getStringField(payload, ["hook_event_name", "event", "type"]),
188
+ title: getStringField(payload, ["title"]),
189
+ message: getStringField(payload, ["message"]),
190
+ projectDir: getStringField(payload, ["cwd", "project-dir", "project_dir", "projectDir"]),
191
+ rawEventType: getStringField(payload, ["type"]),
192
+ client: getStringField(payload, ["client"]),
193
+ payloadKeys: Object.keys(payload).sort(),
194
+ debugSummary: buildCandidateSummary(candidate),
195
+ });
196
+ }
197
+
198
+ function inferSourceId(payload) {
199
+ const client = getStringField(payload, ["client"]);
200
+ if (client.startsWith("codex")) {
201
+ return "codex-json";
202
+ }
203
+
204
+ if (
205
+ hasAnyKey(payload, [
206
+ "thread-id",
207
+ "thread_id",
208
+ "threadId",
209
+ "turn-id",
210
+ "turn_id",
211
+ "turnId",
212
+ "input-messages",
213
+ "input_messages",
214
+ "last-assistant-message",
215
+ "last_assistant_message",
216
+ ])
217
+ ) {
218
+ return "codex-json";
219
+ }
220
+
221
+ if (hasAnyKey(payload, ["hook_event_name", "session_id"])) {
222
+ return "claude-hook";
223
+ }
224
+
225
+ return "unknown";
226
+ }
227
+
228
+ function createNotificationSpec(spec) {
229
+ const sourceId = spec.sourceId || spec.source || "unknown";
230
+ const eventName = spec.eventName || "";
231
+
232
+ return {
233
+ sourceId,
234
+ sourceFamily: getSourceFamily(sourceId),
235
+ source: canonicalizeDisplaySource(spec.source || inferDisplaySource(sourceId)),
236
+ transport: spec.transport || "",
237
+ sessionId: spec.sessionId || "unknown",
238
+ turnId: spec.turnId || "",
239
+ eventName,
240
+ title: canonicalizeNotificationTitle(spec.title || inferNotificationTitle(eventName)),
241
+ message: canonicalizeNotificationMessage(spec.message || inferNotificationMessage(eventName)),
242
+ projectDir: spec.projectDir || "",
243
+ rawEventType: spec.rawEventType || "",
244
+ payloadKeys: Array.isArray(spec.payloadKeys) ? spec.payloadKeys : [],
245
+ client: spec.client || "",
246
+ debugSummary: spec.debugSummary || "",
247
+ };
248
+ }
249
+
250
+ function applyExplicitDisplayOverrides(spec, overrides) {
251
+ return {
252
+ ...spec,
253
+ source: canonicalizeDisplaySource(overrides.source || spec.source),
254
+ title: canonicalizeNotificationTitle(overrides.title || spec.title),
255
+ message: canonicalizeNotificationMessage(overrides.message || spec.message),
256
+ };
257
+ }
258
+
259
+ function getExplicitDisplayOverrides(env) {
260
+ return {
261
+ source: getStringField(env, ["TOAST_NOTIFY_SOURCE"]),
262
+ title: getStringField(env, ["TOAST_NOTIFY_TITLE"]),
263
+ message: getStringField(env, ["TOAST_NOTIFY_MESSAGE"]),
264
+ };
265
+ }
266
+
267
+ function inferDisplaySource(sourceId) {
268
+ if (typeof sourceId === "string" && sourceId.startsWith("codex")) {
269
+ return "Codex";
270
+ }
271
+
272
+ if (typeof sourceId === "string" && sourceId.startsWith("claude")) {
273
+ return "Claude";
274
+ }
275
+
276
+ return "";
277
+ }
278
+
279
+ function inferNotificationTitle(eventName) {
280
+ switch (eventName) {
281
+ case "Stop":
282
+ return "Done";
283
+ case "PermissionRequest":
284
+ return "Needs Approval";
285
+ default:
286
+ return "Notification";
287
+ }
288
+ }
289
+
290
+ function inferNotificationMessage(eventName) {
291
+ switch (eventName) {
292
+ case "Stop":
293
+ return "Task finished";
294
+ case "PermissionRequest":
295
+ return "Waiting for your approval";
296
+ default:
297
+ return "Notification";
298
+ }
299
+ }
300
+
301
+ function canonicalizeDisplaySource(source) {
302
+ const trimmed = typeof source === "string" ? source.trim() : "";
303
+ if (!trimmed) {
304
+ return "";
305
+ }
306
+
307
+ if (/^claude(?:-hook)?$/i.test(trimmed)) {
308
+ return "Claude";
309
+ }
310
+
311
+ if (/^codex(?:[- ].+)?$/i.test(trimmed)) {
312
+ return "Codex";
313
+ }
314
+
315
+ if (/^(unknown|notification)$/i.test(trimmed)) {
316
+ return "";
317
+ }
318
+
319
+ return trimmed;
320
+ }
321
+
322
+ function canonicalizeNotificationTitle(title) {
323
+ const trimmed = typeof title === "string" ? title.trim() : "";
324
+ if (!trimmed) {
325
+ return "Notification";
326
+ }
327
+
328
+ return trimmed
329
+ .replace(/^\[(Claude|Codex|Agent)\]\s*/i, "")
330
+ .replace(/Needs Permission/g, "Needs Approval")
331
+ .replace(/^(Claude|Codex|Agent)\s+Needs Approval$/i, "Needs Approval")
332
+ .replace(/^(Claude|Codex|Agent)\s+Done$/i, "Done")
333
+ .replace(/^(Claude|Codex|Agent)$/i, "Notification");
334
+ }
335
+
336
+ function canonicalizeNotificationMessage(message) {
337
+ const trimmed = typeof message === "string" ? message.trim() : "";
338
+ return trimmed || "Notification";
339
+ }
340
+
341
+ function getSourceFamily(sourceId) {
342
+ if (typeof sourceId === "string" && sourceId.startsWith("codex")) {
343
+ return "codex";
344
+ }
345
+
346
+ if (typeof sourceId === "string" && sourceId.startsWith("claude")) {
347
+ return "claude";
348
+ }
349
+
350
+ return "generic";
351
+ }
352
+
353
+ function buildCandidateSummary(candidate) {
354
+ const keys = isPlainObject(candidate.parsed) ? Object.keys(candidate.parsed).sort().join(",") : "";
355
+ return `payload transport=${candidate.transport} parsed=${candidate.parseState} keys=${keys} rawLength=${candidate.raw.length}`;
356
+ }
357
+
358
+ function normalizePayloadString(value) {
359
+ return typeof value === "string" ? value.replace(/^\uFEFF/, "").trim() : "";
360
+ }
361
+
362
+ function parseJsonMaybe(raw) {
363
+ try {
364
+ return JSON.parse(raw);
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
370
+ function looksLikeJson(value) {
371
+ return value.startsWith("{") || value.startsWith("[");
372
+ }
373
+
374
+ function describeParsedValue(value) {
375
+ if (Array.isArray(value)) {
376
+ return "array";
377
+ }
378
+
379
+ if (value && typeof value === "object") {
380
+ return "object";
381
+ }
382
+
383
+ return value === null ? "invalid" : typeof value;
384
+ }
385
+
386
+ function isPlainObject(value) {
387
+ return !!value && typeof value === "object" && !Array.isArray(value);
388
+ }
389
+
390
+ function hasAnyKey(payload, keys) {
391
+ return keys.some((key) => Object.prototype.hasOwnProperty.call(payload, key));
392
+ }
393
+
394
+ function getStringField(payload, keys) {
395
+ for (const key of keys) {
396
+ const value = payload[key];
397
+ if (typeof value === "string" && value.trim()) {
398
+ return value.trim();
399
+ }
400
+ }
401
+
402
+ return "";
403
+ }
404
+
405
+ module.exports = {
406
+ createNotificationSpec,
407
+ getIncomingPayloadCandidates,
408
+ getSourceFamily,
409
+ normalizeIncomingNotification,
410
+ parseJsonMaybe,
411
+ };