@cydm/happy-elves 0.1.0-beta.632 → 0.1.0-beta.633

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 (76) hide show
  1. package/README.md +2 -2
  2. package/apps/cli/dist/commands/app.js +3 -1
  3. package/apps/cli/dist/commands/lib/bootstrap-config.js +17 -43
  4. package/apps/cli/dist/commands/lib/bootstrap-daemon.js +4 -2
  5. package/apps/cli/dist/commands/lib/bootstrap-output.d.ts +1 -0
  6. package/apps/cli/dist/commands/lib/bootstrap-output.js +1 -1
  7. package/apps/cli/dist/commands/lib/bootstrap.d.ts +2 -0
  8. package/apps/cli/dist/commands/lib/bootstrap.js +53 -11
  9. package/apps/cli/dist/commands/lib/exit.js +7 -0
  10. package/apps/cli/dist/commands/lib/local-daemon.d.ts +1 -0
  11. package/apps/cli/dist/commands/lib/local-daemon.js +72 -55
  12. package/apps/cli/dist/commands/machine.js +3 -1
  13. package/apps/cli/package.json +1 -1
  14. package/apps/daemon/dist/direct/server.js +3 -2
  15. package/apps/daemon/dist/local-control.d.ts +1 -1
  16. package/apps/daemon/dist/local-control.js +14 -20
  17. package/apps/daemon/dist/orchestration/dispatch-delivery.js +1 -1
  18. package/apps/daemon/dist/orchestration/handler.d.ts +2 -1
  19. package/apps/daemon/dist/orchestration/handler.js +20 -7
  20. package/apps/daemon/dist/paths.d.ts +1 -0
  21. package/apps/daemon/dist/paths.js +10 -0
  22. package/apps/daemon/dist/process.js +54 -20
  23. package/apps/daemon/dist/relay/send.d.ts +5 -1
  24. package/apps/daemon/dist/relay/send.js +24 -4
  25. package/apps/daemon/dist/session/he-official/orchestration-commands.d.ts +1 -1
  26. package/apps/daemon/dist/session/lifecycle.d.ts +1 -1
  27. package/apps/daemon/dist/session/prompt-turn-service.js +1 -1
  28. package/apps/daemon/dist/session/turn-control-service.js +22 -6
  29. package/apps/daemon/dist/start.js +3 -2
  30. package/apps/daemon/package.json +1 -1
  31. package/apps/relay/dist/http-routes.js +20 -12
  32. package/apps/relay/dist/packet-store.d.ts +5 -0
  33. package/apps/relay/dist/packet-store.js +15 -0
  34. package/apps/relay/dist/relay-context.d.ts +9 -0
  35. package/apps/relay/dist/relay-context.js +13 -1
  36. package/apps/relay/dist/storage-worker.d.ts +5 -0
  37. package/apps/relay/dist/storage-worker.js +46 -36
  38. package/apps/relay/dist/websocket.d.ts +1 -0
  39. package/apps/relay/dist/websocket.js +43 -29
  40. package/apps/updater/dist/autostart.js +2 -1
  41. package/apps/updater/dist/daemon.js +14 -8
  42. package/apps/updater/dist/run.d.ts +9 -0
  43. package/apps/updater/dist/run.js +29 -3
  44. package/apps/updater/dist/runtime-host-cleanup.d.ts +16 -0
  45. package/apps/updater/dist/runtime-host-cleanup.js +33 -0
  46. package/apps/updater/dist/supervisor.js +39 -12
  47. package/package.json +4 -2
  48. package/packages/claude-provider/dist/claude-provider.d.ts +3 -1
  49. package/packages/claude-provider/dist/claude-provider.js +136 -43
  50. package/packages/client/dist/account.d.ts +1 -0
  51. package/packages/client/dist/account.js +4 -0
  52. package/packages/client/dist/client.d.ts +6 -0
  53. package/packages/client/dist/client.js +156 -21
  54. package/packages/client/dist/errors.d.ts +4 -0
  55. package/packages/client/dist/errors.js +4 -0
  56. package/packages/client/dist/product-materializer.js +13 -4
  57. package/packages/client/dist/transport.js +49 -41
  58. package/packages/client/dist/types.d.ts +2 -0
  59. package/packages/client/dist/update.js +10 -5
  60. package/packages/codex-provider/dist/codex-provider.js +182 -14
  61. package/packages/he-kernel/dist/index.js +6 -24
  62. package/packages/headless-runtime/dist/core.d.ts +6 -1
  63. package/packages/headless-runtime/dist/core.js +129 -69
  64. package/packages/headless-runtime/dist/trace-log.d.ts +6 -0
  65. package/packages/headless-runtime/dist/trace-log.js +35 -1
  66. package/packages/pie-provider/dist/index.js +3 -1
  67. package/packages/product-protocol/dist/protocol-schemas.d.ts +1 -0
  68. package/packages/product-protocol/dist/protocol-schemas.js +1 -0
  69. package/packages/product-protocol/dist/protocol-types.d.ts +2 -0
  70. package/packages/product-protocol/dist/protocol.d.ts +3 -0
  71. package/packages/relay-protocol/dist/packet-relay.d.ts +4 -2
  72. package/packages/relay-protocol/dist/packet-relay.js +2 -1
  73. package/packages/runtime-host/dist/host.d.ts +7 -0
  74. package/packages/runtime-host/dist/host.js +73 -0
  75. package/packages/runtime-host/dist/provider-command.js +4 -3
  76. package/packages/runtime-spi/dist/provider.d.ts +4 -1
package/README.md CHANGED
@@ -6,8 +6,8 @@ Public beta CLI for Happy Elves.
6
6
 
7
7
  Run once in the existing user account; it detects %USERPROFILE%\.happy-elves\hosted, keeps the current daemon and sessions running, installs the independent updater, and registers per-user startup.
8
8
 
9
- npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.632
10
- npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.632 -- happy-elves-updater install --state-root $HOME\.happy-elves\hosted --autostart
9
+ npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.633
10
+ npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.633 -- happy-elves-updater install --state-root $HOME\.happy-elves\hosted --autostart
11
11
 
12
12
  The same updater entrypoint works on macOS after selecting a permanent user prefix.
13
13
 
@@ -20,7 +20,7 @@ import { handleSidechat } from "./sidechat.js";
20
20
  import { handleToken } from "./token.js";
21
21
  import { handleTurn } from "./turn.js";
22
22
  import { handleWorkspace } from "./workspace.js";
23
- import { commandUsage, commandUsageText, handleCliError, parseCliFlags, skillText, startBootstrap, topLevelDoctor, topLevelStatus, usage, usageText } from "./lib/index.js";
23
+ import { commandUsage, commandUsageText, delegateHostedStart, handleCliError, parseCliFlags, skillText, startBootstrap, topLevelDoctor, topLevelStatus, usage, usageText } from "./lib/index.js";
24
24
  export { handleCliError };
