@indianic/dialout 1.12.1 → 1.13.1

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 (46) hide show
  1. package/README.md +4 -3
  2. package/dist/agent-restart.d.ts +46 -0
  3. package/dist/agent-restart.d.ts.map +1 -0
  4. package/dist/agent-restart.js +254 -0
  5. package/dist/agent-restart.js.map +1 -0
  6. package/dist/checklist.js +1 -1
  7. package/dist/checklist.js.map +1 -1
  8. package/dist/cli-client.d.ts +8 -0
  9. package/dist/cli-client.d.ts.map +1 -1
  10. package/dist/cli-client.js +33 -0
  11. package/dist/cli-client.js.map +1 -1
  12. package/dist/cli.js +106 -82
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cowork.js +1 -1
  15. package/dist/has-command.d.ts +13 -0
  16. package/dist/has-command.d.ts.map +1 -1
  17. package/dist/has-command.js +92 -0
  18. package/dist/has-command.js.map +1 -1
  19. package/dist/ssh-local-network.js +1 -1
  20. package/dist/tmux-manager.d.ts +9 -0
  21. package/dist/tmux-manager.d.ts.map +1 -1
  22. package/dist/tmux-manager.js +31 -2
  23. package/dist/tmux-manager.js.map +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/dist/websocket.d.ts.map +1 -1
  29. package/dist/websocket.js +10 -1
  30. package/dist/websocket.js.map +1 -1
  31. package/node_modules/@indianic/dialout-shared/dist/index.d.ts +2 -0
  32. package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
  33. package/node_modules/@indianic/dialout-shared/dist/index.js +20 -2
  34. package/node_modules/@indianic/dialout-shared/dist/notify-template.d.ts +129 -0
  35. package/node_modules/@indianic/dialout-shared/dist/notify-template.d.ts.map +1 -0
  36. package/node_modules/@indianic/dialout-shared/dist/notify-template.js +302 -0
  37. package/node_modules/@indianic/dialout-shared/dist/notify-template.test.d.ts +2 -0
  38. package/node_modules/@indianic/dialout-shared/dist/notify-template.test.d.ts.map +1 -0
  39. package/node_modules/@indianic/dialout-shared/dist/notify-template.test.js +285 -0
  40. package/node_modules/@indianic/dialout-shared/src/index.ts +29 -0
  41. package/node_modules/@indianic/dialout-shared/src/notify-template.test.ts +352 -0
  42. package/node_modules/@indianic/dialout-shared/src/notify-template.ts +319 -0
  43. package/node_modules/@indianic/dialout-shared/src/session-announce.test.ts +48 -0
  44. package/node_modules/@indianic/dialout-shared/src/session-announce.ts +40 -0
  45. package/package.json +2 -2
  46. package/scripts/postinstall.js +8 -9
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.promptMoveKeys = exports.promptKeys = exports.isSafeKeyList = exports.isFreeTextOption = exports.freeTextRow = exports.detectPanePrompt = exports.PROMPT_KEYS = exports.thinkingLabel = exports.thinkingDuration = exports.parseCapturePage = exports.MAX_CAPTURE_PAGE = exports.EMPTY_CAPTURE = exports.mergeEvents = exports.EMPTY_TRANSCRIPT = exports.resolveTmuxPrefs = exports.isValidTmuxColor = exports.isValidStatusTemplate = exports.compileStatusTemplate = exports.MAX_STATUS_TEMPLATE = exports.STATUS_TOKENS = exports.DEFAULT_TMUX_PREFS = exports.renderTerminalName = exports.factsFromSession = exports.TERMINAL_NAME_TOKENS = exports.DEFAULT_TERMINAL_TEMPLATE = exports.setFunctionKeysVisible = exports.getFunctionKeysVisible = exports.FUNCTION_KEYS_PREF = exports.shouldSubmitOnEnter = exports.COARSE_POINTER_QUERY = exports.rankCommands = exports.commandQuery = exports.toolAppearance = exports.toolClass = exports.shouldFollow = exports.PIN_THRESHOLD_PX = exports.readUserMessage = exports.dedent = exports.toolGroupStartsOpen = exports.groupEvents = exports.shouldNotifyNewAiSession = exports.shouldNotifyNewTerminal = exports.shouldNotifyPrompt = exports.shouldNotifyAi = exports.NOTIFY_COOLDOWN_MS = exports.REPLAY_LIMIT = exports.isPermissionMode = exports.PERMISSION_MODES = exports.preview = exports.PREVIEW_LIMIT = void 0;
4
- exports.splitAttachments = exports.safeAttachmentName = exports.isImageName = exports.composeWithAttachments = exports.ATTACHMENT_MAX_FILES = exports.ATTACHMENT_MAX_BYTES = exports.ATTACHMENT_DIR = void 0;
3
+ exports.promptKeys = exports.isSafeKeyList = exports.isFreeTextOption = exports.freeTextRow = exports.detectPanePrompt = exports.PROMPT_KEYS = exports.thinkingLabel = exports.thinkingDuration = exports.parseCapturePage = exports.MAX_CAPTURE_PAGE = exports.EMPTY_CAPTURE = exports.mergeEvents = exports.EMPTY_TRANSCRIPT = exports.resolveTmuxPrefs = exports.isValidTmuxColor = exports.isValidStatusTemplate = exports.compileStatusTemplate = exports.MAX_STATUS_TEMPLATE = exports.STATUS_TOKENS = exports.DEFAULT_TMUX_PREFS = exports.renderTerminalName = exports.factsFromSession = exports.TERMINAL_NAME_TOKENS = exports.DEFAULT_TERMINAL_TEMPLATE = exports.setFunctionKeysVisible = exports.getFunctionKeysVisible = exports.FUNCTION_KEYS_PREF = exports.shouldSubmitOnEnter = exports.COARSE_POINTER_QUERY = exports.rankCommands = exports.commandQuery = exports.toolAppearance = exports.toolClass = exports.shouldFollow = exports.PIN_THRESHOLD_PX = exports.readUserMessage = exports.dedent = exports.toolGroupStartsOpen = exports.groupEvents = exports.shouldAnnounceSession = exports.shouldNotifyNewAiSession = exports.shouldNotifyNewTerminal = exports.shouldNotifyPrompt = exports.shouldNotifyAi = exports.NOTIFY_COOLDOWN_MS = exports.REPLAY_LIMIT = exports.isPermissionMode = exports.PERMISSION_MODES = exports.preview = exports.PREVIEW_LIMIT = void 0;
4
+ exports.isSendable = exports.folderLabel = exports.shouldSendNotification = exports.isNotificationType = exports.parseTemplatePatch = exports.parseActions = exports.renderNotification = exports.categoryId = exports.MAX_ACTIONS = exports.BODY_MAX = exports.TITLE_MAX = exports.DEFAULT_TEMPLATES = exports.TEMPLATE_VARIABLES = exports.ACTION_KINDS = exports.NOTIFICATION_TYPES = exports.splitAttachments = exports.safeAttachmentName = exports.isImageName = exports.composeWithAttachments = exports.ATTACHMENT_MAX_FILES = exports.ATTACHMENT_MAX_BYTES = exports.ATTACHMENT_DIR = exports.promptMoveKeys = void 0;
5
5
  var events_1 = require("./events");
6
6
  Object.defineProperty(exports, "PREVIEW_LIMIT", { enumerable: true, get: function () { return events_1.PREVIEW_LIMIT; } });
7
7
  Object.defineProperty(exports, "preview", { enumerable: true, get: function () { return events_1.preview; } });
@@ -16,6 +16,8 @@ Object.defineProperty(exports, "shouldNotifyAi", { enumerable: true, get: functi
16
16
  Object.defineProperty(exports, "shouldNotifyPrompt", { enumerable: true, get: function () { return notify_1.shouldNotifyPrompt; } });
17
17
  Object.defineProperty(exports, "shouldNotifyNewTerminal", { enumerable: true, get: function () { return notify_1.shouldNotifyNewTerminal; } });
18
18
  Object.defineProperty(exports, "shouldNotifyNewAiSession", { enumerable: true, get: function () { return notify_1.shouldNotifyNewAiSession; } });
19
+ var session_announce_1 = require("./session-announce");
20
+ Object.defineProperty(exports, "shouldAnnounceSession", { enumerable: true, get: function () { return session_announce_1.shouldAnnounceSession; } });
19
21
  var chat_blocks_1 = require("./chat-blocks");
20
22
  Object.defineProperty(exports, "groupEvents", { enumerable: true, get: function () { return chat_blocks_1.groupEvents; } });
21
23
  Object.defineProperty(exports, "toolGroupStartsOpen", { enumerable: true, get: function () { return chat_blocks_1.toolGroupStartsOpen; } });
@@ -77,3 +79,19 @@ Object.defineProperty(exports, "composeWithAttachments", { enumerable: true, get
77
79
  Object.defineProperty(exports, "isImageName", { enumerable: true, get: function () { return attachments_1.isImageName; } });
78
80
  Object.defineProperty(exports, "safeAttachmentName", { enumerable: true, get: function () { return attachments_1.safeAttachmentName; } });
79
81
  Object.defineProperty(exports, "splitAttachments", { enumerable: true, get: function () { return attachments_1.splitAttachments; } });
82
+ var notify_template_1 = require("./notify-template");
83
+ Object.defineProperty(exports, "NOTIFICATION_TYPES", { enumerable: true, get: function () { return notify_template_1.NOTIFICATION_TYPES; } });
84
+ Object.defineProperty(exports, "ACTION_KINDS", { enumerable: true, get: function () { return notify_template_1.ACTION_KINDS; } });
85
+ Object.defineProperty(exports, "TEMPLATE_VARIABLES", { enumerable: true, get: function () { return notify_template_1.TEMPLATE_VARIABLES; } });
86
+ Object.defineProperty(exports, "DEFAULT_TEMPLATES", { enumerable: true, get: function () { return notify_template_1.DEFAULT_TEMPLATES; } });
87
+ Object.defineProperty(exports, "TITLE_MAX", { enumerable: true, get: function () { return notify_template_1.TITLE_MAX; } });
88
+ Object.defineProperty(exports, "BODY_MAX", { enumerable: true, get: function () { return notify_template_1.BODY_MAX; } });
89
+ Object.defineProperty(exports, "MAX_ACTIONS", { enumerable: true, get: function () { return notify_template_1.MAX_ACTIONS; } });
90
+ Object.defineProperty(exports, "categoryId", { enumerable: true, get: function () { return notify_template_1.categoryId; } });
91
+ Object.defineProperty(exports, "renderNotification", { enumerable: true, get: function () { return notify_template_1.renderNotification; } });
92
+ Object.defineProperty(exports, "parseActions", { enumerable: true, get: function () { return notify_template_1.parseActions; } });
93
+ Object.defineProperty(exports, "parseTemplatePatch", { enumerable: true, get: function () { return notify_template_1.parseTemplatePatch; } });
94
+ Object.defineProperty(exports, "isNotificationType", { enumerable: true, get: function () { return notify_template_1.isNotificationType; } });
95
+ Object.defineProperty(exports, "shouldSendNotification", { enumerable: true, get: function () { return notify_template_1.shouldSendNotification; } });
96
+ Object.defineProperty(exports, "folderLabel", { enumerable: true, get: function () { return notify_template_1.folderLabel; } });
97
+ Object.defineProperty(exports, "isSendable", { enumerable: true, get: function () { return notify_template_1.isSendable; } });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * What an automatic notification says, and the buttons it carries.
3
+ *
4
+ * Pure, and here rather than in the ws-server for the same reason the
5
+ * shouldNotify* family is: src/ws-server/index.ts is one deliberately large
6
+ * file with no unit seams, so decision and rendering logic added inline there
7
+ * would be untestable. The ws-server stays the only side-effecting caller.
8
+ */
9
+ export declare const NOTIFICATION_TYPES: readonly ["ai_question", "new_terminal", "new_ai_session", "announcement"];
10
+ export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
11
+ /** A closed set, so a configured button cannot ask for behaviour the app lacks. */
12
+ export declare const ACTION_KINDS: readonly ["open", "reply"];
13
+ export type ActionKind = (typeof ACTION_KINDS)[number];
14
+ export interface NotificationAction {
15
+ id: string;
16
+ label: string;
17
+ kind: ActionKind;
18
+ }
19
+ export interface NotificationTemplate {
20
+ type: NotificationType;
21
+ enabled: boolean;
22
+ title: string;
23
+ body: string;
24
+ actions: NotificationAction[];
25
+ }
26
+ /** Matches src/app/api/push/broadcast/route.ts, and both platforms truncate anyway. */
27
+ export declare const TITLE_MAX = 80;
28
+ export declare const BODY_MAX = 240;
29
+ /** iOS shows four actions on an expanded notification. */
30
+ export declare const 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
+ export declare const TEMPLATE_VARIABLES: Record<NotificationType, string[]>;
38
+ /**
39
+ * Today's hardcoded wording, so shipping the feature changes nothing about
40
+ * what anyone receives. Mirrored in scripts/apply-notification-templates.mjs,
41
+ * which cannot import the built package — a drift between the two is a visible
42
+ * difference in a notification rather than a silent failure.
43
+ */
44
+ export declare const DEFAULT_TEMPLATES: Record<NotificationType, Omit<NotificationTemplate, 'type'>>;
45
+ /**
46
+ * The notification category a type registers under.
47
+ *
48
+ * Expo SDK 57's setNotificationCategoryAsync documents that an identifier may
49
+ * contain no `:` or `-`, and the failure is silent — the notification arrives
50
+ * with no buttons and nothing logs why. Stripping both from the type name
51
+ * makes `ai_question` land on exactly `aiquestion`, which is the string
52
+ * AI_CATEGORY already holds and the ws-server already sends, so notifications
53
+ * in flight during a deploy keep their Reply button.
54
+ */
55
+ export declare function categoryId(type: NotificationType): string;
56
+ /**
57
+ * The folder a notification should name, or `fallback` when there is none.
58
+ *
59
+ * Lives in the sender's vars rather than in the renderer on purpose. The
60
+ * renderer's rule for an empty variable is "render nothing and take the
61
+ * neighbouring separator with it", which cannot mend an English word stranded
62
+ * between two spaces ("agent in on Mac"). Only the sender knows what a sensible
63
+ * stand-in is for its type (a tmux name for a terminal, the word "session" for
64
+ * an agent), so every sender goes through this one function and none passes a
65
+ * raw nullable folder.
66
+ */
67
+ export declare function folderLabel(folder: string | null | undefined, fallback: string): string;
68
+ /**
69
+ * Whether a rendered notification is worth sending. A title alone or a body
70
+ * alone is a legitimate choice (an admin can clear either field), but with
71
+ * neither there is nothing to show: a blank banner is noise, so the sender
72
+ * skips it instead of discovering the case on a lock screen.
73
+ */
74
+ export declare function isSendable(rendered: {
75
+ title: string;
76
+ body: string;
77
+ }): boolean;
78
+ export type TemplateVars = Record<string, string | null | undefined>;
79
+ /**
80
+ * Renders a template's title and body.
81
+ *
82
+ * Never throws: it is called on the ws-server's notify path, where an
83
+ * exception would break the registry upsert chain whose ordering other
84
+ * sessions depend on.
85
+ *
86
+ * `vars.at` is an ISO timestamp used for {time} and {date}; every other key is
87
+ * substituted literally. An unknown or empty variable renders as nothing and
88
+ * takes any separator stranded beside it with it.
89
+ */
90
+ export declare function renderNotification(template: {
91
+ title: string;
92
+ body: string;
93
+ }, rawVars: TemplateVars, timezone: string): {
94
+ title: string;
95
+ body: string;
96
+ };
97
+ /** Normalises whatever the jsonb column or a request body hands over. */
98
+ export declare function parseActions(raw: unknown): NotificationAction[];
99
+ export type TemplatePatch = Partial<Pick<NotificationTemplate, 'enabled' | 'title' | 'body' | 'actions'>>;
100
+ export type TemplateParseResult = {
101
+ ok: true;
102
+ patch: TemplatePatch;
103
+ } | {
104
+ ok: false;
105
+ error: string;
106
+ };
107
+ /**
108
+ * Validates an edit. Text is capped rather than rejected: a long title is the
109
+ * author's problem to see in the preview, not a reason to refuse the save.
110
+ */
111
+ export declare function parseTemplatePatch(body: unknown): TemplateParseResult;
112
+ export declare function isNotificationType(t: string): t is NotificationType;
113
+ /**
114
+ * Whether this notification reaches this device. Both gates must say yes.
115
+ *
116
+ * `enabled` is the admin's instance-wide switch from notification_templates;
117
+ * `userOptedIn` is the recipient's own per-device switch from user_settings.
118
+ * Admin-off means nobody receives the type; user-off means that one device
119
+ * does not.
120
+ *
121
+ * The per-device opt-out is kept deliberately rather than replaced by the
122
+ * admin switch: App Store guideline 4.5.4 and Play policy both require
123
+ * announcement push to be refusable, and removing it risks a rejection.
124
+ *
125
+ * Trivial on purpose. It exists so the rule has one name and one test instead
126
+ * of being spelled out at each of the three senders.
127
+ */
128
+ export declare function shouldSendNotification(enabled: boolean, userOptedIn: boolean): boolean;
129
+ //# sourceMappingURL=notify-template.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify-template.d.ts","sourceRoot":"","sources":["../src/notify-template.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH,eAAO,MAAM,kBAAkB,4EAErB,CAAC;AACX,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnE,mFAAmF;AACnF,eAAO,MAAM,YAAY,4BAA6B,CAAC;AACvD,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;AAEvD,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,gBAAgB,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,kBAAkB,EAAE,CAAC;CAC/B;AAED,uFAAuF;AACvF,eAAO,MAAM,SAAS,KAAK,CAAC;AAC5B,eAAO,MAAM,QAAQ,MAAM,CAAC;AAC5B,0DAA0D;AAC1D,eAAO,MAAM,WAAW,IAAI,CAAC;AAE7B;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAQjE,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,gBAAgB,EAAE,IAAI,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAqC1F,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEvF;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAE7E;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;AAarE;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EACzC,OAAO,EAAE,YAAY,EACrB,QAAQ,EAAE,MAAM,GACf;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAoDjC;AA0BD,yEAAyE;AACzE,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,kBAAkB,EAAE,CAyB/D;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,oBAAoB,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAE1G,MAAM,MAAM,mBAAmB,GAC3B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,aAAa,CAAA;CAAE,GAClC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEjC;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,OAAO,GAAG,mBAAmB,CAWrE;AAED,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,CAAC,IAAI,gBAAgB,CAEnE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,GAAG,OAAO,CAEtF"}
@@ -0,0 +1,302 @@
1
+ "use strict";
2
+ // packages/devdash-shared/src/notify-template.ts
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.DEFAULT_TEMPLATES = exports.TEMPLATE_VARIABLES = exports.MAX_ACTIONS = exports.BODY_MAX = exports.TITLE_MAX = exports.ACTION_KINDS = exports.NOTIFICATION_TYPES = void 0;
5
+ exports.categoryId = categoryId;
6
+ exports.folderLabel = folderLabel;
7
+ exports.isSendable = isSendable;
8
+ exports.renderNotification = renderNotification;
9
+ exports.parseActions = parseActions;
10
+ exports.parseTemplatePatch = parseTemplatePatch;
11
+ exports.isNotificationType = isNotificationType;
12
+ exports.shouldSendNotification = shouldSendNotification;
13
+ /**
14
+ * What an automatic notification says, and the buttons it carries.
15
+ *
16
+ * Pure, and here rather than in the ws-server for the same reason the
17
+ * shouldNotify* family is: src/ws-server/index.ts is one deliberately large
18
+ * file with no unit seams, so decision and rendering logic added inline there
19
+ * would be untestable. The ws-server stays the only side-effecting caller.
20
+ */
21
+ exports.NOTIFICATION_TYPES = [
22
+ 'ai_question', 'new_terminal', 'new_ai_session', 'announcement',
23
+ ];
24
+ /** A closed set, so a configured button cannot ask for behaviour the app lacks. */
25
+ exports.ACTION_KINDS = ['open', 'reply'];
26
+ /** Matches src/app/api/push/broadcast/route.ts, and both platforms truncate anyway. */
27
+ exports.TITLE_MAX = 80;
28
+ exports.BODY_MAX = 240;
29
+ /** iOS shows four actions on an expanded notification. */
30
+ exports.MAX_ACTIONS = 4;
31
+ /**
32
+ * Which variables each type can supply. Per type, not global, because they are
33
+ * not interchangeable: {question} exists for an agent question and not for a
34
+ * new terminal, and offering it everywhere would produce templates that render
35
+ * a gap on every send.
36
+ */
37
+ exports.TEMPLATE_VARIABLES = {
38
+ ai_question: ['agent', 'what', 'kind', 'question', 'folder', 'branch', 'machine', 'session', 'time', 'date'],
39
+ new_terminal: ['machine', 'terminal', 'folder', 'branch', 'time', 'date'],
40
+ new_ai_session: ['agent', 'machine', 'session', 'folder', 'time', 'date'],
41
+ // An announcement has no machine, terminal or session behind it. {title} and
42
+ // {body} are what the admin typed in the compose form; the template supplies
43
+ // the wrapper and the buttons.
44
+ announcement: ['title', 'body', 'user', 'time', 'date'],
45
+ };
46
+ /**
47
+ * Today's hardcoded wording, so shipping the feature changes nothing about
48
+ * what anyone receives. Mirrored in scripts/apply-notification-templates.mjs,
49
+ * which cannot import the built package — a drift between the two is a visible
50
+ * difference in a notification rather than a silent failure.
51
+ */
52
+ exports.DEFAULT_TEMPLATES = {
53
+ ai_question: {
54
+ enabled: true,
55
+ // {what} is "needs you" or "finished". One template string cannot branch, so
56
+ // the distinction is a variable: a finished job must not read as a blocked one.
57
+ title: '{agent} {what}',
58
+ body: '{question} · {kind} · {folder}',
59
+ actions: [
60
+ { id: 'reply', label: 'Reply', kind: 'reply' },
61
+ { id: 'open', label: 'Open', kind: 'open' },
62
+ ],
63
+ },
64
+ new_terminal: {
65
+ enabled: true,
66
+ title: 'New terminal',
67
+ // {folder}, not {terminal}: the push this replaced named a terminal by its
68
+ // folder, and for a browser session the tmux name is an opaque dd-<uuid>.
69
+ // The sender passes folderLabel(folder, tmuxName), so a session with no
70
+ // folder still reads as its tmux name. {terminal} stays available for an
71
+ // admin who wants the raw name. These defaults are seeded with DO NOTHING
72
+ // (scripts/apply-notification-templates.mjs), so a later wording change
73
+ // never reaches an existing row.
74
+ body: '{folder} on {machine}',
75
+ actions: [{ id: 'open', label: 'Open', kind: 'open' }],
76
+ },
77
+ new_ai_session: {
78
+ enabled: true,
79
+ title: 'New agent session',
80
+ body: '{agent} in {folder} on {machine}',
81
+ actions: [{ id: 'open', label: 'Open', kind: 'open' }],
82
+ },
83
+ announcement: {
84
+ enabled: true,
85
+ title: '{title}',
86
+ body: '{body}',
87
+ actions: [],
88
+ },
89
+ };
90
+ /**
91
+ * The notification category a type registers under.
92
+ *
93
+ * Expo SDK 57's setNotificationCategoryAsync documents that an identifier may
94
+ * contain no `:` or `-`, and the failure is silent — the notification arrives
95
+ * with no buttons and nothing logs why. Stripping both from the type name
96
+ * makes `ai_question` land on exactly `aiquestion`, which is the string
97
+ * AI_CATEGORY already holds and the ws-server already sends, so notifications
98
+ * in flight during a deploy keep their Reply button.
99
+ */
100
+ function categoryId(type) {
101
+ return type.replace(/[_:-]/g, '');
102
+ }
103
+ /**
104
+ * The folder a notification should name, or `fallback` when there is none.
105
+ *
106
+ * Lives in the sender's vars rather than in the renderer on purpose. The
107
+ * renderer's rule for an empty variable is "render nothing and take the
108
+ * neighbouring separator with it", which cannot mend an English word stranded
109
+ * between two spaces ("agent in on Mac"). Only the sender knows what a sensible
110
+ * stand-in is for its type (a tmux name for a terminal, the word "session" for
111
+ * an agent), so every sender goes through this one function and none passes a
112
+ * raw nullable folder.
113
+ */
114
+ function folderLabel(folder, fallback) {
115
+ return typeof folder === 'string' && folder.trim() !== '' ? folder : fallback;
116
+ }
117
+ /**
118
+ * Whether a rendered notification is worth sending. A title alone or a body
119
+ * alone is a legitimate choice (an admin can clear either field), but with
120
+ * neither there is nothing to show: a blank banner is noise, so the sender
121
+ * skips it instead of discovering the case on a lock screen.
122
+ */
123
+ function isSendable(rendered) {
124
+ return rendered.title.trim() !== '' || rendered.body.trim() !== '';
125
+ }
126
+ function formatIn(iso, timezone, opts) {
127
+ const d = new Date(iso);
128
+ if (Number.isNaN(d.getTime()))
129
+ return '';
130
+ try {
131
+ return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: timezone }).format(d);
132
+ }
133
+ catch {
134
+ // An unknown zone throws, and this runs while a push is being rendered.
135
+ return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: 'UTC' }).format(d);
136
+ }
137
+ }
138
+ /**
139
+ * Renders a template's title and body.
140
+ *
141
+ * Never throws: it is called on the ws-server's notify path, where an
142
+ * exception would break the registry upsert chain whose ordering other
143
+ * sessions depend on.
144
+ *
145
+ * `vars.at` is an ISO timestamp used for {time} and {date}; every other key is
146
+ * substituted literally. An unknown or empty variable renders as nothing and
147
+ * takes any separator stranded beside it with it.
148
+ */
149
+ function renderNotification(template, rawVars, timezone) {
150
+ // The type forbids null, but "never throws" is this function's contract and
151
+ // a JS caller or a spread of a possibly-undefined object would otherwise
152
+ // throw on the ws-server's notify path.
153
+ const vars = rawVars && typeof rawVars === 'object' && !Array.isArray(rawVars) ? rawVars : {};
154
+ const at = typeof vars.at === 'string' ? vars.at : new Date().toISOString();
155
+ const resolved = {
156
+ ...vars,
157
+ time: formatIn(at, timezone, { hour: '2-digit', minute: '2-digit' }),
158
+ date: formatIn(at, timezone, { day: 'numeric', month: 'short' }),
159
+ };
160
+ // Only a value that was explicitly supplied counts, and only a string or a
161
+ // number. `resolved` is a plain object, so without the own-property check
162
+ // `{constructor}` or `{__proto__}` would resolve to Object.prototype members
163
+ // and render "function Object() { [native code] }" on a lock screen.
164
+ const lookup = (name) => {
165
+ if (!Object.prototype.hasOwnProperty.call(resolved, name))
166
+ return '';
167
+ const v = resolved[name];
168
+ if (typeof v === 'string')
169
+ return v.trim() === '' ? '' : v;
170
+ if (typeof v === 'number' && Number.isFinite(v))
171
+ return String(v);
172
+ return '';
173
+ };
174
+ const one = (raw, max) => {
175
+ if (typeof raw !== 'string')
176
+ return '';
177
+ // Tidy the template skeleton, then substitute values verbatim. The order is
178
+ // the point: tidy() collapses "-", "--", "||" and trims leading/trailing
179
+ // separators, which it cannot tell apart from the same characters inside a
180
+ // value. Run after substitution it turned an agent's question
181
+ // "git push --force?" into "git push - force?", so the lock screen showed a
182
+ // different command from the one being approved, next to a Reply button.
183
+ // Surviving placeholders stand in as opaque markers while the skeleton is
184
+ // tidied, and empty ones are dropped with their neighbouring separator.
185
+ const values = [];
186
+ const skeleton = raw
187
+ .replace(new RegExp(`[${MARK_OPEN}${MARK_CLOSE}]`, 'g'), '')
188
+ .replace(/\{(\w+)\}/g, (_, name) => {
189
+ const value = lookup(name);
190
+ if (value === '')
191
+ return '';
192
+ values.push(value);
193
+ return `${MARK_OPEN}${values.length - 1}${MARK_CLOSE}`;
194
+ });
195
+ // One pass over the tidied skeleton, never a re-scan of inserted text, so a
196
+ // value containing "{other}" cannot pull in another variable.
197
+ const markerRe = new RegExp(`${MARK_OPEN}(\\d+)${MARK_CLOSE}`, 'g');
198
+ const rendered = tidy(skeleton).replace(markerRe, (_, i) => values[Number(i)] ?? '');
199
+ return rendered.slice(0, max);
200
+ };
201
+ return { title: one(template?.title, exports.TITLE_MAX), body: one(template?.body, exports.BODY_MAX) };
202
+ }
203
+ /** Private-use code points that bracket a surviving placeholder while the skeleton is tidied. */
204
+ const MARK_OPEN = '\uE000';
205
+ const MARK_CLOSE = '\uE001';
206
+ /**
207
+ * Removes what an emptied variable leaves behind. "{question} · {folder}" with
208
+ * no folder would otherwise end "Run it? · ", which reads as truncation rather
209
+ * than as an absent field.
210
+ */
211
+ function tidy(s) {
212
+ return s
213
+ // A separator with nothing on one side of it.
214
+ .replace(/\s*[·|–—-]\s*(?=$)/g, '')
215
+ .replace(/(?<=^)\s*[·|–—-]\s*/g, '')
216
+ // A separator with nothing between it and the next one.
217
+ .replace(/\s*([·|–—-])\s*\1\s*/g, ' $1 ')
218
+ .replace(/\s*[·|–—-]\s*[·|–—-]\s*/g, ' · ')
219
+ .replace(/\s+/g, ' ')
220
+ .trim()
221
+ .replace(/\s*[·|–—-]$/, '')
222
+ .replace(/^[·|–—-]\s*/, '')
223
+ .trim();
224
+ }
225
+ /** Normalises whatever the jsonb column or a request body hands over. */
226
+ function parseActions(raw) {
227
+ let value = raw;
228
+ if (typeof value === 'string') {
229
+ try {
230
+ value = JSON.parse(value);
231
+ }
232
+ catch {
233
+ return [];
234
+ }
235
+ }
236
+ if (!Array.isArray(value))
237
+ return [];
238
+ const out = [];
239
+ const seen = new Set();
240
+ for (const item of value) {
241
+ if (!item || typeof item !== 'object')
242
+ continue;
243
+ const a = item;
244
+ const id = typeof a.id === 'string' ? a.id.trim() : '';
245
+ const label = typeof a.label === 'string' ? a.label.trim() : '';
246
+ const kind = a.kind;
247
+ if (!id || !label)
248
+ continue;
249
+ if (!exports.ACTION_KINDS.includes(kind))
250
+ continue;
251
+ // A duplicate id would collide in the response handler, which switches on
252
+ // actionIdentifier.
253
+ if (seen.has(id))
254
+ continue;
255
+ seen.add(id);
256
+ out.push({ id, label: label.slice(0, 32), kind: kind });
257
+ if (out.length === exports.MAX_ACTIONS)
258
+ break;
259
+ }
260
+ return out;
261
+ }
262
+ /**
263
+ * Validates an edit. Text is capped rather than rejected: a long title is the
264
+ * author's problem to see in the preview, not a reason to refuse the save.
265
+ */
266
+ function parseTemplatePatch(body) {
267
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
268
+ return { ok: false, error: 'Expected an object' };
269
+ }
270
+ const b = body;
271
+ const patch = {};
272
+ if (typeof b.enabled === 'boolean')
273
+ patch.enabled = b.enabled;
274
+ if (typeof b.title === 'string')
275
+ patch.title = b.title.trim().slice(0, exports.TITLE_MAX);
276
+ if (typeof b.body === 'string')
277
+ patch.body = b.body.trim().slice(0, exports.BODY_MAX);
278
+ if ('actions' in b)
279
+ patch.actions = parseActions(b.actions);
280
+ return { ok: true, patch };
281
+ }
282
+ function isNotificationType(t) {
283
+ return exports.NOTIFICATION_TYPES.includes(t);
284
+ }
285
+ /**
286
+ * Whether this notification reaches this device. Both gates must say yes.
287
+ *
288
+ * `enabled` is the admin's instance-wide switch from notification_templates;
289
+ * `userOptedIn` is the recipient's own per-device switch from user_settings.
290
+ * Admin-off means nobody receives the type; user-off means that one device
291
+ * does not.
292
+ *
293
+ * The per-device opt-out is kept deliberately rather than replaced by the
294
+ * admin switch: App Store guideline 4.5.4 and Play policy both require
295
+ * announcement push to be refusable, and removing it risks a rejection.
296
+ *
297
+ * Trivial on purpose. It exists so the rule has one name and one test instead
298
+ * of being spelled out at each of the three senders.
299
+ */
300
+ function shouldSendNotification(enabled, userOptedIn) {
301
+ return enabled && userOptedIn;
302
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=notify-template.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify-template.test.d.ts","sourceRoot":"","sources":["../src/notify-template.test.ts"],"names":[],"mappings":""}