#!/usr/bin/env node import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { execFileSync, spawn } from 'node:child_process'; function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg.startsWith('--')) { out._ = out._ || []; out._.push(arg); continue; } const key = arg.slice(2); if (key === 'dry-run') { out[key] = true; continue; } const value = argv[i + 1]; if (!value || value.startsWith('--')) { throw new Error(`Missing value for --${key}`); } out[key] = value; i += 1; } return out; } function usage() { return `Usage: node development/_reference/ai/scripts/run-pi-worker.mjs \\ --version v0.1.0 \\ --name "Add calm reset flow" \\ --task "Implement only the concrete worker task described by Codex." Options: --version Required development version, for example v0.6.47. --name Optional run label. Defaults to a slug from --task. --task Worker assignment text. Required unless --prompt-file is used. --prompt-file Optional file with a longer Codex-authored assignment. --session-id Optional Pi session id. Defaults to a stable version/name id. --model Optional Pi model. Defaults to zai/glm-5.2. --thinking Optional Pi thinking level. Defaults to xhigh, which Z.ai maps to reasoning_effort=max. --pi Optional pi binary path. Defaults to PI_BIN or pi. --pi-mode Optional Pi mode: json or text. Defaults to json. --runs-dir Optional base directory for run artifacts. Defaults to .codex/logs/agent-runs. --event-idle-timeout-ms Optional watchdog timeout without worker output. Defaults to 600000 (10 minutes). Use 0 to disable. --dry-run Write prompt/meta, print command, but do not launch Pi. `; } function requireArg(args, name) { if (!args[name]) throw new Error(`Missing required --${name}\n\n${usage()}`); return args[name]; } function slugify(value) { return String(value || 'worker') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80) || 'worker'; } function timestamp() { return new Date().toISOString().replace(/[:.]/g, '-'); } function readOptionalFile(path) { if (!path) return ''; return readFileSync(resolve(path), 'utf8').trim(); } function parseNonNegativeInt(value, fallback) { if (value === undefined) return fallback; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 0) { throw new Error(`Expected a non-negative integer, got: ${value}`); } return parsed; } function getGitStatus(cwd) { try { return execFileSync('git', ['status', '--short'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); } catch (error) { return `GIT_STATUS_ERROR: ${error.message || error}`; } } function textFromContent(value) { if (!value) return ''; if (typeof value === 'string') return value; if (Array.isArray(value)) return value.map(textFromContent).filter(Boolean).join(''); if (typeof value !== 'object') return ''; if (typeof value.text === 'string') return value.text; if (typeof value.delta === 'string') return value.delta; if (typeof value.content === 'string') return value.content; if (Array.isArray(value.content)) return textFromContent(value.content); if (value.type === 'text' && typeof value.value === 'string') return value.value; return ''; } function textFromMessage(message) { if (!message || message.role !== 'assistant') return ''; return textFromContent(message.content).trim(); } function extractTextDelta(event) { const update = event?.assistantMessageEvent; if (!update || typeof update !== 'object') return ''; if (typeof update.delta === 'string') return update.delta; if (typeof update.text === 'string') return update.text; return ''; } function findLastAssistantTextFromMessages(messages) { if (!Array.isArray(messages)) return ''; for (let i = messages.length - 1; i >= 0; i -= 1) { const text = textFromMessage(messages[i]); if (text) return text; } return ''; } function findFinalStatus(text) { const matches = [...String(text || '').matchAll(/(?:^|\n)\s*WORKER_STATUS:\s*(WORKER_DONE|BLOCKED|NEEDS_DISCUSSION)\b/g)]; return matches.at(-1)?.[1] || null; } function summarizeOrchestratorStatus({ finalStatus, exitCode, signal, killedByIdle, gitStatusAfter }) { if (killedByIdle) { return gitStatusAfter ? 'WORKER_STALLED_WITH_DIFF' : 'WORKER_STALLED_NO_DIFF'; } if (signal) return `PROCESS_SIGNAL_${signal}`; if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'PROCESS_FAILED'; if (finalStatus === 'WORKER_DONE') return 'WORKER_DONE_PENDING_CODEX_GATE'; if (finalStatus === 'BLOCKED') return 'WORKER_BLOCKED'; if (finalStatus === 'NEEDS_DISCUSSION') return 'WORKER_NEEDS_DISCUSSION'; if (exitCode === null || exitCode === undefined) return 'RUNNING_AWAITING_STATUS'; return 'NO_STRUCTURED_STATUS'; } function renderEventForConsole(event) { if (!event || typeof event !== 'object') return null; if (event.type === 'message_update') return extractTextDelta(event) || null; if (event.type === 'tool_execution_start') return `\n[pi] tool start: ${event.toolName || 'unknown'}\n`; if (event.type === 'tool_execution_end') { const status = event.isError ? 'error' : 'ok'; return `\n[pi] tool end: ${event.toolName || 'unknown'} (${status})\n`; } if ( event.type === 'agent_start' || event.type === 'agent_end' || event.type === 'turn_start' || event.type === 'turn_end' || event.type === 'compaction_start' || event.type === 'compaction_end' || event.type === 'auto_retry_start' || event.type === 'auto_retry_end' || event.type === 'extension_error' ) { return `\n[pi] ${event.type}\n`; } return null; } function assertRequiredFiles(cwd, version) { const required = [ 'AGENTS.md', 'development/_reference/ai/autonomous-agent-harness.md', ]; const missing = required.filter((file) => !existsSync(resolve(cwd, file))); if (missing.length > 0) { throw new Error(`Missing required orchestration files:\n${missing.map((file) => `- ${file}`).join('\n')}`); } } function existingContextFiles(cwd, version) { return [ 'README.md', `development/${version}/prd.md`, `development/${version}/plan.md`, `development/${version}/execution-log.md`, 'development/_reference/ai/autonomous-agent-harness.md', ].filter((file) => existsSync(resolve(cwd, file))); } function buildPrompt({ contextFiles, name, taskText, version }) { const contextLine = contextFiles.length > 0 ? `Read these context files when relevant: ${contextFiles.join(', ')}.` : 'No version planning files were found; use the direct Codex assignment as the scoped contract.'; return `You are the Pi worker launched by Codex-Orchestrator for this project. Codex remains the orchestrator, verifier, reviewer, and committer. You are only the bounded implementation worker. Task label: ${name} Worker assignment: ${taskText} Repository contract: - Read AGENTS.md before editing. - ${contextLine} - The direct Codex assignment is the binding scope. Do not broaden it into adjacent cleanup, dependent tasks, or a full release pass. - If the assignment is too large to finish safely, implement a coherent safe subset and report the recommended next worker task. - Do not edit planning or reference docs unless the assignment explicitly asks. - Do not create or update execution logs unless the assignment explicitly asks. Durable progress is captured by Codex through reviewed git checkpoint commits. - Do not commit, push, merge, reset, clean, or rewrite git history. - Do not deploy, publish, or touch external services unless the assignment explicitly says so. - Do not run destructive cleanup or wipe commands. - Do not edit .pi/**, .agents/**, AGENTS.md, or development/_reference/ai/**. - If unrelated uncommitted changes exist, work around them. Stop with NEEDS_DISCUSSION if they block the task. - If credentials, external access, clean local stand setup, or human business choice is required, stop with BLOCKED instead of asking a question. - The ask_question tool is disabled. Do not request interactive confirmation. Implementation rules: - Prefer existing repo patterns and narrow diffs. - This is a SvelteKit + TypeScript app. Keep UI behavior calm, readable, and consistent with the existing design. - Keep game data, game state, and UI components separated according to the current structure. - Keep user-facing copy consistent with the app language and tone already present in the touched files. - Prefer existing npm scripts from package.json. Do not invent scripts. - For UI behavior changes, describe the local browser smoke path Codex should verify. Worker output contract: - Run only relevant local checks that exist in package.json / repo docs. - If UI behavior changed and local browser smoke is required, use only a local dev server. - Finish with one of: WORKER_DONE, BLOCKED, or NEEDS_DISCUSSION. - End your final response with a standalone final status line in this exact shape: WORKER_STATUS: WORKER_DONE or WORKER_STATUS: BLOCKED or WORKER_STATUS: NEEDS_DISCUSSION - List changed files and observed command exit codes. - If there is meaningful follow-up work, propose the next concrete worker task. - Do not claim review approval or human acceptance. Codex will verify, review, and commit if appropriate. `; } async function main() { const args = parseArgs(process.argv.slice(2)); const cwd = process.cwd(); const version = requireArg(args, 'version'); const model = args.model || 'zai/glm-5.2'; const thinking = args.thinking || 'xhigh'; const piBin = args.pi || process.env.PI_BIN || 'pi'; const piMode = args['pi-mode'] || 'json'; const eventIdleTimeoutMs = parseNonNegativeInt(args['event-idle-timeout-ms'], 600000); const taskText = [args.task || '', readOptionalFile(args['prompt-file'])] .filter(Boolean) .join('\n\nAdditional Codex assignment details:\n') .trim(); if (!taskText) throw new Error(`Missing --task or --prompt-file\n\n${usage()}`); if (!['json', 'text'].includes(piMode)) { throw new Error(`Unsupported --pi-mode ${piMode}. Expected json or text.`); } assertRequiredFiles(cwd, version); const name = args.name || slugify(taskText); const contextFiles = existingContextFiles(cwd, version); const runsBaseDir = resolve(cwd, args['runs-dir'] || '.codex/logs/agent-runs'); const runSlug = slugify(`${name}`); const runDir = resolve(runsBaseDir, version, `${timestamp()}-pi-worker-${runSlug}`); mkdirSync(runDir, { recursive: true }); const prompt = buildPrompt({ contextFiles, name, taskText, version }); const promptPath = join(runDir, 'prompt.txt'); const stdoutPath = join(runDir, 'stdout.txt'); const stderrPath = join(runDir, 'stderr.txt'); const metaPath = join(runDir, 'meta.json'); const eventsPath = join(runDir, 'events.jsonl'); const summaryPath = join(runDir, 'summary.json'); const lastMessagePath = join(runDir, 'last-message.txt'); const sessionId = args['session-id'] || `calm-${version.replace(/[^a-zA-Z0-9]+/g, '-')}-${runSlug}`; const piArgs = [ ...(piMode === 'json' ? ['--mode', 'json'] : ['-p']), '--model', model, '--thinking', thinking, '--approve', '--exclude-tools', 'ask_question', '--session-id', sessionId, prompt, ]; writeFileSync(promptPath, prompt); const startedAt = new Date().toISOString(); const baseMeta = { tool: 'pi', piBin, args: piArgs.slice(0, -1).concat(['']), cwd, version, name, model, thinking, piMode, eventIdleTimeoutMs, sessionId, runDir, promptPath, stdoutPath, stderrPath, eventsPath, summaryPath, lastMessagePath, startedAt, dryRun: Boolean(args['dry-run']), }; writeFileSync(metaPath, `${JSON.stringify(baseMeta, null, 2)}\n`); writeFileSync(summaryPath, `${JSON.stringify({ ...baseMeta, status: 'NOT_STARTED', finalStatus: null, orchestratorStatus: 'NOT_STARTED', eventCounts: {}, eventCount: 0, parseErrors: [], gitStatusAfter: null, }, null, 2)}\n`); console.log(`Run directory: ${runDir}`); console.log(`Prompt: ${promptPath}`); console.log(`Events: ${eventsPath}`); console.log(`Summary: ${summaryPath}`); console.log(`Command: ${piBin} ${piArgs.slice(0, -1).join(' ')} `); if (args['dry-run']) { console.log('Dry run: Pi was not launched.'); return; } const stdout = createWriteStream(stdoutPath); const stderr = createWriteStream(stderrPath); const events = createWriteStream(eventsPath); let stdoutRemainder = ''; let stdoutTail = ''; let lastAssistantText = ''; let lastEventAt = null; let lastEventType = null; let parentSignal = null; let killedByIdle = false; let idleTimer = null; const parseErrors = []; const eventCounts = {}; let eventCount = 0; const child = spawn(piBin, piArgs, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], }); function writeRunningSummary(extra = {}) { const gitStatusAfter = extra.gitStatusAfter ?? null; const finalStatus = findFinalStatus(lastAssistantText); const orchestratorStatus = summarizeOrchestratorStatus({ finalStatus, exitCode: extra.exitCode ?? null, signal: extra.signal ?? null, killedByIdle, gitStatusAfter, }); writeFileSync(summaryPath, `${JSON.stringify({ ...baseMeta, status: extra.status || 'RUNNING', finalStatus, orchestratorStatus, eventCounts, eventCount, parseErrors, lastEventAt, lastEventType, parentSignal, killedByIdle, gitStatusAfter, stdoutTail, lastAssistantTextPath: lastMessagePath, ...extra, }, null, 2)}\n`); } function resetIdleTimer() { if (eventIdleTimeoutMs === 0) return; clearTimeout(idleTimer); idleTimer = setTimeout(() => { killedByIdle = true; console.error(`\nPi worker produced no output for ${eventIdleTimeoutMs}ms; sending SIGTERM.`); child.kill('SIGTERM'); setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 10000).unref(); }, eventIdleTimeoutMs); idleTimer.unref(); } function handleEvent(event) { eventCount += 1; eventCounts[event.type || 'unknown'] = (eventCounts[event.type || 'unknown'] || 0) + 1; lastEventAt = new Date().toISOString(); lastEventType = event.type || 'unknown'; events.write(`${JSON.stringify(event)}\n`); if (event.type === 'message_end') { const text = textFromMessage(event.message); if (text) lastAssistantText = text; } else if (event.type === 'agent_end') { const text = findLastAssistantTextFromMessages(event.messages); if (text) lastAssistantText = text; } if (lastAssistantText) writeFileSync(lastMessagePath, `${lastAssistantText}\n`); const rendered = renderEventForConsole(event); if (rendered) process.stdout.write(rendered); } function handleStdoutChunk(chunk) { const text = chunk.toString('utf8'); stdoutTail = `${stdoutTail}${text}`.slice(-12000); stdout.write(chunk); resetIdleTimer(); if (piMode !== 'json') { process.stdout.write(chunk); return; } stdoutRemainder += text; const lines = stdoutRemainder.split('\n'); stdoutRemainder = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { handleEvent(JSON.parse(trimmed)); } catch (error) { parseErrors.push({ at: new Date().toISOString(), error: error.message || String(error), line: trimmed.slice(0, 500), }); process.stdout.write(`${line}\n`); } } writeRunningSummary(); } function handleParentSignal(signal) { parentSignal = signal; console.error(`\nReceived ${signal}; forwarding to Pi worker.`); child.kill(signal); } process.once('SIGINT', handleParentSignal); process.once('SIGTERM', handleParentSignal); child.on('error', (error) => { parseErrors.push({ at: new Date().toISOString(), error: error.message || String(error), line: 'spawn', }); }); child.stdout.on('data', (chunk) => { handleStdoutChunk(chunk); }); child.stderr.on('data', (chunk) => { resetIdleTimer(); process.stderr.write(chunk); stderr.write(chunk); }); resetIdleTimer(); writeRunningSummary(); const { exitCode, signal } = await new Promise((resolveExit) => { child.on('close', (code, closeSignal) => resolveExit({ exitCode: code, signal: closeSignal })); }); clearTimeout(idleTimer); if (piMode === 'json' && stdoutRemainder.trim()) { try { handleEvent(JSON.parse(stdoutRemainder.trim())); } catch (error) { parseErrors.push({ at: new Date().toISOString(), error: error.message || String(error), line: stdoutRemainder.trim().slice(0, 500), }); } } stdout.end(); stderr.end(); events.end(); const finishedAt = new Date().toISOString(); const gitStatusAfter = getGitStatus(cwd); const finalStatus = piMode === 'json' ? findFinalStatus(lastAssistantText) : findFinalStatus(stdoutTail); const orchestratorStatus = summarizeOrchestratorStatus({ finalStatus, exitCode, signal, killedByIdle, gitStatusAfter, }); writeFileSync( metaPath, `${JSON.stringify({ ...baseMeta, finishedAt, exitCode, signal }, null, 2)}\n`, ); writeFileSync( summaryPath, `${JSON.stringify({ ...baseMeta, status: 'FINISHED', finalStatus, orchestratorStatus, eventCounts, eventCount, parseErrors, lastEventAt, lastEventType, parentSignal, killedByIdle, gitStatusAfter, stdoutTail, lastAssistantTextPath: lastMessagePath, finishedAt, exitCode, signal, }, null, 2)}\n`, ); console.log(`\nPi worker finished with ${orchestratorStatus}. See ${runDir}`); if (parentSignal === 'SIGINT') process.exit(130); if (parentSignal === 'SIGTERM') process.exit(143); if (killedByIdle) process.exit(124); if (exitCode !== 0 || signal) { console.error(`Pi worker exited with code ${exitCode ?? 'null'} signal ${signal ?? 'null'}. See ${runDir}`); process.exit(exitCode || 1); } if (!finalStatus) { console.error(`Pi worker exited without WORKER_STATUS marker. See ${summaryPath}`); process.exit(3); } console.log(`Pi worker completed. Structured summary: ${summaryPath}`); } main().catch((error) => { console.error(error.message || error); process.exit(1); });