@pellux/goodvibes-daemon 1.28.19 → 1.28.20

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 (108) hide show
  1. package/CHANGELOG.md +85 -63
  2. package/README.md +13 -13
  3. package/bin/launcher-support.js +5 -5
  4. package/package.json +6 -4
  5. package/scripts/postinstall.js +8 -8
  6. package/src/cli/command-catalog.ts +22 -22
  7. package/src/cli/completion.ts +4 -4
  8. package/src/cli/help.ts +5 -5
  9. package/src/cli/index.ts +3 -3
  10. package/src/cli/parser.ts +2 -2
  11. package/src/cli/surface-catalog.ts +1 -1
  12. package/src/cli/types.ts +2 -2
  13. package/src/cluster/daemon-ws-call.ts +5 -5
  14. package/src/cluster/raw-reply-route.ts +5 -5
  15. package/src/config/checkpoint-settings.ts +7 -7
  16. package/src/config/config-key-guard.ts +22 -0
  17. package/src/config/run-daemon-config-migration.ts +3 -3
  18. package/src/config/secret-config.ts +7 -7
  19. package/src/config/surface.ts +3 -3
  20. package/src/core/pairing-banner.ts +5 -5
  21. package/src/daemon/cli.ts +45 -43
  22. package/src/daemon/config-command.ts +15 -15
  23. package/src/daemon/handlers/context.ts +1 -1
  24. package/src/daemon/handlers/contracts.ts +4 -4
  25. package/src/daemon/handlers/credentials.ts +1 -1
  26. package/src/daemon/handlers/drafts/draft-store.ts +3 -3
  27. package/src/daemon/handlers/drafts/register.ts +4 -4
  28. package/src/daemon/handlers/inbox/aggregator.ts +8 -8
  29. package/src/daemon/handlers/inbox/cursor-store.ts +10 -10
  30. package/src/daemon/handlers/inbox/index.ts +7 -7
  31. package/src/daemon/handlers/inbox/mapping.ts +2 -2
  32. package/src/daemon/handlers/inbox/poller.ts +5 -5
  33. package/src/daemon/handlers/inbox/provider-adapter.ts +8 -8
  34. package/src/daemon/handlers/inbox/providers/discord.ts +6 -6
  35. package/src/daemon/handlers/inbox/providers/email.ts +3 -3
  36. package/src/daemon/handlers/inbox/providers/imap-client.ts +1 -1
  37. package/src/daemon/handlers/inbox/providers/slack.ts +4 -4
  38. package/src/daemon/handlers/index.ts +18 -8
  39. package/src/daemon/handlers/payments/card-store.ts +486 -0
  40. package/src/daemon/handlers/payments/index.ts +32 -0
  41. package/src/daemon/handlers/payments/purchase-ledger.ts +108 -0
  42. package/src/daemon/handlers/payments/register.ts +391 -0
  43. package/src/daemon/handlers/register.ts +3 -3
  44. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +9 -1
  45. package/src/daemon/handlers/remote/backends/process-runner.ts +1 -1
  46. package/src/daemon/handlers/remote/backends/ssh.ts +9 -1
  47. package/src/daemon/handlers/remote/backends/types.ts +2 -2
  48. package/src/daemon/handlers/remote/dispatcher.ts +3 -3
  49. package/src/daemon/handlers/remote/index.ts +1 -1
  50. package/src/daemon/handlers/remote/peer-registry.ts +62 -13
  51. package/src/daemon/handlers/routing/inbox-bridge.ts +5 -5
  52. package/src/daemon/handlers/routing/index.ts +1 -1
  53. package/src/daemon/handlers/routing/route-store.ts +1 -1
  54. package/src/daemon/handlers/routing/routing-resolver.ts +3 -3
  55. package/src/daemon/handlers/sqlite-store.ts +9 -9
  56. package/src/daemon/handlers/triage/index.ts +1 -1
  57. package/src/daemon/handlers/triage/integration.ts +3 -3
  58. package/src/daemon/handlers/triage/pipeline.ts +2 -2
  59. package/src/daemon/handlers/triage/scorer.ts +2 -2
  60. package/src/daemon/handlers/triage/tagger/discord.ts +3 -3
  61. package/src/daemon/handlers/triage/tagger/imap.ts +7 -7
  62. package/src/daemon/handlers/triage/tagger/index.ts +1 -1
  63. package/src/daemon/handlers/triage/tagger/shared.ts +3 -3
  64. package/src/daemon/handlers/triage/tagger/slack.ts +1 -1
  65. package/src/daemon/handlers/triage/types.ts +2 -2
  66. package/src/daemon/lifecycle.ts +5 -5
  67. package/src/daemon/local-daemon-state.ts +7 -7
  68. package/src/daemon/pair-command.ts +14 -14
  69. package/src/daemon/provision-wake-model.ts +5 -5
  70. package/src/daemon/send/channels.ts +7 -7
  71. package/src/daemon/send/command.ts +11 -11
  72. package/src/daemon/send/composition.ts +5 -5
  73. package/src/daemon/send/failure-text.ts +6 -6
  74. package/src/daemon/send/inert-text.ts +18 -18
  75. package/src/daemon/send/stdin.ts +3 -3
  76. package/src/daemon/service-commands.ts +32 -32
  77. package/src/daemon/sessions-command.ts +7 -7
  78. package/src/daemon/status-command.ts +22 -22
  79. package/src/daemon/webui-command.ts +14 -14
  80. package/src/runtime/boot-tasks.ts +1 -1
  81. package/src/runtime/cluster-composition.ts +9 -9
  82. package/src/runtime/cluster-group-composition.ts +7 -7
  83. package/src/runtime/conversation-rewind-port.ts +8 -8
  84. package/src/runtime/credential-composition.ts +2 -2
  85. package/src/runtime/daemon-handler-composition.ts +44 -4
  86. package/src/runtime/device-posture-composition.ts +10 -10
  87. package/src/runtime/disposal-wiring.ts +8 -8
  88. package/src/runtime/fleet-needs-input-push.ts +4 -4
  89. package/src/runtime/fleet-services.ts +1 -1
  90. package/src/runtime/hosted-session-composition.ts +13 -13
  91. package/src/runtime/index.ts +1 -1
  92. package/src/runtime/knowledge-services.ts +2 -2
  93. package/src/runtime/legacy-daemon-migration.ts +43 -43
  94. package/src/runtime/legacy-daemon-reconcile.ts +30 -30
  95. package/src/runtime/mail-composition.ts +6 -6
  96. package/src/runtime/notification-dispatch.ts +7 -7
  97. package/src/runtime/payments-composition.ts +143 -0
  98. package/src/runtime/plugin-composition.ts +7 -7
  99. package/src/runtime/runtime-services-types.ts +9 -9
  100. package/src/runtime/services.ts +29 -27
  101. package/src/runtime/trigger-services.ts +1 -1
  102. package/src/runtime/trust/checkpoint-eligibility.ts +5 -5
  103. package/src/runtime/trust/trust-gated-approvals.ts +9 -9
  104. package/src/runtime/update-check.ts +4 -4
  105. package/src/runtime/workspace-checkpointing.ts +6 -6
  106. package/src/testing/daemon-fixture.ts +11 -11
  107. package/src/testing/hosted-session-failures.ts +4 -4
  108. package/src/version.ts +2 -2
