paimon 0.2.4__tar.gz → 0.2.6__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 (48) hide show
  1. {paimon-0.2.4/paimon.egg-info → paimon-0.2.6}/PKG-INFO +104 -2
  2. {paimon-0.2.4 → paimon-0.2.6}/README.md +101 -1
  3. {paimon-0.2.4 → paimon-0.2.6}/README.zh-CN.md +101 -1
  4. {paimon-0.2.4 → paimon-0.2.6}/paimon/agent.py +124 -24
  5. paimon-0.2.6/paimon/agents.py +259 -0
  6. {paimon-0.2.4 → paimon-0.2.6}/paimon/app.py +93 -26
  7. {paimon-0.2.4 → paimon-0.2.6}/paimon/app.tcss +13 -3
  8. {paimon-0.2.4 → paimon-0.2.6}/paimon/cli.py +16 -0
  9. paimon-0.2.4/paimon/taskpane.py → paimon-0.2.6/paimon/commandpane.py +6 -6
  10. {paimon-0.2.4 → paimon-0.2.6}/paimon/compaction.py +13 -4
  11. {paimon-0.2.4 → paimon-0.2.6}/paimon/config.py +13 -0
  12. {paimon-0.2.4 → paimon-0.2.6}/paimon/headless.py +14 -7
  13. {paimon-0.2.4 → paimon-0.2.6}/paimon/jobs.py +35 -13
  14. {paimon-0.2.4 → paimon-0.2.6}/paimon/pane.py +176 -22
  15. {paimon-0.2.4 → paimon-0.2.6}/paimon/prompt.py +8 -1
  16. {paimon-0.2.4 → paimon-0.2.6}/paimon/retry.py +9 -3
  17. {paimon-0.2.4 → paimon-0.2.6}/paimon/session.py +60 -17
  18. paimon-0.2.6/paimon/skills.py +399 -0
  19. {paimon-0.2.4 → paimon-0.2.6}/paimon/supervisor.py +101 -23
  20. {paimon-0.2.4 → paimon-0.2.6}/paimon/tools.py +149 -14
  21. {paimon-0.2.4 → paimon-0.2.6}/paimon/ui.py +9 -1
  22. {paimon-0.2.4 → paimon-0.2.6/paimon.egg-info}/PKG-INFO +104 -2
  23. {paimon-0.2.4 → paimon-0.2.6}/paimon.egg-info/SOURCES.txt +3 -1
  24. {paimon-0.2.4 → paimon-0.2.6}/paimon.egg-info/requires.txt +2 -0
  25. {paimon-0.2.4 → paimon-0.2.6}/paimon.egg-info/scm_file_list.json +5 -1
  26. paimon-0.2.6/paimon.egg-info/scm_version.json +8 -0
  27. {paimon-0.2.4 → paimon-0.2.6}/pyproject.toml +2 -0
  28. paimon-0.2.4/paimon.egg-info/scm_version.json +0 -8
  29. {paimon-0.2.4 → paimon-0.2.6}/LICENSE +0 -0
  30. {paimon-0.2.4 → paimon-0.2.6}/MANIFEST.in +0 -0
  31. {paimon-0.2.4 → paimon-0.2.6}/paimon/__init__.py +0 -0
  32. {paimon-0.2.4 → paimon-0.2.6}/paimon/__main__.py +0 -0
  33. {paimon-0.2.4 → paimon-0.2.6}/paimon/aside.py +0 -0
  34. {paimon-0.2.4 → paimon-0.2.6}/paimon/commands.py +0 -0
  35. {paimon-0.2.4 → paimon-0.2.6}/paimon/diff.py +0 -0
  36. {paimon-0.2.4 → paimon-0.2.6}/paimon/errors.py +0 -0
  37. {paimon-0.2.4 → paimon-0.2.6}/paimon/llm.py +0 -0
  38. {paimon-0.2.4 → paimon-0.2.6}/paimon/lockfile.py +0 -0
  39. {paimon-0.2.4 → paimon-0.2.6}/paimon/login.py +0 -0
  40. {paimon-0.2.4 → paimon-0.2.6}/paimon/mentions.py +0 -0
  41. {paimon-0.2.4 → paimon-0.2.6}/paimon/model_windows.py +0 -0
  42. {paimon-0.2.4 → paimon-0.2.6}/paimon/skill/SKILL.md +0 -0
  43. {paimon-0.2.4 → paimon-0.2.6}/paimon/tabs.py +0 -0
  44. {paimon-0.2.4 → paimon-0.2.6}/paimon/telemetry.py +0 -0
  45. {paimon-0.2.4 → paimon-0.2.6}/paimon.egg-info/dependency_links.txt +0 -0
  46. {paimon-0.2.4 → paimon-0.2.6}/paimon.egg-info/entry_points.txt +0 -0
  47. {paimon-0.2.4 → paimon-0.2.6}/paimon.egg-info/top_level.txt +0 -0
  48. {paimon-0.2.4 → paimon-0.2.6}/setup.cfg +0 -0
