diff --git a/src/utils/__tests__/claude-service-status.test.ts b/src/utils/__tests__/claude-service-status.test.ts index 5818c662f..a48376ce7 100644 --- a/src/utils/__tests__/claude-service-status.test.ts +++ b/src/utils/__tests__/claude-service-status.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import { + afterEach, describe, expect, it @@ -182,6 +183,54 @@ describe('status page response handling', () => { }); }); +describe('proxy agent wiring', () => { + const ORIGINAL_HTTPS_PROXY = process.env.HTTPS_PROXY; + + afterEach(() => { + if (ORIGINAL_HTTPS_PROXY === undefined) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = ORIGINAL_HTTPS_PROXY; + } + }); + + function captureRequest(captured: { agent: unknown }): StatusPageRequestFn { + return (options, onResponse) => { + captured.agent = (options as { agent?: unknown }).agent; + const response = Object.assign(new EventEmitter(), { + statusCode: 200, + setEncoding: () => undefined + }); + const request = Object.assign(new EventEmitter(), { + destroy: () => undefined, + end() { + onResponse(response); + response.emit('data', 'status body'); + response.emit('end'); + } + }); + + return request; + }; + } + + it('attaches a proxy agent when HTTPS_PROXY is set', async () => { + process.env.HTTPS_PROXY = 'http://proxy.example.test:8080'; + const captured: { agent: unknown } = { agent: undefined }; + + await expect(__testing.fetchStatusPagePath('/test', captureRequest(captured))).resolves.toBe('status body'); + expect(captured.agent).toBeTruthy(); + }); + + it('sends no proxy agent without HTTPS_PROXY', async () => { + delete process.env.HTTPS_PROXY; + const captured: { agent: unknown } = { agent: undefined }; + + await expect(__testing.fetchStatusPagePath('/test', captureRequest(captured))).resolves.toBe('status body'); + expect(captured.agent).toBeUndefined(); + }); +}); + describe('claude-status prefetch predicates', () => { it('detects claude-status widgets in configured lines', () => { expect(hasClaudeStatusWidgets([[{ id: '1', type: 'model' }]])).toBe(false); diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts index 1f40ef987..80e786eec 100644 --- a/src/utils/__tests__/terminal.test.ts +++ b/src/utils/__tests__/terminal.test.ts @@ -66,12 +66,8 @@ describe('terminal utils', () => { it('returns width from the immediate parent tty when available', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -83,8 +79,7 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(120); expect(mockExecFileSync.mock.calls.map(([file, args]) => `${file as string} ${(args as string[]).join(' ')}`)).toEqual([ - `ps -o ppid= -p ${process.pid}`, - 'ps -o tty= -p 1234', + `ps -o ppid= -o tty= -p ${process.ppid}`, 'stty -F /dev/ttys001 size' ]); }); @@ -92,20 +87,16 @@ describe('terminal utils', () => { it('walks ancestor processes until it finds a valid tty', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return '??\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ?\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '5678\n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { + return '5678 ??\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 5678') { - return ' ttys009 \n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 5678') { + return '1 ttys009\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys009 size') { @@ -118,16 +109,31 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(104); }); - it('falls back through stty variants when the first form returns no value', () => { + it('reads the current generation TTY even when its PPID ends the walk', () => { pinPosixPlatform(); - // Simulates BSD/macOS, where `stty -F` exits with an error; `stty -f` succeeds. + // A PPID of 0 terminates the walk after this row, not before it: + // the parent's own width must still win over the tput fallback. mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '0 ttys001\n'; + } + + if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { + return '24 120\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys003\n'; + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); + }); + + expect(getTerminalWidth()).toBe(120); + }); + + it('falls back through stty variants when the first form returns no value', () => { + pinPosixPlatform(); + // Simulates BSD/macOS, where `stty -F` exits with an error; `stty -f` succeeds. + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys003\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys003 size') { @@ -154,25 +160,21 @@ describe('terminal utils', () => { expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual(['cols']); }); - it('returns null when ancestor and fallback probes fail', () => { + it('returns null when ancestor and fallback probes all fail', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { + return '0 ttys001\n'; } if (file === 'stty') { return 'not-a-number not-a-number\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '0\n'; - } - if (file === 'tput') { throw new Error('tput unavailable'); } @@ -186,20 +188,16 @@ describe('terminal utils', () => { it('detects availability when an ancestor tty probe succeeds', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return '??\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ?\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '5678\n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { + return '5678 ??\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 5678') { - return 'ttys010\n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 5678') { + return '1 ttys010\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys010 size') { @@ -232,12 +230,8 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = '0'; mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -255,12 +249,8 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = 'wide'; mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -293,12 +283,8 @@ describe('terminal utils', () => { it('probes only once across repeated calls when a width is found', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -434,12 +420,8 @@ describe('terminal utils', () => { it('never persists a discovered numeric width to the L2 cache', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { diff --git a/src/utils/claude-service-status.ts b/src/utils/claude-service-status.ts index 5a31aef15..174837ead 100644 --- a/src/utils/claude-service-status.ts +++ b/src/utils/claude-service-status.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as https from 'https'; -import { HttpsProxyAgent } from 'https-proxy-agent'; import * as os from 'os'; import * as path from 'path'; import { z } from 'zod'; @@ -282,55 +281,76 @@ type StatusPageRequestFn = ( const requestStatusPage: StatusPageRequestFn = (options, onResponse) => https.request(options, onResponse); +async function getStatusPageRequestOptions(): Promise { + const proxyUrl = getStatusPageProxyUrl(); + + try { + let agent: https.RequestOptions['agent'] | undefined; + if (proxyUrl) { + // Loaded on demand: the agent (and its transitive module graph) is + // only needed when a proxy is actually configured, and the + // statusline entry point re-runs on every repaint (#397). + const { HttpsProxyAgent: ProxyAgent } = await import('https-proxy-agent'); + agent = new ProxyAgent(proxyUrl); + } + + return { + hostname: STATUS_HOST, + path: '', + method: 'GET', + timeout: STATUS_TIMEOUT_MS, + ...(agent ? { agent } : {}) + }; + } catch { + return null; + } +} + function fetchStatusPagePath( pathName: string, requestFn: StatusPageRequestFn = requestStatusPage ): Promise { - return new Promise((resolve) => { - let settled = false; + return getStatusPageRequestOptions().then((baseOptions) => { + if (!baseOptions) { + return null; + } - const finish = (value: string | null) => { - if (settled) { - return; - } - settled = true; - resolve(value); - }; + return new Promise((resolve) => { + let settled = false; - let requestOptions: https.RequestOptions; - try { - const proxyUrl = getStatusPageProxyUrl(); - requestOptions = { - hostname: STATUS_HOST, - path: pathName, - method: 'GET', - timeout: STATUS_TIMEOUT_MS, - ...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {}) + const finish = (value: string | null) => { + if (settled) { + return; + } + settled = true; + resolve(value); }; - } catch { - finish(null); - return; - } - const request = requestFn(requestOptions, (response) => { - let data = ''; - response.setEncoding('utf8'); - response.on('data', (chunk: string) => { - data += chunk; - }); - response.on('end', () => { - finish(response.statusCode === 200 && data ? data : null); + const requestOptions: https.RequestOptions = { + ...baseOptions, + path: pathName + }; + + const request = requestFn(requestOptions, (response) => { + let data = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + data += chunk; + }); + response.on('end', () => { + finish(response.statusCode === 200 && data ? data : null); + }); + response.on('aborted', () => { finish(null); }); + response.on('error', () => { finish(null); }); }); - response.on('aborted', () => { finish(null); }); - response.on('error', () => { finish(null); }); - }); - request.on('error', () => { finish(null); }); - request.on('timeout', () => { - request.destroy(); - finish(null); + request.on('error', () => { finish(null); }); + request.on('timeout', () => { + request.destroy(); + finish(null); + }); + request.end(); }); - request.end(); }); } diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 5faf9ae7d..eb8c4d50b 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -54,25 +54,27 @@ function probeTerminalWidth(): number | null { // Claude Code can spawn ccstatusline with piped stdio, leaving the immediate // parent process without a controlling TTY. Walk up a few ancestors until we - // find the shell process that owns the real PTY. - let pid = process.pid; + // find the shell process that owns the real PTY. process.ppid starts the walk + // at generation 1 for free, matching the old two-spawn walk's generation + // coverage (1..8). + let pid = process.ppid; for (let depth = 0; depth < 8; depth += 1) { - const parentPid = getParentProcessId(pid); - if (parentPid === null) { - break; + const ancestor = getProcessAncestorInfo(pid); + + // Try this generation's TTY even when the PPID looks terminal: a PPID of + // 0 must not hide a perfectly good width on the same ps row. + if (ancestor.tty !== null) { + const width = getWidthForTTY(ancestor.tty); + if (width !== null) { + return width; + } } - pid = parentPid; - - const tty = getTTYForProcess(pid); - if (tty === null) { - continue; + if (ancestor.parentPid === null) { + break; } - const width = getWidthForTTY(tty); - if (width !== null) { - return width; - } + pid = ancestor.parentPid; } // Fallback: try tput cols which might work in some environments @@ -100,35 +102,35 @@ function parsePositiveInteger(value: string): number | null { return parsed; } -function getParentProcessId(pid: number): number | null { - try { - const parentPidOutput = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - windowsHide: true - }).trim(); - - return parsePositiveInteger(parentPidOutput); - } catch { - return null; - } +interface ProcessAncestorInfo { + parentPid: number | null; + tty: string | null; } -function getTTYForProcess(pid: number): string | null { +function getProcessAncestorInfo(pid: number): ProcessAncestorInfo { + // One ps spawn answers both questions of the ancestor walk: the parent pid + // and the controlling TTY. The probe re-runs per render on macOS (numeric + // widths are deliberately not persisted across processes), and under + // several concurrent sessions each saved subprocess is saved per repaint + // (see #397). Two -o flags rather than one comma list: FreeBSD's parser + // treats everything after the first '=' as a single header, so the comma + // form collapses into one bogus column. try { - const tty = execFileSync('ps', ['-o', 'tty=', '-p', String(pid)], { + const output = execFileSync('ps', ['-o', 'ppid=', '-o', 'tty=', '-p', String(pid)], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true - }).replace(/\s+/g, ''); + }).trim(); - if (!tty || tty === '??' || tty === '?') { - return null; - } + const fields = output.split(/\s+/); + const tty = fields[1] ?? ''; - return tty; + return { + parentPid: parsePositiveInteger(fields[0] ?? ''), + tty: !tty || tty === '?' || tty === '??' ? null : tty + }; } catch { - return null; + return { parentPid: null, tty: null }; } } diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 964ba8be4..315212dde 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'; import { createHash } from 'crypto'; import * as fs from 'fs'; import * as https from 'https'; -import { HttpsProxyAgent } from 'https-proxy-agent'; +import type { HttpsProxyAgent } from 'https-proxy-agent'; import * as os from 'os'; import * as path from 'path'; import { z } from 'zod'; @@ -719,10 +719,19 @@ function getUsageApiProxyUrl(): string | null { return proxyUrl ?? null; } -function getUsageApiRequestOptions(token: string): https.RequestOptions | null { +async function getUsageApiRequestOptions(token: string): Promise { const proxyUrl = getUsageApiProxyUrl(); try { + let agent: InstanceType | undefined; + if (proxyUrl) { + // Loaded on demand: the agent (and its transitive module graph) only + // matters when a proxy is actually configured, and the statusline + // entry point is re-run on every repaint (#397). + const { HttpsProxyAgent: ProxyAgent } = await import('https-proxy-agent'); + agent = new ProxyAgent(proxyUrl); + } + return { hostname: USAGE_API_HOST, path: USAGE_API_PATH, @@ -732,7 +741,7 @@ function getUsageApiRequestOptions(token: string): https.RequestOptions | null { 'anthropic-beta': 'oauth-2025-04-20' }, timeout: USAGE_API_TIMEOUT_MS, - ...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {}) + ...(agent ? { agent } : {}) }; } catch { return null; @@ -740,6 +749,11 @@ function getUsageApiRequestOptions(token: string): https.RequestOptions | null { } async function fetchFromUsageApi(token: string): Promise { + const requestOptions = await getUsageApiRequestOptions(token); + if (!requestOptions) { + return { kind: 'error' }; + } + return new Promise((resolve) => { let settled = false; @@ -751,12 +765,6 @@ async function fetchFromUsageApi(token: string): Promise { resolve(value); }; - const requestOptions = getUsageApiRequestOptions(token); - if (!requestOptions) { - finish({ kind: 'error' }); - return; - } - const request = https.request(requestOptions, (response) => { let data = ''; response.setEncoding('utf8');