port desktop app to Effect#2546

merged44
main
t3code/a1238be3
openedMay 6, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 20d ago

Summary

  • Reworked desktop backend readiness probing around Effect-based services and effects, with temporary promise shims preserved for existing call sites.
  • Added Effect/Vitest coverage for readiness polling, request timeouts, abort handling, and backend startup race behavior.
  • Updated the desktop main process to use typed URL handling for backend endpoints and the new readiness error type.

Testing

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run test for apps/desktop/src/backendReadiness.test.ts and apps/desktop/src/backendStartupReadiness.test.ts

Note

High Risk Large refactor of the Electron desktop main process, backend spawning/readiness, and lifecycle handling; mistakes could prevent the app from starting or shut down cleanly. Also changes runtime configuration/paths and protocol handling, which can impact packaging and local file access.

Overview Ports the desktop Electron main process to an Effect-based architecture, introducing layered services for app identity/environment/config, lifecycle/shutdown coordination, observability/tracing, and Electron wrappers (app/dialog/menu/protocol).

Reworks desktop backend startup into Effect-managed spawning with fd-based bootstrap payloads, HTTP readiness polling, structured child-process output logging, and automatic restart scheduling/cancellation logic.

Cleans up legacy promise/imperative utilities and tests (e.g., backendPort, readiness helpers, settings/persistence/dialog helpers) in favor of new Effect/Vitest test coverage, and updates build hygiene (.gitignore consolidation) plus bumps electron to 41.5.0.

Reviewed by Cursor Bugbot for commit 35835a7a84759ebf2731272728570c5fcc332ef5. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Port desktop app to Effect by replacing imperative Electron code with layered Effect-TS services

  • Rewrites main.ts from imperative Electron startup code to a declarative Effect-TS layer composition, using NodeRuntime.runMain to execute a DesktopApp.program with all services provided via layers.
  • Introduces a large set of new Effect-based Electron service wrappers: ElectronApp, ElectronDialog, ElectronMenu, ElectronProtocol, ElectronSafeStorage, ElectronShell, ElectronTheme, ElectronUpdater, and ElectronWindow, each with typed errors and scoped resource management.
  • Adds desktop domain services (DesktopEnvironment, DesktopAppSettings, DesktopClientSettings, DesktopSavedEnvironments, DesktopBackendManager, DesktopBackendConfiguration, DesktopServerExposure, DesktopSshEnvironment, DesktopUpdates, DesktopWindow, etc.) as injectable Effect layers.
  • Moves shared observability utilities (trace sink, NDJSON tracer, compactTraceAttributes) from the server app to @t3tools/shared/observability, and moves DesktopBackendBootstrap schema to @t3tools/contracts.
  • Adds a comprehensive set of typed IPC handlers via DesktopIpc and installDesktopIpcHandlers, covering settings, SSH, server exposure, updates, and window operations; the preload bridge now sends structured object payloads instead of positional arguments.
  • Converts all Effect imports across packages/contracts, packages/ssh, packages/shared, packages/tailscale, and related scripts to namespaced form, enforced by @effect/language-service plugin added to multiple tsconfig.json files.
  • Risk: main.ts is a wholesale rewrite; any existing behavior not covered by the new layers (imperative window creation, old IPC handler signatures, title bar overlay config) is removed or changed.

Macroscope summarized 35835a7.

Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 43ea1a9d-4a21-4540-b157-0dd7ba97ae0b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/a1238be3

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

apps/desktop/src/backendReadiness.tslines 60–69Outdated · ac1b470Open thread
……@@ -1,107 +1,87 @@
66+ HttpClientError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
67+ }),
68+ );
69+});
MmacroscopeappBoton lines 60–69May 6, 2026

🔴 Critical src/backendReadiness.ts:60

Effect.timeoutOption(timeout) returns Option.None on timeout as a success, not a failure, so the catchTags({ TimeoutError: ... }) handler never triggers. When the overall timeout expires, the function incorrectly succeeds with Option.None instead of failing with BackendTimeoutError. Consider using Effect.timeout(timeout) instead to correctly raise TimeoutError on expiration.

✅ Resolved in 63f1bdff2eedbc50b04bd6fc7a0f86763d626046

Ccursor[bot]reviewed · 1 inline threadMay 6, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Timeout silently succeeds instead of failing with error
    • Added Option.match after Effect.timeoutOption to convert Option.None (timeout) into a BackendTimeoutError failure instead of silently succeeding, matching the established pattern used in all other timeoutOption call sites in the codebase.

Create PR

Or push these changes by commenting:

1@cursor push 27f2b65c9b
Preview (pingdotgg/t3code@27f2b65c9b)
diff
1diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts
2--- a/apps/desktop/src/backendReadiness.ts
3+++ b/apps/desktop/src/backendReadiness.ts
4@@ -3,6 +3,7 @@
5 import * as Data from "effect/Data";
6 import * as Duration from "effect/Duration";
7 import * as Effect from "effect/Effect";
8+import * as Option from "effect/Option";
9 import * as Predicate from "effect/Predicate";
10 import * as Schedule from "effect/Schedule";
11 import { HttpClient } from "effect/unstable/http";
12@@ -60,9 +61,13 @@
13 yield* client.get(requestUrl).pipe(
14 Effect.asVoid,
15 Effect.timeoutOption(timeout),
16+ Effect.flatMap(
17+ Option.match({
18+ onNone: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
19+ onSome: () => Effect.void,
20+ }),
21+ ),
22 Effect.catchTags({
23- TimeoutError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
24- // Maybe map this to different error kind?
25 HttpClientError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
26 }),
27 );

