feat: sync upstream pi-mono (386 commits)

从上游 badlogic/pi-mono 同步 386 个提交,手动解决所有冲突:

保留 SproutClaw 独有功能:
- RPC: reload 命令、bash 流式输出、get_extensions 命令
- settings-manager: showChangelogOnStartup 设置
- agent-session: turnIndex getter
- 移除 pi 版本检查通知(interactive-mode)
- skills 系统、启动脚本、自定义工具脚本

直接采用上游版本:
- packages/ai 模型列表及测试文件
- packages/coding-agent 文档
- 其余未冲突的全部上游代码

升级 @mistralai/mistralai 至 2.2.6(修复 promptCacheKey 类型错误)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:18:02 +08:00
409 changed files with 27402 additions and 6514 deletions

View File

@@ -4,11 +4,14 @@
# Mirrors .github/workflows/build-binaries.yml
#
# Usage:
# ./scripts/build-binaries.sh [--skip-deps] [--platform <platform>]
# ./scripts/build-binaries.sh [--skip-install] [--skip-deps] [--skip-build] [--platform <platform>] [--out <dir>]
#
# Options:
# --skip-install Skip npm ci
# --skip-deps Skip installing cross-platform dependencies
# --skip-build Skip npm run build
# --platform <name> Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64)
# --out <dir> Output directory (default: packages/coding-agent/binaries)
#
# Output:
# packages/coding-agent/binaries/
@@ -23,19 +26,34 @@ set -euo pipefail
cd "$(dirname "$0")/.."
SKIP_INSTALL=false
SKIP_DEPS=false
SKIP_BUILD=false
PLATFORM=""
OUTPUT_DIR=""
while [[ $# -gt 0 ]]; do
case $1 in
--skip-install)
SKIP_INSTALL=true
shift
;;
--skip-deps)
SKIP_DEPS=true
shift
;;
--skip-build)
SKIP_BUILD=true
shift
;;
--platform)
PLATFORM="$2"
shift 2
;;
--out)
OUTPUT_DIR="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
@@ -56,45 +74,52 @@ if [[ -n "$PLATFORM" ]]; then
esac
fi
echo "==> Installing dependencies..."
npm ci --ignore-scripts
if [[ -z "$OUTPUT_DIR" ]]; then
OUTPUT_DIR="packages/coding-agent/binaries"
fi
if [[ "$OUTPUT_DIR" != /* ]]; then
OUTPUT_DIR="$(pwd)/$OUTPUT_DIR"
fi
if [[ "$SKIP_INSTALL" == "false" ]]; then
echo "==> Installing dependencies..."
npm ci --ignore-scripts
else
echo "==> Skipping npm ci (--skip-install)"
fi
if [[ "$SKIP_DEPS" == "false" ]]; then
echo "==> Installing cross-platform native bindings..."
CLIPBOARD_VERSION=$(node -p "require('./packages/coding-agent/package.json').optionalDependencies['@mariozechner/clipboard']")
# npm ci only installs optional deps for the current platform
# We need all platform bindings for bun cross-compilation
# We need the base clipboard package and all platform bindings for bun cross-compilation
# Use --force to bypass platform checks (os/cpu restrictions in package.json)
# Install all in one command to avoid npm removing packages from previous installs
npm install --no-save --package-lock=false --force --ignore-scripts \
@mariozechner/clipboard-darwin-arm64@0.3.2 \
@mariozechner/clipboard-darwin-x64@0.3.2 \
@mariozechner/clipboard-linux-x64-gnu@0.3.2 \
@mariozechner/clipboard-linux-arm64-gnu@0.3.2 \
@mariozechner/clipboard-win32-x64-msvc@0.3.2 \
@mariozechner/clipboard-win32-arm64-msvc@0.3.2 \
@img/sharp-darwin-arm64@0.34.5 \
@img/sharp-darwin-x64@0.34.5 \
@img/sharp-linux-x64@0.34.5 \
@img/sharp-linux-arm64@0.34.5 \
@img/sharp-win32-x64@0.34.5 \
@img/sharp-win32-arm64@0.34.5 \
@img/sharp-libvips-darwin-arm64@1.2.4 \
@img/sharp-libvips-darwin-x64@1.2.4 \
@img/sharp-libvips-linux-x64@1.2.4 \
@img/sharp-libvips-linux-arm64@1.2.4
npm install --include=optional --no-save --package-lock=false --force --ignore-scripts \
@mariozechner/clipboard@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-darwin-arm64@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-darwin-x64@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-linux-x64-gnu@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-linux-arm64-gnu@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-win32-x64-msvc@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-win32-arm64-msvc@"$CLIPBOARD_VERSION"
else
echo "==> Skipping cross-platform native bindings (--skip-deps)"
fi
echo "==> Building all packages..."
npm run build
if [[ "$SKIP_BUILD" == "false" ]]; then
echo "==> Building all packages..."
npm run build
else
echo "==> Skipping package build (--skip-build)"
fi
echo "==> Building binaries..."
cd packages/coding-agent
# Clean previous builds
rm -rf binaries
mkdir -p binaries/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64}
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64}
# Determine which platforms to build
if [[ -n "$PLATFORM" ]]; then
@@ -105,10 +130,13 @@ fi
for platform in "${PLATFORMS[@]}"; do
echo "Building for $platform..."
# Bun compiled executables only embed worker scripts when they are passed as
# explicit build entrypoints. The runtime can still use new URL(...), but the
# worker must be present in the compiled executable.
if [[ "$platform" == windows-* ]]; then
bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe"
else
bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi"
fi
done
@@ -116,66 +144,94 @@ echo "==> Creating release archives..."
# Copy shared files to each platform directory
for platform in "${PLATFORMS[@]}"; do
cp package.json binaries/$platform/
cp README.md binaries/$platform/
cp CHANGELOG.md binaries/$platform/
cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm binaries/$platform/
mkdir -p binaries/$platform/theme
cp dist/modes/interactive/theme/*.json binaries/$platform/theme/
mkdir -p binaries/$platform/assets
cp dist/modes/interactive/assets/* binaries/$platform/assets/
cp -r dist/core/export-html binaries/$platform/
cp -r docs binaries/$platform/
cp -r examples binaries/$platform/
cp package.json "$OUTPUT_DIR/$platform/"
cp README.md "$OUTPUT_DIR/$platform/"
cp CHANGELOG.md "$OUTPUT_DIR/$platform/"
cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm "$OUTPUT_DIR/$platform/"
mkdir -p "$OUTPUT_DIR/$platform/theme"
cp dist/modes/interactive/theme/*.json "$OUTPUT_DIR/$platform/theme/"
mkdir -p "$OUTPUT_DIR/$platform/assets"
cp dist/modes/interactive/assets/* "$OUTPUT_DIR/$platform/assets/"
cp -r dist/core/export-html "$OUTPUT_DIR/$platform/"
cp -r docs "$OUTPUT_DIR/$platform/"
cp -r examples "$OUTPUT_DIR/$platform/"
# Copy Windows VT input native helper next to compiled Windows binaries.
case "$platform" in
darwin-arm64)
clipboard_native_package="clipboard-darwin-arm64"
;;
darwin-x64)
clipboard_native_package="clipboard-darwin-x64"
;;
linux-x64)
clipboard_native_package="clipboard-linux-x64-gnu"
;;
linux-arm64)
clipboard_native_package="clipboard-linux-arm64-gnu"
;;
windows-x64)
clipboard_native_package="clipboard-win32-x64-msvc"
;;
windows-arm64)
clipboard_native_package="clipboard-win32-arm64-msvc"
;;
esac
mkdir -p "$OUTPUT_DIR/$platform/node_modules/@mariozechner"
cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/"
cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/"
# Copy terminal input native helpers next to compiled binaries.
if [[ "$platform" == darwin-* ]]; then
mkdir -p "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform"
cp ../tui/native/darwin/prebuilds/$platform/darwin-modifiers.node "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform/"
fi
if [[ "$platform" == windows-* ]]; then
if [[ "$platform" == "windows-arm64" ]]; then
win32_arch_dir="win32-arm64"
else
win32_arch_dir="win32-x64"
fi
mkdir -p binaries/$platform/native/win32/prebuilds/$win32_arch_dir
cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node binaries/$platform/native/win32/prebuilds/$win32_arch_dir/
mkdir -p "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir"
cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir/"
fi
done
# Create archives
cd binaries
cd "$OUTPUT_DIR"
for platform in "${PLATFORMS[@]}"; do
if [[ "$platform" == windows-* ]]; then
# Windows (zip)
echo "Creating pi-$platform.zip..."
(cd $platform && zip -r ../pi-$platform.zip .)
(cd "$platform" && zip -r ../pi-$platform.zip .)
else
# Unix platforms (tar.gz) - use wrapper directory for mise compatibility
echo "Creating pi-$platform.tar.gz..."
mv $platform pi && tar -czf pi-$platform.tar.gz pi && mv pi $platform
mv "$platform" pi && tar -czf pi-$platform.tar.gz pi && mv pi "$platform"
fi
done
# Extract archives for easy local testing
echo "==> Extracting archives for testing..."
for platform in "${PLATFORMS[@]}"; do
rm -rf $platform
rm -rf "$platform"
if [[ "$platform" == windows-* ]]; then
mkdir -p $platform && (cd $platform && unzip -q ../pi-$platform.zip)
mkdir -p "$platform" && (cd "$platform" && unzip -q ../pi-$platform.zip)
else
tar -xzf pi-$platform.tar.gz && mv pi $platform
tar -xzf pi-$platform.tar.gz && mv pi "$platform"
fi
done
echo ""
echo "==> Build complete!"
echo "Archives available in packages/coding-agent/binaries/"
echo "Archives available in $OUTPUT_DIR/"
ls -lh *.tar.gz *.zip 2>/dev/null || true
echo ""
echo "Extracted directories for testing:"
for platform in "${PLATFORMS[@]}"; do
if [[ "$platform" == windows-* ]]; then
echo " binaries/$platform/pi.exe"
echo " $OUTPUT_DIR/$platform/pi.exe"
else
echo " binaries/$platform/pi"
echo " $OUTPUT_DIR/$platform/pi"
fi
done

View File

@@ -4,7 +4,21 @@ import { join } from "node:path";
import { build } from "esbuild";
const outputPath = join(tmpdir(), "pi-browser-smoke.js");
const baseOutputPath = join(tmpdir(), "pi-browser-base-smoke.js");
const selectiveOutputPath = join(tmpdir(), "pi-browser-selective-smoke.js");
const errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log");
const providerImplementationInputs = [
"packages/ai/src/providers/amazon-bedrock.ts",
"packages/ai/src/providers/anthropic.ts",
"packages/ai/src/providers/azure-openai-responses.ts",
"packages/ai/src/providers/google.ts",
"packages/ai/src/providers/google-vertex.ts",
"packages/ai/src/providers/images/openrouter.ts",
"packages/ai/src/providers/mistral.ts",
"packages/ai/src/providers/openai-codex-responses.ts",
"packages/ai/src/providers/openai-completions.ts",
"packages/ai/src/providers/openai-responses.ts",
];
try {
await build({
@@ -15,6 +29,36 @@ try {
logLevel: "silent",
outfile: outputPath,
});
const baseBuild = await build({
stdin: {
contents: `import { complete } from "@earendil-works/pi-ai/base";\nimport { Agent } from "@earendil-works/pi-agent-core/base";\nconsole.log(typeof complete, typeof Agent);\n`,
resolveDir: process.cwd(),
sourcefile: "pi-browser-base-smoke-entry.ts",
},
bundle: true,
platform: "browser",
format: "esm",
logLevel: "silent",
metafile: true,
outfile: baseOutputPath,
});
const bundledInputs = new Set(Object.keys(baseBuild.metafile.inputs));
const reachableProviderImplementations = providerImplementationInputs.filter((input) => bundledInputs.has(input));
if (reachableProviderImplementations.length > 0) {
throw new Error(`Base browser bundle reached provider implementations:\n${reachableProviderImplementations.join("\n")}`);
}
await build({
stdin: {
contents: `import { register as registerAnthropic } from "@earendil-works/pi-ai/anthropic";\nimport { register as registerOpenAICompletions } from "@earendil-works/pi-ai/openai-completions";\nimport { register as registerOpenRouterImages } from "@earendil-works/pi-ai/openrouter-images";\nconsole.log(typeof registerAnthropic, typeof registerOpenAICompletions, typeof registerOpenRouterImages);\n`,
resolveDir: process.cwd(),
sourcefile: "pi-browser-selective-smoke-entry.ts",
},
bundle: true,
platform: "browser",
format: "esm",
logLevel: "silent",
outfile: selectiveOutputPath,
});
process.exit(0);
} catch (error) {
let detailedErrors = "";

View File

@@ -30,30 +30,50 @@ function packageLabel(lockPath, entry) {
return entry?.version ? `${name}@${entry.version}` : name;
}
function summarizeLockfileChange() {
function getLockfilePackageChanges() {
const before = readJsonFromGit("HEAD:package-lock.json");
const after = readJsonFromGit(":package-lock.json");
if (!before?.packages || !after?.packages) return [];
if (!before?.packages || !after?.packages) return undefined;
const changes = [];
const paths = new Set([...Object.keys(before.packages), ...Object.keys(after.packages)]);
for (const lockPath of [...paths].sort()) {
if (!lockPath.includes("node_modules/")) continue;
const oldEntry = before.packages[lockPath];
const newEntry = after.packages[lockPath];
if (!oldEntry && newEntry) {
changes.push(`added ${packageLabel(lockPath, newEntry)}`);
} else if (oldEntry && !newEntry) {
changes.push(`removed ${packageLabel(lockPath, oldEntry)}`);
} else if (oldEntry?.version !== newEntry?.version) {
changes.push(
`changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? "<none>"} -> ${newEntry?.version ?? "<none>"}`,
);
if (JSON.stringify(oldEntry) !== JSON.stringify(newEntry)) {
changes.push({ lockPath, oldEntry, newEntry });
}
}
return changes;
}
function isWorkspacePackagePath(lockPath) {
return lockPath.startsWith("packages/");
}
function hasOnlyWorkspacePackageChanges(changes) {
return changes.length > 0 && changes.every((change) => isWorkspacePackagePath(change.lockPath));
}
function summarizeLockfileChange(changes) {
const nodeModuleChanges = changes.filter((change) => change.lockPath.includes("node_modules/"));
const summary = [];
for (const { lockPath, oldEntry, newEntry } of nodeModuleChanges) {
if (!oldEntry && newEntry) {
summary.push(`added ${packageLabel(lockPath, newEntry)}`);
} else if (oldEntry && !newEntry) {
summary.push(`removed ${packageLabel(lockPath, oldEntry)}`);
} else if (oldEntry?.version !== newEntry?.version) {
summary.push(
`changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? "<none>"} -> ${newEntry?.version ?? "<none>"}`,
);
} else {
summary.push(`changed ${packageLabel(lockPath, newEntry)}`);
}
}
return summary;
}
const stagedFiles = git(["diff", "--cached", "--name-only"])
.split("\n")
.map((line) => line.trim())
@@ -68,6 +88,12 @@ if (allowed) {
process.exit(0);
}
const changes = getLockfilePackageChanges();
if (changes && hasOnlyWorkspacePackageChanges(changes)) {
console.error("package-lock.json only updates workspace package metadata; allowing commit.");
process.exit(0);
}
console.error("package-lock.json is staged.");
console.error("");
console.error("Review lockfile changes before committing:");
@@ -76,15 +102,15 @@ console.error(" - confirm npm age gates were active for resolution");
console.error(" - review any new lifecycle scripts in the dependency tree");
console.error(" - regenerate/check coding-agent shrinkwrap if release deps changed");
const changes = summarizeLockfileChange();
if (changes.length > 0) {
const summary = changes ? summarizeLockfileChange(changes) : [];
if (summary.length > 0) {
console.error("");
console.error("Detected package version changes:");
for (const change of changes.slice(0, 40)) {
for (const change of summary.slice(0, 40)) {
console.error(` - ${change}`);
}
if (changes.length > 40) {
console.error(` ... ${changes.length - 40} more`);
if (summary.length > 40) {
console.error(` ... ${summary.length - 40} more`);
}
}

View File

@@ -12,7 +12,7 @@ const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json");
const internalPackagePrefix = "@earendil-works/pi-";
const allowedInstallScriptPackages = new Map([
["@google/genai@1.52.0", "preinstall is a no-op in the published package"],
["protobufjs@7.5.9", "postinstall only warns about protobufjs version scheme mismatches"],
["protobufjs@7.6.4", "postinstall only warns about protobufjs version scheme mismatches"],
]);
const args = new Set(process.argv.slice(2));

View File

@@ -2,7 +2,7 @@
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { isAbsolute, join, relative, resolve } from "node:path";
import { spawnSync } from "node:child_process";
const packages = [
@@ -131,48 +131,25 @@ function currentBinaryPlatform() {
throw new Error(`Unsupported binary platform: ${process.platform} ${process.arch}`);
}
function copyBinaryAssets(targetDirectory) {
const codingAgentDirectory = join(repoRoot, "packages", "coding-agent");
const distDirectory = join(codingAgentDirectory, "dist");
cpSync(join(codingAgentDirectory, "package.json"), join(targetDirectory, "package.json"));
cpSync(join(codingAgentDirectory, "README.md"), join(targetDirectory, "README.md"));
cpSync(join(codingAgentDirectory, "CHANGELOG.md"), join(targetDirectory, "CHANGELOG.md"));
cpSync(join(repoRoot, "node_modules", "@silvia-odwyer", "photon-node", "photon_rs_bg.wasm"), join(targetDirectory, "photon_rs_bg.wasm"));
cpSync(join(distDirectory, "modes", "interactive", "theme"), join(targetDirectory, "theme"), { recursive: true });
cpSync(join(distDirectory, "modes", "interactive", "assets"), join(targetDirectory, "assets"), { recursive: true });
cpSync(join(distDirectory, "core", "export-html"), join(targetDirectory, "export-html"), { recursive: true });
cpSync(join(codingAgentDirectory, "docs"), join(targetDirectory, "docs"), { recursive: true });
cpSync(join(codingAgentDirectory, "examples"), join(targetDirectory, "examples"), { recursive: true });
}
function copyWindowsConsoleModeHelper(targetDirectory, platform) {
if (!platform.startsWith("windows-")) return;
const win32Arch = platform === "windows-arm64" ? "win32-arm64" : "win32-x64";
const relativeNativePath = join("native", "win32", "prebuilds", win32Arch);
mkdirSync(join(targetDirectory, relativeNativePath), { recursive: true });
cpSync(
join(repoRoot, "packages", "tui", "native", "win32", "prebuilds", win32Arch, "win32-console-mode.node"),
join(targetDirectory, relativeNativePath, "win32-console-mode.node"),
);
}
function buildBunBinaryRelease(targetDirectory, archiveDirectory) {
if (!commandExists("bun")) {
throw new Error("Bun is required for the local binary release build.");
}
const platform = currentBinaryPlatform();
mkdirSync(targetDirectory, { recursive: true });
const executableName = platform.startsWith("windows-") ? "pi.exe" : "pi";
const executablePath = join(targetDirectory, executableName);
const target = `bun-${platform}`;
run("bun", ["build", "--compile", `--target=${target}`, join(repoRoot, "packages", "coding-agent", "dist", "bun", "cli.js"), "--outfile", executablePath]);
copyBinaryAssets(targetDirectory);
copyWindowsConsoleModeHelper(targetDirectory, platform);
if (platform.startsWith("windows-")) {
run("powershell", ["-NoProfile", "-Command", `Compress-Archive -Path '${join(targetDirectory, "*").replaceAll("'", "''")}' -DestinationPath '${join(archiveDirectory, `pi-${platform}.zip`).replaceAll("'", "''")}' -Force`]);
} else {
run("tar", ["-czf", join(archiveDirectory, `pi-${platform}.tar.gz`), "-C", targetDirectory, "."]);
}
const binaryBuildDirectory = join(archiveDirectory, "binary-build");
run("./scripts/build-binaries.sh", [
"--skip-install",
"--skip-deps",
"--skip-build",
"--platform",
platform,
"--out",
binaryBuildDirectory,
]);
rmSync(targetDirectory, { force: true, recursive: true });
cpSync(join(binaryBuildDirectory, platform), targetDirectory, { recursive: true });
const archiveName = platform.startsWith("windows-") ? `pi-${platform}.zip` : `pi-${platform}.tar.gz`;
cpSync(join(binaryBuildDirectory, archiveName), join(archiveDirectory, archiveName));
return platform;
}

115
scripts/publish.mjs Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const packages = [
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
];
const dryRun = process.argv.includes("--dry-run");
const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run");
if (unknownArgs.length > 0) {
console.error(`Usage: node scripts/publish.mjs [--dry-run]`);
process.exit(1);
}
function commandForPlatform(command) {
return process.platform === "win32" ? `${command}.cmd` : command;
}
function run(command, args, options = {}) {
console.log(`$ ${[command, ...args].join(" ")}`);
const result = spawnSync(commandForPlatform(command), args, {
cwd: options.cwd,
encoding: "utf8",
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
});
if (result.status !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`);
}
return result;
}
function readPackageJson(directory) {
return JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
}
function assertBuildOutputExists(directory) {
if (!existsSync(join(directory, "dist"))) {
throw new Error(`${directory}/dist does not exist. Run npm run build before publishing.`);
}
}
function validatePack(directory) {
const result = run("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { capture: true, cwd: directory });
const packed = JSON.parse(result.stdout)[0];
console.log(` ${packed.filename}: ${packed.files.length} files, ${packed.size} bytes packed, ${packed.unpackedSize} bytes unpacked`);
}
function isPublished(name, version) {
const result = spawnSync(commandForPlatform("npm"), ["view", `${name}@${version}`, "version", "--json"], {
encoding: "utf8",
stdio: ["inherit", "pipe", "pipe"],
});
if (result.status === 0 && result.stdout.trim()) {
return true;
}
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.status !== 0 && (output.includes("E404") || output.includes("404 Not Found"))) {
return false;
}
throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`);
}
const packageVersions = new Map();
for (const pkg of packages) {
const packageJson = readPackageJson(pkg.directory);
if (packageJson.name !== pkg.name) {
throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`);
}
packageVersions.set(pkg.name, packageJson.version);
}
const versions = [...new Set(packageVersions.values())];
if (versions.length !== 1) {
throw new Error(`Publish packages are not lockstep versioned: ${versions.join(", ")}`);
}
console.log(`Publishing pi packages at ${versions[0]}${dryRun ? " (dry run)" : ""}\n`);
for (const pkg of packages) {
const version = packageVersions.get(pkg.name);
assertBuildOutputExists(pkg.directory);
const published = isPublished(pkg.name, version);
if (dryRun) {
if (published) {
console.log(`${pkg.name}@${version} is already published; validating package contents only.`);
} else {
console.log(`${pkg.name}@${version} is not published; validating package contents before publish.`);
}
validatePack(pkg.directory);
console.log();
continue;
}
if (published) {
console.log(`Skipping ${pkg.name}@${version}: already published\n`);
continue;
}
run("npm", ["publish", "--access", "public", "--provenance", "--ignore-scripts"], { cwd: pkg.directory });
console.log();
}

364
scripts/release-notes.mjs Normal file
View File

@@ -0,0 +1,364 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
const DEFAULT_REPO = "earendil-works/pi";
const DEFAULT_BASE_PATH = "packages/coding-agent";
const DEFAULT_CHANGELOG = "packages/coding-agent/CHANGELOG.md";
const DEFAULT_FIX_SINCE_TAG = "v0.74.0";
const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/;
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g;
function printUsage() {
console.log(`Usage: node scripts/release-notes.mjs <command> [options]
Commands:
extract Extract release notes from the coding-agent changelog
fix-github-releases Rewrite existing GitHub release note links in place
extract options:
--version <x.y.z> Version to extract
--tag <vX.Y.Z> Release tag used for repository links (defaults to v<version>)
--changelog <path> Changelog path (default: ${DEFAULT_CHANGELOG})
--out <path> Output file (default: stdout)
--repo <owner/repo> GitHub repository for generated links (default: ${DEFAULT_REPO})
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
fix-github-releases options:
--repo <owner/repo> GitHub repository to patch (default: ${DEFAULT_REPO})
--tag <vX.Y.Z> Patch only one release tag
--since-tag <vX.Y.Z> Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG})
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
--dry-run Print releases that would change without updating GitHub
`);
}
function commandForPlatform(command) {
return process.platform === "win32" ? `${command}.cmd` : command;
}
function run(command, args, options = {}) {
const result = spawnSync(commandForPlatform(command), args, {
cwd: options.cwd,
encoding: "utf8",
maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024,
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
});
if (result.status !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`);
}
return result.stdout ?? "";
}
function parseOptions(args) {
const options = {
basePath: DEFAULT_BASE_PATH,
changelog: DEFAULT_CHANGELOG,
dryRun: false,
out: undefined,
repo: DEFAULT_REPO,
sinceTag: DEFAULT_FIX_SINCE_TAG,
tag: undefined,
version: undefined,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--help") {
printUsage();
process.exit(0);
}
if (arg === "--dry-run") {
options.dryRun = true;
continue;
}
const optionNames = new Set(["--base-path", "--changelog", "--out", "--repo", "--since-tag", "--tag", "--version"]);
if (!optionNames.has(arg)) {
throw new Error(`Unknown option: ${arg}`);
}
const value = args[++i];
if (!value) {
throw new Error(`${arg} requires a value`);
}
if (arg === "--base-path") options.basePath = value;
if (arg === "--changelog") options.changelog = value;
if (arg === "--out") options.out = value;
if (arg === "--repo") options.repo = value;
if (arg === "--since-tag") options.sinceTag = value;
if (arg === "--tag") options.tag = value;
if (arg === "--version") options.version = value;
}
return options;
}
function normalizeTag(tagOrVersion) {
if (!tagOrVersion) {
return undefined;
}
return tagOrVersion.startsWith("v") ? tagOrVersion : `v${tagOrVersion}`;
}
function versionFromTag(tag) {
return tag.startsWith("v") ? tag.slice(1) : tag;
}
function compareVersions(a, b) {
const aParts = versionFromTag(a).split(".").map(Number);
const bParts = versionFromTag(b).split(".").map(Number);
for (let i = 0; i < 3; i++) {
const diff = (aParts[i] || 0) - (bParts[i] || 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function extractChangelogSection(changelog, version) {
const headingRe = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`, "m");
const heading = headingRe.exec(changelog);
if (!heading) {
return "";
}
const sectionStart = heading.index + heading[0].length;
const rest = changelog.slice(sectionStart);
const nextHeading = rest.search(/^## \[/m);
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
return section.trim();
}
function splitLocalTarget(target) {
const hashIndex = target.indexOf("#");
const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex);
const fragment = hashIndex === -1 ? "" : target.slice(hashIndex);
const queryIndex = beforeHash.indexOf("?");
if (queryIndex === -1) {
return { fragment, pathPart: beforeHash, query: "" };
}
return {
fragment,
pathPart: beforeHash.slice(0, queryIndex),
query: beforeHash.slice(queryIndex),
};
}
function normalizePathPart(value) {
return value.replaceAll("\\", "/");
}
function normalizeBasePath(basePath) {
const normalized = path.posix.normalize(normalizePathPart(basePath)).replace(/\/+$/, "");
return normalized === "." ? "" : normalized;
}
function resolveRepositoryPath(targetPath, basePath) {
const normalizedTarget = normalizePathPart(targetPath);
const joined = normalizedTarget.startsWith("/")
? path.posix.normalize(normalizedTarget.replace(/^\/+/, ""))
: path.posix.normalize(path.posix.join(normalizeBasePath(basePath), normalizedTarget));
if (joined === "." || joined.startsWith("../") || joined === "..") {
return undefined;
}
return joined;
}
function isDirectoryTarget(originalPath, repositoryPath) {
if (originalPath.endsWith("/")) {
return true;
}
const basename = path.posix.basename(repositoryPath);
return !basename.includes(".");
}
function normalizeLinkTarget(target, options) {
let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${options.repo}`);
const repoUrl = `https://github.com/${options.repo}`;
for (const route of ["blob", "tree"]) {
for (const branch of ["main", "master"]) {
const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`;
if (canonicalTarget.startsWith(floatingRefPrefix)) {
canonicalTarget = `${repoUrl}/${route}/${options.tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`;
}
}
}
if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) {
return canonicalTarget;
}
const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget);
if (!pathPart) {
return canonicalTarget;
}
const repositoryPath = resolveRepositoryPath(pathPart, options.basePath);
if (!repositoryPath) {
return canonicalTarget;
}
const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob";
return `https://github.com/${options.repo}/${route}/${options.tag}/${encodeURI(repositoryPath)}${query}${fragment}`;
}
function normalizeReleaseNoteLinks(markdown, options) {
const changes = [];
const normalized = markdown.replace(INLINE_MARKDOWN_LINK_RE, (match, prefix, target, suffix) => {
const normalizedTarget = normalizeLinkTarget(target, options);
if (normalizedTarget !== target) {
changes.push({ from: target, to: normalizedTarget });
}
return `${prefix}${normalizedTarget}${suffix}`;
});
return { changes, markdown: normalized };
}
function writeOutput(content, outPath) {
if (outPath) {
writeFileSync(outPath, content);
return;
}
process.stdout.write(content);
}
function extractReleaseNotes(options) {
const version = options.version ?? (options.tag ? versionFromTag(options.tag) : undefined);
if (!version) {
throw new Error("extract requires --version or --tag");
}
if (!existsSync(options.changelog)) {
throw new Error(`Changelog does not exist: ${options.changelog}`);
}
const tag = normalizeTag(options.tag ?? version);
const changelog = readFileSync(options.changelog, "utf8");
const section = extractChangelogSection(changelog, version);
const rawNotes = section ? `${section}\n` : `Release ${version}\n`;
const { markdown } = normalizeReleaseNoteLinks(rawNotes, { basePath: options.basePath, repo: options.repo, tag });
writeOutput(markdown, options.out);
}
function listGithubReleases(repo) {
const output = run("gh", ["api", `repos/${repo}/releases`, "--paginate", "--jq", ".[] | {id, tag_name, body} | @json"], {
capture: true,
});
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
function uniqueChanges(changes) {
const seen = new Set();
const unique = [];
for (const change of changes) {
const key = `${change.from}\n${change.to}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
unique.push(change);
}
return unique;
}
function updateGithubRelease(repo, tag, body) {
const tempDir = mkdtempSync(path.join(tmpdir(), "pi-release-notes-"));
try {
const notesPath = path.join(tempDir, "notes.md");
writeFileSync(notesPath, body);
run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesPath], { capture: true });
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
}
function fixGithubReleases(options) {
const tagFilter = normalizeTag(options.tag);
const sinceTag = normalizeTag(options.sinceTag);
const matchingReleases = listGithubReleases(options.repo).filter((release) => !tagFilter || release.tag_name === tagFilter);
if (tagFilter && matchingReleases.length === 0) {
throw new Error(`Release not found: ${tagFilter}`);
}
const releases = matchingReleases.filter((release) => compareVersions(release.tag_name, sinceTag) >= 0);
if (tagFilter && releases.length === 0) {
console.log(`Skipping ${tagFilter}: older than ${sinceTag}.`);
console.log(`${options.dryRun ? "Would update" : "Updated"} 0 releases.`);
return;
}
let changedCount = 0;
for (const release of releases) {
const tag = release.tag_name;
const body = release.body ?? "";
const result = normalizeReleaseNoteLinks(body, { basePath: options.basePath, repo: options.repo, tag });
if (result.markdown === body) {
continue;
}
changedCount++;
const unique = uniqueChanges(result.changes);
console.log(`${options.dryRun ? "Would update" : "Updating"} ${tag} (${unique.length} link${unique.length === 1 ? "" : "s"})`);
for (const change of unique) {
console.log(` ${change.from}`);
console.log(` -> ${change.to}`);
}
if (!options.dryRun) {
updateGithubRelease(options.repo, tag, result.markdown);
}
}
const prefix = options.dryRun ? "Would update" : "Updated";
console.log(`${prefix} ${changedCount} release${changedCount === 1 ? "" : "s"}.`);
}
try {
const [command, ...args] = process.argv.slice(2);
if (!command || command === "--help") {
printUsage();
process.exit(command ? 0 : 1);
}
const options = parseOptions(args);
if (command === "extract") {
extractReleaseNotes(options);
} else if (command === "fix-github-releases") {
fixGithubReleases(options);
} else {
throw new Error(`Unknown command: ${command}`);
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}

View File

@@ -10,11 +10,12 @@
* 1. Check for uncommitted changes
* 2. Bump version via npm run version:xxx or set an explicit version
* 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date
* 4. Generate the coding-agent npm-shrinkwrap.json
* 5. Commit and tag
* 6. Publish to npm
* 4. Regenerate release artifacts
* 5. Run checks
* 6. Commit and tag the release
* 7. Add new [Unreleased] section to changelogs
* 8. Commit
* 8. Commit next-cycle changelog updates
* 9. Push main and the tag to trigger CI publishing
*/
import { execSync } from "child_process";
@@ -91,7 +92,7 @@ function bumpOrSetVersion(target) {
}
console.log(`Setting explicit version (${target})...`);
run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only`);
run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`);
return getVersion();
}
@@ -163,23 +164,25 @@ console.log("Updating CHANGELOG.md files...");
updateChangelogsForRelease(version);
console.log();
// 4. Generate publish shrinkwrap
console.log("Generating coding-agent shrinkwrap...");
// 4. Regenerate release artifacts
console.log("Regenerating release artifacts...");
run("npm --prefix packages/ai run generate-models");
run("npm --prefix packages/ai run generate-image-models");
run("npm run shrinkwrap:coding-agent");
console.log();
// 5. Commit and tag
// 5. Run checks
console.log("Running checks...");
run("npm run check");
console.log();
// 6. Commit and tag
console.log("Committing and tagging...");
stageChangedFiles();
run(`git commit -m "Release v${version}"`);
run(`git tag v${version}`);
console.log();
// 6. Publish
console.log("Publishing to npm...");
run("npm run publish");
console.log();
// 7. Add new [Unreleased] sections
console.log("Adding [Unreleased] sections for next cycle...");
addUnreleasedSection();
@@ -197,4 +200,4 @@ run("git push origin main");
run(`git push origin v${version}`);
console.log();
console.log(`=== Released v${version} ===`);
console.log(`=== Prepared release v${version}; CI publishing starts after the tag push ===`);

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { createBashTool } from "../packages/coding-agent/src/core/tools/bash.ts";
const shellPath = "C:\\Windows\\System32\\bash.exe";
const nameExpansion = "$" + "{name}";
const countExpansion = "$" + "{count}";
const iExpansion = "$" + "{i}";
function getTextOutput(result) {
return result.content
.filter((content) => content.type === "text")
.map((content) => content.text ?? "")
.join("\n");
}
async function runCase(label, command, expectedOutput) {
const tool = createBashTool(process.cwd(), { shellPath });
const result = await tool.execute(label, { command });
const output = getTextOutput(result).trimEnd();
if (output !== expectedOutput) {
throw new Error(
[
`${label} failed`,
"Expected:",
expectedOutput,
"Actual:",
output,
].join("\n"),
);
}
console.log(output);
}
if (process.platform !== "win32") {
throw new Error("This repro must run from Windows PowerShell/CMD, not macOS/Linux or inside WSL.");
}
if (!existsSync(shellPath)) {
throw new Error(`WSL bash launcher not found at ${shellPath}. Install/enable WSL first.`);
}
await runCase(
"issue-5893-simple-variable",
`name='World'; echo "Hello, ${nameExpansion}!"`,
"Hello, World!",
);
await runCase(
"issue-5893-loop-variable",
`count=3; for i in $(seq 1 ${countExpansion}); do echo "Iteration ${iExpansion} of ${countExpansion}"; done`,
"Iteration 1 of 3\nIteration 2 of 3\nIteration 3 of 3",
);
console.log("issue #5893 WSL bash repro passed");

View File

@@ -3,7 +3,7 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawn } from "node:child_process";
import { openBrowser } from "../packages/coding-agent/src/utils/open-browser.ts";
interface TextContent { type: "text"; text: string }
interface ImageContent { type: "image"; data: string; mimeType?: string }
@@ -229,4 +229,4 @@ const html = `<!doctype html>
mkdirSync(resolve(output, ".."), { recursive: true });
writeFileSync(output, html);
console.log(`Wrote ${output}`);
spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open", process.platform === "win32" ? ["/c", "start", output] : [output], { detached: true, stdio: "ignore" }).unref();
openBrowser(output);