@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.
- 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/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/tmux-manager.d.ts +9 -0
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +31 -2
- package/dist/tmux-manager.js.map +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/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +10 -1
- package/dist/websocket.js.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.js +20 -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/src/index.ts +29 -0
- 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/session-announce.test.ts +48 -0
- package/node_modules/@indianic/dialout-shared/src/session-announce.ts +40 -0
- package/package.json +2 -2
- package/scripts/postinstall.js +8 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"notify-template.test.d.ts","sourceRoot":"","sources":["../src/notify-template.test.ts"],"names":[],"mappings":""}
|