fix(devtools): package Codex daemon and use upstream Pi

This commit is contained in:
Rasyidan Akbar F. 2026-10-02 19:09:42 +07:00
commit 037e817b29
9 changed files with 151 additions and 121 deletions

View file

@ -211,7 +211,7 @@
formatter = forEachSystem (system: (mkPkgs system).nixfmt); formatter = forEachSystem (system: (mkPkgs system).nixfmt);
checks = forEachSystem (system: { checks = forEachSystem (system: {
inherit (self.packages.${system}) cf pi; inherit (self.packages.${system}) cf codex;
}); });
packages = forEachSystem ( packages = forEachSystem (

View file

@ -274,10 +274,10 @@ in
config = mkIf cfg.enable { config = mkIf cfg.enable {
home.packages = [ home.packages = [
llmPkgs.claude-code # latest llmPkgs.claude-code # latest
customPkgs.pi # latest, with the local codemode worker fix llmPkgs.pi # latest
llmPkgs.ccstatusline # latest llmPkgs.ccstatusline # latest
llmPkgs.ccusage # latest llmPkgs.ccusage # latest
llmPkgs.codex customPkgs.codex # complete local package for daemon startup
llmPkgs.rtk llmPkgs.rtk
llmPkgs.cursor-agent llmPkgs.cursor-agent
llmPkgs.herdr # latest - terminal agent multiplexer llmPkgs.herdr # latest - terminal agent multiplexer
@ -290,7 +290,7 @@ in
++ cfg.extraPackages; ++ cfg.extraPackages;
home.activation.installPiFff = lib.hm.dag.entryAfter [ "writeBoundary" ] '' home.activation.installPiFff = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
if [ -x ${lib.getExe customPkgs.pi} ]; then if [ -x ${lib.getExe llmPkgs.pi} ]; then
export PI_SKIP_VERSION_CHECK=1 export PI_SKIP_VERSION_CHECK=1
export PI_TELEMETRY=0 export PI_TELEMETRY=0
export PATH=${pkgs.nodejs}/bin:${pkgs.git}/bin:$PATH export PATH=${pkgs.nodejs}/bin:${pkgs.git}/bin:$PATH
@ -299,10 +299,10 @@ in
mkdir -p "$(dirname "$settings_file")" mkdir -p "$(dirname "$settings_file")"
${pkgs.nodejs}/bin/node -e "const fs=require('fs'); const path=process.env.HOME+'/.pi/agent/settings.json'; let settings={}; if (fs.existsSync(path)) settings=JSON.parse(fs.readFileSync(path,'utf8')); settings.theme='dark'; settings.defaultProvider='openai-codex'; settings.defaultModel='gpt-5.6-sol'; fs.writeFileSync(path, JSON.stringify(settings, null, 2)+String.fromCharCode(10));" ${pkgs.nodejs}/bin/node -e "const fs=require('fs'); const path=process.env.HOME+'/.pi/agent/settings.json'; let settings={}; if (fs.existsSync(path)) settings=JSON.parse(fs.readFileSync(path,'utf8')); settings.theme='dark'; settings.defaultProvider='openai-codex'; settings.defaultModel='gpt-5.6-sol'; fs.writeFileSync(path, JSON.stringify(settings, null, 2)+String.fromCharCode(10));"
${lib.getExe customPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@ff-labs/pi-fff' || \ ${lib.getExe llmPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@ff-labs/pi-fff' || \
${lib.getExe customPkgs.pi} install npm:@ff-labs/pi-fff@0.9.4 ${lib.getExe llmPkgs.pi} install npm:@ff-labs/pi-fff@0.9.4
${lib.getExe customPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@juicesharp/rpiv-ask-user-question' || \ ${lib.getExe llmPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@juicesharp/rpiv-ask-user-question' || \
${lib.getExe customPkgs.pi} install npm:@juicesharp/rpiv-ask-user-question@1.20.0 ${lib.getExe llmPkgs.pi} install npm:@juicesharp/rpiv-ask-user-question@1.20.0
fi fi
''; '';

View file

@ -0,0 +1,40 @@
"""Run manually outside the Nix sandbox: python3 packages/codex-daemon-test.py /path/to/codex."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
binary = str(Path(sys.argv[1]).resolve())
with tempfile.TemporaryDirectory(prefix="codex-daemon-test-") as directory:
home = Path(directory)
settings = home / "app-server-daemon/settings.json"
settings.parent.mkdir()
settings.write_text(json.dumps({"updater": {"autoUpdateEnabled": False}}))
env = {**os.environ, "CODEX_HOME": directory}
def run(*arguments, check=True):
return subprocess.run(
[binary, "app-server", "daemon", *arguments],
env=env, check=check, timeout=60, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
try:
started = run("start", check=False)
print(started.stdout)
started.check_returncode()
release = home / "packages/app-server-daemon/current"
assert (release / "codex-package.json").is_file()
assert (release / "bin/codex").is_file()
version = run("version")
print(version.stdout)
status = json.loads(version.stdout)
assert status["status"] == "running", status
assert status["appServerVersion"] == status["cliVersion"], status
print("Codex daemon startup passed")
finally:
stopped = run("stop", check=False)
print(stopped.stdout)
stopped.check_returncode()

View file

@ -0,0 +1,25 @@
"""Check the layout required by Codex daemon provisioning."""
import json
import os
from pathlib import Path
import sys
root = Path(sys.argv[1]).resolve()
version, target = sys.argv[2:]
manifest = json.loads((root / "codex-package.json").read_text())
assert manifest == {
"layoutVersion": 1,
"version": version,
"target": target,
"entrypoint": "bin/codex",
}
required = ["bin/codex", "bin/codex-code-mode-host", "codex-path/rg"]
if "linux" in target:
required.append("codex-resources/bwrap")
for name in required:
path = root / name
assert path.is_file() and os.access(path, os.X_OK), name
for path in root.rglob("*"):
assert path.resolve().is_relative_to(root), f"Escaping link: {path}"
assert not (path.is_symlink() and path.is_dir()), f"Directory link: {path}"
print("Codex package layout checks passed")

39
packages/codex.md Normal file
View file

@ -0,0 +1,39 @@
# Local Codex package adapter
`codex.nix` adds the complete package layout required by Codex daemon startup.
The pinned Numtide package supplies binaries but lacks the manifest and bundled
ripgrep. The adapter copies the binaries without rebuilding Rust.
Remove this adapter and select `llmPkgs.codex` when Numtide supplies the complete
layout. Also remove the custom package export and adjust the flake check.
## Validation
Build the package and run its layout checks:
```sh
nix build .#codex
```
Test daemon provisioning outside the Nix sandbox:
```sh
python3 packages/codex-daemon-test.py ./result/bin/codex
```
The daemon test uses a temporary `CODEX_HOME`, disables automatic updates there,
and stops the test daemon. It does not use your normal Codex sessions or settings.
## Runtime limits
Codex copies the package into `~/.codex/packages/app-server-daemon`.
The copied Nix binaries still reference dependencies in `/nix/store`.
Keep the corresponding Nix system generation while using that daemon version.
After a Nix upgrade, `codex app-server daemon update --help` describes how to
select the current CLI package with `--from-cli`.
The adapter does not change your daemon update settings. Codex can automatically
update its private daemon independently of the CLI installed by Nix.
Only aarch64-darwin daemon startup has been tested locally. The derivation also
provides the required bubblewrap executable on Linux.

38
packages/codex.nix Normal file
View file

@ -0,0 +1,38 @@
{ pkgs, llm-agents }:
let
upstream = llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.codex;
manifest = pkgs.writeText "codex-package.json" (
builtins.toJSON {
layoutVersion = 1;
version = upstream.version;
target = pkgs.stdenv.hostPlatform.rust.rustcTarget;
entrypoint = "bin/codex";
}
);
in
# Temporary adapter for Numtide's source build. Remove when upstream supplies
# the complete daemon package layout. Reuse binaries without rebuilding Rust.
pkgs.runCommand "codex-${upstream.version}"
{
inherit (upstream) version meta;
nativeBuildInputs = [ pkgs.python3 ];
passthru = { inherit upstream; };
}
''
mkdir -p "$out/bin" "$out/codex-path"
source=${upstream}/bin
if [ -d ${upstream}/libexec/codex/bin ]; then
source=${upstream}/libexec/codex/bin
fi
# Copy real executables: daemon provisioning rejects escaping symlinks.
cp -L "$source/codex" "$source/codex-code-mode-host" "$source/logs_client" "$out/bin/"
cp -L ${pkgs.ripgrep}/bin/rg "$out/codex-path/rg"
cp ${manifest} "$out/codex-package.json"
${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux ''
mkdir -p "$out/codex-resources"
cp -L ${pkgs.bubblewrap}/bin/bwrap "$out/codex-resources/bwrap"
''}
cp -RL ${upstream}/share "$out/share"
python ${./codex-package-test.py} "$out" '${upstream.version}' '${pkgs.stdenv.hostPlatform.rust.rustcTarget}'
"$out/bin/codex" --version
''

View file

@ -1,6 +1,6 @@
{ pkgs, llm-agents, ... }: { pkgs, llm-agents, ... }:
{ {
cf = pkgs.callPackage ./cf.nix { }; cf = pkgs.callPackage ./cf.nix { };
pi = pkgs.callPackage ./pi.nix { inherit llm-agents; }; codex = pkgs.callPackage ./codex.nix { inherit llm-agents; };
pikpak-cli = pkgs.callPackage ./pikpak-cli.nix { }; pikpak-cli = pkgs.callPackage ./pikpak-cli.nix { };
} }

View file

@ -1,79 +0,0 @@
import assert from "node:assert/strict";
import { Type } from "@earendil-works/pi-ai";
import {
createCodemodeExtension,
type ExtensionAPI,
type ExtensionToolContext,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
// Run the embedded sandbox without a model request or provider credentials.
export default function (pi: ExtensionAPI) {
let codemode: ToolDefinition | undefined;
createCodemodeExtension({ models: false })({
...pi,
registerTool: (tool) => {
codemode = tool;
},
});
pi.on("session_start", async (_event, ctx) => {
try {
assert.ok(codemode);
let nestedCalls = 0;
const toolContext: ExtensionToolContext = {
...ctx,
tools: [
{
name: "echo",
description: "Return the input value.",
parameters: Type.Object({ value: Type.Number() }),
outputSchema: Type.Object({ value: Type.Number() }),
},
],
executeTool: async (name, args) => {
assert.equal(name, "echo");
nestedCalls += 1;
return {
toolCall: { type: "toolCall", id: `test/${nestedCalls}`, name, arguments: args },
isError: false,
result: {
content: [{ type: "text", text: JSON.stringify(args) }],
structuredContent: args,
details: undefined,
},
};
},
};
const run = (code: string) =>
codemode!.execute("codemode-test", { code }, new AbortController().signal, undefined, toolContext);
const output = (result: Awaited<ReturnType<typeof run>>) =>
result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
const arithmetic = await run("return 6 * 7;");
assert.match(output(arithmetic), /Script completed/);
assert.match(output(arithmetic), /42/);
const nested = await run(`
const values = await Promise.all([
tools.echo({ value: 20 }),
tools.echo({ value: 22 }),
]);
return values.reduce((sum, item) => sum + item.value, 0);
`);
assert.match(output(nested), /Script completed/);
assert.match(output(nested), /42/);
assert.equal(nestedCalls, 2);
const failure = await run('throw new Error("expected-test-error");');
assert.match(output(failure), /Script failed/);
assert.match(output(failure), /expected-test-error/);
console.error("codemode regression test passed");
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
ctx.shutdown();
}
});
}

View file

@ -1,33 +0,0 @@
{ pkgs, llm-agents }:
let
upstream = llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.pi;
compileCommand = "bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi";
in
upstream.overrideAttrs (old: {
# The standalone binary resolves this exact source path at runtime.
# The npm source only ships dist/, so create and embed a worker entry.
# Fail evaluation if the upstream build changes instead of dropping the fix.
preInstall =
assert pkgs.lib.hasInfix compileCommand old.preInstall;
pkgs.lib.replaceStrings
[ compileCommand ]
[
''
mkdir -p src/extensions/codemode
echo 'import "../../../dist/extensions/codemode/worker.js";' > src/extensions/codemode/worker.ts
bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts ./src/extensions/codemode/worker.ts --outfile dist/pi
''
]
old.preInstall;
postInstallCheck = (old.postInstallCheck or "") + ''
test_home=$(mktemp -d)
HOME="$test_home" PI_CODING_AGENT_DIR="$test_home/agent" \
"$out/bin/pi" --offline --no-session --no-extensions --no-skills \
--no-prompt-templates --no-themes --no-context-files --no-approve \
--extension ${./pi-codemode-test.ts} --mode rpc < /dev/null > codemode-test.log 2>&1
tail -n 60 codemode-test.log
# RPC shutdown can return zero after an extension failure.
grep -Fx 'codemode regression test passed' codemode-test.log
'';
})