@getpaseo/server 0.1.88 → 0.1.89

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 (60) hide show
  1. package/dist/server/server/agent/agent-manager.js +4 -1
  2. package/dist/server/server/agent/agent-storage.d.ts +22 -22
  3. package/dist/server/server/agent/create-agent/create.d.ts +2 -0
  4. package/dist/server/server/agent/create-agent/create.js +16 -5
  5. package/dist/server/server/agent/create-agent-lifecycle-dispatch.d.ts +1 -0
  6. package/dist/server/server/agent/create-agent-lifecycle-dispatch.js +4 -0
  7. package/dist/server/server/agent/mcp-server.d.ts +1 -0
  8. package/dist/server/server/agent/mcp-server.js +113 -70
  9. package/dist/server/server/agent/providers/pi/agent.js +13 -0
  10. package/dist/server/server/agent/providers/pi/rpc-types.d.ts +3 -0
  11. package/dist/server/server/auto-archive-on-merge/archive-if-safe.d.ts +1 -0
  12. package/dist/server/server/auto-archive-on-merge/archive-if-safe.js +6 -1
  13. package/dist/server/server/bootstrap.d.ts +7 -2
  14. package/dist/server/server/bootstrap.js +152 -115
  15. package/dist/server/server/config.js +41 -0
  16. package/dist/server/server/loop-service.d.ts +22 -22
  17. package/dist/server/server/package-version.d.ts +2 -2
  18. package/dist/server/server/paseo-worktree-archive-service.d.ts +2 -0
  19. package/dist/server/server/paseo-worktree-archive-service.js +28 -9
  20. package/dist/server/server/persisted-config.d.ts +84 -28
  21. package/dist/server/server/persisted-config.js +17 -0
  22. package/dist/server/server/pid-lock.d.ts +2 -2
  23. package/dist/server/server/script-health-monitor.d.ts +4 -4
  24. package/dist/server/server/script-health-monitor.js +6 -6
  25. package/dist/server/server/script-proxy.d.ts +2 -39
  26. package/dist/server/server/script-proxy.js +1 -244
  27. package/dist/server/server/script-route-branch-handler.d.ts +2 -2
  28. package/dist/server/server/script-route-branch-handler.js +3 -37
  29. package/dist/server/server/script-status-projection.d.ts +6 -4
  30. package/dist/server/server/script-status-projection.js +85 -37
  31. package/dist/server/server/service-proxy.d.ts +237 -0
  32. package/dist/server/server/service-proxy.js +714 -0
  33. package/dist/server/server/session.d.ts +7 -3
  34. package/dist/server/server/session.js +22 -10
  35. package/dist/server/server/websocket-server.d.ts +7 -4
  36. package/dist/server/server/websocket-server.js +9 -4
  37. package/dist/server/server/workspace-directory.js +4 -0
  38. package/dist/server/server/workspace-git-service.d.ts +3 -0
  39. package/dist/server/server/workspace-git-service.js +53 -12
  40. package/dist/server/server/workspace-registry.d.ts +2 -2
  41. package/dist/server/server/workspace-service-env.d.ts +1 -0
  42. package/dist/server/server/workspace-service-env.js +23 -18
  43. package/dist/server/server/worktree/commands.d.ts +2 -0
  44. package/dist/server/server/worktree/commands.js +4 -1
  45. package/dist/server/server/worktree-bootstrap.d.ts +4 -3
  46. package/dist/server/server/worktree-bootstrap.js +14 -13
  47. package/dist/server/server/worktree-core.d.ts +1 -0
  48. package/dist/server/server/worktree-core.js +2 -0
  49. package/dist/server/server/worktree-session.d.ts +6 -2
  50. package/dist/server/server/worktree-session.js +3 -0
  51. package/dist/server/services/github-service.d.ts +1 -0
  52. package/dist/server/services/github-service.js +7 -1
  53. package/dist/server/utils/checkout-git.d.ts +6 -2
  54. package/dist/server/utils/checkout-git.js +17 -7
  55. package/dist/server/utils/worktree.d.ts +17 -12
  56. package/dist/server/utils/worktree.js +39 -22
  57. package/dist/src/server/persisted-config.js +17 -0
  58. package/package.json +5 -5
  59. package/dist/server/utils/script-hostname.d.ts +0 -8
  60. package/dist/server/utils/script-hostname.js +0 -14
@@ -21,7 +21,7 @@ import type { PushNotificationSender } from "./push/notifications.js";
21
21
  import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
22
22
  import type { AgentProviderRuntimeSettingsMap, ProviderOverride } from "./agent/provider-launch-config.js";
23
23
  import type { PersistedConfig } from "./persisted-config.js";
24
- import { ScriptRouteStore } from "./script-proxy.js";
24
+ import { type ServiceProxySubsystem } from "./service-proxy.js";
25
25
  import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
