diff --git a/README.md b/README.md index 3b39c9c..0aef59c 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,17 +116,17 @@ 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,不带项目、不带任务和工具,让猫猫自己随便想想、聊聊。 - **右键** = 泡泡菜单;**拖动** = 移动位置。等授权/等回复时会**自动**弹允许/拒绝气泡。 -- 托盘菜单可开详情面板、静音、唤起 Claude、打开日志、**卸载钩子**、退出。 +- 托盘菜单可开详情面板、静音、唤起 Claude、打开日志、**卸载钩子**、退出。可选的“开机启动并在崩溃后恢复”默认关闭;开启后,发送失败的钩子事件会先落盘,服务恢复监听后再投递。主动点“退出”会阻止钩子复活进程,直到下次手动启动。 - 详情面板里可切皮肤 / 模式 / 设 5h 预算。 - **🥊 领地模式**(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..638b579 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,19 @@ 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. +- **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 -- `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..03ce1cf 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,19 @@ 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 の追加は既存設定を上書きせず、原子的かつ取り消し可能で、削除前にはバックアップを作成します。 +- **ログイン時に起動し、クラッシュ後に復旧**はトレイで明示的に有効化する永続設定で、既定では無効です。有効時に停止中のアプリへ届いた hook イベントはローカルへ待避され、復旧した server の listen 後に再送されます。**終了**を選ぶと、次回の明示的な起動まで 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..75c4b35 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 只 @@ -156,6 +157,9 @@ function createCodexWatch(deps) { // offset,会把整份 rollout 当成新会话从头重放,触发旧欢迎/完成/气泡风暴。 // 启动首轮也会记录所有历史文件的 EOF,使旧会话再次活跃时只吃新增内容。 const cursors = new Map(); + // Windows 上 LastWriteTime 可能被延迟、缓存或保留。活跃判断以实际字节增长 + // 和 rollout 最后一条带 timestamp 的事件为主,mtime 只作为兼容兜底。 + const fileActivity = new Map(); let timer = null; let booted = false; // 首轮扫描 = backfill;之后的新文件才是「新会话」 let tickCount = 0; // 全量扫描节拍(FULL_SWEEP_TICKS 轮一次) @@ -177,10 +181,41 @@ function createCodexWatch(deps) { return dirs; } - function statEntry(fp) { + function readLastEventAt(fp, size) { + const start = Math.max(0, size - TAIL_PROBE_BYTES); + const tail = readBytes(fp, start, size - start); + if (!tail) return 0; + const lines = tail.toString('utf8').split('\n'); + if (start > 0) lines.shift(); + for (let i = lines.length - 1; i >= 0; i--) { + const obj = parseLine(lines[i]); + const at = Date.parse(obj && obj.timestamp); + if (Number.isFinite(at)) return at; + } + return 0; + } + + function statEntry(fp, now = Date.now()) { try { const st = fs.statSync(fp); - return { fp, size: st.size, mtimeMs: st.mtimeMs }; + const previous = fileActivity.get(fp); + const sizeChanged = !!previous && previous.size !== st.size; + const lastEventAt = !previous || sizeChanged + ? readLastEventAt(fp, st.size) + : previous.lastEventAt; + const growthAt = sizeChanged ? now : (previous && previous.growthAt || 0); + fileActivity.set(fp, { size: st.size, lastEventAt, growthAt }); + const mtimeFallback = now - st.mtimeMs <= IDLE_UNTRACK_MS ? st.mtimeMs : 0; + const activityAt = Math.max(lastEventAt || 0, growthAt || 0, mtimeFallback); + return { + fp, + size: st.size, + mtimeMs: st.mtimeMs, + lastEventAt, + growthAt, + activityAt, + active: activityAt > 0 && now - activityAt <= IDLE_UNTRACK_MS, + }; } catch { return null; } } @@ -198,7 +233,7 @@ function createCodexWatch(deps) { return out; } - // 全量扫描:递归所有 年/月/日 目录,只收 1h 内仍在写的文件。 + // 全量扫描:递归所有 年/月/日 目录,只收仍有字节增长或近期事件的文件。 // rollout 永远留在「会话开始日」的目录里——ChatGPT Desktop 一个对话连聊几天, // 文件还在 5 天前的目录里被追加(实测踩坑)。只扫今天/昨天永远看不见它, // 所以启动第一轮 + 之后每 FULL_SWEEP_TICKS 轮做一次全量兜底。 @@ -224,7 +259,7 @@ function createCodexWatch(deps) { const e = statEntry(path.join(sessionsDir, y, m, d, n)); if (!e) continue; if (onFile) onFile(e); - if (now - e.mtimeMs <= IDLE_UNTRACK_MS) out.push(e); + if (e.active) out.push(e); } } } @@ -235,6 +270,7 @@ function createCodexWatch(deps) { function baseFields(t) { const f = { agentId: 'codex', + eventSource: 'codex-rollout', headless: false, transcriptPath: t.fp, }; @@ -244,8 +280,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) { @@ -263,6 +323,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; @@ -270,12 +340,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; @@ -314,6 +383,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) { @@ -327,13 +400,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 @@ -352,6 +429,10 @@ function createCodexWatch(deps) { break; } case 'turn_aborted': + if (t.isSubagent) { + finishSubagent(t); + break; + } update(t, 'idle', 'TurnAborted'); t.turnActive = false; t.didWorkThisTurn = false; @@ -383,8 +464,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; @@ -404,14 +487,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) { @@ -423,13 +510,143 @@ function createCodexWatch(deps) { if (!t.sid) t.sid = fileSessionId(t.fp, null); } - // ── 启动 backfill:头部读 meta、尾部读近况,静默入库 ───────────────────────── - function backfill(t, size, mtimeMs) { + 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, entry) { + const { size, mtimeMs, activityAt, lastEventAt } = entry; 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; // 太久没动的不上列表 + if (!activityAt || Date.now() - activityAt > BACKFILL_MAX_AGE_MS) return; let title = null; let contextUsage = null; @@ -440,31 +657,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, lastEventAt || 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 || lastEventAt || activityAt || 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 ──────────────────────────────── @@ -489,8 +714,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, }; } @@ -508,7 +734,7 @@ function createCodexWatch(deps) { if (fullSweep) { const seen = new Set(found.map((f) => f.fp)); // 首轮把所有历史 rollout 的 EOF 记下来。之后某个旧文件重新活跃时, - // 可以从这个游标继续,而不是因为 mtime 变新就误当作全新文件重放。 + // 可以从这个游标继续,而不是把后续字节增长误当作全新文件重放。 const rememberAtBoot = !booted ? (f) => { if (!cursors.has(f.fp)) cursors.set(f.fp, { offset: f.size, carry: '' }); } : null; @@ -519,14 +745,17 @@ function createCodexWatch(deps) { return; } // ① 发现新文件 → 建 tracker(启动第一轮走静默 backfill,之后按新会话走事件流) - for (const { fp, size, mtimeMs } of found) { + const initialSeeds = []; + for (const entry of found) { + const { fp, size } = entry; if (trackers.has(fp)) continue; - if (now - mtimeMs > IDLE_UNTRACK_MS) continue; // 陈年文件不建 tracker + if (!entry.active) continue; const prior = booted ? cursors.get(fp) : null; const t = newTracker(fp, prior); trackers.set(fp, t); if (!booted) { - backfill(t, size, mtimeMs); + const candidate = backfill(t, entry); + if (candidate) initialSeeds.push(candidate); } else if (prior) { // 旧/暂停会话恢复:meta 只用于恢复身份,不派发 SessionStart;随后只泵增量。 hydrateMeta(t); @@ -536,22 +765,17 @@ function createCodexWatch(deps) { log('codex', `new rollout: ${path.basename(fp)}`); } } + if (!booted) seedBackfill(initialSeeds); // ② 泵所有已跟踪文件——直接 stat,不依赖本轮扫描列表:旧日期目录里的 // 长寿会话只在全量扫描轮被「发现」,但每一轮都要跟进它的新增内容。 for (const [fp, t] of trackers) { const e = statEntry(fp); if (!e) { trackers.delete(fp); continue; } // 文件没了 - if (now - e.mtimeMs > IDLE_UNTRACK_MS) { + if (!e.active) { cursors.set(fp, { offset: t.offset, carry: t.carry }); 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; @@ -569,7 +793,15 @@ function createCodexWatch(deps) { if (timer) { clearInterval(timer); timer = null; } } - return { start, stop, tick, getRateLimits: () => rateLimits, _trackers: trackers, _cursors: cursors }; + return { + start, + stop, + tick, + getRateLimits: () => rateLimits, + _trackers: trackers, + _cursors: cursors, + _fileActivity: fileActivity, + }; } module.exports = { createCodexWatch, toContextUsage, toRateLimits, mapTool }; diff --git a/backend/config.js b/backend/config.js index e43a824..55ea607 100644 --- a/backend/config.js +++ b/backend/config.js @@ -23,10 +23,12 @@ 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) archivedSessions: [], // 会话 HUD 归档项(不影响后端任务本身) + startupRecovery: false, // explicit opt-in: login startup + hook crash recovery }); let cache = null; @@ -59,6 +61,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) }; } @@ -66,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/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/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/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..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; @@ -208,7 +209,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), @@ -326,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 new file mode 100644 index 0000000..5a61a26 --- /dev/null +++ b/backend/startup.js @@ -0,0 +1,126 @@ +'use strict'; + +// 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'); +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'); +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; + 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: options.enabled === true }; + if (platform === 'win32') { + settings.path = executable; + settings.args = ['--autostart']; + } + electronApp.setLoginItemSettings(settings); + 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); + 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 (!recoveryAllowed({ ...options, existsSync })) return false; + 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, + CONFIG_PATH, + INTENTIONAL_QUIT_PATH, + recoveryExecutable, + ensureLoginStartup, + startupRecoveryEnabled, + recoveryAllowed, + recordIntentionalQuit, + clearIntentionalQuit, + recoverPackagedApp, +}; diff --git a/backend/window-drag.js b/backend/window-drag.js new file mode 100644 index 0000000..a9a2d6d --- /dev/null +++ b/backend/window-drag.js @@ -0,0 +1,132 @@ +'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 }; +} + +// BrowserWindow.setShape() uses window-local DIP rectangles on Windows. Keep +// every visible renderer rectangle inside the window and add enough breathing +// room for CSS transforms/drop shadows so animated pets do not clip or lose +// their native mouse hit target at the edge of a frame. +function normalizeHitRegions(regions, windowSize, padding = 16) { + if (!Array.isArray(regions) || !windowSize) return []; + const maxWidth = Math.max(0, Math.round(Number(windowSize.width) || 0)); + const maxHeight = Math.max(0, Math.round(Number(windowSize.height) || 0)); + if (!maxWidth || !maxHeight) return []; + const pad = Math.max(0, Math.min(32, Math.round(Number(padding) || 0))); + const normalized = []; + for (const rect of regions.slice(0, 64)) { + if (!rect || ![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) continue; + if (!(rect.width > 0) || !(rect.height > 0)) continue; + const left = Math.max(0, Math.floor(rect.x - pad)); + const top = Math.max(0, Math.floor(rect.y - pad)); + const right = Math.min(maxWidth, Math.ceil(rect.x + rect.width + pad)); + const bottom = Math.min(maxHeight, Math.ceil(rect.y + rect.height + pad)); + if (right <= left || bottom <= top) continue; + normalized.push({ x: left, y: top, width: right - left, height: bottom - top }); + } + return normalized; +} + +// 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) + || !Number.isFinite(windowBounds.height) + || !intendedSize + || !Number.isFinite(intendedSize.width) + || !Number.isFinite(intendedSize.height) + ) { + throw new TypeError('window bounds and intended size must be finite'); + } + + let width = Math.max(1, Math.round(intendedSize.width)); + let height = Math.max(1, Math.round(intendedSize.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; + + 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)); + } + + 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), + ); + } + + return { + bounds: { x, y, width, height }, + contentOffset: { x: idealX - x, y: idealY - y }, + }; +} + +function resizePetBounds(windowBounds, intendedSize, workArea) { + return resizePetLayout(windowBounds, intendedSize, workArea).bounds; +} + +module.exports = { + beginDrag, + nextDragBounds, + normalizeHitRegions, + resizePetLayout, + resizePetBounds, +}; diff --git a/hook/octopus-hook.js b/hook/octopus-hook.js index a4cefed..74b5062 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 @@ -12,6 +14,11 @@ const transport = require('../backend/transport'); const transcript = require('../backend/transcript'); const pidwalk = require('../backend/pidwalk'); const { detectEmotion } = require('../backend/emotion'); +const { recoveryAllowed, recoverPackagedApp } = require('../backend/startup'); +const { + enqueueHookEvent, + removePendingHookEvent, +} = require('../backend/hook-queue'); const EVENT_STATE = { SessionStart: 'idle', @@ -19,6 +26,7 @@ const EVENT_STATE = { UserPromptSubmit: 'thinking', PreToolUse: 'working', PostToolUse: 'working', + PermissionRequest: 'notification', PostToolUseFailure: 'error', Stop: 'attention', StopFailure: 'error', @@ -51,9 +59,18 @@ function readStdin() { function count(v) { return Array.isArray(v) ? v.length : 0; } -function buildBody(event, p) { +// 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; + 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 +83,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 +107,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 +138,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 +164,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; @@ -151,17 +180,82 @@ function buildBody(event, p) { 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'; 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 || {}); } 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)); + try { body = buildBody(event, payload || {}, source); } catch { body = null; } + if (!body) return finish(); + deliverStateWithRecovery(body, { finish }); + }).catch(finish); } if (require.main === module) main(); -module.exports = { buildBody, EVENT_STATE }; +module.exports = { buildBody, codexSuccessOutput, deliverStateWithRecovery, EVENT_STATE }; diff --git a/main.js b/main.js index 490c06a..d574772 100644 --- a/main.js +++ b/main.js @@ -30,11 +30,24 @@ 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, + normalizeHitRegions, + 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'); @@ -59,11 +72,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,11 +92,15 @@ 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 模式也是主宠的快照) 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 @@ -100,9 +120,11 @@ 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, + startupRecovery: c.startupRecovery, }; } @@ -119,26 +141,22 @@ 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); + 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. + 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; - 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 }); + layout = resizePetLayout(b, { width: w, height: h }, wa, st.contentOffset); } catch { - const bottom = b.y + b.height; - win.setBounds({ x: b.x, y: Math.round(bottom - h), 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 各一只(形象/位置独立) @@ -190,7 +208,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, contentOffset: { x: 0, y: 0 }, + }; // 'closed' 之后绝不能再碰 win.webContents(抛 "Object has been destroyed",主进程 // 未捕获直接崩)——id 在创建时取好。收起一只宠是独立事件,只清自己的状态。 const wcId = win.webContents.id; @@ -213,6 +234,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; @@ -573,6 +595,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')); @@ -593,6 +633,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) => { @@ -627,7 +681,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 +695,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(); @@ -722,14 +785,19 @@ 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/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 +843,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); @@ -816,7 +901,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) => { @@ -1014,6 +1099,14 @@ function registerIpc() { const st = stateOfSender(e.sender); const w = st && st.win && !st.win.isDestroyed() ? st.win : null; if (!w) return; + // Windows Terminal does not reliably forward mousemove back through an + // ignored transparent Electron window. Native setShape regions below make + // only painted pet/UI areas hittable, so the whole window must stay enabled. + if (process.platform === 'win32') { + st.mouseIgnoring = !!ignore; + try { w.setIgnoreMouseEvents(false); } catch {} + return; + } st.mouseIgnoring = !!ignore; // 记录 renderer 期望的穿透状态(巡视结束后恢复用) // 巡视拖拽期间主宠强制穿透:renderer 只能更新“结束后想要的状态”, // 不能把最高层章鱼重新变成可点击并挡住目标。Codex 分身不受巡视约束。 @@ -1022,6 +1115,26 @@ function registerIpc() { }); // 渲染端上报「用户正在交互」(领地模式据此避战/撤退,别的场景以后也能用) + // On Windows use the compositor's native window region for both drawing and + // hit testing. Pixels outside these visible renderer rectangles fall through + // directly to Terminal/other apps without relying on forwarded mousemove. + ipcMain.on('pet-hit-regions', (e, regions) => { + if (process.platform !== 'win32') return; + const st = stateOfSender(e.sender); + const w = st && st.win && !st.win.isDestroyed() ? st.win : null; + if (!w) return; + const bounds = w.getBounds(); + const shape = normalizeHitRegions(regions, { width: bounds.width, height: bounds.height }); + if (!shape.length) return; + const shapeKey = shape.map((rect) => `${rect.x},${rect.y},${rect.width},${rect.height}`).join(';'); + if (shapeKey === st.hitShapeKey) return; + try { + w.setIgnoreMouseEvents(false); + w.setShape(shape); + st.hitShapeKey = shapeKey; + } catch {} + }); + ipcMain.on('ui-busy', (e, on) => { const st = stateOfSender(e.sender); if (st) st.uiBusy = !!on; @@ -1055,6 +1168,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 宠不闪。 @@ -1068,6 +1188,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); } @@ -1097,6 +1218,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 @@ -1133,6 +1273,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([ @@ -1146,6 +1287,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') }, @@ -1156,6 +1299,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') }, @@ -1186,7 +1333,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 }, ])); } @@ -1235,6 +1382,11 @@ 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 { + 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) { log('main', `another LLMPET server is live on 127.0.0.1:${rival} — quitting (OCTOPUS_ALLOW_MULTI=1 to bypass)`); @@ -1259,9 +1411,13 @@ 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 {} + 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..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/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/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/preload.js b/preload.js index 9291f7c..35aaf9a 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) => { @@ -30,9 +31,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'), @@ -51,6 +53,7 @@ contextBridge.exposeInMainWorld('pet', { primaryAction: () => ipcRenderer.send('primary-action'), // 透明空白处点击穿透:渲染端命中测试后切换(true=穿透,鼠标事件仍转发回来) setIgnoreMouse: (ignore) => ipcRenderer.send('set-ignore-mouse', ignore), + setHitRegions: (regions) => ipcRenderer.send('pet-hit-regions', regions), // 选项面板需要更高窗口 setPetTall: (tall) => ipcRenderer.send('pet-tall', tall), // 记事本行动中心需要一大块区域 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 52b1d43..8b91fcb 100644 --- a/renderer/pet.html +++ b/renderer/pet.html @@ -9,7 +9,7 @@
- -
+
💭 思考中
-
+
z z Z @@ -56,50 +56,50 @@ - + -
+
-