vellum 0.2.9 → 0.2.11

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 (61) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +2 -2
  3. package/scripts/capture-x-graphql.ts +1 -18
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
  5. package/src/__tests__/call-bridge.test.ts +40 -0
  6. package/src/__tests__/call-state.test.ts +41 -0
  7. package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
  8. package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
  9. package/src/__tests__/home-base-bootstrap.test.ts +13 -8
  10. package/src/__tests__/intent-routing.test.ts +2 -5
  11. package/src/__tests__/ipc-snapshot.test.ts +49 -0
  12. package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
  13. package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
  14. package/src/__tests__/relay-server.test.ts +55 -0
  15. package/src/__tests__/skills.test.ts +83 -0
  16. package/src/__tests__/system-prompt.test.ts +2 -24
  17. package/src/__tests__/twilio-provider.test.ts +36 -0
  18. package/src/__tests__/twilio-routes.test.ts +108 -0
  19. package/src/calls/call-orchestrator.ts +25 -5
  20. package/src/calls/call-state.ts +23 -0
  21. package/src/calls/relay-server.ts +56 -1
  22. package/src/calls/twilio-config.ts +9 -13
  23. package/src/calls/twilio-provider.ts +6 -1
  24. package/src/calls/twilio-routes.ts +10 -1
  25. package/src/cli/core-commands.ts +12 -4
  26. package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
  27. package/src/config/bundled-skills/document/SKILL.md +11 -3
  28. package/src/config/bundled-skills/followups/icon.svg +24 -0
  29. package/src/config/bundled-skills/messaging/SKILL.md +7 -3
  30. package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
  31. package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
  32. package/src/config/defaults.ts +1 -1
  33. package/src/config/schema.ts +4 -7
  34. package/src/config/system-prompt.ts +64 -360
  35. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
  36. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
  37. package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
  38. package/src/daemon/handlers/config.ts +20 -9
  39. package/src/daemon/handlers/home-base.ts +3 -2
  40. package/src/daemon/handlers/identity.ts +127 -0
  41. package/src/daemon/handlers/index.ts +4 -0
  42. package/src/daemon/handlers/workspace-files.ts +75 -0
  43. package/src/daemon/ipc-contract-inventory.json +16 -4
  44. package/src/daemon/ipc-contract.ts +62 -2
  45. package/src/daemon/lifecycle.ts +16 -0
  46. package/src/daemon/session-notifiers.ts +29 -0
  47. package/src/daemon/session-surfaces.ts +5 -2
  48. package/src/daemon/session-tool-setup.ts +15 -4
  49. package/src/home-base/bootstrap.ts +3 -1
  50. package/src/home-base/prebuilt/seed.ts +16 -5
  51. package/src/inbound/public-ingress-urls.ts +15 -4
  52. package/src/runtime/http-server.ts +123 -20
  53. package/src/security/oauth2.ts +19 -161
  54. package/src/tools/browser/auto-navigate.ts +2 -2
  55. package/src/tools/browser/x-auto-navigate.ts +1 -1
  56. package/src/tools/claude-code/claude-code.ts +1 -1
  57. package/src/tools/system/version.ts +43 -0
  58. package/src/tools/tasks/work-item-run.ts +1 -1
  59. package/src/tools/terminal/parser.ts +29 -7
  60. package/src/tools/tool-manifest.ts +2 -0
  61. package/src/tools/ui-surface/definitions.ts +9 -2
