codex-sidecar 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # codex-sidecar
2
+
3
+ Claude Code から Codex App Server を薄い CLI 経由で呼び出し、同じ Codex thread を継続利用するための小さな sidecar。
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g codex-sidecar
9
+ npx skills add https://github.com/nora/codex-sidecar --yes --global
10
+ ```
11
+
12
+ 前提:
13
+
14
+ - Node.js 22+
15
+ - `codex` CLI が使えること
16
+ - `codex app-server --listen stdio://` が動くこと
17
+
18
+ ## Why
19
+
20
+ - Claude Code を主担当にする
21
+ - Codex を senior engineer / reviewer として横に置く
22
+ - broker や Claude Channel を入れず、小さい構成で長いラリーを回せるようにする
23
+
24
+ ## Current Scope
25
+
26
+ - `codex app-server` を各 CLI 実行時にローカル子プロセスで起動する
27
+ - 1 本の `thread` を state file で保持し、次回以降は resume する
28
+ - CLI から `start / ask / status / reset / stop` を提供する
29
+ - Claude Code 側は Bash または plugin からこの CLI を叩く
30
+ - 既定モデルは `gpt-5.4`、既定 reasoning effort は `high`
31
+ - 現状は state file が `.agents/state/codex-sidecar.json` 固定なので、`1 cwd = 1 sidecar session`
32
+ - thread state は各プロジェクト配下の `.agents/state/codex-sidecar.json` に保存する
33
+
34
+ `stdio://` は別プロセスから再接続できないため、現実装では常駐 app-server は持たず、各 command が app-server を起動して `threadId` / `threadPath` を再利用する。
35
+ `start` は resume 可能な rollout を materialize するために bootstrap turn を 1 回だけ流す。
36
+
37
+ ## Commands
38
+
39
+ グローバルインストール後:
40
+
41
+ ```bash
42
+ codex-sidecar start
43
+ codex-sidecar ask "この設計の弱点を挙げて"
44
+ codex-sidecar ask "さっきの2番目の弱点について、最小修正案を具体化して"
45
+ codex-sidecar status
46
+ codex-sidecar reset
47
+ codex-sidecar ask "新しい前提で、この実装方針をレビューして"
48
+ codex-sidecar stop
49
+ ```
50
+
51
+ リポジトリ内での開発:
52
+
53
+ ```bash
54
+ pnpm install
55
+ pnpm qc
56
+ pnpm dev -- help
57
+ pnpm dev -- start
58
+ pnpm dev -- ask "この設計の弱点を挙げて"
59
+ pnpm dev -- status
60
+ pnpm dev -- reset
61
+ pnpm dev -- stop
62
+ ```
63
+
64
+ ## Recovery
65
+
66
+ - `codex-sidecar status` で現在の thread / state を確認する
67
+ - `ask` が resume/state エラーなら `codex-sidecar reset`
68
+ - state file を強制的に消したいなら `codex-sidecar stop`
69
+
70
+ ## Quality
71
+
72
+ ローカル CI の入口は `pnpm qc`。
73
+
74
+ - `pnpm lint`
75
+ - `pnpm fmt:check`
76
+ - `pnpm typecheck`
77
+ - `pnpm test`
78
+ - `pnpm build`
79
+
80
+ Claude Code の `PostToolUse` hook で、`src/*.ts` への編集後に `oxlint --fix` と `oxfmt --write` が自動で走る。
81
+
82
+ ## Docs
83
+
84
+ - [docs/npm.md](docs/npm.md): npm 初回公開・更新・セキュリティ手順
85
+ - [tasks/progress.md](tasks/progress.md): 軽量ロードマップと進捗チェックリスト
86
+ - [AGENTS.md](AGENTS.md): リポジトリ運用ルール
@@ -0,0 +1,2 @@
1
+ import type { CodexClient } from "./types.js";
2
+ export declare function createAppServerClient(cwd: string): Promise<CodexClient>;
@@ -0,0 +1,299 @@
1
+ import { spawn } from "node:child_process";
2
+ import { DEFAULT_MODEL, DEFAULT_REASONING_EFFORT } from "./defaults.js";
3
+ const CLIENT_VERSION = "0.0.0";
4
+ const TURN_TIMEOUT_MS = 5 * 60 * 1000;
5
+ const SIDECAR_BASE_INSTRUCTIONS = [
6
+ "あなたはこのリポジトリの senior engineer です。",
7
+ "KISS / DRY / YAGNI を優先してください。",
8
+ "日本語で返答してください。",
9
+ "批判的にレビューしてください。",
10
+ "必要なら代替案を提案してください。",
11
+ "返答は簡潔にしてください。",
12
+ ].join("\n");
13
+ export async function createAppServerClient(cwd) {
14
+ const client = new AppServerClient(cwd);
15
+ await client.initialize();
16
+ return client;
17
+ }
18
+ class AppServerClient {
19
+ child;
20
+ pendingRequests = new Map();
21
+ agentMessages = new Map();
22
+ completedTurns = new Map();
23
+ turnWaiters = new Map();
24
+ stderrLines = [];
25
+ cwd;
26
+ nextId = 1;
27
+ stdoutBuffer = "";
28
+ closed = false;
29
+ constructor(cwd) {
30
+ this.cwd = cwd;
31
+ this.child = spawn("codex", ["app-server", "--listen", "stdio://"], {
32
+ cwd,
33
+ stdio: ["pipe", "pipe", "pipe"],
34
+ });
35
+ this.child.stdout.setEncoding("utf8");
36
+ this.child.stdout.on("data", (chunk) => {
37
+ this.handleStdoutChunk(chunk);
38
+ });
39
+ this.child.stderr.setEncoding("utf8");
40
+ this.child.stderr.on("data", (chunk) => {
41
+ this.handleStderrChunk(chunk);
42
+ });
43
+ this.child.on("exit", (code, signal) => {
44
+ this.handleExit(code, signal);
45
+ });
46
+ this.child.on("error", (error) => {
47
+ this.handleFatalError(error);
48
+ });
49
+ }
50
+ async close() {
51
+ if (this.closed) {
52
+ return;
53
+ }
54
+ this.closed = true;
55
+ this.child.stdin.end();
56
+ this.child.kill();
57
+ }
58
+ async createThread() {
59
+ const response = await this.request("thread/start", {
60
+ model: DEFAULT_MODEL,
61
+ cwd: this.cwd,
62
+ approvalPolicy: "never",
63
+ sandbox: "workspace-write",
64
+ baseInstructions: SIDECAR_BASE_INSTRUCTIONS,
65
+ personality: "pragmatic",
66
+ experimentalRawEvents: false,
67
+ persistExtendedHistory: true,
68
+ });
69
+ return response.thread;
70
+ }
71
+ async resumeThread(threadId, threadPath) {
72
+ const response = await this.request("thread/resume", {
73
+ threadId,
74
+ path: threadPath ?? undefined,
75
+ model: DEFAULT_MODEL,
76
+ cwd: this.cwd,
77
+ approvalPolicy: "never",
78
+ sandbox: "workspace-write",
79
+ baseInstructions: SIDECAR_BASE_INSTRUCTIONS,
80
+ personality: "pragmatic",
81
+ persistExtendedHistory: true,
82
+ });
83
+ return response.thread;
84
+ }
85
+ async archiveThread(threadId) {
86
+ await this.request("thread/archive", { threadId });
87
+ }
88
+ async startTurn(threadId, message) {
89
+ const response = await this.request("turn/start", {
90
+ threadId,
91
+ effort: DEFAULT_REASONING_EFFORT,
92
+ input: [
93
+ {
94
+ type: "text",
95
+ text: message,
96
+ text_elements: [],
97
+ },
98
+ ],
99
+ });
100
+ const turnId = response.turn.id;
101
+ const completed = this.completedTurns.get(turnId);
102
+ if (completed) {
103
+ return this.buildTurnResult(turnId, completed);
104
+ }
105
+ return await new Promise((resolve, reject) => {
106
+ const timeout = setTimeout(() => {
107
+ this.turnWaiters.delete(turnId);
108
+ reject(new Error(`Timed out waiting for turn completion: ${turnId}`));
109
+ }, TURN_TIMEOUT_MS);
110
+ this.turnWaiters.set(turnId, { resolve, reject, timeout });
111
+ });
112
+ }
113
+ async initialize() {
114
+ await this.request("initialize", {
115
+ clientInfo: {
116
+ name: "codex-sidecar",
117
+ title: "codex-sidecar",
118
+ version: CLIENT_VERSION,
119
+ },
120
+ capabilities: {
121
+ experimentalApi: true,
122
+ },
123
+ });
124
+ }
125
+ handleStdoutChunk(chunk) {
126
+ this.stdoutBuffer += chunk;
127
+ let newlineIndex = this.stdoutBuffer.indexOf("\n");
128
+ while (newlineIndex >= 0) {
129
+ const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
130
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
131
+ if (line) {
132
+ this.handleMessage(line);
133
+ }
134
+ newlineIndex = this.stdoutBuffer.indexOf("\n");
135
+ }
136
+ }
137
+ handleStderrChunk(chunk) {
138
+ for (const line of chunk.split("\n")) {
139
+ const trimmed = line.trim();
140
+ if (!trimmed) {
141
+ continue;
142
+ }
143
+ this.stderrLines.push(trimmed);
144
+ if (this.stderrLines.length > 20) {
145
+ this.stderrLines.shift();
146
+ }
147
+ }
148
+ }
149
+ handleMessage(line) {
150
+ const message = JSON.parse(line);
151
+ if (typeof message.method === "string") {
152
+ this.handleNotification(message.method, message.params);
153
+ return;
154
+ }
155
+ if (typeof message.id !== "number") {
156
+ return;
157
+ }
158
+ const handler = this.pendingRequests.get(message.id);
159
+ if (!handler) {
160
+ return;
161
+ }
162
+ this.pendingRequests.delete(message.id);
163
+ if (isJsonRpcFailure(message)) {
164
+ handler.reject(new Error(message.error.message));
165
+ return;
166
+ }
167
+ if ("result" in message) {
168
+ handler.resolve(message.result);
169
+ return;
170
+ }
171
+ handler.reject(new Error("Received malformed JSON-RPC response"));
172
+ }
173
+ handleNotification(method, params) {
174
+ if (!isRecord(params)) {
175
+ return;
176
+ }
177
+ if (method === "item/completed") {
178
+ this.handleItemCompleted(params);
179
+ return;
180
+ }
181
+ if (method === "turn/completed") {
182
+ this.handleTurnCompleted(params);
183
+ }
184
+ }
185
+ handleItemCompleted(params) {
186
+ const { turnId, item } = params;
187
+ if (typeof turnId !== "string" || !isRecord(item)) {
188
+ return;
189
+ }
190
+ if (item.type !== "agentMessage" || typeof item.text !== "string") {
191
+ return;
192
+ }
193
+ const messages = this.agentMessages.get(turnId) ?? [];
194
+ messages.push(item.text);
195
+ this.agentMessages.set(turnId, messages);
196
+ }
197
+ handleTurnCompleted(params) {
198
+ const { turn } = params;
199
+ if (!isRecord(turn) || typeof turn.id !== "string") {
200
+ return;
201
+ }
202
+ const status = normalizeTurnStatus(turn.status);
203
+ if (!status) {
204
+ return;
205
+ }
206
+ const errorMessage = isRecord(turn.error) && typeof turn.error.message === "string" ? turn.error.message : null;
207
+ const completedTurn = { status, errorMessage };
208
+ this.completedTurns.set(turn.id, completedTurn);
209
+ const waiter = this.turnWaiters.get(turn.id);
210
+ if (!waiter) {
211
+ return;
212
+ }
213
+ clearTimeout(waiter.timeout);
214
+ this.turnWaiters.delete(turn.id);
215
+ waiter.resolve(this.buildTurnResult(turn.id, completedTurn));
216
+ }
217
+ buildTurnResult(turnId, completedTurn) {
218
+ const message = (this.agentMessages.get(turnId) ?? []).join("\n\n").trim();
219
+ return {
220
+ turnId,
221
+ status: completedTurn.status,
222
+ message,
223
+ errorMessage: completedTurn.errorMessage,
224
+ };
225
+ }
226
+ handleExit(code, signal) {
227
+ if (this.closed) {
228
+ return;
229
+ }
230
+ const reason = new Error(`codex app-server exited unexpectedly (${formatExit(code, signal)})${this.formatStderrSuffix()}`);
231
+ this.closed = true;
232
+ this.rejectAll(reason);
233
+ }
234
+ handleFatalError(error) {
235
+ if (this.closed) {
236
+ return;
237
+ }
238
+ this.closed = true;
239
+ this.rejectAll(error);
240
+ }
241
+ rejectAll(error) {
242
+ for (const handler of this.pendingRequests.values()) {
243
+ handler.reject(error);
244
+ }
245
+ this.pendingRequests.clear();
246
+ for (const waiter of this.turnWaiters.values()) {
247
+ clearTimeout(waiter.timeout);
248
+ waiter.reject(error);
249
+ }
250
+ this.turnWaiters.clear();
251
+ }
252
+ formatStderrSuffix() {
253
+ if (this.stderrLines.length === 0) {
254
+ return "";
255
+ }
256
+ return `: ${this.stderrLines.at(-1)}`;
257
+ }
258
+ async request(method, params) {
259
+ if (this.closed) {
260
+ throw new Error("codex app-server client is already closed");
261
+ }
262
+ const id = this.nextId++;
263
+ const payload = JSON.stringify({
264
+ jsonrpc: "2.0",
265
+ id,
266
+ method,
267
+ params,
268
+ });
269
+ const responsePromise = new Promise((resolve, reject) => {
270
+ this.pendingRequests.set(id, {
271
+ resolve: (value) => resolve(value),
272
+ reject,
273
+ });
274
+ });
275
+ this.child.stdin.write(`${payload}\n`);
276
+ return await responsePromise;
277
+ }
278
+ }
279
+ function normalizeTurnStatus(status) {
280
+ if (status === "completed" || status === "failed" || status === "interrupted") {
281
+ return status;
282
+ }
283
+ return null;
284
+ }
285
+ function formatExit(code, signal) {
286
+ if (signal) {
287
+ return `signal ${signal}`;
288
+ }
289
+ return `code ${code ?? "unknown"}`;
290
+ }
291
+ function isRecord(value) {
292
+ return typeof value === "object" && value !== null;
293
+ }
294
+ function isJsonRpcFailure(value) {
295
+ return (typeof value.id === "number" &&
296
+ isRecord(value.error) &&
297
+ typeof value.error.code === "number" &&
298
+ typeof value.error.message === "string");
299
+ }
@@ -0,0 +1,2 @@
1
+ export declare const DEFAULT_MODEL = "gpt-5.4";
2
+ export declare const DEFAULT_REASONING_EFFORT = "high";
@@ -0,0 +1,2 @@
1
+ export const DEFAULT_MODEL = "gpt-5.4";
2
+ export const DEFAULT_REASONING_EFFORT = "high";
@@ -0,0 +1 @@
1
+ export declare const SESSION_BOOTSTRAP_MESSAGE = "sidecar \u30BB\u30C3\u30B7\u30E7\u30F3\u3092\u958B\u59CB\u3057\u307E\u3059\u3002\u4EE5\u5F8C\u306E\u76F8\u8AC7\u306B\u5099\u3048\u3066\u304F\u3060\u3055\u3044\u3002\u3053\u306E turn \u3067\u306F\u300C\u4E86\u89E3\u3057\u307E\u3057\u305F\u3002\u300D\u3068\u3060\u3051\u8FD4\u7B54\u3057\u3066\u304F\u3060\u3055\u3044\u3002";
@@ -0,0 +1 @@
1
+ export const SESSION_BOOTSTRAP_MESSAGE = "sidecar セッションを開始します。以後の相談に備えてください。この turn では「了解しました。」とだけ返答してください。";
@@ -0,0 +1,10 @@
1
+ import type { SidecarState } from "./types.js";
2
+ export declare class InvalidStateFileError extends Error {
3
+ readonly stateFilePath: string;
4
+ constructor(stateFilePath: string, message: string);
5
+ }
6
+ export declare function isInvalidStateFileError(error: unknown): error is InvalidStateFileError;
7
+ export declare function getDefaultStateFilePath(cwd: string): string;
8
+ export declare function readState(stateFilePath: string): Promise<SidecarState | null>;
9
+ export declare function writeState(stateFilePath: string, state: SidecarState): Promise<void>;
10
+ export declare function deleteState(stateFilePath: string): Promise<void>;
@@ -0,0 +1,68 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export class InvalidStateFileError extends Error {
4
+ stateFilePath;
5
+ constructor(stateFilePath, message) {
6
+ super(`Invalid sidecar state file: ${stateFilePath}: ${message}`);
7
+ this.stateFilePath = stateFilePath;
8
+ this.name = "InvalidStateFileError";
9
+ }
10
+ }
11
+ export function isInvalidStateFileError(error) {
12
+ return error instanceof InvalidStateFileError;
13
+ }
14
+ export function getDefaultStateFilePath(cwd) {
15
+ return path.join(cwd, ".agents", "state", "codex-sidecar.json");
16
+ }
17
+ export async function readState(stateFilePath) {
18
+ try {
19
+ const raw = await readFile(stateFilePath, "utf8");
20
+ return validateState(stateFilePath, JSON.parse(raw));
21
+ }
22
+ catch (error) {
23
+ if (isMissingFileError(error)) {
24
+ return null;
25
+ }
26
+ if (error instanceof SyntaxError) {
27
+ throw new InvalidStateFileError(stateFilePath, "invalid JSON");
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+ export async function writeState(stateFilePath, state) {
33
+ await mkdir(path.dirname(stateFilePath), { recursive: true });
34
+ await writeFile(stateFilePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
35
+ }
36
+ export async function deleteState(stateFilePath) {
37
+ await rm(stateFilePath, { force: true });
38
+ }
39
+ function validateState(stateFilePath, value) {
40
+ if (!isRecord(value)) {
41
+ throw new InvalidStateFileError(stateFilePath, "expected object");
42
+ }
43
+ const { version, threadId, threadPath, cwd, startedAt, updatedAt } = value;
44
+ if (version !== 1) {
45
+ throw new InvalidStateFileError(stateFilePath, "unsupported version");
46
+ }
47
+ if (typeof threadId !== "string" ||
48
+ (threadPath !== null && typeof threadPath !== "string") ||
49
+ typeof cwd !== "string" ||
50
+ typeof startedAt !== "string" ||
51
+ typeof updatedAt !== "string") {
52
+ throw new InvalidStateFileError(stateFilePath, "missing required fields");
53
+ }
54
+ return {
55
+ version,
56
+ threadId,
57
+ threadPath,
58
+ cwd,
59
+ startedAt,
60
+ updatedAt,
61
+ };
62
+ }
63
+ function isRecord(value) {
64
+ return typeof value === "object" && value !== null;
65
+ }
66
+ function isMissingFileError(error) {
67
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
68
+ }
@@ -0,0 +1,34 @@
1
+ export interface SidecarState {
2
+ version: 1;
3
+ threadId: string;
4
+ threadPath: string | null;
5
+ cwd: string;
6
+ startedAt: string;
7
+ updatedAt: string;
8
+ }
9
+ export interface CodexThread {
10
+ id: string;
11
+ path: string | null;
12
+ cwd: string;
13
+ }
14
+ export interface CodexTurnResult {
15
+ turnId: string;
16
+ status: "completed" | "failed" | "interrupted";
17
+ message: string;
18
+ errorMessage: string | null;
19
+ }
20
+ export interface CodexClient {
21
+ createThread(): Promise<CodexThread>;
22
+ resumeThread(threadId: string, threadPath?: string | null): Promise<CodexThread>;
23
+ archiveThread(threadId: string): Promise<void>;
24
+ startTurn(threadId: string, message: string): Promise<CodexTurnResult>;
25
+ close(): Promise<void>;
26
+ }
27
+ export interface CommandContext {
28
+ cwd: string;
29
+ now: () => Date;
30
+ stateFilePath: string;
31
+ stdout: (message: string) => void;
32
+ stderr: (message: string) => void;
33
+ createClient: (cwd: string) => Promise<CodexClient>;
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../codex/types.js";
2
+ export declare function runAskCommand(context: CommandContext, message: string): Promise<void>;
@@ -0,0 +1,37 @@
1
+ import { formatResumeFailureMessage, readStateWithRecoveryHint } from "./errors.js";
2
+ import { writeState } from "../codex/state.js";
3
+ export async function runAskCommand(context, message) {
4
+ if (!message.trim()) {
5
+ throw new Error("Usage: codex-sidecar ask <message>");
6
+ }
7
+ const state = await readStateWithRecoveryHint(context.stateFilePath);
8
+ if (!state) {
9
+ throw new Error("Sidecar session is not started. Run `codex-sidecar start`.");
10
+ }
11
+ const client = await context.createClient(context.cwd);
12
+ try {
13
+ const thread = await resumeThreadWithRecoveryHint(client, state.threadId, state.threadPath);
14
+ const turn = await client.startTurn(thread.id, message);
15
+ await writeState(context.stateFilePath, {
16
+ ...state,
17
+ threadPath: thread.path,
18
+ cwd: thread.cwd,
19
+ updatedAt: context.now().toISOString(),
20
+ });
21
+ if (turn.status !== "completed") {
22
+ throw new Error(turn.errorMessage ?? `Turn ended with status: ${turn.status}`);
23
+ }
24
+ context.stdout(turn.message);
25
+ }
26
+ finally {
27
+ await client.close();
28
+ }
29
+ }
30
+ async function resumeThreadWithRecoveryHint(client, threadId, threadPath) {
31
+ try {
32
+ return await client.resumeThread(threadId, threadPath);
33
+ }
34
+ catch (error) {
35
+ throw new Error(formatResumeFailureMessage(threadId, threadPath, error));
36
+ }
37
+ }
@@ -0,0 +1,7 @@
1
+ import { InvalidStateFileError, isInvalidStateFileError } from "../codex/state.js";
2
+ import type { SidecarState } from "../codex/types.js";
3
+ export declare function readStateWithRecoveryHint(stateFilePath: string): Promise<SidecarState | null>;
4
+ export declare function formatStateRecoveryMessage(error: InvalidStateFileError): string;
5
+ export declare function formatResumeFailureMessage(threadId: string, threadPath: string | null, error: unknown): string;
6
+ export declare function formatArchiveWarning(threadId: string, error: unknown): string;
7
+ export { isInvalidStateFileError };
@@ -0,0 +1,32 @@
1
+ import { isInvalidStateFileError, readState } from "../codex/state.js";
2
+ export async function readStateWithRecoveryHint(stateFilePath) {
3
+ try {
4
+ return await readState(stateFilePath);
5
+ }
6
+ catch (error) {
7
+ if (isInvalidStateFileError(error)) {
8
+ throw new Error(formatStateRecoveryMessage(error));
9
+ }
10
+ throw error;
11
+ }
12
+ }
13
+ export function formatStateRecoveryMessage(error) {
14
+ return [
15
+ error.message,
16
+ "Run `codex-sidecar reset` to recreate the sidecar thread, or `codex-sidecar stop` to clear local state.",
17
+ ].join("\n");
18
+ }
19
+ export function formatResumeFailureMessage(threadId, threadPath, error) {
20
+ const reason = error instanceof Error ? error.message : String(error);
21
+ return [
22
+ `Failed to resume sidecar thread: ${threadId}`,
23
+ `Thread path: ${threadPath ?? "(none)"}`,
24
+ `Reason: ${reason}`,
25
+ "Run `codex-sidecar reset` to recreate the sidecar thread, or `codex-sidecar stop` to clear local state.",
26
+ ].join("\n");
27
+ }
28
+ export function formatArchiveWarning(threadId, error) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ return `Archive warning for ${threadId}: ${message}`;
31
+ }
32
+ export { isInvalidStateFileError };
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../codex/types.js";
2
+ export declare function runResetCommand(context: CommandContext): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import { formatArchiveWarning, formatStateRecoveryMessage } from "./errors.js";
2
+ import { SESSION_BOOTSTRAP_MESSAGE } from "../codex/session.js";
3
+ import { isInvalidStateFileError, readState, writeState } from "../codex/state.js";
4
+ export async function runResetCommand(context) {
5
+ const existingState = await readExistingState(context);
6
+ if (existingState.kind === "missing") {
7
+ throw new Error("Sidecar session is not started. Run `codex-sidecar start`.");
8
+ }
9
+ const client = await context.createClient(context.cwd);
10
+ try {
11
+ if (existingState.kind === "active") {
12
+ try {
13
+ await client.archiveThread(existingState.threadId);
14
+ }
15
+ catch (error) {
16
+ context.stderr(formatArchiveWarning(existingState.threadId, error));
17
+ }
18
+ }
19
+ const thread = await client.createThread();
20
+ const turn = await client.startTurn(thread.id, SESSION_BOOTSTRAP_MESSAGE);
21
+ if (turn.status !== "completed") {
22
+ throw new Error(turn.errorMessage ?? `Turn ended with status: ${turn.status}`);
23
+ }
24
+ const timestamp = context.now().toISOString();
25
+ await writeState(context.stateFilePath, {
26
+ version: 1,
27
+ threadId: thread.id,
28
+ threadPath: thread.path,
29
+ cwd: thread.cwd,
30
+ startedAt: timestamp,
31
+ updatedAt: timestamp,
32
+ });
33
+ context.stdout(`Reset sidecar thread: ${thread.id}`);
34
+ }
35
+ finally {
36
+ await client.close();
37
+ }
38
+ }
39
+ async function readExistingState(context) {
40
+ try {
41
+ const state = await readState(context.stateFilePath);
42
+ if (!state) {
43
+ return { kind: "missing" };
44
+ }
45
+ return { kind: "active", threadId: state.threadId };
46
+ }
47
+ catch (error) {
48
+ if (!isInvalidStateFileError(error)) {
49
+ throw error;
50
+ }
51
+ context.stderr(formatStateRecoveryMessage(error));
52
+ return { kind: "invalid" };
53
+ }
54
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../codex/types.js";
2
+ export declare function runStartCommand(context: CommandContext): Promise<void>;
@@ -0,0 +1,29 @@
1
+ import { SESSION_BOOTSTRAP_MESSAGE } from "../codex/session.js";
2
+ import { readState, writeState } from "../codex/state.js";
3
+ export async function runStartCommand(context) {
4
+ const existingState = await readState(context.stateFilePath);
5
+ if (existingState) {
6
+ throw new Error(`Sidecar session is already active: ${existingState.threadId}`);
7
+ }
8
+ const client = await context.createClient(context.cwd);
9
+ try {
10
+ const thread = await client.createThread();
11
+ const turn = await client.startTurn(thread.id, SESSION_BOOTSTRAP_MESSAGE);
12
+ if (turn.status !== "completed") {
13
+ throw new Error(turn.errorMessage ?? `Turn ended with status: ${turn.status}`);
14
+ }
15
+ const timestamp = context.now().toISOString();
16
+ await writeState(context.stateFilePath, {
17
+ version: 1,
18
+ threadId: thread.id,
19
+ threadPath: thread.path,
20
+ cwd: thread.cwd,
21
+ startedAt: timestamp,
22
+ updatedAt: timestamp,
23
+ });
24
+ context.stdout(`Started sidecar thread: ${thread.id}`);
25
+ }
26
+ finally {
27
+ await client.close();
28
+ }
29
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../codex/types.js";
2
+ export declare function runStatusCommand(context: CommandContext): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { DEFAULT_MODEL, DEFAULT_REASONING_EFFORT } from "../codex/defaults.js";
2
+ import { formatStateRecoveryMessage, isInvalidStateFileError } from "./errors.js";
3
+ import { readState } from "../codex/state.js";
4
+ export async function runStatusCommand(context) {
5
+ try {
6
+ const state = await readState(context.stateFilePath);
7
+ if (!state) {
8
+ context.stdout(formatStoppedStatus(context.stateFilePath));
9
+ return;
10
+ }
11
+ context.stdout([
12
+ "Sidecar session: active",
13
+ `Thread ID: ${state.threadId}`,
14
+ `Thread path: ${state.threadPath ?? "(none)"}`,
15
+ `Thread cwd: ${state.cwd}`,
16
+ `Started at: ${state.startedAt}`,
17
+ `Updated at: ${state.updatedAt}`,
18
+ `State file: ${context.stateFilePath}`,
19
+ `Default model: ${DEFAULT_MODEL}`,
20
+ `Default reasoning effort: ${DEFAULT_REASONING_EFFORT}`,
21
+ ].join("\n"));
22
+ }
23
+ catch (error) {
24
+ if (!isInvalidStateFileError(error)) {
25
+ throw error;
26
+ }
27
+ context.stdout([
28
+ "Sidecar session: invalid-state",
29
+ `State file: ${context.stateFilePath}`,
30
+ formatStateRecoveryMessage(error),
31
+ `Default model: ${DEFAULT_MODEL}`,
32
+ `Default reasoning effort: ${DEFAULT_REASONING_EFFORT}`,
33
+ ].join("\n"));
34
+ }
35
+ }
36
+ function formatStoppedStatus(stateFilePath) {
37
+ return [
38
+ "Sidecar session: stopped",
39
+ `State file: ${stateFilePath}`,
40
+ `Default model: ${DEFAULT_MODEL}`,
41
+ `Default reasoning effort: ${DEFAULT_REASONING_EFFORT}`,
42
+ ].join("\n");
43
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../codex/types.js";
2
+ export declare function runStopCommand(context: CommandContext): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import { formatArchiveWarning, formatStateRecoveryMessage } from "./errors.js";
2
+ import { deleteState, isInvalidStateFileError, readState } from "../codex/state.js";
3
+ export async function runStopCommand(context) {
4
+ const state = await readStateForStop(context);
5
+ if (state.kind === "missing") {
6
+ context.stdout("No active sidecar session.");
7
+ return;
8
+ }
9
+ if (state.kind === "active") {
10
+ const client = await context.createClient(context.cwd);
11
+ try {
12
+ try {
13
+ await client.archiveThread(state.threadId);
14
+ }
15
+ catch (error) {
16
+ context.stderr(formatArchiveWarning(state.threadId, error));
17
+ }
18
+ }
19
+ finally {
20
+ await client.close();
21
+ }
22
+ }
23
+ await deleteState(context.stateFilePath);
24
+ context.stdout("Stopped sidecar session.");
25
+ }
26
+ async function readStateForStop(context) {
27
+ try {
28
+ const state = await readState(context.stateFilePath);
29
+ if (!state) {
30
+ return { kind: "missing" };
31
+ }
32
+ return { kind: "active", threadId: state.threadId };
33
+ }
34
+ catch (error) {
35
+ if (!isInvalidStateFileError(error)) {
36
+ throw error;
37
+ }
38
+ context.stderr(formatStateRecoveryMessage(error));
39
+ return { kind: "invalid" };
40
+ }
41
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./main.js";
3
+ void runCli(process.argv.slice(2));
package/dist/main.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { CommandContext } from "./codex/types.js";
2
+ export declare function getUsageText(): string;
3
+ interface RunCliOptions {
4
+ context?: Partial<CommandContext>;
5
+ }
6
+ export declare function runCli(argv: readonly string[], options?: RunCliOptions): Promise<void>;
7
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,61 @@
1
+ import { runAskCommand } from "./commands/ask.js";
2
+ import { runResetCommand } from "./commands/reset.js";
3
+ import { runStartCommand } from "./commands/start.js";
4
+ import { runStatusCommand } from "./commands/status.js";
5
+ import { runStopCommand } from "./commands/stop.js";
6
+ import { createAppServerClient } from "./codex/app-server-client.js";
7
+ import { getDefaultStateFilePath } from "./codex/state.js";
8
+ export function getUsageText() {
9
+ return `Usage: codex-sidecar <command> [args]
10
+
11
+ Commands:
12
+ start Create and save a new Codex sidecar thread
13
+ ask <message> Send a message to the active Codex thread
14
+ status Show current sidecar state and default model settings
15
+ reset Archive the current thread and create a new one
16
+ stop Archive the current thread and clear local state`;
17
+ }
18
+ export async function runCli(argv, options = {}) {
19
+ const context = createCommandContext(options.context);
20
+ const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv;
21
+ const [command = "help", ...rest] = normalizedArgv;
22
+ try {
23
+ switch (command) {
24
+ case "start":
25
+ await runStartCommand(context);
26
+ return;
27
+ case "ask":
28
+ await runAskCommand(context, rest.join(" "));
29
+ return;
30
+ case "reset":
31
+ await runResetCommand(context);
32
+ return;
33
+ case "status":
34
+ await runStatusCommand(context);
35
+ return;
36
+ case "stop":
37
+ await runStopCommand(context);
38
+ return;
39
+ default:
40
+ context.stdout(getUsageText());
41
+ }
42
+ }
43
+ catch (error) {
44
+ context.stderr(getErrorMessage(error));
45
+ process.exitCode = 1;
46
+ }
47
+ }
48
+ function createCommandContext(overrides) {
49
+ const cwd = overrides?.cwd ?? process.cwd();
50
+ return {
51
+ cwd,
52
+ now: overrides?.now ?? (() => new Date()),
53
+ stateFilePath: overrides?.stateFilePath ?? getDefaultStateFilePath(cwd),
54
+ stdout: overrides?.stdout ?? console.log,
55
+ stderr: overrides?.stderr ?? console.error,
56
+ createClient: overrides?.createClient ?? createAppServerClient,
57
+ };
58
+ }
59
+ function getErrorMessage(error) {
60
+ return error instanceof Error ? error.message : String(error);
61
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "codex-sidecar",
3
+ "version": "0.1.0",
4
+ "description": "Persistent Codex App Server sidecar CLI for Claude Code.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/nora/codex-sidecar.git"
10
+ },
11
+ "homepage": "https://github.com/nora/codex-sidecar#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/nora/codex-sidecar/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "packageManager": "pnpm@10.29.3",
19
+ "files": [
20
+ "dist",
21
+ "!dist/**/*.test.d.ts",
22
+ "!dist/**/*.test.js",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "bin": {
27
+ "codex-sidecar": "dist/index.js"
28
+ },
29
+ "scripts": {
30
+ "prepack": "pnpm build",
31
+ "lint": "pnpm oxlint src",
32
+ "lint:fix": "pnpm oxlint --fix src",
33
+ "fmt": "pnpm oxfmt --write src",
34
+ "fmt:check": "pnpm oxfmt --check src",
35
+ "build": "tsc -p tsconfig.json",
36
+ "test": "pnpm vitest run",
37
+ "test:cov": "pnpm vitest run --coverage",
38
+ "typecheck": "tsc --noEmit",
39
+ "qc": "scripts/quality-check.sh",
40
+ "dev": "tsx src/index.ts"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^25.5.0",
44
+ "@vitest/coverage-v8": "^4.1.2",
45
+ "oxfmt": "^0.42.0",
46
+ "oxlint": "^1",
47
+ "tsx": "^4.20.6",
48
+ "typescript": "^5.8.3",
49
+ "vitest": "^4.1.2"
50
+ }
51
+ }