@@ -1,9 +1,9 @@
1
1
  /**
2
- * status-command.ts `goodvibes-daemon status` and `goodvibes-daemon update`.
2
+ * status-command.ts, `goodvibes-daemon status` and `goodvibes-daemon update`.
3
3
  *
4
4
  * The question a headless box's operator asks first: is it up, what version, on
5
5
  * what address, is anything unhealthy, and what did it do to itself while I was
6
- * not looking. Before this, the binary answered none of that `status` fell
6
+ * not looking. Before this, the binary answered none of that, `status` fell
7
7
  * through the parser and started a SECOND daemon in the foreground.
8
8
  *
9
9
  * WHERE EACH LINE COMES FROM
@@ -13,11 +13,11 @@
13
13
  * established: `/status` for identity, `/api/health` for the health roll-up and
14
14
  * the address it actually bound, `/api/channels/status` for the channels, and
15
15
  * `/api/cluster/status` for this machine's place in its group. Hosted sessions
16
- * are a ws-only verb family, so they go through `callDaemonWsVerb` same
16
+ * are a ws-only verb family, so they go through `callDaemonWsVerb`, same
17
17
  * target, same token, different transport.
18
18
  *
19
- * Everything about the daemon's own HISTORY uptime, the receipts it wrote,
20
- * the version an automatic rollback rejected comes from files on the daemon's
19
+ * Everything about the daemon's own HISTORY, uptime, the receipts it wrote,
20
+ * the version an automatic rollback rejected, comes from files on the daemon's
21
21
  * host, because no verb reports them. That makes those lines local-only, and
22
22
  * they say so for a remote target instead of being guessed at.
23
23
  *
@@ -76,8 +76,8 @@ export interface RemoteCommandDeps {
76
76
  *
77
77
  * It carries a `cluster` block the operator contract does not list, and that
78
78
  * block is where this daemon's ROLE in its group comes from. `cluster.uptimeMs`
79
- * is the coordinator's, not the daemon's it reads 0 on a daemon that has been
80
- * up for hours so the uptime line comes from the lifecycle marker instead.
79
+ * is the coordinator's, not the daemon's, it reads 0 on a daemon that has been
80
+ * up for hours, so the uptime line comes from the lifecycle marker instead.
81
81
  */