@@ -0,0 +1,127 @@
1
+ import * as net from 'node:net';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { getWorkspacePromptPath } from '../../util/platform.js';
6
+ import { log, defineHandlers, type HandlerContext } from './shared.js';
7
+
8
+ function handleIdentityGet(socket: net.Socket, ctx: HandlerContext): void {
9
+ const identityPath = getWorkspacePromptPath('IDENTITY.md');
10
+
11
+ if (!existsSync(identityPath)) {
12
+ ctx.send(socket, {
13
+ type: 'identity_get_response',
14
+ found: false,
15
+ name: '',
16
+ role: '',
17
+ personality: '',
18
+ emoji: '',
19
+ home: '',
20
+ });
21
+ return;
22
+ }
23
+
24
+ try {
25
+ const content = readFileSync(identityPath, 'utf-8');
26
+ const fields: Record<string, string> = {};
27
+ for (const line of content.split('\n')) {
28
+ const trimmed = line.trim();
29
+ const lower = trimmed.toLowerCase();
30
+ const extract = (prefix: string): string | null => {
31
+ if (!lower.startsWith(prefix)) return null;
32
+ return trimmed.split(':**').pop()?.trim() ?? null;
33
+ };
34
+
35
+ const name = extract('- **name:**');
36
+ if (name) { fields.name = name; continue; }
37
+ const role = extract('- **role:**');
38
+ if (role) { fields.role = role; continue; }
39
+ const personality = extract('- **personality:**') ?? extract('- **vibe:**');
40
+ if (personality) { fields.personality = personality; continue; }
41
+ const emoji = extract('- **emoji:**');
42
+ if (emoji) { fields.emoji = emoji; continue; }
43
+ const home = extract('- **home:**');
44
+ if (home) { fields.home = home; continue; }
45
+ }
46
+
47
+ // Read version from package.json
48
+ let version: string | undefined;
49
+ try {
50
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../../package.json');
51
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
52
+ version = pkg.version;
53
+ } catch {
54
+ // ignore
55
+ }
56
+
57
+ // Read createdAt from IDENTITY.md file birthtime
58
+ let createdAt: string | undefined;
59
+ try {
60
+ const stats = statSync(identityPath);
61
+ createdAt = stats.birthtime.toISOString();
62
+ } catch {
63
+ // ignore
64
+ }
65
+
66
+ // Read lockfile for assistantId, cloud, and originSystem
67
+ let assistantId: string | undefined;
68
+ let cloud: string | undefined;
69
+ let originSystem: string | undefined;
70
+ try {
71
+ const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
72
+ const lockfilePaths = [
73
+ join(homedir, '.vellum.lock.json'),
74
+ join(homedir, '.vellum.lockfile.json'),
75
+ ];
76
+ for (const lockPath of lockfilePaths) {
77
+ if (!existsSync(lockPath)) continue;
78
+ const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
79
+ const assistants = lockData.assistants as Array<Record<string, unknown>> | undefined;
80
+ if (assistants && assistants.length > 0) {
81
+ // Use the most recently hatched assistant
82
+ const sorted = [...assistants].sort((a, b) => {
83
+ const dateA = new Date(a.hatchedAt as string || 0).getTime();
84
+ const dateB = new Date(b.hatchedAt as string || 0).getTime();
85
+ return dateB - dateA;
86
+ });
87
+ const latest = sorted[0];
88
+ assistantId = latest.assistantId as string | undefined;
89
+ cloud = latest.cloud as string | undefined;
90
+ originSystem = cloud === 'local' ? 'local' : cloud;
91
+ }
92
+ break;
93
+ }
94
+ } catch {
95
+ // ignore — lockfile may not exist
96
+ }
97
+
98
+ ctx.send(socket, {
99
+ type: 'identity_get_response',
100
+ found: true,
101
+ name: fields.name ?? '',
102
+ role: fields.role ?? '',
103
+ personality: fields.personality ?? '',
104
+ emoji: fields.emoji ?? '',
105
+ home: fields.home ?? '',
106
+ version,
107
+ assistantId,
108
+ createdAt,
109
+ originSystem,
110
+ });
111
+ } catch (err) {
112
+ log.error({ err }, 'Failed to read identity');
113
+ ctx.send(socket, {
114
+ type: 'identity_get_response',
115
+ found: false,
116
+ name: '',
117
+ role: '',
118
+ personality: '',
119
+ emoji: '',
120
+ home: '',
121
+ });
122
+ }
123
+ }
124
+
125
+ export const identityHandlers = defineHandlers({
126
+ identity_get: (_msg, socket, ctx) => handleIdentityGet(socket, ctx),
127
+ });
@@ -20,6 +20,8 @@ import { subagentHandlers } from './subagents.js';
20
20
  import { browserHandlers } from './browser.js';
21
21
  import { signingHandlers } from './signing.js';
22
22
  import { twitterAuthHandlers } from './twitter-auth.js';
