Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
- [2026-07-17] drop the one-shot home migration
- [2026-07-20] replace an unreachable daemon holding the port, fixes #3
- [2026-07-17] [drop the one-shot home migration](https://github.com/RubricLab/tokenmaxx/commit/f1cbbe575fb51a2b5bd9b9a3c2415900a31ccd0d)
- [2026-07-17] [home is ~/.tokenmaxx](https://github.com/RubricLab/tokenmaxx/commit/ae233348f9287b5724b8b66a78fc700a9687f40b)
- [2026-07-17] [exit when the terminal goes away, fixes #2](https://github.com/RubricLab/tokenmaxx/commit/33e9e1b1e2c1cd5a3948675dc3ca298da2a7ed88)
- [2026-07-17] [update rubriclab package](https://github.com/RubricLab/tokenmaxx/commit/6fbdedfde9cb80257a9ce7085747f93d9f1f421c)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.42"
"version": "0.0.43"
}
85 changes: 78 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from './ipc.ts'
import { AccountManager } from './manager.ts'
import { type ApplicationPaths, applicationPaths, ensureApplicationPaths } from './paths.ts'
import { proxyIdentity } from './proxy.ts'
import { createStateStore, type StateStore } from './storage.ts'
import { renderDashboard } from './ui.ts'
import { createMacOsKeychainVault } from './vault.ts'
Expand Down Expand Up @@ -129,6 +130,56 @@ async function commandOutput(command: readonly string[]): Promise<string> {
return stdout.trim() || stderr.trim()
}

async function portOwnerProcessId(port: number): Promise<number | null> {
const output = await commandOutput(['lsof', '-nP', '-t', `-iTCP:${port}`, '-sTCP:LISTEN'])
const processId = Number(output.split('\n')[0])
return Number.isInteger(processId) && processId > 0 ? processId : null
}

async function portFreed(port: number, deadlineMilliseconds: number): Promise<boolean> {
const deadline = Date.now() + deadlineMilliseconds
while ((await proxyIdentity(port)) !== null) {
if (Date.now() > deadline) {
return false
}
await Bun.sleep(200)
}
return true
}

async function replacePortOccupant(port: number): Promise<void> {
const identity = await proxyIdentity(port)
if (identity === null) {
return
}
const processId = await portOwnerProcessId(port)
if (identity === 'foreign' || processId === null) {
throw new ApplicationError(
'PROXY_PORT_IN_USE',
`Port ${port} is in use by another program${processId === null ? '' : ` (process ${processId})`}; stop it or set TOKENMAXX_PROXY_PORT`
)
}
process.stdout.write(
`Replacing an unreachable tokenmaxx daemon (process ${processId}) holding port ${port}…\n`
)
try {
process.kill(processId, 'SIGTERM')
} catch {}
if (await portFreed(port, 5_000)) {
return
}
try {
process.kill(processId, 'SIGKILL')
} catch {}
if (await portFreed(port, 2_000)) {
return
}
throw new ApplicationError(
'PROXY_PORT_IN_USE',
`Port ${port} is still held by process ${processId}`
)
}

const CommandSchema = z.array(z.string())
const EmptyResultSchema = z.unknown()

Expand Down Expand Up @@ -279,6 +330,7 @@ async function startDaemon(context: ApplicationContext): Promise<void> {
if (await managerAvailable(context.paths.managerSocket)) {
return
}
await replacePortOccupant(context.paths.proxyPort)
await mkdir(context.paths.runtime, { mode: 0o700, recursive: true })
const entrypoint = process.argv[1]
if (entrypoint === undefined) {
Expand Down Expand Up @@ -619,9 +671,17 @@ async function doctor(context: ApplicationContext): Promise<void> {
process.stdout.write(`${Bun.which('security') === null ? 'missing' : 'ok '} security\n`)
const running = await managerAvailable(context.paths.managerSocket)
const daemonVersion = running ? await managerVersion(context.paths.managerSocket) : null
process.stdout.write(
`${running ? 'running' : 'stopped'} manager daemon${daemonVersion === null ? '' : ` (${daemonVersion})`}\n`
)
const unreachable = !running && (await proxyIdentity(context.paths.proxyPort)) === 'tokenmaxx'
if (unreachable) {
const processId = await portOwnerProcessId(context.paths.proxyPort)
process.stdout.write(
`warning manager daemon${processId === null ? '' : ` (process ${processId})`} holds port ${context.paths.proxyPort} but does not answer — tokenmaxx daemon start replaces it\n`
)
} else {
process.stdout.write(
`${running ? 'running' : 'stopped'} manager daemon${daemonVersion === null ? '' : ` (${daemonVersion})`}\n`
)
}
const update = await availableUpdate()
process.stdout.write(
update === null
Expand Down Expand Up @@ -793,11 +853,22 @@ export async function runCli(rawArguments: readonly string[]): Promise<number> {
case 'stop':
await stopDaemon(context)
return 0
case 'status':
process.stdout.write(
`${(await managerAvailable(context.paths.managerSocket)) ? 'running' : 'stopped'}\n`
)
case 'status': {
if (await managerAvailable(context.paths.managerSocket)) {
process.stdout.write('running\n')
return 0
}
const port = context.paths.proxyPort
if ((await proxyIdentity(port)) === 'tokenmaxx') {
const processId = await portOwnerProcessId(port)
process.stdout.write(
`unreachable — a tokenmaxx daemon${processId === null ? '' : ` (process ${processId})`} holds port ${port} but does not answer; tokenmaxx daemon start replaces it\n`
)
return 1
}
process.stdout.write('stopped\n')
return 0
}
default:
throw new ApplicationError('USAGE', 'Usage: daemon <start|run|stop|status>')
}
Expand Down
40 changes: 39 additions & 1 deletion src/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { createUsageObserver } from './proxy.ts'
import { createUsageObserver, proxyIdentity, startProxy } from './proxy.ts'

type Observed = {
model: string | null
Expand Down Expand Up @@ -115,3 +115,41 @@ describe('createUsageObserver', () => {
expect(observe('openai', body)?.outputTokens).toBe(2)
})
})

describe('proxyIdentity', () => {
test('recognizes any tokenmaxx proxy by its unknown-route response', async () => {
const proxy = startProxy({
source: { refresh: async () => undefined, resolve: async () => null }
})
expect(await proxyIdentity(proxy.port)).toBe('tokenmaxx')
await proxy.stop()
})

test('reports a foreign listener without claiming it', async () => {
const server = Bun.serve({
fetch: () => new Response('hello'),
hostname: '127.0.0.1',
port: 0
})
const port = server.port
if (port === undefined) {
throw new Error('server did not bind')
}
expect(await proxyIdentity(port)).toBe('foreign')
await server.stop(true)
})

test('reports a free port as nothing listening', async () => {
const server = Bun.serve({
fetch: () => new Response(''),
hostname: '127.0.0.1',
port: 0
})
const port = server.port
if (port === undefined) {
throw new Error('server did not bind')
}
await server.stop(true)
expect(await proxyIdentity(port)).toBe(null)
})
})
16 changes: 15 additions & 1 deletion src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@ function observeStream(
)
}

const proxyFingerprint = 'tokenmaxx proxy'

export async function proxyIdentity(port: number): Promise<'tokenmaxx' | 'foreign' | null> {
try {
const response = await fetch(`http://127.0.0.1:${port}/`, {
signal: AbortSignal.timeout(1_000)
})
const body = await response.text()
return body.startsWith(proxyFingerprint) ? 'tokenmaxx' : 'foreign'
} catch {
return null
}
}

const strippedRequestHeaders = [
'host',
'connection',
Expand Down Expand Up @@ -268,7 +282,7 @@ function createProxyHandler(options: ProxyOptions): ProxyHandler {
const url = new URL(request.url)
const route = routeProvider(url.pathname)
if (route === null) {
return new Response('tokenmaxx proxy: unknown route\n', { status: 404 })
return new Response(`${proxyFingerprint}: unknown route\n`, { status: 404 })
}
const body =
request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer()
Expand Down
Loading