diff --git a/README.md b/README.md
index 2db2185a..cd5e1bb5 100644
--- a/README.md
+++ b/README.md
@@ -132,7 +132,16 @@ Most users never touch this — defaults are sensible.
| `GEMINI_HOME` | Override Gemini CLI directory | `~/.gemini` |
| `TOKENTRACKER_GROK_HOME` | Override Grok Build directory | `~/.grok` |
-To force a dashboard port: `PORT=7700 tokentracker serve` (otherwise it auto-picks the next free port from `7680`).
+To force a dashboard port, use your shell's environment-variable syntax (otherwise TokenTracker auto-picks the next free port from `7680`):
+
+```bash
+PORT=7700 npx --yes @ipv9/tokentracker-cli serve
+```
+
+```powershell
+$env:PORT = 7700
+npx --yes @ipv9/tokentracker-cli serve
+```
Browser auto-open is opt-in: `tokentracker serve --open`. Background services and headless shells should use the default no-open behavior and open the printed URL manually.
@@ -185,7 +194,15 @@ If a tool you use shows as not configured, run `tokentracker activate-if-needed`
-The server auto-picks the next free port (`7681`, `7682`, …) and logs it on startup. To force one: `PORT=7700 tokentracker serve`. To see what's holding the port: `lsof -i :7680`.
+The server auto-picks the next free port (`7681`, `7682`, …) and logs it on startup. To force one, use the Bash or PowerShell command in [Configuration](#configuration). To see what's holding port `7680`:
+
+```bash
+lsof -i :7680
+```
+
+```powershell
+Get-NetTCPConnection -LocalPort 7680 -ErrorAction SilentlyContinue
+```
@@ -213,17 +230,23 @@ tokentracker doctor
-Windows / PowerShell service starts but no browser opens
+Windows / PowerShell: start TokenTracker with npx
-This is expected for background tasks. Scheduled tasks and the Windows tray app run:
+For an interactive PowerShell session:
+
+```powershell
+npx --yes @ipv9/tokentracker-cli serve --open
+```
+
+For headless PowerShell, CI, or a background process, keep browser opening disabled:
```powershell
-tokentracker serve --no-sync --no-open
+npx --yes @ipv9/tokentracker-cli serve --no-sync --no-open
```
-Open the printed local URL in your browser or use the tray app WebView. For an interactive PowerShell session, use `tokentracker serve --open`; TokenTracker uses the Windows shell opener instead of `xdg-open`.
+TokenTracker never stops another process to free a port. When no port is specified it tries the next one up (`7681`, `7682`, …); an explicit `--port` or `$env:PORT` that is already in use fails startup and prints an alternative command to run.
diff --git a/src/commands/serve.js b/src/commands/serve.js
index 946c86d2..77631328 100644
--- a/src/commands/serve.js
+++ b/src/commands/serve.js
@@ -2,7 +2,6 @@ const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const fssync = require("node:fs");
-const cp = require("node:child_process");
const { resolveTrackerPaths } = require("../lib/tracker-paths");
const { createLocalApiHandler, resolveQueuePath } = require("../lib/local-api");
@@ -20,7 +19,7 @@ const NPM_PACKAGE_NAME = "@ipv9/tokentracker-cli";
const LOCAL_BIND_HOST = "127.0.0.1";
function buildPortInUseHint(port) {
- return `Port ${port} is still in use after cleanup. Try: npx ${NPM_PACKAGE_NAME} serve --port ${port + 1}\n`;
+ return `Port ${port} is unavailable. Try: npx ${NPM_PACKAGE_NAME} serve --port ${port + 1}\n`;
}
function isPortUnavailableError(error) {
@@ -155,7 +154,6 @@ async function cmdServe(argv) {
try {
port = await listenOnAvailablePort(server, opts.port, {
allowFallback: !opts.portExplicit,
- ensurePortFreeFn: opts.portExplicit ? ensurePortFree : null,
onRetry: (failedPort) => {
process.stdout.write(`Port ${failedPort} unavailable, trying ${failedPort + 1}...\n`);
},
@@ -208,47 +206,6 @@ async function cmdServe(argv) {
await new Promise(() => {});
}
-function findPidOnPort(port) {
- try {
- const out = cp.execFileSync("lsof", ["-ti", `tcp:${port}`], { encoding: "utf8", timeout: 5000 });
- const pids = out.trim().split(/\s+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
- return pids;
- } catch (_e) {
- return [];
- }
-}
-
-async function ensurePortFree(port) {
- const pids = findPidOnPort(port);
- if (pids.length === 0) return;
-
- // Don't kill ourselves
- const self = process.pid;
- const targets = pids.filter((p) => p !== self);
- if (targets.length === 0) return;
-
- process.stdout.write(`Stopping previous server on port ${port} (pid ${targets.join(", ")})...\n`);
- for (const pid of targets) {
- try {
- process.kill(pid, "SIGTERM");
- } catch (_e) {}
- }
-
- // Wait briefly for port to free up
- for (let i = 0; i < 10; i++) {
- await new Promise((r) => setTimeout(r, 300));
- if (findPidOnPort(port).length === 0) return;
- }
-
- // Force kill if still alive
- for (const pid of targets) {
- try {
- process.kill(pid, "SIGKILL");
- } catch (_e) {}
- }
- await new Promise((r) => setTimeout(r, 500));
-}
-
function listenOnce(server, port, host) {
return new Promise((resolve, reject) => {
let settled = false;
@@ -283,7 +240,6 @@ async function listenOnAvailablePort(
host = LOCAL_BIND_HOST,
allowFallback = false,
maxAttempts = DEFAULT_MAX_PORT_ATTEMPTS,
- ensurePortFreeFn = null,
onRetry = null,
} = {},
) {
@@ -292,10 +248,6 @@ async function listenOnAvailablePort(
let lastError = null;
for (let i = 0; i < attempts && port < 65536; i++, port++) {
- if (ensurePortFreeFn) {
- await ensurePortFreeFn(port);
- }
-
try {
await listenOnce(server, port, host);
return port;
diff --git a/test/serve-port-hint.test.js b/test/serve-port-hint.test.js
index a23372b0..6c33655a 100644
--- a/test/serve-port-hint.test.js
+++ b/test/serve-port-hint.test.js
@@ -14,7 +14,7 @@ test("serve port collision hint references the published npm package name", () =
assert.equal(NPM_PACKAGE_NAME, "@ipv9/tokentracker-cli");
assert.equal(
buildPortInUseHint(7681),
- "Port 7681 is still in use after cleanup. Try: npx @ipv9/tokentracker-cli serve --port 7682\n",
+ "Port 7681 is unavailable. Try: npx @ipv9/tokentracker-cli serve --port 7682\n",
);
});
@@ -52,6 +52,19 @@ test("serve default startup falls through to the next available port", async (t)
assert.equal(selectedPort, occupiedPort + 1);
});
+test("explicit port fails without stopping the process already holding it", async (t) => {
+ const occupied = http.createServer((_req, res) => res.end("occupied"));
+ await new Promise((resolve) => occupied.listen(0, "127.0.0.1", resolve));
+ t.after(() => closeServer(occupied));
+
+ const server = http.createServer();
+ t.after(() => closeServer(server));
+ const port = occupied.address().port;
+
+ await assert.rejects(() => listenOnAvailablePort(server, port), { code: "EADDRINUSE" });
+ assert.equal(occupied.listening, true);
+});
+
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => {