@vellumai/assistant 0.4.37 → 0.4.41

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 (169) hide show
  1. package/ARCHITECTURE.md +3 -3
  2. package/README.md +13 -13
  3. package/bun.lock +80 -24
  4. package/docs/architecture/integrations.md +126 -128
  5. package/docs/runbook-trusted-contacts.md +1 -1
  6. package/docs/trusted-contact-access.md +12 -12
  7. package/package.json +3 -1
  8. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -14
  9. package/src/__tests__/app-bundler.test.ts +209 -0
  10. package/src/__tests__/app-compiler.test.ts +279 -0
  11. package/src/__tests__/app-executors.test.ts +293 -483
  12. package/src/__tests__/app-migration.test.ts +148 -0
  13. package/src/__tests__/app-routes-csp.test.ts +202 -0
  14. package/src/__tests__/avatar-e2e.test.ts +452 -0
  15. package/src/__tests__/avatar-generator.test.ts +193 -0
  16. package/src/__tests__/avatar-router.test.ts +186 -0
  17. package/src/__tests__/browser-download-timeout.test.ts +28 -0
  18. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +9 -9
  19. package/src/__tests__/call-domain.test.ts +3 -7
  20. package/src/__tests__/credential-security-e2e.test.ts +19 -12
  21. package/src/__tests__/credentials-cli.test.ts +30 -4
  22. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +1 -1
  23. package/src/__tests__/handlers-slack-config.test.ts +0 -72
  24. package/src/__tests__/handlers-telegram-config.test.ts +19 -12
  25. package/src/__tests__/handlers-twitter-config.test.ts +105 -48
  26. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  27. package/src/__tests__/integration-status.test.ts +15 -5
  28. package/src/__tests__/integrations-cli.test.ts +1 -1
  29. package/src/__tests__/invite-redemption-service.test.ts +62 -7
  30. package/src/__tests__/ipc-snapshot.test.ts +0 -8
  31. package/src/__tests__/managed-avatar-client.test.ts +280 -0
  32. package/src/__tests__/mcp-cli.test.ts +3 -3
  33. package/src/__tests__/oauth-cli.test.ts +203 -0
  34. package/src/__tests__/relay-server.test.ts +3 -3
  35. package/src/__tests__/secret-onetime-send.test.ts +19 -12
  36. package/src/__tests__/secure-keys.test.ts +78 -0
  37. package/src/__tests__/session-messaging-secret-redirect.test.ts +3 -0
  38. package/src/__tests__/slack-channel-config.test.ts +23 -16
  39. package/src/__tests__/slack-share-routes.test.ts +263 -0
  40. package/src/__tests__/sms-messaging-provider.test.ts +3 -1
  41. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +7 -7
  42. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  43. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  44. package/src/__tests__/twilio-config.test.ts +15 -36
  45. package/src/__tests__/twilio-provider.test.ts +4 -0
  46. package/src/__tests__/twitter-auth-handler.test.ts +27 -14
  47. package/src/__tests__/twitter-cli-error-shaping.test.ts +1 -1
  48. package/src/__tests__/twitter-cli-routing.test.ts +38 -53
  49. package/src/__tests__/twitter-oauth-client.test.ts +18 -47
  50. package/src/__tests__/voice-invite-redemption.test.ts +27 -3
  51. package/src/amazon/cart.ts +1 -1
  52. package/src/amazon/client.ts +89 -7
  53. package/src/approvals/guardian-request-resolvers.ts +2 -2
  54. package/src/bundler/app-bundler.ts +77 -32
  55. package/src/bundler/app-compiler.ts +195 -0
  56. package/src/bundler/manifest.ts +1 -1
  57. package/src/bundler/package-resolver.ts +185 -0
  58. package/src/calls/call-domain.ts +4 -14
  59. package/src/calls/relay-server.ts +2 -2
  60. package/src/calls/twilio-config.ts +5 -24
  61. package/src/calls/twilio-rest.ts +19 -5
  62. package/src/cli/amazon.ts +74 -249
  63. package/src/cli/audit.ts +2 -2
  64. package/src/cli/autonomy.ts +9 -9
  65. package/src/cli/channels.ts +5 -5
  66. package/src/cli/completions.ts +27 -27
  67. package/src/cli/config.ts +14 -14
  68. package/src/cli/contacts.ts +27 -27
  69. package/src/cli/credentials.ts +28 -28
  70. package/src/cli/dev.ts +2 -2
  71. package/src/cli/doctor.ts +2 -2
  72. package/src/cli/email.ts +82 -82
  73. package/src/cli/influencer.ts +13 -13
  74. package/src/cli/integrations.ts +19 -144
  75. package/src/cli/keys.ts +10 -10
  76. package/src/cli/map.ts +4 -4
  77. package/src/cli/mcp.ts +17 -17
  78. package/src/cli/memory.ts +18 -18
  79. package/src/cli/notifications.ts +13 -13
  80. package/src/cli/oauth.ts +77 -0
  81. package/src/cli/program.ts +2 -0
  82. package/src/cli/sequence.ts +27 -27
  83. package/src/cli/sessions.ts +12 -12
  84. package/src/cli/trust.ts +8 -8
  85. package/src/cli/twitter.ts +124 -70
  86. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  87. package/src/config/bundled-skills/agentmail/SKILL.md +34 -34
  88. package/src/config/bundled-skills/amazon/SKILL.md +54 -54
  89. package/src/config/bundled-skills/app-builder/SKILL.md +137 -3
  90. package/src/config/bundled-skills/app-builder/tools/app-create.ts +10 -4
  91. package/src/config/bundled-skills/configure-settings/SKILL.md +18 -18
  92. package/src/config/bundled-skills/contacts/SKILL.md +12 -12
  93. package/src/config/bundled-skills/doordash/lib/client.ts +7 -9
  94. package/src/config/bundled-skills/email-setup/SKILL.md +4 -4
  95. package/src/config/bundled-skills/frontend-design/icon.svg +16 -0
  96. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +143 -162
  97. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +4 -4
  98. package/src/config/bundled-skills/influencer/SKILL.md +13 -13
  99. package/src/config/bundled-skills/mcp-setup/SKILL.md +11 -11
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +48 -54
  101. package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
  102. package/src/config/bundled-skills/slack-app-setup/SKILL.md +1 -1
  103. package/src/config/bundled-skills/sms-setup/SKILL.md +3 -3
  104. package/src/config/bundled-skills/telegram-setup/SKILL.md +2 -2
  105. package/src/config/bundled-skills/twilio-setup/SKILL.md +136 -225
  106. package/src/config/bundled-skills/twitter/SKILL.md +68 -44
  107. package/src/config/bundled-skills/voice-setup/SKILL.md +2 -2
  108. package/src/config/core-schema.ts +26 -0
  109. package/src/config/env.ts +4 -0
  110. package/src/config/feature-flag-registry.json +9 -1
  111. package/src/config/schema.ts +8 -0
  112. package/src/config/system-prompt.ts +6 -3
  113. package/src/config/templates/BOOTSTRAP.md +7 -5
  114. package/src/contacts/contacts-write.ts +5 -1
  115. package/src/daemon/handlers/apps.ts +31 -4
  116. package/src/daemon/handlers/config-ingress.ts +3 -3
  117. package/src/daemon/handlers/config-integrations.ts +120 -49
  118. package/src/daemon/handlers/config-slack-channel.ts +26 -7
  119. package/src/daemon/handlers/config-slack.ts +1 -54
  120. package/src/daemon/handlers/config-telegram.ts +28 -10
  121. package/src/daemon/handlers/config.ts +1 -4
  122. package/src/daemon/handlers/twitter-auth.ts +11 -4
  123. package/src/daemon/ipc-contract/apps.ts +0 -13
  124. package/src/daemon/ipc-contract-inventory.json +0 -2
  125. package/src/daemon/lifecycle.ts +8 -1
  126. package/src/daemon/session-messaging.ts +2 -2
  127. package/src/daemon/tool-side-effects.ts +30 -0
  128. package/src/email/providers/agentmail.ts +1 -1
  129. package/src/email/providers/index.ts +1 -1
  130. package/src/email/service.ts +1 -1
  131. package/src/gallery/default-gallery.ts +538 -0
  132. package/src/gallery/gallery-manifest.ts +5 -1
  133. package/src/influencer/client.ts +8 -6
  134. package/src/mcp/client.ts +1 -1
  135. package/src/media/avatar-router.ts +99 -0
  136. package/src/media/avatar-types.ts +60 -0
  137. package/src/media/managed-avatar-client.ts +189 -0
  138. package/src/memory/app-migration.ts +114 -0
  139. package/src/memory/app-store.ts +11 -0
  140. package/src/memory/qdrant-client.ts +1 -1
  141. package/src/messaging/providers/slack/client.ts +12 -2
  142. package/src/messaging/providers/sms/adapter.ts +6 -10
  143. package/src/migrations/data-layout.ts +8 -1
  144. package/src/oauth/token-persistence.ts +9 -6
  145. package/src/runtime/assistant-scope.ts +5 -0
  146. package/src/runtime/auth/route-policy.ts +4 -0
  147. package/src/runtime/channel-readiness-service.ts +9 -4
  148. package/src/runtime/gateway-internal-client.ts +11 -3
  149. package/src/runtime/http-server.ts +2 -0
  150. package/src/runtime/invite-redemption-service.ts +23 -13
  151. package/src/runtime/middleware/twilio-validation.ts +2 -2
  152. package/src/runtime/routes/app-routes.ts +131 -3
  153. package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -3
  154. package/src/runtime/routes/integration-routes.ts +2 -2
  155. package/src/runtime/routes/slack-share-routes.ts +235 -0
  156. package/src/runtime/routes/twilio-routes.ts +47 -34
  157. package/src/schedule/integration-status.ts +2 -3
  158. package/src/security/token-manager.ts +11 -3
  159. package/src/tools/apps/executors.ts +116 -8
  160. package/src/tools/browser/browser-manager.ts +30 -2
  161. package/src/tools/browser/chrome-cdp.ts +31 -3
  162. package/src/tools/credentials/vault.ts +9 -7
  163. package/src/tools/executor.ts +4 -0
  164. package/src/tools/system/avatar-generator.ts +55 -34
  165. package/src/twitter/client.ts +1 -1
  166. package/src/twitter/oauth-client.ts +31 -43
  167. package/src/twitter/router.ts +25 -23
  168. package/src/util/platform.ts +5 -0
  169. package/src/slack/slack-webhook.ts +0 -66
