@raidou/pi-pm-subagents 0.1.1
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.
- package/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
package/.prettierrc
ADDED
package/AGENTS.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- use `pnpm test` to check and fix after edit, always test types, lint, and unit (if have)
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# pi-pm-subagents
|
|
2
|
+
|
|
3
|
+
pi-pm-subagents extension for [pi](https://pi.dev):
|
|
4
|
+
|
|
5
|
+
a **coordinator** mode that drives subagents.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install @raidou/pi-pm-subagents
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Enter Coordinator mode
|
|
16
|
+
|
|
17
|
+
`/pm` or `/coordinator`
|
|
18
|
+
|
|
19
|
+
or enable it as the default:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/pm-default or /coordinator-default
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then the agent will delegate some subagents to work and supervise them.
|
|
26
|
+
|
|
27
|
+
### Built-in agent roles
|
|
28
|
+
|
|
29
|
+
- explorer
|
|
30
|
+
- researcher
|
|
31
|
+
- reviewer
|
|
32
|
+
- planner
|
|
33
|
+
- worker (default built-in role)
|
|
34
|
+
|
|
35
|
+
**/plan <request>** delegates a planner subagent (in coordinator mode) that explores the code and produces a plan.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
### Prompts
|
|
40
|
+
|
|
41
|
+
**Coordinator**
|
|
42
|
+
|
|
43
|
+
`~/.pi/agent/pm-subagents-prompts/coordinator.md`
|
|
44
|
+
`~/.pi/agent/pm-subagents-prompts/coordinator-append.md`
|
|
45
|
+
|
|
46
|
+
**Roles**
|
|
47
|
+
|
|
48
|
+
Load precedence (later overrides earlier):
|
|
49
|
+
|
|
50
|
+
1. Built-in `worker`
|
|
51
|
+
2. Plugin `agents/` directory
|
|
52
|
+
3. Global `~/.pi/agent/agents`
|
|
53
|
+
4. Project `<cwd>/.pi/agents`
|
|
54
|
+
|
|
55
|
+
Set `"skipPluginAgents": true` in `pi-pm-subagents.json` to skip loading the built-in roles (including `planner`).
|
|
56
|
+
|
|
57
|
+
### Options in `pi-pm-subagents.json`
|
|
58
|
+
|
|
59
|
+
- `"subagentModel": "<model>"` — default subagent model
|
|
60
|
+
- `"subagentModelScoped": ["<model>", ...]` — subagent scoped model pool
|
|
61
|
+
- `"defaultMode": "coordinator"` — enter coordinator mode on startup (persistent equivalent of `/pm-default`)
|
|
62
|
+
|
|
63
|
+
### Model command
|
|
64
|
+
|
|
65
|
+
**Model priority (spawn time):** `role.model > session subagent model (initial value from pi-pm-subagents.json, can be overridden via /pm-subagent-model or Alt+N) > ctx.model`
|
|
66
|
+
|
|
67
|
+
- **`/pm-subagent-model`** — Change subagent model
|
|
68
|
+
- **`/pm-subagent-scoped`** — Manage the subagent scoped model
|
|
69
|
+
- `Alt+N` Cycle pool of models. Can include `DEFAULT` (use pi default model) marker.
|
|
70
|
+
|
|
71
|
+
## Note
|
|
72
|
+
|
|
73
|
+
**Terminal/tmux compatibility note**:
|
|
74
|
+
|
|
75
|
+
> `Alt+N` requires terminal support for Kitty keyboard protocol,
|
|
76
|
+
> or enable `set -g extended-keys on` in tmux.
|
|
77
|
+
> In standard xterm and similar terminals, `Alt+letter` is sent as an ESC sequence and may be unreliable.
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
PI_DEMO=1 pi
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`/subagent-demo` is a UI fixture for development. It is only registered when pi is launched with `PI_DEMO=1`.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# pi-pm-subagents
|
|
2
|
+
|
|
3
|
+
[pi](https://pi.dev) 的 pi-pm-subagents 扩展:
|
|
4
|
+
|
|
5
|
+
提供一个驱动子代理(subagents)的**协调者(coordinator)**模式。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install @raidou/pi-pm-subagents
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 用法
|
|
14
|
+
|
|
15
|
+
进入协调者模式
|
|
16
|
+
|
|
17
|
+
`/pm` or `/coordinator`
|
|
18
|
+
|
|
19
|
+
或将其设为默认模式:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/pm-default or /coordinator-default
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
之后 agent 会派出若干子代理去工作并进行监督。
|
|
26
|
+
|
|
27
|
+
### 内置代理角色
|
|
28
|
+
|
|
29
|
+
- explorer
|
|
30
|
+
- researcher
|
|
31
|
+
- reviewer
|
|
32
|
+
- planner
|
|
33
|
+
- worker(默认内置角色)
|
|
34
|
+
|
|
35
|
+
**/plan <request>** 会(在协调者模式下)派出一个 planner 子代理,探索代码并产出计划。
|
|
36
|
+
|
|
37
|
+
## 配置
|
|
38
|
+
|
|
39
|
+
### 提示词(Prompts)
|
|
40
|
+
|
|
41
|
+
**Coordinator**
|
|
42
|
+
|
|
43
|
+
`~/.pi/agent/pm-subagents-prompts/coordinator.md`
|
|
44
|
+
`~/.pi/agent/pm-subagents-prompts/coordinator-append.md`
|
|
45
|
+
|
|
46
|
+
**Roles**
|
|
47
|
+
|
|
48
|
+
加载优先级(后者覆盖前者):
|
|
49
|
+
|
|
50
|
+
1. 内置 `worker`
|
|
51
|
+
2. 插件 `agents/` 目录
|
|
52
|
+
3. 全局 `~/.pi/agent/agents`
|
|
53
|
+
4. 项目 `<cwd>/.pi/agents`
|
|
54
|
+
|
|
55
|
+
在 `pi-pm-subagents.json` 中设置 `"skipPluginAgents": true` 可跳过加载内置角色(包括 `planner`)。
|
|
56
|
+
|
|
57
|
+
### `pi-pm-subagents.json` 中的配置项
|
|
58
|
+
|
|
59
|
+
- `"subagentModel": "<model>"` — 默认子代理模型
|
|
60
|
+
- `"subagentModelScoped": ["<model>", ...]` — 子代理 scoped 模型池
|
|
61
|
+
- `"defaultMode": "coordinator"` — 启动时进入协调者模式(即 `/pm-default` 的持久化形式)
|
|
62
|
+
|
|
63
|
+
### 模型命令
|
|
64
|
+
|
|
65
|
+
**模型优先级(spawn 时):** `role.model > session subagent model(初始值来自 pi-pm-subagents.json,可通过 /pm-subagent-model 或 Alt+N 覆盖) > ctx.model`
|
|
66
|
+
|
|
67
|
+
- **`/pm-subagent-model`** — 更改子代理模型
|
|
68
|
+
- **`/pm-subagent-scoped`** — 管理子代理 scoped 模型
|
|
69
|
+
- `Alt+N` 在模型池中循环切换。池中可包含 `DEFAULT`(使用 pi 默认模型)标记。
|
|
70
|
+
|
|
71
|
+
## 注意
|
|
72
|
+
|
|
73
|
+
**终端/tmux 兼容性说明**:
|
|
74
|
+
|
|
75
|
+
> `Alt+N` 需要终端支持 Kitty keyboard protocol,
|
|
76
|
+
> 或在 tmux 中启用 `set -g extended-keys on`。
|
|
77
|
+
> 在标准 xterm 及类似终端中,`Alt+字母` 会以 ESC 序列发送,可能不可靠。
|
|
78
|
+
|
|
79
|
+
## 开发
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
PI_DEMO=1 pi
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`/subagent-demo` 是用于开发的 UI fixture。仅在以 `PI_DEMO=1` 启动 pi 时才会注册:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Produces an implementation plan, the plan is reviewed by the user before implementation
|
|
3
|
+
removeTools:
|
|
4
|
+
- write
|
|
5
|
+
- edit
|
|
6
|
+
- bash
|
|
7
|
+
extraTools:
|
|
8
|
+
- bash_readonly
|
|
9
|
+
reviewOnEnd: true
|
|
10
|
+
---
|
|
11
|
+
You are in plan mode — a read-only exploration mode. You cannot modify files.
|
|
12
|
+
|
|
13
|
+
1. Investigate the request thoroughly using read-only tools.
|
|
14
|
+
2. Produce a concrete implementation plan as Markdown
|
|
15
|
+
|
|
16
|
+
The user will review it and choose how to proceed.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Deep research by gathering information from multiple sources
|
|
3
|
+
tools:
|
|
4
|
+
- read
|
|
5
|
+
- find
|
|
6
|
+
- grep
|
|
7
|
+
- bash_readonly
|
|
8
|
+
- web_fetch
|
|
9
|
+
- web_search
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
You are a research expert. Gather accurate, up-to-date information from multiple sources before synthesizing an answer.
|
|
13
|
+
|
|
14
|
+
During research:
|
|
15
|
+
|
|
16
|
+
- Search the web for the latest information and multiple perspectives.
|
|
17
|
+
- Fetch documentation or specification pages for authoritative details.
|
|
18
|
+
- Search the codebase for implementation details when relevant.
|
|
19
|
+
- Cross-validate claims across sources.
|
|
20
|
+
- Cite sources explicitly when presenting findings.
|
|
21
|
+
|
|
22
|
+
Present research results clearly and concisely. Focus on actionable insights rather than exhaustive coverage.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { tsconfig } from '@raidou/eslint-config-base'
|
|
3
|
+
import { defineConfig } from 'eslint/config'
|
|
4
|
+
export default defineConfig([
|
|
5
|
+
{
|
|
6
|
+
files: [
|
|
7
|
+
'src/**/*.{mjs,ts,tsx,js,jsx}',
|
|
8
|
+
'app/*.{mjs,ts,tsx,js,jsx}',
|
|
9
|
+
'app/src/**/*.{mjs,ts,tsx,js,jsx}',
|
|
10
|
+
'*.{mjs,ts,tsx,js,jsx}',
|
|
11
|
+
],
|
|
12
|
+
extends: [tsconfig],
|
|
13
|
+
},
|
|
14
|
+
])
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
## Review code against the following criteria
|
|
2
|
+
|
|
3
|
+
- Prefer self-documenting code over comments, do not add any comments unless the code is complex, difficult to understand, or unclear.
|
|
4
|
+
- Prioritize code readability above all
|
|
5
|
+
- Minimize code hierarchy and encourage early return
|
|
6
|
+
- Use type-safe practices, avoid type casting (unless like `as const`...)
|
|
7
|
+
- Use required types whenever possible instead of optional types.
|
|
8
|
+
- Module files have clearly defined responsibilities, following the DRY + KISS principle.
|
|
9
|
+
- Avoid using mocks in unit tests whenever possible, but don't make your business logic difficult to understand just for the sake of testing.
|
|
10
|
+
- Tests don't need to cover everything, just ensure the main branches don't have errors.
|
|
11
|
+
|
|
12
|
+
## TS code style
|
|
13
|
+
|
|
14
|
+
- Don't use `export {...}` unless necessary
|
|
15
|
+
- Prefer `for of` and `for (const [k, v] of array.entries())`
|
|
16
|
+
- When iterating with an `if-chain`, use a switch instead
|
|
17
|
+
|
|
18
|
+
## Notes
|
|
19
|
+
|
|
20
|
+
- Don't use git mv to rename files
|
|
21
|
+
- Don't add the change to git staged
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@raidou/pi-pm-subagents",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Coordinator mode for pi: a read-only coordinator agent that drives isolated subagents, including a planner role.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"subagents",
|
|
11
|
+
"pm",
|
|
12
|
+
"coordinator"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./src/index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "pnpm test:types && pnpm test:lint && pnpm test:unit",
|
|
23
|
+
"test:types": "tsc --noEmit",
|
|
24
|
+
"test:lint": "eslint --fix .",
|
|
25
|
+
"test:unit": "vitest run"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@earendil-works/pi-agent-core": "0.83.0",
|
|
29
|
+
"@earendil-works/pi-ai": "0.83.0",
|
|
30
|
+
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
31
|
+
"@earendil-works/pi-tui": "0.83.0",
|
|
32
|
+
"@raidou/eslint-config-base": "4.4.4",
|
|
33
|
+
"@types/lodash-es": "^4.17.12",
|
|
34
|
+
"@types/node": "26.1.2",
|
|
35
|
+
"eslint": "10.8.0",
|
|
36
|
+
"prettier": "3.9.6",
|
|
37
|
+
"typescript": "6.0.3",
|
|
38
|
+
"vitest": "4.1.10"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"lodash-es": "^4.18.1",
|
|
42
|
+
"shell-quote": "^1.8.3",
|
|
43
|
+
"typebox": "1.3.9"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
tools: []
|
|
3
|
+
extraTools: []
|
|
4
|
+
removeTools: [find, grep, bash, web_search, web_fetch]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You are a COORDINATOR agent; you are readonly, delegate subagents to do tasks
|
|
8
|
+
|
|
9
|
+
## Responsibilities
|
|
10
|
+
|
|
11
|
+
- Delegate tasks and wait for me to tell you subagent's last message when it finishes.
|
|
12
|
+
- Do not trust a subagent's self-reported result blindly.
|
|
13
|
+
- Don't do the work yourself; let subagents explore, research, code, test, review, etc.
|
|
14
|
+
- Don't skip lint checks
|
|
15
|
+
- Ask for the user's consent before modifying package.json, tsconfig, or eslint etc project settings
|
|
16
|
+
- After changing code, always have the reviewer check it
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
BASH_READONLY_TOOL_NAME,
|
|
6
|
+
checkBashSafety,
|
|
7
|
+
isBashReadonlyCommand,
|
|
8
|
+
} from './bash-readonly.js'
|
|
9
|
+
import { calculateModeTools } from './pm-mode.js'
|
|
10
|
+
import { createState } from './utils/state.js'
|
|
11
|
+
|
|
12
|
+
function fakePi(initialActive: string[]): ExtensionAPI & {
|
|
13
|
+
activeTools: string[]
|
|
14
|
+
} {
|
|
15
|
+
const pi = {
|
|
16
|
+
activeTools: initialActive,
|
|
17
|
+
getActiveTools() {
|
|
18
|
+
return [...pi.activeTools]
|
|
19
|
+
},
|
|
20
|
+
setActiveTools(names: string[]) {
|
|
21
|
+
pi.activeTools = [...names]
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
return pi as ExtensionAPI & { activeTools: string[] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('isBashReadonlyCommand', () => {
|
|
28
|
+
it('allows read-only commands', () => {
|
|
29
|
+
expect(isBashReadonlyCommand('ls -la')).toBe(true)
|
|
30
|
+
expect(isBashReadonlyCommand('git status')).toBe(true)
|
|
31
|
+
expect(isBashReadonlyCommand('cat README.md')).toBe(true)
|
|
32
|
+
expect(isBashReadonlyCommand('rg "foo" src/')).toBe(true)
|
|
33
|
+
expect(isBashReadonlyCommand('pnpm test')).toBe(true)
|
|
34
|
+
expect(isBashReadonlyCommand('npm test')).toBe(true)
|
|
35
|
+
expect(isBashReadonlyCommand('yarn test')).toBe(true)
|
|
36
|
+
expect(isBashReadonlyCommand('pnpm run test')).toBe(true)
|
|
37
|
+
expect(isBashReadonlyCommand('pnpm test:unit')).toBe(true)
|
|
38
|
+
expect(isBashReadonlyCommand('yarn run test')).toBe(true)
|
|
39
|
+
expect(isBashReadonlyCommand('cd /some/path')).toBe(true)
|
|
40
|
+
expect(
|
|
41
|
+
isBashReadonlyCommand(
|
|
42
|
+
'cd /home/raidou/repos/raidou/pi-notify && pnpm test 2>&1 | tail -30',
|
|
43
|
+
),
|
|
44
|
+
).toBe(true)
|
|
45
|
+
expect(isBashReadonlyCommand('cd .. && ls')).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('validates each shell subcommand independently', () => {
|
|
49
|
+
expect(isBashReadonlyCommand('cat README.md && echo done')).toBe(true)
|
|
50
|
+
expect(isBashReadonlyCommand('cat README.md; rm file')).toBe(false)
|
|
51
|
+
expect(isBashReadonlyCommand('ls || rm -rf /')).toBe(false)
|
|
52
|
+
expect(isBashReadonlyCommand('cat foo | grep bar')).toBe(true)
|
|
53
|
+
expect(isBashReadonlyCommand('cat README.md; echo "rm -rf /"')).toBe(true)
|
|
54
|
+
expect(isBashReadonlyCommand('pnpm test | tail')).toBe(true)
|
|
55
|
+
expect(isBashReadonlyCommand('pnpm test 2>&1 | tail -60')).toBe(true)
|
|
56
|
+
expect(isBashReadonlyCommand('pnpm test | grep FAIL')).toBe(true)
|
|
57
|
+
expect(isBashReadonlyCommand('pnpm test | rm file')).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('rejects empty shell subcommands', () => {
|
|
61
|
+
expect(isBashReadonlyCommand('')).toBe(false)
|
|
62
|
+
expect(isBashReadonlyCommand('cat README.md &&')).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('allows read-only commands with forward prefix', () => {
|
|
66
|
+
expect(isBashReadonlyCommand('rtk ls -la')).toBe(true)
|
|
67
|
+
expect(isBashReadonlyCommand('rtk git status')).toBe(true)
|
|
68
|
+
expect(isBashReadonlyCommand('rtk git diff path/to')).toBe(true)
|
|
69
|
+
expect(
|
|
70
|
+
isBashReadonlyCommand(
|
|
71
|
+
`export RTK_DB_PATH='/tmp/pi-rtk-optimizer/history.db'; rtk ls -la`,
|
|
72
|
+
),
|
|
73
|
+
).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('blocks destructive commands', () => {
|
|
77
|
+
expect(isBashReadonlyCommand('rm -rf /')).toBe(false)
|
|
78
|
+
expect(isBashReadonlyCommand('npm install')).toBe(false)
|
|
79
|
+
expect(isBashReadonlyCommand('git commit -m x')).toBe(false)
|
|
80
|
+
expect(isBashReadonlyCommand('echo hi > out.txt')).toBe(false)
|
|
81
|
+
expect(isBashReadonlyCommand('sudo apt-get install evil')).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('blocks destructive commands even with forward prefix', () => {
|
|
85
|
+
expect(isBashReadonlyCommand('rtk rm -rf /')).toBe(false)
|
|
86
|
+
expect(isBashReadonlyCommand('rtk npm install')).toBe(false)
|
|
87
|
+
expect(isBashReadonlyCommand('rtk git commit -m x')).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('allows git stash read-only subcommands', () => {
|
|
91
|
+
expect(isBashReadonlyCommand('git stash list')).toBe(true)
|
|
92
|
+
expect(isBashReadonlyCommand('git stash show')).toBe(true)
|
|
93
|
+
expect(isBashReadonlyCommand('git stash list -n 5')).toBe(true)
|
|
94
|
+
expect(isBashReadonlyCommand('git stash show -p stash@{0}')).toBe(true)
|
|
95
|
+
expect(isBashReadonlyCommand('git stash')).toBe(false)
|
|
96
|
+
expect(isBashReadonlyCommand('git stash push -m "wip"')).toBe(false)
|
|
97
|
+
expect(isBashReadonlyCommand('git stash pop')).toBe(false)
|
|
98
|
+
expect(isBashReadonlyCommand('git stash apply')).toBe(false)
|
|
99
|
+
expect(isBashReadonlyCommand('git stash drop')).toBe(false)
|
|
100
|
+
expect(isBashReadonlyCommand('git stash clear')).toBe(false)
|
|
101
|
+
expect(isBashReadonlyCommand('git stash save "msg"')).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('calculateModeTools', () => {
|
|
106
|
+
it('drops write tools, replaces bash, and merges extras', () => {
|
|
107
|
+
expect(
|
|
108
|
+
calculateModeTools(
|
|
109
|
+
fakePi(['read', 'edit', 'write', 'bash']),
|
|
110
|
+
createState(),
|
|
111
|
+
{
|
|
112
|
+
extraTools: ['subagent_delegate'],
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
).toEqual(['read', BASH_READONLY_TOOL_NAME, 'subagent_delegate'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('deduplicates', () => {
|
|
119
|
+
expect(
|
|
120
|
+
calculateModeTools(fakePi(['read', 'read', 'edit']), createState(), {
|
|
121
|
+
extraTools: ['read'],
|
|
122
|
+
}),
|
|
123
|
+
).toEqual(['read'])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not inject bash_readonly when bash is not present', () => {
|
|
127
|
+
expect(
|
|
128
|
+
calculateModeTools(fakePi(['read', 'grep']), createState(), {}),
|
|
129
|
+
).toEqual(['read', 'grep'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('passes through bash_readonly unchanged when already present', () => {
|
|
133
|
+
expect(
|
|
134
|
+
calculateModeTools(
|
|
135
|
+
fakePi(['read', BASH_READONLY_TOOL_NAME]),
|
|
136
|
+
createState(),
|
|
137
|
+
{},
|
|
138
|
+
),
|
|
139
|
+
).toEqual(['read', BASH_READONLY_TOOL_NAME])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('removes specified tools', () => {
|
|
143
|
+
expect(
|
|
144
|
+
calculateModeTools(fakePi(['read', 'bash', 'grep']), createState(), {
|
|
145
|
+
removeTools: ['grep'],
|
|
146
|
+
}),
|
|
147
|
+
).toEqual(['read', BASH_READONLY_TOOL_NAME])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('restricts to tools list when provided', () => {
|
|
151
|
+
expect(
|
|
152
|
+
calculateModeTools(fakePi(['read', 'bash', 'grep']), createState(), {
|
|
153
|
+
extraTools: ['subagent_delegate'],
|
|
154
|
+
tools: ['read', 'subagent_delegate'],
|
|
155
|
+
}),
|
|
156
|
+
).toEqual(['read', 'subagent_delegate'])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('returns empty when tools list is empty', () => {
|
|
160
|
+
expect(
|
|
161
|
+
calculateModeTools(fakePi(['read', 'bash', 'grep']), createState(), {
|
|
162
|
+
tools: [],
|
|
163
|
+
}),
|
|
164
|
+
).toEqual([])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('checkBashSafety', () => {
|
|
169
|
+
it('returns allowed: true for safe commands', () => {
|
|
170
|
+
expect(checkBashSafety('ls -la')).toEqual({ allowed: true })
|
|
171
|
+
expect(checkBashSafety('git status')).toEqual({ allowed: true })
|
|
172
|
+
expect(checkBashSafety('cat README.md')).toEqual({ allowed: true })
|
|
173
|
+
expect(checkBashSafety('rg "foo" src/')).toEqual({ allowed: true })
|
|
174
|
+
expect(checkBashSafety('pnpm test')).toEqual({ allowed: true })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('reports the failed subcommand for destructive commands', () => {
|
|
178
|
+
expect(checkBashSafety('rm file')).toEqual({
|
|
179
|
+
allowed: false,
|
|
180
|
+
subCommand: 'rm file',
|
|
181
|
+
})
|
|
182
|
+
expect(checkBashSafety('rm -rf /')).toEqual({
|
|
183
|
+
allowed: false,
|
|
184
|
+
subCommand: 'rm -rf /',
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('reports the failed subcommand for output redirect commands', () => {
|
|
189
|
+
expect(checkBashSafety('ls > out.txt')).toEqual({
|
|
190
|
+
allowed: false,
|
|
191
|
+
subCommand: 'ls',
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('reports the failed subcommand for sudo commands', () => {
|
|
196
|
+
expect(checkBashSafety('sudo apt-get install evil')).toEqual({
|
|
197
|
+
allowed: false,
|
|
198
|
+
subCommand: 'sudo apt-get install evil',
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('reports the first failed subcommand in chain', () => {
|
|
203
|
+
expect(checkBashSafety('cat README.md; rm file')).toEqual({
|
|
204
|
+
allowed: false,
|
|
205
|
+
subCommand: 'rm file',
|
|
206
|
+
})
|
|
207
|
+
expect(checkBashSafety('ls && echo hi && rm file')).toEqual({
|
|
208
|
+
allowed: false,
|
|
209
|
+
subCommand: 'rm file',
|
|
210
|
+
})
|
|
211
|
+
expect(checkBashSafety('rm file && ls')).toEqual({
|
|
212
|
+
allowed: false,
|
|
213
|
+
subCommand: 'rm file',
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('returns (empty) for trailing empty subcommands', () => {
|
|
218
|
+
expect(checkBashSafety('cat README.md &&')).toEqual({
|
|
219
|
+
allowed: false,
|
|
220
|
+
subCommand: '(empty)',
|
|
221
|
+
})
|
|
222
|
+
expect(checkBashSafety('ls ;')).toEqual({
|
|
223
|
+
allowed: false,
|
|
224
|
+
subCommand: '(empty)',
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('returns the original command for empty input', () => {
|
|
229
|
+
expect(checkBashSafety('')).toEqual({ allowed: false, subCommand: '' })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('strips rtk prefix when checking for destructive patterns', () => {
|
|
233
|
+
expect(checkBashSafety('rtk git commit -m x')).toEqual({
|
|
234
|
+
allowed: false,
|
|
235
|
+
subCommand: 'git commit -m x',
|
|
236
|
+
})
|
|
237
|
+
expect(checkBashSafety('rtk rm -rf /')).toEqual({
|
|
238
|
+
allowed: false,
|
|
239
|
+
subCommand: 'rm -rf /',
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('allows safe commands with rtk prefix', () => {
|
|
244
|
+
expect(checkBashSafety('rtk ls -la')).toEqual({ allowed: true })
|
|
245
|
+
expect(checkBashSafety('rtk git status')).toEqual({ allowed: true })
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('handles complex multi-subcommand scenarios', () => {
|
|
249
|
+
expect(checkBashSafety('cat README.md && echo done && ls -la')).toEqual({
|
|
250
|
+
allowed: true,
|
|
251
|
+
})
|
|
252
|
+
expect(checkBashSafety('cat README.md | grep foo | tail')).toEqual({
|
|
253
|
+
allowed: true,
|
|
254
|
+
})
|
|
255
|
+
expect(checkBashSafety('pnpm test | grep FAIL | tail -20')).toEqual({
|
|
256
|
+
allowed: true,
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('allows stderr redirects but blocks stdout redirects', () => {
|
|
261
|
+
expect(
|
|
262
|
+
checkBashSafety("find /home/raidou -name 'foo' 2>/dev/null"),
|
|
263
|
+
).toEqual({ allowed: true })
|
|
264
|
+
expect(checkBashSafety('rtk find . 2>/dev/null')).toEqual({
|
|
265
|
+
allowed: true,
|
|
266
|
+
})
|
|
267
|
+
expect(checkBashSafety('ls > out.txt')).toEqual({
|
|
268
|
+
allowed: false,
|
|
269
|
+
subCommand: 'ls',
|
|
270
|
+
})
|
|
271
|
+
expect(checkBashSafety('ls 1>/dev/null')).toEqual({ allowed: true })
|
|
272
|
+
expect(checkBashSafety('ls 2>&1 | grep x')).toEqual({ allowed: true })
|
|
273
|
+
expect(checkBashSafety('cat foo 2>&1')).toEqual({
|
|
274
|
+
allowed: false,
|
|
275
|
+
subCommand: 'cat foo',
|
|
276
|
+
})
|
|
277
|
+
expect(checkBashSafety('ls &> out.txt')).toEqual({
|
|
278
|
+
allowed: false,
|
|
279
|
+
subCommand: 'ls',
|
|
280
|
+
})
|
|
281
|
+
expect(checkBashSafety('ls &>> out.txt')).toEqual({
|
|
282
|
+
allowed: false,
|
|
283
|
+
subCommand: 'ls',
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('allows git stash list and show, rejects destructive stash subcommands', () => {
|
|
288
|
+
expect(checkBashSafety('git stash list')).toEqual({ allowed: true })
|
|
289
|
+
expect(checkBashSafety('git stash show')).toEqual({ allowed: true })
|
|
290
|
+
expect(checkBashSafety('git stash list -n 5')).toEqual({ allowed: true })
|
|
291
|
+
expect(checkBashSafety('git stash show -p stash@{0}')).toEqual({
|
|
292
|
+
allowed: true,
|
|
293
|
+
})
|
|
294
|
+
expect(checkBashSafety('git stash push -m x')).toEqual({
|
|
295
|
+
allowed: false,
|
|
296
|
+
subCommand: 'git stash push -m x',
|
|
297
|
+
})
|
|
298
|
+
expect(checkBashSafety('git stash pop')).toEqual({
|
|
299
|
+
allowed: false,
|
|
300
|
+
subCommand: 'git stash pop',
|
|
301
|
+
})
|
|
302
|
+
expect(checkBashSafety('git stash apply')).toEqual({
|
|
303
|
+
allowed: false,
|
|
304
|
+
subCommand: 'git stash apply',
|
|
305
|
+
})
|
|
306
|
+
expect(checkBashSafety('git stash drop')).toEqual({
|
|
307
|
+
allowed: false,
|
|
308
|
+
subCommand: 'git stash drop',
|
|
309
|
+
})
|
|
310
|
+
expect(checkBashSafety('git stash clear')).toEqual({
|
|
311
|
+
allowed: false,
|
|
312
|
+
subCommand: 'git stash clear',
|
|
313
|
+
})
|
|
314
|
+
expect(checkBashSafety('git stash save msg')).toEqual({
|
|
315
|
+
allowed: false,
|
|
316
|
+
subCommand: 'git stash save msg',
|
|
317
|
+
})
|
|
318
|
+
expect(checkBashSafety('git stash create')).toEqual({
|
|
319
|
+
allowed: false,
|
|
320
|
+
subCommand: 'git stash create',
|
|
321
|
+
})
|
|
322
|
+
expect(checkBashSafety('git stash branch')).toEqual({
|
|
323
|
+
allowed: false,
|
|
324
|
+
subCommand: 'git stash branch',
|
|
325
|
+
})
|
|
326
|
+
expect(checkBashSafety('git stash store')).toEqual({
|
|
327
|
+
allowed: false,
|
|
328
|
+
subCommand: 'git stash store',
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
})
|