@vellumai/assistant 0.3.2 → 0.3.4

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 (109) hide show
  1. package/README.md +82 -21
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
  4. package/src/__tests__/app-git-history.test.ts +22 -27
  5. package/src/__tests__/app-git-service.test.ts +44 -78
  6. package/src/__tests__/call-orchestrator.test.ts +321 -0
  7. package/src/__tests__/channel-approval-routes.test.ts +1267 -93
  8. package/src/__tests__/channel-approval.test.ts +2 -0
  9. package/src/__tests__/channel-approvals.test.ts +51 -2
  10. package/src/__tests__/channel-delivery-store.test.ts +130 -1
  11. package/src/__tests__/channel-guardian.test.ts +371 -1
  12. package/src/__tests__/config-schema.test.ts +1 -1
  13. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  14. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  15. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  16. package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
  17. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  18. package/src/__tests__/handlers-twilio-config.test.ts +738 -5
  19. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  21. package/src/__tests__/run-orchestrator.test.ts +1 -1
  22. package/src/__tests__/secret-scanner.test.ts +223 -0
  23. package/src/__tests__/session-process-bridge.test.ts +2 -0
  24. package/src/__tests__/shell-parser-property.test.ts +357 -2
  25. package/src/__tests__/system-prompt.test.ts +25 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  27. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  28. package/src/__tests__/user-reference.test.ts +68 -0
  29. package/src/calls/call-orchestrator.ts +63 -11
  30. package/src/calls/twilio-config.ts +10 -1
  31. package/src/calls/twilio-rest.ts +70 -0
  32. package/src/cli/map.ts +6 -0
  33. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  34. package/src/commands/cc-command-registry.ts +14 -1
  35. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  36. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  37. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  38. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  39. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  40. package/src/config/defaults.ts +1 -1
  41. package/src/config/schema.ts +6 -3
  42. package/src/config/skills.ts +5 -32
  43. package/src/config/system-prompt.ts +16 -0
  44. package/src/config/user-reference.ts +29 -0
  45. package/src/config/vellum-skills/catalog.json +52 -0
  46. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  47. package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
  48. package/src/daemon/auth-manager.ts +103 -0
  49. package/src/daemon/computer-use-session.ts +8 -1
  50. package/src/daemon/config-watcher.ts +253 -0
  51. package/src/daemon/handlers/config.ts +193 -17
  52. package/src/daemon/handlers/sessions.ts +5 -3
  53. package/src/daemon/handlers/skills.ts +60 -17
  54. package/src/daemon/ipc-contract-inventory.json +4 -0
  55. package/src/daemon/ipc-contract.ts +16 -0
  56. package/src/daemon/ipc-handler.ts +87 -0
  57. package/src/daemon/lifecycle.ts +16 -4
  58. package/src/daemon/ride-shotgun-handler.ts +11 -1
  59. package/src/daemon/server.ts +105 -502
  60. package/src/daemon/session-agent-loop.ts +9 -14
  61. package/src/daemon/session-process.ts +20 -3
  62. package/src/daemon/session-runtime-assembly.ts +60 -44
  63. package/src/daemon/session-slash.ts +50 -2
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session.ts +8 -1
  66. package/src/inbound/public-ingress-urls.ts +20 -3
  67. package/src/index.ts +1 -23
  68. package/src/memory/app-git-service.ts +24 -0
  69. package/src/memory/app-store.ts +0 -21
  70. package/src/memory/channel-delivery-store.ts +74 -3
  71. package/src/memory/channel-guardian-store.ts +54 -26
  72. package/src/memory/conversation-key-store.ts +20 -0
  73. package/src/memory/conversation-store.ts +14 -2
  74. package/src/memory/db-connection.ts +28 -0
  75. package/src/memory/db-init.ts +1019 -0
  76. package/src/memory/db.ts +2 -1995
  77. package/src/memory/embedding-backend.ts +79 -11
  78. package/src/memory/indexer.ts +2 -0
  79. package/src/memory/job-utils.ts +64 -4
  80. package/src/memory/jobs-worker.ts +7 -1
  81. package/src/memory/recall-cache.ts +107 -0
  82. package/src/memory/retriever.ts +30 -1
  83. package/src/memory/schema-migration.ts +984 -0
  84. package/src/memory/schema.ts +6 -0
  85. package/src/memory/search/types.ts +2 -0
  86. package/src/permissions/prompter.ts +14 -3
  87. package/src/permissions/trust-store.ts +7 -0
  88. package/src/runtime/channel-approvals.ts +17 -3
  89. package/src/runtime/gateway-client.ts +2 -1
  90. package/src/runtime/http-server.ts +28 -9
  91. package/src/runtime/routes/channel-routes.ts +279 -100
  92. package/src/runtime/routes/run-routes.ts +7 -1
  93. package/src/runtime/run-orchestrator.ts +8 -1
  94. package/src/security/secret-scanner.ts +218 -0
  95. package/src/skills/clawhub.ts +6 -2
  96. package/src/skills/frontmatter.ts +63 -0
  97. package/src/skills/slash-commands.ts +23 -0
  98. package/src/skills/vellum-catalog-remote.ts +107 -0
  99. package/src/subagent/manager.ts +4 -1
  100. package/src/subagent/types.ts +2 -0
  101. package/src/tools/browser/auto-navigate.ts +132 -24
  102. package/src/tools/browser/browser-manager.ts +67 -61
  103. package/src/tools/claude-code/claude-code.ts +55 -3
  104. package/src/tools/executor.ts +10 -2
  105. package/src/tools/skills/vellum-catalog.ts +75 -127
  106. package/src/tools/subagent/spawn.ts +2 -0
  107. package/src/tools/terminal/parser.ts +21 -5
  108. package/src/util/platform.ts +8 -1
  109. package/src/util/retry.ts +4 -4