You can send follow-ups to the cloud agent here.

apps/desktop/src/backendReadiness.tsline 68Outdated · ac1b470Open thread
……@@ -1,107 +1,87 @@
65+ // Maybe map this to different error kind?
66+ HttpClientError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
67+ }),
68+ );

Timeout silently succeeds instead of failing with error

High Severity

The waitForHttpReadyEffect function silently succeeds when the backend readiness check times out. Effect.timeoutOption returns Option.None on timeout, which is implicitly discarded instead of being handled as a failure. This prevents catchTags from catching a TimeoutError, causing the application to proceed as if the backend is ready when it has actually timed out.

Reviewed by Cursor Bugbot for commit ac1b4700e55639797aa4d80dc599acc9bea16b6c. Configure here.

Synced from GitHub

Approvability

Verdict: Needs human review

Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

apps/desktop/src/main.tslines 1443–1448Outdated · b5c8ea4Open thread
……@@ -1465,22 +1430,38 @@ function startBackend(): void {
1445+ backendProcessFiber = null;
1446+ }
1447+ backendReady = false;
1448+ };
MmacroscopeappBoton lines 1443–1448May 6, 2026

🟠 High src/main.ts:1443

clearStartedBackendState unconditionally sets backendReady = false even when the fiber being finalized is not the current one. If stopBackend() is called and startBackend() is called again before the old fiber's finalizer runs, the old fiber's Effect.ensuring handler will incorrectly clear backendReady after the new backend has already signaled readiness via handleBackendReady(), causing the application to think the backend is not ready when it actually is.

No longer relevant as of 3a55a1d65ed0e6663d7332303ff58f918d8b3bdb

Ccursor[bot]reviewed · 2 inline threadsMay 6, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Port fields lose range validation in bootstrap schema
    • Replaced Schema.Number with a shared PortSchema (Schema.Int with isBetween 1-65535) for both port and tailscaleServePort fields in DesktopBackendBootstrap, restoring the validation that was present in the old BootstrapEnvelopeSchema.
  • ✅ Fixed: Readiness timeout fires but no window ever created
    • Added fallback window creation in the onReadinessFailure handler so users get a window (showing reconnection state) instead of being stuck with no UI when the readiness probe times out.

Create PR

Or push these changes by commenting:

