From 22ddad142ce35b793f43633c2cd3cfa111d1d76f Mon Sep 17 00:00:00 2001 From: ziyuezhou1 Date: Sat, 1 Aug 2026 01:20:12 +0800 Subject: [PATCH 1/7] Improve Codex pet reliability and quota display # Conflicts: # README_EN.md # README_JA.md # package.json --- README.md | 26 ++-- README_EN.md | 16 +-- README_JA.md | 16 +-- backend/adapter.js | 1 + backend/codex-hookinstall.js | 211 ++++++++++++++++++++++++++++++++ backend/codex-rate-limits.js | 228 +++++++++++++++++++++++++++++++++++ backend/codex-watch.js | 1 + backend/config.js | 7 ++ backend/core.js | 30 +++++ backend/hookinstall.js | 11 +- backend/hooks.js | 105 +++++++++++----- backend/server.js | 3 +- backend/window-drag.js | 40 ++++++ hook/octopus-hook.js | 29 ++++- main.js | 101 +++++++++++++--- package.json | 2 +- preload.js | 7 +- renderer/pet.html | 3 +- renderer/pet.js | 58 ++++++--- shared/codex-rate-limits.js | 79 ++++++++++++ shared/i18n.js | 18 +++ test/codex-hooks.js | 102 ++++++++++++++++ test/codex-integration.js | 10 ++ test/codex-rate-limits.js | 134 ++++++++++++++++++++ test/dom-stub.js | 3 +- test/window-drag.js | 40 ++++++ 26 files changed, 1177 insertions(+), 104 deletions(-) create mode 100644 backend/codex-hookinstall.js create mode 100644 backend/codex-rate-limits.js create mode 100644 backend/window-drag.js create mode 100644 shared/codex-rate-limits.js create mode 100644 test/codex-hooks.js create mode 100644 test/codex-rate-limits.js create mode 100644 test/window-drag.js diff --git a/README.md b/README.md index 3b39c9c..4365f05 100644 --- a/README.md +++ b/README.md @@ -58,19 +58,20 @@ Claude Code ──(生命周期 hook)──► octopus-hook.js ──HTTP POST / > **「Claude 客户端消息」**指的是 Claude Code(CLI agent)的回复内容——`Stop` 时从 transcript 抽最后一段 assistant 文本(截断 + 密钥脱敏),对应桌宠的 `💬` 气泡。(不是 Claude 桌面聊天 App 的消息。) -### 🛰️ Codex 后端(零配置、只读) +### 🛰️ Codex 后端(官方 hooks + 只读回退) 除 Claude Code 外,桌宠也能盯 [OpenAI Codex](https://github.com/openai/codex)(CLI / Desktop): ``` -Codex CLI / Desktop ──写 rollout──► ~/.codex/sessions/YYYY/MM/DD/*.jsonl - │ (codex-watch 增量 tail,只读) - ▼ - 同一个会话状态机 (core, agentId: 'codex') ──► 桌宠/面板 +Codex CLI / Desktop ──官方生命周期 hooks──► octopus-hook.js ──► /state + └─旧版 rollout JSONL──► codex-watch(只读回退/计量)──┘ + ▼ + 同一个会话状态机 (core, agentId: 'codex') ──► 桌宠/面板 ``` -- **不装任何钩子**:Codex 只有一个全局 `notify` 配置位(常被 ChatGPT 桌面 App 占用),所以走「监听 rollout 文件」——增量 tail、零配置、卸载无残留。 -- 事件映射:`user_message→思考`;首个 `exec_command/apply_patch` 后整轮保持“干活中”(工具结果和中间 reasoning 不会误降成思考),直到 `task_complete→完成庆祝+💬` 或 `turn_aborted→中断徽标`;`token_count→上下文%`。guardian / auto-review 等 subagent 内部线程自动过滤,长会话恢复时只读取新增事件、不重放历史。 +- **新版实时通道**:首次启动会合并写入 `~/.codex/hooks.json`,注册 `SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop …`。其他程序的 hooks 保留不动;托盘“卸载钩子”也只删除 LLMPET 条目并先备份。Codex 若提示新 hook 待审核,请在 Codex 中运行 `/hooks` 并信任 LLMPET 的 `octopus-hook.js` 命令。 +- **旧版兼容回退**:仍增量 tail `~/.codex/sessions/**/*.jsonl`。hooks 与 rollout 同时报告时会跨来源去重;长会话恢复只读新增事件,不重放历史。 +- 事件映射:`UserPromptSubmit→思考`;`PreToolUse/PostToolUse→干活中`;`Stop→完成庆祝+💬`;`PermissionRequest→等待处理`。guardian / auto-review 等 subagent rollout 仍自动过滤。 - **用量与额度分开**:按 rollout 每条 `last_token_usage` 建立去重台账,显示今日 / 本机留存历史 token;套餐 5h 主窗口与周窗口仍单独读取 `rate_limits`。本地 token 台账不冒充 OpenAI 账单或账号全生命周期统计。 - **两种形态**(托盘 → 设置 → 分身): - **单宠**(默认):一只宠同时盯两个后端,会话列表用图标区分(Claude 橙 burst / Codex 蓝终端块); @@ -104,7 +105,7 @@ Codex CLI / Desktop ──写 rollout──► ~/.codex/sessions/YYYY/MM/DD/*.js git clone https://github.com/myunwang/LLMPET.git cd LLMPET npm ci # 按 package-lock.json 安装(国内网络慢可加:ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm ci) -npm start # 启动桌宠(首次启动会注册 Claude Code 钩子) +npm start # 启动桌宠(首次启动会注册 Claude Code / Codex 钩子) ``` 启动后新开的 Claude Code / Codex 会话会被感知;近期仍活跃的 Codex rollout 也会静默恢复到会话列表。右键桌宠可切三款皮肤和单宠/双宠模式。 @@ -115,7 +116,7 @@ npm start # 启动桌宠(首次启动会注册 Claude Code 钩子 - 终端归属解析(pid 链)首次约 1–2s(起一次 PowerShell),之后按会话缓存在 `~/.octopus/pidwalk-cache.json`,热路径无感。 - 打包安装版:`npm run package:win`(electron-builder,产出 NSIS 安装包 + zip;国内网络可另设 `$env:ELECTRON_BUILDER_BINARIES_MIRROR='https://npmmirror.com/mirrors/electron-builder-binaries/'`)。 -- 首次启动会把钩子写进 `~/.claude/settings.json`(合并、可逆)。之后新开的 `claude` 会话即被桌宠感知。 +- 首次启动会把钩子合并写进 `~/.claude/settings.json` 与 `~/.codex/hooks.json`(可逆)。Codex 若提示待审核,请运行 `/hooks` 信任 LLMPET 命令;之后新开的会话即被桌宠感知。 - **左键点桌宠** = 弹出**会话列表**(状态 + 会话名 + 上下文用量%);可搜索、按 Claude / Codex / 待处理筛选、置顶或归档,点某行把对应终端 / 客户端调到前台。偏好写入 `~/.octopus/config.json`。 - 会话右侧的 **🧳** = 打开青蛙旅行:让对应 agent 在该项目中执行一次独立、只读探索,回来后展示明信片并累计成长 token。 - 会话面板底部的 **🐱 闲逛** = 打开可见 CLI,不带项目、不带任务和工具,让猫猫自己随便想想、聊聊。 @@ -125,7 +126,7 @@ npm start # 启动桌宠(首次启动会注册 Claude Code 钩子 - **🥊 领地模式**(macOS):右键桌宠点“巡视”可立即扫描并执行一次;托盘可开启“自动巡逻”,开启后立即首巡、随后定时轮询(默认关)。两条定律:①**猫爪在上**——检测到别的桌面宠物(Desktop Goose / BongoCat / Shimeji 等)在跑,就把自己的窗口层级抬到最上,谁也不许压着咱(无需额外权限);②**巡视行动**——发现对方窗口,小章鱼走过去把它一步步**顶到屏幕边上**。巡视需要**辅助功能**权限(移动别人的窗口);没授权时「巡视」仍会执行猫爪在上,只是不推窗。对付 AXPosition 失效的透明窗桌宠时,会像 Computer Use 一样显示独立的橙色爪软件光标;底层兼容拖拽仍只在你**输入空闲 ≥2s** 时执行,期间隐藏系统光标,结束或异常都会补发 mouseUp 并把原光标复位,你手上有活时则静默撤退。自定义对手:`~/.octopus/config.json` 的 `territoryRivals` 数组加进程名关键词。 ### 开发 / 验证开关 -- `OCTOPUS_NO_HOOKS=1 npm start` —— 启动但**不动** `~/.claude/settings.json`(只验证主进程 / 界面)。 +- `OCTOPUS_NO_HOOKS=1 npm start` —— 启动但**不动** Claude/Codex 的 hook 配置(只验证主进程 / 界面)。 - `OCTOPUS_ALLOW_MULTI=1 npm start` —— 跳过多实例防护(默认:实例锁 + 启动探测到别的 LLMPET 实例就退出 + 存活期间守护 `runtime.json` 不被其他副本抢走)。 - `OCTOPUS_NO_NET=1 npm start` —— **完全离线**:关掉唯一的外联请求(每 24h 拉一次 [LiteLLM 公开价目表](https://github.com/BerriAI/litellm),只下载、不上传任何本地数据),花费改用内置估算单价。 - `OCTOPUS_DEBUG=1 npm start` —— 开放 `GET /debug`(默认关闭,会暴露会话 cwd / 标题等,仅本机回环可访问)。 @@ -169,12 +170,13 @@ main.js Electron 主进程:窗口 / IPC / 托盘 / 启动编 preload.js 前后端唯一接口(contextBridge) renderer/ assets/ 桌宠 + 面板的视觉与渲染 hook/ - octopus-hook.js Claude Code 触发的钩子脚本(读 stdin/transcript,POST /state) + octopus-hook.js Claude Code / Codex 共用钩子脚本(读 stdin,POST /state) backend/ transport.js 端口发现 / runtime 文件 / 标识头 / 钩子→server 传输 / node 定位 transcript.js transcript 解析(assistant 文本 / 上下文用量 / API 错误 / 标题) pidwalk.js 进程树解析(定位会话所在终端) - hookinstall.js merge-safe 钩子安装器(合并不覆盖 / 原子写 / 卸载备份) + hookinstall.js Claude merge-safe 钩子安装器 + codex-hookinstall.js Codex merge-safe 钩子安装器(原子写 / 卸载备份) launch.js 开终端跑 claude core.js 会话存储 + 状态机 + 快照 + 陈旧清理 server.js 本地 HTTP server(/state /permission /health) diff --git a/README_EN.md b/README_EN.md index 4229dc9..84fc42a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -18,7 +18,7 @@ The interface is available in **Simplified Chinese, English, and Japanese**. Swi - **Three skins** — Octopus 🐙, Pixel Monster 👾, and Salary Cat 🐱. - **Patrol mode on macOS** — LLMPET can detect supported rival desktop pets, stay above them, and attempt to push their windows to the nearest screen edge. -LLMPET's state machine, metering, permission flow, process reconciliation, and desktop UI are implemented in this repository. Claude Code connects through its public hook system. Codex integration is read-only: LLMPET tails local rollout files and does not modify Codex configuration. +LLMPET's state machine, metering, permission flow, process reconciliation, and desktop UI are implemented in this repository. Claude Code and current Codex builds connect through their public hook systems; legacy Codex rollout files remain a read-only fallback and metering source. ## Salary Cat states @@ -61,7 +61,7 @@ Useful commands: npm test # full headless regression suite npm run package:mac:dev # local ad-hoc-signed macOS package npm run package:win # Windows installer + portable ZIP -npm run uninstall:hooks # remove LLMPET's Claude hooks safely +npm run uninstall:hooks # remove LLMPET's Claude/Codex hooks safely ``` ## How the integrations work @@ -76,13 +76,15 @@ LLMPET registers merge-safe lifecycle and permission hooks in `~/.claude/setting ### OpenAI Codex -LLMPET does not install Codex hooks. It incrementally and read-only tails: +LLMPET merge-safely registers documented lifecycle hooks in `~/.codex/hooks.json`. Other applications' handlers are preserved, and uninstall removes only LLMPET entries after making a backup. When Codex marks the new command for review, run `/hooks` and trust LLMPET's `octopus-hook.js` handler. + +For older Codex builds, LLMPET still incrementally and read-only tails: ```text ~/.codex/sessions/YYYY/MM/DD/*.jsonl ``` -It maps rollout events into the same state machine, filters internal subagent threads, restores long-running sessions without replaying old events, and builds a persistent local token ledger from each event's `last_token_usage`. Codex rate-limit windows remain separate; local history is not presented as an OpenAI bill. +Hook and rollout events map into the same state machine and are deduplicated across sources. The rollout fallback filters internal subagent threads, restores long-running sessions without replaying old events, and builds a persistent local token ledger from each event's `last_token_usage`. Codex rate-limit windows remain separate; local history is not presented as an OpenAI bill. ## Travel Frog @@ -124,18 +126,18 @@ Patrol mode is currently macOS-only. - The HTTP server binds only to `127.0.0.1`; write endpoints require a random per-run token in addition to loopback, Host, and browser-origin checks. - Session data, configuration, and usage history stay on the local machine. -- Codex rollout access is read-only. +- Codex lifecycle hooks post only to LLMPET's loopback server; legacy rollout access is read-only. - Background network access is limited to the optional daily LiteLLM pricing download. A Travel Frog run contacts Anthropic or OpenAI only after you explicitly press **Depart**; `OCTOPUS_NO_NET=1` disables LLMPET's pricing fetch, but does not override a CLI trip you explicitly start. - Electron runs with `contextIsolation` enabled and `nodeIntegration` disabled. - Claude hook installation is merge-safe, atomic, reversible, and backed up before uninstall. ## Configuration and development flags -- `OCTOPUS_NO_HOOKS=1 npm start` — launch without changing Claude settings. +- `OCTOPUS_NO_HOOKS=1 npm start` — launch without changing Claude or Codex hook settings. - `OCTOPUS_ALLOW_MULTI=1 npm start` — bypass single-instance protection for development. - `OCTOPUS_NO_NET=1 npm start` — disable all external network requests. - `OCTOPUS_DEBUG=1 npm start` — expose the local `/debug` endpoint. -- `LLMPET_NO_CODEX=1 npm start` — disable Codex rollout watching. +- `LLMPET_NO_CODEX=1 npm start` — disable Codex monitoring. - `LLMPET_CODEX_DIR= npm start` — use a custom rollout directory for testing. ## Contributors diff --git a/README_JA.md b/README_JA.md index 4d0e85e..b4920d1 100644 --- a/README_JA.md +++ b/README_JA.md @@ -18,7 +18,7 @@ LLMPET は、**Claude Code と OpenAI Codex** の動きをひと目で確認で - **3 種類のスキン** — タコ 🐙、ピクセルモンスター 👾、月薪喵 🐱。 - **macOS のパトロールモード** — 対応する他のデスクトップペットを検出し、最前面を維持しながら相手を画面端へ押し出します。 -状態機械、利用量計測、権限処理、プロセス照合、デスクトップ UI はこのリポジトリ内で実装されています。Claude Code は公開 hook API を利用し、Codex はローカルの rollout ファイルを読み取り専用で監視します。Codex の設定は変更しません。 +状態機械、利用量計測、権限処理、プロセス照合、デスクトップ UI はこのリポジトリ内で実装されています。Claude Code と現在の Codex は公開 hook API を利用し、旧版 Codex の rollout ファイル監視は読み取り専用のフォールバックとして残します。 ## 月薪喵スキンの状態 @@ -61,7 +61,7 @@ npm start npm test # ヘッドレス回帰テスト一式 npm run package:mac:dev # ローカル用 ad-hoc 署名 macOS パッケージ npm run package:win # Windows インストーラー + ZIP -npm run uninstall:hooks # LLMPET の Claude hook を安全に削除 +npm run uninstall:hooks # LLMPET の Claude / Codex hook を安全に削除 ``` ## 連携の仕組み @@ -76,13 +76,15 @@ LLMPET は `~/.claude/settings.json` に、既存設定と安全に共存する ### OpenAI Codex -Codex 用の hook はインストールしません。次の rollout を増分かつ読み取り専用で監視します。 +LLMPET は `~/.codex/hooks.json` に公式ライフサイクル hook を既存設定と共存する形で登録します。他のアプリの hook は保持され、アンインストール時もバックアップ後に LLMPET の項目だけを削除します。Codex が新しいコマンドの確認を求めた場合は、`/hooks` を実行して LLMPET の `octopus-hook.js` を信頼してください。 + +旧版 Codex 向けには、次の rollout も増分かつ読み取り専用で監視します。 ```text ~/.codex/sessions/YYYY/MM/DD/*.jsonl ``` -rollout イベントを共通の状態機械へ変換し、内部 subagent スレッドを除外します。長時間セッションの復帰時も過去イベントを再生せず、新しく追加された部分だけを読み取ります。各イベントの `last_token_usage` から永続的なローカル token 台帳を作り、レート制限とは分けて表示します。この台帳を OpenAI の請求履歴とは表示しません。 +hook と rollout のイベントは同じ状態機械へ変換し、データ源をまたいで重複排除します。rollout フォールバックは内部 subagent スレッドを除外し、長時間セッションの復帰時も過去イベントを再生しません。各イベントの `last_token_usage` から永続的なローカル token 台帳を作り、レート制限とは分けて表示します。この台帳を OpenAI の請求履歴とは表示しません。 ## 旅するカエル @@ -124,18 +126,18 @@ assets/memes// - HTTP サーバーは `127.0.0.1` のみにバインドし、loopback / Host / browser-origin の検証に加えて、書き込み API に起動ごとのランダム token を要求します。 - セッション情報、設定、利用履歴はローカル端末内に保存されます。 -- Codex rollout へのアクセスは読み取り専用です。 +- Codex lifecycle hook は LLMPET の loopback server にだけ送信し、旧版 rollout へのアクセスは読み取り専用です。 - バックグラウンド通信は、任意の LiteLLM 公開価格表の日次取得だけです。「旅するカエル」はユーザーが **出発**を押した場合にだけ Anthropic / OpenAI へ接続します。`OCTOPUS_NO_NET=1` は LLMPET の価格取得を止めますが、明示的に開始した CLI 旅行までは無効化しません。 - Electron は `contextIsolation` を有効、`nodeIntegration` を無効にしています。 - Claude hook の追加は既存設定を上書きせず、原子的かつ取り消し可能で、削除前にはバックアップを作成します。 ## 設定・開発用フラグ -- `OCTOPUS_NO_HOOKS=1 npm start` — Claude 設定を変更せずに起動します。 +- `OCTOPUS_NO_HOOKS=1 npm start` — Claude / Codex の hook 設定を変更せずに起動します。 - `OCTOPUS_ALLOW_MULTI=1 npm start` — 開発時に単一インスタンス制限を無効化します。 - `OCTOPUS_NO_NET=1 npm start` — 外部ネットワーク通信を無効化します。 - `OCTOPUS_DEBUG=1 npm start` — ローカル `/debug` エンドポイントを有効化します。 -- `LLMPET_NO_CODEX=1 npm start` — Codex rollout の監視を無効化します。 +- `LLMPET_NO_CODEX=1 npm start` — Codex の監視を無効化します。 - `LLMPET_CODEX_DIR= npm start` — テスト用の rollout ディレクトリを指定します。 ## コントリビューター diff --git a/backend/adapter.js b/backend/adapter.js index 4fd17c6..4a36004 100644 --- a/backend/adapter.js +++ b/backend/adapter.js @@ -482,6 +482,7 @@ function activityToEvents(act) { out.push({ kind: 'say', text: clip(plainText(session.assistantLastOutput), 280), emotion: emo, project, ts: Date.now() }); } break; + case 'PermissionRequest': case 'Notification': case 'Elicitation': out.push({ diff --git a/backend/codex-hookinstall.js b/backend/codex-hookinstall.js new file mode 100644 index 0000000..d764ca9 --- /dev/null +++ b/backend/codex-hookinstall.js @@ -0,0 +1,211 @@ +'use strict'; + +// Merge-safe installer for Codex lifecycle hooks. +// +// Newer Codex builds keep live thread state in SQLite and may no longer append +// rollout JSONL files. Hooks are the documented event interface, so LLMPET uses +// them as the primary live source while retaining codex-watch.js as a legacy +// fallback and metering source. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { resolveNodeBin } = require('./transport'); + +const CODEX_HOOKS_PATH = path.join(os.homedir(), '.codex', 'hooks.json'); +const HOOK_SCRIPT = path.join(__dirname, '..', 'hook', 'octopus-hook.js'); +const MARKER = 'octopus-hook.js'; +const AGENT_ARG = 'codex'; +const STATE_TIMEOUT_S = 5; + +// Only events in the documented Codex hook surface. Claude-only events such as +// StopFailure and Elicitation remain registered exclusively in settings.json. +const CODEX_EVENTS = [ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', + 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'Stop', + 'SubagentStart', 'SubagentStop', 'PreCompact', 'PostCompact', +]; + +function readHooks(filePath = CODEX_HOOKS_PATH) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const obj = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw); + return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {}; + } catch (err) { + if (err.code === 'ENOENT') return {}; + throw new Error(`read Codex hooks.json: ${err.message}`); + } +} + +function writeAtomic(obj, filePath = CODEX_HOOKS_PATH) { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.hooks.${process.pid}.${Date.now()}.tmp`); + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, filePath); +} + +function quote(value) { + return `"${String(value).replace(/"/g, '\\"')}"`; +} + +function commandHook(nodeBin, event, platform = process.platform) { + const command = `${quote(nodeBin)} ${quote(HOOK_SCRIPT)} ${event} ${AGENT_ARG}`; + const hook = { + type: 'command', + command, + timeout: event === 'SessionEnd' ? 1 : STATE_TIMEOUT_S, + }; + // Codex executes commandWindows through PowerShell. A quoted executable path + // is only a string expression there; prefix it with the call operator so an + // absolute Node path containing spaces is actually launched. + if (platform === 'win32') hook.commandWindows = `& ${command}`; + return hook; +} + +function isOurCommand(hook) { + if (!hook || typeof hook !== 'object') return false; + return [hook.command, hook.commandWindows] + .some((command) => typeof command === 'string' && command.includes(MARKER)); +} + +function sameDesired(hook, desired) { + return Object.keys(desired).every((key) => hook[key] === desired[key]) && !('shell' in hook); +} + +// Keep every unrelated handler and matcher group. If a previous LLMPET build +// left duplicate handlers, retain one and remove only the duplicates we own. +function syncEvent(hooks, event, desired) { + if (!Array.isArray(hooks[event])) { + const prior = hooks[event]; + hooks[event] = prior && typeof prior === 'object' ? [prior] : []; + } + + let found = null; + let changed = false; + const groups = []; + for (const group of hooks[event]) { + if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) { + groups.push(group); + continue; + } + const kept = []; + for (const hook of group.hooks) { + if (!isOurCommand(hook)) { + kept.push(hook); + continue; + } + if (found) { + changed = true; + continue; + } + found = hook; + if (!sameDesired(hook, desired)) { + for (const key of Object.keys(desired)) hook[key] = desired[key]; + delete hook.shell; + changed = true; + } + kept.push(hook); + } + if (kept.length) groups.push({ ...group, hooks: kept }); + } + hooks[event] = groups; + + if (!found) { + hooks[event].push({ matcher: '', hooks: [desired] }); + return 'added'; + } + return changed ? 'updated' : 'skipped'; +} + +function removeOurHooks(hooks) { + let removed = 0; + for (const event of Object.keys(hooks)) { + if (!Array.isArray(hooks[event])) continue; + const groups = []; + for (const group of hooks[event]) { + if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) { + groups.push(group); + continue; + } + const kept = group.hooks.filter((hook) => { + if (isOurCommand(hook)) { removed++; return false; } + return true; + }); + if (kept.length) groups.push({ ...group, hooks: kept }); + } + if (groups.length) hooks[event] = groups; + else delete hooks[event]; + } + return removed; +} + +function registerCodexHooks(options = {}) { + const filePath = options.hooksPath || CODEX_HOOKS_PATH; + const nodeBin = options.nodeBin || resolveNodeBin(); + const platform = options.platform || process.platform; + const config = readHooks(filePath); + if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) config.hooks = {}; + const result = { added: 0, updated: 0, skipped: 0 }; + + for (const event of CODEX_EVENTS) { + const status = syncEvent(config.hooks, event, commandHook(nodeBin, event, platform)); + result[status]++; + } + if (result.added || result.updated) writeAtomic(config, filePath); + return { ...result, nodeBin, hooksPath: filePath }; +} + +function unregisterCodexHooks(options = {}) { + const filePath = options.hooksPath || CODEX_HOOKS_PATH; + let config; + try { config = readHooks(filePath); } catch { return { removed: 0, backupPath: null }; } + if (!config.hooks) return { removed: 0, backupPath: null }; + const removed = removeOurHooks(config.hooks); + if (!removed) return { removed: 0, backupPath: null }; + + let backupPath = null; + if (options.backup) { + try { + backupPath = `${filePath}.octopus-backup-${Date.now()}.bak`; + fs.copyFileSync(filePath, backupPath); + } catch { backupPath = null; } + } + writeAtomic(config, filePath); + return { removed, backupPath }; +} + +function codexHooksCurrent(options = {}) { + const filePath = options.hooksPath || CODEX_HOOKS_PATH; + const nodeBin = options.nodeBin || resolveNodeBin(); + const platform = options.platform || process.platform; + try { + const config = readHooks(filePath); + const hooks = config.hooks || {}; + return CODEX_EVENTS.every((event) => { + const desired = commandHook(nodeBin, event, platform); + return Array.isArray(hooks[event]) && hooks[event].some((group) => + Array.isArray(group && group.hooks) && group.hooks.some((hook) => + isOurCommand(hook) && sameDesired(hook, desired))); + }); + } catch { + return false; + } +} + +module.exports = { + registerCodexHooks, + unregisterCodexHooks, + codexHooksCurrent, + readHooks, + removeOurHooks, + CODEX_HOOKS_PATH, + CODEX_EVENTS, + HOOK_SCRIPT, + MARKER, +}; + +if (require.main === module) { + if (process.argv.includes('--uninstall')) console.log(unregisterCodexHooks({ backup: true })); + else console.log(registerCodexHooks()); +} diff --git a/backend/codex-rate-limits.js b/backend/codex-rate-limits.js new file mode 100644 index 0000000..136c0d1 --- /dev/null +++ b/backend/codex-rate-limits.js @@ -0,0 +1,228 @@ +'use strict'; + +// Read the same ChatGPT quota windows exposed by Codex /status through the +// documented App Server account/rateLimits/read method. The rollout watcher is +// still kept by main.js as a compatibility fallback for older Codex builds. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const { findCli } = require('./launch'); +const { log } = require('./log'); +const { normalizeAppServerRateLimits } = require('../shared/codex-rate-limits'); + +const POLL_MS = 2 * 60 * 1000; +const RESTART_MS = 30 * 1000; +const AUTH_RETRY_MS = 30 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 15 * 1000; + +function findBundledWindowsCodex() { + if (process.platform !== 'win32') return null; + const npmRoot = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'npm'); + const scope = path.join(npmRoot, 'node_modules', '@openai', 'codex', 'node_modules', '@openai'); + let packages = []; + try { packages = fs.readdirSync(scope, { withFileTypes: true }); } catch { return null; } + for (const pkg of packages) { + if (!pkg.isDirectory() || !pkg.name.startsWith('codex-win32-')) continue; + const vendor = path.join(scope, pkg.name, 'vendor'); + let targets = []; + try { targets = fs.readdirSync(vendor, { withFileTypes: true }); } catch { continue; } + for (const target of targets) { + if (!target.isDirectory()) continue; + const candidate = path.join(vendor, target.name, 'bin', 'codex.exe'); + try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {} + } + } + return null; +} + +function resolveCodexExecutable(findCliImpl = findCli) { + const bundled = findBundledWindowsCodex(); + if (bundled) return bundled; + return findCliImpl('codex'); +} + +function createCodexRateLimits(deps = {}) { + const onRateLimits = typeof deps.onRateLimits === 'function' ? deps.onRateLimits : () => {}; + const spawnImpl = deps.spawnImpl || spawn; + const findCliImpl = deps.findCliImpl || findCli; + const pollMs = Number.isFinite(deps.pollMs) ? deps.pollMs : POLL_MS; + const restartMs = Number.isFinite(deps.restartMs) ? deps.restartMs : RESTART_MS; + const authRetryMs = Number.isFinite(deps.authRetryMs) ? deps.authRetryMs : AUTH_RETRY_MS; + const requestTimeoutMs = Number.isFinite(deps.requestTimeoutMs) ? deps.requestTimeoutMs : REQUEST_TIMEOUT_MS; + let child = null; + let stopped = true; + let initialized = false; + let nextId = 1; + let stdoutBuffer = ''; + let pollTimer = null; + let restartTimer = null; + let readPending = false; + let authBlocked = false; + const pending = new Map(); + + function write(message) { + if (!child || !child.stdin || child.stdin.destroyed || !child.stdin.writable) return false; + try { child.stdin.write(JSON.stringify(message) + '\n'); return true; } catch { return false; } + } + + function request(method, params, callback) { + const id = nextId++; + const timeout = setTimeout(() => { + const entry = pending.get(id); + if (!entry) return; + pending.delete(id); + entry.callback(null, new Error(`${method} timed out`)); + }, requestTimeoutMs); + if (timeout.unref) timeout.unref(); + pending.set(id, { callback, timeout }); + const message = { method, id }; + if (params !== undefined) message.params = params; + if (!write(message)) { + clearTimeout(timeout); + pending.delete(id); + callback(null, new Error(`${method} write failed`)); + } + return id; + } + + function accept(payload) { + const limits = normalizeAppServerRateLimits(payload); + if (!limits) return; + onRateLimits(limits); + } + + function readRateLimits() { + if (!initialized || readPending) return; + readPending = true; + request('account/rateLimits/read', undefined, (result, error) => { + readPending = false; + if (error) { + log('codex-rate', error.message); + if (/authentication required|not logged in/i.test(error.message)) { + authBlocked = true; + const proc = child; + if (proc) { try { proc.kill(); } catch {} } + } + return; + } + authBlocked = false; + accept(result); + }); + } + + function handleMessage(message) { + if (!message || typeof message !== 'object') return; + if (message.id != null && pending.has(message.id)) { + const entry = pending.get(message.id); + pending.delete(message.id); + clearTimeout(entry.timeout); + entry.callback(message.result || null, message.error ? new Error(message.error.message || 'App Server error') : null); + return; + } + if (message.method === 'account/rateLimits/updated') accept(message.params); + } + + function consumeStdout(chunk) { + stdoutBuffer += chunk.toString('utf8'); + if (stdoutBuffer.length > 2 * 1024 * 1024) stdoutBuffer = stdoutBuffer.slice(-1024 * 1024); + const lines = stdoutBuffer.split(/\r?\n/); + stdoutBuffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { handleMessage(JSON.parse(line)); } catch {} + } + } + + function clearPending(reason) { + for (const entry of pending.values()) { + clearTimeout(entry.timeout); + try { entry.callback(null, new Error(reason)); } catch {} + } + pending.clear(); + readPending = false; + } + + function scheduleRestart() { + if (stopped || restartTimer) return; + restartTimer = setTimeout(() => { restartTimer = null; connect(); }, authBlocked ? authRetryMs : restartMs); + if (restartTimer.unref) restartTimer.unref(); + } + + function connect() { + if (stopped || child) return; + initialized = false; + stdoutBuffer = ''; + const executable = resolveCodexExecutable(findCliImpl); + let proc; + try { + proc = spawnImpl(executable, ['app-server', '--listen', 'stdio://'], { + stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, + }); + } catch (error) { + log('codex-rate', `App Server start failed: ${error.message}`); + scheduleRestart(); + return; + } + child = proc; + proc.stdout.on('data', consumeStdout); + proc.on('spawn', () => { + request('initialize', { + clientInfo: { name: 'llmpet', title: 'LLMPET', version: '1.1.1' }, + capabilities: { optOutNotificationMethods: ['thread/started', 'item/agentMessage/delta'] }, + }, (_result, error) => { + if (error) { + log('codex-rate', `initialize failed: ${error.message}`); + try { proc.kill(); } catch {} + return; + } + initialized = true; + write({ method: 'initialized', params: {} }); + readRateLimits(); + if (!pollTimer) { + pollTimer = setInterval(readRateLimits, pollMs); + if (pollTimer.unref) pollTimer.unref(); + } + log('codex-rate', 'App Server quota reader ready'); + }); + }); + proc.on('error', (error) => log('codex-rate', `App Server error: ${error.message}`)); + proc.on('close', (code) => { + if (child !== proc) return; + child = null; + initialized = false; + clearPending('App Server closed'); + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + if (!stopped) { + log('codex-rate', `App Server exited (${code == null ? 'unknown' : code}); retrying`); + scheduleRestart(); + } + }); + } + + function start() { + if (!stopped) return; + stopped = false; + connect(); + } + + function stop() { + stopped = true; + if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; } + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + clearPending('quota reader stopped'); + const proc = child; + child = null; + initialized = false; + if (proc) { try { proc.kill(); } catch {} } + } + + return { start, stop, refresh: readRateLimits }; +} + +module.exports = { + createCodexRateLimits, + resolveCodexExecutable, + findBundledWindowsCodex, +}; diff --git a/backend/codex-watch.js b/backend/codex-watch.js index 9cc827a..9d3da01 100644 --- a/backend/codex-watch.js +++ b/backend/codex-watch.js @@ -235,6 +235,7 @@ function createCodexWatch(deps) { function baseFields(t) { const f = { agentId: 'codex', + eventSource: 'codex-rollout', headless: false, transcriptPath: t.fp, }; diff --git a/backend/config.js b/backend/config.js index e43a824..bb11cdb 100644 --- a/backend/config.js +++ b/backend/config.js @@ -23,6 +23,7 @@ const DEFAULTS = Object.freeze({ territoryRivals: [], // 用户自定义的对手进程名特征(叠加在内置名单上) petMode: 'single', // 'single' 一只宠监控全部后端 | 'duo' Claude/Codex 各一只 skinCodex: 'cat', // 双宠模式里 Codex 宠的形象(和主形象错开才认得出谁是谁) + codexChipMode: 'usage', // 'usage' 默认统计 | 'weeklyRemaining' 周额度剩余 petPositionCodex: null, // {x,y} | null — Codex 宠的落脚点 lang: 'zh', // 'zh' | 'en' | 'ja' — 界面与表情包文案语言 pinnedSessions: [], // 会话 HUD 置顶项(按稳定 session id) @@ -59,6 +60,12 @@ function sanitize(raw) { } if (raw.petMode === 'duo' || raw.petMode === 'single') out.petMode = raw.petMode; if (['mascot', 'pixel', 'cat'].includes(raw.skinCodex)) out.skinCodex = raw.skinCodex; + if (['usage', 'weeklyRemaining'].includes(raw.codexChipMode)) { + out.codexChipMode = raw.codexChipMode; + } else if (raw.codexTagMode === 'weeklyRemaining') { + // Migrate the short-lived, incorrectly named setting from the first build. + out.codexChipMode = 'weeklyRemaining'; + } if (raw.petPositionCodex && Number.isFinite(raw.petPositionCodex.x) && Number.isFinite(raw.petPositionCodex.y)) { out.petPositionCodex = { x: Math.round(raw.petPositionCodex.x), y: Math.round(raw.petPositionCodex.y) }; } diff --git a/backend/core.js b/backend/core.js index 7ab3425..3db02ee 100644 --- a/backend/core.js +++ b/backend/core.js @@ -115,6 +115,26 @@ function createCore(options = {}) { s[key] = value; } + // Hooks and the rollout watcher can report the same Codex lifecycle event. + // Match equivalent cross-source events as a short-lived multiset so batched + // rollout reads do not replay animations already delivered by hooks. + function isCrossSourceDuplicate(s, incomingState, event, f, now) { + if (f.agentId !== 'codex' || !f.eventSource || !event) return false; + const key = `${event}\u0000${f.toolName || ''}\u0000${incomingState || ''}`; + const recent = Array.isArray(s.codexSourceEvents) + ? s.codexSourceEvents.filter((entry) => now - entry.at < 15000) + : []; + const match = recent.find((entry) => !entry.matched && entry.source !== f.eventSource && entry.key === key); + if (match) { + match.matched = true; + s.codexSourceEvents = recent; + return true; + } + recent.push({ key, source: f.eventSource, at: now, matched: false }); + s.codexSourceEvents = recent.slice(-64); + return false; + } + // Ingest one hook state update for a session (Claude Code only). function updateSession(sid, incomingState, event, f = {}) { const id = sid || 'default'; @@ -161,6 +181,16 @@ function createCore(options = {}) { s.assistantLastOutputTruncated = f.assistantLastOutputTruncated === true; } + if (isCrossSourceDuplicate(s, incomingState, event, f, now)) { + s.pendingUserEmotion = null; + s.pendingAssistantEmotion = null; + s.pendingSessionSource = null; + s.updatedAt = now; + sessions.set(id, s); + onDirty(); + return s; + } + // Resolve the stored state. let resolvedState = VALID_STATES.has(incomingState) ? incomingState : 'idle'; let realCompletion = false; diff --git a/backend/hookinstall.js b/backend/hookinstall.js index 361e230..1be8d02 100644 --- a/backend/hookinstall.js +++ b/backend/hookinstall.js @@ -225,10 +225,17 @@ module.exports = { // CLI: `node backend/hookinstall.js` installs; `--uninstall` removes. if (require.main === module) { + const { registerCodexHooks, unregisterCodexHooks } = require('./codex-hookinstall'); if (process.argv.includes('--uninstall')) { - console.log(unregisterHooks({ backup: true })); + console.log({ + claude: unregisterHooks({ backup: true }), + codex: unregisterCodexHooks({ backup: true }), + }); } else { const runtime = readRuntimeConfig(); - console.log(registerHooks(runtime && runtime.port, runtime && runtime.token)); + console.log({ + claude: registerHooks(runtime && runtime.port, runtime && runtime.token), + codex: registerCodexHooks(), + }); } } diff --git a/backend/hooks.js b/backend/hooks.js index 96573a3..103757a 100644 --- a/backend/hooks.js +++ b/backend/hooks.js @@ -1,61 +1,104 @@ 'use strict'; -// Claude Code hook lifecycle — install/uninstall via backend/hookinstall.js, -// plus a settings.json watcher that re-registers our hooks if another tool -// (CC-Switch, manual edits, …) overwrites the file without them. +// Claude Code + Codex hook lifecycle. Both installers merge only LLMPET-owned +// handlers and preserve other tools' entries. const fs = require('fs'); const path = require('path'); -const { registerHooks, unregisterHooks, hooksCurrent, SETTINGS_PATH } = require('./hookinstall'); +const { + registerHooks, + unregisterHooks, + hooksCurrent: claudeHooksCurrent, + SETTINGS_PATH, +} = require('./hookinstall'); +const { + registerCodexHooks, + unregisterCodexHooks, + codexHooksCurrent, + CODEX_HOOKS_PATH, +} = require('./codex-hookinstall'); const { log } = require('./log'); const SETTINGS_DIR = path.dirname(SETTINGS_PATH); function install(port, token) { + const result = { claude: null, codex: null }; try { const r = registerHooks(port, token); log('hooks', `installed (port ${port}) added=${r.added} updated=${r.updated} skipped=${r.skipped} purged=${r.purged || 0} node=${r.nodeBin}`); - return r; + result.claude = r; } catch (err) { - log('hooks', 'install failed:', err.message); - return null; + log('hooks', 'Claude install failed:', err.message); } + try { + const r = registerCodexHooks(); + log('codex-hooks', `installed added=${r.added} updated=${r.updated} skipped=${r.skipped} node=${r.nodeBin}`); + result.codex = r; + } catch (err) { + log('codex-hooks', 'install failed:', err.message); + } + return result; } function uninstall() { + const result = { claude: null, codex: null }; try { const r = unregisterHooks({ backup: true }); log('hooks', `uninstalled removed=${r.removed}${r.backupPath ? ' backup=' + r.backupPath : ''}`); - return r; + result.claude = r; + } catch (err) { + log('hooks', 'Claude uninstall failed:', err.message); + } + try { + const r = unregisterCodexHooks({ backup: true }); + log('codex-hooks', `uninstalled removed=${r.removed}${r.backupPath ? ' backup=' + r.backupPath : ''}`); + result.codex = r; } catch (err) { - log('hooks', 'uninstall failed:', err.message); - return null; + log('codex-hooks', 'uninstall failed:', err.message); } + return result; } -// Watch the ~/.claude directory (not the file — atomic renames swap the inode). +function hooksCurrent(port, token) { + return claudeHooksCurrent(port, token) && codexHooksCurrent(); +} + +// Watch the parent directories (not the files — atomic renames swap inodes). function startWatcher(getRuntime) { - let watcher = null; + const watchers = []; let debounce = null; - try { - fs.mkdirSync(SETTINGS_DIR, { recursive: true }); - watcher = fs.watch(SETTINGS_DIR, (_e, filename) => { - if (filename && filename !== 'settings.json') return; - if (debounce) clearTimeout(debounce); - debounce = setTimeout(() => { - const runtime = getRuntime(); - if (!runtime || !hooksCurrent(runtime.port, runtime.token)) { - log('hooks', 'settings.json lost or changed our hooks — re-registering'); - if (runtime) install(runtime.port, runtime.token); - } - }, 800); - if (debounce.unref) debounce.unref(); - }); - log('hooks', 'settings watcher started'); - } catch (err) { - log('hooks', 'watcher failed:', err.message); + const targets = [ + { dir: SETTINGS_DIR, file: path.basename(SETTINGS_PATH), label: 'Claude settings' }, + { dir: path.dirname(CODEX_HOOKS_PATH), file: path.basename(CODEX_HOOKS_PATH), label: 'Codex hooks' }, + ]; + + for (const target of targets) { + try { + fs.mkdirSync(target.dir, { recursive: true }); + const watcher = fs.watch(target.dir, (_e, filename) => { + if (filename && filename !== target.file) return; + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + const runtime = getRuntime(); + if (!runtime || !hooksCurrent(runtime.port, runtime.token)) { + log('hooks', `${target.label} lost or changed LLMPET hooks — re-registering`); + if (runtime) install(runtime.port, runtime.token); + } + }, 800); + if (debounce.unref) debounce.unref(); + }); + watchers.push(watcher); + } catch (err) { + log('hooks', `${target.label} watcher failed:`, err.message); + } } - return () => { if (watcher) { try { watcher.close(); } catch {} } }; + if (watchers.length) log('hooks', 'Claude/Codex settings watchers started'); + return () => { + if (debounce) clearTimeout(debounce); + for (const watcher of watchers) { + try { watcher.close(); } catch {} + } + }; } -module.exports = { install, uninstall, startWatcher, hooksCurrent, SETTINGS_PATH }; +module.exports = { install, uninstall, startWatcher, hooksCurrent, SETTINGS_PATH, CODEX_HOOKS_PATH }; diff --git a/backend/server.js b/backend/server.js index 66f1a33..1c974be 100644 --- a/backend/server.js +++ b/backend/server.js @@ -208,7 +208,8 @@ function createServer(deps) { terminalApp: normTerminalApp(data.terminal_app), terminalTty: normTerminalTty(data.terminal_tty), ghosttyTerminalId: typeof data.ghostty_terminal_id === 'string' && data.ghostty_terminal_id.trim() ? data.ghostty_terminal_id.trim() : null, - agentId: 'claude-code', + agentId: data.agent_id === 'codex' ? 'codex' : 'claude-code', + eventSource: data.event_source === 'codex-hook' ? 'codex-hook' : null, headless: data.headless === true, externalResume: data.external_resume === true, transcriptPath: normTranscriptPath(data.transcript_path), diff --git a/backend/window-drag.js b/backend/window-drag.js new file mode 100644 index 0000000..5bc8e5c --- /dev/null +++ b/backend/window-drag.js @@ -0,0 +1,40 @@ +'use strict'; + +function finitePoint(point) { + return point && Number.isFinite(point.x) && Number.isFinite(point.y); +} + +function beginDrag(windowBounds, cursorPoint, intendedSize) { + if (!finitePoint(windowBounds) || !finitePoint(cursorPoint)) { + throw new TypeError('window bounds and cursor point must be finite'); + } + if (!intendedSize || !Number.isFinite(intendedSize.width) || !Number.isFinite(intendedSize.height)) { + throw new TypeError('intended window size must be finite'); + } + return { + originX: windowBounds.x, + originY: windowBounds.y, + cursorX: cursorPoint.x, + cursorY: cursorPoint.y, + width: Math.max(1, Math.round(intendedSize.width)), + height: Math.max(1, Math.round(intendedSize.height)), + lastX: null, + lastY: null, + }; +} + +// Electron reports cursor points and BrowserWindow bounds in DIP coordinates. +// Re-submit the intended logical size instead of recycling getBounds() dimensions: +// on Windows with a fractional display scale, the rounded dimensions can grow by +// one pixel every time they pass through setBounds(). +function nextDragBounds(drag, cursorPoint) { + if (!drag || !finitePoint(cursorPoint)) return null; + const x = Math.round(drag.originX + cursorPoint.x - drag.cursorX); + const y = Math.round(drag.originY + cursorPoint.y - drag.cursorY); + if (x === drag.lastX && y === drag.lastY) return null; + drag.lastX = x; + drag.lastY = y; + return { x, y, width: drag.width, height: drag.height }; +} + +module.exports = { beginDrag, nextDragBounds }; diff --git a/hook/octopus-hook.js b/hook/octopus-hook.js index a4cefed..bcd9c2e 100644 --- a/hook/octopus-hook.js +++ b/hook/octopus-hook.js @@ -1,7 +1,9 @@ #!/usr/bin/env node 'use strict'; -// LLMPET compatibility hook — run by Claude Code as: node octopus-hook.js +// LLMPET compatibility hook: +// Claude Code: node octopus-hook.js +// Codex: node octopus-hook.js codex // // Reads the hook's stdin JSON, derives a pet state, enriches it from the session // transcript (Claude's last message, context usage, API errors, title), figures @@ -19,6 +21,7 @@ const EVENT_STATE = { UserPromptSubmit: 'thinking', PreToolUse: 'working', PostToolUse: 'working', + PermissionRequest: 'notification', PostToolUseFailure: 'error', Stop: 'attention', StopFailure: 'error', @@ -51,9 +54,10 @@ function readStdin() { function count(v) { return Array.isArray(v) ? v.length : 0; } -function buildBody(event, p) { +function buildBody(event, p, source = 'claude') { let state = EVENT_STATE[event]; if (!state) return null; + const isCodex = source === 'codex'; // A subagent launch may surface as PreToolUse(Task) without SubagentStart. if (event === 'PreToolUse' && p.tool_name === 'Task') state = 'juggling'; // /clear shows up as SessionEnd(source=clear) → context sweep, not sleep. @@ -66,12 +70,18 @@ function buildBody(event, p) { if (typeof p.session_id !== 'string' || !p.session_id) return null; const sid = p.session_id; const body = { state, event, session_id: sid }; + if (isCodex) { + body.agent_id = 'codex'; + body.event_source = 'codex-hook'; + } if (typeof p.cwd === 'string' && p.cwd) body.cwd = p.cwd; // Forward CC's real transcript path so the server never has to re-derive the // encoded project dir (its /._→- guess missed '_', breaking the 10s poll for // ~30% of projects: interrupt/API-error/context refresh went silent there). if (typeof p.transcript_path === 'string' && p.transcript_path) body.transcript_path = p.transcript_path; - if (typeof p.tool_name === 'string' && p.tool_name) body.tool_name = p.tool_name; + if (typeof p.tool_name === 'string' && p.tool_name) { + body.tool_name = isCodex && p.tool_name === 'apply_patch' ? 'Edit' : p.tool_name; + } if (typeof p.model === 'string' && p.model) body.model = p.model; if (p.stop_hook_active === true) body.stop_hook_active = true; // StopFailure carries the API/server error kind (CC 2.1.x enum: server_error, @@ -84,7 +94,10 @@ function buildBody(event, p) { body.session_crons_count = count(p.session_crons); // Transcript-derived enrichment (read the tail once). - const entries = transcript.readTail(p.transcript_path); + // Codex explicitly does not guarantee its transcript wire format. Its hooks + // already provide the stable fields LLMPET needs, so only Claude uses the + // transcript enrichment parser. + const entries = isCodex ? null : transcript.readTail(p.transcript_path); // SessionStart 来源(startup/resume/clear/compact):只有 startup 是真·新对话, // resume/compact 进入已有任务不该触发「新会话欢迎」。有的环境(ccd)不带 @@ -112,6 +125,9 @@ function buildBody(event, p) { } } } + if (isCodex && event === 'Stop' && typeof p.last_assistant_message === 'string' && p.last_assistant_message.trim()) { + body.assistant_last_output = p.last_assistant_message; + } if (!body.session_title && event === 'UserPromptSubmit') { const pt = transcript.promptTitle(p.prompt); if (pt) body.session_title = pt; @@ -135,7 +151,7 @@ function buildBody(event, p) { if (memeResume) { body.headless = false; body.external_resume = true; - } else if (FOCUS_EVENTS.has(event)) { + } else if (!isCodex && FOCUS_EVENTS.has(event)) { try { const r = pidwalk.resolve(process.ppid, 10, sid); if (r.sourcePid) body.source_pid = r.sourcePid; @@ -153,10 +169,11 @@ function buildBody(event, p) { function main() { const event = process.argv[2]; + const source = process.argv[3] === 'codex' ? 'codex' : 'claude'; if (!EVENT_STATE[event]) process.exit(0); readStdin().then((payload) => { let body; - try { body = buildBody(event, payload || {}); } catch { body = null; } + try { body = buildBody(event, payload || {}, source); } catch { body = null; } if (!body) process.exit(0); transport.postState(body, () => process.exit(0)); setTimeout(() => process.exit(0), 250); // never hang Claude Code diff --git a/main.js b/main.js index 490c06a..6397ee6 100644 --- a/main.js +++ b/main.js @@ -30,11 +30,13 @@ const { focusSession } = require('./backend/focus'); const { createTerritory, DEFAULT_RIVALS } = require('./backend/territory'); const { launchClaude, launchCodex, findCli } = require('./backend/launch'); const { createCodexWatch } = require('./backend/codex-watch'); +const { createCodexRateLimits } = require('./backend/codex-rate-limits'); const { createCodexMetering } = require('./backend/codex-metering'); const { createTravelManager } = require('./backend/travel'); const { machineGrowth } = require('./backend/growth'); const { publicCatalog, getMeme, watchCatalog } = require('./backend/meme-catalog'); const { createCommandDispatcher, routeForSession } = require('./backend/command-dispatch'); +const { beginDrag, nextDragBounds } = require('./backend/window-drag'); const transport = require('./backend/transport'); const i18n = require('./shared/i18n'); @@ -59,11 +61,14 @@ let server = null; let stopWatcher = null; let territory = null; let codexWatch = null; // Codex rollout 只读监听器 +let codexRateLimitClient = null; // Codex App Server /status 配额读取器 let codexMetering = null; // Codex rollout 累计 token 台账(与状态 watcher 解耦) let travelManager = null; // 独立只读旅行任务 + 明信片/成长台账 let commandDispatcher = null; let stopMemeWatcher = null; let codexLimits = null; // Codex 5h/周窗口配额(token_count 的 rate_limits) +let codexAppServerLimitsAt = 0; // App Server 新数据优先;过期后允许 rollout 接棒 +let codexLimitsLogSig = ''; let petGuided = false; // 领地模式在带宠物走位:期间不把程序性移动当成用户拖拽持久化 let petFrameGuided = false; // CoreGraphics 逐帧拖动期间的同步跟随 // 巡视拖拽期间主宠强制穿透,renderer 不得抢回鼠标(uiBusy / visualRect / @@ -76,7 +81,7 @@ const petState = new Map(); // id → { agent, win, customSize, visualRect, uiBu const petStates = () => [...petState.values()].filter((s) => s.win && !s.win.isDestroyed()); const stateOfSender = (sender) => petState.get(sender.id) || null; const primaryPetState = () => (petWin && !petWin.isDestroyed() ? petState.get(petWin.webContents.id) : null); -const anyUiBusy = () => petStates().some((s) => s.uiBusy); +const anyUiBusy = () => petStates().some((s) => s.uiBusy || s.drag); const primaryVisualRect = () => { const st = primaryPetState(); return st ? st.visualRect : null; }; let lastStats = null; // 全量快照(面板用;single 模式也是主宠的快照) @@ -100,6 +105,7 @@ function frontendConfig(agent = 'all') { territorySupported: process.platform === 'darwin' && agent !== 'codex', agent, petMode: c.petMode, + codexChipMode: c.codexChipMode, lang: c.lang, pinnedSessions: c.pinnedSessions, archivedSessions: c.archivedSessions, @@ -119,6 +125,7 @@ function targetSize(st) { function applyPetSize(st) { if (!st || !st.win || st.win.isDestroyed()) return; + if (st.drag) { st.resizeAfterDrag = true; return; } const win = st.win; const { w } = targetSize(st); let { h } = targetSize(st); @@ -190,7 +197,10 @@ function makePetWindow(agent) { win.loadFile(path.join(__dirname, 'renderer', 'pet.html'), { query: { agent } }); // mouseIgnoring=true:透明窗启动即穿透,renderer 命中测试后再接管(pet.js 同款默认) - const st = { agent, win, customSize: null, visualRect: null, uiBusy: false, mouseIgnoring: true }; + const st = { + agent, win, customSize: null, visualRect: null, uiBusy: false, mouseIgnoring: true, + drag: null, resizeAfterDrag: false, + }; // 'closed' 之后绝不能再碰 win.webContents(抛 "Object has been destroyed",主进程 // 未捕获直接崩)——id 在创建时取好。收起一只宠是独立事件,只清自己的状态。 const wcId = win.webContents.id; @@ -573,6 +583,24 @@ function scheduleEmit() { emitDebounce = setTimeout(() => { emitDebounce = null; emitStats(); }, 150); } +function updateCodexLimits(limits, source) { + if (!limits) return; + const now = Date.now(); + if (source === 'app-server') { + codexAppServerLimitsAt = now; + } else if (codexAppServerLimitsAt && now - codexAppServerLimitsAt < 10 * 60 * 1000) { + return; + } + codexLimits = { ...limits, source }; + const sig = [source, codexLimits.usedPercent, codexLimits.windowMinutes, + codexLimits.secondaryUsedPercent, codexLimits.secondaryWindowMinutes].join(':'); + if (sig !== codexLimitsLogSig) { + codexLimitsLogSig = sig; + log('codex-rate', `quota source=${source} primary=${codexLimits.usedPercent ?? '-'}@${codexLimits.windowMinutes ?? '-'}m secondary=${codexLimits.secondaryUsedPercent ?? '-'}@${codexLimits.secondaryWindowMinutes ?? '-'}m`); + } + scheduleEmit(); +} + function broadcastConfig() { for (const st of petStates()) sendWin(st.win, 'pet:config', frontendConfig(st.agent)); sendPanel('panel:config', frontendConfig('all')); @@ -627,7 +655,8 @@ function bootBackend() { openClaudeThread: (sessionId) => shell.openExternal(`claude://claude.ai/epitaxy/${encodeURIComponent(sessionId)}`), }); - // Codex 后端:只读监听 ~/.codex/sessions 的 rollout(无钩子、零侵入)。 + // Codex backend: documented lifecycle hooks provide live activity; the + // rollout watcher remains a read-only fallback for older Codex builds. // LLMPET_NO_CODEX=1 关闭(比如只想盯 Claude 的机器)。 if (process.env.LLMPET_NO_CODEX === '1') { log('main', 'LLMPET_NO_CODEX=1 — Codex watcher disabled'); @@ -640,9 +669,17 @@ function bootBackend() { core, // 开发/E2E 可用 LLMPET_CODEX_DIR 指到假目录,不碰真实 ~/.codex sessionsDir: process.env.LLMPET_CODEX_DIR || undefined, - onRateLimits: (rl) => { codexLimits = rl; scheduleEmit(); }, + onRateLimits: (rl) => updateCodexLimits(rl, 'rollout'), }); codexWatch.start(); + // New Codex builds expose the same quota data as /status through the + // documented App Server API. Keep rollout parsing above as the fallback. + if (process.env.OCTOPUS_NO_NET !== '1') { + codexRateLimitClient = createCodexRateLimits({ + onRateLimits: (rl) => updateCodexLimits(rl, 'app-server'), + }); + codexRateLimitClient.start(); + } } metering = createMetering(); @@ -726,10 +763,10 @@ function bootBackend() { server.start(); // Install hooks once the server has a port (defer so listen wins the race). - // OCTOPUS_NO_HOOKS=1 skips touching ~/.claude/settings.json (dev/verify mode). + // OCTOPUS_NO_HOOKS=1 skips touching Claude and Codex hook config (dev/verify mode). setTimeout(() => { if (process.env.OCTOPUS_NO_HOOKS === '1') { - log('main', 'OCTOPUS_NO_HOOKS=1 — skipping Claude Code hook install'); + log('main', 'OCTOPUS_NO_HOOKS=1 — skipping Claude/Codex hook install'); return; } const port = server.getPort(); @@ -775,19 +812,36 @@ function registerIpc() { if (agent === 'all') return lastStats || buildStats(); return buildStats(agent); }); - ipcMain.handle('get-win-pos', (e) => { - const win = senderPetWin(e); - if (!win) return [0, 0]; - const b = win.getBounds(); - return [b.x, b.y]; + ipcMain.on('begin-win-drag', (e) => { + const st = stateOfSender(e.sender); + if (!st || !st.win || st.win.isDestroyed()) return; + const b = st.win.getBounds(); + const size = targetSize(st); + try { + size.h = Math.min(size.h, screen.getDisplayMatching(b).workArea.height); + } catch {} + st.drag = beginDrag( + b, + screen.getCursorScreenPoint(), + { width: size.w, height: size.h }, + ); + st.resizeAfterDrag = false; }); - ipcMain.on('set-win-pos', (e, x, y) => { - const win = senderPetWin(e); - if (win && Number.isFinite(x) && Number.isFinite(y)) { - const b = win.getBounds(); - win.setBounds({ x: Math.round(x), y: Math.round(y), width: b.width, height: b.height }); - } + ipcMain.on('update-win-drag', (e) => { + const st = stateOfSender(e.sender); + if (!st || !st.drag || !st.win || st.win.isDestroyed()) return; + const bounds = nextDragBounds(st.drag, screen.getCursorScreenPoint()); + if (bounds) st.win.setBounds(bounds); + }); + + ipcMain.on('end-win-drag', (e) => { + const st = stateOfSender(e.sender); + if (!st || !st.drag) return; + st.drag = null; + const resizeAfterDrag = st.resizeAfterDrag; + st.resizeAfterDrag = false; + if (resizeAfterDrag) applyPetSize(st); }); ipcMain.on('open-panel', openPanel); @@ -1055,6 +1109,13 @@ function applySkin(skin, agent) { broadcastConfig(); refreshTrayMenu(); } +function applyCodexChipMode(codexChipMode) { + if (!['usage', 'weeklyRemaining'].includes(codexChipMode)) return; + config.save({ codexChipMode }); + broadcastConfig(); + refreshTrayMenu(); + log('main', `codexChipMode → ${codexChipMode}`); +} // 补齐当前 petMode 应有的窗口(被单独收起的宠从托盘找回来)。主宠身份变化 // (all⇄claude)时原地重载渲染器——不销毁窗口,位置不动、Codex 宠不闪。 @@ -1133,6 +1194,7 @@ function refreshTrayMenu() { const budget = Number(cfg.budget5h) || 0; const petMode = cfg.petMode || 'single'; const skinCodex = cfg.skinCodex || 'cat'; + const codexChipMode = cfg.codexChipMode || 'usage'; const lang = cfg.lang || 'zh'; tray.setToolTip(t('tray.tooltip')); tray.setContextMenu(Menu.buildFromTemplate([ @@ -1156,6 +1218,10 @@ function refreshTrayMenu() { { label: t('skin.pixel'), type: 'radio', checked: skinCodex === 'pixel', click: () => applySkin('pixel', 'codex') }, { label: t('skin.cat'), type: 'radio', checked: skinCodex === 'cat', click: () => applySkin('cat', 'codex') }, ] }] : []), + ...(petMode === 'duo' ? [{ label: t('tray.codexChip'), submenu: [ + { label: t('tray.codexChipUsage'), type: 'radio', checked: codexChipMode === 'usage', click: () => applyCodexChipMode('usage') }, + { label: t('tray.codexChipWeekly'), type: 'radio', checked: codexChipMode === 'weeklyRemaining', click: () => applyCodexChipMode('weeklyRemaining') }, + ] }] : []), { label: t('tray.shape'), submenu: [ { label: t('shape.pet'), type: 'radio', checked: mode === 'pet', click: () => applyMode('pet') }, { label: t('shape.panel'), type: 'radio', checked: mode === 'panel', click: () => applyMode('panel') }, @@ -1262,6 +1328,7 @@ app.on('before-quit', () => { try { if (territory) territory.stop(); } catch {} try { if (travelManager) travelManager.shutdown(); } catch {} try { if (codexWatch) codexWatch.stop(); } catch {} + try { if (codexRateLimitClient) codexRateLimitClient.stop(); } catch {} try { if (stopMemeWatcher) stopMemeWatcher(); } catch {} try { if (stopWatcher) stopWatcher(); } catch {} try { if (permissions) permissions.cleanup(); } catch {} diff --git a/package.json b/package.json index 7961fe1..46f3f31 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "package:mac:dev": "LLMPET_MAC_SIGN_MODE=adhoc bash scripts/package-mac.sh", "verify:mac": "bash scripts/verify-mac-release.sh", "package:win": "electron-builder --win --publish never", - "test": "node test/smoke.js && node test/state-smoke.js && node test/pricing.js && node test/metering.js && node test/codex-metering.js && node test/travel.js && node test/session-preferences.js && node test/territory.js && node test/codex-watch.js && node test/codex-integration.js && node test/meme-actions.js && node test/i18n.js && node test/popup-style.js && node test/branding.js && node test/mac-release.js" + "test": "node test/smoke.js && node test/state-smoke.js && node test/pricing.js && node test/metering.js && node test/codex-metering.js && node test/travel.js && node test/session-preferences.js && node test/territory.js && node test/window-drag.js && node test/codex-watch.js && node test/codex-hooks.js && node test/codex-rate-limits.js && node test/codex-integration.js && node test/meme-actions.js && node test/i18n.js && node test/popup-style.js && node test/branding.js && node test/mac-release.js" }, "devDependencies": { "@electron/notarize": "2.5.0", diff --git a/preload.js b/preload.js index 9291f7c..6585c71 100644 --- a/preload.js +++ b/preload.js @@ -30,9 +30,10 @@ contextBridge.exposeInMainWorld('pet', { quit: () => ipcRenderer.send('quit-app'), // 双宠模式:只收起自己这只宠(独立事件,另一只和 app 不受影响) closePet: () => ipcRenderer.send('close-pet'), - // 手动拖动窗口 - getWinPos: () => ipcRenderer.invoke('get-win-pos'), - setWinPos: (x, y) => ipcRenderer.send('set-win-pos', x, y), + // Main process owns drag coordinates so DOM and BrowserWindow DPI spaces never mix. + beginWinDrag: () => ipcRenderer.send('begin-win-drag'), + updateWinDrag: () => ipcRenderer.send('update-win-drag'), + endWinDrag: () => ipcRenderer.send('end-win-drag'), // 唤起 Claude / Codex 客户端 launchClaude: () => ipcRenderer.send('launch-claude'), launchCodex: () => ipcRenderer.send('launch-codex'), diff --git a/renderer/pet.html b/renderer/pet.html index 52b1d43..8308534 100644 --- a/renderer/pet.html +++ b/renderer/pet.html @@ -91,7 +91,7 @@
$0.000 - · + · 5h $0.000
@@ -227,6 +227,7 @@ + diff --git a/renderer/pet.js b/renderer/pet.js index 6f4cfc9..71386b2 100644 --- a/renderer/pet.js +++ b/renderer/pet.js @@ -183,6 +183,7 @@ const bubble = document.getElementById('bubble'); const bubbleText = document.getElementById('bubble-text'); const chipCost = document.getElementById('chip-cost'); const chipWindow = document.getElementById('chip-window'); +const chipSep = document.getElementById('chip-sep'); const chip = document.getElementById('chip'); const sessionsEl = document.getElementById('sessions'); const radial = document.getElementById('radial'); @@ -1863,6 +1864,7 @@ let transientUntil = 0; // 短暂状态(happy/error)持续到的时间 let transientState = null; let muted = false; let skin = 'mascot'; +let codexChipMode = 'usage'; let lastWaiting = 0; let lastBgZombie = 0; // 后台疑似僵尸数 let radialOpen = false; @@ -2328,15 +2330,33 @@ function applyStats(s) { // Codex 没有逐 token 价目,额度条显示套餐窗口用量(5h 主窗口 + 周窗口) const rl = s.codexLimits; const today = s.codexUsage && s.codexUsage.today; - chipCost.textContent = today && today.tokens - ? compactTokens(today.tokens) + ' tok' - : 'Codex' + (rl && rl.planType ? ' ' + rl.planType : ''); - chipWindow.textContent = rl && rl.usedPercent != null - ? t('chip.quota', { pct: Math.round(rl.usedPercent) }) - + (rl.secondaryUsedPercent != null ? t('chip.weekly', { pct: Math.round(rl.secondaryUsedPercent) }) : '') - : t('chip.quotaNone'); - chipWindow.title = t('chip.codexTitle'); + if (codexChipMode === 'weeklyRemaining') { + const remaining = window.CodexRateLimits.weeklyRemainingPercent(rl); + chipCost.textContent = remaining == null + ? t('chip.weeklyRemainingUnavailable') + : t('chip.weeklyRemaining', { pct: remaining }); + chipCost.title = t('chip.codexTitle'); + chipSep.style.display = 'none'; + chipWindow.style.display = 'none'; + } else { + chipCost.textContent = today && today.tokens + ? compactTokens(today.tokens) + ' tok' + : 'Codex' + (rl && rl.planType ? ' ' + rl.planType : ''); + chipCost.title = ''; + chipSep.style.display = ''; + chipWindow.style.display = ''; + const primaryIsWeekly = rl && Number(rl.windowMinutes) >= 6 * 24 * 60; + chipWindow.textContent = rl && rl.usedPercent != null + ? (primaryIsWeekly + ? t('chip.weeklyOnly', { pct: Math.round(rl.usedPercent) }) + : t('chip.quota', { pct: Math.round(rl.usedPercent) }) + + (rl.secondaryUsedPercent != null ? t('chip.weekly', { pct: Math.round(rl.secondaryUsedPercent) }) : '')) + : t('chip.quotaNone'); + chipWindow.title = t('chip.codexTitle'); + } } else { + chipSep.style.display = ''; + chipWindow.style.display = ''; chipCost.textContent = '$' + (s.today.cost || 0).toFixed(3); chipWindow.textContent = '5h $' + (s.window5h.cost || 0).toFixed(3); } @@ -2420,9 +2440,11 @@ window.pet.onConfig((cfg) => { territorySupported = !!cfg.territorySupported; if (cfg.lang) applyLang(cfg.lang); if (cfg.skin) applySkin(cfg.skin); + codexChipMode = cfg.codexChipMode === 'weeklyRemaining' ? 'weeklyRemaining' : 'usage'; pinnedSessionIds = Array.isArray(cfg.pinnedSessions) ? cfg.pinnedSessions.slice() : []; archivedSessionIds = Array.isArray(cfg.archivedSessions) ? cfg.archivedSessions.slice() : []; if (sessListOpen && !memeTarget) renderSessList(); + if (lastStats && AGENT === 'codex') applyStats(lastStats); }); // Static markup carries its Chinese text inline (so the window is never blank @@ -2481,17 +2503,17 @@ function attachDrag(el) { if (e.button !== 0) return; try { el.setPointerCapture(e.pointerId); } catch {} el.classList.add('dragging'); - g = { el, pid: e.pointerId, sx: e.screenX, sy: e.screenY, moved: false, win: null }; - window.pet.getWinPos().then(([wx, wy]) => { if (g) g.win = [wx, wy]; }); + g = { el, pid: e.pointerId, sx: e.clientX, sy: e.clientY, moved: false }; + window.pet.beginWinDrag(); }); el.addEventListener('pointermove', (e) => { if (!g) return; - const dx = e.screenX - g.sx; - const dy = e.screenY - g.sy; + const dx = e.clientX - g.sx; + const dy = e.clientY - g.sy; if (!g.moved && Math.abs(dx) + Math.abs(dy) > 4) g.moved = true; - if (g.moved && g.win) { + if (g.moved) { if (radialOpen) closeRadial(); - window.pet.setWinPos(g.win[0] + dx, g.win[1] + dy); + window.pet.updateWinDrag(); } }); el.addEventListener('pointerup', () => { @@ -2500,6 +2522,7 @@ function attachDrag(el) { try { el.releasePointerCapture(g.pid); } catch {} el.classList.remove('dragging'); g = null; + window.pet.endWinDrag(); if (!wasMove) { // 左键短按 = 会话列表 HUD(状态/会话名/上下文用量一览,点行聚焦该会话)。 // 权限的允许/拒绝仍由 waiting 事件自动弹气泡,不走这里。 @@ -2507,7 +2530,11 @@ function attachDrag(el) { else toggleSessList(); } }); - el.addEventListener('pointercancel', () => { if (g) el.classList.remove('dragging'); g = null; }); + el.addEventListener('pointercancel', () => { + if (g) el.classList.remove('dragging'); + g = null; + window.pet.endWinDrag(); + }); // 右键 = 泡泡菜单 el.addEventListener('contextmenu', (e) => { e.preventDefault(); @@ -2780,6 +2807,7 @@ window.addEventListener('blur', () => { if (radialOpen) closeRadial(); }); territorySupported = !!cfg.territorySupported; window.OctoI18n.setLang(cfg.lang || 'zh'); applySkin(cfg.skin || 'mascot'); + codexChipMode = cfg.codexChipMode === 'weeklyRemaining' ? 'weeklyRemaining' : 'usage'; } applyStaticI18n(); await loadMemeCatalog(); diff --git a/shared/codex-rate-limits.js b/shared/codex-rate-limits.js new file mode 100644 index 0000000..b6c126e --- /dev/null +++ b/shared/codex-rate-limits.js @@ -0,0 +1,79 @@ +'use strict'; + +// Shared normalization for Codex App Server quota responses. The main process +// consumes the normalized object; the renderer only needs the weekly remainder. +(function (root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (root) root.CodexRateLimits = api; +})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function () { + function finite(...values) { + for (const value of values) { + if (value == null || value === '') continue; + const n = Number(value); + if (Number.isFinite(n)) return n; + } + return null; + } + + function pickBucket(payload) { + if (!payload || typeof payload !== 'object') return null; + if (payload.rateLimits && typeof payload.rateLimits === 'object') return payload.rateLimits; + if (payload.rate_limits && typeof payload.rate_limits === 'object') return payload.rate_limits; + const byId = payload.rateLimitsByLimitId || payload.rate_limits_by_limit_id; + if (byId && typeof byId === 'object') { + if (byId.codex && typeof byId.codex === 'object') return byId.codex; + const first = Object.values(byId).find((value) => value && typeof value === 'object'); + if (first) return first; + } + return payload.primary || payload.secondary ? payload : null; + } + + function normalizeAppServerRateLimits(payload, now = Date.now()) { + const bucket = pickBucket(payload); + if (!bucket) return null; + const primary = bucket.primary || {}; + const secondary = bucket.secondary || {}; + const out = { ts: now, source: 'app-server' }; + const primaryUsed = finite(primary.usedPercent, primary.used_percent); + const secondaryUsed = finite(secondary.usedPercent, secondary.used_percent); + if (primaryUsed != null) { + out.usedPercent = primaryUsed; + out.windowMinutes = finite(primary.windowDurationMins, primary.window_minutes); + const reset = finite(primary.resetsAt, primary.resets_at); + out.resetsAt = reset == null ? null : reset * 1000; + } + if (secondaryUsed != null) { + out.secondaryUsedPercent = secondaryUsed; + out.secondaryWindowMinutes = finite(secondary.windowDurationMins, secondary.window_minutes); + const reset = finite(secondary.resetsAt, secondary.resets_at); + out.secondaryResetsAt = reset == null ? null : reset * 1000; + } + const planType = bucket.planType || bucket.plan_type || payload.planType || payload.plan_type; + if (typeof planType === 'string' && planType) out.planType = planType; + return out.usedPercent != null || out.secondaryUsedPercent != null ? out : null; + } + + function weeklyUsedPercent(limits) { + if (!limits) return null; + const primary = finite(limits.usedPercent); + const secondary = finite(limits.secondaryUsedPercent); + const primaryWindow = finite(limits.windowMinutes); + const secondaryWindow = finite(limits.secondaryWindowMinutes); + const weeklyWindow = 6 * 24 * 60; + // Current Codex plans can expose the seven-day bucket as primary with no + // secondary bucket; older Plus responses commonly put it in secondary. + if (secondary != null && secondaryWindow != null && secondaryWindow >= weeklyWindow) return secondary; + if (primary != null && primaryWindow != null && primaryWindow >= weeklyWindow) return primary; + if (secondary != null) return secondary; // legacy payloads without duration + return null; + } + + function weeklyRemainingPercent(limits) { + const used = weeklyUsedPercent(limits); + if (used == null) return null; + return Math.round(100 - Math.max(0, Math.min(100, used))); + } + + return { normalizeAppServerRateLimits, weeklyUsedPercent, weeklyRemainingPercent }; +}); diff --git a/shared/i18n.js b/shared/i18n.js index b4f5b82..8806822 100644 --- a/shared/i18n.js +++ b/shared/i18n.js @@ -32,6 +32,9 @@ 'tray.skin': ' 形象', 'tray.skinClaude': ' 形象(Claude 宠)', 'tray.skinCodex': ' 形象(Codex 宠)', + 'tray.codexChip': ' Codex 棕色统计框', + 'tray.codexChipUsage': '显示默认用量统计', + 'tray.codexChipWeekly': '显示 Weekly 剩余', 'tray.shape': ' 形态', 'tray.budget': ' 5h 预算', 'tray.language': ' 🌐 语言 / Language', @@ -311,6 +314,9 @@ // ── quota chip ────────────────────────────────────────────────────────── 'chip.quota': '5h 额度 {pct}%', 'chip.weekly': ' · 周 {pct}%', + 'chip.weeklyOnly': '周额度 {pct}%', + 'chip.weeklyRemaining': 'Weekly 剩余 {pct}%', + 'chip.weeklyRemainingUnavailable': 'Weekly 剩余 —%', 'chip.quotaNone': '额度 --', 'chip.codexTitle': 'Codex 套餐窗口用量(来自 rollout 的 rate_limits)', 'chip.windowTitle': '近5小时消耗', @@ -419,6 +425,9 @@ 'tray.skin': ' Skin', 'tray.skinClaude': ' Skin (Claude pet)', 'tray.skinCodex': ' Skin (Codex pet)', + 'tray.codexChip': ' Codex brown stats box', + 'tray.codexChipUsage': 'Show default usage stats', + 'tray.codexChipWeekly': 'Show weekly remaining', 'tray.shape': ' Layout', 'tray.budget': ' 5h budget', 'tray.language': ' 🌐 Language / 语言', @@ -684,6 +693,9 @@ 'chip.quota': '5h quota {pct}%', 'chip.weekly': ' · wk {pct}%', + 'chip.weeklyOnly': 'weekly {pct}%', + 'chip.weeklyRemaining': 'Weekly left {pct}%', + 'chip.weeklyRemainingUnavailable': 'Weekly left —%', 'chip.quotaNone': 'quota --', 'chip.codexTitle': 'Codex plan window usage (from rollout rate_limits)', 'chip.windowTitle': 'Spend in the last 5 hours', @@ -789,6 +801,9 @@ 'tray.skin': ' 見た目', 'tray.skinClaude': ' 見た目(Claude ペット)', 'tray.skinCodex': ' 見た目(Codex ペット)', + 'tray.codexChip': ' Codex の茶色い統計欄', + 'tray.codexChipUsage': '標準の使用量を表示', + 'tray.codexChipWeekly': '週間残量を表示', 'tray.shape': ' 表示形式', 'tray.budget': ' 5時間の予算', 'tray.language': ' 🌐 言語 / Language', @@ -1054,6 +1069,9 @@ 'chip.quota': '5h 使用量 {pct}%', 'chip.weekly': ' · 週 {pct}%', + 'chip.weeklyOnly': '週の使用量 {pct}%', + 'chip.weeklyRemaining': '週残り {pct}%', + 'chip.weeklyRemainingUnavailable': '週残り —%', 'chip.quotaNone': '使用量 --', 'chip.codexTitle': 'Codex プランの窓口使用量(rollout の rate_limits より)', 'chip.windowTitle': '直近5時間の消費', diff --git a/test/codex-hooks.js b/test/codex-hooks.js new file mode 100644 index 0000000..2fe845d --- /dev/null +++ b/test/codex-hooks.js @@ -0,0 +1,102 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + registerCodexHooks, + unregisterCodexHooks, + codexHooksCurrent, + CODEX_EVENTS, +} = require('../backend/codex-hookinstall'); +const { buildBody } = require('../hook/octopus-hook'); +const { createCore } = require('../backend/core'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llmpet-codex-hooks-')); +const hooksPath = path.join(tmp, 'hooks.json'); +const nodeBin = process.platform === 'win32' ? 'C:\\Program Files\\nodejs\\node.exe' : '/usr/bin/node'; +const platform = process.platform === 'win32' ? 'win32' : 'linux'; +const otherCommand = '"C:\\Tools\\OtherPet.Hook.exe"'; + +fs.writeFileSync(hooksPath, JSON.stringify({ + description: 'User lifecycle hooks.', + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: otherCommand, timeout: 1 }] }], + }, +}, null, 2)); + +const first = registerCodexHooks({ hooksPath, nodeBin, platform }); +assert.strictEqual(first.added, CODEX_EVENTS.length); +let config = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); +assert.strictEqual(config.description, 'User lifecycle hooks.'); +assert(config.hooks.SessionStart.some((group) => + group.hooks.some((hook) => hook.command === otherCommand)), 'unrelated Codex hooks must be preserved'); +for (const event of CODEX_EVENTS) { + const ours = config.hooks[event].flatMap((group) => group.hooks || []) + .filter((hook) => String(hook.command || '').includes('octopus-hook.js')); + assert.strictEqual(ours.length, 1, `${event} must contain exactly one LLMPET hook`); + assert(ours[0].command.endsWith(`${event} codex`), `${event} must identify Codex as the source`); + if (platform === 'win32') { + assert.strictEqual(ours[0].commandWindows, `& ${ours[0].command}`); + assert(ours[0].commandWindows.startsWith('& "C:\\Program Files\\nodejs\\node.exe"'), + 'PowerShell commandWindows must invoke a quoted executable with the call operator'); + } +} + +const installed = fs.readFileSync(hooksPath, 'utf8'); +const second = registerCodexHooks({ hooksPath, nodeBin, platform }); +assert.strictEqual(second.skipped, CODEX_EVENTS.length, 'a current config must not churn hook trust hashes'); +assert.strictEqual(fs.readFileSync(hooksPath, 'utf8'), installed, 'idempotent install must not rewrite hooks.json'); +assert.strictEqual(codexHooksCurrent({ hooksPath, nodeBin, platform }), true); + +const prompt = buildBody('UserPromptSubmit', { + session_id: 'codex-session', + cwd: 'C:\\work\\repo', + prompt: 'Fix the watcher', + model: 'gpt-test', +}, 'codex'); +assert.strictEqual(prompt.agent_id, 'codex'); +assert.strictEqual(prompt.event_source, 'codex-hook'); +assert.strictEqual(prompt.state, 'thinking'); +assert.strictEqual(prompt.session_title, 'Fix the watcher'); + +const tool = buildBody('PreToolUse', { + session_id: 'codex-session', + tool_name: 'apply_patch', +}, 'codex'); +assert.strictEqual(tool.tool_name, 'Edit'); + +const stop = buildBody('Stop', { + session_id: 'codex-session', + last_assistant_message: 'Implemented and tested.', +}, 'codex'); +assert.strictEqual(stop.assistant_last_output, 'Implemented and tested.'); + +const permission = buildBody('PermissionRequest', { session_id: 'codex-session' }, 'codex'); +assert.strictEqual(permission.state, 'notification'); + +const activities = []; +const core = createCore({ onActivity: (activity) => activities.push(activity) }); +core.updateSession('dedupe', 'working', 'PreToolUse', { + agentId: 'codex', eventSource: 'codex-hook', toolName: 'Bash', +}); +core.updateSession('dedupe', 'working', 'PreToolUse', { + agentId: 'codex', eventSource: 'codex-rollout', toolName: 'Bash', +}); +assert.strictEqual(activities.length, 1, 'hook + rollout copies must emit one activity'); +core.updateSession('dedupe', 'working', 'PreToolUse', { + agentId: 'codex', eventSource: 'codex-hook', toolName: 'Bash', +}); +assert.strictEqual(activities.length, 2, 'a repeated event from one source must remain visible'); + +const removed = unregisterCodexHooks({ hooksPath, backup: true }); +assert.strictEqual(removed.removed, CODEX_EVENTS.length); +assert(removed.backupPath && fs.existsSync(removed.backupPath), 'uninstall must back up hooks.json'); +config = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); +assert(config.hooks.SessionStart.some((group) => + group.hooks.some((hook) => hook.command === otherCommand)), 'uninstall must retain unrelated hooks'); +assert.strictEqual(codexHooksCurrent({ hooksPath, nodeBin, platform }), false); + +fs.rmSync(tmp, { recursive: true, force: true }); +console.log('codex hook checks passed'); diff --git a/test/codex-integration.js b/test/codex-integration.js index 3501c46..cb92451 100644 --- a/test/codex-integration.js +++ b/test/codex-integration.js @@ -10,21 +10,31 @@ const pkg = JSON.parse(read('package.json')); const main = read('main.js'); const preload = read('preload.js'); const config = read('backend/config.js'); +const server = read('backend/server.js'); +const hook = read('hook/octopus-hook.js'); const readme = read('README.md'); assert(fs.existsSync(path.join(root, 'backend/codex-watch.js')), 'Codex watcher must ship with the app'); +assert(fs.existsSync(path.join(root, 'backend/codex-hookinstall.js')), 'Codex hook installer must ship with the app'); +assert(fs.existsSync(path.join(root, 'backend/codex-rate-limits.js')), 'Codex App Server quota reader must ship with the app'); assert(fs.existsSync(path.join(root, 'test/codex-watch.js')), 'Codex watcher regression tests must remain in the suite'); assert(/require\('\.\/backend\/codex-watch'\)/.test(main), 'main process must load the Codex watcher'); assert(/codexWatch\s*=\s*createCodexWatch\(/.test(main), 'main process must create the Codex watcher'); assert(/codexWatch\.start\(\)/.test(main), 'main process must start the Codex watcher'); +assert(/codexRateLimitClient\.start\(\)/.test(main), 'main process must start the Codex quota reader'); assert(/if \(codexWatch\) codexWatch\.stop\(\)/.test(main), 'app shutdown must stop the Codex watcher'); +assert(/data\.agent_id === 'codex' \? 'codex' : 'claude-code'/.test(server), 'Codex hook events must retain their agent identity'); +assert(/body\.event_source = 'codex-hook'/.test(hook), 'Codex hook events must identify their source for deduplication'); assert(/function sendPetEvent\(ev\)/.test(main) && /ev\.agent === 'codex'/.test(main), 'Codex events must route to the Codex pet in duo mode'); assert(/function createPetWindows\(\)/.test(main) && /makePetWindow\('codex'\)/.test(main), 'duo mode must create an independent Codex pet'); assert(/petMode: 'single'/.test(config) && /skinCodex: 'cat'/.test(config), 'Codex pet settings must have safe defaults'); +assert(/codexChipMode: 'usage'/.test(config), 'Codex brown stats box must preserve the existing default'); assert(/launchCodex: \(\) => ipcRenderer\.send\('launch-codex'\)/.test(preload), 'renderer must be able to launch Codex'); assert(/closePet: \(\) => ipcRenderer\.send\('close-pet'\)/.test(preload), 'a duo pet must be independently closable'); assert(/Claude Code \/ Codex/.test(readme) && /Codex 后端/.test(readme), 'public documentation must describe Codex support'); assert(pkg.scripts.test.includes('test/codex-watch.js'), 'npm test must execute Codex watcher tests'); +assert(pkg.scripts.test.includes('test/codex-hooks.js'), 'npm test must execute Codex hook tests'); +assert(pkg.scripts.test.includes('test/codex-rate-limits.js'), 'npm test must execute Codex rate-limit tests'); assert(pkg.scripts.test.includes('test/codex-integration.js'), 'npm test must execute the Codex integration contract'); console.log('codex integration checks passed'); diff --git a/test/codex-rate-limits.js b/test/codex-rate-limits.js new file mode 100644 index 0000000..80544ad --- /dev/null +++ b/test/codex-rate-limits.js @@ -0,0 +1,134 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { EventEmitter } = require('events'); +const { PassThrough } = require('stream'); +const { sanitize } = require('../backend/config'); +const { loadRenderer } = require('./dom-stub'); +const { createCodexRateLimits } = require('../backend/codex-rate-limits'); +const { + normalizeAppServerRateLimits, + weeklyRemainingPercent, +} = require('../shared/codex-rate-limits'); + +const normalized = normalizeAppServerRateLimits({ + rateLimitsByLimitId: { + codex: { + primary: { usedPercent: 21.4, windowDurationMins: 300, resetsAt: 100 }, + secondary: { usedPercent: 37.6, windowDurationMins: 10080, resetsAt: 200 }, + planType: 'plus', + }, + }, +}, 1234); +assert.deepStrictEqual(normalized, { + ts: 1234, + source: 'app-server', + usedPercent: 21.4, + windowMinutes: 300, + resetsAt: 100000, + secondaryUsedPercent: 37.6, + secondaryWindowMinutes: 10080, + secondaryResetsAt: 200000, + planType: 'plus', +}); +assert.strictEqual(weeklyRemainingPercent(normalized), 62); +assert.strictEqual(weeklyRemainingPercent({ usedPercent: 44, windowMinutes: 10080 }), 56); +assert.strictEqual(weeklyRemainingPercent({ usedPercent: 44, windowMinutes: 300 }), null); +assert.strictEqual(weeklyRemainingPercent({ secondaryUsedPercent: -5 }), 100); +assert.strictEqual(weeklyRemainingPercent({ secondaryUsedPercent: 105 }), 0); +assert.strictEqual(weeklyRemainingPercent(null), null); + +assert.strictEqual(sanitize({ codexChipMode: 'weeklyRemaining' }).codexChipMode, 'weeklyRemaining'); +assert.strictEqual(sanitize({ codexChipMode: 'invalid' }).codexChipMode, 'usage'); +assert.strictEqual(sanitize({ codexTagMode: 'weeklyRemaining' }).codexChipMode, 'weeklyRemaining'); + +const renderer = loadRenderer([ + 'shared/i18n.js', + 'shared/states.js', + 'shared/codex-rate-limits.js', + 'renderer/icons.js', + 'renderer/pet.js', +], { search: '?agent=codex' }); +renderer.handlers.config({ skin: 'cat', muted: true, lang: 'zh', codexChipMode: 'weeklyRemaining' }); +renderer.handlers.stats({ + today: { cost: 0 }, window5h: { cost: 0 }, codexUsage: { today: { tokens: 1234 } }, + codexLimits: { usedPercent: 45, windowMinutes: 10080 }, sessions: [], bg: { zombie: 0 }, + waitingCount: 0, needsinputCount: 0, workingCount: 0, jugglingCount: 0, + sweepingCount: 0, thinkingCount: 0, loafingCount: 0, errorCount: 0, idleMs: 1000, +}); +assert(renderer.elements('agent-tag').innerHTML.includes('Codex'), 'blue agent tag must keep the Codex name'); +assert(!renderer.elements('agent-tag').innerHTML.includes('Weekly'), 'weekly text must not replace the blue agent tag'); +assert.strictEqual(renderer.elements('chip-cost').textContent, 'Weekly 剩余 55%'); +assert.strictEqual(renderer.elements('chip-sep').style.display, 'none'); +assert.strictEqual(renderer.elements('chip-window').style.display, 'none'); +renderer.handlers.config({ skin: 'cat', muted: true, lang: 'zh', codexChipMode: 'usage' }); +assert.strictEqual(renderer.elements('chip-sep').style.display, ''); +assert.strictEqual(renderer.elements('chip-window').style.display, ''); + +async function integrationCheck() { + const writes = []; + let fake; + const spawnImpl = () => { + fake = new EventEmitter(); + fake.stdout = new PassThrough(); + fake.stderr = new PassThrough(); + fake.stdin = { + destroyed: false, + writable: true, + write(line) { + const message = JSON.parse(line); + writes.push(message); + if (message.method === 'initialize') { + setImmediate(() => fake.stdout.write(JSON.stringify({ id: message.id, result: {} }) + '\n')); + } else if (message.method === 'account/rateLimits/read') { + setImmediate(() => fake.stdout.write(JSON.stringify({ + id: message.id, + result: { + rateLimits: { + primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 100 }, + secondary: { usedPercent: 25, windowDurationMins: 10080, resetsAt: 200 }, + }, + }, + }) + '\n')); + } + return true; + }, + }; + fake.kill = () => { fake.emit('close', 0); return true; }; + setImmediate(() => fake.emit('spawn')); + return fake; + }; + + let client; + const limits = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('fake App Server response timed out')), 1000); + client = createCodexRateLimits({ + spawnImpl, + findCliImpl: () => 'codex-test', + pollMs: 60 * 1000, + requestTimeoutMs: 500, + onRateLimits(value) { clearTimeout(timeout); resolve(value); }, + }); + client.start(); + }); + client.stop(); + assert.strictEqual(limits.secondaryUsedPercent, 25); + assert.deepStrictEqual(writes.map((message) => message.method), [ + 'initialize', 'initialized', 'account/rateLimits/read', + ]); + + const root = path.join(__dirname, '..'); + const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + assert(read('main.js').includes("createCodexRateLimits")); + assert(read('renderer/pet.html').includes('shared/codex-rate-limits.js')); + assert(read('renderer/pet.js').includes("codexChipMode === 'weeklyRemaining'")); + console.log('Codex rate-limit checks passed'); + process.exit(0); +} + +integrationCheck().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/dom-stub.js b/test/dom-stub.js index 57669f0..d0c0be4 100644 --- a/test/dom-stub.js +++ b/test/dom-stub.js @@ -202,8 +202,9 @@ function createStubWorld() { // Load renderer/pet.js (and anything else, e.g. a future shared module) into // the stub world. Returns the world for driving + assertions. -function loadRenderer(files) { +function loadRenderer(files, options = {}) { const world = createStubWorld(); + if (typeof options.search === 'string') world.sandbox.location.search = options.search; vm.createContext(world.sandbox); for (const f of files) { const code = fs.readFileSync(path.join(__dirname, '..', f), 'utf8'); diff --git a/test/window-drag.js b/test/window-drag.js new file mode 100644 index 0000000..6b0cf96 --- /dev/null +++ b/test/window-drag.js @@ -0,0 +1,40 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { beginDrag, nextDragBounds } = require('../backend/window-drag'); + +const drag = beginDrag( + { x: -400, y: -38, width: 323, height: 344 }, + { x: -300, y: 120 }, + { width: 320, height: 340 }, +); + +assert.deepStrictEqual( + nextDragBounds(drag, { x: -290, y: 135 }), + { x: -390, y: -23, width: 320, height: 340 }, + 'window movement must equal the main-process cursor delta', +); +assert.strictEqual( + nextDragBounds(drag, { x: -290, y: 135 }), + null, + 'a synthetic pointermove caused by moving the window must not reapply identical bounds', +); +assert.deepStrictEqual( + nextDragBounds(drag, { x: -289, y: 135 }), + { x: -389, y: -23, width: 320, height: 340 }, + 'the intended logical size must not recycle fractionally rounded getBounds dimensions', +); + +const renderer = fs.readFileSync(path.join(__dirname, '..', 'renderer', 'pet.js'), 'utf8'); +const preload = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'); +const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); +assert(/window\.pet\.beginWinDrag\(\)/.test(renderer), 'renderer must begin drag in the main process'); +assert(/window\.pet\.updateWinDrag\(\)/.test(renderer), 'renderer must use the main process as cursor authority'); +assert(!/window\.pet\.setWinPos\(/.test(renderer), 'renderer must not mix DOM screen coordinates with BrowserWindow bounds'); +assert(/beginWinDrag:/.test(preload) && /endWinDrag:/.test(preload), 'preload must expose the drag lifecycle'); +assert(/screen\.getCursorScreenPoint\(\)/.test(main), 'main process must sample the cursor in BrowserWindow DIP space'); +assert(/if \(st\.drag\) \{ st\.resizeAfterDrag = true; return; \}/.test(main), 'popup resize must be deferred while dragging'); + +console.log('window drag checks passed'); From 8699209d7f2c9dd6ae3a7b754e4dd151950c1a0e Mon Sep 17 00:00:00 2001 From: ziyuezhou1 Date: Fri, 31 Jul 2026 14:38:50 +0800 Subject: [PATCH 2/7] Harden Codex session recovery and hook forwarding --- backend/codex-watch.js | 251 +++++++++++++++++++++++++++++++++++------ hook/octopus-hook.js | 29 ++++- test/codex-hooks.js | 24 +++- test/codex-watch.js | 75 +++++++++--- 4 files changed, 327 insertions(+), 52 deletions(-) diff --git a/backend/codex-watch.js b/backend/codex-watch.js index 9d3da01..f9d4389 100644 --- a/backend/codex-watch.js +++ b/backend/codex-watch.js @@ -21,8 +21,9 @@ // *_approval_request / request_user_input → Notification → 「等你回复」 // token_count → setContextUsage(上下文%) + 全局 rate_limits(5h 窗口) // -// 过滤:thread_source === 'subagent'(guardian / auto-review 等内部线程)整个 -// 文件跳过——它们不是用户会话,会把会话列表刷成审计日志。 +// 子代理:不单独显示,但也不能整文件跳过。Codex 的 subagent rollout 把父 +// session_id 写在 meta 里;它们的活动聚合到父会话,否则父线程等待子代理时会 +// 被误报 idle。多个子代理并行时,任一仍活跃就保持 juggling。 // // 大文件安全:rollout 可达十几 MB。启动 backfill 只探头部(session_meta 必在第 // 一行)和尾部若干 KB 静默入库(不回放历史、不触发欢迎/庆祝);此后每轮 poll 只 @@ -245,8 +246,32 @@ function createCodexWatch(deps) { return f; } + function rememberSourceState(t, state, event, extra, at = Date.now()) { + // core 会把真实 Stop 存成 idle;tracker 也保存稳态,供启动聚合和子代理 + // 结束后的父状态恢复使用。 + t.sourceState = event === 'Stop' ? 'idle' : state; + t.sourceEvent = event || null; + t.sourceStateAt = Number.isFinite(at) ? at : Date.now(); + if (extra && extra.toolName) t.sourceTool = extra.toolName; + } + + function activeSubagentCount(sid) { + let n = 0; + for (const other of trackers.values()) { + if (other.isSubagent && other.sid === sid && other.turnActive) n++; + } + return n; + } + function update(t, state, event, extra) { - core.updateSession(t.sid, state, event, { ...baseFields(t), ...extra }); + rememberSourceState(t, state, event, extra); + // 子代理内部的 thinking/tool/reasoning 都代表父会话正在调度子任务。沿用 + // 原事件名保留工具标签,但聚合态必须保持 juggling。 + const aggregatedState = t.isSubagent && t.turnActive + && state !== 'notification' && state !== 'error' + ? 'juggling' + : state; + core.updateSession(t.sid, aggregatedState, event, { ...baseFields(t), ...extra }); } function beginTurn(t) { @@ -264,6 +289,16 @@ function createCodexWatch(deps) { return t.didWorkThisTurn ? 'working' : 'thinking'; } + function finishSubagent(t) { + t.turnActive = false; + t.didWorkThisTurn = false; + t.lastAgentMessage = null; + const stillActive = activeSubagentCount(t.sid) > 0; + const state = stillActive ? 'juggling' : 'working'; + rememberSourceState(t, state, 'SubagentStop'); + core.updateSession(t.sid, state, 'SubagentStop', baseFields(t)); + } + // ── 逐行事件处理(仅 live 流量;backfill 不走这里) ───────────────────────── function handleLine(t, obj) { const type = obj.type; @@ -271,12 +306,11 @@ function createCodexWatch(deps) { if (type === 'session_meta') { applyMeta(t, p); - if (t.ignored) return; + if (t.isSubagent) return; // 子代理不是一条独立 UI 会话 // 运行期间新出现的会话:SessionStart 进欢迎判定(真正的欢迎等首条 prompt) update(t, 'idle', 'SessionStart', { sessionSource: 'startup' }); return; } - if (t.ignored) return; if (type === 'turn_context') { if (typeof p.cwd === 'string' && p.cwd) t.cwd = p.cwd; @@ -315,6 +349,10 @@ function createCodexWatch(deps) { switch (et) { case 'user_message': { beginTurn(t); + if (t.isSubagent) { + update(t, 'juggling', 'SubagentStart'); + break; + } const msg = typeof p.message === 'string' ? p.message : ''; const extra = {}; if (!t.titleSet) { @@ -328,13 +366,17 @@ function createCodexWatch(deps) { } case 'task_started': beginTurn(t); - update(t, 'thinking', 'TaskStarted'); + update(t, t.isSubagent ? 'juggling' : 'thinking', t.isSubagent ? 'SubagentStart' : 'TaskStarted'); break; case 'agent_message': // 兜底记住最后一条正文(task_complete 通常自带 last_agent_message) if (typeof p.message === 'string' && p.message) t.lastAgentMessage = p.message; break; case 'task_complete': { + if (t.isSubagent) { + finishSubagent(t); + break; + } const text = clipAssistant( typeof p.last_agent_message === 'string' && p.last_agent_message ? p.last_agent_message @@ -353,6 +395,10 @@ function createCodexWatch(deps) { break; } case 'turn_aborted': + if (t.isSubagent) { + finishSubagent(t); + break; + } update(t, 'idle', 'TurnAborted'); t.turnActive = false; t.didWorkThisTurn = false; @@ -384,8 +430,10 @@ function createCodexWatch(deps) { update(t, activeTurnState(t), 'Reasoning'); break; case 'token_count': { - const cu = toContextUsage(p.info); - if (cu) core.setContextUsage(t.sid, cu); + if (!t.isSubagent) { + const cu = toContextUsage(p.info); + if (cu) core.setContextUsage(t.sid, cu); + } const rl = toRateLimits(p.rate_limits); if (rl) { rateLimits = rl; onRateLimits(rl); } break; @@ -405,14 +453,18 @@ function createCodexWatch(deps) { function applyMeta(t, meta) { t.sawMeta = true; - t.sid = fileSessionId(t.fp, meta.id || meta.session_id); + const src = meta.source; + t.isSubagent = meta.thread_source === 'subagent' + || !!(src && typeof src === 'object' && src.subagent); + t.sourceSid = fileSessionId(t.fp, meta.id); + // 官方 Codex hook 约定:subagent 使用父 session_id。rollout 同样携带这个 + // 逻辑父 ID;以它聚合,绝不能用子 rollout 自己的 id 建 UI 会话。 + const logicalId = t.isSubagent + ? (meta.session_id || meta.parent_thread_id || meta.id) + : (meta.session_id || meta.id); + t.sid = fileSessionId(t.fp, logicalId); if (typeof meta.cwd === 'string' && meta.cwd) t.cwd = meta.cwd; if (typeof meta.originator === 'string') t.originator = meta.originator; - // guardian / auto-review 等内部子线程:整个文件不是用户会话 - const src = meta.source; - if (meta.thread_source === 'subagent' || (src && typeof src === 'object' && src.subagent)) { - t.ignored = true; - } } function hydrateMeta(t) { @@ -424,12 +476,141 @@ function createCodexWatch(deps) { if (!t.sid) t.sid = fileSessionId(t.fp, null); } - // ── 启动 backfill:头部读 meta、尾部读近况,静默入库 ───────────────────────── + function eventAt(obj, fallback) { + const ts = Date.parse(obj && obj.timestamp); + return Number.isFinite(ts) ? ts : fallback; + } + + // 启动时只更新 tracker 本地状态,不向 core 回放事件。这样既能恢复 Working, + // 又不会在重启时补放欢迎、庆祝和气泡。 + function observeBackfillLine(t, obj, fallbackAt) { + const p = obj.payload || {}; + const at = eventAt(obj, fallbackAt); + let state = null; + let event = null; + let extra = null; + + if (obj.type === 'turn_context') { + if (typeof p.cwd === 'string' && p.cwd) t.cwd = p.cwd; + if (typeof p.model === 'string' && p.model) t.model = p.model; + return; + } + if (obj.type === 'compacted') { + state = 'sweeping'; event = 'PreCompact'; + } else if (obj.type === 'response_item') { + const pt = p.type; + if (pt === 'function_call' || pt === 'custom_tool_call') { + markWork(t); + t.lastTool = mapTool(p.name); + state = 'working'; event = 'PreToolUse'; extra = { toolName: t.lastTool }; + } else if (pt === 'web_search_call') { + markWork(t); + t.lastTool = 'WebSearch'; + state = 'working'; event = 'PreToolUse'; extra = { toolName: 'WebSearch' }; + } else if (pt === 'function_call_output' || pt === 'custom_tool_call_output') { + markWork(t); + state = 'working'; event = 'PostToolUse'; extra = { toolName: t.lastTool || null }; + } else if (pt === 'reasoning') { + if (!t.turnActive) beginTurn(t); + state = activeTurnState(t); event = 'Reasoning'; + } + } else if (obj.type === 'event_msg') { + switch (p.type) { + case 'user_message': + case 'task_started': + beginTurn(t); + state = 'thinking'; + event = p.type === 'user_message' ? 'UserPromptSubmit' : 'TaskStarted'; + break; + case 'task_complete': + t.turnActive = false; + t.didWorkThisTurn = false; + state = 'idle'; event = 'Stop'; + break; + case 'turn_aborted': + t.turnActive = false; + t.didWorkThisTurn = false; + state = 'idle'; event = 'TurnAborted'; + break; + case 'context_compacted': + state = 'sweeping'; event = 'PreCompact'; + break; + case 'patch_apply_end': + markWork(t); + state = p.success === false ? 'error' : 'working'; + event = p.success === false ? 'PostToolUseFailure' : 'PostToolUse'; + extra = { toolName: 'Edit' }; + break; + case 'mcp_tool_call_end': + markWork(t); + state = 'working'; event = 'PostToolUse'; + extra = { toolName: (p.invocation && p.invocation.tool) ? String(p.invocation.tool) : 'Tool' }; + break; + case 'web_search_end': + markWork(t); + state = 'working'; event = 'PostToolUse'; extra = { toolName: 'WebSearch' }; + break; + case 'agent_reasoning': + if (!t.turnActive) beginTurn(t); + state = activeTurnState(t); event = 'Reasoning'; + break; + case 'error': + case 'stream_error': + state = 'error'; event = 'ApiError'; + break; + default: + if (/approval_request$/.test(p.type) || p.type === 'request_user_input' || p.type === 'elicitation_request') { + state = 'notification'; event = 'Notification'; + } + break; + } + } + if (state) rememberSourceState(t, state, event, extra, at); + } + + function seedBackfill(candidates) { + const groups = new Map(); + for (const candidate of candidates) { + if (!groups.has(candidate.t.sid)) groups.set(candidate.t.sid, []); + groups.get(candidate.t.sid).push(candidate); + } + const newer = (a, b) => ((a && a.stateAt) || 0) - ((b && b.stateAt) || 0); + for (const [sid, group] of groups) { + const roots = group.filter((c) => !c.t.isSubagent); + const children = group.filter((c) => c.t.isSubagent); + const root = roots.sort(newer).at(-1) || null; + const rootTerminalAt = root && (root.t.sourceEvent === 'Stop' || root.t.sourceEvent === 'TurnAborted') + ? root.stateAt + : 0; + const activeChildren = children.filter((c) => c.t.turnActive && c.stateAt > rootTerminalAt); + const activeChild = activeChildren.sort(newer).at(-1) || null; + const chosen = activeChild || root || group.sort(newer).at(-1); + const identity = root || chosen; + const titled = roots.filter((c) => c.seed.sessionTitle).sort(newer).at(-1); + const contextual = roots.filter((c) => c.seed.contextUsage).sort(newer).at(-1); + const state = activeChild ? 'juggling' : (chosen.seed.state || 'idle'); + const lastEvent = activeChild + ? { rawEvent: 'SubagentStart', at: activeChild.stateAt } + : chosen.seed.lastEvent; + core.seedSession({ + ...identity.seed, + id: sid, + state, + sessionTitle: titled ? titled.seed.sessionTitle : identity.seed.sessionTitle, + contextUsage: contextual ? contextual.seed.contextUsage : identity.seed.contextUsage, + lastEvent, + lastEventTool: activeChild ? activeChild.seed.lastEventTool : chosen.seed.lastEventTool, + createdAt: Math.min(...group.map((c) => c.seed.createdAt)), + updatedAt: chosen.stateAt, + }); + } + } + + // ── 启动 backfill:头部读 meta、尾部读近况,静默聚合入库 ────────────────────── function backfill(t, size, mtimeMs) { hydrateMeta(t); t.offset = size; // 历史不回放,此后只吃新增 cursors.set(t.fp, { offset: size, carry: '' }); - if (t.ignored) return; if (Date.now() - mtimeMs > BACKFILL_MAX_AGE_MS) return; // 太久没动的不上列表 let title = null; @@ -441,31 +622,39 @@ function createCodexWatch(deps) { if (start > 0) lines.shift(); // 掐头(可能是半行) for (const line of lines) { const obj = parseLine(line); - if (!obj || obj.type !== 'event_msg') continue; + if (!obj) continue; + observeBackfillLine(t, obj, mtimeMs); + if (obj.type !== 'event_msg') continue; const p = obj.payload || {}; - if (p.type === 'user_message' && !title) title = promptTitle(String(p.message || '')); + if (!t.isSubagent && p.type === 'user_message' && !title) title = promptTitle(String(p.message || '')); if (p.type === 'token_count') { - const cu = toContextUsage(p.info); - if (cu) contextUsage = cu; + if (!t.isSubagent) { + const cu = toContextUsage(p.info); + if (cu) contextUsage = cu; + } const rl = toRateLimits(p.rate_limits); if (rl && (!rateLimits || rl.ts >= rateLimits.ts)) { rateLimits = rl; onRateLimits(rl); } } } } - core.seedSession({ + const stateAt = t.sourceStateAt || mtimeMs; + t.titleSet = !!title; + return { t, stateAt, seed: { id: t.sid, agentId: 'codex', + state: t.sourceState || 'idle', cwd: t.cwd || '', transcriptPath: t.fp, sessionTitle: title, contextUsage, + lastEvent: t.sourceEvent ? { rawEvent: t.sourceEvent, at: stateAt } : null, + lastEventTool: t.sourceTool || null, originator: t.originator || null, sourcePid: null, headless: false, createdAt: mtimeMs, - updatedAt: mtimeMs, - }); - t.titleSet = !!title; + updatedAt: stateAt, + } }; } // ── 增量泵:读新增字节 → 攒整行 → handleLine ──────────────────────────────── @@ -490,8 +679,9 @@ function createCodexWatch(deps) { function newTracker(fp, cursor) { return { fp, sid: null, offset: cursor ? cursor.offset : 0, carry: cursor ? cursor.carry : '', - ignored: false, sawMeta: false, cwd: null, model: null, lastTool: null, + isSubagent: false, sourceSid: null, sawMeta: false, cwd: null, model: null, lastTool: null, lastAgentMessage: null, titleSet: false, turnActive: false, didWorkThisTurn: false, + sourceState: null, sourceEvent: null, sourceStateAt: 0, sourceTool: null, }; } @@ -520,6 +710,7 @@ function createCodexWatch(deps) { return; } // ① 发现新文件 → 建 tracker(启动第一轮走静默 backfill,之后按新会话走事件流) + const initialSeeds = []; for (const { fp, size, mtimeMs } of found) { if (trackers.has(fp)) continue; if (now - mtimeMs > IDLE_UNTRACK_MS) continue; // 陈年文件不建 tracker @@ -527,7 +718,8 @@ function createCodexWatch(deps) { const t = newTracker(fp, prior); trackers.set(fp, t); if (!booted) { - backfill(t, size, mtimeMs); + const candidate = backfill(t, size, mtimeMs); + if (candidate) initialSeeds.push(candidate); } else if (prior) { // 旧/暂停会话恢复:meta 只用于恢复身份,不派发 SessionStart;随后只泵增量。 hydrateMeta(t); @@ -537,6 +729,7 @@ function createCodexWatch(deps) { log('codex', `new rollout: ${path.basename(fp)}`); } } + if (!booted) seedBackfill(initialSeeds); // ② 泵所有已跟踪文件——直接 stat,不依赖本轮扫描列表:旧日期目录里的 // 长寿会话只在全量扫描轮被「发现」,但每一轮都要跟进它的新增内容。 for (const [fp, t] of trackers) { @@ -547,12 +740,6 @@ function createCodexWatch(deps) { trackers.delete(fp); continue; } // 凉了,退场但保留游标 - if (t.ignored && t.sawMeta) { - t.offset = e.size; - t.carry = ''; - cursors.set(fp, { offset: t.offset, carry: '' }); - continue; - } // subagent:光标跟上即可 pump(t, e.size); } booted = true; diff --git a/hook/octopus-hook.js b/hook/octopus-hook.js index bcd9c2e..0bf6c7e 100644 --- a/hook/octopus-hook.js +++ b/hook/octopus-hook.js @@ -54,6 +54,14 @@ function readStdin() { function count(v) { return Array.isArray(v) ? v.length : 0; } +// Codex requires Stop and SubagentStop hooks that exit successfully to return +// a JSON object on stdout. Other events (and Claude Code) should stay silent. +function codexSuccessOutput(event, source) { + return source === 'codex' && (event === 'Stop' || event === 'SubagentStop') + ? { continue: true } + : null; +} + function buildBody(event, p, source = 'claude') { let state = EVENT_STATE[event]; if (!state) return null; @@ -171,14 +179,25 @@ function main() { const event = process.argv[2]; const source = process.argv[3] === 'codex' ? 'codex' : 'claude'; if (!EVENT_STATE[event]) process.exit(0); + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + const output = codexSuccessOutput(event, source); + if (output) { + process.stdout.write(`${JSON.stringify(output)}\n`, () => process.exit(0)); + return; + } + process.exit(0); + }; readStdin().then((payload) => { let body; try { body = buildBody(event, payload || {}, source); } catch { body = null; } - if (!body) process.exit(0); - transport.postState(body, () => process.exit(0)); - setTimeout(() => process.exit(0), 250); // never hang Claude Code - }).catch(() => process.exit(0)); + if (!body) return finish(); + transport.postState(body, finish); + setTimeout(finish, 250); // never hang Claude Code + }).catch(finish); } if (require.main === module) main(); -module.exports = { buildBody, EVENT_STATE }; +module.exports = { buildBody, codexSuccessOutput, EVENT_STATE }; diff --git a/test/codex-hooks.js b/test/codex-hooks.js index 2fe845d..b18a89d 100644 --- a/test/codex-hooks.js +++ b/test/codex-hooks.js @@ -1,6 +1,7 @@ 'use strict'; const assert = require('assert'); +const childProcess = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -10,7 +11,7 @@ const { codexHooksCurrent, CODEX_EVENTS, } = require('../backend/codex-hookinstall'); -const { buildBody } = require('../hook/octopus-hook'); +const { buildBody, codexSuccessOutput } = require('../hook/octopus-hook'); const { createCore } = require('../backend/core'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llmpet-codex-hooks-')); @@ -72,6 +73,27 @@ const stop = buildBody('Stop', { last_assistant_message: 'Implemented and tested.', }, 'codex'); assert.strictEqual(stop.assistant_last_output, 'Implemented and tested.'); +assert.deepStrictEqual(codexSuccessOutput('Stop', 'codex'), { continue: true }); +assert.deepStrictEqual(codexSuccessOutput('SubagentStop', 'codex'), { continue: true }); +assert.strictEqual(codexSuccessOutput('Stop', 'claude'), null); + +const stopProcess = childProcess.spawnSync( + process.execPath, + [path.join(__dirname, '..', 'hook', 'octopus-hook.js'), 'Stop', 'codex'], + { + input: JSON.stringify({ + session_id: 'codex-stop-output', + hook_event_name: 'Stop', + last_assistant_message: 'Done.', + }), + encoding: 'utf8', + env: { ...process.env, HOME: tmp, USERPROFILE: tmp }, + timeout: 3000, + } +); +assert.strictEqual(stopProcess.status, 0, stopProcess.stderr); +assert.deepStrictEqual(JSON.parse(stopProcess.stdout), { continue: true }, + 'Codex Stop hooks must print valid JSON on stdout'); const permission = buildBody('PermissionRequest', { session_id: 'codex-session' }, 'codex'); assert.strictEqual(permission.state, 'notification'); diff --git a/test/codex-watch.js b/test/codex-watch.js index ae9c51c..618c354 100644 --- a/test/codex-watch.js +++ b/test/codex-watch.js @@ -1,7 +1,7 @@ 'use strict'; // codex-watch 单元测试 — 用临时目录伪造 ~/.codex/sessions 的 rollout JSONL, -// 注入假 core 记录调用:backfill 静默入库、live 事件映射、subagent 过滤、 +// 注入假 core 记录调用:backfill 静默聚合、live 事件映射、subagent 父会话归并、 // 半行攒批、token_count → 上下文% + rate_limits。 // Run: node test/codex-watch.js @@ -42,6 +42,7 @@ function mkSessions() { const UUID_A = '019f5103-921c-7ac1-9a8d-c4f8ff8a67aa'; const UUID_B = '019f5103-921c-7ac1-9a8d-c4f8ff8a67bb'; +const UUID_C = '019f5103-921c-7ac1-9a8d-c4f8ff8a67cc'; const line = (o) => JSON.stringify(o) + '\n'; const meta = (id, extra = {}) => line({ type: 'session_meta', payload: { id, session_id: id, cwd: '/tmp/proj', originator: 'codex-tui', thread_source: 'user', ...extra } }); @@ -90,6 +91,7 @@ check('meta+尾部 user_message/token_count → seedSession(不发事件)', () = assert.strictEqual(core.seeds[0].cwd, '/tmp/proj'); assert.strictEqual(core.seeds[0].sessionTitle, '帮我修个 bug'); assert.strictEqual(core.seeds[0].contextUsage.percent, 10); + assert.strictEqual(core.seeds[0].state, 'thinking', '重启后应从尾部恢复正在执行的状态'); assert.strictEqual(core.updates.length, 0, 'backfill 不应发 updateSession'); }); @@ -182,24 +184,69 @@ check('turn_aborted → TurnAborted(idle);approval → Notification', () => { assert.deepStrictEqual(evs, ['SessionStart:idle', 'Notification:notification', 'TurnAborted:idle']); }); -console.log('[C4] 过滤与健壮性'); -check('thread_source=subagent 整个文件跳过(含 backfill 与 live)', () => { +console.log('[C4] 子代理聚合与健壮性'); +check('backfill:活跃 subagent 静默归并到父 session_id,不生成子会话', () => { const { root, dir } = mkSessions(); - // backfill 路径 - const fp1 = path.join(dir, `rollout-2026-07-11T04-00-00-${UUID_A}.jsonl`); - fs.writeFileSync(fp1, meta(UUID_A, { thread_source: 'subagent', source: { subagent: { other: 'guardian' } } }) - + line({ type: 'event_msg', payload: { type: 'user_message', message: 'internal' } })); + const parent = path.join(dir, `rollout-2026-07-11T04-00-00-${UUID_A}.jsonl`); + const child = path.join(dir, `rollout-2026-07-11T04-01-00-${UUID_B}.jsonl`); + fs.writeFileSync(parent, meta(UUID_A) + + line({ timestamp: '2026-07-11T04:00:10Z', type: 'event_msg', payload: { type: 'turn_aborted' } })); + fs.writeFileSync(child, meta(UUID_B, { + session_id: UUID_A, + parent_thread_id: UUID_A, + thread_source: 'subagent', + source: { subagent: { other: 'worker' } }, + }) + line({ timestamp: '2026-07-11T04:01:10Z', type: 'event_msg', payload: { type: 'task_started' } })); const core = fakeCore(); const w = createCodexWatch({ core, sessionsDir: root, pollMs: 999999 }); w.tick(); - // live 路径 - const fp2 = path.join(dir, `rollout-2026-07-11T05-00-00-${UUID_B}.jsonl`); - fs.writeFileSync(fp2, meta(UUID_B, { thread_source: 'subagent' })); + assert.strictEqual(core.seeds.length, 1); + assert.strictEqual(core.seeds[0].id, UUID_A); + assert.strictEqual(core.seeds[0].state, 'juggling'); + assert.strictEqual(core.seeds[0].lastEvent.rawEvent, 'SubagentStart'); + assert.strictEqual(core.seeds[0].transcriptPath, parent, '会话身份应保留父 rollout'); + assert.strictEqual(core.updates.length, 0, '启动聚合仍然不能回放事件'); +}); + +check('live:并行 subagent 归并父会话;一个先结束不释放整体 working', () => { + const { root, dir } = mkSessions(); + const core = fakeCore(); + const w = createCodexWatch({ core, sessionsDir: root, pollMs: 999999 }); w.tick(); - fs.appendFileSync(fp2, line({ type: 'event_msg', payload: { type: 'user_message', message: 'still internal' } })); + const parent = path.join(dir, `rollout-parent-${UUID_A}.jsonl`); + const child1 = path.join(dir, `rollout-child-${UUID_B}.jsonl`); + const child2 = path.join(dir, `rollout-child-${UUID_C}.jsonl`); + fs.writeFileSync(parent, meta(UUID_A)); w.tick(); - assert.strictEqual(core.seeds.length, 0); - assert.strictEqual(core.updates.length, 0); + const childMeta = (id) => meta(id, { + session_id: UUID_A, + parent_thread_id: UUID_A, + thread_source: 'subagent', + source: { subagent: { other: 'worker' } }, + }); + fs.writeFileSync(child1, childMeta(UUID_B)); + fs.writeFileSync(child2, childMeta(UUID_C)); + w.tick(); + assert.strictEqual(core.updates.filter((u) => u.event === 'SessionStart').length, 1, '子 rollout 不建独立会话'); + + fs.appendFileSync(child1, line({ type: 'event_msg', payload: { type: 'task_started' } })); + fs.appendFileSync(child2, line({ type: 'event_msg', payload: { type: 'task_started' } })); + w.tick(); + const starts = core.updates.filter((u) => u.event === 'SubagentStart'); + assert.strictEqual(starts.length, 2); + assert.ok(starts.every((u) => u.sid === UUID_A && u.state === 'juggling')); + + fs.appendFileSync(child1, line({ type: 'event_msg', payload: { type: 'task_complete' } })); + w.tick(); + assert.strictEqual(core.updates.at(-1).sid, UUID_A); + assert.strictEqual(core.updates.at(-1).state, 'juggling', '仍有另一个子代理活跃时必须保持 juggling'); + assert.strictEqual(core.updates.at(-1).event, 'SubagentStop'); + + fs.appendFileSync(child2, line({ type: 'event_msg', payload: { type: 'task_complete' } })); + w.tick(); + assert.strictEqual(core.updates.at(-1).sid, UUID_A); + assert.strictEqual(core.updates.at(-1).state, 'working'); + assert.strictEqual(core.updates.at(-1).event, 'SubagentStop'); }); check('半行写入攒到下一轮,不丢不重', () => { @@ -252,7 +299,7 @@ check('几天前日期目录里的活跃文件:启动即入库,之后每轮 w.tick(); // 第二轮不是全量轮:tracker 直连 stat 也必须泵到 assert.ok(core.updates.some((u) => u.event === 'TaskStarted'), '非全量轮也要跟进旧目录文件的增量'); }); -check('35KB+ 超长 session_meta 行完整解析(cwd / subagent 判定不丢)', () => { +check('35KB+ 超长 session_meta 行完整解析(cwd / 父会话判定不丢)', () => { const { root, dir } = mkSessions(); const fp = path.join(dir, `rollout-2026-07-11T06-00-00-${UUID_B}.jsonl`); const big = { From 08f6eb1b1bc82a914e6ac7b187146d48b8f98e22 Mon Sep 17 00:00:00 2001 From: ziyuezhou1 Date: Fri, 31 Jul 2026 14:39:28 +0800 Subject: [PATCH 3/7] Keep pet position stable during popup resize --- backend/window-drag.js | 48 +++++++++++++++++++++++++++++++++++++++++- main.js | 19 ++++++----------- test/window-drag.js | 28 +++++++++++++++++++++++- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/backend/window-drag.js b/backend/window-drag.js index 5bc8e5c..6bc1f36 100644 --- a/backend/window-drag.js +++ b/backend/window-drag.js @@ -37,4 +37,50 @@ function nextDragBounds(drag, cursorPoint) { return { x, y, width: drag.width, height: drag.height }; } -module.exports = { beginDrag, nextDragBounds }; +// Popups temporarily grow the transparent BrowserWindow around the pet. Keep +// the visible pet anchored at the same screen-space center and bottom instead +// of clamping the whole transparent rectangle into the work area: near the +// right edge that clamp pulled the pet left on every status bubble. +function resizePetBounds(windowBounds, intendedSize, workArea) { + if ( + !finitePoint(windowBounds) + || !Number.isFinite(windowBounds.width) + || !Number.isFinite(windowBounds.height) + || !intendedSize + || !Number.isFinite(intendedSize.width) + || !Number.isFinite(intendedSize.height) + ) { + throw new TypeError('window bounds and intended size must be finite'); + } + + const width = Math.max(1, Math.round(intendedSize.width)); + let height = Math.max(1, Math.round(intendedSize.height)); + const centerX = windowBounds.x + windowBounds.width / 2; + const bottom = windowBounds.y + windowBounds.height; + let y = Math.round(bottom - height); + + if ( + workArea + && finitePoint(workArea) + && Number.isFinite(workArea.width) + && Number.isFinite(workArea.height) + && workArea.width > 0 + && workArea.height > 0 + ) { + height = Math.min(height, Math.round(workArea.height)); + y = Math.round(bottom - height); + y = Math.min( + Math.max(y, Math.round(workArea.y)), + Math.round(workArea.y + workArea.height - height), + ); + } + + return { + x: Math.round(centerX - width / 2), + y, + width, + height, + }; +} + +module.exports = { beginDrag, nextDragBounds, resizePetBounds }; diff --git a/main.js b/main.js index 6397ee6..eff9a65 100644 --- a/main.js +++ b/main.js @@ -36,7 +36,7 @@ const { createTravelManager } = require('./backend/travel'); const { machineGrowth } = require('./backend/growth'); const { publicCatalog, getMeme, watchCatalog } = require('./backend/meme-catalog'); const { createCommandDispatcher, routeForSession } = require('./backend/command-dispatch'); -const { beginDrag, nextDragBounds } = require('./backend/window-drag'); +const { beginDrag, nextDragBounds, resizePetBounds } = require('./backend/window-drag'); const transport = require('./backend/transport'); const i18n = require('./shared/i18n'); @@ -127,24 +127,17 @@ function applyPetSize(st) { if (!st || !st.win || st.win.isDestroyed()) return; if (st.drag) { st.resizeAfterDrag = true; return; } const win = st.win; - const { w } = targetSize(st); - let { h } = targetSize(st); + const { w, h } = targetSize(st); const b = win.getBounds(); // Cap the window to the screen's work area so a tall popup can NEVER push the // pet / footer buttons off-screen — the popup scrolls internally instead. + // Do not horizontally clamp the transparent outer window: preserving its + // center is what keeps a manually positioned pet still when a bubble widens. try { const wa = screen.getDisplayMatching(b).workArea; - h = Math.min(h, wa.height); - const cx = b.x + b.width / 2; - const bottom = b.y + b.height; - let x = Math.round(cx - w / 2); - let y = Math.round(bottom - h); - x = Math.min(Math.max(x, wa.x), wa.x + wa.width - w); - y = Math.min(Math.max(y, wa.y), wa.y + wa.height - h); - win.setBounds({ x, y, width: w, height: h }); + win.setBounds(resizePetBounds(b, { width: w, height: h }, wa)); } catch { - const bottom = b.y + b.height; - win.setBounds({ x: b.x, y: Math.round(bottom - h), width: w, height: h }); + win.setBounds(resizePetBounds(b, { width: w, height: h })); } } diff --git a/test/window-drag.js b/test/window-drag.js index 6b0cf96..4b74f3d 100644 --- a/test/window-drag.js +++ b/test/window-drag.js @@ -3,7 +3,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const { beginDrag, nextDragBounds } = require('../backend/window-drag'); +const { beginDrag, nextDragBounds, resizePetBounds } = require('../backend/window-drag'); const drag = beginDrag( { x: -400, y: -38, width: 323, height: 344 }, @@ -27,6 +27,32 @@ assert.deepStrictEqual( 'the intended logical size must not recycle fractionally rounded getBounds dimensions', ); +const restingBounds = { x: 1576, y: 676, width: 320, height: 340 }; +const expandedBounds = resizePetBounds( + restingBounds, + { width: 520, height: 520 }, + { x: 0, y: 0, width: 1920, height: 1040 }, +); +assert.deepStrictEqual( + expandedBounds, + { x: 1476, y: 496, width: 520, height: 520 }, + 'expanding a popup near the right edge must keep the dragged pet center and feet fixed', +); +assert.strictEqual( + expandedBounds.x + expandedBounds.width / 2, + restingBounds.x + restingBounds.width / 2, + 'popup expansion must not pull the pet left to fit the transparent window on-screen', +); +assert.deepStrictEqual( + resizePetBounds( + expandedBounds, + { width: 320, height: 340 }, + { x: 0, y: 0, width: 1920, height: 1040 }, + ), + restingBounds, + 'closing the popup must restore the exact dragged resting bounds', +); + const renderer = fs.readFileSync(path.join(__dirname, '..', 'renderer', 'pet.js'), 'utf8'); const preload = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'); const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); From 40ef88581be898c5cd4710b874c87e80687bdcb0 Mon Sep 17 00:00:00 2001 From: ziyuezhou1 Date: Sat, 1 Aug 2026 01:21:04 +0800 Subject: [PATCH 4/7] Recover LLMPET after hook disconnects # Conflicts: # package.json --- backend/startup.js | 78 +++++++++++++++++++++++++++++++++++++++ hook/octopus-hook.js | 19 +++++++++- main.js | 4 ++ package.json | 5 ++- test/codex-integration.js | 3 ++ test/startup-recovery.js | 68 ++++++++++++++++++++++++++++++++++ 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 backend/startup.js create mode 100644 test/startup-recovery.js diff --git a/backend/startup.js b/backend/startup.js new file mode 100644 index 0000000..b4f74c5 --- /dev/null +++ b/backend/startup.js @@ -0,0 +1,78 @@ +'use strict'; + +// Keep the packaged desktop app available to the lightweight hook process. +// Login startup covers machine restarts; hook recovery covers an app process +// that was terminated after login. Recovery is deliberately rate-limited so a +// burst of lifecycle events cannot create a burst of Electron processes. + +const childProcess = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const RECOVERY_COOLDOWN_MS = 30_000; +const RECOVERY_STAMP_PATH = path.join(os.homedir(), '.octopus', 'hook-recovery.json'); + +function recoveryExecutable(hookDir, platform = process.platform) { + if (!hookDir) return null; + const pathApi = platform === 'win32' ? path.win32 : path.posix; + const appRoot = pathApi.resolve(hookDir, '..', '..', '..'); + if (platform === 'win32') return pathApi.join(appRoot, 'LLMPET.exe'); + if (platform === 'darwin') return pathApi.join(appRoot, 'MacOS', 'LLMPET'); + return null; +} + +function ensureLoginStartup(electronApp, options = {}) { + const platform = options.platform || process.platform; + const executable = options.executable || process.execPath; + if (!electronApp || !electronApp.isPackaged || !['win32', 'darwin'].includes(platform)) return false; + + const settings = { openAtLogin: true }; + if (platform === 'win32') { + settings.path = executable; + settings.args = ['--autostart']; + } + electronApp.setLoginItemSettings(settings); + return true; +} + +function recoverPackagedApp(options = {}) { + const platform = options.platform || process.platform; + const executable = options.executable || recoveryExecutable(options.hookDir, platform); + const stampPath = options.stampPath || RECOVERY_STAMP_PATH; + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const cooldownMs = Number.isFinite(options.cooldownMs) ? options.cooldownMs : RECOVERY_COOLDOWN_MS; + const existsSync = options.existsSync || fs.existsSync; + const readFileSync = options.readFileSync || fs.readFileSync; + const mkdirSync = options.mkdirSync || fs.mkdirSync; + const writeFileSync = options.writeFileSync || fs.writeFileSync; + const spawn = options.spawn || childProcess.spawn; + + if (!executable || !existsSync(executable)) return false; + + try { + const previous = JSON.parse(readFileSync(stampPath, 'utf8')); + if (Number.isFinite(previous.at) && now - previous.at < cooldownMs) return false; + } catch {} + + try { + mkdirSync(path.dirname(stampPath), { recursive: true }); + writeFileSync(stampPath, JSON.stringify({ at: now }), 'utf8'); + const child = spawn(executable, ['--hook-recovery'], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + if (child && typeof child.unref === 'function') child.unref(); + return true; + } catch { + return false; + } +} + +module.exports = { + RECOVERY_COOLDOWN_MS, + recoveryExecutable, + ensureLoginStartup, + recoverPackagedApp, +}; diff --git a/hook/octopus-hook.js b/hook/octopus-hook.js index 0bf6c7e..55e876d 100644 --- a/hook/octopus-hook.js +++ b/hook/octopus-hook.js @@ -14,6 +14,7 @@ const transport = require('../backend/transport'); const transcript = require('../backend/transcript'); const pidwalk = require('../backend/pidwalk'); const { detectEmotion } = require('../backend/emotion'); +const { recoverPackagedApp } = require('../backend/startup'); const EVENT_STATE = { SessionStart: 'idle', @@ -194,8 +195,22 @@ function main() { let body; try { body = buildBody(event, payload || {}, source); } catch { body = null; } if (!body) return finish(); - transport.postState(body, finish); - setTimeout(finish, 250); // never hang Claude Code + let delivered = false; + const recover = () => { + if (!delivered) recoverPackagedApp({ hookDir: __dirname }); + }; + transport.postState(body, (ok) => { + delivered = ok === true; + if (!delivered) recover(); + finish(); + }); + setTimeout(() => { + // A stale runtime file can make transport exhaust several dead ports. Do + // not hold up Codex/Claude; wake the packaged app and let the next event + // arrive through the newly restored server. + recover(); + finish(); + }, 250); // never hang Claude Code }).catch(finish); } diff --git a/main.js b/main.js index eff9a65..0b518cd 100644 --- a/main.js +++ b/main.js @@ -37,6 +37,7 @@ const { machineGrowth } = require('./backend/growth'); const { publicCatalog, getMeme, watchCatalog } = require('./backend/meme-catalog'); const { createCommandDispatcher, routeForSession } = require('./backend/command-dispatch'); const { beginDrag, nextDragBounds, resizePetBounds } = require('./backend/window-drag'); +const { ensureLoginStartup } = require('./backend/startup'); const transport = require('./backend/transport'); const i18n = require('./shared/i18n'); @@ -1294,6 +1295,9 @@ if (!gotTheLock) { app.on('second-instance', () => { try { for (const st of petStates()) st.win.show(); } catch {} }); app.whenReady().then(async () => { if (process.platform === 'darwin' && app.dock) app.dock.hide(); + try { + if (ensureLoginStartup(app)) log('main', 'login startup enabled'); + } catch (e) { log('main', 'login startup unavailable:', e.message); } const rival = await findRivalInstance(); if (rival) { log('main', `another LLMPET server is live on 127.0.0.1:${rival} — quitting (OCTOPUS_ALLOW_MULTI=1 to bypass)`); diff --git a/package.json b/package.json index 46f3f31..0299c63 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "package:mac:dev": "LLMPET_MAC_SIGN_MODE=adhoc bash scripts/package-mac.sh", "verify:mac": "bash scripts/verify-mac-release.sh", "package:win": "electron-builder --win --publish never", - "test": "node test/smoke.js && node test/state-smoke.js && node test/pricing.js && node test/metering.js && node test/codex-metering.js && node test/travel.js && node test/session-preferences.js && node test/territory.js && node test/window-drag.js && node test/codex-watch.js && node test/codex-hooks.js && node test/codex-rate-limits.js && node test/codex-integration.js && node test/meme-actions.js && node test/i18n.js && node test/popup-style.js && node test/branding.js && node test/mac-release.js" + "test": "node test/smoke.js && node test/state-smoke.js && node test/pricing.js && node test/metering.js && node test/codex-metering.js && node test/travel.js && node test/session-preferences.js && node test/territory.js && node test/window-drag.js && node test/codex-watch.js && node test/codex-hooks.js && node test/startup-recovery.js && node test/codex-rate-limits.js && node test/codex-integration.js && node test/meme-actions.js && node test/i18n.js && node test/popup-style.js && node test/branding.js && node test/mac-release.js" }, "devDependencies": { "@electron/notarize": "2.5.0", @@ -45,7 +45,8 @@ }, "nsis": { "oneClick": false, - "allowToChangeInstallationDirectory": true + "allowToChangeInstallationDirectory": true, + "runAfterFinish": true } } } diff --git a/test/codex-integration.js b/test/codex-integration.js index cb92451..288b101 100644 --- a/test/codex-integration.js +++ b/test/codex-integration.js @@ -25,6 +25,8 @@ assert(/codexRateLimitClient\.start\(\)/.test(main), 'main process must start th assert(/if \(codexWatch\) codexWatch\.stop\(\)/.test(main), 'app shutdown must stop the Codex watcher'); assert(/data\.agent_id === 'codex' \? 'codex' : 'claude-code'/.test(server), 'Codex hook events must retain their agent identity'); assert(/body\.event_source = 'codex-hook'/.test(hook), 'Codex hook events must identify their source for deduplication'); +assert(/recoverPackagedApp\(\{ hookDir: __dirname \}\)/.test(hook), 'a failed hook delivery must wake the packaged LLMPET app'); +assert(/ensureLoginStartup\(app\)/.test(main), 'the packaged app must keep its login startup registration current'); assert(/function sendPetEvent\(ev\)/.test(main) && /ev\.agent === 'codex'/.test(main), 'Codex events must route to the Codex pet in duo mode'); assert(/function createPetWindows\(\)/.test(main) && /makePetWindow\('codex'\)/.test(main), 'duo mode must create an independent Codex pet'); assert(/petMode: 'single'/.test(config) && /skinCodex: 'cat'/.test(config), 'Codex pet settings must have safe defaults'); @@ -34,6 +36,7 @@ assert(/closePet: \(\) => ipcRenderer\.send\('close-pet'\)/.test(preload), 'a du assert(/Claude Code \/ Codex/.test(readme) && /Codex 后端/.test(readme), 'public documentation must describe Codex support'); assert(pkg.scripts.test.includes('test/codex-watch.js'), 'npm test must execute Codex watcher tests'); assert(pkg.scripts.test.includes('test/codex-hooks.js'), 'npm test must execute Codex hook tests'); +assert(pkg.scripts.test.includes('test/startup-recovery.js'), 'npm test must execute startup recovery tests'); assert(pkg.scripts.test.includes('test/codex-rate-limits.js'), 'npm test must execute Codex rate-limit tests'); assert(pkg.scripts.test.includes('test/codex-integration.js'), 'npm test must execute the Codex integration contract'); diff --git a/test/startup-recovery.js b/test/startup-recovery.js new file mode 100644 index 0000000..5b06a61 --- /dev/null +++ b/test/startup-recovery.js @@ -0,0 +1,68 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + RECOVERY_COOLDOWN_MS, + recoveryExecutable, + ensureLoginStartup, + recoverPackagedApp, +} = require('../backend/startup'); + +assert.strictEqual( + recoveryExecutable('C:\\Program Files\\LLMPET\\resources\\app\\hook', 'win32'), + 'C:\\Program Files\\LLMPET\\LLMPET.exe' +); +assert.strictEqual( + recoveryExecutable('/Applications/LLMPET.app/Contents/Resources/app/hook', 'darwin'), + '/Applications/LLMPET.app/Contents/MacOS/LLMPET' +); +assert.strictEqual(recoveryExecutable('/opt/llmpet/hook', 'linux'), null); + +const loginCalls = []; +const packagedApp = { + isPackaged: true, + setLoginItemSettings(settings) { loginCalls.push(settings); }, +}; +assert.strictEqual(ensureLoginStartup(packagedApp, { + platform: 'win32', executable: 'C:\\Program Files\\LLMPET\\LLMPET.exe', +}), true); +assert.deepStrictEqual(loginCalls[0], { + openAtLogin: true, + path: 'C:\\Program Files\\LLMPET\\LLMPET.exe', + args: ['--autostart'], +}); +assert.strictEqual(ensureLoginStartup({ ...packagedApp, isPackaged: false }, { platform: 'win32' }), false); +assert.strictEqual(ensureLoginStartup(packagedApp, { platform: 'linux' }), false); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llmpet-startup-')); +const stampPath = path.join(tmp, 'hook-recovery.json'); +const executable = path.join(tmp, 'LLMPET.exe'); +fs.writeFileSync(executable, 'test'); +const spawns = []; +const spawn = (command, args, options) => { + spawns.push({ command, args, options }); + return { unref() {} }; +}; + +const firstAt = Date.now(); +assert.strictEqual(recoverPackagedApp({ executable, stampPath, now: firstAt, spawn }), true); +assert.strictEqual(spawns.length, 1); +assert.deepStrictEqual(spawns[0].args, ['--hook-recovery']); +assert.strictEqual(spawns[0].options.detached, true); + +assert.strictEqual(recoverPackagedApp({ + executable, stampPath, now: firstAt + RECOVERY_COOLDOWN_MS - 1, spawn, +}), false, 'events inside the cooldown must not spawn another app'); +assert.strictEqual(spawns.length, 1); + +assert.strictEqual(recoverPackagedApp({ + executable, stampPath, now: firstAt + RECOVERY_COOLDOWN_MS, spawn, +}), true, 'recovery must be allowed again after the cooldown'); +assert.strictEqual(spawns.length, 2); +assert.strictEqual(recoverPackagedApp({ executable: path.join(tmp, 'missing.exe'), stampPath, spawn }), false); + +fs.rmSync(tmp, { recursive: true, force: true }); +console.log('startup recovery checks passed'); From 09f580e5be5523175229ba9fb8f37066c2c7588f Mon Sep 17 00:00:00 2001 From: ziyuezhou1 Date: Sat, 1 Aug 2026 01:41:59 +0800 Subject: [PATCH 5/7] Address PR review blockers --- README.md | 2 +- README_EN.md | 1 + README_JA.md | 1 + backend/config.js | 2 + backend/hook-queue.js | 135 ++++++++++++++++++++++++++++++++++++++ backend/server.js | 6 ++ backend/startup.js | 58 ++++++++++++++-- backend/window-drag.js | 50 +++++++++----- hook/octopus-hook.js | 79 +++++++++++++++++----- main.js | 83 +++++++++++++++++++---- preload.js | 1 + renderer/pet.css | 4 ++ renderer/pet.html | 28 ++++---- renderer/pet.js | 8 +++ shared/i18n.js | 3 + test/codex-integration.js | 3 +- test/dom-stub.js | 6 +- test/i18n.js | 1 + test/startup-recovery.js | 96 ++++++++++++++++++++++++--- test/window-drag.js | 50 ++++++++++---- 20 files changed, 525 insertions(+), 92 deletions(-) create mode 100644 backend/hook-queue.js diff --git a/README.md b/README.md index 4365f05..0aef59c 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ npm start # 启动桌宠(首次启动会注册 Claude Code / Codex - 会话右侧的 **🧳** = 打开青蛙旅行:让对应 agent 在该项目中执行一次独立、只读探索,回来后展示明信片并累计成长 token。 - 会话面板底部的 **🐱 闲逛** = 打开可见 CLI,不带项目、不带任务和工具,让猫猫自己随便想想、聊聊。 - **右键** = 泡泡菜单;**拖动** = 移动位置。等授权/等回复时会**自动**弹允许/拒绝气泡。 -- 托盘菜单可开详情面板、静音、唤起 Claude、打开日志、**卸载钩子**、退出。 +- 托盘菜单可开详情面板、静音、唤起 Claude、打开日志、**卸载钩子**、退出。可选的“开机启动并在崩溃后恢复”默认关闭;开启后,发送失败的钩子事件会先落盘,服务恢复监听后再投递。主动点“退出”会阻止钩子复活进程,直到下次手动启动。 - 详情面板里可切皮肤 / 模式 / 设 5h 预算。 - **🥊 领地模式**(macOS):右键桌宠点“巡视”可立即扫描并执行一次;托盘可开启“自动巡逻”,开启后立即首巡、随后定时轮询(默认关)。两条定律:①**猫爪在上**——检测到别的桌面宠物(Desktop Goose / BongoCat / Shimeji 等)在跑,就把自己的窗口层级抬到最上,谁也不许压着咱(无需额外权限);②**巡视行动**——发现对方窗口,小章鱼走过去把它一步步**顶到屏幕边上**。巡视需要**辅助功能**权限(移动别人的窗口);没授权时「巡视」仍会执行猫爪在上,只是不推窗。对付 AXPosition 失效的透明窗桌宠时,会像 Computer Use 一样显示独立的橙色爪软件光标;底层兼容拖拽仍只在你**输入空闲 ≥2s** 时执行,期间隐藏系统光标,结束或异常都会补发 mouseUp 并把原光标复位,你手上有活时则静默撤退。自定义对手:`~/.octopus/config.json` 的 `territoryRivals` 数组加进程名关键词。 diff --git a/README_EN.md b/README_EN.md index 84fc42a..638b579 100644 --- a/README_EN.md +++ b/README_EN.md @@ -130,6 +130,7 @@ Patrol mode is currently macOS-only. - Background network access is limited to the optional daily LiteLLM pricing download. A Travel Frog run contacts Anthropic or OpenAI only after you explicitly press **Depart**; `OCTOPUS_NO_NET=1` disables LLMPET's pricing fetch, but does not override a CLI trip you explicitly start. - Electron runs with `contextIsolation` enabled and `nodeIntegration` disabled. - Claude hook installation is merge-safe, atomic, reversible, and backed up before uninstall. +- **Start at login and recover after crashes** is an explicit, persisted tray preference and is off by default. When enabled, a hook event that encounters a stopped app is queued locally and replayed after the recovered server starts listening. Choosing **Quit** suppresses hook recovery until the next explicit launch. ## Configuration and development flags diff --git a/README_JA.md b/README_JA.md index b4920d1..03ce1cf 100644 --- a/README_JA.md +++ b/README_JA.md @@ -130,6 +130,7 @@ assets/memes// - バックグラウンド通信は、任意の LiteLLM 公開価格表の日次取得だけです。「旅するカエル」はユーザーが **出発**を押した場合にだけ Anthropic / OpenAI へ接続します。`OCTOPUS_NO_NET=1` は LLMPET の価格取得を止めますが、明示的に開始した CLI 旅行までは無効化しません。 - Electron は `contextIsolation` を有効、`nodeIntegration` を無効にしています。 - Claude hook の追加は既存設定を上書きせず、原子的かつ取り消し可能で、削除前にはバックアップを作成します。 +- **ログイン時に起動し、クラッシュ後に復旧**はトレイで明示的に有効化する永続設定で、既定では無効です。有効時に停止中のアプリへ届いた hook イベントはローカルへ待避され、復旧した server の listen 後に再送されます。**終了**を選ぶと、次回の明示的な起動まで hook による復活を抑止します。 ## 設定・開発用フラグ diff --git a/backend/config.js b/backend/config.js index bb11cdb..55ea607 100644 --- a/backend/config.js +++ b/backend/config.js @@ -28,6 +28,7 @@ const DEFAULTS = Object.freeze({ lang: 'zh', // 'zh' | 'en' | 'ja' — 界面与表情包文案语言 pinnedSessions: [], // 会话 HUD 置顶项(按稳定 session id) archivedSessions: [], // 会话 HUD 归档项(不影响后端任务本身) + startupRecovery: false, // explicit opt-in: login startup + hook crash recovery }); let cache = null; @@ -73,6 +74,7 @@ function sanitize(raw) { out.pinnedSessions = sanitizeSessionIds(raw.pinnedSessions); out.archivedSessions = sanitizeSessionIds(raw.archivedSessions) .filter((id) => !out.pinnedSessions.includes(id)); + out.startupRecovery = raw.startupRecovery === true; return out; } diff --git a/backend/hook-queue.js b/backend/hook-queue.js new file mode 100644 index 0000000..bec9b7d --- /dev/null +++ b/backend/hook-queue.js @@ -0,0 +1,135 @@ +'use strict'; + +// Durable handoff for the one hook event that discovers a stopped/crashed app. +// Each event is written as its own atomically-renamed file so concurrent Claude +// and Codex hooks cannot overwrite each other. The recovered app drains the +// queue only after its authenticated loopback server is listening. + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const PENDING_HOOK_DIR = path.join(os.homedir(), '.octopus', 'pending-hooks'); +const MAX_PENDING_EVENTS = 200; +const MAX_PENDING_EVENT_BYTES = 24 * 1024; + +function pendingFiles(directory = PENDING_HOOK_DIR) { + try { + return fs.readdirSync(directory) + .filter((name) => /^event-[\w.-]+\.json$/.test(name)) + .sort() + .map((name) => path.join(directory, name)); + } catch { + return []; + } +} + +function isQueuedPath(filePath, directory = PENDING_HOOK_DIR) { + if (!filePath) return false; + try { + return path.dirname(path.resolve(filePath)) === path.resolve(directory); + } catch { + return false; + } +} + +function removePendingHookEvent(filePath, options = {}) { + const directory = options.directory || PENDING_HOOK_DIR; + if (!isQueuedPath(filePath, directory)) return false; + try { fs.unlinkSync(filePath); return true; } catch { return false; } +} + +function prunePendingHookEvents(directory, limit = MAX_PENDING_EVENTS) { + const files = pendingFiles(directory); + const excess = Math.max(0, files.length - Math.max(1, limit)); + for (const filePath of files.slice(0, excess)) removePendingHookEvent(filePath, { directory }); +} + +function enqueueHookEvent(body, options = {}) { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; + const directory = options.directory || PENDING_HOOK_DIR; + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const id = options.id || `${process.pid}-${crypto.randomBytes(6).toString('hex')}`; + let payload; + try { payload = JSON.stringify({ version: 1, queuedAt: now, body }); } catch { return null; } + if (Buffer.byteLength(payload) > MAX_PENDING_EVENT_BYTES) return null; + + const base = `event-${String(now).padStart(16, '0')}-${String(id).replace(/[^\w.-]/g, '_')}`; + const tempPath = path.join(directory, `${base}.tmp`); + const finalPath = path.join(directory, `${base}.json`); + try { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(tempPath, payload, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempPath, finalPath); + try { fs.chmodSync(finalPath, 0o600); } catch {} + prunePendingHookEvents(directory, options.limit); + return finalPath; + } catch { + try { fs.unlinkSync(tempPath); } catch {} + return null; + } +} + +function readPendingHookEvent(filePath, directory) { + if (!isQueuedPath(filePath, directory)) return null; + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if ( + parsed && parsed.version === 1 && parsed.body + && typeof parsed.body === 'object' && !Array.isArray(parsed.body) + ) return parsed.body; + } catch {} + return null; +} + +function drainPendingHookEvents(options = {}, done = () => {}) { + const directory = options.directory || PENDING_HOOK_DIR; + const postState = options.postState; + const files = pendingFiles(directory); + let index = 0; + let delivered = 0; + + const finish = (failed = null) => { + done({ delivered, failed, remaining: pendingFiles(directory).length }); + }; + const next = () => { + if (index >= files.length) return finish(); + const filePath = files[index++]; + const body = readPendingHookEvent(filePath, directory); + if (!body) { + removePendingHookEvent(filePath, { directory }); + return next(); + } + if (typeof postState !== 'function') return finish(filePath); + + let settled = false; + try { + postState(body, (ok) => { + if (settled) return; + settled = true; + if (ok === true) { + removePendingHookEvent(filePath, { directory }); + delivered++; + next(); + } else { + finish(filePath); + } + }); + } catch { + if (!settled) finish(filePath); + } + }; + + next(); + return files.length; +} + +module.exports = { + PENDING_HOOK_DIR, + MAX_PENDING_EVENTS, + enqueueHookEvent, + removePendingHookEvent, + drainPendingHookEvents, + pendingFiles, +}; diff --git a/backend/server.js b/backend/server.js index 1c974be..8d9e023 100644 --- a/backend/server.js +++ b/backend/server.js @@ -153,6 +153,7 @@ function createServer(deps) { const core = deps.core; const permissions = deps.permissions; const shouldDropForDnd = typeof deps.shouldDropForDnd === 'function' ? deps.shouldDropForDnd : () => false; + const onListening = typeof deps.onListening === 'function' ? deps.onListening : null; let server = null; let activePort = null; @@ -327,6 +328,11 @@ function createServer(deps) { writeRuntimeConfig(activePort, activeToken); log('server', `listening on 127.0.0.1:${activePort}`); startRuntimeGuard(); + if (onListening) { + try { onListening({ port: activePort, token: activeToken }); } catch (err) { + log('server', 'onListening callback failed:', err.message); + } + } }); server.listen(ports[idx], '127.0.0.1'); diff --git a/backend/startup.js b/backend/startup.js index b4f74c5..5a61a26 100644 --- a/backend/startup.js +++ b/backend/startup.js @@ -1,9 +1,9 @@ 'use strict'; -// Keep the packaged desktop app available to the lightweight hook process. -// Login startup covers machine restarts; hook recovery covers an app process -// that was terminated after login. Recovery is deliberately rate-limited so a -// burst of lifecycle events cannot create a burst of Electron processes. +// When the user opts in, keep the packaged desktop app available to the +// lightweight hook process. Login startup covers machine restarts; hook +// recovery covers an unexpected process exit. Intentional Quit is persisted, +// and recovery is rate-limited so event bursts cannot spawn process bursts. const childProcess = require('child_process'); const fs = require('fs'); @@ -12,6 +12,8 @@ const path = require('path'); const RECOVERY_COOLDOWN_MS = 30_000; const RECOVERY_STAMP_PATH = path.join(os.homedir(), '.octopus', 'hook-recovery.json'); +const CONFIG_PATH = path.join(os.homedir(), '.octopus', 'config.json'); +const INTENTIONAL_QUIT_PATH = path.join(os.homedir(), '.octopus', 'intentional-quit.json'); function recoveryExecutable(hookDir, platform = process.platform) { if (!hookDir) return null; @@ -27,7 +29,7 @@ function ensureLoginStartup(electronApp, options = {}) { const executable = options.executable || process.execPath; if (!electronApp || !electronApp.isPackaged || !['win32', 'darwin'].includes(platform)) return false; - const settings = { openAtLogin: true }; + const settings = { openAtLogin: options.enabled === true }; if (platform === 'win32') { settings.path = executable; settings.args = ['--autostart']; @@ -36,6 +38,45 @@ function ensureLoginStartup(electronApp, options = {}) { return true; } +function startupRecoveryEnabled(options = {}) { + if (typeof options.enabled === 'boolean') return options.enabled; + const configPath = options.configPath || CONFIG_PATH; + const readFileSync = options.readFileSync || fs.readFileSync; + try { + const saved = JSON.parse(readFileSync(configPath, 'utf8')); + return saved && saved.startupRecovery === true; + } catch { + return false; + } +} + +function recoveryAllowed(options = {}) { + if (!startupRecoveryEnabled(options)) return false; + const quitPath = options.quitPath || INTENTIONAL_QUIT_PATH; + const existsSync = options.existsSync || fs.existsSync; + try { return !existsSync(quitPath); } catch { return false; } +} + +function recordIntentionalQuit(options = {}) { + const quitPath = options.quitPath || INTENTIONAL_QUIT_PATH; + const now = Number.isFinite(options.now) ? options.now : Date.now(); + try { + fs.mkdirSync(path.dirname(quitPath), { recursive: true }); + const tempPath = `${quitPath}.${process.pid}.${now}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify({ at: now }), { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempPath, quitPath); + try { fs.chmodSync(quitPath, 0o600); } catch {} + return true; + } catch { + return false; + } +} + +function clearIntentionalQuit(options = {}) { + const quitPath = options.quitPath || INTENTIONAL_QUIT_PATH; + try { fs.unlinkSync(quitPath); return true; } catch { return false; } +} + function recoverPackagedApp(options = {}) { const platform = options.platform || process.platform; const executable = options.executable || recoveryExecutable(options.hookDir, platform); @@ -48,6 +89,7 @@ function recoverPackagedApp(options = {}) { const writeFileSync = options.writeFileSync || fs.writeFileSync; const spawn = options.spawn || childProcess.spawn; + if (!recoveryAllowed({ ...options, existsSync })) return false; if (!executable || !existsSync(executable)) return false; try { @@ -72,7 +114,13 @@ function recoverPackagedApp(options = {}) { module.exports = { RECOVERY_COOLDOWN_MS, + CONFIG_PATH, + INTENTIONAL_QUIT_PATH, recoveryExecutable, ensureLoginStartup, + startupRecoveryEnabled, + recoveryAllowed, + recordIntentionalQuit, + clearIntentionalQuit, recoverPackagedApp, }; diff --git a/backend/window-drag.js b/backend/window-drag.js index 6bc1f36..79a1a93 100644 --- a/backend/window-drag.js +++ b/backend/window-drag.js @@ -37,11 +37,12 @@ function nextDragBounds(drag, cursorPoint) { return { x, y, width: drag.width, height: drag.height }; } -// Popups temporarily grow the transparent BrowserWindow around the pet. Keep -// the visible pet anchored at the same screen-space center and bottom instead -// of clamping the whole transparent rectangle into the work area: near the -// right edge that clamp pulled the pet left on every status bubble. -function resizePetBounds(windowBounds, intendedSize, workArea) { +// Popups temporarily grow the transparent BrowserWindow around the pet. The +// outer window must remain inside the display work area, while a renderer-side +// content offset keeps the visible pet anchored at the same screen-space +// center and bottom. Carrying the previous offset makes expand -> collapse +// exactly reversible even when the expanded window was clamped at an edge. +function resizePetLayout(windowBounds, intendedSize, workArea, contentOffset = { x: 0, y: 0 }) { if ( !finitePoint(windowBounds) || !Number.isFinite(windowBounds.width) @@ -53,22 +54,35 @@ function resizePetBounds(windowBounds, intendedSize, workArea) { throw new TypeError('window bounds and intended size must be finite'); } - const width = Math.max(1, Math.round(intendedSize.width)); + let width = Math.max(1, Math.round(intendedSize.width)); let height = Math.max(1, Math.round(intendedSize.height)); - const centerX = windowBounds.x + windowBounds.width / 2; - const bottom = windowBounds.y + windowBounds.height; - let y = Math.round(bottom - height); + const offsetX = Number.isFinite(contentOffset.x) ? contentOffset.x : 0; + const offsetY = Number.isFinite(contentOffset.y) ? contentOffset.y : 0; + const centerX = windowBounds.x + windowBounds.width / 2 + offsetX; + const bottom = windowBounds.y + windowBounds.height + offsetY; - if ( + const validWorkArea = ( workArea && finitePoint(workArea) && Number.isFinite(workArea.width) && Number.isFinite(workArea.height) && workArea.width > 0 && workArea.height > 0 - ) { + ); + if (validWorkArea) { + width = Math.min(width, Math.round(workArea.width)); height = Math.min(height, Math.round(workArea.height)); - y = Math.round(bottom - height); + } + + const idealX = Math.round(centerX - width / 2); + const idealY = Math.round(bottom - height); + let x = idealX; + let y = idealY; + if (validWorkArea) { + x = Math.min( + Math.max(x, Math.round(workArea.x)), + Math.round(workArea.x + workArea.width - width), + ); y = Math.min( Math.max(y, Math.round(workArea.y)), Math.round(workArea.y + workArea.height - height), @@ -76,11 +90,13 @@ function resizePetBounds(windowBounds, intendedSize, workArea) { } return { - x: Math.round(centerX - width / 2), - y, - width, - height, + bounds: { x, y, width, height }, + contentOffset: { x: idealX - x, y: idealY - y }, }; } -module.exports = { beginDrag, nextDragBounds, resizePetBounds }; +function resizePetBounds(windowBounds, intendedSize, workArea) { + return resizePetLayout(windowBounds, intendedSize, workArea).bounds; +} + +module.exports = { beginDrag, nextDragBounds, resizePetLayout, resizePetBounds }; diff --git a/hook/octopus-hook.js b/hook/octopus-hook.js index 55e876d..74b5062 100644 --- a/hook/octopus-hook.js +++ b/hook/octopus-hook.js @@ -14,7 +14,11 @@ const transport = require('../backend/transport'); const transcript = require('../backend/transcript'); const pidwalk = require('../backend/pidwalk'); const { detectEmotion } = require('../backend/emotion'); -const { recoverPackagedApp } = require('../backend/startup'); +const { recoveryAllowed, recoverPackagedApp } = require('../backend/startup'); +const { + enqueueHookEvent, + removePendingHookEvent, +} = require('../backend/hook-queue'); const EVENT_STATE = { SessionStart: 'idle', @@ -176,6 +180,60 @@ function buildBody(event, p, source = 'claude') { return body; } +function deliverStateWithRecovery(body, options = {}) { + const postState = options.postState || transport.postState; + const canRecover = options.canRecover || (() => recoveryAllowed()); + const enqueue = options.enqueue || ((eventBody) => enqueueHookEvent(eventBody)); + const discard = options.discard || ((filePath) => removePendingHookEvent(filePath)); + const recover = options.recover || (() => recoverPackagedApp({ hookDir: __dirname })); + const finish = typeof options.finish === 'function' ? options.finish : () => {}; + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 250; + const setTimer = options.setTimer || setTimeout; + const clearTimer = options.clearTimer || clearTimeout; + let finished = false; + let delivered = false; + let queuedPath = null; + let timer = null; + + const complete = (ok) => { + if (finished) return; + finished = true; + if (timer) clearTimer(timer); + finish(ok); + }; + const queueAndRecover = () => { + if (delivered || !canRecover()) return false; + if (!queuedPath) queuedPath = enqueue(body); + if (!queuedPath) return false; + recover(); + return true; + }; + + try { + postState(body, (ok) => { + if (ok === true) { + delivered = true; + if (queuedPath) discard(queuedPath); + complete(true); + return; + } + queueAndRecover(); + complete(false); + }); + } catch { + queueAndRecover(); + complete(false); + } + + if (!finished) { + timer = setTimer(() => { + queueAndRecover(); + complete(false); + }, timeoutMs); + if (timer && typeof timer.unref === 'function') timer.unref(); + } +} + function main() { const event = process.argv[2]; const source = process.argv[3] === 'codex' ? 'codex' : 'claude'; @@ -195,24 +253,9 @@ function main() { let body; try { body = buildBody(event, payload || {}, source); } catch { body = null; } if (!body) return finish(); - let delivered = false; - const recover = () => { - if (!delivered) recoverPackagedApp({ hookDir: __dirname }); - }; - transport.postState(body, (ok) => { - delivered = ok === true; - if (!delivered) recover(); - finish(); - }); - setTimeout(() => { - // A stale runtime file can make transport exhaust several dead ports. Do - // not hold up Codex/Claude; wake the packaged app and let the next event - // arrive through the newly restored server. - recover(); - finish(); - }, 250); // never hang Claude Code + deliverStateWithRecovery(body, { finish }); }).catch(finish); } if (require.main === module) main(); -module.exports = { buildBody, codexSuccessOutput, EVENT_STATE }; +module.exports = { buildBody, codexSuccessOutput, deliverStateWithRecovery, EVENT_STATE }; diff --git a/main.js b/main.js index 0b518cd..24a8208 100644 --- a/main.js +++ b/main.js @@ -36,8 +36,13 @@ const { createTravelManager } = require('./backend/travel'); const { machineGrowth } = require('./backend/growth'); const { publicCatalog, getMeme, watchCatalog } = require('./backend/meme-catalog'); const { createCommandDispatcher, routeForSession } = require('./backend/command-dispatch'); -const { beginDrag, nextDragBounds, resizePetBounds } = require('./backend/window-drag'); -const { ensureLoginStartup } = require('./backend/startup'); +const { beginDrag, nextDragBounds, resizePetLayout } = require('./backend/window-drag'); +const { + ensureLoginStartup, + recordIntentionalQuit, + clearIntentionalQuit, +} = require('./backend/startup'); +const { drainPendingHookEvents } = require('./backend/hook-queue'); const transport = require('./backend/transport'); const i18n = require('./shared/i18n'); @@ -87,6 +92,10 @@ const primaryVisualRect = () => { const st = primaryPetState(); return st ? st.v let lastStats = null; // 全量快照(面板用;single 模式也是主宠的快照) let statsTimer = null; +let hookDrainTimer = null; +let hookDrainPollTimer = null; +let hookDrainActive = false; +let appQuitting = false; let emitDebounce = null; const recentOps = []; // ring for the panel "操作流"; newest first, capped @@ -110,6 +119,7 @@ function frontendConfig(agent = 'all') { lang: c.lang, pinnedSessions: c.pinnedSessions, archivedSessions: c.archivedSessions, + startupRecovery: c.startupRecovery, }; } @@ -130,16 +140,18 @@ function applyPetSize(st) { const win = st.win; const { w, h } = targetSize(st); const b = win.getBounds(); - // Cap the window to the screen's work area so a tall popup can NEVER push the - // pet / footer buttons off-screen — the popup scrolls internally instead. - // Do not horizontally clamp the transparent outer window: preserving its - // center is what keeps a manually positioned pet still when a bubble widens. + let layout; + // Keep the outer window visible; compensate only the pet visual layer so its + // dragged screen-space anchor remains unchanged at display edges. try { const wa = screen.getDisplayMatching(b).workArea; - win.setBounds(resizePetBounds(b, { width: w, height: h }, wa)); + layout = resizePetLayout(b, { width: w, height: h }, wa, st.contentOffset); } catch { - win.setBounds(resizePetBounds(b, { width: w, height: h })); + layout = resizePetLayout(b, { width: w, height: h }, null, st.contentOffset); } + win.setBounds(layout.bounds); + st.contentOffset = layout.contentOffset; + sendWin(win, 'pet:content-offset', st.contentOffset); } // 双宠开关:single 一只宠盯全部后端;duo Claude/Codex 各一只(形象/位置独立) @@ -193,7 +205,7 @@ function makePetWindow(agent) { // mouseIgnoring=true:透明窗启动即穿透,renderer 命中测试后再接管(pet.js 同款默认) const st = { agent, win, customSize: null, visualRect: null, uiBusy: false, mouseIgnoring: true, - drag: null, resizeAfterDrag: false, + drag: null, resizeAfterDrag: false, contentOffset: { x: 0, y: 0 }, }; // 'closed' 之后绝不能再碰 win.webContents(抛 "Object has been destroyed",主进程 // 未捕获直接崩)——id 在创建时取好。收起一只宠是独立事件,只清自己的状态。 @@ -217,6 +229,7 @@ function makePetWindow(agent) { }); win.webContents.on('did-finish-load', () => { sendWin(win, 'pet:config', frontendConfig(st.agent)); + sendWin(win, 'pet:content-offset', st.contentOffset); if (core) sendWin(win, 'pet:stats', buildStats(st.agent)); }); return win; @@ -615,6 +628,20 @@ function startMemeWatcher() { } // ── backend wiring ──────────────────────────────────────────────────────────── +function schedulePendingHookDrain(delayMs = 0) { + if (appQuitting || hookDrainTimer || hookDrainActive) return; + hookDrainTimer = setTimeout(() => { + hookDrainTimer = null; + hookDrainActive = true; + drainPendingHookEvents({ postState: transport.postState }, (result) => { + hookDrainActive = false; + if (result.delivered) log('main', `replayed ${result.delivered} pending hook event(s)`); + if (result.remaining > 0) schedulePendingHookDrain(1000); + }); + }, Math.max(0, delayMs)); + if (hookDrainTimer.unref) hookDrainTimer.unref(); +} + function bootBackend() { core = createCore({ onActivity: (act) => { @@ -753,8 +780,13 @@ function bootBackend() { core, permissions, shouldDropForDnd: () => false, + onListening: () => schedulePendingHookDrain(), }); server.start(); + hookDrainPollTimer = setInterval(() => { + if (server && server.getPort()) schedulePendingHookDrain(); + }, 5000); + if (hookDrainPollTimer.unref) hookDrainPollTimer.unref(); // Install hooks once the server has a port (defer so listen wins the race). // OCTOPUS_NO_HOOKS=1 skips touching Claude and Codex hook config (dev/verify mode). @@ -864,7 +896,7 @@ function registerIpc() { ipcMain.on('territory-run-now', runTerritoryNow); ipcMain.on('territory-toggle-auto', () => applyTerritory(!config.get().territory)); - ipcMain.on('quit-app', () => app.quit()); + ipcMain.on('quit-app', quitAppIntentionally); // 双宠模式:收起自己这只(独立事件——另一只和 app 都不受影响); // 托盘「显示桌宠」或勾选「Codex 桌宠」随时找回来。 ipcMain.on('close-pet', (e) => { @@ -1123,6 +1155,7 @@ function ensurePetWindows() { if (st && st.agent !== primaryAgent) { st.agent = primaryAgent; st.customSize = null; st.visualRect = null; st.uiBusy = false; st.mouseIgnoring = true; + st.contentOffset = { x: 0, y: 0 }; petWin.loadFile(path.join(__dirname, 'renderer', 'pet.html'), { query: { agent: primaryAgent } }); applyPetSize(st); } @@ -1152,6 +1185,25 @@ function applyBudget(v) { refreshTrayMenu(); } +function applyStartupRecovery(on) { + const enabled = on === true; + config.save({ startupRecovery: enabled }); + if (enabled) clearIntentionalQuit(); + try { + ensureLoginStartup(app, { enabled }); + } catch (e) { + log('main', 'login startup unavailable:', e.message); + } + broadcastConfig(); + refreshTrayMenu(); + log('main', `startup recovery ${enabled ? 'enabled' : 'disabled'}`); +} + +function quitAppIntentionally() { + recordIntentionalQuit(); + app.quit(); +} + // Language switch (tray → Settings → Language). Main-process copy is baked into // the strings the adapter already pushed, so a plain re-broadcast would leave // stale labels on screen until the next session event — force a fresh stats @@ -1202,6 +1254,8 @@ function refreshTrayMenu() { { label: t('tray.language'), submenu: i18n.LANGS.map((code) => ({ label: t('lang.' + code), type: 'radio', checked: lang === code, click: () => applyLang(code), })) }, + { label: t('tray.startupRecovery'), type: 'checkbox', checked: cfg.startupRecovery === true, + click: () => applyStartupRecovery(config.get().startupRecovery !== true) }, { label: petMode === 'duo' ? t('tray.skinClaude') : t('tray.skin'), submenu: [ { label: t('skin.mascot'), type: 'radio', checked: skin === 'mascot', click: () => applySkin('mascot') }, { label: t('skin.pixel'), type: 'radio', checked: skin === 'pixel', click: () => applySkin('pixel') }, @@ -1246,7 +1300,7 @@ function refreshTrayMenu() { try { if (stopWatcher) { stopWatcher(); stopWatcher = null; } } catch {} hooks.uninstall(); } }, - { label: t('tray.quit'), click: () => app.quit() }, + { label: t('tray.quit'), click: quitAppIntentionally }, ])); } @@ -1295,8 +1349,10 @@ if (!gotTheLock) { app.on('second-instance', () => { try { for (const st of petStates()) st.win.show(); } catch {} }); app.whenReady().then(async () => { if (process.platform === 'darwin' && app.dock) app.dock.hide(); + if (!process.argv.includes('--hook-recovery')) clearIntentionalQuit(); try { - if (ensureLoginStartup(app)) log('main', 'login startup enabled'); + const enabled = config.get().startupRecovery === true; + if (ensureLoginStartup(app, { enabled })) log('main', `login startup ${enabled ? 'enabled' : 'disabled'}`); } catch (e) { log('main', 'login startup unavailable:', e.message); } const rival = await findRivalInstance(); if (rival) { @@ -1322,6 +1378,9 @@ if (!gotTheLock) { app.on('window-all-closed', () => { /* tray app: stay alive */ }); app.on('before-quit', () => { + appQuitting = true; + try { if (hookDrainTimer) { clearTimeout(hookDrainTimer); hookDrainTimer = null; } } catch {} + try { if (hookDrainPollTimer) { clearInterval(hookDrainPollTimer); hookDrainPollTimer = null; } } catch {} try { if (territory) territory.stop(); } catch {} try { if (travelManager) travelManager.shutdown(); } catch {} try { if (codexWatch) codexWatch.stop(); } catch {} diff --git a/preload.js b/preload.js index 6585c71..bdfdcdc 100644 --- a/preload.js +++ b/preload.js @@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld('pet', { onStats: (cb) => ipcRenderer.on('pet:stats', (_e, data) => cb(data)), onMeme: (cb) => ipcRenderer.on('pet:meme', (_e, data) => cb(data)), onTravel: (cb) => ipcRenderer.on('pet:travel', (_e, data) => cb(data)), + onContentOffset: (cb) => ipcRenderer.on('pet:content-offset', (_e, data) => cb(data)), onMemeCatalogChanged: (cb) => ipcRenderer.on('pet:meme-catalog-changed', (_e, data) => cb(data)), onPanelStats: (cb) => ipcRenderer.on('panel:stats', (_e, data) => cb(data)), onConfig: (cb) => { diff --git a/renderer/pet.css b/renderer/pet.css index f6fd76b..caba7f9 100644 --- a/renderer/pet.css +++ b/renderer/pet.css @@ -20,6 +20,10 @@ html, body { justify-content: flex-end; } +.pet-anchor { + translate: var(--pet-content-offset-x, 0px) var(--pet-content-offset-y, 0px); +} + /* ---------- 选项面板(点选答案) ---------- */ .ask { max-width: 290px; diff --git a/renderer/pet.html b/renderer/pet.html index 8308534..8b91fcb 100644 --- a/renderer/pet.html +++ b/renderer/pet.html @@ -9,7 +9,7 @@
- -
+
💭 思考中
-
+
z z Z @@ -56,50 +56,50 @@ - + -
+
-