paimon 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. paimon-0.2.3/PKG-INFO +112 -0
  2. paimon-0.2.3/README.md +100 -0
  3. paimon-0.2.3/README.zh-CN.md +100 -0
  4. {paimon-0.2.2 → paimon-0.2.3}/paimon/__init__.py +4 -0
  5. {paimon-0.2.2 → paimon-0.2.3}/paimon/agent.py +88 -7
  6. {paimon-0.2.2 → paimon-0.2.3}/paimon/app.py +46 -3
  7. {paimon-0.2.2 → paimon-0.2.3}/paimon/app.tcss +13 -1
  8. paimon-0.2.3/paimon/aside.py +165 -0
  9. {paimon-0.2.2 → paimon-0.2.3}/paimon/cli.py +4 -0
  10. {paimon-0.2.2 → paimon-0.2.3}/paimon/commands.py +31 -7
  11. {paimon-0.2.2 → paimon-0.2.3}/paimon/compaction.py +6 -1
  12. paimon-0.2.3/paimon/config.py +289 -0
  13. paimon-0.2.3/paimon/diff.py +167 -0
  14. paimon-0.2.3/paimon/errors.py +16 -0
  15. {paimon-0.2.2 → paimon-0.2.3}/paimon/headless.py +6 -1
  16. {paimon-0.2.2 → paimon-0.2.3}/paimon/llm.py +6 -0
  17. {paimon-0.2.2 → paimon-0.2.3}/paimon/lockfile.py +17 -9
  18. {paimon-0.2.2 → paimon-0.2.3}/paimon/login.py +21 -5
  19. {paimon-0.2.2 → paimon-0.2.3}/paimon/pane.py +160 -29
  20. {paimon-0.2.2 → paimon-0.2.3}/paimon/session.py +11 -2
  21. {paimon-0.2.2 → paimon-0.2.3}/paimon/supervisor.py +2 -1
  22. {paimon-0.2.2 → paimon-0.2.3}/paimon/tools.py +77 -35
  23. {paimon-0.2.2 → paimon-0.2.3}/paimon/ui.py +127 -17
  24. paimon-0.2.3/paimon.egg-info/PKG-INFO +112 -0
  25. {paimon-0.2.2 → paimon-0.2.3}/paimon.egg-info/SOURCES.txt +2 -0
  26. {paimon-0.2.2 → paimon-0.2.3}/paimon.egg-info/scm_file_list.json +4 -0
  27. paimon-0.2.3/paimon.egg-info/scm_version.json +8 -0
  28. paimon-0.2.2/PKG-INFO +0 -116
  29. paimon-0.2.2/README.md +0 -104
  30. paimon-0.2.2/README.zh-CN.md +0 -104
  31. paimon-0.2.2/paimon/config.py +0 -162
  32. paimon-0.2.2/paimon/diff.py +0 -71
  33. paimon-0.2.2/paimon.egg-info/PKG-INFO +0 -116
  34. paimon-0.2.2/paimon.egg-info/scm_version.json +0 -8
  35. {paimon-0.2.2 → paimon-0.2.3}/LICENSE +0 -0
  36. {paimon-0.2.2 → paimon-0.2.3}/MANIFEST.in +0 -0
  37. {paimon-0.2.2 → paimon-0.2.3}/paimon/__main__.py +0 -0
  38. {paimon-0.2.2 → paimon-0.2.3}/paimon/jobs.py +0 -0
  39. {paimon-0.2.2 → paimon-0.2.3}/paimon/mentions.py +0 -0
  40. {paimon-0.2.2 → paimon-0.2.3}/paimon/model_windows.py +0 -0
  41. {paimon-0.2.2 → paimon-0.2.3}/paimon/prompt.py +0 -0
  42. {paimon-0.2.2 → paimon-0.2.3}/paimon/retry.py +0 -0
  43. {paimon-0.2.2 → paimon-0.2.3}/paimon/skill/SKILL.md +0 -0
  44. {paimon-0.2.2 → paimon-0.2.3}/paimon/tabs.py +0 -0
  45. {paimon-0.2.2 → paimon-0.2.3}/paimon/taskpane.py +0 -0
  46. {paimon-0.2.2 → paimon-0.2.3}/paimon.egg-info/dependency_links.txt +0 -0
  47. {paimon-0.2.2 → paimon-0.2.3}/paimon.egg-info/entry_points.txt +0 -0
  48. {paimon-0.2.2 → paimon-0.2.3}/paimon.egg-info/requires.txt +0 -0
  49. {paimon-0.2.2 → paimon-0.2.3}/paimon.egg-info/top_level.txt +0 -0
  50. {paimon-0.2.2 → paimon-0.2.3}/pyproject.toml +0 -0
  51. {paimon-0.2.2 → paimon-0.2.3}/setup.cfg +0 -0
