@cydm/happy-elves 0.1.0-beta.496 → 0.1.0-beta.498

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 (64) hide show
  1. package/README.md +2 -10
  2. package/apps/cli/dist/commands/lib/args.js +5 -0
  3. package/apps/cli/dist/commands/lib/usage.js +12 -1
  4. package/apps/cli/dist/commands/machine.js +122 -0
  5. package/apps/cli/package.json +1 -1
  6. package/apps/daemon/dist/direct/server.js +15 -0
  7. package/apps/daemon/dist/local-control.d.ts +4 -0
  8. package/apps/daemon/dist/local-control.js +111 -0
  9. package/apps/daemon/dist/paths.d.ts +1 -0
  10. package/apps/daemon/dist/paths.js +1 -0
  11. package/apps/daemon/dist/process.d.ts +11 -1
  12. package/apps/daemon/dist/process.js +55 -9
  13. package/apps/daemon/dist/relay/connection.js +53 -2
  14. package/apps/daemon/dist/relay/remote-command.js +5 -0
  15. package/apps/daemon/dist/start.js +4 -1
  16. package/apps/daemon/dist/turn-coordinator.d.ts +2 -0
  17. package/apps/daemon/dist/turn-coordinator.js +6 -0
  18. package/apps/daemon/dist/update-barrier.d.ts +26 -0
  19. package/apps/daemon/dist/update-barrier.js +89 -0
  20. package/apps/daemon/package.json +1 -1
  21. package/apps/relay/dist/relay-context.d.ts +1 -1
  22. package/apps/relay/dist/types.d.ts +1 -0
  23. package/apps/relay/dist/websocket.js +45 -9
  24. package/apps/updater/dist/autostart.d.ts +15 -0
  25. package/apps/updater/dist/autostart.js +62 -0
  26. package/apps/updater/dist/cli.d.ts +2 -0
  27. package/apps/updater/dist/cli.js +111 -0
  28. package/apps/updater/dist/control.d.ts +5 -0
  29. package/apps/updater/dist/control.js +49 -0
  30. package/apps/updater/dist/daemon-config.d.ts +19 -0
  31. package/apps/updater/dist/daemon-config.js +28 -0
  32. package/apps/updater/dist/daemon.d.ts +20 -0
  33. package/apps/updater/dist/daemon.js +166 -0
  34. package/apps/updater/dist/host-jobs.d.ts +16 -0
  35. package/apps/updater/dist/host-jobs.js +494 -0
  36. package/apps/updater/dist/launcher.d.ts +9 -0
  37. package/apps/updater/dist/launcher.js +64 -0
  38. package/apps/updater/dist/node-runtime.d.ts +5 -0
  39. package/apps/updater/dist/node-runtime.js +40 -0
  40. package/apps/updater/dist/paths.d.ts +31 -0
  41. package/apps/updater/dist/paths.js +51 -0
  42. package/apps/updater/dist/relay.d.ts +15 -0
  43. package/apps/updater/dist/relay.js +333 -0
  44. package/apps/updater/dist/release.d.ts +30 -0
  45. package/apps/updater/dist/release.js +143 -0
  46. package/apps/updater/dist/run.d.ts +4 -0
  47. package/apps/updater/dist/run.js +396 -0
  48. package/apps/updater/dist/slots.d.ts +15 -0
  49. package/apps/updater/dist/slots.js +48 -0
  50. package/apps/updater/dist/state.d.ts +29 -0
  51. package/apps/updater/dist/state.js +122 -0
  52. package/apps/updater/dist/supervisor.d.ts +31 -0
  53. package/apps/updater/dist/supervisor.js +370 -0
  54. package/package.json +5 -2
  55. package/packages/client/dist/client.d.ts +25 -0
  56. package/packages/client/dist/client.js +96 -0
  57. package/packages/client/dist/index.d.ts +2 -1
  58. package/packages/client/dist/types.d.ts +3 -0
  59. package/packages/client/dist/update.d.ts +6 -0
  60. package/packages/client/dist/update.js +126 -0
  61. package/packages/shared/dist/packet-relay.d.ts +2 -0
  62. package/packages/shared/dist/packet-relay.js +1 -0
  63. package/packages/update-protocol/dist/index.d.ts +311 -0
  64. package/packages/update-protocol/dist/index.js +124 -0
package/README.md CHANGED
@@ -2,14 +2,6 @@
2
2
 
3
3
  Public beta CLI for Happy Elves.
4
4
 
5
- ## Hosted relay
5
+ First-time hosted attach (Windows):
6
6
 