25
25
  const domainHandlers = [
26
26
  handleAccount,
@@ -68,6 +68,8 @@ export async function main() {
68
68
  }
69
69
  if (domain === "start") {
70
70
  const { flags } = parseCliFlags(argv.slice(1));
71
+ if (await delegateHostedStart(argv, flags))
72
+ return;
71
73
  await startBootstrap(flags);
72
74
  return;
73
75
  }
@@ -1,4 +1,3 @@
1
- import fs from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import { ControllerClient } from "../../../../../packages/client/dist/index.js";
4
3
  import { randomId } from "../../../../../packages/foundation/dist/index.js";
@@ -7,21 +6,6 @@ import { CliError } from "../../errors.js";
7
6
  import { configDir, configPath, defaultHostedControllerUrl, defaultHostedRelayUrl } from "./paths.js";
8
7
  import { normalizeRelayUrl, requireRelayUrl, writeConfig, writeDaemonConfig } from "./config.js";
9
8
  import { cliErrorFromFetch, isAuthTokenInvalid, parsePairingClaimResponse, parsePairingStartResponse, readExistingControllerConfig, readExistingDaemonConfig, readRelayJson, throwRelayHttpError } from "./relay-http.js";
10
- function backupTimestamp() {
11
- return new Date().toISOString().replace(/[:.]/g, "-");
12
- }
13
- async function backupConfigFile(filePath, reason) {
14
- const backupPath = `${filePath}.bak.${backupTimestamp()}`;
15
- try {
16
- await fs.rename(filePath, backupPath);
17
- return { path: filePath, backupPath, reason };
18
- }
19
- catch (error) {
20
- if (error.code === "ENOENT")
21
- return undefined;
22
- throw new CliError(`Could not back up ${filePath}: ${error instanceof Error ? error.message : String(error)}`, "CONFIG_INVALID");
23
- }
24
- }
25
9
  async function createControllerConfig(params) {
26
10
  const accountSecret = generateAccountSecret();
27
11
  let response;
@@ -49,33 +33,28 @@ async function createControllerConfig(params) {
49
33
  }
50
34
  export async function ensureControllerConfig(params) {
51
35
  let existing;
52
- let backup;
53
36
  try {
54
37
  existing = await readExistingControllerConfig();
55
38
  }
56
- catch (error) {
57
- if (!(error instanceof CliError) || error.code !== "CONFIG_INVALID")
58
- throw error;
59
- backup = await backupConfigFile(configPath, "invalid-controller-config");
39
+ catch {
40
+ throw new CliError(`Existing controller configuration at ${configPath} is invalid; refusing to replace its account.`, "CONFIG_INVALID");
60
41
  }
61
42
  if (existing) {
62
43
  if (params.explicitRelay && existing.relayUrl !== params.relayUrl) {
63
- backup = await backupConfigFile(configPath, "controller-relay-changed");
44
+ throw new CliError("Existing controller uses a different relay; refusing to create a replacement account during start.", "CONTROLLER_RELAY_MISMATCH");
45
+ }
46
+ try {
47
+ await new ControllerClient(existing).snapshotSummary();
48
+ return { config: existing, created: false, reconfigured: false };
64
49
  }
65
- else {
66
- try {
67
- await new ControllerClient(existing).snapshot();
68
- return { config: existing, created: false, reconfigured: false };
69
- }
70
- catch (error) {
71
- if (!isAuthTokenInvalid(error))
72
- throw error;
73
- backup = await backupConfigFile(configPath, "controller-token-invalid");
74
- }
50
+ catch (error) {
51
+ if (!isAuthTokenInvalid(error))
52
+ throw error;
53
+ throw new CliError("Existing controller token was rejected; refusing to create a replacement account during start.", "CONTROLLER_AUTH_REJECTED");
75
54
  }
76
55
  }
77
56
  const created = await createControllerConfig({ relayUrl: params.relayUrl, deviceName: params.deviceName });
78
- return { config: created.config, created: true, accountId: created.accountId, backup, reconfigured: Boolean(backup) };
57
+ return { config: created.config, created: true, accountId: created.accountId, reconfigured: false };
79
58
  }
80
59
  async function claimMachinePairing(params) {
81
60
  let response;
@@ -115,24 +94,19 @@ async function createDaemonConfig(config, machineName) {
115
94
  export async function ensureDaemonPaired(config, machineName) {
116
95
  const daemonConfigPath = path.join(configDir, "daemon.json");
117
96
  let existing;
118
- let backup;
119
97
  try {
120
98
  existing = await readExistingDaemonConfig();
121
99
  }
122
- catch (error) {
123
- if (!(error instanceof CliError) || error.code !== "CONFIG_INVALID")
124
- throw error;
125
- backup = await backupConfigFile(daemonConfigPath, "invalid-daemon-config");
100
+ catch {
101
+ throw new CliError(`Existing daemon configuration at ${daemonConfigPath} is invalid; refusing to replace its machine.`, "CONFIG_INVALID");
126
102
  }
127
103
  if (existing) {
128
104
  if (existing.relayUrl !== config.relayUrl || existing.accountSecret !== config.accountSecret) {
129
- backup = await backupConfigFile(daemonConfigPath, "daemon-account-changed");
130
- }
131
- else {
132
- return { config: existing, created: false, reconfigured: false };
105
+ throw new CliError("Existing daemon belongs to a different relay or account; refusing to replace its machine during start.", "DAEMON_ACCOUNT_MISMATCH");
133
106
  }
107
+ return { config: existing, created: false, reconfigured: false };
134
108
  }
135
- return { config: await createDaemonConfig(config, machineName), created: true, backup, reconfigured: Boolean(backup) };
109
+ return { config: await createDaemonConfig(config, machineName), created: true, reconfigured: false };
136
110
  }
137
111
  export function controllerBaseUrl(flags) {
138
112
  const value = typeof flags.controller === "string" ? flags.controller : defaultHostedControllerUrl;
@@ -6,6 +6,8 @@ import { daemonBinaryUpdatedAfterStart, daemonConfigUpdatedAfterStart, localDaem
6
6
  export async function ensureDaemonRunning(relayUrl, cwd = process.cwd()) {
7
7
  const current = await localDaemonStatus();
8
8
  if (current.running) {
9
+ if (current.identityVerified !== true)
10
+ throw new CliError("Daemon identity cannot be verified; refusing to reuse or restart it.", "DAEMON_IDENTITY_UNVERIFIED");
9
11
  if ((await daemonBinaryUpdatedAfterStart(current)) || (await daemonConfigUpdatedAfterStart(current))) {
10
12
  return await restartDaemonForNewConfig(relayUrl, cwd);
11
13
  }
@@ -42,9 +44,9 @@ export async function waitForMachineProjection(client, machineId, timeoutMs = 50
42
44
  const deadline = Date.now() + timeoutMs;
43
45
  let lastSeen;
44
46
  while (Date.now() <= deadline) {
45
- const snapshot = await client.snapshot();
47
+ const snapshot = await client.snapshotSummary();
46
48
  lastSeen = snapshot.machines.find((machine) => machine.id === machineId);
47
- if (lastSeen?.online)
49
+ if (lastSeen?.daemonOnline === true)
48
50
  return lastSeen;
49
51
  await new Promise((resolve) => setTimeout(resolve, 300));
50
52
  }
@@ -11,6 +11,7 @@ export declare function writeHumanStartOutput(data: {
11
11
  daemon: {
12
12
  pid?: number;
13
13
  };
14
+ inviteCreated: boolean;
14
15
  }): void;
15
16
  export declare function openUrl(url: string): {
16
17
  opened: boolean;
@@ -12,7 +12,7 @@ export function writeHumanStartOutput(data) {
12
12
  }
13
13
  console.log("");
14
14
  console.log("Phone");
15
- console.log("Open the same link on your phone.");
15
+ console.log(data.inviteCreated ? "Open the same link on your phone within 10 minutes." : "To add a new phone, run happy-elves remote invite-controller.");
16
16
  console.log("");
17
17
  console.log("Machine");
18
18
  console.log(`${data.machine.online ? "online" : "starting"}, ${data.machine.name}`);
@@ -1 +1,3 @@
1
1
  export declare function startBootstrap(flags: Record<string, string | boolean>): Promise<void>;
2
+ /** The default hosted install is the one managed daemon entrypoint after an updater install. */
3
+ export declare function delegateHostedStart(argv: string[], flags: Record<string, string | boolean>): Promise<boolean>;
@@ -1,5 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { spawn } from "node:child_process";
3
5
  import { ControllerClient } from "../../../../../packages/client/dist/index.js";
4
6
  import { CliError } from "../../errors.js";
5
7
  import { configDir, configPath } from "./paths.js";
@@ -31,20 +33,21 @@ export async function startBootstrap(flags) {
31
33
  : await ensureDaemonRunning(controllerState.config.relayUrl, cwd);
32
34
  const client = new ControllerClient(controllerState.config);
33
35
  const machine = await waitForMachineProjection(client, daemonState.config.machineId);
34
- if (!machine?.online) {
36
+ if (machine?.daemonOnline !== true) {
35
37
  throw new CliError("Daemon started, but this machine did not appear online in the relay snapshot. Run happy-elves doctor and happy-elves daemon logs.", "DAEMON_ONLINE_TIMEOUT");
36
38
  }
37
- const invite = await client.createControllerInvite({
39
+ const invite = controllerState.created ? await client.createControllerInvite({
38
40
  deviceName: "Web controller",
39
41
  ttlMs: 10 * 60_000,
40
42
  joinUrlBase: controllerBaseUrl(flags),
41
43
  relayUrl: controllerState.config.relayUrl,
42
44
  }).catch((error) => {
43
45
  throw cliErrorFromFetch(error, "INVITE_CREATE_FAILED", "Could not create controller invite");
44
- });
46
+ }) : undefined;
47
+ const controllerUrl = invite?.joinUrl ?? controllerBaseUrl(flags);
45
48
  const data = {
46
49
  relayUrl: controllerState.config.relayUrl,
47
- controllerUrl: invite.joinUrl,
50
+ controllerUrl,
48
51
  workspace: cwd,
49
52
  config: {
50
53
  controllerPath: configPath,
@@ -59,7 +62,7 @@ export async function startBootstrap(flags) {
59
62
  machine: {
60
63
  id: daemonState.config.machineId,
61
64
  name: machine.name ?? daemonState.config.machineName,
62
- online: machine.online,
65
+ online: machine.daemonOnline,
63
66
  lastSeen: machine?.lastSeen,
64
67
  },
65
68
  daemon: {
@@ -70,19 +73,16 @@ export async function startBootstrap(flags) {
70
73
  alreadyRunning: daemonBefore.running,
71
74
  restarted: daemonBefore.running && daemonState.reconfigured,
72
75
  },
73
- invite: {
74
- id: invite.inviteCode,
75
- expiresAt: invite.expiresAt,
76
- },
76
+ ...(invite ? { invite: { id: invite.inviteCode, expiresAt: invite.expiresAt } } : {}),
77
77
  };
78
78
  if (flags.json === true) {
79
79
  ok("start", data);
80
80
  return;
81
81
  }
82
- const openResult = flags["no-open"] === true ? { opened: false } : openUrl(invite.joinUrl);
82
+ const openResult = flags["no-open"] === true ? { opened: false } : openUrl(controllerUrl);
83
83
  writeHumanStartOutput({
84
84
  workspace: cwd,
85
- controllerUrl: invite.joinUrl,
85
+ controllerUrl,
86
86
  relayUrl: controllerState.config.relayUrl,
87
87
  opened: openResult.opened,
88
88
  openError: openResult.error,
@@ -91,6 +91,48 @@ export async function startBootstrap(flags) {
91
91
  online: data.machine.online,
92
92
  },
93
93
  daemon: data.daemon,
94
+ inviteCreated: Boolean(invite),
95
+ });
96
+ }
97
+ /** The default hosted install is the one managed daemon entrypoint after an updater install. */
98
+ export async function delegateHostedStart(argv, flags) {
99
+ if (process.env.HAPPY_ELVES_HOME || process.env.HAPPY_ELVES_START_DELEGATED === "1" || flags.relay || flags.controller)
100
+ return false;
101
+ const stateRoot = path.join(os.homedir(), ".happy-elves", "hosted");
102
+ const currentPath = path.join(stateRoot, "updater", "current.json");
103
+ const currentStat = await fs.stat(currentPath).catch((error) => {
104
+ if (error.code === "ENOENT")
105
+ return undefined;
106
+ throw error;
107
+ });
108
+ if (!currentStat)
109
+ return false;
110
+ if (!currentStat.isFile() || currentStat.size > 64 * 1024)
111
+ throw new CliError("Hosted updater current slot is invalid.", "HOSTED_INSTALL_INVALID");
112
+ const current = JSON.parse(await fs.readFile(currentPath, "utf8"));
113
+ if (typeof current.version !== "string" || !/^[A-Za-z0-9._-]+$/u.test(current.version) ||
114
+ typeof current.slotPath !== "string" ||
115
+ path.resolve(current.slotPath) !== path.join(stateRoot, "updater", "versions", current.version)) {
116
+ throw new CliError("Hosted updater current slot does not match its state root.", "HOSTED_INSTALL_INVALID");
117
+ }
118
+ const launcher = path.join(stateRoot, "updater", "bin", process.platform === "win32" ? "happy-elves.cmd" : "happy-elves");
119
+ const installedCli = path.join(current.slotPath, "node_modules", "@cydm", "happy-elves", "apps", "cli", "dist", "index.js");
120
+ await Promise.all([fs.access(launcher), fs.access(installedCli)]).catch(() => {
121
+ throw new CliError("Hosted launcher or active package is missing.", "HOSTED_INSTALL_INVALID");
122
+ });
123
+ const result = await new Promise((resolve, reject) => {
124
+ const child = spawn(launcher, argv, {
125
+ stdio: "inherit",
126
+ env: { ...process.env, HAPPY_ELVES_START_DELEGATED: "1" },
127
+ windowsHide: true,
128
+ ...(process.platform === "win32" ? { shell: true } : {}),
129
+ });
130
+ child.once("error", reject);
131
+ child.once("exit", (code, signal) => resolve({ code, signal }));
94
132
  });
133
+ if (result.signal)
134
+ process.kill(process.pid, result.signal);
135
+ process.exitCode = result.code ?? 1;
136
+ return true;
95
137
  }
96
138
  //# sourceMappingURL=bootstrap.js.map
@@ -93,6 +93,11 @@ export function handleCliError(error) {
93
93
  console.error(" happy-elves machine exec <machineId> --shell <command> --wait-timeout-ms <ms>");
94
94
  process.exit(1);
95
95
  }
96
+ if (enriched.operationId && enriched.machineId && ["UPDATER_UNAVAILABLE", "RELAY_WEBSOCKET_ERROR", "UPDATE_PUBLISH_FAILED"].includes(enriched.code ?? "") && !wantsJsonOutput()) {
97
+ console.error(`Update outcome is unknown: ${enriched.operationId}`);
98
+ printRunCommands([`happy-elves machine update status ${enriched.machineId} --operation ${enriched.operationId}`]);
99
+ process.exit(1);
100
+ }
96
101
  if (enriched.code === "TURN_WAIT_TIMEOUT" && enriched.turnId && !wantsJsonOutput()) {
97
102
  console.error(message);
98
103
  if (enriched.sessionId) {
@@ -121,6 +126,8 @@ export function handleCliError(error) {
121
126
  }
122
127
  writeError(enriched.code ?? "COMMAND_FAILED", message, {
123
128
  requestId: enriched.requestId,
129
+ operationId: enriched.operationId,
130
+ machineId: enriched.machineId,
124
131
  sessionId: enriched.sessionId,
125
132
  turnId: enriched.turnId,
126
133
  activeTurnId: enriched.activeTurnId,
@@ -2,6 +2,7 @@ export declare function localDaemonStatus(): Promise<{
2
2
  pidPath: string;
3
3
  pid?: number;
4
4
  running: boolean;
5
+ identityVerified?: boolean;
5
6
  }>;
6
7
  export declare function daemonBinaryUpdatedAfterStart(status: Awaited<ReturnType<typeof localDaemonStatus>>): Promise<boolean>;
7
8
  export declare function daemonConfigUpdatedAfterStart(status: Awaited<ReturnType<typeof localDaemonStatus>>): Promise<boolean>;
@@ -1,14 +1,14 @@
1
- import { execFileSync, spawn } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
2
3
  import fs from "node:fs/promises";
3
4
  import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
5
+ import net from "node:net";
6
+ import os from "node:os";
4
7
  import path from "node:path";
5
8
  import { configDir, daemonPidPath } from "./paths.js";
6
9
  function daemonCliPath() {
7
10
  return path.resolve(import.meta.dirname, "../../../../daemon/dist/cli.js");
8
11
  }
9
- function daemonSourceCliPath() {
10
- return path.resolve(import.meta.dirname, "../../../../daemon/src/cli.ts");
11
- }
12
12
  function isPidAlive(pid) {
13
13
  if (!Number.isInteger(pid) || pid <= 0)
14
14
  return false;
@@ -20,58 +20,59 @@ function isPidAlive(pid) {
20
20
  return false;
21
21
  }
22
22
  }
23
- function readProcessCommand(pid) {
24
- if (process.platform === "win32")
25
- return readWindowsProcessCommand(pid);
23
+ async function readDaemonIdentity() {
26
24
  try {
27
- return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).trim() || undefined;
28
- }
29
- catch {
30
- return undefined;
31
- }
32
- }
33
- function readWindowsProcessCommand(pid) {
34
- try {
35
- const script = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { [Console]::Out.Write($p.CommandLine) }`;
36
- return execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
37
- encoding: "utf8",
38
- windowsHide: true,
39
- }).trim() || undefined;
25
+ const value = JSON.parse(await fs.readFile(path.join(configDir, "daemon.identity.json"), "utf8"));
26
+ if (!Number.isSafeInteger(value.pid) || !value.pid || value.pid <= 0 || typeof value.stateRoot !== "string" || typeof value.machineId !== "string" || typeof value.nonce !== "string" || typeof value.startedAt !== "string")
27
+ return undefined;
28
+ return value;
40
29
  }
41
30
  catch {
42
31
  return undefined;
43
32
  }
44
33
  }
45
- function readProcessStartedAtMs(pid) {
46
- try {
47
- if (process.platform === "win32") {
48
- const script = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { [Console]::Out.Write($p.CreationDate.ToUniversalTime().ToString("o")) }`;
49
- const text = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
50
- encoding: "utf8",
51
- windowsHide: true,
52
- }).trim();
53
- const startedAt = Date.parse(text);
54
- return Number.isFinite(startedAt) ? startedAt : undefined;
55
- }
56
- const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" }).trim();
57
- const startedAt = Date.parse(text);
58
- return Number.isFinite(startedAt) ? startedAt : undefined;
59
- }
60
- catch {
61
- return undefined;
62
- }
34
+ function controlPath(machineId) {
35
+ if (process.platform === "win32")
36
+ return `\\\\.\\pipe\\happy-elves-daemon-${machineId}`;
37
+ const preferred = path.join(configDir, "daemon-control.sock");
38
+ if (preferred.length < 96)
39
+ return preferred;
40
+ const digest = crypto.createHash("sha256").update(path.resolve(configDir)).digest("hex").slice(0, 24);
41
+ return path.join(os.tmpdir(), `happy-elves-daemon-${digest}.sock`);
63
42
  }
64
- function isManagedDaemonPid(pid) {
65
- const command = readProcessCommand(pid);
66
- const scriptPaths = [daemonCliPath(), daemonSourceCliPath()];
67
- const repoSuffixes = [
68
- path.join("apps", "daemon", "dist", "cli.js"),
69
- path.join("apps", "daemon", "src", "cli.ts"),
70
- ];
71
- return (command !== undefined &&
72
- (scriptPaths.some((scriptPath) => command.includes(scriptPath) || command.includes(path.relative(process.cwd(), scriptPath))) ||
73
- repoSuffixes.some((repoSuffix) => command.includes(repoSuffix))) &&
74
- /\bstart\b/.test(command));
43
+ async function controlRequest(machineId, request) {
44
+ return await new Promise((resolve, reject) => {
45
+ const socket = net.createConnection(controlPath(machineId));
46
+ let buffer = "";
47
+ let settled = false;
48
+ const finish = (error, value) => {
49
+ if (settled)
50
+ return;
51
+ settled = true;
52
+ clearTimeout(timer);
53
+ socket.destroy();
54
+ if (error)
55
+ reject(error);
56
+ else
57
+ resolve(value ?? {});
58
+ };
59
+ const timer = setTimeout(() => finish(new Error("Timed out waiting for daemon control")), 2_000);
60
+ socket.setEncoding("utf8");
61
+ socket.on("data", (chunk) => {
62
+ buffer += chunk;
63
+ const index = buffer.indexOf("\n");
64
+ if (index < 0)
65
+ return;
66
+ try {
67
+ finish(undefined, JSON.parse(buffer.slice(0, index)));
68
+ }
69
+ catch (error) {
70
+ finish(error instanceof Error ? error : new Error(String(error)));
71
+ }
72
+ });
73
+ socket.once("error", (error) => finish(error));
74
+ socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`));
75
+ });
75
76
  }
76
77
  async function readDaemonPid() {
77
78
  try {
@@ -87,12 +88,20 @@ async function readDaemonPid() {
87
88
  }
88
89
  export async function localDaemonStatus() {
89
90
  const pid = await readDaemonPid();
90
- return { pidPath: daemonPidPath, pid, running: pid !== undefined && isPidAlive(pid) && isManagedDaemonPid(pid) };
91
+ if (!pid || !isPidAlive(pid))
92
+ return { pidPath: daemonPidPath, pid, running: false };
93
+ const identity = await readDaemonIdentity();
94
+ const handshake = identity?.pid === pid && path.resolve(identity.stateRoot) === path.resolve(configDir)
95
+ ? await controlRequest(identity.machineId, { type: "identity" }).catch(() => undefined)
96
+ : undefined;
97
+ const verified = handshake?.ok === true && handshake.pid === pid && handshake.nonce === identity?.nonce && handshake.machineId === identity?.machineId && handshake.stateRoot === path.resolve(configDir);
98
+ return { pidPath: daemonPidPath, pid, running: true, identityVerified: verified };
91
99
  }
92
100
  export async function daemonBinaryUpdatedAfterStart(status) {
93
101
  if (!status.running || !status.pid)
94
102
  return false;
95
- const startedAt = readProcessStartedAtMs(status.pid);
103
+ const identity = await readDaemonIdentity();
104
+ const startedAt = status.identityVerified && identity?.pid === status.pid ? Date.parse(identity.startedAt) : undefined;
96
105
  if (!startedAt)
97
106
  return false;
98
107
  try {
@@ -106,7 +115,8 @@ export async function daemonBinaryUpdatedAfterStart(status) {
106
115
  export async function daemonConfigUpdatedAfterStart(status) {
107
116
  if (!status.running || !status.pid)
108
117
  return false;
109
- const startedAt = readProcessStartedAtMs(status.pid);
118
+ const identity = await readDaemonIdentity();
119
+ const startedAt = status.identityVerified && identity?.pid === status.pid ? Date.parse(identity.startedAt) : undefined;
110
120
  if (!startedAt)
111
121
  return false;
112
122
  try {
@@ -143,13 +153,20 @@ export function spawnDaemonStart(args, cwd = process.cwd()) {
143
153
  closeSync(logFd);
144
154
  }
145
155
  }
146
- export async function stopLocalDaemon(timeoutMs = 5000) {
156
+ export async function stopLocalDaemon(timeoutMs = 15_000) {
147
157
  const status = await localDaemonStatus();
148
158
  if (!status.pid || !status.running) {
149
159
  await fs.rm(daemonPidPath, { force: true });
150
160
  return { ...status, stopped: false, running: false };
151
161
  }
152
- process.kill(status.pid, "SIGTERM");
162
+ if (status.identityVerified !== true)
163
+ throw Object.assign(new Error("Daemon identity cannot be verified; refusing to stop it"), { code: "DAEMON_IDENTITY_UNVERIFIED" });
164
+ const identity = await readDaemonIdentity();
165
+ if (!identity || identity.pid !== status.pid)
166
+ throw Object.assign(new Error("Daemon identity changed before shutdown"), { code: "DAEMON_IDENTITY_UNVERIFIED" });
167
+ const shutdown = await controlRequest(identity.machineId, { type: "shutdown", operationId: crypto.randomUUID(), expectedPid: identity.pid, expectedNonce: identity.nonce });
168
+ if (shutdown.ok !== true)
169
+ throw Object.assign(new Error("Daemon refused shutdown"), { code: String(shutdown.code ?? "DAEMON_SHUTDOWN_FAILED") });
153
170
  const deadline = Date.now() + timeoutMs;
154
171
  while (Date.now() < deadline) {
155
172
  if (!isPidAlive(status.pid)) {
@@ -158,6 +175,6 @@ export async function stopLocalDaemon(timeoutMs = 5000) {
158
175
  }
159
176
  await new Promise((resolve) => setTimeout(resolve, 100));
160
177
  }
161
- return { ...status, stopped: false, running: isPidAlive(status.pid) };
178
+ throw Object.assign(new Error(`Daemon did not stop within ${timeoutMs}ms`), { code: "DAEMON_STOP_TIMEOUT" });
162
179
  }
163
180
  //# sourceMappingURL=local-daemon.js.map
@@ -179,6 +179,8 @@ export async function handleMachine({ domain, action, positional, flags }) {
179
179
  if (updateAction === "status") {
180
180
  const operationId = typeof flags.operation === "string" ? flags.operation : undefined;
181
181
  const result = await client.machineUpdateStatus(machineId, operationId);
182
+ if (result.errorCode)
183
+ process.exitCode = 1;
182
184
  if (!wantsJson(flags)) {
183
185
  console.log(`${machineId}: ${result.phase}${result.operationId ? ` ${result.operationId}` : ""}`);
184
186
  if (result.currentVersion || result.daemonVersion)
@@ -187,7 +189,7 @@ export async function handleMachine({ domain, action, positional, flags }) {
187
189
  console.log(` ${result.errorCode ?? "error"}: ${result.error}`);
188
190
  return true;
189
191
  }
190
- ok("machine.update.status", { machineId, result }, { machineId, ...(result.operationId ? { operationId: result.operationId } : {}) });
192
+ ok("machine.update.status", { machineId, result }, { machineId, ...(result.operationId ? { operationId: result.operationId } : {}) }, !result.errorCode);
191
193
  return true;
192
194
  }
193
195
  if (updateAction === "apply") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.632",
3
+ "version": "0.1.0-beta.633",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -632,7 +632,7 @@ class DirectState {
632
632
  }
633
633
  const sessionRoutes = [...sessions.keys()].map((sessionId) => ({ sessionId, nodeId: this.config.machineId }));
634
634
  const retainedPackets = [manifestPacket];
635
- if (snapshotMode === "full")
635
+ if (snapshotMode !== "routing")
636
636
  for (const session of sessions.values()) {
637
637
  retainedPackets.push(await this.retainedPacket(`node:${this.config.machineId}:session:${session.sessionId}`, {
638
638
  type: "machine:sessionReady",
@@ -647,7 +647,7 @@ class DirectState {
647
647
  cursor: this.packetSeq,
648
648
  presence: [this.presence()],
649
649
  retainedPackets,
650
- ...(sessionRoutes.length > 0 ? { sessionRoutes } : {}),
650
+ sessionRoutes,
651
651
  };
652
652
  }
653
653
  presence() {
@@ -655,6 +655,7 @@ class DirectState {
655
655
  nodeId: this.config.machineId,
656
656
  nodeKind: "machine",
657
657
  online: true,
658
+ daemonOnline: true,
658
659
  lastSeen: Date.now(),
659
660
  };
660
661
  }
@@ -1,4 +1,4 @@
1
1
  export type LocalControlServer = {
2
2
  close: () => Promise<void>;
3
3
  };
4
- export declare function startLocalControl(machineId: string): Promise<LocalControlServer>;
4
+ export declare function startLocalControl(machineId: string, requestShutdown: () => void): Promise<LocalControlServer>;
@@ -1,28 +1,14 @@
1
- import crypto from "node:crypto";
2
1
  import fs from "node:fs/promises";
3
2
  import net from "node:net";
4
- import os from "node:os";
5
3
  import path from "node:path";
6
- import { configDir } from "./paths.js";
4
+ import { configDir, daemonControlPath } from "./paths.js";
7
5
  import { readDaemonIdentity } from "./process.js";
8
6
  import { beginUpdateBarrier, clearUpdateBarrier, markUpdateBarrierPending } from "./update-barrier.js";
9
7
  import { pendingOptionsForSession } from "./state.js";
10
8
  import { sessionTurns } from "./state.js";
11
9
  import { sessions } from "./state.js";
12
- function localControlPath(machineId) {
13
- return process.platform === "win32"
14
- ? `\\\\.\\pipe\\happy-elves-daemon-${machineId}`
15
- : unixControlPath(configDir);
16
- }
17
- function unixControlPath(root) {
18
- const preferred = path.join(root, "daemon-control.sock");
19
- if (preferred.length < 96)
20
- return preferred;
21
- const digest = crypto.createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 24);
22
- return path.join(os.tmpdir(), `happy-elves-daemon-${digest}.sock`);
23
- }
24
- export async function startLocalControl(machineId) {
25
- const socketPath = localControlPath(machineId);
10
+ export async function startLocalControl(machineId, requestShutdown) {
11
+ const socketPath = daemonControlPath(machineId);
26
12
  if (process.platform !== "win32")
27
13
  await fs.rm(socketPath, { force: true }).catch(() => undefined);
28
14
  const server = net.createServer((socket) => {
@@ -35,7 +21,7 @@ export async function startLocalControl(machineId) {
35
21
  const line = buffer.slice(0, index);
36
22
  buffer = buffer.slice(index + 1);
37
23
  index = buffer.indexOf("\n");
38
- void handleControlRequest(line, socket);
24
+ void handleControlRequest(line, socket, requestShutdown);
39
25
  }
40
26
  });
41
27
  });
@@ -53,7 +39,7 @@ export async function startLocalControl(machineId) {
53
39
  },
54
40
  };
55
41
  }
56
- async function handleControlRequest(raw, socket) {
42
+ async function handleControlRequest(raw, socket, requestShutdown) {
57
43
  try {
58
44
  const request = JSON.parse(raw);
59
45
  if (request.type === "identity") {
@@ -94,8 +80,16 @@ async function handleControlRequest(raw, socket) {
94
80
  return;
95
81
  }
96
82
  if (request.type === "shutdown") {
83
+ if (request.expectedPid !== undefined || request.expectedNonce !== undefined) {
84
+ const identity = await readDaemonIdentity();
85
+ if (!identity || identity.pid !== request.expectedPid || identity.nonce !== request.expectedNonce || identity.stateRoot !== path.resolve(configDir)) {
86
+ write(socket, { ok: false, code: "DAEMON_IDENTITY_UNVERIFIED" });
87
+ return;
88
+ }
89
+ }
97
90
  write(socket, { ok: true });
98
- setTimeout(() => process.kill(process.pid, "SIGTERM"), 25).unref?.();
91
+ socket.once("finish", requestShutdown);
92
+ socket.end();
99
93
  return;
100
94
  }
101
95
  write(socket, { ok: false, code: "INVALID_CONTROL_COMMAND" });