@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,6 +1,6 @@
1
1
  /**
2
2
  * The unattended startup reconcile for the redundant install-script
3
- * `goodvibes-daemon.service` unit split out of `legacy-daemon-migration.ts`
3
+ * `goodvibes-daemon.service` unit, split out of `legacy-daemon-migration.ts`
4
4
  * (which owns the shared unit definition, detection, and the CONSENTED
5
5
  * `migrate-service` engine) so each module stays within the architecture
6
6
  * gate's file-size cap. Same layer (`src/runtime/`), same injectable-seam
@@ -24,8 +24,8 @@ type ManagedServiceActionResult = ReturnType<ManagedServiceActionRunner>;
24
24
  /**
25
25
  * The exact marker string scripts/install.sh writes into every unit it
26
26
  * creates (as a `# managed by goodvibes install.sh` comment). The reconcile
27
- * check below keys on it to tell an installer-created legacy unit safe to
28
- * auto-retire apart from a hand-written one that must only ever be reported.
27
+ * check below keys on it to tell an installer-created legacy unit, safe to
28
+ * auto-retire, apart from a hand-written one that must only ever be reported.
29
29
  * Kept in lockstep with `INSTALLER_MARKER` in scripts/install.sh.
30
30
  */
31
31
  export const INSTALLER_UNIT_MARKER = 'managed by goodvibes install.sh';
@@ -35,7 +35,7 @@ export const INSTALLER_UNIT_MARKER = 'managed by goodvibes install.sh';
35
35
  /**
36
36
  * CUMULATIVE ceiling on the reconcile's whole boot-path pass. Per-call
37
37
  * timeouts alone still allow ~5 sequential slow-but-completing calls to stack
38
- * up (a degraded not wedged user bus), blocking the already-listening
38
+ * up (a degraded, not wedged, user bus), blocking the already-listening
39
39
  * daemon's event loop for tens of seconds. One overall deadline covers the
40
40
  * pass: once it is exceeded, every remaining call is skipped (reported as
41
41
  * `status: null`, which the guards treat as unknown → refusal) and the result
@@ -48,7 +48,7 @@ export const RECONCILE_DEADLINE_MS = 8_000;
48
48
  /**
49
49
  * Wrap a runner with the reconcile's cumulative deadline: calls after the
50
50
  * deadline are skipped outright; calls near it get only the remaining budget
51
- * as their per-call timeout (default runner only injected runners are test
51
+ * as their per-call timeout (default runner only, injected runners are test
52
52
  * fakes that answer instantly).
53
53
  */
