Initialize calm game project

This commit is contained in:
2026-06-27 08:37:40 +03:00
commit cc7aec8db5
34 changed files with 5329 additions and 0 deletions
+454
View File
@@ -0,0 +1,454 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const DONE_STATUS = 'WORKER_DONE_PENDING_CODEX_GATE';
function parseArgs(argv) {
const out = {};
const booleans = new Set(['dry-run', 'gate-only', 'skip-worker', 'help']);
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 (booleans.has(key)) {
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:
npm run agent:loop -- --task "Add a calm end-game reset flow and verify the Svelte app."
Options:
--version Development version. Defaults to v<root package.json version>.
--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.
--run-dir Existing .codex/logs/agent-runs/<version>/... directory for --gate-only.
--runs-dir Optional base directory for run artifacts. Defaults to .codex/logs/agent-runs.
--run-checks none, auto, or full. Defaults to auto.
auto runs git gate and existing npm check/build scripts for app changes.
full is currently the same as auto unless more project checks are added.
--model Optional Pi model. Forwarded to run-pi-worker.mjs.
--thinking Optional Pi thinking level. Forwarded to run-pi-worker.mjs.
--pi Optional pi binary path. Forwarded to run-pi-worker.mjs.
--session-id Optional Pi session id. Forwarded to run-pi-worker.mjs.
--event-idle-timeout-ms
Optional Pi idle watchdog. Forwarded to run-pi-worker.mjs.
--dry-run Generate the Pi prompt/artifacts only; skip the Codex gate.
--gate-only Do not launch Pi; run the Codex gate for --run-dir or latest run.
`;
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
function rootVersion(cwd) {
const pkg = readJson(resolve(cwd, 'package.json'));
if (!pkg.version) throw new Error('Root package.json has no version');
return pkg.version;
}
function normalizeVersion(value) {
const raw = String(value || '').trim();
if (!raw) return raw;
return raw.startsWith('v') ? raw : `v${raw}`;
}
function slugify(value) {
return String(value || 'worker')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'worker';
}
function shellQuote(value) {
const text = String(value);
if (/^[a-zA-Z0-9_./:=@+-]+$/.test(text)) return text;
return `'${text.replace(/'/g, "'\\''")}'`;
}
function formatCommand(command, args) {
return [command, ...args].map(shellQuote).join(' ');
}
function printHeading(title) {
console.log(`\n== ${title} ==`);
}
function capture(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.error) {
return {
command: formatCommand(command, args),
cwd: options.cwd || process.cwd(),
code: 1,
signal: null,
stdout: result.stdout || '',
stderr: `${result.stderr || ''}${result.error.message || result.error}\n`,
};
}
return {
command: formatCommand(command, args),
cwd: options.cwd || process.cwd(),
code: result.status ?? (result.signal ? 1 : 0),
signal: result.signal,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
function runCheck(label, command, args, options = {}) {
console.log(`\n$ ${formatCommand(command, args)}`);
const result = capture(command, args, options);
if (result.stdout.trim()) process.stdout.write(result.stdout);
if (result.stderr.trim()) process.stderr.write(result.stderr);
if (!result.stdout.trim() && !result.stderr.trim()) console.log('(no output)');
console.log(`[exit ${result.code}] ${label}`);
return { label, ...result };
}
function lines(text) {
return String(text || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function uniqueSorted(values) {
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
function getChangedFiles(cwd, pathspecs = []) {
const tracked = capture('git', ['diff', '--name-only', 'HEAD', '--', ...pathspecs], { cwd });
const untracked = capture('git', ['ls-files', '--others', '--exclude-standard', '--', ...pathspecs], { cwd });
return uniqueSorted([
...(tracked.code === 0 ? lines(tracked.stdout) : []),
...(untracked.code === 0 ? lines(untracked.stdout) : []),
]);
}
function defaultRunsDir(cwd, args) {
return resolve(cwd, args['runs-dir'] || '.codex/logs/agent-runs');
}
function newestRunDir(cwd, args, version, name) {
const runsDir = resolve(defaultRunsDir(cwd, args), version);
if (!existsSync(runsDir)) return null;
const wantedSuffix = name ? `-pi-worker-${slugify(name)}` : null;
const dirs = readdirSync(runsDir)
.map((name) => resolve(runsDir, name))
.filter((path) => {
if (!existsSync(path) || !statSync(path).isDirectory()) return false;
if (wantedSuffix && !basename(path).endsWith(wantedSuffix)) return false;
return existsSync(join(path, 'summary.json'));
})
.map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
.sort((a, b) => b.mtimeMs - a.mtimeMs);
return dirs[0]?.path || null;
}
function resolveRunDir(cwd, args, version, name) {
if (args['run-dir']) {
const runDir = resolve(cwd, args['run-dir']);
if (!existsSync(runDir)) throw new Error(`Run directory does not exist: ${runDir}`);
return runDir;
}
const runDir = newestRunDir(cwd, args, version, name);
if (!runDir) {
throw new Error(`Could not find an agent run for ${version}${name ? ` / ${name}` : ''}`);
}
return runDir;
}
function summaryVersionFromRunDir(cwd, args) {
if (!args['run-dir']) return null;
const summaryPath = resolve(cwd, args['run-dir'], 'summary.json');
if (!existsSync(summaryPath)) return null;
const summary = readJson(summaryPath);
return summary.version ? normalizeVersion(summary.version) : null;
}
function runWorker(cwd, args, version, name) {
const workerScript = resolve(cwd, 'development/_reference/ai/scripts/run-pi-worker.mjs');
const workerArgs = [
workerScript,
'--version',
version,
];
if (name) workerArgs.push('--name', name);
if (args.task) workerArgs.push('--task', args.task);
if (args['prompt-file']) workerArgs.push('--prompt-file', args['prompt-file']);
if (args['runs-dir']) workerArgs.push('--runs-dir', args['runs-dir']);
if (args.model) workerArgs.push('--model', args.model);
if (args.thinking) workerArgs.push('--thinking', args.thinking);
if (args.pi) workerArgs.push('--pi', args.pi);
if (args['session-id']) workerArgs.push('--session-id', args['session-id']);
if (args['event-idle-timeout-ms']) {
workerArgs.push('--event-idle-timeout-ms', args['event-idle-timeout-ms']);
}
if (args['dry-run']) workerArgs.push('--dry-run');
printHeading('Pi Worker');
console.log(`$ ${formatCommand(process.execPath, workerArgs)}`);
const result = spawnSync(process.execPath, workerArgs, {
cwd,
env: process.env,
stdio: 'inherit',
});
if (result.error) {
console.error(result.error.message || result.error);
}
return {
code: result.error ? 1 : (result.status ?? (result.signal ? 1 : 0)),
signal: result.signal,
};
}
function forbiddenPathspecs(version) {
return [
'.pi',
'.agents',
'.codex',
'AGENTS.md',
'development/_reference/ai',
];
}
function packageScripts(cwd) {
try {
return readJson(resolve(cwd, 'package.json')).scripts || {};
} catch {
return {};
}
}
function hasScript(cwd, name) {
return Object.prototype.hasOwnProperty.call(packageScripts(cwd), name);
}
function selectAutoChecks(cwd, changedFiles, mode) {
if (mode === 'none') return [];
const checks = [];
const appChanged = changedFiles.some((file) => (
file.startsWith('src/') ||
file.startsWith('static/') ||
[
'package.json',
'package-lock.json',
'svelte.config.js',
'tsconfig.json',
'vite.config.ts',
'vite.config.js',
].includes(file)
));
if (appChanged) {
if (hasScript(cwd, 'check')) checks.push({ label: 'Svelte/type check', command: 'npm', args: ['run', 'check'], cwd });
if (hasScript(cwd, 'build')) checks.push({ label: 'Production build', command: 'npm', args: ['run', 'build'], cwd });
}
return checks;
}
function hasUiBehaviorRisk(changedFiles) {
return changedFiles.some((file) => {
if (!file.startsWith('src/') && !file.startsWith('static/')) return false;
return ['.svelte', '.ts', '.js', '.html', '.css', '.svg'].some((suffix) => file.endsWith(suffix));
});
}
function writeGateArtifact(runDir, payload) {
const path = join(runDir, 'codex-gate.json');
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`);
return path;
}
function runGate({ cwd, args, version, name, preForbiddenFiles, workerResult }) {
const runChecks = args['run-checks'] || 'auto';
if (!['none', 'auto', 'full'].includes(runChecks)) {
throw new Error(`Unsupported --run-checks ${runChecks}. Expected none, auto, or full.`);
}
const runDir = resolveRunDir(cwd, args, version, name);
const summaryPath = join(runDir, 'summary.json');
if (!existsSync(summaryPath)) throw new Error(`Missing summary.json in ${runDir}`);
const summary = readJson(summaryPath);
const checks = [];
const failures = [];
printHeading('Worker Summary');
console.log(`Run directory: ${runDir}`);
console.log(`Final status: ${summary.finalStatus || 'null'}`);
console.log(`Orchestrator status: ${summary.orchestratorStatus || 'null'}`);
console.log(`Last message: ${summary.lastAssistantTextPath || join(runDir, 'last-message.txt')}`);
if (workerResult?.code) {
console.log(`Worker process exit: ${workerResult.code}${workerResult.signal ? ` (${workerResult.signal})` : ''}`);
}
printHeading('Codex Gate');
checks.push(runCheck('git status', 'git', ['status', '--short'], { cwd }));
checks.push(runCheck('git diff whitespace check', 'git', ['diff', '--check'], { cwd }));
const forbidden = forbiddenPathspecs(version);
const forbiddenStatusCheck = runCheck(
'forbidden orchestration path status',
'git',
['status', '--short', '--', ...forbidden],
{ cwd },
);
checks.push(forbiddenStatusCheck);
const currentForbiddenFiles = getChangedFiles(cwd, forbidden);
const preForbidden = new Set(preForbiddenFiles || []);
const newForbiddenFiles = currentForbiddenFiles.filter((file) => !preForbidden.has(file));
if (summary.orchestratorStatus !== DONE_STATUS) {
failures.push(`worker status is ${summary.orchestratorStatus || 'missing'}, expected ${DONE_STATUS}`);
}
for (const check of checks) {
if (check.code !== 0) failures.push(`${check.label} exited ${check.code}`);
}
if (newForbiddenFiles.length > 0) {
failures.push(`forbidden paths changed during/after worker: ${newForbiddenFiles.join(', ')}`);
}
const changedFiles = getChangedFiles(cwd);
const autoChecks = [];
if (failures.length === 0) {
const selectedChecks = selectAutoChecks(cwd, changedFiles, runChecks);
if (selectedChecks.length > 0) printHeading('Auto Checks');
for (const check of selectedChecks) {
const result = runCheck(check.label, check.command, check.args, { cwd: check.cwd });
autoChecks.push(result);
if (result.code !== 0) failures.push(`${check.label} exited ${result.code}`);
}
} else {
console.log('\nSkipping auto checks because the basic gate did not pass.');
}
const uiSmokeRequired = hasUiBehaviorRisk(changedFiles);
const status = failures.length === 0 ? 'PASSED_PENDING_REVIEW' : 'FAILED';
const gatePath = writeGateArtifact(runDir, {
status,
generatedAt: new Date().toISOString(),
runDir,
version,
name,
runChecks,
workerResult: workerResult || null,
workerSummary: {
finalStatus: summary.finalStatus || null,
orchestratorStatus: summary.orchestratorStatus || null,
lastAssistantTextPath: summary.lastAssistantTextPath || null,
gitStatusAfter: summary.gitStatusAfter || null,
},
changedFiles,
forbiddenFiles: currentForbiddenFiles,
newForbiddenFiles,
checks,
autoChecks,
uiSmokeRequired,
failures,
});
printHeading('Gate Result');
console.log(`Gate artifact: ${gatePath}`);
console.log(`Changed files: ${changedFiles.length ? changedFiles.join(', ') : '(none)'}`);
if (uiSmokeRequired) {
console.log('UI smoke: required if this changed visible behavior; use the local stand and a real browser before commit.');
}
if (failures.length > 0) {
console.log('Verdict: FAILED');
for (const failure of failures) console.log(`- ${failure}`);
return 1;
}
console.log('Verdict: PASSED_PENDING_REVIEW');
console.log('Next: Codex reviews the diff, then stages only expected files and commits if approved.');
return 0;
}
function main() {
const cwd = process.cwd();
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
if (!existsSync(resolve(cwd, 'AGENTS.md')) || !existsSync(resolve(cwd, 'development/_reference/ai/scripts/run-pi-worker.mjs'))) {
throw new Error('Run this command from the project repository root.');
}
const gateOnly = Boolean(args['gate-only'] || args['skip-worker']);
const runDirVersion = gateOnly ? summaryVersionFromRunDir(cwd, args) : null;
const version = normalizeVersion(args.version || runDirVersion || rootVersion(cwd));
if (args.version && runDirVersion && normalizeVersion(args.version) !== runDirVersion) {
throw new Error(`--version ${normalizeVersion(args.version)} does not match run summary version ${runDirVersion}`);
}
const name = args.name || (gateOnly ? '' : slugify(args.task || args['prompt-file'] || 'worker'));
const hasTask = Boolean(args.task || args['prompt-file']);
if (!gateOnly && !hasTask) throw new Error(`Missing --task or --prompt-file\n\n${usage()}`);
const runsDir = resolve(defaultRunsDir(cwd, args), version);
mkdirSync(runsDir, { recursive: true });
const preForbiddenFiles = gateOnly ? [] : getChangedFiles(cwd, forbiddenPathspecs(version));
const workerResult = gateOnly ? null : runWorker(cwd, args, version, name);
if (args['dry-run']) {
console.log('\nDry run complete. Pi was not launched and the Codex gate was skipped.');
process.exit(workerResult.code || 0);
}
const exitCode = runGate({ cwd, args, version, name, preForbiddenFiles, workerResult });
process.exit(exitCode);
}
try {
main();
} catch (error) {
console.error(error.message || error);
process.exit(1);
}
+579
View File
@@ -0,0 +1,579 @@
#!/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(['<prompt>']),
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(' ')} <prompt>`);
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);
});