feat(server): t3 service runs T3 Code in the background on Windows - #5848
feat(server): t3 service runs T3 Code in the background on Windows#5848inayayousfi wants to merge 9 commits into
Conversation
The background service was Linux-only, because it installs a systemd user unit. Windows gets a shortcut in the per-user Startup folder instead. The shortcut runs PowerShell hidden, PowerShell starts the launcher with no console window, then exits. The systemd path is untouched. Every addition to the shared launcher is gated on a flag the generated logon script sets and systemd never does. Three gaps had to be covered because Windows has no init system: - Nothing supervises a Startup folder entry, so the launcher restarts its own child on the same terms as RestartSec and StartLimitBurst. - Nothing captures the launcher's output, so the logon script redirects it through cmd.exe. - Windows has no SIGTERM, so stopping goes through a request file the launcher watches. A pid file is what tells the CLI whether a launcher is running, so an install can never write over one it could not confirm dead. Stopping is not graceful on Windows: the child is terminated without its shutdown finalizer. That, and the fact that the service starts at sign-in rather than boot and stops at sign-out, are documented. Install refuses when a path contains a percent sign, which cmd.exe would expand and silently break, and when the Startup entry is switched off in Windows Settings.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
There was a problem hiding this comment.
🟡 Medium
t3code/apps/server/src/cli/service.ts
Line 173 in 6493ba3
On Windows, offerServiceDuringOnboarding returns true after a successful install/update, so the connectCommand caller prints "T3 Code will stay reachable after you log out." — but the new Windows prompt explicitly states the service stops at sign-out. Every successful Windows onboarding therefore shows contradictory and incorrect availability guidance. Consider returning platform-aware state or making the caller's success message platform-specific.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/cli/service.ts around line 173:
On Windows, `offerServiceDuringOnboarding` returns `true` after a successful install/update, so the `connectCommand` caller prints `"T3 Code will stay reachable after you log out."` — but the new Windows prompt explicitly states the service stops at sign-out. Every successful Windows onboarding therefore shows contradictory and incorrect availability guidance. Consider returning platform-aware state or making the caller's success message platform-specific.
There was a problem hiding this comment.
Fixed in dd3bebe, in the caller rather than the return value.
connectCommand now picks the message by platform: Windows gets "T3 Code will start again every time you sign in to Windows." and Linux keeps "T3 Code will stay reachable after you log out." See apps/server/src/cli/connect.ts:697-704.
I kept the boolean return as-is, since it means "background setup is in place", which is true on both platforms. Only the availability sentence differed.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
There was a problem hiding this comment.
Reviewed the new Windows boot-service backend and the touched call sites against the Effect service conventions. Four findings, all in apps/server/src/cloud/bootServiceWindows.ts: the service module is imported with named imports instead of as a namespace, two pure validation failures manufacture an Error only to carry a user-facing message that the generic BootServiceInstallError.message then hides, and one step attribute embeds variable context instead of using structured fields.
Posted via Macroscope — Effect Service Conventions
ApprovabilityVerdict: Needs human review 7 blocking correctness issues found. This PR adds Windows background service support - a significant new feature with complex process coordination logic. Multiple HIGH severity findings identify race conditions in PID file management that could allow two servers against the same database. The scope and unresolved correctness concerns warrant human review. You can customize Macroscope's approvability policy. Learn more. |
Six review findings, all about the same class of failure: something looks successful while a second server ends up on one SQLite database, or while nothing runs at all. - The pid file is claimed exclusively with `wx`, and released only by the process that owns it. A Startup shortcut that fires twice now leaves the second launcher exiting instead of starting a rival server. - Failing to publish the pid file no longer continues silently. A live launcher without one is invisible to the CLI, which reads absence as proof that nothing is running. - A failed stop-request write fails the operation once a live launcher is confirmed, rather than reading as "nothing to stop". - install and uninstall decide whether to stop from the pid file, not from the shortcut. A launcher outlives a shortcut someone deleted by hand. - `status` reads the shortcut back through the Probe action and compares its target and arguments. Comparing the script that wrote the shortcut said nothing about a shortcut edited or replaced since. - Connect onboarding no longer promises Windows users the service survives sign-out, which contradicted the prompt they had just accepted.
…abled flags Five more review findings, four of them about errors the user never sees. The percent-sign guard and the disabled-entry guard both wrapped a synthetic Error inside BootServiceInstallError, whose message is fixed text. The CLI prints message, so both carefully worded explanations were invisible. They are now dedicated tagged errors that derive their message from a field, and the onboarding recovery path prints them directly. The stop timeout put a pid and a duration into the step string, which every other step in this module and in bootService.ts keeps as a stable literal. They are structured attributes on BootServiceCommandError now. The disabled flag is read from a registry value that outlives the shortcut, so uninstalling a disabled entry and reinstalling later told the user to re-enable something Startup apps no longer lists. It is only read when the shortcut is actually there. The module imports bootService.ts as a namespace, matching how every other service-boundary consumer in the repo imports it. Also fixes a real bug the tests could not see: the probe never emitted the shortcut readback, because an escaping mistake dropped those lines from the generated script. The test fake produced them itself, so status looked right while real Windows would have reported "needs repair" forever. A test now pins the generated script rather than the fake.
Three review findings, all about a pid file that means less than it looks. A process id is not identity. The file survives a crash or a hard reboot, and Windows recycles ids freely, so a stale record could name an unrelated live process. The launcher then refused to start for as long as that stranger ran, and the CLI waited on it forever. The record now carries the machine's boot time, so anything written before this boot is obviously stale on both sides. A dead launcher also left its stop request behind. The next install would start a launcher that immediately read that request and stopped itself, while the install reported success. Cleaning up after a dead launcher now clears both files. The percent-sign error told users to set T3CODE_HOME even when the offending path was the Node executable, which that variable cannot move. The message gives no relocation advice now; the path label names what to change.
…present Four review findings. A boot stamp catches a record from an earlier boot, but not one left by a launcher that crashed during this boot whose id has since been handed to someone else. A running launcher now refreshes its record every 15s, and a record nobody has refreshed for 90s is treated as dead on both sides. Claiming a stale record was a delete followed by a create, so two launchers racing could both delete and both claim. An exclusive takeover file picks a single winner, and it re-checks the holder under that file before deleting. A start that failed transiently escaped into the fatal path and killed the launcher outright, which defeats the supervisor over exactly the kind of failure it exists to absorb. It now spends another attempt from the same burst, so the burst limit still bounds it. install stopped the running launcher before checking for a pending remote update, so the guard meant to protect that update interrupted it first. The check now runs before anything is terminated. Also: a transient read error on the pid record no longer counts as proof the launcher is gone. Only a confirmed not-found does.
A launcher that died between creating the takeover file and its finally left that file behind forever. Every later start then read it as "somebody is busy", returned false and exited, so the service could never come back and the only cure was deleting a hidden file by hand. A takeover covers a stat, a read, a delete and a write, so one that has been sitting for 30s was abandoned. Recovery renames it away before retrying, because concurrent renames of one source leave exactly one winner and the rest find the source already gone. A fresh takeover still means somebody is genuinely mid-claim, and that case stands down as before. Also fixes a units bug that made three tests lie. A bare number given to utimes is a Unix timestamp in seconds, so passing milliseconds set the modification time to the year 51969. Two staleness tests were therefore backdating into the future and passing without ever reaching the branch they name. Both now use seconds, one had to provoke the watcher to reach the check at all, and the pid test now stops the fake launcher from answering so a live-looking record would actually fail it.
Four review findings, three of which shared a root cause: I let a timestamp decide something a timestamp cannot decide. A laptop that sleeps for an hour wakes with a perfectly live launcher whose last heartbeat is ancient. Reading that as death cleared its record and started a second server on one SQLite database. An old timestamp now only makes the answer unknown, and the unknown case is settled by watching for the next heartbeat instead of guessing. The CLI gets that for free: its stop wait is already longer than several heartbeats, so a record that never moves during the wait belongs to a recycled process id, and one that does move is a live launcher that will not stop and must not be written over. That also settles the boot tolerance concern. A reboot inside the tolerance window can still look like the same boot, but the record is then quiet and the probe reaches the right answer anyway. The first start had no burst retry, only restarts did, so a transient spawn failure at sign-in left the service down until the next one. Both paths now share one retry. Windows has no KillMode=mixed, so a launcher killed outright leaves its server behind. The record now names the child, and whoever takes a leftover record over puts that orphan down before starting its own.
| } | ||
| if (!processIsAlive(childPid)) return; | ||
| try { | ||
| process.kill(childPid, "SIGKILL"); |
There was a problem hiding this comment.
🟠 High src/serviceLauncher.ts:594
#terminateOrphanedChild sends SIGKILL to whatever process currently holds the recorded childPid. When the original server died during this boot and Windows reused that PID for an unrelated process, the takeover path kills that unrelated process. The boot-time check only validates when the record was written — it does not prove the process now holding childPid is the original server. Consider tracking a child-process identifier that survives PID reuse, or skipping the orphan kill when the current child is still alive under this launcher.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serviceLauncher.ts around line 594:
`#terminateOrphanedChild` sends `SIGKILL` to whatever process currently holds the recorded `childPid`. When the original server died during this boot and Windows reused that PID for an unrelated process, the takeover path kills that unrelated process. The boot-time check only validates when the record was written — it does not prove the process now holding `childPid` is the original server. Consider tracking a child-process identifier that survives PID reuse, or skipping the orphan kill when the current child is still alive under this launcher.
| * modification time proves this launcher is still here. Doubling as the | ||
| * heartbeat keeps one writer for one file. | ||
| */ | ||
| async #refreshPidFile(): Promise<void> { |
There was a problem hiding this comment.
🟠 High src/serviceLauncher.ts:575
#refreshPidFile overwrites the shared pid file without checking that this launcher still owns it. If this launcher's event loop is paused longer than the heartbeat probe, a replacement launcher takes over; when the old launcher resumes, its heartbeat interval rewrites the replacement's presence record and both launchers continue running their own children against the same SQLite database. A pending async refresh can also recreate the file after #releasePidFile has already cleaned it up during shutdown.
The heartbeat should verify ownership (e.g. re-read the file and confirm pid === process.pid) before rewriting it, and skip the write when ownership has been lost.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serviceLauncher.ts around line 575:
`#refreshPidFile` overwrites the shared pid file without checking that this launcher still owns it. If this launcher's event loop is paused longer than the heartbeat probe, a replacement launcher takes over; when the old launcher resumes, its heartbeat interval rewrites the replacement's presence record and both launchers continue running their own children against the same SQLite database. A pending async refresh can also recreate the file after `#releasePidFile` has already cleaned it up during shutdown.
The heartbeat should verify ownership (e.g. re-read the file and confirm `pid === process.pid`) before rewriting it, and skip the write when ownership has been lost.
| @@ -423,6 +875,7 @@ export class Launcher { | |||
| process: child, | |||
| }; | |||
| this.#child = managed; | |||
There was a problem hiding this comment.
🟠 High src/serviceLauncher.ts:877
The child's exit and message listeners are registered after await this.#refreshPidFile(), so a child that crashes or sends its prepared IPC message during that await loses both events permanently. The launcher retains a dead #child and never restarts, or treats a healthy trial as a prepared-timeout and rolls the update back. Register the exit and message listeners before #refreshPidFile so no event can slip past them.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serviceLauncher.ts around line 877:
The child's `exit` and `message` listeners are registered after `await this.#refreshPidFile()`, so a child that crashes or sends its `prepared` IPC message during that await loses both events permanently. The launcher retains a dead `#child` and never restarts, or treats a healthy trial as a `prepared-timeout` and rolls the update back. Register the `exit` and `message` listeners before `#refreshPidFile` so no event can slip past them.
| const timeoutMs = Duration.toMillis(input.stopRequestTimeout ?? STOP_REQUEST_TIMEOUT); | ||
| const pollMs = Math.min(timeoutMs, Duration.toMillis(STOP_REQUEST_ACK_POLL)); |
There was a problem hiding this comment.
🟠 High cloud/bootServiceWindows.ts:482
A zero-valued stopRequestTimeout makes pollMs zero, so timeoutMs / pollMs is NaN and Math.max(1, NaN) returns NaN. The for loop condition attempt < NaN is immediately false, so the polling loop is skipped entirely. The code then reads the PID file mtime, which normally still equals the just-read recordMtimeBefore, classifies the confirmed-live launcher as a recycled PID, deletes its PID and stop-request files, and lets installation start a second server on the same database. Consider clamping pollMs to a minimum positive value so zero timeout still produces at least one poll attempt, or validating the input timeout is positive.
const timeoutMs = Duration.toMillis(input.stopRequestTimeout ?? STOP_REQUEST_TIMEOUT);
- const pollMs = Math.min(timeoutMs, Duration.toMillis(STOP_REQUEST_ACK_POLL));
+ const pollMs = Math.max(1, Math.min(timeoutMs, Duration.toMillis(STOP_REQUEST_ACK_POLL)));🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/cloud/bootServiceWindows.ts around lines 482-483:
A zero-valued `stopRequestTimeout` makes `pollMs` zero, so `timeoutMs / pollMs` is `NaN` and `Math.max(1, NaN)` returns `NaN`. The `for` loop condition `attempt < NaN` is immediately false, so the polling loop is skipped entirely. The code then reads the PID file mtime, which normally still equals the just-read `recordMtimeBefore`, classifies the confirmed-live launcher as a recycled PID, deletes its PID and stop-request files, and lets installation start a second server on the same database. Consider clamping `pollMs` to a minimum positive value so zero timeout still produces at least one poll attempt, or validating the input timeout is positive.
| const pidRecordMtime = async (target: string): Promise<number | undefined> => { | ||
| try { | ||
| return (await NodeFSP.stat(target)).mtimeMs; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| }; |
There was a problem hiding this comment.
🟠 High src/serviceLauncher.ts:341
pidRecordMtime converts every stat failure into undefined, not just ENOENT. In pidRecordIsBeingRefreshed, when the second stat call fails with a transient I/O or permission error, the function returns false — the same result as a confirmed missing record. In #pidFileHolderIsAlive, this makes a verified same-boot, live launcher look dead, so takeover proceeds to kill its recorded child and remove its PID file. That lets a second launcher start against the same database. Only ENOENT should establish absence; other stat failures must be propagated or conservatively treated as alive.
| const pidRecordMtime = async (target: string): Promise<number | undefined> => { | |
| try { | |
| return (await NodeFSP.stat(target)).mtimeMs; | |
| } catch { | |
| return undefined; | |
| } | |
| }; | |
| +const pidRecordMtime = async (target: string): Promise<number | undefined> => { | |
| + try { | |
| + return (await NodeFSP.stat(target)).mtimeMs; | |
| + } catch (cause) { | |
| + if ((cause as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return undefined; | |
| + throw cause; | |
| + } | |
| +}; |
Also found in 1 other location(s)
apps/server/src/cloud/bootServiceWindows.ts:497
pidRecordMtimeconverts everyfs.stat(pidPath)failure intoOption.none, and the timeout path treatsOption.none(recordMtimeAfter)as proof that the PID record never refreshed. A transient sharing/permission/I/O error during this final stat therefore causes lines 500-502 to delete the live launcher's coordination files and returnfalse; installation then rewrites and starts the service while the confirmed launcher may still be running, risking two servers against the same database. Only a confirmedNotFoundshould establish absence; other stat failures must abort safely.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serviceLauncher.ts around lines 341-347:
`pidRecordMtime` converts every `stat` failure into `undefined`, not just `ENOENT`. In `pidRecordIsBeingRefreshed`, when the second `stat` call fails with a transient I/O or permission error, the function returns `false` — the same result as a confirmed missing record. In `#pidFileHolderIsAlive`, this makes a verified same-boot, live launcher look dead, so takeover proceeds to kill its recorded child and remove its PID file. That lets a second launcher start against the same database. Only `ENOENT` should establish absence; other `stat` failures must be propagated or conservatively treated as alive.
Also found in 1 other location(s):
- apps/server/src/cloud/bootServiceWindows.ts:497 -- `pidRecordMtime` converts every `fs.stat(pidPath)` failure into `Option.none`, and the timeout path treats `Option.none(recordMtimeAfter)` as proof that the PID record never refreshed. A transient sharing/permission/I/O error during this final stat therefore causes lines 500-502 to delete the live launcher's coordination files and return `false`; installation then rewrites and starts the service while the confirmed launcher may still be running, risking two servers against the same database. Only a confirmed `NotFound` should establish absence; other stat failures must abort safely.
| // Before anything is stopped. This guard exists to protect an in-flight | ||
| // remote update, and terminating the launcher first would interrupt the | ||
| // very thing it is guarding. | ||
| const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); |
There was a problem hiding this comment.
🟠 High cloud/bootServiceWindows.ts:610
The pending-update guard at fs.readFileString(statePath).pipe(Effect.option) turns every read failure into None, not just NotFound. A transient sharing violation or I/O error is therefore treated as "no state file," bypassing serviceStateHasPendingUpdate. The install then stops the launcher and overwrites service-state.json while a remote update is in flight. Consider catching only NotFound as absent and propagating other read errors as a BootServiceInstallError, matching the pattern already used in requestStop.
- const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
+ const previousStateText = yield* fs.readFileString(statePath).pipe(
+ Effect.map(Option.some),
+ Effect.catch((error) =>
+ error.reason._tag === "NotFound"
+ ? Effect.succeed(Option.none<string>())
+ : Effect.fail(new BootService.BootServiceInstallError({ cause: error })),
+ ),
+ );🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/cloud/bootServiceWindows.ts around line 610:
The pending-update guard at `fs.readFileString(statePath).pipe(Effect.option)` turns every read failure into `None`, not just `NotFound`. A transient sharing violation or I/O error is therefore treated as "no state file," bypassing `serviceStateHasPendingUpdate`. The install then stops the launcher and overwrites `service-state.json` while a remote update is in flight. Consider catching only `NotFound` as absent and propagating other read errors as a `BootServiceInstallError`, matching the pattern already used in `requestStop`.
| if (!this.#selfSupervise) return; | ||
| await NodeFSP.writeFile(pidFilePath(this.#baseDir), this.#presenceRecord(), { | ||
| mode: 0o600, | ||
| }).catch(() => undefined); |
There was a problem hiding this comment.
Heartbeat rewrite races pid ownership
High Severity
Replacing utimes heartbeats with unconditional writeFile in #refreshPidFile makes the pid record non-atomic and unowned. A concurrent requestStop read can observe a truncated file, decode as missing, and clear a live launcher's record; the same write can also recreate or overwrite another launcher's claim. Either path can put two servers on one SQLite database during install/update.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit d3fcacc. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
There are 4 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a10bb84. Configure here.
| if (neverRefreshed) { | ||
| yield* fs.remove(pidPath, { force: true }).pipe(Effect.ignore); | ||
| yield* fs.remove(stopRequestPath, { force: true }).pipe(Effect.ignore); | ||
| return false; |
There was a problem hiding this comment.
Orphan server survives pid clear
High Severity
requestStop clears a leftover pid record when the launcher looks dead or the heartbeat never moves, but it never terminates childPid. Launcher takeover does kill that orphan before claiming. After the CLI deletes the record, the next install starts a new launcher with an empty pid file, so #terminateOrphanedChild never runs and a crashed launcher’s server can keep sharing the same SQLite database.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a10bb84. Configure here.
| `$shortcut.Description = ${quotePowerShellLiteral(`${SHORTCUT_NAME}, started at sign-in`)}`, | ||
| "# Minimized, because PowerShell paints a console before it hides itself.", | ||
| "$shortcut.WindowStyle = 7", | ||
| "$shortcut.Save()", |
There was a problem hiding this comment.
Disabled Startup state outlives uninstall
High Severity
Probe only treats StartupApproved as disabled while the .lnk exists, and neither uninstall nor Install clears or re-enables that registry value. After a user disables the entry, uninstalls, and installs again, the new shortcut is recreated under a leftover disabled flag, so install can report success while sign-in never starts the service.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a10bb84. Configure here.
| const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); | ||
| if (Option.isSome(previousStateText) && serviceStateHasPendingUpdate(previousStateText.value)) { | ||
| return yield* new BootService.BootServiceUpdatePendingError(); | ||
| } |
There was a problem hiding this comment.
Pending update guard swallows read errors
Medium Severity
The pending-update check reads service-state.json with Effect.option, so any non-success read becomes “no pending update.” That undermines the before-stop ordering: a sharing violation or similar failure while an update is in flight skips BootServiceUpdatePendingError and lets requestStop terminate the launcher mid-update.
Reviewed by Cursor Bugbot for commit a10bb84. Configure here.


What
t3 servicewas Linux-only, because it installs a systemd user unit. This adds a Windows path built on a shortcut in the per-user Startup folder.The shortcut runs PowerShell hidden, PowerShell starts the launcher with no console window, then exits. All four subcommands work:
install,status,update,uninstall.The Linux path does not change
bootService.tsgained exactly two things: akindtag on the status record, and one message string that now names Windows too. No systemd line moved.Every addition to the shared launcher is gated on an environment flag the generated logon script sets, which systemd never sets. The platform dispatch sits in the layer builder in
cli/service.ts, using the sameLayer.unwrapplus dynamic-import shape asserver.ts:127, so the Linux module never imports anything Windows.Three gaps Windows opens, and how they are closed
RestartSec=5,StartLimitBurst=5andStartLimitIntervalSec=300.boot-service.logthroughcmd.exe.Known limitations, all documented
updateanduninstallcut in-flight agent work and the server does not release its T3 Connect link.T3 Code Serverblinks once at sign-in.t3 service status.Guards
Install refuses when any path contains a percent sign, which
cmd.exeexpands on its command line even inside quotes, with no escape that works. Left alone it installs cleanly, reportscurrent, and silently never starts. It also refuses to write over an entry switched off in Windows Settings, and warns when Explorer is not the shell.Testing
pnpm typecheck,pnpm lintandpnpm fmt --checkare clean. 151 tests pass acrosssrc/cloud,src/cliand the launcher, including 24 for the Windows backend and 8 for the launcher's Windows-only behaviour.Verified on a real Windows machine and working end to end: install, sign out, sign back in, and the server comes back on its own. The blinked window is named
T3 Code Server, the entry shows up under Startup apps, andt3 service uninstallremoves it cleanly.Note
High Risk
Changes how the server is started, stopped, and upgraded on disk (PowerShell, PID files, non-graceful termination), with real risk of duplicate servers on one database if stop/ownership logic fails; mitigated by extensive tests and guards but still core infrastructure.
Overview
Adds Windows support for
t3 service, which previously only installed a Linux systemd user unit. Onwin32, the CLI lazily loads a new backend that installs a Startup folder shortcut (T3 Code Server) running hidden PowerShell, generated startup/shortcut scripts, and the same pinned runtime + launcher layout as Linux.The service launcher gains an optional self-supervise path (env flag set only by the Windows logon script): it claims a PID file with boot-time stamping and heartbeat, restarts crashed children with systemd-like burst limits, and stops via a stop-request file instead of SIGTERM. The Windows install path refuses paths containing
%(cmd expansion) and blocked Startup apps entries, and coordinates stops so installs do not rewrite runtime under a live launcher.CLI / connect output is platform-aware: status shows Shortcut vs Unit, install/update/onboarding copy explains sign-in vs boot and the expected sign-in window blink, and new boot-service errors surface with actionable messages. Shared
BootServiceStatusadds akinddiscriminator; Linux systemd behavior is otherwise unchanged aside from messaging and thekindfield on status.Reviewed by Cursor Bugbot for commit a10bb84. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add Windows background service support to the
t3 serviceCLI using a Startup folder shortcutBootServicebackend that installs a PowerShell-driven shortcut in the per-user Startup folder to run the T3 launcher hidden at sign-in.win32and adjusts install/update/status output with Windows-specific labels and notices.cmd.exewould expand) and disabled Startup folder entries.BootServiceStatusgains akindfield ('systemd'or'win32-startup-shortcut') so consumers can distinguish backends.Macroscope summarized a10bb84.