@vellumai/assistant 0.4.0 → 0.4.2

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.
@@ -7,8 +7,11 @@ import { type InterfaceId,isChannelId, parseChannelId, parseInterfaceId } from '
7
7
  import { getConfig } from '../../config/loader.js';
8
8
  import { getAttachmentsForMessage, getFilePathForAttachment, setAttachmentThumbnail } from '../../memory/attachments-store.js';
9
9
  import {
10
+ createCanonicalGuardianRequest,
11
+ generateCanonicalRequestCode,
10
12
  listCanonicalGuardianRequests,
11
13
  listPendingCanonicalGuardianRequestsByDestinationConversation,
14
+ resolveCanonicalGuardianRequest,
12
15
  } from '../../memory/canonical-guardian-store.js';
13
16
  import { getAttentionStateByConversationIds } from '../../memory/conversation-attention-store.js';
14
17
  import * as conversationStore from '../../memory/conversation-store.js';
@@ -47,6 +50,7 @@ import { normalizeThreadType } from '../ipc-protocol.js';
47
50
  import { executeRecordingIntent } from '../recording-executor.js';
48
51
  import { resolveRecordingIntent } from '../recording-intent.js';
49
52
  import { classifyRecordingIntentFallback, containsRecordingKeywords } from '../recording-intent-fallback.js';
53
+ import type { Session } from '../session.js';
50
54
  import { buildSessionErrorMessage,classifySessionError } from '../session-error.js';
51
55
  import { resolveChannelCapabilities } from '../session-runtime-assembly.js';
52
56
  import { generateVideoThumbnail } from '../video-thumbnail.js';
