@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 +2 -2
- package/apps/cli/package.json +1 -1
- package/apps/daemon/dist/runtime/headless-event-projector.js +20 -10
- package/apps/daemon/dist/session/native-history-refresh.d.ts +9 -1
- package/apps/daemon/dist/session/native-history-refresh.js +28 -19
- package/apps/daemon/dist/session/prompt.js +16 -10
- package/apps/daemon/dist/session/runtime-truth.js +15 -6
- package/apps/daemon/package.json +1 -1
- package/package.json +2 -2
- package/packages/headless-runtime/dist/core.js +27 -3
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.
|
|
10
|
-
npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.
|
|
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
|
|
package/apps/cli/package.json
CHANGED
|
@@ -363,13 +363,18 @@ export function historicalEventsFromRuntimeEvents(runtimeEvents) {
|
|
|
363
363
|
authoritativeTextItems: completedTextItemIds,
|
|
364
364
|
});
|
|
365
365
|
const events = [];
|
|
366
|
-
const
|
|
366
|
+
const userPromptItemIds = new Set();
|
|
367
367
|
const addEvent = (event) => {
|
|
368
368
|
if (event.payload.type === "user_prompt") {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 (
|
|
127
|
-
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
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
|
-
//
|
|
21
|
-
// independent from
|
|
22
|
-
//
|
|
23
|
-
// page
|
|
24
|
-
//
|
|
25
|
-
|
|
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);
|
package/apps/daemon/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cydm/happy-elves",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|