Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的基本格式,并计划采用[语义化版本](https://semver.org/lang/zh-CN/)。

## [Unreleased]

### Changed

- 超长会话改为流式读取,不再把整个 Codex 会话文件一次性载入内存;同时支持已归档会话和失效路径回退。
- 通知状态与正文会优先提取明确的回复、确认和继续操作要求,并清理内部状态标记、本地路径与空泛摘要。
- 优先使用 ChatGPT 或 Codex App 自带的兼容 Node.js,减少包管理器升级后通知失效的概率。
- 已发送去重记录只保留 90 天,审计日志达到 10 MB 后轮换,安装备份最多保留最近 5 份。

### Fixed

- 修复超大 JSONL 会话导致主任务识别超时、漏发通知或误判为内部任务的问题。
- 修复“告诉我结果”“确认后继续”等明确请求被误判为“本轮结束”的问题。
- 修复通知摘要为空、只显示“请回复”、保留内部隐藏标记或在完整确认事项中间截断的问题。

## [0.1.0] - 2026-08-01

### Added
Expand Down
29 changes: 23 additions & 6 deletions scripts/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
assertSupportedRuntime,
arraysEqual,
atomicWrite,
capBackupRecords,
createBackups,
createRuntimeSnapshot,
captureTextFileState,
Expand All @@ -27,6 +28,7 @@ import {
pathExists,
permissionHook,
promptHiddenDeviceKey,
pruneBackupDirectories,
prepareRuntimeStage,
readDeviceKeyFromFile,
readManifest,
Expand Down Expand Up @@ -63,7 +65,7 @@ Never pass a Bark key as a command-line value. Without --key-file, a hidden
interactive prompt is used.`;

function printPlan(plan) {
console.log(`Node.js: ${process.execPath} (${process.versions.node})`);
console.log(`Node.js: ${plan.managedNotify[0]} (${process.versions.node})`);
console.log(`Codex home: ${plan.paths.codexHome}`);
console.log(`Install root: ${plan.paths.installRoot}`);
console.log(`notify mode: ${plan.notifyMode}`);
Expand Down Expand Up @@ -216,8 +218,18 @@ export async function install({
input = process.stdin,
output = process.stderr,
operations = {},
environment = process.env,
} = {}) {
assertSupportedRuntime(runtime);
const selectedNodePath = String(
environment._CODEX_BARK_SELECTED_NODE ?? process.execPath,
).trim();
if (
!selectedNodePath.startsWith("/") ||
/[\u0000-\u001f\u007f]/u.test(selectedNodePath)
) {
throw new Error("The selected Node.js runtime path is invalid.");
}
const options = parseArguments(argv, "install");
if (options.help) {
console.log(HELP);
Expand All @@ -233,7 +245,11 @@ export async function install({
await assertNotSymlink(paths.hooksJson, { requireFile: true });
const runtimeConfigState = await inspectRuntimeConfig(paths);
const existingManifest = await readManifest(paths);
const plan = await buildInstallPlan({ paths, existingManifest });
const plan = await buildInstallPlan({
paths,
existingManifest,
nodePath: selectedNodePath,
});
printPlan(plan);
if (options.dryRun) {
console.log("Dry run complete; no files were changed and no key was read.");
Expand All @@ -254,7 +270,7 @@ export async function install({
const stage = await prepareRuntimeStage(paths, {
notifyMode: plan.notifyMode,
previousNotify: plan.previousNotify,
nodePath: process.execPath,
nodePath: selectedNodePath,
});
let backup;
let snapshot;
Expand Down Expand Up @@ -318,7 +334,7 @@ export async function install({
version: "0.1.0",
status: "installed",
installRoot: paths.installRoot,
nodePath: process.execPath,
nodePath: selectedNodePath,
installedAt:
existingManifest?.status === "installed"
? existingManifest.installedAt
Expand All @@ -342,17 +358,18 @@ export async function install({
managedEntry: plan.managedHook,
before: plan.hooksBeforeState,
},
backups: [
backups: capBackupRecords([
...(existingManifest?.backups ?? []),
backup,
],
]),
files: {},
};
manifest.files = await runtimeFileHashes(paths);
const manifestSource = `${JSON.stringify(manifest, null, 2)}\n`;
parseJsonObject(manifestSource, "installed.json");
await writeAtomic(paths.manifest, manifestSource, 0o600);
await removeRuntimeTemporary(stage, snapshot);
await pruneBackupDirectories(paths).catch(() => {});
console.log("Installed Codex Bark Notifier.");
console.log(`Backup: ${backup.directory}`);
console.log("Ready now: turn completion, reply-needed, and blocked/error notifications.");
Expand Down
21 changes: 15 additions & 6 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,6 @@ select_node() {
return 0
fi

path_node=$(command -v node 2>/dev/null) || path_node=
if [ -n "$path_node" ] && is_supported_node "$path_node"; then
printf '%s\n' "$path_node"
return 0
fi

# The override below is intentionally private to the test suite. Production
# callers use the literal /Applications root.
system_applications=${_CODEX_BARK_TEST_APPLICATIONS_ROOT:-/Applications}
Expand All @@ -92,6 +86,15 @@ select_node() {
done
fi

# Keep PATH as the final fallback. Homebrew's public bin/node symlink is more
# stable than the versioned Cellar path reported by process.execPath, so the
# wrapper passes this selected path through to the installer below.
path_node=$(command -v node 2>/dev/null) || path_node=
if [ -n "$path_node" ] && is_supported_node "$path_node"; then
printf '%s\n' "$path_node"
return 0
fi

print_error "No compatible Node.js runtime found. Node.js ${minimum_node_major}+ is required."
print_error "Install/update the Codex desktop app, install Node.js ${minimum_node_major}+, or set CODEX_BARK_NODE."
return 1
Expand All @@ -115,6 +118,12 @@ esac
script_directory=${script_path%/*}
project_directory=${script_directory%/*}
node_executable=$(select_node) || exit 1
case $node_executable in
/*) ;;
*) node_executable=$PWD/$node_executable ;;
esac
_CODEX_BARK_SELECTED_NODE=$node_executable
export _CODEX_BARK_SELECTED_NODE

case ${1-} in
--verify)
Expand Down
105 changes: 105 additions & 0 deletions scripts/lib/installer-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import { fileURLToPath } from "node:url";

export const PRODUCT = "codex-bark-notifier";
export const SCHEMA_VERSION = 1;
export const MAX_INSTALL_BACKUPS = 5;
const MANAGED_BACKUP_DIRECTORY_PATTERN =
/^\d{8}T\d{6}\.\d{3}Z$/u;
export class UnsafeManifestError extends Error {
constructor(message, options = {}) {
super(message, options);
Expand Down Expand Up @@ -152,6 +155,8 @@ export function installationPaths({
previousNotify: join(installRoot, "previous-notify.json"),
manifest: join(installRoot, "installed.json"),
auditLog: join(installRoot, "bark-notify.log"),
auditArchive: join(installRoot, "bark-notify.log.1"),
auditRotationLock: join(installRoot, "bark-notify.log.rotate.lock"),
state: join(installRoot, "state"),
jobs: join(installRoot, "jobs"),
configToml: join(codexHome, "config.toml"),
Expand Down Expand Up @@ -2020,6 +2025,64 @@ export async function createBackups(paths, files, stamp = timestamp()) {
return { directory, files: records };
}

export function capBackupRecords(
backups,
maximumBackups = MAX_INSTALL_BACKUPS,
) {
if (!Array.isArray(backups)) {
throw new Error("Backup records must be an array.");
}
if (!Number.isSafeInteger(maximumBackups) || maximumBackups < 1) {
throw new Error("Backup retention must be a positive integer.");
}
return backups.slice(-maximumBackups);
}

export async function pruneBackupDirectories(
paths,
maximumBackups = MAX_INSTALL_BACKUPS,
) {
if (!Number.isSafeInteger(maximumBackups) || maximumBackups < 1) {
throw new Error("Backup retention must be a positive integer.");
}
const rootMetadata = await assertNotSymlink(paths.backupRoot);
if (!rootMetadata) {
return 0;
}
if (!rootMetadata.isDirectory()) {
throw new Error(`Backup root is not a directory: ${paths.backupRoot}`);
}

const managedEntries = (await readdir(paths.backupRoot, {
withFileTypes: true,
}))
.filter(
(entry) =>
entry.isDirectory() &&
MANAGED_BACKUP_DIRECTORY_PATTERN.test(entry.name),
)
.sort((left, right) => left.name.localeCompare(right.name));
const removableNames = new Set(
managedEntries
.slice(0, Math.max(0, managedEntries.length - maximumBackups))
.map((entry) => entry.name),
);
let removed = 0;
for (const entry of managedEntries) {
const directory = join(paths.backupRoot, entry.name);
if (!removableNames.has(entry.name)) {
continue;
}
const metadata = await lstat(directory);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
continue;
}
await rm(directory, { recursive: true, force: true });
removed += 1;
}
return removed;
}

export function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
Expand Down Expand Up @@ -2592,6 +2655,48 @@ export async function readManifest(paths) {
"installed.json managed files are invalid.",
);
}
if (!Array.isArray(manifest.backups)) {
throw new UnsafeManifestError(
"installed.json backup records must be an array.",
);
}
for (const record of manifest.backups) {
const directory = record?.directory;
if (
!record ||
Array.isArray(record) ||
typeof record !== "object" ||
typeof directory !== "string" ||
resolve(directory) !== directory ||
dirname(directory) !== resolve(paths.backupRoot) ||
!MANAGED_BACKUP_DIRECTORY_PATTERN.test(basename(directory)) ||
!Array.isArray(record.files)
) {
throw new UnsafeManifestError(
"installed.json contains invalid backup records.",
);
}
for (const file of record.files) {
const allowedPath = [paths.configToml, paths.hooksJson].includes(
file?.path,
);
const expectedBackup = file?.existed
? join(directory, basename(file.path))
: "";
if (
!file ||
Array.isArray(file) ||
typeof file !== "object" ||
!allowedPath ||
typeof file.existed !== "boolean" ||
file.backup !== expectedBackup
) {
throw new UnsafeManifestError(
"installed.json contains invalid backup file records.",
);
}
}
}
for (const [file, hash] of Object.entries(manifest.files)) {
const resolvedFile = resolve(file);
const relativeToLibrary = relative(paths.library, resolvedFile);
Expand Down
4 changes: 4 additions & 0 deletions src/lib/paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ export function runtimePaths(entryUrl = import.meta.url, environment = process.e
configFile: join(runtimeRoot, "config.json"),
keyFile: join(runtimeRoot, "bark-device-key"),
stateDirectory: join(runtimeRoot, "state"),
stateCleanupStamp: join(runtimeRoot, "state", ".sent-cleanup"),
stateCleanupLock: join(runtimeRoot, "state", ".sent-cleanup.lock"),
jobsDirectory: join(runtimeRoot, "jobs"),
auditLog: join(runtimeRoot, "bark-notify.log"),
auditArchive: join(runtimeRoot, "bark-notify.log.1"),
auditRotationLock: join(runtimeRoot, "bark-notify.log.rotate.lock"),
sessionRoot: join(codexHome, "sessions"),
sessionIndex: join(codexHome, "session_index.jsonl"),
};
Expand Down
Loading