@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 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
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-1.27.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-1.27.1-blue.svg)](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
 
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "1.20.0"
6
+ "version": "1.21.0"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.27.0",
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.20.0",
111
- "@pellux/goodvibes-terminal-shell": "1.20.0",
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.0",
139
- "@pellux/goodvibes-tui-linux-arm64": "1.27.0",
140
- "@pellux/goodvibes-tui-darwin-x64": "1.27.0",
141
- "@pellux/goodvibes-tui-darwin-arm64": "1.27.0"
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": {
@@ -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(`goodvibes failed to start: ${message}\n${stack ? `${stack}\n` : ''}`);
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
- if (result.rawOutput) process.stdout.write(`\u001b${result.rawOutput}`);
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
- // eslint-disable-next-line no-console
142
- if (result.exitCode === 0) console.log(line);
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
- // eslint-disable-next-line no-console
177
- if (result.exitCode === 0) console.log(line);
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
- // eslint-disable-next-line no-console
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
- console.error(cli.errors.join('\n'));
210
- console.error('');
211
- console.error(renderGoodVibesDaemonHelp('goodvibes-daemon'));
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
- console.log(renderGoodVibesDaemonHelp('goodvibes-daemon'));
242
+ writeExitingStdoutLine(renderGoodVibesDaemonHelp('goodvibes-daemon'));
221
243
  process.exit(0);
222
244
  }
223
245
  if (cli.flags.version || cli.command === 'version') {
224
- console.log(renderGoodVibesVersion('goodvibes-daemon'));
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
- const config = new ConfigManager({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
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
- console.error(overrideErrors.join('\n'));
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
- console.error(endpointOverrideErrors.join('\n'));
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
- // eslint-disable-next-line no-console
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({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
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(async (error) => {
633
- logger.error('goodvibes daemon host failed', {
634
- error: summarizeError(error),
635
- });
636
- // The daemon never came up. Without this the reason is buffered in a process
637
- // that is about to stop existing, and the failure reads as a daemon that
638
- // never logged anything at all.
639
- flushActivityLogSync();
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
+ }
@@ -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
- args: ['--daemon-home', params.homeDir],
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.0';
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