@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,828 @@
1
+ /**
2
+ * command-catalog.ts — WHAT the daemon binary understands.
3
+ *
4
+ * This file is data. It holds no parsing logic and reads no argv. The engine is
5
+ * `parseWithCatalog` in @pellux/goodvibes-terminal-shell: it knows tokens,
6
+ * values, arity, `--` and refusals, and nothing about daemons. `DAEMON_CLI_CATALOG`
7
+ * at the bottom is this binary's whole vocabulary expressed in that engine's
8
+ * catalog contract, so a front-end with different commands is a different
9
+ * catalog rather than a second parser.
10
+ *
11
+ * The seam in one sentence: a `CliCatalog` in, a `DaemonCliParseResult` out,
12
+ * and nothing daemon-shaped in between.
13
+ *
14
+ * WHY THIS EXISTS AT ALL
15
+ *
16
+ * This binary's own alias table used to list `tui`, `run`, `doctor`, `models`,
17
+ * `providers`, `auth`, `secrets`, `tasks`, `hooks`, `plugin` and two dozen
18
+ * more, while the entry point actually dispatched on help, version and four
19
+ * service subcommands. Everything else fell through to
20
+ * "start a daemon in the foreground": `goodvibes-daemon doctor` served,
21
+ * `goodvibes-daemon install-servce` (typo) served, and the unknown-command
22
+ * error the parser carried was unreachable because no token could ever fail
23
+ * to match. The vocabulary below is exactly the set of things this binary
24
+ * actually does, and the engine refuses everything else.
25
+ */
26
+ import {
27
+ catalogFlagArity,
28
+ catalogFlagsForCommand,
29
+ resolveCatalogCommand,
30
+ type CliCatalog,
31
+ type CliFlagKind,
32
+ type CommandFlagSpec,
33
+ type CommandSpec,
34
+ type EngineParseResult,
35
+ type RejectedFlagSpec,
36
+ } from '@pellux/goodvibes-terminal-shell';
37
+ import type { DaemonCliFlags } from './types.ts';
38
+
39
+ /** Every command this binary has. There is no other. */
40
+ export type DaemonCommand =
41
+ | 'serve'
42
+ | 'install-service'
43
+ | 'uninstall-service'
44
+ | 'service-status'
45
+ | 'migrate-service'
46
+ | 'start-service'
47
+ | 'stop-service'
48
+ | 'restart-service'
49
+ | 'status'
50
+ | 'pair'
51
+ | 'sessions'
52
+ | 'config'
53
+ | 'update'
54
+ | 'send'
55
+ | 'cluster'
56
+ | 'webui'
57
+ | 'provision-wake-model'
58
+ | 'completion'
59
+ | 'help'
60
+ | 'version';
61
+
62
+ /**
63
+ * Where a flag's value lands in {@link DaemonCliFlags}.
64
+ *
65
+ * Named per flag rather than derived from the flag text because one token
66
+ * legitimately means two things: `--host` names the BIND address for `serve`
67
+ * and the TARGET daemon for `status`. The catalog says which, per command, so
68
+ * neither has to be guessed at the point of use.
69
+ */
70
+ export type DaemonCliFlagField =
71
+ | 'daemonHome'
72
+ | 'workingDir'
73
+ | 'help'
74
+ | 'version'
75
+ | 'json'
76
+ | 'yes'
77
+ | 'check'
78
+ | 'all'
79
+ | 'provider'
80
+ | 'model'
81
+ | 'hostname'
82
+ | 'port'
83
+ | 'host'
84
+ | 'token'
85
+ | 'configOverrides'
86
+ | 'enableFeatures'
87
+ | 'disableFeatures';
88
+
89
+ /**
90
+ * How a flag consumes argv, and what shape its value has.
91
+ *
92
+ * Four of the engine's seven kinds. The other three (`string-optional`,
93
+ * `const`, `enum`) exist for a conversation-shaped vocabulary — an optional
94
+ * `--resume [id]`, two flags writing one field, a checked value set — and this
95
+ * binary declares none of them.
96
+ */
97
+ export type DaemonCliFlagKind = Extract<CliFlagKind, 'boolean' | 'string' | 'port' | 'string-list'>;
98
+
99
+ /**
100
+ * A flag entry, narrowed to what this catalog actually declares.
101
+ *
102
+ * The engine's spec allows every kind and leaves the help fields optional; a
103
+ * daemon flag always has a summary and only ever has the four kinds above, and
104
+ * saying so here is what lets help.ts and completion.ts read those fields
105
+ * without a check that could never fail.
106
+ */
107
+ export type DaemonCommandFlagSpec = CommandFlagSpec<DaemonCliFlagField> & {
108
+ readonly kind: DaemonCliFlagKind;
109
+ readonly summary: string;
110
+ };
111
+
112
+ /**
113
+ * A command entry, narrowed the same way.
114
+ *
115
+ * `summary`, `usage` and `detail` are optional to the engine — a catalog with
116
+ * no help surface of its own may omit them — and required here, because this
117
+ * binary has a `help <command>` page for every command it answers to.
118
+ */
119
+ export type DaemonCommandSpec = CommandSpec<DaemonCommand, DaemonCliFlagField> & {
120
+ readonly summary: string;
121
+ readonly usage: string;
122
+ readonly detail: readonly string[];
123
+ readonly flags: readonly DaemonCommandFlagSpec[];
124
+ };
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Flags
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /** Accepted before or after any command, on every command. */
131
+ export const GLOBAL_FLAGS: readonly DaemonCommandFlagSpec[] = [
132
+ {
133
+ tokens: ['--daemon-home'],
134
+ field: 'daemonHome',
135
+ kind: 'string',
136
+ valueName: 'dir',
137
+ summary: "The daemon's own identity directory (operator tokens, auth users, daemon settings).",
138
+ },
139
+ {
140
+ tokens: ['--working-dir', '--cd', '-C'],
141
+ field: 'workingDir',
142
+ kind: 'string',
143
+ valueName: 'dir',
144
+ summary: 'The directory the daemon treats as its workspace.',
145
+ },
146
+ { tokens: ['--help', '-h'], field: 'help', kind: 'boolean', summary: 'Print help and exit 0.' },
147
+ { tokens: ['--version', '-v'], field: 'version', kind: 'boolean', summary: 'Print the version and exit 0.' },
148
+ ];
149
+
150
+ const JSON_FLAG: DaemonCommandFlagSpec = {
151
+ tokens: ['--json'],
152
+ field: 'json',
153
+ kind: 'boolean',
154
+ summary: 'Print one JSON document instead of prose, for scripting.',
155
+ };
156
+
157
+ const YES_FLAG: DaemonCommandFlagSpec = {
158
+ tokens: ['--yes', '-y', '--non-interactive'],
159
+ field: 'yes',
160
+ kind: 'boolean',
161
+ summary: 'Answer the confirmation prompt with yes. Nothing destructive happens without it.',
162
+ };
163
+
164
+ /**
165
+ * The target-daemon flags, spelled the same way everywhere.
166
+ *
167
+ * This is the convention @pellux/goodvibes-terminal-shell's
168
+ * cluster-remote-daemon-target established and asked later subcommands to
169
+ * follow: `--host`/`--port`/`--token`, each
170
+ * defaulting to this machine's own daemon — the configured control-plane
171
+ * binding and the operator token in `<daemon home>/operator-tokens.json`. A
172
+ * headless box the operator has SSHed into must work with no flags at all.
173
+ */
174
+ const REMOTE_TARGET_FLAGS: readonly DaemonCommandFlagSpec[] = [
175
+ {
176
+ tokens: ['--host'],
177
+ field: 'host',
178
+ kind: 'string',
179
+ valueName: 'name',
180
+ summary: 'The machine to ask. Defaults to this one.',
181
+ },
182
+ {
183
+ tokens: ['--port'],
184
+ field: 'port',
185
+ kind: 'port',
186
+ valueName: 'n',
187
+ summary: "That daemon's control-plane port. Defaults to the configured one.",
188
+ },
189
+ {
190
+ tokens: ['--token'],
191
+ field: 'token',
192
+ kind: 'string',
193
+ valueName: 't',
194
+ summary: 'Operator token for that daemon. Defaults to this machine\'s own.',
195
+ },
196
+ ];
197
+
198
+ const SERVE_FLAGS: readonly DaemonCommandFlagSpec[] = [
199
+ {
200
+ tokens: ['--hostname', '--host'],
201
+ field: 'hostname',
202
+ kind: 'string',
203
+ valueName: 'host',
204
+ summary: 'Bind address for the control plane. 0.0.0.0 means every interface.',
205
+ },
206
+ {
207
+ tokens: ['--port'],
208
+ field: 'port',
209
+ kind: 'port',
210
+ valueName: 'n',
211
+ summary: 'Control-plane port to bind.',
212
+ },
213
+ {
214
+ tokens: ['--provider'],
215
+ field: 'provider',
216
+ kind: 'string',
217
+ valueName: 'id',
218
+ summary: 'Run with this provider instead of the configured one. Not written to settings.',
219
+ },
220
+ {
221
+ tokens: ['--model', '-m'],
222
+ field: 'model',
223
+ kind: 'string',
224
+ valueName: 'registryKey',
225
+ summary: 'Run with this model. A provider:model key also sets the provider.',
226
+ },
227
+ {
228
+ tokens: ['--config', '-c'],
229
+ field: 'configOverrides',
230
+ kind: 'string-list',
231
+ valueName: 'key=value',
232
+ summary: 'Override one settings key for this run only. Repeatable. Never written to disk.',
233
+ },
234
+ {
235
+ tokens: ['--enable'],
236
+ field: 'enableFeatures',
237
+ kind: 'string-list',
238
+ valueName: 'feature',
239
+ summary: 'Switch a capability on for this run through its real settings key. Repeatable.',
240
+ },
241
+ {
242
+ tokens: ['--disable'],
243
+ field: 'disableFeatures',
244
+ kind: 'string-list',
245
+ valueName: 'feature',
246
+ summary: 'Switch a capability off for this run. Repeatable.',
247
+ },
248
+ ];
249
+
250
+ /**
251
+ * Flags this binary once accepted in silence, without acting on them.
252
+ *
253
+ * Every one of them means "start or resume a conversation", which this binary
254
+ * does not do — and each was accepted, stored in a flag record nothing read,
255
+ * and then ignored. `goodvibes-daemon --resume` started a fresh foreground
256
+ * daemon and said nothing about the flag. They are refused by name so the
257
+ * message names the surface that does own them.
258
+ *
259
+ * `reason` is a NOUN PHRASE the engine drops into
260
+ * "<flag> is not a <binary> flag — <reason> belongs to another surface.", so
261
+ * each one names the terminal app as well as the job, and the finished sentence
262
+ * points at where the flag actually works.
263
+ *
264
+ * `takesValue` matters for the refusal, not for the behaviour: the engine has
265
+ * to skip a refused flag's VALUE while hunting for the command word, or
266
+ * `--prompt hello` reports "Unknown command: hello" instead of naming the flag
267
+ * that is actually wrong. `--resume` and `--fork` took an OPTIONAL value, so
268
+ * they are listed as taking none — over-skipping would swallow a real command
269
+ * word.
270
+ */
271
+ export type { RejectedFlagSpec };
272
+
273
+ export const REJECTED_TERMINAL_FLAGS: Readonly<Record<string, RejectedFlagSpec>> = {
274
+ '--resume': { reason: 'resuming a conversation, a terminal app concern that', takesValue: false },
275
+ '-r': { reason: 'resuming a conversation, a terminal app concern that', takesValue: false },
276
+ '--continue': { reason: 'continuing the last conversation, a terminal app concern that', takesValue: false },
277
+ '--fork': { reason: 'forking a conversation, a terminal app concern that', takesValue: false },
278
+ '--print': { reason: 'printing one conversation turn, a terminal app concern that', takesValue: false },
279
+ '--prompt': { reason: 'sending a prompt, a terminal app concern that', takesValue: true },
280
+ '-p': { reason: 'sending a prompt, a terminal app concern that', takesValue: true },
281
+ '--output': { reason: 'choosing a conversation output format, a terminal app concern that', takesValue: true },
282
+ '--output-format': { reason: 'choosing a conversation output format, a terminal app concern that', takesValue: true },
283
+ '-o': { reason: 'choosing a conversation output format, a terminal app concern that', takesValue: true },
284
+ '--open': { reason: 'opening a browser window, a terminal app concern that', takesValue: false },
285
+ '--no-alt-screen': { reason: 'terminal screen handling, a terminal app concern that', takesValue: false },
286
+ '--session': { reason: 'selecting a conversation, a terminal app concern that', takesValue: true },
287
+ '-s': { reason: 'selecting a conversation, a terminal app concern that', takesValue: true },
288
+ '--strict': { reason: "the doctor command's strict mode, a terminal app concern that", takesValue: false },
289
+ };
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Commands
293
+ // ---------------------------------------------------------------------------
294
+
295
+ const SERVICE_DETAIL_TAIL: readonly string[] = [
296
+ '',
297
+ 'The service is a systemd user unit on Linux, a launchd user agent on macOS,',
298
+ 'and a Scheduled Task on Windows. `service-status` names the one in use.',
299
+ ];
300
+
301
+ export const DAEMON_COMMANDS: readonly DaemonCommandSpec[] = [
302
+ {
303
+ name: 'serve',
304
+ aliases: [],
305
+ summary: 'Run the daemon in the foreground. This is what a bare invocation does.',
306
+ usage: 'goodvibes-daemon [serve] [OPTIONS]',
307
+ detail: [
308
+ 'Start the control plane, the channel pollers, the cluster membership and',
309
+ 'every verb family a GoodVibes client calls, and keep running until stopped.',
310
+ '',
311
+ 'Serving happens on a bare invocation or on the word `serve`, and on nothing',
312
+ 'else. Any other first word is a command; an unrecognized one is refused',
313
+ 'rather than quietly treated as "start serving".',
314
+ '',
315
+ 'To have it survive reboots instead, install it as a service:',
316
+ ' goodvibes-daemon install-service',
317
+ ],
318
+ flags: SERVE_FLAGS,
319
+ passthrough: false,
320
+ subcommands: [],
321
+ },
322
+ {
323
+ name: 'install-service',
324
+ aliases: [],
325
+ summary: 'Install the daemon as a host service and start it.',
326
+ usage: 'goodvibes-daemon install-service',
327
+ detail: [
328
+ 'Write the service definition for this platform, then start it, so the daemon',
329
+ 'comes back after a reboot without anyone logging in.',
330
+ '',
331
+ 'Refused when a unit from the older install script is still present, because',
332
+ 'installing beside it would leave two daemons competing for one port. Take',
333
+ 'that one over with `migrate-service` first.',
334
+ ...SERVICE_DETAIL_TAIL,
335
+ ],
336
+ flags: [],
337
+ passthrough: false,
338
+ subcommands: [],
339
+ },
340
+ {
341
+ name: 'uninstall-service',
342
+ aliases: [],
343
+ summary: 'Stop the daemon service and remove its definition.',
344
+ usage: 'goodvibes-daemon uninstall-service',
345
+ detail: [
346
+ 'Stop the service and delete its definition file. On systemd this does not run',
347
+ '`disable`, so a stale enablement symlink can remain until',
348
+ '`systemctl --user daemon-reload`; the receipt says so when it applies.',
349
+ ...SERVICE_DETAIL_TAIL,
350
+ ],
351
+ flags: [],
352
+ passthrough: false,
353
+ subcommands: [],
354
+ },
355
+ {
356
+ name: 'service-status',
357
+ aliases: [],
358
+ summary: 'Report whether the daemon service is installed and running.',
359
+ usage: 'goodvibes-daemon service-status [--json]',
360
+ detail: [
361
+ 'Report the platform, the service name, the definition path, and whether the',
362
+ 'service is installed and currently running. systemd and launchd are queried',
363
+ 'live rather than inferred from a pid file.',
364
+ '',
365
+ 'Exit codes, so a script never has to read the prose:',
366
+ ' 0 installed and running',
367
+ ' 3 installed but not running',
368
+ ' 4 not installed',
369
+ ' 1 the platform refused the query (the error is printed)',
370
+ ...SERVICE_DETAIL_TAIL,
371
+ ],
372
+ flags: [JSON_FLAG],
373
+ passthrough: false,
374
+ subcommands: [],
375
+ },
376
+ {
377
+ name: 'migrate-service',
378
+ aliases: [],
379
+ summary: 'Take over a service unit left by the older install script.',
380
+ usage: 'goodvibes-daemon migrate-service [-y]',
381
+ detail: [
382
+ 'Move from the install script\'s `goodvibes-daemon.service` unit to the one this',
383
+ 'binary manages. Without -y it prints the exact plan and changes nothing.',
384
+ '',
385
+ 'The new service is installed, started and verified healthy BEFORE the old one',
386
+ 'is stopped or removed; a new service that does not come up rolls itself back',
387
+ 'and leaves the working one alone. A process listening on the port with no unit',
388
+ 'behind it is reported, never killed.',
389
+ ...SERVICE_DETAIL_TAIL,
390
+ ],
391
+ flags: [YES_FLAG],
392
+ passthrough: false,
393
+ subcommands: [],
394
+ },
395
+ {
396
+ name: 'start-service',
397
+ aliases: [],
398
+ summary: 'Start the installed daemon service.',
399
+ usage: 'goodvibes-daemon start-service',
400
+ detail: [
401
+ 'Start the service this binary manages, and report what the platform did.',
402
+ 'An absent service is reported as absent rather than silently installed.',
403
+ ...SERVICE_DETAIL_TAIL,
404
+ ],
405
+ flags: [],
406
+ passthrough: false,
407
+ subcommands: [],
408
+ },
409
+ {
410
+ name: 'stop-service',
411
+ aliases: [],
412
+ summary: 'Stop the daemon service without removing it.',
413
+ usage: 'goodvibes-daemon stop-service',
414
+ detail: [
415
+ 'Stop the service this binary manages. The definition stays in place, so',
416
+ '`start-service` brings it back and a reboot still starts it.',
417
+ ...SERVICE_DETAIL_TAIL,
418
+ ],
419
+ flags: [],
420
+ passthrough: false,
421
+ subcommands: [],
422
+ },
423
+ {
424
+ name: 'restart-service',
425
+ aliases: [],
426
+ summary: 'Restart the daemon service.',
427
+ usage: 'goodvibes-daemon restart-service',
428
+ detail: [
429
+ 'Restart the service this binary manages — the usual way to pick up a settings',
430
+ 'change that only applies at boot.',
431
+ ...SERVICE_DETAIL_TAIL,
432
+ ],
433
+ flags: [],
434
+ passthrough: false,
435
+ subcommands: [],
436
+ },
437
+ {
438
+ name: 'status',
439
+ aliases: [],
440
+ summary: 'Ask a running daemon what it is doing.',
441
+ usage: 'goodvibes-daemon status [--json] [--host <name>] [--port <n>] [--token <t>]',
442
+ detail: [
443
+ 'Talk to a daemon that is already running and report: its version, how long it',
444
+ 'has been up, the address it actually bound, what its last update did, whether',
445
+ 'its channels and inbox are healthy, its place in the cluster, and how many',
446
+ 'sessions it is hosting.',
447
+ '',
448
+ 'With no flags it asks the daemon on this machine. --host/--port/--token ask',
449
+ 'another one; the token defaults to this machine\'s operator token, which is not',
450
+ 'the right credential for a different machine.',
451
+ '',
452
+ 'The update, uptime and rollback lines come from files this daemon writes on its',
453
+ 'own host, so they are reported for a local daemon and named as unavailable for',
454
+ 'a remote one rather than guessed at.',
455
+ '',
456
+ 'Exit 0 when the daemon answered, 1 when it could not be reached.',
457
+ ],
458
+ flags: [JSON_FLAG, ...REMOTE_TARGET_FLAGS],
459
+ passthrough: false,
460
+ subcommands: [],
461
+ },
462
+ {
463
+ name: 'pair',
464
+ aliases: ['qr', 'qrcode'],
465
+ summary: 'Print the pairing link and QR code again, or mint one on a remote daemon.',
466
+ usage: 'goodvibes-daemon pair [--json] [--host <name>] [--port <n>] [--token <t>] [-y]',
467
+ detail: [
468
+ 'LOCAL FORM — no --host, or one naming this machine: print the same pairing',
469
+ 'block a daemon prints once at startup: the web origin, the offers a new',
470
+ 'device can accept, what it will be able to do, and a QR code encoding the',
471
+ 'deep link that opens the web app already signed in.',
472
+ '',
473
+ 'It reprints the EXISTING shared token rather than minting a new one, so a link',
474
+ 'printed here and one printed at boot are the same link. Scrolling the startup',
475
+ 'banner off the screen therefore costs nothing.',
476
+ '',
477
+ 'REMOTE FORM — --host naming another machine: ask THAT daemon to MINT A NEW',
478
+ 'per-device pairing token and print the pairing block for it. Minting is a',
479
+ 'different act than reprinting: it is a fresh token, and every token that',
480
+ 'daemon already issued — its shared token included — is left untouched.',
481
+ '',
482
+ 'Because it changes state on a daemon that may not be this process\'s own, it',
483
+ 'states the plan and asks for confirmation before acting: -y (or --yes) is the',
484
+ 'non-interactive answer, the same convention migrate-service uses. Without -y',
485
+ 'nothing is changed. An unreachable daemon, a rejected token, and a daemon too',
486
+ 'old to serve the mint verb are each refused by name, never a stack trace.',
487
+ ],
488
+ flags: [
489
+ JSON_FLAG,
490
+ ...REMOTE_TARGET_FLAGS,
491
+ YES_FLAG,
492
+ ],
493
+ passthrough: false,
494
+ subcommands: [],
495
+ },
496
+ {
497
+ name: 'sessions',
498
+ aliases: ['session'],
499
+ summary: 'List or end the sessions a running daemon is hosting.',
500
+ usage: 'goodvibes-daemon sessions list|kill <id> [--json] [--all] [--host <name>] [--port <n>] [--token <t>]',
501
+ detail: [
502
+ 'sessions list every session this daemon hosts, most recently used first.',
503
+ 'sessions kill <id> end one: its in-flight turn is interrupted and its loop',
504
+ ' taken apart, whoever is attached and whatever its detach',
505
+ ' policy says.',
506
+ '',
507
+ '--all includes sessions that have already ended; they are kept, with the reason',
508
+ 'they ended, until the retention window retires them.',
509
+ '',
510
+ 'These are the daemon\'s own hosted sessions — conversations running INSIDE it,',
511
+ 'which outlive the client that started them. Sessions a terminal runs on this',
512
+ 'machine are that terminal\'s, and are not listed here.',
513
+ ],
514
+ flags: [
515
+ JSON_FLAG,
516
+ { tokens: ['--all'], field: 'all', kind: 'boolean', summary: 'Include sessions that have already ended.' },
517
+ ...REMOTE_TARGET_FLAGS,
518
+ ],
519
+ passthrough: false,
520
+ subcommands: ['list', 'kill'],
521
+ },
522
+ {
523
+ name: 'config',
524
+ aliases: [],
525
+ summary: 'Read and write this daemon\'s settings.',
526
+ usage: 'goodvibes-daemon config list|get <key>|set <key> <value>|unset <key> [--json]',
527
+ detail: [
528
+ 'config list every setting with a value, and where the value came from.',
529
+ 'config get <key> one setting.',
530
+ 'config set <key> <v> write one setting to disk. The value is checked against the',
531
+ ' schema first, and a daemon-owned key lands in the daemon\'s',
532
+ ' own settings file rather than the shared one.',
533
+ 'config unset <key> put one setting back to its shipped default.',
534
+ '',
535
+ 'Values are read and written on this machine\'s settings files directly, so this',
536
+ 'works whether or not a daemon is running. A running daemon picks up most',
537
+ 'changes live; the ones that only apply at bind time say so.',
538
+ '',
539
+ 'Anything that reads like a credential — a token, a password, an API key — is',
540
+ 'printed as <redacted>. `config set` still writes the real value; it is the',
541
+ 'OUTPUT that is redacted, so a settings dump pasted into an issue carries none.',
542
+ ],
543
+ flags: [JSON_FLAG],
544
+ passthrough: false,
545
+ subcommands: ['list', 'get', 'set', 'unset'],
546
+ },
547
+ {
548
+ name: 'update',
549
+ aliases: [],
550
+ summary: 'Report what this daemon knows about its own updates.',
551
+ usage: 'goodvibes-daemon update [--check] [--json] [--host <name>] [--port <n>] [--token <t>]',
552
+ detail: [
553
+ 'Report the running version, the receipts the daemon has written about its own',
554
+ 'updates and restarts, the version an automatic rollback rejected (if any), and',
555
+ 'whether a rollback is currently in force.',
556
+ '',
557
+ '--check asks the daemon to look for a new release now. The daemon checks hourly',
558
+ 'on its own and swaps only at an idle moment; this command exists for the case',
559
+ 'where you do not want to wait for the next hour.',
560
+ '',
561
+ 'The rollback and receipt lines are read from files the daemon writes on its own',
562
+ 'host, so they are reported for a local daemon and named as unavailable for a',
563
+ 'remote one.',
564
+ ],
565
+ flags: [
566
+ { tokens: ['--check'], field: 'check', kind: 'boolean', summary: 'Ask for an update check now instead of waiting for the hourly one.' },
567
+ JSON_FLAG,
568
+ ...REMOTE_TARGET_FLAGS,
569
+ ],
570
+ passthrough: false,
571
+ subcommands: [],
572
+ },
573
+ {
574
+ name: 'send',
575
+ aliases: [],
576
+ summary: 'Send a message to one of your configured channels.',
577
+ usage: 'goodvibes-daemon send [message] [--channel <id>] [--to <address>] [--title <text>] [--list]',
578
+ detail: [
579
+ 'Send a message through Telegram, ntfy, Discord, Slack, Google Chat, Signal,',
580
+ 'WhatsApp, iMessage, Teams, BlueBubbles, Mattermost, Matrix or a webhook. The',
581
+ 'message is an argument or stdin, so it composes with other tooling.',
582
+ '',
583
+ '--channel <id> picks the channel; with none named it uses your one configured',
584
+ 'channel and says which. --to <address> targets a topic, chat or room inside it,',
585
+ '--title <text> sets a title, and --list shows every channel with where it would',
586
+ 'send.',
587
+ '',
588
+ 'A channel that is switched off is refused rather than redirected to the default,',
589
+ 'and a failed send exits non-zero carrying the provider\'s own error. It works',
590
+ 'with no daemon running, which is much of the point: the reason to message',
591
+ 'yourself is usually that something stopped.',
592
+ ],
593
+ flags: [],
594
+ passthrough: true,
595
+ subcommands: [],
596
+ },
597
+ {
598
+ name: 'cluster',
599
+ aliases: [],
600
+ summary: 'Share inbound channel work with your other machines.',
601
+ usage: 'goodvibes-daemon cluster status|create|join|key|nodes|forget|rotate|leave|rename|groups',
602
+ detail: [
603
+ 'Manage the group of machines on this network that share inbound channel work.',
604
+ '',
605
+ ' status what this machine is doing in its group',
606
+ ' create start a group here',
607
+ ' join join one (interactively, or with --group and --key)',
608
+ ' key print the join key for another machine to use',
609
+ ' nodes every machine in the group',
610
+ ' groups groups advertising themselves on this network',
611
+ ' forget <machine> drop a machine from the group',
612
+ ' rotate [--now] change the shared key',
613
+ ' rename <name> rename the group',
614
+ ' leave leave the group',
615
+ '',
616
+ 'Talks to a running daemon over the same --host/--port/--token convention',
617
+ '`status` uses; --json gives a scriptable answer.',
618
+ ],
619
+ flags: [],
620
+ passthrough: true,
621
+ subcommands: ['status', 'create', 'join', 'key', 'nodes', 'forget', 'rotate', 'leave', 'rename', 'groups'],
622
+ },
623
+ {
624
+ name: 'webui',
625
+ aliases: [],
626
+ summary: 'Serve the browser operator surface from this daemon.',
627
+ usage: 'goodvibes-daemon webui enable|disable|status [--bundle-dir <dir>] [--lan|--loopback]',
628
+ detail: [
629
+ 'The web UI is a built bundle of static files served by the daemon\'s own',
630
+ 'control-plane listener, on the same origin as the API — so the URL to open is',
631
+ 'the control-plane one, not the declared web port.',
632
+ '',
633
+ ' enable [--bundle-dir <dir>] serve the bundle at that directory',
634
+ ' disable stop serving it; the bundle stays on disk',
635
+ ' status what is served, from where, and who can reach it',
636
+ '',
637
+ '`enable` changes no network exposure on its own: a daemon bound to loopback',
638
+ 'keeps serving to this machine only. --lan binds every interface, --loopback',
639
+ 'takes it back, and both are stated in the receipt.',
640
+ ],
641
+ flags: [],
642
+ passthrough: true,
643
+ subcommands: ['enable', 'disable', 'status'],
644
+ },
645
+ {
646
+ name: 'provision-wake-model',
647
+ aliases: [],
648
+ summary: 'Fetch any missing wake-word model files.',
649
+ usage: 'goodvibes-daemon provision-wake-model',
650
+ detail: [
651
+ 'Fetch the wake-word model files that are missing from the managed voice tree.',
652
+ '',
653
+ 'The installer runs this on a binary it has just placed, and a daemon start',
654
+ 'retries it, so an install that happened offline heals on its own. A download',
655
+ 'that fails is reported and exits 0 — a machine with no wake word still has a',
656
+ 'perfectly good daemon.',
657
+ ],
658
+ flags: [],
659
+ passthrough: true,
660
+ subcommands: [],
661
+ },
662
+ {
663
+ name: 'completion',
664
+ aliases: ['completions'],
665
+ summary: 'Print a shell completion script.',
666
+ usage: 'goodvibes-daemon completion bash|zsh|fish',
667
+ detail: [
668
+ 'Print a completion script for the named shell on stdout. It completes this',
669
+ 'binary\'s commands, their sub-words and their flags, generated from the same',
670
+ 'catalog the parser and the help text use — so it cannot drift from what the',
671
+ 'binary accepts.',
672
+ '',
673
+ 'Install it by writing it somewhere the shell reads, for example:',
674
+ ' goodvibes-daemon completion bash > ~/.local/share/bash-completion/completions/goodvibes-daemon',
675
+ ' goodvibes-daemon completion zsh > ~/.zfunc/_goodvibes-daemon',
676
+ ' goodvibes-daemon completion fish > ~/.config/fish/completions/goodvibes-daemon.fish',
677
+ ],
678
+ flags: [],
679
+ passthrough: false,
680
+ subcommands: ['bash', 'zsh', 'fish'],
681
+ },
682
+ {
683
+ name: 'help',
684
+ aliases: [],
685
+ summary: 'Print help for the binary, or for one command.',
686
+ usage: 'goodvibes-daemon help [command]',
687
+ detail: [
688
+ 'With no argument, print the command list and the global options.',
689
+ 'With a command name, print that command\'s arguments, flags and behaviour.',
690
+ ],
691
+ flags: [],
692
+ passthrough: false,
693
+ subcommands: [],
694
+ },
695
+ {
696
+ name: 'version',
697
+ aliases: [],
698
+ summary: 'Print the version.',
699
+ usage: 'goodvibes-daemon version',
700
+ detail: ['Print the binary name and its version, and exit 0.'],
701
+ flags: [],
702
+ passthrough: false,
703
+ subcommands: [],
704
+ },
705
+ ];
706
+
707
+ /**
708
+ * The commands whose arguments are read straight off `process.argv` before the
709
+ * parser runs at all, because they must be reachable with no runtime composed
710
+ * and with their own flag vocabulary intact. Kept here so the vocabulary and
711
+ * the dispatch order agree in one place.
712
+ */
713
+ export const RAW_INTERCEPT_COMMANDS: readonly DaemonCommand[] = DAEMON_COMMANDS
714
+ .filter((spec) => spec.passthrough)
715
+ .map((spec) => spec.name);
716
+
717
+ /**
718
+ * The flag record a parse starts from — every field at its empty value, so a
719
+ * command's dispatcher reads only what its own catalog entry declares.
720
+ */
721
+ function createDefaultFlags(): DaemonCliFlags {
722
+ return {
723
+ daemonHome: undefined,
724
+ workingDir: undefined,
725
+ help: false,
726
+ version: false,
727
+ json: false,
728
+ yes: false,
729
+ check: false,
730
+ all: false,
731
+ provider: undefined,
732
+ model: undefined,
733
+ hostname: undefined,
734
+ port: undefined,
735
+ host: undefined,
736
+ token: undefined,
737
+ configOverrides: [],
738
+ enableFeatures: [],
739
+ disableFeatures: [],
740
+ };
741
+ }
742
+
743
+ /**
744
+ * `provider:model` and `provider/model` name the provider inside the model id.
745
+ *
746
+ * Applied once, over the finished parse, rather than at the moment `--model`
747
+ * is read: a `--provider` the operator typed explicitly always wins, and
748
+ * deciding that after both flags have landed means it wins whichever order
749
+ * they were typed in.
750
+ */
751
+ function inferProviderFromModel(
752
+ result: EngineParseResult<DaemonCommand, DaemonCliFlags>,
753
+ ): EngineParseResult<DaemonCommand, DaemonCliFlags> {
754
+ const { provider, model } = result.flags;
755
+ if (provider !== undefined || model === undefined) return result;
756
+ const inferred = model.includes(':')
757
+ ? model.split(':')[0]
758
+ : model.includes('/') ? model.split('/')[0] : undefined;
759
+ if (inferred === undefined) return result;
760
+ return { ...result, flags: { ...result.flags, provider: inferred } };
761
+ }
762
+
763
+ /**
764
+ * This binary's vocabulary, as the shared engine reads it.
765
+ *
766
+ * `unmatchedFirstToken: 'reject'` is the rule this catalog exists to enforce:
767
+ * an unmatched first word must never quietly become a positional under the
768
+ * default command, because the default command here is "start serving" and
769
+ * that is how `goodvibes-daemon install-servce` used to start a daemon.
770
+ * `unresolvedCommandSentinel: 'help'` is what such a parse reports instead, so
771
+ * the caller prints the refusal and the command list rather than serving.
772
+ */
773
+ export const DAEMON_CLI_CATALOG: CliCatalog<DaemonCommand, DaemonCliFlagField, DaemonCliFlags> = {
774
+ commands: DAEMON_COMMANDS,
775
+ globalFlags: GLOBAL_FLAGS,
776
+ rejectedFlags: REJECTED_TERMINAL_FLAGS,
777
+ defaultCommand: 'serve',
778
+ unmatchedFirstToken: 'reject',
779
+ unresolvedCommandSentinel: 'help',
780
+ createDefaultFlags,
781
+ postProcess: inferProviderFromModel,
782
+ };
783
+
784
+ const SPECS_BY_NAME: ReadonlyMap<DaemonCommand, DaemonCommandSpec> = new Map(
785
+ DAEMON_COMMANDS.map((spec) => [spec.name, spec]),
786
+ );
787
+
788
+ /** Every accepted spelling, lowercased, mapped to the command it names. */
789
+ export const DAEMON_COMMAND_ALIASES: Readonly<Record<string, DaemonCommand>> = Object.freeze(
790
+ DAEMON_COMMANDS.reduce<Record<string, DaemonCommand>>((table, spec) => {
791
+ table[spec.name] = spec.name;
792
+ for (const alias of spec.aliases) table[alias] = spec.name;
793
+ return table;
794
+ }, {}),
795
+ );
796
+
797
+ export function daemonCommandSpec(command: DaemonCommand): DaemonCommandSpec {
798
+ const spec = SPECS_BY_NAME.get(command);
799
+ // Unreachable through the union, but a thrown error beats an undefined that
800
+ // travels three frames before failing.
801
+ if (!spec) throw new Error(`No catalog entry for command '${command}'`);
802
+ return spec;
803
+ }
804
+
805
+ /** Resolve a raw argv word to a command, or undefined when it names none. */
806
+ export function resolveDaemonCommand(token: string): DaemonCommand | undefined {
807
+ return resolveCatalogCommand(DAEMON_CLI_CATALOG, token);
808
+ }
809
+
810
+ export function isRawInterceptCommand(command: DaemonCommand): boolean {
811
+ return daemonCommandSpec(command).passthrough;
812
+ }
813
+
814
+ /**
815
+ * Every flag token in the catalog, with its arity.
816
+ *
817
+ * The engine needs this BEFORE it knows which command it is parsing: to find
818
+ * the command word it has to skip over option values, and whether a token
819
+ * takes a value is a property of the token. Every token that appears in more
820
+ * than one command's flag list has the same kind in all of them — asserted by
821
+ * a unit test rather than left as an assumption — so one table is honest.
822
+ */
823
+ export const ALL_FLAG_ARITY: ReadonlyMap<string, CliFlagKind> = catalogFlagArity(DAEMON_CLI_CATALOG);
824
+
825
+ /** Flag specs a given command accepts: the global ones plus its own. */
826
+ export function flagsForCommand(command: DaemonCommand): readonly DaemonCommandFlagSpec[] {
827
+ return catalogFlagsForCommand(DAEMON_CLI_CATALOG, command) as readonly DaemonCommandFlagSpec[];
828
+ }