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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +2 -2
  2. package/apps/cli/package.json +1 -1
  3. package/apps/daemon/dist/direct/server.js +2 -0
  4. package/apps/daemon/dist/orchestration/crew-queue.js +3 -1
  5. package/apps/daemon/dist/orchestration/handler.d.ts +4 -0
  6. package/apps/daemon/dist/orchestration/handler.js +195 -1
  7. package/apps/daemon/dist/orchestration/queue-state.js +3 -0
  8. package/apps/daemon/dist/orchestration/store.d.ts +10 -0
  9. package/apps/daemon/dist/orchestration/store.js +52 -4
  10. package/apps/daemon/dist/relay/connection.js +7 -1
  11. package/apps/daemon/dist/relay/send.js +1 -0
  12. package/apps/daemon/dist/runtime/headless-event-projector.js +8 -6
  13. package/apps/daemon/dist/session/he-official/common.d.ts +6 -0
  14. package/apps/daemon/dist/session/prompt.d.ts +10 -1
  15. package/apps/daemon/dist/session/prompt.js +11 -2
  16. package/apps/daemon/dist/update-barrier.js +1 -0
  17. package/apps/daemon/package.json +1 -1
  18. package/package.json +2 -2
  19. package/packages/client/dist/client.d.ts +9 -1
  20. package/packages/client/dist/client.js +47 -2
  21. package/packages/client/dist/product-materializer.js +2 -0
  22. package/packages/client/dist/transport.d.ts +5 -0
  23. package/packages/client/dist/transport.js +7 -0
  24. package/packages/client/dist/types.d.ts +43 -1
  25. package/packages/client/dist/workspace-shared-config.d.ts +5 -0
  26. package/packages/client/dist/workspace-shared-config.js +35 -0
  27. package/packages/headless-runtime/dist/codex-provider.js +10 -1
  28. package/packages/shared/dist/protocol-schemas.d.ts +70 -0
  29. package/packages/shared/dist/protocol-schemas.js +18 -0
  30. package/packages/shared/dist/protocol-types.d.ts +32 -0
  31. package/packages/shared/dist/protocol.d.ts +62 -1
  32. package/packages/shared/dist/protocol.js +53 -1
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.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
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.506
10
+ npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.506 -- 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.504",
3
+ "version": "0.1.0-beta.506",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -395,6 +395,8 @@ class DirectState {
395
395
  return this.machineScoped(message, "machine:dispatch", connection);
396
396
  if (message.type === "controller:queue")
397
397
  return this.machineScoped(message, "machine:queue", connection);
398
+ if (message.type === "controller:followup")
399
+ return this.machineScoped(message, "machine:followup", connection);
398
400
  if (message.type === "controller:crewQueueEnqueue")
399
401
  return this.machineScoped(message, "machine:crewQueueEnqueue", connection);
400
402
  if (message.type === "controller:crewQueueUpdate")
@@ -298,7 +298,9 @@ async function completeMaterializedCrewQueueHead(config, queue) {
298
298
  .find((record) => record.crewSessionId === queue.crewSessionId && (record.crewMachineId ?? queue.crewMachineId) === queue.crewMachineId);
299
299
  if (!head)
300
300
  return "none";
301
- const turnId = head.turnId ?? stableId("turn", head.taskId, head.requestId, "brief");
301
+ // Follow-ups carry the caller's stable queued turn identity before delivery;
302
+ // recovery must use it to find a runtime turn created just before a crash.
303
+ const turnId = head.turnId ?? (head.kind === "followup" ? head.queuedTurnId : undefined) ?? stableId("turn", head.taskId, head.requestId, "brief");
302
304
  const runtimeTurnId = head.runtimeTurnId ?? head.providerTurnId ?? await runtimeTurnIdForCrewTurn(queue.crewSessionId, turnId);
303
305
  if (!runtimeTurnId)
304
306
  return "none";
@@ -7,6 +7,9 @@ type DispatchCommand = Extract<MachineCommand, {
7
7
  type QueueCommand = Extract<MachineCommand, {
8
8
  type: "machine:queue";
9
9
  }>;
10
+ type FollowUpCommand = Extract<MachineCommand, {
11
+ type: "machine:followup";
12
+ }>;
10
13
  export type DispatchListCommand = Extract<DispatchCommand, {
11
14
  action: "list";
12
15
  }>;
@@ -17,6 +20,7 @@ export type DispatchListResult = {
17
20
  };
18
21
  export declare function handleDispatchCommand(ws: WebSocket, config: DaemonConfig, command: DispatchCommand): Promise<void>;
19
22
  export declare function handleQueueCommand(ws: WebSocket, config: DaemonConfig, command: QueueCommand): Promise<void>;
23
+ export declare function handleFollowUpCommand(ws: WebSocket, config: DaemonConfig, command: FollowUpCommand): Promise<void>;
20
24
  export declare function dispatchListResultForCommand(config: DaemonConfig, command: DispatchListCommand): Promise<DispatchListResult>;
21
25
  export declare function remoteLoadedSessionInventory(config: DaemonConfig, machineId: string, filters?: {
22
26
  cwd?: string;
@@ -8,7 +8,7 @@ import { claimCommandRequest, sendCommandResponse } from "../relay/send.js";
8
8
  import { runtimeRead, runtimeSessionCheckpoint, runtimeTurnSummaryForTask } from "../session/runtime-truth.js";
9
9
  import { loadSessionProjections } from "../session/store.js";
10
10
  import { listArtifacts } from "../artifact/store.js";
11
- import { ackAttention, completeClaimedQueueAdmission, crewQueueHasBacklog, deadAttention, failClaimedQueueAdmission, findAdmissionByTask, findAdmissionByTaskAndRequest, latestAttentionForTask, listAdmissions, listAttentions, recordAdmission, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, retryAttention, retryClaimedQueueAdmission, runtimeTurnIdForCrewTurn, updateAdmission, updatePendingConsumerQueueEnqueue, upsertSubscription, } from "./store.js";
11
+ import { ackAttention, completeClaimedQueueAdmission, crewQueueHasBacklog, deadAttention, failClaimedQueueAdmission, findAdmissionByTask, findAdmissionByTaskAndRequest, latestAttentionForTask, listAdmissions, listAttentions, recordAdmission, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, retryAttention, retryClaimedQueueAdmission, runtimeTurnIdForCrewTurn, updateAdmission, updatePendingConsumerQueueEnqueue, cancelQueuedFollowUp, findFollowUpById, listFollowUpsForSession, upsertSubscription, } from "./store.js";
12
12
  import { catchUpSubscriptionEvents } from "./watcher.js";
13
13
  export async function handleDispatchCommand(ws, config, command) {
14
14
  // Status/list are read projections, not mutation retries. A caller may use
@@ -466,6 +466,161 @@ export async function handleQueueCommand(ws, config, command) {
466
466
  });
467
467
  }
468
468
  }
469
+ export async function handleFollowUpCommand(ws, config, command) {
470
+ // Follow-up request IDs are scoped to this command contract. Never let the
471
+ // generic 24h response cache bypass target/session/payload validation (or
472
+ // replay a response from another command using the same correlation id).
473
+ completedCommandResponses.delete(command.requestId);
474
+ if (command.machineId !== config.machineId) {
475
+ await sendCommandResponse(ws, { type: "machine:error", requestId: command.requestId, code: "WRONG_MACHINE", message: `Follow-up command targeted ${command.machineId}, not ${config.machineId}` });
476
+ return;
477
+ }
478
+ const reply = async (result) => {
479
+ await sendCommandResponse(ws, { type: "machine:followupResult", requestId: command.requestId, machineId: config.machineId, result });
480
+ };
481
+ if (command.action === "list" || command.action === "cancel") {
482
+ const session = await ensureSessionLoaded(command.sessionId);
483
+ if (!session) {
484
+ await sendCommandResponse(ws, {
485
+ type: "machine:error",
486
+ requestId: command.requestId,
487
+ code: "SESSION_NOT_FOUND",
488
+ capability: `followup.${command.action}`,
489
+ message: `Session ${command.sessionId} is not loaded on this daemon`,
490
+ });
491
+ return;
492
+ }
493
+ }
494
+ if (command.action === "list" || command.action === "cancel") {
495
+ if (!claimCommandRequest(ws, command.requestId))
496
+ return;
497
+ }
498
+ if (command.action === "list") {
499
+ const records = await listFollowUpsForSession(command.sessionId);
500
+ await reply({ action: "list", requestId: command.requestId, sessionId: command.sessionId, records: await Promise.all(records.map((record) => followUpQueueRecord(config, record))) });
501
+ return;
502
+ }
503
+ if (command.action === "cancel") {
504
+ const outcome = await cancelQueuedFollowUp(command.sessionId, command.followUpId);
505
+ if (outcome.status === "missing") {
506
+ await reply({ action: "cancel", requestId: command.requestId, sessionId: command.sessionId, followUpId: command.followUpId, cancelled: false, conflict: "Follow-up was not found" });
507
+ return;
508
+ }
509
+ if (outcome.status === "conflict") {
510
+ await reply({ action: "cancel", requestId: command.requestId, sessionId: command.sessionId, followUpId: command.followUpId, cancelled: false, conflict: "Follow-up is already being delivered", ...(outcome.record ? { followUp: await followUpQueueRecord(config, outcome.record) } : {}) });
511
+ return;
512
+ }
513
+ await reply({ action: "cancel", requestId: command.requestId, sessionId: command.sessionId, followUpId: command.followUpId, cancelled: true, ...(outcome.record ? { followUp: await followUpQueueRecord(config, outcome.record) } : {}) });
514
+ return;
515
+ }
516
+ const session = await ensureSessionLoaded(command.sessionId);
517
+ if (!session) {
518
+ await sendCommandResponse(ws, { type: "machine:error", requestId: command.requestId, code: "SESSION_NOT_FOUND", capability: "followup.enqueue", message: `Session ${command.sessionId} is not loaded on this daemon` });
519
+ return;
520
+ }
521
+ const existing = await findFollowUpById(command.sessionId, command.followUpId);
522
+ if (existing) {
523
+ const samePayload = existing.encryptedBrief
524
+ ? await sameFollowUpPayload(config, existing.encryptedBrief, command.encryptedPrompt)
525
+ : false;
526
+ const existingTurnId = existing.queuedTurnId ?? existing.turnId;
527
+ // Optional follow-up fields have protocol defaults. A caller replaying a
528
+ // request may omit them after the first attempt; compare the normalized
529
+ // execution instead of treating an omitted default as a conflict. A
530
+ // supplied turn id remains an explicit identity check, while an omitted
531
+ // turn id is intentionally compatible with the owner-assigned id.
532
+ const sameExecution = existing.requestId === command.requestId &&
533
+ samePayload &&
534
+ (command.turnId === undefined || existingTurnId === command.turnId) &&
535
+ (existing.permissionMode ?? "approve-reads") === crewTurnPermissionMode(command.permissionMode) &&
536
+ (existing.model ?? undefined) === (command.model ?? undefined) &&
537
+ (existing.thinkingLevel ?? undefined) === (command.thinkingLevel ?? undefined);
538
+ if (!sameExecution) {
539
+ await sendCommandResponse(ws, { type: "machine:error", requestId: command.requestId, code: "FOLLOWUP_IDEMPOTENCY_CONFLICT", capability: "followup.enqueue", message: `Follow-up ${command.followUpId} already exists with different content or request id` });
540
+ return;
541
+ }
542
+ if (!claimCommandRequest(ws, command.requestId))
543
+ return;
544
+ const { signalCrewQueue } = await import("./crew-queue.js");
545
+ void signalCrewQueue(config, { crewMachineId: config.machineId, crewSessionId: command.sessionId, reason: "admission" }).catch(() => undefined);
546
+ await reply({ action: "enqueue", requestId: command.requestId, followUp: await followUpQueueRecord(config, existing), replayed: true });
547
+ return;
548
+ }
549
+ if (!claimCommandRequest(ws, command.requestId))
550
+ return;
551
+ const now = new Date().toISOString();
552
+ const record = await recordAdmission({
553
+ kind: "followup",
554
+ followUpId: command.followUpId,
555
+ followUpStatus: "queued",
556
+ ...(command.turnId ? { queuedTurnId: command.turnId } : {}),
557
+ taskId: stableId("followup", command.sessionId, command.followUpId),
558
+ requestId: command.requestId,
559
+ consumerMachineId: config.machineId,
560
+ crewMachineId: config.machineId,
561
+ consumerSessionId: command.sessionId,
562
+ crewSessionId: command.sessionId,
563
+ agent: session.agent,
564
+ cwd: session.cwd,
565
+ briefVersion: 1,
566
+ admission: "accepted",
567
+ dispatch: "queued",
568
+ routeDecision: { kind: "reuse", crewSessionId: command.sessionId, ifBusy: "queue" },
569
+ queuedAt: now,
570
+ queueAuthority: "owner",
571
+ encryptedBrief: command.encryptedPrompt,
572
+ permissionMode: crewTurnPermissionMode(command.permissionMode),
573
+ model: command.model,
574
+ thinkingLevel: command.thinkingLevel,
575
+ subscribe: false,
576
+ queueAttempts: 0,
577
+ createdAt: now,
578
+ updatedAt: now,
579
+ });
580
+ if (record.requestId !== command.requestId) {
581
+ await sendCommandResponse(ws, { type: "machine:error", requestId: command.requestId, code: "FOLLOWUP_IDEMPOTENCY_CONFLICT", capability: "followup.enqueue", message: `Follow-up ${command.followUpId} already exists with a different request id` });
582
+ return;
583
+ }
584
+ const { signalCrewQueue } = await import("./crew-queue.js");
585
+ void signalCrewQueue(config, { crewMachineId: config.machineId, crewSessionId: command.sessionId, reason: "admission" }).catch(() => undefined);
586
+ await reply({ action: "enqueue", requestId: command.requestId, followUp: await followUpQueueRecord(config, record) });
587
+ }
588
+ async function sameFollowUpPayload(config, left, right) {
589
+ try {
590
+ const [leftPayload, rightPayload] = await Promise.all([
591
+ decryptJson(config.accountSecret, left),
592
+ decryptJson(config.accountSecret, right),
593
+ ]);
594
+ return JSON.stringify(leftPayload) === JSON.stringify(rightPayload);
595
+ }
596
+ catch {
597
+ return false;
598
+ }
599
+ }
600
+ async function followUpQueueRecord(config, record) {
601
+ let text;
602
+ if (record.encryptedBrief) {
603
+ try {
604
+ const payload = await decryptJson(config.accountSecret, record.encryptedBrief);
605
+ if (payload.type === "user_prompt")
606
+ text = payload.text;
607
+ }
608
+ catch { /* encrypted content is optional in projection */ }
609
+ }
610
+ return {
611
+ id: record.followUpId ?? record.taskId,
612
+ requestId: record.requestId,
613
+ sessionId: record.consumerSessionId,
614
+ machineId: record.crewMachineId ?? config.machineId,
615
+ status: record.followUpStatus ?? (record.dispatch === "delivered" ? "delivered" : record.dispatch === "failed" ? "failed" : "queued"),
616
+ ...(record.turnId ? { turnId: record.turnId } : {}),
617
+ ...(text !== undefined ? { text } : {}),
618
+ createdAt: record.createdAt,
619
+ ...(record.dispatch === "delivered" ? { deliveredAt: record.updatedAt } : {}),
620
+ ...(record.errorCode ? { errorCode: record.errorCode } : {}),
621
+ ...(record.error ? { error: record.error } : {}),
622
+ };
623
+ }
469
624
  export async function dispatchListResultForCommand(config, command) {
470
625
  const admissions = await listAdmissions({ consumerSessionId: command.consumerSessionId });
471
626
  const records = await Promise.all(admissions.map((admission) => dispatchTaskProjection(config, admission)));
@@ -772,6 +927,8 @@ export async function deliverQueuedCrewAdmission(config, admission) {
772
927
  const claimId = admission.queuedClaim?.claimId;
773
928
  if (!claimId)
774
929
  return admission;
930
+ if (admission.kind === "followup")
931
+ return await deliverQueuedFollowUpAdmission(config, admission);
775
932
  if (!crewSessionId || !admission.encryptedBrief) {
776
933
  const errorCode = "QUEUED_DISPATCH_INVALID";
777
934
  const error = !crewSessionId ? "Queued admission is missing crewSessionId" : "Queued admission is missing encrypted brief";
@@ -863,6 +1020,43 @@ export async function deliverQueuedCrewAdmission(config, admission) {
863
1020
  }
864
1021
  return delivered;
865
1022
  }
1023
+ async function deliverQueuedFollowUpAdmission(config, admission) {
1024
+ const claimId = admission.queuedClaim?.claimId;
1025
+ const sessionId = admission.crewSessionId;
1026
+ if (!claimId || !sessionId || !admission.encryptedBrief)
1027
+ return admission;
1028
+ const state = await crewSessionRuntimeState(config, config.machineId, sessionId);
1029
+ if (state.availability === "busy") {
1030
+ return await retryClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, error: "target Crew is still busy", queueReason: "target-crew-busy", retryAfterMs: 30_000 }) ?? admission;
1031
+ }
1032
+ if (state.availability === "unavailable") {
1033
+ if (state.retryable)
1034
+ return await retryClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, error: state.error ?? "Crew unavailable", queueReason: "target-crew-unavailable", retryAfterMs: queueRetryDelayMs(admission.queueAttempts ?? 1) }) ?? admission;
1035
+ return (await failClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, errorCode: state.errorCode ?? "CREW_SESSION_UNAVAILABLE", error: state.error ?? "Crew session unavailable" })).admission ?? admission;
1036
+ }
1037
+ const turnId = admission.turnId ?? admission.queuedTurnId ?? stableId("followup-turn", sessionId, admission.followUpId ?? admission.taskId);
1038
+ const promptRequestId = stableId("followup-prompt", admission.requestId, sessionId);
1039
+ try {
1040
+ await admitMachinePrompt(config, {
1041
+ requestId: promptRequestId,
1042
+ sessionId,
1043
+ turnId,
1044
+ encryptedPrompt: admission.encryptedBrief,
1045
+ permissionMode: admission.permissionMode ?? "approve-reads",
1046
+ ...(admission.model ? { model: admission.model } : {}),
1047
+ ...(admission.thinkingLevel ? { thinkingLevel: admission.thinkingLevel } : {}),
1048
+ });
1049
+ return await completeClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, turnId }) ?? admission;
1050
+ }
1051
+ catch (error) {
1052
+ const code = dispatchErrorCode(error);
1053
+ const message = error instanceof Error ? error.message : String(error);
1054
+ if (queueDeliveryErrorDisposition(code) === "permanent") {
1055
+ return (await failClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, errorCode: code, error: message })).admission ?? admission;
1056
+ }
1057
+ return await retryClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, error: message, queueReason: "delivery-retry", retryAfterMs: queueRetryDelayMs(admission.queueAttempts ?? 1) }) ?? admission;
1058
+ }
1059
+ }
866
1060
  async function deliverDispatch(config, command, admission, options = {}) {
867
1061
  const crewSessionId = admission.crewSessionId ?? stableId("ses", admission.taskId, admission.requestId, "crew");
868
1062
  const turnId = admission.turnId ?? stableId("turn", admission.taskId, admission.requestId, "brief");
@@ -125,6 +125,7 @@ export function retryClaimedQueueAdmissionRecord(record, input) {
125
125
  return {
126
126
  ...record,
127
127
  dispatch: "queued",
128
+ ...(record.kind === "followup" ? { followUpStatus: "queued" } : {}),
128
129
  turnId: undefined,
129
130
  subscriptionCursor: undefined,
130
131
  runtimeTurnId: undefined,
@@ -144,6 +145,7 @@ export function failClaimedQueueAdmissionRecord(record, input) {
144
145
  return {
145
146
  ...record,
146
147
  dispatch: "failed",
148
+ ...(record.kind === "followup" ? { followUpStatus: "failed" } : {}),
147
149
  queuedClaim: undefined,
148
150
  nextAttemptAt: undefined,
149
151
  lastQueueError: input.error,
@@ -156,6 +158,7 @@ function completeQueuedAdmissionRecord(record, input) {
156
158
  return {
157
159
  ...record,
158
160
  dispatch: "delivered",
161
+ ...(record.kind === "followup" ? { followUpStatus: "delivered" } : {}),
159
162
  turnId: input.turnId,
160
163
  subscriptionCursor: input.subscriptionCursor,
161
164
  runtimeTurnId: input.runtimeTurnId,
@@ -1,5 +1,9 @@
1
1
  import type { DispatchAdmissionRecord, EncryptedEnvelope, PermissionMode, TaskAttentionEventType, TaskAttentionPayloadRef, TaskAttentionRecord, TaskAttentionStatus } from "../../../../packages/shared/dist/index.js";
2
2
  export type StoredDispatchAdmissionRecord = DispatchAdmissionRecord & {
3
+ kind?: "dispatch" | "followup";
4
+ followUpId?: string;
5
+ followUpStatus?: "queued" | "delivered" | "failed" | "cancelled";
6
+ queuedTurnId?: string;
3
7
  dispatchRequestId?: string;
4
8
  queueAuthority?: "owner" | "consumer";
5
9
  encryptedBrief?: EncryptedEnvelope;
@@ -131,6 +135,12 @@ export declare function readOrchestrationStore(): Promise<OrchestrationStore>;
131
135
  export declare function updateOrchestrationStore(update: (store: OrchestrationStore) => OrchestrationStore | Promise<OrchestrationStore>): Promise<OrchestrationStore>;
132
136
  export declare function findAdmissionByTaskAndRequest(taskId: string, requestId: string): Promise<StoredDispatchAdmissionRecord | undefined>;
133
137
  export declare function findAdmissionByTask(taskId: string): Promise<StoredDispatchAdmissionRecord | undefined>;
138
+ export declare function findFollowUpById(sessionId: string, followUpId: string): Promise<StoredDispatchAdmissionRecord | undefined>;
139
+ export declare function listFollowUpsForSession(sessionId: string): Promise<StoredDispatchAdmissionRecord[]>;
140
+ export declare function cancelQueuedFollowUp(sessionId: string, followUpId: string): Promise<{
141
+ status: "cancelled" | "missing" | "conflict";
142
+ record?: StoredDispatchAdmissionRecord;
143
+ }>;
134
144
  export declare function findAdmissionByCrewTurn(crewSessionId: string, turnId: string): Promise<StoredDispatchAdmissionRecord | undefined>;
135
145
  export declare function runtimeTurnIdForCrewTurn(crewSessionId: string, turnId: string | undefined): Promise<string | undefined>;
136
146
  export declare function listAdmissions(input?: {
@@ -29,6 +29,41 @@ export async function findAdmissionByTask(taskId) {
29
29
  const store = await readOrchestrationStore();
30
30
  return latestAdmission(store.admissions.filter((record) => record.taskId === taskId));
31
31
  }
32
+ export async function findFollowUpById(sessionId, followUpId) {
33
+ const store = await readOrchestrationStore();
34
+ return store.admissions.find((record) => record.kind === "followup" && record.consumerSessionId === sessionId && record.followUpId === followUpId);
35
+ }
36
+ export async function listFollowUpsForSession(sessionId) {
37
+ const store = await readOrchestrationStore();
38
+ return store.admissions
39
+ // Keep delivered records queryable until transcript history has caught up.
40
+ // The Web projection removes them after the exact user_prompt echo, but a
41
+ // closed/reloaded page must still be able to reconcile the durable result.
42
+ .filter((record) => record.kind === "followup" && record.consumerSessionId === sessionId && (record.dispatch === "queued" ||
43
+ record.dispatch === "delivered" ||
44
+ record.followUpStatus === "failed" ||
45
+ record.followUpStatus === "cancelled"))
46
+ .sort((left, right) => (left.queueOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.queueOrdinal ?? Number.MAX_SAFE_INTEGER) || left.createdAt.localeCompare(right.createdAt));
47
+ }
48
+ export async function cancelQueuedFollowUp(sessionId, followUpId) {
49
+ let result = { status: "missing" };
50
+ await updateOrchestrationStore((current) => {
51
+ const index = current.admissions.findIndex((record) => record.kind === "followup" && record.consumerSessionId === sessionId && record.followUpId === followUpId);
52
+ if (index < 0)
53
+ return current;
54
+ const previous = current.admissions[index];
55
+ if (previous.dispatch !== "queued" || previous.queuedClaim) {
56
+ result = { status: "conflict", record: previous };
57
+ return current;
58
+ }
59
+ const now = new Date().toISOString();
60
+ const next = { ...previous, dispatch: "failed", followUpStatus: "cancelled", errorCode: "FOLLOWUP_CANCELLED", error: "Follow-up cancelled before delivery", updatedAt: now };
61
+ current.admissions[index] = next;
62
+ result = { status: "cancelled", record: next };
63
+ return current;
64
+ });
65
+ return result;
66
+ }
32
67
  export async function findAdmissionByCrewTurn(crewSessionId, turnId) {
33
68
  const store = await readOrchestrationStore();
34
69
  return latestAdmission(store.admissions.filter((record) => record.crewSessionId === crewSessionId && record.turnId === turnId));
@@ -424,13 +459,17 @@ export async function ownerCrewQueuePromptGate(input) {
424
459
  .find((record) => admissionMatchesCrew(record, input));
425
460
  if (!head)
426
461
  return { allowed: true };
427
- const expectedPromptRequestId = stableId("req", head.taskId, head.requestId, "prompt");
462
+ const expectedPromptRequestId = head.kind === "followup"
463
+ ? stableId("followup-prompt", head.requestId, input.crewSessionId)
464
+ : stableId("req", head.taskId, head.requestId, "prompt");
428
465
  const leaseUntil = head.queuedClaim?.leaseUntil ? Date.parse(head.queuedClaim.leaseUntil) : NaN;
429
466
  const validClaim = Boolean(head.queuedClaim?.claimId) && Number.isFinite(leaseUntil) && leaseUntil > now.getTime();
430
467
  if (validClaim && input.promptRequestId === expectedPromptRequestId)
431
468
  return { allowed: true };
432
469
  if (input.promptRequestId === expectedPromptRequestId) {
433
- const expectedTurnId = stableId("turn", head.taskId, head.requestId, "brief");
470
+ const expectedTurnId = head.kind === "followup"
471
+ ? stableId("followup-turn", input.crewSessionId, head.followUpId ?? head.taskId)
472
+ : stableId("turn", head.taskId, head.requestId, "brief");
434
473
  if (await runtimeTurnIdForCrewTurn(input.crewSessionId, expectedTurnId))
435
474
  return { allowed: true };
436
475
  }
@@ -569,6 +608,7 @@ export async function failQueuedCrewBacklog(input) {
569
608
  const admission = withOwnerQueueRevision(current, {
570
609
  ...previous,
571
610
  dispatch: "failed",
611
+ ...(previous.kind === "followup" ? { followUpStatus: "failed" } : {}),
572
612
  queuedClaim: undefined,
573
613
  nextAttemptAt: undefined,
574
614
  lastQueueError: input.error,
@@ -612,8 +652,16 @@ async function updateClaimedQueueAdmission(input, transition) {
612
652
  return result;
613
653
  }
614
654
  export async function recordAdmission(record) {
655
+ let conflictRecord;
615
656
  const store = await updateOrchestrationStore((current) => {
616
- const index = current.admissions.findIndex((item) => item.taskId === record.taskId && item.requestId === record.requestId);
657
+ let index = current.admissions.findIndex((item) => item.taskId === record.taskId && item.requestId === record.requestId);
658
+ if (index < 0 && record.kind === "followup" && record.followUpId) {
659
+ index = current.admissions.findIndex((item) => item.kind === "followup" && item.followUpId === record.followUpId && item.consumerSessionId === record.consumerSessionId);
660
+ if (index >= 0) {
661
+ conflictRecord = current.admissions[index];
662
+ return current;
663
+ }
664
+ }
617
665
  if (index >= 0) {
618
666
  const next = normalizeQueuedStoreFields(current, { ...current.admissions[index], ...record, updatedAt: record.updatedAt }, false);
619
667
  current.admissions[index] = next;
@@ -626,7 +674,7 @@ export async function recordAdmission(record) {
626
674
  }
627
675
  return current;
628
676
  });
629
- return store.admissions.find((item) => item.taskId === record.taskId && item.requestId === record.requestId) ?? record;
677
+ return conflictRecord ?? store.admissions.find((item) => item.taskId === record.taskId && item.requestId === record.requestId) ?? record;
630
678
  }
631
679
  export async function updateAdmission(taskId, requestId, patch) {
632
680
  const updatedAt = new Date().toISOString();
@@ -4,7 +4,7 @@ import { drainPendingArtifactFeedbackAttentions, handleArtifactCommand } from ".
4
4
  import { handleGatewayCommand } from "../gateway/handler.js";
5
5
  import { tickLoops } from "../loop/runner.js";
6
6
  import { handleMemoryCommand } from "../memory/handler.js";
7
- import { handleDispatchCommand, handleQueueCommand } from "../orchestration/handler.js";
7
+ import { handleDispatchCommand, handleFollowUpCommand, handleQueueCommand } from "../orchestration/handler.js";
8
8
  import { handleCrewQueueEnqueueCommand, handleCrewQueueUpdateCommand, recoverCrewQueues } from "../orchestration/crew-queue.js";
9
9
  import { tickOrchestration } from "../orchestration/runner.js";
10
10
  import { handleScheduleCommand } from "../schedule/handler.js";
@@ -160,6 +160,10 @@ export async function handleServerMessage(ws, config, message) {
160
160
  await handleQueueCommand(ws, config, message);
161
161
  return;
162
162
  }
163
+ if (message.type === "machine:followup") {
164
+ await handleFollowUpCommand(ws, config, message);
165
+ return;
166
+ }
163
167
  if (message.type === "machine:crewQueueEnqueue") {
164
168
  await handleCrewQueueEnqueueCommand(ws, config, message);
165
169
  return;
@@ -491,6 +495,8 @@ async function machineCommandFromController(ws, config, message, senderNodeId) {
491
495
  return { ...message, type: "machine:dispatch" };
492
496
  if (message.type === "controller:queue")
493
497
  return { ...message, type: "machine:queue" };
498
+ if (message.type === "controller:followup")
499
+ return { ...message, type: "machine:followup" };
494
500
  if (message.type === "controller:crewQueueEnqueue") {
495
501
  if (senderNodeId !== message.consumerMachineId) {
496
502
  await sendCommandResponse(ws, {
@@ -23,6 +23,7 @@ const criticalMessageTypes = new Set([
23
23
  "machine:dispatchResult",
24
24
  "machine:crewQueueUpdateApplied",
25
25
  "machine:queueResult",
26
+ "machine:followupResult",
26
27
  "machine:artifactResult",
27
28
  "machine:turnReconciled",
28
29
  "machine:error",
@@ -900,18 +900,20 @@ export function historicalEventsFromThread(items, turns, limit, runtimeEvents =
900
900
  if (event)
901
901
  addEvent(event);
902
902
  }
903
- for (const runtimeEvent of runtimeEvents) {
904
- const event = userMessageHistoricalEventFromRuntimeEvent(runtimeEvent);
905
- if (event)
906
- addEvent(event);
907
- }
903
+ // The canonical item projection is authoritative for text, including user
904
+ // prompts. Do not add user-message snapshots back from the append-only log:
905
+ // a native-history reconciliation can deliberately remove stale HE item
906
+ // identities, and replaying those raw events here would resurrect them in
907
+ // the rebuilt transcript. Forward/anchored reads still use
908
+ // historicalEventsFromRuntimeEvents, where the append-only log is the
909
+ // authority until a canonical rebuild is requested.
908
910
  // Server requests and provider/HE tool calls are not guaranteed to be
909
911
  // represented by canonical ThreadItems. Preserve only those non-text
910
912
  // runtime events which are absent from the canonical identity set. Text
911
913
  // snapshots remain canonical-only: merging them would resurrect the raw
912
914
  // assistant history that the rebuild deliberately replaces.
913
915
  for (const event of historicalEventsFromRuntimeEvents(runtimeEvents)) {
914
- if (event.payload.type === "text_delta" || event.payload.type === "done")
916
+ if (event.payload.type === "user_prompt" || event.payload.type === "text_delta" || event.payload.type === "done")
915
917
  continue;
916
918
  if (event.payload.type === "tool_call" && runtimeEventMatchesCanonicalItem(event, canonicalToolItemIds, canonicalToolCallIds))
917
919
  continue;
@@ -264,6 +264,12 @@ export declare function messageResult(message: MachineClientMessage | undefined,
264
264
  requestId: string;
265
265
  machineId: string;
266
266
  result: import("../../../../../packages/shared/dist/index.js").QueueCommandResult;
267
+ } | {
268
+ type: "machine:followupResult";
269
+ requestId: string;
270
+ machineId: string;
271
+ messageId?: string;
272
+ result: import("../../../../../packages/shared/dist/index.js").FollowUpQueueResult;
267
273
  } | {
268
274
  type: "machine:artifactResult";
269
275
  requestId: string;
@@ -4,6 +4,14 @@ type PromptCommand = Extract<MachineCommand, {
4
4
  type: "machine:prompt";
5
5
  }>;
6
6
  type PromptExecutionPhase = "decrypt-prompt" | "prepare-attachments" | "decrypt-memory-options" | "prepare-memory-context" | "emit-submitted" | "emit-memory-status" | "prepare-first-agent-context" | "start-turn" | "attach-turn" | "persist-runtime-start" | "audit-turn-start" | "stream-turn" | "await-turn-result" | "write-memory" | "refresh-runtime-metadata" | "emit-terminal" | "refresh-authoritative-transcript" | "mark-terminal-pending" | "send-turn-done" | "mark-terminal-enqueued" | "terminal-hooks";
7
+ type PromptExecutionBoundaries = {
8
+ daemonAcceptedAt: number;
9
+ providerStartAt?: number;
10
+ providerAcceptedAt?: number;
11
+ providerTurnId?: string;
12
+ providerTerminalAt?: number;
13
+ terminalEventAt?: number;
14
+ };
7
15
  export declare function handlePrompt(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
8
16
  export declare function handleSteerTurn(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
9
17
  export declare function handleOptionResponse(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
@@ -14,13 +22,14 @@ export declare function promptFailureResponse(command: PromptCommand, phase: Pro
14
22
  code: string;
15
23
  message: string;
16
24
  };
17
- export declare function promptFailureAuditEvent(config: Pick<DaemonConfig, "machineId">, command: PromptCommand, phase: PromptExecutionPhase, error: unknown): {
25
+ export declare function promptFailureAuditEvent(config: Pick<DaemonConfig, "machineId">, command: PromptCommand, phase: PromptExecutionPhase, error: unknown, boundaries?: PromptExecutionBoundaries): {
18
26
  sessionId: string;
19
27
  machineId: string;
20
28
  actor: "system";
21
29
  action: string;
22
30
  summary: string;
23
31
  evidence: {
32
+ boundaries?: PromptExecutionBoundaries | undefined;
24
33
  requestId: string;
25
34
  turnId: string;
26
35
  code: string;
@@ -191,6 +191,7 @@ export async function handlePrompt(ws, config, command) {
191
191
  let turnReleased = false;
192
192
  let terminalNotification = false;
193
193
  let terminalNotified = false;
194
+ const boundaries = { daemonAcceptedAt: Date.now() };
194
195
  const releasePromptTurn = () => {
195
196
  if (turnReleased)
196
197
  return;
@@ -298,6 +299,7 @@ export async function handlePrompt(ws, config, command) {
298
299
  const runtimePrompt = await prepareRuntimePromptForSession(config, session, prompt, memoryContext.text);
299
300
  const runtimeText = runtimePrompt.text;
300
301
  phase = "start-turn";
302
+ boundaries.providerStartAt = Date.now();
301
303
  const turn = await startRuntimePromptTurn({
302
304
  session,
303
305
  command,
@@ -310,6 +312,7 @@ export async function handlePrompt(ws, config, command) {
310
312
  ? { manualHandoffProof: fallback?.manualHandoffProof ?? session.manualHandoffProof }
311
313
  : {}),
312
314
  });
315
+ boundaries.providerAcceptedAt = Date.now();
313
316
  phase = "attach-turn";
314
317
  if (!attachClaimedTurn(command.sessionId, command.requestId, command.turnId, turn)) {
315
318
  throw new Error(`Session turn claim was lost before start: ${command.sessionId}`);
@@ -317,6 +320,7 @@ export async function handlePrompt(ws, config, command) {
317
320
  phase = "persist-runtime-start";
318
321
  const runtimeThreadId = session.runtimeBinding.threadId;
319
322
  const runtimeTurnId = await turn.runtimeTurnId;
323
+ boundaries.providerTurnId = runtimeTurnId;
320
324
  if (!runtimeTurnId) {
321
325
  throw new Error(`Runtime did not return a turn id for ${command.turnId}`);
322
326
  }
@@ -357,6 +361,7 @@ export async function handlePrompt(ws, config, command) {
357
361
  thinkingLevel,
358
362
  memoryMode: memoryOptions?.mode,
359
363
  usedMemories: memoryContext.used.map((item) => ({ path: item.path, citation: item.citation })),
364
+ boundaries,
360
365
  },
361
366
  });
362
367
  let outputPreview = "";
@@ -371,6 +376,7 @@ export async function handlePrompt(ws, config, command) {
371
376
  }
372
377
  phase = "await-turn-result";
373
378
  const result = await turn.result;
379
+ boundaries.providerTerminalAt = Date.now();
374
380
  session = withSessionHead(session, result.currentHead, result.lastTurnId);
375
381
  sessions.set(command.sessionId, session);
376
382
  await persistSessionProjection(session);
@@ -409,6 +415,7 @@ export async function handlePrompt(ws, config, command) {
409
415
  runtimeMetadataRefreshedAt: new Date().toISOString(),
410
416
  });
411
417
  phase = "emit-terminal";
418
+ boundaries.terminalEventAt = Date.now();
412
419
  clearPendingOptionRequestsForTurn(command.sessionId, command.turnId);
413
420
  const completedRuntimeTurnId = result.lastTurnId ?? result.currentHead ?? runtimeTurnId;
414
421
  await emitEvent(ws, config, command.sessionId, command.turnId, result.status === "failed"
@@ -525,6 +532,7 @@ export async function handlePrompt(ws, config, command) {
525
532
  requestId: command.requestId,
526
533
  turnId: command.turnId,
527
534
  ...(result.status === "failed" ? { error: result.error.message } : { stopReason: result.stopReason }),
535
+ boundaries,
528
536
  },
529
537
  });
530
538
  }
@@ -540,7 +548,7 @@ export async function handlePrompt(ws, config, command) {
540
548
  await emitPromptFailureEvent(ws, config, command, phase, error).catch((eventError) => {
541
549
  console.error(`Prompt failure event failed for ${command.turnId}: ${eventError instanceof Error ? eventError.message : String(eventError)}`);
542
550
  });
543
- await appendAudit(promptFailureAuditEvent(config, command, phase, error)).catch((auditError) => {
551
+ await appendAudit(promptFailureAuditEvent(config, command, phase, error, boundaries)).catch((auditError) => {
544
552
  console.error(`Prompt failure audit failed for ${command.turnId}: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
545
553
  });
546
554
  await sendCommandResponse(ws, response);
@@ -784,7 +792,7 @@ export function promptFailureResponse(command, phase, error) {
784
792
  message: promptFailureMessage(code, error),
785
793
  };
786
794
  }
787
- export function promptFailureAuditEvent(config, command, phase, error) {
795
+ export function promptFailureAuditEvent(config, command, phase, error, boundaries) {
788
796
  const code = promptFailureCode(phase, error);
789
797
  return {
790
798
  sessionId: command.sessionId,
@@ -798,6 +806,7 @@ export function promptFailureAuditEvent(config, command, phase, error) {
798
806
  code,
799
807
  phase,
800
808
  message: safeErrorMessage(error),
809
+ ...(boundaries ? { boundaries } : {}),
801
810
  },
802
811
  };
803
812
  }
@@ -74,6 +74,7 @@ export function isUpdateAdmissionCommand(type) {
74
74
  type === "machine:prompt" ||
75
75
  type === "machine:dispatch" ||
76
76
  type === "machine:queue" ||
77
+ type === "machine:followup" ||
77
78
  type === "machine:optionResponse" ||
78
79
  type === "machine:resume" ||
79
80
  type === "machine:fork" ||