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