23
+ import { workspaceFileHandlers } from './workspace-files.js';
24
+ import { identityHandlers } from './identity.js';
23
25
 
24
26
  // Re-export types and utilities for backwards compatibility
25
27
  export type {
@@ -102,6 +104,8 @@ const handlers = {
102
104
  ...browserHandlers,
103
105
  ...signingHandlers,
104
106
  ...twitterAuthHandlers,
107
+ ...workspaceFileHandlers,
108
+ ...identityHandlers,
105
109
  ...inlineHandlers,
106
110
  } satisfies DispatchMap;
107
111
 
@@ -0,0 +1,75 @@
1
+ import * as net from 'node:net';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, resolve, sep } from 'node:path';
4
+ import { getWorkspaceDir } from '../../util/platform.js';
5
+ import { log, defineHandlers, type HandlerContext } from './shared.js';
6
+ import type { WorkspaceFileReadRequest } from '../ipc-protocol.js';
7
+
8
+ /** Well-known workspace prompt files shown in the Identity panel. */
9
+ const WORKSPACE_FILES = ['IDENTITY.md', 'SOUL.md', 'USER.md', 'skills/'];
10
+
11
+ function handleWorkspaceFilesList(socket: net.Socket, ctx: HandlerContext): void {
12
+ const base = getWorkspaceDir();
13
+ const files = WORKSPACE_FILES.map((name) => ({
14
+ path: name,
15
+ name,
16
+ exists: existsSync(join(base, name)),
17
+ }));
18
+ ctx.send(socket, { type: 'workspace_files_list_response', files });
19
+ }
20
+
21
+ function handleWorkspaceFileRead(
22
+ msg: WorkspaceFileReadRequest,
23
+ socket: net.Socket,
24
+ ctx: HandlerContext,
25
+ ): void {
26
+ const base = getWorkspaceDir();
27
+ const requested = msg.path;
28
+
29
+ // Prevent path traversal — reject paths that escape the workspace root.
30
+ // Use resolve() to canonicalize and check with trailing separator to prevent
31
+ // sibling directory prefix matches (e.g. "../workspace-evil/secret").
32
+ const resolved = resolve(base, requested);
33
+ if (!resolved.startsWith(base + sep) && resolved !== base) {
34
+ log.warn({ path: requested }, 'Workspace file read blocked: path traversal attempt');
35
+ ctx.send(socket, {
36
+ type: 'workspace_file_read_response',
37
+ path: requested,
38
+ content: null,
39
+ error: 'Invalid path',
40
+ });
41
+ return;
42
+ }
43
+
44
+ try {
45
+ if (!existsSync(resolved)) {
46
+ ctx.send(socket, {
47
+ type: 'workspace_file_read_response',
48
+ path: requested,
49
+ content: null,
50
+ error: 'File not found',
51
+ });
52
+ return;
53
+ }
54
+ const content = readFileSync(resolved, 'utf-8');
55
+ ctx.send(socket, {
56
+ type: 'workspace_file_read_response',
57
+ path: requested,
58
+ content,
59
+ });
60
+ } catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ log.error({ err, path: requested }, 'Failed to read workspace file');
63
+ ctx.send(socket, {
64
+ type: 'workspace_file_read_response',
65
+ path: requested,
66
+ content: null,
67
+ error: message,
68
+ });
69
+ }
70
+ }
71
+
72
+ export const workspaceFileHandlers = defineHandlers({
73
+ workspace_files_list: (_msg, socket, ctx) => handleWorkspaceFilesList(socket, ctx),
74
+ workspace_file_read: handleWorkspaceFileRead,
75
+ });
@@ -31,6 +31,7 @@
31
31
  "GetSigningIdentityResponse",
32
32
  "HistoryRequest",
33
33
  "HomeBaseGetRequest",
34
+ "IdentityGetRequest",
34
35
  "ImageGenModelSetRequest",
35
36
  "IngressConfigRequest",
36
37
  "IntegrationConnectRequest",
@@ -103,7 +104,9 @@
103
104
  "WorkItemPreflightRequest",
104
105
  "WorkItemRunTaskRequest",
105
106
  "WorkItemUpdateRequest",
