@auroraflow/code 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@auroraflow/code",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "description": "Aurora launcher and sidecar for official Codex and Claude Code clients.",
6
6
  "repository": {
@@ -1,5 +1,6 @@
1
1
  import { ensureLayout, readAccount, readState, writeAccount, writeState } from "../../state/src/index.js";
2
2
  import { officialClientStatuses, runClient, updateOfficialClients } from "../../clients/src/index.js";
3
+ import { ensureSidecarRunning, installSidecarService, sidecarServiceStatus, uninstallSidecarService } from "../../service/src/index.js";
3
4
  import { chooseClient, promptFields } from "../../../lib/prompt.js";
4
5
 
5
6
  export async function runAuroraCLI(argv = process.argv.slice(2)) {
@@ -34,11 +35,41 @@ export async function runAuroraCLI(argv = process.argv.slice(2)) {
34
35
  await updateOfficialClients();
35
36
  console.log(JSON.stringify({ officialClients: officialClientStatuses() }, null, 2));
36
37
  return;
38
+ case "sidecar":
39
+ await sidecarCommand(rest);
40
+ return;
37
41
  default:
38
42
  usage(1);
39
43
  }
40
44
  }
41
45
 
46
+ async function sidecarCommand(args) {
47
+ const action = args[0] ?? "status";
48
+ switch (action) {
49
+ case "install":
50
+ await installSidecarService();
51
+ await ensureSidecarRunning();
52
+ console.log(JSON.stringify(await sidecarServiceStatus(), null, 2));
53
+ return;
54
+ case "uninstall":
55
+ await uninstallSidecarService();
56
+ console.log("Aurora sidecar service uninstalled.");
57
+ return;
58
+ case "restart":
59
+ await uninstallSidecarService();
60
+ await installSidecarService();
61
+ await ensureSidecarRunning();
62
+ console.log(JSON.stringify(await sidecarServiceStatus(), null, 2));
63
+ return;
64
+ case "status":
65
+ console.log(JSON.stringify(await sidecarServiceStatus(), null, 2));
66
+ return;
67
+ default:
68
+ console.error("Usage: aurora sidecar [install|uninstall|restart|status]");
69
+ process.exit(1);
70
+ }
71
+ }
72
+
42
73
  export async function runAuroraClaude(argv = process.argv.slice(2)) {
43
74
  await ensureLayout();
44
75
  await runClient("claude", argv);
@@ -135,6 +166,7 @@ function usage(exitCode) {
135
166
  aurora codex [args...]
136
167
  aurora install-clients
137
168
  aurora update-clients
138
- aurora status`);
169
+ aurora status
170
+ aurora sidecar [install|uninstall|restart|status]`);
139
171
  process.exit(exitCode);
140
172
  }
@@ -1,17 +1,13 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
4
3
  import { mkdir, writeFile } from "node:fs/promises";
5
4
  import { delimiter, dirname, join, resolve } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
- import { AURORA_SIDECAR_PORT } from "../../protocol/src/index.js";
8
- import { AURORA_HOME, CLAUDE_HOME, CODEX_HOME, readSidecarInfo, readState, writeSidecarInfo } from "../../state/src/index.js";
6
+ import { CLAUDE_HOME, CODEX_HOME, readState } from "../../state/src/index.js";
7
+ import { ensureSidecarRunning } from "../../service/src/index.js";
9
8
 
10
9
  const here = dirname(fileURLToPath(import.meta.url));
11
10
  const packageRoot = resolve(here, "..", "..", "..");
12
- const rootSidecarBin = join(packageRoot, "bin", "aurora-sidecar.js");
13
- const sidecarLogPath = join(AURORA_HOME, "log", "sidecar.log");
14
- let sidecarSupervisor = null;
15
11
 
16
12
  const OFFICIAL_CLIENTS = {
17
13
  claude: {
@@ -27,7 +23,7 @@ const OFFICIAL_CLIENTS = {
27
23
  };
28
24
 
29
25
  export async function createClientLaunchSpec(client, args = []) {
30
- const sidecar = await ensureSidecar();
26
+ const sidecar = await ensureSidecarRunning();
31
27
  const state = await readState();
32
28
  const model = selectedModelAlias(state);
33
29
  const key = selectedKey(state);
@@ -144,53 +140,6 @@ function readOfficialClientVersion(bin) {
144
140
  }
145
141
  }
146
142
 
147
- async function ensureSidecar() {
148
- try {
149
- const info = await readSidecarInfo();
150
- if (info?.port && info?.token && await pingSidecar(info)) return normalizedSidecarInfo(info);
151
- } catch {
152
- // Start below.
153
- }
154
- const token = `aurora-local-${randomBytes(24).toString("hex")}`;
155
- const info = { port: AURORA_SIDECAR_PORT, token, baseURL: `http://127.0.0.1:${AURORA_SIDECAR_PORT}` };
156
- await startManagedSidecar(info);
157
- await waitForSidecar(info);
158
- await writeSidecarInfo(info);
159
- return info;
160
- }
161
-
162
- async function startManagedSidecar(info) {
163
- if (sidecarSupervisor) return;
164
- await mkdir(dirname(sidecarLogPath), { recursive: true });
165
- const launch = () => {
166
- const logFd = openSync(sidecarLogPath, "a");
167
- const child = spawn(process.execPath, [
168
- process.env.AURORA_SIDECAR_BIN || rootSidecarBin,
169
- "--token",
170
- info.token,
171
- "--port",
172
- String(info.port)
173
- ], {
174
- detached: true,
175
- stdio: ["ignore", logFd, logFd],
176
- env: process.env
177
- });
178
- closeSync(logFd);
179
- sidecarSupervisor.child = child;
180
- child.on("error", error => {
181
- console.error(`Aurora sidecar failed to start: ${error.message}`);
182
- });
183
- child.on("exit", () => {
184
- sidecarSupervisor.child = null;
185
- setTimeout(launch, 1000);
186
- });
187
- };
188
- sidecarSupervisor = {
189
- child: null
190
- };
191
- launch();
192
- }
193
-
194
143
  async function writeCodexRuntimeFiles({ sidecar, model }) {
195
144
  await mkdir(CODEX_HOME, { recursive: true });
196
145
  const modelCatalogPath = join(CODEX_HOME, "model_catalog.json");
@@ -245,36 +194,6 @@ async function writeCodexModelCatalog(sidecar, modelCatalogPath) {
245
194
  await writeFile(join(CODEX_HOME, "models_cache.json"), `${JSON.stringify(cache, null, 2)}\n`, { mode: 0o600 });
246
195
  }
247
196
 
248
- async function waitForSidecar(info) {
249
- const deadline = Date.now() + 2500;
250
- while (Date.now() < deadline) {
251
- if (await pingSidecar(info)) return;
252
- await new Promise(resolve => setTimeout(resolve, 100));
253
- }
254
- throw new Error("Aurora sidecar did not become ready on 127.0.0.1:17878");
255
- }
256
-
257
- async function pingSidecar(info) {
258
- try {
259
- const normalized = normalizedSidecarInfo(info);
260
- const response = await fetch(`${normalized.baseURL}/aurora/status`, {
261
- headers: { authorization: `Bearer ${normalized.token}` },
262
- signal: AbortSignal.timeout(500)
263
- });
264
- return response.ok;
265
- } catch {
266
- return false;
267
- }
268
- }
269
-
270
- function normalizedSidecarInfo(info) {
271
- return {
272
- port: Number(info.port),
273
- token: String(info.token),
274
- baseURL: info.baseURL || `http://127.0.0.1:${Number(info.port)}`
275
- };
276
- }
277
-
278
197
  function selectedModelAlias(state) {
279
198
  const model = state.selectedModel?.alias;
280
199
  if (!model) throw new Error("Aurora runtime model is not selected. Select a model in Aurora Desktop > 本机调用.");
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@aurora/service",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js"
8
+ }
9
+ }
@@ -0,0 +1,64 @@
1
+ // Fallback backend: a detached, unref'd process.
2
+ //
3
+ // Used when no OS service manager is available (unknown platform, or Linux
4
+ // without systemd --user). It cannot survive a reboot, but combined with the
5
+ // sidecar's crash-only design (no process.exit on per-request errors) it stays
6
+ // up for the whole login session, which is the best a no-service host allows.
7
+ import { spawn } from "node:child_process";
8
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { openSync, closeSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { AURORA_HOME } from "../../state/src/index.js";
12
+
13
+ function pidPath() {
14
+ return join(AURORA_HOME, "sidecar.pid");
15
+ }
16
+
17
+ function alive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export async function install(spec) {
27
+ if (await isInstalled()) return;
28
+ await mkdir(dirname(spec.logPath), { recursive: true });
29
+ const logFd = openSync(spec.logPath, "a");
30
+ const child = spawn(spec.nodePath, [spec.scriptPath], {
31
+ detached: true,
32
+ stdio: ["ignore", logFd, logFd],
33
+ env: { ...process.env, AURORA_HOME: spec.auroraHome }
34
+ });
35
+ closeSync(logFd);
36
+ child.unref();
37
+ await writeFile(pidPath(), String(child.pid), { mode: 0o600 });
38
+ }
39
+
40
+ export async function uninstall() {
41
+ const pid = await readPid();
42
+ if (pid && alive(pid)) {
43
+ try {
44
+ process.kill(pid);
45
+ } catch {
46
+ // Already gone.
47
+ }
48
+ }
49
+ await rm(pidPath(), { force: true }).catch(() => {});
50
+ }
51
+
52
+ export async function isInstalled() {
53
+ const pid = await readPid();
54
+ return Boolean(pid && alive(pid));
55
+ }
56
+
57
+ async function readPid() {
58
+ try {
59
+ const pid = Number(await readFile(pidPath(), "utf8"));
60
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
@@ -0,0 +1,146 @@
1
+ // Cross-platform supervision for the Aurora sidecar.
2
+ //
3
+ // "Always connectable" is delegated to the OS service manager rather than a
4
+ // parent process: a launcher that spawns the sidecar dies with its terminal,
5
+ // orphaning the daemon with no supervisor. Each platform uses its best
6
+ // no-admin (npm-friendly) mechanism:
7
+ //
8
+ // - macOS → launchd LaunchAgent (~/Library/LaunchAgents)
9
+ // - Linux → systemd --user unit (~/.config/systemd/user)
10
+ // - Windows → Scheduled Task at logon (schtasks, no UAC)
11
+ // - other / unavailable → detached supervised process (best effort)
12
+ //
13
+ // All backends run the SAME `bin/aurora-sidecar.js` with no secret in the
14
+ // service definition — the sidecar reads its stable token from sidecar.json.
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { AURORA_LOCALHOST } from "../../protocol/src/index.js";
18
+ import { AURORA_HOME, readSidecarInfo } from "../../state/src/index.js";
19
+ import { ensureSidecarIdentity } from "../../sidecar/src/index.js";
20
+ import * as launchd from "./launchd.js";
21
+ import * as systemd from "./systemd.js";
22
+ import * as schtasks from "./schtasks.js";
23
+ import * as detached from "./detached.js";
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const packageRoot = resolve(here, "..", "..", "..");
27
+
28
+ export const SERVICE_LABEL = "com.auroraflow.sidecar";
29
+
30
+ // serviceSpec is the platform-agnostic description every backend renders into
31
+ // its own format. Absolute paths are resolved fresh each call so a node
32
+ // upgrade (nvm/brew) self-heals on the next launch.
33
+ export function serviceSpec() {
34
+ return {
35
+ label: SERVICE_LABEL,
36
+ nodePath: process.execPath,
37
+ scriptPath: join(packageRoot, "bin", "aurora-sidecar.js"),
38
+ logPath: join(AURORA_HOME, "log", "sidecar.log"),
39
+ auroraHome: AURORA_HOME
40
+ };
41
+ }
42
+
43
+ function backend() {
44
+ switch (process.platform) {
45
+ case "darwin":
46
+ return launchd;
47
+ case "linux":
48
+ return systemd;
49
+ case "win32":
50
+ return schtasks;
51
+ default:
52
+ return detached;
53
+ }
54
+ }
55
+
56
+ // ensureSidecarRunning is the single entry the launcher calls. It seeds the
57
+ // stable identity, installs/repairs the OS service, then waits until the
58
+ // sidecar answers. If the platform's service manager is unavailable, it falls
59
+ // back to a detached process so the client still gets a working sidecar.
60
+ export async function ensureSidecarRunning() {
61
+ const identity = await ensureSidecarIdentity();
62
+ if (await pingSidecar(identity)) return identity;
63
+ const spec = serviceSpec();
64
+ try {
65
+ await backend().install(spec);
66
+ } catch (error) {
67
+ if (backend() !== detached) {
68
+ console.error(`[aurora] ${process.platform} service manager unavailable (${error?.message || error}); using detached fallback.`);
69
+ await detached.install(spec);
70
+ } else {
71
+ throw error;
72
+ }
73
+ }
74
+ await waitForSidecar(identity);
75
+ return identity;
76
+ }
77
+
78
+ export async function installSidecarService() {
79
+ await ensureSidecarIdentity();
80
+ await backend().install(serviceSpec());
81
+ }
82
+
83
+ export async function uninstallSidecarService() {
84
+ await backend().uninstall(serviceSpec());
85
+ // Detached instances are not managed by the OS; stop any stray one too.
86
+ await detached.uninstall(serviceSpec()).catch(() => {});
87
+ }
88
+
89
+ export async function sidecarServiceStatus() {
90
+ const spec = serviceSpec();
91
+ let identity = null;
92
+ try {
93
+ identity = await readSidecarInfo();
94
+ } catch {
95
+ // Not seeded yet.
96
+ }
97
+ const installed = await backend().isInstalled(spec).catch(() => false);
98
+ const running = identity ? await pingSidecar(identity) : false;
99
+ return {
100
+ platform: process.platform,
101
+ backend: backendName(),
102
+ installed,
103
+ running,
104
+ port: identity?.port ?? null,
105
+ scriptPath: spec.scriptPath,
106
+ nodePath: spec.nodePath,
107
+ logPath: spec.logPath
108
+ };
109
+ }
110
+
111
+ function backendName() {
112
+ switch (process.platform) {
113
+ case "darwin":
114
+ return "launchd";
115
+ case "linux":
116
+ return "systemd";
117
+ case "win32":
118
+ return "schtasks";
119
+ default:
120
+ return "detached";
121
+ }
122
+ }
123
+
124
+ export async function pingSidecar(identity) {
125
+ try {
126
+ const port = Number(identity?.port);
127
+ const token = String(identity?.token ?? "");
128
+ if (!port || !token) return false;
129
+ const response = await fetch(`http://${AURORA_LOCALHOST}:${port}/aurora/status`, {
130
+ headers: { authorization: `Bearer ${token}` },
131
+ signal: AbortSignal.timeout(500)
132
+ });
133
+ return response.ok;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ export async function waitForSidecar(identity, timeoutMs = 5000) {
140
+ const deadline = Date.now() + timeoutMs;
141
+ while (Date.now() < deadline) {
142
+ if (await pingSidecar(identity)) return;
143
+ await new Promise(resolve => setTimeout(resolve, 100));
144
+ }
145
+ throw new Error(`Aurora sidecar did not become ready on ${AURORA_LOCALHOST}:${identity?.port}`);
146
+ }
@@ -0,0 +1,99 @@
1
+ // macOS backend: launchd LaunchAgent.
2
+ //
3
+ // KeepAlive is the unconditional boolean: launchd's { Crashed: true } variant
4
+ // does not reliably restart after a SIGKILL, which defeats the whole purpose.
5
+ // Unconditional restart guarantees "always connectable"; the only cost is that
6
+ // the rare single-instance "yield" exit (when another sidecar already owns the
7
+ // port) gets relaunched every ~10s (launchd throttle) until the peer goes away,
8
+ // which self-corrects. RunAtLoad + the LaunchAgents domain give login
9
+ // auto-start without admin rights.
10
+ import { execFile } from "node:child_process";
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { existsSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { promisify } from "node:util";
16
+
17
+ const run = promisify(execFile);
18
+
19
+ function plistPath(spec) {
20
+ return join(homedir(), "Library", "LaunchAgents", `${spec.label}.plist`);
21
+ }
22
+
23
+ function domainTarget() {
24
+ return `gui/${process.getuid()}`;
25
+ }
26
+
27
+ function serviceTarget(spec) {
28
+ return `${domainTarget()}/${spec.label}`;
29
+ }
30
+
31
+ function renderPlist(spec) {
32
+ const escape = value => String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
33
+ return `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>${escape(spec.label)}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>
40
+ <string>${escape(spec.nodePath)}</string>
41
+ <string>${escape(spec.scriptPath)}</string>
42
+ </array>
43
+ <key>EnvironmentVariables</key>
44
+ <dict>
45
+ <key>AURORA_HOME</key><string>${escape(spec.auroraHome)}</string>
46
+ </dict>
47
+ <key>RunAtLoad</key><true/>
48
+ <key>KeepAlive</key><true/>
49
+ <key>ThrottleInterval</key><integer>1</integer>
50
+ <key>ProcessType</key><string>Background</string>
51
+ <key>StandardOutPath</key><string>${escape(spec.logPath)}</string>
52
+ <key>StandardErrorPath</key><string>${escape(spec.logPath)}</string>
53
+ </dict>
54
+ </plist>
55
+ `;
56
+ }
57
+
58
+ export async function install(spec) {
59
+ const path = plistPath(spec);
60
+ const desired = renderPlist(spec);
61
+ await mkdir(dirname(path), { recursive: true });
62
+ await mkdir(dirname(spec.logPath), { recursive: true });
63
+ const current = existsSync(path) ? await readFile(path, "utf8") : null;
64
+ const loaded = await isLoaded(spec);
65
+ if (current === desired && loaded) {
66
+ // Already correct — make sure it is actually started, no-op if running.
67
+ await run("launchctl", ["kickstart", serviceTarget(spec)]).catch(() => {});
68
+ return;
69
+ }
70
+ await writeFile(path, desired, { mode: 0o644 });
71
+ // Reload cleanly: bootout the old definition (ignore if absent), bootstrap
72
+ // the new one, then kickstart to start immediately.
73
+ await run("launchctl", ["bootout", serviceTarget(spec)]).catch(() => {});
74
+ await run("launchctl", ["bootstrap", domainTarget(), path]);
75
+ await run("launchctl", ["enable", serviceTarget(spec)]).catch(() => {});
76
+ await run("launchctl", ["kickstart", "-k", serviceTarget(spec)]).catch(() => {});
77
+ }
78
+
79
+ export async function uninstall(spec) {
80
+ await run("launchctl", ["bootout", serviceTarget(spec)]).catch(() => {});
81
+ const path = plistPath(spec);
82
+ if (existsSync(path)) {
83
+ const { rm } = await import("node:fs/promises");
84
+ await rm(path, { force: true });
85
+ }
86
+ }
87
+
88
+ export async function isInstalled(spec) {
89
+ return existsSync(plistPath(spec));
90
+ }
91
+
92
+ async function isLoaded(spec) {
93
+ try {
94
+ await run("launchctl", ["print", serviceTarget(spec)]);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
@@ -0,0 +1,92 @@
1
+ // Windows backend: Scheduled Task at logon (no admin / no UAC).
2
+ //
3
+ // A real Windows Service requires elevation, which an npm global install does
4
+ // not have. A per-user Scheduled Task triggered at logon, with RestartOnFailure
5
+ // and no execution time limit, is the standard no-admin always-on mechanism.
6
+ //
7
+ // Note: node.exe started by Task Scheduler may flash a console window. That is
8
+ // cosmetic and does not affect connectivity; a windowless vbs shim can be added
9
+ // later if needed.
10
+ import { execFile } from "node:child_process";
11
+ import { mkdir, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { promisify } from "node:util";
14
+ import { AURORA_HOME } from "../../state/src/index.js";
15
+
16
+ const run = promisify(execFile);
17
+ const TASK_NAME = "AuroraSidecar";
18
+
19
+ function taskXmlPath() {
20
+ return join(AURORA_HOME, "aurora-sidecar-task.xml");
21
+ }
22
+
23
+ function renderTaskXml(spec) {
24
+ const escape = value => String(value)
25
+ .replace(/&/g, "&amp;")
26
+ .replace(/</g, "&lt;")
27
+ .replace(/>/g, "&gt;")
28
+ .replace(/"/g, "&quot;");
29
+ return `<?xml version="1.0" encoding="UTF-16"?>
30
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
31
+ <RegistrationInfo>
32
+ <Description>Aurora local sidecar</Description>
33
+ </RegistrationInfo>
34
+ <Triggers>
35
+ <LogonTrigger>
36
+ <Enabled>true</Enabled>
37
+ </LogonTrigger>
38
+ </Triggers>
39
+ <Principals>
40
+ <Principal id="Author">
41
+ <LogonType>InteractiveToken</LogonType>
42
+ <RunLevel>LeastPrivilege</RunLevel>
43
+ </Principal>
44
+ </Principals>
45
+ <Settings>
46
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
47
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
48
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
49
+ <AllowHardTerminate>true</AllowHardTerminate>
50
+ <StartWhenAvailable>true</StartWhenAvailable>
51
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
52
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
53
+ <Enabled>true</Enabled>
54
+ <Hidden>false</Hidden>
55
+ <RestartOnFailure>
56
+ <Interval>PT1M</Interval>
57
+ <Count>999</Count>
58
+ </RestartOnFailure>
59
+ </Settings>
60
+ <Actions Context="Author">
61
+ <Exec>
62
+ <Command>"${escape(spec.nodePath)}"</Command>
63
+ <Arguments>"${escape(spec.scriptPath)}"</Arguments>
64
+ </Exec>
65
+ </Actions>
66
+ </Task>
67
+ `;
68
+ }
69
+
70
+ export async function install(spec) {
71
+ const path = taskXmlPath();
72
+ await mkdir(AURORA_HOME, { recursive: true });
73
+ // Task Scheduler XML must be UTF-16 with a BOM.
74
+ await writeFile(path, "" + renderTaskXml(spec), { encoding: "utf16le" });
75
+ // /f overwrites any existing definition, self-healing a drifted node path.
76
+ await run("schtasks", ["/create", "/tn", TASK_NAME, "/xml", path, "/f"]);
77
+ await run("schtasks", ["/run", "/tn", TASK_NAME]).catch(() => {});
78
+ }
79
+
80
+ export async function uninstall() {
81
+ await run("schtasks", ["/end", "/tn", TASK_NAME]).catch(() => {});
82
+ await run("schtasks", ["/delete", "/tn", TASK_NAME, "/f"]).catch(() => {});
83
+ }
84
+
85
+ export async function isInstalled() {
86
+ try {
87
+ await run("schtasks", ["/query", "/tn", TASK_NAME]);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
@@ -0,0 +1,72 @@
1
+ // Linux backend: systemd --user unit.
2
+ //
3
+ // Restart=on-failure (not always) so the single-instance clean "yield" exit
4
+ // does not loop, while crashes restart after 1s. WantedBy=default.target gives
5
+ // login auto-start without root. If systemctl --user is unavailable (no
6
+ // systemd, containers, some WSL setups) install() throws and the dispatcher
7
+ // falls back to a detached process.
8
+ import { execFile } from "node:child_process";
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { existsSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { promisify } from "node:util";
14
+
15
+ const run = promisify(execFile);
16
+
17
+ function unitName(spec) {
18
+ // systemd unit names cannot contain dots beyond the type suffix.
19
+ return `${spec.label.replace(/\./g, "-")}.service`;
20
+ }
21
+
22
+ function unitPath(spec) {
23
+ return join(homedir(), ".config", "systemd", "user", unitName(spec));
24
+ }
25
+
26
+ function renderUnit(spec) {
27
+ return `[Unit]
28
+ Description=Aurora local sidecar
29
+ After=network.target
30
+
31
+ [Service]
32
+ Type=simple
33
+ ExecStart=${spec.nodePath} ${spec.scriptPath}
34
+ Environment=AURORA_HOME=${spec.auroraHome}
35
+ Restart=on-failure
36
+ RestartSec=1
37
+
38
+ [Install]
39
+ WantedBy=default.target
40
+ `;
41
+ }
42
+
43
+ export async function install(spec) {
44
+ // Probe availability first so an unsupported host triggers the fallback.
45
+ await run("systemctl", ["--user", "--version"]);
46
+ const path = unitPath(spec);
47
+ const desired = renderUnit(spec);
48
+ await mkdir(dirname(path), { recursive: true });
49
+ await mkdir(dirname(spec.logPath), { recursive: true });
50
+ const current = existsSync(path) ? await readFile(path, "utf8") : null;
51
+ if (current !== desired) {
52
+ await writeFile(path, desired, { mode: 0o644 });
53
+ }
54
+ await run("systemctl", ["--user", "daemon-reload"]);
55
+ await run("systemctl", ["--user", "enable", "--now", unitName(spec)]);
56
+ // Apply any unit change and ensure it is up.
57
+ await run("systemctl", ["--user", "restart", unitName(spec)]).catch(() => {});
58
+ }
59
+
60
+ export async function uninstall(spec) {
61
+ await run("systemctl", ["--user", "disable", "--now", unitName(spec)]).catch(() => {});
62
+ const path = unitPath(spec);
63
+ if (existsSync(path)) {
64
+ const { rm } = await import("node:fs/promises");
65
+ await rm(path, { force: true });
66
+ }
67
+ await run("systemctl", ["--user", "daemon-reload"]).catch(() => {});
68
+ }
69
+
70
+ export async function isInstalled(spec) {
71
+ return existsSync(unitPath(spec));
72
+ }
@@ -1,38 +1,150 @@
1
1
  import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { readFile, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { AURORA_LOCALHOST, AURORA_SIDECAR_PORT, isRuntimePath, rewriteRuntimeModel, selectedKeyHeaders, toClientModelItem, toCodexModelInfo, trimSlash } from "../../protocol/src/index.js";
5
- import { CODEX_HOME, readState, writeSidecarInfo } from "../../state/src/index.js";
6
+ import { CODEX_HOME, readSidecarInfo, readState, writeSidecarInfo } from "../../state/src/index.js";
6
7
 
7
8
  const CODEX_MODEL_CAPABILITIES_PATH = join(CODEX_HOME, "model_capabilities.json");
8
9
 
10
+ // Header / idle timeouts for the upstream gateway connection. The stream body
11
+ // has NO total timeout (long answers must not be cut), but a stalled gateway
12
+ // (half-dead connection that stops sending bytes) is detected by the idle
13
+ // timer and turned into a clean error so the client retries that one request
14
+ // instead of spinning forever.
15
+ const GATEWAY_HEADER_TIMEOUT_MS = 60000;
16
+ const GATEWAY_IDLE_TIMEOUT_MS = 120000;
17
+
18
+ // Keep the sidecar->gateway TLS connection warm. The expensive part of a cold
19
+ // request is the ~1s TLS handshake to the gateway edge; undici pools the
20
+ // connection but drops it after ~4s idle, so every think-pause re-handshakes
21
+ // ("时快时慢"). A lightweight heartbeat under that idle window keeps one pooled
22
+ // connection alive so real requests reuse a warm socket. It only runs within a
23
+ // window after real activity, so an idle/abandoned sidecar stops chattering.
24
+ const GATEWAY_KEEPALIVE_INTERVAL_MS = 3000;
25
+ const GATEWAY_KEEPWARM_WINDOW_MS = 10 * 60 * 1000;
26
+ let lastActivityAt = 0;
27
+
28
+ function markActivity() {
29
+ lastActivityAt = Date.now();
30
+ }
31
+
32
+ // ensureSidecarIdentity generates the local token once and persists it so the
33
+ // OS service, the launcher, and the running client all agree on a stable
34
+ // token/port across restarts and node upgrades. Random-per-run tokens broke
35
+ // reconnection when a long-lived service outlived the launcher that seeded it.
36
+ export async function ensureSidecarIdentity() {
37
+ let existing = null;
38
+ try {
39
+ existing = await readSidecarInfo();
40
+ } catch {
41
+ // First run — generate below.
42
+ }
43
+ const port = Number(existing?.port) || AURORA_SIDECAR_PORT;
44
+ const token = existing?.token || `aurora-local-${randomBytes(24).toString("hex")}`;
45
+ const baseURL = `http://${AURORA_LOCALHOST}:${port}`;
46
+ await writeSidecarInfo({ port, token, baseURL });
47
+ return { port, token, baseURL };
48
+ }
49
+
9
50
  export async function startSidecar(options = {}) {
10
- const token = options.token || readArg("--token") || process.env.AURORA_SIDECAR_TOKEN || "aurora-local-dev";
11
- const port = Number(options.port || readArg("--port") || process.env.AURORA_SIDECAR_PORT || AURORA_SIDECAR_PORT);
51
+ const tokenOverride = options.token || readArg("--token") || process.env.AURORA_SIDECAR_TOKEN;
52
+ const portOverride = options.port || readArg("--port") || process.env.AURORA_SIDECAR_PORT;
53
+ const identity = await ensureSidecarIdentity();
54
+ const token = tokenOverride || identity.token;
55
+ const port = Number(portOverride || identity.port);
12
56
  const server = createServer((request, response) => {
13
57
  handle(request, response, token).catch(error => {
58
+ // A streaming response has already flushed its headers, so a 500 here
59
+ // would throw ERR_HTTP_HEADERS_SENT and (via the global rejection
60
+ // handler) take the whole daemon down — that is exactly what produced
61
+ // the Claude Code "ConnectionRefused / retrying" spin. Only synthesize
62
+ // an error body when nothing has been sent yet; otherwise just close.
63
+ if (response.headersSent) {
64
+ response.end();
65
+ return;
66
+ }
14
67
  response.writeHead(500, { "content-type": "application/json" });
15
68
  response.end(JSON.stringify({ error: { type: "sidecar_error", message: error.message } }));
16
69
  });
17
70
  });
18
- server.on("error", error => {
71
+ // Streaming requests can run for many minutes; the default 5-minute
72
+ // requestTimeout would cut long turns. Disable the whole-request timeout and
73
+ // keep only the header timeout that protects against a stuck client.
74
+ server.requestTimeout = 0;
75
+ server.headersTimeout = 60000;
76
+ server.keepAliveTimeout = 75000;
77
+ server.on("error", async error => {
78
+ if (error.code === "EADDRINUSE") {
79
+ // Single-instance: another sidecar already owns the port. If it is
80
+ // healthy, yield with a clean exit (service managers keep-alive only on
81
+ // crash, so exit 0 does not loop). If it is a dead bind, exit non-zero
82
+ // so the service manager retries shortly.
83
+ if (await pingLocalSidecar(port, token)) {
84
+ console.error(`Aurora sidecar: port ${port} already served by a healthy instance; yielding.`);
85
+ process.exit(0);
86
+ }
87
+ console.error(`Aurora sidecar: port ${port} busy but unhealthy; will retry.`);
88
+ process.exit(1);
89
+ }
19
90
  console.error(`Aurora sidecar listen error on ${AURORA_LOCALHOST}:${port}: ${error.message}`);
20
91
  process.exit(1);
21
92
  });
22
93
  server.listen(port, AURORA_LOCALHOST, async () => {
23
94
  await writeSidecarInfo({ port, token, baseURL: `http://${AURORA_LOCALHOST}:${port}` });
24
95
  console.error(`Aurora sidecar listening on http://${AURORA_LOCALHOST}:${port}`);
96
+ // Open the warm window and pre-warm the gateway connection so the very
97
+ // first client request reuses an established TLS socket.
98
+ markActivity();
99
+ startGatewayKeepAlive();
25
100
  });
26
101
  }
27
102
 
103
+ function startGatewayKeepAlive() {
104
+ const beat = async () => {
105
+ if (Date.now() - lastActivityAt > GATEWAY_KEEPWARM_WINDOW_MS) return;
106
+ try {
107
+ const state = await readState();
108
+ const gatewayURL = trimSlash(state.gatewayURL ?? "https://auroramos.com");
109
+ // Same origin as proxyRuntime → same undici pool, so this keeps the edge
110
+ // TLS handshake amortized for real requests. The body MUST be drained:
111
+ // undici destroys (does not pool) a connection whose response body was
112
+ // left unconsumed, which silently defeats the warm-up. Result ignored.
113
+ const response = await fetch(gatewayURL, { signal: AbortSignal.timeout(GATEWAY_HEADER_TIMEOUT_MS) });
114
+ await response.text();
115
+ } catch {
116
+ // Best effort; a failed heartbeat just means the next real request pays
117
+ // a cold handshake, which is the pre-fix behaviour.
118
+ }
119
+ };
120
+ const timer = setInterval(beat, GATEWAY_KEEPALIVE_INTERVAL_MS);
121
+ timer.unref();
122
+ beat();
123
+ }
124
+
125
+ async function pingLocalSidecar(port, token) {
126
+ try {
127
+ const response = await fetch(`http://${AURORA_LOCALHOST}:${port}/aurora/status`, {
128
+ headers: { authorization: `Bearer ${token}` },
129
+ signal: AbortSignal.timeout(500)
130
+ });
131
+ return response.ok;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ // The sidecar is a long-lived local proxy that Claude Code / Codex depend on
138
+ // for every request. A single bad request (e.g. a mid-stream upstream drop)
139
+ // must NOT kill the process: an orphaned sidecar with no live supervisor never
140
+ // comes back, leaving the client stuck on ConnectionRefused. Log and keep
141
+ // serving; the HTTP server stays healthy for the next request.
28
142
  process.on("uncaughtException", error => {
29
143
  console.error(`Aurora sidecar uncaught exception: ${error?.stack || error?.message || error}`);
30
- process.exit(1);
31
144
  });
32
145
 
33
146
  process.on("unhandledRejection", reason => {
34
147
  console.error(`Aurora sidecar unhandled rejection: ${reason?.stack || reason?.message || reason}`);
35
- process.exit(1);
36
148
  });
37
149
 
38
150
  async function handle(request, response, token) {
@@ -62,6 +174,7 @@ async function handle(request, response, token) {
62
174
  }
63
175
 
64
176
  async function proxyModels(response, incomingURL) {
177
+ markActivity();
65
178
  const state = await readState();
66
179
  const gatewayURL = trimSlash(state.gatewayURL ?? "https://auroramos.com");
67
180
  const clientVersion = incomingURL.searchParams.get("client_version");
@@ -87,24 +200,55 @@ async function proxyModels(response, incomingURL) {
87
200
  }
88
201
 
89
202
  async function proxyRuntime(request, response, path) {
203
+ markActivity();
90
204
  const state = await readState();
91
205
  const body = await readBody(request);
92
206
  const capabilitiesByAlias = await readCodexModelCapabilities();
93
207
  const gatewayURL = trimSlash(state.gatewayURL ?? "https://auroramos.com");
94
- const upstream = await fetch(`${gatewayURL}${path}`, {
95
- method: request.method,
96
- headers: {
97
- ...copyForwardHeaders(request.headers),
98
- ...selectedKeyHeaders(state),
99
- "content-type": request.headers["content-type"] ?? "application/json",
100
- "accept-encoding": "identity"
101
- },
102
- body: rewriteRuntimeModel(body, state.selectedModel?.alias, capabilitiesByAlias)
103
- });
208
+ // One controller drives both phases: a header timeout until the gateway
209
+ // responds, then an idle timer that aborts only after a gap with no bytes.
210
+ const controller = new AbortController();
211
+ let watchdog = setTimeout(() => controller.abort(new Error("gateway header timeout")), GATEWAY_HEADER_TIMEOUT_MS);
212
+ let upstream;
213
+ try {
214
+ upstream = await fetch(`${gatewayURL}${path}`, {
215
+ method: request.method,
216
+ headers: {
217
+ ...copyForwardHeaders(request.headers),
218
+ ...selectedKeyHeaders(state),
219
+ "content-type": request.headers["content-type"] ?? "application/json",
220
+ "accept-encoding": "identity"
221
+ },
222
+ body: rewriteRuntimeModel(body, state.selectedModel?.alias, capabilitiesByAlias),
223
+ signal: controller.signal
224
+ });
225
+ } catch (error) {
226
+ clearTimeout(watchdog);
227
+ // Gateway never produced response headers (refused / DNS / header timeout).
228
+ // Nothing has been sent to the client yet, so a clean 502 lets it retry.
229
+ writeJSON(response, 502, { error: { type: "gateway_unreachable", message: error?.message || String(error) } });
230
+ return;
231
+ }
104
232
  response.writeHead(upstream.status, responseHeaders(upstream.headers));
105
233
  if (upstream.body) {
106
- for await (const chunk of upstream.body) response.write(chunk);
234
+ // Switch from header timeout to an idle timeout that resets on every chunk.
235
+ clearTimeout(watchdog);
236
+ watchdog = setTimeout(() => controller.abort(new Error("gateway idle timeout")), GATEWAY_IDLE_TIMEOUT_MS);
237
+ try {
238
+ for await (const chunk of upstream.body) {
239
+ watchdog.refresh();
240
+ response.write(chunk);
241
+ }
242
+ } catch (error) {
243
+ // Mid-stream gateway/upstream drop or idle-timeout abort. Headers are
244
+ // already flushed so we can't promote this to an error status — end the
245
+ // partial response and let the client retry that single request.
246
+ // Re-throwing here used to crash the daemon and trigger the
247
+ // ConnectionRefused spin.
248
+ console.error(`Aurora sidecar stream relay aborted: ${error?.message || error}`);
249
+ }
107
250
  }
251
+ clearTimeout(watchdog);
108
252
  response.end();
109
253
  }
110
254