@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +6616 -2776
- package/package.json +8 -7
- package/src/cli/help.ts +10 -0
- package/src/cli/redaction.ts +68 -3
- package/src/config/goodvibes-home.ts +10 -0
- package/src/config/secret-config.ts +64 -8
- package/src/core/turn-event-wiring.ts +7 -16
- package/src/core/workstream-notification.ts +69 -0
- package/src/daemon/cli.ts +48 -4
- package/src/daemon/handlers/credentials.ts +15 -5
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +332 -0
- package/src/daemon/send/composition.ts +99 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/input/commands/config.ts +68 -2
- package/src/input/commands/local-provider-runtime.ts +9 -1
- package/src/input/commands/operator-runtime.ts +6 -1
- package/src/input/commands/payment-card-intake.ts +200 -0
- package/src/input/commands/profile-render.ts +250 -0
- package/src/input/commands/profile-runtime.ts +362 -0
- package/src/input/commands/profile-types.ts +381 -0
- package/src/input/commands.ts +7 -0
- package/src/input/composer-editor.ts +4 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
- package/src/input/payments-config.ts +167 -0
- package/src/input/provider-key-intake.ts +10 -2
- package/src/input/settings-modal-commit.ts +146 -0
- package/src/input/settings-modal-data.ts +30 -0
- package/src/input/settings-modal-reset.ts +6 -2
- package/src/input/settings-modal-secrets.ts +9 -2
- package/src/input/settings-modal-types.ts +17 -2
- package/src/input/settings-modal.ts +27 -107
- package/src/panels/modals/marketplace-modal.ts +5 -0
- package/src/panels/modals/skills-modal.ts +5 -0
- package/src/renderer/settings-modal-helpers.ts +4 -0
- package/src/renderer/settings-modal.ts +16 -1
- package/src/runtime/bootstrap.ts +2 -2
- package/src/runtime/install-self-check-startup.ts +23 -0
- package/src/runtime/onboarding/types.ts +6 -1
- package/src/runtime/path-shadow-startup.ts +243 -0
- package/src/runtime/path-shadow.ts +418 -0
- package/src/runtime/reachability-notice.ts +100 -0
- package/src/runtime/runtime-services-types.ts +243 -0
- package/src/runtime/services.ts +14 -205
- package/src/verification/verification-ledger.ts +104 -4
- 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.
|
|
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.
|
|
110
|
-
"@pellux/goodvibes-terminal-shell": "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.
|
|
137
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.
|
|
138
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.
|
|
139
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.
|
|
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',
|
package/src/cli/redaction.ts
CHANGED
|
@@ -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 !==
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// chain
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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?:
|
|
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?:
|
|
58
|
+
opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
|
|
49
59
|
): Promise<void> {
|
|
50
60
|
await secrets.set(secretKey, value, {
|
|
51
|
-
scope: opts?.scope ?? '
|
|
61
|
+
scope: opts?.scope ?? 'daemon',
|
|
52
62
|
medium: opts?.medium ?? 'secure',
|
|
53
63
|
});
|
|
54
64
|
},
|