@cydm/happy-elves 0.1.0-beta.74 → 0.1.0-beta.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/apps/cli/dist/commands/app.js +2 -0
  2. package/apps/cli/dist/commands/diagnostics.d.ts +2 -0
  3. package/apps/cli/dist/commands/diagnostics.js +71 -0
  4. package/apps/cli/dist/commands/lib/args.js +4 -0
  5. package/apps/cli/dist/commands/lib/bootstrap-daemon.d.ts +22 -1
  6. package/apps/cli/dist/commands/lib/bootstrap-daemon.js +73 -1
  7. package/apps/cli/dist/commands/lib/bootstrap-device-recovery.d.ts +5 -2
  8. package/apps/cli/dist/commands/lib/bootstrap-device-recovery.js +42 -51
  9. package/apps/cli/dist/commands/lib/bootstrap.js +14 -13
  10. package/apps/cli/dist/commands/lib/usage.js +15 -1
  11. package/apps/daemon/dist/lifecycle/detached-helper-bundle.mjs +292 -2
  12. package/apps/daemon/dist/relay/connection.js +5 -1
  13. package/apps/daemon/dist/relay/register.js +1 -0
  14. package/apps/daemon/dist/relay/send.js +1 -0
  15. package/apps/daemon/dist/runtime/external-provider.js +7 -0
  16. package/apps/daemon/dist/session/lifecycle.d.ts +4 -1
  17. package/apps/daemon/dist/session/lifecycle.js +156 -20
  18. package/apps/daemon/dist/session/metadata.d.ts +2 -1
  19. package/apps/daemon/dist/session/metadata.js +10 -1
  20. package/apps/daemon/dist/session/prompt.js +58 -7
  21. package/apps/daemon/dist/turn-coordinator.d.ts +1 -1
  22. package/apps/daemon/dist/types.d.ts +2 -0
  23. package/apps/daemon/package.json +1 -1
  24. package/apps/relay/dist/connections.d.ts +1 -0
  25. package/apps/relay/dist/connections.js +1 -0
  26. package/apps/relay/dist/controller-handlers.js +91 -4
  27. package/apps/relay/dist/db.js +2 -0
  28. package/apps/relay/dist/machine-command-result-handlers.js +59 -4
  29. package/apps/relay/dist/machine-handler-context.d.ts +7 -0
  30. package/apps/relay/dist/projections.js +5 -0
  31. package/apps/relay/dist/relay-context.d.ts +1 -0
  32. package/apps/relay/dist/relay-context.js +21 -14
  33. package/apps/relay/dist/types.d.ts +8 -1
  34. package/apps/relay/dist/ui-probe-router.d.ts +35 -0
  35. package/apps/relay/dist/ui-probe-router.js +414 -0
  36. package/apps/relay/dist/websocket.js +79 -8
  37. package/build-identity.json +2 -2
  38. package/npm-shrinkwrap.json +5 -5
  39. package/package.json +1 -1
  40. package/packages/client/dist/client.d.ts +1 -0
  41. package/packages/client/dist/client.js +9 -0
  42. package/packages/client/dist/index.d.ts +3 -1
  43. package/packages/client/dist/index.js +1 -0
  44. package/packages/client/dist/transport.d.ts +4 -1
  45. package/packages/client/dist/types.d.ts +2 -0
  46. package/packages/client/dist/ui-probe.d.ts +116 -0
  47. package/packages/client/dist/ui-probe.js +825 -0
  48. package/packages/runtime/dist/index.d.ts +14 -0
  49. package/packages/runtime-cli/dist/codex-app-server.d.ts +5 -0
  50. package/packages/runtime-cli/dist/codex-app-server.js +126 -0
  51. package/packages/runtime-cli/dist/index.js +8 -0
  52. package/packages/runtime-cli/dist/session-store.d.ts +1 -0
  53. package/packages/runtime-cli/dist/session-store.js +32 -0
  54. package/packages/shared/dist/index.d.ts +1 -0
  55. package/packages/shared/dist/index.js +1 -0
  56. package/packages/shared/dist/protocol-schemas.d.ts +5 -0
  57. package/packages/shared/dist/protocol-schemas.js +4 -0
  58. package/packages/shared/dist/protocol-types.d.ts +4 -0
  59. package/packages/shared/dist/protocol.d.ts +70 -0
  60. package/packages/shared/dist/protocol.js +84 -0
  61. package/packages/shared/dist/session-state.d.ts +1 -1
  62. package/packages/shared/dist/ui-probe.d.ts +459 -0
  63. package/packages/shared/dist/ui-probe.js +257 -0