@@ -31,10 +31,7 @@ import type { MessageQueue } from './session-queue-manager.js';
31
31
  import type { QueueDrainReason } from './session-queue-manager.js';
32
32
  import {
33
33
  applyRuntimeInjections,
34
- stripActiveSurfaceContext,
35
- stripWorkspaceTopLevelContext,
36
- stripChannelCapabilityContext,
37
- stripTemporalContext,
34
+ stripInjectedContext,
38
35
  } from './session-runtime-assembly.js';
39
36
  import { buildTemporalContext } from './date-context.js';
40
37
  import type { ActiveSurfaceContext, ChannelCapabilities } from './session-runtime-assembly.js';
@@ -58,6 +55,7 @@ import { repairHistory, deepRepairHistory } from './history-repair.js';
58
55
  import { stripMediaPayloadsForRetry, raceWithTimeout } from './session-media-retry.js';
59
56
  import { commitTurnChanges } from '../workspace/turn-commit.js';
60
57
  import { getWorkspaceGitService } from '../workspace/git-service.js';
58
+ import { commitAppTurnChanges } from '../memory/app-git-service.js';
61
59
  import type { UsageActor } from '../usage/actors.js';
62
60
  import type { SkillProjectionCache } from './session-skill-tools.js';
63
61
 
@@ -739,16 +737,10 @@ export async function runAgentLoopImpl(
739
737
  }
740
738
 
741
739
  const restoredHistory = [...preRepairMessages, ...newMessages];
742
- const recallStripped = stripMemoryRecallMessages(restoredHistory, recall.injectedText, recallInjectionStrategy);
743
- ctx.messages = stripTemporalContext(
744
- stripChannelCapabilityContext(
745
- stripWorkspaceTopLevelContext(
746
- stripActiveSurfaceContext(
747
- stripDynamicProfileMessages(recallStripped, dynamicProfile.text),
748
- ),
749
- ),
750
- ),
751
- );
740
+ ctx.messages = stripInjectedContext(restoredHistory, {
741
+ stripRecall: (msgs) => stripMemoryRecallMessages(msgs, recall.injectedText, recallInjectionStrategy),
742
+ stripDynamicProfile: (msgs) => stripDynamicProfileMessages(msgs, dynamicProfile.text),
743
+ });
752
744
 
753
745
  emitUsage(ctx, exchangeInputTokens, exchangeOutputTokens, model, onEvent, 'main_agent', reqId);
754
746
 
