@cydm/happy-elves 0.1.0-beta.500 → 0.1.0-beta.502

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.500
10
- npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.500 -- 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.502
10
+ npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.502 -- 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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.500",
3
+ "version": "0.1.0-beta.502",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -10,7 +10,7 @@ import { rehydrateRuntimeSession } from "../session/lifecycle.js";
10
10
  import { runtimeRead } from "../session/runtime-truth.js";
11
11
  import { encryptedSessionMetadata } from "../session/metadata.js";
12
12
  import { loadSessionProjections } from "../session/store.js";
13
- import { appendAttention, expireInflightLeases, leaseNextAttention, latestAttentionForTask, markAttentionDelivered, markAttentionDeliveryFailed, markSubscriptionLiveness, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, readOrchestrationStore, } from "./store.js";
13
+ import { appendAttention, closeSubscriptionForTurn, expireInflightLeases, leaseNextAttention, latestAttentionForTask, markAttentionDelivered, markAttentionDeliveryFailed, markSubscriptionLiveness, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, readOrchestrationStore, } from "./store.js";
14
14
  import { attentionFromReconcile, catchUpSubscriptionEvents } from "./watcher.js";
15
15
  import { scheduleRemoteOrchestrationWork } from "./remote-work-budget.js";
16
16
  import { resolveWakeIdentity } from "./wake-identity.js";
