+
📋
@@ -227,6 +227,7 @@
+
diff --git a/renderer/pet.js b/renderer/pet.js
index 6f4cfc9..728ed9b 100644
--- a/renderer/pet.js
+++ b/renderer/pet.js
@@ -7,6 +7,14 @@ const AGENT = new URLSearchParams(location.search).get('agent') || 'all';
const AGENT_LABEL = { claude: 'Claude', codex: 'Codex' }[AGENT] || '';
const stage = document.getElementById('stage');
+if (window.pet && typeof window.pet.onContentOffset === 'function') {
+ window.pet.onContentOffset((offset = {}) => {
+ const x = Number.isFinite(offset.x) ? offset.x : 0;
+ const y = Number.isFinite(offset.y) ? offset.y : 0;
+ stage.style.setProperty('--pet-content-offset-x', `${x}px`);
+ stage.style.setProperty('--pet-content-offset-y', `${y}px`);
+ });
+}
const pixel = document.getElementById('pixel');
const mascot = document.getElementById('mascot');
const mascotImg = document.getElementById('mascot-img');
@@ -183,6 +191,7 @@ const bubble = document.getElementById('bubble');
const bubbleText = document.getElementById('bubble-text');
const chipCost = document.getElementById('chip-cost');
const chipWindow = document.getElementById('chip-window');
+const chipSep = document.getElementById('chip-sep');
const chip = document.getElementById('chip');
const sessionsEl = document.getElementById('sessions');
const radial = document.getElementById('radial');
@@ -1863,6 +1872,7 @@ let transientUntil = 0; // 短暂状态(happy/error)持续到的时间
let transientState = null;
let muted = false;
let skin = 'mascot';
+let codexChipMode = 'usage';
let lastWaiting = 0;
let lastBgZombie = 0; // 后台疑似僵尸数
let radialOpen = false;
@@ -2328,15 +2338,33 @@ function applyStats(s) {
// Codex 没有逐 token 价目,额度条显示套餐窗口用量(5h 主窗口 + 周窗口)
const rl = s.codexLimits;
const today = s.codexUsage && s.codexUsage.today;
- chipCost.textContent = today && today.tokens
- ? compactTokens(today.tokens) + ' tok'
- : 'Codex' + (rl && rl.planType ? ' ' + rl.planType : '');
- chipWindow.textContent = rl && rl.usedPercent != null
- ? t('chip.quota', { pct: Math.round(rl.usedPercent) })
- + (rl.secondaryUsedPercent != null ? t('chip.weekly', { pct: Math.round(rl.secondaryUsedPercent) }) : '')
- : t('chip.quotaNone');
- chipWindow.title = t('chip.codexTitle');
+ if (codexChipMode === 'weeklyRemaining') {
+ const remaining = window.CodexRateLimits.weeklyRemainingPercent(rl);
+ chipCost.textContent = remaining == null
+ ? t('chip.weeklyRemainingUnavailable')
+ : t('chip.weeklyRemaining', { pct: remaining });
+ chipCost.title = t('chip.codexTitle');
+ chipSep.style.display = 'none';
+ chipWindow.style.display = 'none';
+ } else {
+ chipCost.textContent = today && today.tokens
+ ? compactTokens(today.tokens) + ' tok'
+ : 'Codex' + (rl && rl.planType ? ' ' + rl.planType : '');
+ chipCost.title = '';
+ chipSep.style.display = '';
+ chipWindow.style.display = '';
+ const primaryIsWeekly = rl && Number(rl.windowMinutes) >= 6 * 24 * 60;
+ chipWindow.textContent = rl && rl.usedPercent != null
+ ? (primaryIsWeekly
+ ? t('chip.weeklyOnly', { pct: Math.round(rl.usedPercent) })
+ : t('chip.quota', { pct: Math.round(rl.usedPercent) })
+ + (rl.secondaryUsedPercent != null ? t('chip.weekly', { pct: Math.round(rl.secondaryUsedPercent) }) : ''))
+ : t('chip.quotaNone');
+ chipWindow.title = t('chip.codexTitle');
+ }
} else {
+ chipSep.style.display = '';
+ chipWindow.style.display = '';
chipCost.textContent = '$' + (s.today.cost || 0).toFixed(3);
chipWindow.textContent = '5h $' + (s.window5h.cost || 0).toFixed(3);
}
@@ -2420,9 +2448,11 @@ window.pet.onConfig((cfg) => {
territorySupported = !!cfg.territorySupported;
if (cfg.lang) applyLang(cfg.lang);
if (cfg.skin) applySkin(cfg.skin);
+ codexChipMode = cfg.codexChipMode === 'weeklyRemaining' ? 'weeklyRemaining' : 'usage';
pinnedSessionIds = Array.isArray(cfg.pinnedSessions) ? cfg.pinnedSessions.slice() : [];
archivedSessionIds = Array.isArray(cfg.archivedSessions) ? cfg.archivedSessions.slice() : [];
if (sessListOpen && !memeTarget) renderSessList();
+ if (lastStats && AGENT === 'codex') applyStats(lastStats);
});
// Static markup carries its Chinese text inline (so the window is never blank
@@ -2472,6 +2502,32 @@ function reportPetVisualBounds() {
try { window.pet.petVisualBounds({ x: r.left, y: r.top, width: r.width, height: r.height }); } catch {}
}
+let hitRegionFrame = 0;
+function reportPetHitRegions() {
+ hitRegionFrame = 0;
+ const regions = [];
+ for (const el of stage.children) {
+ const style = getComputedStyle(el);
+ if (el.classList.contains('hidden') || style.display === 'none' || style.visibility === 'hidden') continue;
+ const r = el.getBoundingClientRect();
+ if (!(r.width > 0) || !(r.height > 0)) continue;
+ regions.push({ x: r.left, y: r.top, width: r.width, height: r.height });
+ }
+ try { window.pet.setHitRegions(regions); } catch {}
+}
+
+function schedulePetHitRegions() {
+ if (hitRegionFrame) return;
+ hitRegionFrame = requestAnimationFrame(reportPetHitRegions);
+}
+
+new MutationObserver(schedulePetHitRegions).observe(stage, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ attributeFilter: ['class', 'style'],
+});
+
// ====================================================================
// 拖动 + 点击(短按=泡泡菜单 / 拖动=移动窗口)
// ====================================================================
@@ -2481,17 +2537,17 @@ function attachDrag(el) {
if (e.button !== 0) return;
try { el.setPointerCapture(e.pointerId); } catch {}
el.classList.add('dragging');
- g = { el, pid: e.pointerId, sx: e.screenX, sy: e.screenY, moved: false, win: null };
- window.pet.getWinPos().then(([wx, wy]) => { if (g) g.win = [wx, wy]; });
+ g = { el, pid: e.pointerId, sx: e.clientX, sy: e.clientY, moved: false };
+ window.pet.beginWinDrag();
});
el.addEventListener('pointermove', (e) => {
if (!g) return;
- const dx = e.screenX - g.sx;
- const dy = e.screenY - g.sy;
+ const dx = e.clientX - g.sx;
+ const dy = e.clientY - g.sy;
if (!g.moved && Math.abs(dx) + Math.abs(dy) > 4) g.moved = true;
- if (g.moved && g.win) {
+ if (g.moved) {
if (radialOpen) closeRadial();
- window.pet.setWinPos(g.win[0] + dx, g.win[1] + dy);
+ window.pet.updateWinDrag();
}
});
el.addEventListener('pointerup', () => {
@@ -2500,6 +2556,7 @@ function attachDrag(el) {
try { el.releasePointerCapture(g.pid); } catch {}
el.classList.remove('dragging');
g = null;
+ window.pet.endWinDrag();
if (!wasMove) {
// 左键短按 = 会话列表 HUD(状态/会话名/上下文用量一览,点行聚焦该会话)。
// 权限的允许/拒绝仍由 waiting 事件自动弹气泡,不走这里。
@@ -2507,7 +2564,11 @@ function attachDrag(el) {
else toggleSessList();
}
});
- el.addEventListener('pointercancel', () => { if (g) el.classList.remove('dragging'); g = null; });
+ el.addEventListener('pointercancel', () => {
+ if (g) el.classList.remove('dragging');
+ g = null;
+ window.pet.endWinDrag();
+ });
// 右键 = 泡泡菜单
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
@@ -2780,6 +2841,7 @@ window.addEventListener('blur', () => { if (radialOpen) closeRadial(); });
territorySupported = !!cfg.territorySupported;
window.OctoI18n.setLang(cfg.lang || 'zh');
applySkin(cfg.skin || 'mascot');
+ codexChipMode = cfg.codexChipMode === 'weeklyRemaining' ? 'weeklyRemaining' : 'usage';
}
applyStaticI18n();
await loadMemeCatalog();
@@ -2835,6 +2897,9 @@ setInterval(() => {
// 常驻轮询只留一个低频兜底,不必每 500ms 强制一次 getBoundingClientRect 回流。
window.addEventListener('resize', () => requestAnimationFrame(() => {
reportPetVisualBounds();
+ reportPetHitRegions();
alignMemePlayer();
}));
setInterval(reportPetVisualBounds, 3000);
+setInterval(reportPetHitRegions, 3000);
+schedulePetHitRegions();
diff --git a/shared/codex-rate-limits.js b/shared/codex-rate-limits.js
new file mode 100644
index 0000000..b6c126e
--- /dev/null
+++ b/shared/codex-rate-limits.js
@@ -0,0 +1,79 @@
+'use strict';
+
+// Shared normalization for Codex App Server quota responses. The main process
+// consumes the normalized object; the renderer only needs the weekly remainder.
+(function (root, factory) {
+ const api = factory();
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ if (root) root.CodexRateLimits = api;
+})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function () {
+ function finite(...values) {
+ for (const value of values) {
+ if (value == null || value === '') continue;
+ const n = Number(value);
+ if (Number.isFinite(n)) return n;
+ }
+ return null;
+ }
+
+ function pickBucket(payload) {
+ if (!payload || typeof payload !== 'object') return null;
+ if (payload.rateLimits && typeof payload.rateLimits === 'object') return payload.rateLimits;
+ if (payload.rate_limits && typeof payload.rate_limits === 'object') return payload.rate_limits;
+ const byId = payload.rateLimitsByLimitId || payload.rate_limits_by_limit_id;
+ if (byId && typeof byId === 'object') {
+ if (byId.codex && typeof byId.codex === 'object') return byId.codex;
+ const first = Object.values(byId).find((value) => value && typeof value === 'object');
+ if (first) return first;
+ }
+ return payload.primary || payload.secondary ? payload : null;
+ }
+
+ function normalizeAppServerRateLimits(payload, now = Date.now()) {
+ const bucket = pickBucket(payload);
+ if (!bucket) return null;
+ const primary = bucket.primary || {};
+ const secondary = bucket.secondary || {};
+ const out = { ts: now, source: 'app-server' };
+ const primaryUsed = finite(primary.usedPercent, primary.used_percent);
+ const secondaryUsed = finite(secondary.usedPercent, secondary.used_percent);
+ if (primaryUsed != null) {
+ out.usedPercent = primaryUsed;
+ out.windowMinutes = finite(primary.windowDurationMins, primary.window_minutes);
+ const reset = finite(primary.resetsAt, primary.resets_at);
+ out.resetsAt = reset == null ? null : reset * 1000;
+ }
+ if (secondaryUsed != null) {
+ out.secondaryUsedPercent = secondaryUsed;
+ out.secondaryWindowMinutes = finite(secondary.windowDurationMins, secondary.window_minutes);
+ const reset = finite(secondary.resetsAt, secondary.resets_at);
+ out.secondaryResetsAt = reset == null ? null : reset * 1000;
+ }
+ const planType = bucket.planType || bucket.plan_type || payload.planType || payload.plan_type;
+ if (typeof planType === 'string' && planType) out.planType = planType;
+ return out.usedPercent != null || out.secondaryUsedPercent != null ? out : null;
+ }
+
+ function weeklyUsedPercent(limits) {
+ if (!limits) return null;
+ const primary = finite(limits.usedPercent);
+ const secondary = finite(limits.secondaryUsedPercent);
+ const primaryWindow = finite(limits.windowMinutes);
+ const secondaryWindow = finite(limits.secondaryWindowMinutes);
+ const weeklyWindow = 6 * 24 * 60;
+ // Current Codex plans can expose the seven-day bucket as primary with no
+ // secondary bucket; older Plus responses commonly put it in secondary.
+ if (secondary != null && secondaryWindow != null && secondaryWindow >= weeklyWindow) return secondary;
+ if (primary != null && primaryWindow != null && primaryWindow >= weeklyWindow) return primary;
+ if (secondary != null) return secondary; // legacy payloads without duration
+ return null;
+ }
+
+ function weeklyRemainingPercent(limits) {
+ const used = weeklyUsedPercent(limits);
+ if (used == null) return null;
+ return Math.round(100 - Math.max(0, Math.min(100, used)));
+ }
+
+ return { normalizeAppServerRateLimits, weeklyUsedPercent, weeklyRemainingPercent };
+});
diff --git a/shared/i18n.js b/shared/i18n.js
index b4f5b82..ef4694d 100644
--- a/shared/i18n.js
+++ b/shared/i18n.js
@@ -32,6 +32,9 @@
'tray.skin': ' 形象',
'tray.skinClaude': ' 形象(Claude 宠)',
'tray.skinCodex': ' 形象(Codex 宠)',
+ 'tray.codexChip': ' Codex 棕色统计框',
+ 'tray.codexChipUsage': '显示默认用量统计',
+ 'tray.codexChipWeekly': '显示 Weekly 剩余',
'tray.shape': ' 形态',
'tray.budget': ' 5h 预算',
'tray.language': ' 🌐 语言 / Language',
@@ -43,6 +46,7 @@
'tray.launchCodex': '🛰️ 唤起 Codex',
'tray.openLog': '📄 打开日志',
'tray.uninstallHook': '🧹 卸载 Claude 钩子',
+ 'tray.startupRecovery': ' 开机启动并在崩溃后恢复',
'tray.quit': '⏻ 退出',
'tray.budgetOff': '关闭',
@@ -311,6 +315,9 @@
// ── quota chip ──────────────────────────────────────────────────────────
'chip.quota': '5h 额度 {pct}%',
'chip.weekly': ' · 周 {pct}%',
+ 'chip.weeklyOnly': '周额度 {pct}%',
+ 'chip.weeklyRemaining': 'Weekly 剩余 {pct}%',
+ 'chip.weeklyRemainingUnavailable': 'Weekly 剩余 —%',
'chip.quotaNone': '额度 --',
'chip.codexTitle': 'Codex 套餐窗口用量(来自 rollout 的 rate_limits)',
'chip.windowTitle': '近5小时消耗',
@@ -419,6 +426,9 @@
'tray.skin': ' Skin',
'tray.skinClaude': ' Skin (Claude pet)',
'tray.skinCodex': ' Skin (Codex pet)',
+ 'tray.codexChip': ' Codex brown stats box',
+ 'tray.codexChipUsage': 'Show default usage stats',
+ 'tray.codexChipWeekly': 'Show weekly remaining',
'tray.shape': ' Layout',
'tray.budget': ' 5h budget',
'tray.language': ' 🌐 Language / 语言',
@@ -430,6 +440,7 @@
'tray.launchCodex': '🛰️ Launch Codex',
'tray.openLog': '📄 Open log',
'tray.uninstallHook': '🧹 Uninstall Claude hooks',
+ 'tray.startupRecovery': ' Start at login and recover after crashes',
'tray.quit': '⏻ Quit',
'tray.budgetOff': 'Off',
@@ -684,6 +695,9 @@
'chip.quota': '5h quota {pct}%',
'chip.weekly': ' · wk {pct}%',
+ 'chip.weeklyOnly': 'weekly {pct}%',
+ 'chip.weeklyRemaining': 'Weekly left {pct}%',
+ 'chip.weeklyRemainingUnavailable': 'Weekly left —%',
'chip.quotaNone': 'quota --',
'chip.codexTitle': 'Codex plan window usage (from rollout rate_limits)',
'chip.windowTitle': 'Spend in the last 5 hours',
@@ -789,6 +803,9 @@
'tray.skin': ' 見た目',
'tray.skinClaude': ' 見た目(Claude ペット)',
'tray.skinCodex': ' 見た目(Codex ペット)',
+ 'tray.codexChip': ' Codex の茶色い統計欄',
+ 'tray.codexChipUsage': '標準の使用量を表示',
+ 'tray.codexChipWeekly': '週間残量を表示',
'tray.shape': ' 表示形式',
'tray.budget': ' 5時間の予算',
'tray.language': ' 🌐 言語 / Language',
@@ -800,6 +817,7 @@
'tray.launchCodex': '🛰️ Codex を起動',
'tray.openLog': '📄 ログを開く',
'tray.uninstallHook': '🧹 Claude フックを削除',
+ 'tray.startupRecovery': ' ログイン時に起動し、クラッシュ後に復旧',
'tray.quit': '⏻ 終了',
'tray.budgetOff': 'オフ',
@@ -1054,6 +1072,9 @@
'chip.quota': '5h 使用量 {pct}%',
'chip.weekly': ' · 週 {pct}%',
+ 'chip.weeklyOnly': '週の使用量 {pct}%',
+ 'chip.weeklyRemaining': '週残り {pct}%',
+ 'chip.weeklyRemainingUnavailable': '週残り —%',
'chip.quotaNone': '使用量 --',
'chip.codexTitle': 'Codex プランの窓口使用量(rollout の rate_limits より)',
'chip.windowTitle': '直近5時間の消費',
diff --git a/test/codex-hooks.js b/test/codex-hooks.js
new file mode 100644
index 0000000..b18a89d
--- /dev/null
+++ b/test/codex-hooks.js
@@ -0,0 +1,124 @@
+'use strict';
+
+const assert = require('assert');
+const childProcess = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const {
+ registerCodexHooks,
+ unregisterCodexHooks,
+ codexHooksCurrent,
+ CODEX_EVENTS,
+} = require('../backend/codex-hookinstall');
+const { buildBody, codexSuccessOutput } = require('../hook/octopus-hook');
+const { createCore } = require('../backend/core');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llmpet-codex-hooks-'));
+const hooksPath = path.join(tmp, 'hooks.json');
+const nodeBin = process.platform === 'win32' ? 'C:\\Program Files\\nodejs\\node.exe' : '/usr/bin/node';
+const platform = process.platform === 'win32' ? 'win32' : 'linux';
+const otherCommand = '"C:\\Tools\\OtherPet.Hook.exe"';
+
+fs.writeFileSync(hooksPath, JSON.stringify({
+ description: 'User lifecycle hooks.',
+ hooks: {
+ SessionStart: [{ hooks: [{ type: 'command', command: otherCommand, timeout: 1 }] }],
+ },
+}, null, 2));
+
+const first = registerCodexHooks({ hooksPath, nodeBin, platform });
+assert.strictEqual(first.added, CODEX_EVENTS.length);
+let config = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
+assert.strictEqual(config.description, 'User lifecycle hooks.');
+assert(config.hooks.SessionStart.some((group) =>
+ group.hooks.some((hook) => hook.command === otherCommand)), 'unrelated Codex hooks must be preserved');
+for (const event of CODEX_EVENTS) {
+ const ours = config.hooks[event].flatMap((group) => group.hooks || [])
+ .filter((hook) => String(hook.command || '').includes('octopus-hook.js'));
+ assert.strictEqual(ours.length, 1, `${event} must contain exactly one LLMPET hook`);
+ assert(ours[0].command.endsWith(`${event} codex`), `${event} must identify Codex as the source`);
+ if (platform === 'win32') {
+ assert.strictEqual(ours[0].commandWindows, `& ${ours[0].command}`);
+ assert(ours[0].commandWindows.startsWith('& "C:\\Program Files\\nodejs\\node.exe"'),
+ 'PowerShell commandWindows must invoke a quoted executable with the call operator');
+ }
+}
+
+const installed = fs.readFileSync(hooksPath, 'utf8');
+const second = registerCodexHooks({ hooksPath, nodeBin, platform });
+assert.strictEqual(second.skipped, CODEX_EVENTS.length, 'a current config must not churn hook trust hashes');
+assert.strictEqual(fs.readFileSync(hooksPath, 'utf8'), installed, 'idempotent install must not rewrite hooks.json');
+assert.strictEqual(codexHooksCurrent({ hooksPath, nodeBin, platform }), true);
+
+const prompt = buildBody('UserPromptSubmit', {
+ session_id: 'codex-session',
+ cwd: 'C:\\work\\repo',
+ prompt: 'Fix the watcher',
+ model: 'gpt-test',
+}, 'codex');
+assert.strictEqual(prompt.agent_id, 'codex');
+assert.strictEqual(prompt.event_source, 'codex-hook');
+assert.strictEqual(prompt.state, 'thinking');
+assert.strictEqual(prompt.session_title, 'Fix the watcher');
+
+const tool = buildBody('PreToolUse', {
+ session_id: 'codex-session',
+ tool_name: 'apply_patch',
+}, 'codex');
+assert.strictEqual(tool.tool_name, 'Edit');
+
+const stop = buildBody('Stop', {
+ session_id: 'codex-session',
+ last_assistant_message: 'Implemented and tested.',
+}, 'codex');
+assert.strictEqual(stop.assistant_last_output, 'Implemented and tested.');
+assert.deepStrictEqual(codexSuccessOutput('Stop', 'codex'), { continue: true });
+assert.deepStrictEqual(codexSuccessOutput('SubagentStop', 'codex'), { continue: true });
+assert.strictEqual(codexSuccessOutput('Stop', 'claude'), null);
+
+const stopProcess = childProcess.spawnSync(
+ process.execPath,
+ [path.join(__dirname, '..', 'hook', 'octopus-hook.js'), 'Stop', 'codex'],
+ {
+ input: JSON.stringify({
+ session_id: 'codex-stop-output',
+ hook_event_name: 'Stop',
+ last_assistant_message: 'Done.',
+ }),
+ encoding: 'utf8',
+ env: { ...process.env, HOME: tmp, USERPROFILE: tmp },
+ timeout: 3000,
+ }
+);
+assert.strictEqual(stopProcess.status, 0, stopProcess.stderr);
+assert.deepStrictEqual(JSON.parse(stopProcess.stdout), { continue: true },
+ 'Codex Stop hooks must print valid JSON on stdout');
+
+const permission = buildBody('PermissionRequest', { session_id: 'codex-session' }, 'codex');
+assert.strictEqual(permission.state, 'notification');
+
+const activities = [];
+const core = createCore({ onActivity: (activity) => activities.push(activity) });
+core.updateSession('dedupe', 'working', 'PreToolUse', {
+ agentId: 'codex', eventSource: 'codex-hook', toolName: 'Bash',
+});
+core.updateSession('dedupe', 'working', 'PreToolUse', {
+ agentId: 'codex', eventSource: 'codex-rollout', toolName: 'Bash',
+});
+assert.strictEqual(activities.length, 1, 'hook + rollout copies must emit one activity');
+core.updateSession('dedupe', 'working', 'PreToolUse', {
+ agentId: 'codex', eventSource: 'codex-hook', toolName: 'Bash',
+});
+assert.strictEqual(activities.length, 2, 'a repeated event from one source must remain visible');
+
+const removed = unregisterCodexHooks({ hooksPath, backup: true });
+assert.strictEqual(removed.removed, CODEX_EVENTS.length);
+assert(removed.backupPath && fs.existsSync(removed.backupPath), 'uninstall must back up hooks.json');
+config = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
+assert(config.hooks.SessionStart.some((group) =>
+ group.hooks.some((hook) => hook.command === otherCommand)), 'uninstall must retain unrelated hooks');
+assert.strictEqual(codexHooksCurrent({ hooksPath, nodeBin, platform }), false);
+
+fs.rmSync(tmp, { recursive: true, force: true });
+console.log('codex hook checks passed');
diff --git a/test/codex-integration.js b/test/codex-integration.js
index 3501c46..21176fe 100644
--- a/test/codex-integration.js
+++ b/test/codex-integration.js
@@ -10,21 +10,35 @@ const pkg = JSON.parse(read('package.json'));
const main = read('main.js');
const preload = read('preload.js');
const config = read('backend/config.js');
+const server = read('backend/server.js');
+const hook = read('hook/octopus-hook.js');
const readme = read('README.md');
assert(fs.existsSync(path.join(root, 'backend/codex-watch.js')), 'Codex watcher must ship with the app');
+assert(fs.existsSync(path.join(root, 'backend/codex-hookinstall.js')), 'Codex hook installer must ship with the app');
+assert(fs.existsSync(path.join(root, 'backend/codex-rate-limits.js')), 'Codex App Server quota reader must ship with the app');
assert(fs.existsSync(path.join(root, 'test/codex-watch.js')), 'Codex watcher regression tests must remain in the suite');
assert(/require\('\.\/backend\/codex-watch'\)/.test(main), 'main process must load the Codex watcher');
assert(/codexWatch\s*=\s*createCodexWatch\(/.test(main), 'main process must create the Codex watcher');
assert(/codexWatch\.start\(\)/.test(main), 'main process must start the Codex watcher');
+assert(/codexRateLimitClient\.start\(\)/.test(main), 'main process must start the Codex quota reader');
assert(/if \(codexWatch\) codexWatch\.stop\(\)/.test(main), 'app shutdown must stop the Codex watcher');
+assert(/data\.agent_id === 'codex' \? 'codex' : 'claude-code'/.test(server), 'Codex hook events must retain their agent identity');
+assert(/body\.event_source = 'codex-hook'/.test(hook), 'Codex hook events must identify their source for deduplication');
+assert(/recoverPackagedApp\(\{ hookDir: __dirname \}\)/.test(hook), 'a failed hook delivery must wake the packaged LLMPET app');
+assert(/ensureLoginStartup\(app, \{ enabled \}\)/.test(main), 'the packaged app must honor the explicit login startup preference');
+assert(/startupRecovery: false/.test(config), 'login startup and hook recovery must be opt-in');
assert(/function sendPetEvent\(ev\)/.test(main) && /ev\.agent === 'codex'/.test(main), 'Codex events must route to the Codex pet in duo mode');
assert(/function createPetWindows\(\)/.test(main) && /makePetWindow\('codex'\)/.test(main), 'duo mode must create an independent Codex pet');
assert(/petMode: 'single'/.test(config) && /skinCodex: 'cat'/.test(config), 'Codex pet settings must have safe defaults');
+assert(/codexChipMode: 'usage'/.test(config), 'Codex brown stats box must preserve the existing default');
assert(/launchCodex: \(\) => ipcRenderer\.send\('launch-codex'\)/.test(preload), 'renderer must be able to launch Codex');
assert(/closePet: \(\) => ipcRenderer\.send\('close-pet'\)/.test(preload), 'a duo pet must be independently closable');
assert(/Claude Code \/ Codex/.test(readme) && /Codex 后端/.test(readme), 'public documentation must describe Codex support');
assert(pkg.scripts.test.includes('test/codex-watch.js'), 'npm test must execute Codex watcher tests');
+assert(pkg.scripts.test.includes('test/codex-hooks.js'), 'npm test must execute Codex hook tests');
+assert(pkg.scripts.test.includes('test/startup-recovery.js'), 'npm test must execute startup recovery tests');
+assert(pkg.scripts.test.includes('test/codex-rate-limits.js'), 'npm test must execute Codex rate-limit tests');
assert(pkg.scripts.test.includes('test/codex-integration.js'), 'npm test must execute the Codex integration contract');
console.log('codex integration checks passed');
diff --git a/test/codex-rate-limits.js b/test/codex-rate-limits.js
new file mode 100644
index 0000000..80544ad
--- /dev/null
+++ b/test/codex-rate-limits.js
@@ -0,0 +1,134 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { EventEmitter } = require('events');
+const { PassThrough } = require('stream');
+const { sanitize } = require('../backend/config');
+const { loadRenderer } = require('./dom-stub');
+const { createCodexRateLimits } = require('../backend/codex-rate-limits');
+const {
+ normalizeAppServerRateLimits,
+ weeklyRemainingPercent,
+} = require('../shared/codex-rate-limits');
+
+const normalized = normalizeAppServerRateLimits({
+ rateLimitsByLimitId: {
+ codex: {
+ primary: { usedPercent: 21.4, windowDurationMins: 300, resetsAt: 100 },
+ secondary: { usedPercent: 37.6, windowDurationMins: 10080, resetsAt: 200 },
+ planType: 'plus',
+ },
+ },
+}, 1234);
+assert.deepStrictEqual(normalized, {
+ ts: 1234,
+ source: 'app-server',
+ usedPercent: 21.4,
+ windowMinutes: 300,
+ resetsAt: 100000,
+ secondaryUsedPercent: 37.6,
+ secondaryWindowMinutes: 10080,
+ secondaryResetsAt: 200000,
+ planType: 'plus',
+});
+assert.strictEqual(weeklyRemainingPercent(normalized), 62);
+assert.strictEqual(weeklyRemainingPercent({ usedPercent: 44, windowMinutes: 10080 }), 56);
+assert.strictEqual(weeklyRemainingPercent({ usedPercent: 44, windowMinutes: 300 }), null);
+assert.strictEqual(weeklyRemainingPercent({ secondaryUsedPercent: -5 }), 100);
+assert.strictEqual(weeklyRemainingPercent({ secondaryUsedPercent: 105 }), 0);
+assert.strictEqual(weeklyRemainingPercent(null), null);
+
+assert.strictEqual(sanitize({ codexChipMode: 'weeklyRemaining' }).codexChipMode, 'weeklyRemaining');
+assert.strictEqual(sanitize({ codexChipMode: 'invalid' }).codexChipMode, 'usage');
+assert.strictEqual(sanitize({ codexTagMode: 'weeklyRemaining' }).codexChipMode, 'weeklyRemaining');
+
+const renderer = loadRenderer([
+ 'shared/i18n.js',
+ 'shared/states.js',
+ 'shared/codex-rate-limits.js',
+ 'renderer/icons.js',
+ 'renderer/pet.js',
+], { search: '?agent=codex' });
+renderer.handlers.config({ skin: 'cat', muted: true, lang: 'zh', codexChipMode: 'weeklyRemaining' });
+renderer.handlers.stats({
+ today: { cost: 0 }, window5h: { cost: 0 }, codexUsage: { today: { tokens: 1234 } },
+ codexLimits: { usedPercent: 45, windowMinutes: 10080 }, sessions: [], bg: { zombie: 0 },
+ waitingCount: 0, needsinputCount: 0, workingCount: 0, jugglingCount: 0,
+ sweepingCount: 0, thinkingCount: 0, loafingCount: 0, errorCount: 0, idleMs: 1000,
+});
+assert(renderer.elements('agent-tag').innerHTML.includes('Codex'), 'blue agent tag must keep the Codex name');
+assert(!renderer.elements('agent-tag').innerHTML.includes('Weekly'), 'weekly text must not replace the blue agent tag');
+assert.strictEqual(renderer.elements('chip-cost').textContent, 'Weekly 剩余 55%');
+assert.strictEqual(renderer.elements('chip-sep').style.display, 'none');
+assert.strictEqual(renderer.elements('chip-window').style.display, 'none');
+renderer.handlers.config({ skin: 'cat', muted: true, lang: 'zh', codexChipMode: 'usage' });
+assert.strictEqual(renderer.elements('chip-sep').style.display, '');
+assert.strictEqual(renderer.elements('chip-window').style.display, '');
+
+async function integrationCheck() {
+ const writes = [];
+ let fake;
+ const spawnImpl = () => {
+ fake = new EventEmitter();
+ fake.stdout = new PassThrough();
+ fake.stderr = new PassThrough();
+ fake.stdin = {
+ destroyed: false,
+ writable: true,
+ write(line) {
+ const message = JSON.parse(line);
+ writes.push(message);
+ if (message.method === 'initialize') {
+ setImmediate(() => fake.stdout.write(JSON.stringify({ id: message.id, result: {} }) + '\n'));
+ } else if (message.method === 'account/rateLimits/read') {
+ setImmediate(() => fake.stdout.write(JSON.stringify({
+ id: message.id,
+ result: {
+ rateLimits: {
+ primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 100 },
+ secondary: { usedPercent: 25, windowDurationMins: 10080, resetsAt: 200 },
+ },
+ },
+ }) + '\n'));
+ }
+ return true;
+ },
+ };
+ fake.kill = () => { fake.emit('close', 0); return true; };
+ setImmediate(() => fake.emit('spawn'));
+ return fake;
+ };
+
+ let client;
+ const limits = await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('fake App Server response timed out')), 1000);
+ client = createCodexRateLimits({
+ spawnImpl,
+ findCliImpl: () => 'codex-test',
+ pollMs: 60 * 1000,
+ requestTimeoutMs: 500,
+ onRateLimits(value) { clearTimeout(timeout); resolve(value); },
+ });
+ client.start();
+ });
+ client.stop();
+ assert.strictEqual(limits.secondaryUsedPercent, 25);
+ assert.deepStrictEqual(writes.map((message) => message.method), [
+ 'initialize', 'initialized', 'account/rateLimits/read',
+ ]);
+
+ const root = path.join(__dirname, '..');
+ const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
+ assert(read('main.js').includes("createCodexRateLimits"));
+ assert(read('renderer/pet.html').includes('shared/codex-rate-limits.js'));
+ assert(read('renderer/pet.js').includes("codexChipMode === 'weeklyRemaining'"));
+ console.log('Codex rate-limit checks passed');
+ process.exit(0);
+}
+
+integrationCheck().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/test/codex-watch.js b/test/codex-watch.js
index ae9c51c..0f472ee 100644
--- a/test/codex-watch.js
+++ b/test/codex-watch.js
@@ -1,7 +1,7 @@
'use strict';
// codex-watch 单元测试 — 用临时目录伪造 ~/.codex/sessions 的 rollout JSONL,
-// 注入假 core 记录调用:backfill 静默入库、live 事件映射、subagent 过滤、
+// 注入假 core 记录调用:backfill 静默聚合、live 事件映射、subagent 父会话归并、
// 半行攒批、token_count → 上下文% + rate_limits。
// Run: node test/codex-watch.js
@@ -42,6 +42,7 @@ function mkSessions() {
const UUID_A = '019f5103-921c-7ac1-9a8d-c4f8ff8a67aa';
const UUID_B = '019f5103-921c-7ac1-9a8d-c4f8ff8a67bb';
+const UUID_C = '019f5103-921c-7ac1-9a8d-c4f8ff8a67cc';
const line = (o) => JSON.stringify(o) + '\n';
const meta = (id, extra = {}) => line({ type: 'session_meta', payload: { id, session_id: id, cwd: '/tmp/proj', originator: 'codex-tui', thread_source: 'user', ...extra } });
@@ -90,6 +91,7 @@ check('meta+尾部 user_message/token_count → seedSession(不发事件)', () =
assert.strictEqual(core.seeds[0].cwd, '/tmp/proj');
assert.strictEqual(core.seeds[0].sessionTitle, '帮我修个 bug');
assert.strictEqual(core.seeds[0].contextUsage.percent, 10);
+ assert.strictEqual(core.seeds[0].state, 'thinking', '重启后应从尾部恢复正在执行的状态');
assert.strictEqual(core.updates.length, 0, 'backfill 不应发 updateSession');
});
@@ -182,24 +184,69 @@ check('turn_aborted → TurnAborted(idle);approval → Notification', () => {
assert.deepStrictEqual(evs, ['SessionStart:idle', 'Notification:notification', 'TurnAborted:idle']);
});
-console.log('[C4] 过滤与健壮性');
-check('thread_source=subagent 整个文件跳过(含 backfill 与 live)', () => {
+console.log('[C4] 子代理聚合与健壮性');
+check('backfill:活跃 subagent 静默归并到父 session_id,不生成子会话', () => {
const { root, dir } = mkSessions();
- // backfill 路径
- const fp1 = path.join(dir, `rollout-2026-07-11T04-00-00-${UUID_A}.jsonl`);
- fs.writeFileSync(fp1, meta(UUID_A, { thread_source: 'subagent', source: { subagent: { other: 'guardian' } } })
- + line({ type: 'event_msg', payload: { type: 'user_message', message: 'internal' } }));
+ const parent = path.join(dir, `rollout-2026-07-11T04-00-00-${UUID_A}.jsonl`);
+ const child = path.join(dir, `rollout-2026-07-11T04-01-00-${UUID_B}.jsonl`);
+ fs.writeFileSync(parent, meta(UUID_A)
+ + line({ timestamp: '2026-07-11T04:00:10Z', type: 'event_msg', payload: { type: 'turn_aborted' } }));
+ fs.writeFileSync(child, meta(UUID_B, {
+ session_id: UUID_A,
+ parent_thread_id: UUID_A,
+ thread_source: 'subagent',
+ source: { subagent: { other: 'worker' } },
+ }) + line({ timestamp: '2026-07-11T04:01:10Z', type: 'event_msg', payload: { type: 'task_started' } }));
const core = fakeCore();
const w = createCodexWatch({ core, sessionsDir: root, pollMs: 999999 });
w.tick();
- // live 路径
- const fp2 = path.join(dir, `rollout-2026-07-11T05-00-00-${UUID_B}.jsonl`);
- fs.writeFileSync(fp2, meta(UUID_B, { thread_source: 'subagent' }));
+ assert.strictEqual(core.seeds.length, 1);
+ assert.strictEqual(core.seeds[0].id, UUID_A);
+ assert.strictEqual(core.seeds[0].state, 'juggling');
+ assert.strictEqual(core.seeds[0].lastEvent.rawEvent, 'SubagentStart');
+ assert.strictEqual(core.seeds[0].transcriptPath, parent, '会话身份应保留父 rollout');
+ assert.strictEqual(core.updates.length, 0, '启动聚合仍然不能回放事件');
+});
+
+check('live:并行 subagent 归并父会话;一个先结束不释放整体 working', () => {
+ const { root, dir } = mkSessions();
+ const core = fakeCore();
+ const w = createCodexWatch({ core, sessionsDir: root, pollMs: 999999 });
w.tick();
- fs.appendFileSync(fp2, line({ type: 'event_msg', payload: { type: 'user_message', message: 'still internal' } }));
+ const parent = path.join(dir, `rollout-parent-${UUID_A}.jsonl`);
+ const child1 = path.join(dir, `rollout-child-${UUID_B}.jsonl`);
+ const child2 = path.join(dir, `rollout-child-${UUID_C}.jsonl`);
+ fs.writeFileSync(parent, meta(UUID_A));
w.tick();
- assert.strictEqual(core.seeds.length, 0);
- assert.strictEqual(core.updates.length, 0);
+ const childMeta = (id) => meta(id, {
+ session_id: UUID_A,
+ parent_thread_id: UUID_A,
+ thread_source: 'subagent',
+ source: { subagent: { other: 'worker' } },
+ });
+ fs.writeFileSync(child1, childMeta(UUID_B));
+ fs.writeFileSync(child2, childMeta(UUID_C));
+ w.tick();
+ assert.strictEqual(core.updates.filter((u) => u.event === 'SessionStart').length, 1, '子 rollout 不建独立会话');
+
+ fs.appendFileSync(child1, line({ type: 'event_msg', payload: { type: 'task_started' } }));
+ fs.appendFileSync(child2, line({ type: 'event_msg', payload: { type: 'task_started' } }));
+ w.tick();
+ const starts = core.updates.filter((u) => u.event === 'SubagentStart');
+ assert.strictEqual(starts.length, 2);
+ assert.ok(starts.every((u) => u.sid === UUID_A && u.state === 'juggling'));
+
+ fs.appendFileSync(child1, line({ type: 'event_msg', payload: { type: 'task_complete' } }));
+ w.tick();
+ assert.strictEqual(core.updates.at(-1).sid, UUID_A);
+ assert.strictEqual(core.updates.at(-1).state, 'juggling', '仍有另一个子代理活跃时必须保持 juggling');
+ assert.strictEqual(core.updates.at(-1).event, 'SubagentStop');
+
+ fs.appendFileSync(child2, line({ type: 'event_msg', payload: { type: 'task_complete' } }));
+ w.tick();
+ assert.strictEqual(core.updates.at(-1).sid, UUID_A);
+ assert.strictEqual(core.updates.at(-1).state, 'working');
+ assert.strictEqual(core.updates.at(-1).event, 'SubagentStop');
});
check('半行写入攒到下一轮,不丢不重', () => {
@@ -252,7 +299,44 @@ check('几天前日期目录里的活跃文件:启动即入库,之后每轮
w.tick(); // 第二轮不是全量轮:tracker 直连 stat 也必须泵到
assert.ok(core.updates.some((u) => u.event === 'TaskStarted'), '非全量轮也要跟进旧目录文件的增量');
});
-check('35KB+ 超长 session_meta 行完整解析(cwd / subagent 判定不丢)', () => {
+
+check('Windows LastWriteTime 不变时,文件大小增长仍判定为活动', () => {
+ const { root, dir } = mkSessions();
+ const fp = path.join(dir, `rollout-stale-mtime-${UUID_B}.jsonl`);
+ const oldTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
+ fs.writeFileSync(fp, meta(UUID_B)
+ + line({ timestamp: oldTimestamp, type: 'event_msg', payload: { type: 'task_complete' } }));
+ const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
+ fs.utimesSync(fp, old, old);
+ const core = fakeCore();
+ const w = createCodexWatch({ core, sessionsDir: root, pollMs: 999999 });
+ w.tick();
+ assert.strictEqual(w._trackers.has(fp), false, '旧事件且无增长时应保持沉睡');
+
+ fs.appendFileSync(fp, line({ type: 'event_msg', payload: { type: 'task_started' } }));
+ fs.utimesSync(fp, old, old); // 模拟 Windows 未推进 LastWriteTime
+ w.tick();
+ assert.strictEqual(w._trackers.has(fp), true, '字节增长必须重新激活 tracker');
+ assert.deepStrictEqual(core.updates.map((u) => u.event), ['TaskStarted']);
+});
+
+check('启动扫描按最后事件时间识别活动,不依赖 LastWriteTime', () => {
+ const { root, dir } = mkSessions();
+ const fp = path.join(dir, `rollout-recent-event-${UUID_C}.jsonl`);
+ const eventTime = new Date(Date.now() - 1000).toISOString();
+ fs.writeFileSync(fp, meta(UUID_C)
+ + line({ timestamp: eventTime, type: 'event_msg', payload: { type: 'task_started' } }));
+ const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
+ fs.utimesSync(fp, old, old);
+ const core = fakeCore();
+ const w = createCodexWatch({ core, sessionsDir: root, pollMs: 999999 });
+ w.tick();
+ assert.strictEqual(core.seeds.length, 1, '近期事件必须进入启动会话列表');
+ assert.strictEqual(core.seeds[0].id, UUID_C);
+ assert.ok(Math.abs(core.seeds[0].updatedAt - Date.parse(eventTime)) < 10);
+});
+
+check('35KB+ 超长 session_meta 行完整解析(cwd / 父会话判定不丢)', () => {
const { root, dir } = mkSessions();
const fp = path.join(dir, `rollout-2026-07-11T06-00-00-${UUID_B}.jsonl`);
const big = {
diff --git a/test/dom-stub.js b/test/dom-stub.js
index 57669f0..a839f32 100644
--- a/test/dom-stub.js
+++ b/test/dom-stub.js
@@ -117,7 +117,7 @@ function createStubWorld() {
};
// Captured renderer callbacks (registered via window.pet.onX)
- const handlers = { event: null, stats: null, config: null, meme: null, travel: null, memeCatalogChanged: null };
+ const handlers = { event: null, stats: null, config: null, meme: null, travel: null, contentOffset: null, memeCatalogChanged: null };
const calls = []; // record of preload calls for assertions
const pet = {
@@ -126,6 +126,7 @@ function createStubWorld() {
onConfig: (cb) => { handlers.config = cb; },
onMeme: (cb) => { handlers.meme = cb; },
onTravel: (cb) => { handlers.travel = cb; },
+ onContentOffset: (cb) => { handlers.contentOffset = cb; },
onMemeCatalogChanged: (cb) => { handlers.memeCatalogChanged = cb; },
getStats: () => Promise.resolve(null),
getConfig: () => Promise.resolve(null),
@@ -138,8 +139,12 @@ function createStubWorld() {
cancelTravel: () => { calls.push(['cancelTravel']); return Promise.resolve({ ok: true }); },
getWinPos: () => Promise.resolve([0, 0]),
setWinPos: (...a) => calls.push(['setWinPos', a]),
+ beginWinDrag: () => calls.push(['beginWinDrag']),
+ updateWinDrag: () => calls.push(['updateWinDrag']),
+ endWinDrag: () => calls.push(['endWinDrag']),
setPetSize: (...a) => calls.push(['setPetSize', a]),
setIgnoreMouse: (...a) => calls.push(['setIgnoreMouse', a]),
+ setHitRegions: (...a) => calls.push(['setHitRegions', a]),
setSkin: (...a) => calls.push(['setSkin', a]),
toggleMute: () => calls.push(['toggleMute']),
openPanel: () => calls.push(['openPanel']),
@@ -178,6 +183,15 @@ function createStubWorld() {
setInterval,
clearInterval,
requestAnimationFrame: (fn) => setTimeout(fn, 0),
+ getComputedStyle: (el) => ({
+ display: el && el.classList && el.classList.contains('hidden') ? 'none' : 'block',
+ visibility: 'visible',
+ opacity: '1',
+ }),
+ MutationObserver: class MutationObserver {
+ observe() {}
+ disconnect() {}
+ },
console,
Math,
JSON,
@@ -202,8 +216,9 @@ function createStubWorld() {
// Load renderer/pet.js (and anything else, e.g. a future shared module) into
// the stub world. Returns the world for driving + assertions.
-function loadRenderer(files) {
+function loadRenderer(files, options = {}) {
const world = createStubWorld();
+ if (typeof options.search === 'string') world.sandbox.location.search = options.search;
vm.createContext(world.sandbox);
for (const f of files) {
const code = fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
diff --git a/test/i18n.js b/test/i18n.js
index 6ee3b43..282b454 100644
--- a/test/i18n.js
+++ b/test/i18n.js
@@ -102,6 +102,7 @@ assert.strictEqual(i18n.getLang(), 'zh', 'an unknown language must fall back to
// ── 6. config accepts exactly the supported languages ────────────────────────
assert.strictEqual(config.DEFAULTS.lang, 'zh');
+assert.strictEqual(config.DEFAULTS.startupRecovery, false);
// ── 7. meme catalog is localized, prompt included ────────────────────────────
const catalog = loadCatalog();
diff --git a/test/startup-recovery.js b/test/startup-recovery.js
new file mode 100644
index 0000000..e92a28b
--- /dev/null
+++ b/test/startup-recovery.js
@@ -0,0 +1,144 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const {
+ RECOVERY_COOLDOWN_MS,
+ recoveryExecutable,
+ ensureLoginStartup,
+ startupRecoveryEnabled,
+ recoveryAllowed,
+ recordIntentionalQuit,
+ clearIntentionalQuit,
+ recoverPackagedApp,
+} = require('../backend/startup');
+const {
+ enqueueHookEvent,
+ drainPendingHookEvents,
+ pendingFiles,
+} = require('../backend/hook-queue');
+const { deliverStateWithRecovery } = require('../hook/octopus-hook');
+
+assert.strictEqual(
+ recoveryExecutable('C:\\Program Files\\LLMPET\\resources\\app\\hook', 'win32'),
+ 'C:\\Program Files\\LLMPET\\LLMPET.exe',
+);
+assert.strictEqual(
+ recoveryExecutable('/Applications/LLMPET.app/Contents/Resources/app/hook', 'darwin'),
+ '/Applications/LLMPET.app/Contents/MacOS/LLMPET',
+);
+assert.strictEqual(recoveryExecutable('/opt/llmpet/hook', 'linux'), null);
+
+const loginCalls = [];
+const packagedApp = {
+ isPackaged: true,
+ setLoginItemSettings(settings) { loginCalls.push(settings); },
+};
+assert.strictEqual(ensureLoginStartup(packagedApp, {
+ platform: 'win32', executable: 'C:\\Program Files\\LLMPET\\LLMPET.exe', enabled: true,
+}), true);
+assert.deepStrictEqual(loginCalls[0], {
+ openAtLogin: true,
+ path: 'C:\\Program Files\\LLMPET\\LLMPET.exe',
+ args: ['--autostart'],
+});
+assert.strictEqual(ensureLoginStartup(packagedApp, {
+ platform: 'win32', executable: 'C:\\Program Files\\LLMPET\\LLMPET.exe', enabled: false,
+}), true);
+assert.strictEqual(loginCalls[1].openAtLogin, false, 'disabled preference must remove stale login startup');
+assert.strictEqual(ensureLoginStartup({ ...packagedApp, isPackaged: false }, { platform: 'win32' }), false);
+assert.strictEqual(ensureLoginStartup(packagedApp, { platform: 'linux' }), false);
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llmpet-startup-'));
+const configPath = path.join(tmp, 'config.json');
+const quitPath = path.join(tmp, 'intentional-quit.json');
+const stampPath = path.join(tmp, 'hook-recovery.json');
+const pendingDir = path.join(tmp, 'pending-hooks');
+const executable = path.join(tmp, 'LLMPET.exe');
+fs.writeFileSync(executable, 'test');
+
+fs.writeFileSync(configPath, JSON.stringify({ startupRecovery: false }));
+assert.strictEqual(startupRecoveryEnabled({ configPath }), false);
+assert.strictEqual(recoveryAllowed({ configPath, quitPath }), false);
+
+const spawns = [];
+const spawn = (command, args, options) => {
+ spawns.push({ command, args, options });
+ return { unref() {} };
+};
+assert.strictEqual(recoverPackagedApp({ executable, configPath, quitPath, stampPath, spawn }), false,
+ 'hooks must not launch the app before the user opts in');
+
+fs.writeFileSync(configPath, JSON.stringify({ startupRecovery: true }));
+assert.strictEqual(startupRecoveryEnabled({ configPath }), true);
+assert.strictEqual(recoveryAllowed({ configPath, quitPath }), true);
+
+const firstAt = Date.now();
+assert.strictEqual(recoverPackagedApp({ executable, configPath, quitPath, stampPath, now: firstAt, spawn }), true);
+assert.strictEqual(spawns.length, 1);
+assert.deepStrictEqual(spawns[0].args, ['--hook-recovery']);
+assert.strictEqual(spawns[0].options.detached, true);
+assert.strictEqual(recoverPackagedApp({
+ executable, configPath, quitPath, stampPath, now: firstAt + RECOVERY_COOLDOWN_MS - 1, spawn,
+}), false, 'events inside the cooldown must not spawn another app');
+
+assert.strictEqual(recordIntentionalQuit({ quitPath, now: firstAt + 1 }), true);
+assert.strictEqual(recoveryAllowed({ configPath, quitPath }), false);
+assert.strictEqual(recoverPackagedApp({
+ executable, configPath, quitPath, stampPath, now: firstAt + RECOVERY_COOLDOWN_MS, spawn,
+}), false, 'an intentional Quit must suppress hook resurrection');
+assert.strictEqual(clearIntentionalQuit({ quitPath }), true);
+assert.strictEqual(recoveryAllowed({ configPath, quitPath }), true);
+assert.strictEqual(recoverPackagedApp({
+ executable, configPath, quitPath, stampPath, now: firstAt + RECOVERY_COOLDOWN_MS, spawn,
+}), true, 'recovery must resume after an explicit app launch clears the quit marker');
+assert.strictEqual(spawns.length, 2);
+assert.strictEqual(recoverPackagedApp({
+ executable: path.join(tmp, 'missing.exe'), configPath, quitPath, stampPath, spawn,
+}), false);
+
+// Regression: the exact event that finds a stopped app is persisted, then
+// delivered after the recovered server is healthy. Merely spawning is not
+// enough; the queue must empty only after postState confirms success.
+const failedBody = {
+ state: 'attention', event: 'Stop', session_id: 'session-replay', agent_id: 'codex',
+};
+let recoveries = 0;
+let initialDelivery = null;
+deliverStateWithRecovery(failedBody, {
+ postState: (_body, cb) => cb(false),
+ canRecover: () => true,
+ enqueue: (body) => enqueueHookEvent(body, { directory: pendingDir, now: firstAt, id: 'regression' }),
+ recover: () => { recoveries++; return true; },
+ finish: (ok) => { initialDelivery = ok; },
+});
+assert.strictEqual(initialDelivery, false);
+assert.strictEqual(recoveries, 1);
+assert.strictEqual(pendingFiles(pendingDir).length, 1, 'failed event must survive the hook process');
+
+const replayed = [];
+let drainResult = null;
+drainPendingHookEvents({
+ directory: pendingDir,
+ postState: (body, cb) => { replayed.push(body); cb(true); },
+}, (result) => { drainResult = result; });
+assert.deepStrictEqual(replayed, [failedBody], 'recovered server must receive the original failed event');
+assert.deepStrictEqual(drainResult, { delivered: 1, failed: null, remaining: 0 });
+assert.strictEqual(pendingFiles(pendingDir).length, 0, 'successful replay must remove its durable queue entry');
+
+enqueueHookEvent(failedBody, { directory: pendingDir, now: firstAt + 1, id: 'retain-on-failure' });
+drainPendingHookEvents({ directory: pendingDir, postState: (_body, cb) => cb(false) }, () => {});
+assert.strictEqual(pendingFiles(pendingDir).length, 1, 'a failed replay must remain queued for another attempt');
+
+const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
+assert(/onListening: \(\) => schedulePendingHookDrain\(\)/.test(main),
+ 'pending hooks must drain only after the local server starts listening');
+assert(/hookDrainPollTimer = setInterval/.test(main),
+ 'a healthy long-running server must also discover events queued after startup');
+assert(/recordIntentionalQuit\(\)/.test(main) && /quitAppIntentionally/.test(main),
+ 'user-triggered Quit must record intent before the app exits');
+
+fs.rmSync(tmp, { recursive: true, force: true });
+console.log('startup recovery checks passed');
diff --git a/test/window-drag.js b/test/window-drag.js
new file mode 100644
index 0000000..28c0547
--- /dev/null
+++ b/test/window-drag.js
@@ -0,0 +1,117 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const {
+ beginDrag,
+ nextDragBounds,
+ normalizeHitRegions,
+ resizePetLayout,
+} = require('../backend/window-drag');
+
+const drag = beginDrag(
+ { x: -400, y: -38, width: 323, height: 344 },
+ { x: -300, y: 120 },
+ { width: 320, height: 340 },
+);
+
+assert.deepStrictEqual(
+ nextDragBounds(drag, { x: -290, y: 135 }),
+ { x: -390, y: -23, width: 320, height: 340 },
+ 'window movement must equal the main-process cursor delta',
+);
+assert.strictEqual(
+ nextDragBounds(drag, { x: -290, y: 135 }),
+ null,
+ 'a synthetic pointermove caused by moving the window must not reapply identical bounds',
+);
+assert.deepStrictEqual(
+ nextDragBounds(drag, { x: -289, y: 135 }),
+ { x: -389, y: -23, width: 320, height: 340 },
+ 'the intended logical size must not recycle fractionally rounded getBounds dimensions',
+);
+
+const restingBounds = { x: 1576, y: 676, width: 320, height: 340 };
+const workArea = { x: 0, y: 0, width: 1920, height: 1040 };
+const expandedRight = resizePetLayout(
+ restingBounds,
+ { width: 520, height: 520 },
+ workArea,
+);
+assert.deepStrictEqual(
+ expandedRight.bounds,
+ { x: 1400, y: 496, width: 520, height: 520 },
+ 'an expanded popup near the right edge must remain fully inside the work area',
+);
+assert.deepStrictEqual(expandedRight.contentOffset, { x: 76, y: 0 });
+assert.strictEqual(
+ expandedRight.bounds.x + expandedRight.bounds.width / 2 + expandedRight.contentOffset.x,
+ restingBounds.x + restingBounds.width / 2,
+ 'right-edge clamping must not move the visible pet',
+);
+
+assert.deepStrictEqual(
+ normalizeHitRegions(
+ [
+ { x: 90.5, y: 195.25, width: 120, height: 120 },
+ { x: -50, y: -40, width: 30, height: 20 },
+ { x: 10, y: 10, width: 0, height: 8 },
+ { x: Number.NaN, y: 0, width: 10, height: 10 },
+ ],
+ { width: 320, height: 340 },
+ ),
+ [{ x: 74, y: 179, width: 153, height: 153 }],
+ 'native Windows hit regions must be padded, integer, clamped and validated',
+);
+assert.ok(expandedRight.bounds.x >= workArea.x);
+assert.ok(expandedRight.bounds.x + expandedRight.bounds.width <= workArea.x + workArea.width);
+
+const collapsedRight = resizePetLayout(
+ expandedRight.bounds,
+ { width: 320, height: 340 },
+ workArea,
+ expandedRight.contentOffset,
+);
+assert.deepStrictEqual(
+ collapsedRight.bounds,
+ restingBounds,
+ 'closing the popup must restore the exact dragged resting bounds',
+);
+assert.deepStrictEqual(collapsedRight.contentOffset, { x: 0, y: 0 });
+
+const leftResting = { x: 24, y: 676, width: 320, height: 340 };
+const expandedLeft = resizePetLayout(leftResting, { width: 520, height: 520 }, workArea);
+assert.deepStrictEqual(expandedLeft.bounds, { x: 0, y: 496, width: 520, height: 520 });
+assert.deepStrictEqual(expandedLeft.contentOffset, { x: -76, y: 0 });
+assert.strictEqual(
+ expandedLeft.bounds.x + expandedLeft.bounds.width / 2 + expandedLeft.contentOffset.x,
+ leftResting.x + leftResting.width / 2,
+ 'left-edge clamping must not move the visible pet',
+);
+assert.ok(expandedLeft.bounds.x >= workArea.x);
+assert.ok(expandedLeft.bounds.x + expandedLeft.bounds.width <= workArea.x + workArea.width);
+
+const renderer = fs.readFileSync(path.join(__dirname, '..', 'renderer', 'pet.js'), 'utf8');
+const preload = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8');
+const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
+assert(/window\.pet\.beginWinDrag\(\)/.test(renderer), 'renderer must begin drag in the main process');
+assert(/window\.pet\.updateWinDrag\(\)/.test(renderer), 'renderer must use the main process as cursor authority');
+assert(!/window\.pet\.setWinPos\(/.test(renderer), 'renderer must not mix DOM screen coordinates with BrowserWindow bounds');
+assert(/beginWinDrag:/.test(preload) && /endWinDrag:/.test(preload), 'preload must expose the drag lifecycle');
+assert(/screen\.getCursorScreenPoint\(\)/.test(main), 'main process must sample the cursor in BrowserWindow DIP space');
+assert(/if \(st\.drag\) \{ st\.resizeAfterDrag = true; return; \}/.test(main), 'popup resize must be deferred while dragging');
+assert(/resizePetLayout\(/.test(main), 'main process must clamp popup windows and calculate a pet offset');
+assert(/pet:content-offset/.test(main) && /pet:content-offset/.test(preload), 'pet offset must cross the preload boundary');
+assert(/ipcMain\.on\('pet-hit-regions'/.test(main) && /w\.setShape\(shape\)/.test(main),
+ 'Windows must use a native shaped hit region instead of forwarded transparent-window mousemove');
+assert(/process\.platform === 'win32'[\s\S]*w\.setIgnoreMouseEvents\(false\)/.test(main),
+ 'Windows must keep the shaped window mouse-enabled');
+assert(/setHitRegions:/.test(preload) && /window\.pet\.setHitRegions\(regions\)/.test(renderer),
+ 'visible renderer rectangles must cross the preload boundary');
+assert(/new MutationObserver\(schedulePetHitRegions\)/.test(renderer),
+ 'popup/skin changes must refresh the native shape');
+assert(/\.pet-anchor/.test(fs.readFileSync(path.join(__dirname, '..', 'renderer', 'pet.css'), 'utf8')),
+ 'renderer must apply the visual pet offset separately from popup layout');
+
+console.log('window drag checks passed');