7
- ```bash
8
- happy-elves account create --relay https://relay.happyelves.ai --device-name "CLI" --json
9
- ```
10
-
11
- ## Self-host relay
12
-
13
- ```bash
14
- happy-elves relay serve --host 0.0.0.0 --port 8787 --json
15
- ```
7
+ npm.cmd --yes --package @cydm/happy-elves@0.1.0-beta.498 happy-elves-updater install --autostart
@@ -33,6 +33,11 @@ const valueFlags = new Set([
33
33
  "limit",
34
34
  "machine",
35
35
  "machine-id",
36
+ "operation-id",
37
+ "limit-bytes",
38
+ "max-wait-ms",
39
+ "manifest-digest",
40
+ "version",
36
41
  "native-identity",
37
42
  "memory",
38
43
  "memory-mode",
@@ -35,6 +35,14 @@ Check local controller config, relay reachability, and daemon state.
35
35
  machine: `Usage:
36
36
  happy-elves machine list [--verbose] [--json]
37
37
  happy-elves machine status <machineId> [--verbose] [--json]
38
+ happy-elves machine update check <machineId> [--json]
39
+ happy-elves machine update status <machineId> [--operation <operationId>] [--json]
40
+ happy-elves machine update apply <machineId> --version <version> [--reject] [--max-wait-ms <ms>] [--json]
41
+ happy-elves machine daemon <status|start|stop|restart|diagnose|logs> <machineId> [--limit-bytes <n>] [--json]
42
+ happy-elves machine host exec <machineId> <command> [args...] --operation-id <id> [--cwd <path>] [--timeout-ms <ms>] [--json]
43
+ happy-elves machine host exec <machineId> --shell <command> --operation-id <id> [--cwd <path>] [--timeout-ms <ms>] [--json]
44
+ happy-elves machine host status <machineId> --operation-id <id> [--json]
45
+ happy-elves machine host cancel <machineId> <jobId> [--json]
38
46
  happy-elves machine diagnose <machineId> [--json]
39
47
  happy-elves machine directory <machineId> [--path <path>] [--json]
40
48
  happy-elves machine exec <machineId> --shell <command> [--cwd <path>] [--timeout-ms <ms>] [--wait-timeout-ms <ms>] [--json]
@@ -42,7 +50,7 @@ Check local controller config, relay reachability, and daemon state.
42
50
  happy-elves machine file info <machineId> --root <cwd> --path <path> [--json]
43
51
  happy-elves machine file preview <machineId> --root <cwd> --path <path> [--mode auto|text|image|html-source|html-sandbox] [--output <file>] [--json]
44
52
 
45
- diagnose/exec are LAN/dev-only. exec requires the daemon to advertise devTools.
53
+ diagnose/exec are LAN/dev-only. host is the durable updater-managed host command path.
46
54
  --timeout-ms limits the remote process. --wait-timeout-ms limits how long this CLI waits for the result.
47
55
  file preview is read-only and limited to files inside --root.
48
56
  `,
@@ -266,6 +274,9 @@ Happy Elves is a remote controller and orchestration layer for coding agents acr
266
274
  Core commands:
267
275
  machine list [--json]
268
276
  machine status <machineId> [--verbose] [--json]
277
+ machine update check <machineId> [--json]
278
+ machine update status <machineId> [--operation <operationId>] [--json]
279
+ machine update apply <machineId> --version <version> [--reject] [--json]
269
280
  machine directory <machineId> [--path <path>] [--json]
270
281
 
271
282
  session list [--machine <machineId>] [--cwd <path>] [--limit 20] [--json]
@@ -162,6 +162,57 @@ export async function handleMachine({ domain, action, positional, flags }) {
162
162
  return false;
163
163
  const config = await readConfig(flags);
164
164
  const client = new ControllerClient(config);
165
+ if (action === "update") {
166
+ const updateAction = positional[0];
167
+ const machineId = positional[1] ?? (typeof flags.machine === "string" ? flags.machine : undefined);
168
+ if (!machineId)
169
+ throw new CliError("Usage: happy-elves machine update <check|status|apply> <machineId> [--version <version>]", "USAGE");
170
+ if (updateAction === "check") {
171
+ const result = await client.machineUpdateCheck(machineId);
172
+ if (!wantsJson(flags)) {
173
+ console.log(`${machineId}: ${result.phase}${result.currentVersion ? ` (${result.currentVersion})` : ""}`);
174
+ return true;
175
+ }
176
+ ok("machine.update.check", { machineId, result }, { machineId });
177
+ return true;
178
+ }
179
+ if (updateAction === "status") {
180
+ const operationId = typeof flags.operation === "string" ? flags.operation : undefined;
181
+ const result = await client.machineUpdateStatus(machineId, operationId);
182
+ if (!wantsJson(flags)) {
183
+ console.log(`${machineId}: ${result.phase}${result.operationId ? ` ${result.operationId}` : ""}`);
184
+ if (result.error)
185
+ console.log(` ${result.errorCode ?? "error"}: ${result.error}`);
186
+ return true;
187
+ }
188
+ ok("machine.update.status", { machineId, result }, { machineId, ...(result.operationId ? { operationId: result.operationId } : {}) });
189
+ return true;
190
+ }
191
+ if (updateAction === "apply") {
192
+ const version = typeof flags.version === "string" ? flags.version : positional[2];
193
+ if (!version)
194
+ throw new CliError("Missing --version <version>", "INVALID_ARGUMENT");
195
+ const result = await client.machineUpdateApply({
196
+ machineId,
197
+ targetVersion: version,
198
+ operationId: typeof flags["operation-id"] === "string" ? flags["operation-id"] : undefined,
199
+ busyPolicy: flags.reject === true ? "reject" : "wait",
200
+ maxWaitMs: typeof flags["max-wait-ms"] === "string" ? Number(flags["max-wait-ms"]) : undefined,
201
+ });
202
+ const success = result.phase === "succeeded" || result.phase === "recovered";
203
+ if (!success)
204
+ process.exitCode = 1;
205
+ if (!wantsJson(flags)) {
206
+ console.log(`${machineId}: ${result.phase}${result.operationId ? ` ${result.operationId}` : ""}`);
207
+ if (result.error)
208
+ console.log(` ${result.errorCode ?? "error"}: ${result.error}`);
209
+ return true;
210
+ }
211
+ ok("machine.update.apply", { machineId, result }, { machineId, ...(result.operationId ? { operationId: result.operationId } : {}) }, success);
212
+ return true;
213
+ }
214
+ throw new CliError("Usage: happy-elves machine update <check|status|apply> <machineId>", "USAGE");
215
+ }
165
216
  if (action === "directory" || action === "dir" || action === "ls") {
166
217
  const machineId = requirePositional(positional[0], "machineId");
167
218
  const listing = await client.listDirectory({
@@ -269,6 +320,77 @@ export async function handleMachine({ domain, action, positional, flags }) {
269
320
  ok("machine.diagnose", { machineId, diagnostics }, { machineId });
270
321
  return true;
271
322
  }
323
+ if (action === "daemon") {
324
+ const daemonAction = positional[0];
325
+ const machineId = requirePositional(positional[1], "machineId");
326
+ if (!daemonAction || !["status", "start", "stop", "restart", "diagnose", "logs"].includes(daemonAction))
327
+ throw new CliError("Usage: happy-elves machine daemon <status|start|stop|restart|diagnose|logs> <machineId>", "USAGE");
328
+ const result = await client.daemonManage(machineId, daemonAction, typeof flags["limit-bytes"] === "string" ? Number(flags["limit-bytes"]) : undefined);
329
+ if (!wantsJson(flags)) {
330
+ console.log(`${machineId}: ${result.running ? "running" : "stopped"}${result.pid ? ` pid ${result.pid}` : ""}${result.version ? ` ${result.version}` : ""}`);
331
+ if (result.error)
332
+ console.log(`${result.errorCode ?? "error"}: ${result.error}`);
333
+ if (result.output)
334
+ console.log(result.output);
335
+ return true;
336
+ }
337
+ const success = !result.errorCode;
338
+ if (!success)
339
+ process.exitCode = 1;
340
+ ok(`machine.daemon.${daemonAction}`, { machineId, result }, { machineId }, success);
341
+ return true;
342
+ }
343
+ if (action === "host") {
344
+ const hostAction = positional[0];
345
+ const machineId = requirePositional(positional[1], "machineId");
346
+ if (hostAction === "exec") {
347
+ const operationId = typeof flags["operation-id"] === "string" ? flags["operation-id"] : "";
348
+ if (!operationId)
349
+ throw new CliError("Missing --operation-id <id>", "INVALID_ARGUMENT");
350
+ const shell = typeof flags.shell === "string" ? flags.shell : undefined;
351
+ const command = shell ?? requirePositional(positional[2], "command");
352
+ const args = shell ? [] : positional.slice(3);
353
+ const timeoutMs = typeof flags["timeout-ms"] === "string" ? Number(flags["timeout-ms"]) : undefined;
354
+ if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs <= 0))
355
+ throw new CliError(`Invalid --timeout-ms: ${flags["timeout-ms"]}`, "INVALID_ARGUMENT");
356
+ const job = await client.hostExec({
357
+ machineId, operationId, command, args,
358
+ ...(typeof flags.cwd === "string" ? { cwd: flags.cwd } : {}),
359
+ ...(shell ? { shell } : {}),
360
+ ...(timeoutMs ? { timeoutMs } : {}),
361
+ });
362
+ if (!wantsJson(flags)) {
363
+ console.log(`${job.jobId} ${job.status} ${job.operationId}`);
364
+ if (job.stdout)
365
+ console.log(job.stdout);
366
+ if (job.stderr)
367
+ console.error(job.stderr);
368
+ return true;
369
+ }
370
+ ok("machine.host.exec", { machineId, job }, { machineId });
371
+ return true;
372
+ }
373
+ if (hostAction === "status" || hostAction === "cancel") {
374
+ const jobId = positional[2];
375
+ const operationId = typeof flags["operation-id"] === "string" ? flags["operation-id"] : undefined;
376
+ if (hostAction === "status" && !operationId)
377
+ throw new CliError("Missing --operation-id <id>", "INVALID_ARGUMENT");
378
+ if (hostAction === "cancel" && !jobId)
379
+ throw new CliError("Missing jobId", "INVALID_ARGUMENT");
380
+ const job = hostAction === "status" ? await client.hostOperationStatus(machineId, operationId) : await client.hostJobCancel(machineId, jobId);
381
+ if (!wantsJson(flags)) {
382
+ console.log(`${job.jobId} ${job.status}`);
383
+ if (job.stdout)
384
+ console.log(job.stdout);
385
+ if (job.stderr)
386
+ console.error(job.stderr);
387
+ return true;
388
+ }
389
+ ok(`machine.host.${hostAction}`, { machineId, job }, { machineId });
390
+ return true;
391
+ }
392
+ throw new CliError("Usage: happy-elves machine host <exec|status|cancel> <machineId> ...", "USAGE");
393
+ }
272
394
  if (action === "exec" || action === "dev-exec") {
273
395
  const machineId = requirePositional(positional[0], "machineId");
274
396
  const shell = typeof flags.shell === "string" ? flags.shell : undefined;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.496",
3
+ "version": "0.1.0-beta.498",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -13,6 +13,7 @@ import { handleInboundPromptCommand } from "../relay/inbound-prompt.js";
13
13
  import { markDirectSocket, sendMachineMessageToRelay } from "../relay/send.js";
14
14
  import { setCurrentDirectTransportMetadata } from "./state.js";
15
15
  import { DirectGrantVerifier, DirectTicketStore, directAuthError, directTicketConfigFromEnv } from "./tickets.js";
16
+ import { isUpdateAdmissionCommand, updateAdmissionBlocked, updateAdmissionError } from "../update-barrier.js";
16
17
  const DIRECT_PROTOCOL = "he-direct-v1";
17
18
  const DEFAULT_DIRECT_PORT = 8790;
18
19
  const MAX_FRAME_BYTES = 8 * 1024 * 1024;
@@ -282,6 +283,20 @@ class DirectState {
282
283
  this.ackStoredPacket(connection, message.packet);
283
284
  return;
284
285
  }
286
+ if (isUpdateAdmissionCommand(command.type)) {
287
+ const barrier = await updateAdmissionBlocked();
288
+ if (barrier) {
289
+ await this.sendMachinePacket({
290
+ type: "machine:error",
291
+ requestId: command.requestId,
292
+ sessionId: sessionIdFromMachineCommand(command),
293
+ code: updateAdmissionError(barrier).code,
294
+ message: updateAdmissionError(barrier).message,
295
+ }, connection);
296
+ this.ackStoredPacket(connection, message.packet);
297
+ return;
298
+ }
299
+ }
285
300
  if (command.type === "machine:prompt") {
286
301
  await handleInboundPromptCommand({
287
302
  ws: this.handlerSocket(connection),
@@ -0,0 +1,4 @@
1
+ export type LocalControlServer = {
2
+ close: () => Promise<void>;
3
+ };
4
+ export declare function startLocalControl(machineId: string): Promise<LocalControlServer>;
@@ -0,0 +1,111 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import net from "node:net";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { configDir } from "./paths.js";
7
+ import { readDaemonIdentity } from "./process.js";
8
+ import { beginUpdateBarrier, clearUpdateBarrier, markUpdateBarrierPending } from "./update-barrier.js";
9
+ import { pendingOptionsForSession } from "./state.js";
10
+ import { sessionTurns } from "./state.js";
11
+ 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);
26
+ if (process.platform !== "win32")
27
+ await fs.rm(socketPath, { force: true }).catch(() => undefined);
28
+ const server = net.createServer((socket) => {
29
+ let buffer = "";
30
+ socket.setEncoding("utf8");
31
+ socket.on("data", (chunk) => {
32
+ buffer += chunk;
33
+ let index = buffer.indexOf("\n");
34
+ while (index >= 0) {
35
+ const line = buffer.slice(0, index);
36
+ buffer = buffer.slice(index + 1);
37
+ index = buffer.indexOf("\n");
38
+ void handleControlRequest(line, socket);
39
+ }
40
+ });
41
+ });
42
+ await new Promise((resolve, reject) => {
43
+ server.once("error", reject);
44
+ server.listen(socketPath, () => resolve());
45
+ });
46
+ if (process.platform !== "win32")
47
+ await fs.chmod(socketPath, 0o600).catch(() => undefined);
48
+ return {
49
+ close: async () => {
50
+ await new Promise((resolve) => server.close(() => resolve()));
51
+ if (process.platform !== "win32")
52
+ await fs.rm(socketPath, { force: true }).catch(() => undefined);
53
+ },
54
+ };
55
+ }
56
+ async function handleControlRequest(raw, socket) {
57
+ try {
58
+ const request = JSON.parse(raw);
59
+ if (request.type === "identity") {
60
+ const identity = await readDaemonIdentity();
61
+ if (!identity || identity.pid !== process.pid || identity.stateRoot !== path.resolve(configDir)) {
62
+ write(socket, { ok: false, code: "DAEMON_IDENTITY_UNAVAILABLE" });
63
+ return;
64
+ }
65
+ write(socket, { ok: true, pid: process.pid, stateRoot: identity.stateRoot, machineId: identity.machineId, nonce: identity.nonce, ...(identity.generation ? { generation: identity.generation } : {}), startedAt: identity.startedAt });
66
+ return;
67
+ }
68
+ if (request.type === "status") {
69
+ const activeTurns = sessionTurns.activeCount();
70
+ const pendingOptions = [...sessions.values()].reduce((count, session) => count + (sessionTurns.activeTurnId(session.sessionId) ? pendingOptionsForSession(session.sessionId, sessionTurns.activeTurnId(session.sessionId)).length : 0), 0);
71
+ write(socket, { ok: true, activeTurns, pendingOptions, busy: activeTurns > 0 || pendingOptions > 0 });
72
+ return;
73
+ }
74
+ if (request.type === "begin-update") {
75
+ const releasePending = markUpdateBarrierPending({ operationId: request.operationId, generation: request.generation, leaseMs: request.leaseMs ?? 120_000 });
76
+ try {
77
+ const activeTurns = sessionTurns.activeCount();
78
+ const pendingOptions = [...sessions.values()].reduce((count, session) => count + (sessionTurns.activeTurnId(session.sessionId) ? pendingOptionsForSession(session.sessionId, sessionTurns.activeTurnId(session.sessionId)).length : 0), 0);
79
+ if (activeTurns > 0 || pendingOptions > 0) {
80
+ write(socket, { ok: false, code: "DAEMON_BUSY", activeTurns, pendingOptions });
81
+ return;
82
+ }
83
+ const barrier = await beginUpdateBarrier({ operationId: request.operationId, generation: request.generation, leaseMs: request.leaseMs ?? 120_000 });
84
+ write(socket, { ok: true, barrier });
85
+ }
86
+ finally {
87
+ releasePending();
88
+ }
89
+ return;
90
+ }
91
+ if (request.type === "end-update") {
92
+ await clearUpdateBarrier(request.operationId);
93
+ write(socket, { ok: true });
94
+ return;
95
+ }
96
+ if (request.type === "shutdown") {
97
+ write(socket, { ok: true });
98
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 25).unref?.();
99
+ return;
100
+ }
101
+ write(socket, { ok: false, code: "INVALID_CONTROL_COMMAND" });
102
+ }
103
+ catch (error) {
104
+ write(socket, { ok: false, code: "INVALID_CONTROL_COMMAND", error: error instanceof Error ? error.message : String(error) });
105
+ }
106
+ }
107
+ function write(socket, value) {
108
+ if (!socket.destroyed)
109
+ socket.write(`${JSON.stringify(value)}\n`);
110
+ }
111
+ //# sourceMappingURL=local-control.js.map
@@ -13,6 +13,7 @@ export declare const orchestrationStorePath: string;
13
13
  export declare const artifactStorePath: string;
14
14
  export declare const memoryRootDir: string;
15
15
  export declare const pidPath: string;
16
+ export declare const identityPath: string;
16
17
  export declare const daemonScriptPath: string;
17
18
  export declare const daemonScriptRelativePath: string;
18
19
  export declare const daemonScriptRepoSuffix: string;
@@ -18,6 +18,7 @@ export const orchestrationStorePath = path.join(configDir, "orchestration.json")
18
18
  export const artifactStorePath = path.join(configDir, "artifacts.json");
19
19
  export const memoryRootDir = path.join(configDir, "memory");
20
20
  export const pidPath = path.join(configDir, "daemon.pid");
21
+ export const identityPath = path.join(configDir, "daemon.identity.json");
21
22
  export const daemonScriptPath = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url));
22
23
  export const daemonScriptRelativePath = path.relative(process.cwd(), daemonScriptPath);
23
24
  export const daemonScriptRepoSuffix = path.join("apps", "daemon", "dist", "cli.js");
@@ -1,10 +1,20 @@
1
+ export type DaemonIdentity = {
2
+ pid: number;
3
+ stateRoot: string;
4
+ machineId: string;
5
+ nonce: string;
6
+ generation?: string;
7
+ startedAt: string;
8
+ };
1
9
  export declare function isManagedDaemonCommand(command: string | undefined): boolean;
2
- export declare function writePid(): Promise<void>;
10
+ export declare function readDaemonIdentity(): Promise<DaemonIdentity | undefined>;
11
+ export declare function writePid(machineId: string): Promise<void>;
3
12
  export declare function removePidIfCurrent(): Promise<void>;
4
13
  export declare function localStatus(): Promise<{
5
14
  pidPath: string;
6
15
  pid?: number;
7
16
  running: boolean;
17
+ identityVerified?: boolean;
8
18
  }>;
9
19
  export declare function stopDaemon(timeoutMs?: number): Promise<{
10
20
  pidPath: string;
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import crypto from "node:crypto";
3
4
  import { execFileSync, spawn } from "node:child_process";
4
- import { daemonScriptPath, daemonScriptRelativePath, daemonScriptRepoSuffix, pidPath } from "./paths.js";
5
+ import { configDir, daemonScriptPath, daemonScriptRelativePath, daemonScriptRepoSuffix, identityPath, pidPath } from "./paths.js";
5
6
  import { sleep } from "./utils.js";
6
7
  function isPidAlive(pid) {
7
8
  if (!Number.isInteger(pid) || pid <= 0)
@@ -47,31 +48,74 @@ export function isManagedDaemonCommand(command) {
47
48
  command.includes(path.join("apps", "daemon", "src", "cli.ts"))) &&
48
49
  /\bstart\b/.test(command));
49
50
  }
50
- async function readPid() {
51
+ async function readPidRecord() {
51
52
  try {
52
53
  const raw = await fs.readFile(pidPath, "utf8");
53
54
  const pid = Number(raw.trim());
54
- return Number.isInteger(pid) && pid > 0 ? pid : undefined;
55
+ return Number.isInteger(pid) && pid > 0 ? { pid } : undefined;
55
56
  }
56
57
  catch (error) {
57
58
  if (error.code === "ENOENT")
58
59
  return undefined;
59
- throw error;
60
+ return undefined;
61
+ }
62
+ }
63
+ export async function readDaemonIdentity() {
64
+ try {
65
+ const value = JSON.parse(await fs.readFile(identityPath, "utf8"));
66
+ if (typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0 ||
67
+ typeof value.stateRoot !== "string" || !path.isAbsolute(value.stateRoot) ||
68
+ typeof value.machineId !== "string" || !value.machineId ||
69
+ typeof value.nonce !== "string" || !value.nonce ||
70
+ typeof value.startedAt !== "string" || !value.startedAt)
71
+ return undefined;
72
+ return {
73
+ pid: value.pid,
74
+ stateRoot: path.resolve(value.stateRoot),
75
+ machineId: value.machineId,
76
+ nonce: value.nonce,
77
+ ...(typeof value.generation === "string" && value.generation ? { generation: value.generation } : {}),
78
+ startedAt: value.startedAt,
79
+ };
80
+ }
81
+ catch {
82
+ return undefined;
60
83
  }
61
84
  }
62
- export async function writePid() {
85
+ export async function writePid(machineId) {
63
86
  await fs.mkdir(path.dirname(pidPath), { recursive: true });
87
+ const identity = {
88
+ pid: process.pid,
89
+ stateRoot: path.resolve(configDir),
90
+ machineId,
91
+ nonce: crypto.randomUUID(),
92
+ ...(process.env.HAPPY_ELVES_UPDATER_GENERATION ? { generation: process.env.HAPPY_ELVES_UPDATER_GENERATION } : {}),
93
+ startedAt: new Date().toISOString(),
94
+ };
64
95
  await fs.writeFile(pidPath, `${process.pid}\n`, { mode: 0o600 });
96
+ await fs.chmod(pidPath, 0o600);
97
+ const temporary = `${identityPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
98
+ await fs.writeFile(temporary, `${JSON.stringify(identity)}\n`, { mode: 0o600 });
99
+ await fs.rename(temporary, identityPath);
100
+ await fs.chmod(identityPath, 0o600);
65
101
  }
66
102
  export async function removePidIfCurrent() {
67
- const pid = await readPid();
68
- if (pid === process.pid) {
103
+ const record = await readPidRecord();
104
+ const identity = await readDaemonIdentity();
105
+ if (record?.pid === process.pid && identity?.pid === process.pid && identity.stateRoot === path.resolve(configDir)) {
69
106
  await fs.rm(pidPath, { force: true });
107
+ await fs.rm(identityPath, { force: true });
70
108
  }
71
109
  }
72
110
  export async function localStatus() {
73
- const pid = await readPid();
74
- return { pidPath, pid, running: pid !== undefined && isPidAlive(pid) && isManagedDaemonPid(pid) };
111
+ const record = await readPidRecord();
112
+ const pid = record?.pid;
113
+ const alive = pid !== undefined && isPidAlive(pid) && isManagedDaemonPid(pid);
114
+ if (!alive)
115
+ return { pidPath, ...(pid ? { pid } : {}), running: false };
116
+ const identity = await readDaemonIdentity();
117
+ const verified = identity?.pid === pid && identity.stateRoot === path.resolve(configDir);
118
+ return { pidPath, pid, running: true, identityVerified: verified };
75
119
  }
76
120
  export async function stopDaemon(timeoutMs = 5000) {
77
121
  const status = await localStatus();
@@ -79,6 +123,8 @@ export async function stopDaemon(timeoutMs = 5000) {
79
123
  await fs.rm(pidPath, { force: true });
80
124
  return { ...status, stopped: false, running: false };
81
125
  }
126
+ if (status.identityVerified !== true)
127
+ throw Object.assign(new Error("The daemon PID is alive but its state-root identity cannot be verified; refusing to stop it"), { code: "DAEMON_IDENTITY_UNVERIFIED" });
82
128
  process.kill(status.pid, "SIGTERM");
83
129
  const deadline = Date.now() + timeoutMs;
84
130
  while (Date.now() < deadline) {
@@ -23,9 +23,11 @@ import { loadSessionProjections } from "../session/store.js";
23
23
  import { relayWsUrl } from "../relay-http.js";
24
24
  import { applyRelayMachinePresence, applyRelayMachineSnapshot, reconcileRelayMachinePresence, relayMachineRegistry, sessions, sessionTurns } from "../state.js";
25
25
  import { handleDevExec, handleDiagnose } from "./devtools.js";
26
+ import { isUpdateAdmissionCommand, updateAdmissionBlocked, updateAdmissionError } from "../update-barrier.js";
26
27
  import { handleInboundPromptCommand, recoverInboundPromptAdmissions } from "./inbound-prompt.js";
27
28
  import { registerMachine } from "./register.js";
28
29
  import { clearActiveRelaySocket, configureRelaySender, flushRelayOutbox, handleMachineMessageAck, handleMachineMessageError, handleRetainedPacketAck, handleRetainedPacketError, sendCommandResponse, setActiveRelaySocket, } from "./send.js";
30
+ const daemonInternalChannel = "product:daemon-internal";
29
31
  async function applyMachineManifest(config, retained, presence) {
30
32
  try {
31
33
  const message = parseMachineClientMessage(await decryptJson(config.accountSecret, retained.encryptedPayload));
@@ -297,12 +299,14 @@ async function handleRelayWireMessage(ws, config, message) {
297
299
  if (message.type !== "server:packet")
298
300
  return;
299
301
  let payload;
302
+ let internalPayload = false;
300
303
  try {
301
304
  const decrypted = await decryptJson(config.accountSecret, message.packet.encryptedPayload);
302
305
  try {
303
306
  payload = parseControllerClientMessage(decrypted);
304
307
  }
305
308
  catch {
309
+ internalPayload = true;
306
310
  payload = parseInternalControllerMessage(decrypted);
307
311
  }
308
312
  }
@@ -313,6 +317,17 @@ async function handleRelayWireMessage(ws, config, message) {
313
317
  ackIncomingPacket(ws, message);
314
318
  return;
315
319
  }
320
+ if (internalPayload && message.packet.channelId !== daemonInternalChannel) {
321
+ await sendCommandResponse(ws, {
322
+ type: "machine:error",
323
+ requestId: requestIdFromControllerMessage(payload),
324
+ sessionId: sessionIdFromControllerMessage(payload),
325
+ code: "DAEMON_INTERNAL_CHANNEL_REQUIRED",
326
+ message: "Daemon-internal commands must arrive on the machine-only internal channel.",
327
+ });
328
+ ackIncomingPacket(ws, message);
329
+ return;
330
+ }
316
331
  let command;
317
332
  try {
318
333
  command = await machineCommandFromController(ws, config, payload, message.packet.fromNodeId);
@@ -328,6 +343,20 @@ async function handleRelayWireMessage(ws, config, message) {
328
343
  ackIncomingPacket(ws, message);
329
344
  return;
330
345
  }
346
+ if (command && isUpdateAdmissionCommand(command.type)) {
347
+ const barrier = await updateAdmissionBlocked();
348
+ if (barrier) {
349
+ await sendCommandResponse(ws, {
350
+ type: "machine:error",
351
+ requestId: command.requestId,
352
+ sessionId: sessionIdFromMachineCommand(command),
353
+ code: updateAdmissionError(barrier).code,
354
+ message: updateAdmissionError(barrier).message,
355
+ });
356
+ ackIncomingPacket(ws, message);
357
+ return;
358
+ }
359
+ }
331
360
  if (command?.type === "machine:prompt") {
332
361
  await handleInboundPromptCommand({
333
362
  ws,
@@ -462,10 +491,32 @@ async function machineCommandFromController(ws, config, message, senderNodeId) {
462
491
  return { ...message, type: "machine:dispatch" };
463
492
  if (message.type === "controller:queue")
464
493
  return { ...message, type: "machine:queue" };
465
- if (message.type === "controller:crewQueueEnqueue")
494
+ if (message.type === "controller:crewQueueEnqueue") {
495
+ if (senderNodeId !== message.consumerMachineId) {
496
+ await sendCommandResponse(ws, {
497
+ type: "machine:error",
498
+ requestId: message.requestId,
499
+ code: "INTERNAL_SENDER_MISMATCH",
500
+ capability: "crewQueue.enqueue",
501
+ message: "Crew queue enqueue sender must be the declared consumer machine.",
502
+ });
503
+ return null;
504
+ }
466
505
  return { ...message, type: "machine:crewQueueEnqueue" };
467
- if (message.type === "controller:crewQueueUpdate")
506
+ }
507
+ if (message.type === "controller:crewQueueUpdate") {
508
+ if (senderNodeId !== message.crewMachineId) {
509
+ await sendCommandResponse(ws, {
510
+ type: "machine:error",
511
+ requestId: message.requestId,
512
+ code: "INTERNAL_SENDER_MISMATCH",
513
+ capability: "crewQueue.update",
514
+ message: "Crew queue update sender must be the declared Crew owner machine.",
515
+ });
516
+ return null;
517
+ }
468
518
  return { ...message, type: "machine:crewQueueUpdate" };
519
+ }
469
520
  if (message.type === "controller:artifactReplication") {
470
521
  if (!senderNodeId) {
471
522
  await sendCommandResponse(ws, {
@@ -1,6 +1,7 @@
1
1
  import { decryptJson, encryptJson, parseMachineClientMessage, parseRelayServerWireMessage, randomId, } from "../../../../packages/shared/dist/index.js";
2
2
  import { relayWsUrl } from "../relay-http.js";
3
3
  const controllerProjectionChannel = "product:controllers";
4
+ const daemonInternalChannel = "product:daemon-internal";
4
5
  export class RemoteCommandError extends Error {
5
6
  code;
6
7
  retryable;
@@ -147,6 +148,7 @@ async function remoteCommandPacket(config, packetId, targetMachineId, command) {
147
148
  packet: {
148
149
  packetId,
149
150
  toNodeId: targetMachineId,
151
+ ...(isInternalControllerMessage(command) ? { channelId: daemonInternalChannel } : {}),
150
152
  streamId: "sessionId" in command ? command.sessionId : "machineId" in command ? command.machineId : undefined,
151
153
  correlationId: command.requestId,
152
154
  idempotencyKey: command.requestId,
@@ -156,4 +158,7 @@ async function remoteCommandPacket(config, packetId, targetMachineId, command) {
156
158
  },
157
159
  };
158
160
  }
161
+ function isInternalControllerMessage(command) {
162
+ return command.type === "controller:artifactReplication" || command.type === "controller:crewQueueEnqueue" || command.type === "controller:crewQueueUpdate";
163
+ }
159
164
  //# sourceMappingURL=remote-command.js.map
@@ -11,6 +11,7 @@ import { localStatus, removePidIfCurrent, writePid } from "./process.js";
11
11
  import { configureRuntimeDaemonContext, disposeRuntime, initializeRuntime } from "./runtime.js";
12
12
  import { sleep } from "./utils.js";
13
13
  import { loadSessionProjections } from "./session/store.js";
14
+ import { startLocalControl } from "./local-control.js";
14
15
  export async function start(argv) {
15
16
  const flags = parseDaemonFlags(argv);
16
17
  const config = await readConfig();
@@ -40,7 +41,8 @@ export async function start(argv) {
40
41
  void drainPendingArtifactFeedbackAttentions(config).catch((error) => {
41
42
  console.error(`Artifact feedback attention recovery failed: ${error instanceof Error ? error.message : String(error)}`);
42
43
  });
43
- await writePid();
44
+ await writePid(config.machineId);
45
+ const localControl = await startLocalControl(config.machineId);
44
46
  const cleanup = () => {
45
47
  disposeRuntime();
46
48
  void Promise.resolve()
@@ -48,6 +50,7 @@ export async function start(argv) {
48
50
  .then(() => directTransport?.close())
49
51
  .catch(() => undefined)
50
52
  .then(() => stopGatewayRuntime())
53
+ .then(() => localControl.close())
51
54
  .finally(() => removePidIfCurrent().finally(() => process.exit(0)));
52
55
  };
53
56
  process.once("SIGINT", cleanup);
@@ -25,4 +25,6 @@ export declare class SessionTurnCoordinator<Turn> {
25
25
  activeTurn(sessionId: string): Turn | undefined;
26
26
  activeTurnId(sessionId: string): string | undefined;
27
27
  activeClaim(sessionId: string): SessionTurnClaim | undefined;
28
+ activeCount(): number;
29
+ hasActiveWork(): boolean;
28
30
  }