@@ -66,6 +70,86 @@ import {
66
70
 
67
71
  const desktopApprovalConversationGenerator = createApprovalConversationGenerator();
68
72
 
73
+ function syncCanonicalStatusFromIpcConfirmationDecision(
74
+ requestId: string,
75
+ decision: ConfirmationResponse['decision'],
76
+ ): void {
77
+ const targetStatus = decision === 'deny' || decision === 'always_deny'
78
+ ? 'denied' as const
79
+ : 'approved' as const;
80
+
81
+ try {
82
+ resolveCanonicalGuardianRequest(requestId, 'pending', { status: targetStatus });
83
+ } catch (err) {
84
+ log.debug(
85
+ { err, requestId, targetStatus },
86
+ 'Failed to resolve canonical request from IPC confirmation response',
87
+ );
88
+ }
89
+ }
90
+
91
+ function makeIpcEventSender(params: {
92
+ ctx: HandlerContext;
93
+ socket: net.Socket;
94
+ session: Session;
95
+ conversationId: string;
96
+ sourceChannel: string;
97
+ }): (event: ServerMessage) => void {
98
+ const {
99
+ ctx,
100
+ socket,
101
+ session,
102
+ conversationId,
103
+ sourceChannel,
104
+ } = params;
105
+
106
+ return (event: ServerMessage) => {
107
+ if (event.type === 'confirmation_request') {
108
+ pendingInteractions.register(event.requestId, {
109
+ session,
110
+ conversationId,
111
+ kind: 'confirmation',
112
+ confirmationDetails: {
113
+ toolName: event.toolName,
114
+ input: event.input,
115
+ riskLevel: event.riskLevel,
116
+ executionTarget: event.executionTarget,
117
+ allowlistOptions: event.allowlistOptions,
118
+ scopeOptions: event.scopeOptions,
119
+ persistentDecisionsAllowed: event.persistentDecisionsAllowed,
120
+ },
121
+ });
122
+
123
+ try {
124
+ createCanonicalGuardianRequest({
125
+ id: event.requestId,
126
+ kind: 'tool_approval',
127
+ sourceType: 'desktop',
128
+ sourceChannel,
129
+ conversationId,
130
+ toolName: event.toolName,
131
+ status: 'pending',
132
+ requestCode: generateCanonicalRequestCode(),
133
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
134
+ });
135
+ } catch (err) {
136
+ log.debug(
137
+ { err, requestId: event.requestId, conversationId },
138
+ 'Failed to create canonical request from IPC confirmation event',
139
+ );
140
+ }
141
+ } else if (event.type === 'secret_request') {
142
+ pendingInteractions.register(event.requestId, {
143
+ session,
144
+ conversationId,
145
+ kind: 'secret',
146
+ });
147
+ }
148
+
149
+ ctx.send(socket, event);
150
+ };
151
+ }
152
+
69
153
  export async function handleUserMessage(
70
154
  msg: UserMessage,
71
155
  socket: net.Socket,
@@ -83,8 +167,14 @@ export async function handleUserMessage(
83
167
  wireEscalationHandler(session, socket, ctx);
84
168
  }
85
169
 
86
- const sendEvent = (event: ServerMessage) => ctx.send(socket, event);
87
170
  const ipcChannel = parseChannelId(msg.channel) ?? 'vellum';
171
+ const sendEvent = makeIpcEventSender({
172
+ ctx,
173
+ socket,
174
+ session,
175
+ conversationId: msg.sessionId,
176
+ sourceChannel: ipcChannel,
177
+ });
88
178
  const ipcInterface = parseInterfaceId(msg.interface);
89
179
  if (!ipcInterface) {
90
180
  ctx.send(socket, {
@@ -461,11 +551,13 @@ export async function handleUserMessage(
461
551
  }
462
552
  }
463
553
 
464
- // If exactly one live turn is waiting on confirmation (no queued turns),
465
- // try to consume this text as an inline approval decision first.
554
+ // If a live turn is waiting on confirmation, try to consume this text as
555
+ // an inline approval decision before auto-deny. We intentionally do not
556
+ // gate on queue depth: users often retry "approve"/"yes" while the queue
557
+ // is draining after a prior denial, and requiring an empty queue causes a
558
+ // deny/retry cascade where natural-language approvals never land.
466
559
  if (
467
560
  session.hasAnyPendingConfirmation()
468
- && session.getQueueDepth() === 0
469
561
  && messageText.trim().length > 0
470
562
  ) {
471
563
  try {
@@ -598,6 +690,7 @@ export async function handleUserMessage(
598
690
  // stale request IDs are not reused as routing candidates.
599
691
  for (const interaction of pendingInteractions.getByConversation(msg.sessionId)) {
600
692
  if (interaction.session === session && interaction.kind === 'confirmation') {
693
+ syncCanonicalStatusFromIpcConfirmationDecision(interaction.requestId, 'deny');
601
694
  pendingInteractions.resolve(interaction.requestId);
602
695
  }
603
696
  }
@@ -638,6 +731,8 @@ export function handleConfirmationResponse(
638
731
  msg.selectedPattern,
639
732
  msg.selectedScope,
640
733
  );
734
+ syncCanonicalStatusFromIpcConfirmationDecision(msg.requestId, msg.decision);
735
+ pendingInteractions.resolve(msg.requestId);
641
736
  return;
642
737
  }
643
738
  }
@@ -651,6 +746,8 @@ export function handleConfirmationResponse(
651
746
  msg.selectedPattern,
652
747
  msg.selectedScope,
653
748
  );
749
+ syncCanonicalStatusFromIpcConfirmationDecision(msg.requestId, msg.decision);
750
+ pendingInteractions.resolve(msg.requestId);
654
751
  return;
655
752
  }
656
753
  }
@@ -670,6 +767,7 @@ export function handleSecretResponse(
670
767
  clearTimeout(standalone.timer);
671
768
  pendingStandaloneSecrets.delete(msg.requestId);
672
769
  standalone.resolve({ value: msg.value ?? null, delivery: msg.delivery ?? 'store' });
770
+ pendingInteractions.resolve(msg.requestId);
673
771
  return;
674
772
  }
675
773
 
@@ -680,6 +778,7 @@ export function handleSecretResponse(
680
778
  if (session.hasPendingSecret(msg.requestId)) {
681
779
  ctx.touchSession(sessionId);
682
780
  session.handleSecretResponse(msg.requestId, msg.value, msg.delivery);
781
+ pendingInteractions.resolve(msg.requestId);
683
782
  return;
684
783
  }
685
784
  }
@@ -780,11 +879,11 @@ export async function handleSessionCreate(
780
879
 
781
880
  // Auto-send the initial message if provided, kick-starting the skill.
782
881
  if (msg.initialMessage) {
783
- // Queue title generation immediately (matches all other creation paths).
784
- // The agent loop success path will also attempt title generation, but
785
- // queueGenerateConversationTitle is safe to call redundantly the
786
- // replaceability check prevents double-writes. This ensures the title
787
- // is generated even if the agent loop fails or is cancelled.
882
+ // Queue title generation eagerly some processMessage paths (guardian
883
+ // replies, unknown slash commands) bypass the agent loop entirely, so
884
+ // we can't rely on the agent loop's early title generation alone.
885
+ // The agent loop also queues title generation, but isReplaceableTitle
886
+ // prevents double-writes since the first to complete sets a real title.
788
887
  if (title === GENERATING_TITLE) {
789
888
  queueGenerateConversationTitle({
790
889
  conversationId: conversation.id,
@@ -801,9 +900,15 @@ export async function handleSessionCreate(
801
900
  }
802
901
 
803
902
  ctx.socketToSession.set(socket, conversation.id);
804
- const sendEvent = (event: ServerMessage) => ctx.send(socket, event);
805
903
  const requestId = uuid();
806
904
  const transportChannel = parseChannelId(msg.transport?.channelId) ?? 'vellum';
905
+ const sendEvent = makeIpcEventSender({
906
+ ctx,
907
+ socket,
908
+ session,
909
+ conversationId: conversation.id,
910
+ sourceChannel: transportChannel,
911
+ });
807
912
  session.setTurnChannelContext({
808
913
  userMessageChannel: transportChannel,
809
914
  assistantMessageChannel: transportChannel,
@@ -1136,7 +1241,16 @@ export async function handleRegenerate(
1136
1241
  }
1137
1242
  ctx.touchSession(msg.sessionId);
1138
1243
 
1139
- const sendEvent = (event: ServerMessage) => ctx.send(socket, event);
1244
+ const regenerateChannel = parseChannelId(
1245
+ session.getTurnChannelContext()?.assistantMessageChannel,
1246
+ ) ?? 'vellum';
1247
+ const sendEvent = makeIpcEventSender({
1248
+ ctx,
1249
+ socket,
1250
+ session,
1251
+ conversationId: msg.sessionId,
1252
+ sourceChannel: regenerateChannel,
1253
+ });
1140
1254
  const requestId = uuid();
1141
1255
  session.traceEmitter.emit('request_received', 'Regenerate requested', {
1142
1256
  requestId,
@@ -145,15 +145,16 @@ const TIER_SYSTEM_PROMPT =
145
145
 
146
146
  /**
147
147
  * Fire-and-forget Haiku call to classify the conversation trajectory.
148
- * Returns the classified tier or null on any failure.
148
+ * Returns the classified tier, or undefined when no provider is configured
149
+ * or on any failure.
149
150
  */
150
151
  export async function classifyResponseTierAsync(
151
152
  recentUserTexts: string[],
152
- ): Promise<ResponseTier | null> {
153
+ ): Promise<ResponseTier | undefined> {
153
154
  const provider = getConfiguredProvider();
154
155
  if (!provider) {
155
156
  log.debug('No provider available for async tier classification');
156
- return null;
157
+ return undefined;
157
158
  }
158
159
 
159
160
  const combined = recentUserTexts
@@ -186,14 +187,14 @@ export async function classifyResponseTierAsync(
186
187
  }
187
188
 
188
189
  log.debug({ raw }, 'Async tier classification returned unexpected value');
189
- return null;
190
+ return undefined;
190
191
  } finally {
191
192
  cleanup();
192
193
  }
193
194
  } catch (err) {
194
195
  const message = err instanceof Error ? err.message : String(err);
195
196
  log.debug({ err: message }, 'Async tier classification failed');
196
- return null;
197
+ return undefined;
197
198
  }
198
199
  }
199
200
 
@@ -93,6 +93,16 @@ function resolveTurnInterface(sourceInterface?: string): InterfaceId {
93
93
  return 'vellum';
94
94
  }
95
95
 
96
+ function resolveCanonicalRequestSourceType(sourceChannel: string | undefined): 'desktop' | 'channel' | 'voice' {
97
+ if (sourceChannel === 'voice') {
98
+ return 'voice';
99
+ }
100
+ if (sourceChannel === 'vellum') {
101
+ return 'desktop';
102
+ }
103
+ return 'channel';
104
+ }
105
+
96
106
  /**
97
107
  * Build an onEvent callback that registers pending interactions when the agent
98
108
  * loop emits confirmation_request or secret_request events. This ensures that
@@ -121,12 +131,17 @@ function makePendingInteractionRegistrar(
121
131
 
122
132
  // Create a canonical guardian request so IPC/HTTP handlers can find it
123
133
  // via applyCanonicalGuardianDecision.
134
+ const guardianContext = session.guardianContext;
135
+ const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
124
136
  createCanonicalGuardianRequest({
125
137
  id: msg.requestId,
126
138
  kind: 'tool_approval',
127
- sourceType: 'desktop',
128
- sourceChannel: 'vellum',
139
+ sourceType: resolveCanonicalRequestSourceType(sourceChannel),
140
+ sourceChannel,
129
141
  conversationId,
142
+ requesterExternalUserId: guardianContext?.requesterExternalUserId,
143
+ requesterChatId: guardianContext?.requesterChatId,
144
+ guardianExternalUserId: guardianContext?.guardianExternalUserId,
130
145
  toolName: msg.toolName,
131
146
  status: 'pending',
132
147
  requestCode: generateCanonicalRequestCode(),
@@ -20,7 +20,7 @@ import { commitAppTurnChanges } from '../memory/app-git-service.js';
20
20
  import { getApp, listAppFiles } from '../memory/app-store.js';
21
21
  import * as conversationStore from '../memory/conversation-store.js';
22
22
  import { getConversationOriginChannel, getConversationOriginInterface, provenanceFromGuardianContext } from '../memory/conversation-store.js';
23
- import { isReplaceableTitle, queueGenerateConversationTitle, queueRegenerateConversationTitle } from '../memory/conversation-title-service.js';
23
+ import { GENERATING_TITLE, isReplaceableTitle, queueGenerateConversationTitle, queueRegenerateConversationTitle, UNTITLED_FALLBACK } from '../memory/conversation-title-service.js';
24
24
  import { stripMemoryRecallMessages } from '../memory/retriever.js';
25
25
  import type { PermissionPrompter } from '../permissions/prompter.js';
26
26
  import type { ContentBlock,Message } from '../providers/types.js';
@@ -211,10 +211,42 @@ export async function runAgentLoopImpl(
211
211
  ctx.messages.pop();
212
212
  conversationStore.deleteMessageById(userMessageId);
213
213
  }
214
+ // Replace loading placeholder so the thread isn't stuck as "Generating title..."
215
+ const blockedConv = conversationStore.getConversation(ctx.conversationId);
216
+ if (blockedConv?.title === GENERATING_TITLE) {
217
+ conversationStore.updateConversationTitle(ctx.conversationId, UNTITLED_FALLBACK, 1);
218
+ onEvent({ type: 'session_title_updated', sessionId: ctx.conversationId, title: UNTITLED_FALLBACK });
219
+ }
214
220
  onEvent({ type: 'error', message: `Message blocked by hook "${preMessageResult.blockedBy}"` });
215
221
  return;
216
222
  }
217
223
 
224
+ // Generate title early — the user message alone is sufficient context.
225
+ // Firing after hook gating but before the main LLM call removes the
226
+ // delay of waiting for the full assistant response. The second-pass
227
+ // regeneration at turn 3 will refine the title with more context.
228
+ // Deferred via setTimeout so the main agent loop LLM call is queued
229
+ // first, avoiding rate-limit slot contention. No abort signal — title
230
+ // generation should complete even if the user cancels the response,
231
+ // since the user message is already persisted.
232
+ const currentConvForTitle = conversationStore.getConversation(ctx.conversationId);
233
+ if (isReplaceableTitle(currentConvForTitle?.title ?? null)) {
234
+ setTimeout(() => {
235
+ queueGenerateConversationTitle({
236
+ conversationId: ctx.conversationId,
237
+ provider: ctx.provider,
238
+ userMessage: options?.titleText ?? content,
239
+ onTitleUpdated: (title) => {
240
+ onEvent({
241
+ type: 'session_title_updated',
242
+ sessionId: ctx.conversationId,
243
+ title,
244
+ });
245
+ },
246
+ });
247
+ }, 0);
248
+ }
249
+
218
250
  const isFirstMessage = ctx.messages.length === 1;
219
251
 
220
252
  const compacted = await ctx.contextWindowManager.maybeCompact(
@@ -721,27 +753,6 @@ export async function runAgentLoopImpl(
721
753
  });
722
754
  }
723
755
 
724
- // Generate title if the current conversation title is still a replaceable
725
- // placeholder. This replaces the previous `isFirstMessage` gate so that
726
- // assistant-seeded/system-seeded threads also receive generated titles.
727
- const currentConv = conversationStore.getConversation(ctx.conversationId);
728
- if (isReplaceableTitle(currentConv?.title ?? null)) {
729
- queueGenerateConversationTitle({
730
- conversationId: ctx.conversationId,
731
- provider: ctx.provider,
732
- userMessage: options?.titleText ?? content,
733
- assistantResponse: state.firstAssistantText || undefined,
734
- onTitleUpdated: (title) => {
735
- onEvent({
736
- type: 'session_title_updated',
737
- sessionId: ctx.conversationId,
738
- title,
739
- });
740
- },
741
- signal: abortController.signal,
742
- });
743
- }
744
-
745
756
  // Second title pass: after 3 completed turns, re-generate the title
746
757
  // using the last 3 messages for better context. Only fires when the
747
758
  // current title was auto-generated (isAutoTitle = 1).
@@ -147,6 +147,9 @@ function savePages(appId: string, pages: Record<string, string>): void {
147
147
  mkdirSync(pagesDir, { recursive: true });
148
148
  for (const [filename, content] of Object.entries(pages)) {
149
149
  validatePageFilename(filename);
150
+ if (typeof content !== 'string') {
151
+ throw new Error(`Page content for "${filename}" must be a string, got ${typeof content}`);
152
+ }
150
153
  writeFileSync(join(pagesDir, filename), content, 'utf-8');
151
154
  }
152
155
  }
@@ -194,6 +197,9 @@ export function createApp(params: {
194
197
  // Write htmlDefinition to {appId}/index.html on disk
195
198
  const appDir = join(dir, app.id);
196
199
  mkdirSync(appDir, { recursive: true });
200
+ if (typeof params.htmlDefinition !== 'string') {
201
+ throw new Error(`htmlDefinition must be a string, got ${typeof params.htmlDefinition}`);
202
+ }
197
203
  writeFileSync(join(appDir, 'index.html'), params.htmlDefinition, 'utf-8');
198
204
 
199
205
  // Write preview to companion file to keep the JSON small
@@ -81,7 +81,11 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
81
81
  }
82
82
  this.pendingRequests.set(id, { resolve });
83
83
  this.workerProc.stdin.write(JSON.stringify({ id, texts }) + '\n');
84
- this.workerProc.stdin.flush();
84
+ try {
85
+ this.workerProc.stdin.flush();
86
+ } catch {
87
+ // Worker may have exited — pending request will be resolved by stdout reader cleanup
88
+ }
85
89
  });
86
90
  }
87
91
 
@@ -138,8 +142,10 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
138
142
  // Wait for the worker to signal it's ready (model loaded)
139
143
  await this.waitForReady();
140
144
  } catch (err) {
141
- // Worker failed to start — collect stderr for diagnosis
145
+ // Worker failed to start — kill it to avoid deadlock, then collect stderr
142
146
  this.workerProc = null;
147
+ this.stdoutReaderActive = false;
148
+ try { proc.kill(); } catch { /* may already be dead */ }
143
149
  const exitCode = await proc.exited.catch(() => undefined);
144
150
  const stderr = await new Response(proc.stderr).text().catch(() => '');
145
151
  if (stderr.trim()) {
@@ -180,7 +186,9 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
180
186
  if (this.stdoutReaderActive || !this.workerProc) return;
181
187
  this.stdoutReaderActive = true;
182
188
 
183
- const reader = this.workerProc.stdout.getReader();
189
+ // Capture reference to detect if a new worker was spawned during cleanup
190
+ const proc = this.workerProc;
191
+ const reader = proc.stdout.getReader();
184
192
  const decoder = new TextDecoder();
185
193
 
186
194
  (async () => {
@@ -195,17 +203,21 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
195
203
  // Reader cancelled or stream errored
196
204
  }
197
205
 
198
- // Worker exited reject all pending requests and clean up
199
- for (const [, pending] of this.pendingRequests) {
200
- pending.resolve({ error: 'Embedding worker process exited unexpectedly' });
206
+ // Only clean up if this reader's proc is still the active one.
207
+ // A new worker may have been spawned during the async cleanup window.
208
+ if (this.workerProc === proc) {
209
+ // Worker exited — reject all pending requests and clean up
210
+ for (const [, pending] of this.pendingRequests) {
211
+ pending.resolve({ error: 'Embedding worker process exited unexpectedly' });
212
+ }
213
+ this.pendingRequests.clear();
214
+ this.workerProc = null;
215
+ this.stdoutReaderActive = false;
216
+ this.removePidFile();
217
+ this.stdoutBuffer = '';
218
+ // Allow re-initialization on next embed() call
219
+ this.initGuard.reset();
201
220
  }
202
- this.pendingRequests.clear();
203
- this.workerProc = null;
204
- this.stdoutReaderActive = false;
205
- this.removePidFile();
206
- this.stdoutBuffer = '';
207
- // Allow re-initialization on next embed() call
208
- this.initGuard.reset();
209
221
  })();
210
222
  }
211
223
 
@@ -27,12 +27,13 @@ const log = getLogger('embedding-runtime-manager');
27
27
  const ONNXRUNTIME_NODE_VERSION = '1.21.0';
28
28
  const ONNXRUNTIME_COMMON_VERSION = '1.21.0';
29
29
  const TRANSFORMERS_VERSION = '3.8.1';
30
+ const JINJA_VERSION = '0.5.5';
30
31
 
31
32
  /** Bun version to download when system bun is not available. */
32
33
  const BUN_VERSION = '1.2.0';
33
34
 
34
35
  /** Composite version string for cache invalidation. */
35
- const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}`;
36
+ const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}_jinja-${JINJA_VERSION}`;
36
37
 
37
38
  const WORKER_FILENAME = 'embed-worker.mjs';
38
39
 
@@ -104,6 +105,7 @@ function generateWorkerScript(): string {
104
105
  return `\
105
106
  // embed-worker.mjs — Auto-generated by EmbeddingRuntimeManager
106
107
  // Runs in a separate bun process, communicates via JSON-lines over stdin/stdout.
108
+ process.title = 'embed-worker';
107
109
  import { pipeline, env } from '@huggingface/transformers';
108
110
 
109
111
  const model = process.argv[2];
@@ -272,6 +274,12 @@ export class EmbeddingRuntimeManager {
272
274
  const tmpDir = join(this.baseDir, `.installing-${Date.now()}`);
273
275
  mkdirSync(tmpDir, { recursive: true });
274
276
 
277
+ // Declared outside try so catch/finally can reference them for cleanup
278
+ const modelCacheDir = join(this.baseDir, 'model-cache');
279
+ const existingBinDir = join(this.baseDir, 'bin');
280
+ let tmpModelCache: string | null = null;
281
+ let tmpBinDir: string | null = null;
282
+
275
283
  try {
276
284
  // Step 1: Download npm packages (and bun if needed) in parallel
277
285
  const nodeModules = join(tmpDir, 'node_modules');
@@ -291,6 +299,11 @@ export class EmbeddingRuntimeManager {
291
299
  join(nodeModules, '@huggingface', 'transformers'),
292
300
  signal,
293
301
  ),
302
+ downloadAndExtract(
303
+ npmTarballUrl('@huggingface/jinja', JINJA_VERSION),
304
+ join(nodeModules, '@huggingface', 'jinja'),
305
+ signal,
306
+ ),
294
307
  ];
295
308
 
296
309
  // Download bun binary if not already available on the system
@@ -358,19 +371,15 @@ export class EmbeddingRuntimeManager {
358
371
 
359
372
  // Step 6: Atomic swap — remove old install and rename temp to final
360
373
  // Preserve model-cache/, bin/ (downloaded bun), and .gitignore
361
- const modelCacheDir = join(this.baseDir, 'model-cache');
362
374
  const hadModelCache = existsSync(modelCacheDir);
363
- let tmpModelCache: string | null = null;
364
375
  if (hadModelCache) {
365
376
  tmpModelCache = join(this.baseDir, `.model-cache-preserve-${Date.now()}`);
366
377
  renameSync(modelCacheDir, tmpModelCache);
367
378
  }
368
379
 
369
380
  // Preserve downloaded bun binary if it exists and we didn't just download a new one
370
- const existingBinDir = join(this.baseDir, 'bin');
371
381
  const newBinDir = join(tmpDir, 'bin');
372
382
  const hadBinDir = existsSync(existingBinDir) && !existsSync(newBinDir);
373
- let tmpBinDir: string | null = null;
374
383
  if (hadBinDir) {
375
384
  tmpBinDir = join(this.baseDir, `.bin-preserve-${Date.now()}`);
376
385
  renameSync(existingBinDir, tmpBinDir);
@@ -399,11 +408,20 @@ export class EmbeddingRuntimeManager {
399
408
 
400
409
  log.info({ runtimeVersion: RUNTIME_VERSION }, 'Embedding runtime installed successfully');
401
410
  } catch (err) {
411
+ // Restore preserved directories if the swap failed
412
+ if (tmpModelCache && existsSync(tmpModelCache) && !existsSync(modelCacheDir)) {
413
+ try { renameSync(tmpModelCache, modelCacheDir); } catch { /* best effort */ }
414
+ }
415
+ if (tmpBinDir && existsSync(tmpBinDir) && !existsSync(existingBinDir)) {
416
+ try { renameSync(tmpBinDir, existingBinDir); } catch { /* best effort */ }
417
+ }
402
418
  log.error({ err }, 'Failed to install embedding runtime');
403
419
  throw err;
404
420
  } finally {
405
- // Clean up temp directory
421
+ // Clean up temp directory and any leftover preserve dirs
406
422
  rmSync(tmpDir, { recursive: true, force: true });
423
+ if (tmpModelCache) rmSync(tmpModelCache, { recursive: true, force: true });
424
+ if (tmpBinDir) rmSync(tmpBinDir, { recursive: true, force: true });
407
425
  }
408
426
  }
409
427
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { ChannelId } from '../channels/types.js';
9
9
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
10
+ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
10
11
  import {
11
12
  type DenialReason,
12
13
  resolveActorTrust,
@@ -41,11 +42,14 @@ export type ResolveGuardianContextInput = ResolveActorTrustInput;
41
42
  */
42
43
  export function resolveGuardianContext(input: ResolveGuardianContextInput): GuardianContext {
43
44
  const trust = resolveActorTrust(input);
45
+ const canonicalGuardianExternalUserId = trust.guardianBindingMatch?.guardianExternalUserId
46
+ ? canonicalizeInboundIdentity(input.sourceChannel, trust.guardianBindingMatch.guardianExternalUserId) ?? undefined
47
+ : undefined;
44
48
  return {
45
49
  trustClass: trust.trustClass,
46
50
  guardianChatId: trust.guardianBindingMatch?.guardianDeliveryChatId ??
47
51
  (trust.trustClass === 'guardian' ? input.externalChatId : undefined),
48
- guardianExternalUserId: trust.guardianBindingMatch?.guardianExternalUserId,
52
+ guardianExternalUserId: canonicalGuardianExternalUserId,
49
53
  requesterIdentifier: trust.actorMetadata.identifier,
50
54
  requesterDisplayName: trust.actorMetadata.displayName,
51
55
  requesterSenderDisplayName: trust.actorMetadata.senderDisplayName,
@@ -237,6 +237,7 @@ export async function routeGuardianReply(
237
237
  ): Promise<GuardianReplyResult> {
238
238
  const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
239
239
  const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
240
+ const scopedPendingRequestIds = ctx.pendingRequestIds ? new Set(ctx.pendingRequestIds) : null;
240
241
 
241
242
  // ── 1. Deterministic callback parsing (button presses) ──
242
243
  // No conversationId scoping here — the guardian's reply comes from a
@@ -257,6 +258,17 @@ export async function routeGuardianReply(
257
258
  const codeResult = parseRequestCode(messageText);
258
259
  if (codeResult) {
259
260
  const { request } = codeResult;
261
+ if (scopedPendingRequestIds && !scopedPendingRequestIds.has(request.id)) {
262
+ log.info(
263
+ {
264
+ event: 'router_code_out_of_scope',
265
+ requestId: request.id,
266
+ pendingHintCount: scopedPendingRequestIds.size,
267
+ },
268
+ 'Request code matched a pending request outside the caller-provided scope; ignoring',
269
+ );
270
+ return notConsumed();
271
+ }
260
272
 
261
273
  if (request.status !== 'pending') {
262
274
  log.info(
@@ -714,6 +714,7 @@ export class RuntimeHttpServer {
714
714
  processMessage: this.processMessage,
715
715
  persistAndProcessMessage: this.persistAndProcessMessage,
716
716
  sendMessageDeps: this.sendMessageDeps,
717
+ approvalConversationGenerator: this.approvalConversationGenerator,
717
718
  });
718
719
  }
719
720