@getpaseo/server 0.1.88 → 0.1.90

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 (94) hide show
  1. package/dist/server/server/agent/agent-manager.js +4 -1
  2. package/dist/server/server/agent/agent-prompt.js +4 -1
  3. package/dist/server/server/agent/agent-sdk-types.d.ts +1 -0
  4. package/dist/server/server/agent/agent-storage.d.ts +22 -22
  5. package/dist/server/server/agent/agent-storage.js +2 -9
  6. package/dist/server/server/agent/create-agent/create.d.ts +2 -0
  7. package/dist/server/server/agent/create-agent/create.js +26 -7
  8. package/dist/server/server/agent/create-agent-lifecycle-dispatch.d.ts +1 -0
  9. package/dist/server/server/agent/create-agent-lifecycle-dispatch.js +4 -0
  10. package/dist/server/server/agent/create-agent-mode.d.ts +3 -8
  11. package/dist/server/server/agent/create-agent-mode.js +16 -2
  12. package/dist/server/server/agent/import-sessions.js +1 -1
  13. package/dist/server/server/agent/mcp-server.d.ts +1 -0
  14. package/dist/server/server/agent/mcp-server.js +113 -70
  15. package/dist/server/server/agent/provider-snapshot-manager.d.ts +2 -1
  16. package/dist/server/server/agent/provider-snapshot-manager.js +18 -2
  17. package/dist/server/server/agent/providers/acp-agent.d.ts +3 -3
  18. package/dist/server/server/agent/providers/acp-agent.js +18 -13
  19. package/dist/server/server/agent/providers/codex-app-server-agent.js +16 -22
  20. package/dist/server/server/agent/providers/mock-load-test-agent.d.ts +2 -0
  21. package/dist/server/server/agent/providers/mock-load-test-agent.js +69 -2
  22. package/dist/server/server/agent/providers/opencode-agent.js +19 -8
  23. package/dist/server/server/agent/providers/pi/agent.js +13 -0
  24. package/dist/server/server/agent/providers/pi/rpc-types.d.ts +3 -0
  25. package/dist/server/server/agent/timeline-projection.js +30 -1
  26. package/dist/server/server/atomic-file.d.ts +3 -0
  27. package/dist/server/server/atomic-file.js +19 -0
  28. package/dist/server/server/auto-archive-on-merge/archive-if-safe.d.ts +1 -0
  29. package/dist/server/server/auto-archive-on-merge/archive-if-safe.js +10 -2
  30. package/dist/server/server/bootstrap.d.ts +7 -2
  31. package/dist/server/server/bootstrap.js +154 -115
  32. package/dist/server/server/chat/chat-service.js +2 -4
  33. package/dist/server/server/config.js +41 -0
  34. package/dist/server/server/daemon-keypair.js +2 -2
  35. package/dist/server/server/loop-service.d.ts +26 -22
  36. package/dist/server/server/loop-service.js +27 -9
  37. package/dist/server/server/package-version.d.ts +2 -2
  38. package/dist/server/server/paseo-worktree-archive-service.d.ts +2 -0
  39. package/dist/server/server/paseo-worktree-archive-service.js +28 -9
  40. package/dist/server/server/persisted-config.d.ts +84 -28
  41. package/dist/server/server/persisted-config.js +20 -3
  42. package/dist/server/server/pid-lock.d.ts +2 -2
  43. package/dist/server/server/private-files.d.ts +0 -1
  44. package/dist/server/server/private-files.js +0 -5
  45. package/dist/server/server/schedule/service.d.ts +6 -0
  46. package/dist/server/server/schedule/service.js +41 -18
  47. package/dist/server/server/schedule/store.js +3 -2
  48. package/dist/server/server/script-health-monitor.d.ts +4 -4
  49. package/dist/server/server/script-health-monitor.js +6 -6
  50. package/dist/server/server/script-proxy.d.ts +2 -39
  51. package/dist/server/server/script-proxy.js +1 -244
  52. package/dist/server/server/script-route-branch-handler.d.ts +2 -2
  53. package/dist/server/server/script-route-branch-handler.js +3 -37
  54. package/dist/server/server/script-status-projection.d.ts +6 -4
  55. package/dist/server/server/script-status-projection.js +85 -37
  56. package/dist/server/server/server-id.js +3 -3
  57. package/dist/server/server/service-proxy.d.ts +237 -0
  58. package/dist/server/server/service-proxy.js +714 -0
  59. package/dist/server/server/session.d.ts +12 -18
  60. package/dist/server/server/session.js +206 -117
  61. package/dist/server/server/speech/providers/local/worker-client.js +1 -11
  62. package/dist/server/server/websocket-server.d.ts +7 -4
  63. package/dist/server/server/websocket-server.js +9 -4
  64. package/dist/server/server/workspace-bootstrap-dedupe.d.ts +34 -0
  65. package/dist/server/server/workspace-bootstrap-dedupe.js +23 -0
  66. package/dist/server/server/workspace-directory.d.ts +8 -0
  67. package/dist/server/server/workspace-directory.js +141 -11
  68. package/dist/server/server/workspace-git-service.d.ts +3 -0
  69. package/dist/server/server/workspace-git-service.js +53 -12
  70. package/dist/server/server/workspace-registry.d.ts +2 -2
  71. package/dist/server/server/workspace-registry.js +2 -6
  72. package/dist/server/server/workspace-service-env.d.ts +1 -0
  73. package/dist/server/server/workspace-service-env.js +23 -18
  74. package/dist/server/server/worktree/commands.d.ts +2 -0
  75. package/dist/server/server/worktree/commands.js +4 -1
  76. package/dist/server/server/worktree-bootstrap.d.ts +4 -3
  77. package/dist/server/server/worktree-bootstrap.js +14 -13
  78. package/dist/server/server/worktree-core.d.ts +1 -0
  79. package/dist/server/server/worktree-core.js +2 -0
  80. package/dist/server/server/worktree-session.d.ts +6 -2
  81. package/dist/server/server/worktree-session.js +3 -0
  82. package/dist/server/services/github-service.d.ts +1 -0
  83. package/dist/server/services/github-service.js +7 -1
  84. package/dist/server/utils/checkout-git.d.ts +6 -3
  85. package/dist/server/utils/checkout-git.js +40 -38
  86. package/dist/server/utils/worktree.d.ts +17 -12
  87. package/dist/server/utils/worktree.js +39 -22
  88. package/dist/src/server/persisted-config.js +20 -3
  89. package/dist/src/server/private-files.js +0 -5
  90. package/package.json +9 -7
  91. package/dist/server/server/editor-targets.d.ts +0 -18
  92. package/dist/server/server/editor-targets.js +0 -109
  93. package/dist/server/utils/script-hostname.d.ts +0 -8
  94. package/dist/server/utils/script-hostname.js +0 -14
