diff --git a/.gitignore b/.gitignore
index 77a988eb..21f7494d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,6 +53,7 @@ memory/L4_raw_sessions/*
# ljqCtrl related tools
!memory/ljqCtrl.py
!memory/ljqCtrl_sop.md
+!memory/macljqCtrl.py
!memory/computer_use.md
# procmem_scanner related tools
@@ -87,10 +88,6 @@ memory/L4_raw_sessions/*
# Morphling SOP
!memory/morphling_sop.md
-# Skill Search SOP
-!memory/skill_search/
-!memory/skill_search/**
-
# ADB UI tool
!memory/adb_ui.py
@@ -147,5 +144,8 @@ frontends/conductor_im_plugins/config.local.json
.claude/
+# Project Mode SOP
+!memory/project_mode_sop.md
+
# 本地 bug / 局限报告草稿(交给维护者参考,不进仓库)
/BUG_*.md
diff --git a/README.md b/README.md
index f90beec9..0697dea4 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
+
@@ -24,8 +25,7 @@
-> 📌 **Official Channel** — This GitHub repository is the **only** official source of GenericAgent.
-> We have no affiliation with any third-party website using the GenericAgent name.
+> 📌 **Official:** GitHub + https://gaagent.ai only. DintalClaw is the sole authorized commercial partner; others are not affiliated.
---
@@ -47,6 +47,7 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
- [Demo Showcase](#-demo-showcase)
- [Quick Start](#-quick-start)
- [Usage](#-usage)
+- [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities)
- [Architecture](#-architecture)
- [Self-Evolution Mechanism](#-self-evolution-mechanism)
- [Comparison](#-comparison)
@@ -63,7 +64,7 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
| :--- | :--- |
| 🧬 **Self-Evolving** | Automatically crystallizes each task into a Skill. Capabilities grow with every use, forming your personal skill tree. |
| 🪶 **Minimal Architecture** | ~3K lines of core code. Agent Loop is ~100 lines. No complex dependencies, zero deployment overhead. |
-| ⚡ **Strong Execution** | Injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system. |
+| ⚡ **Strong Execution** | **TMWebdriver** injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system. |
| 🔌 **High Compatibility** | Supports Claude / Gemini / Kimi / MiniMax and other major models. Cross-platform. |
| 💰 **Token Efficient** | <30K context window — a fraction of the 200K–1M other agents consume. Less noise, fewer hallucinations, higher success rate, lower cost. |
@@ -73,8 +74,20 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
- | 🧋 Food Delivery Order |
- 📈 Quantitative Stock Screening |
+ 🛡️ Real-Browser CAPTCHA Survival |
+ 🌐 Autonomous Web Exploration |
+
+
+  |
+  |
+
+
+ | While configuring a Discord bot, an hCaptcha "Are you human?" challenge pops up mid-task — GA's real browser session passes it and the task continues. See Browser Realness. |
+ Autonomously browses and periodically summarizes web content. |
+
+
+ | 🧋 Food Delivery Order |
+ 📈 Quantitative Stock Screening |
 |
@@ -85,25 +98,16 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
"Find GEM stocks with EXPMA golden cross, turnover > 5%" — quantitative screening. |
- | 🌐 Autonomous Web Exploration |
💰 Expense Tracking |
+ 💬 Batch Messaging |
-  |
 |
+  |
- | Autonomously browses and periodically summarizes web content. |
"Find expenses over ¥2K in the last 3 months" — drives Alipay via ADB. |
-
-
- | 💬 Batch Messaging |
-
-
-  |
-
-
- | Sends bulk WeChat messages, fully driving the WeChat client. |
+ Sends bulk WeChat messages, fully driving the WeChat client. |
@@ -125,42 +129,40 @@ curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/ma
### For Humans
-#### Method 1 — One-line install *(recommended)*
+#### Method 1 — Clone & install *(recommended)*
-This installs GenericAgent with an isolated Python environment and Git, then downloads a ready-to-run package.
+```bash
+git clone https://github.com/lsdefine/GenericAgent.git && cd GenericAgent
+uv venv && uv pip install -e ".[ui]"
+cp mykey_template_en.py mykey.py # fill in your LLM API key
+```
+
+Dependencies are deliberately tiered: the agent core needs only `requests`, plus four lightweight packages (`beautifulsoup4`, `bottle`, `simple-websocket-server`, `aiohttp`) for TMWebdriver's local server. The `[ui]` extra pulls in frontend libraries (Streamlit, `prompt_toolkit`/`rich` for the TUI, …) — install it for the bundled UIs, or skip it entirely and drive the agent headless. No Playwright, no LangChain, no browser binaries to download.
+
+Then launch:
+
+```bash
+python frontends/tui_v3.py # Terminal UI (recommended)
+python launch.pyw # Streamlit web UI
+```
+
+#### Method 2 — One-line installer *(convenience)*
+
+Sets up a self-contained directory with an isolated Python environment, Git, and a ready-to-run package. The script is in [`assets/`](assets/) if you'd like to read it first.
**Windows PowerShell**
```powershell
-powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm http://fudankw.cn:9000/files/ga_install.ps1 | iex"
+powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.ps1 | iex"
```
**Linux / macOS**
```bash
-GLOBAL=1 bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)"
+GLOBAL=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.sh)"
```
-After installation, launch the desktop app from:
-
-```text
-frontends/GenericAgent.exe
-```
-
-#### Method 2 — Python install *(for developers)*
-
-```bash
-git clone https://github.com/lsdefine/GenericAgent.git
-cd GenericAgent
-uv venv
-uv pip install -e ".[ui]" # Core + UI dependencies
-cp mykey_template.py mykey.py # Fill in your LLM API key
-python launch.pyw
-```
-
-> 💡 GenericAgent is meant to grow its environment **through the Agent itself**, not by pre-installing every possible package.
-
-📖 Full guide: [`docs/GETTING_STARTED.md`](docs/GETTING_STARTED.md)
+> 💡 GenericAgent grows its environment **through the Agent itself** — don't pre-install everything. See [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities) below.
---
@@ -168,20 +170,12 @@ python launch.pyw
### Frontends
-#### Desktop App
+#### Terminal UI *(recommended)*
-For one-line installs on Windows, double-click:
-
-```text
-frontends/GenericAgent.exe
-```
-
-#### Terminal UI
-
-A lightweight, keyboard-driven interface built on [Textual](https://github.com/Textualize/textual). Supports multiple concurrent sessions and real-time streaming.
+A lightweight, scrollback-first terminal interface built on `prompt_toolkit` + `rich`. Supports multiple concurrent sessions and real-time streaming.
```bash
-python frontends/tuiapp_v2.py
+python frontends/tui_v3.py
```
@@ -189,10 +183,10 @@ python frontends/tuiapp_v2.py
TUI rendering on Windows can be flaky depending on terminal + font. Common causes:
-1. `textual` is not on the latest version — `pip install -U textual` first.
+1. `prompt_toolkit` / `rich` are not on the latest version — `pip install -U prompt_toolkit rich` first.
2. PowerShell / cmd ship with terminals that have rough Unicode + key-binding support. **Prefer Git Bash on Windows**, which is much better behaved.
3. If it still looks broken, ask GA itself to fix it:
- > *"My experience using `frontends/tuiapp_v2.py` in PowerShell / cmd / Git Bash on Windows is very poor — lots of incompatibility. Please refer to Claude Code's best practices for the Windows terminal and fix all font and rendering incompatibilities."*
+ > *"My experience using `frontends/tui_v3.py` in PowerShell / cmd / Git Bash on Windows is very poor — lots of incompatibility. Please refer to Claude Code's best practices for the Windows terminal and fix all font and rendering incompatibilities."*
@@ -204,26 +198,41 @@ python launch.pyw
### Bot Interface (IM)
-GenericAgent also supports IM frontends such as Telegram, WeChat, QQ, Feishu / Lark, WeCom, and DingTalk.
+GenericAgent also supports IM frontends such as Telegram, Discord, and Lark.
| Platform | Command |
| :--- | :--- |
| Telegram | `python frontends/tgapp.py` |
-| WeChat | `python frontends/wechatapp.py` |
-| QQ | `python frontends/qqapp.py` |
-| Feishu / Lark | `python frontends/fsapp.py` |
-| WeCom | `python frontends/wecomapp.py` |
-| DingTalk | `python frontends/dingtalkapp.py` |
+| Discord | `python frontends/dcapp.py` |
+| Lark / Feishu | `python frontends/fsapp.py` |
+> WeChat, QQ, WeCom and DingTalk are also supported — see the Chinese section below.
> For detailed setup, ask GenericAgent itself.
-### Common Chat Commands
+---
-| Command | Description |
+## 🔓 Unlocking Advanced Capabilities
+
+In GA, advanced capabilities are unlocked by **instructing the agent**, not by reading
+docs or installing extras. Each instruction below makes GA read its pre-installed SOPs
+(battle-tested playbooks in its memory), install whatever is missing, adapt to your OS,
+and persist the result into its own memory.
+
+| Capability | Just tell GA |
| :--- | :--- |
-| `/new` | Start a fresh conversation and clear the current context |
-| `/continue` | List recoverable conversation snapshots |
-| `/continue N` | Restore the `N`-th recoverable conversation |
+| 🌐 Web automation | *"Set up your web automation capability."* — GA guides you through the one manual step: dragging the bundled Chrome extension into `chrome://extensions`. |
+| 🔤 OCR | *"Set up your OCR capability with rapidocr and save it to memory."* |
+| 👁️ Vision | *"Set up your vision capability from the template in memory/."* — GA copies the template, wires it to your existing LLM keys, and self-tests. |
+| 🖱️ Computer use | *"Probe this system and set up your computer-use capability."* |
+
+> 💡 **About language**: the pre-installed SOPs are written in Chinese — GA reads them
+> natively, so this never blocks you. If you prefer an English knowledge base, just say:
+> *"Read your pre-installed SOPs and rewrite them in English (keep code, paths and error
+> strings verbatim)."*
+>
+> 🌍 **About platforms**: the SOPs were honed on Windows, but cross-platform adaptation is
+> itself a GA task — on macOS/Linux, GA swaps in the platform equivalents (window
+> enumeration, input control, screenshots) on its own. Same self-evolution principle.
---
@@ -298,6 +307,7 @@ This is what fundamentally distinguishes GenericAgent from every other agent fra
| What you say | First time | Every time after |
| :--- | :--- | :--- |
| *"Read my WeChat messages"* | Install deps → reverse DB → write read script → save Skill | **one-line invoke** |
+| *"Give me a morning digest of Hacker News"* | Write scraper → build digest → schedule daily run → save Skill | **one-line invoke** |
| *"Monitor stocks and alert me"* | Install `mootdx` → build selection flow → configure cron → save Skill | **one-line start** |
| *"Send this file via Gmail"* | Configure OAuth → write send script → save Skill | **ready to use** |
@@ -347,6 +357,21 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and
+### Browser Realness of GA Web Tools (TMWebdriver)
+
+GA web tools are powered by **TMWebdriver** — a local WebSocket server plus a Chrome extension — running through a **real, persistent Chrome/Chromium session** rather than a disposable headless sandbox, preserving cookies, login state, extensions, GPU/WebGL behavior, and normal browser-session fingerprints.
+
+| Detection Service / Signal | Vanilla Headless Automation | GA Web Tools | Notes |
+| :--- | :---: | :---: | :--- |
+| SannySoft headless test | Often detected | ✅ 56/56 passed | `bot.sannysoft.com` |
+| bot.incolumitas.com | Commonly fails webdriver / CDP checks | ✅ 36/36 passed | `WEBDRIVER`, `SELENIUM_DRIVER`, `webDriverAdvanced` all OK |
+| BrowserScan bot detection | Often abnormal | ✅ Normal | `browserscan.net` |
+| Device & Browser Info bot test | Multiple bot flags | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` |
+| FingerprintJS bot detection demo | Often detected | ✅ Passed | Demo flow completed without bot verdict |
+| reCAPTCHA v3 demo | Low bot-like score | ✅ 0.9 human-like score | Score-based risk signal; 0.9 is above typical production thresholds |
+
+For reCAPTCHA v3, `0.9` is not a "checkbox solved" result; it is the high-confidence human-like score returned by the risk model, typically sufficient to avoid extra challenges in production flows.
+
---
## 📅 Roadmap & News
@@ -361,9 +386,9 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and
- **2026-04-21** — 📄 [**Technical Report on arXiv**](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*.
- **2026-04-11** — Introduced **L4 session archive memory** and scheduler cron integration.
- **2026-03-23** — Personal WeChat supported as a bot frontend.
-- **2026-03-10** — [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7).
-- **2026-03-08** — [Released "Dintal Claw" — a GenericAgent-powered government-affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg).
-- **2026-03-01** — [Featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg).
+- **2026-03-10** — [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7) *(Chinese)*.
+- **2026-03-08** — [Released "Dintal Claw" — a GenericAgent-powered government-affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg) *(Chinese)*.
+- **2026-03-01** — [Featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg) *(Chinese)*.
- **2026-01-16** — GenericAgent **V1.0** public release.
---
@@ -372,16 +397,6 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and
If this project helped you, please consider leaving a **Star!** 🙏
-You're also welcome to join the **GenericAgent Community Group** for discussion, feedback, and co-building 👏
-
-
-
-
- WeChat Group 20
 |
-
-
-
-
### 🚩 Friendly Links
Thanks to the **LinuxDo** community for the support!
@@ -393,6 +408,7 @@ Thanks to the **LinuxDo** community for the support!
- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager)
- [wangjc683/galley](https://github.com/wangjc683/galley) — Out-of-the-box local agent workbench with a bundled GA runtime (CPython 3.11 + deps), native GUI/CLI, multi-session + Project orchestration, local-first.
- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench)
+- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) — Go + React desktop admin panel: service lifecycle management, native chat, Goal mode, BBS team board, file editor, model config wizard, TMWebDriver monitor, self-update, and Windows tray/desktop-pet integration.
---
@@ -400,7 +416,7 @@ Thanks to the **LinuxDo** community for the support!
Distributed under the **MIT License**. See [`LICENSE`](LICENSE) for full text.
-> *Disclaimer: This project does not build or operate any commercial website. Apart from DintalClaw, no institution, organization, or individual is currently officially authorized to conduct commercial activities under the GenericAgent name.*
+> *Disclaimer: The official GenericAgent channels are this GitHub repository and https://gaagent.ai. DintalClaw is currently the only officially authorized commercial partner; any other third-party website, organization, or individual using the GenericAgent name is not official unless explicitly listed here.*
---
@@ -516,11 +532,10 @@ powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_insta
curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash
```
-安装完成后,双击启动:
+安装完成后启动:
-```text
-frontends/GenericAgent.exe
-```
+- **Windows** — 双击 `frontends/GenericAgent.exe`
+- **Linux / macOS** — 在安装目录运行 `python launch.pyw`
#### 方法二 — Python 安装 *(开发者)*
@@ -547,7 +562,7 @@ python launch.pyw
#### 桌面端
-一键安装自带桌面端,双击:
+一键安装自带桌面端(Windows),双击:
```text
frontends/GenericAgent.exe
@@ -579,11 +594,12 @@ python launch.pyw
### Bot 接口(IM)
-GenericAgent 支持 Telegram、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。
+GenericAgent 支持 Telegram、Discord、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。
| 平台 | 启动命令 |
| :--- | :--- |
| Telegram | `python frontends/tgapp.py` |
+| Discord | `python frontends/dcapp.py` |
| 微信 | `python frontends/wechatapp.py` |
| QQ | `python frontends/qqapp.py` |
| 飞书 / Lark | `python frontends/fsapp.py` |
@@ -592,14 +608,6 @@ GenericAgent 支持 Telegram、微信、QQ、飞书 / Lark、企业微信、钉
> 详细配置直接问 GenericAgent。
-### 通用聊天命令
-
-| 命令 | 说明 |
-| :--- | :--- |
-| `/new` | 开启新对话并清空当前上下文 |
-| `/continue` | 列出可恢复会话快照 |
-| `/continue N` | 恢复第 `N` 个可恢复会话 |
-
---
## 🧠 架构设计
@@ -721,6 +729,21 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
+### GA Web 工具的浏览器真实性
+
+GA Web 工具运行在**真实、持久化的 Chrome/Chromium 会话**中,而不是一次性的 headless 沙箱,因此可以保留 Cookie、登录态、扩展、GPU/WebGL 行为以及正常浏览器会话指纹。
+
+| 检测服务 / 信号 | 普通 Headless 自动化 | GA Web 工具 | 说明 |
+| :--- | :---: | :---: | :--- |
+| SannySoft headless test | 常被识别 | ✅ 56/56 通过 | `bot.sannysoft.com` |
+| bot.incolumitas.com | 常在 webdriver / CDP 项异常 | ✅ 36/36 通过 | `WEBDRIVER`、`SELENIUM_DRIVER`、`webDriverAdvanced` 全部 OK |
+| BrowserScan bot detection | 常显示异常 | ✅ Normal | `browserscan.net` |
+| Device & Browser Info bot test | 多个 bot 标记 | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` |
+| FingerprintJS bot detection demo | 常被识别 | ✅ 通过 | Demo 流程完成,未给出 bot 判定 |
+| reCAPTCHA v3 demo | 低分 / bot-like | ✅ 0.9 真人相似分 | v3 是基于分数的风险信号;0.9 高于常见生产阈值 |
+
+对于 reCAPTCHA v3,`0.9` 不是“点过验证码”的结果,而是风控模型返回的高置信真人相似分,通常足以通过生产环境中的常见阈值,避免进入更严格挑战。
+
---
## 📅 路线图与最新动态
@@ -751,7 +774,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
- 微信群 20
 |
+ 微信群 21
 |
@@ -767,6 +790,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager)
- [wangjc683/galley](https://github.com/wangjc683/galley) —— 开箱即用的本地 Agent 工作台,自带 GA 内核(内置 CPython 3.11 + 运行依赖),GUI/CLI 双原生、多 session + Project 编排、本地优先。
- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench)
+- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) —— Go + React 桌面管理面板:服务生命周期管理、原生 Chat、Goal 模式、BBS 团队看板、文件编辑器、模型配置向导、TMWebDriver 监控、自更新,以及 Windows 托盘/桌面宠物集成。
---
@@ -774,7 +798,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
基于 **MIT License** 发布,详见 [`LICENSE`](LICENSE)。
-> *声明:本项目未构建任何商业站点;除 DintalClaw 外,目前未官方授权任何机构、组织或个人以 GenericAgent 名义从事商业活动。*
+> *声明:GenericAgent 官方渠道为本 GitHub 仓库和 https://gaagent.ai。DintalClaw 是目前唯一官方授权的商业合作方;除非在此处明确列出,其他使用 GenericAgent 名义的第三方网站、机构、组织或个人均非官方。*
---
diff --git a/TMWebDriver.py b/TMWebDriver.py
index b2c4f7d6..c14535a1 100644
--- a/TMWebDriver.py
+++ b/TMWebDriver.py
@@ -93,12 +93,11 @@ class TMWebDriver:
session_id = data.get('sessionId')
code = data.get('code')
timeout = float(data.get('timeout', 10.0))
- try:
- result = self.execute_js(code, timeout=timeout, session_id=session_id)
- print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' '))
- return json.dumps({'r': result}, ensure_ascii=False)
- except Exception as e:
- return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False)
+ try: result = self.execute_js(code, timeout=timeout, session_id=session_id)
+ except Exception as e: return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False)
+ try: print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' '))
+ except Exception: pass
+ return json.dumps({'r': result}, ensure_ascii=False)
return 'ok'
def run():
from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler
@@ -245,7 +244,7 @@ class TMWebDriver:
def _remote_cmd(self, cmd):
try: return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd, timeout=30).json()
except (ConnectionError, requests.exceptions.ConnectionError):
- raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop启动master")
+ raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop后台启动一个TMWebDriver")
def get_all_sessions(self):
if self.is_remote:
diff --git a/agentmain.py b/agentmain.py
index 4e06285d..08b78e09 100644
--- a/agentmain.py
+++ b/agentmain.py
@@ -42,6 +42,10 @@ def get_system_prompt():
prompt += get_global_memory()
return prompt
+# SDK:
+# agent = GenericAgent(); threading.Thread(target=agent.run, daemon=True).start()
+# output1_queue = agent.put_task(prompt1)
+# output2_queue = agent.put_task(prompt2)
class GenericAgent:
def __init__(self):
os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)
@@ -50,12 +54,13 @@ class GenericAgent:
self.history = []; self.handler = None;
self.task_queue = queue.Queue()
self.is_running = False; self.stop_sig = False; self.llm_no = 0;
- self.inc_out = False; self.verbose = True; self.show_mode = 'text'
+ self.inc_out = False; self.verbose = True
self.peer_hint = True
self.force_non_stream = False
logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}'
self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{logid}.txt')
self.load_llm_sessions()
+ self.extra_sys_prompts = []
def load_llm_sessions(self):
mykeys, changed = reload_mykeys()
@@ -136,14 +141,13 @@ class GenericAgent:
if raw_query is None:
self.task_queue.task_done(); continue
self.is_running = True
- if len(raw_query) > 1500:
+ if len(raw_query) > 2000:
task_file = os.path.join(script_dir, 'temp', f'user_prompt_{int(time.time())}.md')
with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query)
raw_query = f'Long user prompt saved to {task_file}. Read and execute.'
rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200)
self.history.append(f"[USER]: {rquery}")
-
- sys_prompt = get_system_prompt() + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
+ sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n"
handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp'))
if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None
@@ -158,7 +162,7 @@ class GenericAgent:
self.llmclient.backend.stream = False
self.llmclient.backend.read_timeout = max(self.llmclient.backend.read_timeout, 1200)
gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, handler, TOOLS_SCHEMA,
- max_turns=80, verbose=self.verbose, yield_info=True)
+ max_turns=180, verbose=self.verbose, yield_info=True)
try:
full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = []
for chunk in gen:
@@ -187,13 +191,13 @@ class GenericAgent:
self.task_queue.task_done()
if self.handler is not None: self.handler.code_stop_signal.append(1)
-GeneraticAgent = GenericAgent
+GeneraticAgent = GenericAgent
if __name__ == '__main__':
import argparse
from datetime import datetime
parser = argparse.ArgumentParser()
- parser.add_argument('--task', metavar='IODIR', help='一次性任务模式(文件IO)')
+ parser.add_argument('--task', metavar='IODIR', help='一次性任务模式,先看subagent.md')
parser.add_argument('--reflect', metavar='SCRIPT', help='反射模式:加载监控脚本,check()触发时发任务')
parser.add_argument('--input', help='prompt')
parser.add_argument('--llm_no', type=int, default=0)
@@ -260,24 +264,24 @@ if __name__ == '__main__':
except Exception as e:
print(f'[Reflect] check() error: {e}'); task = None
if task and task == '/exit': break
- if not task:
- time.sleep(getattr(mod, 'INTERVAL', 5)); continue
- print(f'[Reflect] triggered: {task[:80]}')
- dq = agent.put_task(task, source='reflect')
- try:
- while 'done' not in (item := dq.get(timeout=1200)): pass
- result = item['done']
- print(result)
- except Exception as e:
- if getattr(mod, 'ONCE', False): raise
- print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}'
- log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True)
- script_name = os.path.splitext(os.path.basename(args.reflect))[0]
- open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n')
- if (on_done := getattr(mod, 'on_done', None)):
- try: on_done(result)
- except Exception as e: print(f'[Reflect] on_done error: {e}')
- if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break
+ if task:
+ print(f'[Reflect] triggered: {task[:80]}')
+ dq = agent.put_task(task, source='reflect')
+ try:
+ while 'done' not in (item := dq.get(timeout=1200)): pass
+ result = item['done']
+ print(result)
+ except Exception as e:
+ if getattr(mod, 'ONCE', False): raise
+ print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}'
+ log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True)
+ script_name = os.path.splitext(os.path.basename(args.reflect))[0]
+ open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n')
+ if (on_done := getattr(mod, 'on_done', None)):
+ try: on_done(result)
+ except Exception as e: print(f'[Reflect] on_done error: {e}')
+ if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break
+ time.sleep(getattr(mod, 'INTERVAL', 5))
else:
try: import readline
except Exception: pass
diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py
index 39d93414..b9ed19d7 100644
--- a/assets/configure_mykey.py
+++ b/assets/configure_mykey.py
@@ -29,38 +29,62 @@ MYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py')
# ── 模型厂商定义 ───────────────────────────────────────────────────────────
LLM_PROVIDERS = [
- # ═══════════════════════════ 直连 API(按旗舰能力降序)═══════════════════════════
+ # ═══════════════════════════ 通用协议(官方直连或任意兼容中转)═══════════════════════════
{
- 'id': 'anthropic',
- 'name': 'Anthropic Claude 官方直连',
- 'desc': 'Claude Opus #1 最强模型,原生 tool 协议,sk-ant- 开头',
- 'type': 'native_claude',
- 'template': {
- 'name': 'anthropic-direct', 'apikey': 'sk-ant-',
- 'apibase': 'https://api.anthropic.com', 'model': 'claude-opus-4-7',
- 'thinking_type': 'adaptive', 'max_tokens': 32768, 'temperature': 1,
- },
- 'key_hint': '在 https://console.anthropic.com/ 获取',
- 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'],
- },
- {
- 'id': 'openai',
- 'name': 'OpenAI GPT-5.5 / o 系列',
- 'desc': 'GPT-5.5 旗舰,与 Claude Opus 同级',
+ 'id': 'oai_chat',
+ 'name': 'OpenAI Chat Completions 协议',
+ 'desc': '官方直连或任意 OAI 兼容中转/网关,自填 apibase(回车=OpenAI 官方)',
'type': 'native_oai',
'template': {
- 'name': 'gpt-native', 'apikey': 'sk-',
+ 'name': 'gpt-native', 'apikey': 'sk-',
'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5',
'api_mode': 'chat_completions', 'reasoning_effort': 'high',
'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,
},
- 'key_hint': '在 https://platform.openai.com/api-keys 获取',
+ 'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key',
'model_choices': ['gpt-5.5', 'gpt-5.4'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'},
+ ],
},
+ {
+ 'id': 'oai_responses',
+ 'name': 'OpenAI Responses 协议',
+ 'desc': 'Responses API(o 系列/GPT-5.5 推荐端点),官方或兼容网关,自填 apibase',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'gpt-responses', 'apikey': 'sk-',
+ 'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5',
+ 'api_mode': 'responses', 'reasoning_effort': 'high',
+ 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,
+ },
+ 'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key',
+ 'model_choices': ['gpt-5.5', 'gpt-5.4'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'},
+ ],
+ },
+ {
+ 'id': 'claude_messages',
+ 'name': 'Claude Messages 协议',
+ 'desc': 'Anthropic 官方直连或任意 Claude 兼容中转,自填 apibase(回车=官方)',
+ 'type': 'native_claude',
+ 'template': {
+ 'name': 'anthropic-direct', 'apikey': 'sk-ant-',
+ 'apibase': 'https://api.anthropic.com', 'model': 'claude-opus-4-7',
+ 'thinking_type': 'adaptive', 'max_tokens': 32768, 'temperature': 1,
+ },
+ 'key_hint': '官方在 https://console.anthropic.com/ 获取;中转站填其提供的 Key',
+ 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.anthropic.com'},
+ ],
+ },
+ # ═══════════════════════════ 直连 API(按旗舰能力降序)═══════════════════════════
{
'id': 'deepseek',
'name': 'DeepSeek (v4-Pro / Flash)',
- 'desc': '最强开源模型,v4-Pro 旗舰 1M 上下文',
+ 'desc': '开源模型,v4-Pro 旗舰 1M 上下文',
'type': 'native_oai',
'template': {
'name': 'deepseek', 'apikey': 'sk-',
@@ -73,7 +97,7 @@ LLM_PROVIDERS = [
{
'id': 'kimi',
'name': 'Kimi (k2.6 / k2.5) 双协议',
- 'desc': '月之暗面,国内顶尖,支持 Anthropic 和 OAI 双协议',
+ 'desc': '月之暗面,支持 Anthropic 和 OAI 双协议',
'type': 'native_claude',
'template': {
'name': 'kimi', 'apikey': 'sk-kimi-',
@@ -145,16 +169,16 @@ LLM_PROVIDERS = [
},
{
'id': 'minimax',
- 'name': 'MiniMax M2.7 (双协议)',
- 'desc': 'MiniMax M2.7,支持 Anthropic 和 OpenAI 双协议',
+ 'name': 'MiniMax M3 (双协议)',
+ 'desc': 'MiniMax M3,支持 Anthropic 和 OpenAI 双协议',
'type': 'native_claude',
'template': {
'name': 'minimax', 'apikey': 'eyJh...',
'apibase': 'https://api.minimaxi.com/anthropic',
- 'model': 'MiniMax-M2.7', 'max_retries': 3,
+ 'model': 'MiniMax-M3', 'max_retries': 3,
},
'key_hint': '在 https://platform.minimaxi.com/user-center/basic-information 获取',
- 'model_choices': ['MiniMax-M2.7', 'MiniMax-M2.5'],
+ 'model_choices': ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'],
'extra_fields': [
{
'key': '_protocol', 'label': '选择 API 协议',
diff --git a/assets/demo/discord_hcaptcha_real_browser.gif b/assets/demo/discord_hcaptcha_real_browser.gif
new file mode 100644
index 00000000..5630e8cd
Binary files /dev/null and b/assets/demo/discord_hcaptcha_real_browser.gif differ
diff --git a/assets/ga_install.ps1 b/assets/ga_install.ps1
new file mode 100644
index 00000000..5d5e1950
--- /dev/null
+++ b/assets/ga_install.ps1
@@ -0,0 +1,577 @@
+#requires -version 5.1
+
+<#!
+
+GenericAgent one-click portable deployer for Windows.
+
+
+
+Modes:
+
+ Default/Mainland: download GenericAgent.zip + uv + PortableGit from user's VPS, set China PyPI mirror.
+
+ GLOBAL=1: clone GenericAgent from GitHub; uv and PortableGit also come from GitHub releases; no PyPI mirror.
+
+
+
+Portable components are installed under \.portable:
+
+ uv, Python installed by uv, PortableGit.
+
+#>
+
+param(
+
+ [string]$InstallDir = "$env:USERPROFILE\GenericAgent",
+
+ [string]$PythonVersion = "3.12",
+
+ [switch]$Force
+
+)
+
+
+
+$ErrorActionPreference = "Stop"
+
+
+
+# Make Chinese output reliable in Windows PowerShell 5.1 and redirected logs.
+
+try {
+
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
+
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+
+ $OutputEncoding = [System.Text.Encoding]::UTF8
+
+} catch { }
+
+
+
+$RepoUrl = "https://github.com/lsdefine/GenericAgent.git"
+
+$VpsBase = "http://47.101.182.29:9000"
+
+$GaZipUrl = "$VpsBase/files/GenericAgent.zip"
+
+$UvUrl = "$VpsBase/uv/uv-x86_64-pc-windows-msvc.zip"
+
+$GitUrl = "$VpsBase/files/PortableGit-2.54.0-64-bit.7z.exe"
+
+$Deps = @("requests>=2.28", "beautifulsoup4>=4.12", "bottle>=0.12", "simple-websocket-server>=0.4", "streamlit>=1.28")
+
+$MainlandIndex = "https://pypi.tuna.tsinghua.edu.cn/simple"
+
+$GlobalMode = ($env:GLOBAL -eq "1")
+
+if ($GlobalMode) {
+ # GLOBAL=1: fetch everything from GitHub; no mainland endpoints involved.
+ $UvUrl = "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip"
+ $GitUrl = "https://github.com/git-for-windows/git/releases/download/v2.54.0.windows.1/PortableGit-2.54.0-64-bit.7z.exe"
+}
+
+
+
+$GaDir = [IO.Path]::GetFullPath($InstallDir)
+
+$PortableRoot = Join-Path $GaDir ".portable"
+
+$Bin = Join-Path $PortableRoot "bin"
+
+$Cache = Join-Path $PortableRoot "cache"
+
+$Tools = Join-Path $PortableRoot "tools"
+
+$UvZip = Join-Path $Cache "uv-x86_64-pc-windows-msvc.zip"
+
+$GaZip = Join-Path $Cache "GenericAgent.zip"
+
+$GitExeArchive = Join-Path $Cache "PortableGit-2.54.0-64-bit.7z.exe"
+
+$UvExtract = Join-Path $Cache "uv-extract"
+
+$GaExtract = Join-Path $Cache "ga-extract"
+
+$GitDir = Join-Path $Tools "PortableGit"
+
+$UvExe = Join-Path $Bin "uv.exe"
+
+$GitExe = Join-Path $GitDir "bin\git.exe"
+
+$EnvCmd = Join-Path $GaDir "env.cmd"
+
+$EnvPs1 = Join-Path $GaDir "env.ps1"
+
+
+
+function Say($m) { Write-Host "[ga-deploy] $m" -ForegroundColor Cyan }
+
+function Ok($m) { Write-Host "[ok] $m" -ForegroundColor Green }
+
+function Die($m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
+
+function Invoke-Native([scriptblock]$Command) {
+
+ $prevEAP = $ErrorActionPreference
+
+ $ErrorActionPreference = 'Continue'
+
+ try { & $Command } finally { $ErrorActionPreference = $prevEAP }
+
+ return $LASTEXITCODE
+
+}
+
+
+
+function Download-File($Url, $OutFile) {
+
+ New-Item -ItemType Directory -Force -Path (Split-Path $OutFile) | Out-Null
+
+ Say "Downloading $Url"
+
+ $wc = New-Object System.Net.WebClient
+
+ $wc.Headers.Add("User-Agent", "Mozilla/5.0 ga-deploy")
+
+ try { $wc.DownloadFile($Url, $OutFile) } finally { $wc.Dispose() }
+
+ if (!(Test-Path $OutFile) -or ((Get-Item $OutFile).Length -lt 1024)) { Die "Download failed: $Url" }
+
+}
+
+
+
+function Expand-ZipClean($Zip, $Dest) {
+
+ if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest }
+
+ New-Item -ItemType Directory -Force -Path $Dest | Out-Null
+
+ Expand-Archive -Path $Zip -DestinationPath $Dest -Force
+
+}
+
+
+
+function Copy-DirectoryContents($Src, $Dst) {
+
+ New-Item -ItemType Directory -Force -Path $Dst | Out-Null
+
+ Get-ChildItem -LiteralPath $Src -Force | ForEach-Object {
+
+ Copy-Item -LiteralPath $_.FullName -Destination $Dst -Recurse -Force
+
+ }
+
+}
+
+
+
+Say "Install dir: $GaDir"
+
+Say "Mode: $(if ($GlobalMode) { 'GLOBAL=1 / GitHub clone' } else { 'Mainland / VPS zip' })"
+
+
+
+if ((Test-Path $GaDir) -and $Force) { Remove-Item -Recurse -Force $GaDir }
+
+New-Item -ItemType Directory -Force -Path $GaDir,$PortableRoot,$Bin,$Cache,$Tools | Out-Null
+
+
+
+# uv (GitHub release in GLOBAL mode, user's VPS otherwise)
+
+if (!(Test-Path $UvExe) -or $Force) {
+
+ Download-File $UvUrl $UvZip
+
+ Expand-ZipClean $UvZip $UvExtract
+
+ $foundUv = Get-ChildItem -Path $UvExtract -Recurse -Filter "uv.exe" | Select-Object -First 1
+
+ if (!$foundUv) { Die "uv.exe not found in archive" }
+
+ Copy-Item $foundUv.FullName $UvExe -Force
+
+}
+
+Ok "uv: $(& $UvExe --version)"
+
+
+
+# Configure portable Python location. Mirror only in mainland mode.
+
+$env:UV_PYTHON_INSTALL_DIR = Join-Path $PortableRoot "uv-python"
+
+$env:UV_CACHE_DIR = Join-Path $PortableRoot "uv-cache"
+
+if ($GlobalMode) {
+
+ Remove-Item Env:UV_DEFAULT_INDEX -ErrorAction SilentlyContinue
+
+ Remove-Item Env:PIP_INDEX_URL -ErrorAction SilentlyContinue
+
+} else {
+
+ $env:UV_DEFAULT_INDEX = $MainlandIndex
+
+ $env:PIP_INDEX_URL = $MainlandIndex
+
+}
+
+$env:PATH = "$Bin;$env:PATH"
+
+
+
+
+# Workaround: uv creates minor-version symlinks (junctions) in UV_PYTHON_INSTALL_DIR.
+# If a previous interrupted install left a plain directory, uv fails with os error 4390.
+# Fix: remove any non-junction subdirectory so uv can recreate them cleanly.
+$uvPyDir = $env:UV_PYTHON_INSTALL_DIR
+if (Test-Path $uvPyDir) {
+ Get-ChildItem -LiteralPath $uvPyDir -Directory | ForEach-Object {
+ $attr = $_.Attributes
+ if (($attr -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
+ Say "Removing stale non-junction dir: $($_.Name)"
+ Remove-Item -LiteralPath $_.FullName -Recurse -Force
+ }
+ }
+}
+
+Say "Installing Python $PythonVersion via uv"
+
+$ec = Invoke-Native { & $UvExe python install $PythonVersion }
+
+if ($ec -ne 0) { Die "uv python install failed" }
+
+$PythonExe = (& $UvExe python find $PythonVersion).Trim()
+
+if (!(Test-Path $PythonExe)) { Die "uv installed Python but python.exe was not found" }
+
+Ok "Python: $(& $PythonExe --version)"
+
+
+
+# PortableGit (GitHub release in GLOBAL mode, user's VPS otherwise). Needed for GLOBAL=1 and useful for user shell.
+
+if (!(Test-Path $GitExe) -or $Force) {
+
+ Download-File $GitUrl $GitExeArchive
+
+ if (Test-Path $GitDir) { Remove-Item -Recurse -Force $GitDir }
+
+ New-Item -ItemType Directory -Force -Path $GitDir | Out-Null
+
+ Say "Extracting PortableGit"
+
+ $ec = Invoke-Native { & $GitExeArchive -y -o"$GitDir" | Out-Null }
+
+ if ($ec -ne 0) { Die "PortableGit extraction failed" }
+
+}
+
+if (!(Test-Path $GitExe)) { Die "git.exe missing: $GitExe" }
+
+Ok "Git: $(& $GitExe --version)"
+
+
+
+$PythonDir = Split-Path $PythonExe -Parent
+
+$GitBin = Split-Path $GitExe -Parent
+
+$GitUsrBin = Join-Path $GitDir "usr\bin"
+
+$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;$env:PATH"
+
+
+
+# Fetch/update GenericAgent source.
+
+if ($GlobalMode) {
+
+ Say "Cloning GenericAgent from GitHub"
+
+ $items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" })
+
+ if ($items.Count -gt 0) {
+
+ if (!$Force) { Die "Install dir contains files. Re-run with -Force to replace source while preserving portable tools." }
+
+ $items | Remove-Item -Recurse -Force
+
+ }
+
+ $TmpClone = Join-Path $Cache "ga-clone"
+
+ if (Test-Path $TmpClone) { Remove-Item -Recurse -Force $TmpClone }
+
+ $ec = Invoke-Native { & $GitExe clone --depth 1 $RepoUrl $TmpClone }
+
+ if ($ec -ne 0) { Die "git clone failed" }
+
+ Copy-DirectoryContents $TmpClone $GaDir
+
+ Remove-Item -Recurse -Force $TmpClone
+
+} else {
+
+ Say "Downloading GenericAgent package from VPS"
+
+ Download-File $GaZipUrl $GaZip
+
+ Expand-ZipClean $GaZip $GaExtract
+
+ $SrcDir = Join-Path $GaExtract "GenericAgent"
+
+ if (!(Test-Path $SrcDir)) { $SrcDir = $GaExtract }
+
+ $items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" })
+
+ if ($items.Count -gt 0) { $items | Remove-Item -Recurse -Force }
+
+ Copy-DirectoryContents $SrcDir $GaDir
+
+}
+
+Ok "GenericAgent source ready: $GaDir"
+
+
+
+# Install basic dependencies and project in editable mode into portable Python.
+
+Say "Installing GenericAgent dependencies via uv pip"
+
+$installArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
+
+if (!$GlobalMode) { $installArgs += @("--index-url", $MainlandIndex) }
+
+$installArgs += $Deps
+
+$ec = Invoke-Native { & $UvExe @installArgs }
+
+if ($ec -ne 0) { Die "dependency install failed" }
+
+
+
+if (Test-Path (Join-Path $GaDir "pyproject.toml")) {
+
+ $projectArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
+
+ if (!$GlobalMode) { $projectArgs += @("--index-url", $MainlandIndex) }
+
+ $projectArgs += @("-e", $GaDir)
+
+ $ec = Invoke-Native { & $UvExe @projectArgs }
+
+ if ($ec -ne 0) { Die "editable project install failed" }
+
+}
+
+
+
+# Try-install pywebview (optional UI). Failure is non-fatal.
+
+Say "Attempting to install pywebview (optional, failure is OK)"
+
+$webviewArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
+
+if (!$GlobalMode) { $webviewArgs += @("--index-url", $MainlandIndex) }
+
+$webviewArgs += @("pywebview>=4.0")
+
+$ec = Invoke-Native { & $UvExe @webviewArgs 2>&1 | Out-Null }
+
+if ($ec -ne 0) {
+
+ Write-Host "[warn] pywebview install failed. This is optional." -ForegroundColor Yellow
+
+ Write-Host " On Windows it usually works out of the box." -ForegroundColor Yellow
+
+ Write-Host " If needed later: uv pip install pywebview" -ForegroundColor Yellow
+
+} else {
+
+ Ok "pywebview installed successfully"
+
+}
+
+
+
+# Activation scripts: portable paths are intentionally before system PATH.
+
+if ($GlobalMode) {
+
+@"
+
+@echo off
+
+set "PORTABLE_DEV_ROOT=$PortableRoot"
+
+set "GENERICAGENT_HOME=$GaDir"
+
+set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python"
+
+set "UV_CACHE_DIR=$PortableRoot\uv-cache"
+
+set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%"
+
+echo Activated GenericAgent portable env: %GENERICAGENT_HOME%
+
+"@ | Set-Content -Path $EnvCmd -Encoding ASCII
+
+
+
+@"
+
+`$env:PORTABLE_DEV_ROOT = "$PortableRoot"
+
+`$env:GENERICAGENT_HOME = "$GaDir"
+
+`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python"
+
+`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache"
+
+`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH"
+
+Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green
+
+"@ | Set-Content -Path $EnvPs1 -Encoding UTF8
+
+} else {
+
+@"
+
+@echo off
+
+set "PORTABLE_DEV_ROOT=$PortableRoot"
+
+set "GENERICAGENT_HOME=$GaDir"
+
+set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python"
+
+set "UV_CACHE_DIR=$PortableRoot\uv-cache"
+
+set "UV_DEFAULT_INDEX=$MainlandIndex"
+
+set "PIP_INDEX_URL=$MainlandIndex"
+
+set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%"
+
+echo Activated GenericAgent portable env: %GENERICAGENT_HOME%
+
+"@ | Set-Content -Path $EnvCmd -Encoding ASCII
+
+
+
+@"
+
+`$env:PORTABLE_DEV_ROOT = "$PortableRoot"
+
+`$env:GENERICAGENT_HOME = "$GaDir"
+
+`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python"
+
+`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache"
+
+`$env:UV_DEFAULT_INDEX = "$MainlandIndex"
+
+`$env:PIP_INDEX_URL = "$MainlandIndex"
+
+`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH"
+
+Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green
+
+"@ | Set-Content -Path $EnvPs1 -Encoding UTF8
+
+}
+
+
+
+Ok "Verification:"
+
+& $UvExe --version
+
+& $PythonExe --version
+
+& $GitExe --version
+
+& $PythonExe -c "import requests, bs4, bottle; print('deps ok')"
+
+Write-Host ""
+
+
+
+# Copy mykey template if mykey.py does not exist (GLOBAL mode only)
+
+$MykeyDst = Join-Path $GaDir "mykey.py"
+
+if ($GlobalMode -and !(Test-Path $MykeyDst)) {
+
+ $MykeyTpl = Join-Path $GaDir "mykey_template_en.py"
+
+ if (Test-Path $MykeyTpl) {
+
+ Copy-Item $MykeyTpl $MykeyDst
+
+ Ok "Copied mykey_template_en.py -> mykey.py"
+
+ }
+
+}
+
+
+
+# Final banner
+
+Write-Host ""
+
+if ($GlobalMode) {
+
+ Write-Host @"
+
+╔═══════════════════════════════════════════════╗
+
+║ ✅ GenericAgent installed successfully! ║
+
+╠═══════════════════════════════════════════════╣
+
+║ 📁 Location: $GaDir
+
+║ 🔑 Config: edit mykey.py (copied from template)
+
+║ 🚀 Launch: ga tui / ga launch / ga hub
+
+╚═══════════════════════════════════════════════╝
+
+"@
+
+} else {
+
+ Write-Host @"
+
+╔═══════════════════════════════════════════════╗
+
+║ [OK] GenericAgent 安装完成! ║
+
+╠═══════════════════════════════════════════════╣
+
+║ 安装目录: $GaDir
+
+║ 配置密钥: ga configure
+
+║ 启动: ga tui / ga launch / ga hub
+
+╚═══════════════════════════════════════════════╝
+
+"@
+
+}
+
+Write-Host ""
+
+Write-Host " Activate env: cmd.exe → call `"$EnvCmd`" | PowerShell → . `"$EnvPs1`""
+
diff --git a/assets/ga_install.sh b/assets/ga_install.sh
new file mode 100644
index 00000000..df6ad322
--- /dev/null
+++ b/assets/ga_install.sh
@@ -0,0 +1,289 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# GenericAgent one-click portable deployer for macOS/Linux.
+# Modes:
+# Default/Mainland: download GenericAgent.zip + uv from user's VPS, set China PyPI mirror.
+# GLOBAL=1: clone GenericAgent from GitHub; uv also from GitHub releases; no PyPI mirror.
+# Portable components are installed under /.portable:
+# uv, Python installed by uv. On macOS/Linux git is expected from system package manager.
+
+INSTALL_DIR="${INSTALL_DIR:-$HOME/GenericAgent}"
+PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
+FORCE="${FORCE:-0}"
+GLOBAL="${GLOBAL:-0}"
+
+REPO_URL="https://github.com/lsdefine/GenericAgent.git"
+VPS_BASE="http://47.101.182.29:9000"
+GA_ZIP_URL="$VPS_BASE/files/GenericAgent.zip"
+MAINLAND_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple"
+DEPS=("requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "streamlit>=1.28")
+
+say(){ printf '\033[36m[ga-deploy]\033[0m %s\n' "$*"; }
+ok(){ printf '\033[32m[ok]\033[0m %s\n' "$*"; }
+die(){ printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
+
+usage(){ cat <<'EOF'
+Usage:
+ bash install_portable_env.sh
+ INSTALL_DIR="$HOME/GenericAgent" PYTHON_VERSION=3.12 FORCE=1 bash install_portable_env.sh
+ GLOBAL=1 bash install_portable_env.sh
+
+Environment variables:
+ INSTALL_DIR Install GenericAgent here. Default: ~/GenericAgent
+ PYTHON_VERSION Python version installed by uv. Default: 3.12
+ FORCE=1 Replace existing source files while preserving/reinstalling portable tools.
+ GLOBAL=1 Clone from GitHub directly and do not set China PyPI mirror.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage; exit 0; fi
+
+OS="$(uname -s)"
+ARCH="$(uname -m)"
+case "$OS:$ARCH" in
+ Darwin:x86_64) uv_file="uv-x86_64-apple-darwin.tar.gz" ;;
+ Darwin:arm64) uv_file="uv-aarch64-apple-darwin.tar.gz" ;;
+ Linux:x86_64) uv_file="uv-x86_64-unknown-linux-gnu.tar.gz" ;;
+ Linux:aarch64|Linux:arm64) uv_file="uv-aarch64-unknown-linux-gnu.tar.gz" ;;
+ *) die "Unsupported platform: $OS $ARCH" ;;
+esac
+
+GA_DIR="${INSTALL_DIR/#\~/$HOME}"
+case "$GA_DIR" in
+ /*) ;;
+ *) GA_DIR="$PWD/$GA_DIR" ;;
+esac
+mkdir -p "$GA_DIR"
+GA_DIR="$(cd "$GA_DIR" && pwd -P)"
+PORTABLE_ROOT="$GA_DIR/.portable"
+BIN="$PORTABLE_ROOT/bin"
+CACHE="$PORTABLE_ROOT/cache"
+UV_TGZ="$CACHE/$uv_file"
+GA_ZIP="$CACHE/GenericAgent.zip"
+UV_EXTRACT="$CACHE/uv-extract"
+GA_EXTRACT="$CACHE/ga-extract"
+UV_EXE="$BIN/uv"
+ENV_SH="$GA_DIR/env.sh"
+
+mkdir -p "$GA_DIR" "$PORTABLE_ROOT" "$BIN" "$CACHE"
+
+say "Install dir: $GA_DIR"
+if [[ "$GLOBAL" == "1" ]]; then say "Mode: GLOBAL=1 / GitHub clone"; else say "Mode: Mainland / VPS zip"; fi
+
+if [[ "$FORCE" == "1" ]]; then
+ # Preserve .portable if present; remove source files later before deploying.
+ :
+fi
+
+download(){
+ local url="$1" out="$2"
+ mkdir -p "$(dirname "$out")"
+ say "Downloading $url"
+ if command -v curl >/dev/null 2>&1; then
+ curl -fL --retry 3 -A "ga-deploy" -o "$out" "$url"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -O "$out" "$url"
+ else
+ die "curl or wget is required"
+ fi
+ [[ -s "$out" ]] || die "Download failed: $url"
+}
+
+extract_tgz_clean(){
+ local tgz="$1" dest="$2"
+ rm -rf "$dest"; mkdir -p "$dest"
+ tar -xzf "$tgz" -C "$dest"
+}
+
+extract_zip_clean(){
+ local zip="$1" dest="$2"
+ rm -rf "$dest"; mkdir -p "$dest"
+ if command -v unzip >/dev/null 2>&1; then
+ unzip -q "$zip" -d "$dest"
+ else
+ python3 - "$zip" "$dest" <<'PY'
+import sys, zipfile
+with zipfile.ZipFile(sys.argv[1]) as z:
+ z.extractall(sys.argv[2])
+PY
+ fi
+}
+
+copy_contents(){
+ local src="$1" dst="$2"
+ mkdir -p "$dst"
+ (cd "$src" && tar -cf - .) | (cd "$dst" && tar -xf -)
+}
+
+remove_source_files(){
+ shopt -s dotglob nullglob
+ for p in "$GA_DIR"/*; do
+ [[ "$(basename "$p")" == ".portable" ]] && continue
+ rm -rf "$p"
+ done
+ shopt -u dotglob nullglob
+}
+
+# uv: GitHub release in GLOBAL mode, user's VPS otherwise
+if [[ ! -x "$UV_EXE" || "$FORCE" == "1" ]]; then
+ if [[ "$GLOBAL" == "1" ]]; then
+ download "https://github.com/astral-sh/uv/releases/latest/download/$uv_file" "$UV_TGZ"
+ else
+ download "$VPS_BASE/uv/$uv_file" "$UV_TGZ"
+ fi
+ extract_tgz_clean "$UV_TGZ" "$UV_EXTRACT"
+ found_uv="$(find "$UV_EXTRACT" -type f -name uv | head -n 1 || true)"
+ [[ -n "$found_uv" ]] || die "uv not found in archive"
+ cp "$found_uv" "$UV_EXE"
+ chmod +x "$UV_EXE"
+fi
+ok "uv: $($UV_EXE --version)"
+
+export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python"
+export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache"
+if [[ "$GLOBAL" == "1" ]]; then
+ unset UV_DEFAULT_INDEX PIP_INDEX_URL
+else
+ export UV_DEFAULT_INDEX="$MAINLAND_INDEX"
+ export PIP_INDEX_URL="$MAINLAND_INDEX"
+fi
+export PATH="$BIN:$PATH"
+
+say "Installing Python $PYTHON_VERSION via uv"
+"$UV_EXE" python install "$PYTHON_VERSION"
+PYTHON_EXE="$($UV_EXE python find "$PYTHON_VERSION")"
+[[ -x "$PYTHON_EXE" ]] || die "uv installed Python but executable was not found"
+ok "Python: $($PYTHON_EXE --version)"
+PYTHON_DIR="$(dirname "$PYTHON_EXE")"
+export PATH="$BIN:$PYTHON_DIR:$PATH"
+
+# git: macOS/Linux use system git. In mainland mode it is not required for source fetch.
+GIT_EXE=""
+if command -v git >/dev/null 2>&1; then
+ GIT_EXE="$(command -v git)"
+ ok "git: $($GIT_EXE --version)"
+elif [[ "$GLOBAL" == "1" ]]; then
+ die "GLOBAL=1 requires git. Install git with your system package manager, then rerun."
+else
+ say "git not found; continuing because mainland mode uses VPS zip. Install git later if needed."
+fi
+
+# Fetch/update GenericAgent source.
+if [[ "$GLOBAL" == "1" ]]; then
+ say "Cloning GenericAgent from GitHub"
+ if [[ -n "$(find "$GA_DIR" -mindepth 1 -maxdepth 1 ! -name .portable -print -quit)" ]]; then
+ [[ "$FORCE" == "1" ]] || die "Install dir contains files. Re-run with FORCE=1 to replace source while preserving portable tools."
+ remove_source_files
+ fi
+ TMP_CLONE="$CACHE/ga-clone"
+ rm -rf "$TMP_CLONE"
+ "$GIT_EXE" clone --depth 1 "$REPO_URL" "$TMP_CLONE"
+ copy_contents "$TMP_CLONE" "$GA_DIR"
+ rm -rf "$TMP_CLONE"
+else
+ say "Downloading GenericAgent package from VPS"
+ download "$GA_ZIP_URL" "$GA_ZIP"
+ extract_zip_clean "$GA_ZIP" "$GA_EXTRACT"
+ SRC_DIR="$GA_EXTRACT/GenericAgent"
+ [[ -d "$SRC_DIR" ]] || SRC_DIR="$GA_EXTRACT"
+ remove_source_files
+ copy_contents "$SRC_DIR" "$GA_DIR"
+fi
+ok "GenericAgent source ready: $GA_DIR"
+
+# Install basic dependencies and project in editable mode into portable Python.
+say "Installing GenericAgent dependencies via uv pip"
+install_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
+if [[ "$GLOBAL" != "1" ]]; then install_args+=(--index-url "$MAINLAND_INDEX"); fi
+install_args+=("${DEPS[@]}")
+"$UV_EXE" "${install_args[@]}"
+
+if [[ -f "$GA_DIR/pyproject.toml" ]]; then
+ project_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
+ if [[ "$GLOBAL" != "1" ]]; then project_args+=(--index-url "$MAINLAND_INDEX"); fi
+ project_args+=(-e "$GA_DIR")
+ "$UV_EXE" "${project_args[@]}"
+fi
+
+# Try-install pywebview (optional UI). Failure is non-fatal.
+say "Attempting to install pywebview (optional, failure is OK)"
+webview_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
+if [[ "$GLOBAL" != "1" ]]; then webview_args+=(--index-url "$MAINLAND_INDEX"); fi
+webview_args+=("pywebview>=4.0")
+if "$UV_EXE" "${webview_args[@]}" 2>/dev/null; then
+ ok "pywebview installed successfully"
+else
+ printf '\033[33m[warn]\033[0m pywebview install failed. This is optional.\n'
+ printf ' On Linux, pywebview requires system GTK/WebKit libraries.\n'
+ printf ' Install them first, e.g.:\n'
+ printf ' Debian/Ubuntu: sudo apt install python3-gi gir1.2-webkit2-4.1 libgirepository1.0-dev\n'
+ printf ' Fedora: sudo dnf install python3-gobject webkit2gtk4.1\n'
+ printf ' macOS: usually works out of the box (uses PyObjC)\n'
+ printf ' Then retry: uv pip install pywebview\n'
+fi
+
+# Activation script: portable paths are intentionally before system PATH.
+if [[ "$GLOBAL" == "1" ]]; then
+ cat > "$ENV_SH" < "$ENV_SH" < mykey.py"
+ fi
+fi
+
+# Final banner
+echo ""
+if [[ "$GLOBAL" == "1" ]]; then
+ cat <用户要求code_run helloworld并评价工具配置\n\n```python\nprint(\"Hello, World!\")\n```"},{"type":"tool_use","id":"tooluse_GlmTNw190WGgK4sBBOiDHK","name":"code_run","input":{"script":"print(\"Hello, World!\")"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"tooluse_GlmTNw190WGgK4sBBOiDHK","content":"{\"status\": \"success\", \"stdout\": \"Hello, World!\\r\\n\", \"exit_code\": 0}"},{"type":"text","text":"\n### [WORKING MEMORY]\n\n\n[USER]: code_run个helloworld,根据结果简单评价你的工具配置\n\nCurrent turn: 1\n"}]},{"role":"assistant","content":[{"type":"text","text":"\n\ncode_run helloworld成功,评价工具配置\n\n工具配置没问题。`code_run` 返回了结构化的 JSON(`status`、`stdout`、`exit_code`),执行链路通畅,cwd 默认落在 temp 目录。\n\n刚才还验证了 `inline_eval` 能正确访问 agent 内部状态并导出 history,`file_patch` 也能精准改文件。整套工具链从代码执行、文件读写、到 agent 内省都是通的。"}]}]
\ No newline at end of file
diff --git a/assets/tools_schema.json b/assets/tools_schema.json
index 588e76c2..01078937 100644
--- a/assets/tools_schema.json
+++ b/assets/tools_schema.json
@@ -16,7 +16,6 @@
"path": {"type": "string", "description": "Relative or absolute"},
"start": {"type": "integer", "description": "Start line number (1-based)"},
"count": {"type": "integer", "description": "Number of lines to read", "default": 200},
- "keyword": {"type": "string", "description": "[Optional] If provided, returns first match (case-insensitive) with context"},
"show_linenos": {"type": "boolean", "description": "Show line numbers", "default": true}}}
}},
{"type": "function", "function": {
diff --git a/assets/tools_schema_cn.json b/assets/tools_schema_cn.json
index 3d942b6f..0b70328c 100644
--- a/assets/tools_schema_cn.json
+++ b/assets/tools_schema_cn.json
@@ -16,7 +16,6 @@
"path": {"type": "string", "description": "文件相对或绝对路径"},
"start": {"type": "integer", "description": "起始行号(从 1 开始)"},
"count": {"type": "integer", "description": "读取的行数", "default": 200},
- "keyword": {"type": "string", "description": "可选搜索关键字。如果提供,将返回第一个匹配项(忽略大小写)及其周边的内容"},
"show_linenos": {"type": "boolean", "description": "是否显示行号,建议开启以辅助 file_patch 定位", "default": true}}}
}},
{"type": "function", "function": {
diff --git a/frontends/at_complete.py b/frontends/at_complete.py
new file mode 100644
index 00000000..3499652e
--- /dev/null
+++ b/frontends/at_complete.py
@@ -0,0 +1,272 @@
+"""@ file completion — shared UI-less logic for tui_v2 / tui_v3.
+
+File index (os.scandir, cached per root) + fuzzy match + @token detection +
+insert text. No UI deps; each front-end renders candidates its own way and
+calls candidates_for(query, root). Index root is the front-end's choice
+(session workspace, else CWD). Submit-time: completion-only does NOT read
+content, but absolutize_mentions() rewrites @relative → @absolute so the
+agent's file_read (relative to its own cwd) can locate the file. The
+content-injecting auto-read variant lives in
+temp/plan_v2_at_mention/autoread_version.py.
+"""
+
+import os
+import re
+import threading
+
+# ---------------------------------------------------------------- index
+
+_IGNORE_DIRS = {
+ ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build",
+ ".next", ".idea", ".vscode", "target", ".cache", ".eggs",
+ "model_responses", # GA 会话日志(上千个 .txt),未绑时根=temp 会淹没 @ 候选
+}
+_IGNORE_EXT = {".pyc", ".pyo", ".so", ".o", ".class", ".lock", ".dll", ".exe"}
+_MAX_FILES = 50_000 # 超大目录宁缺毋卡:到上限就停
+
+
+def scan_files(root: str, max_files: int = _MAX_FILES) -> list[str]:
+ """Collect relative file paths under root, '/'-normalized.
+
+ os.scandir over os.walk: one syscall yields is_dir without an extra
+ stat per entry. Dotted dirs are skipped wholesale (.git, .venv...).
+ """
+ out: list[str] = []
+ stack = [root]
+ while stack and len(out) < max_files:
+ d = stack.pop()
+ try:
+ with os.scandir(d) as it:
+ for e in it:
+ try:
+ if e.is_dir(follow_symlinks=False):
+ if e.name not in _IGNORE_DIRS and not e.name.startswith("."):
+ stack.append(e.path)
+ elif e.is_file(follow_symlinks=False):
+ if os.path.splitext(e.name)[1].lower() not in _IGNORE_EXT:
+ rel = os.path.relpath(e.path, root).replace("\\", "/")
+ out.append(rel)
+ if len(out) >= max_files:
+ return out
+ except OSError:
+ continue
+ except OSError:
+ continue
+ return out
+
+
+class FileIndexCache:
+ """Per-root background file index. warm() is idempotent-cheap: a
+ rebuild is only started when none is in flight."""
+
+ def __init__(self, root: str):
+ self.root = root
+ self._files: list[str] = []
+ self._lock = threading.Lock()
+ self._building = False
+ self.ready = threading.Event()
+
+ def warm(self) -> None:
+ with self._lock:
+ if self._building:
+ return
+ self._building = True
+
+ def _build():
+ try:
+ files = scan_files(self.root)
+ with self._lock:
+ self._files = files
+ self.ready.set()
+ finally:
+ with self._lock:
+ self._building = False
+
+ threading.Thread(target=_build, name="ga-at-index", daemon=True).start()
+
+ def snapshot(self) -> list[str]:
+ with self._lock:
+ return self._files
+
+
+_indexes: dict[str, FileIndexCache] = {}
+_indexes_lk = threading.Lock()
+
+
+def get_index(root: str) -> FileIndexCache:
+ key = os.path.normcase(os.path.realpath(root or os.getcwd()))
+ with _indexes_lk:
+ idx = _indexes.get(key)
+ if idx is None:
+ idx = _indexes[key] = FileIndexCache(root)
+ return idx
+
+
+# ---------------------------------------------------------------- fuzzy
+
+def _subseq_score(q: str, path: str):
+ """Subsequence match score (higher = better), None when q doesn't
+ fully appear in order. Contiguous runs dominate (fzf-style): scattered
+ one-char hits across a long path must not beat a tight cluster.
+ Word-boundary hits and basename substring add on top; ties broken by
+ caller on shorter path."""
+ if not q:
+ return 0
+ score, qi, prev_hit = 0, 0, -2
+ for pi, ch in enumerate(path):
+ if qi < len(q) and ch == q[qi]:
+ score += 1
+ if pi == prev_hit + 1:
+ score += 2 # contiguous run: the dominant signal
+ if pi == 0 or path[pi - 1] in "/\\_-. ":
+ score += 3
+ prev_hit = pi
+ qi += 1
+ if qi < len(q):
+ return None
+ base = path.rsplit("/", 1)[-1]
+ if q in base:
+ score += 8
+ elif q in path:
+ score += 4
+ return score
+
+
+def fuzzy_rank(query: str, files: list[str], limit: int = 10) -> list[str]:
+ q = query.lower()
+ if not q:
+ # bare `@`: surface shallow paths first for discoverability
+ return sorted(files, key=lambda f: (f.count("/"), f))[:limit]
+ scored = []
+ for f in files:
+ s = _subseq_score(q, f.lower())
+ if s is not None:
+ scored.append((s, f))
+ scored.sort(key=lambda x: (-x[0], len(x[1]), x[1]))
+ return [f for _, f in scored[:limit]]
+
+
+# ------------------------------------------------------- edit-time token
+
+# `(?:^|\s)@` 前置:@ 前必须是行首或空白 → 邮箱/代码里的 a@b 不触发。
+# 字符集含路径分隔符与 ~ :,\w 在 unicode 下覆盖中文文件名。
+_AT_TOKEN_RE = re.compile(r"(?:^|\s)(@[\w\-./\\~:]*)$", re.UNICODE)
+
+
+def find_at_token(line_before_cursor: str):
+ """Return (query, at_pos) when the cursor sits in an @token being
+ typed on this line, else None. at_pos is the index of '@'."""
+ m = _AT_TOKEN_RE.search(line_before_cursor)
+ if not m:
+ return None
+ tok = m.group(1)
+ return tok[1:], m.start(1)
+
+
+def format_pick(path: str) -> str:
+ """`@path` insert text; dirs get no trailing space (keep completing next
+ level), files get one (close token). Spaces → quoted."""
+ trailing = '' if path.endswith(('/', '\\')) else ' '
+ return f'@"{path}"{trailing}' if ' ' in path else f'@{path}{trailing}'
+
+
+# --- path-like completion: an explicit-path @token (~/ / ./ ../ or C:\) goes
+# to live directory completion instead of index fuzzy — this is how absolute
+# paths outside the index root get completed level by level (claude-code parity).
+
+def is_path_like(token: str) -> bool:
+ if token in ('~', '.', '..'):
+ return True
+ if token.startswith(('~/', '~\\', './', '.\\', '../', '..\\', '/', '\\')):
+ return True
+ return len(token) >= 3 and token[0].isalpha() and token[1] == ':' and token[2] in '/\\'
+
+
+def path_completions(token: str, root: str, limit: int = 15) -> list[str]:
+ """readdir the real dir of a path-like token, prefix-match, dirs first.
+ `~` expanded, relative → root, absolute as-is; candidates keep the token's
+ spelling, dirs carry a trailing '/'."""
+ sep = max(token.rfind('/'), token.rfind('\\'))
+ if sep >= 0:
+ dir_part, prefix = token[:sep + 1], token[sep + 1:]
+ elif token in ('~', '.', '..'):
+ dir_part, prefix = token.rstrip('/\\') + '/', ''
+ else:
+ return []
+ exp = os.path.expanduser(dir_part)
+ real_dir = exp if os.path.isabs(exp) else os.path.join(root, exp)
+ try:
+ with os.scandir(real_dir) as it:
+ entries = list(it)
+ except OSError:
+ return []
+ pl = prefix.lower()
+ rows = []
+ for e in entries:
+ nm = e.name
+ if pl and not nm.lower().startswith(pl):
+ continue
+ if nm.startswith('.') and not prefix.startswith('.'): # 隐藏项需显式 . 才出
+ continue
+ try:
+ is_dir = e.is_dir()
+ except OSError:
+ is_dir = False
+ rows.append((not is_dir, nm.lower(), dir_part + nm + ('/' if is_dir else '')))
+ rows.sort(key=lambda r: (r[0], r[1])) # 目录优先 + 字母序
+ return [d for _, _, d in rows[:limit]]
+
+
+def candidates_for(query: str, root: str, limit: int = 15, absolute: bool = False) -> list[str]:
+ """@token candidates: path-like → directory completion, else index fuzzy.
+ Single dispatch point shared by both front-ends. `absolute=True` returns
+ fuzzy hits as absolute paths (front-end shows full path when no workspace
+ is bound, since the relative root isn't obvious to the user)."""
+ if is_path_like(query):
+ return path_completions(query, root, limit)
+ idx = get_index(root)
+ files = idx.snapshot()
+ if not files:
+ idx.warm() # 惰性兜底:该根还没建索引 → 后台建(本次可能空,下次有)
+ res = fuzzy_rank(query, files, limit) if files else []
+ if absolute:
+ res = [os.path.normpath(os.path.join(root, c)) for c in res]
+ return res
+
+
+# ------------------------------------------------------ submit-time absolutize
+# A fuzzy candidate inserts a path relative to the @ root (workspace/CWD), but
+# the agent's file_read resolves relative to its own ./temp cwd — so a bare
+# `@frontends/x.py` won't be found. At submit we rewrite each @mention naming a
+# real file to an absolute path; display keeps the short form. Still no content
+# read — this only completes the path so the agent can locate it.
+
+_AT_ABS_RE = re.compile(r'(^|\s)@("([^"]+)"|([\w\-./\\~:#]+))', re.UNICODE)
+_LINE_SUFFIX_RE = re.compile(r'(#L\d+(?:-\d+)?)$')
+
+
+def absolutize_mentions(text: str, root: str) -> str:
+ """@relative → @absolute (root-resolved, ~ expanded, quoted if it gains a
+ space), `#Lx-y` suffix kept. Only existing paths are rewritten; decorative
+ @words / typos pass through unchanged."""
+ def repl(m):
+ lead, quoted, bare = m.group(1), m.group(3), m.group(4)
+ raw = quoted if quoted is not None else bare
+ trail = ''
+ if quoted is None: # strip trailing prose punctuation
+ stripped = raw.rstrip(',。,;;))]》>')
+ trail, raw = raw[len(stripped):], stripped
+ sm = _LINE_SUFFIX_RE.search(raw)
+ suffix = sm.group(1) if sm else ''
+ path = raw[:len(raw) - len(suffix)] if suffix else raw
+ if not path:
+ return m.group(0)
+ exp = os.path.expanduser(path)
+ absp = os.path.normpath(exp if os.path.isabs(exp) else os.path.join(root, exp))
+ if not os.path.exists(absp): # decorative / typo → leave as-is
+ return m.group(0)
+ full = absp + suffix
+ token = f'@"{full}"' if ' ' in full else f'@{full}'
+ return lead + token + trail
+ return _AT_ABS_RE.sub(repl, text)
diff --git a/frontends/conductor.html b/frontends/conductor.html
index 89627439..5380d5c8 100644
--- a/frontends/conductor.html
+++ b/frontends/conductor.html
@@ -78,6 +78,8 @@
.hint { color: var(--muted); font-size: 12px; }
.cards {
+ flex: 1;
+ min-height: 0;
padding: 14px;
overflow: auto;
display: flex;
@@ -91,6 +93,9 @@
background: rgba(31,42,68,.16);
border-radius: 10px;
}
+ .approvals-body::-webkit-scrollbar { width: 10px; }
+ .approvals-body::-webkit-scrollbar-track { background: rgba(31,42,68,.08); border-radius: 10px; }
+ .approvals-body::-webkit-scrollbar-thumb { background: rgba(31,42,68,.34); border-radius: 10px; border: 2px solid rgba(255,255,255,.65); }
.empty { color: var(--muted); font-size: 13px; padding: 8px 4px; }
/* ---------- subagent 卡片(可折叠) ---------- */
@@ -154,8 +159,8 @@
.reply pre { white-space: pre-wrap; }
/* ---------- 任务奏折栏(无事则隐) ---------- */
- .approvals { display: none; }
- .approvals.show { display: block; }
+ .approvals { display: none; min-height: 0; overflow: hidden; }
+ .approvals.show { display: flex; flex: 0 0 420px; height: 420px; max-height: 42vh; flex-direction: column; }
.approvals .head {
background: linear-gradient(180deg, rgba(245,158,11,.14), rgba(245,158,11,.05));
border-bottom: 1px solid rgba(245,158,11,.18);
@@ -166,8 +171,9 @@
background: var(--amber); border-radius: 99px; padding: 2px 9px;
box-shadow: 0 4px 12px rgba(245,158,11,.35);
}
- .approvals-body { padding: 13px; display: flex; flex-direction: column; gap: 11px; max-height: 38vh; overflow: auto; }
+ .approvals-body { flex: 1; min-height: 0; padding: 13px; display: flex; flex-direction: column; gap: 11px; overflow-y: scroll; overflow-x: hidden; scrollbar-gutter: stable; }
.approval {
+ flex: 0 0 auto;
border-radius: 16px; background: rgba(255,255,255,.8);
border: 1px solid rgba(245,158,11,.25);
box-shadow: 0 8px 22px rgba(245,158,11,.10);
diff --git a/frontends/continue_cmd.py b/frontends/continue_cmd.py
index f4d53370..a8dc394d 100644
--- a/frontends/continue_cmd.py
+++ b/frontends/continue_cmd.py
@@ -1,7 +1,7 @@
"""`/continue` command: list & restore past model_responses sessions.
Pure functions + one `install(cls)` monkey-patch entry. No side effects at import.
"""
-import ast, glob, json, os, re, time
+import ast, atexit, glob, json, os, random, re, shutil, threading, time
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'temp', 'model_responses')
_LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt')
@@ -36,7 +36,7 @@ def _first_user(pairs):
if not isinstance(msg, dict): continue
for blk in msg.get('content', []) or []:
if isinstance(blk, dict) and blk.get('type') == 'text':
- t = (blk.get('text') or '').strip()
+ t = strip_project_mode(blk.get('text') or '').strip()
if t and '' not in t and not t.startswith('### [WORKING MEMORY]'):
return t
for p, _ in pairs[:1]:
@@ -117,6 +117,7 @@ def _parse_native_history(pairs):
history.append({'role': 'assistant', 'content': blocks})
return history
+
_PREVIEW_WIN = 32 * 1024
# Content-grep budget for `/continue` search box: read at most this many bytes
@@ -293,12 +294,19 @@ def _rounds_for_file(path, st):
return n, key
-def list_sessions(exclude_pid=None):
- """Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only."""
+def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None):
+ """Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only.
+
+ `exclude_log` (basename, e.g. 'model_responses_123456.txt') drops the caller's
+ OWN current session — preferred over `exclude_pid`, which assumed the log file
+ was named by PID (it isn't: agentmain mints a random 6-digit logid), so the
+ pid tag never matched and the current session leaked into its own list."""
files = glob.glob(_LOG_GLOB)
if exclude_pid is not None:
tag = f'model_responses_{exclude_pid}.txt'
files = [f for f in files if not f.endswith(tag)]
+ if exclude_log:
+ files = [f for f in files if os.path.basename(f) != exclude_log]
out = []
valid_keys = []
for f in files:
@@ -316,6 +324,43 @@ def list_sessions(exclude_pid=None):
valid_keys.append(key)
out.append((f, mtime, preview, rounds))
_save_rounds_cache(valid_keys)
+ # 【门控·worldline】树感知发现:日志空/缺失但有非空世界线树的会话(回退到起点后日志被
+ # 清空 → 上面 sz<32 跳过了)。仅当调用方显式传 rewind_root 时启用 → 其他 UI 不传,
+ # 行为逐字节不变。只读 tree.json 的 nodes/head(不依赖 worldline 模块)。
+ if rewind_root and os.path.isdir(rewind_root):
+ have = {os.path.basename(p) for p, *_ in out}
+ try:
+ keys = os.listdir(rewind_root)
+ except OSError:
+ keys = []
+ for key in keys:
+ if not key.startswith('model_responses_'):
+ continue
+ log_name = key + '.txt'
+ if log_name in have or log_name == exclude_log:
+ continue
+ log_path = os.path.join(_LOG_DIR, log_name)
+ try: # 仅收"日志确实空/缺失"的(非空日志已被主循环收录)
+ if os.path.getsize(log_path) >= 32:
+ continue
+ except OSError:
+ pass # 缺失也算
+ try:
+ with open(os.path.join(rewind_root, key, 'tree.json'), encoding='utf-8') as fh:
+ d = json.load(fh)
+ except Exception:
+ continue
+ nodes = d.get('nodes') or {}
+ real = [v for v in nodes.values() if v.get('kind') != 'origin']
+ if not real: # 只有 origin 的空树 → 无内容,跳过
+ continue
+ try:
+ mtime = os.path.getmtime(os.path.join(rewind_root, key, 'tree.json'))
+ except OSError:
+ mtime = 0
+ head = d.get('head')
+ title = (nodes.get(head, {}).get('title') if head else '') or '(已回退至会话起点)'
+ out.append((log_path, mtime, f'[世界线] {title}', len(real)))
out.sort(key=lambda x: x[1], reverse=True)
return out
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
@@ -439,6 +484,17 @@ def handle(agent, query, display_queue):
_INJECT_MARKERS = ('### [WORKING MEMORY]', '[SYSTEM TIPS]', '[SYSTEM]', '[System]',
'[DANGER]', '### [总结提炼经验]')
+# project_mode 插件把 `\n\n---\n[PROJECT MODE: ]\n…\n---` 追加在用户消息末尾
+# (见 plugins/project_mode._build_injection)。它会进日志,所以 /continue 重建 UI 时
+# 必须从显示文本里剔除,只留用户原话。不能加进 _INJECT_MARKERS——那会把整块(连用户
+# 原话)一起丢弃;这里只剜掉注入这一段后缀。
+_PM_BLOCK_RE = re.compile(r"\n*-{3,}\n\[PROJECT MODE:.*?\n-{3,}\s*$", re.DOTALL)
+
+
+def strip_project_mode(text: str) -> str:
+ """剔除用户文本尾部的 project-mode 注入块。"""
+ return _PM_BLOCK_RE.sub("", text or "")
+
def _user_text(prompt_body):
"""User-typed text from a prompt JSON; '' if this is an agent auto-continuation.
@@ -457,7 +513,7 @@ def _user_text(prompt_body):
return ''
for blk in blocks:
if isinstance(blk, dict) and blk.get('type') == 'text':
- t = (blk.get('text') or '').strip()
+ t = strip_project_mode(blk.get('text') or '').strip()
if t and not any(mk in t for mk in _INJECT_MARKERS): return t
return ''
@@ -475,11 +531,22 @@ def _assistant_text(response_body):
def _format_tool_use(block):
- """Match agent_loop.py:72 verbose tool-call header."""
+ """Match agent_loop.py:78 verbose tool-call header byte-for-byte.
+
+ MUST use agent_loop's `get_pretty_json`, not a plain `json.dumps`: the
+ former rewrites a `script` arg's `"; "` into `";\\n "`, so for tools
+ carrying `script` (code_run, web_execute_js) a plain dumps produces a
+ *different* fence body. The TUI's write/read/code cards content-address
+ their captures by `hash(get_pretty_json(args))`; a mismatched fence here
+ means the hash misses and the card silently falls back to the raw block."""
name = block.get('name', '?')
args = block.get('input', {})
- try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n')
- except Exception: pretty = str(args)
+ try:
+ from agent_loop import get_pretty_json
+ pretty = get_pretty_json(args)
+ except Exception:
+ try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n')
+ except Exception: pretty = str(args)
return f"🛠️ Tool: `{name}` 📥 args:\n````text\n{pretty}\n````\n"
@@ -533,6 +600,165 @@ def _format_response_segment(response_body, tool_results):
return '\n\n'.join(p for p in ['\n\n'.join(texts), '\n'.join(tool_parts)] if p)
+_PLAN_ENTRY_RE = re.compile(r'enter_plan_mode\(\s*[\'"]([^\'"]+plan\.md)[\'"]')
+
+
+def find_plan_entry(path):
+ """Last `enter_plan_mode("…plan.md")` call in a model_responses log.
+
+ Plan mode has exactly one entry point (plan_sop.md): a `code_run` tool call
+ whose inline_eval script invokes `handler.enter_plan_mode(...)`. That call
+ survives in the log as a structured `tool_use` block — unlike a plan path
+ merely *mentioned* in chat text, it cannot be produced by the user typing
+ a filename. Scanning these blocks is therefore the restore criterion for
+ the plan card; the last match wins so re-entered plans track the newest.
+
+ Returns the plan.md path string as written in the script, or None.
+ """
+ try:
+ with open(path, encoding='utf-8', errors='replace') as f:
+ content = f.read()
+ except Exception:
+ return None
+ last = None
+ for _prompt, response in _pairs(content):
+ try:
+ blocks = ast.literal_eval(response)
+ except Exception:
+ continue
+ if not isinstance(blocks, list):
+ continue
+ for b in blocks:
+ if not (isinstance(b, dict) and b.get('type') == 'tool_use'
+ and b.get('name') == 'code_run'):
+ continue
+ m = _PLAN_ENTRY_RE.search(str((b.get('input') or {}).get('script') or ''))
+ if m:
+ last = m.group(1)
+ return last
+
+
+def iter_write_captures(path):
+ """Replay a log's file_write/file_patch/file_read calls into capture dicts
+ the TUI can feed to its card renderers (`_WRITE_CAP`), keyed later by
+ hash(get_pretty_json).
+
+ Live mode fills `_WRITE_CAP` from tool_before/tool_after hooks (with a real
+ pre-write disk snapshot); on /continue that history is gone, but the
+ structured `tool_use.input` survives in the log — clean, complete args. We
+ also track each path's content *within this session* so a file
+ written/patched several times shows real old→new diffs (not N× full "new
+ file"). Files first touched by an untracked on-disk state still fall back
+ to a full-content block.
+
+ Returns write entries `{"name", "args", "existed", "old", "status", "msg"}`
+ and read entries `{"name", "args", "content"}` in call order. `status`/`msg`
+ come from the matching tool_result so the header can show ✗ on a failed
+ write; a read's `content` is the raw tool_result text (the read card strips
+ its LLM-facing chrome itself).
+ """
+ try:
+ with open(path, encoding='utf-8', errors='replace') as f:
+ content = f.read()
+ except Exception:
+ return []
+ pairs = _pairs(content)
+ # tool_use_id -> (status, msg) from any prompt's tool_result blocks (the
+ # result lands in the *next* round's Prompt as a tool_result whose content
+ # is the json-dumped outcome.data, e.g. {"status":"success","msg":...}).
+ # tr_raw keeps the undecoded text — a file_read result is plain text.
+ tr_status, tr_raw = {}, {}
+ for prompt, _ in pairs:
+ try:
+ msg_obj = json.loads(prompt)
+ except Exception:
+ continue
+ if not isinstance(msg_obj, dict):
+ continue
+ for blk in msg_obj.get('content', []) or []:
+ if not (isinstance(blk, dict) and blk.get('type') == 'tool_result'):
+ continue
+ tid = blk.get('tool_use_id')
+ c = blk.get('content')
+ if isinstance(c, list):
+ c = ''.join(b.get('text', '') for b in c
+ if isinstance(b, dict) and b.get('type') == 'text')
+ if tid and isinstance(c, str):
+ tr_raw[tid] = c
+ try:
+ d = json.loads(c) if isinstance(c, str) else None
+ except Exception:
+ d = None
+ if tid and isinstance(d, dict):
+ tr_status[tid] = (d.get('status'), str(d.get('msg') or ''))
+
+ out, state = [], {}
+ for _prompt, response in pairs:
+ try:
+ blocks = ast.literal_eval(response)
+ except Exception:
+ continue
+ if not isinstance(blocks, list):
+ continue
+ for b in blocks:
+ if not (isinstance(b, dict) and b.get('type') == 'tool_use'):
+ continue
+ name = b.get('name')
+ if name not in ('file_write', 'file_patch', 'file_read', 'code_run'):
+ continue
+ args = b.get('input') or {}
+ p = args.get('path')
+ if name == 'file_read':
+ out.append({'name': name, 'args': args,
+ 'content': tr_raw.get(b.get('id'))})
+ continue
+ if name == 'code_run':
+ # data = the tool_result text; a dict result is JSON, an
+ # inline_eval / code-missing result is plain text. Pass the
+ # parsed dict when possible so the card reads exit_code/stdout;
+ # else the raw string (the card handles both).
+ raw = tr_raw.get(b.get('id'))
+ d = raw
+ try:
+ parsed = json.loads(raw) if isinstance(raw, str) else None
+ if isinstance(parsed, dict):
+ d = parsed
+ except Exception:
+ pass
+ out.append({'name': name, 'args': args, 'data': d})
+ continue
+ st, mg = tr_status.get(b.get('id'), (None, ''))
+ if name == 'file_patch':
+ # If this file's content is tracked within the session, pass it as
+ # the pre-write full file so the renderer can do a whole-file diff
+ # (real line numbers + context); else fall back to the fragment.
+ pre = state.get(p, '')
+ out.append({'name': name, 'args': args,
+ 'existed': p in state, 'old': pre,
+ 'status': st, 'msg': mg})
+ if st == 'error':
+ continue # failed call left the disk untouched — don't book it
+ old = args.get('old_content') or ''
+ if p in state and old:
+ state[p] = state[p].replace(old, args.get('new_content') or '', 1)
+ else: # file_write
+ existed = p in state
+ old = state.get(p, '')
+ new = str(args.get('content') or '')
+ mode = str(args.get('mode') or 'overwrite')
+ out.append({'name': name, 'args': args, 'existed': existed, 'old': old,
+ 'status': st, 'msg': mg})
+ if st == 'error':
+ continue # failed call left the disk untouched — don't book it
+ if mode == 'append':
+ state[p] = old + new
+ elif mode == 'prepend':
+ state[p] = new + old
+ else:
+ state[p] = new
+ return out
+
+
def extract_ui_messages(path):
"""Parse a model_responses log into [{role, content}, ...] for UI replay.
@@ -593,6 +819,286 @@ def handle_frontend_command(agent, query, exclude_pid=None):
return msg
+# ===========================================================================
+# 原地复原(in-place continue)共享层 —— 仅供 TUI(tui_v2/tui_v3 及其 rewind 副本)
+# 调用;其它前端(IM/qt/streamlit…)不调用这些函数,行为完全不受影响。
+#
+# 模型:每个会话 = 一个 `model_responses_.txt`,身份就是文件本身。
+# · 原地续 X = 把 agent 的 log_path 指回 X,之后的轮次追加到 X 本身(同一会话延续)。
+# · 拷贝续 X = 铸一个新 logid、把 X 拷进去,在副本上续;X 原件不动(并发安全)。
+# · 切走/新对话 = 释放当前锁、旧日志原样留作"空闲会话"(不存快照、不清空),新对话铸新 logid。
+#
+# 独占:每个 TUI 会话出生即持有自己日志的一把锁(`.locks/.lock`);
+# 整进程共用一个心跳线程,每 ~5s touch 锁文件 mtime(无 fsync)。
+# 判活 = 锁 mtime 在 30s 内新鲜;超 30s 视为持锁者已死,可被接管。
+# 抢锁用原子 O_EXCL;锁基础设施任何故障都降级为"假定空闲、放行续接",绝不阻断 /continue。
+# ===========================================================================
+
+_LOCK_DIR = os.path.join(_LOG_DIR, '.locks')
+_HB_INTERVAL = 5.0 # 心跳间隔(秒)
+_STALE_AFTER = 30.0 # 超过这么久无心跳 → 持锁者视为已死,可接管
+_held_locks = set() # 本进程当前持有的 log_path 集合
+_hb_lock = threading.Lock()
+_hb_thread = None
+
+
+def _lock_path(log_path):
+ base = os.path.splitext(os.path.basename(log_path))[0]
+ return os.path.join(_LOCK_DIR, base + '.lock')
+
+
+def _read_lock(lock_file):
+ try:
+ with open(lock_file, encoding='utf-8') as fh:
+ return json.load(fh)
+ except Exception:
+ return None
+
+
+def _lock_fresh(lock_file):
+ """心跳新鲜度 = 锁文件 mtime 距今 < _STALE_AFTER。"""
+ try:
+ return (time.time() - os.path.getmtime(lock_file)) < _STALE_AFTER
+ except OSError:
+ return False
+
+
+def session_occupant(log_path):
+ """若 `log_path` 正被一个活着的(心跳新鲜)进程持有,返回其 owner 元数据 dict;
+ 否则返回 None(空闲,或锁已过期可接管)。供 TUI 判断"原地 / 弹窗拷贝"。"""
+ lf = _lock_path(log_path)
+ meta = _read_lock(lf)
+ if meta is not None and _lock_fresh(lf):
+ return meta
+ return None
+
+
+def acquire_lock(log_path, agent_id=None):
+ """尝试独占 `log_path`。成功(或锁设施故障降级)返回 True;
+ 仅当被另一活进程(心跳新鲜)持有时返回 False。"""
+ try:
+ os.makedirs(_LOCK_DIR, exist_ok=True)
+ lf = _lock_path(log_path)
+ meta = {'pid': os.getpid(), 'agent_id': agent_id,
+ 'log': os.path.basename(log_path),
+ 'started': time.time()}
+ blob = json.dumps(meta, ensure_ascii=False)
+ try:
+ fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
+ with os.fdopen(fd, 'w', encoding='utf-8') as fh:
+ fh.write(blob)
+ except FileExistsError:
+ cur = _read_lock(lf)
+ if cur and cur.get('pid') != os.getpid() and _lock_fresh(lf):
+ return False # 被另一活进程持有
+ # 过期锁 / 本进程自己的 → 接管(覆盖)。小竞态窗口可接受。
+ try: os.remove(lf)
+ except OSError: pass
+ try:
+ fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
+ with os.fdopen(fd, 'w', encoding='utf-8') as fh:
+ fh.write(blob)
+ except FileExistsError:
+ cur2 = _read_lock(lf)
+ if cur2 and cur2.get('pid') != os.getpid() and _lock_fresh(lf):
+ return False # 抢锁竞态输了
+ with open(lf, 'w', encoding='utf-8') as fh:
+ fh.write(blob)
+ with _hb_lock:
+ _held_locks.add(log_path)
+ _ensure_hb_thread()
+ return True
+ except Exception:
+ # 锁设施故障绝不阻断续接 —— 降级为"假定空闲、放行"。
+ return True
+
+
+def release_lock(log_path):
+ """释放本进程对 `log_path` 的锁(只删自己持有/无主的锁文件)。"""
+ with _hb_lock:
+ _held_locks.discard(log_path)
+ try:
+ lf = _lock_path(log_path)
+ cur = _read_lock(lf)
+ if cur is None or cur.get('pid') == os.getpid():
+ os.remove(lf)
+ except Exception:
+ pass
+
+
+def _hb_tick():
+ now = time.time()
+ with _hb_lock:
+ items = list(_held_locks)
+ for lp in items:
+ try:
+ os.utime(_lock_path(lp), (now, now)) # 仅更新 mtime,无 fsync
+ except OSError:
+ pass
+
+
+def _hb_loop():
+ while True:
+ time.sleep(_HB_INTERVAL)
+ try:
+ _hb_tick()
+ except Exception:
+ pass
+
+
+def _ensure_hb_thread():
+ global _hb_thread
+ with _hb_lock:
+ if _hb_thread is None:
+ _hb_thread = threading.Thread(target=_hb_loop,
+ name='ga-session-heartbeat', daemon=True)
+ _hb_thread.start()
+
+
+@atexit.register
+def _release_all_locks():
+ for lp in list(_held_locks):
+ release_lock(lp)
+
+
+def _new_log_path():
+ """铸一个新的 6 位 logid 日志路径(与 agentmain 同公式)。"""
+ logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}'
+ return os.path.join(_LOG_DIR, f'model_responses_{logid}.txt')
+
+
+def _retarget_log(agent, new_path):
+ """把 agent(及其所有 llmclient)的日志写入点切到 new_path —— 之后的轮次写这里。"""
+ try:
+ agent.log_path = new_path
+ except Exception:
+ pass
+ for client in _agent_clients(agent):
+ try: client.log_path = new_path
+ except Exception: pass
+
+
+def is_snapshot(path):
+ """遗留快照存档(model_responses_snapshot_*.txt)。这类只能拷贝续,不参与原地
+ (provisional,待 worktree 复审)。"""
+ return os.path.basename(path).startswith('model_responses_snapshot_')
+
+
+def _clear_conversation_state(agent):
+ """清空对话状态(对齐 reset_conversation,但不碰日志文件)。"""
+ if hasattr(agent, 'history'):
+ agent.history = []
+ for client in _agent_clients(agent):
+ backend = getattr(client, 'backend', None)
+ if backend is not None and hasattr(backend, 'history'):
+ backend.history = []
+ if hasattr(client, 'last_tools'):
+ client.last_tools = ''
+ if hasattr(agent, 'handler'):
+ agent.handler = None
+
+
+def acquire_birth_lock(agent, agent_id=None):
+ """会话出生时持有自己当前日志的锁(新 logid 必然抢到)。TUI 在建会话时调用,
+ 使本会话对"占用检测"可见 —— 别的会话才能据此判定它是否还活着。"""
+ lp = getattr(agent, 'log_path', '') or ''
+ if lp:
+ acquire_lock(lp, agent_id)
+
+
+def release_current(agent):
+ """切走:释放 agent 当前日志的锁,旧日志原样留作"空闲会话"(不存快照、不清空)。"""
+ lp = getattr(agent, 'log_path', '') or ''
+ if lp:
+ release_lock(lp)
+
+
+def begin_fresh_session(agent, agent_id=None):
+ """新对话 / clear:释放当前锁(旧日志留作空闲会话)→ 铸新 logid 重指 → 持新锁 →
+ 清空对话状态。**替代 TUI 里的 reset_conversation**(不再存快照/清空旧日志)。"""
+ try: agent.abort()
+ except Exception: pass
+ release_current(agent)
+ newp = _new_log_path()
+ _retarget_log(agent, newp)
+ acquire_lock(newp, agent_id)
+ _clear_conversation_state(agent)
+
+
+def _load_history_into(agent, path):
+ """把 `path` 解析进 backend.history(native;否则降级摘要)。镜像 restore() 的解析,
+ 但不 abort/不快照(日志重指由调用方先做好)。返回 (msg, is_full)。"""
+ try:
+ with open(path, encoding='utf-8', errors='replace') as fh:
+ content = fh.read()
+ except Exception as e:
+ return f'❌ 读取失败: {e}', False
+ pairs = _pairs(content)
+ if not pairs:
+ return f'❌ {os.path.basename(path)} 为空或格式不符', False
+ history = _parse_native_history(pairs)
+ name = os.path.basename(path)
+ if history is not None:
+ _replace_backend_history(agent, history)
+ return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})', True
+ from chatapp_common import _restore_native_history, _restore_text_pairs
+ summary = _restore_text_pairs(content) or _restore_native_history(content)
+ if not summary:
+ return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False
+ if hasattr(agent, 'history'):
+ agent.history.extend(summary)
+ n = sum(1 for l in summary if l.startswith('[USER]: '))
+ return f'⚠️ 非 native 格式,降级恢复 {n} 轮摘要({name})', False
+
+
+def _is_empty_log(path):
+ """日志空(<32 字节)或缺失。用于 allow_empty:回退到会话起点后日志被清空的会话。"""
+ try:
+ return os.path.getsize(path) < 32
+ except OSError:
+ return True
+
+
+def continue_inplace(agent, path, agent_id=None, allow_empty=False):
+ """原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。
+ 调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。
+ `allow_empty`(仅 worldline UI 传):日志为空时不报错,按【空会话】恢复(清空对话,
+ 由调用方按 `.ga_rewind` 树重连),用于"回退至会话起点"的会话。返回 (msg, ok)。"""
+ try: agent.abort()
+ except Exception: pass
+ if not acquire_lock(path, agent_id): # 先抢到目标锁;失败则保持现状,不丢自己的锁
+ return '❌ 会话已被占用,无法原地接管', False
+ cur = getattr(agent, 'log_path', '') or ''
+ if cur and os.path.basename(cur) != os.path.basename(path):
+ release_lock(cur) # 目标到手,旧会话释放为空闲(同一文件则不放)
+ _retarget_log(agent, path)
+ msg, ok = _load_history_into(agent, path)
+ if not ok and allow_empty and _is_empty_log(path):
+ _replace_backend_history(agent, []) # 空会话:清空对话(载入失败时它没被清)
+ return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
+ return msg, ok
+
+
+def continue_copy(agent, path, agent_id=None, allow_empty=False):
+ """拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。
+ 用于"被占用→用户选拷贝"以及快照源。返回 (msg, ok)。"""
+ try: agent.abort()
+ except Exception: pass
+ release_current(agent)
+ newp = _new_log_path()
+ try:
+ shutil.copyfile(path, newp)
+ except Exception:
+ pass
+ acquire_lock(newp, agent_id)
+ _retarget_log(agent, newp)
+ msg, ok = _load_history_into(agent, newp)
+ if not ok and allow_empty and _is_empty_log(newp):
+ _replace_backend_history(agent, [])
+ return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
+ return msg, ok
+
+
def install(cls):
"""Wrap cls._handle_slash_cmd so /continue is handled before original dispatch."""
orig = cls._handle_slash_cmd
diff --git a/frontends/desktop_pet.pyw b/frontends/desktop_pet.pyw
deleted file mode 100644
index c0bd598e..00000000
--- a/frontends/desktop_pet.pyw
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Desktop Pet with HTTP Toast — ~90 lines"""
-import tkinter as tk, threading, random, os, sys
-from http.server import HTTPServer, BaseHTTPRequestHandler
-from urllib.parse import urlparse, parse_qs
-
-PORT = 41983
-GIF = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'pet.gif')
-
-class Pet:
- def __init__(self):
- self.root = tk.Tk()
- self.root.overrideredirect(True)
- self.root.wm_attributes('-topmost', True)
- self.root.wm_attributes('-transparentcolor', '#01FF01')
- self.root.config(bg='#01FF01')
- self.root.after(50, lambda: self.root.geometry('+300+500'))
- # load GIF frames
- self.frames, i = [], 0
- while True:
- try: self.frames.append(tk.PhotoImage(file=GIF, format=f'gif -index {i}')); i += 1
- except: break
- if not self.frames: raise FileNotFoundError(f'No GIF: {GIF}')
- self.idx = 0
- self.label = tk.Label(self.root, image=self.frames[0], bg='#01FF01', bd=0)
- self.label.pack()
- # drag
- self.label.bind('', lambda e: setattr(self, '_d', (e.x, e.y)))
- self.label.bind('', self._drag)
- self.label.bind('', lambda e: (self.root.destroy(), os._exit(0)))
- # start loops
- self._animate()
- self._wander()
- self._start_server()
- self.root.mainloop()
-
- def _drag(self, e):
- x, y = self.root.winfo_x() + e.x - self._d[0], self.root.winfo_y() + e.y - self._d[1]
- self.root.geometry(f'+{x}+{y}')
-
- def _animate(self):
- self.idx = (self.idx + 1) % len(self.frames)
- self.label.config(image=self.frames[self.idx])
- self.root.after(150, self._animate)
-
- def _wander(self):
- if random.random() < 0.25:
- x = self.root.winfo_x() + random.randint(-15, 15)
- y = self.root.winfo_y() + random.randint(-5, 5)
- self.root.geometry(f'+{x}+{y}')
- self.root.after(4000, self._wander)
-
- def show_toast(self, msg):
- """Show a speech bubble near the pet that auto-dismisses."""
- tw = tk.Toplevel(self.root)
- tw.overrideredirect(True)
- tw.wm_attributes('-topmost', True)
- tw.config(bg='#FFFDE7')
- px, py = self.root.winfo_x(), self.root.winfo_y()
- tw.geometry(f'+{px + 30}+{py - 50}')
- # bubble content
- f = tk.Frame(tw, bg='#FFFDE7', highlightbackground='#888', highlightthickness=1, padx=8, pady=4)
- f.pack()
- tk.Label(f, text=msg, bg='#FFFDE7', fg='#333', font=('Segoe UI', 10), wraplength=220, justify='left').pack()
- # auto dismiss
- tw.after(3000, tw.destroy)
-
- def _start_server(self):
- pet = self
- class H(BaseHTTPRequestHandler):
- def do_GET(self):
- qs = parse_qs(urlparse(self.path).query)
- msg = qs.get('msg', [''])[0]
- if msg:
- pet.root.after(0, pet.show_toast, msg)
- self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
- else:
- self.send_response(400); self.end_headers(); self.wfile.write(b'?msg=xxx')
- def do_POST(self):
- body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()
- if body:
- pet.root.after(0, pet.show_toast, body)
- self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
- else:
- self.send_response(400); self.end_headers(); self.wfile.write(b'empty body')
- def log_message(self, *a): pass
- HTTPServer.allow_reuse_address = False
- srv = HTTPServer(('127.0.0.1', PORT), H)
- t = threading.Thread(target=srv.serve_forever, daemon=True)
- t.start()
- print(f'Toast server: http://127.0.0.1:{PORT}/?msg=hello')
-
-if __name__ == '__main__':
- Pet()
diff --git a/frontends/model_cmd.py b/frontends/model_cmd.py
new file mode 100644
index 00000000..5f9f098c
--- /dev/null
+++ b/frontends/model_cmd.py
@@ -0,0 +1,120 @@
+"""/model 命令的 agent 无关逻辑: 拉模型列表 + 运行时改 model。
+被 tuiapp_v2 import; 不 patch 任何类, 不依赖 llmcore。"""
+from typing import Optional, List, Tuple
+
+import requests
+
+
+def _is_mixin(b) -> bool:
+ return type(b).__name__ == 'MixinSession'
+
+
+def _resolve(agent, sub: Optional[int] = None):
+ """返回 (真正持有 model 的 session, mixin或None)。"""
+ b = agent.llmclient.backend
+ if _is_mixin(b):
+ return b._sessions[b._cur_idx if sub is None else sub], b
+ return b, None
+
+
+def list_subsessions(agent) -> Optional[List[Tuple[int, str, bool]]]:
+ """mixin 渠道 → [(idx, name, is_current)]; 普通渠道 → None。"""
+ b = agent.llmclient.backend
+ if not _is_mixin(b):
+ return None
+ return [(i, s.name, i == b._cur_idx) for i, s in enumerate(b._sessions)]
+
+
+def current_model(agent, sub: Optional[int] = None) -> str:
+ s, _ = _resolve(agent, sub)
+ return s.model
+
+
+def fetch_models(agent, sub: Optional[int] = None, timeout: int = 10) -> List[str]:
+ """GET models 列表, 自动尝试 /models 与 /v1/models、原生头与 Bearer。"""
+ s, _ = _resolve(agent, sub)
+ base = s.api_base.rstrip('/')
+ urls = [f'{base}/models'] + ([] if base.endswith('/v1') else [f'{base}/v1/models'])
+ heads = [{'Authorization': f'Bearer {s.api_key}'}]
+ if 'Claude' in type(s).__name__:
+ heads.insert(0, {'x-api-key': s.api_key, 'anthropic-version': '2023-06-01'})
+ err = None
+ for url in urls:
+ for h in heads:
+ try:
+ r = requests.get(url, headers=h, timeout=timeout,
+ proxies=getattr(s, 'proxies', None),
+ verify=getattr(s, 'verify', True))
+ r.raise_for_status()
+ data = r.json().get('data', []) # 非 JSON(如 HTML 首页)会抛错进入下一候选
+ ids = {m['id'] for m in data if isinstance(m, dict) and m.get('id')}
+ if ids:
+ return sorted(ids)
+ except Exception as e:
+ err = e
+ raise err or RuntimeError('no models endpoint')
+
+
+def set_model(agent, model: str, sub: Optional[int] = None) -> str:
+ """运行时改 model(内存态, mykey 重载/重启后还原)。返回结果描述。"""
+ s, mixin = _resolve(agent, sub)
+ old = s.model
+ s.model = model # mixin 的 model 是只读 property, 必须落子 session
+ warn = ""
+ try: # 对齐 agentmain.next_llm 的中文 schema 切换
+ from agentmain import load_tool_schema
+ load_tool_schema('_cn' if any(x in model.lower() for x in ('glm', 'minimax', 'kimi')) else '')
+ except Exception as ex: # schema 选错会实际影响 agent 行为, 半径不为零, 不静默
+ warn = f" (⚠ schema 切换失败: {type(ex).__name__})"
+ where = f"[{s.name}]" if mixin else s.name
+ return f"{where}: {old} → {model}{warn}"
+
+
+# GA 配置层允许的全部档位(llmcore BaseSession._enum)。各协议的真实支持面不同:
+# Claude(output_config.effort) 只认 low/medium/high + xhigh→max, none/minimal
+# 会被 _apply_claude_thinking 打 WARN 忽略; OpenAI 系(reasoning_effort /
+# reasoning.effort) 原样透传, 由渠道端校验。
+EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
+
+
+def _protocols(agent) -> set:
+ """当前渠道涉及的协议集合 {'claude', 'openai'}。mixin 看全部子渠道(广播
+ 会落到每一个)。NativeOAISession 名字不含 'Claude', 正确归入 openai。"""
+ b = agent.llmclient.backend
+ sessions = b._sessions if _is_mixin(b) else [b]
+ return {('claude' if 'Claude' in type(s).__name__ else 'openai')
+ for s in sessions}
+
+
+def effort_note(level, protocols) -> str:
+ """单点描述某档位在给定协议上的特殊行为(空串=无特殊)。set_effort 的结果
+ 描述与 /effort picker 的行内备注共用它, 领域知识只在此编码一次。"""
+ if level and 'claude' in protocols:
+ if level in ('none', 'minimal'):
+ return 'Claude 渠道忽略'
+ if level == 'xhigh':
+ return 'Claude 对应 max'
+ return ''
+
+
+def current_effort(agent) -> str:
+ return getattr(agent.llmclient.backend, 'reasoning_effort', None) or ''
+
+
+def set_effort(agent, value) -> str:
+ """运行时改 reasoning_effort(内存态)。空值/off 清除(不再发送 effort 字段)。
+ 直接设在 backend 上: MixinSession 把它列为 _BROADCAST_ATTRS, 会同步到所有
+ 子渠道(故障切换后档位不丢); 普通渠道就是 session 本身。请求时各协议现读
+ 该属性, 立即生效。"""
+ e = (value or '').strip().lower()
+ if e in ('', 'off', 'clear', 'unset'):
+ e = None
+ elif e not in EFFORT_LEVELS:
+ return (f"无效 effort: {value!r}"
+ f" (可选 {'/'.join(EFFORT_LEVELS)}, 留空或 off 清除)")
+ b = agent.llmclient.backend
+ old = getattr(b, 'reasoning_effort', None)
+ b.reasoning_effort = e
+ note = effort_note(e, _protocols(agent))
+ tail = f" ({note})" if note else ""
+ return f"reasoning_effort: {old or '(未设置)'} → {e or '(清除)'}{tail}"
diff --git a/frontends/pet.gif b/frontends/pet.gif
deleted file mode 100644
index a297e0fb..00000000
Binary files a/frontends/pet.gif and /dev/null differ
diff --git a/frontends/plan_state.py b/frontends/plan_state.py
index 8138ffb3..bab87ee9 100644
--- a/frontends/plan_state.py
+++ b/frontends/plan_state.py
@@ -159,16 +159,26 @@ def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str:
return ""
-def is_active(agent, messages=None, start_idx: int = 0) -> bool:
- """Plan mode is on. Primary: `working['in_plan_mode']`. Fallback:
- a `plan_*/plan.md` referenced in this session's messages (no global scan)."""
+def is_active(agent, messages=None, start_idx: int = 0,
+ restored_path: str = "") -> bool:
+ """Plan mode is on. Primary: `working['in_plan_mode']`. Then
+ `restored_path` — a path recovered from the transcript's structured
+ `enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry);
+ unlike the message scan it cannot be spoofed by a path typed in chat.
+ Legacy fallback: a `plan_*/plan.md` referenced in this session's messages
+ (no global scan) — only consulted when `messages` is passed."""
if _stashed_plan_path(agent): return True
+ if restored_path and _resolve_stashed(restored_path): return True
return find_path_in_messages(messages, start_idx) is not None
-def resolve_path(agent, messages=None, start_idx: int = 0) -> Optional[str]:
+def resolve_path(agent, messages=None, start_idx: int = 0,
+ restored_path: str = "") -> Optional[str]:
p = _resolve_stashed(_stashed_plan_path(agent))
if p: return p
+ if restored_path:
+ p = _resolve_stashed(restored_path)
+ if p: return p
return find_path_in_messages(messages, start_idx)
diff --git a/frontends/slash_cmds.py b/frontends/slash_cmds.py
index dfd778ef..6c3c0a66 100644
--- a/frontends/slash_cmds.py
+++ b/frontends/slash_cmds.py
@@ -33,6 +33,7 @@ from typing import Optional
_USER_SHELL: tuple[list[str], str] | None = None
+COMMIT_SIGNATURE_PROMPT = 'When you create a git commit, append "Co-Authored-By: GenericAgent " as the final line of the commit message.'
def detect_user_shell() -> tuple[list[str], str]:
"""Return `([executable, ...flags_for_-c], display_name)` for the user's
@@ -152,16 +153,32 @@ def build_update_prompt(args_text: str = "") -> str:
return (
"Update this GenericAgent checkout from the official upstream "
"https://github.com/Lsdefine/GenericAgent .\n"
- "1. Fetch upstream; identify the current branch and whether there are any local "
- "commits ahead of upstream.\n"
+ "1. Run `git fetch upstream`; identify the current branch and whether there are "
+ "local commits ahead of upstream.\n"
"2. Show a concise preview: recent upstream commits not yet local "
"(short hash + subject + date) plus a changed-files summary.\n"
- "3. Apply the update (uncommitted working-tree changes must always be preserved):\n"
- " - If local commits exist: merge upstream into the current branch, auto-resolving "
- "conflicts in favor of upstream, and keep the local commits. A merge commit is acceptable here.\n"
- " - Otherwise: run `git reset --mixed upstream/main` so local `main` aligns exactly "
- "with upstream/main, automatically resolving conflicts in favor of upstream. Keep the local changes but do not create any new commits.\n"
- "4. Finish with a short summary: branch HEAD, changed files, conflicts/resolutions.\n"
+ "3. Align the COMMIT HISTORY to upstream (upstream wins):\n"
+ " - If local commits exist: `git merge upstream/main` into the current branch, "
+ "resolving any conflicts in favor of upstream; keep the local commits (a merge commit is fine).\n"
+ " - Otherwise: `git reset --mixed upstream/main` so the history of local `main` "
+ "matches upstream/main exactly. Do NOT create any new commit.\n"
+ "4. IMPORTANT: also reconcile the WORKING TREE; step 3 alone is not enough. "
+ "`git reset --mixed` and `git merge` only move history and the index. They do not "
+ "rewrite files that already hold uncommitted local edits, so those stale files keep "
+ "shadowing upstream's latest. That is why 'HEAD is aligned but the files are still "
+ "old'. Reconcile per file:\n"
+ " a. `git diff --name-only upstream/main` lists every tracked file whose working-tree "
+ "content differs from upstream.\n"
+ " b. Decide upstream-first per file. A stale leftover, or a local edit that reverts "
+ "an upstream feature to an older form, takes upstream: back it up, then "
+ "`git checkout upstream/main -- `. A genuine local enhancement upstream lacks "
+ "(local config, key-template placeholders, fork-only features) stays, re-applied on "
+ "top of upstream's latest instead of overwriting it.\n"
+ " c. Upstream wins; keep local only where it adds value upstream lacks. Never "
+ "`git add -A`, never blanket whole-branch checkout, never blindly preserve everything.\n"
+ "5. Do not create any git commit. Back up any file before overwriting it.\n"
+ "6. Finish with a short summary: branch HEAD, distance vs upstream, per-file outcome "
+ "(took upstream / kept+adapted local), and where backups went.\n"
"\n"
"#### Say 'Congratulations! 🎉' to the user\n"
"After a successful update, say: \"Congratulations! 🎉 You have successfully "
@@ -175,12 +192,21 @@ def build_update_prompt(args_text: str = "") -> str:
return (
"请你更新当前 GenericAgent 仓库,官方上游为 "
"https://github.com/Lsdefine/GenericAgent 。\n"
- "1. 先 fetch upstream;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n"
+ "1. 先 `git fetch upstream`;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n"
"2. 给出简洁预览:本地尚未包含的 upstream 最近提交(短 hash + 标题 + 日期)及变更文件摘要。\n"
- "3. 执行更新(工作区未提交的改动始终要保留):\n"
- " - 若存在本地 commit:将 upstream 合并进当前分支,自动解决冲突且优先上游。\n"
- " - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 与 upstream/main 完全对齐,自动解决冲突且优先上游。本地修改保留但不要进行commit。\n"
- "4. 最后给出简短小结:分支 HEAD、变更文件、冲突/解决方式。\n"
+ "3. 先对齐【提交历史】到上游(优先上游):\n"
+ " - 若存在本地 commit:`git merge upstream/main` 合并进当前分支,冲突一律优先上游,保留本地 commit(可产生 merge commit)。\n"
+ " - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 的提交历史与 upstream/main 完全对齐。不要创建任何新 commit。\n"
+ "4. 重要:还须核对【工作区文件】,止步于第 3 步并不够。`git reset --mixed` 与 `git merge` "
+ "只移动提交历史与索引,不会重写那些已带未提交本地改动的文件,于是这些过时文件继续覆盖在上游"
+ "最新版之上。这正是“HEAD 已对齐,但文件其实还是旧的”的根因。逐文件核对:\n"
+ " a. `git diff --name-only upstream/main` 列出工作区内容与上游不一致的全部 tracked 文件。\n"
+ " b. 每个文件按优先上游判断。过时残留、或把上游特性改回旧版的本地改动,取上游:先备份,再 "
+ "`git checkout upstream/main -- `。上游没有且仍有效的本地增强(本机配置、密钥模板占位、"
+ "fork 专属功能)保留,并在上游最新版上重新适配,而非整文件覆盖上游。\n"
+ " c. 优先上游;本地仅保留上游缺失且有价值的部分。禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n"
+ "5. 不要进行任何 git commit。覆盖任何文件前先备份。\n"
+ "6. 最后给出简短小结:分支 HEAD、与上游差距、逐文件处理结果(取上游 / 保留并适配本地)、备份位置。\n"
"\n"
"#### 向用户说 'Congratulations! 🎉'\n"
"更新成功后,请对用户说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"\n"
diff --git a/frontends/stapp.py b/frontends/stapp.py
index 5c49c7f3..ce1bcb39 100644
--- a/frontends/stapp.py
+++ b/frontends/stapp.py
@@ -13,26 +13,49 @@ sys.path.append(os.path.abspath(script_dir))
import streamlit as st
import time, json, re, threading, queue
+from datetime import timedelta
from agentmain import GeneraticAgent
import chatapp_common # activate /continue command (monkey patches GeneraticAgent)
from continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages
from btw_cmd import handle_frontend_command as btw_handle_frontend
from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard
-st.set_page_config(page_title="Cowork", layout="wide")
+st.set_page_config(page_title="Cowork", layout="wide", initial_sidebar_state="collapsed")
+
+st.markdown("""
+
+""", unsafe_allow_html=True)
LANG = os.environ.get('GA_LANG', 'zh')
if LANG not in ('zh', 'en'): LANG = 'zh'
I18N = {
'zh': {
'force_stop': '强行停止任务',
- 'reinject_tools': '重新注入工具',
'desktop_pet': '🐱 桌面宠物',
+ 'suggest_btn': '🎯 给我找点事做',
+ 'suggest_prompt': '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣',
+ 'auto_start': '开始空闲自主行动',
+ 'auto_pause': '⏸️ 禁止自主行动',
+ 'auto_enable': '▶️ 允许自主行动',
+ 'auto_on_cap': '🟢 自主行动运行中,会在你离开它30分钟后自动进行',
+ 'auto_off_cap': '🔴 自主行动已停止',
+ 'auto_prompt': '[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。',
},
'en': {
'force_stop': 'Force Stop',
- 'reinject_tools': 'Reinject Tools',
'desktop_pet': '🐱 Desktop Pet',
+ 'suggest_btn': '🎯 Suggest tasks',
+ 'suggest_prompt': 'Following the planning section of autonomous sop, analyze my situation thoroughly and generate a batch of TODOs that will interest me.',
+ 'auto_start': 'Start idle auto-action',
+ 'auto_pause': '⏸️ Pause auto-action',
+ 'auto_enable': '▶️ Enable auto-action',
+ 'auto_on_cap': '🟢 Auto-action enabled, triggers after 30min idle',
+ 'auto_off_cap': '🔴 Auto-action disabled',
+ 'auto_prompt': '[AUTO]🤖 User has been idle for over 30 minutes. As an autonomous agent, read the automation SOP and execute automatic tasks.',
},
}
def T(key): return I18N.get(LANG, I18N['zh']).get(key, key)
@@ -64,18 +87,12 @@ def render_sidebar():
agent.next_llm(selected_idx); st.rerun(scope="fragment")
if st.button(T('force_stop')):
agent.abort(); st.toast("Stop signal sended"); st.rerun()
- if st.button(T('reinject_tools')):
- agent.llmclient.last_tools = ''
- try:
- hist_path = os.path.join(script_dir, '..', 'assets', 'tool_usable_history.json')
- with open(hist_path, 'r', encoding='utf-8') as f: tool_hist = json.load(f)
- agent.llmclient.backend.history.extend(tool_hist)
- st.toast(f"Tools injected")
- except Exception as e: st.toast(f"Injected tools failed: {e}")
if st.button(T('desktop_pet')):
kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {}
pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw')
- if not os.path.exists(pet_script): pet_script = os.path.join(script_dir, 'desktop_pet.pyw')
+ if not os.path.exists(pet_script):
+ st.error("desktop_pet_v2.pyw not found")
+ return
subprocess.Popen([sys.executable, pet_script], **kwargs)
def _pet_req(q):
def _do():
@@ -93,25 +110,44 @@ def render_sidebar():
agent._turn_end_hooks['pet'] = _pet_hook
st.toast("Desktop pet started")
- if LANG == 'zh':
- if st.button('🎯 给我找点事做'):
- st.session_state['_inject_prompt'] = '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣'
- st.rerun(scope="app")
- st.divider()
- if st.button("开始空闲自主行动"):
- st.session_state.last_reply_time = int(time.time()) - 1800
+ if st.button(T('suggest_btn')):
+ st.session_state['_inject_prompt'] = T('suggest_prompt')
+ st.rerun(scope="app")
+ st.divider()
+ st.markdown("""""", unsafe_allow_html=True)
+ def _sync_loop_prompt():
+ st.session_state.loop_prompt = st.session_state.loop_prompt_input
+ loop_prompt = st.text_area("Loop prompt", value=st.session_state.get('loop_prompt', "继续" if LANG=='zh' else 'next'), key="loop_prompt_input", height=1, on_change=_sync_loop_prompt)
+ if st.session_state.get('loop_enabled'):
+ if st.button("⏹️ Stop Loop"):
+ st.session_state.loop_enabled = False
+ st.toast("⏹️ Loop stopped"); st.rerun(scope="app")
+ st.caption("🔁 Looping")
+ else:
+ if st.button("🔁 Loop!"):
+ st.session_state.loop_enabled = True
+ st.session_state.loop_prompt = loop_prompt
+ st.session_state['_inject_prompt'] = loop_prompt
+ st.toast("🔁 Looping"); st.rerun(scope="app")
+ st.divider()
+ if st.button(T('auto_start')):
+ st.session_state.last_reply_time = int(time.time()) - 1800
+ st.session_state.autonomous_enabled = True
+ st.rerun(scope="app")
+ if st.session_state.autonomous_enabled:
+ if st.button(T('auto_pause')):
+ st.session_state.autonomous_enabled = False
+ st.toast(T('auto_pause')); st.rerun(scope="app")
+ st.caption(T('auto_on_cap'))
+ else:
+ if st.button(T('auto_enable'), type="primary"):
st.session_state.autonomous_enabled = True
- st.toast("已将上次回复时间设为1800秒前,自主行动已激活"); st.rerun(scope="app")
- if st.session_state.autonomous_enabled:
- if st.button("⏸️ 禁止自主行动"):
- st.session_state.autonomous_enabled = False
- st.toast("⏸️ 已禁止自主行动"); st.rerun(scope="app")
- st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行")
- else:
- if st.button("▶️ 允许自主行动", type="primary"):
- st.session_state.autonomous_enabled = True
- st.toast("✅ 已允许自主行动"); st.rerun(scope="app")
- st.caption("🔴 自主行动已停止")
+ st.toast("✅"); st.rerun(scope="app")
+ st.caption(T('auto_off_cap'))
with st.sidebar: render_sidebar()
def fold_turns(text):
@@ -216,6 +252,10 @@ def render_main_stream(prompt=None):
if response:
st.session_state.messages.append({"role": "assistant", "content": response})
st.session_state.last_reply_time = int(time.time())
+ # ── 循环回调:回答完成后自动注入下一条 ──
+ if st.session_state.get('loop_enabled'):
+ st.session_state['_inject_prompt'] = st.session_state.get('loop_prompt', '继续')
+ st.rerun()
if "messages" not in st.session_state: st.session_state.messages = []
for msg in st.session_state.messages:
@@ -233,20 +273,6 @@ try:
_embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()})
except (ImportError, AttributeError):
from streamlit.components.v1 import html as _embed_html # ≤1.55
-_js_scroll_fix = (
- "!function(){var p=window.parent;if(p.__sfx2)return;p.__sfx2=1;var d=p.document;"
- "var pending=0;"
- "function f(){pending=0;var m=d.querySelector('section.main');if(!m)return;"
- "var s=m.scrollTop,h=m.scrollHeight;"
- "m.style.minHeight=h+1+'px';void m.offsetHeight;"
- "m.style.minHeight='';void m.offsetHeight;"
- "m.scrollTop=s}"
- "function schedule(){if(!pending){pending=1;requestAnimationFrame(f)}}"
- "d.addEventListener('transitionend',function(e){"
- "e.target.closest&&e.target.closest('details')&&setTimeout(schedule,60)},!0);"
- "new MutationObserver(function(){setTimeout(schedule,80)})"
- ".observe(d.body,{subtree:1,attributes:1,attributeFilter:['open']})}()"
-)
# IME composition fix (macOS only) - prevents Enter from submitting during CJK input
_js_ime_fix = ("" if os.name == 'nt' else
"!function(){if(window.parent.__imeFix)return;window.parent.__imeFix=1;"
@@ -338,5 +364,15 @@ elif st.session_state.get('display_queue') is not None:
# No new prompt but a task is mid-flight (typically a /btw rerun) — resume drain.
render_main_stream()
-if st.session_state.autonomous_enabled:
- st.markdown(f"""{st.session_state.get('last_reply_time', int(time.time()))}
""", unsafe_allow_html=True)
+# ── 空闲自主行动:fragment 定时检测,替代 launch.pyw 的 idle_monitor ──
+@st.fragment(run_every=timedelta(minutes=5))
+def _idle_checker():
+ if not st.session_state.get('autonomous_enabled'): return
+ if st.session_state.get('display_queue') is not None: return # 正在运行中
+ if st.session_state.get('loop_enabled'): return # 循环模式自己管
+ last = st.session_state.get('last_reply_time', int(time.time()))
+ if time.time() - last > 1800:
+ st.session_state['_inject_prompt'] = T('auto_prompt')
+ st.session_state['last_reply_time'] = int(time.time()) # 防重入
+ st.rerun(scope="app")
+_idle_checker()
diff --git a/frontends/tui_v3.py b/frontends/tui_v3.py
index 57efeb8f..0684d538 100644
--- a/frontends/tui_v3.py
+++ b/frontends/tui_v3.py
@@ -32,6 +32,7 @@ from rich.markdown import Markdown
from rich.text import Text
from rich.theme import Theme
from typing import Callable
+from frontends import at_complete, workspace_cmd # @ 补全 + /workspace(与 v2 共用)
# ════════════════════════════════════════════════════════════════════════════
# i18n — minimal dict-based zh/en translation layer (inlined; was tui_v3_i18n.py)
@@ -295,6 +296,10 @@ _I18N: dict[str, dict[str, str]] = {
# menu / picker / palette
'menu.default_title': 'Pick',
'menu.hint': '↑↓ pick · Enter confirm · Esc cancel',
+ 'menu.hint.filter': 'type to filter · ↑↓ pick · Enter confirm · Esc cancel',
+ 'menu.search': 'filter workspaces, or type an abs path + Enter to create one',
+ 'menu.no_match': 'no match',
+ 'menu.free.hint': 'Enter to create/enter this path',
'ask.default_q': 'answer:',
'ask.title': '◉ answer',
'ask.pending': ' +{n} pending',
@@ -304,6 +309,19 @@ _I18N: dict[str, dict[str, str]] = {
# continue picker
'continue.title': 'Restore historical session',
+ 'continue.occupied.title': 'Session in use (pid {p}) — copy it and continue?',
+ 'continue.occupied.copy': 'Copy & continue',
+ 'continue.occupied.cancel': 'Cancel',
+ # /workspace (parity with v2; backed by workspace_cmd.py)
+ 'cmd.workspace.arg': '[path|off]',
+ 'cmd.workspace.desc': 'set working dir (abs path) and enter project mode',
+ 'ws.entered': '✅ entered workspace「{n}」',
+ 'ws.fail': '❌ workspace failed: {e}',
+ 'ws.exited': 'left workspace (project mode off; junction & files kept)',
+ 'ws.inactive': 'not in a workspace right now',
+ 'ws.none': 'no registered workspace yet; /workspace to create/enter',
+ 'ws.pick.title': 'Pick a workspace (↑↓ choose · Enter confirm · Esc cancel)',
+ 'ws.restored': 'restored working dir: {t}',
'continue.row.fmt': '{rel:>4} {rounds:>3}r {preview}',
'continue.unit.round': 'r',
@@ -545,6 +563,10 @@ _I18N: dict[str, dict[str, str]] = {
# menu / picker / palette
'menu.default_title': '选择',
'menu.hint': '↑↓ 选 · Enter 确认 · Esc 取消',
+ 'menu.hint.filter': '输入过滤 · ↑↓ 选 · Enter 确认 · Esc 取消',
+ 'menu.search': '输入筛选工作区或输入绝对路径回车新建工作区',
+ 'menu.no_match': '无匹配',
+ 'menu.free.hint': '回车以该路径新建/进入',
'ask.default_q': '请回答:',
'ask.title': '◉ 请回答',
'ask.pending': ' +{n} 待答',
@@ -554,6 +576,19 @@ _I18N: dict[str, dict[str, str]] = {
# continue picker
'continue.title': '恢复历史会话',
+ 'continue.occupied.title': '该会话正被占用(pid {p})—— 从原会话拷贝一份继续?',
+ 'continue.occupied.copy': '拷贝一份继续',
+ 'continue.occupied.cancel': '取消',
+ # /workspace(与 v2 一致;后端 workspace_cmd.py)
+ 'cmd.workspace.arg': '[path|off]',
+ 'cmd.workspace.desc': '设定工作目录(绝对路径)并进入项目模式',
+ 'ws.entered': '✅ 已进入 workspace「{n}」',
+ 'ws.fail': '❌ workspace 设定失败: {e}',
+ 'ws.exited': '已退出 workspace(项目模式关闭;junction 与文件保留)',
+ 'ws.inactive': '当前未处于 workspace 模式',
+ 'ws.none': '暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入',
+ 'ws.pick.title': '选择 workspace(↑↓ 选择,Enter 确认,Esc 取消)',
+ 'ws.restored': '已恢复工作目录: {t}',
'continue.row.fmt': '{rel:>4} {rounds:>3}轮 {preview}',
'continue.unit.round': '轮',
@@ -842,10 +877,12 @@ def _ptk_keypress_to_bytes(kp) -> bytes:
return b'\x02'
if has('right'):
return b'\x06'
+ # Home/End get dedicated bytes — 0x01 is already Ctrl+A (select-all) and
+ # 0x05 has no editor handler, so reusing them would break/no-op the keys.
if has('home'):
- return b'\x01'
+ return b'\x07'
if has('end'):
- return b'\x05'
+ return b'\x14'
if has('delete'):
return b'\x7f'
if has('backspace') or data == '\x7f' or data == '\x08':
@@ -1336,6 +1373,12 @@ class AgentBridge:
self.agent.llmclient = self.agent.llmclients[llm_no % len(self.agent.llmclients)]
self.agent.inc_out = True
self.agent.verbose = True
+ from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT
+ self.agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT)
+ # 默认普通模式:设 None 让 project_mode 插件不读 pid 文件锚(与 v2 一致)。
+ # /workspace 绑定时改为项目名 + 真实路径。
+ self.agent._ga_project_mode_name = None
+ self.agent._ga_project_mode_workspace_path = ''
# task_dir path enables ga's `_keyinfo` / `_intervene` consume paths.
# PID-scoped so concurrent v3 processes don't share signal files.
# Only the *path* is set here; the dir is created lazily by the writer
@@ -1363,6 +1406,12 @@ class AgentBridge:
if not getattr(self.agent, 'llmclient', None):
self._healthy = False
self._init_error = _t('err.no_llm')
+ # 原地复原:本会话出生即持有自己日志的锁,使占用检测对它可见(别的会话据此判活)。
+ try:
+ from frontends import continue_cmd as _cc
+ _cc.acquire_birth_lock(self.agent)
+ except Exception:
+ pass
self._runner = threading.Thread(target=self._run_safe, daemon=True, name=f'ga-tui-agent')
self._runner.start()
@@ -1451,6 +1500,15 @@ class AgentBridge:
except Exception:
return '?'
+ @property
+ def llm_model(self) -> str:
+ """The concrete model id in use (e.g. claude-opus-4-8), not the
+ channel group `llm_name` returns. Empty string when unavailable."""
+ try:
+ return self.agent.get_llm_name(model=True) or ''
+ except Exception:
+ return ''
+
def list_llms(self) -> list[tuple[int, str, bool]]:
return self.agent.list_llms()
@@ -1587,7 +1645,9 @@ def _strip_bg(s: str) -> str:
out.append(t); i += 1
return '\x1b[' + ';'.join(out) + 'm' if out else '\x1b[0m'
return _SGR_RE.sub(repl, s)
-_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b.')
+# CSI (`\x1b[…`) or SS3 (`\x1bO…`, application-cursor mode for Home/End/arrows)
+# as whole sequences, else any 2-byte `\x1b.` — order matters so SS3 wins over `\x1b.`.
+_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[@-~]|\x1b.')
_FILE_REF_RE = re.compile(r'@([\w./\-~]+)')
_PASTE_PH_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]')
_FILE_PH_RE = re.compile(r'\[File #(\d+)\]')
@@ -1841,6 +1901,8 @@ def _cmds() -> list[tuple[str, str, str]]:
('/scheduler', '', _t('cmd.scheduler.desc')),
('/rewind', _t('cmd.rewind.arg'), _t('cmd.rewind.desc')),
('/continue', _t('cmd.continue.arg'), _t('cmd.continue.desc')),
+ ('/workspace', _t('cmd.workspace.arg', default='[path|off]'),
+ _t('cmd.workspace.desc', default='设定工作目录(绝对路径)并进入项目模式')),
('/new', _t('cmd.new.arg'), _t('cmd.new.desc')),
('/rename', _t('cmd.rename.arg'), _t('cmd.rename.desc')),
('/clear', '', _t('cmd.clear.desc')),
@@ -2060,6 +2122,13 @@ def _esc_repl(m: re.Match) -> bytes:
return b'\x1d' # Shift+↓ → extend selection down
if s in (b'\x1b[27;2;13~', b'\x1b[13;2u'):
return b'\n' # Shift+Enter (modifyOtherKeys / kitty) → newline
+ # Home/End arrive as raw VT sequences via PTK's KeyPress.data (special keys
+ # carry their original escape bytes), so _ptk_keypress_to_bytes returns them
+ # verbatim and they must be decoded here like the arrows above.
+ if s in (b'\x1b[H', b'\x1b[1~', b'\x1b[7~', b'\x1bOH'):
+ return b'\x07' # Home → internal jump-to-line-start
+ if s in (b'\x1b[F', b'\x1b[4~', b'\x1b[8~', b'\x1bOF'):
+ return b'\x14' # End → internal jump-to-line-end
return b'' # swallow every other escape sequence
@@ -2205,6 +2274,46 @@ def _clip_cells(s: str, width: int) -> str:
return out
+def _cell_head(s: str, n: int) -> str:
+ """Keep the head within n cells, suffix … if truncated (省略末尾 — names)."""
+ if cell_len(s) <= n:
+ return s
+ if n <= 1:
+ return '…'
+ out, w = '', 0
+ for ch in s:
+ c = cell_len(ch)
+ if w + c > n - 1:
+ break
+ out += ch; w += c
+ return out + '…'
+
+
+def _cell_mid(s: str, n: int) -> str:
+ """Keep head+tail within n cells, … in the middle (省略中间 — paths: project
+ root and leaf both stay visible). CJK counts as 2 via cell_len."""
+ if cell_len(s) <= n:
+ return s
+ if n <= 1:
+ return '…'
+ avail = n - 1
+ head_budget = avail - avail // 2
+ tail_budget = avail // 2
+ head, w = '', 0
+ for ch in s:
+ c = cell_len(ch)
+ if w + c > head_budget:
+ break
+ head += ch; w += c
+ tail_rev, w = '', 0
+ for ch in reversed(s):
+ c = cell_len(ch)
+ if w + c > tail_budget:
+ break
+ tail_rev += ch; w += c
+ return head + '…' + tail_rev[::-1]
+
+
def _term_safe_text(s: str) -> str:
"""Normalize control chars whose terminal geometry is stateful.
@@ -2380,11 +2489,25 @@ class SB:
# on_submit(_menu_sel) and ignores _menu_checked.
self._menu_multi: bool = False
self._menu_checked: set[int] = set()
+ # opt-in 可过滤菜单(/workspace 用):输入过滤 + free_input 提交原文。
+ self._menu_filterable: bool = False
+ self._menu_free: bool = False
+ self._menu_on_free = None
+ self._menu_query: str = ''
+ self._menu_all_labels: list[str] = [] # 未过滤的全部显示行
+ self._menu_filter_keys: list[str] = [] # 与 all_labels 等长的可搜索文本(完整,不省略)
+ self._menu_map: list[int] = [] # 可见行 idx → all_labels 原始 idx
# Interactive command palette: index into _cmd_matches output when
# buf starts with `/`. ↑↓ steer the highlight, Tab completes the
# highlighted command, Enter still executes whatever is in buf.
self._palette_sel: int = 0
self._palette_scroll: int = 0 # viewport offset into the matches list
+ # workspace 绑定(单会话 → 进程级一个)。空 = 普通模式。
+ self._ws_name: str = ''
+ self._ws_path: str = ''
+ self._ws_link: str = ''
+ # @ 候选缓存:(buf, pos) → list[path],避免每次按键重算 fuzzy_rank。
+ self._at_cache: tuple | None = None
# ── live region ──
#
@@ -2395,7 +2518,9 @@ class SB:
# in callers are harmless legacy resets (they zero state PTK ignores).
def _status_line(self, w: int) -> str:
- name = self._bridge.llm_name if self._bridge else '?'
+ # Show the concrete model id; fall back to the channel group only when
+ # the model is unavailable (e.g. a mixin without a single .model).
+ name = (self._bridge.llm_model or self._bridge.llm_name) if self._bridge else '?'
if self._asking:
state = _t('status.asking')
elif self._running:
@@ -2409,7 +2534,12 @@ class SB:
else:
state = _t('status.ready')
cost = _cost_str(self._bridge.agent) if self._bridge else ''
- return f'[main] {name} │ {state}{cost}'
+ ws = ''
+ if self._ws_name:
+ disp = (os.path.basename((self._ws_path or '').rstrip('/\\'))
+ or re.sub(r'-[0-9a-f]{8}$', '', self._ws_name))
+ ws = f' │ ⌂ {_cell_head(disp, 18)}'
+ return f'[main] {name} │ {state}{cost}{ws}'
# v2-style plan card budget: 5 rows max — header(1) + optional step(1) +
# tasks(rest) + optional overflow(1). Grace periods avoid flicker when the
@@ -2658,7 +2788,9 @@ class SB:
def _show_menu(self, title: str, options: list[str], on_submit,
hint: str | None = None, on_cancel=None,
multi_select: bool = False,
- pre_checked: set[int] | None = None) -> None:
+ pre_checked: set[int] | None = None,
+ filterable: bool = False, free_input: bool = False,
+ on_free=None, filter_keys: list[str] | None = None) -> None:
"""Open a modal arrow-key menu in place of the input box.
The menu takes over the live region; ↑↓ move the highlight, Enter
@@ -2676,11 +2808,23 @@ class SB:
worked logically but left a one-frame window where PTK could render
the menu with the wrong state — observed as /scheduler picker
rendering all unchecked even though `reflect/scheduler.py` was alive.
- Passing the set up-front makes the open atomic."""
+ Passing the set up-front makes the open atomic.
+
+ `filterable=True` adds a search box: printable chars / backspace edit a
+ live query that filters rows by `filter_keys` (defaults to the visible
+ labels; pass the full untruncated text so an elided middle still
+ matches). `free_input=True` + `on_free` lets Enter on a query with NO
+ match commit the raw query (e.g. an abs path → new workspace)."""
if not options:
- return
+ if not (filterable and free_input):
+ return # 空列表但允许 free_input → 仍打开(可输路径新建)
self._menu_active = True
- self._menu_options = list(options)
+ self._menu_filterable = bool(filterable)
+ self._menu_free = bool(free_input)
+ self._menu_on_free = on_free
+ self._menu_query = ''
+ self._menu_all_labels = list(options)
+ self._menu_filter_keys = list(filter_keys) if filter_keys else list(options)
self._menu_title = title
# In multi-select mode show a Space-aware hint by default so the user
# discovers the toggle key without reading the docstring.
@@ -2688,16 +2832,38 @@ class SB:
self._menu_hint = hint
elif multi_select:
self._menu_hint = _t('menu.hint.multi', default='Space toggle · ↑↓ move · Enter submit · Esc cancel')
+ elif filterable:
+ self._menu_hint = _t('menu.hint.filter', default='输入过滤 · ↑↓ 选择 · Enter 确认 · Esc 取消')
else:
self._menu_hint = _t('menu.hint')
- self._menu_sel = 0
- self._menu_scroll = 0
self._menu_on_submit = on_submit
self._menu_on_cancel = on_cancel
self._menu_multi = bool(multi_select)
self._menu_checked = set(pre_checked) if pre_checked else set()
+ self._menu_apply_filter() # 设 _menu_options + _menu_map(初始 query 为空 → 全量)
self._render_live()
+ def _menu_apply_filter(self) -> None:
+ """Recompute visible rows (`_menu_options`) + map-to-original
+ (`_menu_map`) from the live query. No-op shape when not filterable."""
+ q = self._menu_query.strip().lower()
+ if not self._menu_filterable or not q:
+ self._menu_options = list(self._menu_all_labels)
+ self._menu_map = list(range(len(self._menu_all_labels)))
+ else:
+ terms = q.split()
+ self._menu_options, self._menu_map = [], []
+ for i, key in enumerate(self._menu_filter_keys):
+ kl = key.lower()
+ if all(t in kl for t in terms):
+ self._menu_options.append(self._menu_all_labels[i])
+ self._menu_map.append(i)
+ # Focus model (filterable): -1 = the input row itself (a selectable
+ # object in the ↑↓ ring, v2-continue style); 0..n-1 = a candidate.
+ # Typing keeps focus on the input row. Non-filterable menus start at 0.
+ self._menu_sel = -1 if self._menu_filterable else 0
+ self._menu_scroll = 0
+
def _close_menu(self) -> None:
self._menu_active = False
self._menu_options = []
@@ -2709,6 +2875,13 @@ class SB:
self._menu_on_cancel = None
self._menu_multi = False
self._menu_checked = set()
+ self._menu_filterable = False
+ self._menu_free = False
+ self._menu_on_free = None
+ self._menu_query = ''
+ self._menu_all_labels = []
+ self._menu_filter_keys = []
+ self._menu_map = []
@staticmethod
def _scroll_window(sel: int, total: int, visible: int, scroll: int) -> int:
@@ -2729,6 +2902,36 @@ class SB:
checked = sorted(self._menu_checked) if multi else None
if not self._menu_active:
return
+ # Filterable: map the visible selection back to the original index; or,
+ # when the query matches nothing, commit it verbatim via on_free
+ # (free_input — e.g. a typed abs path → new workspace).
+ if self._menu_filterable:
+ mp = self._menu_map
+ on_free = self._menu_on_free
+ q = self._menu_query.strip()
+ # 焦点在某个候选 → 选它(映射回原始 idx)。
+ if sel >= 0 and self._menu_options:
+ orig = mp[sel] if 0 <= sel < len(mp) else mp[0]
+ self._close_menu()
+ if cb is not None:
+ try: cb(orig)
+ except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
+ return
+ # 焦点在输入框(sel<0):有 query → free 提交(当作路径新建/进入)。
+ if self._menu_free and q and on_free is not None:
+ self._close_menu()
+ try: on_free(q)
+ except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
+ return
+ # 输入框空 + 有候选 → 选第一个(便捷)。
+ if self._menu_options:
+ orig = mp[0]
+ self._close_menu()
+ if cb is not None:
+ try: cb(orig)
+ except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
+ return
+ return # 无 query 无候选 → 维持菜单
self._close_menu()
if cb is not None:
try:
@@ -2767,14 +2970,50 @@ class SB:
return ae.candidates[self._picker_sel]
return None
+ def _at_root(self) -> str:
+ # @ 索引根 = workspace(绑定时真实路径),否则 agent 实际工作目录
+ # _ROOT/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。
+ return self._ws_path or os.path.join(_ROOT, "temp")
+
+ def _at_active(self):
+ """@ 补全:返回 (query, at_pos_in_buf) 或 None。基于光标前、当前逻辑行的
+ @token(@ 可在任意行任意位置;菜单 / ask 态不触发)。"""
+ if self._asking is not None or self._menu_active:
+ return None
+ ls = self.buf.rfind('\n', 0, self.pos) + 1
+ tok = at_complete.find_at_token(self.buf[ls:self.pos])
+ if tok is None:
+ return None
+ query, at_in_line = tok
+ return query, ls + at_in_line
+
+ def _at_candidates(self) -> list:
+ """当前 @token 的候选(带 (buf,pos) 缓存,避免每键重算 fuzzy)。"""
+ act = self._at_active()
+ if act is None:
+ self._at_cache = None
+ return []
+ key = (self.buf, self.pos)
+ if self._at_cache is not None and self._at_cache[0] == key:
+ return self._at_cache[1]
+ # 未绑 workspace → 索引根是 temp,相对路径不直观,候选用绝对路径(_hint_lines
+ # 本就整条显示);绑了用相对(短)。
+ items = at_complete.candidates_for(act[0], self._at_root(), absolute=not self._ws_path)
+ self._at_cache = (key, items)
+ return items
+
+ def _palette_total(self) -> int:
+ # 当前 palette 候选数(↓ 键越界判断用):slash 命令 or @ 文件。
+ if self._slash_visible():
+ return len(self._cmd_matches(self.buf))
+ return len(self._at_candidates())
+
def _cmd_matches(self, prefix: str) -> list[tuple[str, str, str]]:
p = prefix.strip().lower()
return [c for c in _cmds() if c[0].startswith(p)]
- def _palette_visible(self) -> bool:
- """True when the live `/`-command palette should appear and own ↑↓/Tab."""
- if self._asking is not None or self._menu_active:
- return False
+ def _slash_visible(self) -> bool:
+ """True when the buffer is a live `/`-command prefix with matches."""
if '\n' in self.buf or not self.buf.startswith('/'):
return False
ms = self._cmd_matches(self.buf)
@@ -2784,19 +3023,29 @@ class SB:
return False
return True
+ def _palette_visible(self) -> bool:
+ """True when the live palette should appear and own ↑↓/Tab — either the
+ `/`-command palette (buf starts with /) or the `@` file palette (cursor
+ sits in an @token). Same machinery, two candidate sources."""
+ if self._asking is not None or self._menu_active:
+ return False
+ return self._slash_visible() or bool(self._at_candidates())
+
def _hint_lines(self, w: int) -> list[str]:
- """Live `/`-command palette: scrollback-style list with an arrow-key
- highlight + scrolling viewport. ↑↓ move the highlight (handled in
- `_keys`), Tab completes the highlighted command, Enter completes into
- the input box. Long match lists scroll the visible window as sel walks
- past the edges (no wrap-around, no "N more" chrome)."""
+ """Live palette: scrollback-style list with an arrow-key highlight +
+ scrolling viewport. ↑↓ move the highlight (handled in `_keys`), Tab /
+ Enter complete the highlighted entry into the input box. Renders the
+ `/`-command set when buf starts with `/`, else the `@` file candidates."""
if not self._palette_visible():
return []
- ms = self._cmd_matches(self.buf)
- total = len(ms)
+ if self._slash_visible():
+ rows = [f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}'
+ for n, a, d in self._cmd_matches(self.buf)]
+ else:
+ rows = [' @ ' + p for p in self._at_candidates()]
+ total = len(rows)
# Cap palette viewport to 6 rows so it doesn't squeeze the input box.
visible = min(total, 6)
- # Clamp sel + slide scroll so sel is in-window.
if self._palette_sel < 0 or self._palette_sel >= total:
self._palette_sel = 0
self._palette_scroll = self._scroll_window(self._palette_sel, total, visible, self._palette_scroll)
@@ -2804,21 +3053,27 @@ class SB:
end = min(total, start + visible)
out: list[str] = []
for i in range(start, end):
- n, a, d = ms[i]
- # `<11` + literal space guarantees a visible gap between the
- # 10-char names (/conductor, /morphling, /scheduler) and their
- # `[arg]` placeholder — without it they render as `/conductor[task]`.
- row = f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}'
- if i == self._palette_sel:
- out.append(_ACCENT + _BOLD + _clip_cells(row, w) + _RST)
- else:
- out.append(_DIM + _clip_cells(row, w) + _RST)
+ styled = (_ACCENT + _BOLD) if i == self._palette_sel else _DIM
+ out.append(styled + _clip_cells(rows[i], w) + _RST)
return out
def _tab(self) -> None:
if self._asking is not None or self._menu_active:
return
- if '\n' in self.buf or not self.buf.startswith('/'):
+ # @ 文件补全:用选中候选替换光标处的 @token(format_pick 加引号/尾空格)。
+ if not self._slash_visible():
+ items = self._at_candidates()
+ act = self._at_active()
+ if not items or act is None:
+ return
+ idx = self._palette_sel if 0 <= self._palette_sel < len(items) else 0
+ rep = at_complete.format_pick(items[idx])
+ at_pos = act[1]
+ self.buf = self.buf[:at_pos] + rep + self.buf[self.pos:]
+ self.pos = at_pos + len(rep)
+ self._palette_sel = 0
+ self._palette_scroll = 0
+ self._at_cache = None
return
ms = self._cmd_matches(self.buf)
if not ms:
@@ -3008,9 +3263,14 @@ class SB:
# Use PTK's current viewport height (set on SB._h by the render loop).
h = max(8, getattr(self, '_h', 24))
visible = min(total, self._menu_visible_count(h))
- # Clamp sel and recompute scroll so sel is in-window.
- self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0
- self._menu_scroll = self._scroll_window(self._menu_sel, total, visible, self._menu_scroll)
+ # Clamp sel and recompute scroll so sel is in-window. Filterable menus
+ # allow sel == -1 (focus on the input row); clamp candidates to range
+ # but leave -1 intact. _scroll_window treats -1 as "top".
+ if self._menu_filterable:
+ self._menu_sel = -1 if self._menu_sel < 0 else (min(total - 1, self._menu_sel) if total else -1)
+ else:
+ self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0
+ self._menu_scroll = self._scroll_window(max(0, self._menu_sel), total, visible, self._menu_scroll)
start = self._menu_scroll
end = min(total, start + visible)
@@ -3051,6 +3311,22 @@ class SB:
return r
rows = [top]
+ # Filterable menu: a search line above the rows, showing the live query
+ # with a caret. Empty match set renders a hint instead of blank.
+ if self._menu_filterable:
+ q = self._menu_query
+ focused = (self._menu_sel < 0) # 焦点在输入框(可被 ↑↓ 选中的对象)
+ # 整行单一 style(纯文本交给 row,颜色走 style 参)——内嵌 ANSI 会让
+ # row() 的 pad=content_w-cell_len(ch) 把转义算进宽度,右边框就错位。
+ # focused → 整行 accent(箭头+占位/输入都高亮,焦点明显);否则 dim。
+ if q:
+ text = '› ' + q + ('▏' if focused else '')
+ else:
+ text = '› ' + _t('menu.search')
+ rows.extend(row(text, _ACCENT if focused else _DIM))
+ if not self._menu_options:
+ hint = (_t('menu.free.hint') if (self._menu_free and q.strip()) else _t('menu.no_match'))
+ rows.extend(row(hint, _DIM))
# Viewport scrolls as sel walks past the edges; the title's N/total tag
# signals position, so no "N more" indicator rows.
for i in range(start, end):
@@ -3997,12 +4273,20 @@ class SB:
self._run_shell(raw[1:].strip())
return
if raw.startswith('/'):
+ # Expand pasted/file/image placeholders BEFORE dispatch so a command
+ # carries the real pasted text, not a `[Pasted text #N]` marker — e.g.
+ # `/morphling `. Without this the command
+ # path returned here before the expansion below ever ran, so the agent
+ # got the literal placeholder. `self.hist` already kept the raw input.
+ expanded = self._expand(raw)
# /btw owns its own live-region panel — keep the command itself
# out of the main scrollback.
- cmd0 = (raw[1:].split(None, 1)[0] or '').lower()
+ cmd0 = (expanded[1:].split(None, 1)[0] or '').lower()
if cmd0 != 'btw':
- self._commit_user(raw)
- self._cmd(raw); return
+ self._commit_user(expanded)
+ self._cmd(expanded)
+ self._pstore.clear(); self._fstore.clear(); self._imgs.clear()
+ return
# Expand placeholders FIRST so the agent receives the resolved text,
# not the [Image #N] / [Pasted #N] markers. This matches the idle
# submit path below — keeping the form identical means a queued
@@ -4010,17 +4294,20 @@ class SB:
imgs = [self._imgs[i] for i in
(int(m.group(1)) for m in _IMG_PH_RE.finditer(raw)) if i in self._imgs]
expanded = self._expand(raw)
+ # @ mentions: agent 收绝对路径(file_read 相对自身 cwd,否则找不到),
+ # scrollback 显示相对(简洁)。仅改路径根、不读内容。
+ agent_text = at_complete.absolutize_mentions(expanded, self._at_root()) if "@" in expanded else expanded
if self._running:
- wrapped = _t('pending.inject_wrap', text=expanded)
+ wrapped = _t('pending.inject_wrap', text=agent_text)
if self._bridge and self._bridge.inject_intervene(wrapped, track=True):
- self._pending.append(expanded)
+ self._pending.append(agent_text)
self._commit_user(_t('pending.queued_marker', text=expanded))
self._pstore.clear(); self._fstore.clear(); self._imgs.clear()
self._render_live()
return
# Agent went idle in the race — fall through to put_task.
- self._commit_user(expanded) # scrollback shows exactly what
- self._submit(expanded, imgs) # the agent receives, not the
+ self._commit_user(expanded) # scrollback 显示相对
+ self._submit(agent_text, imgs) # agent 收绝对
self._pstore.clear(); self._fstore.clear(); self._imgs.clear() # drop placeholders
def _cost_section(self, tname: str, t, be) -> list[str]:
@@ -4060,11 +4347,98 @@ class SB:
"""Wipe the conversation: drop LLM history, clear the screen and every
rendered block. Shared by /clear and /new."""
from frontends import continue_cmd
- continue_cmd.reset_conversation(ag)
+ continue_cmd.begin_fresh_session(ag) # 切走:旧日志留作空闲会话 + 铸新 logid(不存快照)
_w('\x1b[2J\x1b[H'); self._painted = []; self._live_rows = 0
self._blocks = []; self._streaming_block = None; self._sent = 0
self._tool_base = 0; self._tools = {}
+ # ── workspace(与 v2 共用 workspace_cmd;单会话 → 进程级一个绑定)─────────
+ def _bind_workspace(self, info, persist: bool = True) -> None:
+ """绑定 / 解绑 workspace。info=prepare() 的结果 dict → 绑定;None → 解绑。
+ 同步 agent 的 project_mode 属性(插件据此注入项目上下文),刷新 @ 索引根。
+
+ persist=False: 仅刷新内存绑定状态,不写 session→ws 映射表。续接时的
+ reset(_bind_workspace(None)) 用它——原地续后 agent.log_path==被续文件,
+ 若持久化会把本会话映射抹成 ""(= 已 off),反而毁掉自动恢复。映射表只应由
+ 显式 /workspace、/workspace off 与成功的续接恢复来写。"""
+ ag = self._bridge.agent if self._bridge else None
+ if info:
+ self._ws_name = info.get('name') or ''
+ self._ws_path = info.get('target') or info.get('path') or ''
+ self._ws_link = info.get('link') or ''
+ pm_name, pm_path = (self._ws_name or None), self._ws_path
+ else:
+ self._ws_name = self._ws_path = self._ws_link = ''
+ pm_name, pm_path = None, ''
+ self._at_cache = None # 索引根可能变了,@ 候选缓存失效
+ if ag is not None:
+ try:
+ ag._ga_project_mode_name = pm_name
+ ag._ga_project_mode_workspace_path = pm_path
+ except Exception:
+ pass
+ # 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。
+ if persist:
+ workspace_cmd.session_ws_set(getattr(ag, "log_path", "") or "", pm_path or "")
+ at_complete.get_index(self._at_root()).warm() # 预热新根(或 CWD)
+
+ def _do_workspace_activate(self, path: str) -> str:
+ r = workspace_cmd.prepare(path)
+ if not r.get('ok'):
+ return _t('ws.fail', default='❌ workspace 设定失败: {e}').format(e=r.get('error'))
+ self._bind_workspace(r)
+ # 显示名去 hash:真实目录 basename,退回剥 name 尾 hash。
+ disp = (os.path.basename((r.get('target') or '').rstrip('/\\'))
+ or re.sub(r'-[0-9a-f]{8}$', '', r.get('name') or ''))
+ out = _t('ws.entered', default='✅ 已进入 workspace「{n}」').format(n=disp)
+ if r.get('warning'):
+ out += ' ⚠ ' + r['warning']
+ return out
+
+ def _cmd_workspace(self, arg: str) -> None:
+ arg = (arg or '').strip()
+ if arg.lower() == 'off':
+ if self._ws_name:
+ self._bind_workspace(None)
+ self.commit([_DIM + _t('ws.exited',
+ default='已退出 workspace(项目模式关闭;junction 与文件保留)') + _RST])
+ else:
+ self.commit([_DIM + _t('ws.inactive', default='当前未处于 workspace 模式') + _RST])
+ return
+ if arg: # 直接路径:设定/进入
+ self.commit([self._do_workspace_activate(arg)])
+ return
+ # 无参 → 菜单选已登记 workspace(去 hash / 名称末尾省略 / 路径中间省略)。
+ items = workspace_cmd.registry_list()
+ if not items:
+ self.commit([_DIM + _t('ws.none',
+ default='暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入') + _RST])
+ return
+ options: list[str] = []
+ paths: list[str] = []
+ fkeys: list[str] = []
+ for it in items:
+ disp = (os.path.basename((it['path'] or '').rstrip('/\\'))
+ or re.sub(r'-[0-9a-f]{8}$', '', it['name']))
+ age = _rel(it['last_used']) if it['last_used'] else '—'
+ mem = (f"{it['mem_lines']}行" if it['mem_lines'] else '空')
+ flag = ' ⚠' if it['dangling'] else ''
+ options.append(f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}")
+ paths.append(it['path'])
+ # 搜索键含完整路径(显示中间省略,但搜索看完整 — 与 v2 _filter_choices 一致)。
+ fkeys.append(f"{disp} {it['path']}")
+
+ def _pick(idx: int) -> None:
+ self.commit([self._do_workspace_activate(paths[idx])])
+
+ def _free(q: str) -> None: # 输入框内回车一个绝对路径 → 新建/进入
+ self.commit([self._do_workspace_activate(q)])
+
+ self._show_menu(_t('ws.pick.title',
+ default='选择 workspace(输入过滤 / 绝对路径回车新建 · ↑↓ 选 · Esc 取消)'),
+ options, _pick, filterable=True, free_input=True,
+ on_free=_free, filter_keys=fkeys)
+
def _cmd(self, raw: str) -> None:
assert self._bridge is not None
parts = raw[1:].split(None, 1)
@@ -4146,14 +4520,15 @@ class SB:
# self.commit([_t('err.multi_session', name=name)])
elif name == 'continue':
from frontends import continue_cmd
- sess = continue_cmd.list_sessions(exclude_pid=os.getpid())
+ sess = continue_cmd.list_sessions(exclude_log=os.path.basename(getattr(ag, "log_path", "") or ""))
if not sess:
self.commit([_DIM + _t('msg.no_history') + _RST]); return
- def _do_restore(path: str) -> None:
- msg, _ = continue_cmd.restore(ag, path)
+ def _rc_finish(path: str, msg: str) -> None:
+ # restore 后:重放对话到 scrollback + 恢复 workspace。读 ag.log_path
+ # (原地=源文件本身;拷贝=内容相同的新副本),内容一致。
self.commit([_DIM + '┄┄ ' + _t('msg.continue_loading', name=os.path.basename(path)) + ' ┄┄' + _RST])
- for mm in continue_cmd.extract_ui_messages(path):
+ for mm in continue_cmd.extract_ui_messages(getattr(ag, 'log_path', '') or path):
c = (mm.get('content') or '').strip()
if not c:
continue
@@ -4162,6 +4537,47 @@ class SB:
else:
self._commit_assistant(c)
self.commit([_DIM + '┄┄ ' + _t('msg.continue_ready', msg=msg) + ' ┄┄' + _RST])
+ # 自动恢复 workspace:续接的会话若在某个已登记 workspace 里工作过,
+ # 重新绑定(必要时重建 junction),不触碰 project_mode 的进程锚。
+ # persist=False:reset 绑定绝不写 session→ws 映射表——原地续把
+ # agent.log_path 指回 path,若持久化会把本会话映射抹成 "",在读回
+ # 之前就毁掉自动恢复。
+ self._bind_workspace(None, persist=False)
+ try:
+ rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录)
+ if rec is not None:
+ ws_path = rec or None # "" → 该会话已 off,明确不恢复
+ else:
+ info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志
+ ws_path = info['path'] if info else None
+ if ws_path:
+ r = workspace_cmd.prepare(ws_path)
+ if r.get('ok'):
+ self._bind_workspace(r)
+ self.commit([_DIM + '⌂ ' + _t('ws.restored',
+ default='已恢复工作目录: {t}').format(t=r['target']) + _RST])
+ except Exception:
+ pass
+
+ def _do_restore(path: str) -> None:
+ # 默认原地续。快照只能拷贝续;若被活进程占用 → 弹窗问是否拷贝一份。
+ if continue_cmd.is_snapshot(path):
+ msg, _ = continue_cmd.continue_copy(ag, path)
+ _rc_finish(path, msg); return
+ occ = continue_cmd.session_occupant(path)
+ if occ is not None:
+ def _pick(i):
+ if i == 0:
+ msg, _ = continue_cmd.continue_copy(ag, path)
+ _rc_finish(path, msg)
+ self._show_menu(
+ _t('continue.occupied.title', p=occ.get('pid', '?')),
+ [_t('continue.occupied.copy'),
+ _t('continue.occupied.cancel')],
+ _pick)
+ return
+ msg, _ = continue_cmd.continue_inplace(ag, path)
+ _rc_finish(path, msg)
if arg:
# Direct numeric argument still supported for power users / scripts.
@@ -4199,6 +4615,8 @@ class SB:
_do_restore(sess[idx][0])
self._show_menu(_t('continue.title'), options, _pick_session)
+ elif name == 'workspace':
+ self._cmd_workspace(arg)
elif name == 'clear':
self._reset_session(ag)
self.commit([_DIM + _t('msg.cleared') + _RST])
@@ -5121,11 +5539,28 @@ class SB:
if self._menu_active:
n = len(self._menu_options)
if o == 0x10: # ↑
- if n:
+ if self._menu_filterable:
+ # ring [input(-1), 0..n-1]: ↑ from input → last cand;
+ # ↑ from first cand → input; else step up.
+ if self._menu_sel < 0:
+ self._menu_sel = n - 1 if n else -1
+ elif self._menu_sel == 0:
+ self._menu_sel = -1
+ else:
+ self._menu_sel -= 1
+ elif n:
self._menu_sel = max(0, self._menu_sel - 1)
self._render_live(); continue
if o == 0x0e: # ↓
- if n:
+ if self._menu_filterable:
+ # ↓ from input → first cand; ↓ from last cand → input.
+ if self._menu_sel < 0:
+ self._menu_sel = 0 if n else -1
+ elif self._menu_sel >= n - 1:
+ self._menu_sel = -1
+ else:
+ self._menu_sel += 1
+ elif n:
self._menu_sel = min(n - 1, self._menu_sel + 1)
self._render_live(); continue
if self._menu_multi and ch == ' ': # Space toggles
@@ -5148,6 +5583,19 @@ class SB:
# a one-handed rollback without reaching for Esc.
# Menus with no on_cancel just dismiss.
self._menu_cancel(); continue
+ # Filterable menu (e.g. /workspace): printable chars + Backspace
+ # edit a live query that filters rows; everything else (arrows
+ # 0x10/0x0e/0x02/0x06 are < 0x20) still falls through to swallow.
+ if self._menu_filterable:
+ if o in (0x08, 0x7f): # Backspace / Del
+ if self._menu_query:
+ self._menu_query = self._menu_query[:-1]
+ self._menu_apply_filter()
+ self._render_live(); continue
+ if o >= 0x20 and o != 0x7f: # printable → append to query
+ self._menu_query += ch
+ self._menu_apply_filter()
+ self._render_live(); continue
# swallow everything else while the menu is up
continue
# ── ask_user picker key intercept ───────────────────────────────
@@ -5220,7 +5668,7 @@ class SB:
self._stash_draft()
elif o == 0x0e: # ↓ visual-row down (history at bottom)
if self._palette_visible():
- n = len(self._cmd_matches(self.buf))
+ n = self._palette_total()
self._palette_sel = min(n - 1, self._palette_sel + 1) if n else 0
else:
self._sel = None
@@ -5253,6 +5701,14 @@ class SB:
elif o == 0x01: # Ctrl+A — select all
if self.buf:
self._sel = 0; self.pos = len(self.buf)
+ elif o == 0x07: # Home — jump to line start
+ self._sel = None
+ _, _, ls, _ = self._line_region()
+ self.pos = ls
+ elif o == 0x14: # End — jump to line end
+ self._sel = None
+ _, _, _, le = self._line_region()
+ self.pos = le
elif o == 0x18: # Ctrl+X — cut selection
r = self._sel_range()
if r:
@@ -5581,6 +6037,11 @@ def main(argv: list[str] | None = None) -> int:
print(_t('err.no_tty'))
return 1
_sweep_stale_task_dirs() # clear empty signal dirs left by prior runs
+ try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions
+ except Exception: pass
+ try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone
+ except Exception: pass
+ at_complete.get_index(os.path.join(_ROOT, "temp")).warm() # @ 补全:预热未绑时的默认根(temp)
SB().run()
return 0
diff --git a/frontends/tuiapp_v2.py b/frontends/tuiapp_v2.py
index ce9153bc..c8694c5d 100644
--- a/frontends/tuiapp_v2.py
+++ b/frontends/tuiapp_v2.py
@@ -58,7 +58,7 @@ try:
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding
- from textual.containers import Horizontal, Vertical, VerticalScroll
+ from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
from textual.geometry import Region
from textual.message import Message
from textual.screen import ModalScreen
@@ -245,6 +245,633 @@ def _render_tool_use_block(match) -> str:
return f"\n*tool: {name}*\n"
+# ---------------------------------------------------------------------------
+# Write-tool diff rendering.
+#
+# `file_patch` / `file_write` are the only tools whose *content* matters at a
+# glance, so we render them as a real, themed diff instead of the generic
+# `tool: ` line. The render produces two parallel strings:
+# - an ANSI string (line-number gutter + ±/space sign + low-saturation row
+# background) injected into the colored "narrow" markdown stream, and
+# - a plain string (identical visible text, no SGR) injected into the
+# "wide" stream so `_align_md_renders` can pair them line-for-line and
+# mouse-selection / copy still works.
+# Both strings have the SAME line count and the SAME post-rstrip text, which is
+# the invariant `_align_md_renders` relies on (K==W branch).
+#
+# The verbose display stream (agent_loop.py:78) shows tool args as pretty JSON
+# inside a ```` ```text ```` fence — but `get_pretty_json` turns escaped `\n`
+# into *real* newlines, so multi-line content no longer parses as JSON. Instead
+# of parsing the display text, `_install_write_snapshot_hook` captures the real
+# structured `args` dict (plus the file's pre-write content for `overwrite`)
+# from a `tool_before` hook, keyed by `hash(get_pretty_json(args))`. At render
+# time we hash the fence body and look the capture back up — content-addressed,
+# so it survives re-renders and pairs each block with its exact call.
+# ---------------------------------------------------------------------------
+
+# hash(get_pretty_json(args)) -> {"name", "args", "existed", "old"}
+_WRITE_CAP: dict[int, dict] = {}
+
+# Cap the rendered diff body so a 2000-line file_write can't flood the chat.
+_DIFF_MAX_ROWS = 80
+_DIFF_CONTEXT = 3
+
+# Visual-only left margin for the rendered card: prefixed to the ANSI (narrow)
+# stream but NOT the plain (wide) copy source, so mouse-copy stays margin-free.
+# `_align_md_renders` maps the offset via line_indents (single-line groups), and
+# `_md_line_has_box_drawing` exempts the card's `└─ ` line from the table
+# passthrough that would otherwise copy the visible (margined) text.
+_DIFF_MARGIN = 2
+
+# Matches the verbose tool display agent_loop.py emits:
+# 🛠️ Tool: `file_patch` 📥 args:
+# ````text
+# { ...pretty json... }
+# ````
+# Group 1 = tool name, group 2 = the (newline-mangled) pretty-JSON fence body,
+# which we hash to recover the real args captured by the snapshot hook.
+_VERBOSE_WRITE_RE = re.compile(
+ r"🛠️ Tool: `(file_write|file_patch|file_read|code_run)` 📥 args:\n`{4}\w*\n(.*?)\n`{4}"
+ # Also swallow the dispatch output fence that immediately follows
+ # (`[Action]…`/`{status}` lines) — its info moves into the diff header.
+ r"(?:\s*`{5}\n(.*?)\n`{5})?",
+ re.DOTALL,
+)
+
+
+# Any other tool's verbose block — same shape, any tool name. Substituted AFTER
+# `_VERBOSE_WRITE_RE` (write blocks are already sentinels by then), purely to
+# add the left margin while keeping the Rich-markdown look unchanged.
+_VERBOSE_TOOL_RE = re.compile(
+ r"🛠️ Tool: `(\w+)` 📥 args:\n`{4}\w*\n.*?\n`{4}"
+ r"(?:\s*`{5}\n.*?\n`{5})?",
+ re.DOTALL,
+)
+
+
+def _fence_status(fence_body):
+ """Per-block outcome from the dispatch-output fence trailing a write call
+ (a `{"status": ..., "msg": ...}` line). This is the only *per-occurrence*
+ signal: `_WRITE_CAP` is keyed by args, so two identical calls (fail → mkdir
+ → retry) share one capture and its stored status is last-write-wins."""
+ for line in (fence_body or "").splitlines():
+ try:
+ d = json.loads(line)
+ except Exception:
+ continue
+ if isinstance(d, dict) and "status" in d:
+ return d.get("status"), str(d.get("msg") or "")
+ return None
+
+
+def _sgr_fg(hex_color: str) -> str:
+ r, g, b = _hex_rgb(hex_color)
+ return f"\x1b[38;2;{r};{g};{b}m"
+
+
+def _sgr_bg(hex_color: str) -> str:
+ r, g, b = _hex_rgb(hex_color)
+ return f"\x1b[48;2;{r};{g};{b}m"
+
+
+_SGR_RESET = "\x1b[0m"
+
+
+def _chop_cells(s: str, w: int) -> list[str]:
+ """Split into chunks each ≤ w display cells (CJK counts as 2)."""
+ from rich.cells import cell_len
+ out, cur, cw = [], [], 0
+ for ch in s:
+ c = cell_len(ch)
+ if cur and cw + c > w:
+ out.append("".join(cur)); cur, cw = [ch], c
+ else:
+ cur.append(ch); cw += c
+ if cur:
+ out.append("".join(cur))
+ return out or [""]
+
+
+def _cell_tail(s: str, n: int) -> str:
+ """Keep the tail of `s` within n cells, prefixing … if truncated."""
+ from rich.cells import cell_len
+ if cell_len(s) <= n:
+ return s
+ if n <= 1:
+ return "…"
+ out, w = [], 0
+ for ch in reversed(s):
+ c = cell_len(ch)
+ if w + c > n - 1:
+ break
+ out.append(ch); w += c
+ return "…" + "".join(reversed(out))
+
+
+def _cell_head(s: str, n: int) -> str:
+ """Keep the head of `s` within n cells, suffixing … if truncated.
+ (省略末尾 — names: keep the start, drop the tail.)"""
+ from rich.cells import cell_len
+ if cell_len(s) <= n:
+ return s
+ if n <= 1:
+ return "…"
+ out, w = [], 0
+ for ch in s:
+ c = cell_len(ch)
+ if w + c > n - 1:
+ break
+ out.append(ch); w += c
+ return "".join(out) + "…"
+
+
+def _cell_mid(s: str, n: int) -> str:
+ """Keep head+tail of `s` within n cells, eliding the middle with ….
+ (省略中间 — paths: the project root and the leaf both stay visible.)"""
+ from rich.cells import cell_len
+ if cell_len(s) <= n:
+ return s
+ if n <= 1:
+ return "…"
+ avail = n - 1 # reserve one cell for …
+ head_budget = avail - avail // 2 # head gets the rounding bias
+ tail_budget = avail // 2
+ head, w = [], 0
+ for ch in s:
+ c = cell_len(ch)
+ if w + c > head_budget:
+ break
+ head.append(ch); w += c
+ tail, w = [], 0
+ for ch in reversed(s):
+ c = cell_len(ch)
+ if w + c > tail_budget:
+ break
+ tail.append(ch); w += c
+ return "".join(head) + "…" + "".join(reversed(tail))
+
+
+class _CardWriter:
+ """Accumulates a tool card's two parallel streams: `ansi` (narrow, colored,
+ later margined) and `plain` (wide, the copy source — no margin). `row()`
+ takes (text, color) segments for simple uniform lines; `raw()` takes
+ pre-built ansi/plain for rows with gutters/backgrounds. `finish()` applies
+ the visual-only left margin to the ANSI stream ONLY (keeping it out of the
+ copy source) and joins both. Shared by all three tool cards so the _hrow /
+ margin boilerplate lives in one place."""
+ __slots__ = ("ansi", "plain")
+
+ def __init__(self):
+ self.ansi, self.plain = [], []
+
+ def row(self, *segs):
+ self.plain.append("".join(s for s, _ in segs))
+ self.ansi.append("".join(f"{_sgr_fg(c)}{s}" for s, c in segs) + _SGR_RESET)
+
+ def raw(self, ansi_line, plain_line):
+ self.ansi.append(ansi_line)
+ self.plain.append(plain_line)
+
+ def finish(self):
+ m = " " * _DIFF_MARGIN
+ return "\n".join(m + l for l in self.ansi), "\n".join(self.plain)
+
+
+def _card_status_row(cw, base_segs, err, detail, width):
+ """Emit a card's status-bearing line: `base_segs` followed by an inline red
+ `✗ detail` if it fits, else a bare `✗` with `detail` wrapped onto indented
+ red lines. `detail=''` with `err=True` yields a bare ✗. The single place
+ the three cards' failure-header layout lives."""
+ from rich.cells import cell_len
+ col = _diff_colors()
+ base_plain = "".join(s for s, _ in base_segs)
+ mark = (f" ✗ {detail}" if detail else " ✗") if err else ""
+ inline = (not detail) or cell_len(base_plain + mark) <= width
+ cw.row(*base_segs,
+ *([(mark if inline else " ✗", col["del_sign"])] if err else []))
+ if err and not inline and detail:
+ for chunk in _chop_cells(detail, max(8, width - 3)):
+ cw.row((" " + chunk, col["del_sign"]))
+
+
+def _card_note(cw, text):
+ """A card annotation line (truncation / omission notes), head color. One
+ style so every card's `… +N 行…` note reads alike."""
+ cw.row((text, _diff_colors()["head"]))
+
+
+def _emit_gutter(cw, content_w, segs, glyph_first, glyph_rest):
+ """Emit (text, color) segments behind a 2-col gutter: `glyph_first` on the
+ block's first physical row, `glyph_rest` after; soft-wraps each line, gutter
+ always dim. The code card's command (`│ `) and output (`└ `/` `) blocks
+ share it."""
+ col = _diff_colors()
+ first = True
+ for text, color in segs:
+ for chunk in _chop_cells(text, content_w):
+ g = glyph_first if first else glyph_rest
+ cw.raw(f"{_sgr_fg(col['gutter'])}{g}{_sgr_fg(color)}{chunk}{_SGR_RESET}",
+ f"{g}{chunk}".rstrip())
+ first = False
+
+
+def _diff_colors() -> dict:
+ """Derive diff colors from the live `_palette`. Backgrounds/foregrounds are
+ blended *from the theme's own bg/fg*, so the same code yields dark tints on
+ a dark theme and light tints on a light one — no explicit dark/light fork."""
+ p = _palette
+ bg = p.get("bg") or "#0d1117"
+ fg = p.get("fg") or "#c9d1d9"
+ # Fixed add/del hues so the diff semantics stay green/red on any theme
+ # (palette["green"] falls back to a theme's primary, which isn't always
+ # green). Backgrounds are still blended *from the theme bg*, so the tint
+ # auto-darkens on dark themes and lightens on light ones.
+ green = "#3fb950"
+ red = "#e5534b"
+ return {
+ "bg": bg,
+ "add_bg": _mix(bg, green, 0.18),
+ "del_bg": _mix(bg, red, 0.18),
+ "add_fg": _mix(fg, green, 0.50),
+ "del_fg": _mix(fg, red, 0.50),
+ "add_sign": green,
+ "del_sign": red,
+ "ctx_fg": _mix(bg, fg, 0.78),
+ "gutter": p.get("dim") or _mix(bg, fg, 0.35),
+ "head": p.get("muted") or _mix(bg, fg, 0.55),
+ }
+
+
+def _diff_hunks(old: str, new: str, context: int = _DIFF_CONTEXT) -> list[list[tuple]]:
+ """Group a diff into hunks of `(sign, line_no, text)` rows. `sign` is one of
+ '+'/'-'/' '; `line_no` is the new-file number for '+'/' ' and the old-file
+ number for '-'. Uses difflib's grouped opcodes so each hunk carries `context`
+ unchanged lines around the change, and gaps collapse into separate hunks."""
+ from difflib import SequenceMatcher
+ a, b = old.splitlines(), new.splitlines()
+ sm = SequenceMatcher(None, a, b, autojunk=False)
+ hunks: list[list[tuple]] = []
+ for group in sm.get_grouped_opcodes(context):
+ rows: list[tuple] = []
+ for tag, i1, i2, j1, j2 in group:
+ if tag == "equal":
+ for k in range(i2 - i1):
+ rows.append((" ", j1 + k + 1, b[j1 + k]))
+ else:
+ for k in range(i1, i2):
+ rows.append(("-", k + 1, a[k]))
+ for k in range(j1, j2):
+ rows.append(("+", k + 1, b[k]))
+ if rows:
+ hunks.append(rows)
+ return hunks
+
+
+def _insert_hunk(text: str, start_no: int = 1) -> list[list[tuple]]:
+ """A single all-'+' hunk for a brand-new / appended / prepended file."""
+ return [[("+", start_no + k, line) for k, line in enumerate(text.splitlines())]]
+
+
+def _render_write_diff(name: str, args: dict, existed: bool, old: str, width: int,
+ status: str = None, msg: str = ""):
+ """Render a file_write/file_patch call as a themed diff.
+
+ `existed`/`old` describe the target before the write (captured by the hook).
+ For file_patch, `old` (the pre-write full file) lets us render a whole-file
+ diff with real line numbers + surrounding context; without it we fall back to
+ diffing just the old/new fragment. `status` is the tool outcome
+ ('success'/'error'); success stays silent, error tags the header with a red ✗
+ (+msg) so the noisy `[Action]`/`{status}` lines can be dropped. Returns
+ `(ansi, plain)` parallel strings, or `None` to fall back."""
+ args = args or {}
+ old = old or ""
+ width = max(20, width - _DIFF_MARGIN) # body math sees the post-margin width
+ path = str(args.get("path") or "").strip()
+ if not path:
+ return None
+
+ display_mode = None
+ if name == "file_patch":
+ old_c = str(args.get("old_content") or "")
+ new_c = str(args.get("new_content") or "")
+ if not old_c and not new_c:
+ return None
+ # Whole-file diff (real line numbers + context around the change) when we
+ # have the pre-write full file and old_content matches exactly once;
+ # otherwise diff just the fragment (no file context available, e.g.
+ # /continue without a tracked full file).
+ if existed and old and old_c and old.count(old_c) == 1:
+ hunks = _diff_hunks(old, old.replace(old_c, new_c, 1))
+ else:
+ hunks = _diff_hunks(old_c, new_c)
+ elif name == "file_write":
+ new = args.get("content")
+ if new is None:
+ return None # content lives in the response body, not args — can't diff
+ new = str(new)
+ mode = str(args.get("mode") or "overwrite")
+ display_mode = mode
+ if mode in ("append", "prepend"):
+ if existed:
+ # Show append/prepend with the same surrounding unchanged context
+ # as file_patch by diffing the whole pre/post file, instead of an
+ # isolated all-green insertion block.
+ hunks = _diff_hunks(old, old + new if mode == "append" else new + old)
+ else:
+ hunks = _insert_hunk(new)
+ elif existed and old != new:
+ hunks = _diff_hunks(old, new)
+ elif existed:
+ return None # overwrite with identical content — nothing to diff
+ else:
+ hunks = _insert_hunk(new)
+ else:
+ return None
+
+ if not any(hunks):
+ return None
+
+ added = sum(1 for h in hunks for r in h if r[0] == "+")
+ removed = sum(1 for h in hunks for r in h if r[0] == "-")
+ max_no = max((r[1] for h in hunks for r in h), default=1)
+ lw = max(2, len(str(max_no)))
+ content_w = max(8, width - (lw + 3)) # " " prefix
+ col = _diff_colors()
+
+ from rich.cells import cell_len, set_cell_size
+
+ cw = _CardWriter()
+
+ # Header: compact tool-call line + tree child path/counts. Keep file_patch
+ # bare, but show the file_write mode as file_write(overwrite|append|prepend).
+ # Path is tail-truncated by display width so the header never wraps.
+ err = (status == "error")
+ msg = " ".join(str(msg or "").split())
+ tool_label = name if name == "file_patch" else f"{name}({display_mode})"
+ add_seg, del_seg = f" +{added}", f" -{removed}"
+ child_prefix = "└─ "
+ avail = width - cell_len(child_prefix + add_seg + del_seg) - (cell_len(" ✗") if err else 0)
+ shown = _cell_tail(path, max(4, avail))
+ cw.row((tool_label, col["head"]))
+ _card_status_row(cw, [(child_prefix + shown, col["head"]),
+ (add_seg, col["add_sign"]), (del_seg, col["del_sign"])],
+ err, msg, width)
+
+ def emit(sign: str, no, text: str):
+ text = (text or "").replace("\t", " ").replace("\x1b", "")
+ no_str = (str(no) if no is not None else "").rjust(lw)
+ if sign == "+":
+ row_bg, sign_c, text_c = col["add_bg"], col["add_sign"], col["add_fg"]
+ elif sign == "-":
+ row_bg, sign_c, text_c = col["del_bg"], col["del_sign"], col["del_fg"]
+ else:
+ row_bg, sign_c, text_c = None, col["gutter"], col["ctx_fg"]
+ bg = _sgr_bg(row_bg) if row_bg else ""
+ # Active soft-wrap: split long lines into width-sized physical rows so
+ # each row carries its own full-width background (Textual's own wrap
+ # leaves continuation rows unpainted). Continuation rows get a blank
+ # gutter so content stays aligned under the first row.
+ for ci, chunk in enumerate(_chop_cells(text, content_w)):
+ body = set_cell_size(chunk, content_w) # pad to exactly content_w cells
+ if ci == 0:
+ gutter = f"{_sgr_fg(col['gutter'])}{no_str} {_sgr_fg(sign_c)}{sign} "
+ cw.raw(f"{bg}{gutter}{_sgr_fg(text_c)}{body}{_SGR_RESET}",
+ f"{no_str} {sign} {chunk}")
+ else:
+ gutter = " " * (lw + 3)
+ cw.raw(f"{bg}{gutter}{_sgr_fg(text_c)}{body}{_SGR_RESET}",
+ f"{' ' * (lw + 3)}{chunk}")
+
+ rows_emitted = 0
+ truncated = 0
+ for hi, hunk in enumerate(hunks):
+ if hi > 0:
+ cw.raw(f"{_sgr_fg(col['gutter'])}{'⋯'.rjust(lw + 2)}{_SGR_RESET}",
+ "⋯".rjust(lw + 2))
+ for sign, no, text in hunk:
+ if rows_emitted >= _DIFF_MAX_ROWS:
+ truncated += 1
+ continue
+ emit(sign, no, text)
+ rows_emitted += 1
+ if truncated:
+ _card_note(cw, f"… +{truncated} 行未显示")
+
+ return cw.finish()
+
+
+_READ_MAX_ROWS = 10
+
+# Lines of a file_read result that exist for the LLM, not the user: the
+# show_linenos preamble, the `[FILE] N lines | PARTIAL …` header, truncation
+# tips, and the keyword-fallback explanation. All stripped from the card.
+_READ_NOISE_RE = re.compile(
+ r"^(?:由于设置了show_linenos|\[FILE[\] ]|\[FILE PARTIAL|(某些行被截断"
+ r"|Keyword '.*' not found after line )"
+)
+
+
+def _render_read_card(args, content, width):
+ """Render a file_read call like the write cards: header + the lines the
+ tool actually returned, LLM-facing chrome stripped (see _READ_NOISE_RE).
+ The gutter always shows line numbers when they're knowable: parsed from the
+ `N|` prefixes (show_linenos), else synthesized from `start` for sequential
+ reads — numbers and content are decoupled; the only number the renderer
+ can't derive is a keyword window's position without `N|`. smart_format's
+ `[omitted long content]` hole renders as a `⋯` row and ends the gutter for
+ the rest of the card (parsed and synthesized alike — the cut is mid-line,
+ so post-hole numbers mislead).
+ Body capped at _READ_MAX_ROWS with a tail count. `Error:` results render
+ header-✗ only — the Did-you-mean suggestions are for the agent's
+ self-correction, not the user. Returns (ansi, plain) or None."""
+ args = args or {}
+ width = max(20, width - _DIFF_MARGIN)
+ path = str(args.get("path") or "").strip()
+ if content is None or not path:
+ return None
+ from rich.cells import cell_len
+ col = _diff_colors()
+ text = str(content)
+ err = text.startswith("Error:")
+
+ rows: list[tuple] = [] # (lineno_or_None, text)
+ linenos = bool(args.get("show_linenos", True))
+ if not err:
+ # Strip LLM-facing chrome, then trim blank runs at both ends
+ # (separators around the stripped noise) BEFORE numbering; interior
+ # blanks are real file content and stay.
+ lines = [ln for ln in text.split("\n") if not _READ_NOISE_RE.match(ln.strip())]
+ while lines and not lines[0].strip():
+ lines.pop(0)
+ while lines and not lines[-1].strip():
+ lines.pop()
+ # Without `N|` prefixes, synthesize the gutter: a sequential read's
+ # lines are start, start+1, … Stay blank where numbers would be
+ # guesses: a keyword window's position is unknown (unless the keyword
+ # fell back to a sequential read).
+ fell_back = "not found after line" in text.split("\n", 1)[0]
+ synth = (not linenos) and (not args.get("keyword") or fell_back)
+ no = int(args.get("start") or 1) if synth else None
+ holed = False
+ for ln in lines:
+ if ln.strip() == "[omitted long content]":
+ rows.append((None, "⋯"))
+ no, holed = None, True # gutter ends at the hole: the cut is
+ continue # mid-line, post-hole numbers mislead
+ m = re.match(r"^(\d+)\|(.*)$", ln) if linenos else None
+ if m:
+ rows.append((None if holed else int(m.group(1)), m.group(2)))
+ else:
+ rows.append((no, ln))
+ if no is not None:
+ no += 1
+ if not rows:
+ return None
+
+ msg = " ".join(text.split("\n")[0][len("Error:"):].split()) if err else ""
+ child_prefix = "└─ "
+ avail = width - cell_len(child_prefix) - (cell_len(" ✗") if err else 0)
+ shown = _cell_tail(path, max(4, avail))
+
+ cw = _CardWriter()
+ cw.row(("file_read", col["head"]))
+ _card_status_row(cw, [(child_prefix + shown, col["head"])], err, msg, width)
+
+ shown_rows = rows[:_READ_MAX_ROWS]
+ hidden = len(rows) - len(shown_rows)
+ max_no = max((no for no, _ in shown_rows if no), default=0)
+ lw = max(2, len(str(max_no or 1)))
+ content_w = max(8, width - (lw + 3))
+ for no, body in shown_rows:
+ body = (body or "").replace("\t", " ").replace("\x1b", "")
+ no_str = (str(no) if no is not None else "").rjust(lw)
+ for ci, chunk in enumerate(_chop_cells(body, content_w)):
+ gutter = f"{no_str} " if ci == 0 else " " * (lw + 3)
+ cw.raw(f"{_sgr_fg(col['gutter'])}{gutter}"
+ f"{_sgr_fg(col['ctx_fg'])}{chunk}{_SGR_RESET}",
+ f"{gutter}{chunk}".rstrip())
+ if hidden > 0:
+ _card_note(cw, f"… +{hidden} 行未显示")
+
+ return cw.finish()
+
+
+_CODE_MAX_CODE_ROWS = 20 # 代码段从头展示
+_CODE_MAX_OUT_ROWS = 15 # 输出段保尾展示(报错 traceback 关键在末尾)
+_CODE_DEFAULT_CWD = {"", ".", "./", ".\\"}
+
+
+def _code_parse_data(data):
+ """把 code_run 的 StepOutcome.data 归一成 (out_text, exit_code, err_msg, is_err)。
+ 兼容三种返回形态:
+ - 正常/进程异常 dict: {"status","stdout","exit_code"} 或 {"status":"error","msg"}
+ - inline_eval 字符串: eval 的 repr,或 'Error: ...'
+ - 代码缺失字符串: '[Error] Code missing ...'
+ out_text 只取真正的输出(dict 的 stdout / 字符串结果),绝不回退 msg——msg
+ 是 dict 进程异常的摘要,单独走 err_msg 进头部,避免和输出段重复显示。
+ is_err 标记失败但无独立 detail 的情形(字符串错误本身就是输出),让头部至少
+ 打个裸 ✗。out_text 已规整 CRLF→LF。"""
+ exit_code, err_msg, is_err = None, "", False
+ if isinstance(data, dict):
+ out_text = data.get("stdout") or ""
+ exit_code = data.get("exit_code")
+ if data.get("status") == "error":
+ is_err = True
+ err_msg = str(data.get("msg") or "")
+ elif isinstance(data, str):
+ out_text = data
+ if data.lstrip().startswith(("Error:", "[Error]")):
+ is_err = True # 内容即错误,留给输出段;头部裸 ✗
+ else:
+ out_text = "" if data is None else str(data)
+ out_text = str(out_text).replace("\r\n", "\n").replace("\r", "\n")
+ return out_text, exit_code, err_msg, is_err
+
+
+def _render_code_card(args, data, width):
+ """CC/Codex-style gutter card for code_run — no borders, structure comes
+ from dim gutters (Codex's exec cell look, adapted to our card family):
+
+ code_run(python) ← header; red `✗ Exit 1 …` on failure
+ │ import os ← command: `│ ` gutter (plain text)
+ │ print(run())
+ └ epoch 1 loss 0.3 ← output: `└ ` + dim text, continuation
+ … +12 行已省略 indented 2; TAIL-kept (traceback ends
+ ValueError: boom matter most — differs from CC/Codex)
+
+ The command segment renders only when the code rode in via `script`/`code`
+ args; a body ```block``` source is already rendered above by markdown.
+ Returns (ansi, plain); tolerant of every data shape (_code_parse_data)."""
+ args = args or {}
+ width = max(20, width - _DIFF_MARGIN)
+ col = _diff_colors()
+ ctype = str(args.get("type") or "python").strip() or "python"
+ code = str(args.get("script") or args.get("code") or "")
+ out_text, exit_code, err_msg, is_err = _code_parse_data(data)
+
+ # Timeout / manual-stop markers live inside stdout (ga.py:72-73).
+ note_extra = ("超时" if "[Timeout Error]" in out_text
+ else "已停止" if "[Stopped]" in out_text else "")
+ # Header failure detail. exit_code may be None (inline_eval has none, a
+ # killed process polls None) — only a non-zero *integer* counts. err_msg is
+ # the dict-error summary; a string-error's text stays in the output body, so
+ # it sets is_err (bare ✗) but contributes no detail (avoids duplication).
+ parts = []
+ if isinstance(exit_code, int) and exit_code != 0:
+ parts.append(f"Exit {exit_code}")
+ if note_extra:
+ parts.append(note_extra)
+ if err_msg:
+ m = err_msg
+ for p in ("Error:", "[Error]"):
+ if m.startswith(p):
+ m = m[len(p):].strip()
+ if m:
+ parts.append(m)
+ err = is_err or bool(parts)
+ detail = " ".join(" · ".join(parts).split())
+
+ cw = _CardWriter()
+ _card_status_row(cw, [(f"code_run({ctype})", col["head"])], err, detail, width)
+ cwd = str(args.get("cwd") or "").strip()
+ if cwd and cwd not in _CODE_DEFAULT_CWD:
+ for chunk in _chop_cells(f"cwd: {cwd}", max(8, width)):
+ cw.row((chunk, col["gutter"]))
+
+ content_w = max(8, width - 2) # behind the 2-col gutter
+
+ # Command segment: `│ ` gutter on every physical line (plain text). Only
+ # when the code rode in via script/code args (a body ```block``` source is
+ # already rendered above by markdown).
+ code_lines = code.split("\n") if code.strip() else []
+ if code_lines:
+ shown = [l.replace("\t", " ").replace("\x1b", "")
+ for l in code_lines[:_CODE_MAX_CODE_ROWS]]
+ segs = [(l, col["ctx_fg"]) for l in shown]
+ extra = len(code_lines) - len(shown)
+ if extra > 0:
+ segs.append((f"… +{extra} 行未显示", col["head"]))
+ _emit_gutter(cw, content_w, segs, "│ ", "│ ")
+
+ # Output segment: `└ ` first row, ` ` after, dim. Tail-kept — the omission
+ # note leads, the tail lines follow (a traceback's last lines matter most).
+ out_lines = [l.replace("\t", " ").replace("\x1b", "")
+ for l in out_text.split("\n")]
+ while out_lines and not out_lines[-1].strip():
+ out_lines.pop()
+ segs = []
+ omitted = len(out_lines) - _CODE_MAX_OUT_ROWS
+ if omitted > 0:
+ segs.append((f"… +{omitted} 行已省略", col["head"]))
+ out_lines = out_lines[-_CODE_MAX_OUT_ROWS:]
+ if not out_lines and not segs:
+ segs.append(("(无输出)", col["gutter"]))
+ segs += [(l, col["ctx_fg"]) for l in out_lines]
+ _emit_gutter(cw, content_w, segs, "└ ", " ")
+
+ return cw.finish()
+
+
def _extract_user_text(entry: dict) -> str:
c = entry.get("content") if isinstance(entry, dict) else None
if isinstance(c, str):
@@ -546,8 +1173,30 @@ def _md_line_has_box_drawing(line: str) -> bool:
newlines. Use the Unicode Box Drawing block so SIMPLE/ROUNDED/HEAVY/etc.
table styles are covered while em-dashes (`—`) and ASCII/Unicode hyphens are
not mistaken for tables.
+
+ A leading `\u2514\u2500 ` (with the space) is the write-diff card's child-path line,
+ not a table border \u2014 borders run the glyphs together (`\u2514\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2518`). Same
+ for the code card's `\u2502 ` / `\u2514 ` gutters, and a gutter-only row (`\u2502` / `\u2514`
+ left after rstrip trims the trailing space of an empty command/output line).
+ Exempt all of them so the cards keep their exact narrow\u2194wide line mapping
+ instead of the visible-text passthrough (which would copy the card's
+ visual-only margin).
+
+ A pure horizontal run (only `─` + spaces) is NOT exempt: a SIMPLE-box
+ Markdown table's only box glyph is its header rule, so exempting bare
+ `─` rows drops the whole table out of passthrough and misaligns CJK
+ copy. The cosmetic cost is that a real markdown hr copies as a dash run
+ — the pre-card behavior, never reported as a problem.
"""
- return any("\u2500" <= ch <= "\u257f" for ch in line)
+ s = line.lstrip()
+ for pfx in ("\u2514\u2500 ", "\u2502 ", "\u2514 "):
+ if s.startswith(pfx):
+ s = s[len(pfx):]
+ break
+ else:
+ if s in ("\u2502", "\u2514"): # gutter-only row (empty command/output line)
+ s = ""
+ return any("\u2500" <= ch <= "\u257f" for ch in s)
def _md_run_has_box_drawing(lines: list[str]) -> bool:
@@ -640,7 +1289,13 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
if j > g_start - run_start:
content, _ = _strip_quote_deco(nt.lstrip())
else:
- content = nt
+ # First line of the group: drop any visual-only left
+ # margin (narrow lead beyond the wide line's own lead —
+ # margined tool blocks) so the accumulation against the
+ # wide target stays balanced.
+ n_lead = len(nt) - len(nt.lstrip())
+ w_lead = len(w_line) - len(w_line.lstrip())
+ content = nt[max(0, n_lead - w_lead):]
accumulated += len(content)
j += 1
# Each wrap boundary eats one space from the wide line, so
@@ -725,8 +1380,14 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
for k in range(g_start, g_end):
nt = narrow[k]
if k == g_start:
- content = nt
- indent = 0
+ # A narrow line with MORE lead than its wide twin is showing
+ # a visual-only left margin (write-diff cards / margined tool
+ # blocks). Map it as indent so selection x-coords shift and
+ # copies exclude it; drop it from `content` so the pointer
+ # accounting over the wide line stays balanced.
+ nt_lead = len(nt) - len(nt.lstrip())
+ indent = max(0, nt_lead - wide_lead)
+ content = nt[indent:]
else:
indent = len(nt) - len(nt.lstrip())
content = nt.lstrip()
@@ -743,6 +1404,16 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
return "".join(source_parts).rstrip("\n"), line_starts, line_indents, line_lengths
+# ---------------------------------------------------------------------------
+# @ 文件引用(at-mention)— 补全版(completion-only)
+# 编辑期:光标处 @token → 后台文件索引 + 模糊匹配 → 复用 #palette 下拉,
+# 选中把 @路径 补进输入框(索引根 = 会话 workspace,未绑定退化为 CWD)。
+# 提交期:不处理,@路径 作为普通文本发给 agent,由其自行决定是否 file_read。
+# 纯逻辑(索引/模糊/token)抽到 frontends/at_complete.py,与 tui_v3 共用;
+# 自动预读那一版见 temp/plan_v2_at_mention/autoread_version.py。
+from at_complete import get_index, fuzzy_rank, find_at_token, format_pick, candidates_for, absolutize_mentions
+
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
@@ -779,7 +1450,15 @@ from chatapp_common import format_restore
from btw_cmd import handle_frontend_command as btw_handle
from review_cmd import handle as review_handle
from continue_cmd import list_sessions as continue_list, extract_ui_messages as continue_extract
+import workspace_cmd
from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard
+from worldline import (
+ RewindStore, restore_plan,
+ ellipsize, rel_time, files_summary, kind_glyph, kind_label,
+ CheckpointTree, tree_from_store, CompressedTree,
+ _order_depths, next_same_depth, nearest_depth_node, parent_sibling_first_child,
+)
+# RewindTreeScreen 等三栏树 UI 已内联到本文件末尾(原 rewind_tree_view.py),跟随 v2 主题配色。
# Cross-platform clipboard copy for /export clip. Mirrors tui_v3's native-tool
# strategy but stays local to v2 so the Textual frontend has no dependency on
@@ -1090,15 +1769,37 @@ Screen { background: $ga-bg; color: $ga-fg; }
`display: none` default so the empty post-compose frame doesn't flash;
renderer flips it on once items materialize. Fixed height (no scroll)
keeps layout stable; body truncates to 4 items + "+N more" footer. */
-#planbar {
+/* Plan card. Outer #planbar-scroll owns the frame (border/padding) + show-hide.
+ #planbar-head pins the header + current-step line. #planbar-tasks is the only
+ scrolling region: capped at 4 rows so at most 4 TODO items show at once, the
+ rest reachable by wheel/PageUp. */
+#planbar-scroll {
display: none;
- height: 5;
- max-height: 5;
+ height: auto;
background: $ga-sel-bg;
padding: 0 1;
margin: 0 0 1 0;
border-left: thick $ga-green;
}
+#planbar-scroll.-visible { display: block; }
+#planbar-head {
+ height: auto;
+ background: $ga-sel-bg;
+}
+#planbar-tasks {
+ height: auto;
+ max-height: 4;
+ background: $ga-sel-bg;
+ scrollbar-size: 0 1;
+ scrollbar-background: $ga-sel-bg;
+ scrollbar-background-hover: $ga-sel-bg;
+ scrollbar-background-active: $ga-sel-bg;
+ scrollbar-color: $ga-border;
+}
+#planbar {
+ height: auto;
+ background: $ga-sel-bg;
+}
/* `└ Tip:` footer — one dim row, never grows. */
#tipbar {
@@ -1251,6 +1952,16 @@ class ChatMessage:
searchable: bool = False
search_query: str = ""
all_choices: Optional[list] = field(default=None, repr=False)
+ # Free-input opt-in for searchable pickers (/model): when True, Enter in
+ # the search box with NO selectable match commits the raw query text as
+ # the value — the filter box doubles as a custom-name input. /continue
+ # keeps the default False, so a no-match Enter stays a no-op there.
+ free_input: bool = False
+ # Optional placeholder for the search Input ("" → the /continue default).
+ search_placeholder: str = ""
+ # Hint row shown (disabled) under the search Input when choices is empty —
+ # doubles as a "loading…" indicator for pickers filled asynchronously (/model).
+ empty_hint: str = "(no matches)"
image_paths: list[str] = field(default_factory=list)
_role_widget: Any = field(default=None, repr=False)
_hint_widget: Any = field(default=None, repr=False)
@@ -1295,6 +2006,10 @@ class AgentSession:
input_pastes: dict[int, str] = field(default_factory=dict)
input_paste_counter: int = 0
buffer: str = ""
+ # Per-session workspace/project-mode binding. Empty means ordinary mode.
+ workspace_name: str = ""
+ workspace_path: str = ""
+ workspace_link: str = ""
# Drives topbar heat-color ramp + elapsed label; set on first running tick.
_busy_since: Optional[float] = None
# Stamps running→idle; topbar dot flashes green for ~5s after.
@@ -1308,9 +2023,14 @@ class AgentSession:
plan_items: list = field(default_factory=list)
plan_complete_since: Optional[float] = None
plan_lost_since: Optional[float] = None
- # Boundary between restored history (≤ idx) and this run (> idx);
- # `/continue` bumps to `len(messages)` so old plan cards don't resurrect.
+ # Boundary between restored history (≤ idx) and this run (> idx); only
+ # `current_step`'s 📌-line scan uses it now — card activation no longer
+ # reads messages.
plan_scan_baseline: int = 0
+ # plan.md recovered from the transcript's structured `enter_plan_mode`
+ # tool_use by /continue (continue_cmd.find_plan_entry). Drives card
+ # activation alongside the live `working['in_plan_mode']` stash.
+ restored_plan_path: str = ""
# `pending`: raw user text for UI display ([queued #N] chip).
# `pending_wrapped`: same entries wrapped with the "complete current
# task first" supplementary phrasing, in the form actually appended
@@ -1323,8 +2043,10 @@ class AgentSession:
def default_agent_factory() -> Any:
from agentmain import GenericAgent
+ from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT
agent = GenericAgent()
agent.inc_out = True
+ agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT)
return agent
@@ -1338,10 +2060,13 @@ COMMANDS = [
("/close", "", "关闭当前会话"),
("/rename", "", "重命名当前会话(持久化)"),
("/branch", "[name]", "从当前会话分支"),
- ("/rewind", "[n]", "回退最近 n 轮"),
+ ("/rewind", "[n]", "回退面板(时间线)/ 直接回退 n 轮"),
+ ("/worldline", "", "世界线 — checkpoint 树状回退(打开即选中当前节点)"),
("/clear", "", "清空显示(不动 LLM 历史)"),
("/stop", "", "中止当前任务"),
("/llm", "[n]", "查看 / 切换模型"),
+ ("/model", "[name]", "查看 / 设置当前渠道的 model(列表在线拉取)"),
+ ("/effort", "[level]", "查看 / 设置 reasoning effort(off 清除;Claude xhigh→max)"),
("/btw", "", "side question — 不打断主 agent"),
("/review", "[request]", "in-session 代码审查(直接输出报告)"),
# ── slash_cmds bundle (prompt-injection + /scheduler picker). Kept in
@@ -1354,6 +2079,7 @@ COMMANDS = [
("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"),
("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"),
("/continue", "[n|name]", "列出 / 恢复历史会话"),
+ ("/workspace","[path|off]", "设定工作目录(绝对路径)并进入项目模式"),
("/resume", "", "列出最近会话并恢复其中一个"),
("/cost", "[all]", "显示当前会话 token 用量(all = 所有会话)"),
("/export", "clip||all", "导出最后回复"),
@@ -1385,9 +2111,25 @@ class ChoiceList(OptionList):
Binding("escape", "cancel", "Cancel", show=False)]
def __init__(self, msg: "ChatMessage", *options, **kwargs):
- super().__init__(*options, **kwargs)
+ super().__init__(*[self._single_line(o) for o in options], **kwargs)
self.msg = msg
+ @staticmethod
+ def _single_line(item):
+ # A str prompt → a no-wrap, ellipsis-on-overflow Option so a long
+ # candidate (e.g. a workspace `name · /very/long/path · …`) stays on
+ # exactly one row instead of soft-wrapping into several. Already-built
+ # Option / None pass through untouched.
+ if isinstance(item, str):
+ return Option(Text(item, no_wrap=True, overflow="ellipsis"))
+ return item
+
+ def add_option(self, option=None):
+ return super().add_option(self._single_line(option))
+
+ def add_options(self, items):
+ return super().add_options([self._single_line(i) for i in items])
+
def action_cancel(self) -> None:
try:
self.app._cancel_choice(self.msg)
@@ -1453,7 +2195,7 @@ class LazyChoiceList(ChoiceList):
take = (len(self._lazy_labels) - self._lazy_loaded) if count is None else max(1, int(count))
end = min(len(self._lazy_labels), self._lazy_loaded + take)
try:
- self.add_options([Option(self._lazy_labels[i]) for i in range(self._lazy_loaded, end)])
+ self.add_options([self._lazy_labels[i] for i in range(self._lazy_loaded, end)])
except Exception:
# If the list isn't mounted yet (very early call), fall back to
# buffering via _options if available; otherwise silently bail so
@@ -1515,7 +2257,10 @@ def _filter_choices(all_choices: list, query: str) -> list:
`all_choices` is `[(label, value), ...]`. Each whitespace-separated token
in `query` must hit somewhere in either:
* the label text (cheap, always tried first), or
- * the basename of `value` when it looks like a path, or
+ * the **full** `value` when it's a string (e.g. a workspace's complete
+ real path — so a mid-path directory still matches even though the
+ displayed label elides the middle; display-layer truncation must not
+ shrink the searchable data), or
* the **content** of the session file at `value` (first ~1MB), so users
who remember a phrase from inside a session ("Conductor", "subB diff",
a file path they pasted) can find it back.
@@ -1549,7 +2294,10 @@ def _filter_choices(all_choices: list, query: str) -> list:
continue
meta = str(label).lower()
if isinstance(value, str) and value:
- meta = meta + "\n" + os.path.basename(value).lower()
+ # Full value, not just basename: workspace pickers put the complete
+ # real path here, and the displayed label elides the middle — search
+ # must see the whole path so a mid-path term still matches.
+ meta = meta + "\n" + value.lower()
if all(t in meta for t in terms):
out.append(item)
continue
@@ -1595,7 +2343,8 @@ class SearchableChoiceList(Vertical):
def compose(self):
self._search_input = Input(
value=self.msg.search_query or "",
- placeholder="Search sessions: type to filter, Esc to cancel",
+ placeholder=(self.msg.search_placeholder
+ or "Search sessions: type to filter, Esc to cancel"),
id="continue-search",
)
yield self._search_input
@@ -1715,7 +2464,7 @@ class SearchableChoiceList(Vertical):
if not filtered:
# Show a disabled hint row so Enter on an empty result set is a
# no-op rather than a crash inside _collapse_choice.
- empty = ChoiceList(self.msg, "(no matches)", classes="picker")
+ empty = ChoiceList(self.msg, self.msg.empty_hint or "(no matches)", classes="picker")
try:
empty.disabled = True
except Exception:
@@ -1792,10 +2541,41 @@ class SearchableChoiceList(Vertical):
if not at_end:
return
if key in ("enter", "right"):
+ # Don't go through picker.action_select() here: the OptionSelected
+ # it posts is constructed while *this* widget's pump is active, so
+ # its _sender == picker's parent and Textual auto-stops the bubble
+ # one hop up — the App handler never sees it. Collapse directly.
+ committed = False
try:
- self.picker.action_select()
+ hi = self.picker.highlighted
+ opts = getattr(self.picker, "_options", [])
+ # Eligibility must be checked here, not inferred from
+ # _collapse_choice "not raising": it silently no-ops on an
+ # out-of-range idx (the disabled empty-hint picker highlights
+ # its placeholder row at 0 while msg.choices is []).
+ if (hi is not None and 0 <= hi < len(self.picker.msg.choices)
+ and hi < len(opts) and not opts[hi].disabled):
+ self.app._collapse_choice(self.picker.msg, hi)
+ committed = True
except Exception:
pass
+ # Free-input pickers (/model): Enter with no selectable match
+ # commits the raw query as the value — the search box doubles as
+ # a custom-name input (list still loading / fetch failed / name
+ # not in the list).
+ if (not committed and key == "enter"
+ and getattr(self.msg, "free_input", False)):
+ q = ""
+ try:
+ q = (self._search_input.value or "").strip()
+ except Exception:
+ pass
+ if q:
+ try:
+ self.msg.choices = list(self.msg.choices or []) + [(q, q)]
+ self.app._collapse_choice(self.msg, len(self.msg.choices) - 1)
+ except Exception:
+ pass
event.stop(); event.prevent_default()
return
@@ -2032,16 +2812,17 @@ class InputArea(TextArea):
except Exception: pass
def _stash_cleanup_restore(self, stashed: str) -> None:
- """Deferred companion to action_stash (restore path)."""
+ """Deferred companion to action_stash (restore path). Mirrors the
+ clear path: `self.text = stashed` rebuilds Document + WrappedDocument
+ and triggers a full re-wrap + screen-wide relayout, which freezes the
+ UI for seconds on long sessions. Inject through the edit pipeline
+ instead so only the affected range re-wraps; `_insert_via_keyboard`
+ also moves the caret to the end, re-focuses, and resizes."""
try: self._suppress_palette_next_change()
except Exception: pass
- self.text = stashed
- try:
- self.cursor_location = self.document.end
- except Exception:
- pass
- try: self.app._resize_input(self)
- except Exception: pass
+ if self.document.text:
+ self.clear()
+ self._insert_via_keyboard(stashed)
def action_clear_input(self) -> None:
self.reset()
@@ -2088,8 +2869,25 @@ class InputArea(TextArea):
text = f"[Pasted text #{sid} +{line_count} lines]"
self._insert_via_keyboard(text)
+ def _paste_gesture_echo(self, source: str) -> bool:
+ """One VSCode right-click can emit BOTH a forwarded mouse-click
+ (→ action_paste, source='manual') and a native bracketed paste
+ (→ _on_paste, source='bracketed'), pasting the clipboard twice. Treat the
+ second arrival from the *other* mechanism within a short window as an echo
+ and report it so the caller can skip. Same-mechanism repeats (a deliberate
+ double Ctrl+V) and lone gestures are never suppressed."""
+ now = time.monotonic()
+ prev = self._last_paste_gesture
+ if prev and prev[0] != source and now - prev[1] < 0.5:
+ self._last_paste_gesture = None # pair consumed; next gesture starts clean
+ return True
+ self._last_paste_gesture = (source, now)
+ return False
+
def action_paste(self) -> None:
- if self.read_only or self._paste_file_from_clipboard():
+ if self.read_only or self._paste_gesture_echo("manual"):
+ return
+ if self._paste_file_from_clipboard():
return
text = _read_clipboard_text() or getattr(self.app, "clipboard", "")
if text:
@@ -2160,6 +2958,7 @@ class InputArea(TextArea):
super().__init__(*args, **kwargs)
self._pastes: dict[int, str] = {}
self._paste_counter = 0
+ self._last_paste_gesture: Optional[tuple[str, float]] = None # (source, monotonic) — VSCode right-click double-paste guard
self._input_history: list[str] = []
self._history_index: int = -1 # -1 means not browsing
self._history_stash: str = ""
@@ -2258,16 +3057,20 @@ class InputArea(TextArea):
# Terminal Ctrl+V in bracketed-paste mode lands here, bypassing action_paste.
if self.read_only:
return
+ event.stop(); event.prevent_default()
+ # VSCode right-click fires this Paste AND a forwarded mouse-click
+ # (→ _on_click → action_paste); collapse the duplicate. See _paste_gesture_echo.
+ if self._paste_gesture_echo("bracketed"):
+ return
if self._paste_file_from_clipboard():
- event.stop(); event.prevent_default(); return
+ return
# Git-bash / mintty fallback: PIL.ImageGrab can't return Image objects
# in that TTY env, but the OS clipboard does hold the file path the
# screenshot tool wrote. Treat a single-line, on-disk path as if the
# file grab had succeeded — same placeholder + `_pastes` entry.
if self._paste_file_from_text(event.text):
- event.stop(); event.prevent_default(); return
+ return
self._insert_paste_text(event.text)
- event.stop(); event.prevent_default()
def _paste_file_from_text(self, raw: str) -> bool:
if not raw: return False
@@ -2411,7 +3214,8 @@ def render_status_chip(busy: bool, elapsed: int = 0) -> Text:
def render_topbar(session_name: str, status: str, model: str, tasks_running: int,
fold_mode: bool = True, busy_elapsed: int = 0,
effort: str = "", sess_elapsed: int = 0,
- just_done: bool = False, term_width: int = 0) -> Table:
+ just_done: bool = False, term_width: int = 0,
+ workspace: str = "") -> Table:
# Layout: identity-chip + session + status + fold packed LEFT; model + effort
# + tasks CENTERED; clock RIGHT. The 2:2:1 ratio keeps the centered model
# chip visually anchored even when the left column has the long status pill.
@@ -2464,6 +3268,13 @@ def render_topbar(session_name: str, status: str, model: str, tasks_running: int
# narrow `▾ fold` glyph from being eaten by the left's ellipsis when the
# running status pill fills the column budget.
right = Text()
+ # workspace chip (top-right) — only when active. Clean real-dir basename,
+ # never the hashed junction name, so the hash never reaches the user.
+ if workspace:
+ short_ws = workspace if len(workspace) <= 18 else workspace[:17] + "…"
+ right.append("workspace: ", style=C_MUTED)
+ right.append(short_ws, style=f"bold {C_GREEN}")
+ right.append(" · ", style=C_DIM)
if fold_mode:
right.append("▾ fold", style=C_DIM)
right.append(" · ", style=C_DIM)
@@ -2692,6 +3503,282 @@ class ThemePicker(ModalScreen):
self.dismiss()
+# ===========================================================================
+# /rewind 面板 — 会话 checkpoint 的时间线 / 树状可视化 + 对话恢复
+#
+# GA 没有真正的 rewind 后端(checkpoint 树是从 LLM history 现算的),所以这里的
+# 「节点」= 用户提问边界(与既有 /rewind 的 _rewindable_turns 同源),每个节点的
+# `n` 直接喂给 _do_rewind(n)。「恢复对话」= 把 backend.history 截断回该提问之前
+# (复用既有 _do_rewind,仅在 agent 空闲时允许);restore code / fork / diff /
+# delete 需要文件快照后端,本版为占位(详见需求文档 §9 的「暂缓」决策)。
+# ===========================================================================
+
+# tui_v2 主题色(C_FG/C_DIM/C_GREEN/C_BLUE 随主题刷新,这里直接复用模块全局);
+# 补几个 v2 未定义的固定色。
+C_ALT_BG = "#21262d"
+C_AMBER = "#f0883e"
+# 世界线树(/worldline)配色:除 diff 删除红是固定语义色外,其余跟随 v2 主题(_palette)。
+C_BG = _palette["bg"]
+C_BORDER = _palette["border"]
+C_CYAN = C_BLUE # 聚焦高亮 → 跟主题蓝
+C_LAVENDER = C_PURPLE # lane 配色 → 跟主题紫
+C_RED = "#e5534b" # diff 删除行(固定语义色)
+
+
+def _rw_rel_time(ago_s):
+ """粗粒度相对时间(不精确到秒);ago_s 为 None 时返回空串。"""
+ if ago_s is None:
+ return ""
+ if ago_s < 60:
+ return "刚刚"
+ m = ago_s // 60
+ if m < 60:
+ return f"{m} 分钟前"
+ h = m // 60
+ if h < 24:
+ return f"{h} 小时前"
+ return f"{h // 24} 天前"
+
+
+def _rw_files_summary(files):
+ if not files:
+ return ""
+ if len(files) <= 3:
+ return "、".join(files)
+ return "、".join(files[:3]) + f" (+{len(files) - 3})"
+
+
+def _rw_user_text(content):
+ """从一条 history 消息的 content 里取用户可见文本。"""
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ for b in content:
+ if isinstance(b, dict) and b.get("type") == "text" and b.get("text"):
+ return b["text"]
+ return ""
+
+
+def _rw_files_in_range(history, lo, hi):
+ """该提问段内经 file_write / file_patch 改动的文件名(去重,顺序保留)。
+ 与「code_run 暂不追踪」的后端决策一致——只认结构化文件工具的 path。"""
+ seen = []
+ for m in history[lo:hi]:
+ if m.get("role") != "assistant":
+ continue
+ c = m.get("content")
+ if not isinstance(c, list):
+ continue
+ for b in c:
+ if (isinstance(b, dict) and b.get("type") == "tool_use"
+ and b.get("name") in ("file_write", "file_patch")):
+ p = (b.get("input") or {}).get("path")
+ if p:
+ base = os.path.basename(str(p))
+ if base not in seen:
+ seen.append(base)
+ return seen
+
+
+def _rw_collect(sess):
+ """从一个 session 构造 rewind 节点(oldest→newest),末尾追加一个合成的
+ HEAD「当前位置」节点(n=None → 恢复是 no-op)。
+
+ 节点 = 真实用户提问边界(与 _rewindable_turns 同源),故节点的 `n` 可直接
+ 传给 _do_rewind(n)。时间戳来自 _install_rw_time_hook 按 history 长度打的点;
+ 没有记录(如 /continue 恢复来的旧会话)则相对时间为空。"""
+ history = sess.agent.llmclient.backend.history
+ bounds = []
+ for i, m in enumerate(history):
+ if m.get("role") != "user":
+ continue
+ c = m.get("content")
+ if isinstance(c, list) and any(
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in c):
+ continue # tool 回填,不是真实提问
+ txt = _rw_user_text(c)
+ if txt and txt.strip():
+ bounds.append((i, txt))
+
+ times = getattr(sess, "_rw_times", {}) or {}
+ now = time.time()
+
+ def ago_at(hi):
+ ks = [k for k in times if k <= hi]
+ if not ks:
+ return None
+ return int(max(0, now - times[max(ks)]))
+
+ nodes = []
+ total = len(bounds)
+ for p, (idx, txt) in enumerate(bounds):
+ nxt = bounds[p + 1][0] if p + 1 < total else len(history)
+ title = txt.replace("\n", " ").strip()[:80] or "(空)"
+ nodes.append({
+ "n": total - p, # _do_rewind(n):1 = 最近一次提问
+ "title": title,
+ "files": _rw_files_in_range(history, idx, nxt),
+ "ago": ago_at(nxt),
+ "kind": "edit",
+ })
+ nodes.append({
+ "n": None, "title": "当前位置", "files": [],
+ "ago": ago_at(len(history)), "kind": "current",
+ })
+ return nodes
+
+
+def _rw_changed_files(store, node_id):
+ """该节点相对父节点变更的文件名(basename,去重)——列表/详情的文件摘要。"""
+ nd = store.nodes[node_id]
+ par = nd.get("parent")
+ pf = store.nodes[par]["files"] if par in store.nodes else {}
+ out = []
+ for k, v in nd["files"].items():
+ if pf.get(k) != v:
+ b = os.path.basename(k)
+ if b not in out:
+ out.append(b)
+ return out
+
+
+def _rw_node_line(title, files, ago, *, current, glyph):
+ line = Text()
+ line.append(glyph + " ", style=(f"bold {C_GREEN}" if current else C_BLUE))
+ line.append(title or "(空)", style=(f"bold {C_GREEN}" if current else C_FG))
+ t = _rw_rel_time(ago)
+ if t:
+ line.append(f" {t}", style=C_DIM)
+ fs = _rw_files_summary(files)
+ if fs:
+ line.append(f" {fs}", style=C_DIM)
+ return line
+
+
+def _rw_entries_linear(sess):
+ """线性时间线条目 `[(Text, payload)]` + 默认选中索引。
+
+ 有真实 store → root→HEAD 链(payload = node_id,恢复 = 对话+代码);否则回退到
+ history 现算(payload = 回退轮数 n / None,仅对话恢复,兼容尚无 checkpoint 的会话)。"""
+ store = getattr(sess, "store", None)
+ if store is not None and store.nodes and store.head in store.nodes:
+ now = time.time()
+ entries = []
+ for nid in store.linear_path():
+ nd = store.nodes[nid]
+ cur = nid == store.head
+ entries.append((
+ _rw_node_line(nd["title"], _rw_changed_files(store, nid),
+ int(now - nd.get("created", now)),
+ current=cur, glyph=("●" if cur else "○")),
+ nid,
+ ))
+ return entries, len(entries) - 1
+ entries = []
+ for nd in _rw_collect(sess):
+ cur = nd["kind"] == "current"
+ line = Text()
+ if cur:
+ line.append("● 当前位置", style=f"bold {C_GREEN}")
+ else:
+ line.append(f"↩ 回退 {nd['n']} 轮 ", style=C_AMBER)
+ line.append(nd["title"], style=C_FG)
+ t = _rw_rel_time(nd["ago"])
+ if t:
+ line.append(f" {t}", style=C_DIM)
+ fs = _rw_files_summary(nd["files"])
+ if fs:
+ line.append(f" {fs}", style=C_DIM)
+ entries.append((line, nd["n"]))
+ return entries, len(entries) - 1
+
+
+def _rw_entries_tree(sess):
+ """树状条目 `[(Text, payload)]` + 默认选中(HEAD)索引。DFS 走真实 store 树,
+ 用 ├─/╰─ 连线 + 缩进表达分支(fork 后即可见);无 store 时回退到线性。"""
+ store = getattr(sess, "store", None)
+ if not (store is not None and store.nodes and store.root_id in store.nodes):
+ return _rw_entries_linear(sess)
+ now = time.time()
+ entries = []
+ sel = [0]
+
+ def dfs(nid, prefix, is_last, depth):
+ nd = store.nodes[nid]
+ cur = nid == store.head
+ line = Text()
+ if prefix:
+ line.append(prefix, style=C_DIM)
+ if depth > 0:
+ line.append("╰─ " if is_last else "├─ ", style=C_DIM)
+ line.append("● " if cur else "○ ", style=(f"bold {C_GREEN}" if cur else C_BLUE))
+ line.append(nd["title"] or "(空)", style=(f"bold {C_GREEN}" if cur else C_FG))
+ t = _rw_rel_time(int(now - nd.get("created", now)))
+ if t:
+ line.append(f" {t}", style=C_DIM)
+ fs = _rw_files_summary(_rw_changed_files(store, nid))
+ if fs:
+ line.append(f" {fs}", style=C_DIM)
+ if cur:
+ sel[0] = len(entries)
+ entries.append((line, nid))
+ child_prefix = prefix + (" " if is_last else "│ ") if depth > 0 else prefix
+ kids = [c for c in nd["children"] if c in store.nodes]
+ for i, c in enumerate(kids):
+ dfs(c, child_prefix, i == len(kids) - 1, depth + 1)
+
+ dfs(store.root_id, "", True, 0)
+ return entries, sel[0]
+
+
+class RewindScreen(ModalScreen):
+ """checkpoint 选择面板(时间线 / 树共用)。`entries = [(Text, payload)]`;
+ Esc 取消(dismiss None);Enter/点击 dismiss 该条 payload(store 节点 id 或
+ 回退轮数 n)。打开时默认选中当前(HEAD)。"""
+
+ CSS = """
+ RewindScreen { align: center middle; }
+ RewindScreen > Vertical {
+ width: 96; max-width: 92%; height: auto; max-height: 80%;
+ background: $ga-alt-bg; border: solid $ga-border; padding: 1 2;
+ }
+ RewindScreen .rw-head { color: $ga-fg; padding: 0 0 1 0; }
+ RewindScreen OptionList {
+ background: $ga-alt-bg; color: $ga-fg; height: auto; max-height: 1fr; padding: 0;
+ }
+ RewindScreen OptionList > .option-list--option-highlighted {
+ background: $ga-sel-bg; color: $ga-fg;
+ }
+ """
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
+
+ def __init__(self, entries, select_idx, title) -> None:
+ super().__init__()
+ # NB: 不要用 self._nodes —— 那是 Textual 的子节点 NodeList,覆盖会崩。
+ self._entries = entries
+ self._select = select_idx
+ self._title = title
+
+ def compose(self) -> ComposeResult:
+ with Vertical():
+ yield Static(self._title, classes="rw-head")
+ yield OptionList(id="rw-list")
+
+ def on_mount(self) -> None:
+ ol = self.query_one(OptionList)
+ for text, _payload in self._entries:
+ ol.add_option(Option(text))
+ if self._entries:
+ ol.highlighted = max(0, min(self._select, len(self._entries) - 1))
+ ol.focus()
+
+ def on_option_list_option_selected(self, ev) -> None:
+ self.dismiss(self._entries[ev.option_index][1])
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
+
+
class GenericAgentTUI(App[None]):
CSS = _MAIN_CSS
@@ -2763,9 +3850,14 @@ class GenericAgentTUI(App[None]):
"new": self._cmd_new, "switch": self._cmd_switch, "close": self._cmd_close,
"rename": self._cmd_rename,
"branch": self._cmd_branch, "rewind": self._cmd_rewind, "clear": self._cmd_clear,
- "stop": self._cmd_stop, "llm": self._cmd_llm, "export": self._cmd_export,
+ "worldline": self._cmd_rewind_tree, # 世界线(树状回退)
+ "rewind-tree": self._cmd_rewind_tree, # 旧名,保留为静默别名
+ "stop": self._cmd_stop, "llm": self._cmd_llm, "model": self._cmd_model,
+ "effort": self._cmd_effort,
+ "export": self._cmd_export,
"restore": self._cmd_restore, "btw": self._cmd_btw, "review": self._cmd_review,
"continue": self._cmd_continue, "cost": self._cmd_cost,
+ "workspace": self._cmd_workspace,
"reload-keys": self._cmd_reload_keys,
# slash_cmds bundle — see frontends/slash_cmds.py for the prompt
# bodies + reflect/scheduler discovery. All but /scheduler are
@@ -2833,7 +3925,13 @@ class GenericAgentTUI(App[None]):
yield _sidebar
with Vertical(id="main"):
yield VerticalScroll(id="messages")
- yield Static("", id="planbar")
+ # Plan card: pinned header/step (#planbar-head) above a task list
+ # (#planbar) that scrolls inside a 4-row window (#planbar-tasks).
+ with Vertical(id="planbar-scroll"):
+ yield Static("", id="planbar-head")
+ _tasks = VerticalScroll(Static("", id="planbar"), id="planbar-tasks")
+ _tasks.can_focus = False # don't steal Tab focus from input
+ yield _tasks
yield OptionList(id="palette")
yield InputArea(
"",
@@ -2852,11 +3950,16 @@ class GenericAgentTUI(App[None]):
def on_mount(self) -> None:
_sweep_stale_task_dirs() # clear empty signal dirs left by prior runs
+ try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions
+ except Exception: pass
+ try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone
+ except Exception: pass
+ get_index(os.path.join(ROOT_DIR, "temp")).warm() # @ 补全:预热未绑时的默认根(temp)
self.add_session("main")
self._system(f"Welcome to GenericAgent TUI. 按 / 唤起命令面板,{fmt_key('ctrl+n')} 新建会话。")
- # CSS `#planbar { display: none }` keeps it hidden by default —
- # the renderer flips it on once items materialize.
+ # CSS `#planbar-scroll { display: none }` keeps it hidden by default —
+ # the renderer adds `-visible` once plan items materialize.
self.query_one("#input", InputArea).focus()
self.set_interval(0.5, self._tick)
self._patch_auto_scroll_for_selection()
@@ -2962,7 +4065,33 @@ class GenericAgentTUI(App[None]):
f'_tui_v2_{os.getpid()}_{agent_id}')
except Exception:
pass
+ try:
+ # Opt TUI v2 agents into per-agent project-mode selection. The
+ # plugin falls back to the legacy pid anchor only when this
+ # private attribute is absent, so None here means "ordinary mode"
+ # for this session rather than "use the process-global workspace".
+ agent._ga_project_mode_name = None
+ agent._ga_project_mode_workspace_path = ""
+ except Exception:
+ pass
sess = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent)
+ # Rewind 后端:每 session 一个 checkpoint 树 + blob 库,key 对齐 agent.log_path
+ # (GA 稳定会话身份)。cwd = temp(与 GA handler 解析相对路径的基准一致,
+ # 见 agentmain GenericAgentHandler(..., temp))。store 挂到 agent 上供全局
+ # tool_before 钩子按 agent 路由。
+ try:
+ temp_dir = os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp'))
+ log_path = getattr(agent, 'log_path', '') or f'sess_{os.getpid()}_{agent_id}'
+ sess.store = RewindStore.for_log(temp_dir, log_path, temp_dir)
+ agent._rw_store = sess.store
+ except Exception:
+ sess.store = None
+ self._install_rw_tool_hook()
+ try:
+ from continue_cmd import acquire_birth_lock
+ acquire_birth_lock(agent, agent_id) # 原地复原:出生持锁,占用检测可见
+ except Exception:
+ pass
thread = threading.Thread(target=agent.run, name=f"ga-tui-agent-{agent_id}", daemon=True)
thread.start()
sess.thread = thread
@@ -2970,9 +4099,230 @@ class GenericAgentTUI(App[None]):
self.current_id = agent_id
self._install_ask_user_hook(sess)
self._install_intervene_replay_hook(sess)
+ self._install_write_snapshot_hook()
+ self._install_rw_time_hook(sess)
self._refresh_all()
return sess
+ def _bind_workspace(self, sess: AgentSession, info: Optional[dict],
+ persist: bool = True) -> None:
+ # persist=False: 仅刷新内存绑定状态,不写 session→ws 映射表。续接时的
+ # reset(_bind_workspace(sess, None)) 用它——原地续后 agent.log_path==被续
+ # 文件,若持久化会把本会话映射抹成 ""(= 已 off),反而毁掉自动恢复。映射表
+ # 只应由显式 /workspace、/workspace off 与成功的续接恢复来写。
+ if info:
+ sess.workspace_name = info.get("name") or ""
+ sess.workspace_path = info.get("target") or info.get("path") or ""
+ sess.workspace_link = info.get("link") or ""
+ project_name = sess.workspace_name or None
+ project_path = sess.workspace_path
+ else:
+ sess.workspace_name = ""
+ sess.workspace_path = ""
+ sess.workspace_link = ""
+ project_name = None
+ project_path = ""
+ try:
+ sess.agent._ga_project_mode_name = project_name
+ sess.agent._ga_project_mode_workspace_path = project_path
+ except Exception:
+ pass
+ # 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。
+ if persist:
+ workspace_cmd.session_ws_set(getattr(sess.agent, "log_path", "") or "", project_path or "")
+ if project_path:
+ get_index(project_path).warm() # @ 候选跟随 workspace
+
+ def _at_root(self, sess: Optional["AgentSession"] = None) -> str:
+ # @ 索引根:绑了 workspace 用真实 target;否则用 agent 的实际工作目录
+ # ROOT_DIR/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。
+ # 一律真实路径,绝不暴露哈希 junction 名。
+ s = sess or (self.sessions.get(self.current_id) if self.current_id is not None else None)
+ return (s.workspace_path if s and s.workspace_path else os.path.join(ROOT_DIR, "temp"))
+
+ _write_snapshot_hook_installed = False
+
+ def _install_write_snapshot_hook(self) -> None:
+ """Register one global `tool_before` hook that captures the structured
+ args of every file_write/file_patch (plus a file_write target's pre-write
+ content) into `_WRITE_CAP`, keyed by `hash(get_pretty_json(args))`.
+
+ That hash is the bridge to the display layer: agent_loop renders the same
+ `get_pretty_json(args)` inside the verbose `📥 args:` fence, so at render
+ time `_render_md` hashes the fence body and looks the real args back up —
+ no need to parse the newline-mangled pretty JSON. Content-addressed, so it
+ survives re-renders and pairs each block with its exact call. The registry
+ is process-global, so install at most once."""
+ if GenericAgentTUI._write_snapshot_hook_installed:
+ return
+ try:
+ from plugins import hooks as _ph
+ from agent_loop import get_pretty_json
+
+ def _strip_dispatch_keys(raw):
+ # dispatch() injects `_index`/`_tool_num` into args *before* the
+ # hooks fire, but the verbose display (agent_loop.py:78) ran
+ # get_pretty_json on the args *before* dispatch — i.e. without
+ # those keys. Strip them so our hash matches the fence body the
+ # renderer will hash.
+ return {k: v for k, v in (raw or {}).items()
+ if k not in ("_index", "_tool_num")}
+
+ def _snap(ctx):
+ try:
+ name = (ctx or {}).get("tool_name")
+ if name not in ("file_write", "file_patch", "file_read", "code_run"):
+ return ctx
+ handler = (ctx or {}).get("self")
+ args = _strip_dispatch_keys((ctx or {}).get("args"))
+ if name == "code_run":
+ # No path; the card shows args (code) + the result
+ # stamped by _snap_after. Keyed the same way.
+ _WRITE_CAP[hash(get_pretty_json(args))] = {
+ "name": name, "args": args,
+ }
+ if len(_WRITE_CAP) > 256:
+ for k in list(_WRITE_CAP)[:128]:
+ _WRITE_CAP.pop(k, None)
+ return ctx
+ path = args.get("path")
+ if not path or handler is None:
+ return ctx
+ if name == "file_read":
+ # No disk snapshot needed — the read card shows the
+ # tool's own result, stamped by _snap_after from
+ # StepOutcome.data.
+ _WRITE_CAP[hash(get_pretty_json(args))] = {
+ "name": name, "args": args,
+ }
+ else:
+ # Snapshot the pre-write full file for BOTH write tools:
+ # file_write overwrite diffs against it, and file_patch
+ # uses it to render a whole-file diff (real line numbers
+ # + surrounding context) instead of just the fragment.
+ existed, old = False, ""
+ try:
+ abs_path = handler._get_abs_path(path)
+ existed = os.path.exists(abs_path)
+ if existed:
+ with open(abs_path, "r", encoding="utf-8") as f:
+ old = f.read()
+ except Exception:
+ existed, old = False, ""
+ _WRITE_CAP[hash(get_pretty_json(args))] = {
+ "name": name, "args": args, "existed": existed, "old": old,
+ }
+ # Bound memory: keep only the most recent captures.
+ if len(_WRITE_CAP) > 256:
+ for k in list(_WRITE_CAP)[:128]:
+ _WRITE_CAP.pop(k, None)
+ except Exception:
+ pass
+ return ctx
+
+ def _snap_after(ctx):
+ # Stamp the outcome onto the same capture: write tools get
+ # {status, msg} for the header ✗; file_read gets the result text
+ # itself (its StepOutcome.data IS the content the card shows).
+ try:
+ name = (ctx or {}).get("tool_name")
+ if name not in ("file_write", "file_patch", "file_read", "code_run"):
+ return ctx
+ args = _strip_dispatch_keys((ctx or {}).get("args"))
+ data = getattr((ctx or {}).get("ret"), "data", None)
+ h = hash(get_pretty_json(args))
+ if h not in _WRITE_CAP:
+ return ctx
+ if name == "file_read":
+ if isinstance(data, str):
+ _WRITE_CAP[h]["content"] = data
+ elif name == "code_run":
+ # data is the StepOutcome.data verbatim: dict
+ # {status,stdout,exit_code} | {status:error,msg} | str.
+ _WRITE_CAP[h]["data"] = data
+ elif isinstance(data, dict):
+ _WRITE_CAP[h]["status"] = data.get("status")
+ _WRITE_CAP[h]["msg"] = str(data.get("msg") or "")
+ except Exception:
+ pass
+ return ctx
+
+ _ph.register("tool_before")(_snap)
+ _ph.register("tool_after")(_snap_after)
+ GenericAgentTUI._write_snapshot_hook_installed = True
+ except Exception:
+ pass
+
+
+ _rw_tool_hook_installed = False
+
+ def _install_rw_tool_hook(self) -> None:
+ """全局注册一次 `tool_before` 钩子(plugins.hooks):在 file_write/file_patch
+ 真写盘**之前**,把目标文件的「改前内容」存进对应 session 的 store(track_pre_edit)。
+
+ 注册表是全局的(非 per-session),回调对所有 agent 触发 → 靠 ctx 的
+ `self.parent`(GenericAgent)上挂的 `_rw_store` 路由到正确 session。
+ 回调跑在 agent 线程;每 agent 独占自己的 store,无跨线程竞争。"""
+ if GenericAgentTUI._rw_tool_hook_installed:
+ return
+ try:
+ from plugins import hooks as _ph
+
+ def _rw_tool_before(ctx):
+ try:
+ if (ctx or {}).get("tool_name") not in ("file_write", "file_patch"):
+ return ctx
+ handler = ctx.get("self")
+ args = ctx.get("args") or {}
+ path = args.get("path")
+ store = getattr(getattr(handler, "parent", None), "_rw_store", None)
+ if store is not None and path:
+ store.track_pre_edit(handler._get_abs_path(path))
+ except Exception:
+ pass
+ return ctx
+
+ _ph.register("tool_before")(_rw_tool_before)
+ GenericAgentTUI._rw_tool_hook_installed = True
+ except Exception:
+ pass
+
+ def _rw_commit(self, sess: AgentSession) -> None:
+ """用户提问段完成时落一个 checkpoint 节点(对话推进也算 checkpoint)。
+
+ title 取本次用户输入;传整条 `backend.history` 给 store —— commit 会切本轮
+ 增量 `history[parent.hist_len:]` 存成 conv blob(树=真相源),恢复时按路径拼回。
+ store 故障一律静默(rewind 是旁路安全网,绝不冒泡打断任务收尾)。"""
+ store = getattr(sess, "store", None)
+ if store is None:
+ return
+ try:
+ title = (getattr(sess, "_rw_title", "") or "checkpoint").replace("\n", " ").strip()[:80]
+ history = sess.agent.llmclient.backend.history
+ store.commit(title or "checkpoint", history=history)
+ store._rw_cursor = None # 继续提问 → 新末端成为当前,清除 rewind 游标
+ except Exception:
+ pass
+
+ def _install_rw_time_hook(self, sess: AgentSession) -> None:
+ """Stamp wall-clock per turn-end, keyed by LLM-history length, so the
+ /rewind panels can show coarse relative time. GA stores no timestamps;
+ nodes predating this session (e.g. restored via /continue) show none.
+ Hook runs in the agent thread — dict writes are GIL-safe."""
+ agent = sess.agent
+ sess._rw_times = {}
+ try:
+ hooks = getattr(agent, "_turn_end_hooks", None)
+ if hooks is None:
+ hooks = agent._turn_end_hooks = {}
+ def _hook(ctx, _s=sess, _a=agent):
+ try: _s._rw_times[len(_a.llmclient.backend.history)] = time.time()
+ except Exception: pass
+ hooks["_ga_tui_rw_time"] = _hook
+ except Exception:
+ pass
+
+
def _install_ask_user_hook(self, sess: AgentSession) -> None:
"""Capture ask_user INTERRUPT payloads from agent_loop's turn_end hook.
@@ -3011,6 +4361,10 @@ class GenericAgentTUI(App[None]):
self._system(f"Created session #{sess.agent_id} — {sess.name}")
def action_prev_session(self) -> None:
+ # ctrl+up 是 app 级 priority 绑定,会盖过 ModalScreen;世界线屏激活时把它
+ # 转交给屏内「移动非聚焦窗↑」(否则在三栏里 ctrl+↑ 变成切换会话)。
+ if isinstance(self.screen, RewindTreeScreen):
+ self.screen.action_other_up(); return
ids = sorted(self.sessions.keys())
if len(ids) <= 1: return
i = ids.index(self.current_id)
@@ -3018,6 +4372,8 @@ class GenericAgentTUI(App[None]):
self._refresh_all()
def action_next_session(self) -> None:
+ if isinstance(self.screen, RewindTreeScreen):
+ self.screen.action_other_down(); return
ids = sorted(self.sessions.keys())
if len(ids) <= 1: return
i = ids.index(self.current_id)
@@ -3025,17 +4381,10 @@ class GenericAgentTUI(App[None]):
self._refresh_all()
def copy_to_clipboard(self, text: str) -> None:
- """Override Textual's OSC 52 clipboard (broken on macOS Terminal.app).
- Use pbcopy on macOS, fall back to OSC 52 on other platforms."""
- import sys, subprocess as _sp
- if sys.platform == "darwin":
- try:
- _sp.Popen(["pbcopy"], stdin=_sp.PIPE, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL).communicate(text.encode("utf-8"))
- self._clipboard = text
- return
- except Exception:
- pass
- # Non-macOS or pbcopy failed: fall back to Textual default (OSC 52)
+ self._clipboard = text
+ _ssh = os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY")
+ if not _ssh and _copy_to_clipboard(text):
+ return
super().copy_to_clipboard(text)
def action_handle_ctrl_c(self) -> None:
@@ -3308,6 +4657,10 @@ class GenericAgentTUI(App[None]):
return t
def action_complete_command(self) -> None:
+ # `tab` 是 app 级 priority 绑定(命令补全),会盖过 ModalScreen 的 on_key;
+ # 世界线屏激活时把 Tab 转交给它的「切聚焦」(否则 Tab 在三栏里被补全吞掉)。
+ if isinstance(self.screen, RewindTreeScreen):
+ self.screen.action_focus_toggle(); return
palette = self.query_one("#palette", OptionList)
if not palette.has_class("-visible"):
return
@@ -3458,9 +4811,45 @@ class GenericAgentTUI(App[None]):
if first_line.startswith("/") and " " not in first_line and "\n" not in val:
self._populate_palette(first_line)
self._show_palette()
+ return
+ # @ file mention: reuse the same palette for path candidates when the
+ # cursor sits inside an `@token` (claude-code parity, workspace-rooted).
+ try:
+ row, col = inp.cursor_location
+ line = inp.document.get_line(row)[:col]
+ except Exception:
+ line = ""
+ tok = find_at_token(line)
+ if tok is not None:
+ self._populate_at_palette(tok[0])
else:
self._hide_palette()
+ def _populate_at_palette(self, query: str) -> None:
+ sess = self.sessions.get(self.current_id)
+ unbound = not (sess and sess.workspace_path) # 未绑 workspace → 根是 temp,显示完整路径
+ matches = candidates_for(query, self._at_root(), absolute=unbound)
+ palette = self.query_one("#palette", OptionList)
+ palette.clear_options()
+ if not matches:
+ self._hide_palette()
+ return
+ for path in matches:
+ t = Text()
+ if unbound: # 未绑:整条完整路径(根不直观)
+ t.append(path)
+ else: # 绑 workspace:base + 父目录(短)
+ # 目录候选末尾带 '/',先剥掉再拆 base,否则 rsplit 得到空串 → 空白行。
+ is_dir = path.endswith("/")
+ core = path.rstrip("/")
+ parent, name = core.rsplit("/", 1) if "/" in core else ("", core)
+ base = name + ("/" if is_dir else "")
+ t.append(base, style="bold")
+ if parent:
+ t.append(f" {parent}", style=C_MUTED)
+ palette.add_option(Option(t, id=f"at:{path}"))
+ self._show_palette()
+
def _resize_input(self, inp: TextArea) -> None:
# wrapped_document.height counts soft-wrapped lines; document.line_count only logical.
try:
@@ -3530,6 +4919,15 @@ class GenericAgentTUI(App[None]):
# forward the literal so the agent recovers context.
self.submit_user_message(text)
return
+ # @ mentions (completion-only): rewrite @relative → @absolute so the
+ # agent's file_read can locate it; scrollback keeps the short form via
+ # display_text. No content is read here. (Content-injecting auto-read
+ # variant: temp/plan_v2_at_mention/autoread_version.py.)
+ if "@" in text:
+ abs_text = absolutize_mentions(text, self._at_root())
+ if abs_text != text:
+ self.submit_user_message(abs_text, images=images, display_text=text)
+ return
self.submit_user_message(text, images=images)
def _show_palette(self) -> None:
@@ -3558,6 +4956,24 @@ class GenericAgentTUI(App[None]):
ol = event.option_list
if ol.id == "palette":
cmd_id = event.option.id
+ if cmd_id and cmd_id.startswith("at:"):
+ # @ candidate accepted: replace the in-progress @token with the
+ # picked path (quoted when it contains spaces), cursor to end.
+ inp = self.query_one("#input", InputArea)
+ try:
+ row, col = inp.cursor_location
+ line = inp.document.get_line(row)[:col]
+ tok = find_at_token(line)
+ if tok is not None:
+ rep = format_pick(cmd_id[3:])
+ self._suppress_palette_open = True
+ inp.replace(rep, (row, tok[1]), (row, col))
+ inp.move_cursor((row, tok[1] + len(rep)))
+ except Exception:
+ pass
+ self._hide_palette()
+ inp.focus()
+ return
if cmd_id:
inp = self.query_one("#input", InputArea)
needs_args = any(c[1] for c in COMMANDS if c[0] == cmd_id)
@@ -3828,6 +5244,12 @@ class GenericAgentTUI(App[None]):
new.agent.llmclient.backend.history = copy.deepcopy(old.agent.llmclient.backend.history)
except Exception as e:
self._system(f"Branch warning: {e}"); return
+ if old.workspace_name:
+ self._bind_workspace(new, {
+ "name": old.workspace_name,
+ "target": old.workspace_path,
+ "link": old.workspace_link,
+ })
# deepcopy(old.messages) trips on mounted Textual widget refs; shallow-copy each
# ChatMessage and null out widget/cache fields so the new session re-mounts cleanly.
new.messages = []
@@ -3848,35 +5270,184 @@ class GenericAgentTUI(App[None]):
self._system(f"Branched #{old.agent_id} → #{new.agent_id} ({n} msgs).")
def _cmd_rewind(self, args, raw):
+ # 外观:对话流内联选择卡片(旧版样式)。行为:有世界线树时走【持久】通道——
+ # 选中提问后弹 RestoreModeScreen(复用 /worldline 那套)选 对话/代码/两者,再
+ # _rw_restore_node→restore_plan(落盘 tree HEAD + 重写投影日志,continue 后不复活)。
+ # 无树才兜底内存级 _do_rewind(非持久,仅对话)。分支树视图 / diff 走 /worldline。
sess = self.current
if sess.status == "running":
self._system("Cannot rewind while running. /stop first."); return
- turns = self._rewindable_turns()
- if not turns:
- self._system("No rewindable turns."); return
- if args:
+ store = getattr(sess, "store", None)
+ history = sess.agent.llmclient.backend.history
+ # 先对账:让树对齐当前对话(顺带防外部 UI 改写日志的灾难),再按树的线性路径
+ # 建卡片,每个选项 = 一个真实节点 → 选中即可持久回退。
+ nodes = [] # [(node_id, title)] 最近→最旧,不含 origin
+ if store is not None:
+ try: store.reconcile(history)
+ except Exception: pass
+ if store.nodes and store.head in store.nodes:
+ for nid in store.linear_path():
+ if store.nodes[nid].get("kind") == "origin":
+ continue
+ nodes.append((nid, store.nodes[nid].get("title") or "(空)"))
+ nodes.reverse()
+ if nodes: # 持久路径
+ payloads = [nid for nid, _ in nodes]
+ previews = [t for _, t in nodes]
+ durable = True
+ else: # 兜底:无 store/无真实提问 → 内存级
+ turns = self._rewindable_turns()
+ if not turns:
+ self._system("No rewindable turns."); return
+ rec = list(reversed(turns))
+ payloads = [i + 1 for i in range(len(rec))] # 回退轮数 n
+ previews = [p for _, p in rec]
+ durable = False
+ total = len(payloads)
+ if args: # /rewind n 直接回退,不弹卡片
try: n = int(args[0])
- except ValueError: self._system("Usage: /rewind "); return
- if n < 1 or n > len(turns):
- self._system(f"Invalid: 1-{len(turns)}"); return
- self._system(self._do_rewind(n))
+ except ValueError: self._system("Usage: /rewind [n]"); return
+ if n < 1 or n > total:
+ self._system(f"Invalid: 1-{total}"); return
+ if durable:
+ self._rewind_pick_mode(sess, payloads[n - 1]) # 弹模式窗后回退
+ else:
+ self._system(self._do_rewind(payloads[n - 1]))
return
LIMIT = 20
- recent = list(reversed(turns))[:LIMIT]
choices = []
- for offset, (_, prev) in enumerate(recent, 1):
- preview = (prev or "(空)").replace("\n", " ").strip()[:60]
- choices.append((f"回退 {offset} 轮 · {preview}", offset))
+ for offset in range(1, min(total, LIMIT) + 1):
+ preview = (previews[offset - 1] or "(空)").replace("\n", " ").strip()[:60]
+ choices.append((f"回退 {offset} 轮 · {preview}", payloads[offset - 1]))
head = "选择回退到的轮次 (↑/↓ 移动,→/Enter 确认,Esc 取消)"
- if len(turns) > LIMIT:
- head += f" [仅显示最近 {LIMIT}/{len(turns)}]"
- msg = ChatMessage(
- role="system", content=head, kind="choice", choices=choices,
- on_select=lambda v: self._do_rewind(v),
- )
+ if total > LIMIT:
+ head += f" [仅显示最近 {LIMIT}/{total}]"
+ if durable:
+ on_sel = lambda v: self._rewind_pick_mode(sess, v)
+ else:
+ on_sel = lambda v: self._do_rewind(v)
+ msg = ChatMessage(role="system", content=head, kind="choice",
+ choices=choices, on_select=on_sel)
sess.messages.append(msg)
self._refresh_messages()
+ def _rewind_pick_mode(self, sess, node_id) -> None:
+ """选中要回退到的提问后,弹 RestoreModeScreen(复用 /worldline 的模式选择窗)选
+ 对话/代码/两者,再走持久回退 _rw_restore_node(to='before')。取消(返回 None)则不回退。"""
+ store = getattr(sess, "store", None)
+ title = ""
+ if store is not None and node_id in getattr(store, "nodes", {}):
+ title = store.nodes[node_id].get("title") or ""
+ def _after(mode):
+ if mode:
+ self._system(self._rw_restore_node(sess, node_id, mode=mode, to="before"))
+ self.push_screen(RestoreModeScreen(title, "before"), _after)
+
+ def _cmd_rewind_tree(self, args, raw):
+ sess = self.current
+ if sess.status == "running":
+ self._system("Cannot rewind while running. /stop first."); return
+ store = getattr(sess, "store", None)
+ if store is None:
+ self._system("No rewindable checkpoints."); return
+ # 打开即对账:把 live history 中树尚未记录的尾部(别的 UI 续聊时追加的)吸收进树。
+ # 这是防灾的单一咽喉点——restore 只能从已打开的世界线屏发起,对账后树恒 ⊇ 日志,
+ # 故随后任何 rewind 都不可能用陈旧树 rewrite_projection 抹掉外部轮次。
+ # 也统一了「空 store / 老会话」:n=0 即把整条历史合成为 conv-only 主干(界面不降级)。
+ try:
+ store.reconcile(sess.agent.llmclient.backend.history)
+ except Exception:
+ pass # 对账失败不阻断;下面守卫兜底
+ if not (store.nodes and store.root_id in store.nodes):
+ self._system("No rewindable checkpoints."); return # 连一轮真实提问都没有
+ # 三栏全屏可视化器(§3–§7):左压缩树 / 右上折叠段 / 右下详情+操作。
+ self.push_screen(RewindTreeScreen(store), self._on_rewind_tree_result)
+
+ def _on_rewind_pick(self, payload) -> None:
+ # 面板回调:None=取消;str=store 节点 id(恢复对话+代码);int=history 回退轮数。
+ if payload is None:
+ return
+ sess = self.current
+ if isinstance(payload, str):
+ self._system(self._rw_restore_node(sess, payload))
+ else:
+ self._system(self._do_rewind(payload))
+
+ def _on_rewind_tree_result(self, result) -> None:
+ # 三栏屏回调:None=取消/已内联处理(diff/delete);dict=恢复请求。
+ if not isinstance(result, dict) or result.get("action") != "restore":
+ return
+ self._system(self._rw_restore_node(self.current, result.get("node"),
+ mode=result.get("mode", "both"),
+ to=result.get("to", "before")))
+
+ def _rw_restore_node(self, sess, node_id, mode: str = "both", to: str = "before") -> str:
+ """恢复到某节点(薄封装)。**编排在 `worldline.restore_plan`**(UI 无关:算出回退后
+ 的对话/文件/prefill,并落地——改文件 + 移 HEAD + 重写投影);本方法只做 TUI 侧刷新:
+ 把 history 赋回 backend、重组界面消息、prefill 输入框、重挂。
+
+ to: before(回到该提问之前 + prefill) / at(在该节点继续,无 prefill);
+ mode: both/conv/code。需 agent 空闲(调用方已保证)。"""
+ store = getattr(sess, "store", None)
+ log_path = getattr(sess.agent, "log_path", "") or ""
+ hist = sess.agent.llmclient.backend.history
+ old_len = len(hist)
+ res = restore_plan(store, node_id, mode=mode, to=to, log_path=log_path)
+ if res is None:
+ return "❌ 无效的 checkpoint"
+ # rewind 游标:to=before(回到 X 之前,内部 HEAD=parent)时,用户心中「当前」仍是
+ # 选中的 X → 记下供世界线面板把 ◉ 标在 X(下次打开仍停在 X)。to=at 则就在该节点,
+ # 用 HEAD 即可。继续提问(commit)时清除。
+ try:
+ store._rw_cursor = node_id if (to == "before") else None
+ except Exception:
+ pass
+ removed = 0
+ if res["history"] is not None: # 对话有变更(both/conv)
+ hist[:] = res["history"]
+ removed = max(0, old_len - len(res["history"]))
+ self._rw_rebuild_display(sess) # 从重写后的投影重组界面历史消息
+ self._remount_current_session()
+ self._refresh_topbar()
+ self._refresh_sidebar()
+ if res["prefill"]:
+ self._rw_prefill_input(res["prefill"])
+ label = {"both": "对话+代码", "conv": "仅对话", "code": "仅代码"}.get(mode, mode)
+ at_origin, title = res["at_origin"], res["title"]
+ if to == "at":
+ where = "空起点" if at_origin else f"「{title}」之后(在此继续)"
+ else:
+ where = "空起点" if at_origin else f"「{title}」之前"
+ return (f"↩ 已回退到{where}({label}):清除 {removed} 条上下文,"
+ f"代码恢复 {len(res['changed'])} 个文件")
+
+ def _rw_rebuild_display(self, sess) -> None:
+ """rewind 后重组**界面历史消息**:从已重写成新 HEAD 路径的投影日志重新解析
+ (复用 /continue 的 `extract_ui_messages`,与其它视角同一套渲染),替换
+ `sess.messages`。随后由 `_remount_current_session` 重挂显示。
+
+ 没这步的话,backend.history 已回退但屏幕上还停留在旧对话。故障静默降级。"""
+ log_path = getattr(sess.agent, "log_path", "") or ""
+ if not log_path:
+ return
+ try:
+ rebuilt = [ChatMessage(role=h["role"], content=h["content"])
+ for h in continue_extract(log_path)]
+ sess.messages.clear()
+ sess.messages.extend(rebuilt)
+ except Exception:
+ pass
+
+ def _rw_prefill_input(self, text: str) -> None:
+ try:
+ inp = self.query_one("#input", InputArea)
+ inp.text = text
+ inp.move_cursor((inp.document.line_count - 1, len(text.split("\n")[-1])))
+ inp.focus()
+ self._resize_input(inp)
+ except Exception:
+ pass
+
def _rewindable_turns(self) -> list[tuple[int, str]]:
history = self.current.agent.llmclient.backend.history
turns: list[tuple[int, str]] = []
@@ -3930,8 +5501,21 @@ class GenericAgentTUI(App[None]):
def _cmd_stop(self, args, raw):
sess = self.current
- last_user_text = next((m.content for m in reversed(sess.messages)
- if m.role == "user"), None)
+ # Locate the last user message AND whether the agent already produced a
+ # reply for that turn. Walking reversed, any non-empty assistant message
+ # seen *before* we reach the user message means this turn was consumed
+ # (the LLM emitted output → it's in history; a resend would duplicate).
+ # System "[queued #n]" steers are skipped (neither role). The current
+ # task's assistant placeholder starts empty, so an interrupt before any
+ # stream leaves `consumed` False.
+ last_user_text = None
+ consumed = False
+ for m in reversed(sess.messages):
+ if m.role == "assistant" and (m.content or "").strip():
+ consumed = True
+ elif m.role == "user":
+ last_user_text = m.content
+ break
try:
sess.agent.abort()
if sess.status == "running":
@@ -3941,11 +5525,12 @@ class GenericAgentTUI(App[None]):
except Exception as e:
self._system(f"Stop failed: {e}")
# Refill the input box with the interrupted user text so edit-and-
- # resend is one keystroke away. Only when the box is empty (don't
- # clobber a half-typed follow-up). Agent history is untouched — a
- # resend duplicates the turn in LLM context; `/rewind 1` is the
- # manual escape.
- if last_user_text:
+ # resend is one keystroke away — but only for an *unconsumed* turn
+ # (aborted before the LLM replied). Once the agent has answered, the
+ # turn lives in history and a resend would duplicate it, so leave the
+ # box alone. Also only when the box is empty (don't clobber a
+ # half-typed follow-up).
+ if last_user_text and not consumed:
try:
inp = self.query_one("#input", InputArea)
if not inp.text:
@@ -4021,6 +5606,93 @@ class GenericAgentTUI(App[None]):
except Exception as e:
return f"❌ 切换失败: {e}"
+ # ---------------- /model: 渠道内 model 切换(逻辑在 model_cmd.py) ----------------
+ def _cmd_model(self, args, raw):
+ import model_cmd
+ agent = self.current.agent
+ if args: # /model 直设, 不拉列表
+ self._system(model_cmd.set_model(agent, " ".join(args)))
+ return
+ self._open_model_picker()
+
+ def _open_model_picker(self) -> None:
+ """立即挂一个空的 searchable picker(输入框先可用, 下方 hint 行显示加载中),
+ 后台拉取完成后 _fill_model_picker 就地填充。mixin 不再选渠道, 直接作用于
+ 当前子渠道 (model_cmd sub=None 即当前)。"""
+ import model_cmd
+ agent = self.current.agent
+ cur = model_cmd.current_model(agent, None)
+ msg = ChatMessage(
+ role="system",
+ content=f"选择模型 (当前: {cur} · 输入过滤或自定义名称 · ↑/↓ 移动,Enter 确认,Esc 取消)",
+ kind="choice", choices=[],
+ on_select=lambda v: model_cmd.set_model(self.current.agent, v),
+ )
+ msg.searchable = True
+ msg.free_input = True
+ msg.search_placeholder = "输入关键字过滤;无匹配时 Enter 设置自定义模型名"
+ msg.all_choices = []
+ msg.empty_hint = "⏳ 正在拉取模型列表… (或直接输入完整模型名 Enter 直设)"
+ self.current.messages.append(msg)
+ self._refresh_messages()
+
+ def worker():
+ try:
+ models = model_cmd.fetch_models(agent, None)
+ err = None if models else "渠道未返回模型列表"
+ except Exception as e:
+ models, err = [], f"{type(e).__name__}: {e}"
+ self.call_from_thread(self._fill_model_picker, msg, models, err, cur)
+
+ threading.Thread(target=worker, daemon=True, name="ga-tui-model").start()
+
+ # ---------------- /effort: reasoning effort 切换(逻辑在 model_cmd.py) ----------------
+ def _cmd_effort(self, args, raw):
+ import model_cmd
+ if args: # /effort 直设
+ self._system(model_cmd.set_effort(self.current.agent, " ".join(args)))
+ return
+ agent = self.current.agent
+ cur = model_cmd.current_effort(agent)
+ protocols = model_cmd._protocols(agent)
+ # (显示名, value, 备注, 是否当前选中)。有备注的行名字补齐到等宽,
+ # 备注对齐成一列;无备注的行直接用显示名,不留尾随空格。
+ rows = [("默认", "off", "", not cur)]
+ for lv in model_cmd.EFFORT_LEVELS:
+ rows.append((lv, lv, model_cmd.effort_note(lv, protocols), cur == lv))
+ w = max(len(d) for d, _, _, _ in rows)
+ choices = [(("✓ " if tick else " ")
+ + (disp.ljust(w) + f" {note}" if note else disp), val)
+ for disp, val, note, tick in rows]
+ msg = ChatMessage(
+ role="system",
+ content=(f"选择 reasoning effort (当前: {cur or '未设置'} · "
+ "↑/↓ 移动,Enter 确认,Esc 取消)"),
+ kind="choice", choices=choices,
+ on_select=lambda v: model_cmd.set_effort(self.current.agent, v),
+ )
+ self.current.messages.append(msg)
+ self._refresh_messages()
+
+ def _fill_model_picker(self, msg, models, err, cur) -> None:
+ """拉取完成: 就地重建 picker 区, 保留 Input 焦点与已输入的过滤词。"""
+ w = msg._body_widget
+ if (msg.selected_label is not None or w is None
+ or not getattr(w, "is_mounted", False)):
+ return # 用户已 Esc/已选, 静默丢弃
+ if err:
+ msg.empty_hint = f"❌ 拉取失败: {err} · 直接输入完整模型名 Enter 设置"
+ else:
+ msg.all_choices = [(("✓ " if m == cur else " ") + m, m) for m in models]
+ msg.empty_hint = "(无匹配 · Enter 设置自定义模型名)"
+ msg.content = (f"选择模型 ({len(models)} 个 · 当前: {cur} · "
+ "输入过滤或自定义名称 · ↑/↓ 移动,Enter 确认,Esc 取消)")
+ try:
+ msg._hint_widget.update(Text(msg.content, style=C_MUTED))
+ except Exception:
+ pass
+ w._apply_filter(msg.search_query or "")
+
# ---------------- new commands ----------------
def _cmd_btw(self, args, raw):
question = " ".join(args).strip()
@@ -4081,13 +5753,18 @@ class GenericAgentTUI(App[None]):
display_text = raw.strip() if (raw or "").strip() else "/review"
self.submit_user_message(prompt, display_text=display_text)
+ def _rw_rewind_root(self):
+ """世界线树根目录(temp/.ga_rewind),供 continue_list 树感知发现"已回退至起点"的空会话。"""
+ return os.path.join(os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp')), '.ga_rewind')
+
def _cmd_continue(self, args, raw):
sess = self.current
m = re.match(r"/continue\s+(\S.*?)\s*$", (raw or "").strip())
if m:
token = m.group(1)
if token.isdigit():
- sessions = continue_list(exclude_pid=os.getpid())
+ sessions = continue_list(exclude_log=os.path.basename(getattr(sess.agent, "log_path", "") or ""),
+ rewind_root=self._rw_rewind_root())
idx = int(token) - 1
if not (0 <= idx < len(sessions)):
self._system(f"❌ 索引越界(有效范围 1-{len(sessions)})"); return
@@ -4106,7 +5783,8 @@ class GenericAgentTUI(App[None]):
self._system(f"❌ 找不到名为 {token!r} 的会话"); return
self._do_continue_restore(path)
return
- sessions = continue_list(exclude_pid=os.getpid())
+ sessions = continue_list(exclude_log=os.path.basename(getattr(sess.agent, "log_path", "") or ""),
+ rewind_root=self._rw_rewind_root())
if not sessions:
self._system("❌ 没有可恢复的历史会话"); return
choices = []
@@ -4141,25 +5819,54 @@ class GenericAgentTUI(App[None]):
self._refresh_messages()
def _do_continue_restore(self, path: str) -> str:
+ # 默认原地续(接管原日志,延续同一会话)。快照只能拷贝续;被活进程占用 →
+ # 弹窗问是否从原会话拷贝一份继续(复用内联 choice)。
+ import continue_cmd as _cc
sess = self.current
- from continue_cmd import reset_conversation, restore
+ if not _cc.is_snapshot(path):
+ occ = _cc.session_occupant(path)
+ if occ is not None:
+ head = f"该会话正被占用(pid {occ.get('pid', '?')})—— 是否从原会话拷贝一份继续?"
+ msg = ChatMessage(
+ role="system", content=head, kind="choice",
+ choices=[("拷贝一份继续", path), ("取消", None)],
+ on_select=lambda v: (self._continue_restore_apply(v, copy=True) if v else None),
+ )
+ sess.messages.append(msg); self._refresh_messages()
+ return head
+ return self._continue_restore_apply(path, copy=_cc.is_snapshot(path))
+
+ def _continue_restore_apply(self, path: str, copy: bool) -> str:
+ sess = self.current
+ import continue_cmd as _cc
try:
- reset_conversation(sess.agent, message=None)
- result, ok = restore(sess.agent, path)
+ if copy:
+ result, ok = _cc.continue_copy(sess.agent, path, sess.agent_id, allow_empty=True)
+ else:
+ result, ok = _cc.continue_inplace(sess.agent, path, sess.agent_id, allow_empty=True)
except Exception as e:
msg = f"❌ 恢复失败: {e}"
self._system(msg); return msg
if not ok:
self._system(result); return result
- # Mirror the source transcript into this agent's own log file so a
- # future /continue resolves the merged history under the migrated name.
- current_log = getattr(sess.agent, "log_path", "") or ""
- if current_log and path != current_log:
- try:
- import shutil
- shutil.copyfile(path, current_log)
- except Exception:
- pass
+ # 原地:new_log == path(接管原文件);拷贝:new_log 是内容相同的新副本。
+ new_log = getattr(sess.agent, "log_path", "") or ""
+ # 世界线树:重建 store 指向新 log_path;拷贝时再 resume_from 把源会话的树搬过来
+ # (原地直接指向 X 那棵树,无需搬)。
+ try:
+ temp_dir = os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp'))
+ sess.store = RewindStore.for_log(temp_dir, new_log, temp_dir)
+ sess.agent._rw_store = sess.store
+ if copy:
+ old_root = os.path.normpath(os.path.join(
+ temp_dir, '.ga_rewind', RewindStore.key_for_log(path)))
+ sess.store.resume_from(old_root)
+ # 接管后立即对账:此刻 backend.history 已是日志全量(continue_* 末尾 _load_history_into)。
+ # 若源日志曾被不更新树的 UI 续写,树会滞后于日志 → 在这里吸收尾部,使树 ⊇ 日志,
+ # 杜绝之后 rewind 用陈旧树回写、抹掉外部轮次的灾难。
+ sess.store.reconcile(sess.agent.llmclient.backend.history)
+ except Exception:
+ pass
def _finish():
sess.messages.clear()
# Plan state belongs to the *previous* conversation. Clearing it
@@ -4168,31 +5875,71 @@ class GenericAgentTUI(App[None]):
sess.plan_items = []
sess.plan_complete_since = None
sess.plan_lost_since = None
+ sess.restored_plan_path = ""
self._plan_mtime.pop(sess.agent_id, None)
+ # Live mode fills _WRITE_CAP from the tool_before hook; on restore that
+ # is gone, so seed it from the log's structured tool_use inputs (keyed
+ # the same way) — otherwise restored file_write/file_patch fall back to
+ # the raw verbose args block instead of a diff.
+ try:
+ from continue_cmd import iter_write_captures
+ from agent_loop import get_pretty_json
+ for cap in iter_write_captures(path):
+ _WRITE_CAP[hash(get_pretty_json(cap["args"]))] = cap
+ except Exception:
+ pass
for h in continue_extract(path):
sess.messages.append(ChatMessage(role=h["role"], content=h["content"]))
- # baseline=0 lets the scanner see prior plan_X/plan.md refs so an
- # unfinished plan resumes after /continue. Only when the restored
- # plan.md is already all-done do we push baseline past history to
- # suppress the stale ✓ card.
+ # Plan-card restore is keyed off the transcript's structured
+ # `enter_plan_mode` tool_use (find_plan_entry), NOT off plan.md
+ # paths mentioned in chat text — a typed filename can't fake it.
+ # Restore iff the entered plan still exists, parses to ≥1 task,
+ # and isn't all-done (an abandoned finished/headless plan stays
+ # buried). baseline stays 0: it only scopes current_step's 📌 scan.
sess.plan_scan_baseline = 0
import plan_state
- pp = plan_state.resolve_path(sess.agent, messages=sess.messages)
- if pp and os.path.isfile(pp):
+ from continue_cmd import find_plan_entry
+ pp = find_plan_entry(path)
+ rp = plan_state._resolve_stashed(pp) if pp else None
+ if rp:
try:
- with open(pp, encoding="utf-8", errors="replace") as f:
+ with open(rp, encoding="utf-8", errors="replace") as f:
items = plan_state.extract(f.read())
- if items and plan_state.is_complete(items):
- sess.plan_scan_baseline = len(sess.messages)
except OSError:
- pass
+ items = []
+ if items and not plan_state.is_complete(items):
+ sess.restored_plan_path = rp
+ sess.plan_items = items
try:
import session_names
nm = session_names.name_for(path)
if nm:
sess.name = nm
- if current_log:
- session_names.migrate(path, current_log)
+ if new_log and new_log != path: # 仅拷贝续才迁移名字到新副本;原地无需迁移
+ session_names.migrate(path, new_log)
+ except Exception:
+ pass
+ # Auto-restore workspace: if the continued session worked in a
+ # registered workspace, bind it to this session (recreating the
+ # junction if needed) without touching the legacy process anchor.
+ # persist=False: the reset bind must NOT write the session→ws map —
+ # in-place continue points log_path at `path`, so persisting "" here
+ # would erase this session's own mapping before we read it back.
+ self._bind_workspace(self.current, None, persist=False)
+ try:
+ rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录)
+ if rec is not None:
+ ws_path = rec or None # "" → 该会话已 off,明确不恢复
+ else:
+ info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志
+ ws_path = info["path"] if info else None
+ if ws_path:
+ r = workspace_cmd.prepare(ws_path)
+ if r.get("ok"):
+ self._bind_workspace(self.current, r)
+ self._system(f"⌂ 已恢复工作目录: {r['target']}")
+ else:
+ self._system(f"⚠ workspace 恢复失败: {r.get('error')}")
except Exception:
pass
self._remount_current_session()
@@ -4200,6 +5947,68 @@ class GenericAgentTUI(App[None]):
self.call_after_refresh(_finish)
return result.splitlines()[0] if result else "✅ 已恢复"
+ def _cmd_workspace(self, args, raw):
+ # /workspace | /workspace off | /workspace (picker).
+ # Path may contain spaces (Windows) → capture the whole tail.
+ m = re.match(r"/workspace\s+(\S.*?)\s*$", (raw or "").strip())
+ if m:
+ token = m.group(1)
+ if token.lower() == "off":
+ sess = self.current
+ if sess.workspace_name:
+ self._bind_workspace(sess, None)
+ self._system("已退出 workspace(项目模式关闭;junction 与文件保留)")
+ else:
+ self._system("当前未处于 workspace 模式")
+ self._refresh_topbar()
+ return
+ # 直接路径无 picker 面包屑,自己显示一条。
+ self._system(self._do_workspace_activate(token))
+ return
+ # No arg → searchable picker: free-text input (type an abs path to
+ # create/enter) over a candidate list of registered workspaces.
+ sess = self.current
+ choices = []
+ for it in workspace_cmd.registry_list():
+ age = _short_age(it["last_used"]) if it["last_used"] else "—"
+ mem = f"{it['mem_lines']}行记忆" if it["mem_lines"] else "空"
+ flag = " ⚠失效" if it["dangling"] else ""
+ # 显示名取真实目录 basename(天然不含 junction 的 -hash8 后缀);
+ # dangling 无 path 时退回剥掉 name 尾部 hash。名称省略末尾、路径
+ # 省略中间,整行经 ChoiceList 单行渲染不会折行。
+ disp = os.path.basename((it["path"] or "").rstrip("/\\")) \
+ or re.sub(r"-[0-9a-f]{8}$", "", it["name"])
+ label = f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}"
+ choices.append((label, it["path"]))
+ head = ("指定工作目录(输入绝对路径回车新建/进入,或从下方选择已有 · "
+ "↑/↓ 移动,→/Enter 确认,Esc 取消)")
+ msg = ChatMessage(
+ role="system", content=head, kind="choice", choices=choices,
+ on_select=lambda v: self._do_workspace_activate(v),
+ )
+ msg.searchable = True
+ msg.free_input = True # Enter on a typed abs path commits it as a new workspace
+ msg.all_choices = list(choices)
+ sess.messages.append(msg)
+ self._refresh_messages()
+
+ def _do_workspace_activate(self, path: str) -> str:
+ # 唯一展示文本 = 返回值:picker 路径由 _collapse_choice 渲染成 `✓ …`
+ # 面包屑;直接 `/workspace ` 路径由 _cmd_workspace 用 _system 显示。
+ # 两条路径各出一条,故此处不再自行 _system(否则与面包屑重复)。
+ r = workspace_cmd.prepare(path)
+ if not r.get("ok"):
+ return f"❌ workspace 设定失败: {r.get('error')}"
+ self._bind_workspace(self.current, r)
+ self._refresh_topbar()
+ # 显示名去 hash(与 picker 一致):真实目录 basename,退回剥 name 尾 hash。
+ disp = os.path.basename((r.get("target") or "").rstrip("/\\")) \
+ or re.sub(r"-[0-9a-f]{8}$", "", r.get("name") or "")
+ out = f"✅ 已进入 workspace「{disp}」"
+ if r.get("warning"):
+ out += f" ⚠ {r['warning']}"
+ return out
+
def _cmd_cost(self, args, raw):
try:
import cost_tracker
@@ -4812,6 +6621,7 @@ class GenericAgentTUI(App[None]):
sess.current_task_id = tid
sess.buffer = ""
sess.status = "running"
+ sess._rw_title = text # checkpoint 标题 = 本次用户输入(段末 commit 用)
image_paths = list(images or [])
visible_text = text if display_text is None else display_text
sess.messages.append(ChatMessage("user", visible_text, image_paths=image_paths))
@@ -4883,6 +6693,7 @@ class GenericAgentTUI(App[None]):
s.current_display_queue = None
self._update_assistant(agent_id, text, task_id=task_id, done=done, refresh_chrome=True)
if done:
+ self._rw_commit(s) # 落 checkpoint 节点(文件改动已由 tool_before 钩子追踪)
self._update_plan_state(s, text)
self._drain_ask_user_events(s)
@@ -5091,14 +6902,13 @@ class GenericAgentTUI(App[None]):
def _update_plan_state(self, sess: AgentSession, _stream_text: str = "") -> None:
import plan_state
prev = sess.plan_items
- # Detect plan mode: `working['in_plan_mode']` first, fallback to per-
- # session message scan for a `plan_*/plan.md` reference. Strictly
- # per-session via `plan_scan_baseline` to avoid /continue bleed.
+ # Detect plan mode: `working['in_plan_mode']` (live) first, then
+ # `restored_plan_path` (/continue, recovered from the structured
+ # enter_plan_mode tool_use). Chat text mentioning a plan path is
+ # deliberately NOT a signal — no messages passed.
new_items: list = []
- msgs = sess.messages
- base = sess.plan_scan_baseline
- if plan_state.is_active(sess.agent, messages=msgs, start_idx=base):
- path = plan_state.resolve_path(sess.agent, messages=msgs, start_idx=base)
+ if plan_state.is_active(sess.agent, restored_path=sess.restored_plan_path):
+ path = plan_state.resolve_path(sess.agent, restored_path=sess.restored_plan_path)
if path:
try:
with open(path, encoding="utf-8", errors="replace") as f:
@@ -5131,7 +6941,8 @@ class GenericAgentTUI(App[None]):
# Plan-mode armed but no items yet → placeholder (covers the
# enter_plan_mode → first plan.md write gap).
if not items:
- if sess and plan_state.is_active(sess.agent, messages=msgs, start_idx=base):
+ if sess and plan_state.is_active(sess.agent,
+ restored_path=sess.restored_plan_path):
self._render_planbar_placeholder(bar, sess)
return
self._set_planbar_visible(bar, False); return
@@ -5140,29 +6951,26 @@ class GenericAgentTUI(App[None]):
if complete and sess and sess.plan_complete_since is not None:
if time.time() - sess.plan_complete_since >= self._PLAN_GRACE_SEC:
self._set_planbar_visible(bar, False); return
- # 5-row budget: header(1) + step(0/1) + tasks(N) + overflow(0/1).
+ # Render all tasks — #planbar-tasks caps the visible window at 4 rows and
+ # scrolls the rest. Open tasks first, done last (open work stays on top).
step = plan_state.current_step(msgs, start_idx=base)
- budget = 4 - (1 if step else 0)
ordered = [(c, st) for c, st in items if st != "done"] + \
[(c, st) for c, st in items if st == "done"]
- body_lines = budget - 1 if len(ordered) > budget else budget
- shown = ordered[:body_lines]
- overflow = max(0, len(ordered) - body_lines)
- sig = (tuple(shown), overflow, step, bool(complete and sess and sess.plan_complete_since))
- if getattr(bar, "_plan_sig", None) == sig and bar.display: return
+ sig = (tuple(ordered), step, bool(complete and sess and sess.plan_complete_since))
+ if getattr(bar, "_plan_sig", None) == sig and self._planbar_shown(): return
bar._plan_sig = sig
- body = Text()
- head = f"✓ Plan complete ({n_total}/{n_total})\n" if complete else f"📋 Plan ({n_done}/{n_total})\n"
- body.append(head, style=f"bold {C_GREEN}")
+ head = Text()
+ head.append(f"✓ Plan complete ({n_total}/{n_total})" if complete
+ else f"📋 Plan ({n_done}/{n_total})", style=f"bold {C_GREEN}")
if step:
- body.append(" ▸ ", style=C_GREEN)
- body.append(step[:120] + "\n", style=C_MUTED)
- for c, st in shown:
- if st == "done": body.append(" ✔ ", style=C_GREEN); body.append(c + "\n", style=C_DIM)
- else: body.append(" ☐ ", style=C_DIM); body.append(c + "\n", style=C_FG)
- if overflow:
- body.append(f" ⋮ +{overflow} more", style=C_DIM)
- bar.update(body)
+ head.append("\n ▸ ", style=C_GREEN)
+ head.append(step[:120], style=C_MUTED)
+ body = Text()
+ for i, (c, st) in enumerate(ordered):
+ if i: body.append("\n")
+ if st == "done": body.append(" [x] ", style=C_GREEN); body.append(c, style=C_DIM)
+ else: body.append(" [ ] ", style=C_DIM); body.append(c, style=C_FG)
+ self._planbar_paint(head, body, bar)
self._set_planbar_visible(bar, True)
def _render_planbar_placeholder(self, bar: Static, sess: AgentSession) -> None:
@@ -5170,31 +6978,49 @@ class GenericAgentTUI(App[None]):
import plan_state
base = sess.plan_scan_baseline
path = (plan_state._stashed_plan_path(sess.agent)
- or plan_state.find_path_in_messages(sess.messages, start_idx=base)
+ or sess.restored_plan_path
or "")
hint = "/".join(path.replace("\\", "/").rstrip("/").split("/")[-2:]) if path else "plan.md"
step = plan_state.current_step(sess.messages, start_idx=base)
sig = ("__placeholder__", hint, step)
- if getattr(bar, "_plan_sig", None) == sig and bar.display: return
+ if getattr(bar, "_plan_sig", None) == sig and self._planbar_shown(): return
bar._plan_sig = sig
- body = Text()
- body.append("📋 Plan 模式已激活\n", style=f"bold {C_GREEN}")
+ head = Text()
+ head.append("📋 Plan 模式已激活", style=f"bold {C_GREEN}")
if step:
- body.append(" ▸ ", style=C_GREEN)
- body.append(step[:120] + "\n", style=C_MUTED)
+ head.append("\n ▸ ", style=C_GREEN)
+ head.append(step[:120], style=C_MUTED)
+ body = Text()
body.append(f" 等待写入 {hint} …", style=C_DIM)
- bar.update(body)
+ self._planbar_paint(head, body, bar)
self._set_planbar_visible(bar, True)
+ def _planbar_paint(self, head: Text, body: Text, bar: Static) -> None:
+ # Header/step go to the pinned #planbar-head; tasks to #planbar (the
+ # scrolling body). bar is #planbar, passed in by the callers.
+ try: self.query_one("#planbar-head", Static).update(head)
+ except Exception: pass
+ bar.update(body)
+
+ def _planbar_shown(self) -> bool:
+ try: return self.query_one("#planbar-scroll", Vertical).has_class("-visible")
+ except Exception: return False
+
def _set_planbar_visible(self, bar: Static, visible: bool) -> None:
- # Repaint only on show→hide transition; idle ticks no-op.
+ # Visibility lives on the outer container (display:none ↔ -visible),
+ # mirroring #palette. Repaint only on show→hide transition; idle ticks no-op.
+ try: cont = self.query_one("#planbar-scroll", Vertical)
+ except Exception: return
if not visible:
- if not bar.display: return
- bar.display = False
+ if not cont.has_class("-visible"): return
+ cont.remove_class("-visible")
+ try: self.query_one("#planbar-head", Static).update(Text())
+ except Exception: pass
bar.update(Text())
bar._plan_sig = None
return
- if not bar.display: bar.display = True
+ if not cont.has_class("-visible"):
+ cont.add_class("-visible")
def _start_plan_watcher(self) -> None:
if getattr(self, "_plan_timer", None) is not None: return
@@ -5207,11 +7033,9 @@ class GenericAgentTUI(App[None]):
import plan_state
sess = self.sessions.get(self.current_id) if self.current_id is not None else None
if sess is None: return
- msgs = sess.messages
- base = sess.plan_scan_baseline
- if not plan_state.is_active(sess.agent, messages=msgs, start_idx=base):
+ if not plan_state.is_active(sess.agent, restored_path=sess.restored_plan_path):
self._refresh_planbar(); return
- path = plan_state.resolve_path(sess.agent, messages=msgs, start_idx=base)
+ path = plan_state.resolve_path(sess.agent, restored_path=sess.restored_plan_path)
if not path:
self._refresh_planbar(); return
try: mtime = os.path.getmtime(path)
@@ -5332,11 +7156,14 @@ class GenericAgentTUI(App[None]):
self._chip_timer = None
try: term_w = self.size.width
except Exception: term_w = 0
+ # Workspace label is per-session, not the legacy process-global anchor.
+ p = (s.workspace_path or "").rstrip("/\\")
+ ws_name = os.path.basename(p) if p else s.workspace_name
self.query_one("#topbar", Static).update(
render_topbar(s.name, s.status, model, tasks_running,
fold_mode=self.fold_mode, busy_elapsed=elapsed, effort=effort,
sess_elapsed=sess_elapsed, just_done=just_done,
- term_width=term_w))
+ term_width=term_w, workspace=ws_name))
self._ensure_title_timer()
self._update_terminal_title()
@@ -5484,28 +7311,106 @@ class GenericAgentTUI(App[None]):
# A parallel wide render builds a wrap-free "source" string that
# SelectableStatic.get_selection uses, so copy never includes wrap newlines.
try:
- text = _TASKLIST_OPEN_RE.sub(r"\1☐ ", text)
- text = _TASKLIST_DONE_RE.sub(r"\1✔ ", text)
- text = _TOOL_USE_RE.sub(_render_tool_use_block, text)
- text = _META_TAG_RE.sub("", text)
from io import StringIO
from rich.console import Console
render_w = max(1, width - 1)
+ text = _TASKLIST_OPEN_RE.sub(r"\1☐ ", text)
+ text = _TASKLIST_DONE_RE.sub(r"\1✔ ", text)
+ # file_write/file_patch render as a themed diff that markdown can't
+ # express; swap them for a sentinel now and splice the prerendered
+ # ANSI (narrow) / plain (wide) blocks back in after markdown runs.
+ diff_blocks: dict[int, tuple[str, str]] = {}
+ def _stash(rendered, _b=diff_blocks):
+ n = len(_b)
+ _b[n] = rendered
+ return f"\n\nGADIFFSENTINEL{n}END\n\n"
+
+ # Render a standalone markdown block to (narrow-ANSI, wide-plain)
+ # streams with the card left margin: same theme/widths as the main
+ # render so the spliced block matches its surroundings, but only the
+ # narrow lines get the visual-only margin (kept out of the copy
+ # source). Shared by the code_run card and the generic tool margin.
+ def _md_to_streams(seg, _w=render_w):
+ nbuf, wbuf = StringIO(), StringIO()
+ Console(file=nbuf, width=max(8, _w - _DIFF_MARGIN),
+ force_terminal=True, color_system="truecolor",
+ legacy_windows=False,
+ theme=_markdown_rich_theme(_palette, minimal=(self.theme != "ga-default"))
+ ).print(HardBreakMarkdown(seg), end="")
+ Console(file=wbuf, width=10000, force_terminal=False,
+ legacy_windows=False).print(HardBreakMarkdown(seg), end="")
+ margin = " " * _DIFF_MARGIN
+ nar = "\n".join(margin + l for l in nbuf.getvalue().rstrip("\n").split("\n"))
+ return nar, wbuf.getvalue().rstrip("\n")
+
+ # Primary path: the verbose `🛠️ Tool: … 📥 args:` block. Hash the
+ # fence body to recover the real args the snapshot hook captured.
+ def _sub_verbose(m, _w=render_w):
+ cap = _WRITE_CAP.get(hash(m.group(2)))
+ if cap:
+ if cap["name"] == "file_read":
+ r = _render_read_card(cap["args"], cap.get("content"), _w)
+ elif cap["name"] == "code_run":
+ r = _render_code_card(cap["args"], cap.get("data"), _w)
+ else:
+ status, msg = cap.get("status"), cap.get("msg", "")
+ st = _fence_status(m.group(3))
+ if st:
+ status, msg = st
+ r = _render_write_diff(cap["name"], cap["args"],
+ cap["existed"], cap["old"], _w,
+ status=status, msg=msg)
+ if r:
+ return _stash(r)
+ return m.group(0)
+ text = _VERBOSE_WRITE_RE.sub(_sub_verbose, text)
+ # Every other tool keeps its Rich-markdown look but gains the same
+ # left margin: render the block standalone with the same theme.
+ # file_read / code_run with a missing capture fall through here and
+ # at least get the margin (their bespoke cards already ran above).
+ def _sub_tool_margin(m, _w=render_w):
+ return _stash(_md_to_streams(m.group(0), _w))
+ text = _VERBOSE_TOOL_RE.sub(_sub_tool_margin, text)
+ # Legacy path: a raw `{…}` envelope (weak-tool
+ # models that inline tool calls as text rather than native calls).
+ def _sub_tool(m, _w=render_w):
+ try:
+ obj = json.loads(m.group(1))
+ except Exception:
+ return m.group(0)
+ if obj.get("name") in ("file_write", "file_patch"):
+ args = obj.get("arguments") or {}
+ r = _render_write_diff(obj.get("name"), args, False, "", _w)
+ if r:
+ return _stash(r)
+ return _render_tool_use_block(m)
+ text = _TOOL_USE_RE.sub(_sub_tool, text)
+ text = _META_TAG_RE.sub("", text)
buf = StringIO()
Console(file=buf, width=render_w, force_terminal=True,
color_system="truecolor", legacy_windows=False,
theme=_markdown_rich_theme(_palette, minimal=(self.theme != "ga-default"))
).print(HardBreakMarkdown(text), end="")
narrow_raw = buf.getvalue().rstrip("\n")
- t = Text.from_ansi(narrow_raw)
- t.highlight_regex(r"✔[^\n]*", style=C_DIM)
- t.highlight_regex(r"☐", style=C_DIM)
- t.highlight_regex(r"✔", style=C_GREEN)
wide_buf = StringIO()
Console(file=wide_buf, width=10000, force_terminal=False,
legacy_windows=False).print(HardBreakMarkdown(text), end="")
wide_raw = wide_buf.getvalue().rstrip("\n")
+
+ # Splice diff blocks over their sentinel lines (ANSI → narrow,
+ # plain → wide). Identical post-rstrip text keeps line counts equal
+ # so `_align_md_renders` pairs them via its K==W branch.
+ for n, (ansi_block, plain_block) in diff_blocks.items():
+ pat = re.compile(rf"^.*GADIFFSENTINEL{n}END.*$", re.M)
+ narrow_raw = pat.sub(lambda _m, _a=ansi_block: _a, narrow_raw, count=1)
+ wide_raw = pat.sub(lambda _m, _p=plain_block: _p, wide_raw, count=1)
+
+ t = Text.from_ansi(narrow_raw)
+ t.highlight_regex(r"✔[^\n]*", style=C_DIM)
+ t.highlight_regex(r"☐", style=C_DIM)
+ t.highlight_regex(r"✔", style=C_GREEN)
+
narrow_plain = _ANSI_SGR_RE.sub("", narrow_raw)
# `_align_md_renders` handles Rich table/box-drawing runs at run
# granularity: only the table block is copied visually, while normal
@@ -5826,10 +7731,15 @@ class GenericAgentTUI(App[None]):
batch=m.lazy_choice_batch or 50,
classes="picker",
)
+ elif m.searchable and not m.choices:
+ # Async-filled picker still loading (or empty): disabled
+ # hint row under the Input, same shape as the no-matches row.
+ widget = ChoiceList(m, m.empty_hint, classes="picker")
+ widget.disabled = True
else:
widget = ChoiceList(m, classes="picker")
for cl, _ in m.choices:
- widget.add_option(Option(cl))
+ widget.add_option(cl)
# `searchable` wraps the freshly-built picker in a Vertical
# container with an Input filter on top. The original picker
# is preserved as `.picker` so `_active_choice`, key routing
@@ -6052,6 +7962,734 @@ def _warn_mintty():
raise SystemExit(1)
+
+#============================================================================
+# worldline three-pane tree UI (inlined from rewind_tree_view.py; colors follow v2 theme)
+#============================================================================
+LANE_COLORS = [C_BLUE, C_PURPLE, C_GREEN, C_CYAN, C_LAVENDER, C_AMBER]
+
+
+def lane_color(depth: int) -> str:
+ return LANE_COLORS[depth % len(LANE_COLORS)]
+
+
+KIND_COLOR = {"current": C_GREEN, "rewind": C_AMBER, "origin": C_FG}
+
+
+def kind_style(kind: str):
+ """(字形, 颜色, 标签):字形/标签取自后端语义,颜色由前端配色决定。
+ 保留 3 元组形态,render_tree / 详情区调用点无需改动。"""
+ return kind_glyph(kind), KIND_COLOR.get(kind, C_FG), kind_label(kind)
+
+
+@dataclass
+class RenderRow:
+ key: int
+ depth: int
+ text: Text
+ node_start: int
+
+
+def render_tree(ct: CompressedTree, selected_key: int) -> List[RenderRow]:
+ rows: List[RenderRow] = []
+ sel_depth = ct.disp[selected_key].depth if selected_key in ct.disp else 0
+
+ def node_style(key: int, depth: int) -> tuple[str, str]:
+ d = ct.disp[key]
+ base = C_DIM if d.kind == "fold" else kind_style(ct.tree.nodes[d.node_id].kind)[1]
+ if key == selected_key:
+ return base, f"bold {base} on {C_ALT_BG}"
+ if depth == sel_depth:
+ return base, base
+ return base, C_DIM
+
+ def dfs(key: int, is_last: bool, prefix_active: List[bool]) -> None:
+ d = ct.disp[key]
+ line = Text()
+ for col, active in enumerate(prefix_active):
+ if not active:
+ line.append(" ")
+ continue
+ style = lane_color(col) if col == sel_depth else C_DIM
+ line.append("│ ", style=style)
+ if d.parent_key is not None:
+ connector = "╰─ " if is_last else "├─ "
+ cstyle = lane_color(d.depth) if d.depth == sel_depth else C_DIM
+ line.append(connector, style=cstyle)
+ base, gstyle = node_style(key, d.depth)
+ node_start = line.cell_len
+ line.append(ct.glyph(key), style=gstyle)
+ line.append(" ")
+ lbl_style = f"bold {C_FG} on {C_ALT_BG}" if key == selected_key else (
+ base if d.depth == sel_depth else C_MUTED)
+ line.append(ct.label(key), style=lbl_style)
+ line.append(f" {rel_time(ct.end_node(key).ago)}", style=C_DIM)
+ rows.append(RenderRow(key=key, depth=d.depth, text=line, node_start=node_start))
+ kids = d.children
+ child_prefix = prefix_active + [not is_last]
+ for i, ck in enumerate(kids):
+ dfs(ck, i == len(kids) - 1, child_prefix)
+
+ for i, rk in enumerate(ct.roots):
+ dfs(rk, i == len(ct.roots) - 1, [])
+ return rows
+
+# ------------------------------------------------------- Textual 部件
+class ClickableTree(Static):
+ class NodeClicked(Message):
+ def __init__(self, key: int) -> None:
+ self.key = key
+ super().__init__()
+
+ def __init__(self, **kw) -> None:
+ super().__init__("", **kw)
+ self.rows: List[RenderRow] = []
+
+ def set_rows(self, rows: List[RenderRow]) -> None:
+ self.rows = rows
+ body = Text("\n").join(r.text for r in rows) if rows else Text("")
+ body.no_wrap = True # 禁折行:窄窗靠横向 viewport,绝不折行(否则连线错位)
+ self.update(body)
+
+ def on_click(self, event) -> None:
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ if 0 <= off.y < len(self.rows) and off.x >= self.rows[off.y].node_start:
+ event.stop()
+ self.post_message(self.NodeClicked(self.rows[off.y].key))
+
+
+class ClickableList(Static):
+ class SegClicked(Message):
+ def __init__(self, index: int) -> None:
+ self.index = index
+ super().__init__()
+
+ def __init__(self, **kw) -> None:
+ super().__init__("", **kw)
+ self.idxs: List[Optional[int]] = []
+
+ def set_content(self, text: Text, idxs: List[Optional[int]]) -> None:
+ self.idxs = idxs
+ self.update(text)
+
+ def on_click(self, event) -> None:
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ y = off.y
+ if 0 <= y < len(self.idxs) and self.idxs[y] is not None:
+ event.stop()
+ self.post_message(self.SegClicked(self.idxs[y]))
+
+
+class RestoreModeScreen(ModalScreen):
+ """CC 式回退模式选择弹窗:在世界线里 Enter 选中节点后弹出,选恢复模式。
+
+ dismiss 返回 `"both"|"conv"|"code"` 或 None(取消)。`to` 决定文案:
+ `at`=在此节点继续(HEAD→该节点);`before`=回到该提问之前。"""
+
+ CSS = f"""
+ RestoreModeScreen {{ align: center middle; }}
+ #rmode_box {{
+ width: 50; height: auto; padding: 1 2;
+ border: round {C_GREEN}; background: {C_SEL_BG};
+ }}
+ RestoreModeScreen Static {{ background: transparent; }}
+ """
+ BINDINGS = [
+ Binding("up,k", "up", "↑", priority=True),
+ Binding("down,j", "down", "↓", priority=True),
+ Binding("enter", "confirm", "确认", priority=True),
+ Binding("c", "pick_conv", "仅对话", priority=True),
+ Binding("o", "pick_code", "仅代码", priority=True),
+ Binding("escape", "cancel", "取消", priority=True),
+ ]
+ _OPTS = [("both", "恢复对话 + 代码"), ("conv", "仅对话"), ("code", "仅代码")]
+
+ def __init__(self, title: str, to: str) -> None:
+ super().__init__()
+ self._title = title
+ self._to = to
+ self.sel = 0
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="rmode_box"):
+ yield Static("", id="rmode_body")
+
+ def on_mount(self) -> None:
+ self._redraw()
+
+ def _redraw(self) -> None:
+ body = Text()
+ verb = "在此继续会话" if self._to == "at" else "回到此提问之前"
+ body.append(f"{verb}:", style=C_BLUE)
+ body.append(f"{ellipsize(self._title, 28)}\n\n", style=f"bold {C_FG}")
+ for i, (_, lbl) in enumerate(self._OPTS):
+ active = i == self.sel
+ body.append("▶ " if active else " ", style=C_GREEN if active else C_DIM)
+ body.append(lbl + "\n",
+ style=f"bold {C_FG} on {C_ALT_BG}" if active else C_MUTED)
+ body.append("\n↑↓ 选择 Enter 确认 c 仅对话 o 仅代码 Esc 取消", style=C_DIM)
+ self.query_one("#rmode_body", Static).update(body)
+
+ def action_up(self) -> None:
+ self.sel = (self.sel - 1) % len(self._OPTS); self._redraw()
+
+ def action_down(self) -> None:
+ self.sel = (self.sel + 1) % len(self._OPTS); self._redraw()
+
+ def action_confirm(self) -> None:
+ self.dismiss(self._OPTS[self.sel][0])
+
+ def action_pick_conv(self) -> None:
+ self.dismiss("conv")
+
+ def action_pick_code(self) -> None:
+ self.dismiss("code")
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
+
+
+class RewindTreeScreen(ModalScreen):
+ """三栏全屏 checkpoint 浏览器(世界线)。dismiss 结果:
+ `{"action":"restore","mode":"both"|"conv"|"code","node":nid,"to":"before"|"at"}`
+ 或 None(取消)。`to=before`=回到该提问之前(prefill);`to=at`=在该节点继续
+ (HEAD→该节点,无 prefill,= tip「当前位置/新分支」)。
+ diff / delete 在屏内直接读/改 store(delete 后重建树)。"""
+
+ CSS = f"""
+ RewindTreeScreen {{ background: {C_BG}; }}
+ * {{
+ scrollbar-background: {C_BG};
+ scrollbar-color: {C_BORDER};
+ scrollbar-color-hover: {C_DIM};
+ scrollbar-color-active: {C_DIM};
+ scrollbar-corner-color: {C_BG};
+ }}
+ /* ModalScreen defaults to height:auto + a centered dialog box, which
+ collapses our %/fr-based three-pane layout into a small popup. Force the
+ screen to fill the terminal and give the top Horizontal the full height so
+ the panes' percentages resolve against a real size again. */
+ RewindTreeScreen {{ height: 100%; overflow: hidden; }}
+ RewindTreeScreen > Horizontal {{ height: 1fr; width: 100%; }}
+ #rw3_left {{
+ width: 44%; height: 1fr; border: round {C_BORDER}; border-title-color: {C_GREEN};
+ padding: 0 1; overflow-x: auto; overflow-y: auto;
+ }}
+ #rw3_lefttree {{ width: auto; height: auto; }}
+ #rw3_right {{ width: 1fr; height: 1fr; }}
+ #rw3_rtop {{
+ height: 50%; border: round {C_BORDER}; border-title-color: {C_BLUE};
+ padding: 0 1; overflow-y: auto;
+ }}
+ #rw3_rbot {{
+ height: 1fr; border: round {C_BORDER}; border-title-color: {C_PURPLE};
+ padding: 0 1;
+ }}
+ #rw3_rbot_body {{ height: auto; }}
+ #rw3_diff {{ height: 1fr; overflow-y: auto; overflow-x: auto; scrollbar-size-vertical: 1; }}
+ #rw3_diff_body {{ height: auto; width: auto; }}
+ RewindTreeScreen Static {{ background: transparent; }}
+ #rw3_status {{ height: 1; color: {C_DIM}; padding: 0 1; }}
+ """
+ BINDINGS = [
+ # 上下 = 移动**聚焦**窗口(左树按整树视觉行序 / 右上段内含 tip)。
+ Binding("up,k", "up", "↑", priority=True),
+ Binding("down,j", "down", "↓", priority=True),
+ # Ctrl+↑↓ = 移动**非聚焦**窗口(ctrl 在部分终端不可靠 → PgUp/PgDn、[ ] 兜底)。
+ Binding("ctrl+up,pageup,left_square_bracket", "other_up", "非聚焦↑", priority=True),
+ Binding("ctrl+down,pagedown,right_square_bracket", "other_down", "非聚焦↓", priority=True),
+ # ←/→ = 左树切换层级(列);Tab = 翻转聚焦框(另由 on_key 兜底,见下)。
+ Binding("left,h", "level_up", "上层", priority=True),
+ Binding("right,l", "level_down", "下层", priority=True),
+ Binding("tab", "focus_toggle", "切聚焦", priority=True),
+ # Enter:左聚焦 → 进入右侧节点选择;右聚焦 → 弹回退模式窗。
+ Binding("enter", "enter", "进入/回退", priority=True),
+ Binding("x", "delete", "删除子树"),
+ Binding("escape", "cancel", "取消"),
+ ]
+
+ def __init__(self, store) -> None:
+ super().__init__()
+ self.store = store
+ self.ct = CompressedTree(tree_from_store(store, __import__("time").time()))
+ self._mark_current()
+ flat = self.ct.flatten()
+ # 打开即聚焦到「当前位置」所在的显示节点(折叠组)。
+ self.sel_key = self._disp_key_for_node(self._current_node()) or (flat[0] if flat else 0)
+ self.focus_right = False
+ # 默认聚焦到「当前位置」(见 _default_seg_idx)。
+ self.seg_idx = self._default_seg_idx()
+ self._diff_for: Optional[str] = None # 正在内联展示 diff 的节点 id
+
+ # ---- 当前位置:就一个游标。有游标=回退到的那个节点;没有=末端 tip ----
+ def _cursor(self):
+ """rewind 游标 = 被截断回退到的那个节点(用户心中的「当前位置」)。集成层在
+ rewind 时记到 store._rw_cursor,继续提问时清除。无效/无 → None(当前=末端 tip)。
+
+ 一致性:从「当前位置」再进去要重现当前视图。rewind 到 X→视图=parent(X)前;
+ 从 X 进去=rewind 到 X=同样视图 ✓。所以当前位置就是 X 本身。"""
+ cur = getattr(self.store, "_rw_cursor", None)
+ return cur if (cur and cur in self.store.nodes) else None
+
+ def _current_node(self):
+ """开屏定位用的「当前位置」节点:游标 or HEAD。"""
+ return self._cursor() or self.store.head
+
+ def _mark_current(self) -> None:
+ """◉「当前」只两种落点:有游标→标在游标节点;无游标→不标节点(末端 tip 承载)。
+ 先撤掉 tree_from_store 给 HEAD 的自动 current。"""
+ head, nodes = self.store.head, self.ct.tree.nodes
+ if head in nodes:
+ nodes[head].kind = self.store.nodes.get(head, {}).get("kind") or "edit"
+ cur = self._cursor()
+ if cur and cur in nodes:
+ nodes[cur].kind = "current"
+
+ def _default_seg_idx(self) -> int:
+ """▶ 默认落「当前位置」:有游标→游标节点;无游标→末端 tip。"""
+ seg = self._current_seg()
+ if not seg:
+ return 0
+ cur = self._cursor()
+ if cur:
+ idx = seg.index(cur) if cur in seg else len(seg)
+ else:
+ idx = len(seg)
+ return min(idx, self._seg_max_idx(seg)) # 无 tip 的 origin 段:落到末项而非占位项
+
+ def _disp_key_for_node(self, node_id) -> Optional[int]:
+ """找包含 node_id 的显示节点 key(真实节点本身 / 折叠段含它)。"""
+ if node_id is None:
+ return None
+ for k, d in self.ct.disp.items():
+ if d.kind == "real" and d.node_id == node_id:
+ return k
+ if d.kind == "fold" and node_id in d.seg:
+ return k
+ return None
+
+ def _seg_of(self, key) -> List[str]:
+ d = self.ct.disp.get(key)
+ if d is None:
+ return []
+ return list(d.seg) if d.kind == "fold" else [d.node_id]
+
+ def compose(self) -> ComposeResult:
+ with Horizontal():
+ with ScrollableContainer(id="rw3_left"):
+ yield ClickableTree(id="rw3_lefttree")
+ with Vertical(id="rw3_right"):
+ with VerticalScroll(id="rw3_rtop"):
+ yield ClickableList(id="rw3_rtop_body")
+ with Vertical(id="rw3_rbot"):
+ yield Static("", id="rw3_rbot_body")
+ with VerticalScroll(id="rw3_diff"):
+ yield Static("", id="rw3_diff_body")
+ yield Static("", id="rw3_status")
+
+ def on_mount(self) -> None:
+ self.query_one("#rw3_left").border_title = "世界线 · 全局压缩树"
+ self.query_one("#rw3_rtop").border_title = "被折叠的节点"
+ self.query_one("#rw3_rbot").border_title = "详情 / 操作"
+ self.query_one("#rw3_rtop").can_focus = False
+ # 左栏设为可聚焦并**抓住焦点**:让本 modal 真正持有键盘。否则全部子组件
+ # can_focus=False → 焦点留在下层输入框,Tab 会被输入框的命令补全吃掉、屏的
+ # on_key 收不到(↑↓ 靠 priority binding 才照常生效)。聚焦框仍由
+ # _refresh_focus_frame 手动画(亮青边框),与 Textual 默认 focus 视觉无关。
+ left = self.query_one("#rw3_left")
+ left.can_focus = True
+ left.focus()
+ self.refresh_all()
+
+ # ---- 联动 ----
+ def _set_left_selection(self, key: int, keep_focus: bool = False) -> None:
+ """选中左树某显示节点。keep_focus=True 时不夺回聚焦(供 Ctrl 移动非聚焦左树)。
+ 右上默认选中「当前(绿)记录」或最后一条真实记录(见 _default_seg_idx)。"""
+ self.sel_key = key
+ if not keep_focus:
+ self.focus_right = False
+ self.seg_idx = self._default_seg_idx()
+ self._diff_for = None
+ self.refresh_all()
+
+ def refresh_all(self) -> None:
+ rows = render_tree(self.ct, self.sel_key)
+ self.query_one("#rw3_lefttree", ClickableTree).set_rows(rows)
+ self._refresh_rtop()
+ self._refresh_rbot()
+ self._refresh_status()
+ self._refresh_focus_frame()
+ sel_idx = next((i for i, r in enumerate(rows) if r.key == self.sel_key), 0)
+ self._follow_left(rows, sel_idx)
+
+ def _refresh_focus_frame(self) -> None:
+ """聚焦框:聚焦窗口边框高亮(亮青),另一个用普通边框色。"""
+ try:
+ left = self.query_one("#rw3_left")
+ rtop = self.query_one("#rw3_rtop")
+ left.styles.border = ("round", C_BORDER if self.focus_right else C_CYAN)
+ rtop.styles.border = ("round", C_CYAN if self.focus_right else C_BORDER)
+ except Exception:
+ pass
+
+ def _follow_left(self, rows, sel_idx) -> None:
+ self._ensure_line_visible("#rw3_left", sel_idx)
+ try:
+ c = self.query_one("#rw3_left")
+ w = c.scrollable_size.width
+ if w <= 0 or sel_idx >= len(rows):
+ return
+ sx = int(c.scroll_offset.x)
+ x0 = rows[sel_idx].node_start
+ x1 = rows[sel_idx].text.cell_len
+ if x0 < sx or x0 >= sx + w:
+ c.scroll_to(x=max(0, x0 - 2), animate=False)
+ elif x1 > sx + w:
+ c.scroll_to(x=max(0, min(x0 - 2, x1 - w + 1)), animate=False)
+ except Exception:
+ pass
+
+ def _ensure_line_visible(self, selector, y) -> None:
+ try:
+ c = self.query_one(selector)
+ h = c.scrollable_size.height
+ if h <= 0:
+ return
+ sy = int(c.scroll_offset.y)
+ if y < sy:
+ c.scroll_to(y=y, animate=False)
+ elif y >= sy + h:
+ c.scroll_to(y=y - h + 1, animate=False)
+ except Exception:
+ pass
+
+ def _current_seg(self) -> List[str]:
+ return self._seg_of(self.sel_key)
+
+ def _tip_kind(self) -> str:
+ """末尾 tip 的语义:HEAD 是叶 且就是本段末端 → current(当前位置·自此继续);
+ 否则 → new(自此另起一条对话)。HEAD 有后代(rewind 未继续)时,当前位置标在那个
+ 中间节点本身(◉),tip 一律是 new。"""
+ if self._cursor():
+ return "new" # 当前在游标节点(◉),末端一律 new
+ seg = self._current_seg()
+ # 无游标:本段末端就是 HEAD → 末端 tip 即「当前位置·自此继续」。
+ return "current" if (seg and seg[-1] == self.store.head) else "new"
+
+ def _seg_has_tip(self, seg=None) -> bool:
+ """段末是否显示「自此继续」占位项。会话起点(origin)不显示:选中 origin 项本身
+ 即回到空起点,与「自此继续」语义重复。"""
+ seg = self._current_seg() if seg is None else seg
+ return not (seg and self.ct.tree.nodes[seg[-1]].kind == "origin")
+
+ def _seg_max_idx(self, seg=None) -> int:
+ """seg_idx 上界:有 tip → len(seg)(可停在占位项);无 tip → 最后一条真实项。"""
+ seg = self._current_seg() if seg is None else seg
+ return len(seg) if self._seg_has_tip(seg) else max(0, len(seg) - 1)
+
+ def _refresh_rtop(self) -> None:
+ d = self.ct.disp[self.sel_key]
+ seg = self._current_seg()
+ lines: List[Text] = []
+ idxs: List[Optional[int]] = [] # 行 → seg 索引(tip = len(seg)),None=不可选
+ lines.append(Text("选中某条提问可回退到其之前;末尾一项则从当前位置继续", style=C_DIM)); idxs.append(None)
+ lines.append(Text("")); idxs.append(None)
+ for i, nid in enumerate(seg):
+ n = self.ct.tree.nodes[nid]
+ active = i == self.seg_idx
+ g, gs, _ = kind_style(n.kind)
+ rs = (f"bold {C_FG} on {C_ALT_BG}" if active and self.focus_right
+ else (C_FG if active else C_MUTED))
+ line = Text()
+ line.append("▶ " if active else " ",
+ style=(C_CYAN if self.focus_right else C_DIM) if active else C_DIM)
+ line.append(f"{g} ", style=C_GREEN if active else gs)
+ line.append(ellipsize(n.title, 54), style=rs)
+ line.append(f" {rel_time(n.ago)}", style=C_DIM)
+ lines.append(line); idxs.append(i)
+ # 末尾占位项(尚未创建,用括号标注);圆点字形,颜色同普通记录(new)/绿(current)。
+ # 会话起点(origin)不显示此占位项(_seg_has_tip):选中 origin 项本身即回到空起点。
+ if self._seg_has_tip(seg):
+ tip_i = len(seg)
+ tk = self._tip_kind()
+ tip_active = tip_i == self.seg_idx
+ tlabel = "(当前位置 · 自此继续)" if tk == "current" else "(自此继续会话)"
+ glyph = "◉" if tk == "current" else "●" # 当前位置用 ◉(同节点当前标记)
+ base = C_GREEN if tk == "current" else C_FG
+ trs = (f"bold {C_FG} on {C_ALT_BG}" if tip_active and self.focus_right
+ else (base if tip_active else C_DIM))
+ tline = Text()
+ tline.append("▶ " if tip_active else " ",
+ style=(C_CYAN if self.focus_right else C_DIM) if tip_active else C_DIM)
+ tline.append(f"{glyph} ", style=C_GREEN if (tip_active or tk == "current") else C_DIM)
+ tline.append(tlabel, style=trs)
+ lines.append(tline); idxs.append(tip_i)
+ self.query_one("#rw3_rtop_body", ClickableList).set_content(
+ Text("\n").join(lines), idxs)
+
+ def _target_node(self) -> Optional[str]:
+ """diff/delete 的目标节点:右上选中段内节点 > 当前显示节点的叶(tip 也归叶)。"""
+ seg = self._current_seg()
+ if self.focus_right and self.seg_idx < len(seg):
+ return seg[self.seg_idx]
+ return seg[-1] if seg else None
+
+ def _restore_spec(self) -> "tuple[Optional[str], str]":
+ """恢复目标 (node_id, to):
+ - 段内节点(seg_idx None:
+ seg = self._current_seg()
+ on_tip = self.seg_idx >= len(seg)
+ body = Text()
+ if on_tip:
+ tk = self._tip_kind()
+ tail = self.ct.tree.nodes[seg[-1]] if seg else None
+ if tk == "current":
+ body.append("(当前位置)\n", style=f"bold {C_GREEN}")
+ body.append(" 已处于该对话最新处,发送消息即可继续。\n", style=C_MUTED)
+ else:
+ body.append("(自此继续会话)\n", style=f"bold {C_FG}")
+ body.append(" 从这条记录之后继续对话。\n", style=C_MUTED)
+ if tail is not None:
+ body.append(f" 续接于 {ellipsize(tail.title, 40)}\n", style=C_MUTED)
+ body.append(f" 时间 {rel_time(tail.ago)}\n", style=C_MUTED)
+ else:
+ nid = seg[self.seg_idx]
+ n = self.ct.tree.nodes[nid]
+ _, color, kind_label = kind_style(n.kind)
+ body.append(f"{n.title}\n", style=f"bold {color}")
+ if kind_label:
+ body.append(f" 类型 {kind_label}\n", style=C_MUTED)
+ body.append(f" 时间 {rel_time(n.ago)}\n", style=C_MUTED)
+ body.append(f" 改动文件 {files_summary(n.files)}\n", style=C_MUTED)
+ body.append(" 回退至此 清除本次提问及其后内容\n", style=C_DIM)
+
+ body.append("\n操作 ", style=C_DIM)
+ body.append(" Enter " + ("选择回退方式" if self.focus_right else "进入右栏选择"),
+ style=f"{C_FG} on {C_SEL_BG}")
+ body.append(" x 删除节点\n", style=C_DIM)
+ body.append("(回退前自动保存还原点)", style=C_DIM)
+ self.query_one("#rw3_rbot_body", Static).update(body)
+ # 下方可滚动 diff 视窗:选中节点 vs 父节点(上一个状态)的逐行改动。
+ self._refresh_diff(None if on_tip else seg[self.seg_idx])
+
+ _DIFF_MAX_LINES = 500 # diff 视窗最多渲染这么多行,防超长文件卡渲染
+
+ def _render_node_diff(self, node_id):
+ """选中节点 vs 父节点的逐行 diff。返回 (Text, 总增行, 总删行)。"""
+ from difflib import SequenceMatcher
+ out = Text()
+ ins = dele = 0
+ emitted = 0
+ capped = False
+ for fi, f in enumerate(self.store.node_diff(node_id)):
+ a, b = f["old"].splitlines(), f["new"].splitlines()
+ groups = SequenceMatcher(None, a, b, autojunk=False).get_grouped_opcodes(3)
+ if not groups:
+ continue
+ if out:
+ out.append("\n")
+ out.append(f"{f['rel']}\n", style=f"bold {C_BLUE}")
+ for gi, group in enumerate(groups):
+ if gi:
+ out.append(" ⋮\n", style=C_DIM)
+ for tag, i1, i2, j1, j2 in group:
+ if tag == "equal":
+ for k in range(i1, i2):
+ if emitted < self._DIFF_MAX_LINES:
+ out.append(f" {a[k]}\n", style=C_MUTED); emitted += 1
+ else:
+ capped = True
+ else:
+ for k in range(i1, i2):
+ dele += 1
+ if emitted < self._DIFF_MAX_LINES:
+ out.append(f" - {a[k]}\n", style=C_RED); emitted += 1
+ else:
+ capped = True
+ for k in range(j1, j2):
+ ins += 1
+ if emitted < self._DIFF_MAX_LINES:
+ out.append(f" + {b[k]}\n", style=C_GREEN); emitted += 1
+ else:
+ capped = True
+ if capped:
+ out.append(f" …(已截断,仅显示前 {self._DIFF_MAX_LINES} 行)\n", style=C_AMBER)
+ return out, ins, dele
+
+ def _refresh_diff(self, node_id) -> None:
+ dbody = Text()
+ if node_id is None: # 占位项(自此继续/当前位置):diff 视窗留空
+ pass
+ else:
+ diff, ins, dele = self._render_node_diff(node_id)
+ dbody.append(f"+{ins} ", style=f"bold {C_GREEN}")
+ dbody.append(f"-{dele}", style=f"bold {C_RED}")
+ dbody.append(" 本次改动(对比上一个状态)\n", style=C_DIM)
+ dbody.append(diff if (ins or dele) else Text(" 本次无文件改动(仅对话)", style=C_DIM))
+ self.query_one("#rw3_diff_body", Static).update(dbody)
+ try:
+ self.query_one("#rw3_diff").scroll_home(animate=False) # 切节点后回到 diff 顶部
+ except Exception:
+ pass
+
+ def _refresh_status(self) -> None:
+ where = "右栏" if self.focus_right else "左树"
+ self.query_one("#rw3_status", Static).update(Text(
+ f" 聚焦:{where} | 共 {len(self.ct.flatten())}/{len(self.ct.tree.nodes)} | "
+ f"↑↓ 当前栏 Ctrl↑↓(/PgUp·Dn/[ ]) 另一栏 ←→ 切层级 Tab 切聚焦 "
+ f"Enter 进入/回退 x 删 Esc", style=C_DIM))
+
+ # ---- 导航:上下=聚焦窗 / Ctrl上下=非聚焦窗 / Tab·←→=切聚焦 ----
+ def action_up(self) -> None:
+ if self.focus_right: self._move_right(-1)
+ else: self._move_left(-1, keep_focus=False)
+
+ def action_down(self) -> None:
+ if self.focus_right: self._move_right(+1)
+ else: self._move_left(+1, keep_focus=False)
+
+ def action_other_up(self) -> None:
+ if self.focus_right: self._move_left(-1, keep_focus=True)
+ else: self._move_right(-1)
+
+ def action_other_down(self) -> None:
+ if self.focus_right: self._move_left(+1, keep_focus=True)
+ else: self._move_right(+1)
+
+ def _move_left(self, delta: int, keep_focus: bool) -> None:
+ order = self.ct.flatten()
+ if self.sel_key not in order:
+ return
+ i = order.index(self.sel_key)
+ j = max(0, min(i + delta, len(order) - 1))
+ if j != i:
+ self._set_left_selection(order[j], keep_focus=keep_focus)
+
+ def _move_right(self, delta: int) -> None:
+ n = self._seg_max_idx() # 索引 0..n,n=tip(无 tip 的 origin 段则到末项)
+ self.seg_idx = max(0, min(self.seg_idx + delta, n))
+ self._diff_for = None
+ self._refresh_rtop(); self._refresh_rbot(); self._refresh_status()
+ self._ensure_line_visible("#rw3_rtop", self.seg_idx + 3)
+
+ def _set_focus(self, right: bool) -> None:
+ self.focus_right = right
+ if right:
+ self.seg_idx = min(self.seg_idx, self._seg_max_idx()) # 越界落到 tip(或末项)
+ self._diff_for = None
+ self._refresh_rtop(); self._refresh_rbot(); self._refresh_status()
+ self._refresh_focus_frame()
+
+ def action_focus_toggle(self) -> None:
+ self._set_focus(not self.focus_right)
+
+ def on_key(self, event) -> None:
+ # Tab 兜底:真终端里 Tab 会被 Textual 的焦点遍历系统吃掉(headless 正常),
+ # 故在 on_key 直接拦截,prevent_default 阻止默认焦点移动。
+ if event.key in ("tab", "shift+tab"):
+ event.prevent_default()
+ event.stop()
+ self.action_focus_toggle()
+
+ def _order(self):
+ return _order_depths(self.ct)
+
+ def action_level_up(self) -> None:
+ # ←:左树切到上一层级(列);回到左聚焦。
+ self._set_left_selection(nearest_depth_node(self._order(), self.sel_key, -1))
+
+ def action_level_down(self) -> None:
+ self._set_left_selection(nearest_depth_node(self._order(), self.sel_key, +1))
+
+ def action_enter(self) -> None:
+ # 左聚焦:进入右侧节点选择;右聚焦:弹回退模式窗。
+ if not self.focus_right:
+ self._set_focus(True)
+ else:
+ self._open_restore_popup()
+
+ # ---- 操作 actions ----
+ def _open_restore_popup(self) -> None:
+ """右聚焦时 Enter:弹 CC 式回退模式窗,选定后再 dismiss(带 mode + to)。"""
+ node_id, to = self._restore_spec()
+ if node_id is None:
+ return
+ self._pending = (node_id, to)
+ title = self.ct.tree.nodes[node_id].title if node_id in self.ct.tree.nodes else ""
+ self.app.push_screen(RestoreModeScreen(title, to), self._on_mode_picked)
+
+ def _on_mode_picked(self, mode) -> None:
+ if not mode:
+ return
+ node_id, to = getattr(self, "_pending", (None, "at"))
+ if node_id is None:
+ return
+ self.dismiss({"action": "restore", "mode": mode, "node": node_id, "to": to})
+
+ def action_delete(self) -> None:
+ tgt = self._target_node()
+ if tgt is None or tgt == self.store.root_id:
+ return # 不删根
+ try:
+ self.store.delete_subtree(tgt)
+ except Exception:
+ return
+ # 重建压缩树;选中落回当前位置所在显示节点
+ self.ct = CompressedTree(tree_from_store(self.store, __import__("time").time()))
+ self._mark_current()
+ flat = self.ct.flatten()
+ if not flat:
+ self.dismiss(None) # 树空了
+ return
+ self.sel_key = self._disp_key_for_node(self._current_node()) or flat[0]
+ self.focus_right = False
+ self.seg_idx = self._default_seg_idx()
+ self._diff_for = None
+ self.refresh_all()
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
+
+ # ---- 点击 ----
+ def on_click(self, event) -> None:
+ # 点击不同区域切聚焦框:点到节点/条目时各自 handler 已 stop 并设好聚焦,
+ # 这里兜住「点到面板空白处」也能切聚焦。
+ try:
+ x, y = event.screen_x, event.screen_y
+ if self.query_one("#rw3_left").region.contains(x, y):
+ if self.focus_right:
+ self._set_focus(False)
+ elif self.query_one("#rw3_rtop").region.contains(x, y):
+ if not self.focus_right:
+ self._set_focus(True)
+ except Exception:
+ pass
+
+ def on_clickable_tree_node_clicked(self, msg) -> None:
+ self._set_left_selection(msg.key)
+
+ def on_clickable_list_seg_clicked(self, msg) -> None:
+ self.focus_right = True
+ self.seg_idx = max(0, min(msg.index, self._seg_max_idx()))
+ self._diff_for = None
+ self._refresh_rtop(); self._refresh_rbot(); self._refresh_status()
+ self._refresh_focus_frame()
+ self._ensure_line_visible("#rw3_rtop", self.seg_idx + 3)
+
+
def main(argv: Optional[list[str]] = None) -> int:
build_arg_parser().parse_args(argv)
_warn_mintty()
diff --git a/frontends/wechatapp.py b/frontends/wechatapp.py
index aff57bf1..301f10c6 100644
--- a/frontends/wechatapp.py
+++ b/frontends/wechatapp.py
@@ -63,20 +63,42 @@ class WxBotClient:
return r.json()
def login_qr(self, poll_interval=2):
- r = requests.get(f'{API}/ilink/bot/get_bot_qrcode', params={'bot_type': 3}, headers={'User-Agent': UA}, timeout=10)
- r.raise_for_status()
- d = r.json()
+ # 获取二维码:对限流/缺字段(无 'qrcode')退避重试,避免崩溃→重启→更狠地打接口的死亡螺旋
+ d = {}
+ for attempt in range(6):
+ try:
+ r = requests.get(f'{API}/ilink/bot/get_bot_qrcode', params={'bot_type': 3}, headers={'User-Agent': UA}, timeout=10)
+ r.raise_for_status()
+ d = r.json()
+ except requests.exceptions.RequestException as e:
+ print(f'[QR登录] 获取二维码失败({e}),{2 ** attempt}s 后重试...'); time.sleep(2 ** attempt); continue
+ if d.get('qrcode') and d.get('qrcode_img_content'): # 二维码 ID + 可扫图都就绪才算成功
+ break
+ print(f'[QR登录] 二维码未就绪(可能被限流,ret={d.get("ret")}),{2 ** attempt}s 后重试...')
+ time.sleep(2 ** attempt)
+ if not (d.get('qrcode') and d.get('qrcode_img_content')):
+ raise RuntimeError('多次重试仍未获取到可扫二维码(疑似限流),请稍后重试')
qr_id, url = d['qrcode'], d.get('qrcode_img_content', '')
print(f'[QR登录] ID: {qr_id}')
if url:
- img = self._tf.parent / 'wx_qr.png'
- qrcode.make(url).save(str(img)) # 保存到文件,不弹浏览器
+ # 先打 ASCII 二维码(纯文本,无需 PIL;容器/无头环境靠它扫码)
qr = qrcode.QRCode(border=1); qr.add_data(url); qr.make(fit=True); qr.print_ascii(invert=True)
+ # 再尝试存 PNG 兜底——依赖 PIL,缺失/失败不应让登录崩溃
+ try:
+ qrcode.make(url).save(str(self._tf.parent / 'wx_qr.png'))
+ except Exception as e:
+ print(f'[QR登录] PNG 兜底保存失败({e}),用上方 ASCII 二维码扫码即可')
last = ''
while True:
time.sleep(poll_interval)
- try: s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, headers={'User-Agent': UA}, timeout=60).json()
- except requests.exceptions.ReadTimeout: continue
+ # 轮询状态:对所有网络异常 / 非 JSON(被限流时常返回 HTML)容错重试,
+ # 不让单次抖动把进程打崩——配合 restart:unless-stopped 否则会死亡螺旋
+ try:
+ s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, headers={'User-Agent': UA}, timeout=60).json()
+ except requests.exceptions.RequestException:
+ continue
+ except ValueError: # 响应非 JSON(限流/网关页)
+ time.sleep(poll_interval); continue
st = s.get('status', '')
if st != last: print(f' 状态: {st}'); last = st
if st == 'confirmed':
@@ -415,11 +437,14 @@ if __name__ == '__main__':
print(f'[NEW] Process starting {time.strftime("%m-%d %H:%M")}')
bot = WxBotClient()
if _do_relogin or not bot.token:
- if not sys.stdout.isatty():
- print('[Bot] no token and not interactive, exit.'); sys.exit(1)
- sys.stdout = sys.stderr = sys.__stdout__ # restore for QR display
- bot.login_qr()
- sys.stdout = sys.stderr = _logf
+ # QR 登录在无 TTY 的容器里也可用:把二维码打到真实 stdout(docker logs
+ # 可见),而不是日志文件——之前在重定向后才判 isatty(),文件句柄恒 false
+ # 导致容器内必然退出,无法首次登录。PNG 仍存 ~/.wxbot/wx_qr.png 作兜底。
+ sys.stdout = sys.stderr = sys.__stdout__ # restore for QR display (real stdout / container log)
+ try:
+ bot.login_qr()
+ finally:
+ sys.stdout = sys.stderr = _logf
threading.Thread(target=agent.run, daemon=True).start()
print(f'WeChat Bot 已启动 (bot_id={bot.bot_id})', file=sys.__stdout__)
try:
diff --git a/frontends/workspace_cmd.py b/frontends/workspace_cmd.py
new file mode 100644
index 00000000..c9c4d984
--- /dev/null
+++ b/frontends/workspace_cmd.py
@@ -0,0 +1,478 @@
+"""Workspace 命令的共享逻辑(tuiapp_v2 / tui_v3 复用)。
+
+设计要点(详见对话设计稿):
+ * **兼容旧入口** `plugins/project_mode.py` 与 `memory/project_mode_sop.md` 的 pid 锚。
+ 前端在
+ `/temp/projects/` 建一个指向用户真实绝对路径的目录联接(junction),
+ 并可按需写激活锚 `/temp/.active_project.`。project_mode 插件
+ 照常每轮注入 L1,并把 project_memory.md / 产物经 junction 写进真实仓库根
+ (与 Claude Code 在仓库根放 CLAUDE.md 同理,已接受)。
+ * **路径基准必须与插件一致**:插件的 `_TEMP` 是基于其 `__file__` 的 `/temp`
+ 绝对路径(非 cwd)。本模块也从自身 `__file__` 推 `/temp`(frontends/ 的上一级
+ 即 repo 根),两边独立计算但结果一致,互不 import。
+ * **pid 语义**:插件读 `os.getpid()`(GA 进程)。前端就跑在 GA 进程里,写锚同样用
+ `os.getpid()`(不是 SOP 里 code_run 子进程用的 getppid)。
+ * **命名** `name = f"{basename}-{hash8}"`,hash8 = blake2b(规范化绝对路径)[:8]。
+ 同一 workspace 恒定同名(幂等复用);hash 后缀又让 junction 名不与其它 UI 人工命名的
+ 普通项目目录相撞。
+ * **junction 安全**:检测用 reparse 属性(`os.path.islink` 对 junction 返回 False!);
+ 删除用 `os.rmdir`,**绝不 rmtree**(会击穿删真实文件)。cleanup 只动确认是 junction
+ 且悬空/未注册的条目,真实目录(其它 UI 的普通项目)一律不碰。
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import stat
+import subprocess
+import sys
+import time
+from typing import Optional
+
+
+# --------------------------------------------------------------------------- #
+# 路径基准(与 plugins/project_mode.py 的 _TEMP 保持一致)
+# --------------------------------------------------------------------------- #
+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+def _temp_root() -> str:
+ return os.path.join(_REPO_ROOT, "temp")
+
+
+def _projects_root() -> str:
+ return os.path.join(_temp_root(), "projects")
+
+
+def _anchor_path() -> str:
+ """激活锚,pid 键控,与插件 `_ANCHOR` 同。"""
+ return os.path.join(_temp_root(), f".active_project.{os.getpid()}")
+
+
+def _registry_path() -> str:
+ return os.path.join(_temp_root(), "workspaces.json")
+
+
+_REGISTRY_VERSION = 1
+
+
+# --------------------------------------------------------------------------- #
+# 命名
+# --------------------------------------------------------------------------- #
+def _norm_abspath(p: str) -> str:
+ """规范化绝对路径用于 hash:abspath + normcase(Windows 大小写不敏感 ->
+ 同一目录恒定同名)。不走 realpath,避免解析 junction/symlink 带来的意外。"""
+ return os.path.normcase(os.path.abspath(p))
+
+
+def _ws_name(abs_path: str) -> str:
+ base = os.path.basename(abs_path.rstrip("/\\")) or "ws"
+ digest = hashlib.blake2b(_norm_abspath(abs_path).encode("utf-8")).hexdigest()[:8]
+ return f"{base}-{digest}"
+
+
+def _link_path(name: str) -> str:
+ return os.path.join(_projects_root(), name)
+
+
+# --------------------------------------------------------------------------- #
+# junction / symlink 跨平台封装(reparse 安全)
+# --------------------------------------------------------------------------- #
+def make_dir_link(target_abs: str, link_path: str) -> bool:
+ """建目录联接。Windows 用 `mklink /J`(免管理员);POSIX 用 symlink。
+ 成功返回 True;失败打印到 stderr 并返回 False。"""
+ target_abs = os.path.abspath(target_abs)
+ parent = os.path.dirname(link_path)
+ try:
+ os.makedirs(parent, exist_ok=True)
+ except OSError as e:
+ sys.stderr.write(f"[workspace] mkdir {parent} failed: {e}\n")
+ return False
+ if os.name == "nt":
+ # mklink 是 cmd 内建,必须经 cmd 调用。列表传参由 subprocess 负责加引号,
+ # 兼容含空格/中文的路径。
+ try:
+ r = subprocess.run(
+ ["cmd", "/c", "mklink", "/J", link_path, target_abs],
+ capture_output=True, text=True,
+ )
+ except OSError as e:
+ sys.stderr.write(f"[workspace] mklink invoke failed: {e}\n")
+ return False
+ if r.returncode != 0 or not os.path.exists(link_path):
+ sys.stderr.write(f"[workspace] mklink /J failed: "
+ f"{(r.stderr or r.stdout or '').strip()}\n")
+ return False
+ return True
+ # POSIX
+ try:
+ os.symlink(target_abs, link_path, target_is_directory=True)
+ return True
+ except OSError as e:
+ sys.stderr.write(f"[workspace] symlink failed: {e}\n")
+ return False
+
+
+def is_dir_link(path: str) -> bool:
+ """是否目录联接/符号链接。**不能只用 os.path.islink**——它对 Windows junction
+ 返回 False。改看 reparse point 属性 + reparse tag。"""
+ try:
+ if os.path.islink(path): # POSIX symlink、Windows 符号链接
+ return True
+ except OSError:
+ return False
+ if os.name != "nt":
+ return False
+ try:
+ st = os.lstat(path)
+ except OSError:
+ return False
+ attrs = getattr(st, "st_file_attributes", 0)
+ reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+ if not (attrs & reparse):
+ return False
+ # 进一步认 tag:挂载点(junction)或符号链接
+ tag = getattr(st, "st_reparse_tag", 0)
+ mount = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003)
+ syml = getattr(stat, "IO_REPARSE_TAG_SYMLINK", 0xA000000C)
+ if tag:
+ return tag in (mount, syml)
+ return True # 有 reparse 属性但拿不到 tag,保守视作链接(我们只在此目录建链)
+
+
+def link_target(path: str) -> Optional[str]:
+ """读链接目标;清洗 Windows 的 \\??\\ / \\\\?\\ 前缀。失败返回 None。"""
+ try:
+ t = os.readlink(path)
+ except OSError:
+ return None
+ for pre in ("\\??\\", "\\\\?\\"):
+ if t.startswith(pre):
+ t = t[len(pre):]
+ break
+ return t
+
+
+def remove_dir_link(path: str) -> bool:
+ """只摘掉链接本身,绝不递归删目标。Windows junction / 符号链接目录用 os.rmdir,
+ POSIX symlink 用 os.unlink。**调用前务必 is_dir_link 确认。**"""
+ try:
+ if os.name == "nt":
+ os.rmdir(path)
+ else:
+ os.unlink(path)
+ return True
+ except OSError as e:
+ sys.stderr.write(f"[workspace] remove link {path} failed: {e}\n")
+ return False
+
+
+# --------------------------------------------------------------------------- #
+# 注册表 temp/workspaces.json(本功能私有;v2/v3 可能并发 -> 原子写)
+# --------------------------------------------------------------------------- #
+def registry_load() -> dict:
+ try:
+ with open(_registry_path(), encoding="utf-8") as fh:
+ data = json.load(fh)
+ if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION:
+ items = data.get("items")
+ if isinstance(items, dict):
+ return items
+ except (OSError, ValueError):
+ pass
+ return {}
+
+
+def _registry_save(items: dict) -> None:
+ path = _registry_path()
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ tmp = f"{path}.{os.getpid()}.tmp"
+ with open(tmp, "w", encoding="utf-8") as fh:
+ json.dump({"version": _REGISTRY_VERSION, "items": items},
+ fh, ensure_ascii=False, separators=(",", ":"))
+ os.replace(tmp, path)
+ except OSError as e:
+ sys.stderr.write(f"[workspace] registry save failed: {e}\n")
+
+
+# --------------------------------------------------------------------------- #
+# 会话→工作区映射 temp/session_workspaces.json — 让 /continue 即时恢复,不必先
+# 聊一轮在日志留 PROJECT MODE 块。key=会话日志名, value=workspace 真实路径,
+# ""=已 off(区别于缺 key=无记录→回退扫日志)。手动操作触发、极低频,照搬注册
+# 表原子写、不加锁。
+# --------------------------------------------------------------------------- #
+def _session_map_path() -> str:
+ return os.path.join(_temp_root(), "session_workspaces.json")
+
+
+def _session_map_load() -> dict:
+ try:
+ with open(_session_map_path(), encoding="utf-8") as fh:
+ data = json.load(fh)
+ if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION:
+ items = data.get("items")
+ if isinstance(items, dict):
+ return items
+ except (OSError, ValueError):
+ pass
+ return {}
+
+
+def _session_map_save(items: dict) -> None:
+ path = _session_map_path()
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ tmp = f"{path}.{os.getpid()}.tmp"
+ with open(tmp, "w", encoding="utf-8") as fh:
+ json.dump({"version": _REGISTRY_VERSION, "items": items},
+ fh, ensure_ascii=False, separators=(",", ":"))
+ os.replace(tmp, path)
+ except OSError as e:
+ sys.stderr.write(f"[workspace] session map save failed: {e}\n")
+
+
+def session_ws_set(log_path: str, target: str) -> None:
+ """记录会话绑定的 workspace 路径;target="" 表示该会话已 off。"""
+ key = os.path.basename(log_path or "")
+ if not key:
+ return
+ items = _session_map_load()
+ items[key] = target or ""
+ _session_map_save(items)
+
+
+def session_ws_get(log_path: str):
+ """路径=绑定 / ""=已 off / None=无记录(调用方回退扫日志)。"""
+ key = os.path.basename(log_path or "")
+ return _session_map_load().get(key) if key else None
+
+
+def session_map_prune() -> None:
+ """删掉日志文件已不存在的孤儿条目(启动时调一次)。"""
+ items = _session_map_load()
+ logdir = os.path.join(_temp_root(), "model_responses")
+ alive = {k: v for k, v in items.items() if os.path.isfile(os.path.join(logdir, k))}
+ if len(alive) != len(items):
+ _session_map_save(alive)
+
+
+def registry_upsert(name: str, abs_path: str) -> None:
+ items = registry_load()
+ items[name] = {"path": os.path.abspath(abs_path), "last_used": int(time.time())}
+ _registry_save(items)
+
+
+def registry_remove(name: str) -> None:
+ items = registry_load()
+ if items.pop(name, None) is not None:
+ _registry_save(items)
+
+
+def _mem_lines(link: str) -> int:
+ """project_memory.md 行数(经 junction 读真实文件);读不到返回 0。"""
+ mp = os.path.join(link, "project_memory.md")
+ try:
+ with open(mp, encoding="utf-8", errors="replace") as fh:
+ return sum(1 for _ in fh)
+ except OSError:
+ return 0
+
+
+def registry_list() -> list[dict]:
+ """供 picker 候选列表:[{name, path, last_used, mem_lines, dangling}],按最近使用倒序。"""
+ out = []
+ for name, ent in registry_load().items():
+ path = (ent or {}).get("path") or ""
+ out.append({
+ "name": name,
+ "path": path,
+ "last_used": int((ent or {}).get("last_used") or 0),
+ "mem_lines": _mem_lines(_link_path(name)) if path else 0,
+ "dangling": not (path and os.path.isdir(path)),
+ })
+ out.sort(key=lambda x: x["last_used"], reverse=True)
+ return out
+
+
+# --------------------------------------------------------------------------- #
+# 校验
+# --------------------------------------------------------------------------- #
+def validate_path(abs_path: str) -> tuple[bool, str]:
+ if not abs_path or not abs_path.strip():
+ return False, "路径为空"
+ p = abs_path.strip().strip('"').strip("'")
+ if not os.path.isabs(p):
+ return False, "需要绝对路径"
+ if os.name == "nt" and p.startswith("\\\\"):
+ return False, "不支持网络路径(UNC):junction 无法指向网络位置"
+ if not os.path.exists(p):
+ return False, f"路径不存在: {p}"
+ if not os.path.isdir(p):
+ return False, "不是目录"
+ if _norm_abspath(p).startswith(_norm_abspath(_temp_root())):
+ return False, "该路径已在 temp 内,无需 workspace"
+ return True, ""
+
+
+# --------------------------------------------------------------------------- #
+# 主流程
+# --------------------------------------------------------------------------- #
+def prepare(abs_path: str) -> dict:
+ """准备 workspace,但不写进程级激活锚。返回:
+ {ok, name, link, target, mem_text, warning, error}。
+ 流程:校验 -> name -> 幂等建链 -> 确保 project_memory.md 存在 -> 注册 -> 回读记忆。
+ TUI 多会话隔离使用本函数,避免多个 session 争用同一个 pid 锚。"""
+ p = abs_path.strip().strip('"').strip("'") if abs_path else ""
+ ok, msg = validate_path(p)
+ if not ok:
+ return {"ok": False, "error": msg}
+ target = os.path.abspath(p)
+ name = _ws_name(target)
+ link = _link_path(name)
+ warning = ""
+
+ # 幂等建链
+ if os.path.lexists(link):
+ if is_dir_link(link):
+ cur = link_target(link)
+ if cur and _norm_abspath(cur) == _norm_abspath(target):
+ pass # 已指向同一目标 -> 复用
+ else:
+ remove_dir_link(link)
+ if not make_dir_link(target, link):
+ return {"ok": False, "error": "重建 junction 失败(见 stderr)"}
+ else:
+ # 极罕见:同名真实目录占位(其它 UI 的普通项目)。绝不覆盖。
+ return {"ok": False,
+ "error": f"{link} 已是真实目录(可能是其它项目),拒绝覆盖"}
+ else:
+ if not make_dir_link(target, link):
+ return {"ok": False, "error": "创建 junction 失败(见 stderr)"}
+
+ # 确保 project_memory.md 存在(经 junction 落到真实仓库根)
+ mem_path = os.path.join(link, "project_memory.md")
+ if not os.path.isfile(mem_path):
+ try:
+ open(mem_path, "a", encoding="utf-8").close()
+ except OSError as e:
+ warning = f"无法创建 project_memory.md: {e}"
+
+ mem_text = ""
+ try:
+ with open(mem_path, encoding="utf-8", errors="replace") as fh:
+ mem_text = fh.read()
+ except OSError:
+ pass
+
+ registry_upsert(name, target)
+
+ return {"ok": True, "name": name, "link": link, "target": target,
+ "mem_text": mem_text, "warning": warning, "error": ""}
+
+
+def activate(abs_path: str) -> dict:
+ """设定并激活进程级 workspace。保留给旧 SOP / 非多会话 UI 使用。"""
+ r = prepare(abs_path)
+ if not r.get("ok"):
+ return r
+ try:
+ with open(_anchor_path(), "w", encoding="utf-8") as fh:
+ fh.write(r["name"])
+ except OSError as e:
+ r = dict(r)
+ r.update({"ok": False, "error": f"写激活锚失败: {e}"})
+ return r
+
+
+def deactivate() -> bool:
+ """仅删激活锚;junction 与文件保留。返回是否原本处于激活态。"""
+ anchor = _anchor_path()
+ if os.path.isfile(anchor):
+ try:
+ os.remove(anchor)
+ return True
+ except OSError as e:
+ sys.stderr.write(f"[workspace] deactivate failed: {e}\n")
+ return False
+
+
+def current() -> Optional[dict]:
+ """当前激活的 workspace:{name, path};未激活返回 None。"""
+ anchor = _anchor_path()
+ try:
+ name = open(anchor, encoding="utf-8").read().strip()
+ except OSError:
+ return None
+ if not name:
+ return None
+ ent = registry_load().get(name) or {}
+ return {"name": name, "path": ent.get("path") or ""}
+
+
+def is_dangling(name: str) -> bool:
+ """junction 指向的真实目标是否已失效(被删/盘断开)。"""
+ link = _link_path(name)
+ if not is_dir_link(link):
+ return True
+ t = link_target(link)
+ return not (t and os.path.isdir(t))
+
+
+def remove(name: str) -> None:
+ """显式注销:删 junction(不动真实文件)+ 删注册表条目。若正激活该项目则一并删锚。"""
+ link = _link_path(name)
+ if is_dir_link(link):
+ remove_dir_link(link)
+ registry_remove(name)
+ cur = current()
+ if cur and cur["name"] == name:
+ deactivate()
+
+
+# project_mode 插件注入的标记:`[PROJECT MODE: ]`(见 _build_injection)。
+# 它随用户消息写进 model_responses 日志,故可据此判断被 /continue 的会话当时
+# 在哪个 workspace。
+_PM_RE = re.compile(r"\[PROJECT MODE:\s*([^\]\n]+?)\s*\]")
+
+
+def workspace_from_log(log_path: str) -> Optional[dict]:
+ """扫一份 model_responses 日志,返回它最后激活的 workspace {name, path};
+ 仅当该 name 是**已注册的 workspace**(在 workspaces.json 里)才返回——
+ 普通 SOP 项目(无 hash、不在注册表)一律忽略。"""
+ try:
+ with open(log_path, encoding="utf-8", errors="replace") as f:
+ content = f.read()
+ except OSError:
+ return None
+ names = _PM_RE.findall(content)
+ if not names:
+ return None
+ name = names[-1].strip() # 最后一次激活胜出
+ ent = registry_load().get(name)
+ if not ent or not ent.get("path"):
+ return None
+ return {"name": name, "path": ent["path"]}
+
+
+def cleanup() -> None:
+ """v2/v3 启动时调一次:清理 temp/projects/ 下**悬空或未注册**的 junction。
+ 安全纪律:只处理 is_dir_link 确认的链接;真实目录(其它 UI 的普通项目)一律跳过。"""
+ proot = _projects_root()
+ if not os.path.isdir(proot):
+ return
+ registered = set(registry_load().keys())
+ try:
+ entries = os.listdir(proot)
+ except OSError:
+ return
+ for nm in entries:
+ link = os.path.join(proot, nm)
+ if not is_dir_link(link):
+ continue # 真实目录 -> 不碰
+ t = link_target(link)
+ dangling = not (t and os.path.isdir(t))
+ if dangling or nm not in registered:
+ remove_dir_link(link)
diff --git a/frontends/worldline.py b/frontends/worldline.py
new file mode 100644
index 00000000..2a1c3223
--- /dev/null
+++ b/frontends/worldline.py
@@ -0,0 +1,968 @@
+"""GA 世界线(Rewind / checkpoint)统一后端 —— **UI 无关,单文件,tui/gui 共用**。
+
+三块合一(本文件不含任何 Textual/前端交互逻辑,只产数据与做存储):
+ 1. **持久化**(`RewindStore`):content-addressable blob + checkpoint 树 + 全持久化。
+ - 统一 **checkpoint 树**;**content-addressable blob**(内容 sha256 去重);
+ - 只追 `file_write`/`file_patch`(路径由调用方在 `tool_before` 钩子喂入);
+ - **global baseline**(每文件「首次改前」)解决「回退到该文件被追踪之前」。
+ 2. **视图模型 + 压缩**(`CheckpointTree`/`tree_from_store`/`CompressedTree`):把真实
+ store 投影成只读树并强制折叠线性段,供任何前端渲染。
+ 3. **导航数学 + 恢复编排**(`next_same_depth`/`nearest_depth_node`/`restore_plan`):
+ 纯计算;`restore_plan` 算出回退后的 history/文件/prefill 并落地(改文件+移 HEAD+
+ 重写投影),前端只管把结果刷到自己的界面。
+
+参考 Claude Code file-history 思路,按 GA 改造。纯 Python、可离线测试;线程安全交调用方
+(agent 跑在子线程,约定仅 agent 空闲时 restore)。详见需求文档(ga_rewind_tui_requirements)。
+"""
+from __future__ import annotations
+
+import difflib
+import hashlib
+import json
+import os
+import shutil
+import time
+from datetime import datetime
+import zlib
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+
+from rich.cells import cell_len, set_cell_size
+
+# 该值为 None 表示「该版本文件不存在」(对应 CC 的 backupFileName: null)。
+_ABSENT = None
+
+
+class RewindStore:
+ """一个 session 的 checkpoint 树 + blob 库。
+
+ 用法(典型时序):
+ store = RewindStore(root, cwd)
+ # 改前(tool_before 钩子,file_write/file_patch 执行前):
+ store.track_pre_edit(abs_path)
+ # ...工具实际写盘...
+ # 段末(用户提问边界 / turn 收尾):
+ store.commit("修复 stdout", hist_len=42)
+ # 浏览 / 恢复:
+ store.apply_code(node_id) # 把工作区还原到该节点
+ store.rewind_head(node_id) # 只移 HEAD(下次 commit 即从此分叉)
+ """
+
+ def __init__(self, root: str, cwd: str) -> None:
+ self.root = os.path.abspath(root)
+ self.cwd = os.path.abspath(cwd)
+ self.objects_dir = os.path.join(self.root, "objects")
+ self.tree_path = os.path.join(self.root, "tree.json")
+
+ # id -> {parent, children[], title, created, kind, hist_len, files{rel: hash|None}}
+ self.nodes: dict[str, dict] = {}
+ self.head: str | None = None
+ self.root_id: str | None = None
+ self._seq = 0
+ # 全局:每个被追踪文件的「首次改前」状态(hash 或 None=当时不存在)。
+ self.baseline: dict[str, str | None] = {}
+ self.tracked: set[str] = set()
+
+ # 段内暂存:本段触碰过的相对路径(commit 时落定)。
+ self._touched: set[str] = set()
+ # 最近一次 restore 的 redo 点(内存级软保险②),结构同 node.files。
+ self._redo: dict | None = None
+
+ self.load()
+ if self.nodes:
+ self._ensure_origin() # 迁移:旧树(无 origin)挂一个「会话起点」根
+
+ # --------------------------------------------------------------- origin
+ def _ensure_origin(self) -> None:
+ """保证存在一个虚拟根「会话起点」(空状态):所有真实 checkpoint 都是它的
+ 后代。幂等。第一次 commit / 迁移旧树 / resume 时调用。
+
+ - 它代表「第一轮对话之前」,使「回退到第一个会话之前」= 普通地 rewind 到它;
+ - files={} + hist_len=0 → apply_code(origin) 经 baseline 回退 = 还原到空起点;
+ - 它是唯一真根 → 不会出现 forest / head=None。"""
+ if any(nd.get("kind") == "origin" for nd in self.nodes.values()):
+ if self.head is None:
+ self.head = self.root_id
+ return
+ oid = "origin"
+ while oid in self.nodes:
+ oid += "_"
+ tops = self.top_level_nodes()
+ self.nodes[oid] = {"parent": None, "children": list(tops),
+ "title": "会话起点", "created": time.time(),
+ "kind": "origin", "hist_len": 0, "files": {}}
+ for t in tops:
+ self.nodes[t]["parent"] = oid
+ self.root_id = oid
+ if self.head is None:
+ self.head = oid
+
+ # ------------------------------------------------------------------ paths
+ def key(self, abs_path: str) -> str:
+ """绝对路径在 cwd 下 → 存相对(参考 CC maybeShortenFilePath);否则原样。"""
+ ap = os.path.abspath(abs_path)
+ try:
+ if os.path.commonpath([ap, self.cwd]) == self.cwd:
+ return os.path.relpath(ap, self.cwd).replace(os.sep, "/")
+ except ValueError:
+ pass # 跨盘符(Windows)等:commonpath 抛错 → 用绝对路径
+ return ap.replace(os.sep, "/")
+
+ def _abs(self, rel: str) -> str:
+ if os.path.isabs(rel) or (len(rel) > 1 and rel[1] == ":"):
+ return os.path.normpath(rel)
+ return os.path.normpath(os.path.join(self.cwd, rel))
+
+ # ------------------------------------------------------------------ blobs
+ def _put_blob(self, data: bytes) -> str:
+ h = hashlib.sha256(data).hexdigest()
+ p = os.path.join(self.objects_dir, h[:2], h)
+ if not os.path.exists(p):
+ os.makedirs(os.path.dirname(p), exist_ok=True)
+ tmp = p + ".tmp"
+ with open(tmp, "wb") as f:
+ f.write(zlib.compress(data, 6))
+ os.replace(tmp, p)
+ return h
+
+ def _get_blob(self, h: str) -> bytes:
+ with open(os.path.join(self.objects_dir, h[:2], h), "rb") as f:
+ return zlib.decompress(f.read())
+
+ def _snapshot(self, abs_path: str) -> str | None:
+ """当前内容 → blob hash;文件不存在 → None。"""
+ try:
+ with open(abs_path, "rb") as f:
+ data = f.read()
+ except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):
+ return _ABSENT
+ return self._put_blob(data)
+
+ # --------------------------------------------------------------- tracking
+ def track_pre_edit(self, abs_path: str) -> None:
+ """改前埋点:首次见到该文件时记下它的「改前」状态作 baseline。
+
+ 幂等——同一文件同段内多次调用只在首次抓 baseline(file_patch 连改不会
+ 覆盖原始版本,对应 CC trackEdit 的 v1 保护)。"""
+ rel = self.key(abs_path)
+ self._touched.add(rel)
+ if rel not in self.baseline:
+ self.baseline[rel] = self._snapshot(abs_path) # 改前内容,可能是 None
+ self.tracked.add(rel)
+
+ def commit(self, title: str, hist_len: int | None = None, kind: str = "edit",
+ history: list | None = None) -> str:
+ """段末落节点:继承 HEAD.files,把本段触碰文件的「改后」内容写进新节点。
+
+ `history` 给定时(对话树化):切 `history[parent.hist_len:当前]` 为本轮对话增量,
+ 存成 content-addressable conv blob,节点记其 hash + `hist_len=len(history)`。
+ 此刻索引可靠 → 增量精确,恢复时沿路径拼回即可,不再靠会漂的绝对下标。
+
+ 无触碰文件时也会落节点(纯对话推进也是 checkpoint)。返回新节点 id。"""
+ self._ensure_origin() # 第一次提交前先确保有「会话起点」根
+ parent = self.head # 至少是 origin,永不为 None
+ files = dict(self.nodes[parent]["files"]) if parent is not None else {}
+ for rel in self._touched:
+ files[rel] = self._snapshot(self._abs(rel)) # 改后内容,删除则 None
+
+ conv = None
+ if history is not None:
+ parent_len = self.nodes[parent].get("hist_len") if parent is not None else 0
+ parent_len = parent_len or 0 # None(旧节点) / origin → 0
+ delta = list(history[parent_len:])
+ hist_len = len(history)
+ conv = self._put_blob(
+ json.dumps(delta, ensure_ascii=False, default=str).encode("utf-8"))
+
+ nid = self._new_id()
+ self.nodes[nid] = {
+ "parent": parent,
+ "children": [],
+ "title": title,
+ "created": time.time(),
+ "kind": kind,
+ "hist_len": hist_len,
+ "files": files,
+ "conv": conv,
+ }
+ if parent is not None:
+ self.nodes[parent]["children"].append(nid)
+ elif self.root_id is None or self.root_id not in self.nodes:
+ self.root_id = nid # 只认第一个根;rewind-到-根之前再提交会产生第二个顶层节点(forest)
+ self.head = nid
+ self._touched.clear()
+ self.save()
+ return nid
+
+ # ------------------------------------------------------------- navigation
+ def rewind_head(self, node_id) -> None:
+ """移动 HEAD(不动文件)。从非叶 HEAD 下次 commit 即 append 子节点 = fork。
+ node_id=None 表示「回到根之前」(空起点),下次 commit 产生新的顶层节点。"""
+ if node_id is not None and node_id not in self.nodes:
+ raise KeyError(node_id)
+ self.head = node_id
+ self.save()
+
+ def top_level_nodes(self) -> list[str]:
+ """所有顶层节点(parent 为 None 或父已不存在)。通常一个 root,rewind-到-根
+ 之前再提交会出现多个(forest)。"""
+ return [nid for nid, nd in self.nodes.items()
+ if nd.get("parent") is None or nd.get("parent") not in self.nodes]
+
+ def _target_state(self, node_id: str, rel: str):
+ """文件 rel 在 node_id 时应有的状态:节点 files 里有就用它,否则回退到
+ baseline(CC 的 first-version)。返回 (known: bool, hash_or_None)。"""
+ files = self.nodes[node_id]["files"]
+ if rel in files:
+ return True, files[rel]
+ if rel in self.baseline:
+ return True, self.baseline[rel]
+ return False, None
+
+ def apply_code(self, node_id) -> list[tuple[str, str]]:
+ """把工作区文件还原到 node_id 的状态。`node_id=None` → 还原到 baseline
+ (任何 checkpoint 之前的原始状态,用于「回退到根之前」)。
+
+ - **只动本 store 追踪过的路径**(self.tracked),绝不碰未记录文件(软保险①)。
+ - restore 前先把这些路径的当前内容存进 redo 点(软保险②)——误覆盖也能找回。
+ 返回 [(rel, action)],action ∈ {restored, deleted}。"""
+ if node_id is not None and node_id not in self.nodes:
+ raise KeyError(node_id)
+
+ # 软保险②:redo 点(记录当前 = 即将被覆盖的状态)。
+ redo_files: dict[str, str | None] = {}
+ for rel in self.tracked:
+ redo_files[rel] = self._snapshot(self._abs(rel))
+ self._redo = {"from": self.head, "files": redo_files}
+
+ changed: list[tuple[str, str]] = []
+ for rel in self.tracked:
+ if node_id is None:
+ known = rel in self.baseline
+ target = self.baseline.get(rel)
+ else:
+ known, target = self._target_state(node_id, rel)
+ if not known:
+ continue
+ ap = self._abs(rel)
+ cur = self._snapshot(ap)
+ if cur == target:
+ continue
+ if target is _ABSENT:
+ try:
+ os.remove(ap)
+ changed.append((rel, "deleted"))
+ except FileNotFoundError:
+ pass
+ else:
+ os.makedirs(os.path.dirname(ap) or ".", exist_ok=True)
+ with open(ap, "wb") as f:
+ f.write(self._get_blob(target))
+ changed.append((rel, "restored"))
+ return changed
+
+ def diff(self, node_id: str) -> list[dict]:
+ """选中节点 vs 当前工作区,逐文件变更摘要(恢复到该节点会发生什么)。
+
+ 返回 [{rel, action, insertions, deletions}],仅列出会变的文件。
+ action: restore(覆盖)/ delete(删除)/ create(重建被删文件)。"""
+ if node_id not in self.nodes:
+ raise KeyError(node_id)
+ out: list[dict] = []
+ for rel in sorted(self.tracked):
+ known, target = self._target_state(node_id, rel)
+ if not known:
+ continue
+ cur = self._snapshot(self._abs(rel))
+ if cur == target:
+ continue
+ old = self._text(cur) # 当前工作区内容
+ new = self._text(target) # 该节点内容(恢复目标)
+ ins = dele = 0
+ for line in difflib.ndiff(old.splitlines(), new.splitlines()):
+ if line.startswith("+ "):
+ ins += 1
+ elif line.startswith("- "):
+ dele += 1
+ if target is _ABSENT:
+ action = "delete"
+ elif cur is _ABSENT:
+ action = "create"
+ else:
+ action = "restore"
+ out.append({"rel": rel, "action": action, "insertions": ins, "deletions": dele})
+ return out
+
+ def node_diff(self, node_id) -> list[dict]:
+ """选中节点 vs **父节点**的逐文件内容 diff(= 这次 checkpoint 改了什么)。
+ 返回 [{rel, old, new}](文本),仅含两者不同的文件;父缺失/文件不存在 → 空文本。
+ 供 UI 渲染逐行 diff(区别于 `diff()` 的"节点 vs 当前工作区"计数摘要)。"""
+ if node_id not in self.nodes:
+ return []
+ nd = self.nodes[node_id]
+ par = nd.get("parent")
+ pf = self.nodes[par]["files"] if par in self.nodes else {}
+ nf = nd.get("files") or {}
+ out: list[dict] = []
+ for rel in sorted(set(nf) | set(pf)):
+ nh = nf.get(rel)
+ # 「上一个状态」:父节点记录过就用它;父节点没有(本节点首次触碰该文件)→
+ # 回退到 baseline(首次改前内容,CC first-version)。否则被 file_patch 修改的
+ # 已有文件会因父节点无记录而被误显示成整文件新增,而非真正的逐行 diff。
+ ph = pf[rel] if rel in pf else self.baseline.get(rel)
+ if nh == ph:
+ continue
+ out.append({"rel": rel, "old": self._text(ph), "new": self._text(nh)})
+ return out
+
+ def _text(self, h: str | None) -> str:
+ if h is _ABSENT:
+ return ""
+ try:
+ return self._get_blob(h).decode("utf-8", "replace")
+ except Exception:
+ return ""
+
+ # --------------------------------------------------------- delete + gc
+ def delete_subtree(self, node_id: str) -> list[str]:
+ """删除节点及其整棵子树(不能删 HEAD 祖先路径上的节点 / root 由调用方把关)。
+ 返回被删的节点 id 列表。删后做一次 blob GC。"""
+ if node_id not in self.nodes:
+ raise KeyError(node_id)
+ victims: list[str] = []
+
+ def collect(nid: str) -> None:
+ victims.append(nid)
+ for c in list(self.nodes[nid]["children"]):
+ collect(c)
+
+ collect(node_id)
+ parent = self.nodes[node_id]["parent"]
+ if parent is not None:
+ self.nodes[parent]["children"] = [
+ c for c in self.nodes[parent]["children"] if c != node_id
+ ]
+ for nid in victims:
+ self.nodes.pop(nid, None)
+ if node_id == self.root_id:
+ self.root_id = None
+ if self.head in victims:
+ self.head = parent # HEAD 被删 → 退到父
+ self._gc()
+ self.save()
+ return victims
+
+ def _gc(self) -> int:
+ """删除无人引用的 blob。返回删除数。"""
+ referenced: set[str] = set()
+ for node in self.nodes.values():
+ for h in node["files"].values():
+ if h is not _ABSENT:
+ referenced.add(h)
+ ch = node.get("conv") # 对话增量 blob 也是引用
+ if ch:
+ referenced.add(ch)
+ for h in self.baseline.values():
+ if h is not _ABSENT:
+ referenced.add(h)
+ if self._redo:
+ for h in self._redo["files"].values():
+ if h is not _ABSENT:
+ referenced.add(h)
+ removed = 0
+ if not os.path.isdir(self.objects_dir):
+ return 0
+ for sub in os.listdir(self.objects_dir):
+ d = os.path.join(self.objects_dir, sub)
+ if not os.path.isdir(d):
+ continue
+ for h in os.listdir(d):
+ if h not in referenced:
+ try:
+ os.remove(os.path.join(d, h))
+ removed += 1
+ except OSError:
+ pass
+ return removed
+
+ # ------------------------------------------------------------ persistence
+ def save(self) -> None:
+ os.makedirs(self.root, exist_ok=True)
+ payload = {
+ "nodes": self.nodes,
+ "head": self.head,
+ "root": self.root_id,
+ "seq": self._seq,
+ "baseline": self.baseline,
+ "tracked": sorted(self.tracked),
+ }
+ tmp = self.tree_path + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(payload, f, ensure_ascii=False, default=str)
+ # 崩溃兜底:替换前把上一份完好的 tree.json 留作 .bak(load 损坏时回退它)。
+ if os.path.exists(self.tree_path):
+ try:
+ shutil.copyfile(self.tree_path, self.tree_path + ".bak")
+ except OSError:
+ pass
+ os.replace(tmp, self.tree_path)
+
+ def load(self) -> None:
+ """加载树。主文件损坏/缺失 → 回退 .bak;都不行 → 留空 store(降级,不抛)。"""
+ for path in (self.tree_path, self.tree_path + ".bak"):
+ if not os.path.exists(path):
+ continue
+ try:
+ with open(path, encoding="utf-8") as f:
+ d = json.load(f)
+ except Exception:
+ continue # 损坏 → 尝试下一个候选
+ self.nodes = d.get("nodes", {})
+ self.head = d.get("head")
+ self.root_id = d.get("root")
+ self._seq = d.get("seq", 0)
+ self.baseline = d.get("baseline", {})
+ self.tracked = set(d.get("tracked", []))
+ return
+
+ def _new_id(self) -> str:
+ self._seq += 1
+ return f"v{self._seq}"
+
+ # ---------------------------------------------------------- session key
+ @staticmethod
+ def key_for_log(log_path: str) -> str:
+ """会话 key = log_path 的 basename 去扩展名,如 `model_responses_123456`。
+ 这是 GA 唯一稳定、/continue 据以识别会话的身份(task_dir 是 PID 级、重启即变,
+ 不能用)。"""
+ return os.path.splitext(os.path.basename(log_path))[0]
+
+ @classmethod
+ def for_log(cls, temp_dir: str, log_path: str, cwd: str) -> "RewindStore":
+ """按 GA 的 log_path 落到 `temp/.ga_rewind//`。"""
+ root = os.path.join(temp_dir, ".ga_rewind", cls.key_for_log(log_path))
+ return cls(root, cwd)
+
+ def resume_from(self, old_root: str) -> bool:
+ """/continue 续接:把旧会话的 rewind 目录接到本 store(贴近 CC 的
+ copyFileHistoryForResume)。
+
+ GA 每个新 agent 生成新 log_path → 新 key,旧会话的树/blob 默认看不到;
+ 这里在新 key 下重建:① 接管旧 tree.json(仅当本 store 尚为空);
+ ② blob 内容寻址,直接 hardlink(同名即同内容,跳过;失败回退 copy)。
+ 返回是否接管成功。"""
+ if not os.path.isdir(old_root) or os.path.abspath(old_root) == self.root:
+ return False
+ old_tree = os.path.join(old_root, "tree.json")
+ if not self.nodes and os.path.exists(old_tree):
+ try:
+ with open(old_tree, encoding="utf-8") as f:
+ d = json.load(f)
+ self.nodes = d.get("nodes", {})
+ self.head = d.get("head")
+ self.root_id = d.get("root")
+ self._seq = d.get("seq", 0)
+ self.baseline = d.get("baseline", {})
+ self.tracked = set(d.get("tracked", []))
+ except Exception:
+ return False
+ old_objects = os.path.join(old_root, "objects")
+ if os.path.isdir(old_objects):
+ for sub in os.listdir(old_objects):
+ sd = os.path.join(old_objects, sub)
+ if not os.path.isdir(sd):
+ continue
+ dd = os.path.join(self.objects_dir, sub)
+ os.makedirs(dd, exist_ok=True)
+ for h in os.listdir(sd):
+ dst = os.path.join(dd, h)
+ if os.path.exists(dst):
+ continue # 内容寻址:同名即同内容
+ src = os.path.join(sd, h)
+ try:
+ os.link(src, dst) # hardlink 不占额外空间
+ except OSError:
+ try:
+ shutil.copyfile(src, dst) # 跨盘/不支持 → 回退 copy
+ except OSError:
+ pass
+ if self.nodes:
+ self._ensure_origin() # 旧树若无 origin,迁移补上
+ self.save()
+ return True
+
+ # ------------------------------------------------------------------ views
+ def linear_path(self) -> list[str]:
+ """root → HEAD 的线性路径(节点 id),供 /rewind 时间线展示。"""
+ return self.path_to(self.head)
+
+ def path_to(self, node_id) -> list[str]:
+ """root → node_id 的节点 id 列表(含两端)。node_id 缺失 → []。"""
+ if node_id is None or node_id not in self.nodes:
+ return []
+ chain: list[str] = []
+ cur: str | None = node_id
+ while cur is not None and cur in self.nodes:
+ chain.append(cur)
+ cur = self.nodes[cur].get("parent")
+ chain.reverse()
+ return chain
+
+ # ----------------------------------------------------------- conversation
+ def _node_conv(self, node_id) -> list:
+ """单节点的对话增量(list)。无 conv / 读失败 → [](容错降级)。"""
+ h = self.nodes.get(node_id, {}).get("conv")
+ if not h:
+ return []
+ try:
+ data = json.loads(self._get_blob(h).decode("utf-8"))
+ return data if isinstance(data, list) else []
+ except Exception:
+ return []
+
+ def rebuild_history(self, node_id) -> list:
+ """沿 root→node_id 拼接各节点对话增量,整体重建 `backend.history`。
+
+ 这是「树=真相源」的恢复入口:选任意节点(含旁支)都能精确还原其对话,
+ 不依赖内存里那条线性 history。origin / 无 conv 的节点贡献空增量。"""
+ hist: list = []
+ for nid in self.path_to(node_id):
+ hist.extend(self._node_conv(nid))
+ return hist
+
+ # ----------------------------------------------------- 对账(防外部改写灾难)
+ @staticmethod
+ def _msg_user_text(msg) -> str:
+ """真实用户提问文本;非 user / 纯 tool_result 回填 → ""(不算提问边界)。"""
+ if not isinstance(msg, dict) or msg.get("role") != "user":
+ return ""
+ c = msg.get("content")
+ if isinstance(c, str):
+ return c
+ if isinstance(c, list):
+ if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c):
+ return ""
+ return " ".join(b.get("text", "") for b in c
+ if isinstance(b, dict) and b.get("type") == "text")
+ return ""
+
+ @classmethod
+ def _turn_sig(cls, msgs) -> list:
+ """一段消息的「对话身份」= 其中真实用户提问的文本序列(跳过 tool_result 轮,
+ 故不含注入的易变内容)。用于 reconcile 判两段是否同一对话。"""
+ return [t for t in (cls._msg_user_text(m).strip() for m in msgs) if t]
+
+ def reconcile(self, history: list) -> int:
+ """把 live history 里树尚未记录的尾部轮次吸收成 conv-only 节点,使树追平日志。
+ 供「带 worldline 的 UI」在打开世界线 / 接管日志前调用 —— 别的(不更新树的)
+ UI 往同一日志追加后,若不对账,树会滞后;一旦在滞后状态 rewind,
+ `rewrite_projection` 会拿陈旧树回写、**抹掉那些外部轮次**(灾难性丢失)。
+ 对账后树恒 ⊇ 日志,故 rewind 物理上不可能 clobber 未见过的轮次。
+
+ **安全闸**:仅当树 HEAD 路径与 history 前缀是「同一段对话」才在其上 append;
+ 否则判为分歧(用户问了不同的问题),HEAD 退回 origin 另起一条顶层 trunk 容纳
+ live history,旧树保留为旁支、绝不拿它回写日志。空树 = n=0 特例(吸收全部,
+ 即旧 _bootstrap_conv_tree)。幂等:无尾巴 → no-op,可任意重复调用。返回吸收条数。
+
+ 判「同一段对话」用**用户提问序列**(`_turn_sig`)而非逐条全等:消息里会被注入
+ 易变内容(如 `[WORKING MEMORY]`,只在 tool_result 轮),同一段对话在不同时刻
+ 捕获并不逐字节相等;逐条全等会把它误判成分歧、复制出一整条重复 trunk。提问
+ 文本在 tool_result 轮之外、稳定不变,是可靠的对话身份。"""
+ history = list(history or [])
+ self._ensure_origin()
+ head = self.head if self.head in self.nodes else self.root_id
+ known = self.rebuild_history(head)
+ n = len(known)
+ if self._turn_sig(history[:n]) != self._turn_sig(known):
+ # 分歧(提问序列不同):不信任陈旧树。退回空起点,后续 append 只反映 live history。
+ self.rewind_head(self.root_id)
+ n = len(self.rebuild_history(self.root_id)) # origin 无 conv → 0
+ if len(history) <= n:
+ return 0
+ # 按用户提问边界切尾巴 [n, len);逐段 commit(history=history[:段末]),
+ # commit 内部 delta = history[parent.hist_len:段末]。清 _touched 保证纯 conv-only。
+ starts = [i for i in range(n, len(history))
+ if self._msg_user_text(history[i]).strip()]
+ if not starts or starts[0] > n:
+ starts = [n] + starts
+ self._touched.clear()
+ absorbed = 0
+ for p, s in enumerate(starts):
+ end = starts[p + 1] if p + 1 < len(starts) else len(history)
+ title = next((self._msg_user_text(history[i]).strip()
+ for i in range(s, end)
+ if self._msg_user_text(history[i]).strip()), "")
+ title = (title or "(外部续接)").replace("\n", " ").strip()[:80]
+ self.commit(title or "(外部续接)", kind="edit", history=list(history[:end]))
+ absorbed += end - s
+ return absorbed
+
+ def first_user_message(self, node_id) -> dict | None:
+ """node_id 对话增量里的首条 `role==user` 消息(原始 dict);无则 None。
+
+ 供 rewind 后精确 prefill(取「该提问本身」,不靠会漂的绝对下标)。文本提取
+ 交调用方(消息内容格式是 GA backend 私有的)。"""
+ for msg in self._node_conv(node_id):
+ if isinstance(msg, dict) and msg.get("role") == "user":
+ return msg
+ return None
+
+
+# ============================================================================
+# 世界线视图模型 + 压缩 + 导航(从 rewind_tree_view.py 抽出的 UI 无关后端)
+# ============================================================================
+# 节点**语义**(字形 + 文字标签);**呈现色由前端决定**(颜色不属于后端)。
+KIND_GLYPH = {"current": "◉", "rewind": "↩", "origin": "●"} # ◉=当前所在;●=普通/根
+KIND_LABEL = {"current": "当前位置", "rewind": "回退来源", "origin": "会话起点"}
+
+
+def kind_glyph(kind: str) -> str:
+ return KIND_GLYPH.get(kind, "●")
+
+
+def kind_label(kind: str) -> str:
+ return KIND_LABEL.get(kind, "")
+
+
+def rel_time(ago_s) -> str:
+ if ago_s is None:
+ return ""
+ if ago_s < 60:
+ return "刚刚"
+ m = ago_s // 60
+ if m < 60:
+ return f"{m} 分钟前"
+ h = m // 60
+ if h < 24:
+ return f"{h} 小时前"
+ return f"{h // 24} 天前"
+
+
+def files_summary(files: List[str]) -> str:
+ if not files:
+ return "无变更"
+ if len(files) <= 3:
+ return "、".join(files)
+ return "、".join(files[:3]) + f" (+{len(files) - 3})"
+
+
+_TITLE_MAX_CELLS = 40
+
+
+def ellipsize(s: str, max_cells: int = _TITLE_MAX_CELLS) -> str:
+ """按显示宽度(中文算 2 格)截断,超长加 `…`。用于左树/右上的长提问标题。"""
+ s = (s or "").replace("\n", " ").strip()
+ if cell_len(s) <= max_cells:
+ return s
+ return set_cell_size(s, max(1, max_cells - 1)).rstrip() + "…"
+
+
+# --------------------------------------------------------------- 数据模型
+@dataclass
+class CheckpointNode:
+ id: str
+ title: str
+ parent_id: Optional[str] = None
+ children: List[str] = field(default_factory=list)
+ kind: str = "edit"
+ files: List[str] = field(default_factory=list)
+ ago: Optional[int] = 0
+
+
+class CheckpointTree:
+ """只读视图树(供压缩/渲染);从 RewindStore 构造。"""
+
+ def __init__(self) -> None:
+ self.nodes: Dict[str, CheckpointNode] = {}
+ self.root_id: Optional[str] = None
+
+
+def _changed_files(store, node_id) -> List[str]:
+ import os
+ nd = store.nodes[node_id]
+ par = nd.get("parent")
+ pf = store.nodes[par]["files"] if par in store.nodes else {}
+ out: List[str] = []
+ for k, v in nd["files"].items():
+ if pf.get(k) != v:
+ b = os.path.basename(k)
+ if b not in out:
+ out.append(b)
+ return out
+
+
+def tree_from_store(store, now: float) -> CheckpointTree:
+ """把 RewindStore 的真实树投影成只读 CheckpointTree;HEAD 标 current。"""
+ t = CheckpointTree()
+ for nid, nd in store.nodes.items():
+ t.nodes[nid] = CheckpointNode(
+ id=nid,
+ title=nd.get("title") or "(空)",
+ parent_id=nd.get("parent"),
+ children=[c for c in nd.get("children", []) if c in store.nodes],
+ kind="current" if nid == store.head else nd.get("kind", "edit"),
+ files=_changed_files(store, nid),
+ ago=int(max(0, now - nd.get("created", now))),
+ )
+ t.root_id = store.root_id
+ return t
+
+
+# --------------------------------------------- 压缩(强制折叠线性段,§4.2)
+@dataclass
+class DisplayNode:
+ key: int
+ kind: str # 'real' | 'fold'
+ depth: int = 0
+ parent_key: Optional[int] = None
+ children: List[int] = field(default_factory=list)
+ node_id: Optional[str] = None
+ seg: List[str] = field(default_factory=list)
+ end_id: Optional[str] = None
+
+
+class CompressedTree:
+ def __init__(self, tree: CheckpointTree) -> None:
+ self.tree = tree
+ self.disp: Dict[int, DisplayNode] = {}
+ self.roots: List[int] = [] # 顶层显示节点 keys(通常一个 = origin)
+ self._k = 0
+ # 顶层节点(origin「会话起点」)常驻独立显示作锚点;它下面的真实对话链
+ # 照常折叠(§4.2)。origin 是「第一轮对话之前」的空状态,不是某轮对话,
+ # 所以独立显示语义清晰、不会像"第一轮对话被强行拎出"那样让人困惑。
+ for top in self._top_level():
+ self._build_root_node(top)
+
+ def _top_level(self) -> List[str]:
+ return [nid for nid, n in self.tree.nodes.items()
+ if n.parent_id is None or n.parent_id not in self.tree.nodes]
+
+ def _new_key(self) -> int:
+ k = self._k
+ self._k += 1
+ return k
+
+ def _build_root_node(self, root_id: str) -> int:
+ key = self._new_key()
+ self.disp[key] = DisplayNode(key=key, kind="real", node_id=root_id, depth=0)
+ self.roots.append(key)
+ for child_id in self.tree.nodes[root_id].children:
+ self._build_chain(child_id, 1, key)
+ return key
+
+ def _build_chain(self, start_id: str, depth: int, parent: Optional[int]) -> int:
+ seg = [start_id]
+ cur = start_id
+ while len(self.tree.nodes[cur].children) == 1:
+ cur = self.tree.nodes[cur].children[0]
+ seg.append(cur)
+ end = seg[-1]
+ key = self._new_key()
+ if len(seg) == 1:
+ self.disp[key] = DisplayNode(key=key, kind="real", node_id=end,
+ depth=depth, parent_key=parent)
+ else:
+ self.disp[key] = DisplayNode(key=key, kind="fold", depth=depth,
+ parent_key=parent, seg=list(seg), end_id=end)
+ if parent is None:
+ self.roots.append(key)
+ else:
+ self.disp[parent].children.append(key)
+ for child_id in self.tree.nodes[end].children:
+ self._build_chain(child_id, depth + 1, key)
+ return key
+
+ def flatten(self) -> List[int]:
+ order: List[int] = []
+
+ def dfs(k: int) -> None:
+ order.append(k)
+ for c in self.disp[k].children:
+ dfs(c)
+
+ for r in self.roots:
+ dfs(r)
+ return order
+
+ def end_node(self, key: int) -> CheckpointNode:
+ d = self.disp[key]
+ return self.tree.nodes[d.node_id if d.kind == "real" else d.end_id]
+
+ def label(self, key: int) -> str:
+ # 真实/折叠都只放末端标题(截断);折叠的 [N nodes] 标记放在 glyph 位(标题前)。
+ d = self.disp[key]
+ nid = d.node_id if d.kind == "real" else d.end_id
+ return ellipsize(self.tree.nodes[nid].title)
+
+ def glyph(self, key: int) -> str:
+ # 折叠节点开头用 [N nodes] 替代原来的 … 省略号(→ "[3 nodes] 标题");
+ # 真实节点仍用语义字形(● / ↩ / ◉)。
+ d = self.disp[key]
+ if d.kind == "fold":
+ return f"[{len(d.seg)} nodes]"
+ return kind_glyph(self.tree.nodes[d.node_id].kind)
+
+
+# ----------------------------------------------------- 导航(纯计算)
+def _order_depths(ct: CompressedTree) -> List[tuple[int, int]]:
+ return [(k, ct.disp[k].depth) for k in ct.flatten()]
+
+
+def next_same_depth(order, sel, direction):
+ idx = next(i for i, (k, _) in enumerate(order) if k == sel)
+ depth = order[idx][1]
+ same = [i for i, (_, d) in enumerate(order) if d == depth]
+ pos = same.index(idx)
+ return order[same[(pos + direction) % len(same)]][0]
+
+
+def nearest_depth_node(order, sel, delta):
+ idx = next(i for i, (k, _) in enumerate(order) if k == sel)
+ target = order[idx][1] + delta
+ if target < 0:
+ return sel
+ cand = [i for i, (_, d) in enumerate(order) if d == target]
+ if not cand:
+ return sel
+ if delta > 0:
+ best = min(cand, key=lambda i: (abs(i - idx), 0 if i >= idx else 1, i))
+ else:
+ best = min(cand, key=lambda i: (abs(i - idx), 0 if i <= idx else 1, -i))
+ return order[best][0]
+
+
+def parent_sibling_first_child(ct, sel, direction):
+ d = ct.disp[sel]
+ if d.parent_key is None:
+ return sel
+ parent = ct.disp[d.parent_key]
+ if parent.parent_key is None:
+ return sel
+ siblings = ct.disp[parent.parent_key].children
+ if parent.key not in siblings or len(siblings) <= 1:
+ return sel
+ start = siblings.index(parent.key)
+ step = 1 if direction >= 0 else -1
+ for off in range(1, len(siblings)):
+ sib = ct.disp[siblings[(start + step * off) % len(siblings)]]
+ if sib.children:
+ return sib.children[0]
+ return sel
+
+
+# ============================================================================
+# 恢复编排(UI 无关):算出回退后的对话/文件/prefill 并落地,前端只刷新自己的显示
+# ============================================================================
+def user_msg_text(entry) -> str:
+ """从一条 user 消息里取出用户可见文本(content 为 str 或 text block 列表)。"""
+ c = entry.get("content") if isinstance(entry, dict) else None
+ if isinstance(c, str):
+ return c
+ if isinstance(c, list):
+ parts = [b.get("text", "") for b in c
+ if isinstance(b, dict) and b.get("type") == "text"]
+ return "\n".join(p for p in parts if p)
+ return ""
+
+
+def render_native_history(history, ts=None):
+ """Inverse of `continue_cmd._parse_native_history`: render a `backend.history`
+ (alternating user/assistant) back into the native `=== Prompt/Response ===`
+ log text. Lives here (not continue_cmd) because the rewind tree is its only
+ caller — it rewrites `model_responses_.txt` as a linear projection of
+ the current HEAD path so /continue + other UIs get a clean single-branch view
+ (the TUI itself reads only the tree). Round-trip holds:
+ `_parse_native_history(_pairs(render_native_history(h))) == h`.
+
+ Byte-for-byte matches llmcore's `_write_llm_log` for the tool backend:
+ Prompt = `json.dumps(merged, ensure_ascii=False, indent=2)`; Response =
+ `str(content_blocks)` which for a list equals `repr(...)` (survives
+ True/False/None that JSON would emit as true/false/null and break
+ `ast.literal_eval`). Only complete (user, assistant) pairs are emitted."""
+ ts = ts or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ out, i, n = [], 0, len(history)
+ while i < n:
+ u = history[i]
+ if isinstance(u, dict) and u.get('role') == 'user' and i + 1 < n:
+ a = history[i + 1]
+ if isinstance(a, dict) and a.get('role') == 'assistant':
+ out.append(f"=== Prompt === {ts}\n{json.dumps(u, ensure_ascii=False, indent=2)}\n\n")
+ out.append(f"=== Response === {ts}\n{a.get('content')!r}\n\n")
+ i += 2
+ continue
+ i += 1
+ return "".join(out)
+
+
+def rewrite_projection(store, target, log_path: str) -> bool:
+ """把 `model_responses_.txt` 重写成 root→target 的线性投影(native
+ `=== Prompt/Response ===` 格式,`continue_cmd._parse_native_history` 可解析)。
+
+ 给其他 UI + /continue 的兼容视角;消除 rewind/fork 后多分支混排。故障静默。"""
+ if store is None or not log_path:
+ return False
+ try:
+ text = render_native_history(store.rebuild_history(target))
+ # 回退到「会话起点」(origin)时 history 为空 → 日志被写空(0 字节)。这是【硬】回退:
+ # 日志恒等于树 HEAD,无错位,reconcile 不会复制分支。空日志会被普通 UI 的
+ # list_sessions(sz<32) 无害跳过;带 worldline 的 UI 靠 list_sessions(rewind_root=...)
+ # 的树感知发现 + continue_inplace(allow_empty=True) 仍能找回并恢复(空对话 + 重连树)。
+ tmp = log_path + ".tmp"
+ with open(tmp, "w", encoding="utf-8", errors="replace") as f:
+ f.write(text)
+ os.replace(tmp, log_path)
+ return True
+ except Exception:
+ return False
+
+
+def restore_plan(store, node_id, mode: str = "both", to: str = "before",
+ log_path: str = "") -> Optional[dict]:
+ """UI 无关的回退编排。`to`:
+ - **before**(选中段内某节点):回到该提问**之前**(连该节点一起清除),prefill 其提问;
+ target = parent(node) / origin。
+ - **at**(末尾占位项):在该节点处**继续**(HEAD→node,不清除、无 prefill)。
+ `mode`: both/conv/code。会就地:重建对话(返回 history,调用方自行赋给 backend)、
+ `apply_code`(还原文件,前自动留 redo 点)、移 HEAD、重写投影日志。
+
+ 返回 None(无效) 或 dict:
+ {history: list|None(仅 conv 变更时), changed: [(rel,action)], prefill: str,
+ at_origin: bool, target: str, title: str, to: str}
+ —— 前端据此:赋 backend.history、重建界面消息、prefill 输入框、刷新。"""
+ if store is None or not node_id or node_id not in store.nodes:
+ return None
+ nd = store.nodes[node_id]
+ prefill = ""
+ if to == "at":
+ target = node_id
+ at_origin = nd.get("kind") == "origin"
+ else:
+ parent = nd.get("parent")
+ parent = parent if parent in store.nodes else None
+ target = parent if parent is not None else node_id
+ at_origin = parent is None
+ if not at_origin:
+ um = store.first_user_message(node_id)
+ prefill = (user_msg_text(um) if um else "") or nd.get("title", "")
+
+ history = None
+ if mode in ("both", "conv"):
+ history = store.rebuild_history(target)
+ changed = []
+ if mode in ("both", "code"):
+ try:
+ changed = store.apply_code(target)
+ except Exception:
+ changed = []
+ store.rewind_head(target)
+ if mode in ("both", "conv") and log_path:
+ rewrite_projection(store, target, log_path)
+ return {
+ "history": history,
+ "changed": changed,
+ "prefill": prefill,
+ "at_origin": at_origin,
+ "target": target,
+ "title": nd.get("title", ""),
+ "to": to,
+ }
diff --git a/ga.py b/ga.py
index 3ae4f991..f017ff62 100644
--- a/ga.py
+++ b/ga.py
@@ -1,4 +1,4 @@
-import sys, os, re, json, time, threading, importlib
+import sys, os, re, json, time, threading, importlib, webbrowser
from datetime import datetime
from pathlib import Path
import tempfile, traceback, subprocess, itertools, collections, difflib, shutil
@@ -108,14 +108,11 @@ def first_init_driver():
global driver
from TMWebDriver import TMWebDriver
driver = TMWebDriver()
- for i in range(20):
- time.sleep(1)
+ for i in range(7):
+ time.sleep(2)
sess = driver.get_all_sessions()
if len(sess) > 0: break
- if len(sess) == 0: return
- if len(sess) == 1:
- #driver.newtab()
- time.sleep(3)
+ if i == 4: webbrowser.open("https://example.com")
def web_scan(tabs_only=False, switch_tab_id=None, text_only=False, maxlen=35000):
"""获取当前页面的简化HTML内容和标签页列表。注意:简化过程会过滤边栏、浮动元素等非主体内容。
@@ -244,9 +241,9 @@ def file_read(path, start=1, keyword=None, count=200, show_linenos=True):
except FileNotFoundError:
msg = f"Error: File not found: {path}"
try:
- tgt = os.path.basename(path); scan = os.path.dirname(os.path.dirname(os.path.abspath(path)))
- roots = [scan] + [d for d in _read_dirs if not d.startswith(scan)]
- cands = list(itertools.islice((c for base in roots for c in _scan_files(base)), 2000))
+ tgt = os.path.basename(path); parent = os.path.dirname(os.path.abspath(path)); scan = os.path.dirname(parent)
+ roots = [parent, scan] + [d for d in _read_dirs if not d.startswith(scan)]
+ cands = list(dict.fromkeys(itertools.islice((c for base in roots for c in _scan_files(base)), 2000)))
top = sorted([(difflib.SequenceMatcher(None, tgt.lower(), c[0].lower()).ratio(), c) for c in cands[:2000]], key=lambda x: -x[0])[:5]
top = [(s, c) for s, c in top if s > 0.3]
if top: msg += "\n\nDid you mean:\n" + "\n".join(f" {c[1]} ({s:.0%})" for s, c in top)
@@ -503,7 +500,7 @@ class GenericAgentHandler(BaseHandler):
if remaining == 0:
self._exit_plan_mode(); yield "[Info] Plan完成:plan.md中0个[ ]残留,退出plan模式。\n"
- yield "[Info] Final response to user.\n"
+ #yield "[Info] Final response to user.\n"
return StepOutcome(response, next_prompt=None)
def do_start_long_term_update(self, args, response):
@@ -556,7 +553,7 @@ class GenericAgentHandler(BaseHandler):
else:
tc = tool_calls[0]; tool_name, args = tc['tool_name'], tc['args'] # at least one because no_tool
clean_args = {k: v for k, v in args.items() if not k.startswith('_')}
- summary = f"调用工具{tool_name}, args: {clean_args}"
+ summary = f"{tool_name}, args: {clean_args}"
if tool_name == 'no_tool': summary = "直接回答了用户问题"
next_prompt += "\n\n\n[SYSTEM] 必须在回复文本中包含!\n\n"
summary = smart_format(summary.replace('\n', ''), max_str_len=40)
@@ -564,15 +561,17 @@ class GenericAgentHandler(BaseHandler):
self.history_info.append(f'[Agent] {summary}')
_plan = self._in_plan_mode()
- if turn % 75 == 0 and (not _plan):
- next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。必须总结情况进行ask_user,不允许继续重试。"
+ if turn % 175 == 0 and (not _plan):
+ next_prompt += f"\n\n[DANGER] Turn {turn}. Must call ask_user to summarize progress and get direction. No more blind retries."
elif turn % 7 == 0:
- next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。禁止无效重试。若无有效进展,必须切换策略:1. 探测物理边界 2. 请求用户协助。如有需要,可调用 update_working_checkpoint 保存关键上下文。"
+ next_prompt += f"\n\n[SYSTEM] Turn {turn}. Call update_working_checkpoint to save key context. Stop ineffective retries; if no progress, switch strategy: 1) Probe physical boundaries 2) **Re-read relevant SOPs**"
+ elif turn % 25 == 0:
+ next_prompt += f"\n\n[SYSTEM] Turn {turn}. Write checkpoints/key findings/tried approaches to a file for future reference. Avoid losing critical info."
elif turn % 10 == 0: next_prompt += get_global_memory()
if _plan and turn >= 10 and turn % 5 == 0:
next_prompt = f"[Plan Hint] 正在计划模式。必须 file_read({_plan}) 确认当前步骤,回复开头引用:📌 当前步骤:...\n\n" + next_prompt
- if _plan and turn >= 120: next_prompt += f"\n\n[DANGER] Plan模式已运行 {turn} 轮,已达上限。必须 ask_user 汇报进度并确认是否继续。"
+ if _plan and turn >= 190: next_prompt += f"\n\n[DANGER] Plan模式已运行 {turn} 轮,已达上限。必须 ask_user 汇报进度并确认是否继续。"
injkeyinfo = consume_file(self.parent.task_dir, '_keyinfo')
injprompt = consume_file(self.parent.task_dir, '_intervene')
diff --git a/launch.pyw b/launch.pyw
index 74658a4e..ee79c978 100644
--- a/launch.pyw
+++ b/launch.pyw
@@ -1,6 +1,6 @@
import webview, threading, subprocess, sys, time, os, ctypes, atexit, socket, random
-WINDOW_WIDTH, WINDOW_HEIGHT, RIGHT_PADDING, TOP_PADDING = 600, 900, 0, 100
+WINDOW_WIDTH, WINDOW_HEIGHT, RIGHT_PADDING, TOP_PADDING = 1200, 900, 0, 50
script_dir = os.path.dirname(os.path.abspath(__file__))
frontends_dir = os.path.join(script_dir, "frontends")
@@ -22,31 +22,6 @@ def start_streamlit(port):
proc = subprocess.Popen(cmd)
atexit.register(proc.kill)
-def inject(text):
- window.evaluate_js(f"""
- const textarea = document.querySelector('textarea[data-testid="stChatInputTextArea"]');
- if (textarea) {{
- // 1. 用原生 setter 设置值(绕过 React)
- const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
- nativeTextAreaValueSetter.call(textarea, {repr(text)});
- // 2. 触发 React 的 input 事件
- textarea.dispatchEvent(new Event('input', {{ bubbles: true }}));
- // 3. 触发 change 事件(有些组件需要)
- textarea.dispatchEvent(new Event('change', {{ bubbles: true }}));
- // 4. 延迟提交
- setTimeout(() => {{
- const btn = document.querySelector('[data-testid="stChatInputSubmitButton"]');
- if (btn) {{btn.click();console.log('Submitted:', {repr(text)});}}
- }}, 200);
- }}""")
-
-def get_last_reply_time():
- last = window.evaluate_js("""
- const el = document.getElementById('last-reply-time');
- el ? parseInt(el.textContent) : 0;
- """) or 0
- return last or int(time.time())
-
PASTE_HOOK_JS = """if (!window._pasteHooked) { window._pasteHooked = true;
document.addEventListener('paste', e => {
const items = e.clipboardData?.items; if (!items) return;
@@ -65,21 +40,13 @@ PASTE_HOOK_JS = """if (!window._pasteHooked) { window._pasteHooked = true;
}, true);
}"""
-def idle_monitor():
- last_trigger_time = 0
+def paste_hook_injector():
+ """注入剪贴板粘贴图片钩子,等窗口加载后执行一次,之后每60秒补注入防页面刷新丢失"""
+ time.sleep(5) # 等 webview 加载
while True:
- time.sleep(5)
- try:
- window.evaluate_js(PASTE_HOOK_JS)
- now = time.time()
- if now - last_trigger_time < 120: continue
- last_reply = get_last_reply_time()
- if now - last_reply > 1800:
- print('[Idle Monitor] Detected idle state, injecting task...')
- inject("[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。")
- last_trigger_time = now
- except Exception as e:
- print(f'[Idle Monitor] Error: {e}')
+ try: window.evaluate_js(PASTE_HOOK_JS)
+ except: pass
+ time.sleep(60)
if __name__ == '__main__':
import argparse
@@ -140,8 +107,7 @@ if __name__ == '__main__':
print('[Launch] Task Scheduler started (duplicate prevented by scheduler port lock)')
else: print('[Launch] Task Scheduler not enabled (--sched)')
- monitor_thread = threading.Thread(target=idle_monitor, daemon=True)
- monitor_thread.start()
+ threading.Thread(target=paste_hook_injector, daemon=True).start()
if os.name == 'nt':
screen_width = get_screen_width()
x_pos = screen_width - WINDOW_WIDTH - RIGHT_PADDING
diff --git a/llmcore.py b/llmcore.py
index e97375e9..5958e8dc 100644
--- a/llmcore.py
+++ b/llmcore.py
@@ -1,7 +1,7 @@
-import os, json, re, time, requests, sys, threading, urllib3, base64, importlib, uuid
+import os, json, re, time, requests, sys, threading, urllib3, base64, importlib, uuid, pathlib
from datetime import datetime
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
-_RESP_CACHE_KEY = str(uuid.uuid4())
+_RESP_CACHE_KEY = str(uuid.uuid4()); _RESP_CODEX_KEY = str(uuid.uuid4())
_ROOT = os.path.dirname(os.path.abspath(__file__))
if _ROOT not in sys.path: sys.path.append(_ROOT)
@@ -17,7 +17,9 @@ def _load_mykeys():
raise Exception(f'[ERROR] mykey.py has syntax error: {e}') from e
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json')
if not os.path.exists(p): raise Exception('[ERROR] mykey.py not found in sys.path and mykey.json not found. Run "python configure_mykey.py" or copy mykey_template.py to mykey.py and fill in your keys.')
- with open(_mykey_path := p, encoding='utf-8') as f: return json.load(f)
+ with open(_mykey_path := p, encoding='utf-8') as f: mk = json.load(f)
+ if isinstance(mk, dict) and 'remote_url' in mk: return requests.get(mk['remote_url'], timeout=10).json()
+ return mk
_mykey_path = _mykey_mtime = None
def reload_mykeys():
@@ -186,7 +188,8 @@ def _parse_claude_sse(resp_lines):
content_blocks.append(current_block); current_block = None
if warn:
print(f"[WARN] {warn.strip()}")
- content_blocks.append({"type": "text", "text": warn}); yield warn
+ insert_at = next((i for i,b in enumerate(content_blocks) if b.get("type") == "tool_use"), len(content_blocks))
+ content_blocks.insert(insert_at, {"type": "text", "text": warn}); yield warn
return content_blocks
def _try_parse_tool_args(raw):
@@ -271,8 +274,8 @@ def _parse_openai_sse(resp_lines, api_mode="chat_completions"):
except: continue
ch = (evt.get("choices") or [{}])[0]
delta = ch.get("delta") or {}
- if delta.get("reasoning_content"):
- reasoning_text += delta["reasoning_content"]
+ if rc := delta.get("reasoning_content") or delta.get("reasoning", ""):
+ reasoning_text += rc; yield rc
if delta.get("content"):
text = delta["content"]; content_text += text; yield text
for tc in (delta.get("tool_calls") or []):
@@ -331,7 +334,7 @@ def _parse_openai_json(data, api_mode="chat_completions"):
else:
_record_usage(data.get("usage") or {}, api_mode)
msg = (data.get("choices") or [{}])[0].get("message", {})
- reasoning = msg.get("reasoning_content", "")
+ reasoning = msg.get("reasoning_content") or msg.get("reasoning", "")
if reasoning:
blocks.append({"type": "thinking", "thinking": reasoning})
content = msg.get("content", "")
@@ -379,12 +382,13 @@ def _stream_with_retry(sess, url, headers, payload, parse_fn):
yield err; return [{"type": "text", "text": err}]
gen = parse_fn(r)
try:
- while True: streamed = True; yield next(gen)
+ while True: chunk = next(gen); streamed = True; yield chunk
except StopIteration as e:
if not e.value and not streamed: raise requests.ConnectionError("empty response")
return e.value or []
except (requests.Timeout, requests.ConnectionError) as e:
- err = f"!!!Error: {type(e).__name__}"
+ #pathlib.Path(__file__).parent.joinpath('temp','bad_requests.json').write_text(json.dumps({"url":url,"headers":headers,"payload":payload,"err":str(e),"t":time.time()},ensure_ascii=False),encoding='utf-8')
+ err = f"!!!Error: {type(e).__name__}: {e}" if str(e) else f"!!!Error: {type(e).__name__}"
if attempt < sess.max_retries:
d = _delay(None, attempt)
print(f"[LLM Retry] {type(e).__name__}, retry in {d:.1f}s ({attempt+1}/{sess.max_retries+1})")
@@ -400,11 +404,14 @@ def _openai_stream(sess, messages):
temperature = sess.temperature
if 'kimi' in ml or 'moonshot' in ml: temperature = 1
elif 'minimax' in ml: temperature = max(0.01, min(temperature, 1.0)) # MiniMax requires temp in (0, 1]
- headers = {"Authorization": f"Bearer {sess.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream"}
+ headers = {"Authorization": f"Bearer {sess.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream", 'originator': 'codex_exec'}
+ headers["User-Agent"] = sess.user_agent
if api_mode == "responses":
url = auto_make_url(sess.api_base, "responses")
payload = {"model": model, "input": _to_responses_input(messages), "stream": sess.stream,
- "prompt_cache_key": _RESP_CACHE_KEY, "instructions": sess.system or "You are an Omnipotent Executor."}
+ "prompt_cache_key": _RESP_CACHE_KEY, "instructions": sess.system or "You are an Omnipotent Executor.",
+ "client_metadata": {"x-codex-window-id": f"{_RESP_CACHE_KEY}:0","x-codex-installation-id": _RESP_CODEX_KEY},
+ 'include': ['reasoning.encrypted_content']}
if sess.reasoning_effort: payload["reasoning"] = {"effort": sess.reasoning_effort}
if sess.max_tokens: payload["max_output_tokens"] = sess.max_tokens
else:
@@ -531,7 +538,7 @@ class BaseSession:
self.max_retries = max(0, int(cfg.get('max_retries', 4)))
self.verify = cfg.get('verify', True)
self.stream = cfg.get('stream', True)
- default_ct, default_rt = (5, 30) if self.stream else (10, 240)
+ default_ct, default_rt = (5, 40) if self.stream else (10, 240)
self.connect_timeout = max(1, int(cfg.get('timeout', default_ct)))
self.read_timeout = max(5, int(cfg.get('read_timeout', default_rt)))
def _enum(key, valid):
@@ -545,6 +552,8 @@ class BaseSession:
self.api_mode = 'responses' if mode in ('responses', 'response') else 'chat_completions'
self.temperature = cfg.get('temperature', 1)
self.max_tokens = cfg.get('max_tokens')
+ self.default_ua = "claude-cli/2.1.152 (external, cli)"
+ self.user_agent = cfg.get("user_agent", self.default_ua)
def _apply_claude_thinking(self, payload):
if self.thinking_type:
thinking = {"type": self.thinking_type}
@@ -654,14 +663,15 @@ def _fix_messages(messages):
return merged
class NativeClaudeSession(BaseSession):
+ native_ua = "claude-cli/2.1.152 (native, cli)"
def __init__(self, cfg):
super().__init__(cfg)
self.fake_cc_system_prompt = cfg.get("fake_cc_system_prompt", False)
- self.user_agent = cfg.get("user_agent", "claude-cli/2.1.152 (external, cli)")
self._session_id = str(uuid.uuid4())
self._account_uuid = str(uuid.uuid4())
self._device_id = uuid.uuid4().hex + uuid.uuid4().hex[:32]
self.tools = None
+ if self.user_agent == self.default_ua: self.user_agent = self.native_ua
def raw_ask(self, messages):
if self.max_tokens is None: self.max_tokens = 8192
model = self.model
@@ -678,7 +688,7 @@ class NativeClaudeSession(BaseSession):
if self.api_key.startswith("sk-ant-"): headers["x-api-key"] = self.api_key
else: headers["authorization"] = f"Bearer {self.api_key}"
payload = {"model": model, "messages": messages, "max_tokens": self.max_tokens, "stream": self.stream}
- if self.fake_cc_system_prompt: payload["max_tokens"] = 64000
+ #if self.fake_cc_system_prompt: payload["max_tokens"] = 64000
if self.temperature != 1: payload["temperature"] = self.temperature
self._apply_claude_thinking(payload)
payload["context_management"] = {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]};
@@ -732,6 +742,7 @@ class NativeClaudeSession(BaseSession):
return MockResponse(thinking, content, tool_calls, str(content_blocks))
class NativeOAISession(NativeClaudeSession):
+ native_ua = "codex_exec/0.139.0 (Windows 10.0.26200; x86_64) unknown (codex_exec; 0.139.0)"
def raw_ask(self, messages):
messages = _fix_messages(messages)
messages = _ensure_thinking_blocks(messages, self.model)
@@ -789,7 +800,7 @@ class ToolClient:
raw_text = ''
for chunk in gen:
raw_text += chunk; yield chunk
- _write_llm_log('Response', raw_text, self.log_path)
+ _write_llm_log('Response', raw_text, self.log_path, model=self.backend.model)
return self._parse_mixed_response(raw_text)
def _prepare_tool_instruction(self, tools):
@@ -906,13 +917,14 @@ def _ensure_text_block(blocks):
blocks.insert(1, {"type": "text", "text": txt})
return txt
-def _write_llm_log(label, content, log_path=None):
+def _write_llm_log(label, content, log_path=None, model=''):
if not log_path:
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f'temp/model_responses/model_responses_{os.getpid()}.txt')
os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True)
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ if model: model = f' model={model}'
with open(log_path, 'a', encoding='utf-8', errors='replace') as f:
- f.write(f"=== {label} === {ts}\n{content}\n\n")
+ f.write(f"=== {label} === {ts}{model}\n{content}\n\n")
def tryparse(json_str):
try: return json.loads(json_str)
@@ -1044,7 +1056,7 @@ class NativeToolClient:
while True:
chunk = next(gen); yield chunk
except StopIteration as e: resp = e.value
- if resp: _write_llm_log('Response', resp.raw, self.log_path)
+ if resp: _write_llm_log('Response', resp.raw, self.log_path, model=self.backend.model)
if resp and hasattr(resp, 'tool_calls') and resp.tool_calls: self._pending_tool_ids = [tc.id for tc in resp.tool_calls]
return resp
diff --git a/memory/adb_ui.py b/memory/adb_ui.py
index e69cf743..08b74e63 100644
--- a/memory/adb_ui.py
+++ b/memory/adb_ui.py
@@ -1,4 +1,6 @@
# adb_ui.py - 一键dump+解析Android UI (u2优先,原生fallback)
+# 要先看 computer_use.md;dump配合ui_detect,微信/支付宝小程序常需detect补盲
+# 搜索框一般直接输入拼音/首字母即可,别硬啃 adb 中文输入
# u2 (uiautomator2) 不受idle限制,适合动画密集app(美团等)
# 弹窗检测: ui(clickable_only=True, raw=True) 找全屏FrameLayout+底部小ImageView(关闭X)
# 已知包名: 美团外卖=com.sankuai.meituan.takeoutnew 淘宝=com.taobao.taobao
diff --git a/memory/computer_use.md b/memory/computer_use.md
index ddd3b66b..2e5340c4 100644
--- a/memory/computer_use.md
+++ b/memory/computer_use.md
@@ -1,12 +1,16 @@
# computer_use
-L3 memory: ljqCtrl.py ljqCtrl_sop.md ui_detect.py
+相关L3 memory: **ui_detect.py** ljqCtrl.py ljqCtrl_sop.md
## 1. 基础规则
-枚举窗口总是可用:GUI任务先用 win32gui 枚举标题/类名/rect,确定目标窗口、前台状态和客户区原点后再操作。
-优先使用 Python UIA;若UIA可用,探测和操作均可使用UIA;游戏不使用UIA;
-一旦 UIA 对该窗口无效,立刻改用 ui_detect 定位;
-ui_detect 不足时才用 vision,vision 只用于语义理解、确认界面状态和辅助判断目标,不可信其坐标,不要直接按 vision 坐标点击。
+### 探测/定位四工具(按优先级降级,前者无效才用后者)
+| 工具 | 用法时机 | 能力/适用 | 限制 |
+|------|----------|-----------|------|
+| 0 win32gui 窗口枚举 | 始终先行,总是可用 | 枚举标题/类名/rect,确定目标窗口、前台状态、客户区原点 | 仅定位窗口,不进控件 |
+| 1 Python UIA(控件树)| 首选探测+操作 | 控件树可用时,探测与操作(含免坐标点击)都用 UIA | **游戏禁用**;对该窗口一旦无效则后续也不用 |
+| 2 ui_detect.py(配合ljqCtrl截图) | 1无效时才用 | 截图视觉检测控件,返回 bbox+OCR 文本 | bbox 是截图内坐标需转屏幕物理坐标 |
+| 3 vision(VLM) | 2仍不足时才用 | 仅语义理解、确认界面状态、辅助判断目标 | 不可信其坐标 |
+
Windows 下窗口截图和操作使用 ljqCtrl:严禁 pyautogui;记得先 Activate 到前台;
坑1-DPI:一律物理坐标;坑2-遮盖/失焦:混乱先枚举窗口确认前台;
ui_detect 的 bbox 是截图内坐标,点击前必须用 `ClientToScreen(hwnd,(0,0))/dpi_scale + bbox中心` 转屏幕物理坐标
@@ -20,3 +24,18 @@ ljqCtrl 失效或目标为网络游戏时,必须使用硬件键鼠 Xbananakb /
明确一个操作后,可以在同一轮执行该动作,短暂等待,再立刻枚举窗口 + 截图/ui_detect 验证新状态;不要在未知状态下把多步决策写进大脚本
尽量不要预测关键词筛候选,应看 detect 输出、坐标、层级和上下文判断
若确定UIA可用则少用ui_detect/ljqCtrl;若UIA不可用,则后续不用UIA
+
+临时截图/可视化文件用后清理,或固定文件名覆盖,避免堆积。
+ui_detect 可跨端复用;手机端沿用本原则时,UIA 换成 ui dump/adb_ui,ljqCtrl 控制换成 adb
+
+## 3. macOS 平台
+macOS 定位链与 §1 一致,工具映射如下:
+- 控制层:`import macljqCtrl as ljqCtrl`(替代 Windows ljqCtrl)
+- 窗口枚举:`ListWindows()` → 返回 id/app/title/bbox/pid(替代 win32gui)
+- 激活:`ActivateApp(pid)`(pid 来自 ListWindows,禁止用名字子串——同厂商 bundle 前缀会误伤)
+- UIA 层 = AX 辅助功能 API:`AXElements(pid)` 枚举控件树 → role/desc/title/id/物理坐标 xywh;`AXFind(pid, role=, desc=, title=)` 过滤;`AXPress(el)` 免坐标点击
+- 坐标:AX 返回逻辑点,库内自动 /dpi_scale 转物理像素,与 Click/Screenshot 统一;Retina 默认 scale=0.5
+- 截图:`GrabWindow(window_id)` 或 `ScreenCapAt(x, y, radius)`(物理坐标)
+- 权限:首次使用需授予「辅助功能」权限(系统设置 > 隐私与安全 > 辅助功能);`AXIsProcessTrusted()` 检测
+- 依赖:`pip install pyobjc-framework-ApplicationServices`(AX 相关,软依赖——未装时键鼠/截图正常,仅 AX 函数不可用)
+- 节奏同 §2:先 ListWindows + AXElements 探测,确认控件后再操作;AXPress 优先,回退 Click(phys_cx, phys_cy)
diff --git a/memory/incubator_sop.md b/memory/incubator_sop.md
index fb358299..d85c1c4f 100644
--- a/memory/incubator_sop.md
+++ b/memory/incubator_sop.md
@@ -6,13 +6,23 @@
GARoot/*.py
GARoot/assets/*.txt *.json
mykey.py
-GARoot/memory/ # 默认gitignore白名单的SOP,另外可以直接看情况修改干预
+GARoot/memory/
```
+memory下大部分文件不适合分发,不要复制 memory 下未被 gitignore 白名单的文件!
+不要复制 memory 下的L1/L2文件(global_mem(_insight).txt),会自动初始化
+
+打包红线:严格按上面4行清单执行,`*` 是 glob 全匹配,不得擅自改成“必要文件/可启动闭包”。
+- `GARoot/*.py` 必须包含根目录所有 `.py`。
+- `GARoot/assets/*.txt *.json` 必须包含 assets 顶层所有 `.txt`/`.json`。
+- `GARoot/memory/` 只取 `.gitignore` 白名单/已允许分发文件;排除 `global_mem.txt`、`global_mem_insight.txt`、`__pycache__/`、`*.pyc`。
+- 按当前清单实测压缩包约153KB/55文件;正常不应超过170KB,文件数不应超过60。
## 依赖
requests beautifulsoup4
+尽量复用远端已有python/venv
## 通信
+看subagent.md
同subagent协议:`agentmain.py --task {name} --input "..."`
或起reflect worker并设置bbs信息
diff --git a/memory/ljqCtrl.py b/memory/ljqCtrl.py
index cfc933a7..fddd6619 100644
--- a/memory/ljqCtrl.py
+++ b/memory/ljqCtrl.py
@@ -21,6 +21,8 @@ try:
import cv2
except: pass
+ctypes.windll.user32.SetProcessDPIAware()
+
_hdc = ctypes.windll.user32.GetDC(0)
swidth = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 118) # DESKTOPHORZRES (物理)
sheight = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 117) # DESKTOPVERTRES
@@ -158,9 +160,9 @@ def FindBlock(fn, wrect=None, verbose=0, threshold=0.8):
obj = (oj + wrect[0] + tsw//2, oi + wrect[1] + tsh//2)
if verbose:
print(f'Max match: {max_val:.4f} at ({oj}, {oi}) cost: {time.process_time() - tic:.3f}s')
- sscr = scr.crop([oj, oi, oj+tsw, oi+tsh])
- sscr.show()
- return obj, max_val > threshold
+ #sscr = scr.crop([oj, oi, oj+tsw, oi+tsh])
+ #sscr.show()
+ return obj, max_val
def ScreenCapAt(x, y, r=100):
"""物理坐标(x,y)为中心±r的屏幕截图 → PIL Image"""
diff --git a/memory/ljqCtrl_sop.md b/memory/ljqCtrl_sop.md
index 952517ef..10ecbd73 100644
--- a/memory/ljqCtrl_sop.md
+++ b/memory/ljqCtrl_sop.md
@@ -10,10 +10,13 @@
- `ljqCtrl.GrabWindow(hwnd_or_name)`: 前台截图(先Activate), 传hwnd(int)或窗口标题子串(str), 返回PIL Image
- `ljqCtrl.GrabWindowBg(hwnd_or_name, timeout=5)`: WGC后台截图(Win10+)
- `ljqCtrl.MouseDClick(staytime=0.05)`: 鼠标双击
+- 可先阅读computer_use.md
## 1. 环境载入
import ljqCtrl
+> **macOS**: 改 `import macljqCtrl as ljqCtrl`(API 镜像,Quartz/screencapture 实现)。依赖 `pyobjc-framework-Quartz`/`-Cocoa`,首次用前 `macljqCtrl.check_permissions()` 自检辅助功能/录屏授权。
+
## 2. 核心:High-DPI 物理坐标换算
`ljqCtrl` 的 `Click/MoveTo` 接口接收的是**物理像素坐标**。
当使用 `pygetwindow` 等其他工具获取窗口位置(逻辑坐标)时,必须除以缩放系数。
@@ -30,11 +33,21 @@ ljqCtrl.Click(ox + (bbox[0]+bbox[2])//2, oy + (bbox[1]+bbox[3])//2)
```
禁止全屏ImageGrab(必须针对窗口),所有逻辑坐标都要转物理。
+**macOS (`macljqCtrl`)**:`GrabScreen(bbox)` 区域截图后,图内点转屏幕物理坐标用 `CropToScreen(bbox, px, py)`,别手搓 `screencapture -R`(它吃逻辑点,会点歪)。
+
## 4. 避坑指南
- **⚠️ 一律使用物理坐标**:传给 ljqCtrl.Click/SetCursorPos 的坐标必须是物理坐标(=截图像素坐标)。禁止传入逻辑坐标。
- **物理验证**:模拟操作前必须确保窗口已通过 `activate()` 置于前台。
- **坐标对齐**: 物理坐标 = 截图坐标;ljqCtrl 自动处理 DPI 换算,禁止手动重复计算。
- **⚠️ 窗口坐标转换陷阱**:使用 `win32gui.GetWindowRect(hwnd)` 获取的矩形包含标题栏和边框,而截图内容是客户区。点击截图内元素时,必须用 `win32gui.ClientToScreen(hwnd, (0, 0))` 获取客户区原点的屏幕坐标,再加上截图内坐标。禁止直接用 GetWindowRect 左上角 + 截图坐标。**同理禁止 `DwmGetWindowAttribute(hwnd, 9, ...)` 取窗口矩形替代 ClientToScreen,它也包含标题栏/阴影。**
-- **⚠️ Click 后 0% 像素变化 = 点歪了**:ljqCtrl.Click 会报告像素变化百分比。若为 0% 或接近 0%,说明点击落在了错误位置(坐标计算有误),必须立即停下来诊断坐标转换逻辑,禁止盲目重试。常见原因:用了错误的窗口原点API、忘记 `/dpi_scale`、混淆了客户区与窗口矩形。
+- **⚠️ Click 后 0% 像素变化 = 点歪了**:ljqCtrl.Click 会报告像素变化百分比。若为 0% 或接近 0%,说明点击落在了错误位置(坐标计算有误),必须立即停下来诊断坐标转换逻辑,禁止盲目重试。常见原因:用了错误的窗口原点API、忘记 `/dpi_scale`、混淆了客户区与窗口矩形。macOS 上多为忘加裁剪原点(应走 `CropToScreen`)。
- **⚠️ win32 DPI 坐标陷阱**:未调用 `SetProcessDPIAware()` 时,`GetWindowRect/ClientToScreen/GetClientRect` 等拿到的窗口/客户区坐标通常是**逻辑坐标**,必须进行换算!
-- **文本输入**:ljqCtrl 无 TypeText/SendKeys。向输入框键入文本:先点击/三击选中字段,再 `pyperclip.copy('文本'); ljqCtrl.Press('ctrl+v')`。
\ No newline at end of file
+- **文本输入**:ljqCtrl 无 TypeText/SendKeys。向输入框键入文本:先点击/三击选中字段,再 `pyperclip.copy('文本'); ljqCtrl.Press('ctrl+v')`。
+
+## 5. macOS:OCR/vision 认不准图标时,用辅助功能 API 枚举真实控件(强烈推荐)
+> **两条通路**:①`macljqCtrl.py` 已封装原生 pyobjc AX API(首选,免 shell):`AXElements(pid或bundle_id或app名)` 枚举控件树(带 role/desc/title/id/value/**enabled**/物理坐标),`AXFind(...,enabled_only=)` 过滤,`AXClick(node)` = AXPress 优先失败回退物理坐标 Click。②无 pyobjc 时回退下述 osascript 方案。
+图标类按钮(···更多 / 铅笔编辑 / 关闭等)靠 OCR/vision 极易误判误点。优先走 GUI 优先链的「UIA」层:用 `osascript` 的 System Events 递归 `entire contents` 枚举进程**所有窗口**的真实控件,拿到 `AXRole + description(标识符) + position`,直接 `perform action "AXPress"` 点中。
+- **关键坑**:弹窗/详情卡常是**独立子窗口**,`front window` 只返回主窗(如红绿灯按钮)。必须 `every window` 遍历 + `entire contents`,否则找不到目标控件。
+- 控件常自带语义化 `description`/`identifier`(如 `xxx_button_more`),按 description 精确匹配比坐标稳定,枚举一次记下目标标识即可复用。
+- **坐标换算**:AX 返回的是**逻辑坐标**,截图/Click 用**物理坐标**,retina 屏 ×2(逻辑(537,121)↔物理(1074,242)实测吻合)。AX `AXPress` 直接作用元素免换算;若 AX 偶发 NOTFOUND(时序波动),用换算后物理坐标 `Click` 兜底。
+- **失焦陷阱**:点击坐标若落在窗口边界外,会点到背后别的 app 导致目标失焦。osascript `tell application "" to activate` 比 ljqCtrl 的 ActivateApp 更可靠,激活后用 `frontmost` 确认。
\ No newline at end of file
diff --git a/memory/macljqCtrl.py b/memory/macljqCtrl.py
new file mode 100644
index 00000000..7124f378
--- /dev/null
+++ b/memory/macljqCtrl.py
@@ -0,0 +1,590 @@
+"""
+macljqCtrl —— ljqCtrl 的 macOS 等价实现 (Quartz CGEvent + screencapture)。
+与 ljqCtrl.py API 镜像对齐, 跨平台代码可写 `import macljqCtrl as ljqCtrl`。
+
+CRITICAL: 严禁 import pyautogui。
+依赖: pyobjc-framework-Quartz, pyobjc-framework-Cocoa (其余 numpy/opencv/Pillow 同 Windows 版)。
+坐标约定 (与 Windows 版完全一致):
+ - 对外 API 接收【物理像素坐标】(= screencapture/ui_detect 截图内坐标)
+ - dpi_scale = 逻辑点 / 物理像素 (Retina=0.5, 普通屏=1.0)
+ - 逻辑坐标 = 物理坐标 * dpi_scale (CGEvent 内部用逻辑点)
+
+权限前置 (缺失则键鼠/截图静默失败):
+ - 辅助功能(Accessibility): 系统设置>隐私与安全性>辅助功能, 授权 GA 宿主进程
+ - 屏幕录制(Screen Recording): 同上>屏幕录制
+ 用 macljqCtrl.check_permissions() 自检。
+
+API 快速参考:
+ - dpi_scale: float
+ - Click(x, y=None, check=True): 物理坐标; check=True 比较前后像素变化, 返回变化信息
+ - SetCursorPos((x,y)): 物理坐标移动鼠标
+ - Press(cmd, staytime=0): 快捷键, 如 'cmd+v' 'cmd+c' 'enter' 'cmd+shift+4'
+ - TypeText(s): 直接键入文本(Unicode, 无需剪贴板)
+ - MouseClick / MouseDClick / MouseDown / MouseUp / RightClick
+ - GrabWindow(win) -> PIL Image: 指定窗口截图(物理像素)。win=窗口号(int)或标题/应用名子串(str)
+ - GrabScreen(bbox=None) -> PIL Image: 全屏或区域截图, bbox=(l,u,r,b)物理像素
+ - ScreenCapAt(x, y, r=100) -> PIL Image: 物理坐标(x,y)±r 区域截图
+ - FindBlock(fn, wrect=None, threshold=0.8) -> ((cx,cy), is_found): 模板匹配, 物理坐标
+ - ListWindows(name=None) -> [dict]: 枚举窗口(替代 win32gui), 返回号/标题/应用/物理bbox
+ - ActivateApp(name): 激活应用到前台(替代 win32gui.SetForegroundWindow)
+"""
+import os, sys, time, subprocess, tempfile, math
+import numpy as np
+
+try:
+ from PIL import Image, ImageGrab, ImageEnhance, ImageFilter, ImageDraw
+ import cv2
+ _HAS_CV2 = True
+except Exception:
+ _HAS_CV2 = False
+
+import Quartz
+from AppKit import NSScreen, NSWorkspace, NSPasteboard, NSStringPboardType, NSRunningApplication
+
+verbose_click = True # Click 像素变化打印开关
+
+# ---------- 屏幕几何 & dpi_scale ----------
+_main = Quartz.CGMainDisplayID()
+_bounds = Quartz.CGDisplayBounds(_main)
+cwidth = int(_bounds.size.width) # 逻辑点宽 (CGEvent 坐标系)
+cheight = int(_bounds.size.height)
+_mode = Quartz.CGDisplayCopyDisplayMode(_main)
+swidth = int(Quartz.CGDisplayModeGetPixelWidth(_mode)) # 物理像素宽
+sheight = int(Quartz.CGDisplayModeGetPixelHeight(_mode))
+dpi_scale = cwidth / swidth if swidth else 1.0 # 逻辑/物理, Retina=0.5
+
+
+def check_permissions(verbose=True):
+ """返回 (accessibility_ok, screen_recording_ok)。缺失时打印授权指引。"""
+ sc = bool(Quartz.CGPreflightScreenCaptureAccess()) if hasattr(Quartz, 'CGPreflightScreenCaptureAccess') else None
+ ax = None
+ try:
+ import HIServices
+ ax = bool(HIServices.AXIsProcessTrusted())
+ except Exception:
+ try:
+ from ApplicationServices import AXIsProcessTrusted
+ ax = bool(AXIsProcessTrusted())
+ except Exception:
+ ax = None
+ if verbose:
+ print(f'[PERM] Accessibility(键鼠): {ax} ScreenRecording(截图): {sc}')
+ if ax is False:
+ print(' → 系统设置>隐私与安全性>辅助功能, 勾选 GA 宿主进程后重启 GA')
+ if sc is False:
+ print(' → 系统设置>隐私与安全性>屏幕录制, 勾选 GA 宿主进程后重启 GA')
+ return ax, sc
+
+
+# ---------- 鼠标 ----------
+def _post(ev):
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev)
+
+def _cursor_logical():
+ e = Quartz.CGEventCreate(None)
+ loc = Quartz.CGEventGetLocation(e)
+ return loc.x, loc.y
+
+def _phys_to_logical(x, y):
+ return x * dpi_scale, y * dpi_scale
+
+def SetCursorPos(z):
+ """z=(x,y) 物理坐标。移动鼠标(不点击)。"""
+ lx, ly = _phys_to_logical(int(z[0]), int(z[1]))
+ ev = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved,
+ Quartz.CGPointMake(lx, ly), Quartz.kCGMouseButtonLeft)
+ _post(ev)
+ time.sleep(0.05)
+
+def _mouse_event(etype, button=Quartz.kCGMouseButtonLeft):
+ lx, ly = _cursor_logical()
+ ev = Quartz.CGEventCreateMouseEvent(None, etype, Quartz.CGPointMake(lx, ly), button)
+ _post(ev)
+
+def MouseDown():
+ _mouse_event(Quartz.kCGEventLeftMouseDown)
+def MouseUp():
+ _mouse_event(Quartz.kCGEventLeftMouseUp)
+
+def MouseClick(staytime=0.05):
+ MouseDown(); time.sleep(staytime)
+ MouseUp(); time.sleep(0.05)
+
+def MouseDClick(staytime=0.05):
+ # 真双击: 同坐标连发2次, 用 click state=2
+ lx, ly = _cursor_logical()
+ p = Quartz.CGPointMake(lx, ly)
+ for state in (1, 2):
+ down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, p, Quartz.kCGMouseButtonLeft)
+ Quartz.CGEventSetIntegerValueField(down, Quartz.kCGMouseEventClickState, state)
+ _post(down)
+ up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, p, Quartz.kCGMouseButtonLeft)
+ Quartz.CGEventSetIntegerValueField(up, Quartz.kCGMouseEventClickState, state)
+ _post(up)
+ time.sleep(0.05)
+
+def RightClick(staytime=0.05):
+ lx, ly = _cursor_logical()
+ p = Quartz.CGPointMake(lx, ly)
+ down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseDown, p, Quartz.kCGMouseButtonRight)
+ _post(down); time.sleep(staytime)
+ up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseUp, p, Quartz.kCGMouseButtonRight)
+ _post(up); time.sleep(0.05)
+
+# ---------- 键盘 ----------
+# macOS 虚拟键码 (kVK_*)
+_KEYMAP = {
+ 'a':0,'s':1,'d':2,'f':3,'h':4,'g':5,'z':6,'x':7,'c':8,'v':9,'b':11,'q':12,
+ 'w':13,'e':14,'r':15,'y':16,'t':17,'1':18,'2':19,'3':20,'4':21,'6':22,'5':23,
+ '=':24,'9':25,'7':26,'-':27,'8':28,'0':29,']':30,'o':31,'u':32,'[':33,'i':34,
+ 'p':35,'l':37,'j':38,"'":39,'k':40,';':41,'\\':42,',':43,'/':44,'n':45,'m':46,
+ '.':47,'`':50,
+ 'enter':36,'return':36,'tab':48,'space':49,' ':49,'delete':51,'backspace':51,
+ 'esc':53,'escape':53,'forwarddelete':117,
+ 'left':123,'right':124,'down':125,'up':126,
+ 'home':115,'end':119,'pageup':116,'pagedown':121,
+ 'f1':122,'f2':120,'f3':99,'f4':118,'f5':96,'f6':97,'f7':98,'f8':100,'f9':101,
+ 'f10':109,'f11':103,'f12':111,
+}
+_MODS = {
+ 'cmd':Quartz.kCGEventFlagMaskCommand, 'command':Quartz.kCGEventFlagMaskCommand,
+ 'ctrl':Quartz.kCGEventFlagMaskControl, 'control':Quartz.kCGEventFlagMaskControl,
+ 'alt':Quartz.kCGEventFlagMaskAlternate, 'option':Quartz.kCGEventFlagMaskAlternate,
+ 'opt':Quartz.kCGEventFlagMaskAlternate,
+ 'shift':Quartz.kCGEventFlagMaskShift,
+ 'fn':Quartz.kCGEventFlagMaskSecondaryFn,
+}
+
+def _key_tap(keycode, flags=0):
+ down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True)
+ if flags: Quartz.CGEventSetFlags(down, flags)
+ _post(down)
+ up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
+ if flags: Quartz.CGEventSetFlags(up, flags)
+ _post(up)
+
+def Press(cmd, staytime=0):
+ """快捷键。如 'cmd+v' 'cmd+shift+4' 'enter' 'cmd+c'。Win版的 ctrl 在mac多对应 cmd, 调用方自行决定。"""
+ parts = [p.strip().lower() for p in str(cmd).split('+') if p.strip()]
+ flags = 0; key = None
+ for p in parts:
+ if p in _MODS: flags |= _MODS[p]
+ else: key = p
+ if key is None:
+ return
+ kc = _KEYMAP.get(key)
+ if kc is None:
+ # 单字符走 TypeText
+ TypeText(key)
+ else:
+ _key_tap(kc, flags)
+ if staytime: time.sleep(staytime)
+ time.sleep(0.03)
+
+def TypeText(s):
+ """直接键入 Unicode 文本 (无需剪贴板)。"""
+ for ch in str(s):
+ ev = Quartz.CGEventCreateKeyboardEvent(None, 0, True)
+ Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch)
+ _post(ev)
+ ev2 = Quartz.CGEventCreateKeyboardEvent(None, 0, False)
+ Quartz.CGEventKeyboardSetUnicodeString(ev2, len(ch), ch)
+ _post(ev2)
+ time.sleep(0.005)
+
+# 剪贴板 (替代 pyperclip)
+def set_clipboard(text):
+ pb = NSPasteboard.generalPasteboard()
+ pb.clearContents()
+ pb.setString_forType_(text, NSStringPboardType)
+
+def get_clipboard():
+ pb = NSPasteboard.generalPasteboard()
+ return pb.stringForType_(NSStringPboardType)
+
+def Paste(text):
+ """把 text 放剪贴板并 cmd+v 粘贴 (等价 Win版 pyperclip+ctrl+v)。"""
+ set_clipboard(text); time.sleep(0.05); Press('cmd+v')
+
+
+# ---------- 截图 (screencapture, 输出物理像素) ----------
+def GrabScreen(bbox=None):
+ """全屏或区域截图 -> PIL Image。bbox=(l,u,r,b) 物理像素坐标。
+ 传 bbox 后图内坐标相对裁剪原点; 转屏幕绝对坐标用 CropToScreen, 勿手搓 screencapture -R。
+ """
+ fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
+ try:
+ if bbox:
+ l, u, r, b = bbox
+ # screencapture -R 用逻辑点; 转换 物理->逻辑
+ lx, ly = l*dpi_scale, u*dpi_scale
+ lw, lh = (r-l)*dpi_scale, (b-u)*dpi_scale
+ cmd = ['/usr/sbin/screencapture','-x','-t','png',
+ f'-R{lx:.0f},{ly:.0f},{lw:.0f},{lh:.0f}', fn]
+ else:
+ cmd = ['/usr/sbin/screencapture','-x','-t','png', fn]
+ subprocess.run(cmd, check=True, capture_output=True)
+ return Image.open(fn).copy()
+ finally:
+ try: os.remove(fn)
+ except Exception: pass
+
+def ScreenCapAt(x, y, r=100):
+ """物理坐标(x,y)为中心±r 截图 -> PIL Image。"""
+ return GrabScreen((x-r, y-r, x+r, y+r))
+
+def CropToScreen(bbox, x, y=None):
+ """裁剪图内坐标 -> 屏幕绝对物理坐标。bbox=GrabScreen 用的 (l,u,r,b) 物理像素。
+ (x,y)=在 GrabScreen(bbox) 返回图内找到的点。返回可直接喂给 Click 的 (X,Y)。
+ macOS 版的 ClientToScreen: 纯加裁剪原点偏移, 不做缩放(裁剪图与 bbox 同物理像素)。"""
+ if y is None and isinstance(x, (tuple, list)):
+ x, y = x[0], x[1]
+ return int(bbox[0] + x), int(bbox[1] + y)
+
+def GrabWindow(win):
+ """窗口截图 -> PIL Image。win=窗口号(int) 或 标题/应用名子串(str)。物理像素。"""
+ if isinstance(win, str):
+ ws = ListWindows(win)
+ if not ws: raise RuntimeError(f'window not found: {win}')
+ win = ws[0]['id']
+ fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
+ try:
+ subprocess.run(['/usr/sbin/screencapture','-x','-o','-l',str(win),'-t','png',fn],
+ check=True, capture_output=True)
+ return Image.open(fn).copy()
+ finally:
+ try: os.remove(fn)
+ except Exception: pass
+
+# ---------- 窗口枚举 / 激活 (替代 win32gui) ----------
+def ListWindows(name=None):
+ """枚举屏上窗口 -> [{'id','title','app','bbox'(物理像素 l,u,r,b),'pid'}]。
+ name: 标题或应用名子串过滤(不区分大小写)。"""
+ opts = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements
+ infos = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID)
+ out = []
+ inv = 1.0 / dpi_scale # 逻辑->物理
+ for w in infos:
+ b = w.get('kCGWindowBounds') or {}
+ layer = w.get('kCGWindowLayer', 0)
+ if layer != 0: # 只要普通应用窗口层
+ continue
+ title = w.get('kCGWindowName') or ''
+ app = w.get('kCGWindowOwnerName') or ''
+ l = b.get('X', 0)*inv; u = b.get('Y', 0)*inv
+ r = l + b.get('Width', 0)*inv; bo = u + b.get('Height', 0)*inv
+ rec = {'id': int(w.get('kCGWindowNumber', 0)), 'title': title, 'app': app,
+ 'pid': int(w.get('kCGWindowOwnerPID', 0)),
+ 'bbox': (int(l), int(u), int(r), int(bo))}
+ if name:
+ n = name.lower()
+ if n not in title.lower() and n not in app.lower():
+ continue
+ out.append(rec)
+ return out
+
+def ActivateApp(target):
+ """激活应用到前台 (替代 SetForegroundWindow)。
+
+ macOS 的前台是 *应用* 粒度而非窗口句柄粒度, 故无法 1:1 镜像 Win 版
+ Activate(hwnd)。target 支持两种定位键, 优先用精确的:
+ - int : 进程 pid (来自 ListWindows 的 'pid' 字段) —— 精确, 不受语言/本地化影响, **推荐**
+ - str : 应用名 / bundle id。按精确度分级匹配, 避免误中同厂商应用:
+ ① bundle id 精确等值 (如 'com.tencent.meeting') —— 最可靠
+ ② localizedName 精确等值 (如 '腾讯会议')
+ ③ localizedName 子串 (最后兜底, 可能模糊)
+ 注意: 不对 bundle id 做子串匹配, 因同厂商应用共享前缀
+ (微信 com.tencent.xinWeChat 与腾讯会议 com.tencent.meeting 都含 'tencent')。
+ 返回是否成功。"""
+ ws = NSWorkspace.sharedWorkspace()
+ # 1) pid 精确激活 (首选)
+ if isinstance(target, int):
+ app = NSRunningApplication.runningApplicationWithProcessIdentifier_(target)
+ if app is not None:
+ app.activateWithOptions_(1 << 1) # NSApplicationActivateAllWindows
+ time.sleep(0.3)
+ return True
+ return False
+ # 2) 字符串: 按精确度分级匹配 (避免同厂商前缀误伤)
+ key = str(target)
+ keyl = key.lower()
+ apps = list(ws.runningApplications())
+ def _fire(app):
+ app.activateWithOptions_(1 << 1)
+ time.sleep(0.3)
+ return True
+ # ① bundle id 精确等值
+ for app in apps:
+ if (app.bundleIdentifier() or '').lower() == keyl:
+ return _fire(app)
+ # ② localizedName 精确等值
+ for app in apps:
+ if (app.localizedName() or '').lower() == keyl:
+ return _fire(app)
+ # ③ localizedName 子串 (兜底)
+ for app in apps:
+ if keyl in (app.localizedName() or '').lower():
+ return _fire(app)
+ # 3) 兜底用 open -a
+ try:
+ subprocess.run(['open', '-a', str(target)], check=True, capture_output=True)
+ time.sleep(0.5); return True
+ except Exception:
+ return False
+
+
+# ---------- 模板匹配 FindBlock ----------
+def GetWRect(sr):
+ """快捷区域名 -> 物理像素 [l,u,r,b]。如 'left2'=左半屏, 'top3'=上1/3。"""
+ num = int(sr[-1])
+ l, u, r, b = 0, 0, swidth, sheight
+ if 'left' in sr: r = swidth // num
+ if 'right' in sr: l = swidth * (num - 1) // num
+ if 'top' in sr: b = sheight // num
+ if 'bottom' in sr: u = sheight * (num - 1) // num
+ return [l, u, r, b]
+
+def FindBlock(fn, wrect=None, verbose=0, threshold=0.8):
+ """在屏幕(或wrect区域)内找模板图 fn。返回 ((cx,cy), is_found), 物理坐标。
+ fn: 模板图路径(str)或 PIL Image。
+ wrect: None=全屏 | [l,u,r,b]物理像素 | 'left2'等快捷名 | PIL Image(直接当搜索底图)。"""
+ if not _HAS_CV2:
+ raise RuntimeError('FindBlock 需要 opencv-python 和 Pillow')
+ if wrect is not None and isinstance(wrect, Image.Image):
+ scr, wrect = wrect, None
+ else:
+ if isinstance(wrect, str): wrect = GetWRect(wrect)
+ scr = GrabScreen(wrect) # 物理像素
+ blc = Image.open(fn) if isinstance(fn, str) else fn
+ T = cv2.cvtColor(np.array(blc), cv2.COLOR_RGB2BGR)
+ B = cv2.cvtColor(np.array(scr), cv2.COLOR_RGB2BGR)
+ tsh, tsw = T.shape[:2]
+ res = cv2.matchTemplate(B, T, cv2.TM_CCOEFF_NORMED)
+ _, max_val, _, max_loc = cv2.minMaxLoc(res)
+ oj, oi = max_loc
+ if wrect is None: wrect = [0, 0, scr.size[0], scr.size[1]]
+ obj = (oj + wrect[0] + tsw // 2, oi + wrect[1] + tsh // 2)
+ if verbose:
+ print(f'FindBlock {fn}: score={max_val:.4f} at phys={obj}')
+ return obj, max_val > threshold
+
+def imshow(mt, sec=0):
+ cv2.imshow('cc', mt); cv2.waitKey(sec)
+
+
+
+# ---------- Click (带像素变化检测) ----------
+def Click(x, y=None, check=True, r=60):
+ """物理坐标点击。check=True 时比较点击前后周边像素变化, 返回 (变化百分比, 截图)。
+ 若变化≈0 → 可能点歪了 (同 Win 版语义)。"""
+ if y is None and isinstance(x, (tuple, list)):
+ x, y = x[0], x[1]
+ x, y = int(x), int(y)
+ before = None
+ if check:
+ try: before = np.array(ScreenCapAt(x, y, r))
+ except Exception: before = None
+ SetCursorPos((x, y)); time.sleep(0.05)
+ MouseClick()
+ if check:
+ time.sleep(0.25)
+ try:
+ after = np.array(ScreenCapAt(x, y, r))
+ except Exception:
+ return None
+ if before is not None and before.shape == after.shape:
+ diff = np.mean(np.any(before != after, axis=-1)) * 100
+ if verbose_click:
+ print(f'Click({x},{y}) pixel change: {diff:.1f}%')
+ if diff < 0.5:
+ print(f'[WARN] Click({x},{y}) 像素变化≈0%, 可能点歪了, 请诊断坐标! '
+ '常见错因: 用了裁剪图内坐标却没加裁剪原点(用 CropToScreen), '
+ '或对已是物理像素的坐标又做了 *dpi_scale 换算。')
+ return diff, Image.fromarray(after)
+ return None
+
+
+# ---------- AX 辅助功能控件枚举 (UIA 层的 macOS 等价) ----------
+try:
+ from ApplicationServices import (
+ AXUIElementCreateApplication, AXUIElementCopyAttributeValue,
+ AXValueGetValue, AXUIElementPerformAction, AXIsProcessTrusted,
+ kAXChildrenAttribute, kAXRoleAttribute, kAXDescriptionAttribute,
+ kAXPositionAttribute, kAXSizeAttribute, kAXWindowsAttribute,
+ kAXTitleAttribute, kAXValueCGPointType, kAXValueCGSizeType,
+ kAXPressAction, kAXEnabledAttribute,
+ )
+ _HAS_AX = True
+except ImportError:
+ _HAS_AX = False
+
+
+def _resolve_pid(target):
+ """target(int pid | str bundle_id/应用名) → pid(int)。
+
+ str 优先按 bundle id 精确匹配, 再按 localizedName 精确/子串兜底,
+ 与 ActivateApp 的匹配纪律一致, 避免同厂商前缀误伤。"""
+ if isinstance(target, int):
+ return int(target)
+ key = str(target); keyl = key.lower()
+ ws = NSWorkspace.sharedWorkspace()
+ apps = list(ws.runningApplications())
+ for a in apps: # ① bundle id 精确
+ if (a.bundleIdentifier() or '') == key:
+ return int(a.processIdentifier())
+ for a in apps: # ② localizedName 精确
+ if (a.localizedName() or '').lower() == keyl:
+ return int(a.processIdentifier())
+ for a in apps: # ③ localizedName 子串
+ if keyl in (a.localizedName() or '').lower():
+ return int(a.processIdentifier())
+ raise ValueError(f'找不到 target={target!r} 对应的运行中应用')
+
+
+def _ax_attr(el, key):
+ """读取单个 AX 属性,失败返回 None。"""
+ if not _HAS_AX:
+ return None
+ err, val = AXUIElementCopyAttributeValue(el, key, None)
+ return val if err == 0 else None
+
+
+def AXElements(target, max_depth=10, include_zero_size=False):
+ """枚举应用控件树。
+
+ Parameters
+ ----------
+ target : int | str
+ pid(int) 或 bundle_id(str, 如 'com.tencent.meeting')。
+ max_depth : int
+ 递归深度上限。
+ include_zero_size : bool
+ 是否包含 w<=0 或 h<=0 的零尺寸节点。
+
+ Returns
+ -------
+ list[dict] : 每项含 role/desc/title/id/value/x/y/w/h(物理像素)/depth/el。
+ value 为控件当前值(文本/输入框内容等),非文本值为 None。
+ el 是原始 AXUIElement 引用,目标窗口关闭/重建后失效,AXPress 前宜就近重新枚举。
+ """
+ if not _HAS_AX:
+ raise RuntimeError(
+ 'AX 不可用。请安装: pip install pyobjc-framework-ApplicationServices')
+ if not AXIsProcessTrusted():
+ raise PermissionError('需要授予辅助功能权限(系统设置 > 隐私与安全 > 辅助功能)')
+ pid = _resolve_pid(target)
+
+ app_el = AXUIElementCreateApplication(pid)
+ wins = _ax_attr(app_el, kAXWindowsAttribute) or []
+ scale = dpi_scale # 逻辑/物理, Retina=0.5
+
+ results = []
+
+ def _walk(el, depth):
+ if depth > max_depth:
+ return
+ role = _ax_attr(el, kAXRoleAttribute)
+ desc = _ax_attr(el, kAXDescriptionAttribute)
+ title = _ax_attr(el, kAXTitleAttribute)
+ ident = _ax_attr(el, 'AXIdentifier')
+ value = _ax_attr(el, 'AXValue')
+ enabled = _ax_attr(el, kAXEnabledAttribute)
+ # AXValue 多为 str/num(文本/输入框);若是 AXValueRef(坐标等)忽略
+ if value is not None and not isinstance(value, (str, int, float, bool)):
+ value = None
+ pos_val = _ax_attr(el, kAXPositionAttribute)
+ sz_val = _ax_attr(el, kAXSizeAttribute)
+ # 解包坐标(逻辑点)
+ px = py_ = pw = ph = 0.0
+ if pos_val is not None:
+ ok, pt = AXValueGetValue(pos_val, kAXValueCGPointType, None)
+ if ok:
+ px, py_ = pt.x, pt.y
+ if sz_val is not None:
+ ok, sz = AXValueGetValue(sz_val, kAXValueCGSizeType, None)
+ if ok:
+ pw, ph = sz.width, sz.height
+ # 转物理像素
+ phys_x = px / scale if scale else px
+ phys_y = py_ / scale if scale else py_
+ phys_w = pw / scale if scale else pw
+ phys_h = ph / scale if scale else ph
+
+ if not include_zero_size and (phys_w <= 0 or phys_h <= 0):
+ pass # 跳过零尺寸,但仍递归子节点
+ else:
+ results.append(dict(
+ depth=depth, role=role, desc=desc, title=title, id=ident,
+ value=value, enabled=bool(enabled) if enabled is not None else None,
+ x=round(phys_x), y=round(phys_y),
+ w=round(phys_w), h=round(phys_h), el=el))
+ for child in (_ax_attr(el, kAXChildrenAttribute) or []):
+ _walk(child, depth + 1)
+
+ for win in wins:
+ _walk(win, 0)
+ return results
+
+
+def AXPress(element) -> bool:
+ """对 AX element 执行 Press 动作(免坐标点击)。"""
+ if not _HAS_AX:
+ return False
+ err = AXUIElementPerformAction(element, kAXPressAction)
+ return err == 0
+
+
+def AXClick(node, check=True) -> bool:
+ """点击控件: AXPress 优先(免坐标), 失败回退到中心点物理坐标 Click。
+ node: AXFind/AXElements 返回的 dict(含 el 与 x/y/w/h), 或裸 AXUIElement。
+ 返回是否点击成功(回退路径据像素变化判定, check=False 时无法判定按 True)。
+ 呼应 computer_use SOP: AXPress 优先, 回退 Click(phys_cx, phys_cy)。"""
+ if not isinstance(node, dict):
+ return AXPress(node)
+ if node.get('enabled') is False:
+ print(f"[WARN] AXClick: 控件 disabled (role={node.get('role')}, "
+ f"title={node.get('title')!r}), 点击可能无效")
+ if AXPress(node.get('el')):
+ return True
+ # 回退: 中心点物理坐标
+ cx = node['x'] + node['w'] // 2
+ cy = node['y'] + node['h'] // 2
+ res = Click(cx, cy, check=check)
+ if not check or res is None:
+ return check is False # 无法判定时: 关检查按成功, 截图失败按失败
+ diff, _ = res
+ return diff >= 0.5
+
+
+def AXFind(target, role=None, desc=None, title=None, identifier=None,
+ enabled_only=False, max_depth=10):
+ """枚举并过滤控件。所有过滤条件为子串匹配(大小写不敏感)。
+ enabled_only=True 时只返回 enabled 的控件(SOP: 点前查 disabled)。
+
+ Returns
+ -------
+ list[dict] : 匹配项,同 AXElements 返回格式。
+ """
+ def _hit(field, needle):
+ return needle is None or (field and needle.lower() in field.lower())
+ return [n for n in AXElements(target, max_depth=max_depth)
+ if _hit(n['role'], role) and _hit(n['desc'], desc)
+ and _hit(n['title'], title) and _hit(n['id'], identifier)
+ and not (enabled_only and n.get('enabled') is False)]
+
+
+# ---------- API 镜像别名 (drop-in 替换 ljqCtrl 用) ----------
+click = Click
+press = Press
+activate = ActivateApp # 注意: Win 版 Activate(hwnd) 收窗口句柄, Mac 版收应用名子串
+GrabWindowBg = GrabWindow # Mac screencapture 本身支持后台窗口
+VK_CODE = _KEYMAP # 名称兼容
+
+
+if __name__ == '__main__':
+ print('--- macljqCtrl self-check ---')
+ check_permissions()
+ print('cursor(logical):', _cursor_logical())
+ print('windows(top5):')
+ for w in ListWindows()[:5]:
+ print(' ', w['id'], '|', w['app'], '|', w['title'][:30], '|', w['bbox'])
diff --git a/memory/memory_cleanup_sop.md b/memory/memory_cleanup_sop.md
index d0b66dda..abed4feb 100644
--- a/memory/memory_cleanup_sop.md
+++ b/memory/memory_cleanup_sop.md
@@ -21,9 +21,11 @@ ROI = (不放这几个词的犯错概率 × 代价) / 每轮词数成本
## 压缩四原则
1. **命名自解释 > 加描述**:SOP名能说清的,L1不加注释;改名的ROI常高于改L1
+ - 推论:极低ROI且文件名自解释的条目可不单独列入L1,但仍需被原则2的集合包含住,靠集合触发词+ls兜底
2. **存在性集合最小描述**:多个相近条目若可被同一上位场景覆盖,用集合名表达这类能力的存在,不必平铺子项。如`qq操作/飞书操作/企微操作`→`im操作:*_im_sop`;子项名自解释则只列名不翻译
3. **条目 = 场景↔方案存在性**:如`视频理解:yt-dlp取字幕`、`fofa(资产测绘)`——场景名是触发词,方案名编码存在性;括号内**只放反直觉触发词**,非反直觉的(纯翻译/内容描述/实现细节)全是浪费
-4. **分层归位**:带行为规则或高频高ROI的条目放上方场景行,纯存在性指针归L2/L3平铺列表
+ - **触发词判定**:假设用户说出这个词,你能否想到去查对应SOP?能→直觉(不需要);不能→反直觉(必须留)。如`game_download(百度网盘)`:用户说"百度网盘下载",没这个词你不会想到game_download→必须留
+4. **分层归位**:L1内部调整位置——带行为规则或高频高ROI的条目放上方场景行,纯存在性指针归L1下方平铺列表。**归位≠移出L1,存在性不可丢**
## 整理流程
1. 逐行读L1,按`|`拆片段,先分类:存在性指针 / RULES / 翻译 / 内容描述 / 实现细节 / 冗余
@@ -31,7 +33,9 @@ ROI = (不放这几个词的犯错概率 × 代价) / 每轮词数成本
- 全局高ROI → 留
- 特定场景 / 低危险 → 降级到L3或删除
3. 再清存在性指针:检查是否在表达**场景↔方案存在性**;场景触发词只在**反直觉**时才加,翻译/内容描述/实现细节删掉
-4. 检查L3文件名是否自解释;能靠改名解决的,不靠L1加描述;最后验证总行数 ≤ 30
+4. 审计幽灵条目:L1指向的L3文件是否真实存在?不存在则按ROI决定删除或补建文件
+5. 禁值存储:L1括号内禁放参数值(IP/端口/凭证等),这些属于L2,L1只编码存在性
+6. 检查L3文件名是否自解释;能靠改名解决的,不靠L1加描述;最后验证总行数 ≤ 30
**红线**:记忆修改是持久性伤害,错误每轮复利。L1只能patch词级别修改,禁overwrite
产生误导应及时修正L1或记忆更名
diff --git a/memory/project_mode_sop.md b/memory/project_mode_sop.md
new file mode 100644
index 00000000..86cc9f7f
--- /dev/null
+++ b/memory/project_mode_sop.md
@@ -0,0 +1,30 @@
+# Project Mode SOP
+
+## 定义
+
+Project Mode = 跨会话保持项目认知的工作模式
+载体:`project_memory.md` + 项目私域目录。两层注入:每轮自动注入的只有 L1(规则+记忆文件指针),L2(`project_memory.md` 全文)不注入——任务涉及项目上下文时自己用 file 工具去读,无关则不读
+
+## 进入
+
+锚 = `./.active_project.<宿主pid>`,只对当前 GA 进程有效:GA 关闭即自动失活;多开 GA 各自激活不同项目,互不干扰。(下文路径一律以 cwd 为基准,cwd 即 GA 的 temp 目录;禁写 `temp/xxx` 前缀,会嵌套出 temp/temp)
+
+- 用户只说「进入项目模式」未指明项目:列出 `./projects/` 下各项目(名字 + memory 行数 + 最后修改时间),ask_user 让用户选定后再继续
+- 用户明确说「进入/切换到 <项目名> 项目」:视为已确认,直接执行:
+
+1. 建目录 `./projects/<项目名>/`,无则创建 `project_memory.md`(空文件即可)
+2. 写文件锚,必须用 code_run(锚文件名含宿主 pid,ppid 即 GA 宿主进程):
+ `import os; open(f'./.active_project.{os.getppid()}', 'w').write('<项目名>')`
+3. 回读 `project_memory.md` 全文,向用户复述项目现状
+
+## 期间纪律
+
+- 项目文件(todo、草稿、产物)一律放 `./projects/<项目名>/`,禁止丢 temp 根目录
+- 入库判据(唯一标准):每得到一条信息,自问「记忆归零、重新接手本项目的我,缺了这条会不会重复付出认知代价——再踩一次坑、再摸索一次、再问一次用户?」会则立即追加进 `project_memory.md`,不会则不记
+- 一条一句,写成未来的自己能直接复用的形式;已有条目增量更新,不整篇重写
+
+## 离开
+
+用户表示「离开项目模式」时:删除 `./.active_project.<宿主pid>`(仅关闭激活态,项目目录与 `project_memory.md` 原样保留)
+切换到另一项目时无需先离开,直接按「进入」覆盖文件锚即可
+GA 关闭不需任何操作:锚随进程消亡自动失效,残留文件由插件下次启动时清扫
diff --git a/memory/skill_search/SKILL.md b/memory/skill_search/SKILL.md
deleted file mode 100644
index e89b34b1..00000000
--- a/memory/skill_search/SKILL.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Skill Search — 105K 技能卡检索
-
-> 从 105K+ 技能卡中语义搜索最匹配的 skill。零依赖,内置默认 API 地址,开箱即用。
-
-## 最简调用
-
-```python
-import sys; sys.path.append('../memory/skill_search')
-from skill_search import search
-
-results = search("python send email") # ⚠️ 必须用英文查询,中文匹配效果极差
-for r in results:
- s = r.skill
- print(f"[{r.final_score:.2f}] {s.name} — {s.one_line_summary}")
- print(f" key: {s.key} category: {s.category} tags: {s.tags[:3]}")
-```
-
-## API 签名
-
-```python
-search(query, env=None, category=None, top_k=10) -> list[SearchResult]
-# env: 自动检测,一般不传
-# category: 可选过滤,如 "devops"
-# top_k: 返回数量,默认10
-```
-
-## 返回结构
-
-```
-SearchResult
- .final_score float 综合评分 (0~1)
- .relevance float 语义相关度
- .quality float 质量分
- .match_reasons list[str] 匹配原因
- .warnings list[str] 警告
- .skill SkillIndex ↓
-
-SkillIndex (常用字段)
- .key str 唯一标识/路径
- .name str 名称
- .one_line_summary str 一句话摘要
- .description str 详细描述
- .category str 类别
- .tags list[str] 标签
- .form str 形式(sop/script/...)
- .autonomous_safe bool 是否自主安全
-```
-
-## CLI
-
-```bash
-python -m skill_search "python testing"
-python -m skill_search "docker deployment" --category devops --top 5
-python -m skill_search "git" --json
-python -m skill_search --stats
-python -m skill_search --env
-```
-
-## 配置
-
-| 项 | 默认值 | 说明 |
-|---|---|---|
-| API地址 | `http://www.fudankw.cn:58787` | 环境变量 `SKILL_SEARCH_API` 可覆盖 |
-| API密钥 | 无(可选) | 环境变量 `SKILL_SEARCH_KEY` |
\ No newline at end of file
diff --git a/memory/skill_search/skill_search/__init__.py b/memory/skill_search/skill_search/__init__.py
deleted file mode 100644
index 70b246f8..00000000
--- a/memory/skill_search/skill_search/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""skill_search — Skill 检索 API 客户端"""
-from .engine import (
- SkillIndex, SearchResult, SkillSearchError,
- search, get_stats, detect_environment,
-)
-
-__all__ = ["SkillIndex", "SearchResult", "SkillSearchError",
- "search", "get_stats", "detect_environment"]
\ No newline at end of file
diff --git a/memory/skill_search/skill_search/__main__.py b/memory/skill_search/skill_search/__main__.py
deleted file mode 100644
index 6691a27b..00000000
--- a/memory/skill_search/skill_search/__main__.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""CLI 入口: python -m skill_search"""
-from __future__ import annotations
-import argparse, json, sys
-from .engine import SearchResult, SkillSearchError, detect_environment, search, get_stats
-
-
-# ── 格式化 ───────────────────────────────────────────────
-
-def format_results(results: list[SearchResult], env: dict, query: str) -> str:
- lines = [f'🔍 搜索: "{query}"',
- f"🖥️ 环境: {env.get('os','?')} / {env.get('shell','?')} / {', '.join(env.get('runtimes',[]))}",
- f"📊 找到 {len(results)} 个匹配结果\n"]
- if not results:
- lines.append("未找到匹配的 skill。试试其他关键词?")
- return "\n".join(lines)
- for i, r in enumerate(results, 1):
- s = r.skill
- safe_icon = "🟢" if s.autonomous_safe else "🔴"
- score_bar = "█" * int(r.final_score * 10) + "░" * (10 - int(r.final_score * 10))
- lines += [
- f"{'─'*60}",
- f"#{i} {safe_icon} {s.name}",
- f" 路径: {s.key}",
- f" 类别: {s.category} | 标签: {', '.join(s.tags[:5])}",
- f" 摘要: {s.one_line_summary}",
- f" 评分: [{score_bar}] {r.final_score:.2f} (相关={r.relevance:.2f} 质量={r.quality:.1f})",
- f" 清晰={s.clarity} 完整={s.completeness} 可操作={s.actionability} | 形式={s.form}",
- ]
- if r.match_reasons:
- lines.append(f" 匹配: {' | '.join(r.match_reasons[:3])}")
- if r.warnings:
- lines.extend(f" {w}" for w in r.warnings)
- lines.append("")
- lines.append(f"{'─'*60}")
- return "\n".join(lines)
-
-
-def format_results_json(results: list[SearchResult]) -> list[dict]:
- out = []
- for r in results:
- s = r.skill
- out.append({
- "rank": len(out) + 1, "key": s.key, "name": s.name,
- "category": s.category, "tags": s.tags,
- "description": s.description, "one_line_summary": s.one_line_summary,
- "scores": {"final": round(r.final_score, 3), "relevance": round(r.relevance, 3),
- "quality": round(r.quality, 1), "clarity": s.clarity,
- "completeness": s.completeness, "actionability": s.actionability},
- "safety": {"autonomous_safe": s.autonomous_safe, "blast_radius": s.blast_radius,
- "requires_credentials": s.requires_credentials,
- "data_exposure": s.data_exposure, "effect_scope": s.effect_scope},
- "platform": {"os": s.os, "runtimes": s.runtimes, "tools": s.tools, "services": s.services},
- "warnings": r.warnings, "match_reasons": r.match_reasons,
- })
- return out
-
-
-# ── CLI ──────────────────────────────────────────────────
-
-def main():
- parser = argparse.ArgumentParser(prog="skill_search",
- description="Skill 检索系统 — 根据环境和需求智能推荐 skill(API 客户端)")
- parser.add_argument("query", nargs="?", help="搜索关键词(如: 'python testing')")
- parser.add_argument("--category", "-cat", help="限定类别")
- parser.add_argument("--top", "-k", type=int, default=10, help="返回结果数(默认 10)")
- parser.add_argument("--json", action="store_true", help="JSON 格式输出")
- parser.add_argument("--env", action="store_true", help="仅显示检测到的环境信息")
- parser.add_argument("--stats", action="store_true", help="显示索引统计信息")
- parser.add_argument("--api-url", help="指定 API 地址(也可用 SKILL_SEARCH_API 环境变量)")
- args = parser.parse_args()
-
- if args.api_url:
- import os; os.environ["SKILL_SEARCH_API"] = args.api_url
-
- env = detect_environment()
-
- if args.env:
- print("🖥️ 当前环境:")
- print(f" OS: {env['os']}")
- print(f" Shell: {env['shell']}")
- print(f" 运行时: {', '.join(env['runtimes'])}")
- print(f" 工具: {', '.join(env['tools'])}")
- print(f" 模型能力: tool_calling={env['model']['tool_calling']}, "
- f"reasoning={env['model']['reasoning']}, context={env['model']['context_window']}")
- return
-
- if args.stats:
- try:
- stats = get_stats(env)
- print(f"📊 索引统计:")
- print(f" 总计: {stats.get('total', '?')} 个 skills")
- print(f" 自动安全: {stats.get('safe_count', '?')} 个")
- if 'categories' in stats:
- print(f" 类别分布:")
- for cat, cnt in sorted(stats['categories'].items(), key=lambda x: -x[1]):
- print(f" {cat:15s} {cnt:4d}")
- except SkillSearchError as e:
- print(f"❌ {e}", file=sys.stderr); sys.exit(1)
- return
-
- if not args.query:
- parser.print_help(); return
-
- try:
- results = search(query=args.query, env=env, category=args.category, top_k=args.top)
- except SkillSearchError as e:
- print(f"❌ {e}", file=sys.stderr); sys.exit(1)
-
- if args.json:
- print(json.dumps(format_results_json(results), indent=2, ensure_ascii=False))
- else:
- print(format_results(results, env, args.query))
-
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/memory/skill_search/skill_search/engine.py b/memory/skill_search/skill_search/engine.py
deleted file mode 100644
index 5625a4fa..00000000
--- a/memory/skill_search/skill_search/engine.py
+++ /dev/null
@@ -1,156 +0,0 @@
-"""Skill 检索引擎 — API 客户端(含数据模型与环境检测)"""
-from __future__ import annotations
-import json, os, platform, shutil, subprocess, urllib.request, urllib.error
-from dataclasses import dataclass, field
-
-# ── 数据模型 ─────────────────────────────────────────────
-
-@dataclass
-class SkillIndex:
- """Skill 索引条目(与服务端结构对齐)"""
- key: str
- name: str = ""
- description: str = ""
- one_line_summary: str = ""
- category: str = ""
- tags: list[str] = field(default_factory=list)
- language: str = "en"
- os: list[str] = field(default_factory=list)
- shell: list[str] = field(default_factory=list)
- runtimes: list[str] = field(default_factory=list)
- tools: list[str] = field(default_factory=list)
- services: list[str] = field(default_factory=list)
- needs_tool_calling: bool = False
- needs_reasoning: bool = False
- min_context_window: str = "standard"
- decay_risk: str = "low"
- clarity: int = 0
- completeness: int = 0
- actionability: int = 0
- autonomous_safe: bool = True
- blast_radius: str = "low"
- requires_credentials: bool = False
- data_exposure: str = "none"
- effect_scope: str = "local"
- form: str = ""
- estimated_tokens: str = "medium"
- capabilities: list[str] = field(default_factory=list)
- github_stars: int = 0
- github_url: str = ""
-
- @property
- def quality_score(self):
- return self.clarity * 0.3 + self.completeness * 0.3 + self.actionability * 0.4
-
- @classmethod
- def from_dict(cls, d):
- known = {f.name for f in cls.__dataclass_fields__.values()}
- return cls(**{k: v for k, v in d.items() if k in known})
-
-
-@dataclass
-class SearchResult:
- """单条检索结果"""
- skill: SkillIndex
- relevance: float = 0.0
- quality: float = 0.0
- final_score: float = 0.0
- match_reasons: list[str] = field(default_factory=list)
- warnings: list[str] = field(default_factory=list)
-
- @classmethod
- def from_dict(cls, d):
- skill = SkillIndex.from_dict(d.get("skill", d))
- return cls(skill=skill, relevance=d.get("relevance", 0.0),
- quality=d.get("quality", 0.0), final_score=d.get("final_score", 0.0),
- match_reasons=d.get("match_reasons", []), warnings=d.get("warnings", []))
-
-
-# ── 环境检测 ─────────────────────────────────────────────
-
-def _run(cmd):
- try:
- r = subprocess.run(cmd.split(), capture_output=True, text=True, timeout=5)
- return r.stdout.strip() if r.returncode == 0 else ""
- except Exception:
- return ""
-
-def _detect_os():
- s = platform.system().lower()
- return {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(s, s)
-
-def _detect_shell():
- shell = os.environ.get("SHELL", "")
- if "zsh" in shell: return "zsh"
- if "bash" in shell: return "bash"
- if platform.system() == "Windows": return "powershell"
- return os.path.basename(shell) if shell else "unknown"
-
-def _detect_runtimes():
- checks = {"python": ["python3", "python"], "node": ["node"], "go": ["go"],
- "rust": ["rustc"], "java": ["java"], "ruby": ["ruby"],
- "php": ["php"], "dotnet": ["dotnet"]}
- found = []
- for name, cmds in checks.items():
- for cmd in cmds:
- if shutil.which(cmd):
- found.append(name); break
- return found
-
-def _detect_tools():
- tools = ["git", "docker", "npm", "pip", "curl", "wget", "kubectl",
- "terraform", "aws", "gcloud", "az", "brew", "cargo", "make", "cmake"]
- return [t for t in tools if shutil.which(t)]
-
-def detect_environment():
- """采集完整环境信息"""
- return {"os": _detect_os(), "shell": _detect_shell(),
- "runtimes": _detect_runtimes(), "tools": _detect_tools(),
- "model": {"tool_calling": True, "reasoning": True, "context_window": "large"}}
-
-
-# ── API 配置与调用 ────────────────────────────────────────
-
-DEFAULT_API_URL = "http://www.fudankw.cn:58787"
-
-def _get_api_url():
- return os.environ.get("SKILL_SEARCH_API", DEFAULT_API_URL)
-
-def _get_api_key():
- return os.environ.get("SKILL_SEARCH_KEY")
-
-class SkillSearchError(Exception):
- pass
-
-def _api_request(endpoint, payload):
- url = f"{_get_api_url()}/{endpoint}"
- data = json.dumps(payload).encode("utf-8")
- headers = {"Content-Type": "application/json"}
- api_key = _get_api_key()
- if api_key:
- headers["Authorization"] = f"Bearer {api_key}"
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- try:
- with urllib.request.urlopen(req, timeout=30) as resp:
- return json.loads(resp.read().decode("utf-8"))
- except urllib.error.HTTPError as e:
- body = e.read().decode("utf-8", errors="replace")
- raise SkillSearchError(f"API 错误 {e.code}: {body}") from e
- except urllib.error.URLError as e:
- raise SkillSearchError(f"无法连接服务: {e.reason}") from e
- except Exception as e:
- raise SkillSearchError(f"请求失败: {e}") from e
-
-
-# ── 公开接口 ─────────────────────────────────────────────
-
-def search(query, env=None, category=None, top_k=10):
- if env is None: env = detect_environment()
- payload = {"query": query, "env": env, "top_k": top_k}
- if category: payload["category"] = category
- resp = _api_request("search", payload)
- return [SearchResult.from_dict(r) for r in resp.get("results", [])]
-
-def get_stats(env=None):
- if env is None: env = detect_environment()
- return _api_request("stats", {"env": env})
\ No newline at end of file
diff --git a/memory/subagent.md b/memory/subagent.md
index 199f20ef..702667e4 100644
--- a/memory/subagent.md
+++ b/memory/subagent.md
@@ -6,13 +6,13 @@
- 启动:`python agentmain.py --task {name} [--input "短文本"] [--llm_no N]`(cwd=代码根)
- `--input`自动建目录+清旧output+写input.txt;长文本先手动写input.txt再启动(不带--input)
- 自动后台启动,print PID then exit
-- subagent的cwd还是temp,不是task目录
+- 统一设定:所有agent的cwd都是temp,方便文件共享,不是task目录
- input:目标+约束即可,subagent同等智能。**禁写步骤/过度描述**,大量数据给路径
-- 可选fork功能(继承对话上下文): code_run(inline_eval=True),将变量history(自动注入,str)写入task目录下_history.json
- 通信:output.txt(append,`[ROUND END]`=轮完成) → 写reply.txt继续 → 不写10min退出。reply后输出为output1/2/3.txt(同格式)
- 干预文件:`_stop`(当轮结束) | `_keyinfo`(注入working memory) | `_intervene`(追加指令)
-- 监察模式:**主agent空闲时应读output观察进度,必要时用干预文件纠偏,禁止无脑长时间sleep**
-- 若加`--verbose`,output将包含工具执行结果,主agent可直接审查原始数据而非仅信任摘要
+- [[可选fork功能]](继承对话上下文): 事先code_run(inline_eval=True),将变量history(自动注入,str)写入task目录下_history.json
+- [[可选监察者模式]]:**主agent空闲时应读output观察进度,必要时用干预文件纠偏,禁止无脑长时间sleep**
+ 若加`--verbose`,output将包含工具执行结果,主agent可直接审查原始数据而非仅信任摘要
## 场景1:测试模式 - 行为验证
**用途**:观察agent真实行为,修正RULES/L2/L3/SOP
@@ -29,7 +29,7 @@
**核心优势**:独立上下文。避免处理文档A的长上下文污染处理文档B的质量
**约束**:
- 文件系统共享是优点:不同agent处理不同输入文件,产生不同输出文件
-- 共享资源冲突:键鼠不可共享;浏览器暂时不可并行使用,避免同时操作同一标签页
+- 共享资源冲突:键鼠不可共享;浏览器避免操作同一tab
- 不满足map模式的任务 → 主agent顺序执行即可,别用subagent
**标准流程(map-reduce)**:
1. 主agent准备阶段:爬取/dump数据,存为多个独立输入文件
diff --git a/memory/supervisor_sop.md b/memory/supervisor_sop.md
index 96f68875..af3643f6 100644
--- a/memory/supervisor_sop.md
+++ b/memory/supervisor_sop.md
@@ -1,42 +1,59 @@
# 监察者模式 SOP
-> 你是挑刺的监工,不是干活的工人。你的唯一任务:确保工作agent高质量完成任务。有SOP按SOP约束,无SOP凭常理和经验把关。
+目标:让用户一次说明任务后,尽量不用多轮纠偏。
+核心:**先定行动协议,再盯协议偏离,最后看完成证据。**
-## 红线
+## 1. 定位
-- **禁止下场干活**:不操作浏览器、不写代码、不执行任务步骤。你只读、只判断、只干预
-- **可以读环境**:file_read/web_scan/web_execute_js/code_run(只读命令)获取情报,辅助判断工作agent进度和状态
+监察者不是工人。你是挑刺的监工,只负责看 subagent 是否按目标、约束和证据闭环完成任务;有 SOP 按 SOP 把关,无 SOP 按常理和经验把关。
-## 启动
+## 2. 红线
-1. **有SOP时**:读SOP原文,提取所有约束(⚠️/禁止/必须/格式要求),按步骤列成**约束清单**存working memory
-1. **无SOP时**:根据任务性质和进度,预估未来会遇到的关键风险点
-2. **启动subagent**(cwd=代码根):
- ```
- python agentmain.py --task {name} --verbose
- ```
- input.txt:`用{SOP名}完成{用户任务}`(只给目标,不复述步骤)
+- **禁止下场干活**:不替 subagent 操作浏览器、不写代码、不执行任务步骤。
+- **只读探测**:可用 `file_read`、`web_scan`、`web_execute_js`、只读 `code_run` 辅助判断对象、进度和证据;探测不是代做。
+- **沉默为主**:没发现会导致用户纠偏的问题,就不说话。
+- **一句话干预**:必须短、硬、具体,像用户直接纠偏,禁长篇教程。
+- **`_keyinfo` 只用于预注入**:在 subagent 到达关键步骤前提醒;已经犯错必须用 `_intervene`。
-## 监控循环
+## 3. 执行顺序(硬性,不可跳步)
-持续轮询 `temp/{task_name}/output.txt` 的新增内容(sleep间隔读取),每发现新输出:
+**Step A — 立刻启动 subagent**(纯机械,不需思考)
-1. 判断工作agent当前在哪一步,对照约束清单检查(约束记不清时重读SOP原文,禁凭印象)
-2. 可读环境信息(文件/网页/进程)补充判断依据
-3. 工作agent ask_user时给予回复
+读完本 SOP 和 `subagent.md` 后,从用户 prompt 中剥离任务,写 input,启动。调用细节见 `subagent.md`;input 只给目标+约束,长文本给路径。
-| 发现 | 干预 |
-|------|------|
-| 跳步 | `_intervene`:你跳过了StepN,先做 |
-| 细节遗漏 | `_intervene`:你漏了XX约束,重做/补上 |
-| 光说不做 | `_intervene`:别说了,直接做 |
-| 断言无据 | `_intervene`:你怎么确认的?验证一下 |
-| 连续失败 | `_intervene`:停,先读错误日志再决定 |
-| 感觉要偏 | `_intervene`:去重读SOP的StepN再继续 |
-| 即将进入中后期步骤 | `_keyinfo`:提前注入该步骤的⚠️细节(趁还没到,先塞进working memory) |
+**Step B — 写预期文档**(利用 subagent 启动+读环境的时间)
-## 干预原则
+subagent 在跑前几轮环境探测时,你写一屏预期文档(产出文件),只写三项:
-- **沉默为主**:没问题不说话
-- **一句话**:像用户一样直接说,禁长篇解释
-- **`_keyinfo`只用于提前预注入**:在工作agent到达该步之前塞细节。已经犯错的一律用`_intervene`纠正
\ No newline at end of file
+1. **意图与锚点**:目标、必须先看的对象、明显不是目标的对象;只列显性或高代价的账号/身份/归属字段。
+2. **行动协议**:正确推进顺序;必须先找/读哪些 SOP 或工具说明,并把其中的禁止/必须/格式要求纳入约束;何时核对对象/路径/账号/窗口/字段;危险动作前验证什么;调研/测试类先跑通最小可复现闭环再扩大。
+3. **完成证据**:最后必须有什么证据才能交付:状态、截图、日志、diff、测试结果、报告路径、使用方法等。
+
+**Step C — tail 监控与干预**(预期文档写完后开始)
+
+轮询 `temp/{task_name}/output*.txt`。看到 `[ROUND END]` 后判断是否需要写 `reply.txt` 续聊或用干预文件纠偏。tail 中若发现新信息(真实文件结构、字段、约束、隐藏难点),回头补充/更新预期文档,尤其补全此前想不到的验收指标。tail 时盯这些偏离:
+
+- 没先落到用户锚点,凭记忆/猜测行动。
+- 没找应有 SOP/工具说明,或读了却没按其中禁止/必须/格式要求做。
+- 对象、路径、窗口、账号、环境、数据源偏了。
+- 顺序错,尤其危险/不可逆动作前没验证。
+- 粒度错:该先跑通却大设计,该最小改动却重构,该调研测试却泛泛写报告。
+- 要交付了,但证据、路径、格式、测试或说明不够。
+- 反复试错、泛泛搜索、卡住不读日志/不看 SOP/不收敛到下一步。
+
+干预示例:
+
+```text
+停。用户锚点是 X,不是 Y。先看 X。
+不要提交。危险动作前还没验证 X。
+粒度错了。先跑通最小闭环。
+不要交付。还缺 X 证据/路径/格式。
+先停。这个任务应先读/遵守 X SOP,把禁止/必须项列进约束。
+你漏了 XX 约束,补上后再继续。
+连续失败先停,读错误日志再决定。
+别继续乱试。先读 X SOP/日志,列出一个最小下一步再做。
+```
+
+## 4. 最终验收
+
+按预期文档逐条核对:锚点是否命中、行动协议是否遵守、危险前是否验证、最终产物/结果是否满足用户目标、质量是否够、证据是否足够且路径/格式明确。任一不满足就返工;全部满足才交付用户。
diff --git a/memory/ui_detect.py b/memory/ui_detect.py
index e0ff9205..148d3f8e 100644
--- a/memory/ui_detect.py
+++ b/memory/ui_detect.py
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
"""
-UI元素检测 - YOLO + RapidOCR
+UI元素检测 - 基于 OmniParser-v2.0 的 icon_detect YOLO 模型 + RapidOCR
+
+首次使用前必须准备对应权重;这是 OmniParser-v2.0 的 icon_detect YOLO 模型。
+若缺失模型文件,新用户/AI 应搜索并下载 OmniParser-v2.0 icon_detect YOLO 权重。
+
用法:
from ui_detect import detect
elements = detect("screenshot.png") # 默认match模式
@@ -157,7 +161,7 @@ def detect(image_path, mode='match', conf=0.25, iou_thresh=0.5):
return elements
def visualize_for_debug(image_path, elements, output_path=None):
- """调试用: 可视化元素列表"""
+ """Only use when user wants to DEBUG!"""
from PIL import ImageFont
img = Image.open(image_path)
draw = ImageDraw.Draw(img)
diff --git a/memory/vision_api.template.py b/memory/vision_api.template.py
index cfa4ff15..7cfdc1ed 100644
--- a/memory/vision_api.template.py
+++ b/memory/vision_api.template.py
@@ -3,6 +3,12 @@ from io import BytesIO
from pathlib import Path
# ============ 用户配置区(从 template 拷贝后只需改这里)============
+# GA自举提示:
+# mykey.py配置变量名不固定;默认值失败时,枚举候选变量名,并用最小vision请求实验探测哪组配置可用。
+# 只打印变量名/字段名/model/apibase域名路径/HTTP状态码/错误类型,禁止打印完整dict和apikey/token。
+# 常见格式:
+# xxx_config = {"apibase": "https://.../v1", "apikey": "...", "model": "...", "proxy": None}
+# 不同中转的 apibase/endpoint 可能不同,按实际状态码和响应结构修正。
CLAUDE_CONFIG_KEY = 'claude_config141' # mykey.py 中 Claude 配置的变量名
OPENAI_CONFIG_KEY = 'oai_config1' # mykey.py 中 OpenAI 配置的变量名
MODELSCOPE_API_KEY = '' # 直接填你的 ModelScope token
@@ -79,7 +85,7 @@ def _call_claude(b64, prompt, timeout, max_tokens=1024):
mk = _load_config()
cfg = getattr(mk, CLAUDE_CONFIG_KEY)
resp = requests.post(
- cfg['apibase'] + '/v1/messages',
+ cfg['apibase'] + '/v1/messages', # endpoint按中转实际情况改:有的apibase已含/v1,或路径不同
json={'model': cfg['model'], 'max_tokens': max_tokens, 'messages': [{
'role': 'user',
'content': [
@@ -96,7 +102,7 @@ def _call_claude(b64, prompt, timeout, max_tokens=1024):
def _call_openai_compat(b64, prompt, timeout, *, apibase, apikey, model, proxy=None):
proxies = {'https': proxy, 'http': proxy} if proxy else None
resp = requests.post(
- apibase.rstrip('/') + '/v1/chat/completions',
+ apibase.rstrip('/') + '/v1/chat/completions', # endpoint按中转实际情况改:有的apibase已含/v1,或路径不同
json={'model': model, 'messages': [{
'role': 'user',
'content': [
diff --git a/mykey_template.py b/mykey_template.py
index e3e0e243..4531ae8e 100644
--- a/mykey_template.py
+++ b/mykey_template.py
@@ -253,13 +253,13 @@ mixin_config = {
# MiniMax 同时提供 OAI 和 Anthropic 兼容接口,同一个 key 两个端点都能用:
# - /v1 → chat/completions (LLMSession)
# - /anthropic → Anthropic Messages (NativeClaudeSession)
-# Anthropic 路径更简洁,OAI 路径会返回 标签(M2.7 自带思考)。
-# 温度自动修正为 (0, 1],支持 M2.7 / M2.5 全系列,204K 上下文。
+# Anthropic 路径更简洁,OAI 路径会返回 标签(M3/M2.7 自带思考)。
+# 温度自动修正为 (0, 1],支持 M3 / M2.7 全系列,M3 512K 上下文。
# native_claude_config_minimax = {
# 'name': 'minimax-anthropic', # /llms 显示名
# 'apikey': 'sk-', # 与 OAI 路径同一个 key
# 'apibase': 'https://api.minimaxi.com/anthropic', # Anthropic Messages 兼容端点
-# 'model': 'MiniMax-M2.7',
+# 'model': 'MiniMax-M3',
# 'max_retries': 3, # int
# # 'fake_cc_system_prompt': False, # MiniMax 不做 CC 指纹校验
# }
@@ -358,14 +358,14 @@ mixin_config = {
# ══════════════════════════════════════════════════════════════════════════════
# ── 4a. MiniMax OAI 路径 (/v1 chat/completions) ────────────────────────────
-# OAI 路径会返回 标签(M2.7 自带思考);Anthropic 路径更简洁(见 1f)。
-# 温度自动修正为 (0, 1],支持 M2.7/M2.5 全系列,204K 上下文。
+# OAI 路径会返回 标签(M3/M2.7 自带思考);Anthropic 路径更简洁(见 1f)。
+# 温度自动修正为 (0, 1],支持 M3/M2.7 全系列,M3 512K 上下文。
# oai_config_minimax = {
# 'name': 'minimax-oai', # /llms 显示名
# 'apikey': 'sk-', # 形如 sk-cp-xxxxxxxxx;Bearer 鉴权
# 'apibase': 'https://api.minimaxi.com/v1', # OAI 兼容端点
-# 'model': 'MiniMax-M2.7', # 名含 'minimax' → temp 夹到 (0.01,1.0]
-# 'context_win': 50000, # int;MiniMax 204K 上下文,此处是裁剪阈值
+# 'model': 'MiniMax-M3', # 名含 'minimax' → temp 夹到 (0.01,1.0]
+# 'context_win': 50000, # int;MiniMax M3 512K 上下文,此处是裁剪阈值
# }
diff --git a/plugins/project_mode.py b/plugins/project_mode.py
new file mode 100644
index 00000000..cca29d32
--- /dev/null
+++ b/plugins/project_mode.py
@@ -0,0 +1,128 @@
+"""Project Mode plugin — 零核心代码改动实现 GA 项目模式。
+
+机制:注册 agent_before hook(agent_loop.py 中每个用户轮触发一次)。
+当项目模式激活时,把 L1 层(规则 + 记忆文件指针 + 收尾纪律)追加到最后一条 user message(str 直接拼接,多模态 list 追加 text block)。
+两层设计:L1 每轮全量注入(轻量、稳定);L2 = project_memory.md 全文不注入,
+由模型按 L1 中的指针与线索(行数/大小)自行判断是否用 file 工具读取。
+利用 messages 是 list 引用的事实,直接 mutate 即反映到真正发给 LLM 的内容。
+
+激活态载体 = 文件锚 temp/.active_project.<宿主pid>(存当前项目名)。PID 键控:
+ - 锚只对写它的那个 GA 进程有效 → 多开 GA 各自激活不同项目,互不可见
+ - GA 关闭即自动失活(重启后 pid 变,旧锚作废);重新激活需经用户确认(SOP 指示)
+ - 进入:agent 读 project_mode_sop,经用户确认后写锚(code_run 中 os.getppid() 即宿主 pid)
+ - 退出:删除该文件。插件加载时清扫旧版无后缀锚与自己 pid 的前世残留(不碰他进程的锚)
+
+目录约定:
+ temp/projects/<项目名>/project_memory.md 单文件全文注入的项目记忆
+ temp/projects/<项目名>/ 项目私域文件(todo 等),解决多项目覆盖
+"""
+import os
+import plugins.hooks as hooks
+
+_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_TEMP = os.path.join(_PROJECT_ROOT, 'temp')
+_ANCHOR = os.path.join(_TEMP, f'.active_project.{os.getpid()}')
+
+
+def _cleanup_stale_anchors():
+ """清扫无主锚:仅删两类能纯靠文件名判定的——旧版无后缀的、自己 pid 的(刚启动
+ 不可能写过锚,必是 pid 复用的前世残留)。他进程的锚一律不碰:故意不做存活探测,
+ os.kill(pid, 0) 在 Windows 上语义是终止进程而非探活,任何平台分支都不值得为
+ 删除无害残留文件引入杀进程风险(激活判定只认自己 pid 的锚,残留不影响行为)。"""
+ import glob
+ for path in glob.glob(os.path.join(_TEMP, '.active_project*')):
+ pid = path.rsplit('.', 1)[-1]
+ if path != _ANCHOR and pid.isdigit():
+ continue # 他进程的锚(含已死进程的),不碰
+ try: os.remove(path)
+ except OSError: pass
+
+
+_cleanup_stale_anchors()
+
+
+def _active_project(ctx=None):
+ """返回当前激活的项目名;未激活返回 None。
+
+ 兼容策略:
+ - 新 TUI 多会话可在当前 GenericAgent 实例上设置 _ga_project_mode_name。
+ 只要该属性存在,就以它为准;值为 None/空串表示该 agent 普通模式。
+ - 其它 UI / 旧 SOP 不设置该属性,继续读取 pid 键控文件锚。
+ 异常不在此捕获——hooks.trigger 统一捕获并打印,保持可观测。"""
+ parent = None
+ if isinstance(ctx, dict):
+ handler = ctx.get('handler')
+ parent = getattr(handler, 'parent', None)
+ if parent is not None and hasattr(parent, '_ga_project_mode_name'):
+ return getattr(parent, '_ga_project_mode_name', None) or None
+ if not os.path.isfile(_ANCHOR):
+ return None
+ return open(_ANCHOR, encoding='utf-8').read().strip() or None
+
+
+def _project_dir(name):
+ return os.path.join(_TEMP, 'projects', name)
+
+
+def _mem_path(name):
+ return os.path.join(_project_dir(name), 'project_memory.md')
+
+
+def _memory_stat(name):
+ """返回 project_memory.md 的 (存在, 行数, 字节数),供 L1 指针给模型判断依据。"""
+ path = _mem_path(name)
+ if os.path.isfile(path):
+ data = open(path, encoding='utf-8').read()
+ return True, len(data.splitlines()), len(data.encode('utf-8'))
+ return False, 0, 0
+
+
+def _build_injection(name):
+ """构造追加到 user message 末尾的内容(两层设计的 L1 层)。
+
+ L1(每轮全量注入):规范/规则/操作说明 + 记忆文件指针(含行数/大小线索)。
+ L2(按需):project_memory.md 全文不注入,由模型自行判断是否用 file 工具去读。
+ """
+ pdir = _project_dir(name)
+ mem_path = _mem_path(name)
+ exists, lines, nbytes = _memory_stat(name)
+ if exists and nbytes > 0:
+ mem_hint = (
+ f"项目全量记忆在 {mem_path}({lines} 行 / {nbytes} 字节)。"
+ f"本轮任务若涉及项目上下文(接续工作、查约定、避坑),先读它再动手;"
+ f"若与项目认知无关(闲聊、独立小事),可不读。自行判断。"
+ )
+ else:
+ mem_hint = f"项目记忆 {mem_path} 暂为空(本项目尚无沉淀),无需读取。"
+ return (
+ f"\n\n---\n"
+ f"[PROJECT MODE: {name}]\n"
+ f"你正在「{name}」项目模式中。\n\n"
+ f"## 规则\n"
+ f"- 项目私域目录:{pdir}(todo、草稿、产物一律放这里,勿放 temp 根目录)\n"
+ f"- {mem_hint}\n\n"
+ f"## 收尾纪律\n"
+ f"干完本轮活后自问一个问题:「记忆归零、重新接手本项目的我,缺了本轮哪条信息会重复付出认知代价"
+ f"——再踩一次坑、再摸索一次、再问一次用户?」会的,就用 file 工具把那条追加进 {mem_path},"
+ f"写成未来的自己能直接复用的一句话;不会的,一个字都不写。\n"
+ f"---"
+ )
+
+
+@hooks.register('agent_before')
+def inject_project_context(ctx):
+ """每个用户轮起始时,若项目模式激活,把项目上下文追加到 user message。"""
+ name = _active_project(ctx)
+ if not name:
+ return # 未激活,普通模式,什么都不做
+
+ # 从尾部找最后一条 user message(不依赖 messages[1] 的位置约定)
+ um = next((m for m in reversed(ctx.get('messages') or [])
+ if isinstance(m, dict) and m.get('role') == 'user'), None)
+ if um is None:
+ return
+ content = um.get('content')
+ if isinstance(content, str):
+ um['content'] = content + _build_injection(name)
+ elif isinstance(content, list): # 多模态:追加 text block
+ content.append({'type': 'text', 'text': _build_injection(name)})
diff --git a/reflect/goal_mode.py b/reflect/goal_mode.py
index 22f30eeb..edec793a 100644
--- a/reflect/goal_mode.py
+++ b/reflect/goal_mode.py
@@ -3,7 +3,7 @@
# 配置: agent按SOP写好state json,通过环境变量GOAL_STATE指定路径
import os, json, time
-INTERVAL = 3 # check间隔短,agent跑完立刻再检查
+INTERVAL = 5 # check间隔短,agent跑完立刻再检查
ONCE = False
_dir = os.path.dirname(os.path.abspath(__file__))
@@ -23,7 +23,7 @@ def _save(state):
json.dump(state, f, ensure_ascii=False, indent=2)
# --- prompt 模板 ---
-CONTINUATION_PROMPT = """[Goal Mode — 持续推进]
+CONTINUATION_PROMPT = """[Goal Mode — 持续优化]
{objective}
@@ -31,16 +31,25 @@ CONTINUATION_PROMPT = """[Goal Mode — 持续推进]
⏱ 已用 {elapsed_min:.0f} 分钟,剩余约 {remaining_min:.0f} 分钟。第 {turn} 次唤醒。
-你正在 Goal Mode 下工作:
-1. 禁止说"已完成,是否继续"——预算没到就不准停,不准提前交付
-2. 分析用户需要的交付物,落实成少量核心成果,后续任务都围绕核心成果的质量提升
-3. 在 cwd 下建立工作文件夹存放成果和进度,读取记忆中可能对成果质量有帮助的原则
-4. 每轮选你认为最能提升成果质量的方向,深入打磨持续修改核心成果
- 如果多轮都是同类型的小修——换一个完全不同的角度重新审视,
- 找到新的薄弱点再深入。包括但不限于测试/边界case/性能/安全/代码质量/美观。
-5. 换角度的方法:假装你是第一次看到这个成果的使用者/审阅者/攻击者,
- 找到它最容易出问题的地方,然后修它。
-6. 要为了目标持续推进,在工作文件夹中记录进度,不要更新全局记忆。
+你正在 Goal Mode 下工作:无法宣告完成,你会被无法阻止地持续唤醒直到预算耗尽
+唤醒后流程(3选1):
+1. 创造阶段(第一次唤醒):分析objective,在cwd建工作文件夹,严格按照objective执行
+2. 检验阶段:从不同视角检验创造结果,产出检验报告
+ - 换身份查看(读者/受众/用户/测试工程师/领导) | 设计未跑过的更难测例 | 查素材/事实/引用的真实性与数量/说服力 | 代码质量/产物格式/美观 | 实测验证(亲自执行/模拟用户操作)
+ - 按任务类型**轮换**选用合适的角色和方法
+ - 在遵循原始需求约束下追求超预期,拒绝保守和平庸,必须提出“不够出色”的点
+ - 先保及格线(无事实错误/乱码/格式错误,能运行,过基础测例,遵循用户约束),及格同时追求出色
+3. 改进阶段:针对检验报告优化改进交付物,必须实质性改进
+
+原则:
+1. 每次唤醒**交替**进行检验阶段和改进阶段,保留每次的检验报告和改进changelog。
+2. 除非发现严重问题,不要对创造结果进行完全重写,而是改进
+3. 严格区分交付物和进度报告,交付物中不要混入`已检验`等中间信息
+4. 若检验都是无关紧要问题,下次升级检验(要求更出色产物/更苛刻视角/更难测试/对照原始需求重审/开subagent第三方评审)
+5. 改进阶段禁止产出"无改动"。若检验未发现值得改的点,说明检验标准太低——本轮产出"检验标准升级报告",论证当前标准为何不够高并提出新标准,下轮按新标准重新检验。
+6. 在工作文件夹中记录进度,不要更新全局记忆
+7. 所有阶段都建议进行充分调研:web调研、查看记忆和相关SOP、获取用户倾向
+8. 禁止进行sha1等无用验证,文件版本不会出错
"""
BUDGET_LIMIT_PROMPT = """[Goal Mode — 预算耗尽,收口]