1@cursor push 1291f41e07
Preview (pingdotgg/t3code@1291f41e07)
diff
1diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
2--- a/apps/desktop/src/main.ts
3+++ b/apps/desktop/src/main.ts
4@@ -1496,6 +1496,12 @@
5 `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
6 );
7 console.warn("[desktop] backend readiness check failed during bootstrap", error);
8+
9+ if (isDevelopment) return;
10+ const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
11+ if (existingWindow !== null) return;
12+ mainWindow = createWindow();
13+ writeDesktopLogHeader("bootstrap main window created (readiness timeout fallback)");
14 }),
15 onOutput: (streamName, chunk) =>
16 Effect.sync(() => {
17
18diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts
19--- a/apps/server/src/cli.ts
20+++ b/apps/server/src/cli.ts
21@@ -5,6 +5,7 @@
22 CommandId,
23 DesktopBackendBootstrap,
24 OrchestrationReadModel,
25+ PortSchema,
26 ProjectId,
27 type ClientOrchestrationCommand,
28 } from "@t3tools/contracts";
29@@ -67,8 +68,6 @@
30 import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
31 import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
32
33-const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
34-
35 const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
36 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
37 Flag.optional,
38
39diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts
40--- a/packages/contracts/src/baseSchemas.ts
41+++ b/packages/contracts/src/baseSchemas.ts
42@@ -5,6 +5,7 @@
43
44 export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
45 export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
46+export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
47
48 export const IsoDateTime = Schema.String;
49 export type IsoDateTime = typeof IsoDateTime.Type;
50
51diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts
52--- a/packages/contracts/src/desktopBootstrap.ts
53+++ b/packages/contracts/src/desktopBootstrap.ts
54@@ -1,14 +1,16 @@
55 import { Schema } from "effect";
56
57+import { PortSchema } from "./baseSchemas.ts";
58+
59 export const DesktopBackendBootstrap = Schema.Struct({
60 mode: Schema.Literal("desktop"),
61 noBrowser: Schema.Boolean,
62- port: Schema.Number,
63+ port: PortSchema,
64 t3Home: Schema.String,
65 host: Schema.String,
66 desktopBootstrapToken: Schema.String,
67 tailscaleServeEnabled: Schema.Boolean,
68- tailscaleServePort: Schema.Number,
69+ tailscaleServePort: PortSchema,
70 otlpTracesUrl: Schema.optional(Schema.String),
71 otlpMetricsUrl: Schema.optional(Schema.String),
72 });

You can send follow-ups to the cloud agent here.

apps/desktop/src/backendProcess.tsline 119Outdated · b5c8ea4Open thread
……@@ -0,0 +1,122 @@
116+ Effect.tap(() => options.onReady?.() ?? Effect.void),
117+ Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void),
118+ Effect.forkScoped,
119+ );

Readiness timeout fires but no window ever created

Medium Severity

When the readiness probe times out (after 1 minute), onReadinessFailure logs a warning but the process keeps running. Meanwhile backendReady stays false, so handleBackendReady never fires and no window is created. The process continues indefinitely without scheduling a restart or creating a window. In the old code, the timeout would log and the ensureInitialBackendWindowOpen promise chain would end—but the process also kept running, so this is comparable. However, the new code's Effect.match onSuccess handler only fires when the process exits, meaning a backend that starts serving but takes longer than the timeout window will leave the user staring at nothing with no recovery path until the process happens to exit.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit b5c8ea48395f7ed2082891e6efc2ceae78d8f704. Configure here.

packages/contracts/src/desktopBootstrap.tsline 11Outdated · b5c8ea4Open thread
……@@ -0,0 +1,16 @@
8+ host: Schema.String,
9+ desktopBootstrapToken: Schema.String,
10+ tailscaleServeEnabled: Schema.Boolean,
11+ tailscaleServePort: Schema.Number,

Port fields lose range validation in bootstrap schema

Low Severity

The port and tailscaleServePort fields in DesktopBackendBootstrap use Schema.Number instead of the PortSchema (Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))) that was previously used in the old BootstrapEnvelopeSchema inside cli.ts. This means the server-side bootstrap decoder no longer validates that these values are integers within the valid port range, accepting fractional numbers, zero, negative values, or ports above 65535.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit b5c8ea48395f7ed2082891e6efc2ceae78d8f704. Configure here.

apps/desktop/src/backend/DesktopBackendManager.tsline 166Outdated · add1b6fOpen thread
……@@ -0,0 +1,474 @@
163+ run: ActiveBackendRun,
164+ options?: { readonly timeout?: Duration.Duration },
165+): Effect.Effect<void> => {
166+ const waitForFiber = Option.match(run.fiber, {
MmacroscopeappBoton line 166May 6, 2026

🟡 Medium src/desktopBackendManager.ts:166

When stop({ timeout }) times out before the fiber completes, closeRun returns early via Effect.timeoutOption(...).pipe(Effect.ignore), but the old fiber keeps running. Because stop already cleared state.active to None, a subsequent start sees no active run and spawns a new process, leaving the old zombie process running concurrently.

No longer relevant as of 6a07b7adfe4f54ddb9082432ed41d5c46b859940

apps/desktop/src/main.tsfileOutdated · add1b6fOpen thread

🔴 Critical

https://github.com/pingdotgg/t3code/blob/add1b6f6cd00e6de9a4c89efd11970e3caeac18c/apps/desktop/src/main.ts#L2195

On line 2199, applyAutoUpdaterChannel(nextChannel) returns an Effect.Effect<void> that is never executed — it is discarded without being yielded or passed to runIpcEffect(). When users change the update channel through UPDATE_SET_CHANNEL_CHANNEL, the autoUpdater.channel, allowPrerelease, and allowDowngrade properties are not actually updated, so the app continues checking for updates on the old channel.

No longer relevant as of 3a55a1d65ed0e6663d7332303ff58f918d8b3bdb

apps/desktop/src/backendPort.tsfileOutdated · add1b6fOpen thread

🟢 Low src/backendPort.ts:64

The default value for canListenOnHost uses an unsafe type cast as (port: number, host: string) => Effect.Effect<boolean, Error, R>. When a caller explicitly specifies a type parameter R that differs from NetService but relies on the default, the returned Effect incorrectly claims to require R while actually requiring NetService at runtime. For example, resolveDesktopBackendPortEffect<never>({ host: "localhost" }) returns Effect<number, Error, never> but fails at runtime with a missing NetService dependency. Consider making the parameter required or restructuring to preserve type safety.

No longer relevant as of 6a07b7adfe4f54ddb9082432ed41d5c46b859940

apps/desktop/src/main.tslines 1463–1470Outdated · add1b6fOpen thread
……@@ -1113,56 +1374,101 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
1467+ logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }),
1468+ ),
1469+ Effect.forkIn(scope),
1470+ );
MmacroscopeappBoton lines 1463–1470May 6, 2026

🟠 High src/main.ts:1463

Effect.catchCause is applied to the entire Effect.forever chain, so when checkForUpdates("poll") fails, the error is logged but forever receives a successful void effect and terminates. The polling loop stops after the first failure instead of continuing. Move catchCause inside forever so failures are logged per-iteration without terminating the loop.

No longer relevant as of 6a07b7adfe4f54ddb9082432ed41d5c46b859940

apps/desktop/src/main.tslines 1563–1563Outdated · add1b6fOpen thread
……@@ -1200,791 +1506,761 @@ function setUpdateState(patch: Partial<DesktopUpdateState>): void {
1560+ setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, checkedAt));
1561+ yield* logUpdaterInfo("checking for updates", { reason });
1562+
1563+ return yield* Effect.promise(() => autoUpdater.checkForUpdates()).pipe(
MmacroscopeappBoton lines 1563–1563May 6, 2026

🔴 Critical src/main.ts:1563

autoUpdater.checkForUpdates() can reject per the electron-updater API, but Effect.promise treats rejections as defects rather than recoverable failures. Since Effect.catch only catches failures, the error handler on lines 1565-1572 never executes when the promise rejects, leaving the update state stuck at "checking" instead of transitioning to "error". Use Effect.tryPromise to convert rejections into failures that Effect.catch can handle.

No longer relevant as of 3a55a1d65ed0e6663d7332303ff58f918d8b3bdb

apps/desktop/src/backend/DesktopBackendManager.tsline 240Outdated · add1b6fOpen thread
……@@ -0,0 +1,474 @@
237+ desiredRunning: true,
238+ ready: false,
239+ restartFiber: Option.none(),
240+ }));

Start leaks restart fiber without interrupting it

Medium Severity

The start function clears the restartFiber reference in the manager's state without interrupting any currently scheduled restart fiber. This orphans the fiber, which can then unexpectedly restart the backend later, potentially undoing an explicit stop() and leading to unintended behavior.

Reviewed by Cursor Bugbot for commit add1b6f6cd00e6de9a4c89efd11970e3caeac18c. Configure here.

Synced from GitHub

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Start leaks restart fiber without interrupting it
    • Added a call to the existing cancelRestart helper before the state update in start(), which properly interrupts the restart fiber before clearing its reference, preventing the orphaned fiber leak.

Create PR

Or push these changes by commenting:

1@cursor push fbac652b11
Preview (pingdotgg/t3code@fbac652b11)
diff
1diff --git a/apps/desktop/src/desktopBackendManager.ts b/apps/desktop/src/desktopBackendManager.ts
2--- a/apps/desktop/src/desktopBackendManager.ts
3+++ b/apps/desktop/src/desktopBackendManager.ts
4@@ -232,11 +232,11 @@
5 .exists(config.entryPath)
6 .pipe(Effect.orElseSucceed(() => false));
7
8+ yield* cancelRestart;
9 yield* Ref.update(state, (latest) => ({
10 ...latest,
11 desiredRunning: true,
12 ready: false,
13- restartFiber: Option.none(),
14 }));
15
16 if (!entryExists) {

You can send follow-ups to the cloud agent here.

Ccursor[bot]reviewed · 1 inline threadMay 6, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Restart backoff resets on spawn, not readiness
    • Moved the restartAttempt: 0 reset from the onStarted callback to the onReady callback so exponential backoff is maintained for backends that crash during initialization.

Create PR

Or push these changes by commenting:

1@cursor push 269ea9ef0e
Preview (pingdotgg/t3code@269ea9ef0e)
diff
1diff --git a/apps/desktop/src/desktopBackendManager.ts b/apps/desktop/src/desktopBackendManager.ts
2--- a/apps/desktop/src/desktopBackendManager.ts
3+++ b/apps/desktop/src/desktopBackendManager.ts
4@@ -324,16 +324,13 @@
5 ...run,
6 pid: Option.some(pid),
7 }));
8- yield* Ref.update(state, (latest) => ({
9- ...latest,
10- restartAttempt: 0,
11- }));
12 yield* events.onStarted({ pid, config });
13 }),
14 onReady: () =>
15 Effect.gen(function* () {
16 yield* Ref.update(state, (latest) => ({
17 ...latest,
18+ restartAttempt: 0,
19 ready: Option.match(latest.active, {
20 onNone: () => latest.ready,
21 onSome: (run) => (run.id === runId ? true : latest.ready),

You can send follow-ups to the cloud agent here.

apps/desktop/src/desktopBackendManager.tsline 330Outdated · 6d22abbOpen thread
……@@ -0,0 +1,474 @@
327+ yield* Ref.update(state, (latest) => ({
328+ ...latest,
329+ restartAttempt: 0,
330+ }));

Restart backoff resets on spawn, not readiness

Medium Severity

The restartAttempt counter resets to 0 in the onStarted callback (when the process obtains a PID), rather than in the onReady callback (when the health check passes). This defeats the exponential backoff for backends that crash during initialization — they will always restart with the initial 500ms delay, creating a tight restart loop with no backoff.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 6d22abb9f53598811eaffbf1e16e0ccb19ad43ab. Configure here.

apps/desktop/src/electron/ElectronMenu.tslines 140–146Outdated · 71a332eOpen thread
……@@ -0,0 +1,181 @@
143+ }
144+ Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
145+ return Effect.void;
146+ },
MmacroscopeappBoton lines 140–146May 7, 2026

🟡 Medium electron/ElectronMenu.ts:140

In popupTemplate, Electron.Menu.buildFromTemplate(...).popup(...) executes immediately when the function is called, not when the returned Effect.void is executed. This causes the popup to appear before the Effect runs, breaking lazy evaluation. Consider wrapping the popup call in Effect.sync and returning that Effect.

✅ Resolved in f57d34aa6225052977ef11f1b739342f839b7bfa

Ccursor[bot]reviewed · 1 inline threadMay 7, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 5 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Side effects in popupTemplate escape Effect boundary
    • Wrapped the buildFromTemplate and popup calls in Effect.sync(() => { ... }) so side effects are deferred to Effect execution and exceptions are captured by the Effect error channel, matching the pattern used by setApplicationMenu.

Create PR

Or push these changes by commenting:

1@cursor push a885ed0c61
Preview (pingdotgg/t3code@a885ed0c61)
diff
1diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts
2--- a/apps/desktop/src/electron/ElectronMenu.ts
3+++ b/apps/desktop/src/electron/ElectronMenu.ts
4@@ -141,8 +141,9 @@
5 if (input.template.length === 0) {
6 return Effect.void;
7 }
8- Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
9- return Effect.void;
10+ return Effect.sync(() => {
11+ Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
12+ });
13 },
14 showContextMenu: (input) =>
15 Effect.callback<Option.Option<string>>((resume) => {

You can send follow-ups to the cloud agent here.

apps/desktop/src/electron/ElectronMenu.tsline 146Outdated · 04cb180Open thread
……@@ -0,0 +1,181 @@
143+ }
144+ Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
145+ return Effect.void;
146+ },

Side effects in popupTemplate escape Effect boundary

Medium Severity

The popupTemplate method executes Electron.Menu.buildFromTemplate and .popup() as bare side effects before returning Effect.void, rather than wrapping them in Effect.sync like setApplicationMenu does on line 137. This means the side effects run eagerly at call time (not when the Effect is executed), and any exception thrown by the Electron APIs will propagate as an unhandled throw instead of being captured by the Effect error channel.

Reviewed by Cursor Bugbot for commit 04cb1806d720c41a849017316aeadcc33c75874c. Configure here.

Ccursor[bot]reviewed · 1 inline threadMay 7, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Restart backoff defeated by premature attempt counter reset
    • Moved the restartAttempt: 0 reset from onStarted to onReady so the backoff counter only resets after the backend process proves healthy via the HTTP readiness check.

Create PR

Or push these changes by commenting:

1@cursor push e48c9bf69f
Preview (pingdotgg/t3code@e48c9bf69f)
diff
1diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts
2--- a/apps/desktop/src/backend/DesktopBackendManager.ts
3+++ b/apps/desktop/src/backend/DesktopBackendManager.ts
4@@ -422,16 +422,13 @@
5 ...run,
6 pid: Option.some(pid),
7 }));
8- yield* Ref.update(state, (latest) => ({
9- ...latest,
10- restartAttempt: 0,
11- }));
12 yield* events.onStarted({ pid, config });
13 }),
14 onReady: () =>
15 Effect.gen(function* () {
16 yield* Ref.update(state, (latest) => ({
17 ...latest,
18+ restartAttempt: 0,
19 ready: Option.match(latest.active, {
20 onNone: () => latest.ready,
21 onSome: (run) => (run.id === runId ? true : latest.ready),

You can send follow-ups to the cloud agent here.

apps/desktop/src/backend/DesktopBackendManager.tsline 428Outdated · c9345f4Open thread
……@@ -0,0 +1,562 @@
425+ yield* Ref.update(state, (latest) => ({
426+ ...latest,
427+ restartAttempt: 0,
428+ }));

Restart backoff defeated by premature attempt counter reset

Medium Severity

The onStarted callback resets restartAttempt to 0 immediately after the backend process spawns, before it has proven healthy via onReady. A crash-looping process that spawns successfully but exits immediately will always see restartAttempt === 0 when scheduleRestart runs in finalizeRun, so calculateRestartDelay always returns the initial 500ms delay. The exponential backoff (up to MAX_RESTART_DELAY of 10s) never kicks in. The reset belongs in onReady instead, so only a process that becomes fully ready resets the backoff counter.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit c9345f4cd9cf84003f11674f7b0c0d7ceed4a44e. Configure here.

packages/effect-acp/tsconfig.jsonlines 17–17Outdated · 62e25a4Open thread
……@@ -14,5 +14,5 @@
1515 ]
1616 },
17- "include": ["src", "scripts", "test"]
17+ "include": ["src"]
MmacroscopeappBoton lines 17–17May 7, 2026

🟢 Low effect-acp/tsconfig.json:17

The include array was narrowed to ["src"], but scripts/ and test/ directories still contain .ts files with no separate tsconfig.json. TypeScript will no longer type-check these files, so type errors in scripts/generate.ts, test/examples/cursor-acp-client.example.ts, and test/fixtures/acp-mock-peer.ts will go undetected during compilation.

✅ Resolved in 3947f31cc22dad121863892c2b0003981a9f6b15

apps/desktop/src/backend/DesktopBackendManager.tsline 540Outdated · ab476a0Open thread
……@@ -0,0 +1,589 @@
537+ });
538+ });
539+
540+ const stop = (options?: { readonly timeout?: Duration.Duration }): Effect.Effect<void> =>
MmacroscopeappBoton line 540May 7, 2026

🟠 High backend/DesktopBackendManager.ts:540

When stop() is called, desktopState.backendReady is not set to false, leaving the UI state inconsistent. The finalizeRun callback (line 418) only runs via matchEffect handlers, but when stop() interrupts the fiber by closing runScope, Effect's interruption semantics skip matchEffect and run only the ensuring finalizer—so backendReady never resets. Consider explicitly setting desktopState.backendReady to false in the stop function.

✅ Resolved in f57d34aa6225052977ef11f1b739342f839b7bfa

apps/desktop/src/updates/DesktopUpdates.tslines 638–660Outdated · ab476a0Open thread
……@@ -0,0 +1,703 @@
657+ yield* checkForUpdates("channel-change").pipe(
658+ Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)),
659+ );
660+ return yield* Ref.get(updateStateRef);
MmacroscopeappBoton lines 638–660May 7, 2026

🟠 High updates/DesktopUpdates.ts:638

setChannel calls updatePersistedSettings before checking if the channel actually changed. When nextChannel === state.channel, this still writes to disk and incorrectly sets updateChannelConfiguredByUser: true even though the user made no change. Consider moving the persistence after the early-return check at line 643.

✅ Resolved in f57d34aa6225052977ef11f1b739342f839b7bfa

apps/desktop/src/electron/ElectronMenu.tslines 140–144Outdated · f57d34aOpen thread
……@@ -0,0 +1,181 @@
141+ Effect.sync(() => {
142+ if (input.template.length === 0) {
143+ return Effect.void;
144+ }
MmacroscopeappBoton lines 140–144May 7, 2026

🟠 High electron/ElectronMenu.ts:140

In popupTemplate, when input.template.length === 0, the code returns Effect.void from inside Effect.sync. Effect.sync expects its callback to return a plain value that becomes the effect's success value; returning Effect.void makes that object itself the success value rather than executing it. Consider using return (or restructuring with Effect.when or Effect.flatMap) to properly handle the empty case.

✅ Resolved in 01e167316e57ae03c07cd6a5912e4df6223c4fc3

Ccursor[bot]reviewed · 1 inline threadMay 7, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Returning Effect value from sync thunk instead of void
    • Replaced return Effect.void with a plain return inside the Effect.sync thunk so the early exit path correctly returns undefined rather than an unexecuted Effect object.

Create PR

Or push these changes by commenting:

1@cursor push 16417b5abc
Preview (pingdotgg/t3code@16417b5abc)
diff
1diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts
2--- a/apps/desktop/src/electron/ElectronMenu.ts
3+++ b/apps/desktop/src/electron/ElectronMenu.ts
4@@ -140,7 +140,7 @@
5 popupTemplate: (input) =>
6 Effect.sync(() => {
7 if (input.template.length === 0) {
8- return Effect.void;
9+ return;
10 }
11 Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
12 }),

You can send follow-ups to the cloud agent here.

apps/desktop/src/electron/ElectronMenu.tsline 146Outdated · f57d34aOpen thread
……@@ -0,0 +1,181 @@
143+ return Effect.void;
144+ }
145+ Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
146+ }),

Returning Effect value from sync thunk instead of void

Low Severity

The popupTemplate implementation uses return Effect.void inside Effect.sync(), which returns an unexecuted Effect object as the success value of the sync thunk rather than performing a no-op. The developer likely intended a plain return (or return undefined) to exit early when the template is empty. While the behavior is correct (nothing happens for empty templates), this pattern is misleading — it looks like it's trying to "run" Effect.void but actually just produces a dead value that's discarded by the caller.

Reviewed by Cursor Bugbot for commit f57d34aa6225052977ef11f1b739342f839b7bfa. Configure here.

Ccursor[bot]reviewed · 1 inline threadMay 7, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Gitignore patterns now overly broad across monorepo
    • Path-qualified the desktop-specific gitignore patterns with apps/desktop/ prefix to scope them to the desktop app directory, consistent with how other app-specific patterns (e.g. apps/web/.playwright) are already qualified in the root .gitignore.

Create PR

Or push these changes by commenting:

1@cursor push 5a8c2b0a6a
Preview (pingdotgg/t3code@5a8c2b0a6a)
diff
1diff --git a/.gitignore b/.gitignore
2--- a/.gitignore
3+++ b/.gitignore
4@@ -23,5 +23,5 @@
5 .tanstack
6 squashfs-root/
7 .vercel
8-dist-electron/
9-.electron-runtime/
10+apps/desktop/dist-electron/
11+apps/desktop/.electron-runtime/

You can send follow-ups to the cloud agent here.

.gitignoreline 27Outdated · 01e1673Open thread
……@@ -23,3 +23,5 @@ __screenshots__/
2424squashfs-root/
2525.vercel
26+dist-electron/
27+.electron-runtime/

Gitignore patterns now overly broad across monorepo

Low Severity

Moving dist-electron/ and .electron-runtime/ from apps/desktop/.gitignore to the root .gitignore broadens matching scope from just apps/desktop/ to any directory at any depth in the monorepo. Gitignore patterns without a leading path separator match anywhere in the tree. Since this is a monorepo with multiple apps/packages, any future package that happens to use a dist-electron/ directory would have it silently ignored.

Reviewed by Cursor Bugbot for commit 01e167316e57ae03c07cd6a5912e4df6223c4fc3. Configure here.

apps/desktop/src/settings/DesktopAppSettings.tslines 145–147Outdated · 01e1673Open thread
……@@ -0,0 +1,311 @@
144+ }
145+ if (settings.updateChannel !== defaults.updateChannel) {
146+ document.updateChannel = settings.updateChannel;
147+ }
MmacroscopeappBoton lines 145–147May 7, 2026

🟡 Medium settings/DesktopAppSettings.ts:145

When updateChannelConfiguredByUser is true but settings.updateChannel equals defaults.updateChannel, the updateChannel field is omitted from the persisted document (line 145-147). On reload, normalizeDesktopSettingsDocument sees no saved channel and applies the current default (lines 123-125). If the app's default channel changed between versions, the user's explicit choice is lost and silently replaced with the new default.

Consider always persisting updateChannel when updateChannelConfiguredByUser is true, regardless of whether it matches the current default.

No longer relevant as of ccee7cecd56505518b51df28e9d41f51942c4810

Added 35 commits

  • nit167afc1
  • Fix timeout silently succeeding in waitForHttpReadyEffect4d3358e
  • Port desktop backend readiness checks to Effectaa9dd25
  • Refactor desktop SSH IPC handlers0f5850d
  • Refactor desktop window, theme, and updater services0f9fb93
  • Split desktop SSH handling into dedicated services3f82d86
  • Refactor desktop app into main-layer modules4ecf54a
  • Refactor desktop Electron services5461149
  • Centralize desktop window and quitting state8e162b5
  • Refactor desktop runtime to Effect91c982f
  • Refactor desktop IPC into shared handlers9e2d64c
  • Use desktop server exposure domain folder9fb7747
  • Namespace preload IPC channel importsa122a35
  • Use default update channel for desktop update filteringa8b9c4b
  • Refactor desktop update handling into dedicated servicesa90619e
  • Refactor desktop IPC to use main service layersac4ff5e
  • Refactor desktop Electron IPC into shared servicesadfeee0
  • Refactor desktop server exposure into scoped serviced9435a0
  • Reorganize desktop app modules by domaine4c0d77
  • Refactor desktop IPC methods to use channel namespace importsf0c204d
  • Refactor desktop backend startup into Effect processf5a6173
  • Refactor desktop shell env sync into Effect servicef9000d4
  • Refine desktop error handling and Effect imports5a3e6b1
  • Refactor contracts to use Effect subpath importsffc7c77
  • Normalize Effect imports across shared packages244e066
  • Normalize SSH password prompt cancelled IPC result354c05f
  • Handle desktop secret decode and window load errorsa59ee63
  • Tighten Effect tsconfig diagnosticsb07e626
  • Inline SSH password cancel IPC resultec26ca7
  • Refactor desktop bootstrap around direct environment inputsefa529d
  • Reuse effect context for SSH password prompt cleanupf854da1
  • Harden desktop backend restart and update handling02fa783
  • Refactor desktop settings into dedicated services0ee84dc
  • Fix rebased desktop bootstrap config7bdfc8c
  • Simplify desktop shutdown and timeout handlingccee7ce
apps/desktop/src/updates/DesktopUpdates.tsline 408Outdated · ccee7ceOpen thread
……@@ -0,0 +1,669 @@
405+ );
406+ });
407+
408+ const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
MmacroscopeappBoton line 408May 7, 2026

🟢 Low updates/DesktopUpdates.ts:408

The polling loop at startUpdatePollers breaks permanently when a defect (runtime exception) occurs during checkForUpdates. The Effect.catchCause is placed outside Effect.forever, so when an uncaught defect propagates from the inner effect, catchCause logs it and returns, terminating the fiber. Since checkForUpdates only uses Effect.catch for typed errors, any unexpected throw from electronUpdater.checkForUpdates or other operations stops update polling until app restart. Consider moving catchCause inside forever so each iteration is isolated, or wrapping checkForUpdates with Effect.catchAllDefect to ensure defects are caught and logged without breaking the loop.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/desktop/src/updates/DesktopUpdates.ts around line 408:
2
3The polling loop at `startUpdatePollers` breaks permanently when a defect (runtime exception) occurs during `checkForUpdates`. The `Effect.catchCause` is placed **outside** `Effect.forever`, so when an uncaught defect propagates from the inner effect, `catchCause` logs it and returns, terminating the fiber. Since `checkForUpdates` only uses `Effect.catch` for typed errors, any unexpected throw from `electronUpdater.checkForUpdates` or other operations stops update polling until app restart. Consider moving `catchCause` inside `forever` so each iteration is isolated, or wrapping `checkForUpdates` with `Effect.catchAllDefect` to ensure defects are caught and logged without breaking the loop.
4
5Evidence trail:
6apps/desktop/src/updates/DesktopUpdates.ts lines 408-426 (startUpdatePollers with Effect.forever before Effect.catchCause in pipe chain); lines 303-336 (checkForUpdates using only Effect.catch for typed errors, not catching defects); lines 324-326 (electronUpdater.checkForUpdates piped through Effect.catch only)

Added 1 commit

  • Refactor desktop logging around run annotationsdaf956b
Ccursor[bot]reviewed · 1 inline threadMay 7, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Misleading log emitted when backend start is skipped
    • Moved the log statement inside the conditional block and added a distinct "skipped" log for the quitting case, so logs now accurately reflect whether the backend was started or skipped.

Create PR

Or push these changes by commenting:

1@cursor push 5f643f6923
Preview (pingdotgg/t3code@5f643f6923)
diff
1diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts
2--- a/apps/desktop/src/app/DesktopApp.ts
3+++ b/apps/desktop/src/app/DesktopApp.ts
4@@ -178,8 +178,10 @@
5
6 if (!(yield* Ref.get(state.quitting))) {
7 yield* backendManager.start;
8+ yield* Effect.logInfo("bootstrap backend start requested");
9+ } else {
10+ yield* Effect.logInfo("bootstrap backend start skipped (quitting)");
11 }
12- yield* Effect.logInfo("bootstrap backend start requested");
13
14 if (environment.isDevelopment) {
15 yield* desktopWindow.ensureMain;

You can send follow-ups to the cloud agent here.

apps/desktop/src/app/DesktopApp.tsline 182Outdated · daf956bOpen thread
……@@ -0,0 +1,237 @@
179+ if (!(yield* Ref.get(state.quitting))) {
180+ yield* backendManager.start;
181+ }
182+ yield* Effect.logInfo("bootstrap backend start requested");

Misleading log emitted when backend start is skipped

Low Severity

The log message "bootstrap backend start requested" at line 182 fires unconditionally, even when the backend start was actually skipped because state.quitting is true. This produces misleading diagnostic output during shutdown races, making it harder to determine from logs whether the backend was actually started.

Reviewed by Cursor Bugbot for commit daf956bf6dc59d8a37d7cafb8c2405f0aad5ca91. Configure here.

Added 2 commits

  • Probe backend readiness on environment endpointd008e09
  • Improve desktop startup logging and window readiness413d2f9
Ccursor[bot]reviewed · 1 inline threadMay 7, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: desktopState.backendReady is never set to true
    • Added Ref.set(desktopState.backendReady, true) in the onReady callback and removed the manual workaround in the test that externally set it to true.

Create PR

Or push these changes by commenting:

1@cursor push e5394e09b4
Preview (pingdotgg/t3code@e5394e09b4)
diff
1diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts
2--- a/apps/desktop/src/backend/DesktopBackendManager.test.ts
3+++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts
4@@ -328,7 +328,6 @@
5 yield* manager.start;
6 assert.equal(yield* Queue.take(startedPids), 123);
7 yield* Deferred.await(ready);
8- yield* Ref.set(backendReady, true);
9 assert.isTrue(yield* Ref.get(backendReady));
10 assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig));
11
12
13diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts
14--- a/apps/desktop/src/backend/DesktopBackendManager.ts
15+++ b/apps/desktop/src/backend/DesktopBackendManager.ts
16@@ -443,6 +443,7 @@
17 onSome: (run) => (run.id === runId ? true : latest.ready),
18 }),
19 }));
20+ yield* Ref.set(desktopState.backendReady, true);
21 yield* desktopWindow.handleBackendReady.pipe(
22 Effect.catch((error) =>
23 Effect.logError("failed to open main window after backend readiness").pipe(

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 413d2f9dedf6ecf724e494543cf43515c5af2257. Configure here.

apps/desktop/src/backend/DesktopBackendManager.tsline 453Outdated · 413d2f9Open thread
……@@ -0,0 +1,575 @@
450+ ),
451+ ),
452+ );
453+ }),

desktopState.backendReady is never set to true

High Severity

The onReady callback updates the internal manager state's ready field but never sets desktopState.backendReady to true. The ref is only ever set to false (on start, finalize, and stop), so any consumer reading desktopState.backendReady will always see false even after the backend is confirmed ready via HTTP. The test works around this by manually setting the ref to true externally.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 413d2f9dedf6ecf724e494543cf43515c5af2257. Configure here.

Added 1 commit

  • Consolidate desktop and server observability layersfe6d0ee
apps/desktop/src/backend/DesktopBackendManager.tsline 507Outdated · fe6d0eeOpen thread
……@@ -0,0 +1,575 @@
504+ }),
505+ );
506+ const restartFiber = yield* Effect.forkIn(
507+ Effect.sleep(delay).pipe(
MmacroscopeappBoton line 507May 8, 2026

🟢 Low backend/DesktopBackendManager.ts:507

When stop() is called after a restart is scheduled but before it fires, the restart fiber at line 507 still proceeds to call start() because it only checked desiredRunning at scheduling time (line 482). The fiber updates restartFiber to Option.none() at line 509 and then unconditionally calls start() at line 514, even though desiredRunning may now be false. Consider re-checking desiredRunning atomically when clearing restartFiber before invoking start.

✅ Resolved in 79b34209267f9b18abaf1f6086b3b6e6be05bcef

Added 2 commits

  • Cancel desktop backend restart when stopped79b3420
  • Add span tracing to desktop startup and lifecycle6937dab
apps/desktop/src/electron/ElectronWindow.tsline 121Outdated · 6937dabOpen thread
……@@ -0,0 +1,132 @@
118+ window.destroy();
119+ }
120+ }),
121+ syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* <E, R>(
MmacroscopeappBoton line 121May 8, 2026

🟢 Low electron/ElectronWindow.ts:121

In syncAllAppearance, the code iterates over all windows from Electron.BrowserWindow.getAllWindows() and directly calls sync(window) without checking window.isDestroyed() first. If a window is destroyed between the getAllWindows() call and the sync execution, the callback will receive a destroyed window, potentially causing errors. Other methods in this service (sendAll, reveal, liveMain, etc.) consistently filter out destroyed windows with isDestroyed() checks.

✅ Resolved in dc3aa278f8e8c9c2f627b14f72fd7ac585abc010

Added 3 commits

  • Merge origin/main into desktop effect rewritee8344e5
  • Skip destroyed Electron windows during appearance syncdc3aa27
  • Unify desktop component logging35835a7