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

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.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
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.504
10
+ npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.504 -- 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.502",
3
+ "version": "0.1.0-beta.504",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -363,13 +363,18 @@ export function historicalEventsFromRuntimeEvents(runtimeEvents) {
363
363
  authoritativeTextItems: completedTextItemIds,
364
364
  });
365
365
  const events = [];
366
- const userPromptKeys = new Set();
366
+ const userPromptItemIds = new Set();
367
367
  const addEvent = (event) => {
368
368
  if (event.payload.type === "user_prompt") {
369
- const key = `${event.turnId}\0${event.payload.text}`;
370
- if (userPromptKeys.has(key))
371
- return;
372
- userPromptKeys.add(key);
369
+ // The same provider item can arrive through both the canonical item
370
+ // list and the append-only event log. Deduplicate only by that stable
371
+ // item identity; text (including trailing newlines) is not identity.
372
+ const itemId = event.itemId ?? event.payload.itemId;
373
+ if (itemId) {
374
+ if (userPromptItemIds.has(itemId))
375
+ return;
376
+ userPromptItemIds.add(itemId);
377
+ }
373
378
  }
374
379
  events.push(event);
375
380
  };
@@ -849,7 +854,7 @@ function historicalEventFromItem(item) {
849
854
  }
850
855
  export function historicalEventsFromThread(items, turns, limit, runtimeEvents = []) {
851
856
  const events = [];
852
- const userPromptKeys = new Set();
857
+ const userPromptItemIds = new Set();
853
858
  // Canonical ThreadItems are authoritative for text and for any tool item
854
859
  // the provider materialized. Some HE providers intentionally keep
855
860
  // orchestration calls only in the event log, though (for example a
@@ -861,10 +866,15 @@ export function historicalEventsFromThread(items, turns, limit, runtimeEvents =
861
866
  const canonicalToolCallIds = new Set();
862
867
  const addEvent = (event) => {
863
868
  if (event.payload.type === "user_prompt") {
864
- const key = `${event.turnId}\0${event.payload.text}`;
865
- if (userPromptKeys.has(key))
866
- return;
867
- userPromptKeys.add(key);
869
+ // Canonical items and runtime events may describe the same item. Do
870
+ // not collapse distinct user messages by text: providers are allowed
871
+ // to emit two prompts with equal text in one turn.
872
+ const itemId = event.itemId ?? event.payload.itemId;
873
+ if (itemId) {
874
+ if (userPromptItemIds.has(itemId))
875
+ return;
876
+ userPromptItemIds.add(itemId);
877
+ }
868
878
  }
869
879
  events.push(event);
870
880
  };
@@ -1,4 +1,12 @@
1
1
  import type { DaemonConfig, SessionState } from "../types.js";
2
+ export type NativeHistoryRefreshOptions = {
3
+ /**
4
+ * Recovery workers may wait for the provider operation itself. User-facing
5
+ * reads keep the short bounded race, but a background worker needs the
6
+ * completion edge so it can publish the repaired retained transcript.
7
+ */
8
+ waitForCompletion?: boolean;
9
+ };
2
10
  /**
3
11
  * Mark a managed provider session's history dirty after a new Headless turn.
4
12
  *
@@ -22,4 +30,4 @@ export declare function invalidateNativeHistoryRefreshForLoadedSession(session:
22
30
  * truth reads; otherwise an old same-turn duplicate remains visible until an
23
31
  * internal caller manually invokes the runtime operation.
24
32
  */
25
- export declare function refreshNativeHistoryForLoadedSession(session: SessionState, config: DaemonConfig | undefined, requestId: string): Promise<SessionState>;
33
+ export declare function refreshNativeHistoryForLoadedSession(session: SessionState, config: DaemonConfig | undefined, requestId: string, options?: NativeHistoryRefreshOptions): Promise<SessionState>;
@@ -9,6 +9,10 @@ const orchestrationRefreshRetryAt = new Map();
9
9
  const orchestrationRefreshGenerations = new Map();
10
10
  const orchestrationRefreshRetryDelayMs = 1_000;
11
11
  const orchestrationRefreshMaxWaitMs = 2_000;
12
+ function cleanupOrchestrationRefresh(refreshKey, entry) {
13
+ if (orchestrationRefreshes.get(refreshKey) === entry)
14
+ orchestrationRefreshes.delete(refreshKey);
15
+ }
12
16
  /**
13
17
  * Mark a managed provider session's history dirty after a new Headless turn.
14
18
  *
@@ -48,7 +52,7 @@ export function invalidateNativeHistoryRefreshForLoadedSession(session) {
48
52
  * truth reads; otherwise an old same-turn duplicate remains visible until an
49
53
  * internal caller manually invokes the runtime operation.
50
54
  */
51
- export async function refreshNativeHistoryForLoadedSession(session, config, requestId) {
55
+ export async function refreshNativeHistoryForLoadedSession(session, config, requestId, options = {}) {
52
56
  const isNative = session.source === "native";
53
57
  const isManagedProviderSession = session.source !== "native" && Boolean(session.providerSessionId);
54
58
  if (!isNative && !isManagedProviderSession)
@@ -66,19 +70,20 @@ export async function refreshNativeHistoryForLoadedSession(session, config, requ
66
70
  }
67
71
  const active = orchestrationRefreshes.get(refreshKey);
68
72
  if (active) {
73
+ if (options.waitForCompletion)
74
+ return await active.promise;
69
75
  const remainingMs = Math.max(0, orchestrationRefreshMaxWaitMs - (Date.now() - active.startedAt));
70
76
  const outcome = await Promise.race([
71
77
  active.promise.then((result) => ({ kind: "result", result })),
72
78
  new Promise((resolve) => setTimeout(() => resolve({ kind: "timeout" }), remainingMs)),
73
79
  ]);
74
80
  if (outcome.kind === "timeout") {
75
- active.abandoned = true;
76
- if (orchestrationRefreshes.get(refreshKey) === active)
77
- orchestrationRefreshes.delete(refreshKey);
78
- orchestrationRefreshGenerations.set(refreshKey, (orchestrationRefreshGenerations.get(refreshKey) ?? 0) + 1);
79
- // Disposing the client cancels the bounded IPC request instead of
80
- // leaving every later official read behind the same hung promise.
81
- void getHeadlessRuntimeManager(session.cwd).invalidateClientForBinding(session.runtimeBinding, session.cwd);
81
+ // Keep the provider read alive after the short foreground deadline.
82
+ // Native history reads can legitimately take several seconds on a
83
+ // remote/Windows provider. The caller remains bounded, while the
84
+ // singleflight operation continues and publishes the repaired
85
+ // projection when it completes. Explicit generation invalidation
86
+ // (for example, a newer terminal turn) is the only cancellation path.
82
87
  return sessions.get(session.sessionId) ?? session;
83
88
  }
84
89
  const result = outcome.result;
@@ -88,7 +93,7 @@ export async function refreshNativeHistoryForLoadedSession(session, config, requ
88
93
  if (!active.abandoned && active.generation !== (orchestrationRefreshGenerations.get(refreshKey) ?? 0)) {
89
94
  if (orchestrationRefreshes.get(refreshKey) === active)
90
95
  orchestrationRefreshes.delete(refreshKey);
91
- return await refreshNativeHistoryForLoadedSession(session, config, requestId);
96
+ return await refreshNativeHistoryForLoadedSession(session, config, requestId, options);
92
97
  }
93
98
  if (active.abandoned)
94
99
  return sessions.get(session.sessionId) ?? session;
@@ -97,34 +102,38 @@ export async function refreshNativeHistoryForLoadedSession(session, config, requ
97
102
  const operation = refreshNativeHistory(session, config, requestId, false, refreshKey, generation);
98
103
  const entry = { generation, promise: operation, startedAt: Date.now() };
99
104
  orchestrationRefreshes.set(refreshKey, entry);
105
+ // A timed-out foreground caller must not orphan the operation in the
106
+ // singleflight map. Resolve/reject handlers are attached immediately so
107
+ // the map is cleaned when the provider read eventually settles.
108
+ void operation.then(() => cleanupOrchestrationRefresh(refreshKey, entry), () => cleanupOrchestrationRefresh(refreshKey, entry));
109
+ if (options.waitForCompletion)
110
+ return await operation;
111
+ let foregroundTimedOut = false;
100
112
  try {
101
113
  const outcome = await Promise.race([
102
114
  operation.then((result) => ({ kind: "result", result })),
103
115
  new Promise((resolve) => setTimeout(() => resolve({ kind: "timeout" }), orchestrationRefreshMaxWaitMs)),
104
116
  ]);
105
117
  if (outcome.kind === "timeout") {
106
- entry.abandoned = true;
107
- if (orchestrationRefreshes.get(refreshKey) === entry)
108
- orchestrationRefreshes.delete(refreshKey);
109
- orchestrationRefreshGenerations.set(refreshKey, (orchestrationRefreshGenerations.get(refreshKey) ?? 0) + 1);
110
- // The first caller must have the same bounded wait as followers. Drop
111
- // the provider client so the old IPC cannot poison the next refresh.
112
- void getHeadlessRuntimeManager(session.cwd).invalidateClientForBinding(session.runtimeBinding, session.cwd);
118
+ foregroundTimedOut = true;
119
+ // Do not cancel or invalidate the provider client here. The read is
120
+ // still the authoritative singleflight for this binding and may
121
+ // finish successfully after the UI/control request has returned.
113
122
  return sessions.get(session.sessionId) ?? session;
114
123
  }
115
124
  const result = outcome.result;
116
125
  if (!entry.abandoned && generation !== (orchestrationRefreshGenerations.get(refreshKey) ?? 0)) {
117
126
  if (orchestrationRefreshes.get(refreshKey) === entry)
118
127
  orchestrationRefreshes.delete(refreshKey);
119
- return await refreshNativeHistoryForLoadedSession(session, config, requestId);
128
+ return await refreshNativeHistoryForLoadedSession(session, config, requestId, options);
120
129
  }
121
130
  if (entry.abandoned)
122
131
  return sessions.get(session.sessionId) ?? session;
123
132
  return result;
124
133
  }
125
134
  finally {
126
- if (orchestrationRefreshes.get(refreshKey) === entry)
127
- orchestrationRefreshes.delete(refreshKey);
135
+ if (!foregroundTimedOut)
136
+ cleanupOrchestrationRefresh(refreshKey, entry);
128
137
  }
129
138
  }
130
139
  return await refreshNativeHistory(session, config, requestId, true);
@@ -43,12 +43,18 @@ function scheduleNativeHistoryRecovery(ws, config, session, requestId, turnId) {
43
43
  return;
44
44
  }
45
45
  try {
46
- // Each attempt gets a fresh provider read. A successful early read can
47
- // still precede provider canonicalization, so do not let the refresh
48
- // cache suppress the later attempts.
49
- invalidateNativeHistoryRefreshForLoadedSession(current);
50
- const refreshed = await refreshNativeHistoryForLoadedSession(current, config, `${requestId}:history-recovery:${recovery.attempt}`);
46
+ // Re-enter the refresh singleflight without cancelling an in-flight
47
+ // provider read. Native history can be slow on a remote/Windows
48
+ // provider; the bounded terminal wait must not turn a successful
49
+ // background read into a repeated cancel/retry loop. A later terminal
50
+ // generation explicitly invalidates the cache when a genuinely fresh
51
+ // read is required.
52
+ const refreshed = await refreshNativeHistoryForLoadedSession(current, config, `${requestId}:history-recovery:${recovery.attempt}`, { waitForCompletion: true });
51
53
  const latest = sessions.get(session.sessionId) ?? refreshed;
54
+ if (sessionTurns.activeTurnId(session.sessionId) || latest.runtimeBinding.threadId !== session.runtimeBinding.threadId) {
55
+ nativeHistoryRecoveries.delete(key);
56
+ return;
57
+ }
52
58
  await replaceAuthoritativeTranscript(ws, config, latest, undefined, {
53
59
  replacementKind: "sync",
54
60
  requestId: `${requestId}:history-recovery:${recovery.attempt}`,
@@ -427,7 +433,7 @@ export async function handlePrompt(ws, config, command) {
427
433
  // the authoritative transcript, so live projections and subsequent
428
434
  // turn.result/status reads converge without a second turn execution.
429
435
  invalidateNativeHistoryRefreshForLoadedSession(session);
430
- const nativeRefresh = refreshNativeHistoryForLoadedSession(sessions.get(command.sessionId) ?? session, config, command.requestId).catch((error) => {
436
+ const nativeRefresh = refreshNativeHistoryForLoadedSession(sessions.get(command.sessionId) ?? session, config, command.requestId, { waitForCompletion: true }).catch((error) => {
431
437
  console.error(`Legacy Crew post-turn history refresh failed for ${command.turnId}: ${error instanceof Error ? error.message : String(error)}`);
432
438
  });
433
439
  // Provider-native history is read-only cleanup. It must not hold the
@@ -440,10 +446,10 @@ export async function handlePrompt(ws, config, command) {
440
446
  new Promise((resolve) => setTimeout(() => resolve(refreshTimeout), 2_000)),
441
447
  ]);
442
448
  if (refreshOutcome === refreshTimeout) {
443
- // Detach the provider operation at the terminal boundary instead of
444
- // leaving a stalled IPC request alive until its much longer transport
445
- // timeout. The next official read/recovery pass starts a new generation.
446
- invalidateNativeHistoryRefreshForLoadedSession(sessions.get(command.sessionId) ?? session);
449
+ // Keep the provider operation alive after the short terminal wait.
450
+ // The refresh layer owns its singleflight and will publish the
451
+ // provider-canonical transcript when the slow read completes; the
452
+ // recovery timer below only re-enters that operation if needed.
447
453
  scheduleNativeHistoryRecovery(ws, config, sessions.get(command.sessionId) ?? session, command.requestId, command.turnId);
448
454
  }
449
455
  session = sessions.get(command.sessionId) ?? session;
@@ -17,12 +17,21 @@ export async function runtimeRead(config, input) {
17
17
  // Reconcile that history before projecting turn.result/status so an
18
18
  // upgrade/restart repairs old same-turn duplicates through the official
19
19
  // read path, without requiring an internal syncNativeHistory call.
20
- // Event catch-up reads the authoritative Headless event log and must stay
21
- // independent from the provider-native history refresh. The latter can
22
- // involve a slow/remote provider IPC call; putting it in every polling
23
- // page made one stale Crew subscription block all other orchestration.
24
- // Result/status/list retain the refresh for legacy native-history repair.
25
- if (input.action !== "turn.eventsPage") {
20
+ // Forward/anchored event catch-up reads the append-only Headless event
21
+ // log and must stay independent from provider-native refresh: a slow
22
+ // provider IPC call must not block every subscription poll. An unanchored
23
+ // first page, however, is the canonical rebuild boundary. Refresh legacy
24
+ // native-backed sessions there once (the refresh layer single-flights and
25
+ // caches it) so old same-turn provider duplicates are repaired before the
26
+ // page is projected. Active turns remain event-only until terminal.
27
+ const isUnanchoredEventPage = input.action === "turn.eventsPage"
28
+ && input.after === undefined
29
+ && input.before === undefined
30
+ && input.beforeItemId === undefined
31
+ && input.afterItemId === undefined
32
+ && !sessionTurns.activeTurnId(input.sessionId)
33
+ && (session.source === "native" || (session.source === "orchestration" && Boolean(session.providerSessionId)));
34
+ if (input.action !== "turn.eventsPage" || isUnanchoredEventPage) {
26
35
  session = await refreshNativeHistoryForLoadedSession(session, config, `runtime_truth_refresh_${input.requestId}`);
27
36
  }
28
37
  runtimeSessionId = runtimeSessionIdForSession(session);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.502",
3
+ "version": "0.1.0-beta.504",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.502",
3
+ "version": "0.1.0-beta.504",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
- "buildId": "9ee7dcc44e6e7465aaa011b2c13085c8",
5
+ "buildId": "d074135e7e6ab20470b2dcf91f68dcdc",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "happy-elves": "./apps/cli/dist/index.js",
@@ -575,7 +575,7 @@ export class HeadlessRuntimeCore {
575
575
  .filter((item) => !mappedItemIds.has(item.id))
576
576
  .filter((item) => item.type === "agentMessage" || item.type === "userMessage")
577
577
  .filter((item) => mappedSourceItems.some((sourceItem) => (sourceItem.kind === "assistant" ? item.type === "agentMessage" : item.type === "userMessage")
578
- && item.text === sourceItem.text))
578
+ && (item.text === sourceItem.text || nativeUserInputTextEqual(sourceItem, item))))
579
579
  .map((item) => item.id);
580
580
  if (duplicateTextItemIds.length > 0) {
581
581
  removedItemIds.push(...duplicateTextItemIds);
@@ -3094,6 +3094,21 @@ function runtimeTurnIdForProviderTurn(thread, providerTurnId, providerCheckpoint
3094
3094
  function nativeItemBindingKey(providerTurnId, providerItemId) {
3095
3095
  return `${providerTurnId}\0${providerItemId}`;
3096
3096
  }
3097
+ /**
3098
+ * Native providers occasionally append a transport newline to a user input
3099
+ * when reopening a turn. Treat that as the same input only at this mapping
3100
+ * boundary: the comparison is turn-scoped, kind-scoped, and one-to-one. We
3101
+ * intentionally do not trim arbitrary whitespace or apply this to assistant
3102
+ * output, so legitimate repeated prompts remain distinct.
3103
+ */
3104
+ function nativeUserInputTextEqual(source, projected) {
3105
+ if (source.kind !== "user" || projected.type !== "userMessage" || !("text" in projected))
3106
+ return false;
3107
+ if (projected.text === source.text)
3108
+ return true;
3109
+ const withoutTerminalNewline = (value) => value.replace(/(?:\r\n|\n|\r)+$/g, "");
3110
+ return withoutTerminalNewline(projected.text) === withoutTerminalNewline(source.text);
3111
+ }
3097
3112
  /**
3098
3113
  * Map provider-native items onto an existing HE turn projection.
3099
3114
  *
@@ -3141,7 +3156,15 @@ function mapNativeItemsToProjection(sourceItems, existingItems, priorBindings, p
3141
3156
  continue;
3142
3157
  }
3143
3158
  const sourceItem = sourceItems[sourceIndex];
3144
- const exact = candidateAtOrAfterCursor((item) => item.type === sourceType(sourceItem) && "text" in item && item.text === sourceItem.text);
3159
+ // Prefer the exact provider text before the transport-normalized fallback.
3160
+ // This keeps a stable native item bound to the right projection when a
3161
+ // legacy duplicate and the canonical item are both present. The newline
3162
+ // comparison is only a positional, one-to-one recovery for an otherwise
3163
+ // unbound native item; it is never a global text dedupe.
3164
+ const exact = candidateAtOrAfterCursor((item) => item.type === sourceType(sourceItem) && "text" in item && item.text === sourceItem.text)
3165
+ ?? (sourceItem.kind === "user"
3166
+ ? candidateAtOrAfterCursor((item) => item.type === sourceType(sourceItem) && "text" in item && nativeUserInputTextEqual(sourceItem, item))
3167
+ : undefined);
3145
3168
  if (exact) {
3146
3169
  itemIds[sourceIndex] = exact.item.id;
3147
3170
  usedExisting.add(exact.item.id);
@@ -3188,7 +3211,8 @@ function mapNativeItemsToProjection(sourceItems, existingItems, priorBindings, p
3188
3211
  continue;
3189
3212
  }
3190
3213
  const mappedText = indexes.map((index) => sourceItems[index].text).join("");
3191
- if (mappedText !== existing.text)
3214
+ const singleUserItem = indexes.length === 1 && sourceItems[indexes[0]]?.kind === "user";
3215
+ if (!(singleUserItem ? nativeUserInputTextEqual(sourceItems[indexes[0]], existing) : mappedText === existing.text))
3192
3216
  valid = false;
3193
3217
  }
3194
3218
  const complete = valid