@@ -1,13 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paimon
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: A minimal code agent built on pydantic-ai + textual
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
+ Requires-Dist: httpx>=0.27
8
9
  Requires-Dist: pydantic-ai-slim[anthropic,openai]>=2.31.0
9
10
  Requires-Dist: textual>=8.2.7
10
11
  Requires-Dist: textual-serve>=1.1.3
12
+ Requires-Dist: pyyaml>=6
11
13
  Dynamic: license-file
12
14
 
13
15
  # Paimon
@@ -38,10 +40,14 @@ uvx paimon
38
40
 
39
41
  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
42
 
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.
43
+ 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. A line starting with `!` runs in a shell instead of being sent, and Paimon sees what it printed. `!!` keeps it to yourself.
42
44
 
43
45
  `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
46
 
47
+ ## Skills
48
+
49
+ Paimon loads [Agent Skills](https://agentskills.io) from `~/.config/paimon/skills`, `~/.agents/skills` and every `.agents/skills` from the working directory up to the repository root. Only each skill's name and description go into the system prompt; the model reads the `SKILL.md` when a task matches, and `/skill:name args` sends it explicitly (the `/` command palette lists them). More locations go in `config.json` as `"skills": ["~/.claude/skills"]` or on the command line with `--skill PATH`; `--no-skills` skips the default locations. When two skills share a name, explicit paths beat the project's, which beat the global ones.
50
+
45
51
  ## Using Paimon as a subagent
46
52
 
47
53
  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:
@@ -111,6 +117,102 @@ Each profile keeps its model settings in `~/.config/paimon/<name>/config.json`,
111
117
 
112
118
  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.
113
119
 
120
+ ## Architecture
121
+
122
+ `Agent.run` is a UI-agnostic stream of events; the TUI, `--web` and headless mode are three renderers over that one stream. `Supervisor` sits between an agent and the subagents or background commands it starts, and is the permission boundary for both.
123
+
124
+ ```mermaid
125
+ flowchart TD
126
+ subgraph entry["Entry points"]
127
+ CLI["cli.py"]
128
+ Commands["commands.py<br/>status / login / sessions"]
129
+ Headless["headless.py<br/>-p, one-shot"]
130
+ App["app.py<br/>Textual TUI / --web"]
131
+ end
132
+
133
+ subgraph tui["TUI widgets"]
134
+ Pane["pane.py<br/>SessionPane"]
135
+ CommandPane["commandpane.py<br/>background command pane"]
136
+ Tabs["tabs.py<br/>pane strip"]
137
+ Login["login.py<br/>provider / model / key"]
138
+ UIWidgets["ui.py<br/>prompt input, confirmations"]
139
+ Diff["diff.py<br/>side-by-side diff rendering"]
140
+ end
141
+
142
+ subgraph core["Agent loop"]
143
+ AgentLoop["agent.py<br/>Agent.run()"]
144
+ LLM["llm.py<br/>build_model()"]
145
+ PromptMod["prompt.py<br/>system prompt"]
146
+ ToolsMod["tools.py<br/>tool REGISTRY"]
147
+ SessionMod["session.py<br/>JSONL persistence"]
148
+ Compaction["compaction.py"]
149
+ ModelWindows["model_windows.py<br/>context window sizes"]
150
+ Retry["retry.py"]
151
+ Mentions["mentions.py<br/>@path expansion"]
152
+ Aside["aside.py<br/>off-turn question, unrecorded"]
153
+ end
154
+
155
+ subgraph concurrency["Jobs & subagents"]
156
+ Supervisor["supervisor.py<br/>job pool, permissions"]
157
+ Jobs["jobs.py<br/>AgentJob / CommandJob"]
158
+ AgentTypes["agents.py<br/>subagent types"]
159
+ end
160
+
161
+ subgraph support["Config & skills"]
162
+ Config["config.py<br/>profiles, credentials"]
163
+ Skills["skills.py<br/>Agent Skills discovery"]
164
+ end
165
+
166
+ CLI --> Commands
167
+ CLI --> Headless
168
+ CLI --> App
169
+ CLI --> Config
170
+
171
+ Headless --> AgentLoop
172
+ Headless --> Mentions
173
+
174
+ App --> Pane
175
+ App --> CommandPane
176
+ App --> Tabs
177
+ App --> Login
178
+ App --> Supervisor
179
+ App --> Config
180
+
181
+ Pane --> AgentLoop
182
+ Pane --> Aside
183
+ Pane --> Diff
184
+ Pane --> UIWidgets
185
+ Pane --> LLM
186
+ CommandPane --> Pane
187
+ UIWidgets --> Diff
188
+
189
+ AgentLoop --> LLM
190
+ AgentLoop --> PromptMod
191
+ AgentLoop --> ToolsMod
192
+ AgentLoop --> SessionMod
193
+ AgentLoop --> Compaction
194
+ AgentLoop --> Retry
195
+ AgentLoop --> Mentions
196
+ AgentLoop -. "spawn_agent / run_background" .-> Supervisor
197
+
198
+ Aside --> Retry
199
+ Aside --> SessionMod
200
+
201
+ PromptMod --> Skills
202
+ Compaction --> ModelWindows
203
+
204
+ Supervisor --> Jobs
205
+ Supervisor --> AgentTypes
206
+ Supervisor --> ToolsMod
207
+ Supervisor -. "launch callback" .-> App
208
+ Jobs --> AgentLoop
209
+
210
+ AgentTypes --> ToolsMod
211
+ AgentTypes --> Config
212
+ Skills --> Config
213
+ Config --> LLM
214
+ ```
215
+
114
216
  ## Telemetry
115
217
 
116
218
  Each launch sends one anonymous event to Google Analytics: a random install id, the launch mode, the version, the OS name and the configured provider and model name. Nothing from your sessions, prompts, files or credentials is included. Set `PAIMON_NO_TELEMETRY=1` or `DO_NOT_TRACK=1` to turn it off.
@@ -26,10 +26,14 @@ uvx paimon
26
26
 
27
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
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.
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. A line starting with `!` runs in a shell instead of being sent, and Paimon sees what it printed. `!!` keeps it to yourself.
30
30
 
31
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
32
 
33
+ ## Skills
34
+
35
+ Paimon loads [Agent Skills](https://agentskills.io) from `~/.config/paimon/skills`, `~/.agents/skills` and every `.agents/skills` from the working directory up to the repository root. Only each skill's name and description go into the system prompt; the model reads the `SKILL.md` when a task matches, and `/skill:name args` sends it explicitly (the `/` command palette lists them). More locations go in `config.json` as `"skills": ["~/.claude/skills"]` or on the command line with `--skill PATH`; `--no-skills` skips the default locations. When two skills share a name, explicit paths beat the project's, which beat the global ones.
36
+
33
37
  ## Using Paimon as a subagent
34
38
 
35
39
  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:
@@ -99,6 +103,102 @@ Each profile keeps its model settings in `~/.config/paimon/<name>/config.json`,
99
103
 
100
104
  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.
101
105
 
106
+ ## Architecture
107
+
108
+ `Agent.run` is a UI-agnostic stream of events; the TUI, `--web` and headless mode are three renderers over that one stream. `Supervisor` sits between an agent and the subagents or background commands it starts, and is the permission boundary for both.
109
+
110
+ ```mermaid
111
+ flowchart TD
112
+ subgraph entry["Entry points"]
113
+ CLI["cli.py"]
114
+ Commands["commands.py<br/>status / login / sessions"]
115
+ Headless["headless.py<br/>-p, one-shot"]
116
+ App["app.py<br/>Textual TUI / --web"]
117
+ end
118
+
119
+ subgraph tui["TUI widgets"]
120
+ Pane["pane.py<br/>SessionPane"]
121
+ CommandPane["commandpane.py<br/>background command pane"]
122
+ Tabs["tabs.py<br/>pane strip"]
123
+ Login["login.py<br/>provider / model / key"]
124
+ UIWidgets["ui.py<br/>prompt input, confirmations"]
125
+ Diff["diff.py<br/>side-by-side diff rendering"]
126
+ end
127
+
128
+ subgraph core["Agent loop"]
129
+ AgentLoop["agent.py<br/>Agent.run()"]
130
+ LLM["llm.py<br/>build_model()"]
131
+ PromptMod["prompt.py<br/>system prompt"]
132
+ ToolsMod["tools.py<br/>tool REGISTRY"]
133
+ SessionMod["session.py<br/>JSONL persistence"]
134
+ Compaction["compaction.py"]
135
+ ModelWindows["model_windows.py<br/>context window sizes"]
136
+ Retry["retry.py"]
137
+ Mentions["mentions.py<br/>@path expansion"]
138
+ Aside["aside.py<br/>off-turn question, unrecorded"]
139
+ end
140
+
141
+ subgraph concurrency["Jobs & subagents"]
142
+ Supervisor["supervisor.py<br/>job pool, permissions"]
143
+ Jobs["jobs.py<br/>AgentJob / CommandJob"]
144
+ AgentTypes["agents.py<br/>subagent types"]
145
+ end
146
+
147
+ subgraph support["Config & skills"]
148
+ Config["config.py<br/>profiles, credentials"]
149
+ Skills["skills.py<br/>Agent Skills discovery"]
150
+ end
151
+
152
+ CLI --> Commands
153
+ CLI --> Headless
154
+ CLI --> App
155
+ CLI --> Config
156
+
157
+ Headless --> AgentLoop
158
+ Headless --> Mentions
159
+
160
+ App --> Pane
161
+ App --> CommandPane
162
+ App --> Tabs
163
+ App --> Login
164
+ App --> Supervisor
165
+ App --> Config
166
+
167
+ Pane --> AgentLoop
168
+ Pane --> Aside
169
+ Pane --> Diff
170
+ Pane --> UIWidgets
171
+ Pane --> LLM
172
+ CommandPane --> Pane
173
+ UIWidgets --> Diff
174
+
175
+ AgentLoop --> LLM
176
+ AgentLoop --> PromptMod
177
+ AgentLoop --> ToolsMod
178
+ AgentLoop --> SessionMod
179
+ AgentLoop --> Compaction
180
+ AgentLoop --> Retry
181
+ AgentLoop --> Mentions
182
+ AgentLoop -. "spawn_agent / run_background" .-> Supervisor
183
+
184
+ Aside --> Retry
185
+ Aside --> SessionMod
186
+
187
+ PromptMod --> Skills
188
+ Compaction --> ModelWindows
189
+
190
+ Supervisor --> Jobs
191
+ Supervisor --> AgentTypes
192
+ Supervisor --> ToolsMod
193
+ Supervisor -. "launch callback" .-> App
194
+ Jobs --> AgentLoop
195
+
196
+ AgentTypes --> ToolsMod
197
+ AgentTypes --> Config
198
+ Skills --> Config
199
+ Config --> LLM
200
+ ```
201
+
102
202
  ## Telemetry
103
203
 
104
204
  Each launch sends one anonymous event to Google Analytics: a random install id, the launch mode, the version, the OS name and the configured provider and model name. Nothing from your sessions, prompts, files or credentials is included. Set `PAIMON_NO_TELEMETRY=1` or `DO_NOT_TRACK=1` to turn it off.
@@ -26,10 +26,14 @@ uvx paimon
26
26
 
27
27
  首次启动会询问 provider、模型、API base 和 key。之后输入要完成的任务即可。在提示中写 `@path/to/file` 可以把文件提供给 agent。
28
28
 
29
- 运行时:`Shift+Tab` 切换 agent 的自主程度(**read** 写文件或执行命令前先询问,**edit** 放行工作目录内的编辑,**yolo** 从不询问,也是默认值),`Esc` 打断当前回合,`Ctrl+P` 打开命令面板,`Ctrl+C` 退出。
29
+ 运行时:`Shift+Tab` 切换 agent 的自主程度(**read** 写文件或执行命令前先询问,**edit** 放行工作目录内的编辑,**yolo** 从不询问,也是默认值),`Esc` 打断当前回合,`Ctrl+P` 打开命令面板,`Ctrl+C` 退出。以 `!` 开头的一行不发给模型,而是直接在 shell 里执行,输出 Paimon 也能看到。`!!` 则不告诉它。
30
30
 
31
31
  `Ctrl+T` 在新 pane 里打开另一个会话,`Ctrl+W` 关闭当前 pane,`Ctrl+PageUp` 和 `Ctrl+PageDown` 在 pane 之间切换,`Ctrl+G` 跳到正在等待授权的 pane。Paimon 自己也能开 pane:让它同时做两件互不相干的事,它会在新 tab 里起第二个 agent。它也能把一条命令留在单独的 tab 里跑,比如开发服务器或者文件监视,不占着当前回合。
32
32
 
33
+ ## Skills
34
+
35
+ Paimon 会从 `~/.config/paimon/skills`、`~/.agents/skills` 以及工作目录到仓库根之间每一层的 `.agents/skills` 加载 [Agent Skills](https://agentskills.io)。system prompt 里只放每个 skill 的名字和描述,任务匹配时模型自己去读 `SKILL.md`,也可以用 `/skill:name 参数` 显式发送(`/` 命令面板里列出了它们)。其他位置可以写进 `config.json` 的 `"skills": ["~/.claude/skills"]`,或者用命令行参数 `--skill PATH`;`--no-skills` 跳过默认位置。同名时显式指定的优先于项目的,项目的优先于全局的。
36
+
33
37
  ## 当作 subagent 使用
34
38
 
35
39
  前沿模型擅长制定计划和验收结果,中间的执行步骤往往比较机械。让 Paimon 使用成本较低的模型执行,由 Claude Code 或 Codex 制定计划并检查结果。用一个 profile 单独保存该模型的账号:
@@ -99,6 +103,102 @@ paimon --profile work # 单独配置的另一个账号
99
103
 
100
104
  read 和 edit 模式会不经询问执行一小组明确只读的命令(`ls`、`cat`、`git status` 等),`--strict` 可以关掉。**这是防止 agent 失误的护栏,不是安全边界。** 需要真正的隔离时,请在容器或虚拟机中运行 Paimon。
101
105
 
106
+ ## 架构
107
+
108
+ `Agent.run` 产出一串与 UI 无关的事件流,TUI、`--web` 和无头模式都是这条事件流的渲染器。`Supervisor` 位于 agent 和它启动的子 agent、后台命令之间,是两者共同的权限边界。
109
+
110
+ ```mermaid
111
+ flowchart TD
112
+ subgraph entry["入口"]
113
+ CLI["cli.py"]
114
+ Commands["commands.py<br/>status / login / sessions"]
115
+ Headless["headless.py<br/>-p,单次运行"]
116
+ App["app.py<br/>Textual TUI / --web"]
117
+ end
118
+
119
+ subgraph tui["TUI 组件"]
120
+ Pane["pane.py<br/>SessionPane"]
121
+ CommandPane["commandpane.py<br/>后台命令 pane"]
122
+ Tabs["tabs.py<br/>pane 标签栏"]
123
+ Login["login.py<br/>provider / 模型 / key"]
124
+ UIWidgets["ui.py<br/>输入框、确认面板"]
125
+ Diff["diff.py<br/>并排 diff 渲染"]
126
+ end
127
+
128
+ subgraph core["Agent 主循环"]
129
+ AgentLoop["agent.py<br/>Agent.run()"]
130
+ LLM["llm.py<br/>build_model()"]
131
+ PromptMod["prompt.py<br/>system prompt"]
132
+ ToolsMod["tools.py<br/>工具注册表"]
133
+ SessionMod["session.py<br/>JSONL 持久化"]
134
+ Compaction["compaction.py"]
135
+ ModelWindows["model_windows.py<br/>各模型上下文窗口大小"]
136
+ Retry["retry.py"]
137
+ Mentions["mentions.py<br/>@path 展开"]
138
+ Aside["aside.py<br/>回合外提问,不落盘"]
139
+ end
140
+
141
+ subgraph concurrency["任务与子 agent"]
142
+ Supervisor["supervisor.py<br/>任务池、权限"]
143
+ Jobs["jobs.py<br/>AgentJob / CommandJob"]
144
+ AgentTypes["agents.py<br/>子 agent 类型"]
145
+ end
146
+
147
+ subgraph support["配置与 skills"]
148
+ Config["config.py<br/>profile、凭证"]
149
+ Skills["skills.py<br/>Agent Skills 发现"]
150
+ end
151
+
152
+ CLI --> Commands
153
+ CLI --> Headless
154
+ CLI --> App
155
+ CLI --> Config
156
+
157
+ Headless --> AgentLoop
158
+ Headless --> Mentions
159
+
160
+ App --> Pane
161
+ App --> CommandPane
162
+ App --> Tabs
163
+ App --> Login
164
+ App --> Supervisor
165
+ App --> Config
166
+
167
+ Pane --> AgentLoop
168
+ Pane --> Aside
169
+ Pane --> Diff
170
+ Pane --> UIWidgets
171
+ Pane --> LLM
172
+ CommandPane --> Pane
173
+ UIWidgets --> Diff
174
+
175
+ AgentLoop --> LLM
176
+ AgentLoop --> PromptMod
177
+ AgentLoop --> ToolsMod
178
+ AgentLoop --> SessionMod
179
+ AgentLoop --> Compaction
180
+ AgentLoop --> Retry
181
+ AgentLoop --> Mentions
182
+ AgentLoop -. "spawn_agent / run_background" .-> Supervisor
183
+
184
+ Aside --> Retry
185
+ Aside --> SessionMod
186
+
187
+ PromptMod --> Skills
188
+ Compaction --> ModelWindows
189
+
190
+ Supervisor --> Jobs
191
+ Supervisor --> AgentTypes
192
+ Supervisor --> ToolsMod
193
+ Supervisor -. "启动回调" .-> App
194
+ Jobs --> AgentLoop
195
+
196
+ AgentTypes --> ToolsMod
197
+ AgentTypes --> Config
198
+ Skills --> Config
199
+ Config --> LLM
200
+ ```
201
+
102
202
  ## 遥测
103
203
 
104
204
  每次启动会向 Google Analytics 发送一条匿名事件:随机生成的安装 id、启动方式、版本号、操作系统名称,以及配置的 provider 和模型名。会话、提示词、文件和密钥一概不上报。设置 `PAIMON_NO_TELEMETRY=1` 或 `DO_NOT_TRACK=1` 可以关闭。
@@ -10,7 +10,7 @@ import json
10
10
  import time
11
11
  from dataclasses import dataclass
12
12
  from pathlib import Path
13
- from typing import AsyncIterator, Callable, Optional
13
+ from typing import AsyncIterator, Callable, Optional, Sequence
14
14
 
15
15
  from pydantic_ai.direct import model_request_stream
16
16
  from pydantic_ai.messages import (
@@ -30,18 +30,22 @@ from pydantic_ai.messages import (
30
30
  )
31
31
  from pydantic_ai.models import Model, ModelRequestParameters
32
32
 
33
- from . import aside, compaction, retry, tools
33
+ from . import agents, aside, compaction, retry, tools
34
34
  from .config import Config
35
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
+ from .skills import Skill, SkillDiagnostic, discover_skills, expand_skill_command
38
39
  from .session import (
39
40
  Session,
40
41
  SessionIncompleteError,
41
42
  agents_message,
42
43
  agents_text,
43
44
  is_agents_message,
45
+ is_shell_message,
44
46
  is_summary_message,
47
+ shell_message,
48
+ shell_text,
45
49
  )
46
50
 
47
51
  # Transient compaction failures tolerated in one turn before it is left off.
@@ -166,13 +170,26 @@ class AgentsNotice:
166
170
  text: str
167
171
 
168
172
 
173
+ @dataclass
174
+ class ShellRun:
175
+ """A command the user ran themselves with the "!" prefix.
176
+
177
+ Like AgentsNotice this is not replay-only: the run is persisted as a
178
+ synthetic user message, so it is both yielded when it happens and rebuilt
179
+ when the session is resumed.
180
+ """
181
+
182
+ command: str
183
+ output: str
184
+
185
+
169
186
  # Everything ``Agent.run`` and ``replay_events`` can yield. Renderers dispatch
170
187
  # on isinstance; the alias exists so a type checker can flag an unhandled one.
171
188
  AgentEvent = (
172
189
  TextDelta | ReasoningDelta | ToolStart | ToolEnd | TodosUpdate
173
190
  | SessionHandoff | RequestStats | ToolBudgetExhausted | TurnEnd
174
191
  | ContextCompacted | ContextCompactionFailed | ModelRetry | UserInput
175
- | CompactionNotice | AgentsNotice
192
+ | CompactionNotice | AgentsNotice | ShellRun
176
193
  )
177
194
 
178
195
 
@@ -207,6 +224,9 @@ def replay_events(messages: list[ModelMessage]) -> list[AgentEvent]:
207
224
  if is_agents_message(message):
208
225
  events.append(AgentsNotice(agents_text(message)))
209
226
  continue
227
+ if is_shell_message(message):
228
+ events.append(ShellRun(*shell_text(message)))
229
+ continue
210
230
  if isinstance(message, ModelRequest):
211
231
  for part in message.parts:
212
232
  if isinstance(part, UserPromptPart) and isinstance(part.content, str) and part.content:
@@ -287,10 +307,13 @@ def _strip_foreign_thinking(history: list[ModelMessage], model: Model) -> list[M
287
307
  # Re-exported so UI code can keep importing it from here.
288
308
  ConfirmFn = tools.ConfirmFn
289
309
 
290
- # Takes the messages a user queued while a turn was already running, clearing
291
- # the queue as it goes. The loop calls it before every model request, so what
292
- # it returns reaches the model at the next step rather than the next turn.
293
- PendingFn = Callable[[], list[str]]
310
+ # Takes what the user queued while a turn was already running, clearing the
311
+ # queue as it goes. The loop calls it before every model request, so what it
312
+ # returns reaches the model at the next step rather than the next turn. A
313
+ # plain string is a typed prompt and goes through the usual expansion; a
314
+ # ready-made request is one paimon assembled (a "!" shell run) and is appended
315
+ # verbatim, since @path in a command's output is output, not a mention.
316
+ PendingFn = Callable[[], list[str | ModelRequest]]
294
317
 
295
318
 
296
319
  class Agent:
@@ -312,8 +335,20 @@ class Agent:
312
335
  confirm: Optional[ConfirmFn] = None, mode: str = "yolo",
313
336
  config: Optional[Config] = None,
314
337
  toolset: Optional[dict[str, tools.Tool]] = None,
315
- model_override: Optional[str] = None):
338
+ model_override: Optional[str] = None,
339
+ skills: Sequence[Skill] = (),
340
+ skill_diagnostics: Sequence[SkillDiagnostic] = (),
341
+ agent_types: Sequence[agents.AgentType] = (),
342
+ agent_type_diagnostics: Sequence[SkillDiagnostic] = ()):
316
343
  self.cwd = Path(cwd or Path.cwd())
344
+ # The skills listed in the system prompt and expandable by /skill:name,
345
+ # with whatever discovery had to complain about (for the UI to show).
346
+ self.skills = list(skills)
347
+ self.skill_diagnostics = list(skill_diagnostics)
348
+ # The agent types spawn_agent may name, discovered like skills; the
349
+ # launcher reads them off whoever called spawn_agent.
350
+ self.agent_types = list(agent_types)
351
+ self.agent_type_diagnostics = list(agent_type_diagnostics)
317
352
  self.confirm = confirm
318
353
  self.mode = mode
319
354
  self.config = config or Config.load()
@@ -339,6 +374,12 @@ class Agent:
339
374
  self.history: list[ModelMessage] = session.messages()
340
375
  # This agent's tool set; None means everything in tools.REGISTRY.
341
376
  self.toolset = dict(tools.REGISTRY if toolset is None else toolset)
377
+ # The registry's spawn_agent knows no types; this agent's copy lists
378
+ # the ones it discovered, so the schema below advertises them. A
379
+ # toolset without spawn_agent (subagents, headless) skips it whole.
380
+ if "spawn_agent" in self.toolset and self.agent_types:
381
+ self.toolset["spawn_agent"] = agents.spawn_tool_with_types(
382
+ self.toolset["spawn_agent"], self.agent_types)
342
383
  self.tool_schemas = tools.schemas(self.toolset)
343
384
  self._tool_definitions = tools.definitions(self.toolset)
344
385
  self._cached_model: Optional[tuple[tuple, Model]] = None
@@ -356,19 +397,23 @@ class Agent:
356
397
  append_system_prompt: Optional[str] = None,
357
398
  toolset: Optional[dict[str, tools.Tool]] = None,
358
399
  model_override: Optional[str] = None,
359
- parent: Optional[str] = None) -> "Agent":
400
+ parent_session_id: Optional[str] = None,
401
+ agent_type: Optional[str] = None) -> "Agent":
360
402
  """Start a new session, or resume ``session``, and take its lock.
361
403
 
362
404
  ``append_system_prompt`` is added to the end of a new session's system
363
405
  prompt and persisted with it, so a resumed session keeps it. Resuming
364
406
  with it set raises ``ValueError``: the persisted role is immutable.
365
- The dynamic parts of the prompt (date, environment, AGENTS.md) are
366
- rebuilt on resume — a session picked up months later must not keep
367
- telling the model the old date or stale project rules — and when they
368
- changed, the rebuilt snapshot is appended to the log, so it always
369
- records the prompt each turn actually ran with.
370
- ``parent`` marks the new session as a subagent's, which keeps it out of
371
- the session listings its parent shows up in.
407
+ The dynamic parts of the prompt (date, environment, AGENTS.md, the
408
+ skill list) are rebuilt on resume — a session picked up months later
409
+ must not keep telling the model the old date or stale project rules —
410
+ and when they changed, the rebuilt snapshot is appended to the log, so
411
+ it always records the prompt each turn actually ran with.
412
+ ``parent_session_id`` marks the new session as a subagent's, which
413
+ keeps it out of the session listings its parent shows up in;
414
+ ``agent_type`` records what type it was spawned as, so resuming the
415
+ session can restore the type's narrowing. Both apply to new sessions
416
+ only and are ignored on resume, where the header already says.
372
417
 
373
418
  Raises ``SessionBusyError`` when the session is already open (here or
374
419
  in another process) and ``SessionIncompleteError`` when a resumed log
@@ -378,9 +423,14 @@ class Agent:
378
423
  cwd = Path(cwd or Path.cwd())
379
424
  if session is not None and append_system_prompt:
380
425
  raise ValueError("append_system_prompt only applies to a new session")
426
+ config = config or Config.load()
427
+ skills, skill_diagnostics = discover_skills(
428
+ cwd, extra_paths=config.skills, include_defaults=config.include_default_skills)
429
+ agent_types, agent_type_diagnostics = agents.discover_agent_types(
430
+ cwd, extra_paths=config.agents, include_defaults=config.include_default_agents)
381
431
  is_new = session is None
382
432
  if session is None:
383
- session = Session.create(cwd, parent)
433
+ session = Session.create(cwd, parent_session_id, agent_type)
384
434
  session.lock()
385
435
  # Everything after the lock can fail — a full disk while writing the
386
436
  # prompt, a message the current pydantic-ai cannot parse — and no
@@ -388,7 +438,7 @@ class Agent:
388
438
  try:
389
439
  if is_new:
390
440
  appended = append_system_prompt.strip() if append_system_prompt else None
391
- system_prompt = build_system_prompt(cwd)
441
+ system_prompt = build_system_prompt(cwd, skills)
392
442
  if appended:
393
443
  system_prompt += f"\n\n{appended}"
394
444
  session.append_system_prompt(system_prompt, appended=appended)
@@ -397,13 +447,16 @@ class Agent:
397
447
  stored, appended = session.system_prompt_parts()
398
448
  if stored is None:
399
449
  raise SessionIncompleteError("Session does not contain a persisted system prompt")
400
- system_prompt = build_system_prompt(cwd)
450
+ system_prompt = build_system_prompt(cwd, skills)
401
451
  if appended:
402
452
  system_prompt += f"\n\n{appended}"
403
453
  if system_prompt != stored:
404
454
  session.append_system_prompt(system_prompt, appended=appended)
405
455
  return cls(session, system_prompt, cwd=cwd, confirm=confirm, mode=mode,
406
- config=config, toolset=toolset, model_override=model_override)
456
+ config=config, toolset=toolset, model_override=model_override,
457
+ skills=skills, skill_diagnostics=skill_diagnostics,
458
+ agent_types=agent_types,
459
+ agent_type_diagnostics=agent_type_diagnostics)
407
460
  except BaseException:
408
461
  session.unlock()
409
462
  raise
@@ -554,6 +607,27 @@ class Agent:
554
607
  max_tokens=max_tokens):
555
608
  yield delta
556
609
 
610
+ # ---- Commands the user runs themselves ---------------------------------
611
+
612
+ async def run_shell(self, command: str) -> str:
613
+ """Run a shell command on the user's behalf and return what it printed.
614
+
615
+ The TUI's "!" prefix. No permission gate: the gate exists so the model
616
+ cannot run something the user did not ask for, and here the user *is*
617
+ the one asking. Nothing is recorded — the caller decides whether the
618
+ run reaches the model now or at the running turn's next step.
619
+ """
620
+ return await tools.user_shell(command, self.cwd, self.tool_context)
621
+
622
+ def record_shell(self, command: str, output: str) -> None:
623
+ """Persist a "!" run so the model sees what the user just did.
624
+
625
+ Only between turns. While one is running the message belongs in the
626
+ pending queue instead, which the loop drains between steps — appended
627
+ here it would land between a tool call and its result.
628
+ """
629
+ self._append_message(shell_message(command, output))
630
+
557
631
  # ---- Tools the agent loop runs itself ----------------------------------
558
632
  # These mutate agent-held state or end the turn, so they cannot go through
559
633
  # the stateless tools.run_tool. Each handler fills ``slot`` with the tool
@@ -659,10 +733,25 @@ class Agent:
659
733
  "stop_job": _run_supervised,
660
734
  }
661
735
 
662
- async def run(self, user_input: str, *, expand: bool = True,
736
+ def expand_input(self, text: str) -> str:
737
+ """What a typed message becomes: a /skill:name command expands to the
738
+ skill's content, then @path mentions are inlined."""
739
+ def mentions(part: str) -> str:
740
+ return expand_mentions(part, self.cwd)
741
+
742
+ expanded = expand_skill_command(text, self.skills, expand_args=mentions)
743
+ # Unchanged (the same object back) means it was not a skill command.
744
+ return mentions(text) if expanded is text else expanded
745
+
746
+ async def run(self, user_input: Optional[str], *, expand: bool = True,
663
747
  max_tool_calls: Optional[int] = None) -> AsyncIterator[AgentEvent]:
664
748
  """Run one user turn to completion, yielding events along the way.
665
749
 
750
+ ``user_input`` may be None: a wake-up turn, run so the model reacts to
751
+ agents that finished — the status line injected below is its only new
752
+ input. When there is nothing to report either, the turn never happened:
753
+ no events, nothing persisted, no model request.
754
+
666
755
  ``expand=False`` skips @path expansion, for callers that assembled the
667
756
  prompt themselves and must not have unrelated text rewritten (piped
668
757
  stdin, where a line like ``@foo.py`` is data rather than a mention).
@@ -674,16 +763,19 @@ class Agent:
674
763
  an explicit refusal persisted as its result, ToolBudgetExhausted is
675
764
  yielded and the turn ends.
676
765
  """
677
- prompt = expand_mentions(user_input, self.cwd) if expand else user_input
678
766
  # Agents this session started report in here, at the top of the next
679
767
  # turn, rather than by interrupting whatever the user is typing. It has
680
768
  # to be a persisted message: an event the model never sees would defeat
681
769
  # the point, which is to get it to call read_job.
682
770
  summary = self.supervisor.status_summary(self) if self.supervisor is not None else None
771
+ if user_input is None and not summary:
772
+ return
683
773
  if summary:
684
774
  self._append_message(agents_message(summary))
685
775
  yield AgentsNotice(summary)
686
- self._append_message(ModelRequest(parts=[UserPromptPart(content=prompt)]))
776
+ if user_input is not None:
777
+ prompt = self.expand_input(user_input) if expand else user_input
778
+ self._append_message(ModelRequest(parts=[UserPromptPart(content=prompt)]))
687
779
 
688
780
  # Every turn leaves exactly one terminal ``turn_end`` record in the
689
781
  # session: success, error (with the exception), max_tool_calls or
@@ -742,8 +834,16 @@ class Agent:
742
834
  # results already recorded. Before compaction, so they count
743
835
  # towards the context check and survive a history replacement.
744
836
  for queued in (self.pending() if self.pending is not None else []):
837
+ # A ready-made request is one the caller already showed the
838
+ # user (a "!" run, whose output was on screen the moment the
839
+ # command exited), so it is appended without an event of its
840
+ # own — and without expansion, since @path in a command's
841
+ # output is output rather than a mention.
842
+ if isinstance(queued, ModelRequest):
843
+ self._append_message(queued)
844
+ continue
745
845
  self._append_message(ModelRequest(
746
- parts=[UserPromptPart(content=expand_mentions(queued, self.cwd))]))
846
+ parts=[UserPromptPart(content=self.expand_input(queued))]))
747
847
  yield UserInput(queued)
748
848
 
749
849
  if not compaction_off: