@cydm/happy-elves 0.1.0-beta.495 → 0.1.0-beta.497

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 (67) hide show
  1. package/apps/cli/dist/commands/lib/args.js +5 -0
  2. package/apps/cli/dist/commands/lib/usage.js +12 -1
  3. package/apps/cli/dist/commands/machine.js +122 -0
  4. package/apps/cli/package.json +1 -1
  5. package/apps/daemon/dist/direct/server.js +16 -0
  6. package/apps/daemon/dist/local-control.d.ts +4 -0
  7. package/apps/daemon/dist/local-control.js +111 -0
  8. package/apps/daemon/dist/orchestration/watcher.d.ts +1 -0
  9. package/apps/daemon/dist/orchestration/watcher.js +67 -7
  10. package/apps/daemon/dist/paths.d.ts +1 -0
  11. package/apps/daemon/dist/paths.js +1 -0
  12. package/apps/daemon/dist/process.d.ts +11 -1
  13. package/apps/daemon/dist/process.js +55 -9
  14. package/apps/daemon/dist/relay/connection.js +53 -2
  15. package/apps/daemon/dist/relay/register.js +1 -0
  16. package/apps/daemon/dist/relay/remote-command.js +5 -0
  17. package/apps/daemon/dist/session/he-official/common.d.ts +3 -0
  18. package/apps/daemon/dist/start.js +4 -1
  19. package/apps/daemon/dist/turn-coordinator.d.ts +2 -0
  20. package/apps/daemon/dist/turn-coordinator.js +6 -0
  21. package/apps/daemon/dist/update-barrier.d.ts +26 -0
  22. package/apps/daemon/dist/update-barrier.js +89 -0
  23. package/apps/daemon/package.json +1 -1
  24. package/apps/relay/dist/relay-context.d.ts +1 -1
  25. package/apps/relay/dist/websocket.js +11 -2
  26. package/apps/updater/dist/autostart.d.ts +15 -0
  27. package/apps/updater/dist/autostart.js +62 -0
  28. package/apps/updater/dist/cli.d.ts +2 -0
  29. package/apps/updater/dist/cli.js +111 -0
  30. package/apps/updater/dist/control.d.ts +5 -0
  31. package/apps/updater/dist/control.js +49 -0
  32. package/apps/updater/dist/daemon-config.d.ts +19 -0
  33. package/apps/updater/dist/daemon-config.js +28 -0
  34. package/apps/updater/dist/daemon.d.ts +20 -0
  35. package/apps/updater/dist/daemon.js +166 -0
  36. package/apps/updater/dist/host-jobs.d.ts +16 -0
  37. package/apps/updater/dist/host-jobs.js +494 -0
  38. package/apps/updater/dist/launcher.d.ts +9 -0
  39. package/apps/updater/dist/launcher.js +64 -0
  40. package/apps/updater/dist/node-runtime.d.ts +5 -0
  41. package/apps/updater/dist/node-runtime.js +40 -0
  42. package/apps/updater/dist/paths.d.ts +30 -0
  43. package/apps/updater/dist/paths.js +50 -0
  44. package/apps/updater/dist/relay.d.ts +15 -0
  45. package/apps/updater/dist/relay.js +338 -0
  46. package/apps/updater/dist/release.d.ts +30 -0
  47. package/apps/updater/dist/release.js +143 -0
  48. package/apps/updater/dist/run.d.ts +4 -0
  49. package/apps/updater/dist/run.js +396 -0
  50. package/apps/updater/dist/slots.d.ts +15 -0
  51. package/apps/updater/dist/slots.js +48 -0
  52. package/apps/updater/dist/state.d.ts +29 -0
  53. package/apps/updater/dist/state.js +122 -0
  54. package/apps/updater/dist/supervisor.d.ts +31 -0
  55. package/apps/updater/dist/supervisor.js +364 -0
  56. package/package.json +5 -2
  57. package/packages/client/dist/client.d.ts +25 -0
  58. package/packages/client/dist/client.js +96 -0
  59. package/packages/client/dist/index.d.ts +2 -1
  60. package/packages/client/dist/types.d.ts +3 -0
  61. package/packages/client/dist/update.d.ts +6 -0
  62. package/packages/client/dist/update.js +123 -0
  63. package/packages/shared/dist/protocol-schemas.d.ts +6 -0
  64. package/packages/shared/dist/protocol-schemas.js +3 -0
  65. package/packages/shared/dist/protocol-types.d.ts +3 -0
  66. package/packages/update-protocol/dist/index.d.ts +311 -0
  67. package/packages/update-protocol/dist/index.js +124 -0