106
- "WorkItemsListRequest"
107
+ "WorkItemsListRequest",
108
+ "WorkspaceFileReadRequest",
109
+ "WorkspaceFilesListRequest"
107
110
  ],
108
111
  "serverMessageTypes": [
109
112
  "AcceptStarterBundleResponse",
@@ -143,6 +146,7 @@
143
146
  "GetSigningIdentityRequest",
144
147
  "HistoryResponse",
145
148
  "HomeBaseGetResponse",
149
+ "IdentityGetResponse",
146
150
  "IngressConfigResponse",
147
151
  "IntegrationConnectResult",
148
152
  "IntegrationListResponse",
@@ -222,7 +226,9 @@
222
226
  "WorkItemRunTaskResponse",
223
227
  "WorkItemStatusChanged",
224
228
  "WorkItemUpdateResponse",
225
- "WorkItemsListResponse"
229
+ "WorkItemsListResponse",
230
+ "WorkspaceFileReadResponse",
231
+ "WorkspaceFilesListResponse"
226
232
  ],
227
233
  "clientWireTypes": [
228
234
  "accept_starter_bundle",
@@ -256,6 +262,7 @@
256
262
  "get_signing_identity_response",
257
263
  "history_request",
258
264
  "home_base_get",
265
+ "identity_get",
259
266
  "image_gen_model_set",
260
267
  "ingress_config",
261
268
  "integration_connect",
@@ -328,7 +335,9 @@
328
335
  "work_item_preflight",
329
336
  "work_item_run_task",
330
337
  "work_item_update",
331
- "work_items_list"
338
+ "work_items_list",
339
+ "workspace_file_read",
340
+ "workspace_files_list"
332
341
  ],
333
342
  "serverWireTypes": [
334
343
  "accept_starter_bundle_response",
@@ -368,6 +377,7 @@
368
377
  "get_signing_identity",
369
378
  "history_response",
370
379
  "home_base_get_response",
380
+ "identity_get_response",
371
381
  "ingress_config_response",
372
382
  "integration_connect_result",
373
383
  "integration_list_response",
@@ -446,6 +456,8 @@
446
456
  "work_item_run_task_response",
447
457
  "work_item_status_changed",
448
458
  "work_item_update_response",
449
- "work_items_list_response"
459
+ "work_items_list_response",
460
+ "workspace_file_read_response",
461
+ "workspace_files_list_response"
450
462
  ]
451
463
  }
@@ -476,6 +476,7 @@ export interface IngressConfigRequest {
476
476
  type: 'ingress_config';
477
477
  action: 'get' | 'set';
478
478
  publicBaseUrl?: string;
479
+ enabled?: boolean;
479
480
  }
480
481
 
