feat: publish Codex 429 auto-continue plugin

This commit is contained in:
2026-09-11 01:29:34 +08:00
commit 6661bf26f1
14 changed files with 990 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "codex-429-auto-continue",
"interface": {
"displayName": "Codex 429 Auto Continue"
},
"plugins": [
{
"name": "codex-429-auto-continue",
"source": {
"source": "local",
"path": "./plugins/codex-429-auto-continue"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
# Changelog
All notable changes to this project are documented in this file.
## 0.1.0 - 2026-09-11
- Detect structured Codex HTTP 429 task failures.
- Continue the original task after a fixed 3-second wait.
- Prefer native continuation and fall back to sending `继续`.
- Support new, reopened, and currently loaded tasks.
- Prevent duplicate per-task watchers and false positives from ordinary text.
+17
View File
@@ -0,0 +1,17 @@
# Contributing / 参与贡献
Use Node.js 20 or newer. Keep changes focused on reliable Codex 429 recovery and add a regression test for behavior changes.
使用 Node.js 20 或更高版本。修改应聚焦于 Codex 429 续跑,并为行为变更补充回归测试。
Run the test suite before submitting a change:
提交修改前请运行:
```powershell
npm test
```
Do not commit task transcripts, plugin data, tokens, pipe names, or other machine-specific information.
请勿提交任务会话记录、插件数据、令牌、管道名称或其他机器相关信息。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cneicy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+60
View File
@@ -0,0 +1,60 @@
# Codex 429 Auto Continue
[简体中文](README.zh-CN.md)
A Codex Desktop plugin that continues a task after Codex exhausts its built-in retries with HTTP 429.
## Features
- Detects only structured `response_too_many_failed_attempts` failures with HTTP status 429.
- Waits exactly 3 seconds for every continuation attempt, with at most four plugin-initiated attempts in one failure chain.
- Uses a native retry or continue tool when Codex exposes one; otherwise sends only `继续` to the original task.
- Covers new and reopened tasks through `SessionStart` and backfills all currently loaded tasks through `UserPromptSubmit`.
- Cancels its pending continuation when Codex or the user has already started another turn.
- Uses one watcher per task to avoid duplicate continuations.
## Requirements
- Codex Desktop on Windows.
- Node.js available as `node` on `PATH`.
The plugin currently depends on Codex Desktop's internal local task-tool pipe. That interface is not a stable public API and may require compatibility updates after a Codex release.
## Install
```powershell
codex plugin marketplace add https://git.crash.work/cneicy/codex-429-auto-continue.git
codex plugin add codex-429-auto-continue@codex-429-auto-continue
```
Start or reopen a task after installation. If Codex asks you to review the hook, approve it from `/hooks`.
## Behavior
The watcher reads new records from the task transcript. After a structured 429 task failure, it waits 3 seconds and checks whether a new turn has already started. If not, it attempts native continuation and falls back to sending `继续`.
A successful task completion resets the failure chain. The chain also resets after 15 minutes without a plugin-initiated continuation. A watcher exits when its task ends or after 24 hours without transcript activity.
Prompt text, assistant text, and tool output that merely contain `429` do not trigger the plugin.
## Local Data
Per-task JSONL event logs and lock files are stored under:
```text
%USERPROFILE%\.codex\plugin-data\codex-429-auto-continue
```
The plugin does not make its own network requests. It reads local Codex transcripts and communicates with the local Codex Desktop task pipe.
## Development
```powershell
npm test
```
The installable plugin is under `plugins/codex-429-auto-continue`. The marketplace manifest is `.agents/plugins/marketplace.json`.
## License
[MIT](LICENSE)
+60
View File
@@ -0,0 +1,60 @@
# Codex 429 自动继续
[English](README.md)
这是一个 Codex Desktop 插件。当 Codex 内置重试耗尽并以 HTTP 429 中断任务时,插件会自动继续原任务。
## 功能
- 只识别结构化的 `response_too_many_failed_attempts` HTTP 429 失败记录。
- 每次续跑统一等待 3 秒;同一失败链最多由插件发起四次续跑。
- Codex 提供原生重试或继续工具时优先使用;否则只向原任务发送“继续”。
- 通过 `SessionStart` 支持新建和重新打开的任务,通过 `UserPromptSubmit` 为安装前已经加载的任务补启动监听。
- 如果 Codex 或用户已经开始新一轮任务,取消当前等待中的续跑。
- 每个任务只运行一个监听进程,避免重复续跑。
## 运行要求
- Windows 版 Codex Desktop。
- `PATH` 中可以直接调用 `node`
插件目前依赖 Codex Desktop 的本地内部任务工具管道。这个接口不是稳定的公开 API,Codex 更新后可能需要同步适配。
## 安装
```powershell
codex plugin marketplace add https://git.crash.work/cneicy/codex-429-auto-continue.git
codex plugin add codex-429-auto-continue@codex-429-auto-continue
```
安装后新建或重新打开一个任务。如果 Codex 要求审查 hook,请在 `/hooks` 中批准。
## 工作方式
监听进程持续读取任务会话记录。发现结构化 429 失败后,先等待 3 秒,再检查是否已经开始新一轮任务;如果没有,就优先调用原生继续能力,不可用时发送“继续”。
任务成功完成后会重置失败链;连续 15 分钟没有由插件发起续跑也会重置。任务结束或会话记录连续 24 小时没有变化时,监听进程退出。
用户提示、助手回复或工具输出中仅仅出现 `429` 字样,不会触发插件。
## 本地数据
每个任务的 JSONL 事件日志和锁文件保存在:
```text
%USERPROFILE%\.codex\plugin-data\codex-429-auto-continue
```
插件不会自行发起网络请求。它只读取本地 Codex 会话记录,并通过本地 Codex Desktop 任务工具管道继续任务。
## 开发
```powershell
npm test
```
可安装插件位于 `plugins/codex-429-auto-continue`marketplace 清单位于 `.agents/plugins/marketplace.json`
## 许可证
[MIT](LICENSE)
+11
View File
@@ -0,0 +1,11 @@
# Security Policy / 安全策略
## Reporting / 报告方式
Do not publish tokens, Codex transcripts, session identifiers, or local pipe details in a public issue. Report security concerns privately to `im@crash.work` with reproduction steps and the affected version.
请勿在公开 Issue 中发布令牌、Codex 会话记录、会话 ID 或本地管道信息。请将复现步骤和受影响版本通过 `im@crash.work` 私下报告。
Only the latest released version receives security fixes.
仅当前最新发布版本提供安全修复。
+14
View File
@@ -0,0 +1,14 @@
{
"name": "codex-429-auto-continue",
"version": "0.1.0",
"private": true,
"description": "Automatically continue Codex Desktop tasks after exhausted HTTP 429 retries.",
"type": "module",
"scripts": {
"test": "node --test plugins/codex-429-auto-continue/tests/hook.test.mjs"
},
"engines": {
"node": ">=20"
},
"license": "MIT"
}
@@ -0,0 +1,19 @@
{
"name": "codex-429-auto-continue",
"version": "0.1.0",
"description": "Automatically continues a Codex desktop task after an exhausted HTTP 429 retry cycle.",
"author": {
"name": "im"
},
"interface": {
"displayName": "Codex 429 Auto Continue",
"shortDescription": "Resume interrupted tasks after Codex HTTP 429 errors.",
"longDescription": "Detects a structured HTTP 429 task failure, waits 3 seconds, and continues the original Codex desktop task.",
"developerName": "im",
"category": "Productivity",
"capabilities": [
"Automation"
],
"defaultPrompt": "Show whether Codex 429 Auto Continue is installed."
}
}
@@ -0,0 +1,9 @@
# Codex 429 Auto Continue
When a Codex task ends with the structured `response_too_many_failed_attempts` HTTP 429 error, this plugin continues the same task through the Codex desktop app. It prefers a native retry/continue tool when Codex exposes one and otherwise sends `继续`.
The plugin starts one watcher per loaded session. `SessionStart` covers new and reopened sessions, while `UserPromptSubmit` backfills watchers for sessions that were already loaded when the plugin was installed. It gives Codex's built-in retry a chance to start first; when no new task has started, every continuation attempt waits exactly 3 seconds. A successful completion or 15 minutes without a plugin-initiated retry resets the chain. Prompt text, assistant text, and tool output containing `429` do not trigger it.
The watcher requires `node` on `PATH` and the Codex desktop app to remain open. Continuations appear in the original task. The watcher exits on `SessionEnd` or after 24 hours without transcript activity. Detection and delivery events are recorded under the plugin data directory as per-session JSONL logs.
After installation, start a new task and review the plugin hook once in `/hooks` when Codex asks you to trust it.
@@ -0,0 +1,523 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { appendFile, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
import net from "node:net";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export const RETRY_DELAYS_MS = [3_000, 3_000, 3_000, 3_000];
const RETRY_RESET_MS = 15 * 60_000;
const WATCH_IDLE_MS = 24 * 60 * 60_000;
const POLL_MS = 1_500;
const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
const PIPE_TIMEOUT_MS = 5_000;
const THREAD_LIST_LIMIT = 50;
const CONTINUATION_PROMPT = "继续";
const NATIVE_CONTINUATION_TOOL_NAMES = [
"retry_turn",
"continue_thread",
"retry_thread",
"resume_thread",
];
function pluginDataDirectory() {
return process.env.PLUGIN_DATA
|| join(homedir(), ".codex", "plugin-data", "codex-429-auto-continue");
}
function sessionKey(sessionId) {
return createHash("sha256").update(sessionId).digest("hex").slice(0, 24);
}
function sessionPaths(sessionId) {
const root = pluginDataDirectory();
const key = sessionKey(sessionId);
return {
lock: join(root, `${key}.lock.json`),
log: join(root, `${key}.log.jsonl`),
stop: join(root, `${key}.stop`),
};
}
async function readHookInput() {
let input = "";
for await (const chunk of process.stdin) input += chunk;
try {
const payload = JSON.parse(input);
return payload && typeof payload === "object" ? payload : {};
} catch {
return {};
}
}
export function is429Record(record) {
const payload = record?.type === "event_msg" ? record.payload : null;
if (payload?.type !== "task_complete" || typeof payload.error !== "object") {
return false;
}
const status = payload.error?.codex_error_info
?.response_too_many_failed_attempts?.http_status_code;
if (status === 429) return true;
const message = payload.error?.message;
return typeof message === "string"
&& /exceeded retry limit/i.test(message)
&& /\b429\b/.test(message);
}
export function isSuccessfulCompletion(record) {
const payload = record?.type === "event_msg" ? record.payload : null;
return payload?.type === "task_complete" && !payload.error;
}
export function hasTaskStartedAfter(records, failedRecord) {
return records.some((record) => {
if (record?.type !== "event_msg" || record.payload?.type !== "task_started") {
return false;
}
if (Number.isFinite(record.ordinal) && Number.isFinite(failedRecord?.ordinal)) {
return record.ordinal > failedRecord.ordinal;
}
return record.payload.turn_id !== failedRecord?.payload?.turn_id;
});
}
async function readTranscriptTail(path) {
const metadata = await stat(path);
const start = Math.max(0, metadata.size - TRANSCRIPT_TAIL_BYTES);
const handle = await open(path, "r");
try {
const buffer = Buffer.alloc(metadata.size - start);
await handle.read(buffer, 0, buffer.length, start);
let text = buffer.toString("utf8");
if (start > 0) {
const firstNewline = text.indexOf("\n");
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
}
return text.split(/\r?\n/).filter(Boolean).flatMap((line) => {
try {
return [JSON.parse(line)];
} catch {
return [];
}
});
} finally {
await handle.close();
}
}
async function readNewRecords(path, cursor) {
const metadata = await stat(path);
if (metadata.size < cursor.offset) {
cursor.offset = 0;
cursor.carry = "";
}
if (metadata.size === cursor.offset) return [];
const handle = await open(path, "r");
try {
const buffer = Buffer.alloc(metadata.size - cursor.offset);
await handle.read(buffer, 0, buffer.length, cursor.offset);
cursor.offset = metadata.size;
const parts = `${cursor.carry}${buffer.toString("utf8")}`.split(/\r?\n/);
cursor.carry = parts.pop() ?? "";
return parts.flatMap((line) => {
if (!line) return [];
try {
return [JSON.parse(line)];
} catch {
return [];
}
});
} finally {
await handle.close();
}
}
function pause(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function processIsAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function acquireLock(path) {
await mkdir(dirname(path), { recursive: true });
try {
const existing = JSON.parse(await readFile(path, "utf8"));
if (await processIsAlive(existing.pid)) return false;
await unlink(path).catch(() => {});
} catch {
await unlink(path).catch(() => {});
}
try {
const handle = await open(path, "wx");
await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: Date.now() })}\n`);
await handle.close();
return true;
} catch {
return false;
}
}
function encodeFrame(message) {
const payload = Buffer.from(JSON.stringify(message), "utf8");
const frame = Buffer.alloc(payload.length + 4);
frame.writeUInt32LE(payload.length, 0);
payload.copy(frame, 4);
return frame;
}
function pipeRequest(pipePath, method, params, timeoutMs = PIPE_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const socket = net.createConnection(pipePath);
let settled = false;
let pending = Buffer.alloc(0);
const finish = (error, result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
if (error) reject(error);
else resolve(result);
};
const timer = setTimeout(() => finish(new Error("Codex desktop pipe timed out")), timeoutMs);
socket.once("connect", () => {
socket.write(encodeFrame({ id: 1, jsonrpc: "2.0", method, params }));
});
socket.on("data", (chunk) => {
pending = Buffer.concat([pending, chunk]);
if (pending.length < 4) return;
const length = pending.readUInt32LE(0);
if (pending.length < length + 4) return;
try {
const response = JSON.parse(pending.subarray(4, length + 4).toString("utf8"));
if (response.error) finish(new Error(response.error.message));
else finish(null, response.result);
} catch (error) {
finish(error);
}
});
socket.once("error", (error) => finish(error));
socket.once("close", () => {
if (!settled) finish(new Error("Codex desktop pipe closed"));
});
});
}
function parseToolText(result) {
const text = result?.contentItems?.find((item) => item?.type === "inputText")?.text;
if (typeof text !== "string") return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
export function threadListContains(result, sessionId) {
const parsed = parseToolText(result);
if (!parsed || typeof parsed !== "object") return false;
return [...(parsed.pinnedThreads ?? []), ...(parsed.threads ?? [])]
.some((thread) => thread?.id === sessionId);
}
export function loadedThreadIds(result) {
const parsed = parseToolText(result);
if (!parsed || typeof parsed !== "object") return [];
return [...(parsed.pinnedThreads ?? []), ...(parsed.threads ?? [])]
.filter((thread) => {
const status = typeof thread?.status === "string"
? thread.status
: thread?.status?.type;
return thread?.id && status !== "notLoaded";
})
.map((thread) => thread.id);
}
async function pipeCandidates() {
const configured = process.env.CODEX_APP_TOOLS_PIPE_PATH?.trim();
const candidates = configured ? [configured] : [];
if (process.platform !== "win32") return candidates;
const names = await readdir("\\\\.\\pipe\\").catch(() => []);
for (const name of names) {
if (/^codex-browser-use-/i.test(name)) {
candidates.push(`\\\\.\\pipe\\${name}`);
}
}
return [...new Set(candidates)];
}
function toolCallParams(input, tool, args) {
const callId = `codex-429-${Date.now()}-${process.pid}`;
return {
arguments: args,
callId,
namespace: "codex_app",
threadId: input.sessionId,
tool,
turnId: input.failedTurnId || callId,
};
}
async function loadedDesktopSessionIds(input, dependencies = {}) {
const listCandidates = dependencies.pipeCandidates ?? pipeCandidates;
const request = dependencies.pipeRequest ?? pipeRequest;
for (const pipePath of await listCandidates()) {
try {
await request(pipePath, "tools/list", { threadStartKind: "all" });
const listed = await request(pipePath, "tools/call", toolCallParams(input, "list_threads", {
limit: THREAD_LIST_LIMIT,
}));
if (listed?.success) return loadedThreadIds(listed);
} catch {
// Other Codex pipes do not expose app task tools.
}
}
return [];
}
async function findSessionTranscripts(sessionIds, root = join(homedir(), ".codex", "sessions")) {
const wanted = new Set(sessionIds);
const found = new Map();
async function visit(directory) {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
await visit(path);
return;
}
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return;
const sessionId = [...wanted].find((id) => entry.name.includes(id));
if (!sessionId) return;
const modifiedAt = (await stat(path)).mtimeMs;
if (!found.has(sessionId) || found.get(sessionId).modifiedAt < modifiedAt) {
found.set(sessionId, { modifiedAt, path });
}
}));
}
await visit(root);
return new Map([...found].map(([sessionId, value]) => [sessionId, value.path]));
}
function nativeContinuationArguments(tool, input) {
const schema = tool?.inputSchema;
const properties = schema?.properties ?? {};
const args = {};
if (properties.threadId) args.threadId = input.sessionId;
if (properties.thread_id) args.thread_id = input.sessionId;
if (properties.turnId && input.failedTurnId) args.turnId = input.failedTurnId;
if (properties.turn_id && input.failedTurnId) args.turn_id = input.failedTurnId;
const required = Array.isArray(schema?.required) ? schema.required : [];
return required.every((name) => Object.hasOwn(args, name)) ? args : null;
}
export async function sendDesktopContinuation(input, dependencies = {}) {
const listCandidates = dependencies.pipeCandidates ?? pipeCandidates;
const request = dependencies.pipeRequest ?? pipeRequest;
const candidates = await listCandidates();
let lastError = new Error("No Codex desktop task pipe was found");
for (const pipePath of candidates) {
try {
const catalog = await request(pipePath, "tools/list", { threadStartKind: "all" });
const tools = Array.isArray(catalog?.tools) ? catalog.tools : [];
const listed = await request(pipePath, "tools/call", toolCallParams(input, "list_threads", { limit: THREAD_LIST_LIMIT }));
if (!listed?.success || !threadListContains(listed, input.sessionId)) continue;
for (const toolName of NATIVE_CONTINUATION_TOOL_NAMES) {
const tool = tools.find((candidate) => candidate?.name === toolName);
const args = nativeContinuationArguments(tool, input);
if (!tool || args == null) continue;
try {
const resumed = await request(pipePath, "tools/call", toolCallParams(input, tool.name, args));
if (resumed?.success) {
return { delivery: "native", pipePath, result: parseToolText(resumed) };
}
} catch {
// Fall back to the normal Codex message path below.
}
}
const sent = await request(pipePath, "tools/call", toolCallParams(input, "send_message_to_thread", {
prompt: CONTINUATION_PROMPT,
threadId: input.sessionId,
}));
if (!sent?.success) throw new Error("Codex desktop rejected the continuation message");
return { delivery: "message", pipePath, result: parseToolText(sent) };
} catch (error) {
lastError = error;
}
}
throw lastError;
}
export async function shouldResumeAfterDelay(failedRecord, transcriptPath, delay, dependencies = {}) {
const sleep = dependencies.pause ?? pause;
const readTail = dependencies.readTranscriptTail ?? readTranscriptTail;
await sleep(delay);
const currentRecords = await readTail(transcriptPath);
return !hasTaskStartedAfter(currentRecords, failedRecord);
}
async function watch(input) {
const paths = sessionPaths(input.sessionId);
if (!(await acquireLock(paths.lock))) return;
await unlink(paths.stop).catch(() => {});
const log = async (event, details = {}) => {
await appendFile(paths.log, `${JSON.stringify({
timestamp: new Date().toISOString(),
event,
sessionId: input.sessionId,
...details,
})}\n`, "utf8").catch(() => {});
};
let attempts = 0;
let lastResumeAt = 0;
let lastActivityAt = Date.now();
const cursor = { offset: 0, carry: "" };
try {
cursor.offset = (await stat(input.transcriptPath)).size;
await log("watcher_started", { transcriptPath: input.transcriptPath });
while (Date.now() - lastActivityAt < WATCH_IDLE_MS) {
if (await stat(paths.stop).then(() => true).catch(() => false)) break;
const records = await readNewRecords(input.transcriptPath, cursor).catch(() => []);
if (records.length === 0) {
await pause(POLL_MS);
continue;
}
lastActivityAt = Date.now();
for (const record of records) {
if (isSuccessfulCompletion(record)) {
attempts = 0;
lastResumeAt = 0;
continue;
}
if (!is429Record(record)) continue;
await log("429_detected", { turnId: record.payload?.turn_id });
if (lastResumeAt && Date.now() - lastResumeAt >= RETRY_RESET_MS) attempts = 0;
if (attempts >= RETRY_DELAYS_MS.length) continue;
for (let deliveryAttempt = attempts; deliveryAttempt < RETRY_DELAYS_MS.length; deliveryAttempt += 1) {
const delay = RETRY_DELAYS_MS[deliveryAttempt];
await log("waiting", { delayMs: delay, turnId: record.payload?.turn_id });
if (!(await shouldResumeAfterDelay(record, input.transcriptPath, delay))) {
await log("cancelled_new_task_started", { turnId: record.payload?.turn_id });
break;
}
try {
const sent = await sendDesktopContinuation({
...input,
failedTurnId: record.payload?.turn_id,
});
attempts += 1;
lastResumeAt = Date.now();
await log("desktop_continue_sent", {
delivery: sent.delivery,
pipePath: sent.pipePath,
turnId: record.payload?.turn_id,
});
break;
} catch (error) {
await log("desktop_continue_failed", {
error: error instanceof Error ? error.message : String(error),
turnId: record.payload?.turn_id,
});
}
}
}
}
} finally {
await log("watcher_stopped");
await unlink(paths.lock).catch(() => {});
await unlink(paths.stop).catch(() => {});
}
}
async function signalStop(input) {
if (!input.session_id) return;
const path = sessionPaths(input.session_id).stop;
await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.tmp`;
await writeFile(temporary, "stop\n", "utf8");
await rename(temporary, path);
}
function launchWatcher(input) {
const environment = {
...process.env,
CODEX_429_SESSION_ID: input.session_id,
CODEX_429_TRANSCRIPT_PATH: input.transcript_path,
CODEX_429_CWD: input.cwd || "",
CODEX_429_MODEL: input.model || "",
};
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "watch"], {
detached: true,
env: environment,
stdio: "ignore",
windowsHide: true,
});
child.unref();
}
async function launchLoadedSessionWatchers(input) {
const sessionIds = new Set([input.session_id]);
const loaded = await loadedDesktopSessionIds({ sessionId: input.session_id });
for (const sessionId of loaded) sessionIds.add(sessionId);
const transcripts = await findSessionTranscripts(sessionIds);
if (input.transcript_path) transcripts.set(input.session_id, input.transcript_path);
for (const [sessionId, transcriptPath] of transcripts) {
launchWatcher({
session_id: sessionId,
transcript_path: transcriptPath,
cwd: sessionId === input.session_id ? input.cwd : "",
model: sessionId === input.session_id ? input.model : "",
});
}
}
async function main() {
const mode = process.argv[2] || "start";
if (mode === "watch") {
await watch({
sessionId: process.env.CODEX_429_SESSION_ID,
transcriptPath: process.env.CODEX_429_TRANSCRIPT_PATH,
cwd: process.env.CODEX_429_CWD,
model: process.env.CODEX_429_MODEL,
});
return;
}
const input = await readHookInput();
if (process.env.CODEX_429_AUTO_CONTINUE_CHILD === "1") return;
if (mode === "stop") {
await signalStop(input);
return;
}
if ((input.hook_event_name === "SessionStart" || input.hook_event_name === "UserPromptSubmit")
&& input.session_id
&& input.transcript_path) {
await launchLoadedSessionWatchers(input);
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await main();
}
@@ -0,0 +1,40 @@
{
"description": "Watch a Codex task and resume it after a structured HTTP 429 failure.",
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/codex-429-auto-continue.mjs\"",
"timeout": 5,
"statusMessage": "Starting the Codex 429 watcher"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/codex-429-auto-continue.mjs\"",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/codex-429-auto-continue.mjs\" stop",
"timeout": 3
}
]
}
]
}
}
@@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
hasTaskStartedAfter,
is429Record,
isSuccessfulCompletion,
loadedThreadIds,
RETRY_DELAYS_MS,
sendDesktopContinuation,
shouldResumeAfterDelay,
threadListContains,
} from "../hooks/codex-429-auto-continue.mjs";
const failed = {
ordinal: 10,
type: "event_msg",
payload: {
type: "task_complete",
turn_id: "turn-1",
error: {
message: "exceeded retry limit, last status: 429 Too Many Requests",
codex_error_info: {
response_too_many_failed_attempts: { http_status_code: 429 },
},
},
},
};
test("recognizes only a structured 429 task failure", () => {
assert.equal(is429Record(failed), true);
assert.equal(is429Record({ type: "message", message: failed.payload.error.message }), false);
assert.equal(is429Record({ type: "event_msg", payload: { type: "task_complete" } }), false);
});
test("recognizes a successful task completion", () => {
assert.equal(isSuccessfulCompletion({
type: "event_msg",
payload: { type: "task_complete", last_agent_message: "done" },
}), true);
assert.equal(isSuccessfulCompletion(failed), false);
});
test("detects Codex or user retry activity after a failure", () => {
assert.equal(hasTaskStartedAfter([{
ordinal: 11,
type: "event_msg",
payload: { type: "task_started", turn_id: "turn-2" },
}], failed), true);
assert.equal(hasTaskStartedAfter([{
ordinal: 9,
type: "event_msg",
payload: { type: "task_started", turn_id: "turn-0" },
}], failed), false);
});
test("starts the retry chain after three seconds", async () => {
assert.deepEqual(RETRY_DELAYS_MS, [3_000, 3_000, 3_000, 3_000]);
let waited;
const shouldResume = await shouldResumeAfterDelay(failed, "transcript.jsonl", RETRY_DELAYS_MS[0], {
pause: async (delay) => { waited = delay; },
readTranscriptTail: async () => [failed],
});
assert.equal(waited, 3_000);
assert.equal(shouldResume, true);
const builtInRetryWon = await shouldResumeAfterDelay(failed, "transcript.jsonl", 3_000, {
pause: async () => {},
readTranscriptTail: async () => [failed, {
ordinal: 11,
type: "event_msg",
payload: { type: "task_started", turn_id: "turn-2" },
}],
});
assert.equal(builtInRetryWon, false);
});
test("finds a target task in a Codex desktop tool result", () => {
const result = {
contentItems: [{
type: "inputText",
text: JSON.stringify({ pinnedThreads: [], threads: [{ id: "session-1" }] }),
}],
success: true,
};
assert.equal(threadListContains(result, "session-1"), true);
assert.equal(threadListContains(result, "session-2"), false);
});
test("selects existing loaded tasks for watcher backfill", () => {
const result = {
contentItems: [{
type: "inputText",
text: JSON.stringify({
pinnedThreads: [{ id: "active-1", status: "active" }],
threads: [
{ id: "idle-1", status: "idle" },
{ id: "failed-1", status: "systemError" },
{ id: "closed-1", status: "notLoaded" },
],
}),
}],
success: true,
};
assert.deepEqual(loadedThreadIds(result), ["active-1", "idle-1", "failed-1"]);
});
test("sends continuation through the desktop pipe that owns the task", async () => {
const calls = [];
const result = await sendDesktopContinuation({ sessionId: "session-1", failedTurnId: "turn-1" }, {
pipeCandidates: async () => ["wrong-pipe", "right-pipe"],
pipeRequest: async (pipePath, method, params) => {
calls.push({ pipePath, method, params });
if (method === "tools/list") {
if (pipePath === "wrong-pipe") throw new Error("wrong pipe");
return { tools: [{ name: "list_threads" }, { name: "send_message_to_thread" }] };
}
if (params.tool === "list_threads") {
return {
success: true,
contentItems: [{
type: "inputText",
text: JSON.stringify({
pinnedThreads: [],
threads: pipePath === "right-pipe" ? [{ id: "session-1" }] : [],
}),
}],
};
}
return { success: true, contentItems: [{ type: "inputText", text: "{}" }] };
},
});
assert.equal(result.pipePath, "right-pipe");
const listed = calls.find((call) => call.params.tool === "list_threads");
assert.equal(listed.params.arguments.limit, 50);
const sent = calls.find((call) => call.params.tool === "send_message_to_thread");
assert.equal(sent.pipePath, "right-pipe");
assert.equal(sent.params.arguments.threadId, "session-1");
assert.equal(sent.params.arguments.prompt, "继续");
assert.equal(result.delivery, "message");
});
test("prefers a native continuation tool when Codex exposes one", async () => {
const calls = [];
const result = await sendDesktopContinuation({ sessionId: "session-1", failedTurnId: "turn-1" }, {
pipeCandidates: async () => ["app-pipe"],
pipeRequest: async (pipePath, method, params) => {
calls.push({ pipePath, method, params });
if (method === "tools/list") {
return {
tools: [
{ name: "list_threads" },
{
name: "retry_turn",
inputSchema: {
properties: { threadId: {}, turnId: {} },
required: ["threadId", "turnId"],
},
},
{ name: "send_message_to_thread" },
],
};
}
if (params.tool === "list_threads") {
return {
success: true,
contentItems: [{
type: "inputText",
text: JSON.stringify({ pinnedThreads: [], threads: [{ id: "session-1" }] }),
}],
};
}
return { success: true, contentItems: [{ type: "inputText", text: "{}" }] };
},
});
assert.equal(result.delivery, "native");
const nativeCall = calls.find((call) => call.params?.tool === "retry_turn");
assert.deepEqual(nativeCall.params.arguments, { threadId: "session-1", turnId: "turn-1" });
assert.equal(calls.some((call) => call.params?.tool === "send_message_to_thread"), false);
});