fix(worker): add JSON status output for hook framework (#655)

* fix(worker): add JSON status output for hook framework (#638)

Adds JSON output before all process.exit() calls in the start command
so Claude Code's hook framework can track worker startup progress.

- Add exitWithStatus() helper function
- Output {"continue":true,"suppressOutput":true,"status":"ready"|"error"}
- Maintains exit code 0 for Windows Terminal compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(worker): add unit tests for buildStatusOutput function

Extract buildStatusOutput() as pure function for testability and add
comprehensive unit tests validating JSON structure for hook framework.

Tests cover:
- Ready/error status variants
- Required fields (continue, suppressOutput, status)
- Optional message field inclusion logic
- Edge cases (empty strings, special characters, long messages)
- JSON serialization roundtrip

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(worker): add CLI output capture tests for start command

Add integration tests that spawn actual worker-service start command
and verify JSON output matches hook framework contract.

Tests:
- Valid JSON with required fields (continue, suppressOutput, status)
- JSON structure matches expected format when worker healthy
- Skipped placeholders for error scenarios requiring complex setup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(worker): add Claude Code hook framework compatibility tests

Add contract tests documenting the hook framework requirements:
- Exit code 0 (Windows Terminal compatibility)
- JSON output on stdout (not stderr)
- Valid JSON structure with required fields
- Critical 'continue: true' for Claude Code to proceed
- 'suppressOutput: true' for transcript mode

These tests serve as living documentation of the hook output contract,
explaining both WHAT and WHY for each requirement.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-01-10 03:11:42 -05:00
committed by GitHub
parent e6df88bf42
commit 644cccd3e1
3 changed files with 560 additions and 90 deletions
+39 -15
View File
@@ -77,6 +77,30 @@ import { LogsRoutes } from './worker/http/routes/LogsRoutes.js';
// Re-export updateCursorContextForProject for SDK agents
export { updateCursorContextForProject };
/**
* Build JSON status output for hook framework communication.
* This is a pure function extracted for testability.
*
* @param status - 'ready' for successful startup, 'error' for failures
* @param message - Optional error message (only included when provided)
* @returns JSON object with continue, suppressOutput, status, and optionally message
*/
export interface StatusOutput {
continue: true;
suppressOutput: true;
status: 'ready' | 'error';
message?: string;
}
export function buildStatusOutput(status: 'ready' | 'error', message?: string): StatusOutput {
return {
continue: true,
suppressOutput: true,
status,
...(message && { message })
};
}
export class WorkerService {
private server: Server;
private startTime: number = Date.now();
@@ -622,6 +646,14 @@ async function main() {
const command = process.argv[2];
const port = getWorkerPort();
// Helper for JSON status output in 'start' command
// Exit code 0 ensures Windows Terminal doesn't keep tabs open
function exitWithStatus(status: 'ready' | 'error', message?: string): never {
const output = buildStatusOutput(status, message);
console.log(JSON.stringify(output));
process.exit(0);
}
switch (command) {
case 'start': {
if (await waitForHealth(port, 1000)) {
@@ -636,14 +668,12 @@ async function main() {
const freed = await waitForPortFree(port, getPlatformTimeout(15000));
if (!freed) {
logger.error('SYSTEM', 'Port did not free up after shutdown for version mismatch restart', { port });
// Exit gracefully: Windows Terminal won't keep tab open on exit 0
// The wrapper/plugin will handle restart logic if needed
process.exit(0);
exitWithStatus('error', 'Port did not free after version mismatch restart');
}
removePidFile();
} else {
logger.info('SYSTEM', 'Worker already running and healthy');
process.exit(0);
exitWithStatus('ready');
}
}
@@ -653,21 +683,17 @@ async function main() {
const healthy = await waitForHealth(port, getPlatformTimeout(15000));
if (healthy) {
logger.info('SYSTEM', 'Worker is now healthy');
process.exit(0);
exitWithStatus('ready');
}
logger.error('SYSTEM', 'Port in use but worker not responding to health checks');
// Exit gracefully: Windows Terminal won't keep tab open on exit 0
// The wrapper/plugin will handle restart logic if needed
process.exit(0);
exitWithStatus('error', 'Port in use but worker not responding');
}
logger.info('SYSTEM', 'Starting worker daemon');
const pid = spawnDaemon(__filename, port);
if (pid === undefined) {
logger.error('SYSTEM', 'Failed to spawn worker daemon');
// Exit gracefully: Windows Terminal won't keep tab open on exit 0
// The wrapper/plugin will handle restart logic if needed
process.exit(0);
exitWithStatus('error', 'Failed to spawn worker daemon');
}
writePidFile({ pid, port, startedAt: new Date().toISOString() });
@@ -676,13 +702,11 @@ async function main() {
if (!healthy) {
removePidFile();
logger.error('SYSTEM', 'Worker failed to start (health check timeout)');
// Exit gracefully: Windows Terminal won't keep tab open on exit 0
// The wrapper/plugin will handle restart logic if needed
process.exit(0);
exitWithStatus('error', 'Worker failed to start (health check timeout)');
}
logger.info('SYSTEM', 'Worker started successfully');
process.exit(0);
exitWithStatus('ready');
}
case 'stop': {