@@ -22,6 +22,7 @@ import {
22
22
  getPhoneNumberSid,
23
23
  getTollFreeVerificationBySid,
24
24
  getTollFreeVerificationStatus,
25
+ getTwilioCredentials,
25
26
  hasTwilioCredentials,
26
27
  listIncomingPhoneNumbers,
27
28
  provisionPhoneNumber,
@@ -37,9 +38,9 @@ import { getReadinessService } from "../../daemon/handlers/config-channels.js";
37
38
  import { syncTwilioWebhooks } from "../../daemon/handlers/config-ingress.js";
38
39
  import type { IngressConfig } from "../../inbound/public-ingress-urls.js";
39
40
  import {
40
- deleteSecureKey,
41
+ deleteSecureKeyAsync,
41
42
  getSecureKey,
42
- setSecureKey,
43
+ setSecureKeyAsync,
43
44
  } from "../../security/secure-keys.js";
44
45
  import {
45
46
  deleteCredentialMetadata,
@@ -149,7 +150,7 @@ function pruneAssistantPhoneNumbers(
149
150
  export function handleGetTwilioConfig(): Response {
150
151
  const hasCredentials = hasTwilioCredentials();
151
152
  const accountSid = hasCredentials
152
- ? getSecureKey("credential:twilio:account_sid")
153
+ ? getTwilioCredentials().accountSid
153
154
  : undefined;
154
155
  const raw = loadRawConfig();
155
156
  const sms = (raw?.sms ?? {}) as Record<string, unknown>;
@@ -213,8 +214,8 @@ export async function handleSetTwilioCredentials(
213
214
  });
214
215
  }
215
216
 
216
- // Store credentials securely
217
- const sidStored = setSecureKey(
217
+ // Store credentials securely (async — writes broker + encrypted store)
218
+ const sidStored = await setSecureKeyAsync(
218
219
  "credential:twilio:account_sid",
219
220
  body.accountSid,
220
221
  );
@@ -226,12 +227,12 @@ export async function handleSetTwilioCredentials(
226
227
  });
227
228
  }
228
229
 
229
- const tokenStored = setSecureKey(
230
+ const tokenStored = await setSecureKeyAsync(
230
231
  "credential:twilio:auth_token",
231
232
  body.authToken,
232
233
  );
233
234
  if (!tokenStored) {
234
- deleteSecureKey("credential:twilio:account_sid");
235
+ await deleteSecureKeyAsync("credential:twilio:account_sid");
235
236
  return Response.json({
236
237
  success: false,
237
238
  hasCredentials: false,
@@ -239,6 +240,11 @@ export async function handleSetTwilioCredentials(
239
240
  });
240
241
  }
241
242
 
243
+ const raw = loadRawConfig();
244
+ const twilio = (raw?.twilio ?? {}) as Record<string, unknown>;
245
+ twilio.accountSid = body.accountSid;
246
+ saveRawConfig({ ...raw, twilio });
247
+
242
248
  upsertCredentialMetadata("twilio", "account_sid", {
243
249
  injectionTemplates: [
244
250
  {
@@ -275,9 +281,25 @@ export async function handleSetTwilioCredentials(
275
281
  /**
276
282
  * DELETE /v1/integrations/twilio/credentials
277
283
  */
278
- export function handleClearTwilioCredentials(): Response {
279
- deleteSecureKey("credential:twilio:account_sid");
280
- deleteSecureKey("credential:twilio:auth_token");
284
+ export async function handleClearTwilioCredentials(): Promise<Response> {
285
+ const r1 = await deleteSecureKeyAsync("credential:twilio:account_sid");
286
+ const r2 = await deleteSecureKeyAsync("credential:twilio:auth_token");
287
+
288
+ if (r1 === "error" || r2 === "error") {
289
+ return Response.json(
290
+ {
291
+ success: false,
292
+ error: "Failed to delete Twilio credentials from secure storage",
293
+ },
294
+ { status: 500 },
295
+ );
296
+ }
297
+
298
+ const raw = loadRawConfig();
299
+ const twilio = (raw?.twilio ?? {}) as Record<string, unknown>;
300
+ delete twilio.accountSid;
301
+ saveRawConfig({ ...raw, twilio });
302
+
281
303
  deleteCredentialMetadata("twilio", "account_sid");
282
304
  deleteCredentialMetadata("twilio", "auth_token");
283
305
 
@@ -296,8 +318,7 @@ export async function handleListTwilioNumbers(): Promise<Response> {
296
318
  });
297
319
  }
298
320
 
299
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
300
- const authToken = getSecureKey("credential:twilio:auth_token")!;
321
+ const { accountSid, authToken } = getTwilioCredentials();
301
322
  const numbers = await listIncomingPhoneNumbers(accountSid, authToken);
302
323
 
303
324
  return Response.json({ success: true, hasCredentials: true, numbers });
@@ -323,8 +344,7 @@ export async function handleProvisionTwilioNumber(
323
344
  country?: string;
324
345
  areaCode?: string;
325
346
  };
326
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
327
- const authToken = getSecureKey("credential:twilio:auth_token")!;
347
+ const { accountSid, authToken } = getTwilioCredentials();
328
348
  const country = body.country ?? "US";
329
349
 
330
350
  const available = await searchAvailableNumbers(
@@ -347,7 +367,7 @@ export async function handleProvisionTwilioNumber(
347
367
  available[0].phoneNumber,
348
368
  );
349
369
 
350
- const phoneStored = setSecureKey(
370
+ const phoneStored = await setSecureKeyAsync(
351
371
  "credential:twilio:phone_number",
352
372
  purchased.phoneNumber,
353
373
  );
@@ -403,7 +423,7 @@ export async function handleAssignTwilioNumber(
403
423
  );
404
424
  }
405
425
 
406
- const phoneStored = setSecureKey(
426
+ const phoneStored = await setSecureKeyAsync(
407
427
  "credential:twilio:phone_number",
408
428
  body.phoneNumber,
409
429
  );
@@ -424,8 +444,8 @@ export async function handleAssignTwilioNumber(
424
444
  // Best-effort webhook configuration when credentials are available
425
445
  let webhookWarning: string | undefined;
426
446
  if (hasTwilioCredentials()) {
427
- const acctSid = getSecureKey("credential:twilio:account_sid")!;
428
- const acctToken = getSecureKey("credential:twilio:auth_token")!;
447
+ const { accountSid: acctSid, authToken: acctToken } =
448
+ getTwilioCredentials();
429
449
  const webhookResult = await syncTwilioWebhooks(
430
450
  body.phoneNumber,
431
451
  acctSid,
@@ -473,8 +493,7 @@ export async function handleReleaseTwilioNumber(
473
493
  });
474
494
  }
475
495
 
476
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
477
- const authToken = getSecureKey("credential:twilio:auth_token")!;
496
+ const { accountSid, authToken } = getTwilioCredentials();
478
497
 
479
498
  await releasePhoneNumber(accountSid, authToken, phoneNumber);
480
499
 
@@ -486,7 +505,7 @@ export async function handleReleaseTwilioNumber(
486
505
 
487
506
  const storedPhone = getSecureKey("credential:twilio:phone_number");
488
507
  if (storedPhone === phoneNumber) {
489
- deleteSecureKey("credential:twilio:phone_number");
508
+ await deleteSecureKeyAsync("credential:twilio:phone_number");
490
509
  }
491
510
 
492
511
  return Response.json({
@@ -521,8 +540,7 @@ export async function handleGetSmsCompliance(): Promise<Response> {
521
540
  });
522
541
  }
523
542
 
524
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
525
- const authToken = getSecureKey("credential:twilio:auth_token")!;
543
+ const { accountSid, authToken } = getTwilioCredentials();
526
544
 
527
545
  const tollFreePrefixes = [
528
546
  "+1800",
@@ -691,8 +709,7 @@ export async function handleSubmitTollfreeVerification(
691
709
  );
692
710
  }
693
711
 
694
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
695
- const authToken = getSecureKey("credential:twilio:auth_token")!;
712
+ const { accountSid, authToken } = getTwilioCredentials();
696
713
 
697
714
  const submitParams: TollFreeVerificationSubmitParams = {
698
715
  tollfreePhoneNumberSid: vp.tollfreePhoneNumberSid as string,
@@ -743,8 +760,7 @@ export async function handleUpdateTollfreeVerification(
743
760
  });
744
761
  }
745
762
 
746
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
747
- const authToken = getSecureKey("credential:twilio:auth_token")!;
763
+ const { accountSid, authToken } = getTwilioCredentials();
748
764
 
749
765
  const currentVerification = await getTollFreeVerificationBySid(
750
766
  accountSid,
@@ -827,8 +843,7 @@ export async function handleDeleteTollfreeVerification(
827
843
  });
828
844
  }
829
845
 
830
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
831
- const authToken = getSecureKey("credential:twilio:auth_token")!;
846
+ const { accountSid, authToken } = getTwilioCredentials();
832
847
 
833
848
  await deleteTollFreeVerification(accountSid, authToken, verificationSid);
834
849
 
@@ -885,8 +900,7 @@ export async function handleSmsSendTest(req: Request): Promise<Response> {
885
900
  });
886
901
  }
887
902
 
888
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
889
- const authToken = getSecureKey("credential:twilio:auth_token")!;
903
+ const { accountSid, authToken } = getTwilioCredentials();
890
904
  const text = body.text || "Test SMS from your Vellum assistant";
891
905
 
892
906
  // Send via gateway's /deliver/sms endpoint
@@ -1001,8 +1015,7 @@ export async function handleSmsDoctor(): Promise<Response> {
1001
1015
  getSecureKey("credential:twilio:phone_number") ||
1002
1016
  "";
1003
1017
  if (phoneNumber) {
1004
- const accountSid = getSecureKey("credential:twilio:account_sid")!;
1005
- const authToken = getSecureKey("credential:twilio:auth_token")!;
1018
+ const { accountSid, authToken } = getTwilioCredentials();
1006
1019
  const isTollFree =
1007
1020
  phoneNumber.startsWith("+1") &&
1008
1021
  ["800", "888", "877", "866", "855", "844", "833"].some((p) =>
@@ -1178,7 +1191,7 @@ export function twilioRouteDefinitions(): RouteDefinition[] {
1178
1191
  {
1179
1192
  endpoint: "integrations/twilio/credentials",
1180
1193
  method: "DELETE",
1181
- handler: () => handleClearTwilioCredentials(),
1194
+ handler: async () => handleClearTwilioCredentials(),
1182
1195
  },
1183
1196
  {
1184
1197
  endpoint: "integrations/twilio/numbers",
@@ -1,3 +1,4 @@
1
+ import { hasTwilioCredentials } from "../calls/twilio-rest.js";
1
2
  import { getSecureKey } from "../security/secure-keys.js";
2
3
 
3
4
  interface IntegrationProbe {
@@ -29,9 +30,7 @@ const INTEGRATION_PROBES: IntegrationProbe[] = [
29
30
  {
30
31
  name: "SMS",
31
32
  category: "messaging",
32
- isConnected: () =>
33
- !!getSecureKey("credential:twilio:account_sid") &&
34
- !!getSecureKey("credential:twilio:auth_token"),
33
+ isConnected: () => hasTwilioCredentials(),
35
34
  },
36
35
  {
37
36
  name: "Telegram",
@@ -12,7 +12,7 @@ import {
12
12
  } from "../tools/credentials/metadata-store.js";
13
13
  import { getLogger } from "../util/logger.js";
14
14
  import { refreshOAuth2Token, type TokenEndpointAuthMethod } from "./oauth2.js";
15
- import { getSecureKey, setSecureKey } from "./secure-keys.js";
15
+ import { getSecureKey, setSecureKeyAsync } from "./secure-keys.js";
16
16
 
17
17
  const log = getLogger("token-manager");
18
18
 
@@ -217,7 +217,12 @@ async function doRefresh(service: string): Promise<string> {
217
217
  throw err;
218
218
  }
219
219
 
220
- if (!setSecureKey(`credential:${service}:access_token`, result.accessToken)) {
220
+ if (
221
+ !(await setSecureKeyAsync(
222
+ `credential:${service}:access_token`,
223
+ result.accessToken,
224
+ ))
225
+ ) {
221
226
  throw new TokenExpiredError(
222
227
  service,
223
228
  `Failed to store refreshed access token for "${service}".`,
@@ -226,7 +231,10 @@ async function doRefresh(service: string): Promise<string> {
226
231
 
227
232
  if (result.refreshToken) {
228
233
  if (
229
- !setSecureKey(`credential:${service}:refresh_token`, result.refreshToken)
234
+ !(await setSecureKeyAsync(
235
+ `credential:${service}:refresh_token`,
236
+ result.refreshToken,
237
+ ))
230
238
  ) {
231
239
  throw new TokenExpiredError(
232
240
  service,
@@ -8,10 +8,14 @@
8
8
  * ToolDefinition or ToolContext types.
9
9
  */
10
10
 
11
+ import { compileApp } from "../../bundler/app-compiler.js";
11
12
  import { setHomeBaseAppLink } from "../../home-base/app-link-store.js";
12
13
  import { generateAppIcon } from "../../media/app-icon-generator.js";
13
- import type { AppDefinition } from "../../memory/app-store.js";
14
- import type { EditEngineResult } from "../../memory/app-store.js";
14
+ import type {
15
+ AppDefinition,
16
+ EditEngineResult,
17
+ } from "../../memory/app-store.js";
18
+ import { getAppsDir, isMultifileApp } from "../../memory/app-store.js";
15
19
 
16
20
  // ---------------------------------------------------------------------------
17
21
  // Shared result type
@@ -45,6 +49,7 @@ export interface AppStoreWriter {
45
49
  schemaJson: string;
46
50
  htmlDefinition: string;
47
51
  pages?: Record<string, string>;
52
+ formatVersion?: number;
48
53
  }): AppDefinition;
49
54
  updateApp(
50
55
  id: string,
@@ -77,6 +82,28 @@ export type ProxyResolver = (
77
82
  input: Record<string, unknown>,
78
83
  ) => Promise<ExecutorResult>;
79
84
 
85
+ // ---------------------------------------------------------------------------
86
+ // Path resolution — multifile apps default to src/ for file operations
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * For multifile (formatVersion 2) apps, prepend `src/` to paths that don't
91
+ * already target a known top-level directory (src/, dist/, records/).
92
+ * Legacy apps pass through unchanged.
93
+ */
94
+ export function resolveAppFilePath(app: AppDefinition, path: string): string {
95
+ if (!isMultifileApp(app)) return path;
96
+ const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
97
+ if (
98
+ normalized.startsWith("src/") ||
99
+ normalized.startsWith("dist/") ||
100
+ normalized.startsWith("records/")
101
+ ) {
102
+ return normalized;
103
+ }
104
+ return `src/${normalized}`;
105
+ }
106
+
80
107
  // ---------------------------------------------------------------------------
81
108
  // app_create
82
109
  // ---------------------------------------------------------------------------
@@ -90,6 +117,8 @@ export interface AppCreateInput {
90
117
  auto_open?: boolean;
91
118
  set_as_home_base?: boolean;
92
119
  preview?: Record<string, unknown>;
120
+ /** When provided, controls multifile scaffold behavior. */
121
+ featureFlags?: { multifileEnabled: boolean };
93
122
  }
94
123
 
95
124
  export async function executeAppCreate(
@@ -146,15 +175,71 @@ export async function executeAppCreate(
146
175
  const rawIcon = preview?.icon as string | undefined;
147
176
  const icon = rawIcon && !rawIcon.startsWith("http") ? rawIcon : undefined;
148
177
 
178
+ const multifileEnabled = input.featureFlags?.multifileEnabled === true;
179
+
149
180
  const app = store.createApp({
150
181
  name,
151
182
  description,
152
183
  icon,
153
184
  schemaJson,
154
- htmlDefinition,
155
- pages,
185
+ htmlDefinition: multifileEnabled ? "" : htmlDefinition,
186
+ pages: multifileEnabled ? undefined : pages,
187
+ formatVersion: multifileEnabled ? 2 : undefined,
156
188
  });
157
189
 
190
+ // Scaffold multifile app with src/ files and compile to dist/
191
+ if (multifileEnabled) {
192
+ const htmlSafeName = name
193
+ .replace(/&/g, "&amp;")
194
+ .replace(/</g, "&lt;")
195
+ .replace(/>/g, "&gt;")
196
+ .replace(/"/g, "&quot;");
197
+ const jsxSafeName = name.replace(/[<>{}&"']/g, "");
198
+
199
+ const indexHtml =
200
+ typeof input.html === "string"
201
+ ? input.html
202
+ : `<!DOCTYPE html>
203
+ <html lang="en">
204
+ <head>
205
+ <meta charset="UTF-8">
206
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
207
+ <title>${htmlSafeName}</title>
208
+ </head>
209
+ <body>
210
+ <div id="app"></div>
211
+ </body>
212
+ </html>`;
213
+
214
+ const mainTsx = `import { render } from 'preact';
215
+
216
+ function App() {
217
+ return <div>{"Hello, ${jsxSafeName}!"}</div>;
218
+ }
219
+
220
+ render(<App />, document.getElementById('app')!);
221
+ `;
222
+
223
+ store.writeAppFile(app.id, "src/index.html", indexHtml);
224
+ store.writeAppFile(app.id, "src/main.tsx", mainTsx);
225
+
226
+ // Compile src/ → dist/
227
+ const { join } = await import("node:path");
228
+ const appDir = join(getAppsDir(), app.id);
229
+ const compileResult = await compileApp(appDir);
230
+ if (!compileResult.ok) {
231
+ return {
232
+ content: JSON.stringify({
233
+ ...app,
234
+ compile_errors: compileResult.errors,
235
+ compile_warnings: compileResult.warnings,
236
+ compile_duration_ms: compileResult.durationMs,
237
+ }),
238
+ isError: false,
239
+ };
240
+ }
241
+ }
242
+
158
243
  if (input.set_as_home_base) {
159
244
  setHomeBaseAppLink(app.id, "personalized");
160
245
  }
@@ -305,6 +390,23 @@ export function executeAppFileList(
305
390
  store: AppStoreReader,
306
391
  ): ExecutorResult {
307
392
  const files = store.listAppFiles(input.app_id);
393
+ const app = store.getApp(input.app_id);
394
+
395
+ if (app && isMultifileApp(app)) {
396
+ // Separate build output paths from source paths without mutating the
397
+ // file path strings — consumers need clean paths for subsequent tool calls.
398
+ const buildOutputPaths = files.filter((f) =>
399
+ f.replace(/\\/g, "/").startsWith("dist/"),
400
+ );
401
+ return {
402
+ content: JSON.stringify({
403
+ files,
404
+ buildOutput: buildOutputPaths,
405
+ }),
406
+ isError: false,
407
+ };
408
+ }
409
+
308
410
  return { content: JSON.stringify(files), isError: false };
309
411
  }
310
412
 
@@ -326,7 +428,9 @@ export function executeAppFileRead(
326
428
  const offset = input.offset ?? 1;
327
429
  const limit = input.limit;
328
430
 
329
- const raw = store.readAppFile(input.app_id, input.path);
431
+ const app = store.getApp(input.app_id);
432
+ const resolvedPath = app ? resolveAppFilePath(app, input.path) : input.path;
433
+ const raw = store.readAppFile(input.app_id, resolvedPath);
330
434
  const allLines = raw.split("\n");
331
435
  const startIndex = Math.max(0, offset - 1);
332
436
  const sliced =
@@ -368,10 +472,13 @@ export function executeAppFileEdit(
368
472
  };
369
473
  }
370
474
 
475
+ const app = store.getApp(input.app_id);
476
+ const resolvedPath = app ? resolveAppFilePath(app, input.path) : input.path;
477
+
371
478
  const replaceAll = input.replace_all ?? false;
372
479
  const result = store.editAppFile(
373
480
  input.app_id,
374
- input.path,
481
+ resolvedPath,
375
482
  input.old_string,
376
483
  input.new_string,
377
484
  replaceAll,
@@ -406,9 +513,10 @@ export function executeAppFileWrite(
406
513
  };
407
514
  }
408
515
 
409
- store.writeAppFile(input.app_id, input.path, input.content);
516
+ const resolvedPath = resolveAppFilePath(app, input.path);
517
+ store.writeAppFile(input.app_id, resolvedPath, input.content);
410
518
  return {
411
- content: JSON.stringify({ written: true, path: input.path }),
519
+ content: JSON.stringify({ written: true, path: resolvedPath }),
412
520
  isError: false,
413
521
  status: input.status,
414
522
  };
@@ -25,6 +25,30 @@ function getDownloadsDir(): string {
25
25
  return dir;
26
26
  }
27
27
 
28
+ /** Wraps a promise with a timeout to prevent indefinite hangs. */
29
+ export function withTimeout<T>(
30
+ promise: Promise<T>,
31
+ ms: number,
32
+ label: string,
33
+ ): Promise<T> {
34
+ return new Promise<T>((resolve, reject) => {
35
+ const timer = setTimeout(
36
+ () => reject(new Error(`${label} timed out after ${ms}ms`)),
37
+ ms,
38
+ );
39
+ promise.then(
40
+ (val) => {
41
+ clearTimeout(timer);
42
+ resolve(val);
43
+ },
44
+ (err) => {
45
+ clearTimeout(timer);
46
+ reject(err);
47
+ },
48
+ );
49
+ });
50
+ }
51
+
28
52
  export type DownloadInfo = { path: string; filename: string };
29
53
 
30
54
  type BrowserContext = {
@@ -679,7 +703,7 @@ class BrowserManager {
679
703
  try {
680
704
  const filename = dl.suggestedFilename();
681
705
  const destPath = join(getDownloadsDir(), `${Date.now()}-${filename}`);
682
- await dl.saveAs(destPath);
706
+ await withTimeout(dl.saveAs(destPath), 120_000, "Download save");
683
707
  const info: DownloadInfo = { path: destPath, filename };
684
708
 
685
709
  // Resolve a pending waiter if one exists, otherwise store for later retrieval
@@ -696,7 +720,11 @@ class BrowserManager {
696
720
 
697
721
  log.info({ sessionId, filename, path: destPath }, "Download completed");
698
722
  } catch (err) {
699
- const failure = await dl.failure();
723
+ const failure = await withTimeout(
724
+ dl.failure(),
725
+ 5_000,
726
+ "Download failure check",
727
+ ).catch(() => null);
700
728
  log.warn({ err, failure, sessionId }, "Download failed");
701
729
 
702
730
  // Reject any pending waiters
@@ -7,7 +7,7 @@
7
7
  * make cleanup decisions (e.g. only kill Chrome if *we* launched it).
8
8
  */
9
9
 
10
- import { spawn as spawnChild } from "node:child_process";
10
+ import { execSync, spawn as spawnChild } from "node:child_process";
11
11
  import { homedir } from "node:os";
12
12
  import { join as pathJoin } from "node:path";
13
13
 
@@ -51,14 +51,23 @@ export interface EnsureChromeOptions {
51
51
  // ---------------------------------------------------------------------------
52
52
 
53
53
  /**
54
- * Returns `true` when a CDP endpoint is responding at the given base URL.
54
+ * Returns `true` when a CDP endpoint is responding at the given base URL
55
+ * and has at least one open page tab. A CDP endpoint with zero tabs is
56
+ * stale and unusable — callers should treat it as not ready.
55
57
  */
56
58
  export async function isCdpReady(
57
59
  cdpBase: string = DEFAULT_CDP_BASE,
58
60
  ): Promise<boolean> {
59
61
  try {
60
62
  const res = await fetch(`${cdpBase}/json/version`);
61
- return res.ok;
63
+ if (!res.ok) return false;
64
+
65
+ // Verify there's at least one page tab — a CDP endpoint with no tabs
66
+ // is a stale Chrome process that should be relaunched.
67
+ const listRes = await fetch(`${cdpBase}/json/list`);
68
+ if (!listRes.ok) return false;
69
+ const targets = (await listRes.json()) as Array<{ type: string }>;
70
+ return targets.some((t) => t.type === "page");
62
71
  } catch {
63
72
  return false;
64
73
  }
@@ -86,6 +95,25 @@ export async function ensureChromeWithCdp(
86
95
  return { baseUrl, launchedByUs: false, userDataDir };
87
96
  }
88
97
 
98
+ // If CDP is responding but has no tabs (stale), kill the process holding the port.
99
+ try {
100
+ const versionRes = await fetch(`${baseUrl}/json/version`);
101
+ if (versionRes.ok) {
102
+ // Stale Chrome — CDP up but no tabs. Kill it so we can relaunch.
103
+ try {
104
+ execSync(`lsof -ti :${port} | xargs kill -9 2>/dev/null`, {
105
+ stdio: "ignore",
106
+ });
107
+ } catch {
108
+ // Ignore — process may have already exited.
109
+ }
110
+ // Brief wait for port to clear.
111
+ await new Promise((r) => setTimeout(r, 500));
112
+ }
113
+ } catch {
114
+ // CDP not responding at all — port is free, proceed to launch.
115
+ }
116
+
89
117
  const args = [
90
118
  `--remote-debugging-port=${port}`,
91
119
  `--force-renderer-accessibility`,
@@ -9,10 +9,10 @@ import { RiskLevel } from "../../permissions/types.js";
9
9
  import type { ToolDefinition } from "../../providers/types.js";
10
10
  import type { TokenEndpointAuthMethod } from "../../security/oauth2.js";
11
11
  import {
12
- deleteSecureKey,
12
+ deleteSecureKeyAsync,
13
13
  getSecureKey,
14
14
  listSecureKeys,
15
- setSecureKey,
15
+ setSecureKeyAsync,
16
16
  } from "../../security/secure-keys.js";
17
17
  import { getLogger } from "../../util/logger.js";
18
18
  import type { Tool, ToolContext, ToolExecutionResult } from "../types.js";
@@ -381,7 +381,7 @@ class CredentialStoreTool implements Tool {
381
381
  }
382
382
 
383
383
  const key = `credential:${service}:${field}`;
384
- const ok = setSecureKey(key, value);
384
+ const ok = await setSecureKeyAsync(key, value);
385
385
  if (!ok) {
386
386
  return {
387
387
  content: "Error: failed to store credential",
@@ -494,7 +494,7 @@ class CredentialStoreTool implements Tool {
494
494
  }
495
495
 
496
496
  const key = `credential:${service}:${field}`;
497
- const result = deleteSecureKey(key);
497
+ const result = await deleteSecureKeyAsync(key);
498
498
  if (result === "error") {
499
499
  return {
500
500
  content: `Error: failed to delete credential ${service}/${field} from secure storage`,
@@ -564,7 +564,9 @@ class CredentialStoreTool implements Tool {
564
564
  const promptPolicy = toPolicyFromInput(promptPolicyInput);
565
565
 
566
566
  // Parse and validate injection templates (same logic as store action)
567
- const promptRawTemplates = input.injection_templates as unknown[] | undefined;
567
+ const promptRawTemplates = input.injection_templates as
568
+ | unknown[]
569
+ | undefined;
568
570
  let promptInjectionTemplates: CredentialInjectionTemplate[] | undefined;
569
571
  if (promptRawTemplates !== undefined) {
570
572
  if (!Array.isArray(promptRawTemplates)) {
@@ -732,7 +734,7 @@ class CredentialStoreTool implements Tool {
732
734
 
733
735
  // Default: persist to keychain
734
736
  const key = `credential:${service}:${field}`;
735
- const ok = setSecureKey(key, result.value);
737
+ const ok = await setSecureKeyAsync(key, result.value);
736
738
  if (!ok) {
737
739
  return {
738
740
  content: "Error: failed to store credential",
@@ -752,7 +754,7 @@ class CredentialStoreTool implements Tool {
752
754
  "metadata write failed after storing credential",
753
755
  );
754
756
  }
755
- const promptMeta = getCredentialMetadata(service, field);
757
+ const promptMeta = getCredentialMetadata(service, field);
756
758
  const promptCredIdSuffix = promptMeta
757
759
  ? ` (credential_id: ${promptMeta.credentialId})`
758
760
  : "";
@@ -155,6 +155,10 @@ export class ToolExecutor {
155
155
  );
156
156
  // Buffer so the shell's own timeout fires first and handles cleanup
157
157
  toolTimeoutMs = (shellTimeoutSec + 5) * 1000;
158
+ } else if (name === "claude_code") {
159
+ // Claude Code spawns a subprocess that manages its own turn limits
160
+ // (maxTurns). Give it a generous timeout so it isn't killed mid-task.
161
+ toolTimeoutMs = 10 * 60 * 1000; // 10 minutes
158
162
  } else {
159
163
  const rawTimeoutSec = getConfig().timeouts.toolExecutionTimeoutSec;
160
164
  toolTimeoutMs = safeTimeoutMs(rawTimeoutSec);