@@ -99,7 +99,7 @@ import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
99
99
  import { startRelayTransport } from "./relay-transport.js";
100
100
  import { getOrCreateServerId } from "./server-id.js";
101
101
  import { resolveDaemonVersion } from "./daemon-version.js";
102
- import { ScriptRouteStore, createScriptProxyMiddleware, createScriptProxyUpgradeHandler, } from "./script-proxy.js";
102
+ import { createServiceProxySubsystem } from "./service-proxy.js";
103
103
  import { ScriptHealthMonitor } from "./script-health-monitor.js";
104
104
  import { createScriptStatusEmitter } from "./script-status-projection.js";
105
105
  import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -187,30 +187,42 @@ export async function createPaseoDaemon(config, rootLogger) {
187
187
  const app = express();
188
188
  let boundListenTarget = null;
189
189
  let workspaceRegistry = null;
190
- const scriptRouteStore = new ScriptRouteStore();
190
+ const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl
191
+ ? config.serviceProxy.publicBaseUrl
192
+ : null;
193
+ const serviceProxy = createServiceProxySubsystem({
194
+ logger,
195
+ publicBaseUrl: serviceProxyPublicBaseUrl,
196
+ });
191
197
  const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
192
198
  const configuredHostnames = config.hostnames ?? config.allowedHosts;
193
199
  let wsServer = null;
200
+ let serviceProxyListenTarget = null;
194
201
  const scriptHealthMonitor = new ScriptHealthMonitor({
195
- routeStore: scriptRouteStore,
202
+ serviceProxy,
196
203
  onChange: createScriptStatusEmitter({
197
204
  sessions: () => wsServer?.listActiveSessions().map((session) => ({
198
205
  emit: (message) => session.emitServerMessage(message),
199
206
  })) ?? [],
200
- routeStore: scriptRouteStore,
207
+ serviceProxy,
201
208
  runtimeStore: scriptRuntimeStore,
202
209
  daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
203
210
  resolveWorkspaceDirectory: async (workspaceId) => (await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
204
211
  logger,
212
+ serviceProxyPublicBaseUrl,
205
213
  }),
206
214
  });
207
215
  const handleBranchChange = createBranchChangeRouteHandler({
208
- routeStore: scriptRouteStore,
216
+ serviceProxy,
209
217
  onRoutesChanged: (workspaceId) => {
210
218
  scriptHealthMonitor.invalidateWorkspace(workspaceId);
211
219
  },
212
220
  logger,
213
221
  });
222
+ // Service proxy classifies service hosts before daemon auth/route fallthrough.
223
+ // Registered service hosts proxy directly; known service namespaces without a
224
+ // route return 404 and never reach daemon APIs.
225
+ app.use(serviceProxy.middleware());
214
226
  // Host allowlist / DNS rebinding protection (vite-like semantics).
215
227
  // For non-TCP (unix sockets), skip host validation.
216
228
  if (listenTarget.type === "tcp") {
@@ -254,10 +266,6 @@ export async function createPaseoDaemon(config, rootLogger) {
254
266
  app.use(createRequireBearerMiddleware(config.auth, (context) => {
255
267
  logger.warn(context, "Rejected HTTP request with invalid daemon password");
256
268
  }));
257
- // Script proxy — intercepts requests for registered *.localhost hostnames
258
- // and forwards them to the corresponding local script port. Placed after
259
- // host/CORS/auth checks but before the rest of the routes.
260
- app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
261
269
  // Serve static files from public directory
262
270
  app.use("/public", express.static(staticDir));
263
271
  // Middleware
@@ -331,11 +339,10 @@ export async function createPaseoDaemon(config, rootLogger) {
331
339
  // VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
332
340
  // script-bound upgrades are forwarded first. The handler is a no-op for
333
341
  // requests that don't match a registered script route.
334
- const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({
335
- routeStore: scriptRouteStore,
336
- logger,
337
- });
338
- httpServer.on("upgrade", scriptProxyUpgradeHandler);
342
+ httpServer.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: true }));
343
+ if (config.serviceProxy?.standaloneListen) {
344
+ serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
345
+ }
339
346
  const agentStorage = new AgentStorage(config.agentStoragePath, logger);
340
347
  const projectRegistry = new FileBackedProjectRegistry(path.join(config.paseoHome, "projects", "projects.json"), logger);
341
348
  workspaceRegistry = new FileBackedWorkspaceRegistry(path.join(config.paseoHome, "projects", "workspaces.json"), logger);
@@ -348,6 +355,7 @@ export async function createPaseoDaemon(config, rootLogger) {
348
355
  const workspaceGitService = new WorkspaceGitServiceImpl({
349
356
  logger,
350
357
  paseoHome: config.paseoHome,
358
+ worktreesRoot: config.worktreesRoot,
351
359
  deps: {
352
360
  github,
353
361
  },
@@ -410,6 +418,7 @@ export async function createPaseoDaemon(config, rootLogger) {
410
418
  paseoHome: config.paseoHome,
411
419
  logger,
412
420
  agentManager,
421
+ providerSnapshotManager,
413
422
  });
414
423
  await loopService.initialize();
415
424
  logger.info({ elapsed: elapsed() }, "Loop service initialized");
@@ -418,6 +427,7 @@ export async function createPaseoDaemon(config, rootLogger) {
418
427
  logger,
419
428
  agentManager,
420
429
  agentStorage,
430
+ providerSnapshotManager,
421
431
  });
422
432
  await scheduleService.start();
423
433
  agentManager.setAgentArchivedCallback(async (agentId) => {
@@ -467,6 +477,7 @@ export async function createPaseoDaemon(config, rootLogger) {
467
477
  };
468
478
  setupAutoArchiveOnMerge({
469
479
  paseoHome: config.paseoHome,
480
+ worktreesRoot: config.worktreesRoot,
470
481
  daemonConfigStore,
471
482
  workspaceGitService,
472
483
  github,
@@ -501,6 +512,7 @@ export async function createPaseoDaemon(config, rootLogger) {
501
512
  createPaseoWorktree: async (input, serviceOptions) => {
502
513
  return createPaseoWorktreeWorkflow({
503
514
  paseoHome: config.paseoHome,
515
+ worktreesRoot: config.worktreesRoot,
504
516
  createPaseoWorktree: async (workflowInput, workflowOptions) => {
505
517
  return createRegisteredPaseoWorktree(workflowInput, {
506
518
  github,
@@ -530,14 +542,16 @@ export async function createPaseoDaemon(config, rootLogger) {
530
542
  sessionLogger: logger,
531
543
  terminalManager,
532
544
  archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
533
- scriptRouteStore,
545
+ serviceProxy,
534
546
  scriptRuntimeStore,
535
547
  getDaemonTcpPort: () => boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
536
548
  getDaemonTcpHost: () => boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
549
+ serviceProxyPublicBaseUrl,
537
550
  onScriptsChanged: null,
538
551
  }, input, serviceOptions);
539
552
  },
540
553
  paseoHome: config.paseoHome,
554
+ worktreesRoot: config.worktreesRoot,
541
555
  callerAgentId,
542
556
  enableVoiceTools: false,
543
557
  resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
@@ -652,112 +666,136 @@ export async function createPaseoDaemon(config, rootLogger) {
652
666
  logger.info({ elapsed: elapsed() }, "Speech service created");
653
667
  logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
654
668
  const start = async () => {
655
- // Start main HTTP server
656
- await new Promise((resolve, reject) => {
657
- const onError = (err) => {
658
- httpServer.off("listening", onListening);
659
- reject(err);
660
- };
661
- const onListening = () => {
662
- httpServer.off("error", onError);
663
- const logAndResolve = async () => {
664
- boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
665
- const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
666
- agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
667
- agentManager.setMcpBaseUrl(agentMcpBaseUrl);
668
- daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
669
- agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
670
- });
671
- daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
672
- agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
673
- });
674
- const relayEnabled = config.relayEnabled ?? true;
675
- const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
676
- const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
677
- const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
678
- const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
679
- const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
680
- if (boundListenTarget.type === "tcp") {
681
- logger.info({
682
- host: boundListenTarget.host,
683
- port: boundListenTarget.port,
684
- authRequired: !!config.auth?.password,
685
- elapsed: elapsed(),
686
- }, `Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`);
687
- }
688
- else {
689
- logger.info({
690
- path: boundListenTarget.path,
691
- authRequired: !!config.auth?.password,
692
- elapsed: elapsed(),
693
- }, `Server listening on ${boundListenTarget.path}`);
694
- }
695
- if (config.auth?.password) {
696
- logger.info("Daemon password authentication enabled");
697
- }
698
- wsServer = new VoiceAssistantWebSocketServer(httpServer, logger, serverId, agentManager, agentStorage, downloadTokenStore, config.paseoHome, daemonConfigStore, mcpBaseUrl, { allowedOrigins, hostnames: configuredHostnames }, config.auth, speechService, terminalManager, {
699
- finalTimeoutMs: config.dictationFinalTimeoutMs,
700
- }, daemonVersion, (intent) => {
701
- try {
702
- config.onLifecycleIntent?.(intent);
669
+ let mainStarted = false;
670
+ try {
671
+ if (serviceProxyListenTarget) {
672
+ const boundServiceProxyTarget = await serviceProxy.startStandalone({
673
+ listenTarget: serviceProxyListenTarget,
674
+ });
675
+ serviceProxyListenTarget = boundServiceProxyTarget;
676
+ logger.info({
677
+ listen: formatListenTarget(serviceProxyListenTarget),
678
+ publicBaseUrl: serviceProxyPublicBaseUrl,
679
+ elapsed: elapsed(),
680
+ }, "Service proxy listening");
681
+ }
682
+ // Start main HTTP server
683
+ await new Promise((resolve, reject) => {
684
+ const onError = (err) => {
685
+ httpServer.off("listening", onListening);
686
+ reject(err);
687
+ };
688
+ const onListening = () => {
689
+ httpServer.off("error", onError);
690
+ mainStarted = true;
691
+ const logAndResolve = async () => {
692
+ boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
693
+ const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
694
+ agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
695
+ agentManager.setMcpBaseUrl(agentMcpBaseUrl);
696
+ daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
697
+ agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
698
+ });
699
+ daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
700
+ agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
701
+ });
702
+ const relayEnabled = config.relayEnabled ?? true;
703
+ const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
704
+ const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
705
+ const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
706
+ const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
707
+ const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
708
+ if (boundListenTarget.type === "tcp") {
709
+ logger.info({
710
+ host: boundListenTarget.host,
711
+ port: boundListenTarget.port,
712
+ authRequired: !!config.auth?.password,
713
+ elapsed: elapsed(),
714
+ }, `Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`);
703
715
  }
704
- catch (error) {
705
- logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
716
+ else {
717
+ logger.info({
718
+ path: boundListenTarget.path,
719
+ authRequired: !!config.auth?.password,
720
+ elapsed: elapsed(),
721
+ }, `Server listening on ${boundListenTarget.path}`);
706
722
  }
707
- }, projectRegistry, workspaceRegistry, chatService, loopService, scheduleService, checkoutDiffManager, scriptRouteStore, scriptRuntimeStore, handleBranchChange, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), workspaceGitService, github, config.pushNotificationSender, providerSnapshotManager, {
708
- listen: formatListenTarget(boundListenTarget ?? listenTarget),
709
- relay: {
710
- enabled: relayEnabled,
711
- endpoint: relayEndpoint,
712
- publicEndpoint: relayPublicEndpoint,
713
- useTls: relayUseTls,
714
- publicUseTls: relayPublicUseTls,
715
- },
716
- });
717
- if (relayEnabled) {
718
- const offer = await createConnectionOfferV2({
719
- serverId,
720
- daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
723
+ if (config.auth?.password) {
724
+ logger.info("Daemon password authentication enabled");
725
+ }
726
+ wsServer = new VoiceAssistantWebSocketServer(httpServer, logger, serverId, agentManager, agentStorage, downloadTokenStore, config.paseoHome, daemonConfigStore, mcpBaseUrl, { allowedOrigins, hostnames: configuredHostnames }, config.auth, speechService, terminalManager, {
727
+ finalTimeoutMs: config.dictationFinalTimeoutMs,
728
+ }, daemonVersion, (intent) => {
729
+ try {
730
+ config.onLifecycleIntent?.(intent);
731
+ }
732
+ catch (error) {
733
+ logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
734
+ }
735
+ }, projectRegistry, workspaceRegistry, chatService, loopService, scheduleService, checkoutDiffManager, serviceProxy, scriptRuntimeStore, handleBranchChange, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), workspaceGitService, github, config.pushNotificationSender, providerSnapshotManager, {
736
+ listen: formatListenTarget(boundListenTarget ?? listenTarget),
737
+ worktreesRoot: config.worktreesRoot,
721
738
  relay: {
722
- endpoint: relayPublicEndpoint,
723
- useTls: relayPublicUseTls,
739
+ enabled: relayEnabled,
740
+ endpoint: relayEndpoint,
741
+ publicEndpoint: relayPublicEndpoint,
742
+ useTls: relayUseTls,
743
+ publicUseTls: relayPublicUseTls,
724
744
  },
725
- });
726
- encodeOfferToFragmentUrl({ offer, appBaseUrl });
727
- relayTransport?.stop().catch(() => undefined);
728
- relayTransport = startRelayTransport({
729
- logger,
730
- attachSocket: (ws, metadata) => {
731
- if (!wsServer) {
732
- throw new Error("WebSocket server not initialized");
733
- }
734
- return wsServer.attachExternalSocket(ws, metadata);
735
- },
736
- relayEndpoint,
737
- relayUseTls,
738
- serverId,
739
- daemonKeyPair: daemonKeyPair.keyPair,
740
- });
741
- }
745
+ }, serviceProxyPublicBaseUrl);
746
+ if (relayEnabled) {
747
+ const offer = await createConnectionOfferV2({
748
+ serverId,
749
+ daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
750
+ relay: {
751
+ endpoint: relayPublicEndpoint,
752
+ useTls: relayPublicUseTls,
753
+ },
754
+ });
755
+ encodeOfferToFragmentUrl({ offer, appBaseUrl });
756
+ relayTransport?.stop().catch(() => undefined);
757
+ relayTransport = startRelayTransport({
758
+ logger,
759
+ attachSocket: (ws, metadata) => {
760
+ if (!wsServer) {
761
+ throw new Error("WebSocket server not initialized");
762
+ }
763
+ return wsServer.attachExternalSocket(ws, metadata);
764
+ },
765
+ relayEndpoint,
766
+ relayUseTls,
767
+ serverId,
768
+ daemonKeyPair: daemonKeyPair.keyPair,
769
+ });
770
+ }
771
+ };
772
+ logAndResolve().then(resolve, reject);
742
773
  };
743
- logAndResolve().then(resolve, reject);
744
- };
745
- httpServer.once("error", onError);
746
- httpServer.once("listening", onListening);
747
- if (listenTarget.type === "tcp") {
748
- httpServer.listen(listenTarget.port, listenTarget.host);
749
- }
750
- else {
751
- if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
752
- unlinkSync(listenTarget.path);
774
+ httpServer.once("error", onError);
775
+ httpServer.once("listening", onListening);
776
+ if (listenTarget.type === "tcp") {
777
+ httpServer.listen(listenTarget.port, listenTarget.host);
753
778
  }
754
- httpServer.listen(listenTarget.path);
779
+ else {
780
+ if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
781
+ unlinkSync(listenTarget.path);
782
+ }
783
+ httpServer.listen(listenTarget.path);
784
+ }
785
+ });
786
+ // Start speech service after listening so synchronous Sherpa native
787
+ // model loading doesn't block the server from accepting connections.
788
+ speechService.start();
789
+ scriptHealthMonitor.start();
790
+ }
791
+ catch (error) {
792
+ await serviceProxy.stopStandalone().catch(() => undefined);
793
+ if (mainStarted) {
794
+ httpServer.closeAllConnections();
795
+ await new Promise((resolve) => httpServer.close(() => resolve()));
755
796
  }
756
- });
757
- // Start speech service after listening so synchronous Sherpa native
758
- // model loading doesn't block the server from accepting connections.
759
- speechService.start();
760
- scriptHealthMonitor.start();
797
+ throw error;
798
+ }
761
799
  };
762
800
  const stop = async () => {
763
801
  scriptHealthMonitor.stop();
@@ -773,6 +811,7 @@ export async function createPaseoDaemon(config, rootLogger) {
773
811
  if (wsServer) {
774
812
  await wsServer.close();
775
813
  }
814
+ await serviceProxy.stopStandalone();
776
815
  // Force-drop remaining sockets so httpServer.close() resolves promptly.
777
816
  // We've already closed wsServer (which sent ws-layer close frames) and
778
817
  // stopped every other service, so anything still attached is a TCP
@@ -794,7 +833,7 @@ export async function createPaseoDaemon(config, rootLogger) {
794
833
  agentManager,
795
834
  agentStorage,
796
835
  terminalManager,
797
- scriptRouteStore,
836
+ serviceProxy,
798
837
  scriptRuntimeStore,
799
838
  start,
800
839
  stop,
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
+ import { writeJsonFileAtomic } from "../atomic-file.js";
5
6
  import { ChatMessageSchema, ChatRoomDetailSchema, ChatRoomSchema, } from "@getpaseo/protocol/chat/types";
6
7
  const ChatStorePayloadSchema = z.object({
7
8
  rooms: z.array(ChatRoomSchema),
@@ -246,10 +247,7 @@ export class FileBackedChatService {
246
247
  .flat()
247
248
  .sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
248
249
  };
249
- await fs.mkdir(path.dirname(this.filePath), { recursive: true });
250
- const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
251
- await fs.writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8");
252
- await fs.rename(tempPath, this.filePath);
250
+ await writeJsonFileAtomic(this.filePath, payload);
253
251
  }
254
252
  findRoomByName(name) {
255
253
  const normalizedName = normalizeRoomName(name);
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { resolvePaseoNodeEnv } from "./paseo-env.js";
3
3
  import { z } from "zod";
4
+ import { expandTilde } from "../utils/path.js";
4
5
  import { loadPersistedConfig, LogFormatSchema, LogLevelSchema, } from "./persisted-config.js";
5
6
  import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
6
7
  import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
@@ -109,6 +110,33 @@ function resolveRelayConfig(input) {
109
110
  const publicUseTls = resolveTlsFromEnv(input.env.PASEO_RELAY_PUBLIC_USE_TLS, input.persisted.daemon?.relay?.publicUseTls, useTls);
110
111
  return { enabled, endpoint, publicEndpoint, useTls, publicUseTls };
111
112
  }
113
+ function resolveServiceProxyPublicBaseUrl(value) {
114
+ if (value === null) {
115
+ return null;
116
+ }
117
+ try {
118
+ return new URL(value).toString().replace(/\/$/, "");
119
+ }
120
+ catch {
121
+ throw new Error(`Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: ${value}`);
122
+ }
123
+ }
124
+ function resolveServiceProxyConfig(env, persisted) {
125
+ const enabledShim = parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? persisted.daemon?.serviceProxy?.enabled;
126
+ // COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
127
+ // `enabled=false` used to disable the separate service proxy listener. Localhost
128
+ // service proxying is now always enabled; this only suppresses optional layers.
129
+ const optionalLayersEnabled = enabledShim !== false;
130
+ const publicBaseUrl = optionalLayersEnabled
131
+ ? resolveServiceProxyPublicBaseUrl(env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
132
+ persisted.daemon?.serviceProxy?.publicBaseUrl ??
133
+ null)
134
+ : null;
135
+ const standaloneListen = optionalLayersEnabled
136
+ ? (env.PASEO_SERVICE_PROXY_LISTEN ?? persisted.daemon?.serviceProxy?.listen ?? null)
137
+ : null;
138
+ return { publicBaseUrl, standaloneListen };
139
+ }
112
140
  function resolveVoiceLlmConfig(env, persisted) {
113
141
  const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER);
114
142
  const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider(persisted.features?.voiceMode?.llm?.provider);
@@ -145,6 +173,16 @@ function resolveAuthConfig(env, persisted) {
145
173
  ? { password: persisted.daemon.auth.password }
146
174
  : undefined;
147
175
  }
176
+ function resolveWorktreesRoot(paseoHome, persisted) {
177
+ const configuredRoot = persisted.worktrees?.root?.trim();
178
+ if (!configuredRoot) {
179
+ return undefined;
180
+ }
181
+ const expandedRoot = expandTilde(configuredRoot);
182
+ return path.isAbsolute(expandedRoot)
183
+ ? path.resolve(expandedRoot)
184
+ : path.resolve(paseoHome, expandedRoot);
185
+ }
148
186
  function resolveAppendSystemPrompt(persisted) {
149
187
  return persisted.daemon?.appendSystemPrompt ?? "";
150
188
  }
@@ -173,6 +211,7 @@ export function loadConfig(paseoHome, options) {
173
211
  cliRelayEnabled: options?.cli?.relayEnabled,
174
212
  cliRelayUseTls: options?.cli?.relayUseTls,
175
213
  });
214
+ const serviceProxy = resolveServiceProxyConfig(env, persisted);
176
215
  const { openai, speech } = resolveSpeechConfig({
177
216
  paseoHome,
178
217
  env,
@@ -183,6 +222,7 @@ export function loadConfig(paseoHome, options) {
183
222
  return {
184
223
  listen,
185
224
  paseoHome,
225
+ worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
186
226
  corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
187
227
  hostnames,
188
228
  mcpEnabled,
@@ -199,6 +239,7 @@ export function loadConfig(paseoHome, options) {
199
239
  relayPublicEndpoint: relay.publicEndpoint,
200
240
  relayUseTls: relay.useTls,
201
241
  relayPublicUseTls: relay.publicUseTls,
242
+ serviceProxy,
202
243
  appBaseUrl,
203
244
  auth: resolveAuthConfig(env, persisted),
204
245
  openai,
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { generateKeyPair, exportPublicKey, exportSecretKey, importPublicKey, importSecretKey, } from "@getpaseo/relay/e2ee";
5
- import { ensurePrivateFile, writePrivateFileSync } from "./private-files.js";
5
+ import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
6
6
  const KeyPairSchema = z.object({
7
7
  v: z.literal(2),
8
8
  publicKeyB64: z.string().min(1),
@@ -35,7 +35,7 @@ export async function loadOrCreateDaemonKeyPair(paseoHome, logger) {
35
35
  publicKeyB64,
36
36
  secretKeyB64,
37
37
  };
38
- writePrivateFileSync(filePath, JSON.stringify(payload, null, 2) + "\n");
38
+ writePrivateFileAtomicSync(filePath, JSON.stringify(payload, null, 2) + "\n");
39
39
  log?.info({ filePath }, "Saved daemon keypair");
40
40
  return { keyPair, publicKeyB64 };
41
41
  }