@cydm/happy-elves 0.1.0-beta.40 → 0.1.0-beta.41

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.
@@ -27,6 +27,8 @@ const valueFlags = new Set([
27
27
  "reason",
28
28
  "relay",
29
29
  "root",
30
+ "runtime-limit",
31
+ "runtime-session",
30
32
  "shell",
31
33
  "actions",
32
34
  "expires-in",
@@ -58,6 +60,7 @@ const booleanFlags = new Set([
58
60
  "h",
59
61
  "include-archived",
60
62
  "json",
63
+ "keep-source",
61
64
  "local",
62
65
  "no-open",
63
66
  "no-wait",
@@ -50,7 +50,8 @@ Check local controller config, relay reachability, and daemon state.
50
50
  happy-elves session history --machine <machineId> [--cwd <path>] [--agent <agent>] [--include-archived] [--limit 20] --json
51
51
  happy-elves session continue <runtimeSessionId> --machine <machineId> [--name <name>] --json
52
52
  happy-elves session status <sessionId> [--verbose] [--json]
53
- happy-elves session diagnose <sessionId> [--limit 200] [--repair-head] [--json]
53
+ happy-elves session diagnose <sessionId> [--limit 200] [--runtime-limit 500] [--repair-head] [--json]
54
+ happy-elves session repair-binding <sessionId> [--runtime-session <runtimeSessionId>] [--runtime-limit 500] [--keep-source] --json
54
55
  happy-elves session close <sessionId> [--reason <reason>] --json
55
56
 
56
57
  Session management:
@@ -64,6 +65,8 @@ Session management:
64
65
  counters, and latest materialized relay page counts for debugging empty or
65
66
  stale transcripts. --repair-head advances a stale canonical head to the
66
67
  latest completed relay event when the relay already has the event basis.
68
+ repair-binding imports the matching runtime history row as a new loaded
69
+ session and closes the empty/stale source session unless --keep-source is set.
67
70
  close archives a loaded session. There is no separate archive command.
68
71
  `,
69
72
  turn: `Usage:
@@ -141,8 +144,9 @@ Core commands:
141
144
  session create --machine <machineId> [--agent codex] [--cwd <path>] [--name <name>] --json
142
145
  session history --machine <machineId> [--cwd <path>] [--limit 20] --json
143
146
  session continue <runtimeSessionId> --machine <machineId> --json
147
+ session repair-binding <sessionId> [--runtime-session <runtimeSessionId>] --json
144
148
  session status <sessionId> [--verbose] [--json]
145
- session diagnose <sessionId> [--limit 200] [--repair-head] [--json]
149
+ session diagnose <sessionId> [--limit 200] [--runtime-limit 500] [--repair-head] [--json]
146
150
  session close <sessionId> [--reason <reason>] --json
147
151
 
148
152
  turn list <sessionId> [--limit 10] [--json]
@@ -217,6 +221,9 @@ Session management:
217
221
  provider sessions unless --include-archived is set.
218
222
  session continue <runtimeSessionId> --machine <machineId> --json continues a
219
223
  runtime history row as a loaded session, or returns an existing sessionId.
224
+ session repair-binding <sessionId> --json recovers a loaded session that was
225
+ bound to the wrong/empty provider history by importing the matching runtime
226
+ history row as a new loaded session.
220
227
  session close <sessionId> --reason "archived" --json archives a loaded
221
228
  session. There is no separate archive command.
222
229
 
@@ -2,7 +2,7 @@ import { CliError } from "../../errors.js";
2
2
  import { showMachine } from "./session-view.js";
3
3
  export async function listWorkspaceHistoricalSessions(client, input) {
4
4
  const snapshot = await client.snapshot();
5
- const providerLimit = Math.min(200, Math.max(input.limit, 50));
5
+ const providerPageSize = Math.min(200, Math.max(Math.min(input.limit, 200), 50));
6
6
  const selectedMachines = snapshot.machines.filter((machine) => {
7
7
  if (input.machineId)
8
8
  return machine.id === input.machineId;
@@ -17,13 +17,26 @@ export async function listWorkspaceHistoricalSessions(client, input) {
17
17
  const rows = await Promise.all(machines.map(async ({ machine, projected }) => {
18
18
  const machineName = projected.metadata.name ?? projected.metadata.hostname ?? machine.name;
19
19
  try {
20
- const sessions = await client.listHistoricalSessions({
21
- machineId: machine.id,
22
- cwd: input.cwd,
23
- agent: input.agent,
24
- includeArchived: input.includeArchived,
25
- limit: providerLimit,
26
- });
20
+ const sessions = [];
21
+ let cursor;
22
+ const seenCursors = new Set();
23
+ while (sessions.length < input.limit) {
24
+ const page = await client.listHistoricalSessionsPage({
25
+ machineId: machine.id,
26
+ cwd: input.cwd,
27
+ agent: input.agent,
28
+ includeArchived: input.includeArchived,
29
+ limit: providerPageSize,
30
+ cursor,
31
+ });
32
+ sessions.push(...page.sessions);
33
+ if (!page.hasMore || !page.nextCursor)
34
+ break;
35
+ if (seenCursors.has(page.nextCursor))
36
+ break;
37
+ seenCursors.add(page.nextCursor);
38
+ cursor = page.nextCursor;
39
+ }
27
40
  return {
28
41
  sessions: sessions
29
42
  .filter((session) => input.includeArchived === true || session.runtimeState !== "archived")
@@ -217,6 +217,130 @@ function diagnosticSessionMetadata(metadata) {
217
217
  emptyRuntimeSession: metadata.emptyRuntimeSession === true,
218
218
  };
219
219
  }
220
+ function addRuntimeSessionId(ids, value) {
221
+ if (typeof value === "string" && value.trim())
222
+ ids.add(value.trim());
223
+ }
224
+ function metadataRuntimeSessionIds(metadata) {
225
+ const ids = new Set();
226
+ addRuntimeSessionId(ids, stringMetadata(metadata, "importedFromRuntimeSessionId"));
227
+ const runtime = isRecord(metadata.runtime) ? metadata.runtime : {};
228
+ addRuntimeSessionId(ids, runtime.backendSessionId);
229
+ addRuntimeSessionId(ids, runtime.agentSessionId);
230
+ addRuntimeSessionId(ids, runtime.acpxRecordId);
231
+ if (isRecord(runtime.externalHandle))
232
+ addRuntimeSessionId(ids, runtime.externalHandle.sessionId);
233
+ return ids;
234
+ }
235
+ function normalizedMatchValue(value) {
236
+ return typeof value === "string" && value.trim() ? value.trim().toLocaleLowerCase() : undefined;
237
+ }
238
+ function runtimeBindingCandidates(session, metadata, historicalSessions) {
239
+ const knownRuntimeIds = metadataRuntimeSessionIds(metadata);
240
+ const expectedAgent = stringMetadata(metadata, "agent") ?? session.agent;
241
+ const expectedCwd = stringMetadata(metadata, "cwd") ?? session.cwd;
242
+ const names = new Set([
243
+ normalizedMatchValue(stringMetadata(metadata, "name")),
244
+ normalizedMatchValue(stringMetadata(metadata, "providerName")),
245
+ normalizedMatchValue(session.name),
246
+ ].filter((value) => Boolean(value)));
247
+ const summaries = new Set([
248
+ normalizedMatchValue(stringMetadata(metadata, "importedSummary")),
249
+ normalizedMatchValue(session.name),
250
+ ].filter((value) => Boolean(value)));
251
+ return historicalSessions
252
+ .filter((historical) => historical.agent === expectedAgent &&
253
+ historical.cwd === expectedCwd &&
254
+ !knownRuntimeIds.has(historical.runtimeSessionId))
255
+ .map((historical) => {
256
+ const reasons = [];
257
+ let score = 0;
258
+ const historicalName = normalizedMatchValue(historical.name ?? historical.runtimeSessionName);
259
+ const historicalSummary = normalizedMatchValue(historical.summary);
260
+ if (historicalName && names.has(historicalName)) {
261
+ score += 100;
262
+ reasons.push("name");
263
+ }
264
+ if (historicalSummary && summaries.has(historicalSummary)) {
265
+ score += 30;
266
+ reasons.push("summary");
267
+ }
268
+ if (historical.loadedSessionId) {
269
+ score -= 20;
270
+ reasons.push("already-loaded");
271
+ }
272
+ if (score <= 0)
273
+ return undefined;
274
+ return {
275
+ runtimeSessionId: historical.runtimeSessionId,
276
+ agent: historical.agent,
277
+ cwd: historical.cwd,
278
+ name: historical.name ?? historical.runtimeSessionName,
279
+ summary: historical.summary,
280
+ updatedAt: historical.updatedAt,
281
+ score,
282
+ reasons,
283
+ ...(historical.runtimeState === "archived" ? { runtimeState: "archived" } : {}),
284
+ };
285
+ })
286
+ .filter((candidate) => Boolean(candidate))
287
+ .sort((left, right) => right.score - left.score || right.updatedAt - left.updatedAt || left.runtimeSessionId.localeCompare(right.runtimeSessionId));
288
+ }
289
+ function shouldSearchRuntimeBindingCandidates(latestEvents, metadata) {
290
+ const available = numberMetadata(metadata, "availableHistoricalEvents");
291
+ const emitted = numberMetadata(metadata, "backfilledEvents");
292
+ return latestEvents.length === 0 &&
293
+ (available === undefined || available === 0) &&
294
+ (emitted === undefined || emitted === 0);
295
+ }
296
+ function runtimeBindingSuspected(candidates) {
297
+ return candidates.some((candidate) => candidate.reasons.includes("name") && !candidate.reasons.includes("already-loaded"));
298
+ }
299
+ async function findRuntimeBindingCandidates(client, session, metadata, flags) {
300
+ const limit = parsePositiveIntFlag(flags, "runtime-limit") ?? 500;
301
+ const result = await listWorkspaceHistoricalSessions(client, {
302
+ machineId: session.machineId,
303
+ cwd: stringMetadata(metadata, "cwd") ?? session.cwd,
304
+ agent: stringMetadata(metadata, "agent") ?? session.agent,
305
+ includeArchived: true,
306
+ limit,
307
+ });
308
+ return {
309
+ candidates: runtimeBindingCandidates(session, metadata, result.sessions).slice(0, 8),
310
+ searched: true,
311
+ limit,
312
+ };
313
+ }
314
+ async function resolveRuntimeBindingRepairTarget(client, session, metadata, flags) {
315
+ const runtimeSessionId = typeof flags["runtime-session"] === "string" ? flags["runtime-session"].trim() : undefined;
316
+ const limit = parsePositiveIntFlag(flags, "runtime-limit") ?? 500;
317
+ const result = await listWorkspaceHistoricalSessions(client, {
318
+ machineId: session.machineId,
319
+ cwd: stringMetadata(metadata, "cwd") ?? session.cwd,
320
+ agent: stringMetadata(metadata, "agent") ?? session.agent,
321
+ includeArchived: true,
322
+ limit,
323
+ });
324
+ const candidateDiagnostics = runtimeBindingCandidates(session, metadata, result.sessions);
325
+ if (runtimeSessionId) {
326
+ const explicit = result.sessions.find((item) => item.runtimeSessionId === runtimeSessionId);
327
+ if (!explicit)
328
+ throw new CliError(`Runtime session not found in the first ${limit} historical rows: ${runtimeSessionId}`, "RUNTIME_SESSION_NOT_FOUND");
329
+ return { candidate: explicit, candidates: candidateDiagnostics.slice(0, 8) };
330
+ }
331
+ const usableCandidates = candidateDiagnostics.filter((candidate) => !candidate.reasons.includes("already-loaded"));
332
+ if (usableCandidates.length === 0) {
333
+ throw new CliError("No unambiguous runtime binding repair candidate found. Re-run with --runtime-session <id>.", "SESSION_BINDING_REPAIR_UNAVAILABLE");
334
+ }
335
+ const [first, second] = usableCandidates;
336
+ if (second && second.score === first.score) {
337
+ throw new CliError("Multiple runtime binding repair candidates found. Re-run with --runtime-session <id>.", "SESSION_BINDING_REPAIR_AMBIGUOUS");
338
+ }
339
+ const selected = result.sessions.find((item) => item.runtimeSessionId === first.runtimeSessionId);
340
+ if (!selected)
341
+ throw new CliError("Selected repair candidate disappeared from runtime history", "SESSION_BINDING_REPAIR_UNAVAILABLE");
342
+ return { candidate: selected, candidates: candidateDiagnostics.slice(0, 8) };
343
+ }
220
344
  export async function handleSession({ domain, action, positional, flags }) {
221
345
  if (domain !== "session")
222
346
  return false;
@@ -372,6 +496,9 @@ export async function handleSession({ domain, action, positional, flags }) {
372
496
  const turns = summarizeTurns(latestPage.events);
373
497
  const latestCompletedTurn = turns.find((turn) => turn.status === "completed");
374
498
  const latestTerminalTurn = turns.find((turn) => turn.status !== "running");
499
+ const runtimeBinding = shouldSearchRuntimeBindingCandidates(latestPage.events, metadata)
500
+ ? await findRuntimeBindingCandidates(client, session, metadata, flags)
501
+ : { candidates: [], searched: false, limit: parsePositiveIntFlag(flags, "runtime-limit") ?? 500 };
375
502
  const repair = flags["repair-head"] === true
376
503
  ? await client.repairSessionHead(session.id, repairHeadInputForLatestCompletedEvent(latestPage.events))
377
504
  : undefined;
@@ -430,9 +557,20 @@ export async function handleSession({ domain, action, positional, flags }) {
430
557
  backfillAvailableEvents: numberMetadata(metadata, "availableHistoricalEvents"),
431
558
  backfillEmittedEvents: numberMetadata(metadata, "backfilledEvents"),
432
559
  backfillStatus: stringMetadata(metadata, "historicalBackfillStatus") ?? stringMetadata(metadata, "historicalBackfill"),
560
+ wrongRuntimeBindingSuspected: runtimeBindingSuspected(runtimeBinding.candidates),
561
+ runtimeBindingCandidateCount: runtimeBinding.candidates.length,
433
562
  staleHeadSuspected: staleHeadSuspected(session, latestCompletedTurn),
434
563
  headMatchesLatestCompletedTurn: headMatchesTurn(session, latestCompletedTurn),
435
564
  },
565
+ runtimeBinding: {
566
+ searched: runtimeBinding.searched,
567
+ searchLimit: runtimeBinding.limit,
568
+ suspected: runtimeBindingSuspected(runtimeBinding.candidates),
569
+ candidates: runtimeBinding.candidates,
570
+ repairCommand: runtimeBinding.candidates[0]
571
+ ? `happy-elves session repair-binding ${session.id} --runtime-session ${runtimeBinding.candidates[0].runtimeSessionId} --json`
572
+ : undefined,
573
+ },
436
574
  repair: repair ? {
437
575
  advanced: repair.advanced,
438
576
  basis: repair.basis,
@@ -452,7 +590,57 @@ export async function handleSession({ domain, action, positional, flags }) {
452
590
  console.log(`Backfill: ${data.diagnosis.backfillStatus ?? "-"} available=${data.diagnosis.backfillAvailableEvents ?? "-"} emitted=${data.diagnosis.backfillEmittedEvents ?? "-"}`);
453
591
  console.log(`Head: ${session.currentHead ?? "-"} lastTurn=${session.lastTurnId ?? "-"} stale=${data.diagnosis.staleHeadSuspected ? "yes" : "no"}`);
454
592
  console.log(`Latest page: ${latestPage.events.length} events, visible=${latestPage.events.filter(isVisibleTranscriptPayloadEvent).length}, hasMore=${latestPage.hasMoreBefore}`);
593
+ if (data.runtimeBinding.suspected && data.runtimeBinding.candidates[0]) {
594
+ console.log(`Runtime binding: suspected wrong binding; candidate=${data.runtimeBinding.candidates[0].runtimeSessionId}`);
595
+ console.log(`Repair: ${data.runtimeBinding.repairCommand}`);
596
+ }
597
+ }
598
+ return true;
599
+ }
600
+ if (action === "repair-binding") {
601
+ const sessionId = requirePositional(positional[0], "sessionId");
602
+ const client = await makeClient();
603
+ const session = await client.getSession(sessionId);
604
+ if (!session)
605
+ throw new CliError("Session not found", "SESSION_NOT_FOUND");
606
+ if (session.status === "running")
607
+ throw new CliError("Cannot repair binding for a running session", "SESSION_BINDING_REPAIR_RUNNING");
608
+ const metadata = await client.decodeSessionMetadata(session);
609
+ const { candidate, candidates } = await resolveRuntimeBindingRepairTarget(client, session, metadata, flags);
610
+ const name = typeof flags.name === "string" && flags.name.trim()
611
+ ? flags.name.trim()
612
+ : stringMetadata(metadata, "name") ?? session.name ?? candidate.name ?? candidate.summary ?? candidate.runtimeSessionId;
613
+ const imported = await client.importHistoricalSession({
614
+ machineId: session.machineId,
615
+ agent: candidate.agent,
616
+ cwd: candidate.cwd,
617
+ runtimeSessionId: candidate.runtimeSessionId,
618
+ name,
619
+ summary: candidate.summary,
620
+ runtimeState: candidate.runtimeState,
621
+ ...(candidate.name ? { providerName: candidate.name } : {}),
622
+ });
623
+ const closeSource = flags["keep-source"] !== true;
624
+ let sourceClosed = false;
625
+ if (closeSource) {
626
+ await client.close(session.id, `replaced by runtime session ${candidate.runtimeSessionId}`);
627
+ sourceClosed = true;
455
628
  }
629
+ ok("session.repair-binding", {
630
+ sourceSessionId: session.id,
631
+ sessionId: imported.sessionId,
632
+ runtimeSessionId: candidate.runtimeSessionId,
633
+ mode: "created",
634
+ sourceClosed,
635
+ candidate: {
636
+ runtimeSessionId: candidate.runtimeSessionId,
637
+ name: candidate.name ?? candidate.runtimeSessionName,
638
+ summary: candidate.summary,
639
+ updatedAt: candidate.updatedAt,
640
+ ...(candidate.runtimeState === "archived" ? { runtimeState: "archived" } : {}),
641
+ },
642
+ candidates,
643
+ }, { requestId: imported.requestId, machineId: session.machineId, sessionId: imported.sessionId });
456
644
  return true;
457
645
  }
458
646
  if (action === "history") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.40",
3
+ "version": "0.1.0-beta.41",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.40",
3
+ "version": "0.1.0-beta.41",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {