Skip to content
Merged
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
71 changes: 71 additions & 0 deletions src/__tests__/serve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
describe,
expect,
it
} from 'vitest';

import {
createProcessServeDependencies,
handleServeLine
} from '../serve';
import type { RenderInvocation } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import { DEFAULT_SETTINGS } from '../types/Settings';
import type { LoadedSettings } from '../utils/config';

const MINIMAL_LINES: Settings['lines'] = [[{ id: 'model', type: 'model' }]];

const testDeps = {
loadSettings: (): Promise<LoadedSettings> => Promise.resolve({
settings: { ...DEFAULT_SETTINGS, lines: MINIMAL_LINES },
loadError: null
}),
resolveTerminalWidth: () => null,
buildInvocation: (terminalWidth: number | null): RenderInvocation => ({
configPath: '/tmp/test-settings.json',
cwd: '/tmp',
env: {},
terminalWidth
})
};

describe('handleServeLine', () => {
it('renders a valid payload into a JSON response with the status text', async () => {
const response = await handleServeLine(
JSON.stringify({ model: { id: 'claude-test-model' }, cwd: '/tmp' }),
testDeps
);

const parsed = JSON.parse(response) as { text?: string; error?: string };
expect(parsed.error).toBeUndefined();
expect(parsed.text).toContain('claude-test-model');
});

it('answers invalid JSON with an error object instead of crashing', async () => {
const response = await handleServeLine('{not json', testDeps);

const parsed = JSON.parse(response) as { text?: string; error?: string };
expect(parsed.text).toBeUndefined();
expect(parsed.error).toContain('invalid JSON');
});

it('answers schema-invalid payloads with an error object', async () => {
const response = await handleServeLine(JSON.stringify({ model: 42 }), testDeps);

const parsed = JSON.parse(response) as { text?: string; error?: string };
expect(parsed.error).toContain('invalid status JSON');
});

it('returns no response for blank input lines', async () => {
expect(await handleServeLine('', testDeps)).toBe('');
expect(await handleServeLine(' \n', testDeps)).toBe('');
});

it('wires process dependencies that read the real config path', () => {
const deps = createProcessServeDependencies();
const invocation = deps.buildInvocation(null);

expect(invocation.terminalWidth).toBeNull();
expect(typeof deps.loadSettings).toBe('function');
});
});
8 changes: 8 additions & 0 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { renderStatusLines } from './render';
import { runServeLoop } from './serve';
import type { StatusJSON } from './types/StatusJSON';
import { StatusJSONSchema } from './types/StatusJSON';
import {
Expand Down Expand Up @@ -149,6 +150,13 @@ async function main() {
return;
}

// Serve mode (#14): NDJSON requests on stdin, JSON responses on stdout,
// one long-lived process instead of a process per repaint.
if (process.argv.includes('--serve')) {
await runServeLoop();
return;
}

// Non-interactive CLI subcommands (#602): args present + TTY stdin means an
// agent or human is configuring the tool, not rendering a status line.
// Piped stdin (Claude Code) keeps the render path regardless of args.
Expand Down
85 changes: 85 additions & 0 deletions src/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as readline from 'node:readline';

import { renderStatusLines } from './render';
import type { RenderInvocation } from './types/RenderContext';
import type { StatusJSON } from './types/StatusJSON';
import { StatusJSONSchema } from './types/StatusJSON';
import type { LoadedSettings } from './utils/config';
import {
getConfigPath,
loadSettingsFrom
} from './utils/config';
import { getTerminalWidth } from './utils/terminal';

// Dependencies of the serve loop, injectable so tests can run hermetically
// (fixed settings, no terminal probe) while production wires process state.
export interface ServeDependencies {
loadSettings: () => Promise<LoadedSettings>;
resolveTerminalWidth: (sessionId: string | undefined, ttlSeconds: number) => number | null;
buildInvocation: (terminalWidth: number | null) => RenderInvocation;
}

export function createProcessServeDependencies(): ServeDependencies {
return {
loadSettings: () => loadSettingsFrom(getConfigPath()),
resolveTerminalWidth: (sessionId, ttlSeconds) => getTerminalWidth({ sessionId, ttlSeconds }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-probe terminal width for each served request

When the serve process can discover a numeric terminal width, the first request sets the module-level hasProbed cache in getTerminalWidth; every later request returns that value immediately (src/utils/terminal.ts:197-200). Because this mode keeps the process alive, resizing the terminal leaves truncation and flex layout stuck at the initial width, unlike the one-shot path where each repaint starts with a fresh cache. Reset or otherwise refresh the terminal-width probe between served requests.

Useful? React with 👍 / 👎.

buildInvocation: terminalWidth => ({
configPath: getConfigPath(),
cwd: process.cwd(),
env: { ...process.env },
terminalWidth
})
};
}

/**
* Handle one newline-delimited request of the serve loop (#14): parse the
* payload, render through the request-scoped orchestration (#15), and return
* one JSON response line. Blank input lines return '' (no response); parse or
* validation failures return {"error": ...} instead of killing the process, so
* one bad session cannot take down the shared runner.
*/
export async function handleServeLine(line: string, deps: ServeDependencies): Promise<string> {
const trimmed = line.trim();
if (trimmed === '') {
return '';
}

let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch (error) {
return JSON.stringify({ error: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` });
}

const result = StatusJSONSchema.safeParse(parsed);
if (!result.success) {
return JSON.stringify({ error: `invalid status JSON: ${result.error.message}` });
}

try {
const data: StatusJSON = result.data;
const loaded = await deps.loadSettings();
const terminalWidth = deps.resolveTerminalWidth(data.session_id, loaded.settings.terminalWidthCacheTtlSeconds);
const { text } = await renderStatusLines(data, loaded, deps.buildInvocation(terminalWidth));
return JSON.stringify({ text });
} catch (error) {
return JSON.stringify({ error: `render failed: ${error instanceof Error ? error.message : String(error)}` });
}
}

/**
* Long-lived serve mode (--serve): NDJSON requests on stdin (one status JSON
* payload per line), one JSON response line per request on stdout. The process
* stays alive across requests, so module loading, startup I/O, and code are
* paid once instead of per repaint.
*/
export async function runServeLoop(deps: ServeDependencies = createProcessServeDependencies()): Promise<void> {
const rl = readline.createInterface({ input: process.stdin });
for await (const line of rl) {
const response = await handleServeLine(line, deps);
Comment on lines +79 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Evict expired custom-command cache entries in serve mode

When customCommandCacheTtlSeconds is enabled, each distinct session ID, command, and terminal width creates an entry in the process-global customCommandCache (src/utils/custom-command.ts:59,82-88,331), but expired or inactive keys are never removed. The new long-lived request loop prevents those entries—and cached command output of up to 16 KiB each—from being reclaimed when sessions end, so a daemon serving many sessions can grow without bound. Bound or periodically prune this cache for the lifetime of the serve loop.

Useful? React with 👍 / 👎.

if (response !== '') {
console.log(response);
}
}
}
Loading