@vellumai/assistant 0.3.2 → 0.3.3

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 (52) hide show
  1. package/README.md +82 -13
  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__/channel-approval-routes.test.ts +930 -14
  7. package/src/__tests__/channel-approval.test.ts +2 -0
  8. package/src/__tests__/channel-delivery-store.test.ts +104 -1
  9. package/src/__tests__/channel-guardian.test.ts +184 -1
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  12. package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
  13. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  14. package/src/__tests__/handlers-twilio-config.test.ts +665 -5
  15. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  16. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  17. package/src/__tests__/run-orchestrator.test.ts +1 -1
  18. package/src/__tests__/session-process-bridge.test.ts +2 -0
  19. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  20. package/src/calls/twilio-config.ts +10 -1
  21. package/src/calls/twilio-rest.ts +70 -0
  22. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  23. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  24. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  25. package/src/config/schema.ts +3 -0
  26. package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
  27. package/src/daemon/handlers/config.ts +168 -15
  28. package/src/daemon/handlers/sessions.ts +5 -3
  29. package/src/daemon/handlers/skills.ts +61 -17
  30. package/src/daemon/ipc-contract-inventory.json +4 -0
  31. package/src/daemon/ipc-contract.ts +10 -0
  32. package/src/daemon/session-agent-loop.ts +4 -0
  33. package/src/daemon/session-process.ts +20 -3
  34. package/src/daemon/session-slash.ts +50 -2
  35. package/src/daemon/session-surfaces.ts +17 -1
  36. package/src/inbound/public-ingress-urls.ts +20 -3
  37. package/src/index.ts +1 -23
  38. package/src/memory/app-git-service.ts +24 -0
  39. package/src/memory/app-store.ts +0 -21
  40. package/src/memory/channel-delivery-store.ts +74 -3
  41. package/src/memory/channel-guardian-store.ts +54 -26
  42. package/src/memory/conversation-key-store.ts +20 -0
  43. package/src/memory/conversation-store.ts +14 -2
  44. package/src/memory/db.ts +12 -0
  45. package/src/memory/schema.ts +5 -0
  46. package/src/runtime/http-server.ts +13 -5
  47. package/src/runtime/routes/channel-routes.ts +134 -43
  48. package/src/skills/clawhub.ts +6 -2
  49. package/src/subagent/manager.ts +4 -1
  50. package/src/subagent/types.ts +2 -0
  51. package/src/tools/skills/vellum-catalog.ts +45 -2
  52. package/src/tools/subagent/spawn.ts +2 -0
@@ -5,8 +5,9 @@ import { getConfig, loadRawConfig, saveRawConfig, invalidateConfigCache } from '
5
5
  import { loadSkillCatalog, loadSkillBySelector, ensureSkillIcon } from '../../config/skills.js';
6
6
  import { resolveSkillStates } from '../../config/skill-state.js';
7
7
  import { getWorkspaceSkillsDir } from '../../util/platform.js';
8
- import { clawhubInstall, clawhubUpdate, clawhubSearch, clawhubCheckUpdates, clawhubInspect } from '../../skills/clawhub.js';
8
+ import { clawhubInstall, clawhubUpdate, clawhubSearch, clawhubCheckUpdates, clawhubInspect, type ClawhubSearchResultItem } from '../../skills/clawhub.js';
9
9
  import { removeSkillsIndexEntry, deleteManagedSkill, validateManagedSkillId } from '../../skills/managed-store.js';
