@pellux/goodvibes-daemon 1.28.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 (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * inert-text.ts — make a message arrive as LITERAL TEXT on whichever channel
3
+ * `goodvibes-daemon send` delivers it to.
4
+ *
5
+ * ## What "inert" means here, and why it is per (surface × transport), not
6
+ * per surface
7
+ *
8
+ * The transform a surface needs depends on what the delivery strategy in
9
+ * `platform/channels/delivery/strategies-core.ts` actually does with the body,
10
+ * not on what the surface is capable of parsing. Two examples that pull in
11
+ * opposite directions:
12
+ *
13
+ * - Telegram's `sendMessage` CAN parse MarkdownV2, but the strategy sends with
14
+ * NO `parse_mode`, which is Telegram's plain-text mode. Running a MarkdownV2
15
+ * escaper over the body on this path would not protect anything — it would
16
+ * put visible backslashes in front of every `.` `-` `!` `(` in the owner's
17
+ * message. The correct transform is identity, and the property that makes it
18
+ * correct is asserted by a test against the real wire payload rather than
19
+ * asserted here in prose (see src/test/daemon/send-command-wire.test.ts).
20
+ *
21
+ * - Discord's `postMessage` renders markdown unconditionally, INCLUDING masked
22
+ * links, so the same body needs real escaping.
23
+ *
24
+ * A single "escape for channel X" table that ignored the transport would get
25
+ * Telegram wrong in the direction that mangles the owner's text, and would get
26
+ * Discord wrong in the direction that renders a link. Hence the split below.
27
+ *
28
+ * ## Provenance of the escaper bodies
29
+ *
30
+ * `escapeDiscordMarkdown` and `escapeSlackMrkdwn` are the escapers written for
31
+ * the inbound-mail structured notices, in the SDK at
32
+ * `packages/sdk/src/platform/email/inbound-notice.ts` — same character classes,
33
+ * same zero-width-space mention break, same rationale. They are reproduced here
34
+ * rather than imported for one reason: that module lives on the unmerged
35
+ * `inbound-email-*` branches and is absent from the published
36
+ * `@pellux/goodvibes-sdk@1.18.1` this product consumes, so there is nothing to
37
+ * import yet.
38
+ *
39
+ * This is a second copy and that is a defect, not a design. When the
40
+ * inbound-mail round merges and an SDK release publishes `platform/email`'s
41
+ * notice escapers, THIS FILE'S `escapeDiscordMarkdown` and `escapeSlackMrkdwn`
42
+ * are to be deleted and the SDK's imported in their place; the surface-kind
43
+ * table below is the only part that should survive that change. The behaviour
44
+ * is pinned by tests either way, so the swap is verifiable rather than hopeful.
45
+ */
46
+
47
+ import type { ChannelDeliverySurfaceKind } from '@pellux/goodvibes-sdk/platform/channels';
48
+
49
+ /**
50
+ * Zero-width space. Used to break a token that a surface would otherwise read
51
+ * as syntax, on surfaces that offer no backslash escape (Slack) or where
52
+ * backslash-escaping demonstrably does not defeat the token (Discord mentions).
53
+ */
54
+ const ZERO_WIDTH_SPACE = '​';
55
+
56
+ /**
57
+ * `@everyone`, `@here`, and raw role/channel/user mentions. Backslash-escaping
58
+ * the `@` does NOT reliably suppress these in every Discord client, so the
59
+ * zero-width break is used instead.
60
+ */
61
+ function breakMentionForms(text: string): string {
62
+ return text
63
+ .replace(/@(everyone|here)/g, (_match, word: string) => `@${ZERO_WIDTH_SPACE}${word}`)
64
+ .replace(/<@[!&]?(\d+)>/g, (_match, id: string) => `<@${ZERO_WIDTH_SPACE}${id}>`)
65
+ .replace(/<#(\d+)>/g, (_match, id: string) => `<#${ZERO_WIDTH_SPACE}${id}>`);
66
+ }
67
+
68
+ /**
69
+ * Discord markdown. Backslash-escaping `* _ ~ \` |` and `>` renders each as its
70
+ * literal character rather than triggering bold/italic/strikethrough/code/
71
+ * spoiler/quote.
72
+ *
73
+ * `[` `]` `(` `)` are escaped too, and this is REQUIRED rather than
74
+ * precautionary: masked links (`[text](url)`) DO render as clickable in
75
+ * bot-sent and webhook messages — which is exactly how this product delivers to
76
+ * Discord — even though they do not render for text a human typed into the
77
+ * client.
78
+ * https://github.com/discord/discord-api-docs/issues/6096
79
+ * https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51
80
+ *
81
+ * Without this, a message body assembled from anything the owner did not type
82
+ * arrives in his Discord as a clickable link reading whatever the body said.
83
+ * An escaper that looks optional gets tidied away by the next reader: it is not
84
+ * optional, and it stays.
85
+ *
86
+ * `\` is inside the character class, so a backslash in the input is escaped
87
+ * first and cannot un-escape what follows it.
88
+ */
89
+ function escapeDiscordMarkdown(text: string): string {
90
+ return breakMentionForms(text.replace(/[*_~`|>[\]()\\]/g, (ch) => `\\${ch}`));
91
+ }
92
+
93
+ /**
94
+ * Slack mrkdwn. `&`, `<`, `>` MUST be HTML-entity-escaped per Slack's own
95
+ * formatting reference. That is also what defeats `<url|text>` link syntax and
96
+ * `<!channel>` / `<@id>` mention syntax outright, since both require a literal
97
+ * unescaped `<`.
98
+ *
99
+ * Slack has no backslash escape for `* _ ~ \``, so the zero-width break is
100
+ * applied to those as the best available mitigation — stated as a mitigation,
101
+ * not a guarantee, because Slack's whitespace-adjacency rule for what breaks a
102
+ * delimiter pair is not publicly specified to that precision. The residual risk
103
+ * is cosmetic (accidental bold/italic); the injection class this exists to
104
+ * close — a clickable link or a real mention — is closed by the entity escaping
105
+ * above.
106
+ */
107
+ function escapeSlackMrkdwn(text: string): string {
108
+ const entityEscaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
109
+ return entityEscaped.replace(/[*_~`]/g, (ch) => `${ch}${ZERO_WIDTH_SPACE}`);
110
+ }
111
+
112
+ /**
113
+ * Google Chat's `text` field renders `*bold*`, `_italic_`, `~strike~`,
114
+ * `` `code` `` and `<url|text>` links. The `<` entity escape is what defeats
115
+ * the link and `<users/all>` mention forms; the delimiters get the zero-width
116
+ * break, as Google Chat documents no backslash escape either.
117
+ */
118
+ function escapeGoogleChatMarkup(text: string): string {
119
+ const entityEscaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
120
+ return entityEscaped.replace(/[*_~`]/g, (ch) => `${ch}${ZERO_WIDTH_SPACE}`);
121
+ }
122
+
123
+ /**
124
+ * WhatsApp Cloud API text messages render `*bold*`, `_italic_`, `~strike~` and
125
+ * ```` ```mono``` ````. There is no masked-link syntax — a URL in the body is
126
+ * auto-linked showing its real address — so the injection class Discord has
127
+ * does not exist here and only the delimiters need neutralizing. WhatsApp
128
+ * documents no backslash escape, so the zero-width break is used.
129
+ */
130
+ function escapeWhatsAppMarkup(text: string): string {
131
+ return text.replace(/[*_~`]/g, (ch) => `${ch}${ZERO_WIDTH_SPACE}`);
132
+ }
133
+
134
+ /**
135
+ * The surfaces whose delivery strategy hands the body to a renderer that parses
136
+ * markup. Every other routable surface is absent DELIBERATELY, and each absence
137
+ * is a checked claim about the strategy, not an oversight:
138
+ *
139
+ * - `telegram` — `sendMessage` without `parse_mode`; Telegram's plain-text
140
+ * mode. Escaping here would corrupt, not protect.
141
+ * - `ntfy` — `publish` sends the body as `text/plain` and never sets the
142
+ * `Markdown` header, so ntfy renders it literally. The title
143
+ * is derived by `titleFromBody` (first non-empty LINE) and
144
+ * passed through `toHeaderSafeTitle`, so no line break or
145
+ * non-ASCII byte from the body can reach an HTTP header.
146
+ * - `webhook` — the body is a JSON string field; the receiver decides what
147
+ * to do with it and there is no markup layer to neutralize.
148
+ *
149
+ * - `signal` — the strategy posts `text` to a signal bridge. Signal renders
150
+ * no markup in a plain message body; styling travels as
151
+ * explicit range metadata the strategy never sends.
152
+ * - `imessage`,
153
+ * `bluebubbles` — both end at iMessage, which renders no markup at all.
154
+ * - `msteams` — the strategy sends `textFormat: 'plain'` alongside the text,
155
+ * which is Teams' own instruction not to parse it.
156
+ * - `matrix` — `msgtype: 'm.text'` with no `format`/`formatted_body`. A
157
+ * Matrix event without the HTML format field is rendered
158
+ * literally by clients, per the spec.
159
+ *
160
+ * `web` is absent for a different reason: its strategy needs a live
161
+ * `ControlPlaneGateway` in the same process, which a short-lived CLI does not
162
+ * have, so the command does not offer it as a channel at all. `telephony` is
163
+ * absent because a CLI `send` to it would place a phone call or an SMS through
164
+ * a paid carrier — a different act from messaging a channel — and because the
165
+ * strategy already XML-escapes the voice path itself via `escapeTwiml`.
166
+ */
167
+ const INERT_TRANSFORMS: Partial<Record<ChannelDeliverySurfaceKind, (text: string) => string>> = {
168
+ // Renders markup, including a masked link: full escaping.
169
+ discord: escapeDiscordMarkdown,
170
+ mattermost: escapeDiscordMarkdown,
171
+ // Renders markup, but has no masked-link syntax: delimiters only.
172
+ slack: escapeSlackMrkdwn,
173
+ 'google-chat': escapeGoogleChatMarkup,
174
+ whatsapp: escapeWhatsAppMarkup,
175
+ // Delivered as plain text by the strategy: transforming would corrupt, not
176
+ // protect. Each of these is a checked claim about the strategy, not a guess —
177
+ // see the list above.
178
+ telegram: (text) => text,
179
+ ntfy: (text) => text,
180
+ webhook: (text) => text,
181
+ signal: (text) => text,
182
+ imessage: (text) => text,
183
+ bluebubbles: (text) => text,
184
+ msteams: (text) => text,
185
+ matrix: (text) => text,
186
+ };
187
+
188
+ /** Surfaces `goodvibes-daemon send` will deliver to, in a stable display order. */
189
+ export const INERT_RENDERABLE_SURFACE_KINDS: readonly ChannelDeliverySurfaceKind[] = [
190
+ 'telegram', 'ntfy', 'discord', 'slack', 'google-chat', 'webhook',
191
+ 'signal', 'whatsapp', 'imessage', 'msteams', 'bluebubbles', 'mattermost', 'matrix',
192
+ ];
193
+
194
+ /**
195
+ * Whether this surface has a verified inert transform.
196
+ *
197
+ * A surface with no entry gets a refusal from the send command, never an
198
+ * untransformed body. Returning the body unchanged for an unknown surface is
199
+ * precisely how a markup-rendering channel acquires a live link: the default
200
+ * has to be "refuse", and it has to be checkable from the caller.
201
+ */
202
+ export function canRenderInert(surfaceKind: ChannelDeliverySurfaceKind): boolean {
203
+ return Object.prototype.hasOwnProperty.call(INERT_TRANSFORMS, surfaceKind);
204
+ }
205
+
206
+ /**
207
+ * Render `text` so it arrives on `surfaceKind` as the literal characters the
208
+ * caller supplied.
209
+ *
210
+ * Throws for a surface with no verified transform. This function has no
211
+ * "pass it through" branch and must never grow one: every caller of the send
212
+ * command reaches the world through here, so a permissive fallback would make
213
+ * the command a way to put live markup on the owner's phone.
214
+ */
215
+ export function inertBodyFor(surfaceKind: ChannelDeliverySurfaceKind, text: string): string {
216
+ const transform = INERT_TRANSFORMS[surfaceKind];
217
+ if (!transform) {
218
+ throw new Error(
219
+ `No verified inert-text transform for surface '${surfaceKind}'. `
220
+ + 'Sending would risk the message being rendered as markup rather than as text, '
221
+ + `so nothing was sent. Supported: ${INERT_RENDERABLE_SURFACE_KINDS.join(', ')}.`,
222
+ );
223
+ }
224
+ return transform(text);
225
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * stdin.ts — read the whole of stdin as the message body.
3
+ *
4
+ * Kept out of command.ts so the command stays a pure function of its arguments
5
+ * and its injected dependencies: every test drives it with a stub rather than
6
+ * having to arrange a real pipe on the process running the suite.
7
+ */
8
+
9
+ /**
10
+ * Read stdin to end, as UTF-8.
11
+ *
12
+ * There is deliberately no timeout. `send` reads stdin only when the operator
13
+ * gave no message argument AND stdin is not a terminal — meaning something is
14
+ * piping into it — so waiting for that producer to finish is the correct
15
+ * behaviour, and cutting it off at an arbitrary deadline would silently
16
+ * truncate a long message.
17
+ */
18
+ export async function readAllStdin(): Promise<string> {
19
+ const chunks: Buffer[] = [];
20
+ for await (const chunk of process.stdin) {
21
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
22
+ }
23
+ return Buffer.concat(chunks).toString('utf-8');
24
+ }