@pellux/goodvibes-tui 1.27.0 → 1.27.1
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 +9 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/tui-startup.ts +10 -1
- package/src/daemon/cli.ts +81 -29
- package/src/daemon/fatal-boot-report.ts +111 -0
- package/src/runtime/bootstrap.ts +2 -2
- package/src/runtime/embedded-service-factories.ts +16 -0
- package/src/runtime/legacy-daemon-migration.ts +12 -1
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ All notable changes to GoodVibes TUI.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.27.0] - 2026-07-30
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
|
|
11
|
+
- Fixed: the daemon died without saying why. Measured against the released 1.27.0 daemon binary, in a throwaway home whose `.goodvibes/daemon/settings.json` could not be parsed: it exited 1 with zero bytes on stdout, zero bytes on stderr, and no activity log at all. It restarted and failed 77 times overnight and the only sign anything was wrong was that everything had stopped. The cause was not output buffering and not a handler that got skipped — the daemon reported the failure to the activity logger and exited, and the daemon had never given that logger a file to write to, so nothing reached a console, a journal or a log. Writing to a log object is not the same as writing to the terminal. Every place the daemon can exit before its screen is up now writes to the error stream directly, before anything else and before the exit: the startup failure itself, the flag-parse refusal, the config and endpoint override refusals, and the `cluster`, `send`, `provision-wake-model` and service subcommands. Written to the file descriptor rather than through `process.stderr`, because this program replaces `process.stderr` to keep the rendered screen clean, and because a stream write issued immediately before the process exits can still be in flight when the process stops existing. Proven the only way it can be proven — by compiling a binary and running it — against a control that pins the old shape at zero bytes, so nobody can put the silence back without a test failing.
|
|
12
|
+
- Fixed: the daemon now writes an activity log. The standalone daemon never named a log file at boot, so every line it logged for its whole lifetime went nowhere, and the flushes it performs before exiting had nothing to flush.
|
|
13
|
+
- Fixed: the interactive program's startup failure message goes to the error stream directly too, for the same reason: the guard that keeps the screen clean is exactly the thing that could swallow the one line explaining why the screen is blank.
|
|
14
|
+
- Fixed: `--daemon-home` is given the daemon's own state directory — `~/.goodvibes/daemon`, where its operator tokens, its users and its daemon settings live — instead of the home directory above it. The installer's systemd unit, the installer's macOS launch agent, the in-app install-service, and a daemon started in the background by the interactive program all named the home directory, so a daemon filed its identity one level above where every reader of it looks, and the program that started it went on reading an empty file. An already-installed service unit that carries no pinned host or port is still left alone on upgrade, so an existing install keeps the old location until its unit is regenerated.
|
|
15
|
+
|
|
7
16
|
## [1.26.0] - 2026-07-29
|
|
8
17
|
|
|
9
18
|
### Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-tui)
|
|
6
6
|
|
|
7
7
|
GoodVibes is a terminal console for coding and operations work with an AI model. You run `goodvibes` in a project directory and get a full-screen terminal app where you talk to a model that can read and edit your files, run shell commands, search the web, and hand work off to background agents — asking your permission before anything that writes or executes. It talks to many model providers (OpenAI, Anthropic, Gemini, Bedrock, Copilot, OpenRouter and other OpenAI-compatible gateways, plus local servers like Ollama and LM Studio that it finds on startup), keeps its settings, sessions, and secrets on your own machine, and shows you the token count and running cost of every turn. Alongside the conversation, panels turn background work — running agents, git state, diffs, tokens, cost — into live control rooms.
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "1.27.
|
|
3
|
+
"version": "1.27.1",
|
|
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",
|
|
@@ -107,8 +107,8 @@
|
|
|
107
107
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
108
108
|
"@ast-grep/napi": "^0.42.0",
|
|
109
109
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
110
|
-
"@pellux/goodvibes-sdk": "1.
|
|
111
|
-
"@pellux/goodvibes-terminal-shell": "1.
|
|
110
|
+
"@pellux/goodvibes-sdk": "1.21.0",
|
|
111
|
+
"@pellux/goodvibes-terminal-shell": "1.21.0",
|
|
112
112
|
"bash-language-server": "^5.6.0",
|
|
113
113
|
"fuse.js": "^7.1.0",
|
|
114
114
|
"graphql": "^16.13.2",
|
|
@@ -135,10 +135,10 @@
|
|
|
135
135
|
"typescript": "^5.9.3"
|
|
136
136
|
},
|
|
137
137
|
"optionalDependencies": {
|
|
138
|
-
"@pellux/goodvibes-tui-linux-x64": "1.27.
|
|
139
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.27.
|
|
140
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.27.
|
|
141
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.27.
|
|
138
|
+
"@pellux/goodvibes-tui-linux-x64": "1.27.1",
|
|
139
|
+
"@pellux/goodvibes-tui-linux-arm64": "1.27.1",
|
|
140
|
+
"@pellux/goodvibes-tui-darwin-x64": "1.27.1",
|
|
141
|
+
"@pellux/goodvibes-tui-darwin-arm64": "1.27.1"
|
|
142
142
|
},
|
|
143
143
|
"trustedDependencies": [],
|
|
144
144
|
"overrides": {
|
package/src/cli/tui-startup.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { hasResumableWizardProgress, readOnboardingCheckMarker, readWizardProgre
|
|
|
6
6
|
import { startOnboardingFastPath } from '../runtime/onboarding/fast-path.ts';
|
|
7
7
|
import { checkRecoveryForSession, readLastSessionPointer, type SessionSurface } from '@/runtime/index.ts';
|
|
8
8
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
9
|
+
import { writeFatalLine } from '../daemon/fatal-boot-report.ts';
|
|
9
10
|
import { offerRecoverySnapshot } from '../runtime/recovery-prompt.ts';
|
|
10
11
|
import { buildRecoveryOfferWiring } from '../runtime/recovery-offer-wiring.ts';
|
|
11
12
|
import type { ConversationManager } from '../core/conversation.ts';
|
|
@@ -309,7 +310,15 @@ export function reportFatalStartupError(err: unknown): void {
|
|
|
309
310
|
// Startup diagnostics must never hide the original launch failure.
|
|
310
311
|
}
|
|
311
312
|
try {
|
|
312
|
-
process.stderr.write
|
|
313
|
+
// writeFatalLine, not process.stderr.write: this repository replaces
|
|
314
|
+
// process.stderr to keep the rendered screen clean
|
|
315
|
+
// (runtime/terminal-output-guard.ts), so a replaced writer that records
|
|
316
|
+
// instead of printing would swallow the one line explaining why the screen
|
|
317
|
+
// is blank — and a stream write issued immediately before the exit below
|
|
318
|
+
// can still be in flight when the process stops existing. The descriptor
|
|
319
|
+
// write is neither interceptable nor droppable. See
|
|
320
|
+
// daemon/fatal-boot-report.ts.
|
|
321
|
+
writeFatalLine(`goodvibes failed to start: ${message}${stack ? `\n${stack}` : ''}`);
|
|
313
322
|
} catch {
|
|
314
323
|
// Ignore secondary stderr failures during process teardown.
|
|
315
324
|
}
|
package/src/daemon/cli.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import {
|
|
3
4
|
ConfigManager,
|
|
5
|
+
daemonConfigPathForHome,
|
|
4
6
|
deriveControlPlaneBaseUrl,
|
|
5
7
|
readControlPlaneBinding,
|
|
6
8
|
resolveDaemonEnabled,
|
|
@@ -15,8 +17,9 @@ import { createRuntimeServices } from '../runtime/services.ts';
|
|
|
15
17
|
import { startDeviceHousekeeping } from '../runtime/device-posture-composition.ts';
|
|
16
18
|
import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
17
19
|
import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
|
|
18
|
-
import { flushActivityLogSync, logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
20
|
+
import { configureActivityLogger, flushActivityLogSync, logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
19
21
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
22
|
+
import { reportFatalBootFailure, writeExitingStdoutLine, writeFatalLine } from './fatal-boot-report.ts';
|
|
20
23
|
import {
|
|
21
24
|
getOrCreateCompanionToken,
|
|
22
25
|
pruneStaleOperatorTokens,
|
|
@@ -130,17 +133,26 @@ async function main(): Promise<void> {
|
|
|
130
133
|
workingDir: ownership.workingDirectory,
|
|
131
134
|
homeDir: ownership.homeDirectory,
|
|
132
135
|
surfaceRoot: 'tui',
|
|
136
|
+
// Named explicitly rather than left to ConfigManager's own
|
|
137
|
+
// `<homeDir>/.goodvibes/daemon/settings.json` derivation, so the daemon
|
|
138
|
+
// tier this reads/writes agrees with `ownership.daemonHomeDirectory`
|
|
139
|
+
// even when `GOODVIBES_DAEMON_HOME` moved it away from the plain home.
|
|
140
|
+
daemonTierPath: daemonConfigPathForHome(ownership.daemonHomeDirectory),
|
|
133
141
|
});
|
|
134
142
|
const result = await runClusterCommand({
|
|
135
143
|
argv: rawArgs.slice(1),
|
|
136
144
|
configManager: clusterConfig,
|
|
137
145
|
daemonHomeDir: ownership.daemonHomeDirectory,
|
|
138
146
|
});
|
|
139
|
-
|
|
147
|
+
// Every write below is immediately followed by process.exit, so all of them
|
|
148
|
+
// go to the descriptor synchronously rather than through a stream that can
|
|
149
|
+
// still be in flight when the process stops existing — see
|
|
150
|
+
// fatal-boot-report.ts. (The OSC 52 clipboard sequence is unterminated
|
|
151
|
+
// either way; the line terminator this adds after it is inert.)
|
|
152
|
+
if (result.rawOutput) writeExitingStdoutLine(`\u001b${result.rawOutput}`);
|
|
140
153
|
for (const line of result.lines) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
else console.error(line);
|
|
154
|
+
if (result.exitCode === 0) writeExitingStdoutLine(line);
|
|
155
|
+
else writeFatalLine(line);
|
|
144
156
|
}
|
|
145
157
|
process.exit(result.exitCode);
|
|
146
158
|
}
|
|
@@ -160,6 +172,10 @@ async function main(): Promise<void> {
|
|
|
160
172
|
// reason to message the owner is usually that something stopped.
|
|
161
173
|
if (rawArgs[0] === 'send') {
|
|
162
174
|
const ownership = resolveDaemonCliOwnership();
|
|
175
|
+
// Named before the delivery runs, because the flushActivityLogSync() below
|
|
176
|
+
// is what preserves this send's OUTBOUND_HTTP record — and a logger with no
|
|
177
|
+
// destination has nothing to flush, so that record was never written.
|
|
178
|
+
configureActivityLogger(join(ownership.workingDirectory, '.goodvibes', 'logs'));
|
|
163
179
|
runDaemonConfigMigration(ownership.homeDirectory);
|
|
164
180
|
const stack = createSendStack({
|
|
165
181
|
workingDirectory: ownership.workingDirectory,
|
|
@@ -172,10 +188,12 @@ async function main(): Promise<void> {
|
|
|
172
188
|
readStdin: readAllStdin,
|
|
173
189
|
stdinIsTty: process.stdin.isTTY === true,
|
|
174
190
|
});
|
|
191
|
+
// Descriptor writes, not stream writes: the exit two lines below can leave
|
|
192
|
+
// a `console.log` still in flight, and `send` is most often run precisely
|
|
193
|
+
// because something has already stopped. See fatal-boot-report.ts.
|
|
175
194
|
for (const line of result.lines) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
else console.error(line);
|
|
195
|
+
if (result.exitCode === 0) writeExitingStdoutLine(line);
|
|
196
|
+
else writeFatalLine(line);
|
|
179
197
|
}
|
|
180
198
|
// The activity log holds the OUTBOUND_HTTP record for the send that just
|
|
181
199
|
// happened (or did not); a process exiting this promptly would drop it.
|
|
@@ -195,9 +213,10 @@ async function main(): Promise<void> {
|
|
|
195
213
|
const result = await runProvisionWakeModelCommand(rawArgs.slice(1), {
|
|
196
214
|
homeDirectory: ownership.homeDirectory,
|
|
197
215
|
});
|
|
216
|
+
// The curl installer runs this on a binary it has just placed and reads the
|
|
217
|
+
// result; an exit that discards the receipt would report nothing at all.
|
|
198
218
|
for (const line of result.lines) {
|
|
199
|
-
|
|
200
|
-
console.log(line);
|
|
219
|
+
writeExitingStdoutLine(line);
|
|
201
220
|
}
|
|
202
221
|
process.exit(result.exitCode);
|
|
203
222
|
}
|
|
@@ -206,9 +225,12 @@ async function main(): Promise<void> {
|
|
|
206
225
|
// before resolveDaemonCliOwnership() reads them.
|
|
207
226
|
const cli = parseGoodVibesCli(process.argv.slice(2), 'goodvibes-daemon');
|
|
208
227
|
if (cli.errors.length > 0) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
228
|
+
// A parse refusal is the most common reason a service unit's daemon never
|
|
229
|
+
// starts, so it goes on the descriptor the journal is attached to rather
|
|
230
|
+
// than through a stream that exits out from under it.
|
|
231
|
+
writeFatalLine(cli.errors.join('\n'));
|
|
232
|
+
writeFatalLine('');
|
|
233
|
+
writeFatalLine(renderGoodVibesDaemonHelp('goodvibes-daemon'));
|
|
212
234
|
process.exit(2);
|
|
213
235
|
}
|
|
214
236
|
if (cli.warnings.length > 0) {
|
|
@@ -217,11 +239,11 @@ async function main(): Promise<void> {
|
|
|
217
239
|
}
|
|
218
240
|
}
|
|
219
241
|
if (cli.flags.help || cli.command === 'help') {
|
|
220
|
-
|
|
242
|
+
writeExitingStdoutLine(renderGoodVibesDaemonHelp('goodvibes-daemon'));
|
|
221
243
|
process.exit(0);
|
|
222
244
|
}
|
|
223
245
|
if (cli.flags.version || cli.command === 'version') {
|
|
224
|
-
|
|
246
|
+
writeExitingStdoutLine(renderGoodVibesVersion('goodvibes-daemon'));
|
|
225
247
|
process.exit(0);
|
|
226
248
|
}
|
|
227
249
|
const cliFlags = cli.flags;
|
|
@@ -235,13 +257,33 @@ async function main(): Promise<void> {
|
|
|
235
257
|
}
|
|
236
258
|
|
|
237
259
|
const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory, isOverridden: hasOverriddenHome } = resolveDaemonCliOwnership();
|
|
260
|
+
// Give the shared logger a destination before anything else runs. It never
|
|
261
|
+
// had one in this entrypoint: `logger` only writes to a file once
|
|
262
|
+
// `configureActivityLogger()` has named one, so every logger.info/warn/error
|
|
263
|
+
// in the standalone daemon — including the fatal handler at the bottom of
|
|
264
|
+
// this file and every flushActivityLogSync() call — went nowhere at all.
|
|
265
|
+
// That is why a crash-looping daemon left an empty activity log next to a
|
|
266
|
+
// silent console. src/cli/entrypoint.ts does the same for the interactive
|
|
267
|
+
// process; this is the daemon finally matching it.
|
|
268
|
+
configureActivityLogger(join(workingDir, '.goodvibes', 'logs'));
|
|
238
269
|
runDaemonConfigMigration(homeDirectory);
|
|
239
|
-
|
|
270
|
+
// `daemonTierPath` named explicitly (SDK 1.21.0) rather than left to
|
|
271
|
+
// ConfigManager's own `<homeDir>/.goodvibes/daemon/settings.json`
|
|
272
|
+
// derivation: `daemonHomeDirectory` is this process's own resolution of
|
|
273
|
+
// `--daemon-home`/`GOODVIBES_DAEMON_HOME`, and the two must agree so the
|
|
274
|
+
// daemon's config reads and writes the same file its identity state
|
|
275
|
+
// (operator-tokens.json, auth-users.json) already lives beside.
|
|
276
|
+
const config = new ConfigManager({
|
|
277
|
+
workingDir,
|
|
278
|
+
homeDir: homeDirectory,
|
|
279
|
+
surfaceRoot: 'tui',
|
|
280
|
+
daemonTierPath: daemonConfigPathForHome(daemonHomeDirectory),
|
|
281
|
+
});
|
|
240
282
|
new GlobalNetworkTransportInstaller().install(config);
|
|
241
283
|
|
|
242
284
|
const overrideErrors = applyRuntimeConfigOverrides(config, cliFlags.configOverrides);
|
|
243
285
|
if (overrideErrors.length > 0) {
|
|
244
|
-
|
|
286
|
+
writeFatalLine(overrideErrors.join('\n'));
|
|
245
287
|
process.exit(2);
|
|
246
288
|
}
|
|
247
289
|
applyRuntimeFeatureFlagOverrides(config, {
|
|
@@ -261,7 +303,7 @@ async function main(): Promise<void> {
|
|
|
261
303
|
}
|
|
262
304
|
const endpointOverrideErrors = applyRuntimeEndpointFlagOverrides(config, 'controlPlane', cliFlags);
|
|
263
305
|
if (endpointOverrideErrors.length > 0) {
|
|
264
|
-
|
|
306
|
+
writeFatalLine(endpointOverrideErrors.join('\n'));
|
|
265
307
|
process.exit(2);
|
|
266
308
|
}
|
|
267
309
|
if (cliFlags.port !== undefined) logger.info('daemon: --port flag applied', { port: cliFlags.port });
|
|
@@ -301,9 +343,11 @@ async function main(): Promise<void> {
|
|
|
301
343
|
// consent as any other non-interactive destructive confirmation.
|
|
302
344
|
confirmMigration: cliFlags.yes,
|
|
303
345
|
});
|
|
346
|
+
// install-service / uninstall-service / service-status print the unit path,
|
|
347
|
+
// the follow-up commands and the honest result, then exit immediately —
|
|
348
|
+
// exactly the race a stream write loses. Descriptor writes instead.
|
|
304
349
|
for (const line of result.lines) {
|
|
305
|
-
|
|
306
|
-
console.log(line);
|
|
350
|
+
writeExitingStdoutLine(line);
|
|
307
351
|
}
|
|
308
352
|
process.exit(result.exitCode);
|
|
309
353
|
}
|
|
@@ -573,7 +617,12 @@ async function main(): Promise<void> {
|
|
|
573
617
|
// that is what clients resolve when they look for the daemon.
|
|
574
618
|
try {
|
|
575
619
|
runDaemonConfigMigration(homeDirectory);
|
|
576
|
-
const clientViewConfig = new ConfigManager({
|
|
620
|
+
const clientViewConfig = new ConfigManager({
|
|
621
|
+
workingDir,
|
|
622
|
+
homeDir: homeDirectory,
|
|
623
|
+
surfaceRoot: 'tui',
|
|
624
|
+
daemonTierPath: daemonConfigPathForHome(daemonHomeDirectory),
|
|
625
|
+
});
|
|
577
626
|
const clientEndpoint = resolveRuntimeEndpointBinding(clientViewConfig, 'controlPlane');
|
|
578
627
|
const reconcile = await reconcileRedundantLegacyUnit({
|
|
579
628
|
homeDir: homedir(),
|
|
@@ -629,13 +678,16 @@ async function main(): Promise<void> {
|
|
|
629
678
|
console.log(bannerLines.join('\n'));
|
|
630
679
|
}
|
|
631
680
|
|
|
632
|
-
void main().catch(
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
681
|
+
void main().catch((error) => {
|
|
682
|
+
// A daemon that dies during startup must leave the reason where an operator
|
|
683
|
+
// will actually find it: on the file descriptor the service journal is
|
|
684
|
+
// attached to, written synchronously, BEFORE the activity log is attempted.
|
|
685
|
+
//
|
|
686
|
+
// Doing it the other way round is what shipped mute. This handler used to
|
|
687
|
+
// call logger.error and flushActivityLogSync and nothing else, and the
|
|
688
|
+
// logger had no destination this early in boot — so the released 1.27.0
|
|
689
|
+
// binary crash-looped 77 times with exit 1, zero bytes on stdout, zero bytes
|
|
690
|
+
// on stderr and an empty activity log. See fatal-boot-report.ts.
|
|
691
|
+
reportFatalBootFailure(error);
|
|
640
692
|
process.exit(1);
|
|
641
693
|
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fatal-boot-report.ts — saying why, on a stream, before the process stops.
|
|
3
|
+
*
|
|
4
|
+
* ── Where this module comes from ──────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* This is a local mirror of the SDK's `platform/daemon/fatal-boot-report.ts`,
|
|
7
|
+
* kept identical in shape and in exported names (`writeFatalLine`,
|
|
8
|
+
* `writeExitingStdoutLine`, `reportFatalBootFailure`). It lives here only
|
|
9
|
+
* because the published `@pellux/goodvibes-sdk` this repository compiles
|
|
10
|
+
* against predates that export — importing it would not typecheck and CI
|
|
11
|
+
* builds against the published package regardless. Delete this file and switch
|
|
12
|
+
* every importer to the SDK's copy at the next re-pin — but check the SDK's
|
|
13
|
+
* `package.json` "exports" map first: as of the 1.21.0 re-pin (2026-07-30),
|
|
14
|
+
* the compiled `dist/platform/daemon/fatal-boot-report.js` still exists
|
|
15
|
+
* inside the published tarball and `cli.js` still imports it internally, but
|
|
16
|
+
* there is no `./platform/daemon/fatal-boot-report` (or any other) entry in
|
|
17
|
+
* "exports" that makes it importable from outside the package — confirmed by
|
|
18
|
+
* attempting `import { reportFatalBootFailure } from
|
|
19
|
+
* '@pellux/goodvibes-sdk/platform/daemon/fatal-boot-report'`, which Bun
|
|
20
|
+
* refuses to resolve. This file stays load-bearing until a future SDK release
|
|
21
|
+
* adds that subpath export.
|
|
22
|
+
*
|
|
23
|
+
* ── The failure this exists to close ──────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* The shipped daemon died mute. Measured, against the released 1.27.0 binary
|
|
26
|
+
* in an isolated home with an unparseable `.goodvibes/daemon/settings.json`:
|
|
27
|
+
* exit code 1, **zero bytes on stdout, zero bytes on stderr, and no activity
|
|
28
|
+
* log written at all**. It crash-looped 77 times overnight and the only signal
|
|
29
|
+
* an operator had was that everything had stopped.
|
|
30
|
+
*
|
|
31
|
+
* The mechanism was not buffering and not a bypassed handler. It was simpler:
|
|
32
|
+
* `daemon/cli.ts` reported the failure to the activity LOGGER and exited, and
|
|
33
|
+
* at that point in boot the logger had no destination — the daemon entrypoint
|
|
34
|
+
* never called `configureActivityLogger` at all — so the line went nowhere and
|
|
35
|
+
* no file descriptor was ever touched. A `logger.error` is not a disclosure.
|
|
36
|
+
* Only a write to a file descriptor is.
|
|
37
|
+
*
|
|
38
|
+
* ── Why `writeSync(2, …)` and not `process.stderr.write` ──────────────────
|
|
39
|
+
*
|
|
40
|
+
* Because the fatal path must not depend on anything a host can replace or
|
|
41
|
+
* defer. `process.stderr.write` is a property on a mutable global, and THIS
|
|
42
|
+
* repository is the one that replaces it: `src/runtime/terminal-output-guard.ts`
|
|
43
|
+
* intercepts terminal output to keep the rendered screen clean, and a replaced
|
|
44
|
+
* writer that records instead of printing turns a fatal error into silence. It
|
|
45
|
+
* is also a stream, so a write issued immediately before `process.exit()` can
|
|
46
|
+
* still be in flight when the process stops existing.
|
|
47
|
+
*
|
|
48
|
+
* `writeSync(fd, …)` is neither. It is a direct write to the descriptor the
|
|
49
|
+
* service journal is attached to, it has completed when it returns, and no
|
|
50
|
+
* amount of replacing `process.stderr` upstream can intercept it. That is the
|
|
51
|
+
* whole property this module provides, and it is why every early-exit site in
|
|
52
|
+
* the daemon boot path routes through here.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { writeSync } from 'node:fs';
|
|
56
|
+
import { flushActivityLogSync, logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
57
|
+
|
|
58
|
+
/** stdout and stderr, as the file descriptors they actually are. */
|
|
59
|
+
const STDOUT_FD = 1;
|
|
60
|
+
const STDERR_FD = 2;
|
|
61
|
+
|
|
62
|
+
function writeLineToFd(fd: number, line: string): void {
|
|
63
|
+
try {
|
|
64
|
+
writeSync(fd, line.endsWith('\n') ? line : `${line}\n`);
|
|
65
|
+
} catch {
|
|
66
|
+
// A closed or unwritable descriptor must never turn a diagnostic into a
|
|
67
|
+
// second failure. There is nothing further to fall back to: this IS the
|
|
68
|
+
// fallback.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write one line to stderr synchronously, immune to a replaced
|
|
74
|
+
* `process.stderr` and to exit-time truncation. Use this for anything that
|
|
75
|
+
* gates a process exit.
|
|
76
|
+
*/
|
|
77
|
+
export function writeFatalLine(line: string): void {
|
|
78
|
+
writeLineToFd(STDERR_FD, line);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The stdout twin, for output that must survive an exit that follows it — the
|
|
83
|
+
* help text, the version line, the service-subcommand receipt and the cluster
|
|
84
|
+
* output are all printed and then immediately exited on, which is the same
|
|
85
|
+
* race.
|
|
86
|
+
*/
|
|
87
|
+
export function writeExitingStdoutLine(line: string): void {
|
|
88
|
+
writeLineToFd(STDOUT_FD, line);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Report a fatal boot failure everywhere it can be found, then leave the exit
|
|
93
|
+
* to the caller.
|
|
94
|
+
*
|
|
95
|
+
* The stream write happens FIRST and synchronously. The activity log is
|
|
96
|
+
* attempted after, because it is the part that can fail — it needs a
|
|
97
|
+
* configured destination, a writable directory, and a flush — and the
|
|
98
|
+
* guarantee this function makes is the stream line, not the log line.
|
|
99
|
+
*/
|
|
100
|
+
export function reportFatalBootFailure(error: unknown, context = 'goodvibes daemon host'): void {
|
|
101
|
+
const summary = summarizeError(error);
|
|
102
|
+
writeFatalLine(`${context} failed: ${summary}`);
|
|
103
|
+
if (error instanceof Error && error.stack) writeFatalLine(error.stack);
|
|
104
|
+
try {
|
|
105
|
+
logger.error(`${context} failed`, { error: summary });
|
|
106
|
+
flushActivityLogSync();
|
|
107
|
+
} catch {
|
|
108
|
+
// The reason is already on stderr, which is the guarantee. A log that
|
|
109
|
+
// cannot be written must not escalate into a different failure.
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -482,9 +482,9 @@ export async function bootstrapRuntime(
|
|
|
482
482
|
};
|
|
483
483
|
|
|
484
484
|
// Embedded DaemonServer/HttpListener factories (incl. the facade auto-update
|
|
485
|
-
// suppression) — see embedded-service-factories.ts.
|
|
485
|
+
// suppression), plus the daemon STATE directory — see embedded-service-factories.ts.
|
|
486
486
|
const createExternalServiceFactories = (token: string): ExternalServiceFactories =>
|
|
487
|
-
createEmbeddedServiceFactories(token, services.clusterCoordinator, services.clusterGroup.verbs);
|
|
487
|
+
createEmbeddedServiceFactories(token, services.clusterCoordinator, services.clusterGroup.verbs, join(services.homeDirectory, '.goodvibes', 'daemon'));
|
|
488
488
|
|
|
489
489
|
let externalServices: ExternalServicesHandle = {
|
|
490
490
|
daemonServer: null, httpListener: null,
|
|
@@ -27,9 +27,25 @@ export function createEmbeddedServiceFactories(
|
|
|
27
27
|
sharedDaemonToken: string,
|
|
28
28
|
clusterCoordinator: ClusterCoordinator,
|
|
29
29
|
clusterGroupVerbs: ClusterGroupVerbSurface,
|
|
30
|
+
daemonStateDirectory: string,
|
|
30
31
|
): ExternalServiceFactories {
|
|
31
32
|
return {
|
|
32
33
|
sharedDaemonToken,
|
|
34
|
+
// `daemonRuntimeDir` names the daemon's own STATE directory — the one
|
|
35
|
+
// holding operator-tokens.json, auth-users.json and daemon-settings.json —
|
|
36
|
+
// which is `<home>/.goodvibes/daemon`, not the user home above it. Every
|
|
37
|
+
// reader in this repository resolves it that way (config/goodvibes-home.ts,
|
|
38
|
+
// cli/service-posture.ts, runtime/bootstrap.ts). Passing it explicitly
|
|
39
|
+
// through this typed field (rather than appending a duplicate
|
|
40
|
+
// `--daemon-home` CLI arg via `daemonLaunchArgs` and relying on
|
|
41
|
+
// last-wins flag parsing) also keeps a detached spawn correct when
|
|
42
|
+
// `GOODVIBES_HOME` overrides the tree root away from the OS home
|
|
43
|
+
// directory, which the SDK's own `daemonHomeDir` default does not account
|
|
44
|
+
// for. The SDK's
|
|
45
|
+
// fix (sdk commit d1336a2b) that put the state directory behind the FIRST
|
|
46
|
+
// `--daemon-home` argument shipped in 1.21.0 and made the old
|
|
47
|
+
// flag-duplication workaround redundant; this is its replacement.
|
|
48
|
+
daemonRuntimeDir: daemonStateDirectory,
|
|
33
49
|
createDaemonServer: (bus, userAuth, runtimeServices) => {
|
|
34
50
|
// No updateArtifact: the embedded daemon keeps updates host-managed (the
|
|
35
51
|
// TUI client updates itself), so the facade runs no self-update loop.
|
|
@@ -135,7 +135,18 @@ export function buildManagedDaemonServiceManager(params: BuildManagedDaemonServi
|
|
|
135
135
|
description: MANAGED_SERVICE_DESCRIPTION,
|
|
136
136
|
workingDirectory,
|
|
137
137
|
command: params.binaryPath,
|
|
138
|
-
|
|
138
|
+
// `--daemon-home` names the daemon's own STATE directory — the one holding
|
|
139
|
+
// operator-tokens.json, auth-users.json and daemon-settings.json — which is
|
|
140
|
+
// `<home>/.goodvibes/daemon`. This baked the USER HOME, so a serviced
|
|
141
|
+
// daemon filed its identity a level above where every reader in this
|
|
142
|
+
// repository looks: config/goodvibes-home.ts resolves the flag AS the state
|
|
143
|
+
// directory, cli/service-posture.ts already writes the state directory into
|
|
144
|
+
// GOODVIBES_DAEMON_HOME for the unit it installs, and runtime/bootstrap.ts
|
|
145
|
+
// reads the companion token from the state directory. On a normal machine
|
|
146
|
+
// the mismatch is invisible from the outside — the daemon simply mints a
|
|
147
|
+
// second operator-tokens.json in the home directory and the client keeps
|
|
148
|
+
// reading the empty one under .goodvibes/daemon.
|
|
149
|
+
args: ['--daemon-home', join(params.homeDir, '.goodvibes', 'daemon')],
|
|
139
150
|
env: {},
|
|
140
151
|
restartOnFailure: true,
|
|
141
152
|
};
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '1.27.
|
|
9
|
+
let _version = '1.27.1';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
// Only trust a version read from OUR OWN package.json. A Bun single-file
|