@@ -2,6 +2,7 @@ import { handleAccount } from "./account.js";
2
2
  import { handleCollect } from "./collect.js";
3
3
  import { handleConfig } from "./config.js";
4
4
  import { handleDaemon } from "./daemon.js";
5
+ import { handleDiagnostics } from "./diagnostics.js";
5
6
  import { handleGateway } from "./gateway.js";
6
7
  import { handleLoop } from "./loop.js";
7
8
  import { handleMachine } from "./machine.js";
@@ -22,6 +23,7 @@ const domainHandlers = [
22
23
  handleToken,
23
24
  handleLoop,
24
25
  handleDaemon,
26
+ handleDiagnostics,
25
27
  handleRelay,
26
28
  handleMachine,
27
29
  handleMemory,
@@ -0,0 +1,2 @@
1
+ import type { CommandInput } from "./command.js";
2
+ export declare function handleDiagnostics({ domain, action, flags }: CommandInput): Promise<boolean>;
@@ -0,0 +1,71 @@
1
+ import { UiProbeClient } from "../../../../packages/client/dist/index.js";
2
+ import { CliError, ok, parseDurationMs, readConfig, requireString, wantsJson } from "./lib/index.js";
3
+ const DEFAULT_CAPTURE_TIMEOUT_MS = 30_000;
4
+ const DEFAULT_WATCH_DURATION_MS = 10 * 60_000;
5
+ const MAX_WATCH_DURATION_MS = 10 * 60_000;
6
+ function durationFlag(flags, key, fallbackMs, maximumMs) {
7
+ const durationMs = parseDurationMs(flags[key], fallbackMs);
8
+ if (!Number.isFinite(durationMs) || durationMs <= 0 || durationMs > maximumMs) {
9
+ throw new CliError(`Invalid --${key}: expected a positive duration no greater than 10m`, "INVALID_ARGUMENT");
10
+ }
11
+ return durationMs;
12
+ }
13
+ function printTelemetry(telemetry) {
14
+ console.log(JSON.stringify(telemetry));
15
+ }
16
+ export async function handleDiagnostics({ domain, action, flags }) {
17
+ if (domain !== "diagnostics")
18
+ return false;
19
+ const config = await readConfig(flags);
20
+ const client = new UiProbeClient(config);
21
+ try {
22
+ if (action === "surfaces") {
23
+ const surfaces = await client.listSurfaces();
24
+ if (wantsJson(flags))
25
+ ok("diagnostics.surfaces", { surfaces });
26
+ else if (surfaces.length === 0)
27
+ console.log("No Device is sharing live diagnostics.");
28
+ else {
29
+ for (const surface of surfaces) {
30
+ console.log(`${surface.deviceId} ${surface.surfaceId} ${surface.kind} expires ${new Date(surface.expiresAt).toISOString()}`);
31
+ }
32
+ }
33
+ return true;
34
+ }
35
+ if (action === "capture") {
36
+ const deviceId = requireString(flags, "device");
37
+ const surfaceId = requireString(flags, "surface");
38
+ const timeoutMs = durationFlag(flags, "timeout", DEFAULT_CAPTURE_TIMEOUT_MS, MAX_WATCH_DURATION_MS);
39
+ const telemetry = await client.capture({ deviceId, surfaceId, timeoutMs });
40
+ if (wantsJson(flags))
41
+ ok("diagnostics.capture", telemetry);
42
+ else
43
+ console.log(JSON.stringify(telemetry, null, 2));
44
+ return true;
45
+ }
46
+ if (action === "watch") {
47
+ if (flags.jsonl !== true) {
48
+ throw new CliError("diagnostics watch requires --jsonl", "MISSING_ARGUMENT");
49
+ }
50
+ const deviceId = requireString(flags, "device");
51
+ const surfaceId = requireString(flags, "surface");
52
+ const durationMs = durationFlag(flags, "duration", DEFAULT_WATCH_DURATION_MS, MAX_WATCH_DURATION_MS);
53
+ const abortController = new AbortController();
54
+ const onSigint = () => abortController.abort();
55
+ process.once("SIGINT", onSigint);
56
+ try {
57
+ const watch = await client.watch({ deviceId, surfaceId, durationMs, signal: abortController.signal });
58
+ for await (const telemetry of watch)
59
+ printTelemetry(telemetry);
60
+ }
61
+ finally {
62
+ process.removeListener("SIGINT", onSigint);
63
+ }
64
+ return true;
65
+ }
66
+ throw new CliError(`Unknown diagnostics action: ${action ?? "(missing)"}`, "INVALID_ARGUMENT");
67
+ }
68
+ finally {
69
+ await client.close();
70
+ }
71
+ }
@@ -25,7 +25,9 @@ const valueFlags = new Set([
25
25
  "device-id",
26
26
  "device-name",
27
27
  "display-name",
28
+ "device",
28
29
  "domain",
30
+ "duration",
29
31
  "from",
30
32
  "host",
31
33
  "host-machine",
@@ -69,6 +71,7 @@ const valueFlags = new Set([
69
71
  "runtime-limit",
70
72
  "runtime-session",
71
73
  "shell",
74
+ "surface",
72
75
  "actions",
73
76
  "expires-in",
74
77
  "format",
@@ -122,6 +125,7 @@ const booleanFlags = new Set([
122
125
  "include-archived",
123
126
  "isolated",
124
127
  "json",
128
+ "jsonl",
125
129
  "keep-source",
126
130
  "local",
127
131
  "longterm",
@@ -1,6 +1,6 @@
1
1
  import { type ControllerClient, type ControllerSnapshot } from "../../../../../packages/client/dist/index.js";
2
2
  import type { MachineSnapshot } from "../../../../../packages/shared/dist/index.js";
3
- import { localDaemonStatus, readLocalDaemonPidIdentity, type SpawnedDaemonHandle } from "./local-daemon.js";
3
+ import { inspectLocalDaemonRuntime, localDaemonStatus, readLocalDaemonPidIdentity, stopLocalDaemon, stopSpawnedDaemon, type SpawnedDaemonHandle } from "./local-daemon.js";
4
4
  import type { DaemonConfig } from "./types.js";
5
5
  export declare function startFailureMayUseCommittedConfig(error: unknown): boolean;
6
6
  export declare function ensureDaemonRunning(expectedConfig: DaemonConfig, cwd?: string): Promise<{
@@ -12,6 +12,27 @@ export declare function ensureDaemonRunning(expectedConfig: DaemonConfig, cwd?:
12
12
  committedConfigMayBeInUse?: boolean;
13
13
  autostartNeedsAttention?: string;
14
14
  }>;
15
+ type BootstrapDaemonRestartOptions = {
16
+ readStatus?: typeof localDaemonStatus;
17
+ inspectRuntime?: typeof inspectLocalDaemonRuntime;
18
+ readIdentity?: typeof readLocalDaemonPidIdentity;
19
+ stopLocal?: typeof stopLocalDaemon;
20
+ stopStarted?: typeof stopSpawnedDaemon;
21
+ ensureRunning?: typeof ensureDaemonRunning;
22
+ assertConfigFence?: (expectedConfig: DaemonConfig) => Promise<void>;
23
+ };
24
+ /**
25
+ * `happy-elves start` is an explicit foreground recovery/entry command. When
26
+ * it finds a verified local Daemon, restart that exact owner so the process
27
+ * always loads the currently installed package before the browser opens.
28
+ *
29
+ * The lower-level `ensureDaemonRunning()` deliberately remains idempotent for
30
+ * `happy-elves daemon start`, enrollment rollback, and service recovery.
31
+ */
32
+ export declare function restartDaemonForBootstrap(expectedConfig: DaemonConfig, cwd?: string, options?: BootstrapDaemonRestartOptions): Promise<Awaited<ReturnType<typeof ensureDaemonRunning>> & {
33
+ restarted: boolean;
34
+ previousInstanceGeneration?: string;
35
+ }>;
15
36
  export type DaemonReadinessResult = {
16
37
  phase: "relay-authenticated" | "process-exited" | "owner-changed" | "relay-pending";
17
38
  local: Awaited<ReturnType<typeof localDaemonStatus>>;
@@ -3,7 +3,7 @@ import { daemonRuntimeConfigFingerprint } from "../../../../../packages/shared/d
3
3
  import { CliError } from "../../errors.js";
4
4
  import { startDaemonTimeoutMs } from "./paths.js";
5
5
  import { readDaemonConfig } from "./config.js";
6
- import { assertLocalDaemonRuntimeMatchesConfig, assertLocalDaemonVerified, inspectLocalDaemonRuntime, ensureRunningDaemonAutostart, localDaemonStatus, readLocalDaemonPidIdentity, spawnDaemonStartTracked, stopSpawnedDaemon, } from "./local-daemon.js";
6
+ import { assertLocalDaemonRuntimeMatchesConfig, assertLocalDaemonVerified, inspectLocalDaemonRuntime, ensureRunningDaemonAutostart, localDaemonStatus, readLocalDaemonPidIdentity, spawnDaemonStartTracked, stopLocalDaemon, stopSpawnedDaemon, } from "./local-daemon.js";
7
7
  const committedConfigMayBeInUseErrors = new WeakSet();
8
8
  export function startFailureMayUseCommittedConfig(error) {
9
9
  return Boolean(error && typeof error === "object" && committedConfigMayBeInUseErrors.has(error));
@@ -183,6 +183,78 @@ export async function ensureDaemonRunning(expectedConfig, cwd = process.cwd()) {
183
183
  throw error;
184
184
  }
185
185
  }
186
+ /**
187
+ * `happy-elves start` is an explicit foreground recovery/entry command. When
188
+ * it finds a verified local Daemon, restart that exact owner so the process
189
+ * always loads the currently installed package before the browser opens.
190
+ *
191
+ * The lower-level `ensureDaemonRunning()` deliberately remains idempotent for
192
+ * `happy-elves daemon start`, enrollment rollback, and service recovery.
193
+ */
194
+ export async function restartDaemonForBootstrap(expectedConfig, cwd = process.cwd(), options = {}) {
195
+ const readStatus = options.readStatus ?? localDaemonStatus;
196
+ const inspectRuntime = options.inspectRuntime ?? inspectLocalDaemonRuntime;
197
+ const readIdentity = options.readIdentity ?? readLocalDaemonPidIdentity;
198
+ const stopLocal = options.stopLocal ?? stopLocalDaemon;
199
+ const stopStarted = options.stopStarted ?? stopSpawnedDaemon;
200
+ const ensureRunning = options.ensureRunning ?? ensureDaemonRunning;
201
+ const assertConfigFence = options.assertConfigFence ?? assertDaemonConfigFence;
202
+ await assertConfigFence(expectedConfig);
203
+ const current = await readStatus();
204
+ assertLocalDaemonVerified(current);
205
+ if (!current.running) {
206
+ return {
207
+ ...await ensureRunning(expectedConfig, cwd),
208
+ restarted: false,
209
+ };
210
+ }
211
+ const owner = await readIdentity();
212
+ if (!owner.record || owner.record.pid !== current.pid) {
213
+ throw new CliError("The verified Daemon owner identity changed before restart; no process was signalled.", "DAEMON_PID_INVALID");
214
+ }
215
+ const runtime = await inspectRuntime(expectedConfig);
216
+ if (runtime.state !== "match" && runtime.state !== "missing") {
217
+ const code = runtime.state === "invalid"
218
+ ? "DAEMON_RUNTIME_IDENTITY_INVALID"
219
+ : runtime.state === "owner-mismatch"
220
+ ? "DAEMON_RUNTIME_IDENTITY_MISMATCH"
221
+ : "DAEMON_RUNTIME_CONFIG_MISMATCH";
222
+ throw new CliError("The running Daemon identity could not be matched to daemon.json, so happy-elves start did not restart or signal it.", code);
223
+ }
224
+ if (runtime.state === "match" &&
225
+ (runtime.identity.pid !== owner.record.pid ||
226
+ runtime.identity.instanceNonce !== owner.record.instanceNonce ||
227
+ runtime.identity.startedAt !== owner.record.startedAt)) {
228
+ throw new CliError("The Daemon runtime owner changed while restart was being verified; no process was signalled.", "DAEMON_RUNTIME_IDENTITY_MISMATCH");
229
+ }
230
+ const previousInstanceGeneration = owner.record.instanceNonce;
231
+ await assertConfigFence(expectedConfig);
232
+ const stopped = await stopLocal(startDaemonTimeoutMs, {
233
+ pid: owner.record.pid,
234
+ instanceNonce: owner.record.instanceNonce,
235
+ startedAt: owner.record.startedAt,
236
+ });
237
+ if (stopped.running || stopped.alive) {
238
+ throw new CliError("Daemon did not stop before the restart deadline; no replacement process was started.", "DAEMON_STOP_FAILED");
239
+ }
240
+ const started = await ensureRunning(expectedConfig, cwd);
241
+ if (started.instanceGeneration === previousInstanceGeneration) {
242
+ if (started.startedHandle) {
243
+ try {
244
+ await stopStarted(started.startedHandle);
245
+ }
246
+ catch (error) {
247
+ throw new CliError(`Daemon restart returned a stale generation and its replacement cleanup failed: ${error instanceof Error ? error.message : String(error)}`, "DAEMON_START_CLEANUP_FAILED");
248
+ }
249
+ }
250
+ throw new CliError("Daemon restart did not publish a fresh process generation.", "DAEMON_RESTART_GENERATION_MISMATCH");
251
+ }
252
+ return {
253
+ ...started,
254
+ restarted: true,
255
+ previousInstanceGeneration,
256
+ };
257
+ }
186
258
  function committedConfigInUseError(message) {
187
259
  return markCommittedConfigMayBeInUse(new CliError(message, "DAEMON_START_CONFLICT"));
188
260
  }
@@ -2,7 +2,7 @@ import { ControllerClient } from "../../../../../packages/client/dist/index.js";
2
2
  import { type DeviceRecoveryIntentV1 } from "../../../../../packages/shared/dist/index.js";
3
3
  import { stopSpawnedDaemon } from "./local-daemon.js";
4
4
  import { type StartConfigPreflight } from "./bootstrap-config.js";
5
- import { ensureDaemonRunning } from "./bootstrap-daemon.js";
5
+ import { type ensureDaemonRunning } from "./bootstrap-daemon.js";
6
6
  export declare function startDeviceRecoveryBootstrap(params: {
7
7
  flags: Record<string, string | boolean>;
8
8
  preflight: StartConfigPreflight;
@@ -12,12 +12,15 @@ export declare function startDeviceRecoveryBootstrap(params: {
12
12
  }): Promise<void>;
13
13
  export declare function deviceRecoveryIntentNeedsReplacement(intent: Pick<DeviceRecoveryIntentV1, "status">): boolean;
14
14
  export declare function isTerminalRecoveryIntentCreationFailure(error: unknown): boolean;
15
- export declare function projectRecoveredDaemonReadinessFailure(started: Awaited<ReturnType<typeof ensureDaemonRunning>>, machineName: string, readinessError: unknown, options?: {
15
+ export declare function projectRecoveredDaemonReadinessFailure(started: Awaited<ReturnType<typeof ensureDaemonRunning>> & {
16
+ restarted?: boolean;
17
+ }, machineName: string, readinessError: unknown, options?: {
16
18
  stopStarted?: typeof stopSpawnedDaemon;
17
19
  }): Promise<{
18
20
  online: false;
19
21
  pid?: number;
20
22
  started: boolean;
23
+ restarted: boolean;
21
24
  name: string;
22
25
  needsAttention: string;
23
26
  }>;
@@ -7,10 +7,10 @@ import { accountFingerprintV1, deviceRecoveryTtlMsV1, randomId, } from "../../..
7
7
  import { daemonRuntimeConfigFingerprint } from "../../../../../packages/shared/dist/node/daemon-process.js";
8
8
  import { CliError } from "../../errors.js";
9
9
  import { ok } from "./json.js";
10
- import { assertLocalDaemonVerified, ensureRunningDaemonAutostart, inspectLocalDaemonRuntime, localDaemonStatus, stopSpawnedDaemon, } from "./local-daemon.js";
10
+ import { assertLocalDaemonVerified, localDaemonStatus, stopSpawnedDaemon, } from "./local-daemon.js";
11
11
  import { withAccountConfigTransaction } from "./config.js";
12
12
  import { controllerBaseUrl } from "./bootstrap-config.js";
13
- import { ensureDaemonRunning, readDaemonProjectionBaseline, waitForDaemonReadiness, } from "./bootstrap-daemon.js";
13
+ import { readDaemonProjectionBaseline, restartDaemonForBootstrap, waitForDaemonReadiness, } from "./bootstrap-daemon.js";
14
14
  import { openUrl } from "./bootstrap-output.js";
15
15
  import { assertNoPendingDaemonEnrollmentForDeviceRecovery } from "./daemon-enrollment-intent.js";
16
16
  import { configDir, configPath, deviceRecoveryPendingPath, startDaemonTimeoutMs } from "./paths.js";
@@ -318,34 +318,10 @@ async function ensureRecoveredDaemonReady(params) {
318
318
  ...projection,
319
319
  ...(current.pid ? { pid: current.pid } : {}),
320
320
  started: false,
321
+ restarted: false,
321
322
  needsAttention: error instanceof Error ? error.message : "The local Daemon owner needs verification.",
322
323
  };
323
324
  }
324
- if (current.running) {
325
- const runtime = await inspectLocalDaemonRuntime(params.daemonConfig);
326
- if (runtime.state !== "match") {
327
- const projection = await readProjection();
328
- const needsAttention = runtime.state === "missing"
329
- ? "Automatic startup metadata will be repaired on the next controlled Daemon restart."
330
- : "The running Daemon identity needs repair before Happy Elves can manage its process.";
331
- return {
332
- ...projection,
333
- ...(current.pid ? { pid: current.pid } : {}),
334
- started: false,
335
- needsAttention,
336
- };
337
- }
338
- const autostart = await ensureRunningDaemonAutostart();
339
- const projection = await readProjection();
340
- return {
341
- ...projection,
342
- ...(current.pid ? { pid: current.pid } : {}),
343
- started: false,
344
- ...(autostart.state === "needs-attention"
345
- ? { needsAttention: autostart.message }
346
- : {}),
347
- };
348
- }
349
325
  let baseline;
350
326
  try {
351
327
  baseline = await readDaemonProjectionBaseline(client, params.daemonConfig.machineId, {
@@ -357,37 +333,51 @@ async function ensureRecoveredDaemonReady(params) {
357
333
  }
358
334
  let started;
359
335
  try {
360
- started = await ensureDaemonRunning(params.daemonConfig, params.cwd);
361
- }
362
- catch (error) {
363
- const projection = await readProjection();
364
- return {
365
- ...projection,
366
- ...(current.pid ? { pid: current.pid } : {}),
367
- started: false,
368
- needsAttention: error instanceof Error ? error.message : "The Daemon could not be started automatically.",
369
- };
370
- }
371
- try {
372
- const readiness = await waitForDaemonReadiness(client, params.daemonConfig.machineId, {
373
- baseline,
374
- expectedInstanceGeneration: started.instanceGeneration,
375
- requireFreshProjection: started.started,
376
- timeoutMs: startDaemonTimeoutMs,
336
+ const ready = await withAccountConfigTransaction(async (transaction) => {
337
+ const currentDaemonConfig = await transaction.readDaemon();
338
+ if (!currentDaemonConfig ||
339
+ daemonRuntimeConfigFingerprint(currentDaemonConfig) !== daemonRuntimeConfigFingerprint(params.daemonConfig)) {
340
+ throw new CliError("The local Daemon identity changed before restart. No process was signalled.", "CONFIG_CHANGED_DURING_START");
341
+ }
342
+ started = await restartDaemonForBootstrap(params.daemonConfig, params.cwd);
343
+ const readiness = await waitForDaemonReadiness(client, params.daemonConfig.machineId, {
344
+ baseline,
345
+ expectedInstanceGeneration: started.instanceGeneration,
346
+ requireFreshProjection: started.started || started.restarted,
347
+ timeoutMs: startDaemonTimeoutMs,
348
+ });
349
+ if (readiness.phase !== "relay-authenticated" || !readiness.machine?.online) {
350
+ throw new CliError("The Daemon did not authenticate before the startup deadline.", "DAEMON_ONLINE_TIMEOUT");
351
+ }
352
+ const confirmedDaemonConfig = await transaction.readDaemon();
353
+ if (!confirmedDaemonConfig ||
354
+ daemonRuntimeConfigFingerprint(confirmedDaemonConfig) !== daemonRuntimeConfigFingerprint(params.daemonConfig)) {
355
+ throw new CliError("The local Daemon identity changed during restart.", "CONFIG_CHANGED_DURING_START");
356
+ }
357
+ return { started, readiness };
377
358
  });
378
- if (readiness.phase !== "relay-authenticated" || !readiness.machine?.online) {
379
- throw new CliError("The Daemon did not authenticate before the startup deadline.", "DAEMON_ONLINE_TIMEOUT");
380
- }
381
- const pid = started.local.pid ?? started.startedPid;
359
+ const pid = ready.started.local.pid ?? ready.started.startedPid;
382
360
  return {
383
361
  online: true,
384
362
  ...(pid ? { pid } : {}),
385
- started: started.started,
386
- name: readiness.machine.name ?? params.daemonConfig.machineName,
363
+ started: ready.started.started,
364
+ restarted: ready.started.restarted,
365
+ name: ready.readiness.machine?.name ?? params.daemonConfig.machineName,
387
366
  };
388
367
  }
389
368
  catch (error) {
390
- return await projectRecoveredDaemonReadinessFailure(started, params.daemonConfig.machineName, error);
369
+ if (started) {
370
+ return await projectRecoveredDaemonReadinessFailure(started, params.daemonConfig.machineName, error);
371
+ }
372
+ const settled = await localDaemonStatus().catch(() => current);
373
+ return {
374
+ online: false,
375
+ name: params.daemonConfig.machineName,
376
+ ...(settled.pid ? { pid: settled.pid } : {}),
377
+ started: false,
378
+ restarted: current.running && !settled.running,
379
+ needsAttention: error instanceof Error ? error.message : "The Daemon could not be restarted automatically.",
380
+ };
391
381
  }
392
382
  }
393
383
  export async function projectRecoveredDaemonReadinessFailure(started, machineName, readinessError, options = {}) {
@@ -408,6 +398,7 @@ export async function projectRecoveredDaemonReadinessFailure(started, machineNam
408
398
  online: false,
409
399
  ...(started.local.pid ?? started.startedPid ? { pid: started.local.pid ?? started.startedPid } : {}),
410
400
  started: daemonCleanupError ? started.started : false,
401
+ restarted: started.restarted === true,
411
402
  name: machineName,
412
403
  needsAttention,
413
404
  };
@@ -10,7 +10,7 @@ import { assertLocalDaemonRuntimeMatchesConfig, assertLocalDaemonVerified, inspe
10
10
  import { redactedRelayUrl, withAccountConfigTransaction, } from "./config.js";
11
11
  import { cliErrorFromFetch } from "./relay-http.js";
12
12
  import { controllerBaseUrl, ensureControllerConfig, ensureDaemonPaired, readStartConfigPreflight } from "./bootstrap-config.js";
13
- import { ensureDaemonRunning, readDaemonProjectionBaseline, startFailureMayUseCommittedConfig, waitForDaemonReadiness, } from "./bootstrap-daemon.js";
13
+ import { readDaemonProjectionBaseline, restartDaemonForBootstrap, startFailureMayUseCommittedConfig, waitForDaemonReadiness, } from "./bootstrap-daemon.js";
14
14
  import { openUrl, writeHumanStartOutput } from "./bootstrap-output.js";
15
15
  import { startJoinBootstrap } from "./bootstrap-join.js";
16
16
  import { reconcileSatisfiedDeviceRecoveryPending, startDeviceRecoveryBootstrap } from "./bootstrap-device-recovery.js";
@@ -85,16 +85,15 @@ export async function startBootstrap(flags) {
85
85
  if (daemonBefore.running && preflight.kind !== "ready") {
86
86
  throw new CliError("A verified daemon is running, but the matching controller/daemon config pair is incomplete. No configuration or daemon process was changed. Run happy-elves doctor before recovering this workspace.", "CONFIG_SPLIT_BRAIN");
87
87
  }
88
- // A running owner is accepted only when its signed runtime marker proves
89
- // that it loaded the same daemon identity we just read under this lease.
90
- // Ordinary `start` never restarts a healthy daemon because package files
91
- // happen to have a newer mtime.
88
+ // Top-level `happy-elves start` deliberately restarts a verified owner so
89
+ // it always loads the currently installed package. A missing marker is the
90
+ // supported legacy-upgrade case: exact PID v2 ownership still fences the
91
+ // stop, and the replacement publishes a fresh marker. Invalid, mismatched,
92
+ // or unverified identities remain fail-closed and are never signalled.
92
93
  if (!daemonProcessNeedsAttention && daemonBefore.running && preflight.daemon) {
93
94
  const runtime = await inspectLocalDaemonRuntime(preflight.daemon);
94
- if (runtime.state !== "match") {
95
- daemonProcessNeedsAttention = runtime.state === "missing"
96
- ? "Automatic startup metadata will be repaired on the next controlled Daemon restart."
97
- : "The running Daemon identity needs repair before Happy Elves can manage its process.";
95
+ if (runtime.state !== "match" && runtime.state !== "missing") {
96
+ daemonProcessNeedsAttention = "The running Daemon identity needs repair before Happy Elves can manage its process.";
98
97
  }
99
98
  }
100
99
  const networkDeadline = Date.now() + startDaemonTimeoutMs;
@@ -187,19 +186,20 @@ export async function startBootstrap(flags) {
187
186
  started: false,
188
187
  startedPid: undefined,
189
188
  committedConfigMayBeInUse: daemonBefore.running,
189
+ restarted: false,
190
190
  },
191
191
  machine: baseline,
192
192
  invite,
193
193
  daemonProcessNeedsAttention,
194
194
  };
195
195
  }
196
- const daemon = await ensureDaemonRunning(daemonState.config, cwd);
196
+ const daemon = await restartDaemonForBootstrap(daemonState.config, cwd);
197
197
  startedHandle = daemon.startedHandle;
198
198
  committedConfigMayBeInUse = daemon.committedConfigMayBeInUse === true;
199
199
  const readiness = await waitForDaemonReadiness(client, daemonState.config.machineId, {
200
200
  baseline,
201
201
  expectedInstanceGeneration: daemon.instanceGeneration,
202
- requireFreshProjection: daemon.started,
202
+ requireFreshProjection: daemon.started || daemon.restarted,
203
203
  // Lifetime-lock acquisition/reclaim and relay-authenticated readiness
204
204
  // are separate bounded phases.
205
205
  timeoutMs: startDaemonTimeoutMs,
@@ -317,8 +317,9 @@ export async function startBootstrap(flags) {
317
317
  pid: daemon.local.pid ?? daemon.startedPid,
318
318
  pidPath: daemon.local.pidPath,
319
319
  started: daemon.started,
320
- alreadyRunning: daemonBefore.running || daemon.committedConfigMayBeInUse === true,
321
- restarted: false,
320
+ alreadyRunning: daemon.restarted !== true &&
321
+ (daemonBefore.running || daemon.committedConfigMayBeInUse === true),
322
+ restarted: daemon.restarted,
322
323
  ...(daemonNeedsAttention ? { needsAttention: daemonNeedsAttention } : {}),
323
324
  },
324
325
  ...(invite ? {
@@ -12,6 +12,15 @@ Show the current Account, Daemon, and latest Session state.
12
12
  doctor: `Usage: happy-elves doctor [--relay <url>] [--json]
13
13
 
14
14
  Check local Device access, Account consistency, service reachability, and Daemon state.
15
+ `,
16
+ diagnostics: `Usage:
17
+ happy-elves diagnostics surfaces --json
18
+ happy-elves diagnostics capture --device <deviceId> --surface <surfaceId> [--timeout 30s] --json
19
+ happy-elves diagnostics watch --device <deviceId> --surface <surfaceId> [--duration 10m] --jsonl
20
+
21
+ Observe privacy-filtered layout, focus, viewport, pointer, and scroll telemetry
22
+ from a Device that explicitly enabled Settings > Diagnostics > Share live diagnostics.
23
+ Probe traffic is end-to-end encrypted and expires after ten minutes.
15
24
  `,
16
25
  account: `Usage:
17
26
  happy-elves account create --relay <url> [--device-id <id>] [--device-name <name>] [--reveal-secrets] --json
@@ -300,6 +309,11 @@ Advanced and compatibility commands:
300
309
  relay status [--relay <url>] [--json]
301
310
  relay doctor [--relay <url>] [--json]
302
311
 
312
+ # Privacy-filtered diagnostics shared by another trusted Device.
313
+ diagnostics surfaces --json
314
+ diagnostics capture --device <deviceId> --surface <surfaceId> --json
315
+ diagnostics watch --device <deviceId> --surface <surfaceId> --jsonl
316
+
303
317
  Usage:
304
318
  happy-elves <domain> <action> [args] [flags]
305
319
  happy-elves <domain> --help
@@ -316,7 +330,7 @@ More:
316
330
  happy-elves --version
317
331
  happy-elves version --json
318
332
  happy-elves <domain> --help
319
- start, status, doctor, collect, config, account, daemon, relay, token, gateway, loop.
333
+ start, status, doctor, collect, config, account, daemon, relay, diagnostics, token, gateway, loop.
320
334
  orchestrator, skill.
321
335
  `;
322
336
  }