54
54
  function makeDeadlineBoundRunner(
@@ -62,7 +62,7 @@ function makeDeadlineBoundRunner(
62
62
  const remaining = deadlineMs - (Date.now() - startedAt);
63
63
  if (remaining <= 0) {
64
64
  hit = true;
65
- return { status: null, stdout: '', stderr: 'reconcile time budget exceeded call skipped' } as ManagedServiceActionResult;
65
+ return { status: null, stdout: '', stderr: 'reconcile time budget exceeded; call skipped' } as ManagedServiceActionResult;
66
66
  }
67
67
  if (injected) return injected(command, args);
68
68
  return defaultActionRunner(Math.min(perCallTimeoutMs, remaining))(command, args);
@@ -167,7 +167,7 @@ function defaultReadOwnCgroup(): string {
167
167
  * This runs on daemon startup (no user invocation needed) and, guard-railed:
168
168
  * - Only acts when the canonical unit is CONFIRMED serving: `is-active`
169
169
  * reports active AND its MainPID resolves to a live process. `is-active`
170
- * alone is not proof a Type=simple unit reports active from fork onward,
170
+ * alone is not proof, a Type=simple unit reports active from fork onward,
171
171
  * including the pre-bind window of a daemon that is about to crash-loop.
172
172
  * - NEVER acts from inside the legacy unit itself: if this process IS the
173
173
  * legacy unit's MainPID, or /proc/self/cgroup names the legacy unit,
@@ -175,20 +175,20 @@ function defaultReadOwnCgroup(): string {
175
175
  * code (mid-boot, from inside a blocking spawnSync). Refuses instead.
176
176
  * - NEVER stops a RUNNING legacy daemon: if the legacy unit's MainPID is a
177
177
  * live process, it may be the daemon serving the endpoint clients are
178
- * configured to reach (the wrong-port two-daemon state) the unattended
178
+ * configured to reach (the wrong-port two-daemon state), the unattended
179
179
  * path only retires enabled-but-NOT-running legacy units; a running one
180
180
  * gets a notice pointing at the consented migrate-service.
181
181
  * - Requires the CONFIGURED endpoint (the one clients resolve from
182
- * settings.json, no runtime overrides) to be answering before retiring
182
+ * settings.json, no runtime overrides) to be answering before retiring,
183
183
  * a canonical daemon alive on some other port proves nothing about the
184
184
  * endpoint clients use.
185
185
  * - AUTO-RETIRES only an installer-MARKER-managed legacy unit, and only
186
186
  * removes its unit file AFTER `disable --now` reports success. A NONZERO
187
187
  * exit leaves everything in place and says so; a TIMED-OUT disable
188
- * (status null) has an UNKNOWN outcome the enablement state is
188
+ * (status null) has an UNKNOWN outcome, the enablement state is
189
189
  * re-inspected before anything is printed, and the receipt states what
190
190
  * was actually confirmed, never a blanket claim.
191
- * - A hand-written legacy unit (no marker) is never touched a one-line
191
+ * - A hand-written legacy unit (no marker) is never touched, a one-line
192
192
  * actionable notice. An UNREADABLE unit file is reported as unreadable,
193
193
  * never misdiagnosed as hand-written.
194
194
  * - The whole pass shares ONE cumulative time budget (RECONCILE_DEADLINE_MS)
@@ -214,11 +214,11 @@ export async function reconcileRedundantLegacyUnit(
214
214
  const legacyUnit = `${LEGACY_SERVICE_UNIT_NAME}.service`;
215
215
  const deadlineNote = (): string[] =>
216
216
  deadlineHit()
217
- ? ['note: the reconcile hit its overall time budget remaining checks were skipped; it will re-run at the next daemon start.']
217
+ ? ['note: the reconcile hit its overall time budget: remaining checks were skipped; it will re-run at the next daemon start.']
218
218
  : [];
219
219
 
220
220
  // Guard 1: the canonical unit must report active. (A timed-out, skipped, or
221
- // failed probe e.g. a wedged user bus lands here too and refuses.)
221
+ // failed probe, e.g. a wedged user bus, lands here too and refuses.)
222
222
  const probe = run(systemctl, ['--user', 'is-active', canonicalUnit]);
223
223
  const canonicalActive = (probe.status ?? 1) === 0 && (probe.stdout ?? '').trim() === 'active';
224
224
  if (!canonicalActive) {
@@ -226,7 +226,7 @@ export async function reconcileRedundantLegacyUnit(
226
226
  action: 'noop',
227
227
  reason: 'canonical-not-active',
228
228
  lines: [
229
- `legacy-unit reconcile: a ${legacyUnit} unit file exists at ${path} but ${canonicalUnit} is not confirmably active ` +
229
+ `legacy-unit reconcile: a ${legacyUnit} unit file exists at ${path} but ${canonicalUnit} is not confirmably active; ` +
230
230
  'left untouched (it may be the only daemon).',
231
231
  ...deadlineNote(),
232
232
  ],
@@ -243,7 +243,7 @@ export async function reconcileRedundantLegacyUnit(
243
243
  action: 'noop',
244
244
  reason: 'canonical-mainpid-not-alive',
245
245
  lines: [
246
- `legacy-unit reconcile: ${canonicalUnit} reports active but its MainPID could not be confirmed alive ` +
246
+ `legacy-unit reconcile: ${canonicalUnit} reports active but its MainPID could not be confirmed alive; ` +
247
247
  `left the ${legacyUnit} unit untouched.`,
248
248
  ...deadlineNote(),
249
249
  ],
@@ -256,7 +256,7 @@ export async function reconcileRedundantLegacyUnit(
256
256
  //
257
257
  // The legacy MainPID probe is TRI-STATE, exactly like guards 1-2: a reply
258
258
  // whose status is non-zero or null (bus flap, timeout, skipped-by-deadline)
259
- // does NOT mean "MainPID=0, unit affirmatively stopped" reading it that
259
+ // does NOT mean "MainPID=0, unit affirmatively stopped", reading it that
260
260
  // way let one transient systemctl failure on exactly this call authorize
261
261
  // `disable --now` against a LIVE legacy daemon (and guard 5 cannot catch
262
262
  // it: in the wrong-port state the legacy daemon itself answers the
@@ -271,7 +271,7 @@ export async function reconcileRedundantLegacyUnit(
271
271
  action: 'noop',
272
272
  reason: 'self-supervised-by-legacy',
273
273
  lines: [
274
- `legacy-unit reconcile: this daemon appears to be running UNDER ${legacyUnit} itself refusing to disable ` +
274
+ `legacy-unit reconcile: this daemon appears to be running UNDER ${legacyUnit} itself; refusing to disable ` +
275
275
  `the unit supervising the current process. Migrate from the canonical side instead: goodvibes-daemon migrate-service`,
276
276
  ],
277
277
  };
@@ -282,14 +282,14 @@ export async function reconcileRedundantLegacyUnit(
282
282
  reason: 'legacy-state-unknown',
283
283
  lines: [
284
284
  `legacy-unit reconcile: could not determine whether ${legacyUnit} has a running daemon (the MainPID query ` +
285
- 'failed or timed out) refusing to act on a guess; it will be re-checked at the next daemon start.',
285
+ 'failed or timed out), refusing to act on a guess; it will be re-checked at the next daemon start.',
286
286
  ...deadlineNote(),
287
287
  ],
288
288
  };
289
289
  }
290
290
 
291
291
  // Guard 4: never stop a RUNNING legacy daemon from the unattended path. A
292
- // live legacy MainPID means a second daemon is actually serving something
292
+ // live legacy MainPID means a second daemon is actually serving something,
293
293
  // possibly the endpoint clients resolve from settings.json (the wrong-port
294
294
  // two-daemon state). Retiring an enabled-but-idle unit needs no --now kill;
295
295
  // stopping a serving one needs consent. (A KNOWN reply of MainPID=0 is the
@@ -299,7 +299,7 @@ export async function reconcileRedundantLegacyUnit(
299
299
  action: 'noop',
300
300
  reason: 'legacy-running',
301
301
  lines: [
302
- `legacy-unit reconcile: ${legacyUnit} has a live main process (pid ${legacyPid}) a second daemon is ` +
302
+ `legacy-unit reconcile: ${legacyUnit} has a live main process (pid ${legacyPid}); a second daemon is ` +
303
303
  'actually running, and it may be the one serving the endpoint your clients are configured to reach. ' +
304
304
  'Refusing to stop it unattended; migrate deliberately with: goodvibes-daemon migrate-service',
305
305
  ],
@@ -308,7 +308,7 @@ export async function reconcileRedundantLegacyUnit(
308
308
 
309
309
  // Guard 5: the endpoint clients resolve from settings.json must be answered
310
310
  // before anything is retired. The canonical daemon being alive proves only
311
- // that A daemon runs not that the configured endpoint is served (its unit
311
+ // that A daemon runs, not that the configured endpoint is served (its unit
312
312
  // may pin different launch args).
313
313
  if (input.configuredEndpoint) {
314
314
  const endpointProbe = input.endpointProbe ?? defaultPortProbe;
@@ -319,7 +319,7 @@ export async function reconcileRedundantLegacyUnit(
319
319
  reason: 'configured-endpoint-unserved',
320
320
  lines: [
321
321
  `legacy-unit reconcile: nothing is answering on the CONFIGURED endpoint ` +
322
- `${input.configuredEndpoint.host}:${input.configuredEndpoint.port} the canonical daemon is alive but not ` +
322
+ `${input.configuredEndpoint.host}:${input.configuredEndpoint.port}; the canonical daemon is alive but not ` +
323
323
  `provably serving what clients resolve from settings. Left the ${legacyUnit} unit untouched; check the ` +
324
324
  "canonical unit's launch arguments against the controlPlane settings.",
325
325
  ],
@@ -345,7 +345,7 @@ export async function reconcileRedundantLegacyUnit(
345
345
  reason: 'marker-unreadable',
346
346
  lines: [
347
347
  `note: ${canonicalUnit} is active and a separate ${legacyUnit} exists at ${path}, but its unit file could not ` +
348
- `be read (${readError}) left untouched. Inspect it yourself; if it is redundant, retire it with: ` +
348
+ `be read (${readError}). Left untouched. Inspect it yourself; if it is redundant, retire it with: ` +
349
349
  `systemctl --user disable --now ${legacyUnit} && rm ${path} && systemctl --user daemon-reload`,
350
350
  ],
351
351
  };
@@ -365,14 +365,14 @@ export async function reconcileRedundantLegacyUnit(
365
365
  }
366
366
 
367
367
  // Installer-marker-managed and redundant: disable first, and only remove the
368
- // unit file if the disable actually succeeded otherwise the enablement
368
+ // unit file if the disable actually succeeded, otherwise the enablement
369
369
  // symlink dangles at a deleted file and this tool can never repair it (the
370
370
  // next pass no-ops at the file-exists check).
371
371
  const disableResult = run(systemctl, ['--user', 'disable', '--now', legacyUnit]);
372
372
  const disableStatus = disableResult.status ?? 1;
373
373
  if (disableStatus !== 0) {
374
374
  if (disableResult.status === null) {
375
- // TIMED OUT: the outcome is UNKNOWN, not failed `disable --now`
375
+ // TIMED OUT: the outcome is UNKNOWN, not failed, `disable --now`
376
376
  // removes the enablement symlinks synchronously BEFORE waiting on the
377
377
  // stop job, so the disable may well have taken effect even though the
378
378
  // client was killed. Re-inspect instead of printing a blanket denial.
@@ -393,11 +393,11 @@ export async function reconcileRedundantLegacyUnit(
393
393
  run(systemctl, ['--user', 'daemon-reload']);
394
394
  const lines = [
395
395
  `reconciled: the disable command timed out waiting on the stop job, but re-inspection confirms the ` +
396
- `installer-managed ${legacyUnit} is no longer enabled${removeErrorAfterTimeout ? '' : ` its unit file was removed (${path})`}. ` +
396
+ `installer-managed ${legacyUnit} is no longer enabled${removeErrorAfterTimeout ? '' : `, its unit file was removed (${path})`}. ` +
397
397
  'Its stop may still be completing.',
398
398
  ];
399
399
  if (removeErrorAfterTimeout) {
400
- lines.push(`note: could not remove ${path}: ${removeErrorAfterTimeout} remove it by hand.`);
400
+ lines.push(`note: could not remove ${path}: ${removeErrorAfterTimeout}; remove it by hand.`);
401
401
  }
402
402
  lines.push(...deadlineNote());
403
403
  return { action: 'removed', reason: 'retired', lines };
@@ -406,7 +406,7 @@ export async function reconcileRedundantLegacyUnit(
406
406
  action: 'failed',
407
407
  reason: 'disable-timeout',
408
408
  lines: [
409
- `legacy-unit reconcile: the disable of ${legacyUnit} timed out and its outcome is UNKNOWN the enablement ` +
409
+ `legacy-unit reconcile: the disable of ${legacyUnit} timed out and its outcome is UNKNOWN; the enablement ` +
410
410
  `state could not be re-confirmed. This tool removed nothing; the unit file at ${path} was left in place ` +
411
411
  'and will be re-checked at the next daemon start.',
412
412
  `Verify it yourself: systemctl --user is-enabled ${legacyUnit}`,
@@ -420,7 +420,7 @@ export async function reconcileRedundantLegacyUnit(
420
420
  reason: 'disable-failed',
421
421
  lines: [
422
422
  `legacy-unit reconcile: the disable of the redundant installer-managed ${legacyUnit} reported failure ` +
423
- `(${detail}) this tool removed nothing; the unit file at ${path} was left in place.`,
423
+ `(${detail}); this tool removed nothing; the unit file at ${path} was left in place.`,
424
424
  `Verify its state and retire it yourself: systemctl --user is-enabled ${legacyUnit} ; ` +
425
425
  `systemctl --user disable --now ${legacyUnit} && rm ${path} && systemctl --user daemon-reload`,
426
426
  ],
@@ -441,7 +441,7 @@ export async function reconcileRedundantLegacyUnit(
441
441
  `was disabled${removeError ? '' : ` and removed (${path})`}.`,
442
442
  ];
443
443
  if (removeError) {
444
- lines.push(`note: could not remove ${path}: ${removeError} remove it by hand.`);
444
+ lines.push(`note: could not remove ${path}: ${removeError}; remove it by hand.`);
445
445
  }
446
446
  lines.push(...deadlineNote());
447
447
  return { action: 'removed', reason: 'retired', lines };
@@ -4,7 +4,7 @@
4
4
  * The deps that let the platform register this daemon's `email.*` verbs.
5
5
  *
6
6
  * `calendar.*` and `email.*` are served by the platform now, not by handlers in
7
- * this repository those were deleted when the platform gained an
7
+ * this repository, those were deleted when the platform gained an
8
8
  * implementation the daemon could call. What is left for a product to supply is
9
9
  * the wiring, and it is not optional: without `homeDirectory` the calendar
10
10
  * composition returns null, without these deps the mail one does, and either way
@@ -27,7 +27,7 @@ import { nodeEmailTransport } from '@pellux/goodvibes-sdk/platform/email/node';
27
27
 
28
28
  /** The narrow slices this composition needs; the real managers satisfy them. */
29
29
  interface MailCompositionInput {
30
- readonly configManager: { get(key: never): unknown };
30
+ readonly configManager: { get(key: string): unknown };
31
31
  readonly secretsManager: { get(key: string): Promise<string | null> };
32
32
  }
33
33
 
@@ -36,8 +36,8 @@ interface MailCompositionInput {
36
36
  * `registerGatewayVerbGroups`.
37
37
  *
38
38
  * The settings come from the daemon's own `surfaces.email.*` keys through
39
- * `withSurfaceEmailConfig`, so the keys an operator has already set and that
40
- * the settings modal now shows keep working unchanged, and a not-configured
39
+ * `withSurfaceEmailConfig`, so the keys an operator has already set, and that
40
+ * the settings modal now shows, keep working unchanged, and a not-configured
41
41
  * answer names the keys THIS operator actually has rather than the ones the
42
42
  * service validates internally.
43
43
  */
@@ -45,7 +45,7 @@ export function composeMailDeps(input: MailCompositionInput): {
45
45
  readonly emailServiceDeps: EmailServiceDeps;
46
46
  readonly describeEmailConfigProblem: () => Promise<SurfaceEmailConfigProblem | null>;
47
47
  } {
48
- const getConfig = (key: string): unknown => input.configManager.get(key as never);
48
+ const getConfig = (key: string): unknown => input.configManager.get(key);
49
49
  const emailServiceDeps = withSurfaceEmailConfig({
50
50
  getConfig,
51
51
  secretsManager: input.secretsManager,
@@ -56,7 +56,7 @@ export function composeMailDeps(input: MailCompositionInput): {
56
56
  // raises display confidence only, and commandAuthority is the literal
57
57
  // 'none'.
58
58
  describeSenderClaim: describeSenderClaimNeutrally,
59
- } as never);
59
+ });
60
60
  return {
61
61
  emailServiceDeps,
62
62
  describeEmailConfigProblem: () =>
@@ -1,9 +1,9 @@
1
1
  /**
2
- * notification-dispatch.ts how this daemon dispatches notices without a screen.
2
+ * notification-dispatch.ts, how this daemon dispatches notices without a screen.
3
3
  *
4
4
  * The SDK's NotificationRouter decides where a domain notification goes and
5
- * collapses bursts and batches. Its three targets `conversation`,
6
- * `status_bar`, `panel_only` are all SCREEN targets: an inline conversation
5
+ * collapses bursts and batches. Its three targets, `conversation`,
6
+ * `status_bar`, `panel_only`, are all SCREEN targets: an inline conversation
7
7
  * line, a status bar, a panel. There is no channel member in that type, and
8
8
  * there never was. Which means the router is a surface mechanism end to end,
9
9
  * and this process has no screen.
@@ -11,7 +11,7 @@
11
11
  * This module used to wire the router to every curated domain anyway, writing
12
12
  * into a bounded ring whose `list()` had no caller anywhere in this repository.
13
13
  * Six domains of events, for the daemon's whole lifetime, into a buffer nobody
14
- * read and the type declaring it still described the ring as "the panel's
14
+ * read, and the type declaring it still described the ring as "the panel's
15
15
  * live producer", for a product with no panels. That silent-success failure
16
16
  * class is exactly what this module removes: the producer goes.
17
17
  *
@@ -23,7 +23,7 @@
23
23
  *
24
24
  * One notice had no such path: memory pressure. The MemoryGovernor measures the
25
25
  * process it runs in, so the daemon's pressure is the daemon's own and no
26
- * surface can report it and the daemon that ran out of memory is exactly the
26
+ * surface can report it, and the daemon that ran out of memory is exactly the
27
27
  * one that cannot tell you afterwards. It now goes out over the operator's
28
28
  * configured notice destination (`notifications.webhookUrls`, the same list the
29
29
  * bus bridge uses), and says so at its own level in the activity log when no
@@ -56,7 +56,7 @@ export interface DaemonNoticeChannel {
56
56
  * one event type rather than a subscription to the domain: the tier change is
57
57
  * the operator's business and the churn is not.
58
58
  *
59
- * Delivery failure is logged, never thrown a webhook endpoint being down is
59
+ * Delivery failure is logged, never thrown, a webhook endpoint being down is
60
60
  * not a reason for the process reporting memory pressure to also crash.
61
61
  */
62
62
  export function wireMemoryPressureChannelNotice(
@@ -69,7 +69,7 @@ export function wireMemoryPressureChannelNotice(
69
69
  const level = memoryPressureLevel(payload);
70
70
  const line = memoryPressureLine(payload);
71
71
  if (!channel.isConfigured()) {
72
- // No destination configured. The notice still exists at its own
72
+ // No destination configured. The notice still exists, at its own
73
73
  // severity, where the operator looks when the daemon misbehaves.
74
74
  if (level === 'critical') logger.error(line);
75
75
  else if (level === 'warning') logger.warn(line);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * payments-composition.ts, the payment capability inside THIS daemon.
3
+ *
4
+ * ── What was wrong ────────────────────────────────────────────────────────
5
+ *
6
+ * All seven `payments.*` verbs were cataloged, advertised with a real HTTP
7
+ * route, and answered 501 NOT_INVOKABLE to every caller. The route existed, the
8
+ * descriptor was honest about its shape, and nothing was attached, so the webui
9
+ * and the desktop app could read the contract and never use it. The terminal app
10
+ * did not paper over this: it has no payments handlers either; what it has is a
11
+ * settings surface (`/payments card`) that writes four flat config keys, which
12
+ * is a per-surface stopgap, not a served capability.
13
+ *
14
+ * ── What this composes ────────────────────────────────────────────────────
15
+ *
16
+ * The SDK owns the capability (`platform/payments`) and states, in
17
+ * card-material.ts, that the store behind it "is implemented by the daemon
18
+ * against its own secret store". It ships no such implementation. So this
19
+ * composes the two stores that were missing, from paths the daemon already owns:
20
+ *
21
+ * - card metadata at `<home>/.goodvibes/<surface>/control-plane/payments-cards.json`,
22
+ * through `controlPlaneStorePath`, the one resolver every control-plane
23
+ * store path goes through, so it lands beside pairing-tokens.json rather
24
+ * than in the unscoped orphan directory that resolver exists to prevent;
25
+ * - card MATERIAL one field per key in the DAEMON secret tier, written with an
26
+ * explicit `scope: 'daemon'`. Explicit because a payments card key is not in
27
+ * the SDK's daemon-needed registry, so an omitted scope would file the
28
+ * material in the surface tier while the daemon reads the daemon tier, the
29
+ * exact split secret-config.ts's `defaultSecretBackedScope` documents as the
30
+ * mailbox-password failure and names "the same shape of failure at purchase
31
+ * time" for a card;
32
+ * - the purchase audit ledger beside the card file.
33
+ *
34
+ * ── The budget ledger is in-process, and that is a statement ──────────────
35
+ *
36
+ * `BudgetLedger` is constructed empty here and never persisted. That is correct
37
+ * ONLY while checkout is unattached: the sole writer of a spend record is the
38
+ * checkout flow, so there is nothing to persist, and a state file nothing writes
39
+ * would be decoration. It stops being correct the moment `payments.checkout.begin`
40
+ * is wired, and the failure would be silent and expensive, a daemon restarted at
41
+ * noon would hand back a daily budget it had already spent. Making this ledger
42
+ * durable is part of wiring checkout.
43
+ *
44
+ * ── What is NOT composed ──────────────────────────────────────────────────
45
+ *
46
+ * `payments.checkout.begin` and `payments.checkout.fillCard` stay unattached and
47
+ * keep answering 501. They need a `CheckoutPageDriver` over an open browser
48
+ * page, and this composition has no way to obtain one: the SDK builds its
49
+ * browser engine inside `registerGatewayVerbGroups`
50
+ * (control-plane/routes/browser-composition.ts), hands back only the
51
+ * `BrowserGatewayService` slice, which exposes no page handle and no
52
+ * `fillSecret`, and constructs it with no `cardFieldGuard`, which its own
53
+ * secret-fill path refuses without. There is also no adapter anywhere from a
54
+ * browser session to a `CheckoutPageDriver`. Writing one here would put the
55
+ * card-into-page seam in this repository, and card-material.ts is explicit that
56
+ * exactly one module in the platform may produce card material. So the two verbs
57
+ * refuse honestly instead.
58
+ */
59
+ import { controlPlaneStorePath } from '@pellux/goodvibes-sdk/platform/control-plane';
60
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
61
+ import { BudgetLedger, readCvvHandling } from '@pellux/goodvibes-sdk/platform/payments';
62
+ import type { PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
63
+ import type { ConfigManager, SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
64
+ import type { ShellPathService } from '@/runtime/index.ts';
65
+ import {
66
+ DaemonCardStore,
67
+ DaemonPurchaseLedger,
68
+ registerPaymentsMethods,
69
+ type PaymentsSecretStore,
70
+ } from '../daemon/handlers/payments/index.ts';
71
+ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../config/surface.ts';
72
+
73
+ export interface PaymentsCompositionOptions {
74
+ readonly configManager: ConfigManager;
75
+ readonly shellPaths: ShellPathService;
76
+ readonly secretsManager: SecretsManager;
77
+ /** Binding the catalog is what turns the family from a 501 facade into handlers. */
78
+ readonly gatewayMethods: GatewayMethodCatalog;
79
+ /**
80
+ * Whether this node is the one currently allowed to spend.
81
+ *
82
+ * Reported by `payments.budget.status` and never defaulted, see the SDK's
83
+ * gates.ts: on a clustered install the wrong answer is a double-spend.
84
+ */
85
+ readonly isPaymentsLeader: () => boolean;
86
+ }
87
+
88
+ export interface PaymentsServices {
89
+ readonly cards: DaemonCardStore;
90
+ readonly purchases: DaemonPurchaseLedger;
91
+ readonly budget: BudgetLedger;
92
+ /** Detaches the handlers. Held by the runtime disposal scope. */
93
+ readonly unregister: () => void;
94
+ }
95
+
96
+ /**
97
+ * The narrow secret port the card store gets: three operations over the daemon
98
+ * tier, and no way to reach any other credential in the process. The same
99
+ * treatment cluster-group-composition.ts gives the group key.
100
+ */
101
+ function daemonScopedSecrets(secretsManager: SecretsManager): PaymentsSecretStore {
102
+ return {
103
+ get: (key) => secretsManager.get(key),
104
+ set: async (key, value) => {
105
+ await secretsManager.set(key, value, { scope: 'daemon', medium: 'secure' });
106
+ },
107
+ delete: async (key) => {
108
+ await secretsManager.delete(key, { scope: 'daemon' });
109
+ },
110
+ };
111
+ }
112
+
113
+ /** Read live, per call: a budget raised five minutes ago applies to the next read. */
114
+ function livePaymentsConfig(configManager: ConfigManager): PaymentsConfigReader {
115
+ return { get: (key: string) => configManager.get(key as Parameters<ConfigManager['get']>[0]) };
116
+ }
117
+
118
+ /**
119
+ * Build the payment stores and bind the answerable verbs to them.
120
+ *
121
+ * Constructing this touches no disk: both stores read lazily and write only when
122
+ * a verb asks them to, so composing a runtime in a test creates no files.
123
+ */
124
+ export function createPaymentsServices(options: PaymentsCompositionOptions): PaymentsServices {
125
+ const config = livePaymentsConfig(options.configManager);
126
+ const cards = new DaemonCardStore({
127
+ filePath: controlPlaneStorePath(options.shellPaths, GOODVIBES_DAEMON_SURFACE_ROOT, 'payments-cards.json'),
128
+ secrets: daemonScopedSecrets(options.secretsManager),
129
+ cvvHandling: () => readCvvHandling(config),
130
+ });
131
+ const purchases = new DaemonPurchaseLedger({
132
+ filePath: controlPlaneStorePath(options.shellPaths, GOODVIBES_DAEMON_SURFACE_ROOT, 'payments-purchases.json'),
133
+ });
134
+ const budget = new BudgetLedger();
135
+ const unregister = registerPaymentsMethods(options.gatewayMethods, {
136
+ cards,
137
+ purchases,
138
+ budget,
139
+ config,
140
+ isPaymentsLeader: options.isPaymentsLeader,
141
+ });
142
+ return { cards, purchases, budget, unregister };
143
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * plugin-composition.ts what a plugin dropped into the plugin directory gets
2
+ * plugin-composition.ts, what a plugin dropped into the plugin directory gets
3
3
  * to register when the host loading it is the daemon.
4
4
  *
5
5
  * A plugin registers into whatever registries the host hands its loader. Two
6
- * hosts load a plugin today the terminal app and this daemon and neither
6
+ * hosts load a plugin today, the terminal app and this daemon, and neither
7
7
  * holds every registry a plugin might want. The design (see
8
8
  * goodvibes-tui/docs/decisions/2026-07-30-plugin-registrations-split-verb-side-and-surface-side.md)
9
9
  * is: one plugin package, loaded by both hosts, each loading the registrations
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * The two surface-side kinds are the ones this host cannot serve. The SDK's
24
24
  * PluginLoaderDeps has no optional members and its API guards nothing, so a
25
- * host that passes nothing for a registry does not decline that kind it
25
+ * host that passes nothing for a registry does not decline that kind, it
26
26
  * throws a TypeError inside the plugin's own init, which the loader catches by
27
27
  * dropping THE WHOLE PLUGIN, including the halves this host could have run.
28
28
  * "Ignores the rest" therefore has to be a real registry that accepts the
@@ -67,8 +67,8 @@ export function createUnservedCommandRegistry(): CommandRegistryLike {
67
67
  *
68
68
  * The runs this daemon hosts build their tools through the agent orchestrator's
69
69
  * own registry, not this one, so a plugin tool registered here is cataloged and
70
- * unhandled. That is the recorded classification `registerTool` is on the
71
- * surface-side list and it was written when the daemon hosted no runs of its
70
+ * unhandled. That is the recorded classification, `registerTool` is on the
71
+ * surface-side list, and it was written when the daemon hosted no runs of its
72
72
  * own; the round that moves session hosting daemon-side is the one that
73
73
  * re-examines it. Until then, a registration here is honest rather than quiet.
74
74
  */
@@ -84,8 +84,8 @@ export class UnservedToolRegistry extends ToolRegistry {
84
84
  /**
85
85
  * Build the loader dependencies for this host.
86
86
  *
87
- * The delivery router is deliberately the manager's own the one replies leave
88
- * through so a registered strategy is a strategy that sends. Handing over a
87
+ * The delivery router is deliberately the manager's own, the one replies leave
88
+ * through, so a registered strategy is a strategy that sends. Handing over a
89
89
  * second router built from the same arguments is how a registration succeeds
90
90
  * and reaches nothing, which is the shape this whole seam is about.
91
91
  */
@@ -1,12 +1,12 @@
1
1
  /**
2
- * runtime-services-types.ts the public contract createRuntimeServices() takes
2
+ * runtime-services-types.ts, the public contract createRuntimeServices() takes
3
3
  * and returns.
4
4
  *
5
5
  * Split out of services.ts (the composition root that builds every one of
6
6
  * these fields) so the construction logic can stay under the repo's
7
7
  * architecture line-count gate without trimming 35 arbitrary lines to clear
8
8
  * the number. This module owns ONLY the shape of the input options and the
9
- * output surface no runtime code, no wiring order, nothing that constructs
9
+ * output surface, no runtime code, no wiring order, nothing that constructs
10
10
  * anything. services.ts re-exports both types from here, so no import site
11
11
  * anywhere else in the app had to change.
12
12
  */
@@ -76,7 +76,7 @@ export interface RuntimeServicesOptions {
76
76
  * Threaded into `SecretsManager` so the override MOVES the daemon-scoped
77
77
  * credential store; without it a daemon told to run out of a temp tree still
78
78
  * read the real home's daemon secrets, so an "isolated" test daemon held the
79
- * owner's live credentials. One name for one thing `resolveGoodVibesHomeOwnership`
79
+ * owner's live credentials. One name for one thing, `resolveGoodVibesHomeOwnership`
80
80
  * is the single reader that produces it.
81
81
  */
82
82
  readonly daemonHomeDirectory?: string | undefined;
@@ -104,7 +104,7 @@ export interface RuntimeServices {
104
104
  readonly homeDirectory: string;
105
105
  /**
106
106
  * The `.goodvibes/<surface root>/` segment this daemon's own state lives
107
- * under always GOODVIBES_DAEMON_SURFACE_ROOT here (config/surface.ts).
107
+ * under, always GOODVIBES_DAEMON_SURFACE_ROOT here (config/surface.ts).
108
108
  * Declared so a consumer ASKS for it instead of deriving a second one; the
109
109
  * unscoped pre-split control-plane store is what deriving it twice produced.
110
110
  */
@@ -162,7 +162,7 @@ export interface RuntimeServices {
162
162
  readonly hookWorkbench: HookWorkbench;
163
163
  readonly pluginManager: PluginManager;
164
164
  readonly workflow: WorkflowServices;
165
- /** Stream watchers, on-exit process triggers and condition checks, supervised as one see trigger-services.ts. */
165
+ /** Stream watchers, on-exit process triggers and condition checks, supervised as one, see trigger-services.ts. */
166
166
  readonly triggerManager: TriggerManager;
167
167
  readonly voiceProviders: VoiceProviderRegistry;
168
168
  readonly voiceService: VoiceService;
@@ -219,13 +219,13 @@ export interface RuntimeServices {
219
219
  readonly contextAccountingHolder: ContextAccountingHolder; // bound at bootstrap.ts; see context-accounting-source.ts
220
220
  readonly wrfcController: WrfcController;
221
221
  readonly processManager: ProcessManager;
222
- /** The phase/work-item orchestration engine the SDK's platform/orchestration. */
222
+ /** The phase/work-item orchestration engine, the SDK's platform/orchestration. */
223
223
  readonly orchestrationEngine: OrchestrationEngine;
224
224
  readonly workstreamCommands: WorkstreamCommandService;
225
225
  /** The repo source-tree code index. */
226
226
  readonly codeIndexStore: CodeIndexStore;
227
227
  readonly codeIndexReindexScheduler: CodeIndexReindexScheduler; // tool-site reindex
228
- /** Daily snapshots of every SQLite store this runtime writes, with bounded retention; unref'd timers (mirrors the SDK composition hosts that tear down a runtime stop() it themselves). */
228
+ /** Daily snapshots of every SQLite store this runtime writes, with bounded retention; unref'd timers (mirrors the SDK composition, hosts that tear down a runtime stop() it themselves). */
229
229
  readonly storeSnapshotScheduler: StoreSnapshotScheduler;
230
230
  readonly appendOnlyRetentionScheduler: operations.DurabilityServices['appendOnlyRetentionScheduler']; // periodic append-only sweep; unref'd timers, stop() on teardown
231
231
  /** Stops the recurring crash-residue sweep; idempotent, unref'd timer (hosts that tear a runtime down call it). */
@@ -241,7 +241,7 @@ export interface RuntimeServices {
241
241
  /** Controller the governor uses to pause/resume the deferrable background jobs under pressure. */
242
242
  readonly pauseController: PauseController;
243
243
  readonly sessionLiveTurnControls: SessionLiveTurnControlsHolder;
244
- /** Unified live process registry (agents, WRFC chains, workflows, watchers, background processes) backing the Fleet panel; archive-aware finished subtrees can be moved to the session archive view. */
244
+ /** Unified live process registry (agents, WRFC chains, workflows, watchers, background processes) backing the Fleet panel; archive-aware, finished subtrees can be moved to the session archive view. */
245
245
  readonly processRegistry: ArchivableProcessRegistry;
246
246
  readonly modeManager: ModeManager;
247
247
  readonly fileUndoManager: FileUndoManager;
@@ -250,7 +250,7 @@ export interface RuntimeServices {
250
250
  readonly checkpointsCurrentlyAllowed: () => boolean;
251
251
  /** Surface-scoped continuity reads (recovery-file presence, last-session pointer). */
252
252
  readonly integrationHelpers: IntegrationHelperService;
253
- /** Per-workspace trust gate restricts write/execute/delegate tools until the workspace is trusted. */
253
+ /** Per-workspace trust gate, restricts write/execute/delegate tools until the workspace is trusted. */
254
254
  readonly workspaceTrustManager: operations.WorkspaceTrustManager;
255
255
  /**
256
256
  * The permission manager the runs this daemon hosts ask through. Its ask seam