Skip to content

feat(desktop): launchd-based process architecture#405

Merged
lefarcen merged 59 commits intomainfrom
refactor/launchd-process-architecture
Mar 24, 2026
Merged

feat(desktop): launchd-based process architecture#405
lefarcen merged 59 commits intomainfrom
refactor/launchd-process-architecture

Conversation

@lefarcen
Copy link
Copy Markdown
Collaborator

@lefarcen lefarcen commented Mar 23, 2026

Summary

Migrate desktop process management from Electron child-process tree to macOS launchd LaunchAgents. Services (Controller, OpenClaw) run as independent system-managed processes with automatic crash recovery.

Process Management

  • Controller and OpenClaw as independent LaunchAgents with KeepAlive crash recovery
  • Background mode: close window without stopping services, reopen from Dock
  • Localized quit dialog (zh-CN / en): Quit Completely / Run in Background / Cancel

Startup Performance

  • Cold start reduced from ~22s to ~7s
  • Parallel service bootstrap (Controller + OpenClaw + Web Server)
  • Adaptive WS reconnect (500ms→4s cap, health-triggered immediate retry)
  • Parallel controller bootstrap prep, removed redundant config compilation

Startup UX

  • 4-color Nexu logo splash animation (matching design system prototype)
  • Seamless splash → UI transition (no blank frames, no spinner)
  • "Starting" status during boot instead of scary "Offline"
  • Channels show "Connecting" during startup instead of "Disconnected"

Status Display

  • New starting RuntimeStatus with bootPhase lifecycle
  • Friendly channel error labels: "Reconnect required" for session expired
  • Health-aware channel status during gateway startup

Packaged App

  • Default to launchd on macOS for packaged builds
  • OpenClaw sidecar tar extraction before launchd bootstrap
  • Gateway auth token passed to controller plist
  • Mock better-auth session in embedded web server

Dev Workflow

  • pnpm start / pnpm stop / pnpm restart for launchd mode
  • pnpm --filter @nexu/desktop dev for orchestrator mode (vite HMR)
  • Auto-build controller + web on start
  • Guard against missing dist/index.html

Closes #442
Related to #400, #401

Test plan

  • pnpm typecheck passes
  • pnpm test passes
  • pnpm start → services start, UI shows Starting → Running
  • pnpm stop → all services cleanly stopped
  • pnpm restart → clean restart cycle
  • Packaged build: cold start, quit dialog, background mode
  • Channel status: Starting → Connecting → Connected
  • Nexu logo splash → seamless transition to UI

🤖 Generated with Claude Code

lefarcen and others added 5 commits March 23, 2026 16:10
Replace reserved close code 1008 (Policy Violation) with private code
4008 when closing WebSocket connections on error. Code 1008 is reserved
for server use and Node.js 22's native WebSocket throws
DOMException [InvalidAccessError] when clients attempt to use it,
causing the controller process to crash on authentication failures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Cherry-pick WebSocket close code fix from PR #365
- Change launchd namespace from com.nexu.* to io.nexu.*
- Add progress tracking directory with STATUS, DECISIONS, ISSUES

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Phase 1-3 of launchd architecture refactor:

- LaunchdManager: wrapper for launchctl commands (install, start,
  stop, status, graceful shutdown)
- PlistGenerator: generates launchd plist XML for Controller and
  OpenClaw services with proper env vars and dependencies
- EmbeddedWebServer: serves static files and proxies API requests
  to Controller, replacing the web sidecar process

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- launchd-bootstrap.ts: complete bootstrap flow for launchd-based
  startup (install services, start controller, start openclaw,
  start embedded web server)
- Feature flag NEXU_USE_LAUNCHD=1 for gradual rollout
- Unified log directory at ~/.nexu/logs/
- Path resolution for packaged vs dev environments
- Index file exporting all services

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- quit-handler.ts: handles before-quit event with dialog
- Options: Quit Completely (stop services), Run in Background, Cancel
- Graceful shutdown of launchd services
- Exported via services/index.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a macOS launchd-based service stack and tooling to the Desktop app (LaunchdManager, plist generation, bootstrap orchestration, embedded HTTP server, quit integration, dev script, tests, and docs), plus controller runtime changes (new "starting" status, WebSocket close-code tweak, OpenClaw config defaults).

Changes

