@pellux/goodvibes-tui 1.25.0 → 1.26.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 (50) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +6616 -2776
  4. package/package.json +8 -7
  5. package/src/cli/help.ts +10 -0
  6. package/src/cli/redaction.ts +68 -3
  7. package/src/config/goodvibes-home.ts +10 -0
  8. package/src/config/secret-config.ts +64 -8
  9. package/src/core/turn-event-wiring.ts +7 -16
  10. package/src/core/workstream-notification.ts +69 -0
  11. package/src/daemon/cli.ts +48 -4
  12. package/src/daemon/handlers/credentials.ts +15 -5
  13. package/src/daemon/send/channels.ts +200 -0
  14. package/src/daemon/send/command.ts +332 -0
  15. package/src/daemon/send/composition.ts +99 -0
  16. package/src/daemon/send/failure-text.ts +93 -0
  17. package/src/daemon/send/inert-text.ts +225 -0
  18. package/src/daemon/send/stdin.ts +24 -0
  19. package/src/input/commands/config.ts +68 -2
  20. package/src/input/commands/local-provider-runtime.ts +9 -1
  21. package/src/input/commands/operator-runtime.ts +6 -1
  22. package/src/input/commands/payment-card-intake.ts +200 -0
  23. package/src/input/commands/profile-render.ts +250 -0
  24. package/src/input/commands/profile-runtime.ts +362 -0
  25. package/src/input/commands/profile-types.ts +381 -0
  26. package/src/input/commands.ts +7 -0
  27. package/src/input/composer-editor.ts +4 -0
  28. package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
  29. package/src/input/payments-config.ts +167 -0
  30. package/src/input/provider-key-intake.ts +10 -2
  31. package/src/input/settings-modal-commit.ts +146 -0
  32. package/src/input/settings-modal-data.ts +30 -0
  33. package/src/input/settings-modal-reset.ts +6 -2
  34. package/src/input/settings-modal-secrets.ts +9 -2
  35. package/src/input/settings-modal-types.ts +17 -2
  36. package/src/input/settings-modal.ts +27 -107
  37. package/src/panels/modals/marketplace-modal.ts +5 -0
  38. package/src/panels/modals/skills-modal.ts +5 -0
  39. package/src/renderer/settings-modal-helpers.ts +4 -0
  40. package/src/renderer/settings-modal.ts +16 -1
  41. package/src/runtime/bootstrap.ts +2 -2
  42. package/src/runtime/install-self-check-startup.ts +23 -0
  43. package/src/runtime/onboarding/types.ts +6 -1
  44. package/src/runtime/path-shadow-startup.ts +243 -0
  45. package/src/runtime/path-shadow.ts +418 -0
  46. package/src/runtime/reachability-notice.ts +100 -0
  47. package/src/runtime/runtime-services-types.ts +243 -0
  48. package/src/runtime/services.ts +14 -205
  49. package/src/verification/verification-ledger.ts +104 -4
  50. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -43,6 +43,7 @@
43
43
  "build:all-shell": "bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:macos-x64 && bun run build:macos-arm64 && bun run build:windows",
44
44
  "typecheck": "bunx tsc --noEmit",
45
45
  "typecheck:test": "bunx tsc --noEmit -p tsconfig.test.json",
46
+ "typecheck:coverage": "bun run scripts/typecheck-coverage-rule.ts",
46
47
  "test": "bun run scripts/run-tests.ts",
47
48
  "test:coverage": "bun run scripts/coverage-gate.ts",
48
49
  "version": "bun run scripts/prebuild.ts",
@@ -106,8 +107,8 @@
106
107
  "@anthropic-ai/vertex-sdk": "^0.16.0",
107
108
  "@ast-grep/napi": "^0.42.0",
108
109
  "@aws/bedrock-token-generator": "^1.1.0",
109
- "@pellux/goodvibes-sdk": "1.18.1",
110
- "@pellux/goodvibes-terminal-shell": "1.18.1",
110
+ "@pellux/goodvibes-sdk": "1.19.2",
111
+ "@pellux/goodvibes-terminal-shell": "1.19.2",
111
112
  "bash-language-server": "^5.6.0",
112
113
  "fuse.js": "^7.1.0",
113
114
  "graphql": "^16.13.2",
@@ -133,10 +134,10 @@
133
134
  "typescript": "^5.9.3"