481
482
  export interface VercelApiConfigRequest {
@@ -875,6 +876,22 @@ export interface WorkItemCancelRequest {
875
876
  id: string;
876
877
  }
877
878
 
879
+ // === Workspace File IPC ─────────────────────────────────────────────────────
880
+
881
+ export interface WorkspaceFilesListRequest {
882
+ type: 'workspace_files_list';
883
+ }
884
+
885
+ export interface WorkspaceFileReadRequest {
886
+ type: 'workspace_file_read';
887
+ /** Relative path within the workspace directory (e.g. "IDENTITY.md"). */
888
+ path: string;
889
+ }
890
+
891
+ export interface IdentityGetRequest {
892
+ type: 'identity_get';
893
+ }
894
+
878
895
  export type ClientMessage =
879
896
  | AuthMessage
880
897
  | UserMessage
@@ -979,7 +996,10 @@ export type ClientMessage =
979
996
  | SubagentAbortRequest
980
997
  | SubagentStatusRequest
981
998
  | SubagentMessageRequest
982
- | SubagentDetailRequest;
999
+ | SubagentDetailRequest
1000
+ | WorkspaceFilesListRequest
1001
+ | WorkspaceFileReadRequest
1002
+ | IdentityGetRequest;
983
1003
 
984
1004
  export interface IntegrationListRequest {
985
1005
  type: 'integration_list';
@@ -1690,6 +1710,7 @@ export interface SlackWebhookConfigResponse {
1690
1710
 
1691
1711
  export interface IngressConfigResponse {
1692
1712
  type: 'ingress_config_response';
1713
+ enabled: boolean;
1693
1714
  publicBaseUrl: string;
1694
1715
  /** Read-only gateway target computed from GATEWAY_PORT env var (default 7830) + loopback host. */
1695
1716
  localGatewayTarget: string;
@@ -2103,6 +2124,42 @@ export interface TaskRunThreadCreated {
2103
2124
  title: string;
2104
2125
  }
2105
2126
 
2127
+ // === Workspace File Responses ────────────────────────────────────────────────
2128
+
2129
+ export interface WorkspaceFilesListResponse {
2130
+ type: 'workspace_files_list_response';
2131
+ files: Array<{
2132
+ /** Relative path within the workspace (e.g. "IDENTITY.md", "skills/my-skill"). */
2133
+ path: string;
2134
+ /** Display name (e.g. "IDENTITY.md"). */
2135
+ name: string;
2136
+ /** Whether the file/directory exists. */
2137
+ exists: boolean;
2138
+ }>;
2139
+ }
2140
+
2141
+ export interface WorkspaceFileReadResponse {
2142
+ type: 'workspace_file_read_response';
2143
+ path: string;
2144
+ content: string | null;
2145
+ error?: string;
2146
+ }
2147
+
2148
+ export interface IdentityGetResponse {
2149
+ type: 'identity_get_response';
2150
+ /** Whether an IDENTITY.md file was found. When false, all fields are empty defaults. Optional for backwards compat with older daemons. */
2151
+ found?: boolean;
2152
+ name: string;
2153
+ role: string;
2154
+ personality: string;
2155
+ emoji: string;
2156
+ home: string;
2157
+ version?: string;
2158
+ assistantId?: string;
2159
+ createdAt?: string;
2160
+ originSystem?: string;
2161
+ }
2162
+
2106
2163
  export type ServerMessage =
2107
2164
  | AuthResult
2108
2165
  | UserMessageEcho
@@ -2220,7 +2277,10 @@ export type ServerMessage =
2220
2277
  | SubagentSpawned
2221
2278
  | SubagentStatusChanged
2222
2279
  | SubagentEvent
2223
- | SubagentDetailResponse;
2280
+ | SubagentDetailResponse
2281
+ | WorkspaceFilesListResponse
2282
+ | WorkspaceFileReadResponse
2283
+ | IdentityGetResponse;
2224
2284
 
2225
2285
  // === Subagent IPC ─────────────────────────────────────────────────────
2226
2286
 
@@ -21,6 +21,7 @@ import { initializeProviders } from '../providers/registry.js';
21
21
  import { initializeTools } from '../tools/registry.js';
22
22
  import { loadConfig } from '../config/loader.js';
23
23
  import { ensurePromptFiles } from '../config/system-prompt.js';
24
+ import { loadPrebuiltHtml } from '../home-base/prebuilt/seed.js';
24
25
  import { DaemonServer } from './server.js';
25
26
  import { listWorkItems, updateWorkItem } from '../work-items/work-item-store.js';
26
27
  import { getLogger, initLogger } from '../util/logger.js';
@@ -278,6 +279,21 @@ export async function runDaemon(): Promise<void> {
278
279
  log.info({ seedDir, interfacesDir }, 'Seeded initial interface files');
279
280
  }
280
281
 
282
+ // Seed the vellum-desktop interface from the prebuilt Home Base HTML if it
283
+ // doesn't already exist. This ensures the Home tab renders immediately
284
+ // on first launch for both local and remote hatches.
285
+ const desktopIndexPath = join(getInterfacesDir(), 'vellum-desktop', 'index.html');
286
+ if (!existsSync(desktopIndexPath)) {
287
+ const prebuiltHtml = loadPrebuiltHtml();
288
+ if (prebuiltHtml) {
289
+ mkdirSync(join(getInterfacesDir(), 'vellum-desktop'), { recursive: true });
290
+ writeFileSync(desktopIndexPath, prebuiltHtml);
291
+ log.info('Seeded vellum-desktop/index.html from prebuilt Home Base');
292
+ } else {
293
+ log.warn('Could not seed vellum-desktop/index.html — prebuilt HTML not found (missing embedded index.html in home-base/prebuilt/)');
294
+ }
295
+ }
296
+
281
297
  log.info('Daemon startup: installing templates and initializing DB');
282
298
  installTemplates();
283
299
  ensurePromptFiles();
@@ -25,6 +25,8 @@ import { lastCommentaryBySession, lastSummaryBySession } from './watch-handler.j
25
25
  import {
26
26
  registerCallQuestionNotifier,
27
27
  unregisterCallQuestionNotifier,
28
+ registerCallTranscriptNotifier,
29
+ unregisterCallTranscriptNotifier,
28
30
  registerCallCompletionNotifier,
29
31
  unregisterCallCompletionNotifier,
30
32
  } from '../calls/call-state.js';
@@ -114,6 +116,32 @@ export function registerSessionNotifiers(
114
116
  });
115
117
  });
116
118
 
119
+ registerCallTranscriptNotifier(
120
+ conversationId,
121
+ (_callSessionId: string, speaker: 'caller' | 'assistant', text: string) => {
122
+ const speakerLabel = speaker === 'caller' ? 'Caller' : 'Assistant';
123
+ const transcriptText = `**Live call transcript**\n${speakerLabel}: ${text}`;
124
+
125
+ conversationStore.addMessage(
126
+ conversationId,
127
+ 'assistant',
128
+ JSON.stringify([{ type: 'text', text: transcriptText }]),
129
+ );
130
+
131
+ ctx.messages.push(createAssistantMessage(transcriptText));
132
+
133
+ ctx.sendToClient({
134
+ type: 'assistant_text_delta',
135
+ text: transcriptText,
136
+ sessionId: conversationId,
137
+ });
138
+ ctx.sendToClient({
139
+ type: 'message_complete',
140
+ sessionId: conversationId,
141
+ });
142
+ },
143
+ );
144
+
117
145
  registerCallCompletionNotifier(conversationId, (callSessionId: string) => {
118
146
  const callSession = getCallSession(callSessionId);
119
147
  const events = getCallEvents(callSessionId);
@@ -160,5 +188,6 @@ export function unregisterWatchNotifiers(conversationId: string): void {
160
188
  */
161
189
  export function unregisterCallNotifiers(conversationId: string): void {
162
190
  unregisterCallQuestionNotifier(conversationId);
191
+ unregisterCallTranscriptNotifier(conversationId);
163
192
  unregisterCallCompletionNotifier(conversationId);
164
193
  }
@@ -407,10 +407,11 @@ export function handleSurfaceAction(ctx: SurfaceSessionContext, surfaceId: strin
407
407
  /**
408
408
  * After an app_update, refresh any active surface that displays the updated app.
409
409
  */
410
- export function refreshSurfacesForApp(ctx: SurfaceSessionContext, appId: string, opts?: { fileChange?: boolean; status?: string }): void {
410
+ export function refreshSurfacesForApp(ctx: SurfaceSessionContext, appId: string, opts?: { fileChange?: boolean; status?: string }): boolean {
411
411
  const app = getApp(appId);
412
- if (!app) return;
412
+ if (!app) return false;
413
413
 
414
+ let refreshed = false;
414
415
  for (const [surfaceId, stored] of ctx.surfaceState.entries()) {
415
416
  if (stored.surfaceType !== 'dynamic_page') continue;
416
417
  const data = stored.data as DynamicPageSurfaceData;
@@ -436,8 +437,10 @@ export function refreshSurfacesForApp(ctx: SurfaceSessionContext, appId: string,
436
437
  data: updatedData,
437
438
  });
438
439
 
440
+ refreshed = true;
439
441
  log.info({ conversationId: ctx.conversationId, surfaceId, appId }, 'Auto-refreshed surface after app_update');
440
442
  }
443
+ return refreshed;
441
444
  }
442
445
 
443
446
  export function buildCompletionSummary(surfaceType: string | undefined, actionId: string, data?: Record<string, unknown>): string {
@@ -27,6 +27,7 @@ import {
27
27
  } from './session-surfaces.js';
28
28
  import type { SurfaceSessionContext } from './session-surfaces.js';
29
29
  import { updatePublishedAppDeployment } from '../services/published-app-updater.js';
30
+ import { openAppViaSurface } from '../tools/apps/open-proxy.js';
30
31
  import { registerSessionSender } from '../tools/browser/browser-screencast.js';
31
32
  import type { ProxyApprovalCallback, ProxyApprovalRequest } from '../tools/network/script-proxy/index.js';
32
33
  import { projectSkillTools, type SkillProjectionCache } from './session-skill-tools.js';
@@ -265,13 +266,18 @@ export function createToolExecutor(
265
266
  },
266
267
  });
267
268
 
268
- // Auto-refresh workspace surfaces when a persisted app is updated
269
+ // Auto-refresh workspace surfaces when a persisted app is updated.
270
+ // If no surface is currently showing the app, auto-open it.
269
271
  if (name === 'app_update' && !result.isError) {
270
272
  const appId = input.app_id as string | undefined;
271
273
  if (appId) {
272
- refreshSurfacesForApp(ctx, appId);
274
+ const refreshed = refreshSurfacesForApp(ctx, appId);
273
275
  broadcastToAllClients?.({ type: 'app_files_changed', appId });
274
276
  void updatePublishedAppDeployment(appId);
277
+ if (!refreshed && !ctx.hasNoClient && !ctx.headlessLock) {
278
+ const resolver = (tn: string, pi: Record<string, unknown>) => surfaceProxyResolver(ctx, tn, pi);
279
+ void openAppViaSurface(appId, resolver);
280
+ }
275
281
  }
276
282
  }
277
283
 
@@ -286,14 +292,19 @@ export function createToolExecutor(
286
292
  broadcastToAllClients?.({ type: 'tasks_changed' });
287
293
  }
288
294
 
289
- // Auto-refresh workspace surfaces when app files are edited
295
+ // Auto-refresh workspace surfaces when app files are edited.
296
+ // If no surface is currently showing the app, auto-open it.
290
297
  if ((name === 'app_file_edit' || name === 'app_file_write') && !result.isError) {
291
298
  const appId = input.app_id as string | undefined;
292
299
  const status = input.status as string | undefined;
293
300
  if (appId) {
294
- refreshSurfacesForApp(ctx, appId, { fileChange: true, status });
301
+ const refreshed = refreshSurfacesForApp(ctx, appId, { fileChange: true, status });
295
302
  broadcastToAllClients?.({ type: 'app_files_changed', appId });
296
303
  void updatePublishedAppDeployment(appId);
304
+ if (!refreshed && !ctx.hasNoClient && !ctx.headlessLock) {
305
+ const resolver = (tn: string, pi: Record<string, unknown>) => surfaceProxyResolver(ctx, tn, pi);
306
+ void openAppViaSurface(appId, resolver);
307
+ }
297
308
  }
298
309
  }
299
310
 
@@ -23,7 +23,7 @@ export interface HomeBaseBootstrapResult {
23
23
  created: boolean;
24
24
  }
25
25
 
26
- export function bootstrapHomeBaseAppLink(): HomeBaseBootstrapResult {
26
+ export function bootstrapHomeBaseAppLink(): HomeBaseBootstrapResult | null {
27
27
  const linked = resolveExistingLink();
28
28
  if (linked) {
29
29
  return {
@@ -46,6 +46,8 @@ export function bootstrapHomeBaseAppLink(): HomeBaseBootstrapResult {
46
46
  }
47
47
 
48
48
  const seeded = ensurePrebuiltHomeBaseSeeded();
49
+ if (!seeded) return null;
50
+
49
51
  const next = setHomeBaseAppLink(seeded.appId, 'prebuilt_seed');
50
52
  log.info({ appId: next.appId, created: seeded.created }, 'Bootstrapped Home Base app link');
51
53
 
@@ -6,6 +6,8 @@ import {
6
6
  HOME_BASE_PREBUILT_DESCRIPTION_PREFIX,
7
7
  isPrebuiltHomeBaseApp,
8
8
  } from '../prebuilt-home-base-updater.js';
9
+ // Static import so the JSON is bundled into compiled binaries (avoids ENOENT on $bunfs)
10
+ import seedMetadataJson from './seed-metadata.json' with { type: 'json' };
9
11
 
10
12
  const log = getLogger('home-base-seed');
11
13
 
@@ -26,12 +28,16 @@ function getPrebuiltDir(): string {
26
28
  }
27
29
 
28
30
  function loadSeedMetadata(): SeedMetadata {
29
- const raw = readFileSync(join(getPrebuiltDir(), 'seed-metadata.json'), 'utf-8');
30
- return JSON.parse(raw) as SeedMetadata;
31
+ return seedMetadataJson as SeedMetadata;
31
32
  }
32
33
 
33
- function loadPrebuiltHtml(): string {
34
- return readFileSync(join(getPrebuiltDir(), 'index.html'), 'utf-8');
34
+ export function loadPrebuiltHtml(): string | null {
35
+ try {
36
+ return readFileSync(join(getPrebuiltDir(), 'index.html'), 'utf-8');
37
+ } catch {
38
+ log.warn('Could not load prebuilt index.html (expected in compiled binary)');
39
+ return null;
40
+ }
35
41
  }
36
42
 
37
43
  function buildDescription(metadata: SeedMetadata): string {
@@ -80,7 +86,7 @@ export function getPrebuiltHomeBaseTaskPayload(): PrebuiltHomeBaseTaskPayload {
80
86
  };
81
87
  }
82
88
 
83
- export function ensurePrebuiltHomeBaseSeeded(): { appId: string; created: boolean } {
89
+ export function ensurePrebuiltHomeBaseSeeded(): { appId: string; created: boolean } | null {
84
90
  const existing = findSeededHomeBaseApp();
85
91
  if (existing) {
86
92
  return { appId: existing.id, created: false };
@@ -88,6 +94,11 @@ export function ensurePrebuiltHomeBaseSeeded(): { appId: string; created: boolea
88
94
 
89
95
  const metadata = loadSeedMetadata();
90
96
  const html = loadPrebuiltHtml();
97
+ if (html === null) {
98
+ log.warn('Skipping Home Base seed — prebuilt HTML not available');
99
+ return null;
100
+ }
101
+
91
102
  const created = createApp({
92
103
  name: metadata.appName,
93
104
  description: buildDescription(metadata),
@@ -20,14 +20,15 @@
20
20
  * - The assistant's outbound callback URLs (Twilio webhooks, OAuth
21
21
  * redirect URIs, etc.) match the gateway's inbound signature
22
22
  * reconstruction URL.
23
- * - Changing the URL in Settings propagates to the gateway on restart,
24
- * eliminating Twilio signature mismatch risk.
23
+ * - Changing the URL in Settings immediately updates outbound callback
24
+ * registration, while the gateway can validate inbound Twilio signatures
25
+ * using forwarded public URL headers from tunnels/proxies.
25
26
  *
26
27
  * All public-facing ingress URL construction is centralized here.
27
28
  */
28
29
 
29
30
  export interface IngressConfig {
30
- ingress?: { publicBaseUrl?: string };
31
+ ingress?: { enabled?: boolean; publicBaseUrl?: string };
31
32
  }
32
33
 
33
34
  /**
@@ -41,9 +42,19 @@ function normalizeUrl(url: string): string {
41
42
  * Resolve the canonical public base URL using the precedence chain
42
43
  * documented at the top of this module.
43
44
  *
44
- * Throws if no source provides a non-empty value.
45
+ * When `ingress.enabled` is explicitly `false`, the public ingress is
46
+ * considered disabled regardless of whether a URL is configured. This
47
+ * allows the user to toggle ingress off without clearing the URL value.
48
+ *
49
+ * Throws if no source provides a non-empty value or if ingress is disabled.
45
50
  */
46
51
  export function getPublicBaseUrl(config: IngressConfig): string {
52
+ if (config.ingress?.enabled === false) {
53
+ throw new Error(
54
+ 'Public ingress is disabled. Enable it in Settings to use public-facing webhooks.',
55
+ );
56
+ }
57
+
47
58
  const ingressValue = config.ingress?.publicBaseUrl;
48
59
  if (ingressValue) {
49
60
  const normalized = normalizeUrl(ingressValue);