@@ -859,6 +851,9 @@ export async function runAgentLoopImpl(
859
851
  'Turn-boundary commit timed out — continuing without waiting (commit still runs in background)',
860
852
  );
861
853
  }
854
+
855
+ // Commit app changes (fire-and-forget — apps repo is separate from workspace)
856
+ void commitAppTurnChanges(ctx.conversationId, ctx.turnCount);
862
857
  }
863
858
 
864
859
  ctx.profiler.emitSummary(ctx.traceEmitter, reqId);
@@ -8,12 +8,13 @@
8
8
 
9
9
  import type { Message } from '../providers/types.js';
10
10
  import type { ServerMessage, UserMessageAttachment } from './ipc-protocol.js';
11
+ import type { UsageStats } from './ipc-contract.js';
11
12
  import type { MessageQueue } from './session-queue-manager.js';
12
13
  import type { QueueDrainReason } from './session-queue-manager.js';
13
14
  import type { TraceEmitter } from './trace-emitter.js';
14
15
  import { createUserMessage, createAssistantMessage } from '../agent/message-types.js';
15
16
  import * as conversationStore from '../memory/conversation-store.js';
16
- import { resolveSlash } from './session-slash.js';
17
+ import { resolveSlash, type SlashContext } from './session-slash.js';
17
18
  import { getConfig } from '../config/loader.js';
18
19
  import { getLogger } from '../util/logger.js';
19
20
  import { tryRouteCallMessage } from '../calls/call-bridge.js';
@@ -56,6 +57,8 @@ export interface ProcessSessionContext {
56
57
  readonly traceEmitter: TraceEmitter;
57
58
  currentActiveSurfaceId?: string;
58
59
  currentPage?: string;
60
+ /** Cumulative token usage stats for the session. */
61
+ readonly usageStats: UsageStats;
59
62
  /** Request-scoped skill IDs preactivated via slash resolution. */
60
63
  preactivatedSkillIds?: string[];
61
64
  persistUserMessage(content: string, attachments: UserMessageAttachment[], requestId?: string, metadata?: Record<string, unknown>): string;
@@ -67,6 +70,20 @@ export interface ProcessSessionContext {
67
70
  ): Promise<void>;
68
71
  }
69
72
 
73
+ /** Build a SlashContext from the current session state and config. */
74
+ function buildSlashContext(session: ProcessSessionContext): SlashContext {
75
+ const config = getConfig();
76
+ return {
77
+ messageCount: session.messages.length,
78
+ inputTokens: session.usageStats.inputTokens,
79
+ outputTokens: session.usageStats.outputTokens,
80
+ maxInputTokens: config.contextWindow.maxInputTokens,
81
+ model: config.model,
82
+ provider: config.provider,
83
+ estimatedCost: session.usageStats.estimatedCost,
84
+ };
85
+ }
86
+
70
87
  // ── drainQueue ───────────────────────────────────────────────────────
71
88
 
72
89
  /**
@@ -96,7 +113,7 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
96
113
  });
97
114
 
98
115
  // Resolve slash commands for queued messages
99
- const slashResult = resolveSlash(next.content);
116
+ const slashResult = resolveSlash(next.content, buildSlashContext(session));
100
117
 
101
118
  // Unknown slash — persist the exchange and continue draining.
102
119
  // Persist each message before pushing to session.messages so that a
@@ -242,7 +259,7 @@ export async function processMessage(
242
259
  session.currentPage = currentPage;
243
260
 
244
261
  // Resolve slash commands before persistence
245
- const slashResult = resolveSlash(content);
262
+ const slashResult = resolveSlash(content, buildSlashContext(session));
246
263
 
247
264
  // Unknown slash command — persist the exchange (user + assistant) so the
248
265
  // messageId is real. Persist each message before pushing to session.messages
@@ -264,16 +264,24 @@ export function injectChannelCapabilityContext(message: Message, caps: ChannelCa
264
264
  };
265
265
  }
266
266
 
267
+ // ---------------------------------------------------------------------------
268
+ // Prefix-based stripping primitive
269
+ // ---------------------------------------------------------------------------
270
+
267
271
  /**
268
- * Strip `<channel_capabilities>` blocks injected by
269
- * `injectChannelCapabilityContext`.
272
+ * Remove text blocks from user messages whose text starts with any of the
273
+ * given prefixes. If stripping removes all content blocks from a message,
274
+ * the message itself is dropped.
275
+ *
276
+ * This is the shared primitive behind the individual strip* functions and
277
+ * the `stripInjectedContext` pipeline.
270
278
  */
