
Codex Subagents
- 89 installs
- 24 repo stars
- Updated December 24, 2025
- obra/external-subagents
Dispatch Codex subagents for isolated tasks with scoped prompts and review.
About
Codex Subagents skill documents dispatching external Codex agents for isolated implementation tasks—scoped prompts, result integration, and review before merge.
- Codex subagent dispatch patterns.
- Scoped prompts per independent task.
- Integrates with Superpowers review skills.
Codex Subagents by the numbers
- 89 all-time installs (skills.sh)
- +2 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #4,729 of 16,659 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 26, 2026 (Skillselion catalog sync)
npx skills add https://github.com/obra/external-subagents --skill codex-subagentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 89 |
|---|---|
| repo stars | ★ 24 |
| Security audit | 3 / 3 scanners passed |
| Last updated | December 24, 2025 |
| Repository | obra/external-subagents ↗ |
What it does
Dispatch Codex subagents for isolated tasks with scoped prompts and review.
Files
Codex Subagents
Overview
codex-subagent offloads work to background threads so your main context stays lean. Threads run detached by default; use wait/peek to check results.
Critical rules: 1. You cannot send to a running thread. Always wait before sending follow-ups. 2. Use --permissions read-only or --permissions workspace-write (not workspace-read)
For detailed workflow documentation, see reference/workflow.md.
When to Use
| Situation | Why subagents help |
|---|---|
| Parallel research tasks | Multiple threads run simultaneously |
| Context would bloat | Research stays in separate thread |
| Long-running work | Detached execution, check later |
Don't use for: Quick inline questions, tightly-coupled work needing your current state.
Core Workflow
1. start with --prompt (inline text or stdin)
2. wait/peek → check result
3. send → resume with follow-up (ONLY after thread stops)
4. archive/clean → lifecycle managementThe Running Thread Rule
You CANNOT send to a running thread. This is the #1 mistake.
# WRONG - thread might still be running
echo "followup" | codex-subagent send thread-abc --prompt -
# RIGHT - wait first, then send
codex-subagent wait --threads thread-abc
echo "followup" | codex-subagent send thread-abc --prompt -Resumable statuses: completed, failed, stopped, waiting
Quick Reference
| Command | Purpose | Key flags |
|---|---|---|
start | Launch new thread | --role, --permissions, --prompt, --label, -w |
send | Resume stopped thread | <thread-id>, --prompt, -w |
peek | Read newest unseen message | <thread-id>, --save-response |
log | Full history | <thread-id>, --tail, --json |
status | Thread summary | <thread-id> |
wait | Block until threads stop | --threads, --labels, --all, --follow-last |
list | Show threads | --status, --label, --role |
archive | Move completed to archive | --completed, --yes, --dry-run |
clean | Delete old archives | --older-than-days, --yes |
Prompt input: --prompt "text" for simple prompts, --prompt - reads from stdin (for multi-line), -f/--prompt-file for files
Positional thread IDs: peek abc123 works like peek -t abc123
Common Patterns
Simple inline prompt
codex-subagent start --role researcher --permissions read-only \
--label "quick-task" --prompt "List all exported functions in src/lib/"Multi-line prompt (heredoc)
cat <<'EOF' | codex-subagent start --role researcher --permissions read-only --label "auth-research" --prompt -
Research authentication patterns in this codebase:
1. Find all auth-related files
2. Document the auth flow
3. Note any security concerns
EOFParallel research
# Launch multiple researchers
cat <<'EOF' | codex-subagent start --role researcher --permissions read-only --label "API: Stripe" --prompt -
Research Stripe API authentication methods and best practices
EOF
cat <<'EOF' | codex-subagent start --role researcher --permissions read-only --label "API: Twilio" --prompt -
Research Twilio API authentication methods and best practices
EOF
# Wait for all, see results
codex-subagent wait --labels "API:" --follow-lastBlocking task
# -w blocks until complete (may take minutes)
cat <<'EOF' | codex-subagent start --role researcher --permissions read-only --prompt - -w --save-response result.txt
Analyze error handling patterns in src/
EOF
cat result.txtWarning: -w blocks for as long as the agent runs. For long tasks, prefer detached mode with wait --follow-last.
Cleanup old work
# Two-phase: archive completed, then clean old archives
codex-subagent archive --completed --yes
codex-subagent clean --older-than-days 30 --yesTroubleshooting
| Problem | Cause | Fix |
|---|---|---|
| "profile does not exist" | Wrong permissions | Use read-only or workspace-write (not workspace-read) |
| "not resumable" error | Thread still running | wait first, then send |
| "different controller" | Wrong session | Use --controller-id or check list |
| Thread disappeared | Was archived | Check archive dir or re-run task |
Checklist
- [ ]
starthas--role,--permissions,--prompt, and--label - [ ] Permissions are
read-onlyorworkspace-write - [ ]
waitbeforesend(never send to running thread) - [ ] Results captured with
--save-responseorpeek
dev/node_modules/
codex-subagent.map
.tmp/
.DS_Store
coverage/
.npm-debug.log*
.codex-subagent/
.codex-home/
.claude/
.pytest_cache/
[hooks.pre-commit]
steps = [
{ run = "npm run --prefix dev lint" },
{ run = "npm run --prefix dev format" },
{ run = "npm run --prefix dev typecheck" },
{ run = "npm run --prefix dev test" }
]
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
engine-strict=true
fund=false
audit=false
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "es5"
}
import { build } from 'esbuild';
import { writeFileSync, readFileSync, chmodSync } from 'fs';
const outfile = '../codex-subagent';
await build({
entryPoints: ['src/bin/codex-subagent.ts'],
bundle: true,
platform: 'node',
target: 'node20',
format: 'cjs',
outfile: outfile + '.tmp',
sourcemap: true,
});
// Prepend shebang (esbuild's banner escapes the !)
const code = readFileSync(outfile + '.tmp', 'utf8');
writeFileSync(outfile, '#!/usr/bin/env node\n' + code);
writeFileSync(outfile + '.map', readFileSync(outfile + '.tmp.map'));
chmodSync(outfile, 0o755);
// Clean up temp files
import { unlinkSync } from 'fs';
unlinkSync(outfile + '.tmp');
unlinkSync(outfile + '.tmp.map');
console.log(`Built ${outfile}`);
You are a Codex subagent doing a tiny watch demo. Reply with a short status update and then finish.
#!/usr/bin/env tsx
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { execa } from 'execa';
interface ThreadEntry {
thread_id: string;
updated_at?: string;
}
async function readLatestThread(stateFile: string): Promise<string> {
const raw = await readFile(stateFile, 'utf8');
const parsed = JSON.parse(raw) as Record<string, ThreadEntry>;
const entries = Object.values(parsed);
if (entries.length === 0) {
throw new Error('No threads found in registry');
}
const latest = entries.sort((a, b) => {
const left = a.updated_at ?? '';
const right = b.updated_at ?? '';
return right.localeCompare(left);
})[0];
return latest.thread_id;
}
async function main() {
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const distBin = path.join(repoRoot, 'dist', 'codex-subagent.js');
const promptFile = path.join(repoRoot, 'scripts', 'demo-watch-prompt.txt');
const stateFile = path.join(repoRoot, '.codex-subagent', 'state', 'threads.json');
console.log('Starting demo thread...');
await execa(
'node',
[
distBin,
'start',
'--role',
'researcher',
'--policy',
'workspace-write',
'--prompt-file',
promptFile,
],
{
stdio: 'inherit',
}
);
const latestThread = await readLatestThread(stateFile);
console.log(`\nWatching thread ${latestThread}... (Ctrl+C to stop)`);
await execa('node', [distBin, 'watch', '--thread', latestThread], {
stdio: 'inherit',
});
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['dist', 'node_modules', '*.config.js', '*.mjs'],
},
...tseslint.configs.recommended,
{
files: ['tests/**/*.ts'],
rules: {
'@typescript-eslint/no-misused-promises': 'off',
},
}
);
{
"name": "codex-subagent-cli",
"version": "0.1.0",
"description": "TypeScript CLI for managing Codex subagent threads",
"type": "module",
"bin": {
"codex-subagent": "../codex-subagent"
},
"scripts": {
"build": "node build.mjs",
"dev": "tsx src/bin/codex-subagent.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"prepare": "node run-prek-install.mjs",
"demo": "tsx demo-watch.ts"
},
"engines": {
"node": ">=20",
"npm": ">=10"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.11.0",
"@typescript-eslint/parser": "^8.11.0",
"@vitest/coverage-istanbul": "^1.6.1",
"chokidar": "^3.6.0",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"globals": "^16.5.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"tsup": "^8.3.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"typescript-eslint": "^8.48.0",
"vitest": "^1.6.1"
},
"dependencies": {
"execa": "^9.6.0",
"yaml": "^2.4.3"
}
}
import { spawnSync } from 'node:child_process';
function binaryExists(cmd) {
const result = spawnSync(cmd, ['--version'], { stdio: 'ignore' });
return !result.error && result.status === 0;
}
if (!binaryExists('prek')) {
console.log('[prek] binary not found on PATH; skipping prek install hook.');
process.exit(0);
}
const installResult = spawnSync('prek', ['install'], { stdio: 'inherit' });
process.exit(installResult.status ?? 0);
import process from 'node:process';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { listCommand } from '../commands/list.ts';
import { startCommand } from '../commands/start.ts';
import { sendCommand } from '../commands/send.ts';
import { peekCommand } from '../commands/peek.ts';
import { logCommand } from '../commands/log.ts';
import { watchCommand } from '../commands/watch.ts';
import { statusCommand } from '../commands/status.ts';
import { archiveCommand } from '../commands/archive.ts';
import { labelCommand } from '../commands/label.ts';
import { waitCommand } from '../commands/wait.ts';
import { cleanCommand } from '../commands/clean.ts';
import { RegistryLoadError } from '../lib/registry.ts';
import { getControllerId } from '../lib/controller-id.ts';
import { parseStartManifest, StartManifest } from '../lib/start-manifest.ts';
import { runStartThreadWorkflow } from '../lib/start-thread.ts';
import { runSendThreadWorkflow } from '../lib/send-thread.ts';
import type { PersonaRuntime } from '../lib/personas.ts';
import { Paths } from '../lib/paths.ts';
import { LaunchRegistry } from '../lib/launch-registry.ts';
import { markThreadError } from '../lib/thread-errors.ts';
// Use process.argv[1] for bundled executable compatibility
const CLI_ENTRY_PATH = process.argv[1];
interface ParsedArgs {
command: string;
rootDir?: string;
controllerId?: string;
rest: string[];
}
function parseArgs(argv: string[]): ParsedArgs {
let command: string | undefined;
let rootDir: string | undefined;
let controllerId: string | undefined;
const rest: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--root') {
const next = argv[i + 1];
if (!next) {
throw new Error('--root flag requires a path');
}
rootDir = path.resolve(next);
i++; // skip path value
continue;
}
if (arg === '--controller-id') {
const next = argv[i + 1];
if (!next) {
throw new Error('--controller-id flag requires a value');
}
controllerId = next;
i++;
continue;
}
if (!command) {
command = arg;
continue;
}
rest.push(arg);
}
return { command: command ?? 'list', rootDir, controllerId, rest };
}
interface StartFlags {
role?: string;
permissions?: 'read-only' | 'workspace-write';
profile?: string;
promptFile?: string;
prompt?: string;
promptFromStdin?: boolean;
outputLastPath?: string;
wait?: boolean;
workingDir?: string;
label?: string;
persona?: string;
manifestPath?: string;
manifestFromStdin?: boolean;
jsonSource?: string;
jsonFromStdin?: boolean;
printPrompt?: boolean;
dryRun?: boolean;
backend?: 'codex' | 'claude';
model?: string;
}
function parseStartFlags(args: string[]): StartFlags {
const flags: StartFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
switch (arg) {
case '--role':
if (!next) {
throw new Error('--role flag requires a value');
}
flags.role = next;
i++;
break;
case '--permissions':
if (!next) {
throw new Error('--permissions flag requires a value (read-only or workspace-write)');
}
if (next !== 'read-only' && next !== 'workspace-write') {
throw new Error('--permissions must be "read-only" or "workspace-write"');
}
flags.permissions = next;
i++;
break;
case '--profile':
if (!next) {
throw new Error('--profile flag requires a value');
}
flags.profile = next;
i++;
break;
case '-f':
case '--prompt-file':
if (!next) {
throw new Error('--prompt-file flag requires a path');
}
flags.promptFile = path.resolve(next);
i++;
break;
case '--prompt':
if (!next) {
throw new Error('--prompt flag requires text or "-" for stdin');
}
if (next === '-') {
flags.promptFromStdin = true;
} else {
flags.prompt = next;
}
i++;
break;
case '--save-response':
case '--output-last':
if (!next) {
throw new Error('--save-response flag requires a path');
}
flags.outputLastPath = path.resolve(next);
i++;
break;
case '--cwd':
if (!next) {
throw new Error('--cwd flag requires a path');
}
flags.workingDir = path.resolve(next);
i++;
break;
case '--label':
if (!next) {
throw new Error('--label flag requires a value');
}
flags.label = next;
i++;
break;
case '--persona':
if (!next) {
throw new Error('--persona flag requires a value');
}
flags.persona = next;
i++;
break;
case '--json':
if (!next) {
throw new Error('--json flag requires a path or "-" for stdin');
}
flags.jsonSource = next === '-' ? '-' : path.resolve(next);
flags.jsonFromStdin = next === '-';
i++;
break;
case '--manifest':
if (!next) {
throw new Error('--manifest flag requires a path or "-" for stdin');
}
if (next === '-') {
flags.manifestFromStdin = true;
} else {
flags.manifestPath = path.resolve(next);
}
i++;
break;
case '--print-prompt':
flags.printPrompt = true;
break;
case '--dry-run':
flags.dryRun = true;
break;
case '-w':
case '--wait':
flags.wait = true;
break;
case '--backend':
if (!next) {
throw new Error('--backend flag requires a value (codex or claude)');
}
if (next !== 'codex' && next !== 'claude') {
throw new Error('--backend must be "codex" or "claude"');
}
flags.backend = next;
i++;
break;
case '--model':
if (!next) {
throw new Error('--model flag requires a value');
}
flags.model = next;
i++;
break;
default:
throw new Error(`Unknown flag for start command: ${arg}`);
}
}
return flags;
}
async function loadManifestFromFlags(flags: StartFlags): Promise<StartManifest> {
if (flags.manifestPath) {
const body = await readFile(flags.manifestPath, 'utf8');
const parsed = JSON.parse(body);
return parseStartManifest(parsed, flags.manifestPath);
}
if (flags.manifestFromStdin) {
const body = await readStdin();
if (!body.trim()) {
throw new Error('Manifest JSON from stdin was empty.');
}
const parsed = JSON.parse(body);
return parseStartManifest(parsed, 'stdin');
}
throw new Error('Manifest flags were requested but no manifest source was provided.');
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: string[] = [];
process.stdin.on('data', (chunk) => {
if (typeof chunk === 'string') {
chunks.push(chunk);
} else {
chunks.push(chunk.toString('utf8'));
}
});
process.stdin.on('end', () => resolve(chunks.join('')));
process.stdin.on('error', (error) => reject(error));
});
}
interface StartJsonPayload {
promptBody?: string;
promptFile?: string;
role?: string;
permissions?: 'read-only' | 'workspace-write';
profile?: string;
workingDir?: string;
label?: string;
persona?: string;
outputLastPath?: string;
wait?: boolean;
}
async function loadStartJsonPayload(
flags: StartFlags
): Promise<{ manifest?: StartManifest; single?: StartJsonPayload }> {
if (!flags.jsonSource) {
return {};
}
const body = flags.jsonFromStdin ? await readStdin() : await readFile(flags.jsonSource, 'utf8');
if (!body.trim()) {
throw new Error('JSON prompt payload was empty.');
}
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch (error) {
throw new Error(
`Failed to parse JSON prompt payload: ${error instanceof Error ? error.message : String(error)}`
);
}
const sourceLabel = flags.jsonFromStdin ? 'stdin' : (flags.jsonSource ?? 'json');
const baseDir =
flags.jsonFromStdin || !flags.jsonSource ? process.cwd() : path.dirname(flags.jsonSource);
if (
Array.isArray(parsed) ||
(parsed &&
typeof parsed === 'object' &&
Array.isArray((parsed as Record<string, unknown>).tasks as unknown[]))
) {
return { manifest: parseStartManifest(parsed, sourceLabel) };
}
return { single: normalizeStartJsonPayload(parsed, sourceLabel, baseDir) };
}
function normalizeStartJsonPayload(
data: unknown,
source: string,
baseDir: string
): StartJsonPayload {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`Start JSON payload from ${source} must be an object.`);
}
const record = data as Record<string, unknown>;
const pickString = (...keys: string[]) => {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return undefined;
};
const permissions = pickString('permissions') as 'read-only' | 'workspace-write' | undefined;
return {
promptBody: pickString('prompt'),
promptFile: resolveMaybePath(pickString('promptFile', 'prompt_file'), baseDir),
role: pickString('role'),
permissions,
profile: pickString('profile'),
workingDir: resolveMaybePath(pickString('cwd'), baseDir),
label: pickString('label'),
persona: pickString('persona'),
outputLastPath: resolveMaybePath(pickString('outputLast', 'output_last'), baseDir),
wait: typeof record.wait === 'boolean' ? record.wait : undefined,
};
}
function resolveMaybePath(value: string | undefined, baseDir: string): string | undefined {
if (!value) {
return undefined;
}
if (path.isAbsolute(value)) {
return value;
}
return path.resolve(baseDir, value);
}
interface SendJsonPayload {
promptBody?: string;
promptFile?: string;
workingDir?: string;
persona?: string;
outputLastPath?: string;
wait?: boolean;
}
async function loadSendJsonPayload(flags: SendFlags): Promise<SendJsonPayload | undefined> {
if (!flags.jsonSource) {
return undefined;
}
const body = flags.jsonFromStdin ? await readStdin() : await readFile(flags.jsonSource, 'utf8');
if (!body.trim()) {
throw new Error('Send JSON payload was empty.');
}
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch (error) {
throw new Error(
`Failed to parse JSON payload: ${error instanceof Error ? error.message : String(error)}`
);
}
const baseDir =
flags.jsonFromStdin || !flags.jsonSource ? process.cwd() : path.dirname(flags.jsonSource);
return normalizeSendJsonPayload(
parsed,
flags.jsonFromStdin ? 'stdin' : (flags.jsonSource ?? 'json'),
baseDir
);
}
function normalizeSendJsonPayload(data: unknown, source: string, baseDir: string): SendJsonPayload {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`Send JSON payload from ${source} must be an object.`);
}
const record = data as Record<string, unknown>;
const pickString = (...keys: string[]) => {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return undefined;
};
return {
promptBody: pickString('prompt'),
promptFile: resolveMaybePath(pickString('promptFile', 'prompt_file'), baseDir),
workingDir: resolveMaybePath(pickString('cwd'), baseDir),
persona: pickString('persona'),
outputLastPath: resolveMaybePath(pickString('outputLast', 'output_last'), baseDir),
wait: typeof record.wait === 'boolean' ? record.wait : undefined,
};
}
interface SendFlags {
threadId?: string;
promptFile?: string;
prompt?: string;
promptFromStdin?: boolean;
outputLastPath?: string;
wait?: boolean;
workingDir?: string;
persona?: string;
jsonSource?: string;
jsonFromStdin?: boolean;
printPrompt?: boolean;
dryRun?: boolean;
}
function parseSendFlags(args: string[]): SendFlags {
const flags: SendFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
// Handle positional thread ID (first non-flag argument)
if (!arg.startsWith('-') && !flags.threadId) {
flags.threadId = arg;
continue;
}
switch (arg) {
case '-t':
case '--thread':
if (!next) {
throw new Error('--thread flag requires a value');
}
flags.threadId = next;
i++;
break;
case '-f':
case '--prompt-file':
if (!next) {
throw new Error('--prompt-file flag requires a path');
}
flags.promptFile = path.resolve(next);
i++;
break;
case '--prompt':
if (!next) {
throw new Error('--prompt flag requires text or "-" for stdin');
}
if (next === '-') {
flags.promptFromStdin = true;
} else {
flags.prompt = next;
}
i++;
break;
case '--save-response':
case '--output-last':
if (!next) {
throw new Error('--save-response flag requires a path');
}
flags.outputLastPath = path.resolve(next);
i++;
break;
case '--cwd':
if (!next) {
throw new Error('--cwd flag requires a path');
}
flags.workingDir = path.resolve(next);
i++;
break;
case '--persona':
if (!next) {
throw new Error('--persona flag requires a value');
}
flags.persona = next;
i++;
break;
case '--json':
if (!next) {
throw new Error('--json flag requires a path or "-" for stdin');
}
flags.jsonSource = next === '-' ? '-' : path.resolve(next);
flags.jsonFromStdin = next === '-';
i++;
break;
case '--print-prompt':
flags.printPrompt = true;
break;
case '--dry-run':
flags.dryRun = true;
break;
case '-w':
case '--wait':
flags.wait = true;
break;
default:
throw new Error(`Unknown flag for send command: ${arg}`);
}
}
return flags;
}
interface PeekFlags {
threadId?: string;
outputLastPath?: string;
verbose?: boolean;
}
function parsePeekFlags(args: string[]): PeekFlags {
const flags: PeekFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
// Handle positional thread ID (first non-flag argument)
if (!arg.startsWith('-') && !flags.threadId) {
flags.threadId = arg;
continue;
}
switch (arg) {
case '-t':
case '--thread':
if (!next) {
throw new Error('--thread flag requires a value');
}
flags.threadId = next;
i++;
break;
case '--save-response':
case '--output-last':
if (!next) {
throw new Error('--save-response flag requires a path');
}
flags.outputLastPath = path.resolve(next);
i++;
break;
case '--verbose':
flags.verbose = true;
break;
default:
throw new Error(`Unknown flag for peek command: ${arg}`);
}
}
return flags;
}
interface LogFlags {
threadId?: string;
tail?: number;
raw?: boolean;
verbose?: boolean;
}
interface LabelFlags {
threadId?: string;
label?: string;
}
interface StatusFlags {
threadId?: string;
tail?: number;
raw?: boolean;
staleMinutes?: number;
}
interface ArchiveFlags {
threadId?: string;
completed?: boolean;
yes?: boolean;
dryRun?: boolean;
}
interface WaitFlags {
threads?: string[];
labels?: string[];
all?: boolean;
intervalMs?: number;
timeoutMs?: number;
followLast?: boolean;
}
interface ListFlags {
filterStatus?: string;
filterLabel?: string;
filterRole?: string;
}
interface CleanFlags {
olderThanDays?: number;
yes?: boolean;
dryRun?: boolean;
}
function parseListFlags(args: string[]): ListFlags {
const flags: ListFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
switch (arg) {
case '--status':
if (!next) {
throw new Error('--status flag requires a value');
}
flags.filterStatus = next;
i++;
break;
case '--label':
if (!next) {
throw new Error('--label flag requires a value');
}
flags.filterLabel = next;
i++;
break;
case '--role':
if (!next) {
throw new Error('--role flag requires a value');
}
flags.filterRole = next;
i++;
break;
default:
throw new Error(`Unknown flag for list command: ${arg}`);
}
}
return flags;
}
function parseLogFlags(args: string[]): LogFlags {
const flags: LogFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
// Handle positional thread ID (first non-flag argument)
if (!arg.startsWith('-') && !flags.threadId) {
flags.threadId = arg;
continue;
}
switch (arg) {
case '-t':
case '--thread':
if (!next) {
throw new Error('--thread flag requires a value');
}
flags.threadId = next;
i++;
break;
case '--tail':
if (!next) {
throw new Error('--tail flag requires a value');
}
flags.tail = Number(next);
if (Number.isNaN(flags.tail) || flags.tail! < 1) {
throw new Error('--tail must be a positive integer');
}
i++;
break;
case '--json':
case '--raw':
flags.raw = true;
break;
case '--verbose':
flags.verbose = true;
break;
default:
throw new Error(`Unknown flag for log command: ${arg}`);
}
}
return flags;
}
function parseStatusFlags(args: string[]): StatusFlags {
const flags: StatusFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
// Handle positional thread ID (first non-flag argument)
if (!arg.startsWith('-') && !flags.threadId) {
flags.threadId = arg;
continue;
}
switch (arg) {
case '-t':
case '--thread':
if (!next) {
throw new Error('--thread flag requires a value');
}
flags.threadId = next;
i++;
break;
case '--tail':
if (!next) {
throw new Error('--tail flag requires a value');
}
flags.tail = Number(next);
if (Number.isNaN(flags.tail) || flags.tail! < 1) {
throw new Error('--tail must be a positive integer');
}
i++;
break;
case '--json':
case '--raw':
flags.raw = true;
break;
case '--stale-minutes':
if (!next) {
throw new Error('--stale-minutes flag requires a value');
}
flags.staleMinutes = Number(next);
if (Number.isNaN(flags.staleMinutes) || flags.staleMinutes! <= 0) {
throw new Error('--stale-minutes must be greater than 0');
}
i++;
break;
default:
throw new Error(`Unknown flag for status command: ${arg}`);
}
}
return flags;
}
function parseArchiveFlags(args: string[]): ArchiveFlags {
const flags: ArchiveFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
// Handle positional thread ID (first non-flag argument)
if (!arg.startsWith('-') && !flags.threadId) {
flags.threadId = arg;
continue;
}
switch (arg) {
case '-t':
case '--thread':
if (!next) {
throw new Error('--thread flag requires a value');
}
flags.threadId = next;
i++;
break;
case '--completed':
flags.completed = true;
break;
case '--yes':
flags.yes = true;
break;
case '--dry-run':
flags.dryRun = true;
break;
default:
throw new Error(`Unknown flag for archive command: ${arg}`);
}
}
return flags;
}
function parseLabelFlags(args: string[]): LabelFlags {
const flags: LabelFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
// Handle positional thread ID (first non-flag argument)
if (!arg.startsWith('-') && !flags.threadId) {
flags.threadId = arg;
continue;
}
switch (arg) {
case '-t':
case '--thread':
if (!next) {
throw new Error('--thread flag requires a value');
}
flags.threadId = next;
i++;
break;
case '--label':
if (!next) {
throw new Error('--label flag requires a value');
}
flags.label = next;
i++;
break;
default:
throw new Error(`Unknown flag for label command: ${arg}`);
}
}
return flags;
}
function parseWaitFlags(args: string[]): WaitFlags {
const flags: WaitFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
switch (arg) {
case '--threads':
if (!next) {
throw new Error('--threads flag requires a comma-separated list of thread IDs');
}
flags.threads = next
.split(',')
.map((value) => value.trim())
.filter(Boolean);
i++;
break;
case '--labels':
if (!next) {
throw new Error('--labels flag requires a comma-separated list of labels');
}
flags.labels = next
.split(',')
.map((value) => value.trim())
.filter(Boolean);
i++;
break;
case '--all':
case '--all-controller':
flags.all = true;
break;
case '--interval-ms':
if (!next) {
throw new Error('--interval-ms flag requires a value');
}
flags.intervalMs = Number(next);
if (Number.isNaN(flags.intervalMs) || flags.intervalMs! <= 0) {
throw new Error('--interval-ms must be greater than 0');
}
i++;
break;
case '--timeout-ms':
if (!next) {
throw new Error('--timeout-ms flag requires a value');
}
flags.timeoutMs = Number(next);
if (Number.isNaN(flags.timeoutMs) || flags.timeoutMs! <= 0) {
throw new Error('--timeout-ms must be greater than 0');
}
i++;
break;
case '--follow-last':
flags.followLast = true;
break;
default:
throw new Error(`Unknown flag for wait command: ${arg}`);
}
}
return flags;
}
interface WatchFlags {
threadId?: string;
intervalMs?: number;
outputLastPath?: string;
durationMs?: number;
}
function parseWatchFlags(args: string[]): WatchFlags {
const flags: WatchFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
// Handle positional thread ID (first non-flag argument)
if (!arg.startsWith('-') && !flags.threadId) {
flags.threadId = arg;
continue;
}
switch (arg) {
case '-t':
case '--thread':
if (!next) {
throw new Error('--thread flag requires a value');
}
flags.threadId = next;
i++;
break;
case '--interval-ms':
if (!next) {
throw new Error('--interval-ms flag requires a value');
}
flags.intervalMs = Number(next);
if (Number.isNaN(flags.intervalMs) || flags.intervalMs! < 1) {
throw new Error('--interval-ms must be a positive integer');
}
i++;
break;
case '--save-response':
case '--output-last':
if (!next) {
throw new Error('--save-response flag requires a path');
}
flags.outputLastPath = path.resolve(next);
i++;
break;
case '--timeout-ms':
case '--duration-ms':
if (!next) {
throw new Error('--timeout-ms flag requires a value');
}
flags.durationMs = Number(next);
if (Number.isNaN(flags.durationMs) || flags.durationMs! < 1) {
throw new Error('--timeout-ms must be a positive integer');
}
i++;
break;
default:
throw new Error(`Unknown flag for watch command: ${arg}`);
}
}
return flags;
}
function parseCleanFlags(args: string[]): CleanFlags {
const flags: CleanFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
switch (arg) {
case '--older-than-days':
if (!next) {
throw new Error('--older-than-days flag requires a value');
}
flags.olderThanDays = Number(next);
if (Number.isNaN(flags.olderThanDays) || flags.olderThanDays! <= 0) {
throw new Error('--older-than-days must be greater than 0');
}
i++;
break;
case '--yes':
flags.yes = true;
break;
case '--dry-run':
flags.dryRun = true;
break;
default:
throw new Error(`Unknown flag for clean command: ${arg}`);
}
}
return flags;
}
function printHelp(): void {
const lines = [
'codex-subagent <command>',
'',
'Commands:',
' list List stored threads (default)',
' start Launch a new Codex exec thread',
' send Send a new prompt to an existing thread (resume)',
' peek Show the newest unseen assistant message for a thread',
' log Print the stored log for a thread (no Codex call)',
' status Summarize the latest activity for a thread',
' watch Continuously peek a thread until interrupted',
' wait Block until threads reach a stopped state',
' archive Move completed thread logs/state into archive',
' label Attach or update a friendly label for a thread',
' clean Remove old archived threads to free disk space',
'',
'Options:',
' --root <path> Override the .codex-subagent root directory',
' --controller-id <id> Override auto-detected controller session ID',
' list flags:',
' --status <status> Filter by status (running, completed, etc.)',
' --label <text> Filter by label substring (case-insensitive)',
' --role <role> Filter by exact role',
' start flags:',
' --role <name> Required role (e.g., researcher)',
' --permissions <level> Required: read-only or workspace-write',
' --profile <name> Codex-only: custom profile (overrides permissions)',
' --backend <backend> Backend: codex (default) or claude',
' --model <model> Model to use (e.g., sonnet, opus, haiku)',
' --prompt <text|-> Prompt text directly, or - for stdin',
' -f, --prompt-file <path> Prompt contents from file',
' --save-response <path> Optional file for last message text',
' --cwd <path> Optional working directory instruction for the subagent',
' --label <text> Optional friendly label stored with the thread',
' --persona <name> Optional persona to load from .codex/agents',
' --manifest <path|-> Launch multiple tasks from JSON manifest (use - for stdin)',
' --json <path|-> Alternative JSON input format (use - for stdin)',
' -w, --wait Block until finished (default: detach)',
' send flags:',
' <thread-id> Positional thread ID (alternative to --thread)',
' -t, --thread <id> Target thread to resume (required)',
' --prompt <text|-> Prompt text directly, or - for stdin',
' -f, --prompt-file <path> Prompt contents from file',
' --save-response <path> Optional file for last message text',
' --cwd <path> Optional working directory instruction for the subagent',
' --persona <name> Optional persona override for this turn',
' --json <path|-> Alternative JSON input format (use - for stdin)',
' -w, --wait Block until Codex finishes (default: detach)',
' peek flags:',
' <thread-id> Positional thread ID (alternative to --thread)',
' -t, --thread <id> Target thread to inspect (required)',
' --save-response <path> Optional file for last message text',
' --verbose Include last-activity metadata even when no updates',
' log flags:',
' <thread-id> Positional thread ID (alternative to --thread)',
' -t, --thread <id> Target thread to inspect (required)',
' --tail <n> Optional number of most recent entries to show',
' --json Output raw NDJSON lines',
' --verbose Append last-activity metadata',
' status flags:',
' <thread-id> Positional thread ID (alternative to --thread)',
' -t, --thread <id> Target thread to inspect (required)',
' --tail <n> Optional number of most recent entries to show',
' --json Output raw NDJSON lines',
' --stale-minutes <n> Override idle threshold for follow-up suggestion (default 15)',
' archive flags:',
' <thread-id> Positional thread ID (alternative to --thread)',
' -t, --thread <id> Archive a specific thread',
' --completed Archive all completed threads (per controller)',
' --yes Required to actually archive (safety guard)',
' --dry-run Show what would archive without moving files',
' watch flags:',
' <thread-id> Positional thread ID (alternative to --thread)',
' -t, --thread <id> Target thread to watch (required)',
' --interval-ms <n> Interval between peeks (default 5000)',
' --save-response <path> Optional file for last message text',
' --timeout-ms <n> Optional max runtime before exiting cleanly',
' label flags:',
' <thread-id> Positional thread ID (alternative to --thread)',
' -t, --thread <id> Target thread to label (required)',
' --label <text> Friendly label text (empty string clears it)',
' wait flags:',
' --threads <ids> Comma-separated thread IDs to wait on',
' --labels <labels> Comma-separated labels to wait on',
' --all Wait for every thread owned by this controller',
' --interval-ms <n> Polling interval (default 5000)',
' --timeout-ms <n> Optional timeout before exiting with failure',
' --follow-last Print the last assistant message when each thread stops',
' clean flags:',
' --older-than-days <n> Age threshold in days (default 30)',
' --yes Required to actually delete (safety guard)',
' --dry-run Show what would be deleted without removing files',
'',
'Examples:',
' # Launch with prompt from stdin (no tempfile needed)',
' echo "Research topic X" | codex-subagent start --role researcher --permissions read-only --prompt -',
' # Launch a detached researcher subagent with Codex',
' codex-subagent start --role researcher --permissions workspace-write --prompt-file task.txt',
' # Launch with Claude backend (requires CODEX_SUBAGENT_ENABLE_CLAUDE=1)',
' CODEX_SUBAGENT_ENABLE_CLAUDE=1 codex-subagent start --role researcher --permissions workspace-write --backend claude --prompt-file task.txt',
' # Resume a thread but wait for completion (positional thread ID)',
' codex-subagent send 019... --prompt-file followup.txt --wait',
' # Or with explicit --thread flag (both work)',
' codex-subagent send --thread 019... --prompt-file followup.txt --wait',
' # Peek the most recent assistant turn without resuming',
' codex-subagent peek 019...',
' # Watch for new turns for up to 60 seconds, then exit cleanly',
' codex-subagent watch 019... --timeout-ms 60000',
' # Give a friendly label to a thread',
' codex-subagent label 019... --label "Task 3 – log summaries"',
'',
'Notes:',
' start/send run detached unless you pass --wait.',
' watch never resumes the agent; it only replays peek output. Prefer peek/log when you just need the latest turn.',
];
process.stdout.write(`${lines.join('\n')}\n`);
}
async function run(): Promise<void> {
const {
command,
rootDir,
controllerId: overrideControllerId,
rest,
} = parseArgs(process.argv.slice(2));
const controllerId = getControllerId({ override: overrideControllerId });
switch (command) {
case 'list':
try {
const flags = parseListFlags(rest);
await listCommand({
rootDir,
controllerId,
filterStatus: flags.filterStatus,
filterLabel: flags.filterLabel,
filterRole: flags.filterRole,
});
} catch (error) {
if (error instanceof RegistryLoadError) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
} else {
throw error;
}
}
break;
case 'start':
try {
const flags = parseStartFlags(rest);
// Validate stdin conflicts
const stdinSources = [
flags.promptFromStdin && '--prompt -',
flags.jsonFromStdin && '--json -',
flags.manifestFromStdin && '--manifest -',
].filter(Boolean);
if (stdinSources.length > 1) {
throw new Error(`Cannot combine multiple stdin sources: ${stdinSources.join(', ')}`);
}
// Read prompt from stdin if requested
let stdinPrompt: string | undefined;
if (flags.promptFromStdin) {
stdinPrompt = await readStdin();
if (!stdinPrompt.trim()) {
throw new Error('Prompt from stdin was empty.');
}
}
let manifest: StartManifest | undefined;
if (flags.manifestPath || flags.manifestFromStdin) {
manifest = await loadManifestFromFlags(flags);
}
const { manifest: jsonManifest, single: jsonSingle } = await loadStartJsonPayload(flags);
if (jsonManifest) {
if (manifest) {
throw new Error('Cannot combine --manifest with a JSON manifest payload.');
}
manifest = jsonManifest;
}
if (manifest) {
if (flags.printPrompt || flags.dryRun) {
throw new Error('--print-prompt/--dry-run are not supported with manifest mode yet.');
}
await startCommand({
rootDir,
controllerId,
manifest,
cliPath: CLI_ENTRY_PATH,
});
break;
}
const resolvedRole = jsonSingle?.role ?? flags.role ?? '';
const resolvedPermissions = jsonSingle?.permissions ?? flags.permissions;
const resolvedProfile = jsonSingle?.profile ?? flags.profile;
const resolvedPromptFile = jsonSingle?.promptFile ?? flags.promptFile;
const resolvedPromptBody = jsonSingle?.promptBody ?? stdinPrompt ?? flags.prompt;
const resolvedWorkingDir = jsonSingle?.workingDir ?? flags.workingDir;
const resolvedLabel = jsonSingle?.label ?? flags.label;
const resolvedPersona = jsonSingle?.persona ?? flags.persona;
const resolvedOutputLast = jsonSingle?.outputLastPath ?? flags.outputLastPath;
const resolvedWait = jsonSingle?.wait ?? Boolean(flags.wait);
await startCommand({
rootDir,
role: resolvedRole,
permissions: resolvedPermissions,
profile: resolvedProfile,
promptFile: resolvedPromptFile,
promptBody: resolvedPromptBody,
outputLastPath: resolvedOutputLast,
wait: resolvedWait,
controllerId,
workingDir: resolvedWorkingDir,
label: resolvedLabel,
personaName: resolvedPersona,
printPrompt: Boolean(flags.printPrompt),
dryRun: Boolean(flags.dryRun),
cliPath: CLI_ENTRY_PATH,
backend: flags.backend,
model: flags.model,
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'send':
try {
const flags = parseSendFlags(rest);
// Validate stdin conflicts
const stdinSources = [
flags.promptFromStdin && '--prompt -',
flags.jsonFromStdin && '--json -',
].filter(Boolean);
if (stdinSources.length > 1) {
throw new Error(`Cannot combine multiple stdin sources: ${stdinSources.join(', ')}`);
}
// Read prompt from stdin if requested
let stdinPrompt: string | undefined;
if (flags.promptFromStdin) {
stdinPrompt = await readStdin();
if (!stdinPrompt.trim()) {
throw new Error('Prompt from stdin was empty.');
}
}
const jsonPayload = await loadSendJsonPayload(flags);
const resolvedPromptFile = jsonPayload?.promptFile ?? flags.promptFile;
const resolvedPromptBody = jsonPayload?.promptBody ?? stdinPrompt ?? flags.prompt;
const resolvedWorkingDir = jsonPayload?.workingDir ?? flags.workingDir;
const resolvedPersona = jsonPayload?.persona ?? flags.persona;
const resolvedOutputLast = jsonPayload?.outputLastPath ?? flags.outputLastPath;
const resolvedWait = jsonPayload?.wait ?? Boolean(flags.wait);
await sendCommand({
rootDir,
threadId: flags.threadId ?? '',
promptFile: resolvedPromptFile,
promptBody: resolvedPromptBody,
outputLastPath: resolvedOutputLast,
controllerId,
wait: resolvedWait,
workingDir: resolvedWorkingDir,
personaName: resolvedPersona,
printPrompt: Boolean(flags.printPrompt),
dryRun: Boolean(flags.dryRun),
cliPath: CLI_ENTRY_PATH,
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'worker-start':
try {
await runWorkerStart(rest);
} catch (error) {
process.stderr.write(
`Detached start failed: ${error instanceof Error ? error.message : String(error)}\n`
);
process.exitCode = 1;
}
break;
case 'worker-send':
try {
await runWorkerSend(rest);
} catch (error) {
process.stderr.write(
`Detached send failed: ${error instanceof Error ? error.message : String(error)}\n`
);
process.exitCode = 1;
}
break;
case 'peek':
try {
const flags = parsePeekFlags(rest);
await peekCommand({
rootDir,
threadId: flags.threadId ?? '',
outputLastPath: flags.outputLastPath,
verbose: Boolean(flags.verbose),
controllerId,
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'log':
try {
const flags = parseLogFlags(rest);
await logCommand({
rootDir,
threadId: flags.threadId ?? '',
tail: flags.tail,
raw: Boolean(flags.raw),
verbose: Boolean(flags.verbose),
controllerId,
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'status':
try {
const flags = parseStatusFlags(rest);
await statusCommand({
rootDir,
threadId: flags.threadId ?? '',
tail: flags.tail,
raw: Boolean(flags.raw),
staleMinutes: flags.staleMinutes,
controllerId,
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'watch':
try {
const flags = parseWatchFlags(rest);
const controller = new AbortController();
const handleSigint = () => {
controller.abort();
};
process.on('SIGINT', handleSigint);
try {
await watchCommand({
rootDir,
threadId: flags.threadId ?? '',
intervalMs: flags.intervalMs,
outputLastPath: flags.outputLastPath,
durationMs: flags.durationMs,
signal: controller.signal,
controllerId,
});
} finally {
process.off('SIGINT', handleSigint);
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'wait':
try {
const flags = parseWaitFlags(rest);
const controller = new AbortController();
const handleSigint = () => {
controller.abort();
};
process.on('SIGINT', handleSigint);
try {
await waitCommand({
rootDir,
controllerId,
threadIds: flags.threads,
labels: flags.labels,
includeAll: Boolean(flags.all),
intervalMs: flags.intervalMs,
timeoutMs: flags.timeoutMs,
followLast: Boolean(flags.followLast),
signal: controller.signal,
});
} finally {
process.off('SIGINT', handleSigint);
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'label':
try {
const flags = parseLabelFlags(rest);
await labelCommand({
rootDir,
threadId: flags.threadId ?? '',
label: flags.label ?? '',
controllerId,
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'archive':
try {
const flags = parseArchiveFlags(rest);
await archiveCommand({
rootDir,
threadId: flags.threadId,
completed: Boolean(flags.completed),
yes: Boolean(flags.yes),
dryRun: Boolean(flags.dryRun),
controllerId,
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'clean':
try {
const flags = parseCleanFlags(rest);
await cleanCommand({
rootDir,
olderThanDays: flags.olderThanDays,
yes: Boolean(flags.yes),
dryRun: Boolean(flags.dryRun),
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
break;
case 'help':
case '--help':
case '-h':
printHelp();
break;
default:
process.stderr.write(`Unknown command: ${command}\n`);
printHelp();
process.exitCode = 1;
}
}
async function runWorkerStart(rest: string[]): Promise<void> {
const payload = decodeWorkerPayload(rest);
const role = asString(payload.role);
const permissions = asString(payload.permissions) as 'read-only' | 'workspace-write' | undefined;
const profile = asString(payload.profile);
const rootDir = asPath(payload.rootDir);
const launchId = asString(payload.launchId);
const paths = new Paths(rootDir);
const launchRegistry = launchId ? new LaunchRegistry(paths) : undefined;
if (!role) {
throw new Error('worker payload missing role');
}
if (!permissions) {
throw new Error('worker payload missing permissions');
}
const backend = asString(payload.backend) as 'codex' | 'claude' | undefined;
const model = asString(payload.model);
try {
const result = await runStartThreadWorkflow({
rootDir,
role,
permissions,
profile,
promptFile: asPath(payload.promptFile),
promptBody: asString(payload.promptBody),
outputLastPath: asPath(payload.outputLastPath),
controllerId: asString(payload.controllerId) ?? '',
workingDir: asPath(payload.workingDir),
label: asString(payload.label),
persona: asPersonaRuntime(payload.persona),
launchId,
backend,
model,
});
if (launchRegistry && launchId) {
await launchRegistry.markSuccess(launchId, { threadId: result.threadId });
}
} catch (error) {
if (launchRegistry && launchId) {
await launchRegistry.markFailure(launchId, { error });
}
throw error;
}
}
async function runWorkerSend(rest: string[]): Promise<void> {
const payload = decodeWorkerPayload(rest);
const threadId = asString(payload.threadId);
if (!threadId) {
throw new Error('worker payload missing threadId');
}
const rootDir = asPath(payload.rootDir);
const controllerId = asString(payload.controllerId) ?? '';
const launchId = asString(payload.launchId);
const paths = new Paths(rootDir);
const launchRegistry = launchId ? new LaunchRegistry(paths) : undefined;
try {
await runSendThreadWorkflow({
rootDir,
threadId,
promptFile: asPath(payload.promptFile),
promptBody: asString(payload.promptBody),
outputLastPath: asPath(payload.outputLastPath),
controllerId,
workingDir: asPath(payload.workingDir),
personaName: asString(payload.personaName),
});
if (launchRegistry && launchId) {
await launchRegistry.markSuccess(launchId, { threadId });
}
} catch (error) {
if (launchRegistry && launchId) {
await launchRegistry.markFailure(launchId, { error });
}
await markThreadError(paths, {
threadId,
controllerId,
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
function decodeWorkerPayload(rest: string[]): Record<string, unknown> {
const payloadIndex = rest.indexOf('--payload');
if (payloadIndex === -1 || payloadIndex === rest.length - 1) {
throw new Error('worker command requires --payload <base64>');
}
const base64 = rest[payloadIndex + 1];
const json = Buffer.from(base64, 'base64').toString('utf8');
try {
const parsed = JSON.parse(json);
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch (error) {
throw new Error(
`failed to parse worker payload JSON: ${error instanceof Error ? error.message : String(error)}`
);
}
throw new Error('worker payload must decode to an object');
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function asPath(value: unknown): string | undefined {
if (typeof value !== 'string' || value.length === 0) {
return undefined;
}
return path.resolve(value);
}
function asPersonaRuntime(value: unknown): PersonaRuntime | undefined {
if (value && typeof value === 'object') {
return value as PersonaRuntime;
}
return undefined;
}
run().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
import process from 'node:process';
import { rename, mkdir, writeFile, access } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { Writable } from 'node:stream';
import path from 'node:path';
import { Paths } from '../lib/paths.ts';
import { Registry } from '../lib/registry.ts';
import { assertThreadOwnership } from '../lib/thread-ownership.ts';
interface ArchiveTarget {
thread_id: string;
}
export interface ArchiveCommandOptions {
rootDir?: string;
threadId?: string;
completed?: boolean;
yes?: boolean;
dryRun?: boolean;
controllerId: string;
stdout?: Writable;
}
function ensureConfirmation(options: ArchiveCommandOptions): void {
if (options.dryRun) {
return;
}
if (!options.yes) {
throw new Error('archive command requires --yes to proceed (or use --dry-run).');
}
}
export async function archiveCommand(options: ArchiveCommandOptions): Promise<void> {
if (!options.threadId && !options.completed) {
throw new Error('archive command requires either --thread or --completed');
}
ensureConfirmation(options);
const stdout = options.stdout ?? process.stdout;
const paths = new Paths(options.rootDir);
await paths.ensure();
const registry = new Registry(paths);
let candidates: ArchiveTarget[] = [];
if (options.threadId) {
candidates = [{ thread_id: options.threadId }];
} else {
const threads = await registry.listThreads();
candidates = threads
.filter((thread) => (thread.status ?? 'completed') !== 'running')
.map((thread) => ({ thread_id: thread.thread_id }));
}
if (candidates.length === 0) {
stdout.write('No eligible threads to archive.\n');
return;
}
for (const candidate of candidates) {
const thread = await assertThreadOwnership(
await registry.get(candidate.thread_id),
options.controllerId,
registry
);
if (!thread) {
continue;
}
if (thread.status === 'running') {
throw new Error(`Thread ${thread.thread_id} is still running and cannot be archived`);
}
const archiveDir = paths.archiveDir(thread.thread_id);
if (options.dryRun) {
stdout.write(`[dry-run] Would archive ${thread.thread_id} to ${archiveDir}\n`);
continue;
}
await mkdir(archiveDir, { recursive: true });
const logPath = paths.logFile(thread.thread_id);
try {
await access(logPath, fsConstants.F_OK);
await rename(logPath, paths.archivedLogFile(thread.thread_id));
} catch {
// no log to move, ignore
}
const metadataPath = path.join(archiveDir, 'metadata.json');
await writeFile(metadataPath, JSON.stringify(thread, null, 2), 'utf8');
await registry.remove(thread.thread_id);
stdout.write(`Archived thread ${thread.thread_id}\n`);
}
}
import { readdir, stat, rm } from 'node:fs/promises';
import path from 'node:path';
import { Writable } from 'node:stream';
import process from 'node:process';
import { Paths } from '../lib/paths.ts';
export interface CleanCommandOptions {
rootDir?: string;
olderThanDays?: number;
yes?: boolean;
dryRun?: boolean;
stdout?: Writable;
}
const DEFAULT_OLDER_THAN_DAYS = 30;
export async function cleanCommand(options: CleanCommandOptions): Promise<void> {
const stdout = options.stdout ?? process.stdout;
const olderThanDays = options.olderThanDays ?? DEFAULT_OLDER_THAN_DAYS;
const paths = new Paths(options.rootDir);
const archiveDir = paths.archiveRoot;
if (!options.yes && !options.dryRun) {
throw new Error('clean command requires --yes or --dry-run for safety');
}
let entries: string[];
try {
entries = await readdir(archiveDir);
} catch {
stdout.write('No archive directory found. Nothing to clean.\n');
return;
}
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
const toDelete: string[] = [];
for (const entry of entries) {
const entryPath = path.join(archiveDir, entry);
const stats = await stat(entryPath);
if (stats.isDirectory() && stats.mtimeMs < cutoff) {
toDelete.push(entryPath);
}
}
if (toDelete.length === 0) {
stdout.write(`No archived directories older than ${olderThanDays} days.\n`);
return;
}
if (options.dryRun) {
stdout.write(`Would delete ${toDelete.length} archived directory(ies):\n`);
for (const dir of toDelete) {
stdout.write(` ${path.basename(dir)}\n`);
}
return;
}
for (const dir of toDelete) {
await rm(dir, { recursive: true });
}
stdout.write(`Deleted ${toDelete.length} archived directory(ies).\n`);
}
import process from 'node:process';
import { Writable } from 'node:stream';
import { Paths } from '../lib/paths.ts';
import { Registry } from '../lib/registry.ts';
import { assertThreadOwnership } from '../lib/thread-ownership.ts';
export interface LabelCommandOptions {
rootDir?: string;
threadId: string;
label: string;
controllerId: string;
stdout?: Writable;
}
export async function labelCommand(options: LabelCommandOptions): Promise<void> {
if (!options.threadId) {
throw new Error('label command requires --thread');
}
if (options.label === undefined) {
throw new Error('label command requires --label');
}
const stdout = options.stdout ?? process.stdout;
const paths = new Paths(options.rootDir);
const registry = new Registry(paths);
const existing = await registry.get(options.threadId);
if (!existing) {
throw new Error(`Thread ${options.threadId} not found`);
}
const thread = await assertThreadOwnership(existing, options.controllerId, registry);
if (!thread) {
throw new Error(`Thread ${options.threadId} not found`);
}
await registry.updateThread(options.threadId, {
label: options.label,
});
stdout.write(`Thread ${options.threadId} labeled as "${options.label}"\n`);
}
import process from 'node:process';
import { Writable } from 'node:stream';
import { Registry } from '../lib/registry.ts';
import type { ThreadMetadata } from '../lib/registry.ts';
import { assertThreadOwnership } from '../lib/thread-ownership.ts';
import { Paths } from '../lib/paths.ts';
import { formatRelativeTime } from '../lib/time.ts';
import { LaunchRegistry, LaunchAttempt } from '../lib/launch-registry.ts';
export interface ListCommandOptions {
rootDir?: string;
stdout?: Writable;
controllerId: string;
now?: () => number;
filterStatus?: string;
filterLabel?: string;
filterRole?: string;
}
const PENDING_WARNING_MS = 2 * 60 * 1000;
function formatThreadLine(thread: ThreadMetadata, nowMs: number): string {
const idSegment = thread.label ? `${thread.thread_id} (${thread.label})` : thread.thread_id;
const status = formatStatusLabel(thread.status);
const parts = [idSegment, status, thread.role ?? 'unknown-role'];
if (thread.permissions) {
parts.push(thread.permissions);
}
parts.push(`updated ${formatRelativeTime(thread.updated_at, nowMs)}`);
const lines = [`- ${parts.join(' · ')}`];
if (thread.error_message) {
lines.push(` Error: ${thread.error_message}`);
}
return lines.join('\n');
}
function formatStatusLabel(rawStatus?: string): string {
if (!rawStatus) {
return 'stopped';
}
const normalized = rawStatus.toLowerCase();
if (normalized === 'running') {
return 'running';
}
if (normalized === 'failed' || normalized === 'not_running') {
return 'NOT RUNNING';
}
return 'stopped';
}
function formatLaunchDiagnostics(attempts: LaunchAttempt[], nowMs: number): string[] {
if (attempts.length === 0) {
return [];
}
return ['Launch diagnostics:', ...attempts.map((attempt) => formatLaunchAttempt(attempt, nowMs))];
}
function formatLaunchAttempt(attempt: LaunchAttempt, nowMs: number): string {
const idSegment = attempt.label ? `${attempt.id} (${attempt.label})` : attempt.id;
const status = attempt.status === 'failed' ? 'NOT RUNNING' : 'pending';
const parts = [
idSegment,
status,
attempt.type,
`launched ${formatRelativeTime(attempt.created_at, nowMs)}`,
];
const lines = [`- ${parts.join(' · ')}`];
if (attempt.status === 'failed' && attempt.error_message) {
lines.push(` Error: ${attempt.error_message}`);
if (attempt.log_path) {
lines.push(` See ${attempt.log_path} for details.`);
}
}
if (attempt.status === 'pending' && isPendingWarning(attempt, nowMs)) {
lines.push(' Warning: still waiting for Codex (no thread yet).');
}
return lines.join('\n');
}
function isPendingWarning(attempt: LaunchAttempt, nowMs: number): boolean {
if (!attempt.created_at) {
return false;
}
const created = Date.parse(attempt.created_at);
if (Number.isNaN(created)) {
return false;
}
return nowMs - created >= PENDING_WARNING_MS;
}
export async function listCommand(options: ListCommandOptions): Promise<void> {
const stdout = options.stdout ?? process.stdout;
const nowMs = options.now ? options.now() : Date.now();
const paths = new Paths(options.rootDir);
const registry = new Registry(paths);
const normalized = await Promise.all(
(await registry.listThreads()).map(async (thread) => {
try {
return await assertThreadOwnership(thread, options.controllerId, registry);
} catch {
return null;
}
})
);
const threads = normalized.filter((thread): thread is ThreadMetadata => Boolean(thread));
let filtered = threads;
if (options.filterStatus) {
filtered = filtered.filter(t =>
t.status?.toLowerCase() === options.filterStatus?.toLowerCase()
);
}
if (options.filterLabel) {
filtered = filtered.filter(t =>
t.label?.toLowerCase().includes(options.filterLabel!.toLowerCase())
);
}
if (options.filterRole) {
filtered = filtered.filter(t =>
t.role === options.filterRole
);
}
if (filtered.length === 0) {
stdout.write('No threads found.\n');
} else {
const header = `Found ${filtered.length} thread${filtered.length === 1 ? '' : 's'} in ${paths.root}`;
const sorted = filtered.sort((a, b) => {
const aRunning = a.status === 'running' ? 0 : 1;
const bRunning = b.status === 'running' ? 0 : 1;
if (aRunning !== bRunning) {
return aRunning - bRunning;
}
const aTime = Date.parse(a.updated_at ?? '') || 0;
const bTime = Date.parse(b.updated_at ?? '') || 0;
return bTime - aTime;
});
const lines = sorted.map((thread) => formatThreadLine(thread, nowMs));
stdout.write(`${header}\n${lines.join('\n')}\n`);
}
const launchRegistry = new LaunchRegistry(paths);
const STALE_LAUNCH_MS = 60 * 60 * 1000; // 1 hour
await launchRegistry.cleanupStale(STALE_LAUNCH_MS, nowMs);
const launchAttempts = (await launchRegistry.listAttempts()).filter(
(attempt) => attempt.controller_id === options.controllerId
);
const diagnostics = formatLaunchDiagnostics(launchAttempts, nowMs);
if (diagnostics.length > 0) {
stdout.write(`${diagnostics.join('\n')}\n`);
}
}
import process from 'node:process';
import { access, readFile } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { Writable } from 'node:stream';
import { Paths } from '../lib/paths.ts';
import { Registry } from '../lib/registry.ts';
import { assertThreadOwnership } from '../lib/thread-ownership.ts';
export interface LogCommandOptions {
rootDir?: string;
threadId: string;
tail?: number;
raw?: boolean;
stdout?: Writable;
controllerId: string;
verbose?: boolean;
}
export async function logCommand(options: LogCommandOptions): Promise<void> {
if (!options.threadId) {
throw new Error('log command requires --thread');
}
const stdout = options.stdout ?? process.stdout;
const paths = new Paths(options.rootDir);
await paths.ensure();
const registry = new Registry(paths);
const thread = await assertThreadOwnership(
await registry.get(options.threadId),
options.controllerId,
registry
);
const logPath = paths.logFile(options.threadId);
try {
await access(logPath, fsConstants.F_OK);
} catch {
stdout.write(`No log entries found for thread ${options.threadId}\n`);
if (options.verbose) {
writeLastActivity(stdout, thread?.updated_at);
}
return;
}
const raw = await readFile(logPath, 'utf8');
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
stdout.write(`No log entries found for thread ${options.threadId}\n`);
if (options.verbose) {
writeLastActivity(stdout, thread?.updated_at);
}
return;
}
const tail = options.tail && options.tail > 0 ? options.tail : undefined;
const slice = tail ? lines.slice(-tail) : lines;
if (options.raw) {
stdout.write(`${slice.join('\n')}\n`);
} else {
stdout.write(`Log entries for thread ${options.threadId} (${slice.length})\n`);
for (const line of slice) {
try {
const entry = JSON.parse(line);
const text = typeof entry.text === 'string' ? entry.text : '[no-text]';
stdout.write(`- ${entry.id ?? '[unknown-id]'} · ${text}\n`);
} catch {
stdout.write(`- [invalid-json] · ${line}\n`);
}
}
}
if (options.verbose) {
const timestamp = slice.length > 0 ? extractTimestampFromLine(slice[slice.length - 1]) : undefined;
writeLastActivity(stdout, timestamp ?? thread?.updated_at);
}
}
function writeLastActivity(stdout: Writable, timestamp?: string): void {
if (!timestamp) {
stdout.write('Last activity time unavailable\n');
return;
}
stdout.write(`Last activity ${timestamp}\n`);
}
function extractTimestampFromLine(line: string): string | undefined {
try {
const entry = JSON.parse(line);
if (entry && typeof entry.created_at === 'string') {
return entry.created_at;
}
} catch {
return undefined;
}
return undefined;
}
import process from 'node:process';
import { access, readFile, writeFile } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { Writable } from 'node:stream';
import { Paths } from '../lib/paths.ts';
import { Registry } from '../lib/registry.ts';
import { assertThreadOwnership } from '../lib/thread-ownership.ts';
export interface PeekCommandOptions {
rootDir?: string;
threadId: string;
outputLastPath?: string;
stdout?: Writable;
controllerId: string;
verbose?: boolean;
}
interface LoggedMessage {
id: string;
text?: string;
created_at?: string;
}
function parseLine(line: string): LoggedMessage | undefined {
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed.id === 'string') {
return parsed as LoggedMessage;
}
} catch {
return undefined;
}
return undefined;
}
export async function peekCommand(options: PeekCommandOptions): Promise<void> {
if (!options.threadId) {
throw new Error('peek command requires --thread');
}
const stdout = options.stdout ?? process.stdout;
const paths = new Paths(options.rootDir);
await paths.ensure();
const registry = new Registry(paths);
const thread = await assertThreadOwnership(
await registry.get(options.threadId),
options.controllerId,
registry
);
const logPath = paths.logFile(options.threadId);
try {
await access(logPath, fsConstants.F_OK);
} catch {
stdout.write(`No log entries found for thread ${options.threadId}\n`);
if (options.verbose) {
writeLastActivity(stdout, thread.updated_at);
}
return;
}
const raw = await readFile(logPath, 'utf8');
const messages = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map(parseLine)
.filter((value): value is LoggedMessage => Boolean(value));
if (messages.length === 0) {
stdout.write(`No log entries found for thread ${options.threadId}\n`);
if (options.verbose) {
writeLastActivity(stdout, thread.updated_at);
}
return;
}
let latest: LoggedMessage | undefined;
const mostRecent = messages[messages.length - 1];
if (thread.last_pulled_id) {
const idx = messages.findIndex((message) => message.id === thread.last_pulled_id);
if (idx >= 0 && idx < messages.length - 1) {
latest = messages[messages.length - 1];
} else if (idx === messages.length - 1) {
stdout.write(`No updates for thread ${options.threadId}\n`);
if (options.verbose) {
writeLastActivity(stdout, mostRecent?.created_at ?? thread.updated_at);
}
return;
}
}
if (!latest) {
latest = messages[messages.length - 1];
}
stdout.write(`Latest message for thread ${options.threadId}\n`);
stdout.write(`- ${latest.id} · ${latest.text ?? '[no-text]'}\n`);
if (options.verbose) {
writeLastActivity(stdout, latest.created_at ?? thread.updated_at);
}
await registry.updateThread(options.threadId, {
last_pulled_id: latest.id,
});
if (options.outputLastPath) {
await writeFile(options.outputLastPath, latest.text ?? '', 'utf8');
}
}
function writeLastActivity(stdout: Writable, timestamp?: string): void {
if (!timestamp) {
stdout.write('Last activity time unavailable\n');
return;
}
stdout.write(`Last activity ${timestamp}\n`);
}
import process from 'node:process';
import path from 'node:path';
import { Writable } from 'node:stream';
import { spawn } from 'node:child_process';
import { runSendThreadWorkflow } from '../lib/send-thread.ts';
import { Paths } from '../lib/paths.ts';
import { LaunchRegistry } from '../lib/launch-registry.ts';
import { markThreadError } from '../lib/thread-errors.ts';
export interface SendCommandOptions {
rootDir?: string;
threadId: string;
promptFile?: string;
promptBody?: string;
outputLastPath?: string;
stdout?: Writable;
controllerId: string;
wait?: boolean;
workingDir?: string;
personaName?: string;
printPrompt?: boolean;
dryRun?: boolean;
cliPath?: string;
}
export async function sendCommand(options: SendCommandOptions): Promise<void> {
if (!options.threadId) {
throw new Error('send command requires --thread');
}
if (!options.promptFile && !options.promptBody) {
throw new Error('send command requires prompt content (--prompt-file or --json).');
}
const stdout = options.stdout ?? process.stdout;
const paths = new Paths(options.rootDir ? path.resolve(options.rootDir) : undefined);
const launchRegistry = new LaunchRegistry(paths);
const runInline = options.wait || options.printPrompt || options.dryRun;
if (runInline) {
const useHeartbeat = options.wait && !options.printPrompt && !options.dryRun;
if (useHeartbeat) {
stdout.write(`Sending to thread ${options.threadId}... (this may take minutes)\n`);
}
// Track progress for heartbeat
const startTime = Date.now();
let lineCount = 0;
const onProgress = useHeartbeat
? (count: number) => {
lineCount = count;
}
: undefined;
// Heartbeat every 30s so caller knows we're not dead
const heartbeat = useHeartbeat
? setInterval(() => {
const elapsed = Math.round((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timestamp = new Date().toLocaleTimeString();
stdout.write(`[${timestamp}] Still running... (${mins}m ${secs}s, ${lineCount} events)\n`);
}, 30_000)
: undefined;
try {
await runSendThreadWorkflow({
rootDir: options.rootDir,
threadId: options.threadId,
promptFile: options.promptFile ? path.resolve(options.promptFile) : undefined,
promptBody: options.promptBody,
outputLastPath: options.outputLastPath ? path.resolve(options.outputLastPath) : undefined,
controllerId: options.controllerId,
workingDir: options.workingDir ? path.resolve(options.workingDir) : undefined,
personaName: options.personaName,
printPrompt: Boolean(options.printPrompt),
dryRun: Boolean(options.dryRun),
stdout,
onProgress,
});
} catch (error) {
if (!options.dryRun) {
await markThreadError(paths, {
threadId: options.threadId,
controllerId: options.controllerId,
message: formatErrorMessage(error),
});
}
throw error;
} finally {
if (heartbeat) clearInterval(heartbeat);
}
if (options.dryRun) {
stdout.write('Dry run: prompt not sent.\n');
} else {
stdout.write(`Sent prompt to thread ${options.threadId}\n`);
}
return;
}
const attempt = await launchRegistry.createAttempt({
controllerId: options.controllerId,
type: 'send',
label: options.threadId,
threadId: options.threadId,
});
launchDetachedSendWorker({ ...options, launchId: attempt.id });
stdout.write(
'Prompt sent in the background; Codex will continue processing. Use `peek`/`log` later to inspect results.\n'
);
}
function launchDetachedSendWorker(options: SendCommandOptions & { launchId?: string }): void {
const cliPath = resolveCliPath(options.cliPath);
const payloadData = {
rootDir: options.rootDir ? path.resolve(options.rootDir) : undefined,
threadId: options.threadId,
promptFile: options.promptFile ? path.resolve(options.promptFile) : undefined,
promptBody: options.promptBody ?? undefined,
outputLastPath: options.outputLastPath ? path.resolve(options.outputLastPath) : undefined,
controllerId: options.controllerId,
workingDir: options.workingDir ? path.resolve(options.workingDir) : undefined,
personaName: options.personaName,
launchId: options.launchId,
};
const payload = Buffer.from(JSON.stringify(payloadData), 'utf8').toString('base64');
const child = spawn(process.execPath, [cliPath, 'worker-send', '--payload', payload], {
detached: true,
stdio: 'ignore',
});
child.unref();
}
function resolveCliPath(overridePath?: string): string {
if (overridePath) {
return path.resolve(overridePath);
}
if (process.argv[1]) {
return process.argv[1];
}
throw new Error('Cannot determine CLI path: process.argv[1] is not set');
}
function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : JSON.stringify(error);
}
import process from 'node:process';
import { Writable } from 'node:stream';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { runStartThreadWorkflow } from '../lib/start-thread.ts';
import { loadPersonaRuntime, mapModelAliasToPermissions, PersonaRuntime } from '../lib/personas.ts';
import { StartManifest } from '../lib/start-manifest.ts';
import { composePrompt } from '../lib/prompt.ts';
import { LaunchRegistry } from '../lib/launch-registry.ts';
import { Paths } from '../lib/paths.ts';
import { validateSpawnedWorker } from '../lib/spawn-validation.ts';
import { isClaudeBackendEnabled, type PermissionLevel } from '../lib/backends.ts';
export interface StartCommandOptions {
rootDir?: string;
role?: string;
/** Permission level: 'read-only' or 'workspace-write' */
permissions?: PermissionLevel;
/** Codex-only: custom profile name (overrides permissions) */
profile?: string;
promptFile?: string;
promptBody?: string;
manifest?: StartManifest;
outputLastPath?: string;
stdout?: Writable;
controllerId: string;
wait?: boolean;
workingDir?: string;
label?: string;
personaName?: string;
printPrompt?: boolean;
dryRun?: boolean;
cliPath?: string;
/** Backend to use: 'codex' (default) or 'claude' */
backend?: 'codex' | 'claude';
/** Model to use (e.g., 'sonnet', 'opus', 'haiku') */
model?: string;
}
interface ResolvedManifestTask {
promptBody?: string;
promptFile?: string;
role: string;
permissions: PermissionLevel;
profile?: string;
workingDir?: string;
label?: string;
personaName?: string;
wait: boolean;
outputLastPath?: string;
}
interface ManifestResult {
index: number;
label?: string;
mode: 'waited' | 'detached';
threadId?: string;
}
export async function startCommand(options: StartCommandOptions): Promise<string | undefined> {
const stdout = options.stdout ?? process.stdout;
const projectRoot = getProjectRoot(options.rootDir);
const paths = new Paths(options.rootDir ? path.resolve(options.rootDir) : undefined);
const launchRegistry = new LaunchRegistry(paths);
const personaCache = new Map<string, PersonaRuntime>();
if (options.backend === 'claude' && !isClaudeBackendEnabled()) {
throw new Error('Claude backend is disabled. Set CODEX_SUBAGENT_ENABLE_CLAUDE=1 to enable it.');
}
if (options.manifest) {
if (options.promptFile || options.promptBody) {
throw new Error('Manifest mode does not accept --prompt-file or inline prompt text.');
}
await runManifestStart({
stdout,
controllerId: options.controllerId,
manifest: options.manifest,
baseOptions: options,
projectRoot,
personaCache,
launchRegistry,
});
return undefined;
}
const role = options.role;
if (!role) {
throw new Error('start command requires --role');
}
const permissions = options.permissions;
if (!permissions) {
throw new Error('start command requires --permissions');
}
const prompt = ensurePromptSource(
{ promptFile: options.promptFile, promptBody: options.promptBody },
'start command'
);
const persona = options.personaName
? await loadPersonaCached(options.personaName, projectRoot, personaCache)
: undefined;
const { permissions: resolvedPermissions, profile: resolvedProfile } = applyPersonaPermissions(permissions, options.profile, persona);
const promptFile = prompt.promptFile;
let promptBody = prompt.promptBody;
if (options.printPrompt || options.dryRun) {
if (!promptBody && !promptFile) {
throw new Error('start command requires prompt content before printing.');
}
if (!promptBody && promptFile) {
promptBody = await readFile(promptFile, 'utf8');
}
const preview = composePrompt(promptBody ?? '', {
workingDir: options.workingDir,
persona,
});
stdout.write(`${preview}\n`);
if (options.dryRun) {
stdout.write('Dry run: Codex exec not started.\n');
return undefined;
}
}
if (options.wait) {
const backendName = options.backend === 'claude' ? 'Claude' : 'Codex';
const labelHint = options.label ? ` (${options.label})` : '';
stdout.write(`Running ${backendName}${labelHint}... (this may take minutes)\n`);
// Track progress for heartbeat
const startTime = Date.now();
let lineCount = 0;
const onProgress = (count: number) => {
lineCount = count;
};
// Heartbeat every 30s so caller knows we're not dead
const heartbeat = setInterval(() => {
const elapsed = Math.round((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timestamp = new Date().toLocaleTimeString();
stdout.write(`[${timestamp}] Still running... (${mins}m ${secs}s, ${lineCount} events)\n`);
}, 30_000);
try {
const result = await runStartThreadWorkflow({
rootDir: options.rootDir,
role,
permissions: resolvedPermissions,
profile: resolvedProfile,
promptFile: promptBody ? undefined : promptFile,
promptBody,
outputLastPath: options.outputLastPath,
controllerId: options.controllerId,
workingDir: options.workingDir,
label: options.label,
persona,
onProgress,
backend: options.backend,
model: options.model,
});
stdout.write(`Started thread ${result.threadId}\n`);
return result.threadId;
} finally {
clearInterval(heartbeat);
}
}
let launchId: string | undefined;
if (!options.printPrompt && !options.dryRun) {
const attempt = await launchRegistry.createAttempt({
controllerId: options.controllerId,
type: 'start',
label: options.label,
role,
permissions: resolvedPermissions,
});
launchId = attempt.id;
}
await launchDetachedWorker({
rootDir: options.rootDir,
role,
permissions: resolvedPermissions,
profile: resolvedProfile,
promptFile: promptBody ? undefined : promptFile,
promptBody,
outputLastPath: options.outputLastPath,
controllerId: options.controllerId,
workingDir: options.workingDir,
label: options.label,
persona,
cliPath: options.cliPath,
launchId,
launchRegistry,
backend: options.backend,
model: options.model,
});
const backendName = options.backend === 'claude' ? 'Claude' : 'Codex';
stdout.write(
`Subagent launched in the background; ${backendName} may run for minutes or hours. Use \`codex-subagent list\`, \`peek\`, or \`log\` later to inspect results.\n`
);
return undefined;
}
async function runManifestStart(args: {
stdout: Writable;
controllerId: string;
manifest: StartManifest;
baseOptions: StartCommandOptions;
projectRoot: string;
personaCache: Map<string, PersonaRuntime>;
launchRegistry: LaunchRegistry;
}): Promise<void> {
const baseDir =
args.manifest.source && args.manifest.source !== 'stdin'
? path.dirname(path.resolve(args.manifest.source))
: process.cwd();
const tasks = resolveManifestTasks(args.manifest, args.baseOptions, baseDir);
if (tasks.length === 0) {
throw new Error('Manifest did not contain any tasks to launch.');
}
// Preload personas to fail fast before launching anything.
const personaNames = Array.from(
new Set(tasks.map((task) => task.personaName).filter((name): name is string => Boolean(name)))
);
for (const name of personaNames) {
await loadPersonaCached(name, args.projectRoot, args.personaCache);
}
const results: ManifestResult[] = [];
for (const [index, task] of tasks.entries()) {
const persona = task.personaName
? await loadPersonaCached(task.personaName, args.projectRoot, args.personaCache)
: undefined;
const { permissions: resolvedPermissions, profile: resolvedProfile } = applyPersonaPermissions(task.permissions, task.profile, persona);
if (task.wait) {
const result = await runStartThreadWorkflow({
rootDir: args.baseOptions.rootDir,
role: task.role,
permissions: resolvedPermissions,
profile: resolvedProfile,
promptFile: task.promptFile,
promptBody: task.promptBody,
outputLastPath: task.outputLastPath,
controllerId: args.controllerId,
workingDir: task.workingDir,
label: task.label,
persona,
backend: args.baseOptions.backend,
model: args.baseOptions.model,
});
results.push({
index,
label: task.label,
mode: 'waited',
threadId: result.threadId,
});
} else {
const attempt = await args.launchRegistry.createAttempt({
controllerId: args.controllerId,
type: 'start',
label: task.label,
role: task.role,
permissions: resolvedPermissions,
});
await launchDetachedWorker({
rootDir: args.baseOptions.rootDir,
role: task.role,
permissions: resolvedPermissions,
profile: resolvedProfile,
promptFile: task.promptFile,
promptBody: task.promptBody,
outputLastPath: task.outputLastPath,
controllerId: args.controllerId,
workingDir: task.workingDir,
label: task.label,
persona,
cliPath: args.baseOptions.cliPath,
launchId: attempt.id,
launchRegistry: args.launchRegistry,
backend: args.baseOptions.backend,
model: args.baseOptions.model,
});
results.push({
index,
label: task.label,
mode: 'detached',
});
}
}
args.stdout.write(
`Launched ${results.length} manifest task${results.length === 1 ? '' : 's'}:\n`
);
for (const result of results) {
const label = result.label ?? `Task ${result.index + 1}`;
const threadInfo =
result.mode === 'waited' ? `thread ${result.threadId ?? '[unknown]'}` : 'thread pending';
args.stdout.write(`- [${result.index}] ${label} · ${result.mode} · ${threadInfo}\n`);
}
}
function resolveManifestTasks(
manifest: StartManifest,
options: StartCommandOptions,
baseDir: string
): ResolvedManifestTask[] {
const defaults = manifest.defaults ?? {};
return manifest.tasks.map((task, index) => {
const promptBody =
task.prompt && task.prompt.trim().length > 0 ? task.prompt : options.promptBody;
const promptFile = task.promptFile ?? options.promptFile;
if (!promptBody && !promptFile) {
throw new Error(
`Manifest task ${index} is missing a prompt. Provide "prompt" text or "promptFile".`
);
}
const role = task.role ?? options.role ?? defaults.role;
if (!role) {
throw new Error(
`Manifest task ${index} is missing a role. Set it on the task, defaults.role, or --role.`
);
}
const permissions = (task.permissions ?? options.permissions ?? defaults.permissions) as PermissionLevel | undefined;
if (!permissions) {
throw new Error(
`Manifest task ${index} is missing permissions. Set it on the task, defaults.permissions, or --permissions.`
);
}
const workingDir = task.cwd ?? defaults.cwd ?? options.workingDir;
const outputLast = task.outputLast ?? defaults.outputLast ?? options.outputLastPath;
return {
promptBody,
promptFile: resolvePathRelative(promptFile, baseDir),
role,
permissions,
profile: task.profile ?? options.profile ?? defaults.profile,
workingDir: resolvePathRelative(workingDir, baseDir),
label: task.label ?? defaults.label ?? options.label,
personaName: task.persona ?? defaults.persona ?? options.personaName,
wait: task.wait ?? options.wait ?? defaults.wait ?? false,
outputLastPath: resolvePathRelative(outputLast, baseDir),
};
});
}
function resolvePathRelative(value: string | undefined, baseDir: string): string | undefined {
if (!value) {
return undefined;
}
if (path.isAbsolute(value)) {
return value;
}
return path.resolve(baseDir, value);
}
async function loadPersonaCached(
name: string,
projectRoot: string,
cache: Map<string, PersonaRuntime>
): Promise<PersonaRuntime> {
if (cache.has(name)) {
return cache.get(name)!;
}
const persona = await loadPersonaRuntime(name, { projectRoot });
cache.set(name, persona);
return persona;
}
function applyPersonaPermissions(
basePermissions: PermissionLevel,
baseProfile: string | undefined,
persona?: PersonaRuntime
): { permissions: PermissionLevel; profile?: string } {
let permissions = basePermissions;
const profile = baseProfile;
if (persona?.model) {
const mapping = mapModelAliasToPermissions(persona.model);
if (mapping.warning) {
process.stderr.write(`${mapping.warning}\n`);
}
if (mapping.permissions) {
permissions = mapping.permissions;
}
}
return { permissions, profile };
}
function ensurePromptSource(
source: { promptFile?: string; promptBody?: string },
context: string
): { promptFile?: string; promptBody?: string } {
if (source.promptBody && source.promptBody.trim().length > 0) {
return { promptBody: source.promptBody };
}
if (source.promptFile) {
return { promptFile: path.resolve(source.promptFile) };
}
throw new Error(`${context} requires prompt content (file or inline body).`);
}
interface DetachedWorkerOptions {
rootDir?: string;
role: string;
permissions: PermissionLevel;
profile?: string;
promptFile?: string;
promptBody?: string;
outputLastPath?: string;
controllerId: string;
workingDir?: string;
label?: string;
persona?: PersonaRuntime;
cliPath?: string;
launchId?: string;
launchRegistry?: LaunchRegistry;
backend?: 'codex' | 'claude';
model?: string;
}
async function launchDetachedWorker(options: DetachedWorkerOptions): Promise<void> {
const cliPath = resolveCliPath(options.cliPath);
const payloadData = {
rootDir: options.rootDir ? path.resolve(options.rootDir) : undefined,
role: options.role,
permissions: options.permissions,
profile: options.profile,
promptFile: options.promptFile ? path.resolve(options.promptFile) : undefined,
promptBody: options.promptBody ?? undefined,
outputLastPath: options.outputLastPath ? path.resolve(options.outputLastPath) : undefined,
controllerId: options.controllerId,
workingDir: options.workingDir ? path.resolve(options.workingDir) : undefined,
label: options.label,
persona: options.persona ?? null,
launchId: options.launchId,
backend: options.backend,
model: options.model,
};
const payload = Buffer.from(JSON.stringify(payloadData), 'utf8').toString('base64');
const child = spawn(process.execPath, [cliPath, 'worker-start', '--payload', payload], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr during validation
});
child.unref();
const validation = await validateSpawnedWorker(child, 500);
if (!validation.healthy) {
if (options.launchRegistry && options.launchId) {
await options.launchRegistry.markFailure(options.launchId, {
error: new Error(validation.error ?? 'Worker failed to start'),
});
}
throw new Error(`Detached worker failed to start: ${validation.error}`);
}
}
function resolveCliPath(overridePath?: string): string {
if (overridePath) {
return path.resolve(overridePath);
}
if (process.argv[1]) {
return process.argv[1];
}
throw new Error('Cannot determine CLI path: process.argv[1] is not set');
}
function getProjectRoot(rootDir?: string): string {
if (rootDir) {
return path.resolve(path.resolve(rootDir), '..');
}
return process.cwd();
}
import process from 'node:process';
import { Writable } from 'node:stream';
import { Paths } from '../lib/paths.ts';
import { Registry } from '../lib/registry.ts';
import { assertThreadOwnership } from '../lib/thread-ownership.ts';
import { formatRelativeTime } from '../lib/time.ts';
import { readLogLines, parseLogLine, LoggedMessage } from '../lib/log-lines.ts';
export interface StatusCommandOptions {
rootDir?: string;
threadId: string;
tail?: number;
raw?: boolean;
staleMinutes?: number;
stdout?: Writable;
controllerId: string;
now?: () => number;
}
const DEFAULT_STALE_MINUTES = 15;
export async function statusCommand(options: StatusCommandOptions): Promise<void> {
if (!options.threadId) {
throw new Error('status command requires --thread');
}
const stdout = options.stdout ?? process.stdout;
const staleMinutes = options.staleMinutes ?? DEFAULT_STALE_MINUTES;
if (staleMinutes <= 0) {
throw new Error('--stale-minutes must be greater than 0');
}
const nowMs = options.now ? options.now() : Date.now();
const paths = new Paths(options.rootDir);
await paths.ensure();
const registry = new Registry(paths);
const thread = await assertThreadOwnership(
await registry.get(options.threadId),
options.controllerId,
registry
);
if (!thread) {
throw new Error(`Thread ${options.threadId} not found`);
}
const logPath = paths.logFile(options.threadId);
const lines = await readLogLines(logPath);
const messages = lines
.map(parseLogLine)
.filter((message): message is LoggedMessage => Boolean(message));
const latest = messages.at(-1);
const lastActivityTs = latest?.created_at ?? thread.updated_at;
stdout.write(`Thread ${thread.thread_id}${thread.label ? ` (${thread.label})` : ''}\n`);
const status = formatStatus(thread.status);
stdout.write(`Status: ${status} · updated ${formatRelativeTime(thread.updated_at, nowMs)}\n`);
if (thread.error_message) {
stdout.write(`Error: ${thread.error_message}\n`);
}
if (latest) {
stdout.write('Latest assistant message:\n');
stdout.write(`- ${latest.id} · ${latest.text ?? '[no-text]'}\n`);
} else {
stdout.write('No assistant messages recorded yet.\n');
}
stdout.write(
`Last activity ${lastActivityTs ?? 'unknown'} (${formatRelativeTime(lastActivityTs, nowMs)})\n`
);
if (lastActivityTs) {
const idleMinutes = Math.floor((nowMs - Date.parse(lastActivityTs)) / 60000);
if (idleMinutes >= staleMinutes) {
stdout.write(`Suggestion: send a follow-up prompt (idle for ${idleMinutes}m).\n`);
}
}
if (options.tail && options.tail > 0) {
const slice = lines.slice(-options.tail);
if (options.raw) {
if (slice.length > 0) {
stdout.write(`${slice.join('\n')}\n`);
}
} else {
stdout.write(`Log tail for thread ${options.threadId} (${slice.length})\n`);
for (const line of slice) {
try {
const entry = JSON.parse(line);
stdout.write(`- ${entry.id ?? '[unknown-id]'} · ${entry.text ?? '[no-text]'}\n`);
} catch {
stdout.write(`- [invalid-json] · ${line}\n`);
}
}
}
}
}
function formatStatus(status?: string): string {
if (!status) {
return 'unknown';
}
const normalized = status.toLowerCase();
if (normalized === 'failed' || normalized === 'not_running') {
return 'NOT RUNNING';
}
return normalized;
}
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
import { stat } from 'node:fs/promises';
import { Writable } from 'node:stream';
import { Paths } from '../lib/paths.ts';
import { Registry, ThreadMetadata } from '../lib/registry.ts';
import { assertThreadOwnership } from '../lib/thread-ownership.ts';
import { readLogLines, parseLogLine } from '../lib/log-lines.ts';
import { LaunchAttempt, LaunchRegistry } from '../lib/launch-registry.ts';
export interface WaitCommandOptions {
rootDir?: string;
threadIds?: string[];
labels?: string[];
includeAll?: boolean;
intervalMs?: number;
timeoutMs?: number;
followLast?: boolean;
stdout?: Writable;
controllerId: string;
sleep?: (ms: number, signal?: AbortSignal) => Promise<boolean>;
now?: () => number;
signal?: AbortSignal;
}
const DEFAULT_INTERVAL_MS = 5000;
type OwnedThreadMap = Map<string, ThreadMetadata>;
async function defaultSleep(ms: number, signal?: AbortSignal): Promise<boolean> {
if (signal) {
try {
await delay(ms, undefined, { signal });
return !signal.aborted;
} catch (error) {
if ((error as Error).name === 'AbortError') {
return false;
}
throw error;
}
}
await delay(ms);
return true;
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remSeconds = seconds % 60;
if (remSeconds === 0) {
return `${minutes}m`;
}
return `${minutes}m ${remSeconds}s`;
}
function normalizedStatus(status?: string): string {
if (!status) {
return 'unknown';
}
return status.toLowerCase();
}
function isRunningStatus(status?: string): boolean {
const normalized = normalizedStatus(status);
return normalized === 'running' || normalized === 'queued' || normalized === 'pending';
}
async function selectOwnedThreads(
registry: Registry,
controllerId: string
): Promise<OwnedThreadMap> {
const threads = await registry.listThreads();
const ownedEntries = await Promise.all(
threads.map(async (thread) => {
try {
return await assertThreadOwnership(thread, controllerId, registry);
} catch {
return null;
}
})
);
const owned = ownedEntries.filter(
(thread): thread is ThreadMetadata => thread !== null && Boolean(thread.thread_id)
);
return new Map(owned.map((thread) => [thread.thread_id, thread]));
}
async function loadThreadById(
registry: Registry,
controllerId: string,
threadId: string
): Promise<ThreadMetadata> {
const entry = await registry.get(threadId);
return assertThreadOwnership(entry, controllerId, registry);
}
function formatThreadLabel(thread: ThreadMetadata | undefined, threadId: string): string {
if (thread?.label) {
return `${threadId} (${thread.label})`;
}
return threadId;
}
async function getLastAssistantSummary(paths: Paths, threadId: string): Promise<string | undefined> {
const lines = await readLogLines(paths.logFile(threadId));
if (lines.length === 0) {
return undefined;
}
const messages = lines
.map(parseLogLine)
.filter((message): message is NonNullable<ReturnType<typeof parseLogLine>> => Boolean(message));
if (messages.length === 0) {
return undefined;
}
const assistants = messages.filter((message) => (message.role ?? 'assistant') === 'assistant');
const latest = assistants.at(-1) ?? messages.at(-1);
if (!latest) {
return undefined;
}
const timestamp = latest.created_at ? ` (${latest.created_at})` : '';
const text = latest.text ?? '[no-text]';
return `${text}${timestamp}`;
}
interface TargetSelection {
ids: string[];
lookup: Map<string, ThreadMetadata>;
pendingLaunches: Map<string, LaunchAttempt>;
}
async function buildTargetSelection(
registry: Registry,
launchRegistry: LaunchRegistry,
controllerId: string,
options: WaitCommandOptions
): Promise<TargetSelection> {
const owned = await selectOwnedThreads(registry, controllerId);
const selected = new Map<string, ThreadMetadata>();
const pendingLaunches = new Map<string, LaunchAttempt>();
const controllerLaunches = (await launchRegistry.listAttempts()).filter(
(attempt) => attempt.controller_id === controllerId && attempt.status === 'pending'
);
if (options.includeAll) {
for (const [threadId, thread] of owned.entries()) {
selected.set(threadId, thread);
}
for (const attempt of controllerLaunches) {
pendingLaunches.set(attempt.id, attempt);
}
}
if (options.labels && options.labels.length > 0) {
for (const label of options.labels) {
const matches = Array.from(owned.values()).filter((thread) => thread.label === label);
const pendingMatches = controllerLaunches.filter((attempt) => attempt.label === label);
if (matches.length === 0 && pendingMatches.length === 0) {
throw new Error(`No threads found with label "${label}" for controller ${controllerId}`);
}
for (const match of matches) {
selected.set(match.thread_id, match);
}
for (const attempt of pendingMatches) {
pendingLaunches.set(attempt.id, attempt);
}
}
}
if (options.threadIds && options.threadIds.length > 0) {
for (const threadId of options.threadIds) {
const trimmed = threadId.trim();
if (!trimmed) {
throw new Error('Thread IDs cannot be empty strings.');
}
const local = owned.get(trimmed) ?? (await loadThreadById(registry, controllerId, trimmed));
selected.set(trimmed, local);
}
}
return { ids: Array.from(selected.keys()), lookup: selected, pendingLaunches };
}
export async function waitCommand(options: WaitCommandOptions): Promise<void> {
const hasSelection =
(options.threadIds && options.threadIds.length > 0) ||
(options.labels && options.labels.length > 0) ||
options.includeAll;
if (!hasSelection) {
throw new Error(
'wait command requires at least one selector: --threads, --labels, or --all-controller'
);
}
const stdout = options.stdout ?? process.stdout;
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
if (intervalMs <= 0) {
throw new Error('--interval-ms must be greater than 0');
}
if (options.timeoutMs !== undefined && options.timeoutMs <= 0) {
throw new Error('--timeout-ms must be greater than 0');
}
const sleep = options.sleep ?? defaultSleep;
const now = options.now ?? Date.now;
const paths = new Paths(options.rootDir);
const registry = new Registry(paths);
const launchRegistry = new LaunchRegistry(paths);
const selection = await buildTargetSelection(registry, launchRegistry, options.controllerId, options);
if (selection.ids.length === 0 && selection.pendingLaunches.size === 0) {
throw new Error('No matching threads found for wait command.');
}
const pending = new Set(selection.ids);
const pendingLaunches = new Map(selection.pendingLaunches);
const startTimestamp = now();
const targetCount = selection.ids.length + selection.pendingLaunches.size;
stdout.write(
`Waiting for ${targetCount} thread${targetCount === 1 ? '' : 's'} to stop (interval ${intervalMs}ms).\n`
);
while (pending.size > 0 || pendingLaunches.size > 0) {
const snapshot = await registry.listThreads();
const snapshotMap = new Map(snapshot.map((thread) => [thread.thread_id, thread]));
const launchThreadMap = new Map(
snapshot
.filter((thread) => thread.launch_id)
.map((thread) => [thread.launch_id as string, thread])
);
const launchAttempts = await launchRegistry.listAttempts();
const launchAttemptMap = new Map(launchAttempts.map((attempt) => [attempt.id, attempt]));
for (const [launchId, attempt] of Array.from(pendingLaunches.entries())) {
const current = launchAttemptMap.get(launchId) ?? attempt;
if (current.status === 'failed') {
const detail = current.error_message ?? 'launch failed';
const logHint = current.log_path ? ` (see ${current.log_path})` : '';
throw new Error(
`Launch ${launchId} failed before producing a thread: ${detail}${logHint}`
);
}
const linkedThread = launchThreadMap.get(launchId);
if (linkedThread) {
pending.add(linkedThread.thread_id);
selection.lookup.set(linkedThread.thread_id, linkedThread);
pendingLaunches.delete(launchId);
}
}
for (const threadId of Array.from(pending)) {
const entry = snapshotMap.get(threadId);
if (!entry) {
// Check if thread was archived
const archivePath = paths.archivedLogFile(threadId);
let wasArchived = false;
try {
await stat(archivePath);
wasArchived = true;
} catch {
// Not archived
}
if (wasArchived) {
pending.delete(threadId);
stdout.write(
`- ${formatThreadLabel(selection.lookup.get(threadId), threadId)} was archived\n`
);
if (options.followLast) {
const summary = await getLastAssistantSummary(paths, threadId);
if (summary) {
stdout.write(` Last assistant: ${summary}\n`);
}
}
} else {
throw new Error(
`Thread ${threadId} disappeared from registry unexpectedly. ` +
`This may indicate a bug or manual registry modification.`
);
}
continue;
}
const normalized = normalizedStatus(entry.status);
if (!isRunningStatus(normalized)) {
pending.delete(threadId);
stdout.write(
`- ${formatThreadLabel(entry, threadId)} finished with status ${normalized}\n`
);
if (options.followLast) {
const summary = await getLastAssistantSummary(paths, threadId);
if (summary) {
stdout.write(` Last assistant: ${summary}\n`);
}
}
}
}
if (pending.size === 0 && pendingLaunches.size === 0) {
break;
}
if (options.timeoutMs !== undefined) {
const elapsed = now() - startTimestamp;
if (elapsed >= options.timeoutMs) {
throw new Error(
`Timed out waiting for ${pending.size + pendingLaunches.size} target${
pending.size + pendingLaunches.size === 1 ? '' : 's'
} after ${formatDuration(options.timeoutMs)}`
);
}
}
const shouldContinue = await sleep(intervalMs, options.signal);
if (!shouldContinue) {
throw new Error('wait command aborted before threads completed.');
}
}
const totalElapsed = now() - startTimestamp;
stdout.write(
`All threads stopped after ${formatDuration(totalElapsed)}.\n`
);
}
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
import { Writable } from 'node:stream';
import { peekCommand } from './peek.ts';
export interface WatchCommandOptions {
rootDir?: string;
threadId: string;
intervalMs?: number;
outputLastPath?: string;
stdout?: Writable;
signal?: AbortSignal;
iterations?: number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<boolean>;
durationMs?: number;
now?: () => number;
controllerId: string;
}
const DEFAULT_INTERVAL_MS = 5000;
async function defaultSleep(ms: number, signal?: AbortSignal): Promise<boolean> {
if (signal) {
try {
await delay(ms, undefined, { signal });
return !signal.aborted;
} catch (error) {
if ((error as Error).name === 'AbortError') {
return false;
}
throw error;
}
} else {
await delay(ms);
}
return true;
}
export async function watchCommand(options: WatchCommandOptions): Promise<void> {
if (!options.threadId) {
throw new Error('watch command requires --thread');
}
const stdout = options.stdout ?? process.stdout;
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
const signal = options.signal;
const sleep = options.sleep ?? defaultSleep;
const durationMs = options.durationMs;
const nowFn = options.now ?? Date.now;
if (durationMs !== undefined && durationMs <= 0) {
throw new Error('--duration-ms must be greater than 0');
}
let remaining = options.iterations ?? Number.POSITIVE_INFINITY;
const startedAt = nowFn();
stdout.write(
`Watching thread ${options.threadId} every ${intervalMs}ms. Press Ctrl+C to stop.\n`
);
while (remaining > 0) {
if (signal?.aborted) {
break;
}
await peekCommand({
rootDir: options.rootDir,
threadId: options.threadId,
outputLastPath: options.outputLastPath,
controllerId: options.controllerId,
stdout,
});
remaining -= 1;
if (remaining <= 0) {
break;
}
if (durationMs !== undefined) {
const elapsed = nowFn() - startedAt;
if (elapsed >= durationMs) {
stdout.write(
`No updates for thread ${options.threadId} after ${formatDuration(durationMs)}; exiting.\n`
);
break;
}
}
if (signal?.aborted) {
break;
}
const shouldContinue = await sleep(intervalMs, signal);
if (!shouldContinue) {
break;
}
}
stdout.write(`Stopped watching thread ${options.threadId}\n`);
}
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
}
const seconds = ms / 1000;
if (seconds < 60) {
return `${seconds.toFixed(seconds % 1 === 0 ? 0 : 1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) {
return `${minutes}m`;
}
return `${minutes}m ${remainingSeconds.toFixed(remainingSeconds % 1 === 0 ? 0 : 1)}s`;
}
import process from 'node:process';
import path from 'node:path';
/**
* Unified permission levels across backends.
*/
export type PermissionLevel = 'read-only' | 'workspace-write';
/**
* Normalized event from any backend's JSONL stream.
*/
export interface ParsedEvent {
kind: 'session_started' | 'assistant_message' | 'completed' | 'other';
sessionId?: string;
message?: {
text?: string;
raw: unknown;
rawId?: string;
};
}
/**
* Backend-specific configuration for spawning and parsing.
*/
export interface Backend {
name: string;
command: string;
buildArgs(options: BackendExecOptions): string[];
parseEvent(event: Record<string, unknown>): ParsedEvent;
formatError(baseMessage: string, stderr: string, stdout: string): string;
}
/**
* Options passed to backend.buildArgs().
*/
export interface BackendExecOptions {
outputLastPath?: string;
permissions?: PermissionLevel;
/** Codex-only: custom profile name (overrides permissions) */
profile?: string;
extraArgs?: string[];
model?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function isClaudeBackendEnabled(): boolean {
const value = process.env.CODEX_SUBAGENT_ENABLE_CLAUDE;
if (!value) {
return false;
}
return value === '1' || value.toLowerCase() === 'true';
}
/**
* Codex CLI backend (codex exec --json)
*/
export const codexBackend: Backend = {
name: 'codex',
command: 'codex',
buildArgs(options) {
const args = ['exec', '--json', '--skip-git-repo-check'];
if (options.outputLastPath) {
args.push('--output-last-message', path.resolve(options.outputLastPath));
}
// Profile takes precedence over permissions
if (options.profile) {
args.push('--profile', options.profile);
} else if (options.permissions) {
// permissions maps directly to Codex sandbox values
args.push('--sandbox', options.permissions);
}
if (options.extraArgs && options.extraArgs.length > 0) {
args.push(...options.extraArgs);
}
args.push('-');
return args;
},
parseEvent(event) {
const eventType = typeof event.type === 'string' ? event.type : undefined;
if (eventType === 'thread.started' && typeof event.thread_id === 'string') {
return { kind: 'session_started', sessionId: event.thread_id };
}
if (
eventType === 'item.completed' &&
isRecord(event.item) &&
event.item.type === 'agent_message'
) {
const item = event.item as Record<string, unknown>;
return {
kind: 'assistant_message',
message: {
text: typeof item.text === 'string' ? item.text : undefined,
raw: item,
rawId: typeof item.id === 'string' ? item.id : undefined,
},
};
}
if (eventType === 'turn.completed') {
return { kind: 'completed' };
}
return { kind: 'other' };
},
formatError(baseMessage, stderr, stdout) {
const haystack = `${stderr}\n${stdout}`.toLowerCase();
let hint: string | undefined;
if (haystack.includes('config profile') && haystack.includes('not found')) {
hint =
'The requested policy maps to a Codex config profile that does not exist. Use the built-in policies (workspace-write/read-only) or create the profile via `codex config`.';
} else if (haystack.includes('failed to initialize rollout recorder')) {
hint =
'Codex CLI could not initialize its rollout recorder. Rerun your parent codex session with --dangerously-bypass-approvals-and-sandbox or from an environment where the rollout recorder is permitted.';
} else if (haystack.includes('command not found')) {
hint = 'Verify that the `codex` CLI is installed and available on PATH in this environment.';
}
return hint ? `codex exec failed: ${baseMessage}\nRecovery hint: ${hint}` : `codex exec failed: ${baseMessage}`;
},
};
/**
* Claude Code CLI backend (claude -p --output-format stream-json)
*/
export const claudeBackend: Backend = {
name: 'claude',
command: 'claude',
buildArgs(options) {
const args = ['-p', '--output-format', 'stream-json'];
if (options.model) {
args.push('--model', options.model);
}
// Map unified permissions to Claude's permission mode
const permissionMode = options.permissions === 'read-only'
? 'plan' // plan mode is read-only in Claude
: 'acceptEdits'; // workspace-write maps to acceptEdits
args.push('--permission-mode', permissionMode);
if (options.extraArgs && options.extraArgs.length > 0) {
args.push(...options.extraArgs);
}
// Prompt comes via stdin
args.push('-');
return args;
},
parseEvent(event) {
const eventType = typeof event.type === 'string' ? event.type : undefined;
// Session init: {"type":"system","subtype":"init","session_id":"..."}
if (
eventType === 'system' &&
event.subtype === 'init' &&
typeof event.session_id === 'string'
) {
return { kind: 'session_started', sessionId: event.session_id };
}
// Assistant message: {"type":"assistant","message":{...},"session_id":"..."}
if (eventType === 'assistant' && isRecord(event.message)) {
const msg = event.message as Record<string, unknown>;
const content = Array.isArray(msg.content)
? (msg.content as Array<{ type: string; text?: string }>).find((c) => c.type === 'text')
: undefined;
return {
kind: 'assistant_message',
message: {
text: content?.text,
raw: event,
rawId: typeof msg.id === 'string' ? msg.id : undefined,
},
};
}
// Result: {"type":"result","subtype":"success"|"error_*",...}
if (eventType === 'result') {
return { kind: 'completed' };
}
return { kind: 'other' };
},
formatError(baseMessage, stderr, stdout) {
const haystack = `${stderr}\n${stdout}`.toLowerCase();
let hint: string | undefined;
if (haystack.includes('command not found')) {
hint = 'Verify that the `claude` CLI is installed and available on PATH in this environment.';
} else if (haystack.includes('anthropic_api_key')) {
hint = 'Ensure ANTHROPIC_API_KEY is set or you are logged in via `claude login`.';
}
return hint ? `claude failed: ${baseMessage}\nRecovery hint: ${hint}` : `claude failed: ${baseMessage}`;
},
};
/**
* Get backend by name.
*/
export function getBackend(name: 'codex' | 'claude'): Backend {
switch (name) {
case 'codex':
return codexBackend;
case 'claude':
if (!isClaudeBackendEnabled()) {
throw new Error(
'Claude backend is disabled. Set CODEX_SUBAGENT_ENABLE_CLAUDE=1 to enable it.'
);
}
return claudeBackend;
default:
throw new Error(`Unknown backend: ${name}`);
}
}
import process from 'node:process';
import { spawnSync } from 'node:child_process';
export interface ControllerIdOptions {
override?: string;
psReader?: (pid: number) => ProcInfo | undefined;
startPid?: number;
}
interface ProcInfo {
pid: number;
ppid: number;
command: string;
}
let cachedId: string | undefined;
function defaultPsReader(pid: number): ProcInfo | undefined {
const result = spawnSync('ps', ['-p', String(pid), '-o', 'pid=,ppid=,command='], {
encoding: 'utf8',
});
if (result.status !== 0) {
return undefined;
}
const line = result.stdout.trim();
if (!line) {
return undefined;
}
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
if (!match) {
return undefined;
}
return {
pid: Number(match[1]),
ppid: Number(match[2]),
command: match[3],
};
}
const MAX_TREE_DEPTH = 100;
function findControllerPid(
psReader: (pid: number) => ProcInfo | undefined,
startPid: number
): string {
const visited = new Set<number>();
let currentPid = startPid;
let iterations = 0;
while (!visited.has(currentPid)) {
if (iterations++ > MAX_TREE_DEPTH) {
throw new Error(
`Process tree walk exceeded maximum depth (${MAX_TREE_DEPTH}). ` +
`Possible cycle or unusually deep process hierarchy.`
);
}
visited.add(currentPid);
const info = psReader(currentPid);
if (!info) {
break;
}
if (/^codex\b/.test(info.command)) {
return String(info.pid);
}
if (info.ppid === 0) {
break;
}
currentPid = info.ppid;
}
return String(process.pid);
}
export function getControllerId(options: ControllerIdOptions = {}): string {
if (options.override?.trim()) {
cachedId = options.override.trim();
return cachedId;
}
if (cachedId) {
return cachedId;
}
const psReader = options.psReader ?? defaultPsReader;
const startPid = options.startPid ?? process.pid;
cachedId = findControllerPid(psReader, startPid);
return cachedId;
}
export function resetControllerIdCache(): void {
cachedId = undefined;
}
import { open, mkdir, unlink } from 'node:fs/promises';
import path from 'node:path';
import type { FileHandle } from 'node:fs/promises';
export interface FileLock {
acquired: boolean;
handle: FileHandle;
path: string;
}
export async function acquireLock(lockPath: string, timeoutMs = 5000): Promise<FileLock> {
await mkdir(path.dirname(lockPath), { recursive: true });
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const handle = await open(lockPath, 'wx');
return { acquired: true, handle, path: lockPath };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
await new Promise((resolve) => setTimeout(resolve, 50));
continue;
}
throw error;
}
}
throw new Error(`Failed to acquire lock on ${lockPath} within ${timeoutMs}ms`);
}
export async function releaseLock(lock: FileLock): Promise<void> {
await lock.handle.close();
try {
await unlink(lock.path);
} catch {
// Ignore if already deleted
}
}
import { randomUUID } from 'node:crypto';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { Paths } from './paths.ts';
export type LaunchStatus = 'pending' | 'failed';
export type LaunchType = 'start' | 'send';
export interface LaunchAttempt {
id: string;
controller_id: string;
type: LaunchType;
status: LaunchStatus;
label?: string;
role?: string;
permissions?: string;
thread_id?: string;
error_message?: string;
log_path?: string;
created_at: string;
updated_at: string;
}
interface LaunchRecordMap {
[id: string]: LaunchAttempt;
}
interface CreateAttemptInput {
controllerId: string;
type: LaunchType;
label?: string;
role?: string;
permissions?: string;
threadId?: string;
}
interface FailureOptions {
error: unknown;
stderr?: string;
}
interface SuccessOptions {
threadId?: string;
}
export class LaunchRegistry {
constructor(private readonly paths: Paths) {}
async listAttempts(): Promise<LaunchAttempt[]> {
const records = await this.readAll();
return Object.values(records).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
}
async createAttempt(input: CreateAttemptInput): Promise<LaunchAttempt> {
await this.paths.ensure();
const records = await this.readAll();
const id = `launch-${randomUUID()}`;
const now = new Date().toISOString();
const entry: LaunchAttempt = {
id,
controller_id: input.controllerId,
type: input.type,
status: 'pending',
label: input.label,
role: input.role,
permissions: input.permissions,
thread_id: input.threadId,
created_at: now,
updated_at: now,
};
records[id] = entry;
await this.writeAll(records);
return entry;
}
async markFailure(id: string, options: FailureOptions): Promise<void> {
const records = await this.readAll();
const existing = records[id];
if (!existing) {
return;
}
const message = formatErrorMessage(options.error);
const logPath = await this.writeErrorLog(id, message, options);
records[id] = {
...existing,
status: 'failed',
error_message: message,
log_path: logPath,
updated_at: new Date().toISOString(),
};
await this.writeAll(records);
}
async markSuccess(id: string, options?: SuccessOptions): Promise<void> {
const records = await this.readAll();
const existing = records[id];
if (!existing) {
return;
}
if (options?.threadId) {
existing.thread_id = options.threadId;
}
delete records[id];
await this.writeAll(records);
}
async cleanupStale(maxAgeMs: number, nowMs?: number): Promise<number> {
const records = await this.readAll();
const now = nowMs ?? Date.now();
let cleaned = 0;
for (const [id, attempt] of Object.entries(records)) {
if (attempt.status !== 'pending') {
continue;
}
const age = now - new Date(attempt.created_at).getTime();
if (age > maxAgeMs) {
records[id] = {
...attempt,
status: 'failed' as LaunchStatus,
error_message: `Stale: no response after ${Math.round(age / 60000)} minutes`,
updated_at: new Date(now).toISOString(),
};
cleaned++;
}
}
if (cleaned > 0) {
await this.writeAll(records);
}
return cleaned;
}
private async writeErrorLog(
id: string,
message: string,
options: FailureOptions
): Promise<string> {
await mkdir(this.paths.launchErrorsDir, { recursive: true });
const logPath = this.paths.launchErrorFile(id);
const lines = [`Timestamp: ${new Date().toISOString()}`, `Message: ${message}`];
const stderr = options.stderr ?? extractStdErr(options.error);
if (stderr) {
lines.push('--- stderr ---', stderr);
}
if (options.error instanceof Error && options.error.stack) {
lines.push('--- stack ---', options.error.stack);
}
await writeFile(logPath, `${lines.join('\n')}\n`, 'utf8');
return logPath;
}
private async readAll(): Promise<LaunchRecordMap> {
try {
const raw = await readFile(this.paths.launchesFile, 'utf8');
if (!raw.trim()) {
return {};
}
return JSON.parse(raw) as LaunchRecordMap;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
private async writeAll(data: LaunchRecordMap): Promise<void> {
await mkdir(path.dirname(this.paths.launchesFile), { recursive: true });
await writeFile(this.paths.launchesFile, JSON.stringify(data, null, 2), 'utf8');
}
}
function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : JSON.stringify(error);
}
function extractStdErr(error: unknown): string | undefined {
if (error && typeof error === 'object' && 'stderr' in error) {
const stderr = (error as { stderr?: unknown }).stderr;
if (typeof stderr === 'string' && stderr.trim()) {
return stderr;
}
}
return undefined;
}
import { readFile } from 'node:fs/promises';
export interface LoggedMessage {
id: string;
text?: string;
created_at?: string;
role?: string;
}
export async function readLogLines(logPath: string): Promise<string[]> {
let raw: string;
try {
raw = await readFile(logPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
export function parseLogLine(line: string): LoggedMessage | undefined {
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const candidate = parsed as Record<string, unknown>;
const id = candidate.id;
if (typeof id === 'string' && id.length > 0) {
const message: LoggedMessage = { id };
if (typeof candidate.text === 'string') {
message.text = candidate.text;
}
if (typeof candidate.created_at === 'string') {
message.created_at = candidate.created_at;
}
if (typeof candidate.role === 'string') {
message.role = candidate.role;
}
return message;
}
}
} catch {
return undefined;
}
return undefined;
}
import { appendFile } from 'node:fs/promises';
import { ExecMessage } from './exec-runner.ts';
export async function appendMessages(
logPath: string,
messages: ExecMessage[] | undefined
): Promise<number> {
if (!messages || messages.length === 0) {
await appendFile(logPath, '', 'utf8');
return 0;
}
const payload = messages.map((message) => JSON.stringify(message)).join('\n') + '\n';
await appendFile(logPath, payload, 'utf8');
return messages.length;
}
{
"thread_id": "thread-123",
"last_message_id": "msg-new",
"status": "running",
"messages": [
{
"id": "msg-new",
"role": "assistant",
"created_at": "2025-11-26T16:00:00Z",
"content": [
{
"type": "text",
"text": "New update ready."
}
]
}
]
}
{
"thread_id": "thread-123",
"last_message_id": "msg-old",
"status": "running",
"messages": []
}
Related skills
FAQ
Is Codex Subagents safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.