@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,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.12.0",
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"
@@ -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(` npm rebuild node-pty`);
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 the DevDash web UI (MACHINES tab)');
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}devdash-agent init${NC}`);
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. Start the agent:');
73
- console.log(` ${BOLD}devdash-agent start${NC}`);
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 on boot):');
76
- console.log(` ${BOLD}devdash-agent install-service${NC}`);
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('');