paimon-0.2.3/PKG-INFO ADDED
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: paimon
3
+ Version: 0.2.3
4
+ Summary: A minimal code agent built on pydantic-ai + textual
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: pydantic-ai-slim[anthropic,openai]>=2.17.0
9
+ Requires-Dist: textual>=8.2.7
10
+ Requires-Dist: textual-serve>=1.1.3
11
+ Dynamic: license-file
12
+
13
+ # Paimon
14
+
15
+ ![Paimon](https://automaton-media.com/wp-content/uploads/2020/10/20201019-140524-header.jpg)
16
+
17
+ English | [简体中文](README.zh-CN.md)
18
+
19
+ Paimon is a coding agent that lives in your terminal. It reads and edits files in the current directory and runs commands. It also runs headless and imports as a library, so a stronger agent or a program of your own can drive it.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ uv tool install paimon # or: pip install paimon
25
+ ```
26
+
27
+ ## Getting started
28
+
29
+ ```bash
30
+ paimon
31
+ ```
32
+
33
+ Or run it without installing anything:
34
+
35
+ ```bash
36
+ uvx paimon
37
+ ```
38
+
39
+ The first launch asks for a provider, model, API base and key. Then just type what you want done. Write `@path/to/file` in a prompt to hand a file to the agent.
40
+
41
+ While it runs: `Shift+Tab` switches how much the agent may do on its own (**read** asks before writing files or running commands, **edit** lets edits inside the working directory through, **yolo** never asks and is the default), `Esc` interrupts the current turn, `Ctrl+P` opens the command palette, `Ctrl+C` quits.
42
+
43
+ `Ctrl+T` opens another session in a pane of its own, `Ctrl+W` closes one, `Ctrl+PageUp` and `Ctrl+PageDown` move between them, and `Ctrl+G` jumps to a pane waiting for permission. Paimon can open panes itself: ask for two independent things and it starts a second agent in its own tab. It can also leave a command running in a tab of its own, a dev server or a watcher, instead of holding up a turn.
44
+
45
+ ## Using Paimon as a subagent
46
+
47
+ Frontier models are good at planning and reviewing; the steps in between are often mechanical. Point Paimon at a cheaper model and let Claude Code or Codex write the plan and check the result. A profile keeps that model's account separate:
48
+
49
+ ```bash
50
+ paimon login --profile glm --model zai:glm-4.7 --api-key-env ZAI_API_KEY
51
+ paimon --profile glm -p "apply the plan in PLAN.md" --mode edit --output-format result
52
+ ```
53
+
54
+ The bundled skill teaches the calling agent this workflow:
55
+
56
+ ```bash
57
+ paimon install-skill # into Claude Code (~/.claude/skills/paimon)
58
+ paimon install-skill --target codex # into Codex; --dest DIR for anywhere else
59
+ npx skills add aisk/paimon # the same skill, via skills.sh
60
+ ```
61
+
62
+ ## Using Paimon as a library
63
+
64
+ The agent loop is importable, so a Python program can drive it without going through the CLI. `Agent.open()` starts or resumes a session and `agent.run()` yields typed events, one per text chunk, tool call and turn end, which the caller renders or filters however it likes:
65
+
66
+ ```python
67
+ import asyncio
68
+
69
+ from paimon.agent import Agent, TextDelta
70
+
71
+ async def main():
72
+ agent = Agent.open(mode="edit")
73
+ async for event in agent.run("summarize the tests in this directory"):
74
+ if isinstance(event, TextDelta):
75
+ print(event.text, end="", flush=True)
76
+
77
+ asyncio.run(main())
78
+ ```
79
+
80
+ `Agent.open()` also takes a working directory, an async `confirm` callback for permission prompts, and a `toolset` to hand the model fewer tools or tools of your own. An agent holds its session until it goes away; to give it back at a definite moment, call `close()` or use the agent as a context manager. It writes the same session files as the CLI, so a run started in code can be resumed later with `paimon -r`.
81
+
82
+ ## Sessions
83
+
84
+ Every conversation is saved, and long ones are summarized in place near the context limit. Paimon prints the command that brings a session back when you leave:
85
+
86
+ ```bash
87
+ paimon -r # choose a session started in this directory
88
+ paimon -r a1b2c3 # resume one by id
89
+ paimon -c # resume the most recent one
90
+ paimon sessions # list them (--json for machines)
91
+ paimon log a1b2c3 # what a session did, one line per event
92
+ ```
93
+
94
+ ## Other ways to run it
95
+
96
+ ```bash
97
+ paimon --mode read # start in a more cautious permission mode (yolo is the default)
98
+ paimon --strict # ask before every command, even read-only ones
99
+ paimon --web # the same UI in a browser (--port, default 8000)
100
+ paimon -p "what does cli.py do?" # one answer on stdout, no UI
101
+ cat log.txt | paimon -p "summarize this"
102
+ paimon --model zai:glm-4.7 # this model for this run only
103
+ paimon --profile work # a separately configured account
104
+ ```
105
+
106
+ `-p` never stops to ask, so with the default `yolo` mode it can already write files and run commands. Add `--output-format result` for a single JSON object with the outcome, which is what a calling program should read. `paimon --help` lists the rest.
107
+
108
+ ## Configuration
109
+
110
+ Each profile keeps its model settings in `~/.config/paimon/<name>/config.json`, written by the first launch or by `paimon login`. Sessions live in `~/.local/share/paimon/sessions/`. File changes render nicer if [delta](https://github.com/dandavison/delta) is installed.
111
+
112
+ Read and edit modes run a small set of clearly read-only commands (`ls`, `cat`, `git status`, …) without asking; `--strict` turns that off. **This is a guardrail against agent mistakes, not a security boundary.** For real isolation, run Paimon inside a container or VM.
paimon-0.2.3/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # Paimon
2
+
3
+ ![Paimon](https://automaton-media.com/wp-content/uploads/2020/10/20201019-140524-header.jpg)
4
+
5
+ English | [简体中文](README.zh-CN.md)
6
+
7
+ Paimon is a coding agent that lives in your terminal. It reads and edits files in the current directory and runs commands. It also runs headless and imports as a library, so a stronger agent or a program of your own can drive it.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ uv tool install paimon # or: pip install paimon
13
+ ```
14
+
15
+ ## Getting started
16
+
17
+ ```bash
18
+ paimon
19
+ ```
20
+
21
+ Or run it without installing anything:
22
+
23
+ ```bash
24
+ uvx paimon
25
+ ```
26
+
27
+ The first launch asks for a provider, model, API base and key. Then just type what you want done. Write `@path/to/file` in a prompt to hand a file to the agent.
28
+
29
+ While it runs: `Shift+Tab` switches how much the agent may do on its own (**read** asks before writing files or running commands, **edit** lets edits inside the working directory through, **yolo** never asks and is the default), `Esc` interrupts the current turn, `Ctrl+P` opens the command palette, `Ctrl+C` quits.
30
+
31
+ `Ctrl+T` opens another session in a pane of its own, `Ctrl+W` closes one, `Ctrl+PageUp` and `Ctrl+PageDown` move between them, and `Ctrl+G` jumps to a pane waiting for permission. Paimon can open panes itself: ask for two independent things and it starts a second agent in its own tab. It can also leave a command running in a tab of its own, a dev server or a watcher, instead of holding up a turn.
32
+
33
+ ## Using Paimon as a subagent
34
+
35
+ Frontier models are good at planning and reviewing; the steps in between are often mechanical. Point Paimon at a cheaper model and let Claude Code or Codex write the plan and check the result. A profile keeps that model's account separate:
36
+
37
+ ```bash
38
+ paimon login --profile glm --model zai:glm-4.7 --api-key-env ZAI_API_KEY
39
+ paimon --profile glm -p "apply the plan in PLAN.md" --mode edit --output-format result
40
+ ```
41
+
42
+ The bundled skill teaches the calling agent this workflow:
43
+
44
+ ```bash
45
+ paimon install-skill # into Claude Code (~/.claude/skills/paimon)
46
+ paimon install-skill --target codex # into Codex; --dest DIR for anywhere else
47
+ npx skills add aisk/paimon # the same skill, via skills.sh
48
+ ```
49
+
50
+ ## Using Paimon as a library
51
+
52
+ The agent loop is importable, so a Python program can drive it without going through the CLI. `Agent.open()` starts or resumes a session and `agent.run()` yields typed events, one per text chunk, tool call and turn end, which the caller renders or filters however it likes:
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ from paimon.agent import Agent, TextDelta
58
+
59
+ async def main():
60
+ agent = Agent.open(mode="edit")
61
+ async for event in agent.run("summarize the tests in this directory"):
62
+ if isinstance(event, TextDelta):
63
+ print(event.text, end="", flush=True)
64
+
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ `Agent.open()` also takes a working directory, an async `confirm` callback for permission prompts, and a `toolset` to hand the model fewer tools or tools of your own. An agent holds its session until it goes away; to give it back at a definite moment, call `close()` or use the agent as a context manager. It writes the same session files as the CLI, so a run started in code can be resumed later with `paimon -r`.
69
+
70
+ ## Sessions
71
+
72
+ Every conversation is saved, and long ones are summarized in place near the context limit. Paimon prints the command that brings a session back when you leave:
73
+
74
+ ```bash
75
+ paimon -r # choose a session started in this directory
76
+ paimon -r a1b2c3 # resume one by id
77
+ paimon -c # resume the most recent one
78
+ paimon sessions # list them (--json for machines)
79
+ paimon log a1b2c3 # what a session did, one line per event
80
+ ```
81
+
82
+ ## Other ways to run it
83
+
84
+ ```bash
85
+ paimon --mode read # start in a more cautious permission mode (yolo is the default)
86
+ paimon --strict # ask before every command, even read-only ones
87
+ paimon --web # the same UI in a browser (--port, default 8000)
88
+ paimon -p "what does cli.py do?" # one answer on stdout, no UI
89
+ cat log.txt | paimon -p "summarize this"
90
+ paimon --model zai:glm-4.7 # this model for this run only
91
+ paimon --profile work # a separately configured account
92
+ ```
93
+
94
+ `-p` never stops to ask, so with the default `yolo` mode it can already write files and run commands. Add `--output-format result` for a single JSON object with the outcome, which is what a calling program should read. `paimon --help` lists the rest.
95
+
96
+ ## Configuration
97
+
98
+ Each profile keeps its model settings in `~/.config/paimon/<name>/config.json`, written by the first launch or by `paimon login`. Sessions live in `~/.local/share/paimon/sessions/`. File changes render nicer if [delta](https://github.com/dandavison/delta) is installed.
99
+
100
+ Read and edit modes run a small set of clearly read-only commands (`ls`, `cat`, `git status`, …) without asking; `--strict` turns that off. **This is a guardrail against agent mistakes, not a security boundary.** For real isolation, run Paimon inside a container or VM.
@@ -0,0 +1,100 @@
1
+ # Paimon
2
+
3
+ ![Paimon](https://automaton-media.com/wp-content/uploads/2020/10/20201019-140524-header.jpg)
4
+
5
+ [English](README.md) | 简体中文
6
+
7
+ Paimon 是一个终端里的 coding agent。它读写当前目录下的文件,执行命令。它也支持无头运行,还可以作为库导入,由更强的 agent 或者你自己的程序来驱动。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ uv tool install paimon # 或者:pip install paimon
13
+ ```
14
+
15
+ ## 快速开始
16
+
17
+ ```bash
18
+ paimon
19
+ ```
20
+
21
+ 或者不安装直接运行:
22
+
23
+ ```bash
24
+ uvx paimon
25
+ ```
26
+
27
+ 首次启动会询问 provider、模型、API base 和 key。之后输入要完成的任务即可。在提示中写 `@path/to/file` 可以把文件提供给 agent。
28
+
29
+ 运行时:`Shift+Tab` 切换 agent 的自主程度(**read** 写文件或执行命令前先询问,**edit** 放行工作目录内的编辑,**yolo** 从不询问,也是默认值),`Esc` 打断当前回合,`Ctrl+P` 打开命令面板,`Ctrl+C` 退出。
30
+
31
+ `Ctrl+T` 在新 pane 里打开另一个会话,`Ctrl+W` 关闭当前 pane,`Ctrl+PageUp` 和 `Ctrl+PageDown` 在 pane 之间切换,`Ctrl+G` 跳到正在等待授权的 pane。Paimon 自己也能开 pane:让它同时做两件互不相干的事,它会在新 tab 里起第二个 agent。它也能把一条命令留在单独的 tab 里跑,比如开发服务器或者文件监视,不占着当前回合。
32
+
33
+ ## 当作 subagent 使用
34
+
35
+ 前沿模型擅长制定计划和验收结果,中间的执行步骤往往比较机械。让 Paimon 使用成本较低的模型执行,由 Claude Code 或 Codex 制定计划并检查结果。用一个 profile 单独保存该模型的账号:
36
+
37
+ ```bash
38
+ paimon login --profile glm --model zai:glm-4.7 --api-key-env ZAI_API_KEY
39
+ paimon --profile glm -p "apply the plan in PLAN.md" --mode edit --output-format result
40
+ ```
41
+
42
+ 自带的 skill 会向调用方 agent 说明这套流程:
43
+
44
+ ```bash
45
+ paimon install-skill # 安装到 Claude Code(~/.claude/skills/paimon)
46
+ paimon install-skill --target codex # 安装到 Codex;--dest DIR 安装到任意目录
47
+ npx skills add aisk/paimon # 通过 skills.sh 安装同一个 skill
48
+ ```
49
+
50
+ ## 当作库使用
51
+
52
+ agent 循环本身是可以导入的,Python 程序不必经过 CLI 就能驱动它。`Agent.open()` 新建或恢复一个会话,`agent.run()` 产出类型化的事件,每段文本、每次工具调用、每个回合结束各一个,调用方自己决定怎么渲染或过滤:
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ from paimon.agent import Agent, TextDelta
58
+
59
+ async def main():
60
+ agent = Agent.open(mode="edit")
61
+ async for event in agent.run("summarize the tests in this directory"):
62
+ if isinstance(event, TextDelta):
63
+ print(event.text, end="", flush=True)
64
+
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ `Agent.open()` 还接受工作目录、用于权限确认的异步 `confirm` 回调,以及 `toolset`,可以只给模型一部分工具,或者换成你自己的工具。agent 被回收时会交还会话,想在确定的时刻交还就调 `close()`,或者把它当上下文管理器用。它写的会话文件和 CLI 一样,所以代码里跑出来的会话之后可以用 `paimon -r` 恢复。
69
+
70
+ ## 会话
71
+
72
+ 每次对话都会保存,很长的对话在接近上下文上限时会被原地总结。退出时 Paimon 会打印恢复该会话的命令:
73
+
74
+ ```bash
75
+ paimon -r # 从当前目录的会话中选择
76
+ paimon -r a1b2c3 # 按 id 恢复
77
+ paimon -c # 恢复最近一个会话
78
+ paimon sessions # 列出会话(--json 输出机器可读格式)
79
+ paimon log a1b2c3 # 查看会话做了什么,每个事件一行
80
+ ```
81
+
82
+ ## 其他运行方式
83
+
84
+ ```bash
85
+ paimon --mode read # 以更谨慎的权限模式启动(默认为 yolo)
86
+ paimon --strict # 每条命令都先询问,包括只读命令
87
+ paimon --web # 在浏览器中使用同一套 UI(--port,默认 8000)
88
+ paimon -p "what does cli.py do?" # 直接在 stdout 输出回答,不启动 UI
89
+ cat log.txt | paimon -p "summarize this"
90
+ paimon --model zai:glm-4.7 # 仅本次运行使用该模型
91
+ paimon --profile work # 单独配置的另一个账号
92
+ ```
93
+
94
+ `-p` 不会停下来询问,配合默认的 `yolo` 模式,它已经可以修改文件和执行命令。加 `--output-format result` 会输出一个包含结果的 JSON 对象,调用方程序读这个就够了。其余选项见 `paimon --help`。
95
+
96
+ ## 配置
97
+
98
+ 每个 profile 的模型设置保存在 `~/.config/paimon/<name>/config.json`,由首次启动或 `paimon login` 写入。会话存放在 `~/.local/share/paimon/sessions/`。安装 [delta](https://github.com/dandavison/delta) 后文件改动的展示效果更好。
99
+
100
+ read 和 edit 模式会不经询问执行一小组明确只读的命令(`ls`、`cat`、`git status` 等),`--strict` 可以关掉。**这是防止 agent 失误的护栏,不是安全边界。** 需要真正的隔离时,请在容器或虚拟机中运行 Paimon。
@@ -1 +1,5 @@
1
1
  """Paimon, a minimal code agent built on pydantic-ai + textual."""
2
+
3
+ from .errors import PaimonError
4
+
5
+ __all__ = ["PaimonError"]
@@ -30,9 +30,9 @@ from pydantic_ai.messages import (
30
30
  )
31
31
  from pydantic_ai.models import Model, ModelRequestParameters
32
32
 
33
- from . import compaction, retry, tools
33
+ from . import aside, compaction, retry, tools
34
34
  from .config import Config
35
- from .llm import build_model, user_agent
35
+ from .llm import NoModelError, build_model, user_agent
36
36
  from .mentions import expand_mentions
37
37
  from .prompt import build_system_prompt
38
38
  from .session import (
@@ -93,10 +93,18 @@ class SessionHandoff:
93
93
 
94
94
  @dataclass
95
95
  class RequestStats:
96
- """Output speed of one finished model request, from provider-reported usage."""
96
+ """Output speed and cache usage of one finished model request, from
97
+ provider-reported usage. ``input_tokens`` includes the cached portion
98
+ (pydantic-ai normalizes providers that report them separately), so the
99
+ cache hit rate is ``cache_read_tokens / input_tokens``. Providers that
100
+ do not report caching leave both cache fields at zero.
101
+ """
97
102
 
98
103
  output_tokens: int
99
104
  seconds: float
105
+ input_tokens: int = 0
106
+ cache_read_tokens: int = 0
107
+ cache_write_tokens: int = 0
100
108
 
101
109
 
102
110
  @dataclass
@@ -238,6 +246,11 @@ def _strip_foreign_thinking(history: list[ModelMessage], model: Model) -> list[M
238
246
  # Re-exported so UI code can keep importing it from here.
239
247
  ConfirmFn = tools.ConfirmFn
240
248
 
249
+ # Takes the messages a user queued while a turn was already running, clearing
250
+ # the queue as it goes. The loop calls it before every model request, so what
251
+ # it returns reaches the model at the next step rather than the next turn.
252
+ PendingFn = Callable[[], list[str]]
253
+
241
254
 
242
255
  class Agent:
243
256
  """One conversation against one session.
@@ -245,6 +258,13 @@ class Agent:
245
258
  Built through :meth:`open`, which is where the session file is created or
246
259
  resumed and its lock taken; the constructor itself only wires up state a
247
260
  caller already holds, so it can neither fail nor leave a lock behind.
261
+
262
+ An agent holds its session until :meth:`close`, which ``with`` calls on the
263
+ way out::
264
+
265
+ with Agent.open(cwd=path) as agent:
266
+ async for event in agent.run("..."):
267
+ ...
248
268
  """
249
269
 
250
270
  def __init__(self, session: Session, system_prompt: str, *, cwd: Optional[Path] = None,
@@ -265,6 +285,9 @@ class Agent:
265
285
  # None everywhere else (headless, tests), where the agent tools refuse
266
286
  # rather than pretend.
267
287
  self.supervisor = None
288
+ # Set by the UI too: the hook the loop pulls queued user messages from.
289
+ # None where nobody can type while a turn runs (headless, tests).
290
+ self.pending: Optional[PendingFn] = None
268
291
  # Per-agent tool state, kept off the tool functions so one agent's
269
292
  # shell overflow files stay invisible to the next one.
270
293
  self.tool_context = tools.ToolContext()
@@ -325,6 +348,28 @@ class Agent:
325
348
  session.unlock()
326
349
  raise
327
350
 
351
+ def close(self) -> None:
352
+ """Release the session, so another agent may open it. Idempotent."""
353
+ self.session.unlock()
354
+
355
+ def __enter__(self) -> "Agent":
356
+ return self
357
+
358
+ def __exit__(self, *exc_info) -> None:
359
+ self.close()
360
+
361
+ def __del__(self) -> None:
362
+ # Backstop for a caller that used neither ``with`` nor close(): the
363
+ # claim is refcounted in this process, so a forgotten agent would keep
364
+ # its session unopenable for as long as the process lives. Runs at
365
+ # interpreter shutdown too, where anything may already be torn down.
366
+ try:
367
+ session = getattr(self, "session", None)
368
+ if session is not None:
369
+ session.unlock()
370
+ except Exception:
371
+ pass
372
+
328
373
  @property
329
374
  def model_name(self) -> Optional[str]:
330
375
  """The model this agent talks to: its own override, else the config's."""
@@ -334,7 +379,7 @@ class Agent:
334
379
  """The configured model, rebuilt when login changes the config."""
335
380
  name = self.model_name
336
381
  if not name:
337
- raise RuntimeError("No model configured; log in first")
382
+ raise NoModelError("No model configured; log in first")
338
383
  key = (name, self.config.api_base, self.config.api_key)
339
384
  if self._cached_model is None or self._cached_model[0] != key:
340
385
  self._cached_model = (key, build_model(*key))
@@ -408,6 +453,29 @@ class Agent:
408
453
  """Compact on demand; None when the history is too short to be worth it."""
409
454
  return await self._maybe_compact(force=True)
410
455
 
456
+ async def ask_aside(self, question: str, *,
457
+ instructions: str = aside.DEFAULT_INSTRUCTIONS,
458
+ max_tokens: int = aside.DEFAULT_MAX_TOKENS) -> AsyncIterator[str]:
459
+ """Ask a question over this agent's context, yielding the answer as text.
460
+
461
+ Read-only: neither the question nor the answer enters ``self.history``
462
+ or the session log, and no compaction is triggered. That is what lets
463
+ it be asked while a turn is already waiting on the model: it takes a
464
+ snapshot and opens a request of its own rather than joining the one in
465
+ flight. A cancelled aside leaves nothing behind either.
466
+
467
+ The history it sends is whatever exists when the first delta is asked
468
+ for, so a turn running alongside it may add messages the answer never
469
+ saw.
470
+ """
471
+ model = self._model()
472
+ context = _strip_foreign_thinking(aside.usable_history(self.history), model)
473
+ async for delta in aside.stream(model, self.system_prompt, context, question,
474
+ instructions=instructions,
475
+ tool_definitions=self._tool_definitions,
476
+ max_tokens=max_tokens):
477
+ yield delta
478
+
411
479
  # ---- Tools the agent loop runs itself ----------------------------------
412
480
  # These mutate agent-held state or end the turn, so they cannot go through
413
481
  # the stateless tools.run_tool. Each handler fills ``slot`` with the tool
@@ -531,6 +599,16 @@ class Agent:
531
599
  compaction_failures = 0
532
600
 
533
601
  while True:
602
+ # Messages the user queued while this turn was already running.
603
+ # Appended as their own request so the model sees them at the next
604
+ # step, without touching the stream that just ended or the tool
605
+ # results already recorded. Before compaction, so they count
606
+ # towards the context check and survive a history replacement.
607
+ for queued in (self.pending() if self.pending is not None else []):
608
+ self._append_message(ModelRequest(
609
+ parts=[UserPromptPart(content=expand_mentions(queued, self.cwd))]))
610
+ yield UserInput(queued)
611
+
534
612
  if not compaction_off:
535
613
  try:
536
614
  compacted = await self._maybe_compact()
@@ -591,9 +669,12 @@ class Agent:
591
669
  response = stream.get()
592
670
  if first_event_at is not None:
593
671
  elapsed = time.monotonic() - first_event_at
594
- output_tokens = response.usage.output_tokens
595
- if output_tokens and elapsed > 0:
596
- stats = RequestStats(output_tokens, elapsed)
672
+ usage = response.usage
673
+ if usage.output_tokens and elapsed > 0:
674
+ stats = RequestStats(usage.output_tokens, elapsed,
675
+ usage.input_tokens,
676
+ usage.cache_read_tokens,
677
+ usage.cache_write_tokens)
597
678
  break
598
679
  except asyncio.CancelledError:
599
680
  # Interrupted mid-stream: keep partial text but drop incomplete
@@ -15,6 +15,7 @@ from textual.widgets import ContentSwitcher, Static
15
15
  from . import compaction, tools
16
16
  from .agent import Agent
17
17
  from .config import DEFAULT_PROFILE, Config, list_profiles
18
+ from .errors import PaimonError
18
19
  from .login import LoginScreen, PickerScreen, PromptScreen
19
20
  from .pane import Pane, SessionPane
20
21
  from .session import SessionError
@@ -66,6 +67,11 @@ class PaimonApp(App):
66
67
  "Show or hide the model's reasoning stream (it is generated either way)",
67
68
  self.action_toggle_reasoning,
68
69
  ),
70
+ SystemCommand(
71
+ "Toggle idle recap",
72
+ "Offer a short recap after a turn once you go quiet (it costs an extra request)",
73
+ self.action_toggle_recap,
74
+ ),
69
75
  SystemCommand(
70
76
  "Compact context",
71
77
  "Summarize the earlier conversation now instead of waiting for the context to fill",
@@ -319,10 +325,28 @@ class PaimonApp(App):
319
325
  self._switch_to(pane)
320
326
  return
321
327
 
328
+ def save_config(self, **fields) -> None:
329
+ """Persist config fields without blocking or crashing the UI.
330
+
331
+ save() waits on the cross-process lock (up to 10s when another
332
+ instance is stuck) and fsyncs twice, and it raises ConfigError on a
333
+ corrupt file, so it runs on a worker thread and a failure lands as a
334
+ notice instead of an exception tearing down the app.
335
+ """
336
+ def _write() -> None:
337
+ try:
338
+ self.config.save(**fields)
339
+ except PaimonError as exc:
340
+ self.call_from_thread(
341
+ self.pane.notice,
342
+ Content.from_markup("[$text-error b]Config not saved:[/] $body",
343
+ body=str(exc)))
344
+ self.run_worker(_write, thread=True, group="config-save")
345
+
322
346
  def _watch_theme(self, theme_name: str) -> None:
323
347
  super()._watch_theme(theme_name)
324
348
  if self._persist_theme_changes:
325
- self.config.save(theme=theme_name)
349
+ self.save_config(theme=theme_name)
326
350
 
327
351
  def compose(self) -> ComposeResult:
328
352
  yield self._tabs
@@ -389,10 +413,27 @@ class PaimonApp(App):
389
413
  self.pane.interrupt()
390
414
 
391
415
  def action_toggle_reasoning(self) -> None:
392
- self.config.save(show_reasoning=not self.config.show_reasoning)
416
+ # Flipped on the instance up front: the write happens on a worker
417
+ # thread, and the UI must reflect the toggle immediately.
418
+ self.config.show_reasoning = not self.config.show_reasoning
419
+ self.save_config(show_reasoning=self.config.show_reasoning)
393
420
  state = "streamed live" if self.config.show_reasoning else "folded"
394
421
  self.pane.notice(Content.from_markup(f"[$text-muted]Thinking: {state}[/]"))
395
422
 
423
+ def action_toggle_recap(self) -> None:
424
+ """Turn the after-idle recap off (or back on), for every pane.
425
+
426
+ Config is process-wide, so one switch covers all panes; turning it off
427
+ also drops recaps already armed, which check the flag only when armed.
428
+ """
429
+ self.config.recap_enabled = not self.config.recap_enabled
430
+ self.save_config(recap_enabled=self.config.recap_enabled)
431
+ if not self.config.recap_enabled:
432
+ for pane in self.sessions:
433
+ pane._cancel_recap()
434
+ state = "on" if self.config.recap_enabled else "off"
435
+ self.pane.notice(Content.from_markup(f"[$text-muted]Idle recap: {state}[/]"))
436
+
396
437
  # ---- login --------------------------------------------------------------
397
438
 
398
439
  def _config_is_busy(self) -> bool:
@@ -456,7 +497,7 @@ class PaimonApp(App):
456
497
  return
457
498
  try:
458
499
  switched = Config.load(name)
459
- except ValueError as exc:
500
+ except (ValueError, PaimonError) as exc:
460
501
  self.pane.notice(Content.from_markup("[$text-error b]Cannot switch:[/] $body", body=str(exc)))
461
502
  self.pane._focus_input()
462
503
  return
@@ -525,6 +566,8 @@ class PaimonApp(App):
525
566
  "(auto-compaction off: unknown context window)")
526
567
  if pane._tps is not None:
527
568
  parts.append(f"{pane._tps:.0f} tokens per second")
569
+ if pane._cache_hit is not None:
570
+ parts.append(f"cache hit {pane._cache_hit:.0%}")
528
571
  return parts
529
572
 
530
573
  @work(exclusive=True, group="statusbar")
@@ -1,7 +1,10 @@
1
1
  /* Styles for PaimonApp. Loaded via PaimonApp.CSS_PATH. */
2
2
 
3
3
  Screen { background: $background; }
4
- #panes { height: 1fr; }
4
+ /* 100% rather than the default 1fr: fr widths resolve against the container
5
+ minus the widest sibling margins, and the status bar's own 0 2 margin would
6
+ otherwise shave four dead columns off the right edge of every pane. */
7
+ #panes { height: 1fr; width: 100%; }
5
8
  SessionPane { height: 1fr; margin: 1 2 0 2; }
6
9
 
7
10
  /* ---- pane tabs ---- */
@@ -46,7 +49,16 @@ Screen.-tabs-bottom #statusbar { margin: 0 2 0 2; }
46
49
  .assistant { padding: 0 1; }
47
50
  .reasoning { color: $text-disabled; text-style: italic; text-opacity: 60%; }
48
51
  .tool-result { color: $text-disabled; }
52
+ .edit-call { height: auto; }
53
+ .edit-call-diff { margin-top: 1; }
49
54
  .todos { padding: 0 1; border-left: solid $primary 45%; }
55
+ /* Unprompted, so it is the quietest block that still has a rule of its own.
56
+ The Markdown widget's own default padding and $foreground would both win
57
+ over "quiet", so both are restated here. */
58
+ .recap { padding: 0 1; border-left: solid $accent 60%; color: $text-muted; }
59
+ /* A heading that slips past the recap instructions still belongs to the muted
60
+ block, and the breathing room the log gives blocks is enough for it. */
61
+ .recap MarkdownHeader { color: $text-muted; margin: 0 0 1 0; text-style: b; }
50
62
  .tool-result.denied { color: $text; background: $error 20%; padding: 0 1; }
51
63
  .user-message {
52
64
  width: 100%;