271
- export function stripChannelCapabilityContext(messages: Message[]): Message[] {
279
+ export function stripUserTextBlocksByPrefix(messages: Message[], prefixes: string[]): Message[] {
272
280
  return messages.map((message) => {
273
281
  if (message.role !== 'user') return message;
274
282
  const nextContent = message.content.filter((block) => {
275
283
  if (block.type !== 'text') return true;
276
- return !block.text.startsWith('<channel_capabilities>');
284
+ return !prefixes.some((p) => block.text.startsWith(p));
277
285
  });
278
286
  if (nextContent.length === message.content.length) return message;
279
287
  if (nextContent.length === 0) return null;
@@ -281,6 +289,15 @@ export function stripChannelCapabilityContext(messages: Message[]): Message[] {
281
289
  }).filter((message): message is NonNullable<typeof message> => message !== null);
282
290
  }
283
291
 
292
+ // ---------------------------------------------------------------------------
293
+ // Individual strip functions (thin wrappers around the primitive)
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /** Strip `<channel_capabilities>` blocks injected by `injectChannelCapabilityContext`. */
297
+ export function stripChannelCapabilityContext(messages: Message[]): Message[] {
298
+ return stripUserTextBlocksByPrefix(messages, ['<channel_capabilities>']);
299
+ }
300
+
284
301
  /**
285
302
  * Prepend workspace top-level directory context to a user message.
286
303
  */
@@ -294,22 +311,9 @@ export function injectWorkspaceTopLevelContext(message: Message, contextText: st
294
311
  };
295
312
  }
296
313
 
297
- /**
298
- * Strip `<workspace_top_level>` blocks injected by
299
- * `injectWorkspaceTopLevelContext`. Called after the agent run to prevent
300
- * workspace context from persisting in session history.
301
- */
314
+ /** Strip `<workspace_top_level>` blocks injected by `injectWorkspaceTopLevelContext`. */
302
315
  export function stripWorkspaceTopLevelContext(messages: Message[]): Message[] {
303
- return messages.map((message) => {
304
- if (message.role !== 'user') return message;
305
- const nextContent = message.content.filter((block) => {
306
- if (block.type !== 'text') return true;
307
- return !block.text.startsWith('<workspace_top_level>');
308
- });
309
- if (nextContent.length === message.content.length) return message;
310
- if (nextContent.length === 0) return null;
311
- return { ...message, content: nextContent };
312
- }).filter((message): message is NonNullable<typeof message> => message !== null);
316
+ return stripUserTextBlocksByPrefix(messages, ['<workspace_top_level>']);
313
317
  }
314
318
 
315
319
  /**
@@ -328,8 +332,6 @@ export function injectTemporalContext(message: Message, temporalContext: string)
328
332
 
329
333
  /**
330
334
  * Strip `<temporal_context>` blocks injected by `injectTemporalContext`.
331
- * Called after the agent run to prevent temporal context from persisting
332
- * in session history.
333
335
  *
334
336
  * Uses a specific prefix (`<temporal_context>\nToday:`) so that
335
337
  * user-authored text that happens to start with `<temporal_context>`
@@ -338,35 +340,49 @@ export function injectTemporalContext(message: Message, temporalContext: string)
338
340
  const TEMPORAL_INJECTED_PREFIX = '<temporal_context>\nToday:';
339
341
 
340
342
  export function stripTemporalContext(messages: Message[]): Message[] {
341
- return messages.map((message) => {
342
- if (message.role !== 'user') return message;
343
- const nextContent = message.content.filter((block) => {
344
- if (block.type !== 'text') return true;
345
- return !block.text.startsWith(TEMPORAL_INJECTED_PREFIX);
346
- });
347
- if (nextContent.length === message.content.length) return message;
348
- if (nextContent.length === 0) return null;
349
- return { ...message, content: nextContent };
350
- }).filter((message): message is NonNullable<typeof message> => message !== null);
343
+ return stripUserTextBlocksByPrefix(messages, [TEMPORAL_INJECTED_PREFIX]);
351
344
  }
352
345
 
353
346
  /**
354
347
  * Strip `<active_workspace>` (and legacy `<active_dynamic_page>`) blocks
355
- * injected by `injectActiveSurfaceContext`. Called after the agent run to
356
- * prevent the (potentially 100 KB) surface HTML from persisting in session
357
- * history.
348
+ * injected by `injectActiveSurfaceContext`.
358
349
  */
359
350
  export function stripActiveSurfaceContext(messages: Message[]): Message[] {
360
- return messages.map((message) => {
361
- if (message.role !== 'user') return message;
362
- const nextContent = message.content.filter((block) => {
363
- if (block.type !== 'text') return true;
364
- return !block.text.startsWith('<active_workspace>') && !block.text.startsWith('<active_dynamic_page>');
365
- });
366
- if (nextContent.length === message.content.length) return message;
367
- if (nextContent.length === 0) return null;
368
- return { ...message, content: nextContent };
369
- }).filter((message): message is NonNullable<typeof message> => message !== null);
351
+ return stripUserTextBlocksByPrefix(messages, ['<active_workspace>', '<active_dynamic_page>']);
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Declarative strip pipeline
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /** Prefixes stripped by the pipeline (order doesn't matter — single pass). */
359
+ const RUNTIME_INJECTION_PREFIXES = [
360
+ '<channel_capabilities>',
361
+ '<workspace_top_level>',
362
+ TEMPORAL_INJECTED_PREFIX,
363
+ '<active_workspace>',
364
+ '<active_dynamic_page>',
365
+ ];
366
+
367
+ /**
368
+ * Strip all runtime-injected context from message history in a single pass.
369
+ *
370
+ * Composes:
371
+ * 1. `stripMemoryRecallMessages` (caller-supplied, handles its own logic)
372
+ * 2. `stripDynamicProfileMessages` (caller-supplied, handles its own logic)
373
+ * 3. Prefix-based stripping for channel capabilities, workspace top-level,
374
+ * temporal context, and active surface context (single pass).
375
+ */
376
+ export function stripInjectedContext(
377
+ messages: Message[],
378
+ options: {
379
+ stripRecall: (msgs: Message[]) => Message[];
380
+ stripDynamicProfile: (msgs: Message[]) => Message[];
381
+ },
382
+ ): Message[] {
383
+ const afterRecall = options.stripRecall(messages);
384
+ const afterProfile = options.stripDynamicProfile(afterRecall);
385
+ return stripUserTextBlocksByPrefix(afterProfile, RUNTIME_INJECTION_PREFIXES);
370
386
  }
371
387
 
372
388
  /**
@@ -15,6 +15,18 @@ export type SlashResolution =
15
15
  | { kind: 'rewritten'; content: string; skillId: string }
16
16
  | { kind: 'unknown'; message: string };
17
17
 
18
+ // ── /status command ──────────────────────────────────────────────────
19
+
20
+ export interface SlashContext {
21
+ messageCount: number;
22
+ inputTokens: number;
23
+ outputTokens: number;
24
+ maxInputTokens: number;
25
+ model: string;
26
+ provider: string;
27
+ estimatedCost: number;
28
+ }
29
+
18
30
  // ── /model command ───────────────────────────────────────────────────
19
31
 
20
32
  const AVAILABLE_MODELS = [
@@ -242,11 +254,31 @@ function resolveModelCommand(content: string): SlashResolution | null {
242
254
  };
243
255
  }
244
256
 
257
+ function resolveStatusCommand(context: SlashContext): SlashResolution {
258
+ const { inputTokens, maxInputTokens, model, provider, messageCount, outputTokens, estimatedCost } = context;
259
+ const pct = maxInputTokens > 0 ? Math.min(Math.round((inputTokens / maxInputTokens) * 100), 100) : 0;
260
+ const filled = Math.round(pct / 5);
261
+ const bar = '█'.repeat(filled) + '░'.repeat(20 - filled);
262
+ const fmt = (n: number) => n.toLocaleString('en-US');
263
+ const displayName = MODEL_DISPLAY_NAMES[model] ?? model;
264
+
265
+ const lines = [
266
+ 'Session Status\n',
267
+ `Context: ${bar} ${pct}% (${fmt(inputTokens)} / ${fmt(maxInputTokens)} tokens)`,
268
+ `Model: ${displayName} (${provider})`,
269
+ `Messages: ${fmt(messageCount)}`,
270
+ `Tokens: ${fmt(inputTokens)} in / ${fmt(outputTokens)} out`,
271
+ `Cost: $${estimatedCost.toFixed(2)} (estimated)`,
272
+ ];
273
+
274
+ return { kind: 'unknown', message: lines.join('\n') };
275
+ }
276
+
245
277
  /**
246
278
  * Resolve slash commands against the current skill catalog.
247
279
  * Returns `unknown` with a deterministic message, or the (possibly rewritten) content.
248
280
  */
249
- export function resolveSlash(content: string): SlashResolution {
281
+ export function resolveSlash(content: string, context?: SlashContext): SlashResolution {
250
282
  // Check provider shortcuts first (/gpt4, /opus, etc.)
251
283
  const providerResult = resolveProviderModelCommand(content);
252
284
  if (providerResult) return providerResult;
@@ -255,11 +287,27 @@ export function resolveSlash(content: string): SlashResolution {
255
287
  const modelResult = resolveModelCommand(content);
256
288
  if (modelResult) return modelResult;
257
289
 
290
+ // Handle /status command
291
+ if (content.trim() === '/status') {
292
+ if (!context) {
293
+ return { kind: 'unknown', message: 'Status information is not available in this context.' };
294
+ }
295
+ return resolveStatusCommand(context);
296
+ }
297
+
258
298
  // Handle /commands command
259
299
  if (content.trim() === '/commands') {
300
+ const lines = [
301
+ '/commands — List all available commands',
302
+ '/model — Show or switch the current model',
303
+ '/models — List all available models',
304
+ ];
305
+ if (context) {
306
+ lines.push('/status — Show session status and context usage');
307
+ }
260
308
  return {
261
309
  kind: 'unknown',
262
- message: '/commands — List all available commands\n/model — Show or switch the current model\n/models — List all available models',
310
+ message: lines.join('\n'),
263
311
  };
264
312
  }
265
313
 
@@ -429,6 +429,12 @@ export function refreshSurfacesForApp(ctx: SurfaceSessionContext, appId: string,
429
429
  };
430
430
  stored.data = updatedData;
431
431
 
432
+ // Keep the persisted snapshot in sync so updates survive session restart.
433
+ const idx = ctx.currentTurnSurfaces.findIndex(s => s.surfaceId === surfaceId);
434
+ if (idx !== -1) {
435
+ ctx.currentTurnSurfaces[idx].data = updatedData;
436
+ }
437
+
432
438
  // Push the update to the client
433
439
  ctx.sendToClient({
434
440
  type: 'ui_surface_update',
@@ -603,6 +609,13 @@ export async function surfaceProxyResolver(
603
609
  surfaceId,
604
610
  data: mergedData,
605
611
  });
612
+
613
+ // Keep the persisted snapshot in sync so updates survive session restart.
614
+ const idx = ctx.currentTurnSurfaces.findIndex(s => s.surfaceId === surfaceId);
615
+ if (idx !== -1) {
616
+ ctx.currentTurnSurfaces[idx].data = mergedData;
617
+ }
618
+
606
619
  return { content: 'Surface updated', isError: false };
607
620
  }
608
621
 
@@ -662,7 +675,10 @@ export async function surfaceProxyResolver(
662
675
  const seededHomeBase = findSeededHomeBaseApp();
663
676
  const defaultPreview = seededHomeBase && seededHomeBase.id === app.id
664
677
  ? getPrebuiltHomeBasePreview()
665
- : undefined;
678
+ // Generate a minimal fallback preview from app metadata so that the
679
+ // surface is always rendered as a clickable preview card (not an
680
+ // un-clickable fallback chip) after session restart.
681
+ : { title: app.name, subtitle: app.description };
666
682
 
667
683
  const surfaceData: DynamicPageSurfaceData = {
668
684
  html: app.htmlDefinition,
@@ -308,8 +308,15 @@ export class Session {
308
308
  decision: UserDecision,
309
309
  selectedPattern?: string,
310
310
  selectedScope?: string,
311
+ decisionContext?: string,
311
312
  ): void {
312
- this.prompter.resolveConfirmation(requestId, decision, selectedPattern, selectedScope);
313
+ this.prompter.resolveConfirmation(
314
+ requestId,
315
+ decision,
316
+ selectedPattern,
317
+ selectedScope,
318
+ decisionContext,
319
+ );
313
320
  }
314
321
 
315
322
  handleSecretResponse(requestId: string, value?: string, delivery?: 'store' | 'transient_send'): void {
@@ -73,11 +73,20 @@ export function getPublicBaseUrl(config: IngressConfig): string {
73
73
  }
74
74
 
75
75
  /**
76
- * Build the Twilio voice webhook URL for a given call session.
76
+ * Build the Twilio voice webhook URL.
77
+ *
78
+ * When `callSessionId` is provided (outbound calls), it is included as a
79
+ * query parameter so the gateway can correlate the webhook to an existing
80
+ * session. When omitted (phone-number-level webhook configuration for
81
+ * inbound calls), the URL is returned without the query parameter — the
82
+ * gateway will create a new session for inbound calls.
77
83
  */
78
- export function getTwilioVoiceWebhookUrl(config: IngressConfig, callSessionId: string): string {
84
+ export function getTwilioVoiceWebhookUrl(config: IngressConfig, callSessionId?: string): string {
79
85
  const base = getPublicBaseUrl(config);
80
- return `${base}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
86
+ if (callSessionId) {
87
+ return `${base}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
88
+ }
89
+ return `${base}/webhooks/twilio/voice`;
81
90
  }
82
91
 
83
92
  /**
@@ -114,6 +123,14 @@ export function getOAuthCallbackUrl(config: IngressConfig): string {
114
123
  return `${base}/webhooks/oauth/callback`;
115
124
  }
116
125
 
126
+ /**
127
+ * Build the Twilio SMS webhook URL.
128
+ */
129
+ export function getTwilioSmsWebhookUrl(config: IngressConfig): string {
130
+ const base = getPublicBaseUrl(config);
131
+ return `${base}/webhooks/twilio/sms`;
132
+ }
133
+
117
134
  /**
118
135
  * Build the Telegram webhook URL.
119
136
  */
package/src/index.ts CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  import { Command } from 'commander';
4
4
  import { createRequire } from 'node:module';
5
- import { dirname, join } from 'node:path';
6
- import { spawn } from 'node:child_process';
7
5
 
8
6
  const require = createRequire(import.meta.url);
9
7
  const { version } = require('../package.json') as { version: string };
@@ -58,24 +56,4 @@ registerCompletionsCommand(program);
58
56
  registerTwitterCommand(program);
59
57
  registerMapCommand(program);
60
58
 
61
- const knownCommands = new Set(program.commands.map(cmd => cmd.name()));
62
- const firstArg = process.argv[2];
63
-
64
- if (firstArg && !firstArg.startsWith('-') && !knownCommands.has(firstArg)) {
65
- try {
66
- const cliPkgPath = require.resolve('@vellumai/cli/package.json');
67
- const cliEntry = join(dirname(cliPkgPath), 'src', 'index.ts');
68
- const child = spawn('bun', ['run', cliEntry, ...process.argv.slice(2)], {
69
- stdio: 'inherit',
70
- });
71
- child.on('exit', (code) => {
72
- process.exit(code ?? 1);
73
- });
74
- } catch {
75
- console.error(`Unknown command: ${firstArg}`);
76
- console.error('Install the full stack with: bun install -g vellum');
77
- process.exit(1);
78
- }
79
- } else {
80
- program.parse();
81
- }
59
+ program.parse();
@@ -141,6 +141,30 @@ export async function commitAppChange(message: string): Promise<void> {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * Commit app changes at turn boundaries.
146
+ *
147
+ * Called once per agent turn (after all tool calls complete). Only creates
148
+ * a commit if there are actual changes in the apps directory, so multiple
149
+ * mutations within a single turn are batched into one version.
150
+ *
151
+ * Fire-and-forget safe: errors are logged but never thrown.
152
+ */
153
+ export async function commitAppTurnChanges(sessionId: string, turnNumber: number): Promise<void> {
154
+ try {
155
+ const appsDir = getAppsDir();
156
+ ensureAppGitignoreRules(appsDir);
157
+
158
+ const gitService = getWorkspaceGitService(appsDir);
159
+ await gitService.commitIfDirty(() => ({
160
+ message: `Turn ${turnNumber}: app changes`,
161
+ metadata: { sessionId, turnNumber },
162
+ }));
163
+ } catch (err) {
164
+ log.error({ err, sessionId, turnNumber }, 'Failed to commit app turn changes');
165
+ }
166
+ }
167
+
144
168
  // ---------------------------------------------------------------------------
145
169
  // Query methods
146
170
  // ---------------------------------------------------------------------------
@@ -29,7 +29,6 @@ import {
29
29
  isPrebuiltHomeBaseApp,
30
30
  validatePrebuiltHomeBaseHtml,
31
31
  } from '../home-base/prebuilt-home-base-updater.js';
32
- import { commitAppChange } from './app-git-service.js';
33
32
 
34
33
  export interface AppDefinition {
35
34
  id: string;
@@ -211,8 +210,6 @@ export function createApp(params: {
211
210
  app.pages = params.pages;
212
211
  }
213
212
 
214
- void commitAppChange(`Create app: ${params.name}`);
215
-
216
213
  return app;
217
214
  }
218
215
 
@@ -375,27 +372,14 @@ export function updateApp(
375
372
  updated.pages = loadedPages;
376
373
  }
377
374
 
378
- const changedFields = Object.keys(updates).filter(k => updates[k as keyof typeof updates] !== undefined);
379
- void commitAppChange(`Update app: ${updated.name}\n\nChanged: ${changedFields.join(', ')}`);
380
-
381
375
  return updated;
382
376
  }
383
377
 
384
378
  export function deleteApp(id: string): void {
385
379
  validateId(id);
386
380
  const dir = getAppsDir();
387
-
388
- // Read app name before deleting for the commit message
389
- let appName = id;
390
381
  const filePath = join(dir, `${id}.json`);
391
382
  if (existsSync(filePath)) {
392
- try {
393
- const raw = readFileSync(filePath, 'utf-8');
394
- const app = JSON.parse(raw);
395
- if (app.name) appName = app.name;
396
- } catch {
397
- // fall back to id
398
- }
399
383
  unlinkSync(filePath);
400
384
  }
401
385
  const previewPath = join(dir, `${id}.preview`);
@@ -404,8 +388,6 @@ export function deleteApp(id: string): void {
404
388
  }
405
389
  const appDir = join(dir, id);
406
390
  rmSync(appDir, { recursive: true, force: true });
407
-
408
- void commitAppChange(`Delete app: ${appName}`);
409
391
  }
410
392
 
411
393
  export function createAppRecord(appId: string, data: Record<string, unknown>): AppRecord {
@@ -545,8 +527,6 @@ export function writeAppFile(appId: string, path: string, content: string): void
545
527
  const dir = join(resolved, '..');
546
528
  mkdirSync(dir, { recursive: true });
547
529
  writeFileSync(resolved, content, 'utf-8');
548
-
549
- void commitAppChange(`Write ${path} in app ${appId}`);
550
530
  }
551
531
 
552
532
  /**
@@ -569,7 +549,6 @@ export function editAppFile(
569
549
  const result = applyEdit(content, oldString, newString, replaceAll ?? false);
570
550
  if (result.ok) {
571
551
  writeFileSync(resolved, result.updatedContent, 'utf-8');
572
- void commitAppChange(`Edit ${path} in app ${appId}`);
573
552
  }
574
553
  return result;
575
554
  }