@@ -146,9 +146,6 @@ async function reconcileSubscriptionsOnce(config) {
146
146
  if (reconciledSubscriptions.has(key))
147
147
  continue;
148
148
  reconciledSubscriptions.add(key);
149
- const latest = await latestAttentionForTask(subscription.taskId);
150
- if (latest?.eventTypes.some((type) => type === "terminal" || type === "failure"))
151
- continue;
152
149
  if (crewMachineId !== config.machineId) {
153
150
  // Remote reconcile shares the same bounded/fair scheduler as transcript
154
151
  // catch-up. Startup recovery must not create an unbounded second fleet
@@ -252,8 +249,20 @@ async function reconcileRemoteSubscription(config, subscription) {
252
249
  return;
253
250
  }
254
251
  const attention = attentionFromReconcile(result);
255
- if (!attention || !subscription.eventTypes.includes(attention.eventType))
252
+ if (!attention)
256
253
  return;
254
+ const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
255
+ if (!subscription.eventTypes.includes(attention.eventType)) {
256
+ if (closesTurn) {
257
+ await closeSubscriptionForTurn({
258
+ taskId: subscription.taskId,
259
+ consumerSessionId: subscription.consumerSessionId,
260
+ crewSessionId: subscription.crewSessionId,
261
+ turnId: subscription.turnId,
262
+ });
263
+ }
264
+ return;
265
+ }
257
266
  await appendAttention({
258
267
  taskId: subscription.taskId,
259
268
  consumerMachineId: subscription.consumerMachineId ?? config.machineId,
@@ -270,6 +279,7 @@ async function reconcileRemoteSubscription(config, subscription) {
270
279
  turnId: subscription.turnId,
271
280
  ref: result.status,
272
281
  },
282
+ closeSubscription: closesTurn,
273
283
  });
274
284
  }
275
285
  catch (error) {
@@ -307,14 +317,16 @@ async function markStaleSubscriptions(config) {
307
317
  if (Number.isFinite(lastSeenAt) && now - lastSeenAt < staleMs)
308
318
  continue;
309
319
  const latest = await latestAttentionForTask(subscription.taskId);
310
- if (latest?.eventTypes.some((type) => type === "terminal" || type === "failure"))
311
- continue;
312
- if (latest?.eventTypes.includes("stale") && latest.status !== "acked")
320
+ if (latest?.turnId === subscription.turnId && latest.eventTypes.includes("stale") && latest.status !== "acked")
313
321
  continue;
314
322
  if ((subscription.crewMachineId ?? config.machineId) === config.machineId) {
315
323
  await reconcileLocalSubscription(config, subscription, "stale-check");
316
- const reconciledLatest = await latestAttentionForTask(subscription.taskId);
317
- if (reconciledLatest?.eventTypes.some((type) => type === "terminal" || type === "failure" || type === "unknown"))
324
+ const reconciledStore = await readOrchestrationStore();
325
+ const currentSubscription = reconciledStore.subscriptions.find((record) => record.taskId === subscription.taskId &&
326
+ record.consumerSessionId === subscription.consumerSessionId &&
327
+ record.crewSessionId === subscription.crewSessionId &&
328
+ record.turnId === subscription.turnId);
329
+ if (!currentSubscription || currentSubscription.status !== "active")
318
330
  continue;
319
331
  if (await localSubscriptionTurnIsActive(subscription))
320
332
  continue;
@@ -123,6 +123,8 @@ export type AttentionInput = {
123
123
  eventType: TaskAttentionEventType;
124
124
  reason: string;
125
125
  payloadRef?: TaskAttentionPayloadRef;
126
+ /** Terminal/failure attention closes the exact turn subscription atomically. */
127
+ closeSubscription?: boolean;
126
128
  now?: string;
127
129
  };
128
130
  export declare function readOrchestrationStore(): Promise<OrchestrationStore>;
@@ -249,6 +251,9 @@ export declare function markSubscriptionRemoteScan(taskId: string, input: {
249
251
  at?: string;
250
252
  }): Promise<void>;
251
253
  export declare function appendAttention(input: AttentionInput): Promise<TaskAttentionRecord[]>;
254
+ export declare function closeSubscriptionForTurn(input: Pick<AttentionInput, "taskId" | "consumerSessionId" | "crewSessionId" | "turnId"> & {
255
+ at?: string;
256
+ }): Promise<void>;
252
257
  export declare function listAttentions(input?: {
253
258
  consumerSessionId?: string;
254
259
  status?: TaskAttentionStatus[];
@@ -794,6 +794,9 @@ export async function appendAttention(input) {
794
794
  let updated;
795
795
  const store = await updateOrchestrationStore((current) => {
796
796
  updated = appendAttentionRecord(current, { ...input, now: input.now ?? new Date().toISOString() });
797
+ if (input.closeSubscription && (input.eventType === "terminal" || input.eventType === "failure")) {
798
+ closeSubscriptionForTurnRecord(current, input);
799
+ }
797
800
  return current;
798
801
  });
799
802
  if (!updated)
@@ -802,6 +805,30 @@ export async function appendAttention(input) {
802
805
  record.consumerSessionId === updated?.consumerSessionId &&
803
806
  record.eventCursor === updated?.eventCursor);
804
807
  }
808
+ export async function closeSubscriptionForTurn(input) {
809
+ if (!input.turnId)
810
+ return;
811
+ const at = input.at ?? new Date().toISOString();
812
+ await updateOrchestrationStore((current) => {
813
+ closeSubscriptionForTurnRecord(current, { ...input, now: at });
814
+ return current;
815
+ });
816
+ }
817
+ function closeSubscriptionForTurnRecord(store, input) {
818
+ if (!input.turnId)
819
+ return;
820
+ const now = input.now ?? new Date().toISOString();
821
+ for (const subscription of store.subscriptions) {
822
+ if (subscription.status !== "active")
823
+ continue;
824
+ if (subscription.taskId !== input.taskId || subscription.consumerSessionId !== input.consumerSessionId)
825
+ continue;
826
+ if (subscription.crewSessionId !== input.crewSessionId || subscription.turnId !== input.turnId)
827
+ continue;
828
+ subscription.status = "deleted";
829
+ subscription.updatedAt = now;
830
+ }
831
+ }
805
832
  function appendAttentionRecord(store, input) {
806
833
  const index = store.attentions.findIndex((record) => record.taskId === input.taskId && record.consumerSessionId === input.consumerSessionId);
807
834
  const payloadRefs = input.payloadRef ? [input.payloadRef] : [];
@@ -22,7 +22,6 @@ 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;
26
25
  export declare function attentionFromReconcile(result: TurnReconcileResult): {
27
26
  eventType: TaskAttentionEventType;
28
27
  reason: string;
@@ -2,15 +2,11 @@ import { randomId, } from "../../../../packages/shared/dist/index.js";
2
2
  import { appendAudit } from "../audit.js";
3
3
  import { RemoteCommandError, requestRemoteMachineResult } from "../relay/remote-command.js";
4
4
  import { runtimeRead } from "../session/runtime-truth.js";
5
- import { activeSubscriptionsForTurn, appendAttention, markSubscriptionLiveness, markSubscriptionRemoteScan, recordTurnRuntimeBinding, } from "./store.js";
5
+ import { activeSubscriptionsForTurn, appendAttention, closeSubscriptionForTurn, markSubscriptionLiveness, markSubscriptionRemoteScan, recordTurnRuntimeBinding, } from "./store.js";
6
6
  import { scheduleRemoteOrchestrationWork } from "./remote-work-budget.js";
7
7
  import { relayMachineRegistry } from "../state.js";
8
8
  const remoteCatchupsInFlight = new Map();
9
- const legacyRemoteCursorFallbackUntil = new Map();
10
- const remoteForwardCursorConfirmed = new Set();
11
- const legacyRemoteCursorFallbackMs = 5 * 60 * 1000;
12
9
  const initialRemoteEventCursor = "event_000000000000";
13
- const forwardEventCursorSinceBeta = 495;
14
10
  export async function observeSessionEvent(input) {
15
11
  const attention = attentionFromEvent(input.payload);
16
12
  if (!attention) {
@@ -21,8 +17,18 @@ export async function observeSessionEvent(input) {
21
17
  }
22
18
  const subscriptions = await activeSubscriptionsForTurn(input.sessionId, input.turnId);
23
19
  for (const subscription of subscriptions) {
24
- if (!subscription.eventTypes.includes(attention.eventType))
20
+ const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
21
+ if (!subscription.eventTypes.includes(attention.eventType)) {
22
+ if (closesTurn) {
23
+ await closeSubscriptionForTurn({
24
+ taskId: subscription.taskId,
25
+ consumerSessionId: subscription.consumerSessionId,
26
+ crewSessionId: subscription.crewSessionId,
27
+ turnId: subscription.turnId,
28
+ });
29
+ }
25
30
  continue;
31
+ }
26
32
  await appendAttention({
27
33
  taskId: subscription.taskId,
28
34
  consumerMachineId: subscription.consumerMachineId ?? input.config.machineId,
@@ -39,6 +45,7 @@ export async function observeSessionEvent(input) {
39
45
  turnId: input.turnId,
40
46
  eventSeq: input.seq,
41
47
  },
48
+ closeSubscription: closesTurn,
42
49
  });
43
50
  await appendOrchestrationAudit(input.config, "orchestration.attention.append", `Recorded ${attention.eventType} attention for ${subscription.taskId}`, {
44
51
  taskId: subscription.taskId,
@@ -89,6 +96,7 @@ export async function observeTurnReconcile(input) {
89
96
  turnId: input.result.turnId,
90
97
  ref: input.result.status,
91
98
  },
99
+ closeSubscription: attention.eventType === "terminal" || attention.eventType === "failure",
92
100
  });
93
101
  await appendOrchestrationAudit(input.config, "orchestration.attention.reconcile", `Recorded ${attention.eventType} reconcile attention for ${subscription.taskId}`, {
94
102
  taskId: subscription.taskId,
@@ -122,8 +130,10 @@ async function catchUpSubscriptionEventsInternal(input) {
122
130
  const remoteCursor = crewMachineId !== input.config.machineId
123
131
  ? remoteEventCursorForSubscription(crewMachineId, input.subscription)
124
132
  : undefined;
125
- const remoteCursorEnabled = remoteCursor !== undefined;
126
133
  try {
134
+ if (crewMachineId !== input.config.machineId && remotePeerForwardCursorSupport(crewMachineId) !== true) {
135
+ throw new RemoteCommandError(`Remote Crew ${crewMachineId} does not advertise the required forward event cursor protocol`, "REMOTE_FORWARD_CURSOR_UNSUPPORTED", false);
136
+ }
127
137
  const result = crewMachineId === input.config.machineId
128
138
  ? await runtimeRead(input.config, {
129
139
  requestId: randomId("orch_catchup_events"),
@@ -146,8 +156,7 @@ async function catchUpSubscriptionEventsInternal(input) {
146
156
  action: "turn.eventsPage",
147
157
  sessionId: input.subscription.crewSessionId,
148
158
  turnId: input.subscription.turnId,
149
- ...(remoteCursor ? { after: remoteCursor } : {}),
150
- ...(input.subscription.remoteEventItemId && !remoteCursor ? { afterItemId: input.subscription.remoteEventItemId } : {}),
159
+ after: remoteCursor,
151
160
  ...(input.subscription.runtimeTurnId ?? input.subscription.providerTurnId
152
161
  ? { runtimeTurnId: input.subscription.runtimeTurnId ?? input.subscription.providerTurnId }
153
162
  : {}),
@@ -169,19 +178,8 @@ async function catchUpSubscriptionEventsInternal(input) {
169
178
  });
170
179
  return { status: "unreachable" };
171
180
  }
172
- // A rolling-upgrade peer may accept the old runtimeRead command but omit
173
- // the forward cursor in its response. Treat that as the legacy path for
174
- // this machine; in particular, never advance a durable cursor from a
175
- // page whose peer cannot prove append-only progress.
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);
184
- legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
181
+ if (crewMachineId !== input.config.machineId && result.cursorSupported !== true) {
182
+ throw new RemoteCommandError(`Remote Crew ${crewMachineId} returned a response without forward cursor support`, "REMOTE_FORWARD_CURSOR_UNSUPPORTED", false);
185
183
  }
186
184
  const observed = await observeSubscriptionRuntimeEvents({
187
185
  config: input.config,
@@ -191,7 +189,7 @@ async function catchUpSubscriptionEventsInternal(input) {
191
189
  skipThroughSeq: crewMachineId === input.config.machineId ? 0 : input.subscription.remoteEventSeq ?? 0,
192
190
  });
193
191
  if (crewMachineId !== input.config.machineId) {
194
- const canAdvance = Boolean(result.nextAfter);
192
+ const canAdvance = result.cursorSupported === true;
195
193
  await markSubscriptionRemoteScan(input.subscription.taskId, {
196
194
  crewMachineId,
197
195
  crewSessionId: input.subscription.crewSessionId,
@@ -209,86 +207,32 @@ async function catchUpSubscriptionEventsInternal(input) {
209
207
  };
210
208
  }
211
209
  catch (error) {
212
- if (crewMachineId !== input.config.machineId && remoteCursorEnabled && isRemoteEventCursorCompatibilityFailure(error)) {
213
- remoteForwardCursorConfirmed.delete(crewMachineId);
214
- legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
215
- }
216
210
  await appendOrchestrationAudit(input.config, "orchestration.subscription.catchup_failed", `Subscription catch-up failed for ${input.subscription.taskId}`, {
217
211
  taskId: input.subscription.taskId,
218
212
  crewMachineId,
219
213
  crewSessionId: input.subscription.crewSessionId,
220
214
  turnId: input.subscription.turnId,
215
+ errorCode: error instanceof RemoteCommandError ? error.code : undefined,
221
216
  error: error instanceof Error ? error.message : String(error),
222
217
  });
223
218
  return { status: "failed" };
224
219
  }
225
220
  }
226
- function canUseRemoteEventCursor(crewMachineId) {
227
- const until = legacyRemoteCursorFallbackUntil.get(crewMachineId);
228
- if (until === undefined)
229
- return remotePeerAdvertisesForwardCursor(crewMachineId);
230
- if (until <= Date.now()) {
231
- legacyRemoteCursorFallbackUntil.delete(crewMachineId);
232
- return remotePeerAdvertisesForwardCursor(crewMachineId);
233
- }
234
- return false;
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.
221
+ function remoteEventCursorForSubscription(crewMachineId, subscription) {
222
+ // Every current daemon speaks the append-only forward cursor protocol. The
223
+ // capability gate lives in catchUpSubscriptionEventsInternal so an
224
+ // unavailable/old peer fails with a structured protocol error rather than
225
+ // silently switching to an unsafe legacy page.
226
+ void crewMachineId;
251
227
  if (subscription.remoteEventCursor)
252
228
  return subscription.remoteEventCursor;
253
- if (advertised !== true || !canUseRemoteEventCursor(crewMachineId))
254
- return undefined;
255
229
  return subscription.remoteEventSeq
256
230
  ? `event_${String(subscription.remoteEventSeq).padStart(12, "0")}`
257
231
  : initialRemoteEventCursor;
258
232
  }
259
- function remotePeerAdvertisesForwardCursor(crewMachineId) {
260
- return remotePeerForwardCursorSupport(crewMachineId) === true;
261
- }
262
233
  function remotePeerForwardCursorSupport(crewMachineId) {
263
234
  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
- }
286
- function isRemoteEventCursorCompatibilityFailure(error) {
287
- if (!(error instanceof RemoteCommandError))
288
- return false;
289
- return error.code === "REMOTE_COMMAND_TIMEOUT" ||
290
- error.code === "REMOTE_MACHINE_ERROR" ||
291
- /INVALID|UNKNOWN|UNRECOGNIZED|SCHEMA|PROTOCOL|CURSOR|AFTER/u.test(error.code);
235
+ return machine?.capabilities.runtimeEvents?.forwardCursor;
292
236
  }
293
237
  async function observeSubscriptionRuntimeEvents(input) {
294
238
  const crewMachineId = input.crewMachineId ?? input.subscription.crewMachineId ?? input.config.machineId;
@@ -360,8 +304,18 @@ async function observeSubscriptionPayload(input) {
360
304
  }
361
305
  return;
362
306
  }
363
- if (!input.subscription.eventTypes.includes(attention.eventType))
307
+ const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
308
+ if (!input.subscription.eventTypes.includes(attention.eventType)) {
309
+ if (closesTurn) {
310
+ await closeSubscriptionForTurn({
311
+ taskId: input.subscription.taskId,
312
+ consumerSessionId: input.subscription.consumerSessionId,
313
+ crewSessionId: input.subscription.crewSessionId,
314
+ turnId: input.subscription.turnId,
315
+ });
316
+ }
364
317
  return;
318
+ }
365
319
  await appendAttention({
366
320
  taskId: input.subscription.taskId,
367
321
  consumerMachineId: input.subscription.consumerMachineId ?? input.config.machineId,
@@ -378,6 +332,7 @@ async function observeSubscriptionPayload(input) {
378
332
  turnId: input.subscription.turnId,
379
333
  eventSeq: input.seq,
380
334
  },
335
+ closeSubscription: closesTurn,
381
336
  });
382
337
  await appendOrchestrationAudit(input.config, "orchestration.attention.append", `Recorded ${input.crewMachineId === input.config.machineId ? "" : "remote "}${attention.eventType} attention for ${input.subscription.taskId}`, {
383
338
  taskId: input.subscription.taskId,
@@ -703,10 +703,37 @@ export async function connectOnce(config) {
703
703
  let loopTimer;
704
704
  let scheduleTimer;
705
705
  let orchestrationTimer;
706
+ let authenticated = false;
707
+ const startAuthenticatedWork = () => {
708
+ if (!authenticated || ws.readyState !== WebSocket.OPEN)
709
+ return;
710
+ if (loopTimer || scheduleTimer || orchestrationTimer)
711
+ return;
712
+ const onlineSinceMs = Date.now();
713
+ loopTimer = setInterval(() => {
714
+ void tickLoops(ws, config).catch((error) => {
715
+ console.error(`Loop tick failed: ${error instanceof Error ? error.message : String(error)}`);
716
+ });
717
+ }, 1000);
718
+ scheduleTimer = setInterval(() => {
719
+ void tickSchedules(config, onlineSinceMs).catch((error) => {
720
+ console.error(`Schedule tick failed: ${error instanceof Error ? error.message : String(error)}`);
721
+ });
722
+ }, 1000);
723
+ orchestrationTimer = setInterval(() => {
724
+ void tickOrchestration(config).catch((error) => {
725
+ console.error(`Orchestration tick failed: ${error instanceof Error ? error.message : String(error)}`);
726
+ });
727
+ }, 1000);
728
+ void recoverCrewQueues(config).catch((error) => {
729
+ console.error(`Crew queue relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
730
+ });
731
+ void drainPendingArtifactFeedbackAttentions(config).catch((error) => {
732
+ console.error(`Artifact feedback attention relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
733
+ });
734
+ };
706
735
  await new Promise((resolve) => {
707
736
  ws.addEventListener("open", () => {
708
- const onlineSinceMs = Date.now();
709
- setActiveRelaySocket(ws);
710
737
  const hello = {
711
738
  type: "hello",
712
739
  token: config.machineToken,
@@ -714,27 +741,6 @@ export async function connectOnce(config) {
714
741
  nodeId: config.machineId,
715
742
  };
716
743
  ws.send(JSON.stringify(hello));
717
- loopTimer = setInterval(() => {
718
- void tickLoops(ws, config).catch((error) => {
719
- console.error(`Loop tick failed: ${error instanceof Error ? error.message : String(error)}`);
720
- });
721
- }, 1000);
722
- scheduleTimer = setInterval(() => {
723
- void tickSchedules(config, onlineSinceMs).catch((error) => {
724
- console.error(`Schedule tick failed: ${error instanceof Error ? error.message : String(error)}`);
725
- });
726
- }, 1000);
727
- orchestrationTimer = setInterval(() => {
728
- void tickOrchestration(config).catch((error) => {
729
- console.error(`Orchestration tick failed: ${error instanceof Error ? error.message : String(error)}`);
730
- });
731
- }, 1000);
732
- void recoverCrewQueues(config).catch((error) => {
733
- console.error(`Crew queue relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
734
- });
735
- void drainPendingArtifactFeedbackAttentions(config).catch((error) => {
736
- console.error(`Artifact feedback attention relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
737
- });
738
744
  });
739
745
  ws.addEventListener("message", (event) => {
740
746
  let message;
@@ -745,11 +751,24 @@ export async function connectOnce(config) {
745
751
  console.error(`Invalid relay message: ${error instanceof Error ? error.message : String(error)}`);
746
752
  return;
747
753
  }
748
- void handleRelayWireMessage(ws, config, message).catch(async (error) => {
754
+ void (async () => {
755
+ if (message.type === "server:hello" && !authenticated) {
756
+ // Relay authentication is complete only after the server hello.
757
+ // Do not activate the socket earlier: a reconnect with a durable
758
+ // outbox would otherwise flush publishPacket before the relay has
759
+ // installed the machine's authenticated routing context.
760
+ authenticated = true;
761
+ setActiveRelaySocket(ws);
762
+ }
763
+ await handleRelayWireMessage(ws, config, message);
764
+ if (message.type === "server:hello")
765
+ startAuthenticatedWork();
766
+ })().catch(async (error) => {
749
767
  await appendUnscopedRelayError(config, "DAEMON_COMMAND_FAILED", error);
750
768
  });
751
769
  });
752
770
  ws.addEventListener("close", () => {
771
+ authenticated = false;
753
772
  clearActiveRelaySocket(ws);
754
773
  if (loopTimer)
755
774
  clearInterval(loopTimer);
@@ -761,6 +780,7 @@ export async function connectOnce(config) {
761
780
  resolve();
762
781
  });
763
782
  ws.addEventListener("error", () => {
783
+ authenticated = false;
764
784
  clearActiveRelaySocket(ws);
765
785
  if (loopTimer)
766
786
  clearInterval(loopTimer);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.500",
3
+ "version": "0.1.0-beta.502",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
6
6
  import { updaterPaths } from "./paths.js";
7
7
  import { daemonControlRequest } from "./control.js";
8
8
  import { readUpdaterMarker, readUpdaterOwner } from "./ownership.js";
9
+ import { managedProcessEnvironment } from "./managed-environment.js";
9
10
  const execFileAsync = promisify(execFile);
10
11
  export async function localDaemonStatus(stateRoot) {
11
12
  const file = updaterPaths(stateRoot).daemonPidPath;
@@ -91,7 +92,7 @@ export async function startDaemon(input) {
91
92
  const stderr = fsSync.openSync(paths.daemonStderrPath, "a", 0o600);
92
93
  const child = spawn(input.nodeExecutable, [input.daemonEntrypoint, "start"], {
93
94
  cwd: input.stateRoot,
94
- env: { ...process.env, HAPPY_ELVES_HOME: input.stateRoot, HAPPY_ELVES_UPDATER_GENERATION: input.generation },
95
+ env: { ...managedProcessEnvironment(), HAPPY_ELVES_HOME: input.stateRoot, HAPPY_ELVES_UPDATER_GENERATION: input.generation },
95
96
  detached: true,
96
97
  stdio: ["ignore", stdout, stderr],
97
98
  windowsHide: true,
@@ -119,7 +120,7 @@ export function spawnUpdaterFromStableLauncher(input) {
119
120
  const child = spawn(input.nodeExecutable, [runtimeScript, "updater", "run", "--state-root", input.stateRoot], {
120
121
  cwd: input.stateRoot,
121
122
  env: {
122
- ...process.env,
123
+ ...managedProcessEnvironment(),
123
124
  HAPPY_ELVES_HOME: input.stateRoot,
124
125
  ...(input.handoffFromPid ? { HAPPY_ELVES_UPDATER_HANDOFF_FROM: String(input.handoffFromPid) } : {}),
125
126
  ...(input.handoffToken ? { HAPPY_ELVES_UPDATER_HANDOFF_TOKEN: input.handoffToken } : {}),
@@ -1,9 +1,17 @@
1
1
  /**
2
- * Install a stable, state-root-local launcher. The launcher deliberately resolves
3
- * the active slot at invocation time so an update never leaves a shell shim
4
- * pointing at a removed staging directory.
2
+ * Select the Node runtime used by stable launchers. An explicit executable is
3
+ * always authoritative (the `--node` escape hatch); otherwise retain the
4
+ * previously verified launcher runtime across upgrades, rollback and recovery.
5
+ * A stale/broken launcher record is not trusted and falls back to the runtime
6
+ * currently executing the updater.
5
7
  */
6
- export declare function writeStableLaunchers(stateRoot: string, nodeExecutable: string): Promise<{
8
+ export declare function resolveStableNodeExecutable(stateRoot: string, explicitNodeExecutable?: string): Promise<string>;
9
+ /**
10
+ * Install a stable, state-root-local launcher. The launcher deliberately
11
+ * resolves the active slot at invocation time so an update never leaves a
12
+ * shell shim pointing at a removed staging directory.
13
+ */
14
+ export declare function writeStableLaunchers(stateRoot: string, nodeExecutable?: string): Promise<{
7
15
  cliPath: string;
8
16
  updaterPath: string;
9
17
  }>;
@@ -3,36 +3,61 @@ import path from "node:path";
3
3
  import { updaterPaths } from "./paths.js";
4
4
  import { readCurrentSlot } from "./slots.js";
5
5
  import { atomicWriteJson } from "./state.js";
6
+ import { inspectNodeRuntime } from "./node-runtime.js";
6
7
  /**
7
- * Install a stable, state-root-local launcher. The launcher deliberately resolves
8
- * the active slot at invocation time so an update never leaves a shell shim
9
- * pointing at a removed staging directory.
8
+ * Select the Node runtime used by stable launchers. An explicit executable is
9
+ * always authoritative (the `--node` escape hatch); otherwise retain the
10
+ * previously verified launcher runtime across upgrades, rollback and recovery.
11
+ * A stale/broken launcher record is not trusted and falls back to the runtime
12
+ * currently executing the updater.
13
+ */
14
+ export async function resolveStableNodeExecutable(stateRoot, explicitNodeExecutable) {
15
+ if (explicitNodeExecutable !== undefined)
16
+ return (await inspectNodeRuntime(explicitNodeExecutable)).executable;
17
+ const manifestPath = path.join(updaterPaths(stateRoot).launcherDir, "launcher.json");
18
+ try {
19
+ const parsed = JSON.parse(await fs.readFile(manifestPath, "utf8"));
20
+ if (typeof parsed.nodeExecutable === "string" && parsed.nodeExecutable.trim()) {
21
+ return (await inspectNodeRuntime(parsed.nodeExecutable)).executable;
22
+ }
23
+ }
24
+ catch {
25
+ // The launcher may not exist during the first install, or its old runtime
26
+ // may have been removed. In either case use the updater's current Node.
27
+ }
28
+ return (await inspectNodeRuntime(process.execPath)).executable;
29
+ }
30
+ /**
31
+ * Install a stable, state-root-local launcher. The launcher deliberately
32
+ * resolves the active slot at invocation time so an update never leaves a
33
+ * shell shim pointing at a removed staging directory.
10
34
  */
11
35
  export async function writeStableLaunchers(stateRoot, nodeExecutable) {
12
36
  const current = await readCurrentSlot(stateRoot);
13
37
  if (!current)
14
38
  throw Object.assign(new Error("Cannot create launcher before a current slot exists"), { code: "UPDATE_SLOT_MISSING" });
39
+ const selectedNodeExecutable = await resolveStableNodeExecutable(stateRoot, nodeExecutable);
15
40
  const paths = updaterPaths(stateRoot);
16
41
  await fs.mkdir(paths.launcherDir, { recursive: true, mode: 0o700 });
17
42
  const runtimeScript = path.join(paths.launcherDir, "launcher-runtime.cjs");
18
- await fs.writeFile(runtimeScript, runtimeScriptSource(stateRoot, nodeExecutable), { mode: 0o700 });
43
+ await fs.writeFile(runtimeScript, runtimeScriptSource(stateRoot, selectedNodeExecutable), { mode: 0o700 });
19
44
  const cliEntry = path.join(current.slotPath, "node_modules", "@cydm", "happy-elves", "apps", "cli", "dist", "index.js");
20
45
  const updaterEntry = path.join(current.slotPath, "node_modules", "@cydm", "happy-elves", "apps", "updater", "dist", "cli.js");
21
46
  await fs.access(cliEntry);
22
47
  await fs.access(updaterEntry);
23
48
  if (process.platform === "win32") {
24
- await atomicWriteText(paths.launcherPath, windowsLauncher(nodeExecutable, runtimeScript, "cli"));
25
- await atomicWriteText(paths.updaterLauncherPath, windowsLauncher(nodeExecutable, runtimeScript, "updater"));
49
+ await atomicWriteText(paths.launcherPath, windowsLauncher(selectedNodeExecutable, runtimeScript, "cli"));
50
+ await atomicWriteText(paths.updaterLauncherPath, windowsLauncher(selectedNodeExecutable, runtimeScript, "updater"));
26
51
  }
27
52
  else {
28
- await atomicWriteText(paths.launcherPath, posixLauncher(nodeExecutable, runtimeScript, "cli"));
29
- await atomicWriteText(paths.updaterLauncherPath, posixLauncher(nodeExecutable, runtimeScript, "updater"));
53
+ await atomicWriteText(paths.launcherPath, posixLauncher(selectedNodeExecutable, runtimeScript, "cli"));
54
+ await atomicWriteText(paths.updaterLauncherPath, posixLauncher(selectedNodeExecutable, runtimeScript, "updater"));
30
55
  await fs.chmod(paths.launcherPath, 0o700);
31
56
  await fs.chmod(paths.updaterLauncherPath, 0o700);
32
57
  }
33
58
  await atomicWriteJson(path.join(paths.launcherDir, "launcher.json"), {
34
59
  schema: 1,
35
- nodeExecutable,
60
+ nodeExecutable: selectedNodeExecutable,
36
61
  runtimeScript,
37
62
  version: current.version,
38
63
  cliPath: cliEntry,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Read the current user's Windows environment without making it part of any
3
+ * host-job request. A manager can itself be launched by a deliberately
4
+ * sanitized host job; in that case process.env no longer contains provider
5
+ * credentials that the normal user-launched daemon would inherit.
6
+ *
7
+ * The result is used only for manager-owned updater/daemon children. It is
8
+ * never logged, persisted, sent over Relay, or passed to arbitrary host jobs.
9
+ */
10
+ export declare function managedProcessEnvironment(base?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, userEnvironment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
11
+ export declare function parseWindowsUserEnvironment(output: string, expansionEnvironment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,125 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const environmentLookupTimeoutMs = 2_000;
3
+ /**
4
+ * Read the current user's Windows environment without making it part of any
5
+ * host-job request. A manager can itself be launched by a deliberately
6
+ * sanitized host job; in that case process.env no longer contains provider
7
+ * credentials that the normal user-launched daemon would inherit.
8
+ *
9
+ * The result is used only for manager-owned updater/daemon children. It is
10
+ * never logged, persisted, sent over Relay, or passed to arbitrary host jobs.
11
+ */
12
+ export function managedProcessEnvironment(base = process.env, platform = process.platform, userEnvironment = platform === "win32" ? readWindowsUserEnvironment(base) : {}) {
13
+ if (platform !== "win32")
14
+ return { ...base };
15
+ const result = { ...base };
16
+ for (const [key, value] of Object.entries(userEnvironment)) {
17
+ if (!key || value === undefined || hasEnvironmentKey(result, key))
18
+ continue;
19
+ result[key] = value;
20
+ }
21
+ return result;
22
+ }
23
+ export function parseWindowsUserEnvironment(output, expansionEnvironment = {}) {
24
+ const entries = new Map();
25
+ const trimmed = output.trim();
26
+ if (trimmed.startsWith("{")) {
27
+ try {
28
+ const parsed = JSON.parse(trimmed);
29
+ for (const [key, value] of Object.entries(parsed)) {
30
+ if (!key)
31
+ continue;
32
+ if (typeof value === "string")
33
+ entries.set(key, { value, expand: false });
34
+ else if (value && typeof value === "object" && "value" in value && typeof value.value === "string") {
35
+ const typedValue = value;
36
+ entries.set(key, { value: typedValue.value, expand: isExpandableRegistryType(typedValue.type) });
37
+ }
38
+ }
39
+ return materializeEnvironment(entries, expansionEnvironment);
40
+ }
41
+ catch {
42
+ // Fall through to the line-oriented reg.exe parser.
43
+ }
44
+ }
45
+ for (const line of output.split(/\r?\n/u)) {
46
+ // `reg query HKCU\\Environment` prints:
47
+ // NAME REG_SZ value
48
+ const match = /^\s{4,}([^\s]+)\s+(REG_[A-Z0-9_]+)\s*(.*)$/u.exec(line);
49
+ if (!match)
50
+ continue;
51
+ const [, key, type, rawValue] = match;
52
+ if (!key || !rawValue)
53
+ continue;
54
+ entries.set(key, { value: rawValue, expand: isExpandableRegistryType(type) });
55
+ }
56
+ return materializeEnvironment(entries, expansionEnvironment);
57
+ }
58
+ function isExpandableRegistryType(type) {
59
+ if (typeof type !== "string")
60
+ return false;
61
+ const normalized = type.trim().toUpperCase().replace(/[-_\s]/gu, "");
62
+ // PowerShell's RegistryValueKind.ToString() returns ExpandString, while
63
+ // reg.exe uses REG_EXPAND_SZ. Both describe the same registry semantics.
64
+ return normalized === "EXPANDSTRING" || normalized === "REGEXPANDSZ";
65
+ }
66
+ function readWindowsUserEnvironment(base) {
67
+ try {
68
+ const output = execFileSync("powershell.exe", [
69
+ "-NoLogo",
70
+ "-NoProfile",
71
+ "-NonInteractive",
72
+ "-Command",
73
+ "$ErrorActionPreference='Stop'; $OutputEncoding=New-Object System.Text.UTF8Encoding($false); [Console]::OutputEncoding=New-Object System.Text.UTF8Encoding($false); $k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment'); if ($null -eq $k) { '{}' } else { $o=[ordered]@{}; foreach ($n in $k.GetValueNames()) { $o[$n]=[ordered]@{ value=[string]$k.GetValue($n,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); type=$k.GetValueKind($n).ToString() } }; $o | ConvertTo-Json -Compress; $k.Dispose() }",
74
+ ], {
75
+ encoding: "utf8",
76
+ stdio: ["ignore", "pipe", "ignore"],
77
+ windowsHide: true,
78
+ timeout: environmentLookupTimeoutMs,
79
+ });
80
+ return parseWindowsUserEnvironment(output, base);
81
+ }
82
+ catch {
83
+ try {
84
+ const output = execFileSync("reg.exe", ["query", "HKCU\\Environment"], {
85
+ encoding: "utf8",
86
+ stdio: ["ignore", "pipe", "ignore"],
87
+ windowsHide: true,
88
+ timeout: environmentLookupTimeoutMs,
89
+ });
90
+ return parseWindowsUserEnvironment(output, base);
91
+ }
92
+ catch {
93
+ // A missing registry helper or a locked-down profile must not prevent
94
+ // the updater from starting. The daemon will surface a provider error
95
+ // if it genuinely needs a credential that is unavailable in its env.
96
+ return {};
97
+ }
98
+ }
99
+ }
100
+ function materializeEnvironment(entries, expansionEnvironment) {
101
+ const result = {};
102
+ const combined = { ...expansionEnvironment };
103
+ for (const [key, entry] of entries)
104
+ combined[key] = entry.value;
105
+ for (const [key, entry] of entries) {
106
+ result[key] = entry.expand ? expandWindowsVariables(entry.value, combined) : entry.value;
107
+ }
108
+ return result;
109
+ }
110
+ function hasEnvironmentKey(environment, key) {
111
+ if (Object.hasOwn(environment, key))
112
+ return true;
113
+ const normalized = key.toUpperCase();
114
+ return Object.keys(environment).some((candidate) => candidate.toUpperCase() === normalized);
115
+ }
116
+ function expandWindowsVariables(value, environment) {
117
+ return value.replace(/%([^%]+)%/gu, (match, name) => {
118
+ const direct = environment[name];
119
+ if (direct !== undefined)
120
+ return direct;
121
+ const existing = Object.entries(environment).find(([key]) => key.toUpperCase() === name.toUpperCase());
122
+ return existing?.[1] ?? match;
123
+ });
124
+ }
125
+ //# sourceMappingURL=managed-environment.js.map
@@ -7,7 +7,7 @@ import { startUpdaterRelay } from "./relay.js";
7
7
  import fs from "node:fs/promises";
8
8
  import { cancelHostJob, readHostJob, readHostJobByOperation, recoverHostJobs, startHostJob } from "./host-jobs.js";
9
9
  import { daemonEntrypoint, localDaemonStatus, spawnUpdaterFromStableLauncher, startDaemon, stopLocalDaemon, waitForUpdaterReady } from "./daemon.js";
10
- import { writeStableLaunchers } from "./launcher.js";
10
+ import { resolveStableNodeExecutable, writeStableLaunchers } from "./launcher.js";
11
11
  import { clearStaleUpdaterMarkers, claimUpdaterOwner, markUpdaterHandoff, readUpdaterOwner, removeUpdaterPidIfOwned, restoreUpdaterOwner, writeUpdaterPid, writeUpdaterReady } from "./ownership.js";
12
12
  import { randomUUID } from "node:crypto";
13
13
  let daemonLifecycleTail = Promise.resolve();
@@ -132,13 +132,14 @@ async function recoverStaleUpdate(stateRoot) {
132
132
  const targetWasActivated = Boolean(current?.version && current.version === operation.targetVersion && operation.fromVersion);
133
133
  if (targetWasActivated && operation.fromVersion) {
134
134
  try {
135
+ const nodeExecutable = await resolveStableNodeExecutable(stateRoot);
135
136
  if (operation.daemonWasRunning !== false)
136
137
  await stopLocalDaemon(stateRoot, 15_000);
137
138
  const packageJson = await readSlotPackage(stateRoot, operation.fromVersion);
138
139
  const restored = await activateSlot(stateRoot, { version: operation.fromVersion, digest: operation.fromDigest, buildId: operation.fromBuildId ?? (typeof packageJson?.buildId === "string" ? packageJson.buildId : undefined) });
139
- await writeStableLaunchers(stateRoot, process.execPath);
140
+ await writeStableLaunchers(stateRoot, nodeExecutable);
140
141
  if (operation.daemonWasRunning !== false) {
141
- const pid = await startDaemon({ stateRoot, nodeExecutable: process.execPath, daemonEntrypoint: daemonEntrypoint(restored.slotPath), generation: `recovery-${Date.now()}` });
142
+ const pid = await startDaemon({ stateRoot, nodeExecutable, daemonEntrypoint: daemonEntrypoint(restored.slotPath), generation: `recovery-${Date.now()}` });
142
143
  if (!(await waitForDaemonPid(stateRoot, pid, 20_000)))
143
144
  throw new Error("Recovered daemon did not become ready");
144
145
  }
@@ -170,7 +171,8 @@ async function handoffToCurrentSlot(stateRoot, owner) {
170
171
  // replacement can therefore restore the old marker without a duplicate
171
172
  // entrypoint deleting it.
172
173
  await fs.rm(readyPath, { force: true }).catch(() => undefined);
173
- const nextPid = spawnUpdaterFromStableLauncher({ stateRoot, nodeExecutable: process.execPath, handoffFromPid: process.pid, handoffToken: token });
174
+ const nodeExecutable = await resolveStableNodeExecutable(stateRoot);
175
+ const nextPid = spawnUpdaterFromStableLauncher({ stateRoot, nodeExecutable, handoffFromPid: process.pid, handoffToken: token });
174
176
  if (await waitForUpdaterReady(stateRoot, nextPid, 10_000, { handoffToken: token }))
175
177
  return { ok: true };
176
178
  await recoverFailedHandoff(stateRoot, owner, token, previousReady);
@@ -264,7 +266,8 @@ async function handleDaemonCommand(command, input) {
264
266
  const current = await readCurrentSlot(input.stateRoot);
265
267
  if (!current)
266
268
  return daemonError(command, input.machineId, "BOOTSTRAP_REQUIRED", "No installed daemon slot is available", action);
267
- const pid = await startDaemon({ stateRoot: input.stateRoot, nodeExecutable: process.execPath, daemonEntrypoint: daemonEntrypoint(current.slotPath), generation: `host-${Date.now()}` });
269
+ const nodeExecutable = await resolveStableNodeExecutable(input.stateRoot);
270
+ const pid = await startDaemon({ stateRoot: input.stateRoot, nodeExecutable, daemonEntrypoint: daemonEntrypoint(current.slotPath), generation: `host-${Date.now()}` });
268
271
  const status = await waitForDaemonPid(input.stateRoot, pid, 5_000);
269
272
  if (!status)
270
273
  throw Object.assign(new Error("Daemon exited before becoming ready"), { code: "DAEMON_START_FAILED" });
@@ -11,7 +11,7 @@ import { resolveOfficialRelease, downloadAndVerifyRelease, installOfficialPackag
11
11
  import { daemonControlRequest } from "./control.js";
12
12
  import { daemonEntrypoint, localDaemonStatus, startDaemon, startUpdater, stopLocalDaemon, waitForUpdaterReady } from "./daemon.js";
13
13
  import { activeHostJobCount } from "./host-jobs.js";
14
- import { writeStableLaunchers } from "./launcher.js";
14
+ import { resolveStableNodeExecutable, writeStableLaunchers } from "./launcher.js";
15
15
  import { installAutostart } from "./autostart.js";
16
16
  import { readDaemonConfig } from "./daemon-config.js";
17
17
  export async function supervisorStatus(options) {
@@ -19,7 +19,14 @@ export async function supervisorStatus(options) {
19
19
  const operation = await readOperation(stateRoot);
20
20
  const current = await readCurrentSlot(stateRoot);
21
21
  const daemon = await localDaemonStatus(stateRoot);
22
- const node = await inspectNodeRuntime(options.nodeExecutable ?? process.execPath).catch((error) => ({ errorCode: errorCode(error), error: error instanceof Error ? error.message : String(error) }));
22
+ let node;
23
+ try {
24
+ const selectedNode = await resolveStableNodeExecutable(stateRoot, options.nodeExecutable);
25
+ node = await inspectNodeRuntime(selectedNode);
26
+ }
27
+ catch (error) {
28
+ node = { errorCode: errorCode(error), error: error instanceof Error ? error.message : String(error) };
29
+ }
23
30
  return {
24
31
  stateRoot,
25
32
  updaterVersion: options.updaterVersion ?? "0.1.0",
@@ -35,7 +42,7 @@ export async function bootstrapFromTarball(input) {
35
42
  const daemon = await localDaemonStatus(stateRoot);
36
43
  if (daemon.running)
37
44
  throw Object.assign(new Error("Bootstrap requires a local maintenance window; stop the existing daemon after confirming it is idle"), { code: "BOOTSTRAP_MAINTENANCE_REQUIRED" });
38
- const node = await inspectNodeRuntime(input.nodeExecutable ?? process.execPath);
45
+ const node = await inspectNodeRuntime(await resolveStableNodeExecutable(stateRoot, input.nodeExecutable));
39
46
  const existingCurrent = await readCurrentSlot(stateRoot);
40
47
  if (existingCurrent?.version === input.version)
41
48
  throw Object.assign(new Error("The requested bootstrap version is already active"), { code: "UPDATE_ALREADY_CURRENT" });
@@ -93,7 +100,7 @@ export async function bootstrapFromTarball(input) {
93
100
  export async function installCurrentPackage(input) {
94
101
  const stateRoot = resolveStateRoot(input.stateRoot);
95
102
  const config = await readDaemonConfig(stateRoot);
96
- const node = await inspectNodeRuntime(input.nodeExecutable ?? process.execPath);
103
+ const node = await inspectNodeRuntime(await resolveStableNodeExecutable(stateRoot, input.nodeExecutable));
97
104
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
98
105
  const manifest = JSON.parse(await fs.readFile(path.join(packageRoot, "package.json"), "utf8"));
99
106
  const version = typeof manifest.version === "string" && manifest.version ? manifest.version : undefined;
@@ -150,7 +157,7 @@ async function waitForDaemonPid(stateRoot, pid, timeoutMs) {
150
157
  }
151
158
  export async function applyOfficialUpdate(input) {
152
159
  const stateRoot = resolveStateRoot(input.stateRoot);
153
- const node = await inspectNodeRuntime(input.nodeExecutable ?? process.execPath);
160
+ const node = await inspectNodeRuntime(await resolveStableNodeExecutable(stateRoot, input.nodeExecutable));
154
161
  const existing = await readOperation(stateRoot);
155
162
  const operationId = input.operationId ?? randomId("upd");
156
163
  if (existing && existing.operationId === operationId) {
@@ -269,6 +276,7 @@ export async function commitDeferredUpdate(input) {
269
276
  }
270
277
  /** Restore the previous slot after a replacement updater fails its handoff. */
271
278
  export async function recoverDeferredUpdate(input) {
279
+ const nodeExecutable = await resolveStableNodeExecutable(input.stateRoot, input.nodeExecutable);
272
280
  const operation = await readOperation(input.stateRoot);
273
281
  if (!operation || operation.operationId !== input.operationId)
274
282
  throw Object.assign(new Error("Deferred update operation was not found"), { code: "UPDATE_OPERATION_MISSING" });
@@ -281,9 +289,9 @@ export async function recoverDeferredUpdate(input) {
281
289
  await stopLocalDaemon(input.stateRoot, 15_000).catch(() => undefined);
282
290
  await activateSlot(input.stateRoot, { version: previous.version, digest: operation.fromDigest ?? previous.digest, buildId: operation.fromBuildId ?? previous.buildId });
283
291
  if (operation.daemonWasRunning !== false) {
284
- await startAndVerify({ stateRoot: input.stateRoot, machineId: input.machineId, nodeExecutable: input.nodeExecutable ?? process.execPath, version: previous.version, generation: operation.barrierGeneration ?? randomId("rollback") });
292
+ await startAndVerify({ stateRoot: input.stateRoot, machineId: input.machineId, nodeExecutable, version: previous.version, generation: operation.barrierGeneration ?? randomId("rollback") });
285
293
  }
286
- await writeStableLaunchers(input.stateRoot, input.nodeExecutable ?? process.execPath);
294
+ await writeStableLaunchers(input.stateRoot, nodeExecutable);
287
295
  await clearBarrier(input.stateRoot, input.machineId, input.operationId).catch(() => undefined);
288
296
  return await updateOperation(input.stateRoot, { phase: "recovered", errorCode: input.errorCode ?? "UPDATE_HANDOFF_FAILED", error: input.error ?? "Replacement updater failed; the previous slot was restored.", recoveredFrom: operation.targetVersion, targetVersion: previous.version });
289
297
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.500",
3
+ "version": "0.1.0-beta.502",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
- "buildId": "48f1f04bc7d6747ed2bf59b3fb1e2a58",
5
+ "buildId": "9ee7dcc44e6e7465aaa011b2c13085c8",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "happy-elves": "./apps/cli/dist/index.js",
@@ -594,6 +594,16 @@ class CodexHeadlessRuntimeProvider {
594
594
  if (codexTurnId && !active.settled && !active.terminalObserved) {
595
595
  active.codexTurnId = codexTurnId;
596
596
  this.activeTurnsByCodexTurn.set(codexTurnId, active);
597
+ // Persist ownership as soon as the provider materialises the native
598
+ // turn. A failed/interrupted HE turn is still an HE-owned append;
599
+ // waiting until `turn/completed` made a restart misclassify that
600
+ // failed turn as an external native handoff.
601
+ // Serialize the durable metadata write with provider event handling,
602
+ // but do not await it here: app-servers may issue a tool/approval
603
+ // request immediately after the `turn/start` response and before
604
+ // the first notification. Blocking that response would let a fast
605
+ // terminal notification fence the still-active request.
606
+ this.enqueueActive(active, () => this.bindStartedCodexTurn(active, codexTurnId));
597
607
  }
598
608
  };
599
609
  startProviderTurn(codexThreadId).catch(async (error) => {
@@ -1127,6 +1137,27 @@ class CodexHeadlessRuntimeProvider {
1127
1137
  providerMetadata,
1128
1138
  });
1129
1139
  }
1140
+ async bindStartedCodexTurn(active, codexTurnId) {
1141
+ const priorIds = Array.isArray(active.providerMetadata?.nativeHistoryTurnIds)
1142
+ ? active.providerMetadata.nativeHistoryTurnIds.filter((value) => typeof value === "string")
1143
+ : [];
1144
+ const nativeHistoryTurnIds = priorIds.includes(codexTurnId) ? priorIds : [...priorIds, codexTurnId];
1145
+ const ownedPriorIds = Array.isArray(active.providerMetadata?.headlessOwnedNativeTurnIds)
1146
+ ? active.providerMetadata.headlessOwnedNativeTurnIds.filter((value) => typeof value === "string")
1147
+ : [];
1148
+ const headlessOwnedNativeTurnIds = ownedPriorIds.includes(codexTurnId) ? ownedPriorIds : [...ownedPriorIds, codexTurnId];
1149
+ const providerMetadata = {
1150
+ ...active.providerMetadata,
1151
+ nativeHistoryTurnIds,
1152
+ headlessOwnedNativeTurnIds,
1153
+ nativeHistoryVersion: nativeHistoryVersionForTurnIds(nativeHistoryTurnIds),
1154
+ };
1155
+ active.providerMetadata = providerMetadata;
1156
+ await active.context.bindThreadProvider({
1157
+ providerSessionId: active.codexThreadId,
1158
+ providerMetadata,
1159
+ });
1160
+ }
1130
1161
  enqueueActive(active, task) {
1131
1162
  if (active.settled)
1132
1163
  return;