@indianic/dialout 1.12.1 → 1.13.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.
Files changed (39) hide show
  1. package/README.md +4 -3
  2. package/dist/agent-restart.d.ts +46 -0
  3. package/dist/agent-restart.d.ts.map +1 -0
  4. package/dist/agent-restart.js +254 -0
  5. package/dist/agent-restart.js.map +1 -0
  6. package/dist/checklist.js +1 -1
  7. package/dist/checklist.js.map +1 -1
  8. package/dist/cli-client.d.ts +8 -0
  9. package/dist/cli-client.d.ts.map +1 -1
  10. package/dist/cli-client.js +33 -0
  11. package/dist/cli-client.js.map +1 -1
  12. package/dist/cli.js +106 -82
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cowork.js +1 -1
  15. package/dist/has-command.d.ts +13 -0
  16. package/dist/has-command.d.ts.map +1 -1
  17. package/dist/has-command.js +92 -0
  18. package/dist/has-command.js.map +1 -1
  19. package/dist/ssh-local-network.js +1 -1
  20. package/dist/update-check.d.ts +9 -1
  21. package/dist/update-check.d.ts.map +1 -1
  22. package/dist/update-check.js +46 -14
  23. package/dist/update-check.js.map +1 -1
  24. package/node_modules/@indianic/dialout-shared/dist/index.d.ts +2 -0
  25. package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
  26. package/node_modules/@indianic/dialout-shared/dist/index.js +20 -2
  27. package/node_modules/@indianic/dialout-shared/dist/notify-template.d.ts +129 -0
  28. package/node_modules/@indianic/dialout-shared/dist/notify-template.d.ts.map +1 -0
  29. package/node_modules/@indianic/dialout-shared/dist/notify-template.js +302 -0
  30. package/node_modules/@indianic/dialout-shared/dist/notify-template.test.d.ts +2 -0
  31. package/node_modules/@indianic/dialout-shared/dist/notify-template.test.d.ts.map +1 -0
  32. package/node_modules/@indianic/dialout-shared/dist/notify-template.test.js +285 -0
  33. package/node_modules/@indianic/dialout-shared/src/index.ts +29 -0
  34. package/node_modules/@indianic/dialout-shared/src/notify-template.test.ts +352 -0
  35. package/node_modules/@indianic/dialout-shared/src/notify-template.ts +319 -0
  36. package/node_modules/@indianic/dialout-shared/src/session-announce.test.ts +48 -0
  37. package/node_modules/@indianic/dialout-shared/src/session-announce.ts +40 -0
  38. package/package.json +2 -2
  39. package/scripts/postinstall.js +8 -9
