@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.
- package/README.md +4 -3
- package/dist/agent-restart.d.ts +46 -0
- package/dist/agent-restart.d.ts.map +1 -0
- package/dist/agent-restart.js +254 -0
- package/dist/agent-restart.js.map +1 -0
- package/dist/ai-sessions.d.ts +19 -0
- package/dist/ai-sessions.d.ts.map +1 -1
- package/dist/ai-sessions.js +25 -3
- package/dist/ai-sessions.js.map +1 -1
- package/dist/checklist.js +1 -1
- package/dist/checklist.js.map +1 -1
- package/dist/cli-client.d.ts +8 -0
- package/dist/cli-client.d.ts.map +1 -1
- package/dist/cli-client.js +33 -0
- package/dist/cli-client.js.map +1 -1
- package/dist/cli.js +106 -82
- package/dist/cli.js.map +1 -1
- package/dist/cowork.js +1 -1
- package/dist/has-command.d.ts +13 -0
- package/dist/has-command.d.ts.map +1 -1
- package/dist/has-command.js +92 -0
- package/dist/has-command.js.map +1 -1
- package/dist/ssh-local-network.js +1 -1
- package/dist/update-check.d.ts +9 -1
- package/dist/update-check.d.ts.map +1 -1
- package/dist/update-check.js +46 -14
- package/dist/update-check.js.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts +3 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.js +22 -2
- package/node_modules/@indianic/dialout-shared/dist/notify-template.d.ts +129 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.js +302 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.test.js +285 -0
- package/node_modules/@indianic/dialout-shared/dist/notify.d.ts +9 -0
- package/node_modules/@indianic/dialout-shared/dist/notify.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/notify.js +27 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.d.ts +20 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.js +20 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.test.js +30 -0
- package/node_modules/@indianic/dialout-shared/src/index.ts +37 -1
- package/node_modules/@indianic/dialout-shared/src/notify-template.test.ts +352 -0
- package/node_modules/@indianic/dialout-shared/src/notify-template.ts +319 -0
- package/node_modules/@indianic/dialout-shared/src/notify.ts +51 -0
- package/node_modules/@indianic/dialout-shared/src/session-announce.test.ts +48 -0
- package/node_modules/@indianic/dialout-shared/src/session-announce.ts +40 -0
- package/package.json +1 -1
- 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 @@
|
|
|
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
|
+
}
|