@howaboua/pi-codex-conversion 1.0.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 Umberto B.
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,99 @@
1
+ # pi-codex-conversion
2
+
3
+ Codex-oriented adapter for [Pi](https://github.com/badlogic/pi-mono).
4
+
5
+ This package replaces Pi's default Codex/GPT experience with a narrower Codex-like surface while staying close to Pi's own runtime and prompt construction:
6
+
7
+ - swaps active tools to `exec_command`, `write_stdin`, `apply_patch`, and `view_image`
8
+ - preserves Pi's composed system prompt and applies a narrow Codex-oriented delta on top
9
+ - renders exec activity with Codex-style command and background-terminal labels
10
+
11
+ ![Available tools](./available-tools.png)
12
+
13
+ ## Active tools in adapter mode
14
+
15
+ When the adapter is active, the LLM sees these tools:
16
+
17
+ - `exec_command` — shell execution with Codex-style `cmd` parameters and resumable sessions
18
+ - `write_stdin` — continue or poll a running exec session
19
+ - `apply_patch` — patch tool
20
+ - `view_image` — image-only wrapper around Pi's native image reading, enabled only for image-capable models
21
+
22
+ Notably:
23
+
24
+ - there is **no** dedicated `read`, `edit`, or `write` tool in adapter mode
25
+ - local text-file inspection should happen through `exec_command`
26
+ - file creation and edits should default to `apply_patch`
27
+ - Pi may still expose additional runtime tools such as `parallel`; the prompt is written to tolerate that instead of assuming a fixed four-tool universe
28
+
29
+ ## Layout
30
+
31
+ - `src/index.ts` — extension entrypoint, model gating, tool-set swapping, prompt transformation
32
+ - `src/adapter/` — model detection and active-tool constants
33
+ - `src/tools/` — Pi tool wrappers, exec session management, and execution rendering
34
+ - `src/shell/` — shell tokenization, parsing, and exploration summaries
35
+ - `src/patch/` — patch parsing, path policy, and execution
36
+ - `src/prompt/` — Codex delta transformer over Pi's composed prompt
37
+ - `tests/` — deterministic unit tests
38
+
39
+ ## Checks
40
+
41
+ ```bash
42
+ npm run typecheck
43
+ npm test
44
+ npm run check
45
+ ```
46
+
47
+ ## Examples
48
+
49
+ - `rg -n foo src` -> `Explored / Search foo in src`
50
+ - `rg --files src | head -n 50` -> `Explored / List src`
51
+ - `cat README.md` -> `Explored / Read README.md`
52
+ - `exec_command({ cmd: "npm test", yield_time_ms: 1000 })` may return `session_id`, then continue with `write_stdin`
53
+ - `write_stdin({ session_id, chars: "" })` renders like `Waited for background terminal`
54
+ - `write_stdin({ session_id, chars: "y\\n" })` renders like `Interacted with background terminal`
55
+ - `view_image({ path: "/absolute/path/to/screenshot.png" })` is available on image-capable models
56
+
57
+ Raw command output is still available by expanding the tool result.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pi install npm:@howaboua/pi-codex-conversion
63
+ ```
64
+
65
+ Local development:
66
+
67
+ ```bash
68
+ pi install ./pi-codex-conversion
69
+ ```
70
+
71
+ Alternative Git install:
72
+
73
+ ```bash
74
+ pi install git:github.com/IgorWarzocha/pi-codex-conversion
75
+ ```
76
+
77
+ ## Prompt behavior
78
+
79
+ The adapter does not build a standalone replacement prompt anymore. Instead it:
80
+
81
+ - keeps Pi's tool descriptions, Pi docs section, AGENTS/project context, skills inventory, and date/cwd when Pi already surfaced them
82
+ - rewrites the top-level role framing to Codex-style wording
83
+ - adds a small Codex delta to the existing `Guidelines` section
84
+
85
+ That keeps the prompt much closer to `pi-mono` while still steering the model toward Codex-style tool use.
86
+
87
+ ## Notes
88
+
89
+ - Adapter mode activates automatically for OpenAI `gpt*` and `codex*` models.
90
+ - When you switch away from those models, Pi restores the previous active tool set.
91
+ - `view_image` resolves paths against the active session cwd and only exposes `detail: "original"` for Codex-family image-capable models.
92
+ - `apply_patch` paths stay restricted to the current working directory.
93
+ - `exec_command` / `write_stdin` use a custom PTY-backed session manager via `node-pty` for interactive sessions.
94
+ - PTY output handling applies basic terminal rewrite semantics (`\r`, `\b`, erase-in-line, and common escape cleanup) so interactive redraws replay sensibly.
95
+ - Skills inventory is reintroduced in a Codex-style section when Pi's composed prompt already exposed the underlying Pi skills inventory.
96
+
97
+ ## License
98
+
99
+ MIT
Binary file
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@howaboua/pi-codex-conversion",
3
+ "version": "1.0.0",
4
+ "description": "Codex-oriented tool and prompt adapter for pi coding agent",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/IgorWarzocha/pi-codex-conversion.git"
9
+ },
10
+ "homepage": "https://github.com/IgorWarzocha/pi-codex-conversion#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/IgorWarzocha/pi-codex-conversion/issues"
13
+ },
14
+ "keywords": [
15
+ "pi-package",
16
+ "pi",
17
+ "pi-coding-agent",
18
+ "extension",
19
+ "codex",
20
+ "adapter",
21
+ "apply-patch"
22
+ ],
23
+ "license": "MIT",
24
+ "os": [
25
+ "darwin",
26
+ "linux"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/index.ts"
31
+ ]
32
+ },
33
+ "files": [
34
+ "src/**/*.ts",
35
+ "src/**/*.md",
36
+ "available-tools.png",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "typecheck": "tsc -p tsconfig.json",
42
+ "test": "tsx --test tests/**/*.test.ts",
43
+ "check": "npm run typecheck && npm run test",
44
+ "prepack": "npm run check",
45
+ "prepublishOnly": "npm run check"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "peerDependencies": {
51
+ "@mariozechner/pi-coding-agent": "*",
52
+ "@mariozechner/pi-tui": "*",
53
+ "@sinclair/typebox": "*"
54
+ },
55
+ "devDependencies": {
56
+ "tsx": "^4.20.5",
57
+ "typescript": "^5.9.3"
58
+ },
59
+ "dependencies": {
60
+ "node-pty": "^1.1.0"
61
+ }
62
+ }
@@ -0,0 +1,22 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ export interface CodexLikeModelDescriptor {
4
+ provider: string;
5
+ api: string;
6
+ id: string;
7
+ }
8
+
9
+ // Keep model detection intentionally conservative. The adapter replaces the
10
+ // system prompt and tool surface, so false positives are worse than misses.
11
+ export function isCodexLikeModel(model: Partial<CodexLikeModelDescriptor> | null | undefined): boolean {
12
+ if (!model) return false;
13
+
14
+ const provider = (model.provider ?? "").toLowerCase();
15
+ const api = (model.api ?? "").toLowerCase();
16
+ const id = (model.id ?? "").toLowerCase();
17
+ return provider.includes("codex") || api.includes("codex") || id.includes("codex") || (provider.includes("openai") && id.includes("gpt"));
18
+ }
19
+
20
+ export function isCodexLikeContext(ctx: ExtensionContext): boolean {
21
+ return isCodexLikeModel(ctx.model);
22
+ }
@@ -0,0 +1,7 @@
1
+ export const STATUS_KEY = "codex-adapter";
2
+ export const STATUS_TEXT = "\u001b[38;2;0;76;255mCodex adapter\u001b[0m";
3
+
4
+ export const DEFAULT_TOOL_NAMES = ["read", "bash", "edit", "write"];
5
+
6
+ export const CORE_ADAPTER_TOOL_NAMES = ["exec_command", "write_stdin", "apply_patch"];
7
+ export const VIEW_IMAGE_TOOL_NAME = "view_image";
package/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { CORE_ADAPTER_TOOL_NAMES, DEFAULT_TOOL_NAMES, STATUS_KEY, STATUS_TEXT, VIEW_IMAGE_TOOL_NAME } from "./adapter/tool-set.ts";
3
+ import { registerApplyPatchTool } from "./tools/apply-patch-tool.ts";
4
+ import { isCodexLikeContext } from "./adapter/codex-model.ts";
5
+ import { createExecCommandTracker } from "./tools/exec-command-state.ts";
6
+ import { registerExecCommandTool } from "./tools/exec-command-tool.ts";
7
+ import { createExecSessionManager } from "./tools/exec-session-manager.ts";
8
+ import { buildCodexSystemPrompt, extractPiPromptSkills, type PromptSkill } from "./prompt/build-system-prompt.ts";
9
+ import { registerViewImageTool, supportsOriginalImageDetail } from "./tools/view-image-tool.ts";
10
+ import { registerWriteStdinTool } from "./tools/write-stdin-tool.ts";
11
+
12
+ interface AdapterState {
13
+ enabled: boolean;
14
+ previousToolNames?: string[];
15
+ promptSkills: PromptSkill[];
16
+ }
17
+
18
+ function getCommandArg(args: unknown): string | undefined {
19
+ if (!args || typeof args !== "object" || !("cmd" in args) || typeof args.cmd !== "string") {
20
+ return undefined;
21
+ }
22
+ return args.cmd;
23
+ }
24
+
25
+ export default function codexConversion(pi: ExtensionAPI) {
26
+ const tracker = createExecCommandTracker();
27
+ const state: AdapterState = { enabled: false, promptSkills: [] };
28
+ const sessions = createExecSessionManager();
29
+
30
+ registerApplyPatchTool(pi);
31
+ registerExecCommandTool(pi, tracker, sessions);
32
+ registerWriteStdinTool(pi, sessions);
33
+
34
+ sessions.onSessionExit((_sessionId, command) => {
35
+ tracker.recordCommandFinished(command);
36
+ });
37
+
38
+ pi.on("session_start", async (_event, ctx) => {
39
+ syncAdapter(pi, ctx, state);
40
+ });
41
+
42
+ pi.on("model_select", async (_event, ctx) => {
43
+ syncAdapter(pi, ctx, state);
44
+ });
45
+
46
+ pi.on("tool_execution_start", async (event) => {
47
+ if (event.toolName !== "exec_command") return;
48
+ const command = getCommandArg(event.args);
49
+ if (!command) return;
50
+ tracker.recordStart(event.toolCallId, command);
51
+ });
52
+
53
+ pi.on("tool_execution_end", async (event) => {
54
+ if (event.toolName !== "exec_command") return;
55
+ tracker.recordEnd(event.toolCallId);
56
+ });
57
+
58
+ pi.on("session_shutdown", async () => {
59
+ sessions.shutdown();
60
+ });
61
+
62
+ pi.on("before_agent_start", async (event, ctx) => {
63
+ if (!isCodexLikeContext(ctx)) {
64
+ return undefined;
65
+ }
66
+ return { systemPrompt: buildCodexSystemPrompt(event.systemPrompt, { skills: state.promptSkills }) };
67
+ });
68
+ }
69
+
70
+ function syncAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
71
+ state.promptSkills = extractPiPromptSkills(ctx.getSystemPrompt());
72
+
73
+ registerViewImageTool(pi, { allowOriginalDetail: supportsOriginalImageDetail(ctx.model) });
74
+
75
+ if (isCodexLikeContext(ctx)) {
76
+ enableAdapter(pi, ctx, state);
77
+ } else {
78
+ disableAdapter(pi, ctx, state);
79
+ }
80
+ }
81
+
82
+ function enableAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
83
+ const toolNames = getAdapterToolNames(ctx);
84
+ if (!state.enabled) {
85
+ // Preserve the previous active set once so switching away from Codex-like
86
+ // models restores the user's existing Pi tool configuration.
87
+ state.previousToolNames = pi.getActiveTools();
88
+ state.enabled = true;
89
+ }
90
+ pi.setActiveTools(toolNames);
91
+ setStatus(ctx, true);
92
+ }
93
+
94
+ function disableAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
95
+ if (state.enabled) {
96
+ pi.setActiveTools(state.previousToolNames && state.previousToolNames.length > 0 ? state.previousToolNames : DEFAULT_TOOL_NAMES);
97
+ state.enabled = false;
98
+ }
99
+ setStatus(ctx, false);
100
+ }
101
+
102
+ function setStatus(ctx: ExtensionContext, enabled: boolean): void {
103
+ if (!ctx.hasUI) return;
104
+ ctx.ui.setStatus(STATUS_KEY, enabled ? STATUS_TEXT : undefined);
105
+ }
106
+
107
+ function getAdapterToolNames(ctx: ExtensionContext): string[] {
108
+ if (Array.isArray(ctx.model?.input) && ctx.model.input.includes("image")) {
109
+ return [...CORE_ADAPTER_TOOL_NAMES, VIEW_IMAGE_TOOL_NAME];
110
+ }
111
+ return [...CORE_ADAPTER_TOOL_NAMES];
112
+ }
@@ -0,0 +1,220 @@
1
+ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { parsePatchActions, parseUpdateFile } from "./parser.ts";
4
+ import { openFileAtPath, pathExists, removeFileAtPath, resolvePatchPath, writeFileAtPath } from "./paths.ts";
5
+ import { DiffError, type ExecutePatchResult, type ParsedPatchAction, type ParserState, type PatchAction } from "./types.ts";
6
+
7
+ function splitFileLines(text: string): string[] {
8
+ const lines = text.split("\n");
9
+ if (lines.at(-1) === "") {
10
+ lines.pop();
11
+ }
12
+ return lines;
13
+ }
14
+
15
+ function getUpdatedFile({ text, action, path }: { text: string; action: PatchAction; path: string }): string {
16
+ if (action.type !== "update") {
17
+ throw new DiffError(`Invalid action type for update: ${action.type}`);
18
+ }
19
+
20
+ const origLines = splitFileLines(text);
21
+ const destLines: string[] = [];
22
+ let origIndex = 0;
23
+ let destIndex = 0;
24
+
25
+ for (const chunk of action.chunks) {
26
+ if (chunk.origIndex > origLines.length) {
27
+ throw new DiffError(`_get_updated_file: ${path}: chunk.orig_index ${chunk.origIndex} > len(lines) ${origLines.length}`);
28
+ }
29
+ if (origIndex > chunk.origIndex) {
30
+ throw new DiffError(`_get_updated_file: ${path}: orig_index ${origIndex} > chunk.orig_index ${chunk.origIndex}`);
31
+ }
32
+
33
+ destLines.push(...origLines.slice(origIndex, chunk.origIndex));
34
+ const delta = chunk.origIndex - origIndex;
35
+ origIndex += delta;
36
+ destIndex += delta;
37
+
38
+ for (const line of chunk.delLines) {
39
+ if (origLines[origIndex] !== line) {
40
+ throw new DiffError(`_get_updated_file: ${path}: Expected ${line} but got ${origLines[origIndex]} at line ${origIndex + 1}`);
41
+ }
42
+ origIndex += 1;
43
+ }
44
+
45
+ if (chunk.insLines.length > 0) {
46
+ destLines.push(...chunk.insLines);
47
+ destIndex += chunk.insLines.length;
48
+ }
49
+ }
50
+
51
+ destLines.push(...origLines.slice(origIndex));
52
+ const tailDelta = origLines.length - origIndex;
53
+ origIndex += tailDelta;
54
+ destIndex += tailDelta;
55
+
56
+ if (origIndex !== origLines.length) {
57
+ throw new DiffError(`Unexpected final orig_index for ${path}`);
58
+ }
59
+ if (destIndex !== destLines.length) {
60
+ throw new DiffError(`Unexpected final dest_index for ${path}`);
61
+ }
62
+
63
+ if (destLines.length === 0) {
64
+ return "";
65
+ }
66
+
67
+ return `${destLines.join("\n")}\n`;
68
+ }
69
+
70
+ function resolveUpdateAction({ path, text, lines }: { path: string; text: string; lines: string[] }): { action: PatchAction; fuzz: number } {
71
+ const state: ParserState = {
72
+ lines,
73
+ index: 0,
74
+ fuzz: 0,
75
+ };
76
+ const action = parseUpdateFile({ state, text, path });
77
+ if (action.chunks.length === 0) {
78
+ throw new DiffError(`Invalid patch hunk on line 2: Update file hunk for path '${path}' is empty`);
79
+ }
80
+ return { action, fuzz: state.fuzz };
81
+ }
82
+
83
+ function applyMove({
84
+ cwd,
85
+ path,
86
+ movePath,
87
+ content,
88
+ changedFiles,
89
+ createdFiles,
90
+ deletedFiles,
91
+ movedFiles,
92
+ }: {
93
+ cwd: string;
94
+ path: string;
95
+ movePath: string;
96
+ content: string;
97
+ changedFiles: Set<string>;
98
+ createdFiles: Set<string>;
99
+ deletedFiles: Set<string>;
100
+ movedFiles: Set<string>;
101
+ }): void {
102
+ const fromAbsolutePath = resolvePatchPath({ cwd, patchPath: path });
103
+ const toAbsolutePath = resolvePatchPath({ cwd, patchPath: movePath });
104
+ const destinationExisted = pathExists({ cwd, path: movePath });
105
+
106
+ mkdirSync(dirname(toAbsolutePath), { recursive: true });
107
+ writeFileSync(toAbsolutePath, content, "utf8");
108
+ if (fromAbsolutePath !== toAbsolutePath) {
109
+ unlinkSync(fromAbsolutePath);
110
+ }
111
+
112
+ changedFiles.add(path);
113
+ changedFiles.add(movePath);
114
+ movedFiles.add(`${path} -> ${movePath}`);
115
+ if (!destinationExisted) {
116
+ createdFiles.add(movePath);
117
+ }
118
+ if (fromAbsolutePath !== toAbsolutePath) {
119
+ deletedFiles.add(path);
120
+ }
121
+ }
122
+
123
+ function applyAction({
124
+ cwd,
125
+ action,
126
+ changedFiles,
127
+ createdFiles,
128
+ deletedFiles,
129
+ movedFiles,
130
+ }: {
131
+ cwd: string;
132
+ action: ParsedPatchAction;
133
+ changedFiles: Set<string>;
134
+ createdFiles: Set<string>;
135
+ deletedFiles: Set<string>;
136
+ movedFiles: Set<string>;
137
+ }): number {
138
+ if (action.type === "delete") {
139
+ removeFileAtPath({ cwd, path: action.path });
140
+ changedFiles.add(action.path);
141
+ deletedFiles.add(action.path);
142
+ return 0;
143
+ }
144
+
145
+ if (action.type === "add") {
146
+ const { created } = writeFileAtPath({
147
+ cwd,
148
+ path: action.path,
149
+ content: action.newFile ?? "",
150
+ });
151
+ changedFiles.add(action.path);
152
+ if (created) {
153
+ createdFiles.add(action.path);
154
+ }
155
+ return 0;
156
+ }
157
+
158
+ if (!action.lines) {
159
+ throw new DiffError(`Update File Error: Missing patch lines for ${action.path}`);
160
+ }
161
+
162
+ const originalText = openFileAtPath({ cwd, path: action.path });
163
+ const { action: resolvedAction, fuzz } = resolveUpdateAction({
164
+ path: action.path,
165
+ text: originalText,
166
+ lines: action.lines,
167
+ });
168
+ resolvedAction.movePath = action.movePath;
169
+ const newContent = getUpdatedFile({ text: originalText, action: resolvedAction, path: action.path });
170
+
171
+ if (action.movePath) {
172
+ applyMove({
173
+ cwd,
174
+ path: action.path,
175
+ movePath: action.movePath,
176
+ content: newContent,
177
+ changedFiles,
178
+ createdFiles,
179
+ deletedFiles,
180
+ movedFiles,
181
+ });
182
+ return fuzz;
183
+ }
184
+
185
+ writeFileAtPath({ cwd, path: action.path, content: newContent });
186
+ changedFiles.add(action.path);
187
+ return fuzz;
188
+ }
189
+
190
+ export function executePatch({ cwd, patchText }: { cwd: string; patchText: string }): ExecutePatchResult {
191
+ if (!patchText.startsWith("*** Begin Patch")) {
192
+ throw new DiffError("Patch must start with '*** Begin Patch'");
193
+ }
194
+
195
+ const actions = parsePatchActions({ text: patchText });
196
+ const changedFiles = new Set<string>();
197
+ const createdFiles = new Set<string>();
198
+ const deletedFiles = new Set<string>();
199
+ const movedFiles = new Set<string>();
200
+ let fuzz = 0;
201
+
202
+ for (const action of actions) {
203
+ fuzz += applyAction({
204
+ cwd,
205
+ action,
206
+ changedFiles,
207
+ createdFiles,
208
+ deletedFiles,
209
+ movedFiles,
210
+ });
211
+ }
212
+
213
+ return {
214
+ changedFiles: [...changedFiles],
215
+ createdFiles: [...createdFiles],
216
+ deletedFiles: [...deletedFiles],
217
+ movedFiles: [...movedFiles],
218
+ fuzz,
219
+ };
220
+ }