@@ -0,0 +1,352 @@
1
+ // packages/devdash-shared/src/notify-template.test.ts
2
+ import { describe, it, expect } from 'vitest';
3
+ import {
4
+ renderNotification, parseActions, categoryId, DEFAULT_TEMPLATES,
5
+ TEMPLATE_VARIABLES, NOTIFICATION_TYPES, parseTemplatePatch, shouldSendNotification,
6
+ folderLabel, isSendable,
7
+ } from './notify-template';
8
+
9
+ describe('renderNotification', () => {
10
+ // The old hardcoded title chose between these two; a single template string
11
+ // cannot, so {what} carries it. Guards against "finished" reading as blocked.
12
+ it('default ai_question title keeps "needs you" distinct from "finished"', () => {
13
+ const t = DEFAULT_TEMPLATES.ai_question;
14
+ expect(renderNotification(t, { agent: 'Claude', what: 'needs you' }, 'UTC').title).toBe('Claude needs you');
15
+ expect(renderNotification(t, { agent: 'Claude', what: 'finished' }, 'UTC').title).toBe('Claude finished');
16
+ });
17
+
18
+ // The old hardcoded body had a fallback for each missing half; the values
19
+ // now carry them (folder -> 'session'), so no case may render empty and a
20
+ // finished session must still name its CLI.
21
+ it('default ai_question body is never empty and always names the CLI', () => {
22
+ const t = DEFAULT_TEMPLATES.ai_question;
23
+ const body = (question: string, folder: string) =>
24
+ renderNotification(t, { agent: 'Claude', what: 'finished', kind: 'claude', question, folder }, 'UTC').body;
25
+ expect(body('Q', 'dialout')).toBe('Q · claude · dialout');
26
+ expect(body('Q', 'session')).toBe('Q · claude · session');
27
+ expect(body('', 'dialout')).toBe('claude · dialout');
28
+ expect(body('', 'session')).toBe('claude · session');
29
+ });
30
+
31
+ it('substitutes the variables it is given', () => {
32
+ expect(renderNotification(
33
+ { title: '{agent} needs you', body: '{question} · {folder}' },
34
+ { agent: 'Claude', question: 'Run the migration?', folder: 'dialout' },
35
+ 'UTC',
36
+ )).toEqual({ title: 'Claude needs you', body: 'Run the migration? · dialout' });
37
+ });
38
+
39
+ // A notification showing its own template syntax on a lock screen is worse
40
+ // than one with a gap in it.
41
+ it('renders an unknown or missing variable as empty, never as its own name', () => {
42
+ const r = renderNotification({ title: 'Hi {nobody}', body: '{alsoNobody}' }, {}, 'UTC');
43
+ expect(r.title).toBe('Hi');
44
+ expect(r.body).toBe('');
45
+ expect(r.title).not.toContain('{');
46
+ });
47
+
48
+ it('treats a null or undefined value as missing', () => {
49
+ expect(renderNotification({ title: 'a{x}b', body: '' }, { x: null }, 'UTC').title).toBe('ab');
50
+ expect(renderNotification({ title: 'a{x}b', body: '' }, { x: undefined }, 'UTC').title).toBe('ab');
51
+ });
52
+
53
+ // Emptying a variable otherwise strands the separator that was joining it to
54
+ // the next thing, and "Run it? · " reads as truncation.
55
+ it('collapses a separator left stranded by an empty variable', () => {
56
+ expect(renderNotification(
57
+ { title: 'x', body: '{question} · {folder}' },
58
+ { question: 'Run it?' },
59
+ 'UTC',
60
+ ).body).toBe('Run it?');
61
+ expect(renderNotification(
62
+ { title: 'x', body: '{a} · {b} · {c}' },
63
+ { b: 'middle' },
64
+ 'UTC',
65
+ ).body).toBe('middle');
66
+ });
67
+
68
+ it('collapses runs of whitespace', () => {
69
+ expect(renderNotification({ title: 'a {x} b', body: '' }, {}, 'UTC').title).toBe('a b');
70
+ });
71
+
72
+ it('formats time and date in the given timezone', () => {
73
+ const vars = { at: '2026-10-29T18:30:00Z' };
74
+ const utc = renderNotification({ title: '{time}', body: '{date}' }, vars, 'UTC');
75
+ const ist = renderNotification({ title: '{time}', body: '{date}' }, vars, 'Asia/Kolkata');
76
+ expect(utc.title).not.toBe(ist.title);
77
+ expect(ist.title).toMatch(/\d/);
78
+ });
79
+
80
+ // An unknown zone throws inside Intl, and this runs on the notify path.
81
+ it('falls back to UTC rather than throwing on an unknown timezone', () => {
82
+ expect(() => renderNotification(
83
+ { title: '{time}', body: '' }, { at: '2026-10-29T18:30:00Z' }, 'Mars/Olympus',
84
+ )).not.toThrow();
85
+ });
86
+
87
+ it('caps the title at 80 and the body at 240', () => {
88
+ const r = renderNotification({ title: '{x}', body: '{x}' }, { x: 'y'.repeat(500) }, 'UTC');
89
+ expect(r.title).toHaveLength(80);
90
+ expect(r.body).toHaveLength(240);
91
+ });
92
+
93
+ it('never throws on a template that is not a string', () => {
94
+ expect(() => renderNotification(
95
+ { title: undefined as unknown as string, body: null as unknown as string }, {}, 'UTC',
96
+ )).not.toThrow();
97
+ });
98
+
99
+ describe('renderNotification: names that were never supplied', () => {
100
+ it.each(['constructor', '__proto__', 'toString', 'hasOwnProperty'])(
101
+ 'renders {%s} as nothing',
102
+ (name) => {
103
+ expect(renderNotification({ title: `a{${name}}b`, body: `{${name}}` }, {}, 'UTC'))
104
+ .toEqual({ title: 'ab', body: '' });
105
+ },
106
+ );
107
+
108
+ it('renders a supplied value that is not a string or number as nothing, without throwing', () => {
109
+ const vars = { x: { nested: 1 }, y: ['a'], z: true } as unknown as Record<string, string>;
110
+ expect(renderNotification({ title: 'a{x}b{y}c{z}d', body: '' }, vars, 'UTC').title).toBe('abcd');
111
+ });
112
+
113
+ it('still substitutes a supplied number', () => {
114
+ const vars = { n: 3 } as unknown as Record<string, string>;
115
+ expect(renderNotification({ title: 'n={n}', body: '' }, vars, 'UTC').title).toBe('n=3');
116
+ });
117
+ });
118
+
119
+ // These guard the marker indirection specifically (surviving placeholders are
120
+ // swapped for private-use markers while the skeleton is tidied). They look
121
+ // redundant next to the verbatim-value tests; do not delete them as such.
122
+ describe('renderNotification: marker indirection', () => {
123
+ const E0 = '';
124
+ const E1 = '';
125
+
126
+ it('renders values when the template itself contains marker characters, and emits none', () => {
127
+ const r = renderNotification(
128
+ { title: `a${E0}{x}${E1}b${E0}0${E1}`, body: `${E0}{y}` }, { x: 'X', y: 'Y' }, 'UTC',
129
+ );
130
+ expect(r.title).toBe('aXb0');
131
+ expect(r.body).toBe('Y');
132
+ expect(r.title + r.body).not.toMatch(/[]/);
133
+ });
134
+
135
+ it('renders marker-shaped text inside a value literally, without re-scanning it', () => {
136
+ const lookalike = `${E0}1${E1}`;
137
+ const r = renderNotification({ title: '', body: '{a} {b}' }, { a: lookalike, b: 'B' }, 'UTC');
138
+ expect(r.body).toBe(`${lookalike} B`);
139
+ });
140
+
141
+ it('keeps twelve placeholders in their own slots, including adjacent ones', () => {
142
+ const names = 'abcdefghijkl'.split('');
143
+ const vars = Object.fromEntries(names.map((n, i) => [n, `v${i}`]));
144
+ const template = '{a}{b} {c} {d} {e} {f} {g} {h} {i} {j} {k} {l}';
145
+ const r = renderNotification({ title: '', body: template }, vars, 'UTC');
146
+ expect(r.body).toBe('v0v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 v11');
147
+ });
148
+
149
+ it('substitutes values verbatim, including surrounding whitespace and newlines', () => {
150
+ const r = renderNotification({ title: '', body: '[{x}]' }, { x: ' a\nb ' }, 'UTC');
151
+ expect(r.body).toBe('[ a\nb ]');
152
+ });
153
+ });
154
+
155
+ describe('renderNotification: vars that are not an object', () => {
156
+ it.each([null, undefined, 'str', 42, true, ['a']])('does not throw for vars = %j', (v) => {
157
+ const r = renderNotification({ title: 'a{question}b', body: '{time}' }, v as never, 'UTC');
158
+ expect(r.title).toBe('ab');
159
+ expect(r.body).toMatch(/^\d{2}:\d{2}$/);
160
+ });
161
+ });
162
+
163
+ describe('renderNotification: a value is never tidied', () => {
164
+ it.each([
165
+ 'Should I run git push --force?',
166
+ 'Use the --no-verify flag',
167
+ 'Run cmd1 || cmd2 ?',
168
+ '-rf flag is set',
169
+ 'ends with a dash -',
170
+ ' spaced out ',
171
+ 'a · · b',
172
+ ])('keeps %j character for character', (q) => {
173
+ expect(renderNotification({ title: '', body: '{question}' }, { question: q }, 'UTC').body).toBe(q);
174
+ });
175
+
176
+ it('does not let a value inject a placeholder', () => {
177
+ expect(renderNotification({ title: '', body: '{a}' }, { a: '{b}', b: 'no' }, 'UTC').body).toBe('{b}');
178
+ });
179
+
180
+ it('still tidies the skeleton around a verbatim value', () => {
181
+ expect(renderNotification({ title: '', body: '{question} · {folder}' }, { question: 'git push --force?' }, 'UTC').body)
182
+ .toBe('git push --force?');
183
+ expect(renderNotification({ title: '', body: '{folder} · {question}' }, { question: '-rf' }, 'UTC').body)
184
+ .toBe('-rf');
185
+ });
186
+ });
187
+ });
188
+
189
+ describe('DEFAULT_TEMPLATES', () => {
190
+ it('covers every type', () => {
191
+ for (const t of NOTIFICATION_TYPES) expect(DEFAULT_TEMPLATES[t]).toBeDefined();
192
+ });
193
+
194
+ // A template may only use variables its own type supplies, or it renders a
195
+ // gap on every send.
196
+ it('only uses variables its own type offers', () => {
197
+ for (const t of NOTIFICATION_TYPES) {
198
+ const used = `${DEFAULT_TEMPLATES[t].title} ${DEFAULT_TEMPLATES[t].body}`
199
+ .match(/\{(\w+)\}/g)?.map((m) => m.slice(1, -1)) ?? [];
200
+ for (const v of used) expect(TEMPLATE_VARIABLES[t], `${t} uses {${v}}`).toContain(v);
201
+ }
202
+ });
203
+ });
204
+
205
+ describe('categoryId', () => {
206
+ // SDK 57: a category identifier may contain no ':' or '-', and a hyphen
207
+ // silently costs the action buttons. ai_question must land on exactly the
208
+ // string AI_CATEGORY already holds, so notifications in flight during the
209
+ // deploy keep their Reply button.
210
+ it('strips underscores and hyphens', () => {
211
+ expect(categoryId('ai_question')).toBe('aiquestion');
212
+ expect(categoryId('new_terminal')).toBe('newterminal');
213
+ expect(categoryId('new_ai_session')).toBe('newaisession');
214
+ expect(categoryId('announcement')).toBe('announcement');
215
+ });
216
+
217
+ it('never produces an id with a character the SDK rejects', () => {
218
+ for (const t of NOTIFICATION_TYPES) {
219
+ expect(categoryId(t)).not.toMatch(/[:-]/);
220
+ }
221
+ });
222
+ });
223
+
224
+ describe('parseActions', () => {
225
+ it('keeps well-formed actions', () => {
226
+ expect(parseActions([{ id: 'reply', label: 'Reply', kind: 'reply' }]))
227
+ .toEqual([{ id: 'reply', label: 'Reply', kind: 'reply' }]);
228
+ });
229
+
230
+ it('parses a JSON string, which is what the jsonb column may hand back', () => {
231
+ expect(parseActions('[{"id":"open","label":"Open","kind":"open"}]'))
232
+ .toEqual([{ id: 'open', label: 'Open', kind: 'open' }]);
233
+ });
234
+
235
+ // kind is a closed set so a button cannot be configured to do something the
236
+ // app has no code for.
237
+ it('drops an action with an unknown kind', () => {
238
+ expect(parseActions([{ id: 'x', label: 'X', kind: 'launchMissiles' }])).toEqual([]);
239
+ });
240
+
241
+ it('drops an action missing an id or a label', () => {
242
+ expect(parseActions([{ label: 'X', kind: 'open' }, { id: 'y', kind: 'open' }])).toEqual([]);
243
+ });
244
+
245
+ it('returns an empty array for anything unusable', () => {
246
+ for (const bad of [null, undefined, 42, 'not json', {}]) {
247
+ expect(parseActions(bad)).toEqual([]);
248
+ }
249
+ });
250
+
251
+ it('caps the list at four, which is what iOS shows', () => {
252
+ const many = Array.from({ length: 9 }, (_, i) => ({ id: `a${i}`, label: 'A', kind: 'open' }));
253
+ expect(parseActions(many)).toHaveLength(4);
254
+ });
255
+
256
+ it('drops a duplicate id, which would collide in the response handler', () => {
257
+ expect(parseActions([
258
+ { id: 'open', label: 'Open', kind: 'open' },
259
+ { id: 'open', label: 'Again', kind: 'open' },
260
+ ])).toHaveLength(1);
261
+ });
262
+ });
263
+
264
+ describe('shouldSendNotification', () => {
265
+ // The feature's central rule, and the one most likely to be re-implemented
266
+ // wrongly at a third call site. The repo has no route tests — the convention
267
+ // is pure-logic tests in src/lib/__tests__ — so this is where the gate is
268
+ // pinned.
269
+ it('needs both gates to say yes', () => {
270
+ expect(shouldSendNotification(true, true)).toBe(true);
271
+ });
272
+
273
+ it('sends nothing when the admin has switched the type off', () => {
274
+ expect(shouldSendNotification(false, true)).toBe(false);
275
+ });
276
+
277
+ it('sends nothing to a device whose owner opted out', () => {
278
+ expect(shouldSendNotification(true, false)).toBe(false);
279
+ });
280
+
281
+ it('is false when neither says yes', () => {
282
+ expect(shouldSendNotification(false, false)).toBe(false);
283
+ });
284
+ });
285
+
286
+ describe('parseTemplatePatch', () => {
287
+ it('accepts a partial patch', () => {
288
+ const r = parseTemplatePatch({ enabled: false, title: ' Hi ' });
289
+ expect(r).toEqual({ ok: true, patch: { enabled: false, title: 'Hi' } });
290
+ });
291
+
292
+ it('rejects a body that is not an object', () => {
293
+ expect(parseTemplatePatch(null).ok).toBe(false);
294
+ });
295
+
296
+ it('caps title and body rather than rejecting them', () => {
297
+ const r = parseTemplatePatch({ title: 'x'.repeat(300), body: 'y'.repeat(900) });
298
+ expect(r.ok).toBe(true);
299
+ if (r.ok) {
300
+ expect(r.patch.title).toHaveLength(80);
301
+ expect(r.patch.body).toHaveLength(240);
302
+ }
303
+ });
304
+
305
+ it('normalises actions through parseActions', () => {
306
+ const r = parseTemplatePatch({ actions: [{ id: 'x', label: 'X', kind: 'nope' }] });
307
+ expect(r.ok).toBe(true);
308
+ if (r.ok) expect(r.patch.actions).toEqual([]);
309
+ });
310
+ });
311
+
312
+ // The push this wording replaced named a terminal by its folder and fell back
313
+ // to the tmux name. For a browser session the tmux name is dd-<uuid>, so a
314
+ // default built on {terminal} would turn "dialout" into an opaque id.
315
+ describe('default wording for a new session', () => {
316
+ const tz = 'UTC';
317
+
318
+ it('new_terminal names the folder, not the tmux name', () => {
319
+ const r = renderNotification(DEFAULT_TEMPLATES.new_terminal, {
320
+ terminal: 'dd-7f3a9c2b4e', folder: 'dialout', machine: 'MacBook Pro',
321
+ }, tz);
322
+ expect(r.body).toBe('dialout on MacBook Pro');
323
+ });
324
+
325
+ it('new_ai_session never leaves a gap when the sender has no folder', () => {
326
+ const vars = { agent: 'dd-ai-x', machine: 'MacBook Pro', folder: folderLabel(null, 'session') };
327
+ expect(renderNotification(DEFAULT_TEMPLATES.new_ai_session, vars, tz).body)
328
+ .toBe('dd-ai-x in session on MacBook Pro');
329
+ });
330
+ });
331
+
332
+ describe('folderLabel', () => {
333
+ it('uses the folder when there is one', () => {
334
+ expect(folderLabel('dialout', 'dd-abc')).toBe('dialout');
335
+ });
336
+ it('falls back for null, undefined, empty and whitespace', () => {
337
+ for (const f of [null, undefined, '', ' ']) expect(folderLabel(f, 'dd-abc')).toBe('dd-abc');
338
+ });
339
+ });
340
+
341
+ // A cleared title or body is a deliberate choice, so the sender has to decide
342
+ // what an empty one means rather than discovering it on a lock screen.
343
+ describe('isSendable', () => {
344
+ it('sends a notification with only a body or only a title', () => {
345
+ expect(isSendable({ title: '', body: 'Run it?' })).toBe(true);
346
+ expect(isSendable({ title: 'Agent needs you', body: '' })).toBe(true);
347
+ });
348
+ it('does not send one with neither', () => {
349
+ expect(isSendable({ title: '', body: '' })).toBe(false);
350
+ expect(isSendable({ title: ' ', body: ' ' })).toBe(false);
351
+ });
352
+ });
@@ -0,0 +1,319 @@
1
+ // packages/devdash-shared/src/notify-template.ts
2
+
3
+ /**
4
+ * What an automatic notification says, and the buttons it carries.
5
+ *
6
+ * Pure, and here rather than in the ws-server for the same reason the
7
+ * shouldNotify* family is: src/ws-server/index.ts is one deliberately large
8
+ * file with no unit seams, so decision and rendering logic added inline there
9
+ * would be untestable. The ws-server stays the only side-effecting caller.
10
+ */
11
+
12
+ export const NOTIFICATION_TYPES = [
13
+ 'ai_question', 'new_terminal', 'new_ai_session', 'announcement',
14
+ ] as const;
15
+ export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
16
+
17
+ /** A closed set, so a configured button cannot ask for behaviour the app lacks. */
18
+ export const ACTION_KINDS = ['open', 'reply'] as const;
19
+ export type ActionKind = (typeof ACTION_KINDS)[number];
20
+
21
+ export interface NotificationAction {
22
+ id: string;
23
+ label: string;
24
+ kind: ActionKind;
25
+ }
26
+
27
+ export interface NotificationTemplate {
28
+ type: NotificationType;
29
+ enabled: boolean;
30
+ title: string;
31
+ body: string;
32
+ actions: NotificationAction[];
33
+ }
34
+
35
+ /** Matches src/app/api/push/broadcast/route.ts, and both platforms truncate anyway. */
36
+ export const TITLE_MAX = 80;
37
+ export const BODY_MAX = 240;
38
+ /** iOS shows four actions on an expanded notification. */
39
+ export const MAX_ACTIONS = 4;
40
+
41
+ /**
42
+ * Which variables each type can supply. Per type, not global, because they are
43
+ * not interchangeable: {question} exists for an agent question and not for a
44
+ * new terminal, and offering it everywhere would produce templates that render
45
+ * a gap on every send.
46
+ */
47
+ export const TEMPLATE_VARIABLES: Record<NotificationType, string[]> = {
48
+ ai_question: ['agent', 'what', 'kind', 'question', 'folder', 'branch', 'machine', 'session', 'time', 'date'],
49
+ new_terminal: ['machine', 'terminal', 'folder', 'branch', 'time', 'date'],
50
+ new_ai_session: ['agent', 'machine', 'session', 'folder', 'time', 'date'],
51
+ // An announcement has no machine, terminal or session behind it. {title} and
52
+ // {body} are what the admin typed in the compose form; the template supplies
53
+ // the wrapper and the buttons.
54
+ announcement: ['title', 'body', 'user', 'time', 'date'],
55
+ };
56
+
57
+ /**
58
+ * Today's hardcoded wording, so shipping the feature changes nothing about
59
+ * what anyone receives. Mirrored in scripts/apply-notification-templates.mjs,
60
+ * which cannot import the built package — a drift between the two is a visible
61
+ * difference in a notification rather than a silent failure.
62
+ */
63
+ export const DEFAULT_TEMPLATES: Record<NotificationType, Omit<NotificationTemplate, 'type'>> = {
64
+ ai_question: {
65
+ enabled: true,
66
+ // {what} is "needs you" or "finished". One template string cannot branch, so
67
+ // the distinction is a variable: a finished job must not read as a blocked one.
68
+ title: '{agent} {what}',
69
+ body: '{question} · {kind} · {folder}',
70
+ actions: [
71
+ { id: 'reply', label: 'Reply', kind: 'reply' },
72
+ { id: 'open', label: 'Open', kind: 'open' },
73
+ ],
74
+ },
75
+ new_terminal: {
76
+ enabled: true,
77
+ title: 'New terminal',
78
+ // {folder}, not {terminal}: the push this replaced named a terminal by its
79
+ // folder, and for a browser session the tmux name is an opaque dd-<uuid>.
80
+ // The sender passes folderLabel(folder, tmuxName), so a session with no
81
+ // folder still reads as its tmux name. {terminal} stays available for an
82
+ // admin who wants the raw name. These defaults are seeded with DO NOTHING
83
+ // (scripts/apply-notification-templates.mjs), so a later wording change
84
+ // never reaches an existing row.
85
+ body: '{folder} on {machine}',
86
+ actions: [{ id: 'open', label: 'Open', kind: 'open' }],
87
+ },
88
+ new_ai_session: {
89
+ enabled: true,
90
+ title: 'New agent session',
91
+ body: '{agent} in {folder} on {machine}',
92
+ actions: [{ id: 'open', label: 'Open', kind: 'open' }],
93
+ },
94
+ announcement: {
95
+ enabled: true,
96
+ title: '{title}',
97
+ body: '{body}',
98
+ actions: [],
99
+ },
100
+ };
101
+
102
+ /**
103
+ * The notification category a type registers under.
104
+ *
105
+ * Expo SDK 57's setNotificationCategoryAsync documents that an identifier may
106
+ * contain no `:` or `-`, and the failure is silent — the notification arrives
107
+ * with no buttons and nothing logs why. Stripping both from the type name
108
+ * makes `ai_question` land on exactly `aiquestion`, which is the string
109
+ * AI_CATEGORY already holds and the ws-server already sends, so notifications
110
+ * in flight during a deploy keep their Reply button.
111
+ */
112
+ export function categoryId(type: NotificationType): string {
113
+ return type.replace(/[_:-]/g, '');
114
+ }
115
+
116
+ /**
117
+ * The folder a notification should name, or `fallback` when there is none.
118
+ *
119
+ * Lives in the sender's vars rather than in the renderer on purpose. The
120
+ * renderer's rule for an empty variable is "render nothing and take the
121
+ * neighbouring separator with it", which cannot mend an English word stranded
122
+ * between two spaces ("agent in on Mac"). Only the sender knows what a sensible
123
+ * stand-in is for its type (a tmux name for a terminal, the word "session" for
124
+ * an agent), so every sender goes through this one function and none passes a
125
+ * raw nullable folder.
126
+ */
127
+ export function folderLabel(folder: string | null | undefined, fallback: string): string {
128
+ return typeof folder === 'string' && folder.trim() !== '' ? folder : fallback;
129
+ }
130
+
131
+ /**
132
+ * Whether a rendered notification is worth sending. A title alone or a body
133
+ * alone is a legitimate choice (an admin can clear either field), but with
134
+ * neither there is nothing to show: a blank banner is noise, so the sender
135
+ * skips it instead of discovering the case on a lock screen.
136
+ */
137
+ export function isSendable(rendered: { title: string; body: string }): boolean {
138
+ return rendered.title.trim() !== '' || rendered.body.trim() !== '';
139
+ }
140
+
141
+ export type TemplateVars = Record<string, string | null | undefined>;
142
+
143
+ function formatIn(iso: string, timezone: string, opts: Intl.DateTimeFormatOptions): string {
144
+ const d = new Date(iso);
145
+ if (Number.isNaN(d.getTime())) return '';
146
+ try {
147
+ return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: timezone }).format(d);
148
+ } catch {
149
+ // An unknown zone throws, and this runs while a push is being rendered.
150
+ return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: 'UTC' }).format(d);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Renders a template's title and body.
156
+ *
157
+ * Never throws: it is called on the ws-server's notify path, where an
158
+ * exception would break the registry upsert chain whose ordering other
159
+ * sessions depend on.
160
+ *
161
+ * `vars.at` is an ISO timestamp used for {time} and {date}; every other key is
162
+ * substituted literally. An unknown or empty variable renders as nothing and
163
+ * takes any separator stranded beside it with it.
164
+ */
165
+ export function renderNotification(
166
+ template: { title: string; body: string },
167
+ rawVars: TemplateVars,
168
+ timezone: string,
169
+ ): { title: string; body: string } {
170
+ // The type forbids null, but "never throws" is this function's contract and
171
+ // a JS caller or a spread of a possibly-undefined object would otherwise
172
+ // throw on the ws-server's notify path.
173
+ const vars: TemplateVars =
174
+ rawVars && typeof rawVars === 'object' && !Array.isArray(rawVars) ? rawVars : {};
175
+ const at = typeof vars.at === 'string' ? vars.at : new Date().toISOString();
176
+ const resolved: TemplateVars = {
177
+ ...vars,
178
+ time: formatIn(at, timezone, { hour: '2-digit', minute: '2-digit' }),
179
+ date: formatIn(at, timezone, { day: 'numeric', month: 'short' }),
180
+ };
181
+
182
+ // Only a value that was explicitly supplied counts, and only a string or a
183
+ // number. `resolved` is a plain object, so without the own-property check
184
+ // `{constructor}` or `{__proto__}` would resolve to Object.prototype members
185
+ // and render "function Object() { [native code] }" on a lock screen.
186
+ const lookup = (name: string): string => {
187
+ if (!Object.prototype.hasOwnProperty.call(resolved, name)) return '';
188
+ const v: unknown = resolved[name];
189
+ if (typeof v === 'string') return v.trim() === '' ? '' : v;
190
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
191
+ return '';
192
+ };
193
+
194
+ const one = (raw: unknown, max: number): string => {
195
+ if (typeof raw !== 'string') return '';
196
+ // Tidy the template skeleton, then substitute values verbatim. The order is
197
+ // the point: tidy() collapses "-", "--", "||" and trims leading/trailing
198
+ // separators, which it cannot tell apart from the same characters inside a
199
+ // value. Run after substitution it turned an agent's question
200
+ // "git push --force?" into "git push - force?", so the lock screen showed a
201
+ // different command from the one being approved, next to a Reply button.
202
+ // Surviving placeholders stand in as opaque markers while the skeleton is
203
+ // tidied, and empty ones are dropped with their neighbouring separator.
204
+ const values: string[] = [];
205
+ const skeleton = raw
206
+ .replace(new RegExp(`[${MARK_OPEN}${MARK_CLOSE}]`, 'g'), '')
207
+ .replace(/\{(\w+)\}/g, (_, name: string) => {
208
+ const value = lookup(name);
209
+ if (value === '') return '';
210
+ values.push(value);
211
+ return `${MARK_OPEN}${values.length - 1}${MARK_CLOSE}`;
212
+ });
213
+ // One pass over the tidied skeleton, never a re-scan of inserted text, so a
214
+ // value containing "{other}" cannot pull in another variable.
215
+ const markerRe = new RegExp(`${MARK_OPEN}(\\d+)${MARK_CLOSE}`, 'g');
216
+ const rendered = tidy(skeleton).replace(markerRe, (_, i: string) => values[Number(i)] ?? '');
217
+ return rendered.slice(0, max);
218
+ };
219
+
220
+ return { title: one(template?.title, TITLE_MAX), body: one(template?.body, BODY_MAX) };
221
+ }
222
+
223
+ /** Private-use code points that bracket a surviving placeholder while the skeleton is tidied. */
224
+ const MARK_OPEN = '\uE000';
225
+ const MARK_CLOSE = '\uE001';
226
+
227
+ /**
228
+ * Removes what an emptied variable leaves behind. "{question} · {folder}" with
229
+ * no folder would otherwise end "Run it? · ", which reads as truncation rather
230
+ * than as an absent field.
231
+ */
232
+ function tidy(s: string): string {
233
+ return s
234
+ // A separator with nothing on one side of it.
235
+ .replace(/\s*[·|–—-]\s*(?=$)/g, '')
236
+ .replace(/(?<=^)\s*[·|–—-]\s*/g, '')
237
+ // A separator with nothing between it and the next one.
238
+ .replace(/\s*([·|–—-])\s*\1\s*/g, ' $1 ')
239
+ .replace(/\s*[·|–—-]\s*[·|–—-]\s*/g, ' · ')
240
+ .replace(/\s+/g, ' ')
241
+ .trim()
242
+ .replace(/\s*[·|–—-]$/, '')
243
+ .replace(/^[·|–—-]\s*/, '')
244
+ .trim();
245
+ }
246
+
247
+ /** Normalises whatever the jsonb column or a request body hands over. */
248
+ export function parseActions(raw: unknown): NotificationAction[] {
249
+ let value = raw;
250
+ if (typeof value === 'string') {
251
+ try { value = JSON.parse(value); } catch { return []; }
252
+ }
253
+ if (!Array.isArray(value)) return [];
254
+
255
+ const out: NotificationAction[] = [];
256
+ const seen = new Set<string>();
257
+ for (const item of value) {
258
+ if (!item || typeof item !== 'object') continue;
259
+ const a = item as Record<string, unknown>;
260
+ const id = typeof a.id === 'string' ? a.id.trim() : '';
261
+ const label = typeof a.label === 'string' ? a.label.trim() : '';
262
+ const kind = a.kind;
263
+ if (!id || !label) continue;
264
+ if (!(ACTION_KINDS as readonly unknown[]).includes(kind)) continue;
265
+ // A duplicate id would collide in the response handler, which switches on
266
+ // actionIdentifier.
267
+ if (seen.has(id)) continue;
268
+ seen.add(id);
269
+ out.push({ id, label: label.slice(0, 32), kind: kind as ActionKind });
270
+ if (out.length === MAX_ACTIONS) break;
271
+ }
272
+ return out;
273
+ }
274
+
275
+ export type TemplatePatch = Partial<Pick<NotificationTemplate, 'enabled' | 'title' | 'body' | 'actions'>>;
276
+
277
+ export type TemplateParseResult =
278
+ | { ok: true; patch: TemplatePatch }
279
+ | { ok: false; error: string };
280
+
281
+ /**
282
+ * Validates an edit. Text is capped rather than rejected: a long title is the
283
+ * author's problem to see in the preview, not a reason to refuse the save.
284
+ */
285
+ export function parseTemplatePatch(body: unknown): TemplateParseResult {
286
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
287
+ return { ok: false, error: 'Expected an object' };
288
+ }
289
+ const b = body as Record<string, unknown>;
290
+ const patch: TemplatePatch = {};
291
+ if (typeof b.enabled === 'boolean') patch.enabled = b.enabled;
292
+ if (typeof b.title === 'string') patch.title = b.title.trim().slice(0, TITLE_MAX);
293
+ if (typeof b.body === 'string') patch.body = b.body.trim().slice(0, BODY_MAX);
294
+ if ('actions' in b) patch.actions = parseActions(b.actions);
295
+ return { ok: true, patch };
296
+ }
297
+
298
+ export function isNotificationType(t: string): t is NotificationType {
299
+ return (NOTIFICATION_TYPES as readonly string[]).includes(t);
300
+ }
301
+
302
+ /**
303
+ * Whether this notification reaches this device. Both gates must say yes.
304
+ *
305
+ * `enabled` is the admin's instance-wide switch from notification_templates;
306
+ * `userOptedIn` is the recipient's own per-device switch from user_settings.
307
+ * Admin-off means nobody receives the type; user-off means that one device
308
+ * does not.
309
+ *
310
+ * The per-device opt-out is kept deliberately rather than replaced by the
311
+ * admin switch: App Store guideline 4.5.4 and Play policy both require
312
+ * announcement push to be refusable, and removing it risks a rejection.
313
+ *
314
+ * Trivial on purpose. It exists so the rule has one name and one test instead
315
+ * of being spelled out at each of the three senders.
316
+ */
317
+ export function shouldSendNotification(enabled: boolean, userOptedIn: boolean): boolean {
318
+ return enabled && userOptedIn;
319
+ }