134
135
  },
135
136
  "optionalDependencies": {
136
- "@pellux/goodvibes-tui-linux-x64": "1.25.0",
137
- "@pellux/goodvibes-tui-linux-arm64": "1.25.0",
138
- "@pellux/goodvibes-tui-darwin-x64": "1.25.0",
139
- "@pellux/goodvibes-tui-darwin-arm64": "1.25.0"
137
+ "@pellux/goodvibes-tui-linux-x64": "1.26.0",
138
+ "@pellux/goodvibes-tui-linux-arm64": "1.26.0",
139
+ "@pellux/goodvibes-tui-darwin-x64": "1.26.0",
140
+ "@pellux/goodvibes-tui-darwin-arm64": "1.26.0"
140
141
  },
141
142
  "trustedDependencies": [],
142
143
  "overrides": {
package/src/cli/help.ts CHANGED
@@ -341,6 +341,16 @@ export function renderGoodVibesDaemonHelp(binary = 'goodvibes-daemon'): string {
341
341
  ' service-status Show whether the daemon service is installed / enabled / active',
342
342
  ' migrate-service Guided migration from an install-script goodvibes-daemon.service unit; prints a plan',
343
343
  ' unless run with -y/--yes (never auto-migrates)',
344
+ ' send [message] Send a message to one of your configured channels — Telegram, ntfy,',
345
+ ' Discord, Slack, Google Chat, Signal, WhatsApp, iMessage, Teams,',
346
+ ' BlueBubbles, Mattermost, Matrix or a webhook. Takes the message as',
347
+ ' an argument or on stdin, so it composes with other tooling.',
348
+ ' --channel <id> picks the channel; with none named it uses your one',
349
+ ' configured channel and says which. --to <address> targets a specific',
350
+ ' topic / chat / room within it, --title <text> sets a title, and',
351
+ ' --list shows every channel with where it would send. A channel that',
352
+ ' is off is refused rather than redirected to the default, and a',
353
+ ' failed send exits non-zero with the provider\'s own error.',
344
354
  ' cluster <command> Share inbound channel work with your other machines on this network.',
345
355
  ' status | create | join | key | nodes | forget <machine> | rotate [--now]',
346
356
  ' | leave | rename | groups',
@@ -1,6 +1,56 @@
1
1
  export const REDACTED_VALUE = '<redacted>';
2
2
 
3
+ // Matches a config path whose LAST SEGMENT is exactly one of these words. That
4
+ // is the whole reach of this pattern, and it is why the declared list below
5
+ // exists: `surfaces.calendar.caldavPassword` does not end in a `.password`
6
+ // segment, it ends in a `caldavPassword` segment, so this pattern answers false
7
+ // for it — as it does for `imapPassword`, `appPassword` and `authToken`.
3
8
  const SENSITIVE_PATH_PATTERN = /(^|\.)(apiKey|accessToken|botToken|appToken|signingSecret|webhookSecret|verifyToken|verificationToken|secret|password|token|keyFile)$/i;
9
+
10
+ // Credential-bearing config keys that are sensitive by NAME rather than by the
11
+ // generic trailing-word list above. Every one of these was carried in the clear
12
+ // by a support bundle before it was listed here, because the pattern matches a
13
+ // trailing WORD and these names carry the credential word in the middle or
14
+ // prefixed by a protocol ("caldavPassword", "imapPassword", "appPassword",
15
+ // "authToken").
16
+ //
17
+ // The `*Ref` Cloudflare keys normally hold a `goodvibes://secrets/...`
18
+ // reference, which shouldRedactValue already lets through as safe. They are
19
+ // listed as a BACKSTOP, for the same reason the mail keys are worth listing
20
+ // twice over: it costs nothing, and it is what stands between a future code
21
+ // path that writes a literal token there and that token reaching a file the
22
+ // owner emails to someone for support.
23
+ const SENSITIVE_CONFIG_KEYS: ReadonlySet<string> = new Set([
24
+ // Mailbox / CalDAV — the credentials the daemon polls mail and calendar with.
25
+ 'surfaces.email.password',
26
+ 'surfaces.email.imapPassword',
27
+ 'surfaces.email.imap.password',
28
+ 'surfaces.email.smtp.password',
29
+ 'surfaces.calendar.caldavPassword',
30
+ // Telephony.
31
+ 'surfaces.telephony.authToken',
32
+ 'surfaces.msteams.appPassword',
33
+ // Cloudflare token references.
34
+ 'cloudflare.apiTokenRef',
35
+ 'cloudflare.tunnelTokenRef',
36
+ 'cloudflare.accessServiceTokenRef',
37
+ 'cloudflare.workerTokenRef',
38
+ 'cloudflare.workerClientTokenRef',
39
+ // Cluster shared phrase.
40
+ 'cluster.secret',
41
+ // Payment card material. ADDED to this set, never substituted for it: this
42
+ // set is a hardcoded list with no delegation, so replacing it wholesale with
43
+ // another product's would silently stop redacting whatever that one omits.
44
+ // The four names below end in no word the suffix pattern knows —
45
+ // "cardNumber", "cardExpiry" and "cardholderName" match none of them — which
46
+ // is the whole reason a declared list exists rather than a naming habit.
47
+ // (The `.map` below lowercases every entry; the lookup lowercases too.)
48
+ 'payments.cardNumber',
49
+ 'payments.cardExpiry',
50
+ 'payments.cardCvv',
51
+ 'payments.cardholderName',
52
+ ].map((key) => key.toLowerCase()));
53
+
4
54
  const SECRET_LIKE_TEXT_PATTERNS: readonly RegExp[] = [
5
55
  /\bsk-[A-Za-z0-9_-]{16,}\b/g,
6
56
  /\bghp_[A-Za-z0-9_]{16,}\b/g,
@@ -13,7 +63,7 @@ const SECRET_LIKE_TEXT_PATTERNS: readonly RegExp[] = [
13
63
  ];
14
64
 
15
65
  export function isSensitiveConfigPath(path: string): boolean {
16
- return SENSITIVE_PATH_PATTERN.test(path);
66
+ return SENSITIVE_PATH_PATTERN.test(path) || SENSITIVE_CONFIG_KEYS.has(path.toLowerCase());
17
67
  }
18
68
 
19
69
  export function isRedactedValue(value: unknown): boolean {
@@ -25,9 +75,14 @@ export interface RedactedConfigResult<T> {
25
75
  readonly redactedPaths: readonly string[];
26
76
  }
27
77
 
78
+ // Redaction rule for sensitive config paths:
79
+ // - Non-string values: redact if truthy (i.e. non-null, non-undefined, non-zero, non-false).
80
+ // Rationale: zero and false are never meaningful secrets; null/undefined mean absent.
81
+ // - String values: redact non-empty strings that are not goodvibes:// secret refs.
82
+ // Rationale: empty string means unset; secret refs are safe placeholders, not raw values.
28
83
  function shouldRedactValue(path: string, value: unknown): boolean {
29
84
  if (!isSensitiveConfigPath(path)) return false;
30
- if (typeof value !== 'string') return value !== undefined && value !== null;
85
+ if (typeof value !== 'string') return value !== null && value !== undefined && Boolean(value);
31
86
  if (value.trim().length === 0) return false;
32
87
  if (value.startsWith('goodvibes://secrets/')) return false;
33
88
  return true;
@@ -64,7 +119,17 @@ export function redactConfig<T>(config: T): RedactedConfigResult<T> {
64
119
  }
65
120
 
66
121
  export function redactText(input: string): string {
67
- let output = input;
122
+ // Assignment form: keyword=value — anchored so 'monkey=' and 'donkey=' do NOT match.
123
+ // Matches: token=, access_token=, api_key=, api-key=, secret=, password= and colon form token: value
124
+ let output = input
125
+ .replace(
126
+ /(?<![A-Za-z])(?:access_token|api[_-]?key|secret|password|token)\s*=\s*([^ \t\r\n"'`]+)/gi,
127
+ (m, val) => m.slice(0, m.length - val.length) + REDACTED_VALUE,
128
+ )
129
+ .replace(
130
+ /(?<![A-Za-z])(?:access_token|api[_-]?key|secret|password|token)\s*:\s*([^ \t\r\n"'`]+)/gi,
131
+ (m, val) => m.slice(0, m.length - val.length) + REDACTED_VALUE,
132
+ );
68
133
  for (const pattern of SECRET_LIKE_TEXT_PATTERNS) {
69
134
  output = output.replace(pattern, REDACTED_VALUE);
70
135
  }
@@ -44,6 +44,15 @@ export interface GoodVibesHomeOwnership {
44
44
  readonly homeDirectory: string;
45
45
  /** The daemon's own identity directory, under the tree root by default. */
46
46
  readonly daemonHomeDirectory: string;
47
+ /**
48
+ * True when either `GOODVIBES_HOME` or `GOODVIBES_DAEMON_HOME` named an
49
+ * override (a blank/whitespace-only value does not count — see
50
+ * resolveGoodVibesHome). Callers that need to know "is this process running
51
+ * out of a relocated tree or identity directory" (e.g. to refuse adopting
52
+ * the machine's real service unit) read this instead of re-reading the raw
53
+ * environment variable themselves — this module is the one reader.
54
+ */
55
+ readonly isOverridden: boolean;
47
56
  }
48
57
 
49
58
  /**
@@ -117,6 +126,7 @@ export function resolveGoodVibesHomeOwnership(
117
126
  return {
118
127
  homeDirectory,
119
128
  daemonHomeDirectory: resolveGoodVibesDaemonHome(homeDirectory, env),
129
+ isOverridden: Boolean(env['GOODVIBES_HOME']?.trim()) || Boolean(env['GOODVIBES_DAEMON_HOME']?.trim()),
120
130
  };
121
131
  }
122
132
 
@@ -1,8 +1,27 @@
1
- import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config';
1
+ import { isSecretRefInput, isDaemonOwnedConfigKey } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { ConfigKey } from './index.ts';
3
3
  import type { SecretScope, SecretStorageMedium } from './secrets.ts';
4
4
 
5
5
  export const SECRET_CONFIG_KEYS = new Set<ConfigKey>([
6
+ // Mailbox and CalDAV credentials. Their own CONFIG_SCHEMA descriptions read
7
+ // "Stored in the daemon secret tier, never in config" — and until they were
8
+ // listed here that sentence was aspirational: the settings modal wrote them
9
+ // as plain strings into a config JSON file, because membership in this set is
10
+ // the thing that routes an edit through the secret manager instead.
11
+ //
12
+ // The daemon reads each of these back with
13
+ // resolveConfigSecret('<key>') → GOODVIBES_<KEY>, which is exactly the store
14
+ // key buildGoodVibesSecretKey() writes — see
15
+ // daemon/handlers/inbox/providers/email.ts.
16
+ 'surfaces.email.password',
17
+ 'surfaces.email.imapPassword',
18
+ 'surfaces.email.imap.password',
19
+ 'surfaces.email.smtp.password',
20
+ 'surfaces.calendar.caldavPassword',
21
+ // Telephony delivery credentials — same shape, same file, same gap.
22
+ 'surfaces.telephony.authToken',
23
+ 'surfaces.telephony.token',
24
+ 'surfaces.telephony.webhookSecret',
6
25
  'surfaces.slack.signingSecret',
7
26
  'surfaces.slack.botToken',
8
27
  'surfaces.slack.appToken',
@@ -23,6 +42,16 @@ export const SECRET_CONFIG_KEYS = new Set<ConfigKey>([
23
42
  'surfaces.bluebubbles.password',
24
43
  'surfaces.mattermost.botToken',
25
44
  'surfaces.matrix.accessToken',
45
+ // TUI-local synthetic sub-keys, one level under the SDK's real `payments`
46
+ // section (not yet a scalar CONFIG_SCHEMA entry — same situation as
47
+ // tts.speed, behavior.notifyAfterSeconds, etc. in settings-modal-data.ts),
48
+ // hence the cast. See input/payments-config.ts for why these are named flat
49
+ // (payments.cardNumber, not payments.card.number): a flat one-level leaf is
50
+ // the shape the real ConfigManager tolerates for an undeclared key.
51
+ 'payments.cardNumber' as ConfigKey,
52
+ 'payments.cardExpiry' as ConfigKey,
53
+ 'payments.cardCvv' as ConfigKey,
54
+ 'payments.cardholderName' as ConfigKey,
26
55
  ]);
27
56
 
28
57
  export interface SecretBackedConfigUpdate {
@@ -96,6 +125,28 @@ export function buildSecretBackedConfigUpdate(configKey: ConfigKey, rawValue: st
96
125
  };
97
126
  }
98
127
 
128
+ /**
129
+ * Where a secret-backed write lands when the caller did not name a scope.
130
+ *
131
+ * A daemon-owned config key (`surfaces.*`, `payments.*`, `controlPlane.*`, ...)
132
+ * names a credential the DAEMON executes with, not this interactive client, so
133
+ * its secret material belongs in the daemon-scoped tier the daemon actually
134
+ * reads — the same rule the SDK's config-ownership.ts already applies to the
135
+ * `goodvibes://` reference that points at it.
136
+ *
137
+ * Defaulting these to 'user' (the historical behavior here) split the pair: the
138
+ * reference landed in the daemon's own settings file, because ConfigManager
139
+ * routes daemon-owned keys there, while the value it pointed at sat in a tier
140
+ * the daemon never resolves. The surface reported success and the daemon found
141
+ * nothing. For the mailbox password that is the whole feature failing silently —
142
+ * the daemon is the process that polls IMAP and answers over Telegram, and it
143
+ * does so with every surface closed. A payment card entered through
144
+ * /payments card is the same shape of failure at purchase time.
145
+ */
146
+ export function defaultSecretBackedScope(configKey: ConfigKey): SecretScope {
147
+ return isDaemonOwnedConfigKey(configKey) ? 'daemon' : 'user';
148
+ }
149
+
99
150
  export async function persistSecretBackedConfigValue(
100
151
  configManager: SecretBackedConfigManager,
101
152
  secretsManager: SecretBackedSecretStore | null | undefined,
@@ -104,16 +155,21 @@ export async function persistSecretBackedConfigValue(
104
155
  options: { readonly scope?: SecretScope } = {},
105
156
  ): Promise<string> {
106
157
  const update = buildSecretBackedConfigUpdate(configKey, rawValue);
107
- const scope = options.scope ?? 'user';
158
+ const scope = options.scope ?? defaultSecretBackedScope(configKey);
159
+ const medium = getSecretWriteMedium(configManager.get('storage.secretPolicy'));
160
+
161
+ // 1. Validate config write first. If setDynamic throws, no secret is written (avoids orphans).
162
+ configManager.setDynamic(configKey, update.configValue);
163
+
164
+ // 2. Write new secret only after config accepted it.
108
165
  if (update.secretKey && update.secretValue !== undefined && secretsManager) {
109
- await secretsManager.set(update.secretKey, update.secretValue, {
110
- scope,
111
- medium: getSecretWriteMedium(configManager.get('storage.secretPolicy')),
112
- });
166
+ await secretsManager.set(update.secretKey, update.secretValue, { scope, medium });
113
167
  }
168
+
169
+ // 3. Clear old secret — pass the same medium so plaintext-medium secrets are found for deletion.
114
170
  if (update.clearSecretKey && secretsManager?.delete) {
115
- await secretsManager.delete(update.clearSecretKey, { scope });
171
+ await secretsManager.delete(update.clearSecretKey, { scope, medium });
116
172
  }
117
- configManager.setDynamic(configKey, update.configValue);
173
+
118
174
  return update.configValue;
119
175
  }
@@ -1,7 +1,7 @@
1
1
  import type { UiRuntimeEvents, RuntimeEventBus } from '@/runtime/index.ts';
2
2
  import { buildPersistedSessionContext, persistConversation } from '@/runtime/index.ts';
3
3
  import { buildCompactionReceiptBlock } from './compaction-receipt.ts';
4
- import { formatTurnBudgetOutcome } from './turn-budget-outcome.ts';
4
+ import { workstreamFailureNotification } from './workstream-notification.ts';
5
5
  import { persistTurnAnchors, recordTurnAnchor, summarizeTurnLabel } from './rewind-turn-anchors.ts';
6
6
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
7
7
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -328,21 +328,12 @@ export function wireTurnEventHandlers(
328
328
  unsubs.push(events.workflows.on('WORKFLOW_CHAIN_FAILED', (payload) => {
329
329
  if (!shouldFireAlert(focusTracker, configGet, 'behavior.notifyOnChainFailure')) return;
330
330
  try {
331
- // An operator cancellation is an intended stop, not a failure — narrate it
332
- // as cancelled (the reason already carries the landed-work count from the
333
- // chain's edit ledger), so the notification never contradicts the cancelled
334
- // chain/owner/cohort surfaces.
335
- if (payload.failureKind === 'cancelled') {
336
- notifyCompletion('GoodVibes — WRFC chain cancelled', `chain ${payload.chainId.slice(0, 12)} cancelled: ${payload.reason}`, FORCE_NOTIFY_DURATION_MS);
337
- } else if (payload.failureKind === 'max_turns') {
338
- // A turn-budget exhaustion is a spent ceiling, not an infrastructure
339
- // failure — narrate the limit and where it came from, from the typed
340
- // event fields, never a regex of the prose reason.
341
- notifyCompletion('GoodVibes — WRFC chain hit its turn budget', `chain ${payload.chainId.slice(0, 12)} ${formatTurnBudgetOutcome({ limit: payload.turnLimit, source: payload.turnLimitSource })}`, FORCE_NOTIFY_DURATION_MS);
342
- } else {
343
- const kindLabel = payload.failureKind === 'transport' ? 'transient transport error' : payload.reason;
344
- notifyCompletion('GoodVibes — WRFC chain failed', `chain ${payload.chainId.slice(0, 12)} failed: ${kindLabel}`, FORCE_NOTIFY_DURATION_MS);
345
- }
331
+ // Title and body come from workstream-notification.ts, which is where
332
+ // the three branches (cancelled / turn budget / failed) are narrated and
333
+ // tested. A notification is a message to a person, so it carries neither
334
+ // the internal name for the machinery nor the chain id.
335
+ const notice = workstreamFailureNotification(payload);
336
+ notifyCompletion(notice.title, notice.body, FORCE_NOTIFY_DURATION_MS);
346
337
  } catch (err) {
347
338
  logger.debug('turn-event-wiring: chain-failure notify error', { error: String(err) });
348
339
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * workstream-notification.ts — the desktop notification a finished workstream
3
+ * pushes, in words rather than identifiers.
4
+ *
5
+ * These three notifications used to read:
6
+ *
7
+ * GoodVibes — WRFC chain failed
8
+ * chain 7f3a91c02b4e failed: review rejected
9
+ *
10
+ * A desktop notification is a message TO a person, so the standing rule applies
11
+ * to it the same way it applies to a chat message: no internal name for the
12
+ * machinery, and no register id. `WRFC` is the first; `7f3a91c02b4e` is the
13
+ * second, and it is not something the reader can do anything with — it is not
14
+ * the commit, not the branch, not the session.
15
+ *
16
+ * What the reader can act on is why the work stopped, so that is what the body
17
+ * now leads with. Two workstreams that end at the same moment are told apart by
18
+ * their reasons, which is the same principle the channel renderer follows: in
19
+ * plain words, never by an opaque identifier.
20
+ *
21
+ * Split out of turn-event-wiring.ts as a pure function so the text is testable
22
+ * on its own — the wiring itself cannot be asserted against without mocking a
23
+ * process-global notifier.
24
+ */
25
+ import { formatTurnBudgetOutcome } from './turn-budget-outcome.ts';
26
+
27
+ /** The fields of WORKFLOW_CHAIN_FAILED this narration reads. */
28
+ export interface WorkstreamFailureNarrationInput {
29
+ readonly reason: string;
30
+ readonly failureKind?: 'transport' | 'other' | 'cancelled' | 'max_turns' | undefined;
31
+ readonly turnLimit?: number | undefined;
32
+ readonly turnLimitSource?: 'default' | 'spawn-override' | 'policy-bound' | undefined;
33
+ }
34
+
35
+ export interface WorkstreamNotification {
36
+ readonly title: string;
37
+ readonly body: string;
38
+ }
39
+
40
+ /**
41
+ * Title and body for a workstream that reached a terminal state.
42
+ *
43
+ * An operator cancellation is an intended stop, not a failure — it is narrated
44
+ * as cancelled (the reason already carries the landed-work count from the
45
+ * workstream's edit ledger) so the notification never contradicts the cancelled
46
+ * workstream/owner/cohort surfaces. A turn-budget exhaustion is a spent ceiling
47
+ * rather than an infrastructure error, and its limit and source are read from
48
+ * the typed event fields, never from a regex of the prose reason.
49
+ */
50
+ export function workstreamFailureNotification(
51
+ payload: WorkstreamFailureNarrationInput,
52
+ ): WorkstreamNotification {
53
+ if (payload.failureKind === 'cancelled') {
54
+ return {
55
+ title: 'GoodVibes — workstream cancelled',
56
+ body: `Cancelled: ${payload.reason}`,
57
+ };
58
+ }
59
+ if (payload.failureKind === 'max_turns') {
60
+ return {
61
+ title: 'GoodVibes — workstream hit its turn budget',
62
+ body: `The workstream ${formatTurnBudgetOutcome({ limit: payload.turnLimit, source: payload.turnLimitSource })}`,
63
+ };
64
+ }
65
+ return {
66
+ title: 'GoodVibes — workstream failed',
67
+ body: `Failed: ${payload.failureKind === 'transport' ? 'transient transport error' : payload.reason}`,
68
+ };
69
+ }
package/src/daemon/cli.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  } from '@pellux/goodvibes-sdk/platform/config';
8
8
  import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
9
9
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
10
- import { resolveGoodVibesHomeOwnership, hasOverriddenGoodVibesHome } from '../config/goodvibes-home.ts';
10
+ import { resolveGoodVibesHomeOwnership } from '../config/goodvibes-home.ts';
11
11
  import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
12
12
  import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
13
13
  import { createRuntimeStore } from '../runtime/store/index.ts';
@@ -34,6 +34,9 @@ import {
34
34
  persistProviders,
35
35
  } from '@pellux/goodvibes-sdk/platform/discovery';
36
36
  import { createSafeHostServeFactory } from './safe-serve.ts';
37
+ import { runSendCommand } from './send/command.ts';
38
+ import { createSendStack } from './send/composition.ts';
39
+ import { readAllStdin } from './send/stdin.ts';
37
40
  import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
38
41
  import { runClusterCommand } from '../cluster/commands.ts';
39
42
  import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
@@ -59,6 +62,8 @@ type DaemonCliOwnership = {
59
62
  readonly homeDirectory: string;
60
63
  /** The daemon's OWN identity home (auth users, operator tokens, daemon settings). */
61
64
  readonly daemonHomeDirectory: string;
65
+ /** True when GOODVIBES_HOME or GOODVIBES_DAEMON_HOME named an override — see goodvibes-home.ts. */
66
+ readonly isOverridden: boolean;
62
67
  };
63
68
 
64
69
  // CLI flag parsing delegated to shared module — see src/cli-flags.ts
@@ -87,11 +92,12 @@ function resolveDaemonCliOwnership(): DaemonCliOwnership {
87
92
  // point also uses. They were resolved independently, and the client's copy
88
93
  // simply did not read GOODVIBES_HOME — so a redirected client wrote into the
89
94
  // real tree while the daemon honoured the redirect.
90
- const { homeDirectory, daemonHomeDirectory } = resolveGoodVibesHomeOwnership();
95
+ const { homeDirectory, daemonHomeDirectory, isOverridden } = resolveGoodVibesHomeOwnership();
91
96
  return {
92
97
  workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
93
98
  homeDirectory,
94
99
  daemonHomeDirectory,
100
+ isOverridden,
95
101
  };
96
102
  }
97
103
 
@@ -137,6 +143,44 @@ async function main(): Promise<void> {
137
143
  process.exit(result.exitCode);
138
144
  }
139
145
 
146
+ // `send …` is intercepted here for both of the reasons `cluster` is, plus one
147
+ // of its own.
148
+ //
149
+ // Before the parser, because the message is arbitrary operator text: a
150
+ // message beginning with a dash, or one carrying `--port` inside it, must
151
+ // reach the channel rather than be eaten as a daemon flag.
152
+ //
153
+ // Before the runtime, because this composes only the services a delivery
154
+ // needs (see send/composition.ts) and must not start a second copy of the
155
+ // pollers, cluster election and LAN scan a running daemon already owns.
156
+ //
157
+ // And it must work when NO daemon is running, which is much of the point: the
158
+ // reason to message the owner is usually that something stopped.
159
+ if (rawArgs[0] === 'send') {
160
+ const ownership = resolveDaemonCliOwnership();
161
+ runDaemonConfigMigration(ownership.homeDirectory);
162
+ const stack = createSendStack({
163
+ workingDirectory: ownership.workingDirectory,
164
+ homeDirectory: ownership.homeDirectory,
165
+ daemonHomeDirectory: ownership.daemonHomeDirectory,
166
+ });
167
+ const result = await runSendCommand(rawArgs.slice(1), {
168
+ configManager: stack.configManager,
169
+ deliver: stack.deliver,
170
+ readStdin: readAllStdin,
171
+ stdinIsTty: process.stdin.isTTY === true,
172
+ });
173
+ for (const line of result.lines) {
174
+ // eslint-disable-next-line no-console
175
+ if (result.exitCode === 0) console.log(line);
176
+ else console.error(line);
177
+ }
178
+ // The activity log holds the OUTBOUND_HTTP record for the send that just
179
+ // happened (or did not); a process exiting this promptly would drop it.
180
+ flushActivityLogSync();
181
+ process.exit(result.exitCode);
182
+ }
183
+
140
184
  // Parse CLI flags first so --daemon-home and --working-dir env vars are set
141
185
  // before resolveDaemonCliOwnership() reads them.
142
186
  const cli = parseGoodVibesCli(process.argv.slice(2), 'goodvibes-daemon');
@@ -169,7 +213,7 @@ async function main(): Promise<void> {
169
213
  logger.info('daemon: --working-dir flag applied', { workingDir: cliFlags.workingDir });
170
214
  }
171
215
 
172
- const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory } = resolveDaemonCliOwnership();
216
+ const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory, isOverridden: hasOverriddenHome } = resolveDaemonCliOwnership();
173
217
  runDaemonConfigMigration(homeDirectory);
174
218
  const config = new ConfigManager({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
175
219
  new GlobalNetworkTransportInstaller().install(config);
@@ -371,7 +415,7 @@ async function main(): Promise<void> {
371
415
  // found the unit not running, wrote its own scratchpad ExecStart into
372
416
  // ~/.config/systemd/user/goodvibes.service and exited, and systemd then
373
417
  // supervised the throwaway as the machine's daemon for five hours.
374
- hasOverriddenHome: hasOverriddenGoodVibesHome(),
418
+ hasOverriddenHome,
375
419
  // The SAME coordinator this repository's inbox poller registered with (see
376
420
  // runtime/cluster-composition.ts). The facade must reuse it rather than
377
421
  // compose its own: two coordinators in one process are two nodes in the
@@ -1,5 +1,5 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
- import type { SecretsManager } from '../../config/secrets.ts';
2
+ import type { SecretsManager, SecretScope } from '../../config/secrets.ts';
3
3
  import {
4
4
  buildGoodVibesSecretKey,
5
5
  isSecretReferenceValue,
@@ -16,11 +16,21 @@ export interface DaemonCredentialStore {
16
16
  * (e.g. 'surfaces.slack.botToken' → GOODVIBES_SURFACES_SLACK_BOT_TOKEN).
17
17
  */
18
18
  resolveConfigSecret(configKey: string): Promise<string | null>;
19
- /** Store a daemon-owned credential. scope defaults 'user'. */
19
+ /**
20
+ * Store a daemon-owned credential. scope defaults 'daemon'.
21
+ *
22
+ * This is the DAEMON's own credential store: every caller is daemon code, and
23
+ * every value written through it is one the daemon itself has to read back —
24
+ * including its own generated draft encryption key (createAtRestCipher below,
25
+ * which calls put() with no scope at all). Defaulting to 'user' filed those
26
+ * in a surface-scoped tier, so the daemon's own material lived somewhere it
27
+ * only found by accident, and a value written on one surface's behalf was
28
+ * invisible to the next.
29
+ */
20
30
  put(
21
31
  secretKey: string,
22
32
  value: string,
23
- opts?: { scope?: 'user' | 'project'; medium?: 'plaintext' | 'secure' },
33
+ opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
24
34
  ): Promise<void>;
25
35
  has(secretKey: string): Promise<boolean>;
26
36
  }
@@ -45,10 +55,10 @@ export function createDaemonCredentialStore(secrets: SecretsManager): DaemonCred
45
55
  async put(
46
56
  secretKey: string,
47
57
  value: string,
48
- opts?: { scope?: 'user' | 'project'; medium?: 'plaintext' | 'secure' },
58
+ opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
49
59
  ): Promise<void> {
50
60
  await secrets.set(secretKey, value, {
51
- scope: opts?.scope ?? 'user',
61
+ scope: opts?.scope ?? 'daemon',
52
62
  medium: opts?.medium ?? 'secure',
53
63
  });
54
64
  },