@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,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
|
+
}
|
|
@@ -46,3 +46,54 @@ export function shouldNotifyPrompt(
|
|
|
46
46
|
if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
|
|
47
47
|
return true;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** Which events a user wants pushed. One flag per event, no nesting. */
|
|
51
|
+
export interface PushPrefs {
|
|
52
|
+
aiQuestions: boolean;
|
|
53
|
+
newTerminal: boolean;
|
|
54
|
+
newAiSession: boolean;
|
|
55
|
+
announcements: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Per-subscription, not per-session: whether a session is worth announcing
|
|
60
|
+
* depends on which device is being told.
|
|
61
|
+
*
|
|
62
|
+
* `createdByInstallId` is null for a native tmux shell the agent merely found,
|
|
63
|
+
* which is the case most worth announcing — so a null creator always notifies,
|
|
64
|
+
* whatever the subscriber knows about itself. An unknown subscriber (one
|
|
65
|
+
* registered before install ids existed) also notifies: a missed notification
|
|
66
|
+
* is a worse failure than one extra.
|
|
67
|
+
*/
|
|
68
|
+
function notifyNewSession(
|
|
69
|
+
enabled: boolean,
|
|
70
|
+
subscriberInstallId: string | null,
|
|
71
|
+
createdByInstallId: string | null,
|
|
72
|
+
lastNotifiedAt: number | undefined,
|
|
73
|
+
nowMs: number,
|
|
74
|
+
): boolean {
|
|
75
|
+
if (!enabled) return false;
|
|
76
|
+
if (createdByInstallId !== null && subscriberInstallId === createdByInstallId) return false;
|
|
77
|
+
if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function shouldNotifyNewTerminal(
|
|
82
|
+
prefs: PushPrefs,
|
|
83
|
+
subscriberInstallId: string | null,
|
|
84
|
+
createdByInstallId: string | null,
|
|
85
|
+
lastNotifiedAt: number | undefined,
|
|
86
|
+
nowMs: number,
|
|
87
|
+
): boolean {
|
|
88
|
+
return notifyNewSession(prefs.newTerminal, subscriberInstallId, createdByInstallId, lastNotifiedAt, nowMs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function shouldNotifyNewAiSession(
|
|
92
|
+
prefs: PushPrefs,
|
|
93
|
+
subscriberInstallId: string | null,
|
|
94
|
+
createdByInstallId: string | null,
|
|
95
|
+
lastNotifiedAt: number | undefined,
|
|
96
|
+
nowMs: number,
|
|
97
|
+
): boolean {
|
|
98
|
+
return notifyNewSession(prefs.newAiSession, subscriberInstallId, createdByInstallId, lastNotifiedAt, nowMs);
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { shouldAnnounceSession } from './session-announce';
|
|
3
|
+
|
|
4
|
+
describe('shouldAnnounceSession', () => {
|
|
5
|
+
it('announces a session with no prior row at all', () => {
|
|
6
|
+
expect(shouldAnnounceSession(null, '2026-10-29T09:00:00Z')).toBe(true);
|
|
7
|
+
expect(shouldAnnounceSession(null, null)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// The reported bug. The agent reconnects, the row was marked ended, and the
|
|
11
|
+
// same tmux session is re-reported — it is not news.
|
|
12
|
+
it('stays quiet when the prior row is the same session by creation time', () => {
|
|
13
|
+
expect(shouldAnnounceSession(
|
|
14
|
+
{ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: null },
|
|
15
|
+
'2026-10-29T07:00:00Z',
|
|
16
|
+
)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('announces a genuinely new session that reuses a name', () => {
|
|
20
|
+
expect(shouldAnnounceSession(
|
|
21
|
+
{ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: '2026-10-29T07:00:01Z' },
|
|
22
|
+
'2026-10-29T09:30:00Z',
|
|
23
|
+
)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Durable suppression: lastNewNotifiedAt is in memory, so a ws-server
|
|
27
|
+
// restart must not replay an announcement the database already recorded.
|
|
28
|
+
it('stays quiet when the prior row was already announced and is still live', () => {
|
|
29
|
+
expect(shouldAnnounceSession(
|
|
30
|
+
{ endedAt: null, createdLocal: '2026-10-29T07:00:00Z', notifiedAt: '2026-10-29T07:00:01Z' },
|
|
31
|
+
'2026-10-29T07:00:00Z',
|
|
32
|
+
)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The documented reversal of the original push spec. Without a creation time
|
|
36
|
+
// there is no way to tell a revival from a new session, and the complaint is
|
|
37
|
+
// bursts, so silence is the right direction here.
|
|
38
|
+
it('treats a missing creation time on either side as the same session', () => {
|
|
39
|
+
expect(shouldAnnounceSession(
|
|
40
|
+
{ endedAt: '2026-10-29T08:00:00Z', createdLocal: null, notifiedAt: null },
|
|
41
|
+
'2026-10-29T09:00:00Z',
|
|
42
|
+
)).toBe(false);
|
|
43
|
+
expect(shouldAnnounceSession(
|
|
44
|
+
{ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: null },
|
|
45
|
+
null,
|
|
46
|
+
)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// packages/devdash-shared/src/session-announce.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a tmux session the agent just reported is news.
|
|
5
|
+
*
|
|
6
|
+
* The bug this exists to kill: the daemon's close handler used to set
|
|
7
|
+
* `ended_at` on every session, so a reconnect made the registry's UPDATE miss,
|
|
8
|
+
* take the INSERT branch, and push "New terminal" for every terminal that was
|
|
9
|
+
* already open. Task 2 stops writing `ended_at` there; this is the second
|
|
10
|
+
* line of defence, for a session the vanish-pass legitimately ended and that
|
|
11
|
+
* then came back.
|
|
12
|
+
*/
|
|
13
|
+
export interface PriorSession {
|
|
14
|
+
/** Null while the session is live. */
|
|
15
|
+
endedAt: string | null;
|
|
16
|
+
/** When tmux created the session, as the agent reported it. */
|
|
17
|
+
createdLocal: string | null;
|
|
18
|
+
/** When a push for this row went out, or null if none ever did. */
|
|
19
|
+
notifiedAt: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shouldAnnounceSession(
|
|
23
|
+
prior: PriorSession | null,
|
|
24
|
+
createdLocal: string | null,
|
|
25
|
+
): boolean {
|
|
26
|
+
// Never seen on this machine under this name: unambiguously new.
|
|
27
|
+
if (!prior) return true;
|
|
28
|
+
|
|
29
|
+
// Both creation times known and different: tmux really did make a new
|
|
30
|
+
// session that happens to reuse the name.
|
|
31
|
+
if (prior.createdLocal && createdLocal && prior.createdLocal !== createdLocal) return true;
|
|
32
|
+
|
|
33
|
+
// Everything else is the same session returning — including the case where
|
|
34
|
+
// either creation time is missing. The original push spec said a missed
|
|
35
|
+
// notification is worse than an extra one; for this trigger that is
|
|
36
|
+
// backwards, because the failure being fixed is a burst. The cost is that an
|
|
37
|
+
// agent too old to report a creation time cannot announce a genuinely new
|
|
38
|
+
// session that reuses an old name.
|
|
39
|
+
return false;
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indianic/dialout",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Dialout agent — reach your office, home or build machine from any browser or phone. Remote tmux terminals, live port checks, filesystem browsing, process control and HTTP tunnels. The agent dials out: no inbound ports, no VPN, no port forwarding.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"dialout": "./bin/devdash-agent.js"
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const { execSync } = require('child_process');
|
|
4
3
|
const os = require('os');
|
|
5
4
|
|
|
6
5
|
const GREEN = '\x1b[32m';
|
|
@@ -51,7 +50,7 @@ console.log('');
|
|
|
51
50
|
|
|
52
51
|
if (!ptyOk) {
|
|
53
52
|
console.log(`${YELLOW} node-pty failed to load. Try rebuilding:${NC}`);
|
|
54
|
-
console.log(
|
|
53
|
+
console.log(' npm rebuild node-pty');
|
|
55
54
|
console.log('');
|
|
56
55
|
}
|
|
57
56
|
|
|
@@ -64,16 +63,16 @@ if (nodeOk && platformOk && ptyOk) {
|
|
|
64
63
|
console.log('');
|
|
65
64
|
console.log(`${CYAN}Next steps:${NC}`);
|
|
66
65
|
console.log('');
|
|
67
|
-
console.log(' 1. Generate an API key in
|
|
66
|
+
console.log(' 1. Generate an API key in Dialout (Settings → Machines)');
|
|
68
67
|
console.log('');
|
|
69
|
-
console.log(' 2. Configure the agent:');
|
|
70
|
-
console.log(` ${BOLD}
|
|
68
|
+
console.log(' 2. Configure the agent (includes remote-control setup):');
|
|
69
|
+
console.log(` ${BOLD}dialout init${NC}`);
|
|
71
70
|
console.log('');
|
|
72
|
-
console.log(' 3.
|
|
73
|
-
console.log(` ${BOLD}
|
|
71
|
+
console.log(' 3. Or wrap terminals later:');
|
|
72
|
+
console.log(` ${BOLD}dialout rc${NC}`);
|
|
74
73
|
console.log('');
|
|
75
|
-
console.log(' 4. Install as service (auto-start
|
|
76
|
-
console.log(` ${BOLD}
|
|
74
|
+
console.log(' 4. Install as a service (auto-start):');
|
|
75
|
+
console.log(` ${BOLD}dialout install-service${NC}`);
|
|
77
76
|
console.log('');
|
|
78
77
|
console.log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}`);
|
|
79
78
|
console.log('');
|