82
82
  interface ControlStatusPayload {
83
83
  readonly status?: string;
@@ -179,7 +179,7 @@ function optionalLine(label: string, value: string | undefined): string[] {
179
179
  *
180
180
  * `/status` reports `version` from the platform package while the cluster block
181
181
  * it carries reports the DAEMON build's version, and against a live daemon
182
- * those disagreed 1.21.0 against 1.28.0. Printing one of them silently would
182
+ * those disagreed, 1.21.0 against 1.28.0. Printing one of them silently would
183
183
  * put a number on this page that is wrong for whichever question the reader had
184
184
  * in mind, so both are printed and labelled until the daemon states one.
185
185
  */
@@ -210,7 +210,7 @@ function localStateLines(state: LocalDaemonState): string[] {
210
210
  }
211
211
  if (state.marker?.rejectedVersion !== undefined) {
212
212
  lines.push(
213
- ` rejected: ${state.marker.rejectedVersion} crash looped and was rolled back `
213
+ ` rejected: ${state.marker.rejectedVersion} crash looped and was rolled back; `
214
214
  + 'the update loop will not install that version again',
215
215
  );
216
216
  }
@@ -229,7 +229,7 @@ function localStateLines(state: LocalDaemonState): string[] {
229
229
  }
230
230
 
231
231
  function healthLines(outcome: DaemonVerbOutcome<HealthPayload>): string[] {
232
- if (!outcome.ok) return [` health: could not read ${outcome.error}`];
232
+ if (!outcome.ok) return [` health: could not read: ${outcome.error}`];
233
233
  const health = outcome.data;
234
234
  const lines = [` health: ${health.overall ?? 'unknown'}`];
235
235
  const binding = health.network?.controlPlane;
@@ -250,12 +250,12 @@ function healthLines(outcome: DaemonVerbOutcome<HealthPayload>): string[] {
250
250
  *
251
251
  * Only a channel that is switched ON and not healthy is named. Every channel
252
252
  * the daemon knows about appears in this payload, and a daemon with one
253
- * configured channel ships sixteen more in state `disabled` listing those as
253
+ * configured channel ships sixteen more in state `disabled`, listing those as
254
254
  * problems produced a seventeen-line wall under a healthy daemon and buried the
255
255
  * one line that meant something.
256
256
  */
257
257
  function channelLines(outcome: DaemonVerbOutcome<ChannelsPayload>): string[] {
258
- if (!outcome.ok) return [` channels: could not read ${outcome.error}`];
258
+ if (!outcome.ok) return [` channels: could not read: ${outcome.error}`];
259
259
  const channels = outcome.data.channels ?? [];
260
260
  if (channels.length === 0) return [' channels: none configured'];
261
261
  const on = channels.filter((channel) => channel.enabled !== false);
@@ -276,7 +276,7 @@ function channelLines(outcome: DaemonVerbOutcome<ChannelsPayload>): string[] {
276
276
  * Two sources, because neither answers the whole question: `/status` carries
277
277
  * the ROLE this node currently holds, and `/api/cluster/status` carries the
278
278
  * GROUP it holds that role in. A daemon with sharing switched off says so and
279
- * stops a role inside no group is not information.
279
+ * stops, a role inside no group is not information.
280
280
  */
281
281
  function clusterLines(
282
282
  identity: ControlStatusPayload,
@@ -284,18 +284,18 @@ function clusterLines(
284
284
  ): string[] {
285
285
  const role = identity.cluster?.role;
286
286
  if (identity.cluster?.enabled === false) {
287
- return [' cluster: off this machine handles its own inbound work'];
287
+ return [' cluster: off, this machine handles its own inbound work'];
288
288
  }
289
289
  if (!outcome.ok) {
290
290
  return [
291
291
  role === undefined
292
- ? ` cluster: could not read ${outcome.error}`
293
- : ` cluster: ${role} (the group view could not be read ${outcome.error})`,
292
+ ? ` cluster: could not read: ${outcome.error}`
293
+ : ` cluster: ${role} (the group view could not be read: ${outcome.error})`,
294
294
  ];
295
295
  }
296
296
  const cluster = outcome.data;
297
297
  if (cluster.membership === 'no-group') {
298
- return [' cluster: in no group yet `goodvibes-daemon cluster create` starts one'];
298
+ return [' cluster: in no group yet, `goodvibes-daemon cluster create` starts one'];
299
299
  }
300
300
  const group = cluster.groupName ?? cluster.groupId ?? 'its group';
301
301
  const members = cluster.memberCount === undefined ? '' : ` of ${cluster.memberCount}`;
@@ -303,7 +303,7 @@ function clusterLines(
303
303
  }
304
304
 
305
305
  function hostedSessionLines(outcome: DaemonVerbOutcome<HostedSessionsPayload>): string[] {
306
- if (!outcome.ok) return [` sessions: could not read ${outcome.error}`];
306
+ if (!outcome.ok) return [` sessions: could not read: ${outcome.error}`];
307
307
  const sessions = outcome.data.sessions ?? [];
308
308
  return [` sessions: ${sessions.length} hosted by this daemon`];
309
309
  }
@@ -317,7 +317,7 @@ export interface RunStatusCommandInput extends RemoteCommandDeps {
317
317
  *
318
318
  * Exit 0 when the daemon answered its identity call, 1 when it could not be
319
319
  * reached. Every other sub-question that fails is one line inside a successful
320
- * report a daemon with a broken channel is up, and saying otherwise would be
320
+ * report, a daemon with a broken channel is up, and saying otherwise would be
321
321
  * the kind of wrong that makes an operator distrust the whole page.
322
322
  */
323
323
  export async function runStatusCommand(input: RunStatusCommandInput): Promise<DaemonCommandResult> {
@@ -407,8 +407,8 @@ export interface RunUpdateCommandInput extends RemoteCommandDeps {
407
407
  * rejected, and whether a rollback is in force.
408
408
  *
409
409
  * --check is honest about a gap. The daemon runs the whole self-update loop
410
- * itself it checks hourly, swaps at an idle moment and keeps the outgoing
411
- * binary but the control plane publishes NO verb to trigger that check early:
410
+ * itself, it checks hourly, swaps at an idle moment and keeps the outgoing
411
+ * binary, but the control plane publishes NO verb to trigger that check early:
412
412
  * the operator contract this build was written against has no update method of
413
413
  * any kind (no `update.*`, no `admin.update`, nothing under `control.` that
414
414
  * checks). Rather than invent a verb this daemon does not answer, --check says
@@ -439,7 +439,7 @@ export async function runUpdateCommand(input: RunUpdateCommandInput): Promise<Da
439
439
  '',
440
440
  'update --check: this daemon publishes no verb to trigger an update check early.',
441
441
  ' It checks once an hour on its own and swaps only at an idle moment.',
442
- ' To make it check now, restart it it checks on the way up:',
442
+ ' To make it check now, restart it; it checks on the way up:',
443
443
  ' goodvibes-daemon restart-service',
444
444
  ]
445
445
  : [];
@@ -1,5 +1,5 @@
1
1
  // ---------------------------------------------------------------------------
2
- // webui-command.ts `goodvibes-daemon webui enable|disable|status`.
2
+ // webui-command.ts, `goodvibes-daemon webui enable|disable|status`.
3
3
  //
4
4
  // WHAT THIS IS FOR
5
5
  //
@@ -8,7 +8,7 @@
8
8
  // serve such a directory: with `controlPlane.webui.serve` on, its own HTTP
9
9
  // router answers `/` from `controlPlane.webui.bundleDir` and falls back to
10
10
  // index.html for app routes. So installing the web UI is two config writes and
11
- // a directory on disk and this is the command that makes those writes, so the
11
+ // a directory on disk, and this is the command that makes those writes, so the
12
12
  // curl installer does not have to know the key names, the file format, or which
13
13
  // of the three settings tiers a daemon-owned key belongs in. Same reason
14
14
  // `provision-wake-model` exists: the installer runs the binary it just placed
@@ -17,12 +17,12 @@
17
17
  // WHERE IT IS SERVED, AND WHY THE URL SAYS WHAT IT SAYS
18
18
  //
19
19
  // The bundle is served BY THE CONTROL-PLANE LISTENER, same origin as the API
20
- // (that is the whole point a same-origin bundle makes the browser's
20
+ // (that is the whole point, a same-origin bundle makes the browser's
21
21
  // same-origin policy a non-issue and needs no CORS allowlist). So the URL that
22
22
  // opens the web UI is the control-plane origin: `http://<host>:<controlPlane.port>`,
23
23
  // not `web.port`. `web.port` is the surface's DECLARED endpoint, used for links
24
- // and for `tailscale serve`; nothing binds it. This command therefore reports
25
- // and, when it is still sitting on the shipped placeholder, writes
24
+ // and for `tailscale serve`; nothing binds it. This command therefore reports,
25
+ // and, when it is still sitting on the shipped placeholder, writes,
26
26
  // `web.publicBaseUrl` as the origin that actually answers, so the printed URL,
27
27
  // the pairing deep link and the running server all agree.
28
28
  //
@@ -41,7 +41,7 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
41
41
  import { resolveRuntimeEndpointBinding } from '@pellux/goodvibes-terminal-shell';
42
42
  import { probeStableHostInputs, stableUrlHostForBindHost, type StableHostInputs } from '@pellux/goodvibes-sdk/platform/pairing';
43
43
 
44
- /** The shipped `web.publicBaseUrl` placeholder a port nothing binds. */
44
+ /** The shipped `web.publicBaseUrl` placeholder, a port nothing binds. */
45
45
  const SHIPPED_PUBLIC_BASE_URL = 'http://127.0.0.1:3423';
46
46
 
47
47
  export interface WebuiCommandResult {
@@ -175,13 +175,13 @@ function postureLines(origin: string, loopback: boolean): string[] {
175
175
  ];
176
176
  }
177
177
  return [
178
- ` reachable from your network at ${origin} the control-plane listener is bound to all interfaces.`,
178
+ ` reachable from your network at ${origin}; the control-plane listener is bound to all interfaces.`,
179
179
  ' To take it back to this machine only: goodvibes-daemon webui enable --loopback',
180
180
  ];
181
181
  }
182
182
 
183
183
  /**
184
- * Run the command and report it. Never throws for an ordinary refusal a bad
184
+ * Run the command and report it. Never throws for an ordinary refusal, a bad
185
185
  * argument, a missing bundle, an unwritable settings file all come back as an
186
186
  * exit code and lines, because the caller is often an installer reading both.
187
187
  */
@@ -212,14 +212,14 @@ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps)
212
212
  try {
213
213
  config.set('controlPlane.webui.serve', false);
214
214
  } catch (error) {
215
- return { exitCode: 1, lines: [`webui: could not write settings ${message(error)}`] };
215
+ return { exitCode: 1, lines: [`webui: could not write settings: ${message(error)}`] };
216
216
  }
217
217
  const kept = readString(config, 'controlPlane.webui.bundleDir');
218
218
  return {
219
219
  exitCode: 0,
220
220
  lines: [
221
221
  'web UI: no longer served by the daemon.',
222
- ...(kept ? [` the bundle is left on disk at ${kept} 'goodvibes-daemon webui enable' serves it again`] : []),
222
+ ...(kept ? [` the bundle is left on disk at ${kept}; 'goodvibes-daemon webui enable' serves it again`] : []),
223
223
  ' Restart the daemon for this to take effect on a running process.',
224
224
  ],
225
225
  };
@@ -242,7 +242,7 @@ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps)
242
242
  return {
243
243
  exitCode: 1,
244
244
  lines: [
245
- `webui: refusing to serve ${bundleDir} ${problem}.`,
245
+ `webui: refusing to serve ${bundleDir}: ${problem}.`,
246
246
  ' Nothing was changed.',
247
247
  ],
248
248
  };
@@ -260,7 +260,7 @@ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps)
260
260
  config.set('web.hostMode', 'local');
261
261
  }
262
262
  } catch (error) {
263
- return { exitCode: 1, lines: [`webui: could not write settings ${message(error)}`] };
263
+ return { exitCode: 1, lines: [`webui: could not write settings: ${message(error)}`] };
264
264
  }
265
265
 
266
266
  const serving = servingOrigin(config, probe);
@@ -284,7 +284,7 @@ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps)
284
284
  }
285
285
 
286
286
  if (!serving.recognized) {
287
- lines.push(' note: controlPlane.hostMode is not one of local|network|custom the daemon cannot bind until that is corrected.');
287
+ lines.push(' note: controlPlane.hostMode is not one of local|network|custom; the daemon cannot bind until that is corrected.');
288
288
  }
289
289
  lines.push(...postureLines(serving.origin, serving.loopback));
290
290
  lines.push(' Restart the daemon for this to take effect on a running process.');
@@ -324,7 +324,7 @@ function renderStatus(
324
324
  } else {
325
325
  const absoluteDir = deps.absolute(directory);
326
326
  const problem = describeBundleProblem(absoluteDir, deps.directoryExists, deps.fileExists);
327
- lines.push(problem ? ` bundle (${source}): ${absoluteDir} UNUSABLE: ${problem}` : ` bundle (${source}): ${absoluteDir}`);
327
+ lines.push(problem ? ` bundle (${source}): ${absoluteDir} (UNUSABLE: ${problem})` : ` bundle (${source}): ${absoluteDir}`);
328
328
  }
329
329
  const currentPublic = readString(config, 'web.publicBaseUrl');
330
330
  if (currentPublic && currentPublic !== serving.origin) {
@@ -98,7 +98,7 @@ export async function runDaemonBootTasks(services: RuntimeServices): Promise<voi
98
98
 
99
99
  // Load the plugins this host can serve. The manager was constructed by the
100
100
  // graph and never initialised, so it could list a plugin directory and never
101
- // load anything out of it `enable` persisted a flag that turned nothing on.
101
+ // load anything out of it, `enable` persisted a flag that turned nothing on.
102
102
  // Both hosts read the same directories; each takes the registrations it can
103
103
  // serve (plugin-composition.ts). Best-effort like everything else here: a
104
104
  // plugin that will not load is not a reason for the daemon not to start.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * cluster-composition.ts this daemon's seat in the LAN leader election.
2
+ * cluster-composition.ts, this daemon's seat in the LAN leader election.
3
3
  *
4
- * When the same goodvibes install runs more than once on one network a
5
- * laptop and a desktop, or two processes on one machine every copy
4
+ * When the same goodvibes install runs more than once on one network, a
5
+ * laptop and a desktop, or two processes on one machine, every copy
6
6
  * independently reads the shared inbox, so one message is picked up twice and
7
7
  * answered twice. The SDK's cluster coordinator elects exactly one node to be
8
8
  * responsible for inbound consumption; everything else stays warm and silent.
@@ -10,8 +10,8 @@
10
10
  * This file exists because this daemon does NOT get that for free from the
11
11
  * SDK facade. The facade gates the consumers IT owns (Telegram
12
12
  * ingress, the ntfy/Slack/Discord provider runtime), but this repository
13
- * composes an inbound consumer of its own the Slack/Discord/email inbox
14
- * poller in daemon/handlers/inbox and the facade knows nothing about it.
13
+ * composes an inbound consumer of its own, the Slack/Discord/email inbox
14
+ * poller in daemon/handlers/inbox, and the facade knows nothing about it.
15
15
  * A fix that only landed in the facade would leave the poller double-reading
16
16
  * on exactly the machine this product runs on.
17
17
  *
@@ -51,7 +51,7 @@ export interface GatedPollerControl {
51
51
  * Constructing it is inert: no socket is opened and no state is written until
52
52
  * `start()` runs, so composing a runtime in a test never joins a network.
53
53
  *
54
- * The version is THIS BINARY's version, not the SDK package's the same
54
+ * The version is THIS BINARY's version, not the SDK package's, the same
55
55
  * distinction daemon/cli.ts already makes for the self-update artifact. It is
56
56
  * the first ranking tier, so getting it wrong would let a stale build hold the
57
57
  * role through an update.
@@ -62,7 +62,7 @@ export function createClusterComposition(options: {
62
62
  * `readClusterSettings` asks for. The daemon passes a whole ConfigManager;
63
63
  * the handler context carries only this slice, and a test standing in a
64
64
  * minimal config object should not have to fabricate the full fifty-odd
65
- * members to compose a coordinator a cast there would hide a real shape
65
+ * members to compose a coordinator, a cast there would hide a real shape
66
66
  * mismatch rather than document one.
67
67
  */
68
68
  readonly configManager: Pick<ConfigManager, 'getCategory'>;
@@ -75,7 +75,7 @@ export function createClusterComposition(options: {
75
75
  * the current group key. That is what stops a daemon belonging to somebody
76
76
  * else on the same network from taking part in this election at all.
77
77
  *
78
- * Absent the plain default means the coordinator opens its own socket and
78
+ * Absent, the plain default, means the coordinator opens its own socket and
79
79
  * coordinates with anything that answers on the configured port.
80
80
  */
81
81
  readonly transport?: ClusterTransport | undefined;
@@ -96,7 +96,7 @@ export function createClusterComposition(options: {
96
96
  *
97
97
  * One gate per account, not one for the poller. Each inbox account is its own
98
98
  * surface in the election, so this laptop can hold the work Slack account
99
- * while the desktop holds the mailbox and losing one machine moves only the
99
+ * while the desktop holds the mailbox, and losing one machine moves only the
100
100
  * accounts it was reading. A single gate covering the whole poller could not
101
101
  * express that: it would hand every account over together, and a node with a
102
102
  * credential for only one of them could never take part at all.
@@ -1,10 +1,10 @@
1
1
  /**
2
- * cluster-group-composition.ts this machine's membership of a LAN group.
2
+ * cluster-group-composition.ts, this machine's membership of a LAN group.
3
3
  *
4
4
  * Leader election answers "which of us reads the inbox". This answers the
5
5
  * question underneath it: "which machines are US". Without a group, any daemon
6
- * that happened to be on the same network a neighbour's, a colleague's, a
7
- * container someone left running would join the same coordination and one of
6
+ * that happened to be on the same network, a neighbour's, a colleague's, a
7
+ * container someone left running, would join the same coordination and one of
8
8
  * you would silently stop receiving messages.
9
9
  *
10
10
  * The group layer owns the socket and the election rides on it: every
@@ -77,7 +77,7 @@ export function createClusterGroupComposition(options: {
77
77
  * The leader election's own answer to "am I the master".
78
78
  *
79
79
  * Config replication needs exactly one machine issuing revisions, and it must
80
- * be the SAME machine leadership already picked two notions of master in
80
+ * be the SAME machine leadership already picked, two notions of master in
81
81
  * one process would disagree the moment one of them changed.
82
82
  *
83
83
  * Late-bound because the coordinator is built from this composition's
@@ -91,7 +91,7 @@ export function createClusterGroupComposition(options: {
91
91
  * in `cluster status` and in /status. Late-bound for the same reason
92
92
  * `isMaster` is. Absent means the group layer reports that the information is
93
93
  * unavailable rather than reporting an empty list as though this machine held
94
- * nothing a distinction an operator diagnosing a silent inbox depends on.
94
+ * nothing, a distinction an operator diagnosing a silent inbox depends on.
95
95
  */
96
96
  readonly surfaceHoldings?: (() => readonly ClusterSurfaceHolding[]) | undefined;
97
97
  }): ClusterGroupComposition {
@@ -181,7 +181,7 @@ export function createClusterGroupComposition(options: {
181
181
  * through a closure because the coordinator does not exist yet when the group
182
182
  * layer is constructed.
183
183
  *
184
- * Constructing either is inert no socket, no key material read until
184
+ * Constructing either is inert, no socket, no key material read, until
185
185
  * `startCluster` runs.
186
186
  */
187
187
  export function createClusterServices(options: {
@@ -228,7 +228,7 @@ export async function startClusterServices(services: {
228
228
  * Ask the group to take this machine back, on start.
229
229
  *
230
230
  * This is the zero-touch return. A machine that has been switched off for
231
- * months through many group-key rotations and possibly a join-key change
231
+ * months, through many group-key rotations and possibly a join-key change,
232
232
  * comes up holding stale keys, says "it is still me" with a key that never
233
233
  * rotates, and is re-keyed to the current generation by whichever member
234
234
  * answers. The operator does nothing.
@@ -1,5 +1,5 @@
1
1
  // ---------------------------------------------------------------------------
2
- // conversation-rewind-port.ts the daemon's RewindConversationPort for the
2
+ // conversation-rewind-port.ts, the daemon's RewindConversationPort for the
3
3
  // SDK's unified rewind service.
4
4
  //
5
5
  // The SDK's UnifiedRewindService (platform/rewind) joins files rewind (workspace
@@ -9,7 +9,7 @@
9
9
  // truncation and captures the pre-/post-truncation snapshots so the reversal can
10
10
  // be undone and re-applied. The truncation boundary is the message count recorded
11
11
  // for the anchor's turnId at TURN_COMPLETED (the SDK's platform/rewind turn
12
- // anchors) the same
12
+ // anchors), the same
13
13
  // join key files rewind uses against the workspace checkpoint.
14
14
  //
15
15
  // This module is the daemon's implementation of that port, for sessions THIS
@@ -17,7 +17,7 @@
17
17
  // anchor.sessionId from the registry below.
18
18
  //
19
19
  // While conversation loops run in the surfaces, that registry is empty here, and
20
- // it used to answer an empty resolution with "0 messages to drop" the same
20
+ // it used to answer an empty resolution with "0 messages to drop", the same
21
21
  // answer a conversation already at the anchor gives, which is a confident wrong
22
22
  // answer rather than a missing one. It now reports the anchor as UNAVAILABLE
23
23
  // with the reason, which the SDK's rewind service turns into a plan warning.
@@ -25,7 +25,7 @@
25
25
  // The surfaces reach conversation rewind a different way now: they offer their
26
26
  // live conversation over the control plane (rewind.conversation.*), and the
27
27
  // SDK's host broker asks them directly. This port is the fallback the broker
28
- // falls through to for sessions no surface has offered which is exactly the
28
+ // falls through to for sessions no surface has offered, which is exactly the
29
29
  // case where the daemon hosts the conversation itself. Files-scope rewind,
30
30
  // entirely daemon-owned, was never affected either way.
31
31
  //
@@ -65,7 +65,7 @@ export interface ConversationRewindPort extends RewindConversationPort {
65
65
  restoreAfter(undoSnapshotId: string): boolean;
66
66
  }
67
67
 
68
- /** One truncation's captured state the target conversation and its snapshots. */
68
+ /** One truncation's captured state, the target conversation and its snapshots. */
69
69
  interface SnapshotPair {
70
70
  readonly conv: RewindableConversation;
71
71
  readonly before: ConversationJson;
@@ -80,7 +80,7 @@ const NO_LIVE_CONVERSATION =
80
80
  * Build a conversation rewind port. `resolveConversation` maps an anchor's
81
81
  * sessionId to the live conversation: the daemon looks the session up in the
82
82
  * registry below. A null resolution is reported as unavailable with the reason
83
- * never as a count, because "nobody here is holding those messages" and
83
+ *, never as a count, because "nobody here is holding those messages" and
84
84
  * "there are no messages to drop" are different facts and only one of them is
85
85
  * true.
86
86
  */
@@ -139,7 +139,7 @@ export function createConversationRewindPort(
139
139
  }
140
140
 
141
141
  // ---------------------------------------------------------------------------
142
- // Live per-session conversation registry the daemon-hosted mutable store the
142
+ // Live per-session conversation registry, the daemon-hosted mutable store the
143
143
  // composed daemon's rewind.plan/apply verbs fall back to. A process INSIDE this
144
144
  // daemon that runs a conversation registers it here; a surface in another
145
145
  // process offers its conversation over the control plane instead
@@ -162,7 +162,7 @@ export function unregisterSessionConversation(sessionId: string): void {
162
162
 
163
163
  /**
164
164
  * The conversation rewind port the composed daemon threads into
165
- * registerGatewayVerbGroups it resolves each anchor's live conversation from
165
+ * registerGatewayVerbGroups, it resolves each anchor's live conversation from
166
166
  * the registry above, so the daemon's own rewind verbs serve conversation scope
167
167
  * live in this process.
168
168
  */
@@ -7,7 +7,7 @@
7
7
  * These are grouped because they share one fact, and it is the fact this round
8
8
  * had to fix: WHERE the daemon's own credentials live. `daemonHome` is threaded
9
9
  * rather than defaulted, because without it a daemon told to run out of a temp
10
- * tree moved its identity directory and nothing else the credential store
10
+ * tree moved its identity directory and nothing else, the credential store
11
11
  * stayed in the real home, so an "isolated" test daemon held live credentials
12
12
  * and long-polled a real account. A default here would silently restore that.
13
13
  *
@@ -25,7 +25,7 @@ interface CredentialCompositionInput {
25
25
  readonly homeDirectory: string;
26
26
  /**
27
27
  * The daemon's state root when the host was told one. Absent means "not
28
- * overridden" and must stay absent rather than being defaulted see above.
28
+ * overridden" and must stay absent rather than being defaulted, see above.
29
29
  */
30
30
  readonly daemonHomeDirectory?: string | undefined;
31
31
  readonly configManager: ConstructorParameters<typeof SecretsManager>[0]['configManager'];
@@ -1,9 +1,9 @@
1
1
  /**
2
- * daemon-handler-composition.ts the daemon's HOST-side handler surfaces.
2
+ * daemon-handler-composition.ts, the daemon's HOST-side handler surfaces.
3
3
  *
4
4
  * Attaches this repository's handlers to the SDK-auto-registered builtin
5
5
  * gateway descriptors (channels.* / email.* / calendar.*) via
6
- * catalog.register(descriptor, handler, { replace: true }) the SDK owns
6
+ * catalog.register(descriptor, handler, { replace: true }), the SDK owns
7
7
  * every id, descriptor and schema; only the behaviour is ours. The remote
8
8
  * surface reuses the SAME DistributedRuntimeManager the SDK facade injects.
9
9
  *
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * The one behavioural decision that lives here: the inbox poller is handed to
14
14
  * the cluster coordinator instead of being started eagerly. It is this
15
- * product's own inbound consumer the SDK facade does not know it exists
15
+ * product's own inbound consumer, the SDK facade does not know it exists,
16
16
  * so if it is not gated here it is not gated anywhere, and two goodvibes nodes
17
17
  * on one network each read the shared inbox and answer the same message twice.
18
18
  */
@@ -28,7 +28,9 @@ import { registerInboxMethods } from '../daemon/handlers/inbox/index.ts';
28
28
  import { registerTriagedInbox } from '../daemon/handlers/triage/index.ts';
29
29
  import { registerDraftMethods } from '../daemon/handlers/drafts/index.ts';
30
30
  import { registerRemoteSurface } from '../daemon/handlers/remote/index.ts';
31
+ import { createPaymentsServices } from './payments-composition.ts';
31
32
  import { inboxPollerGate } from './cluster-composition.ts';
33
+ import type { ShellPathService } from '@/runtime/index.ts';
32
34
 
33
35
  export interface DaemonHandlerCompositionOptions {
34
36
  readonly gatewayMethods: GatewayMethodCatalog;
@@ -36,6 +38,8 @@ export interface DaemonHandlerCompositionOptions {
36
38
  readonly configManager: ConfigManager;
37
39
  readonly workingDirectory: string;
38
40
  readonly homeDirectory: string;
41
+ /** Resolves the surface-scoped control-plane paths the payment stores live at. */
42
+ readonly shellPaths: ShellPathService;
39
43
  readonly distributedRuntime: NonNullable<Parameters<typeof registerRemoteSurface>[1]>['manager'];
40
44
  /**
41
45
  * Decides whether THIS node polls the shared inbox. Always supplied by the
@@ -65,12 +69,48 @@ export function createDaemonHandlerComposition(
65
69
  registerInbox: (ctx, routing) =>
66
70
  registerTriagedInbox(ctx, (inboxCtx) => registerInboxMethods(inboxCtx, routing, {
67
71
  // Hands polling to leadership. The `channels.inbox.list` read stays
68
- // available on every node a standby still SERVES the persisted feed,
72
+ // available on every node, a standby still SERVES the persisted feed,
69
73
  // it just does not FETCH into it.
70
74
  gatePolling: (providerId, control) =>
71
75
  options.clusterCoordinator.register(inboxPollerGate(providerId, control)),
72
76
  })).unregister,
73
77
  registerDrafts: (ctx) => registerDraftMethods(ctx),
78
+ // The payment stores need a path resolver and a daemon-scoped secret writer,
79
+ // neither of which is on HandlerContext, so they are built here and the
80
+ // provider only carries the teardown. See payments-composition.ts for which
81
+ // verbs this attaches and which two it deliberately leaves refusing.
82
+ registerPayments: () => createPaymentsServices({
83
+ gatewayMethods: options.gatewayMethods,
84
+ configManager: options.configManager,
85
+ secretsManager: options.secretsManager,
86
+ shellPaths: options.shellPaths,
87
+ // True only on a machine that is not clustered at all, and false on every
88
+ // node of a cluster.
89
+ //
90
+ // `isMaster` was the obvious answer and it is the wrong one: it means
91
+ // "this node holds at least one inbound surface", which two nodes sharing
92
+ // a mailbox and a Slack workspace both satisfy, so a two-node cluster
93
+ // would have answered true twice. The SDK's gates.ts is explicit that
94
+ // exactly one node may act and that the wrong answer here is a
95
+ // double-spend.
96
+ //
97
+ // The alternative was to register a payments surface with the coordinator
98
+ // and read holdsSurface(), which is this repo's per-surface idiom
99
+ // (inboxPollerGate). It is the right answer once there is something to
100
+ // elect over, and today there is not: ClusterConsumerGate.start() is
101
+ // specified to not resolve until consumption has actually begun, and this
102
+ // composition has no payments consumer to start, checkout is unattached.
103
+ // Registering a gate whose start() does nothing would put a fake consumer
104
+ // in the election and in `cluster status`.
105
+ //
106
+ // So the honest reading of the topology: clustering off means this is the
107
+ // only node, and it is trivially the one that would spend; clustering on
108
+ // means no payments election has been held and this node cannot claim to
109
+ // have won it. False on every node is also the safe direction, checkPaymentGates
110
+ // refuses on false. Wiring payments.checkout.* is what should replace this
111
+ // with a real gate and holdsSurface().
112
+ isPaymentsLeader: () => !options.clusterCoordinator.enabled,
113
+ }).unregister,
74
114
  registerRemote: (ctx) => registerRemoteSurface(ctx, { manager: options.distributedRuntime }),
75
115
  });
76
116
  }
@@ -1,11 +1,11 @@
1
1
  /**
2
- * device-posture-composition.ts the paired-phone feature inside THIS daemon.
2
+ * device-posture-composition.ts, the paired-phone feature inside THIS daemon.
3
3
  *
4
4
  * A phone pairs with whichever daemon the person runs. Until this module
5
5
  * existed this daemon had no device posture at all: `device.nodes.maxPaired`
6
6
  * was enforced at the pairing path (SDK-side) and `device.capabilities.mode`
7
7
  * was an onboarding toggle, while the other eleven `device.*` keys were
8
- * recorded, read back, and governed nothing the capability service they
8
+ * recorded, read back, and governed nothing, the capability service they
9
9
  * describe was never built here, so there was nothing for them to govern.
10
10
  *
11
11
  * The feature itself is platform-owned (`platform/devices`): the settings→policy
@@ -13,16 +13,16 @@
13
13
  * confirmation flow, and the `phone` tool. This module supplies the three seams
14
14
  * that are actually ours and nothing else:
15
15
  *
16
- * - the peer transport the same DistributedRuntimeManager the remote surface
16
+ * - the peer transport, the same DistributedRuntimeManager the remote surface
17
17
  * pairs devices onto, so a phone paired here is a node here,
18
- * - the approval path the shared approval broker, so the confirmation
18
+ * - the approval path, the shared approval broker, so the confirmation
19
19
  * appears wherever the person is looking (terminal, web app, companion),
20
20
  * - the storage root and the live config manager.
21
21
  *
22
22
  * Constructing this starts nothing. `startHousekeeping()` is called from the
23
23
  * bootstrap tail: grants and captures both outlive a restart, so a grant whose
24
24
  * phone is gone, or a capture torn by a crash, is reaped BEFORE the first
25
- * request of this run is served and the periodic sweep after it is what keeps
25
+ * request of this run is served, and the periodic sweep after it is what keeps
26
26
  * a long-running daemon from going days without one. A failed sweep is logged
27
27
  * and the app still runs: housekeeping failing is a reason to say so, not a
28
28
  * reason to refuse to start.
@@ -52,8 +52,8 @@ export interface DevicePostureCompositionOptions {
52
52
  /**
53
53
  * Binding the catalog turns the whole devices.* family from
54
54
  * cataloged-but-unhandled into real handlers: nodes.list, grants.*,
55
- * housekeeping.run, and since a surface with no device runtime of its own
56
- * could read the grants and never open a camera capability.request and
55
+ * housekeeping.run, and, since a surface with no device runtime of its own
56
+ * could read the grants and never open a camera, capability.request and
57
57
  * artifacts.list/read. That is what makes the paired phone usable from the web
58
58
  * app and the companion rather than only through this process's own tool.
59
59
  *
@@ -85,8 +85,8 @@ export function createDevicePostureServices(options: DevicePostureCompositionOpt
85
85
 
86
86
  /**
87
87
  * Everything a host with a tool registry has to do once it is up: register the
88
- * `phone` tool the only path that reaches the capability service, so without it
89
- * the posture keys govern nothing a session can observe and start housekeeping.
88
+ * `phone` tool, the only path that reaches the capability service, so without it
89
+ * the posture keys govern nothing a session can observe, and start housekeeping.
90
90
  *
91
91
  * One call, because these two belong to the same feature and a host that did the
92
92
  * first and forgot the second would serve requests while never reaping a grant
@@ -103,7 +103,7 @@ export function installDevicePosture(
103
103
  /**
104
104
  * The recovery sweep plus the periodic timer. Separate from construction so
105
105
  * composing a runtime in a test starts no timer and touches no disk, and
106
- * separate from the call above so this daemon which registers no tools
106
+ * separate from the call above so this daemon, which registers no tools,
107
107
  * still sweeps.
108
108
  */
109
109
  export function startDeviceHousekeeping(devicePosture: DevicePostureRuntime): void {