26
26
  import { type HostnamesConfig } from "./hostnames.js";
27
27
  import { type DaemonAuthConfig } from "./auth.js";
@@ -49,6 +49,7 @@ export type DaemonLifecycleIntent = {
49
49
  export interface PaseoDaemonConfig {
50
50
  listen: string;
51
51
  paseoHome: string;
52
+ worktreesRoot?: string;
52
53
  corsAllowedOrigins: string[];
53
54
  allowedHosts?: HostnamesConfig;
54
55
  hostnames?: HostnamesConfig;
@@ -66,6 +67,10 @@ export interface PaseoDaemonConfig {
66
67
  relayPublicEndpoint?: string;
67
68
  relayUseTls?: boolean;
68
69
  relayPublicUseTls?: boolean;
70
+ serviceProxy?: {
71
+ publicBaseUrl: string | null;
72
+ standaloneListen: string | null;
73
+ };
69
74
  appBaseUrl?: string;
70
75
  auth?: DaemonAuthConfig;
71
76
  openai?: PaseoOpenAIConfig;
@@ -93,7 +98,7 @@ export interface PaseoDaemon {
93
98
  agentManager: AgentManager;
94
99
  agentStorage: AgentStorage;
95
100
  terminalManager: TerminalManager;
96
- scriptRouteStore: ScriptRouteStore;
101
+ serviceProxy: ServiceProxySubsystem;
97
102
  scriptRuntimeStore: WorkspaceScriptRuntimeStore;
98
103
  start(): Promise<void>;
99
104
  stop(): Promise<void>;
@@ -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
  },
@@ -467,6 +475,7 @@ export async function createPaseoDaemon(config, rootLogger) {
467
475
  };
468
476
  setupAutoArchiveOnMerge({
469
477
  paseoHome: config.paseoHome,
478
+ worktreesRoot: config.worktreesRoot,
470
479
  daemonConfigStore,
471
480
  workspaceGitService,
472
481
  github,
@@ -501,6 +510,7 @@ export async function createPaseoDaemon(config, rootLogger) {
501
510
  createPaseoWorktree: async (input, serviceOptions) => {
502
511
  return createPaseoWorktreeWorkflow({
503
512
  paseoHome: config.paseoHome,
513
+ worktreesRoot: config.worktreesRoot,
504
514
  createPaseoWorktree: async (workflowInput, workflowOptions) => {
505
515
  return createRegisteredPaseoWorktree(workflowInput, {
506
516
  github,
@@ -530,14 +540,16 @@ export async function createPaseoDaemon(config, rootLogger) {
530
540
  sessionLogger: logger,
531
541
  terminalManager,
532
542
  archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
533
- scriptRouteStore,
543
+ serviceProxy,
534
544
  scriptRuntimeStore,
535
545
  getDaemonTcpPort: () => boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
536
546
  getDaemonTcpHost: () => boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
547
+ serviceProxyPublicBaseUrl,
537
548
  onScriptsChanged: null,
538
549
  }, input, serviceOptions);
539
550
  },
540
551
  paseoHome: config.paseoHome,
552
+ worktreesRoot: config.worktreesRoot,
541
553
  callerAgentId,
542
554
  enableVoiceTools: false,
543
555
  resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
@@ -652,112 +664,136 @@ export async function createPaseoDaemon(config, rootLogger) {
652
664
  logger.info({ elapsed: elapsed() }, "Speech service created");
653
665
  logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
654
666
  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);
667
+ let mainStarted = false;
668
+ try {
669
+ if (serviceProxyListenTarget) {
670
+ const boundServiceProxyTarget = await serviceProxy.startStandalone({
671
+ listenTarget: serviceProxyListenTarget,
672
+ });
673
+ serviceProxyListenTarget = boundServiceProxyTarget;
674
+ logger.info({
675
+ listen: formatListenTarget(serviceProxyListenTarget),
676
+ publicBaseUrl: serviceProxyPublicBaseUrl,
677
+ elapsed: elapsed(),
678
+ }, "Service proxy listening");
679
+ }
680
+ // Start main HTTP server
681
+ await new Promise((resolve, reject) => {
682
+ const onError = (err) => {
683
+ httpServer.off("listening", onListening);
684
+ reject(err);
685
+ };
686
+ const onListening = () => {
687
+ httpServer.off("error", onError);
688
+ mainStarted = true;
689
+ const logAndResolve = async () => {
690
+ boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
691
+ const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
692
+ agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
693
+ agentManager.setMcpBaseUrl(agentMcpBaseUrl);
694
+ daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
695
+ agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
696
+ });
697
+ daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
698
+ agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
699
+ });
700
+ const relayEnabled = config.relayEnabled ?? true;
701
+ const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
702
+ const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
703
+ const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
704
+ const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
705
+ const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
706
+ if (boundListenTarget.type === "tcp") {
707
+ logger.info({
708
+ host: boundListenTarget.host,
709
+ port: boundListenTarget.port,
710
+ authRequired: !!config.auth?.password,
711
+ elapsed: elapsed(),
712
+ }, `Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`);
703
713
  }
704
- catch (error) {
705
- logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
714
+ else {
715
+ logger.info({
716
+ path: boundListenTarget.path,
717
+ authRequired: !!config.auth?.password,
718
+ elapsed: elapsed(),
719
+ }, `Server listening on ${boundListenTarget.path}`);
706
720
  }
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,
721
+ if (config.auth?.password) {
722
+ logger.info("Daemon password authentication enabled");
723
+ }
724
+ wsServer = new VoiceAssistantWebSocketServer(httpServer, logger, serverId, agentManager, agentStorage, downloadTokenStore, config.paseoHome, daemonConfigStore, mcpBaseUrl, { allowedOrigins, hostnames: configuredHostnames }, config.auth, speechService, terminalManager, {
725
+ finalTimeoutMs: config.dictationFinalTimeoutMs,
726
+ }, daemonVersion, (intent) => {
727
+ try {
728
+ config.onLifecycleIntent?.(intent);
729
+ }
730
+ catch (error) {
731
+ logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
732
+ }
733
+ }, 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, {
734
+ listen: formatListenTarget(boundListenTarget ?? listenTarget),
735
+ worktreesRoot: config.worktreesRoot,
721
736
  relay: {
722
- endpoint: relayPublicEndpoint,
723
- useTls: relayPublicUseTls,
737
+ enabled: relayEnabled,
738
+ endpoint: relayEndpoint,
739
+ publicEndpoint: relayPublicEndpoint,
740
+ useTls: relayUseTls,
741
+ publicUseTls: relayPublicUseTls,
724
742
  },
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
- }
743
+ }, serviceProxyPublicBaseUrl);
744
+ if (relayEnabled) {
745
+ const offer = await createConnectionOfferV2({
746
+ serverId,
747
+ daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
748
+ relay: {
749
+ endpoint: relayPublicEndpoint,
750
+ useTls: relayPublicUseTls,
751
+ },
752
+ });
753
+ encodeOfferToFragmentUrl({ offer, appBaseUrl });
754
+ relayTransport?.stop().catch(() => undefined);
755
+ relayTransport = startRelayTransport({
756
+ logger,
757
+ attachSocket: (ws, metadata) => {
758
+ if (!wsServer) {
759
+ throw new Error("WebSocket server not initialized");
760
+ }
761
+ return wsServer.attachExternalSocket(ws, metadata);
762
+ },
763
+ relayEndpoint,
764
+ relayUseTls,
765
+ serverId,
766
+ daemonKeyPair: daemonKeyPair.keyPair,
767
+ });
768
+ }
769
+ };
770
+ logAndResolve().then(resolve, reject);
742
771
  };
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);
772
+ httpServer.once("error", onError);
773
+ httpServer.once("listening", onListening);
774
+ if (listenTarget.type === "tcp") {
775
+ httpServer.listen(listenTarget.port, listenTarget.host);
753
776
  }
754
- httpServer.listen(listenTarget.path);
777
+ else {
778
+ if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
779
+ unlinkSync(listenTarget.path);
780
+ }
781
+ httpServer.listen(listenTarget.path);
782
+ }
783
+ });
784
+ // Start speech service after listening so synchronous Sherpa native
785
+ // model loading doesn't block the server from accepting connections.
786
+ speechService.start();
787
+ scriptHealthMonitor.start();
788
+ }
789
+ catch (error) {
790
+ await serviceProxy.stopStandalone().catch(() => undefined);
791
+ if (mainStarted) {
792
+ httpServer.closeAllConnections();
793
+ await new Promise((resolve) => httpServer.close(() => resolve()));
755
794
  }
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();
795
+ throw error;
796
+ }
761
797
  };
762
798
  const stop = async () => {
763
799
  scriptHealthMonitor.stop();
@@ -773,6 +809,7 @@ export async function createPaseoDaemon(config, rootLogger) {
773
809
  if (wsServer) {
774
810
  await wsServer.close();
775
811
  }
812
+ await serviceProxy.stopStandalone();
776
813
  // Force-drop remaining sockets so httpServer.close() resolves promptly.
777
814
  // We've already closed wsServer (which sent ws-layer close frames) and
778
815
  // stopped every other service, so anything still attached is a TCP
@@ -794,7 +831,7 @@ export async function createPaseoDaemon(config, rootLogger) {
794
831
  agentManager,
795
832
  agentStorage,
796
833
  terminalManager,
797
- scriptRouteStore,
834
+ serviceProxy,
798
835
  scriptRuntimeStore,
799
836
  start,
800
837
  stop,
@@ -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,