Cohort / File(s) Summary
WebSocket Client
apps/controller/src/runtime/openclaw-ws-client.ts
Changed handshake WebSocket close code from 10084008 for missing nonce, connect timeout, and connect failure; reason strings unchanged.
Controller Runtime & API
apps/controller/src/runtime/state.ts, apps/controller/src/runtime/loops.ts, apps/controller/src/runtime/openclaw-process.ts, apps/controller/src/runtime/openclaw-config-compiler.ts, apps/controller/src/routes/desktop-routes.ts, apps/controller/openapi.json
Added "starting" runtime status (type, initial state, severity mapping); adaptive controller readiness backoff; finer gateway-health distinctions; added OpenClawProcessManager.isAlive(); minor OpenClaw config defaults; API/schema updated to include "starting".
Embedded HTTP Server
apps/desktop/main/services/embedded-web-server.ts
New Electron main-process HTTP server: proxies /api//v1//openapi.json to controller (headers/body streaming, 502 on proxy error), serves SPA static files with index.html fallback, sets MIME and Content-Length, and exposes start/close API.
Launchd Manager & Plist Gen
apps/desktop/main/services/launchd-manager.ts, apps/desktop/main/services/plist-generator.ts
New macOS-only LaunchdManager for install/uninstall/bootstrap/start/stop/restart/graceful stop/status checks; SERVICE_LABELS; plist XML generator for controller/openclaw with env, paths, logging, XML-escaping.
Bootstrap Orchestration & Barrel
apps/desktop/main/services/launchd-bootstrap.ts, apps/desktop/main/services/index.ts
New bootstrap flow to ensure/install controller and OpenClaw via launchd, start embedded web server, poll controller readiness; added services barrel exports.
Quit Handling
apps/desktop/main/services/quit-handler.ts
Electron quit integration: synchronous quit dialog, before-quit handler coordinating web server shutdown and selective service stopping (run-in-background vs quit-completely), plus programmatic quit helper.
App Integration
apps/desktop/main/index.ts
Wires launchd bootstrap behind NEXU_USE_LAUNCHD, adds runLaunchdColdStart(), preserves legacy path when disabled, adjusts controller readiness backoff, and defers before-quit handling to launchd quit handler when used.
Dev Tooling
scripts/dev-launchd.sh
New dev helper script (start
Tests
tests/desktop/launchd-manager.test.ts, tests/desktop/plist-generator.test.ts
New Vitest suites mocking os/fs/child_process to validate LaunchdManager behavior, SERVICE_LABELS, plist XML contents, escaping, and log path construction.
Static Plugin
apps/controller/static/runtime-plugins/nexu-platform-bootstrap/index.js
Updated plugin.register signature to accept api, added TOOL_PROGRESS_PROMPT and prepended system context on before_prompt_build.
Web UI Types & Pages
apps/web/lib/api/types.gen.ts, apps/web/src/pages/home.tsx
Added "starting" to generated API types and UI handling for runtime.status === "starting" (warning/pulsing labels and subtitle).
Design Docs & Progress
specs/designs/launchd-process-architecture/*
Docs updated to io.nexu.* namespace; added DECISIONS/ISSUES/STATUS progress artifacts describing label namespace, feature-flag, and bootstrap module decisions.

Sequence Diagram(s)

sequenceDiagram
    participant Electron as Electron App
    participant Bootstrap as LaunchdBootstrap
    participant LaunchdMgr as LaunchdManager
    participant launchd as launchd / launchctl
    participant Controller as Controller Service
    participant WebServer as Embedded Web Server
    participant OpenClaw as OpenClaw Service
    participant User as User

    Electron->>Bootstrap: bootstrapWithLaunchd(env)
    Bootstrap->>LaunchdMgr: ensure/install plists & services
    LaunchdMgr->>launchd: bootstrap controller plist
    launchd-->>Controller: start controller
    Controller-->>LaunchdMgr: reported running
    Bootstrap->>WebServer: startEmbeddedWebServer(opts)
    WebServer->>Controller: proxy health request
    Controller-->>WebServer: 200 OK
    WebServer-->>Bootstrap: controller healthy
    LaunchdMgr->>launchd: bootstrap openclaw plist
    launchd-->>OpenClaw: start openclaw
    OpenClaw-->>LaunchdMgr: reported running
    User->>Electron: request quit
    Electron->>Electron: showQuitDialog()
    User-->>Electron: "quit-completely"
    Electron->>WebServer: close()
    Electron->>LaunchdMgr: stopAllServices()
    LaunchdMgr->>launchd: stop openclaw, then controller
    launchd-->>OpenClaw: stopped
    launchd-->>Controller: stopped
    Electron->>Electron: app.quit()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • mrcfps
  • nettee

Poem

🐰 Hops through plists and launchd light,

Services wake and logs take flight,
A webserver hums and controller sings,
Quit or background—two gentle things,
Rabbit claps: the desktop springs!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(desktop): launchd-based process architecture' clearly and concisely summarizes the main objective of the pull request: implementing launchd-based process management for the Desktop application.
Description check ✅ Passed The pull request description comprehensively covers all required sections with clear details on process management, startup performance, UX improvements, status displays, and testing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/launchd-process-architecture

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

lefarcen and others added 3 commits March 23, 2026 16:19
scripts/dev-launchd.sh provides:
- start: generate plists, bootstrap and start services
- stop: gracefully stop services
- restart: stop then start
- status: show launchd service status
- logs: tail all log files

Uses io.nexu.*.dev labels and ~/.nexu/logs/ for logging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add launchd service imports
- Add runLaunchdColdStart function that uses bootstrapWithLaunchd
- Check NEXU_USE_LAUNCHD=1 flag to choose bootstrap mode
- Install launchd quit handler after successful launchd bootstrap
- Modify before-quit handler to skip orchestrator cleanup in launchd mode
- Derive openclaw paths from nexuHome config

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- launchd-manager.test.ts: tests for LaunchdManager class and SERVICE_LABELS
- plist-generator.test.ts: tests for generatePlist function

Tests cover:
- Platform check (darwin only)
- Default and custom plist directories
- UID-based domain construction
- Dev vs prod label generation
- Plist XML generation with correct structure
- XML character escaping
- Log path configuration
- Service dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (6)
apps/desktop/main/services/launchd-manager.ts (1)

54-60: Consider structured logging for service operations.

The current implementation uses plain console.log/warn/error calls. Per coding guidelines, logging should be structured (pino or console JSON) for better observability and debugging of launchd operations.

This is a good-to-have improvement that could be addressed in a follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/services/launchd-manager.ts` around lines 54 - 60, Replace
the plain console calls in the launchd bootstrap block so logging is structured
(use the project's pino/logger) rather than console.log/warn/error: when stdout
is present call logger.info with an object containing { event:
'launchd.bootstrap', label, stdout }, when stderr call logger.warn with { event:
'launchd.bootstrap_warning', label, stderr }, and in the catch block call
logger.error with { event: 'launchd.bootstrap_failed', label, error: err
instanceof Error ? err.message : String(err), stack: err instanceof Error ?
err.stack : undefined } so the label, stdout/stderr and error details are
captured as structured fields instead of plain strings; update imports/usages
accordingly where label, stdout, stderr and err are referenced.
scripts/dev-launchd.sh (1)

171-177: Unused loop variable i.

The loop counter i is never used. Use _ to indicate an intentionally unused variable.

📝 Suggested fix
-  for i in {1..30}; do
+  for _ in {1..30}; do
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/dev-launchd.sh` around lines 171 - 177, The for-loop in
scripts/dev-launchd.sh uses an unused loop variable `i`; change the loop header
from `for i in {1..30}` to use a placeholder like `for _ in {1..30}` to indicate
the counter is intentionally unused, leaving the curl readiness check (`curl -s
"http://127.0.0.1:$CONTROLLER_PORT/api/auth/get-session"`) and the break/sleep
logic unchanged.
apps/desktop/main/services/launchd-bootstrap.ts (2)

212-219: process.cwd() may be unreliable in dev mode.

In development, process.cwd() depends on where Electron was launched from, which could vary. Consider using __dirname or a more explicit path anchor.

♻️ Suggested improvement
 export function getDefaultPlistDir(isDev: boolean): string {
   if (isDev) {
     // Dev mode: use repo-local directory
-    return path.join(process.cwd(), ".tmp", "launchd");
+    // Use app.getAppPath() or require.main?.path for more reliable anchoring
+    return path.join(process.cwd(), ".tmp", "launchd");
   }
   // Production: use standard LaunchAgents directory
   return path.join(os.homedir(), "Library", "LaunchAgents");
 }

This is acceptable for now since it's dev-only and documented behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/services/launchd-bootstrap.ts` around lines 212 - 219,
getDefaultPlistDir uses process.cwd() which can vary in dev; change it to use a
stable module-based anchor (e.g., __dirname) or another explicit repo-root
resolver so the dev .tmp/launchd path is deterministic. Locate
getDefaultPlistDir and replace the process.cwd() usage with a directory derived
from __dirname (or a project-root helper) to construct
path.join(<stable-dev-root>, ".tmp", "launchd") while leaving the production
branch unchanged.

250-271: process.cwd() in development path resolution has the same caveat.

Same concern as getDefaultPlistDir—works but depends on launch context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/services/launchd-bootstrap.ts` around lines 250 - 271, The
code uses process.cwd() to set repoRoot (in the launchd-bootstrap.ts path bundle
that builds
nodePath/controllerEntryPath/openclawPath/controllerCwd/openclawCwd), which is
fragile depending on launch context; change repoRoot to be derived from the
source file location (e.g., compute it relative to this module using __dirname
or import.meta.url and path.resolve up to the repo root) instead of
process.cwd(), and update any related logic that relies on repoRoot
(controllerEntryPath, openclawPath, controllerCwd, openclawCwd) so paths remain
correct regardless of working directory—mirror the same fix you applied to
getDefaultPlistDir.
apps/desktop/main/services/quit-handler.ts (1)

113-116: removeAllListeners("before-quit") may remove unrelated handlers.

If other parts of the app register before-quit listeners, this will remove them too. Consider tracking and removing only the handler installed by this module.

♻️ Safer alternative
+  const handler = async (event: Electron.Event) => {
+    // ... handler body ...
+  };
+
-  app.on("before-quit", async (event) => {
+  app.on("before-quit", handler);
     // ... existing code ...
-    app.removeAllListeners("before-quit");
+    app.removeListener("before-quit", handler);
     app.quit();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/services/quit-handler.ts` around lines 113 - 116, Currently
the code calls app.removeAllListeners("before-quit"), which can remove unrelated
listeners; instead keep a reference to the listener this module adds (e.g., the
function currently registered in this file, locate the listener passed to
app.on("before-quit") or the enclosing function in quit-handler.ts) and call
app.removeListener("before-quit", yourListener) (or app.off) before calling
app.quit(); update the registration to assign the listener to a const (e.g.,
quitHandler) and use that symbol when removing so only this module's handler is
removed.
apps/desktop/main/services/embedded-web-server.ts (1)

17-32: Consider adding .map and .webmanifest MIME types.

Common web app assets that may be missing:

♻️ Suggested additions
 const MIME_TYPES: Record<string, string> = {
   ".html": "text/html",
   ".js": "application/javascript",
   ".css": "text/css",
   ".json": "application/json",
   ".png": "image/png",
   ".jpg": "image/jpeg",
   ".jpeg": "image/jpeg",
   ".gif": "image/gif",
   ".svg": "image/svg+xml",
   ".ico": "image/x-icon",
   ".woff": "font/woff",
   ".woff2": "font/woff2",
   ".ttf": "font/ttf",
   ".eot": "application/vnd.ms-fontobject",
+  ".map": "application/json",
+  ".webmanifest": "application/manifest+json",
+  ".webp": "image/webp",
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/services/embedded-web-server.ts` around lines 17 - 32, The
MIME_TYPES map is missing handlers for source maps and web manifests; update the
MIME_TYPES constant to include ".map" with "application/json" (or
"application/json; charset=utf-8") and ".webmanifest" with
"application/manifest+json" so the embedded web server serves .map and
.webmanifest files with correct content-types (update the MIME_TYPES
Record<string,string> in embedded-web-server.ts).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/main/services/embedded-web-server.ts`:
- Around line 131-147: The code currently joins webRoot with the unsanitized
url.pathname (in the static file handling block) which allows path traversal;
update the logic in the handler that calculates filePath (the section using
path.join(webRoot, url.pathname), fileExists and stat) to first normalize and
sanitize the pathname (e.g. decode URL, remove/nullify any leading "/" and
reject or strip segments containing ".."), then use
path.resolve(path.join(webRoot, sanitizedPath)) and verify the resolved path is
inside webRoot (e.g. by checking that
resolvedPath.startsWith(path.resolve(webRoot) + path.sep) or using path.relative
and ensuring it does not start with ".."); only after that perform
fileExists/stat and fallback to index.html if the path is invalid, non-existent,
or a directory.

In `@apps/desktop/main/services/launchd-manager.ts`:
- Around line 108-134: The catch in stopServiceGracefully currently swallows all
errors from stopService; update it to only treat the "service not running" case
as a no-op and rethrow or surface other errors: in stopServiceGracefully call
stopService(label) inside try/catch, inspect the caught error (e.g., check for a
specific error class or message indicating "not running" from stopService),
return only for that case, and otherwise rethrow or log and throw the original
error so permission/validation errors aren’t silently ignored; reference
stopServiceGracefully and stopService to locate and implement the conditional
error handling.

In `@apps/desktop/main/services/quit-handler.ts`:
- Around line 122-140: The quitWithDecision function currently calls
opts.launchd.stopServiceGracefully (and opts.webServer.close) without catching
errors, so any throw can prevent app.quit; update quitWithDecision to mirror
installLaunchdQuitHandler by wrapping stopServiceGracefully calls in try/catch
(and webServer.close if desired), log or handle errors via
opts.logger/processLogger, and ensure app.quit() is always called (use a finally
block or call app.quit() after catching errors) so the app cannot be left
running in an inconsistent state; reference symbols: quitWithDecision,
opts.launchd.stopServiceGracefully, opts.webServer.close, app.quit, and
installLaunchdQuitHandler for how errors are handled.

In `@scripts/dev-launchd.sh`:
- Around line 14-15: LOG_DIR is pointing to $HOME/.nexu/logs which conflicts
with the design doc; change it to use the repository-local path by setting
LOG_DIR="$REPO_ROOT/.tmp/logs" (matching PLIST_DIR's use of REPO_ROOT), and
ensure any code that creates or writes logs uses LOG_DIR so logs are created
under .tmp/logs inside the repo; update any mkdir or file-write commands that
reference the old path to use the LOG_DIR variable instead.
- Around line 67-70: Update the StandardErrorPath filenames to match the design
docs by changing the controller and openclaw error log names from
"$LOG_DIR/controller.error.log" and "$LOG_DIR/openclaw.error.log" to
"$LOG_DIR/controller.err" and "$LOG_DIR/openclaw.err" respectively; modify the
XML entries that use the StandardErrorPath key (the block around
StandardOutPath/StandardErrorPath in this script) and apply the same change for
openclaw (the analogous block near the openclaw section).
- Around line 36-41: The cleanup() function (which kills the launchctl jobs for
"$DOMAIN/$OPENCLAW_LABEL" and "$DOMAIN/$CONTROLLER_LABEL") is never wired to
signals; add a trap to invoke cleanup on EXIT, SIGINT and SIGTERM so the
services are stopped when the script exits or is interrupted, or if keeping
services running is intentional, remove the unused cleanup() to avoid confusion;
ensure the trap references the existing cleanup symbol and covers at least EXIT,
SIGINT and SIGTERM.
- Line 28: The script currently sets NODE_PATH="${NODE_PATH:-$(which node)}",
which conflicts with Node.js's NODE_PATH; rename this variable to NODE_BIN (e.g.
NODE_BIN="${NODE_BIN:-$(which node)}") and update every usage of NODE_PATH in
the script and in the plist generation code to use NODE_BIN instead so spawned
processes receive the node binary path without affecting module resolution;
search for all references to NODE_PATH and replace them with NODE_BIN (including
any string interpolations passed into generated plist content).

In `@specs/designs/launchd-process-architecture/progress/STATUS.md`:
- Around line 52-67: Update the checklist entries that reference file paths with
the incorrect "/src/" segment: replace
`apps/desktop/src/main/services/launchd-manager.ts`,
`apps/desktop/src/main/services/plist-generator.ts`, and
`apps/desktop/src/main/embedded-web-server.ts` with the actual implementation
paths that omit "/src/" (i.e., `apps/desktop/main/...`) so the TODOs match the
real filenames and locations used by LaunchdManager, plist-generator, and
embedded-web-server.

---

Nitpick comments:
In `@apps/desktop/main/services/embedded-web-server.ts`:
- Around line 17-32: The MIME_TYPES map is missing handlers for source maps and
web manifests; update the MIME_TYPES constant to include ".map" with
"application/json" (or "application/json; charset=utf-8") and ".webmanifest"
with "application/manifest+json" so the embedded web server serves .map and
.webmanifest files with correct content-types (update the MIME_TYPES
Record<string,string> in embedded-web-server.ts).

In `@apps/desktop/main/services/launchd-bootstrap.ts`:
- Around line 212-219: getDefaultPlistDir uses process.cwd() which can vary in
dev; change it to use a stable module-based anchor (e.g., __dirname) or another
explicit repo-root resolver so the dev .tmp/launchd path is deterministic.
Locate getDefaultPlistDir and replace the process.cwd() usage with a directory
derived from __dirname (or a project-root helper) to construct
path.join(<stable-dev-root>, ".tmp", "launchd") while leaving the production
branch unchanged.
- Around line 250-271: The code uses process.cwd() to set repoRoot (in the
launchd-bootstrap.ts path bundle that builds
nodePath/controllerEntryPath/openclawPath/controllerCwd/openclawCwd), which is
fragile depending on launch context; change repoRoot to be derived from the
source file location (e.g., compute it relative to this module using __dirname
or import.meta.url and path.resolve up to the repo root) instead of
process.cwd(), and update any related logic that relies on repoRoot
(controllerEntryPath, openclawPath, controllerCwd, openclawCwd) so paths remain
correct regardless of working directory—mirror the same fix you applied to
getDefaultPlistDir.

In `@apps/desktop/main/services/launchd-manager.ts`:
- Around line 54-60: Replace the plain console calls in the launchd bootstrap
block so logging is structured (use the project's pino/logger) rather than
console.log/warn/error: when stdout is present call logger.info with an object
containing { event: 'launchd.bootstrap', label, stdout }, when stderr call
logger.warn with { event: 'launchd.bootstrap_warning', label, stderr }, and in
the catch block call logger.error with { event: 'launchd.bootstrap_failed',
label, error: err instanceof Error ? err.message : String(err), stack: err
instanceof Error ? err.stack : undefined } so the label, stdout/stderr and error
details are captured as structured fields instead of plain strings; update
imports/usages accordingly where label, stdout, stderr and err are referenced.

In `@apps/desktop/main/services/quit-handler.ts`:
- Around line 113-116: Currently the code calls
app.removeAllListeners("before-quit"), which can remove unrelated listeners;
instead keep a reference to the listener this module adds (e.g., the function
currently registered in this file, locate the listener passed to
app.on("before-quit") or the enclosing function in quit-handler.ts) and call
app.removeListener("before-quit", yourListener) (or app.off) before calling
app.quit(); update the registration to assign the listener to a const (e.g.,
quitHandler) and use that symbol when removing so only this module's handler is
removed.

In `@scripts/dev-launchd.sh`:
- Around line 171-177: The for-loop in scripts/dev-launchd.sh uses an unused
loop variable `i`; change the loop header from `for i in {1..30}` to use a
placeholder like `for _ in {1..30}` to indicate the counter is intentionally
unused, leaving the curl readiness check (`curl -s
"http://127.0.0.1:$CONTROLLER_PORT/api/auth/get-session"`) and the break/sleep
logic unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ae45104-39c8-428b-b6c4-4b55c1adee11

📥 Commits

Reviewing files that changed from the base of the PR and between fbcfe37 and 90d1a7a.

📒 Files selected for processing (14)
  • apps/controller/src/runtime/openclaw-ws-client.ts
  • apps/desktop/main/services/embedded-web-server.ts
  • apps/desktop/main/services/index.ts
  • apps/desktop/main/services/launchd-bootstrap.ts
  • apps/desktop/main/services/launchd-manager.ts
  • apps/desktop/main/services/plist-generator.ts
  • apps/desktop/main/services/quit-handler.ts
  • scripts/dev-launchd.sh
  • specs/designs/launchd-process-architecture/en.md
  • specs/designs/launchd-process-architecture/progress/DECISIONS.md
  • specs/designs/launchd-process-architecture/progress/ISSUES.md
  • specs/designs/launchd-process-architecture/progress/README.md
  • specs/designs/launchd-process-architecture/progress/STATUS.md
  • specs/designs/launchd-process-architecture/zh.md

Comment thread apps/desktop/main/services/embedded-web-server.ts Outdated
Comment thread apps/desktop/main/services/launchd-manager.ts
Comment thread apps/desktop/main/services/quit-handler.ts
Comment thread scripts/dev-launchd.sh
Comment thread scripts/dev-launchd.sh Outdated
Comment thread scripts/dev-launchd.sh Outdated
Comment thread scripts/dev-launchd.sh Outdated
Comment thread specs/designs/launchd-process-architecture/progress/STATUS.md Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/main/index.ts`:
- Around line 848-861: The launchd path registers a second app "before-quit" and
can run cleanup out of order (and miss installation on bootstrap failure); fix
by consolidating to a single authoritative before-quit flow: stop adding another
app.on("before-quit") inside installLaunchdQuitHandler and instead have
installLaunchdQuitHandler register cleanup callbacks with the existing global
before-quit handler (or expose a registerLaunchdQuitCallback API); also ensure
installLaunchdQuitHandler is invoked reliably (either only after auth/bootstrap
fully succeeds or by setting a launchd-managed flag early and always registering
the centralized handler) so that the cleanup using sleepGuard.dispose,
diagnosticsReporter.flushNow and flushRuntimeLoggers executes only from the
single global before-quit path.
- Around line 843-849: The launchd branch is only gated by
isLaunchdBootstrapEnabled(), so on non-macOS machines NEXU_USE_LAUNCHD=1 will
try runLaunchdColdStart(); change the gating to require both
isLaunchdBootstrapEnabled() and a macOS platform check (process.platform ===
'darwin') before calling runLaunchdColdStart(), update the logColdStart message
accordingly or keep it based on the combined boolean, and ensure
runLaunchdColdStart() is never invoked on Linux/Windows.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 659ff481-34ea-4a92-a43f-dec183607aef

📥 Commits

Reviewing files that changed from the base of the PR and between 90d1a7a and de0a27e.

📒 Files selected for processing (4)
  • apps/desktop/main/index.ts
  • specs/designs/launchd-process-architecture/progress/STATUS.md
  • tests/desktop/launchd-manager.test.ts
  • tests/desktop/plist-generator.test.ts
✅ Files skipped from review due to trivial changes (1)
  • specs/designs/launchd-process-architecture/progress/STATUS.md

Comment thread apps/desktop/main/index.ts
Comment thread apps/desktop/main/index.ts Outdated
- Fix OpenClaw config paths to match controller defaults in env.ts
  (OPENCLAW_STATE_DIR=~/.nexu/runtime/openclaw/state)
- Add `gateway` subcommand to OpenClaw plist generation
- Use OPENCLAW_CONFIG_PATH env var instead of --config argument
- Add --auth none for dev mode to simplify local development
- Update tests to verify OPENCLAW_CONFIG_PATH env var presence

Tested with ./scripts/dev-launchd.sh - Controller and OpenClaw
WebSocket connection verified working.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/main/index.ts`:
- Around line 856-861: Duplicate cleanup is running both in the onBeforeQuit
callback passed to installLaunchdQuitHandler and in the global 'before-quit'
handler; remove the double-run by centralizing cleanup in one place: either move
sleepGuard.dispose("launchd-quit"), await diagnosticsReporter?.flushNow(), and
flushRuntimeLoggers() exclusively into the installLaunchdQuitHandler's
onBeforeQuit callback, or keep them only in the global 'before-quit' handler and
wrap that cleanup with if (!launchdResult) (the variable set by
installLaunchdQuitHandler) so the global handler skips cleanup when the launchd
flow handled the quit; update references to sleepGuard.dispose,
diagnosticsReporter.flushNow, flushRuntimeLoggers, installLaunchdQuitHandler,
and the global 'before-quit' handler accordingly.
- Around line 848-862: The issue is that launchdResult may be set by
runLaunchdColdStart()/bootstrapWithLaunchd but if ensureDesktopAuthSession
throws the quit handler never gets installed; fix by installing the launchd quit
handler immediately after bootstrapWithLaunchd/runLaunchdColdStart returns
(i.e., right after launchdResult is assigned and before calling
ensureDesktopAuthSession) using installLaunchdQuitHandler with the same {
launchd: launchdResult.launchd, labels: launchdResult.labels, webServer:
launchdResult.webServer, onBeforeQuit: (...) } block so the handler is present
even if auth fails; alternatively, if you prefer not to install early, ensure
the catch block clears launchdResult = null when the quit handler wasn’t
installed so the before-quit handler won’t assume a handler exists.

In `@scripts/dev-launchd.sh`:
- Around line 222-227: The tail_logs function can call tail with a glob that
fails when no *.log files exist; update tail_logs to defensively check for logs
before calling tail by testing the glob expansion (e.g., set positional params
or an array from "$LOG_DIR"/*.log and verify a file exists) and print a friendly
message or wait/exit if none found; reference the tail_logs function and the
LOG_DIR/*.log glob when making this change.
- Around line 173-181: The loop uses an unused variable and calls curl without a
timeout; change the loop header from using the unused "i" to a throwaway
variable (e.g., "_" in the for loop) to silence SC2034, and add explicit curl
timeout options to the probe (for example --connect-timeout and/or --max-time
and --fail or --silent flags) when calling curl against
"http://127.0.0.1:$CONTROLLER_PORT/api/auth/get-session" so each iteration
cannot hang indefinitely.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3cbe7789-8869-49fe-9e3a-8c665a37e417

📥 Commits

Reviewing files that changed from the base of the PR and between de0a27e and 27b3f5c.

📒 Files selected for processing (5)
  • apps/desktop/main/index.ts
  • apps/desktop/main/services/plist-generator.ts
  • scripts/dev-launchd.sh
  • specs/designs/launchd-process-architecture/progress/STATUS.md
  • tests/desktop/plist-generator.test.ts
✅ Files skipped from review due to trivial changes (2)
  • specs/designs/launchd-process-architecture/progress/STATUS.md
  • tests/desktop/plist-generator.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/main/services/plist-generator.ts

Comment thread apps/desktop/main/index.ts Outdated
Comment thread apps/desktop/main/index.ts Outdated
Comment thread scripts/dev-launchd.sh Outdated
Comment thread scripts/dev-launchd.sh
lefarcen and others added 2 commits March 23, 2026 22:46
… workflow

- Add "starting" RuntimeStatus: when OpenClaw gateway is unreachable but
  process is alive, show "启动中" instead of "已离线"
- Parallelize launchd service install/start + web server (Promise.all)
- Use adaptive readiness polling (50ms→250ms) instead of fixed 250ms
- Fix dev-launchd.sh stop: use bootout directly instead of SIGTERM+bootout
  race with KeepAlive; use SIGKILL for Electron to bypass quit handler
- Dev quit handler keeps services running (run-in-background) so vite HMR
  restarts don't kill launchd services
- Add tool progress prompt to nexu-platform-bootstrap plugin
- Disable humanDelay in config compiler
- Cold start time reduced from ~5s to ~2s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Stop: wait for ports to free after bootout, SIGKILL orphans including
  chrome_crashpad_handler
- Fix resolveLaunchdPaths for packaged mode: OpenClaw is at
  runtime/openclaw/node_modules/openclaw/openclaw.mjs, not
  runtime/openclaw-runtime/openclaw.mjs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (5)
apps/desktop/main/index.ts (3)

857-863: ⚠️ Potential issue | 🟠 Major

Gate launchd bootstrap to macOS.

Line 857 only checks the feature flag. On Linux/Windows, NEXU_USE_LAUNCHD=1 still sends startup through runLaunchdColdStart(), where the launchctl path fails instead of falling back to the orchestrator.

🛠️ Minimal fix
-      const useLaunchd = isLaunchdBootstrapEnabled();
+      const useLaunchd =
+        process.platform === "darwin" && isLaunchdBootstrapEnabled();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/index.ts` around lines 857 - 863, The code currently calls
runLaunchdColdStart() based solely on isLaunchdBootstrapEnabled(); update the
gating logic to also require macOS by checking process.platform === "darwin" (or
an equivalent platform check) before invoking runLaunchdColdStart(), so
non-macOS systems fall back to the orchestrator path; modify the condition
around useLaunchd and the call to runLaunchdColdStart() (and adjust the
logColdStart message if needed) to include the platform check.

511-530: ⚠️ Potential issue | 🟠 Major

Install the launchd quit handler before auth bootstrap can fail.

launchdResult is assigned at Line 511, but installLaunchdQuitHandler() is only reached after runLaunchdColdStart() finishes. If ensureDesktopAuthSession() rejects at Line 530, the global before-quit path still treats the app as launchd-managed, but there is no launchd quit handler to show the dialog or stop services gracefully.

Also applies to: 862-876

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/index.ts` around lines 511 - 530, After obtaining
launchdResult from bootstrapWithLaunchd, call installLaunchdQuitHandler(...)
immediately (using the launchdResult or its relevant fields) before awaiting
ensureDesktopAuthSession so the quit handler is registered even if
ensureDesktopAuthSession rejects; update the runLaunchdColdStart / launchd
startup flow (where launchdResult is created and where ensureDesktopAuthSession
is awaited) to invoke installLaunchdQuitHandler(launchdResult) right after
launchdResult is set and before any awaits that can throw, and apply the same
change in the other launchd startup block that mirrors this flow.

866-875: ⚠️ Potential issue | 🟠 Major

Keep launchd quit on one authoritative before-quit path.

This adds a second handler, but the existing listeners at Lines 920-934 still run first. On a canceled quit, diagnostics are already unsubscribed and sleepGuard/log flushing have already run, so the app stays open with launchd services intact but local runtime cleanup already torn down.

Also applies to: 920-942

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/index.ts` around lines 866 - 875, There are two before-quit
paths: the installLaunchdQuitHandler call is adding an onBeforeQuit that
duplicates teardown (sleepGuard.dispose, diagnosticsReporter.flushNow,
flushRuntimeLoggers) which races with the file's existing centralized
before-quit listeners; remove the onBeforeQuit callback from
installLaunchdQuitHandler and instead invoke the same cleanup
(sleepGuard?.dispose("launchd-quit"); await
diagnosticsReporter?.flushNow().catch(() => undefined); flushRuntimeLoggers();)
from the single authoritative before-quit handler already defined in this file
so launchd teardown always follows the central quit flow.
apps/desktop/main/services/quit-handler.ts (1)

128-145: ⚠️ Potential issue | 🟡 Minor

Make app.quit() best-effort, not conditional on cleanup succeeding.

Lines 132-142 await several shutdown steps without catching. If onBeforeQuit, webServer.close(), or stopServiceGracefully() rejects, quitWithDecision() exits early and never reaches app.quit(), unlike installLaunchdQuitHandler() above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/services/quit-handler.ts` around lines 128 - 145,
quitWithDecision currently awaits opts.onBeforeQuit, opts.webServer.close, and
opts.launchd.stopServiceGracefully calls directly so any rejection prevents
reaching app.quit(); wrap the cleanup calls in a try/catch (or a try/finally) so
failures are caught/logged but do not stop execution, ensuring app.quit() is
always invoked (similar behavior to installLaunchdQuitHandler). Specifically,
handle errors from opts.onBeforeQuit(), opts.webServer.close(), and
opts.launchd.stopServiceGracefully(opts.labels.openclaw/opts.labels.controller)
and then call app.quit() regardless.
scripts/dev-launchd.sh (1)

181-185: ⚠️ Potential issue | 🟡 Minor

Handle the empty-log case before tailing.

On a fresh run, $LOG_DIR may not contain any *.log files yet. Then the glob stays literal and tail -f exits immediately with a missing-file error.

🛠️ Defensive fix
 tail_logs() {
   echo "Tailing logs from $LOG_DIR..."
   echo "(Press Ctrl+C to stop)"
   echo ""
-  tail -f "$LOG_DIR"/*.log
+  local files=("$LOG_DIR"/*.log)
+  if [ ! -e "${files[0]}" ]; then
+    echo "No log files found yet in $LOG_DIR"
+    return 0
+  fi
+  tail -f "${files[@]}"
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/dev-launchd.sh` around lines 181 - 185, The tail_logs function calls
tail -f "$LOG_DIR"/*.log which fails if no .log files exist; update tail_logs to
check for matching log files before invoking tail: test for any files (e.g.
using a glob check like set -- "$LOG_DIR"/*.log && [ -e "$1" ] or ls
"$LOG_DIR"/*.log >/dev/null 2>&1) and if none are found print a friendly "No
logs yet" message (or wait/exit) instead of calling tail; keep references to
tail_logs, LOG_DIR and the tail -f invocation so the check is placed immediately
before that command.
🧹 Nitpick comments (1)
apps/desktop/main/services/quit-handler.ts (1)

80-117: Use the desktop logger here instead of raw console.*.

These shutdown events bypass the unified lifecycle logs and are harder to correlate once the app is packaged. Please route them through the existing runtime logging helper or inject a logger into this service.

As per coding guidelines "Logging must be structured (pino or console JSON) and never log credentials".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/services/quit-handler.ts` around lines 80 - 117, Replace
all raw console.log/console.error calls in quit-handler.ts with the structured
runtime logger used elsewhere: accept or use the injected logger (e.g.,
opts.logger or the runtime logging helper) and call logger.info/logger.error
with the same messages and error objects for the onBeforeQuit, webServer.close
and launchd.stopServiceGracefully blocks (including the "Stopping launchd
services..." / "Keeping services running in background" and the per-label stop
messages). Ensure the logger is present on opts (or import the shared runtime
logger), update calls around opts.onBeforeQuit, opts.webServer.close, and
opts.launchd.stopServiceGracefully to pass the error object as a structured
field (not concatenated strings) and do not log any sensitive data or
credentials.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/controller/src/runtime/loops.ts`:
- Around line 69-84: The gateway health logic currently never triggers restarts
when the process has a PID or when HTTP errors occur; update the branch handling
result.status !== null and the branch where processAlive is true to increment a
failure counter on params.state (e.g., params.state.gatewayFailureCount) and
call params.processManager?.restartForHealth() once that counter exceeds a short
threshold (or after a timeout), while resetting the counter on success; ensure
you update params.state.gatewayStatus and params.state.lastGatewayError as now,
but add/clear gatewayFailureCount so a wedged-but-alive gateway (HTTP 5xx or
never binding) will be restarted instead of staying stuck in
"starting"/"degraded".

In `@apps/controller/src/runtime/openclaw-process.ts`:
- Around line 73-77: The isAlive() method currently falls back to PID 0 when
child.pid is undefined, causing isProcessAlive(0) to probe the wrong process;
update isAlive() to validate child.pid is a defined positive integer (e.g., pid
> 0) before calling isProcessAlive(this.child.pid), and return false if pid is
missing or non-positive so that non-started/spawn-failed children aren't
reported alive; reference the isAlive method, the child property, child.pid, and
isProcessAlive when making the change.

In `@scripts/dev-launchd.sh`:
- Around line 24-26: The script currently hard-codes CONTROLLER_PORT to 50800;
update it to read the controller port dynamically (the same value desktop sets
at runtime) instead of a fixed default so cleanup/status use the real socket.
Replace the fixed CONTROLLER_PORT="${CONTROLLER_PORT:-50800}" with logic that
loads the runtime configuration or an exported environment variable (e.g. parse
runtimeConfig.ports.controller from the runtime config file or accept
CONTROLLER_PORT from the caller) and propagate that value to the locations
mentioned (lines around the CONTROLLER_PORT assignment, the blocks referenced at
64-68 and in full_cleanup()/status handlers at ~150-166). Ensure full_cleanup()
and status reference this dynamic CONTROLLER_PORT variable so they wait/check
the actual controller socket.

---

Duplicate comments:
In `@apps/desktop/main/index.ts`:
- Around line 857-863: The code currently calls runLaunchdColdStart() based
solely on isLaunchdBootstrapEnabled(); update the gating logic to also require
macOS by checking process.platform === "darwin" (or an equivalent platform
check) before invoking runLaunchdColdStart(), so non-macOS systems fall back to
the orchestrator path; modify the condition around useLaunchd and the call to
runLaunchdColdStart() (and adjust the logColdStart message if needed) to include
the platform check.
- Around line 511-530: After obtaining launchdResult from bootstrapWithLaunchd,
call installLaunchdQuitHandler(...) immediately (using the launchdResult or its
relevant fields) before awaiting ensureDesktopAuthSession so the quit handler is
registered even if ensureDesktopAuthSession rejects; update the
runLaunchdColdStart / launchd startup flow (where launchdResult is created and
where ensureDesktopAuthSession is awaited) to invoke
installLaunchdQuitHandler(launchdResult) right after launchdResult is set and
before any awaits that can throw, and apply the same change in the other launchd
startup block that mirrors this flow.
- Around line 866-875: There are two before-quit paths: the
installLaunchdQuitHandler call is adding an onBeforeQuit that duplicates
teardown (sleepGuard.dispose, diagnosticsReporter.flushNow, flushRuntimeLoggers)
which races with the file's existing centralized before-quit listeners; remove
the onBeforeQuit callback from installLaunchdQuitHandler and instead invoke the
same cleanup (sleepGuard?.dispose("launchd-quit"); await
diagnosticsReporter?.flushNow().catch(() => undefined); flushRuntimeLoggers();)
from the single authoritative before-quit handler already defined in this file
so launchd teardown always follows the central quit flow.

In `@apps/desktop/main/services/quit-handler.ts`:
- Around line 128-145: quitWithDecision currently awaits opts.onBeforeQuit,
opts.webServer.close, and opts.launchd.stopServiceGracefully calls directly so
any rejection prevents reaching app.quit(); wrap the cleanup calls in a
try/catch (or a try/finally) so failures are caught/logged but do not stop
execution, ensuring app.quit() is always invoked (similar behavior to
installLaunchdQuitHandler). Specifically, handle errors from
opts.onBeforeQuit(), opts.webServer.close(), and
opts.launchd.stopServiceGracefully(opts.labels.openclaw/opts.labels.controller)
and then call app.quit() regardless.

In `@scripts/dev-launchd.sh`:
- Around line 181-185: The tail_logs function calls tail -f "$LOG_DIR"/*.log
which fails if no .log files exist; update tail_logs to check for matching log
files before invoking tail: test for any files (e.g. using a glob check like set
-- "$LOG_DIR"/*.log && [ -e "$1" ] or ls "$LOG_DIR"/*.log >/dev/null 2>&1) and
if none are found print a friendly "No logs yet" message (or wait/exit) instead
of calling tail; keep references to tail_logs, LOG_DIR and the tail -f
invocation so the check is placed immediately before that command.

---

Nitpick comments:
In `@apps/desktop/main/services/quit-handler.ts`:
- Around line 80-117: Replace all raw console.log/console.error calls in
quit-handler.ts with the structured runtime logger used elsewhere: accept or use
the injected logger (e.g., opts.logger or the runtime logging helper) and call
logger.info/logger.error with the same messages and error objects for the
onBeforeQuit, webServer.close and launchd.stopServiceGracefully blocks
(including the "Stopping launchd services..." / "Keeping services running in
background" and the per-label stop messages). Ensure the logger is present on
opts (or import the shared runtime logger), update calls around
opts.onBeforeQuit, opts.webServer.close, and opts.launchd.stopServiceGracefully
to pass the error object as a structured field (not concatenated strings) and do
not log any sensitive data or credentials.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 01cc76f5-b5e7-4346-b077-c2a5ec8d5a18

📥 Commits

Reviewing files that changed from the base of the PR and between 27b3f5c and 987910f.

📒 Files selected for processing (13)
  • apps/controller/openapi.json
  • apps/controller/src/lib/openclaw-config-compiler.ts
  • apps/controller/src/routes/desktop-routes.ts
  • apps/controller/src/runtime/loops.ts
  • apps/controller/src/runtime/openclaw-process.ts
  • apps/controller/src/runtime/state.ts
  • apps/controller/static/runtime-plugins/nexu-platform-bootstrap/index.js
  • apps/desktop/main/index.ts
  • apps/desktop/main/services/launchd-bootstrap.ts
  • apps/desktop/main/services/quit-handler.ts
  • apps/web/lib/api/types.gen.ts
  • apps/web/src/pages/home.tsx
  • scripts/dev-launchd.sh
✅ Files skipped from review due to trivial changes (3)
  • apps/controller/src/lib/openclaw-config-compiler.ts
  • apps/controller/src/routes/desktop-routes.ts
  • apps/controller/openapi.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/main/services/launchd-bootstrap.ts

Comment thread apps/controller/src/runtime/loops.ts
Comment thread apps/controller/src/runtime/openclaw-process.ts Outdated
Comment thread scripts/dev-launchd.sh
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
scripts/dev-launchd.sh (2)

193-198: ⚠️ Potential issue | 🟡 Minor

Guard tail_logs when no log files exist.

Line 197 can fail on first run because "$LOG_DIR"/*.log may not expand to any files.

Suggested fix
 tail_logs() {
   echo "Tailing logs from $LOG_DIR..."
   echo "(Press Ctrl+C to stop)"
   echo ""
-  tail -f "$LOG_DIR"/*.log
+  if compgen -G "$LOG_DIR"/*.log >/dev/null 2>&1; then
+    tail -f "$LOG_DIR"/*.log
+  else
+    echo "No log files found yet in $LOG_DIR"
+    exit 0
+  fi
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/dev-launchd.sh` around lines 193 - 198, The tail_logs function
currently calls tail -f "$LOG_DIR"/*.log which will fail if the glob matches
nothing; update tail_logs (referencing tail_logs and LOG_DIR) to first expand
the glob into an array or check for existence (e.g., files=("$LOG_DIR"/*.log)
and test [ -e "${files[0]}" ] or similar) and only call tail -f with the file
list when at least one log exists, otherwise print a clear "No logs found in
$LOG_DIR" message and return gracefully.

25-26: ⚠️ Potential issue | 🟠 Major

Use the runtime-selected controller port instead of a fixed 50800.

Line 25 hard-codes CONTROLLER_PORT, but launchd bootstrap uses dynamic ports. This can make cleanup/status target the wrong socket (Line 63-66, Line 162-165), causing false “not listening” and incomplete waits.

Suggested fix
-CONTROLLER_PORT="${CONTROLLER_PORT:-50800}"
+# Prefer caller-provided value; otherwise derive from runtime config if available.
+# Fallback only as last resort.
+CONTROLLER_PORT="${CONTROLLER_PORT:-}"
+if [ -z "$CONTROLLER_PORT" ] && [ -f "$HOME/.nexu/runtime/runtime-config.json" ]; then
+  CONTROLLER_PORT="$(sed -nE 's/.*"controller"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' "$HOME/.nexu/runtime/runtime-config.json" | head -1)"
+fi
+CONTROLLER_PORT="${CONTROLLER_PORT:-50800}"

Also applies to: 63-66, 158-166

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/dev-launchd.sh` around lines 25 - 26, The script currently hard-codes
CONTROLLER_PORT to 50800; remove that fixed default so the script uses the
runtime-selected CONTROLLER_PORT provided by launchd (i.e. change
CONTROLLER_PORT="${CONTROLLER_PORT:-50800}" to just use the existing
CONTROLLER_PORT environment value or an empty/default-less expansion), and
ensure all references that check/cleanup sockets (the places using
CONTROLLER_PORT and OPENCLAW_PORT later in the script) use that variable rather
than a fixed port; update any socket checks/cleanup/status logic to read from
CONTROLLER_PORT so they target the dynamic launchd-assigned port.
🧹 Nitpick comments (1)
scripts/dev-launchd.sh (1)

29-35: Remove unused path variables or wire them into plist generation.

Line 29-35 defines values that are never consumed in this script (NODE_PATH, CONTROLLER_ENTRY, OPENCLAW_PATH, OPENCLAW_CONFIG). This is easy to drift and confuses source-of-truth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/dev-launchd.sh` around lines 29 - 35, The variables NODE_PATH,
CONTROLLER_ENTRY, OPENCLAW_PATH, OPENCLAW_STATE_DIR and OPENCLAW_CONFIG are
defined but never used; either remove these unused definitions from the script
or wire them into the launchd plist generation so they become authoritative
values. Locate the assignments for NODE_PATH, CONTROLLER_ENTRY, OPENCLAW_PATH,
OPENCLAW_STATE_DIR and OPENCLAW_CONFIG in scripts/dev-launchd.sh and either (A)
delete those unused lines to avoid drift, or (B) reference them when building
the plist (e.g., populate EnvironmentVariables/<key> or program arguments with
CONTROLLER_ENTRY/OPENCLAW_PATH and set OPENCLAW_CONFIG/OPENCLAW_STATE_DIR where
the runtime expects them) to ensure the script is the single source of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@scripts/dev-launchd.sh`:
- Around line 193-198: The tail_logs function currently calls tail -f
"$LOG_DIR"/*.log which will fail if the glob matches nothing; update tail_logs
(referencing tail_logs and LOG_DIR) to first expand the glob into an array or
check for existence (e.g., files=("$LOG_DIR"/*.log) and test [ -e "${files[0]}"
] or similar) and only call tail -f with the file list when at least one log
exists, otherwise print a clear "No logs found in $LOG_DIR" message and return
gracefully.
- Around line 25-26: The script currently hard-codes CONTROLLER_PORT to 50800;
remove that fixed default so the script uses the runtime-selected
CONTROLLER_PORT provided by launchd (i.e. change
CONTROLLER_PORT="${CONTROLLER_PORT:-50800}" to just use the existing
CONTROLLER_PORT environment value or an empty/default-less expansion), and
ensure all references that check/cleanup sockets (the places using
CONTROLLER_PORT and OPENCLAW_PORT later in the script) use that variable rather
than a fixed port; update any socket checks/cleanup/status logic to read from
CONTROLLER_PORT so they target the dynamic launchd-assigned port.

---

Nitpick comments:
In `@scripts/dev-launchd.sh`:
- Around line 29-35: The variables NODE_PATH, CONTROLLER_ENTRY, OPENCLAW_PATH,
OPENCLAW_STATE_DIR and OPENCLAW_CONFIG are defined but never used; either remove
these unused definitions from the script or wire them into the launchd plist
generation so they become authoritative values. Locate the assignments for
NODE_PATH, CONTROLLER_ENTRY, OPENCLAW_PATH, OPENCLAW_STATE_DIR and
OPENCLAW_CONFIG in scripts/dev-launchd.sh and either (A) delete those unused
lines to avoid drift, or (B) reference them when building the plist (e.g.,
populate EnvironmentVariables/<key> or program arguments with
CONTROLLER_ENTRY/OPENCLAW_PATH and set OPENCLAW_CONFIG/OPENCLAW_STATE_DIR where
the runtime expects them) to ensure the script is the single source of truth.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b4a2183-4cf7-4ab6-91c9-f0f8188ba4fa

📥 Commits

Reviewing files that changed from the base of the PR and between 987910f and 921e560.

📒 Files selected for processing (2)
  • apps/desktop/main/services/launchd-bootstrap.ts
  • scripts/dev-launchd.sh
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/main/services/launchd-bootstrap.ts

lefarcen and others added 12 commits March 23, 2026 23:18
…tartup

When the WebSocket to OpenClaw gateway isn't connected yet (during
startup), channels were shown as "disconnected" (red). Now they show
as "connecting" (yellow pulse) when the runtime is still starting,
giving users a much less alarming startup experience.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add explicit "booting" → "ready" lifecycle to ControllerRuntimeState.
During boot, gateway-unreachable is always treated as "starting" (not
"unhealthy"), regardless of whether the process manager owns the
OpenClaw process (fixes launchd mode where processManager.isAlive()
returns false). Channel live status also uses bootPhase to show
"connecting" during startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The embedded web server serves static files from apps/web/dist, so
code changes to the web app require a build step before starting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bootPhase was set to "ready" immediately after wsClient.connect(),
but the WS handshake hadn't completed yet. Health loop then saw
gateway-unreachable + bootPhase=ready → "unhealthy" → UI showed
"已离线" during startup.

Now bootPhase transitions to "ready" inside the onConnected callback,
so the entire startup shows "starting" → "active" cleanly.

Also adds temporary debug logs to home.tsx for startup diagnostics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Channel error status now shows translated lastError (e.g. "会话已过期"
  instead of generic "错误")
- Controller maps WeChat "not configured" + not running to
  "session expired" for better UX
- Add i18n keys for common channel errors (session expired, not
  configured, disabled)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use warning color (orange) instead of danger (red) for known
recoverable errors like session expired, with actionable label
"请重新连接" / "Reconnect required".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The exponential backoff for OpenClaw WebSocket reconnection could
reach 16s+ during startup, causing the UI to stay in "starting"
state for 20+ seconds. Cap at 4s so retry sequence is 1→2→4→4→4s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…way up

When the health loop detects the gateway HTTP endpoint becomes
reachable, it calls wsClient.retryNow() to cancel the backoff timer
and connect immediately. This eliminates the 4-16s gap between
gateway ready and WS connected during startup.

Also replaces the ugly "Starting local services..." loading screen
with a minimal Nexu logo pulse animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- main.tsx had a duplicate SurfaceFrame with old loading text; replaced
  with Nexu logo pulse animation matching surface-frame.tsx
- dev-launchd.sh now checks for dist/index.html and rebuilds desktop
  if missing, preventing blank screen after accidental dist deletion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace plain loading screen with animated Nexu logo matching the
design system prototype (NexuLoader.tsx). Four quadrants light up
sequentially in brand colors: orange, green, pink, gold.
Pure CSS animation, no framer-motion dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
main.tsx had its own SurfaceFrame copy with the old loading screen.
Now imports from components/surface-frame.tsx so both Runtime Console
and Desktop Shell views use the same 4-color Nexu loader.

Background updated to dark radial gradient matching desktop theme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
lefarcen and others added 13 commits March 24, 2026 14:01
…urface

- Move webview load event listener into the ref callback to avoid race
  where dom-ready fires before useEffect binds. Use did-finish-load
  (fires after navigation complete) instead of dom-ready.
- Default activeSurface to "web" in both dev and packaged mode so the
  Nexu loader and web UI are visible immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Launchd mode uses an embedded web server (no web sidecar process),
but better-auth's /api/auth/get-session endpoint was missing, causing
AuthLayout to block rendering. Add a mock desktop session response
so the web app proceeds past auth in desktop local mode.

Also removes temporary DevTools debug code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In dev mode, load the renderer from vite dev server (VITE_DEV_SERVER_URL)
instead of pre-built dist/index.html. This fixes blank pages after
vite HMR restarts Electron, since the dev server always serves the
latest code. Production still uses loadFile for the static dist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When renderer loads from vite dev server (localhost:5180), fetch to
embedded web server (127.0.0.1:50810) is cross-origin. Add CORS
headers to allow dev mode requests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dev mode was hiding the window on close (for vite HMR), but this
made Dock right-click quit feel broken. Now dev mode lets the
window close normally — services are managed by pnpm stop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add trap to dev-launchd.sh so launchd services are cleaned up when
Electron quits, Ctrl+C is pressed, or the script exits for any reason.
Previously services stayed running after Dock quit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The before-quit handler was calling preventDefault() even in dev mode,
preventing Dock right-click quit from working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add aria-label to loader SVG to fix biome a11y/noSvgWithoutTitle
- Use pnpm build (all packages) instead of individual filter builds
  in dev-launchd.sh to ensure @nexu/shared builds before web

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Development progress files no longer needed after implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI runners may not support launchd properly. Now launchd is only
enabled via explicit NEXU_USE_LAUNCHD=1 (dev scripts) or in
packaged macOS apps. CI and plain `pnpm dev` use orchestrator mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pnpm start must be non-blocking for CI compatibility (desktop-check-dev.sh).
Restore dev.sh (tmux orchestrator) as default. Launchd mode available via:
- pnpm start:launchd / stop:launchd / restart:launchd

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Set NEXU_USE_LAUNCHD=0 so desktop-ci uses the tmux orchestrator
mode which is non-blocking and CI-compatible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove vite dev server loadURL (causes CORS + preload issues)
- dev-launchd.sh launches electron directly after build (no vite watch)
- Restore loadFile for all modes (file:// protocol, no CORS)
- CI desktop-check uses dev.sh directly (non-blocking tmux mode)
- package.json: pnpm start uses dev-launchd.sh (launchd mode)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lefarcen lefarcen merged commit 9beeb87 into main Mar 24, 2026
7 checks passed
lefarcen added a commit that referenced this pull request Mar 24, 2026
Resolve conflicts by keeping our branch's versions (all changes
are incremental improvements after #405).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]Version 0.1.5 nightly fails to start (White Screen / Blank Page)

3 participants