@@ -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.495",
3
+ "version": "0.1.0-beta.497",
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),
@@ -742,6 +757,7 @@ class DirectState {
742
757
  acpxVersion: runtimeCapabilities.version,
743
758
  daemonVersion: daemonPackage.version,
744
759
  ...(runtimeCapabilities.turnPrimitives ? { turnPrimitives: runtimeCapabilities.turnPrimitives } : {}),
760
+ runtimeEvents: { forwardCursor: true },
745
761
  sessionPrimitives: runtimePrimitiveSupportForCapabilities(runtimeCapabilities.sessionPrimitives, "native"),
746
762
  ...(runtimeCapabilities.sidechat ? { sidechat: runtimeCapabilities.sidechat } : {}),
747
763
  directoryBrowser: true,
@@ -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
@@ -22,6 +22,7 @@ export declare function catchUpSubscriptionEvents(input: {
22
22
  config: DaemonConfig;
23
23
  subscription: OrchestrationSubscriptionRecord;
24
24
  }): Promise<SubscriptionCatchUpResult>;
25
+ export declare function remoteEventCursorForSubscription(crewMachineId: string, subscription: Pick<OrchestrationSubscriptionRecord, "remoteEventCursor" | "remoteEventSeq">): string | undefined;
25
26
  export declare function attentionFromReconcile(result: TurnReconcileResult): {
26
27
  eventType: TaskAttentionEventType;
27
28
  reason: string;
@@ -4,10 +4,13 @@ import { RemoteCommandError, requestRemoteMachineResult } from "../relay/remote-
4
4
  import { runtimeRead } from "../session/runtime-truth.js";
5
5
  import { activeSubscriptionsForTurn, appendAttention, markSubscriptionLiveness, markSubscriptionRemoteScan, recordTurnRuntimeBinding, } from "./store.js";
6
6
  import { scheduleRemoteOrchestrationWork } from "./remote-work-budget.js";
7
+ import { relayMachineRegistry } from "../state.js";
7
8
  const remoteCatchupsInFlight = new Map();
8
9
  const legacyRemoteCursorFallbackUntil = new Map();
10
+ const remoteForwardCursorConfirmed = new Set();
9
11
  const legacyRemoteCursorFallbackMs = 5 * 60 * 1000;
10
12
  const initialRemoteEventCursor = "event_000000000000";
13
+ const forwardEventCursorSinceBeta = 495;
11
14
  export async function observeSessionEvent(input) {
12
15
  const attention = attentionFromEvent(input.payload);
13
16
  if (!attention) {
@@ -116,11 +119,10 @@ export async function catchUpSubscriptionEvents(input) {
116
119
  }
117
120
  async function catchUpSubscriptionEventsInternal(input) {
118
121
  const crewMachineId = input.subscription.crewMachineId ?? input.config.machineId;
119
- const remoteCursorEnabled = crewMachineId !== input.config.machineId && canUseRemoteEventCursor(crewMachineId);
120
- const remoteCursor = remoteCursorEnabled
121
- ? input.subscription.remoteEventCursor
122
- ?? (input.subscription.remoteEventSeq ? `event_${String(input.subscription.remoteEventSeq).padStart(12, "0")}` : initialRemoteEventCursor)
122
+ const remoteCursor = crewMachineId !== input.config.machineId
123
+ ? remoteEventCursorForSubscription(crewMachineId, input.subscription)
123
124
  : undefined;
125
+ const remoteCursorEnabled = remoteCursor !== undefined;
124
126
  try {
125
127
  const result = crewMachineId === input.config.machineId
126
128
  ? await runtimeRead(input.config, {
@@ -171,7 +173,14 @@ async function catchUpSubscriptionEventsInternal(input) {
171
173
  // the forward cursor in its response. Treat that as the legacy path for
172
174
  // this machine; in particular, never advance a durable cursor from a
173
175
  // page whose peer cannot prove append-only progress.
174
- if (crewMachineId !== input.config.machineId && remoteCursorEnabled && !result.cursorSupported && !result.nextAfter) {
176
+ if (crewMachineId !== input.config.machineId && result.cursorSupported === true) {
177
+ remoteForwardCursorConfirmed.add(crewMachineId);
178
+ }
179
+ else if (crewMachineId !== input.config.machineId && remoteCursorEnabled && !result.nextAfter) {
180
+ // A peer that accepted the forward request but cannot prove the cursor
181
+ // contract is no longer trusted. Remove the positive handshake so the
182
+ // next safe, legacy-compatible request can renegotiate it.
183
+ remoteForwardCursorConfirmed.delete(crewMachineId);
175
184
  legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
176
185
  }
177
186
  const observed = await observeSubscriptionRuntimeEvents({
@@ -201,6 +210,7 @@ async function catchUpSubscriptionEventsInternal(input) {
201
210
  }
202
211
  catch (error) {
203
212
  if (crewMachineId !== input.config.machineId && remoteCursorEnabled && isRemoteEventCursorCompatibilityFailure(error)) {
213
+ remoteForwardCursorConfirmed.delete(crewMachineId);
204
214
  legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
205
215
  }
206
216
  await appendOrchestrationAudit(input.config, "orchestration.subscription.catchup_failed", `Subscription catch-up failed for ${input.subscription.taskId}`, {
@@ -216,13 +226,63 @@ async function catchUpSubscriptionEventsInternal(input) {
216
226
  function canUseRemoteEventCursor(crewMachineId) {
217
227
  const until = legacyRemoteCursorFallbackUntil.get(crewMachineId);
218
228
  if (until === undefined)
219
- return true;
229
+ return remotePeerAdvertisesForwardCursor(crewMachineId);
220
230
  if (until <= Date.now()) {
221
231
  legacyRemoteCursorFallbackUntil.delete(crewMachineId);
222
- return true;
232
+ return remotePeerAdvertisesForwardCursor(crewMachineId);
223
233
  }
224
234
  return false;
225
235
  }
236
+ export function remoteEventCursorForSubscription(crewMachineId, subscription) {
237
+ const fallbackUntil = legacyRemoteCursorFallbackUntil.get(crewMachineId);
238
+ if (fallbackUntil !== undefined && fallbackUntil > Date.now())
239
+ return undefined;
240
+ if (fallbackUntil !== undefined)
241
+ legacyRemoteCursorFallbackUntil.delete(crewMachineId);
242
+ const advertised = remotePeerForwardCursorSupport(crewMachineId);
243
+ // An explicit old version wins over a stale persisted cursor (for example,
244
+ // after a peer downgrade). Unknown manifests are different: a persisted
245
+ // cursor is still positive evidence until the peer identity is refreshed.
246
+ if (advertised === false)
247
+ return undefined;
248
+ // A persisted cursor is positive evidence from a previous successful
249
+ // forward-page response. It remains usable even when the peer's manifest is
250
+ // temporarily unavailable during reconnect.
251
+ if (subscription.remoteEventCursor)
252
+ return subscription.remoteEventCursor;
253
+ if (advertised !== true || !canUseRemoteEventCursor(crewMachineId))
254
+ return undefined;
255
+ return subscription.remoteEventSeq
256
+ ? `event_${String(subscription.remoteEventSeq).padStart(12, "0")}`
257
+ : initialRemoteEventCursor;
258
+ }
259
+ function remotePeerAdvertisesForwardCursor(crewMachineId) {
260
+ return remotePeerForwardCursorSupport(crewMachineId) === true;
261
+ }
262
+ function remotePeerForwardCursorSupport(crewMachineId) {
263
+ const machine = relayMachineRegistry.get(crewMachineId);
264
+ const daemonVersion = machine?.capabilities.daemonVersion?.trim();
265
+ const advertisedFeature = machine?.capabilities.runtimeEvents?.forwardCursor;
266
+ if (advertisedFeature === false)
267
+ return false;
268
+ if (!daemonVersion) {
269
+ if (advertisedFeature === true || remoteForwardCursorConfirmed.has(crewMachineId))
270
+ return true;
271
+ return undefined;
272
+ }
273
+ // Published beta495 introduced the append-only eventsPage `after` field.
274
+ // Workspace daemons also publish an explicit feature bit. The historical
275
+ // package version 0.1.0 alone is not evidence: older development daemons
276
+ // used that same version while rejecting `after`.
277
+ const match = /^0\.1\.0-beta\.(\d+)$/.exec(daemonVersion);
278
+ if (match && Number(match[1]) < forwardEventCursorSinceBeta)
279
+ return false;
280
+ if (advertisedFeature === true || remoteForwardCursorConfirmed.has(crewMachineId))
281
+ return true;
282
+ if (match)
283
+ return Number(match[1]) >= forwardEventCursorSinceBeta;
284
+ return undefined;
285
+ }
226
286
  function isRemoteEventCursorCompatibilityFailure(error) {
227
287
  if (!(error instanceof RemoteCommandError))
228
288
  return false;
@@ -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) {