10
+ import { listCatalogEntries, installFromVellumCatalog } from '../../tools/skills/vellum-catalog.js';
10
11
  import type {
11
12
  SkillDetailRequest,
12
13
  SkillsEnableRequest,
@@ -186,26 +187,45 @@ export async function handleSkillsInstall(
186
187
  ctx: HandlerContext,
187
188
  ): Promise<void> {
188
189
  try {
189
- const result = await clawhubInstall(msg.slug, { version: msg.version });
190
- if (!result.success) {
191
- ctx.send(socket, {
192
- type: 'skills_operation_response',
193
- operation: 'install',
194
- success: false,
195
- error: result.error ?? 'Unknown error',
196
- });
197
- return;
190
+ // Check if the slug matches a vellum-skills catalog entry first
191
+ const catalogEntries = listCatalogEntries();
192
+ const isVellumSkill = catalogEntries.some((e) => e.id === msg.slug);
193
+
194
+ let skillId: string;
195
+
196
+ if (isVellumSkill) {
197
+ // Install from vellum-skills catalog (local copy)
198
+ const result = installFromVellumCatalog(msg.slug);
199
+ if (!result.success) {
200
+ ctx.send(socket, {
201
+ type: 'skills_operation_response',
202
+ operation: 'install',
203
+ success: false,
204
+ error: result.error ?? 'Unknown error',
205
+ });
206
+ return;
207
+ }
208
+ skillId = result.skillName ?? msg.slug;
209
+ } else {
210
+ // Install from clawhub (community)
211
+ const result = await clawhubInstall(msg.slug, { version: msg.version });
212
+ if (!result.success) {
213
+ ctx.send(socket, {
214
+ type: 'skills_operation_response',
215
+ operation: 'install',
216
+ success: false,
217
+ error: result.error ?? 'Unknown error',
218
+ });
219
+ return;
220
+ }
221
+ const rawId = result.skillName ?? msg.slug;
222
+ skillId = rawId.includes('/') ? rawId.split('/').pop()! : rawId;
198
223
  }
199
224
 
200
225
  // Reload skill catalog so the newly installed skill is picked up
201
226
  loadSkillCatalog();
202
227
 
203
228
  // Auto-enable the newly installed skill so it's immediately usable.
204
- // Use basename of slug to match the catalog ID (directory basename), since
205
- // install slugs can be namespaced (e.g. "org/name") but skill state keys use
206
- // the bare directory name.
207
- const rawId = result.skillName ?? msg.slug;
208
- const skillId = rawId.includes('/') ? rawId.split('/').pop()! : rawId;
209
229
  try {
210
230
  const raw = loadRawConfig();
211
231
  ensureSkillEntry(raw, skillId).enabled = true;
@@ -404,12 +424,36 @@ export async function handleSkillsSearch(
404
424
  ctx: HandlerContext,
405
425
  ): Promise<void> {
406
426
  try {
407
- const result = await clawhubSearch(msg.query);
427
+ // Search vellum-skills catalog locally
428
+ const catalogEntries = listCatalogEntries();
429
+ const query = (msg.query ?? '').toLowerCase();
430
+ const matchingCatalog = catalogEntries.filter((e) => {
431
+ if (!query) return true;
432
+ return e.name.toLowerCase().includes(query) || e.description.toLowerCase().includes(query) || e.id.toLowerCase().includes(query);
433
+ });
434
+ const vellumSkills: ClawhubSearchResultItem[] = matchingCatalog.map((e) => ({
435
+ name: e.name,
436
+ slug: e.id,
437
+ description: e.description,
438
+ author: 'Vellum',
439
+ stars: 0,
440
+ installs: 0,
441
+ version: '',
442
+ createdAt: 0,
443
+ source: 'vellum' as const,
444
+ }));
445
+
446
+ // Search clawhub concurrently
447
+ const clawhubResult = await clawhubSearch(msg.query);
448
+
449
+ // Merge: vellum first, then clawhub
450
+ const merged = { skills: [...vellumSkills, ...clawhubResult.skills] };
451
+
408
452
  ctx.send(socket, {
409
453
  type: 'skills_operation_response',
410
454
  operation: 'search',
411
455
  success: true,
412
- data: result,
456
+ data: merged,
413
457
  });
414
458
  } catch (err) {
415
459
  const message = err instanceof Error ? err.message : String(err);
@@ -91,6 +91,7 @@
91
91
  "ToolNamesListRequest",
92
92
  "ToolPermissionSimulateRequest",
93
93
  "TrustRulesList",
94
+ "TwilioConfigRequest",
94
95
  "TwitterAuthStartRequest",
95
96
  "TwitterAuthStatusRequest",
96
97
  "TwitterIntegrationConfigRequest",
@@ -215,6 +216,7 @@
215
216
  "ToolUseStart",
216
217
  "TraceEvent",
217
218
  "TrustRulesListResponse",
219
+ "TwilioConfigResponse",
218
220
  "TwitterAuthResult",
219
221
  "TwitterAuthStatusResponse",
220
222
  "TwitterIntegrationConfigResponse",
@@ -338,6 +340,7 @@
338
340
  "tool_names_list",
339
341
  "tool_permission_simulate",
340
342
  "trust_rules_list",
343
+ "twilio_config",
341
344
  "twitter_auth_start",
342
345
  "twitter_auth_status",
343
346
  "twitter_integration_config",
@@ -462,6 +465,7 @@
462
465
  "tool_use_start",
463
466
  "trace_event",
464
467
  "trust_rules_list_response",
468
+ "twilio_config_response",
465
469
  "twitter_auth_result",
466
470
  "twitter_auth_status_response",
467
471
  "twitter_integration_config_response",
@@ -67,6 +67,10 @@ export interface SecretResponse {
67
67
 
68
68
  export interface SessionListRequest {
69
69
  type: 'session_list';
70
+ /** Number of sessions to skip (for pagination). Defaults to 0. */
71
+ offset?: number;
72
+ /** Maximum number of sessions to return. Defaults to 50. */
73
+ limit?: number;
70
74
  }
71
75
 
72
76
  /** Lightweight session transport metadata for channel identity and natural-language guidance. */
@@ -557,6 +561,7 @@ export interface TwilioConfigRequest {
557
561
  phoneNumber?: string; // Only for action: 'assign_number'
558
562
  areaCode?: string; // Only for action: 'provision_number'
559
563
  country?: string; // Only for action: 'provision_number' (ISO 3166-1 alpha-2, default 'US')
564
+ assistantId?: string; // Scope number assignment/lookup to a specific assistant
560
565
  }
561
566
 
562
567
  export interface TwilioConfigResponse {
@@ -566,6 +571,8 @@ export interface TwilioConfigResponse {
566
571
  phoneNumber?: string;
567
572
  numbers?: Array<{ phoneNumber: string; friendlyName: string; capabilities: { voice: boolean; sms: boolean } }>;
568
573
  error?: string;
574
+ /** Non-fatal warning message (e.g. webhook sync failure that did not prevent the primary operation). */
575
+ warning?: string;
569
576
  }
570
577
 
571
578
  export interface GuardianVerificationRequest {
@@ -573,6 +580,7 @@ export interface GuardianVerificationRequest {
573
580
  action: 'create_challenge' | 'status' | 'revoke';
574
581
  channel?: string; // Defaults to 'telegram'
575
582
  sessionId?: string;
583
+ assistantId?: string; // Defaults to 'self'
576
584
  }
577
585
 
578
586
  export interface GuardianVerificationResponse {
@@ -1271,6 +1279,8 @@ export interface ChannelBinding {
1271
1279
  export interface SessionListResponse {
1272
1280
  type: 'session_list_response';
1273
1281
  sessions: Array<{ id: string; title: string; updatedAt: number; threadType?: ThreadType; channelBinding?: ChannelBinding }>;
1282
+ /** Whether more sessions exist beyond the returned page. */
1283
+ hasMore?: boolean;
1274
1284
  }
1275
1285
 
1276
1286
  export interface SessionsClearResponse {
@@ -58,6 +58,7 @@ import { repairHistory, deepRepairHistory } from './history-repair.js';
58
58
  import { stripMediaPayloadsForRetry, raceWithTimeout } from './session-media-retry.js';
59
59
  import { commitTurnChanges } from '../workspace/turn-commit.js';
60
60
  import { getWorkspaceGitService } from '../workspace/git-service.js';
61
+ import { commitAppTurnChanges } from '../memory/app-git-service.js';
61
62
  import type { UsageActor } from '../usage/actors.js';
62
63
  import type { SkillProjectionCache } from './session-skill-tools.js';
63
64
 
@@ -859,6 +860,9 @@ export async function runAgentLoopImpl(
859
860
  'Turn-boundary commit timed out — continuing without waiting (commit still runs in background)',
860
861
  );
861
862
  }
863
+
864
+ // Commit app changes (fire-and-forget — apps repo is separate from workspace)
865
+ void commitAppTurnChanges(ctx.conversationId, ctx.turnCount);
862
866
  }
863
867
 
864
868
  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
@@ -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,
@@ -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
  }
@@ -15,7 +15,7 @@ import { eq, and, lte, isNotNull } from 'drizzle-orm';
15
15
  import { v4 as uuid } from 'uuid';
16
16
  import { getDb } from './db.js';
17
17
  import { channelInboundEvents, conversations } from './schema.js';
18
- import { getOrCreateConversation } from './conversation-key-store.js';
18
+ import { getConversationByKey, getOrCreateConversation, setConversationKeyIfAbsent } from './conversation-key-store.js';
19
19
  import {
20
20
  classifyError,
21
21
  retryDelayForAttempt,
@@ -31,6 +31,7 @@ export interface InboundResult {
31
31
 
32
32
  export interface RecordInboundOptions {
33
33
  sourceMessageId?: string;
34
+ assistantId?: string;
34
35
  }
35
36
 
36
37
  /**
@@ -69,8 +70,30 @@ export function recordInbound(
69
70
  };
70
71
  }
71
72
 
72
- const conversationKey = `${sourceChannel}:${externalChatId}`;
73
- const mapping = getOrCreateConversation(conversationKey);
73
+ const assistantId = options?.assistantId;
74
+ const legacyKey = `${sourceChannel}:${externalChatId}`;
75
+ const scopedKey = assistantId ? `asst:${assistantId}:${sourceChannel}:${externalChatId}` : legacyKey;
76
+
77
+ // Resolve conversation mapping with assistant-scoped keying:
78
+ // 1. If scoped key exists, use it directly.
79
+ // 2. If assistantId is "self" and legacy key exists, reuse the legacy
80
+ // conversation and create a scoped alias to prevent future bleed.
81
+ // 3. Otherwise, create/get conversation from the scoped key.
82
+ let mapping: { conversationId: string; created: boolean };
83
+ const scopedMapping = assistantId ? getConversationByKey(scopedKey) : null;
84
+ if (scopedMapping) {
85
+ mapping = { conversationId: scopedMapping.conversationId, created: false };
86
+ } else if (assistantId === 'self') {
87
+ const legacyMapping = getConversationByKey(legacyKey);
88
+ if (legacyMapping) {
89
+ mapping = { conversationId: legacyMapping.conversationId, created: false };
90
+ setConversationKeyIfAbsent(scopedKey, legacyMapping.conversationId);
91
+ } else {
92
+ mapping = getOrCreateConversation(scopedKey);
93
+ }
94
+ } else {
95
+ mapping = getOrCreateConversation(scopedKey);
96
+ }
74
97
  const now = Date.now();
75
98
  const eventId = uuid();
76
99
 
@@ -316,6 +339,54 @@ export function getDeadLetterEvents(): Array<{
316
339
  .all();
317
340
  }
318
341
 
342
+ // ── Deliver-once guard for terminal reply idempotency ────────────────
343
+ //
344
+ // When both the main poll (processChannelMessageWithApprovals) and the
345
+ // post-decision poll (schedulePostDecisionDelivery) race to deliver the
346
+ // final assistant reply for the same run, this guard ensures only one
347
+ // of them actually sends the message. The guard is run-scoped so old
348
+ // assistant messages from previous runs are not affected.
349
+
350
+ const deliveredRuns = new Set<string>();
351
+
352
+ /** TTL for delivery claims — 10 minutes, well beyond the poll max-wait. */
353
+ const CLAIM_TTL_MS = 10 * 60 * 1000;
354
+
355
+ /**
356
+ * Atomically claim the right to deliver the final reply for a run.
357
+ * Returns `true` if this caller won the claim (and should proceed with
358
+ * delivery). Returns `false` if another caller already claimed it.
359
+ *
360
+ * This is an in-memory guard — sufficient because both racing pollers
361
+ * execute within the same process. The Set is never persisted; on restart
362
+ * there are no in-flight pollers to race.
363
+ *
364
+ * Claims are automatically evicted after CLAIM_TTL_MS to prevent
365
+ * unbounded Set growth over the lifetime of the process.
366
+ */
367
+ export function claimRunDelivery(runId: string): boolean {
368
+ if (deliveredRuns.has(runId)) return false;
369
+ deliveredRuns.add(runId);
370
+ setTimeout(() => deliveredRuns.delete(runId), CLAIM_TTL_MS);
371
+ return true;
372
+ }
373
+
374
+ /**
375
+ * Reset the deliver-once guard for a run. Used to release a claim when
376
+ * delivery fails (so the other racing poller can retry) and in tests
377
+ * for isolation between test cases.
378
+ */
379
+ export function resetRunDeliveryClaim(runId: string): void {
380
+ deliveredRuns.delete(runId);
381
+ }
382
+
383
+ /**
384
+ * Clear all delivery claims. Used in tests for full isolation.
385
+ */
386
+ export function resetAllRunDeliveryClaims(): void {
387
+ deliveredRuns.clear();
388
+ }
389
+
319
390
  /**
320
391
  * Reset dead-lettered events back to 'failed' so the sweep can retry
321
392
  * them. Resets attempt counter and sets an immediate retry_after.