@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.
Files changed (69) hide show
  1. package/.prettierrc +7 -0
  2. package/AGENTS.md +1 -0
  3. package/README.md +85 -0
  4. package/README.zh-CN.md +85 -0
  5. package/agents/explorer.md +10 -0
  6. package/agents/planner.md +16 -0
  7. package/agents/researcher.md +22 -0
  8. package/agents/reviewer.md +10 -0
  9. package/eslint.config.mjs +14 -0
  10. package/example-prompts/coordinator.md +21 -0
  11. package/package.json +48 -0
  12. package/pm-subagents-prompts/coordinator.md +16 -0
  13. package/pnpm-workspace.yaml +5 -0
  14. package/src/bash-readonly.test.ts +331 -0
  15. package/src/bash-readonly.ts +205 -0
  16. package/src/coordinator/coordinator.test.ts +28 -0
  17. package/src/coordinator/coordinator.ts +275 -0
  18. package/src/custom-select.test.ts +91 -0
  19. package/src/custom-select.ts +209 -0
  20. package/src/index.ts +87 -0
  21. package/src/models-config/models-config.test.ts +88 -0
  22. package/src/models-config/models-config.ts +205 -0
  23. package/src/models-config/scoped-models-editor.test.ts +189 -0
  24. package/src/models-config/scoped-models-editor.ts +412 -0
  25. package/src/models-config/subagent-model-constants.ts +2 -0
  26. package/src/models-config/subagent-model-cycle.ts +53 -0
  27. package/src/models-config/subagent-model-utils.test.ts +250 -0
  28. package/src/models-config/subagent-model-utils.ts +52 -0
  29. package/src/pm-mode.test.ts +324 -0
  30. package/src/pm-mode.ts +142 -0
  31. package/src/prompts/mode.test.ts +289 -0
  32. package/src/prompts/mode.ts +31 -0
  33. package/src/prompts/roles.test.ts +724 -0
  34. package/src/prompts/roles.ts +119 -0
  35. package/src/subagent/activity.test.ts +230 -0
  36. package/src/subagent/activity.ts +60 -0
  37. package/src/subagent/batcher.test.ts +198 -0
  38. package/src/subagent/batcher.ts +51 -0
  39. package/src/subagent/consts.ts +1 -0
  40. package/src/subagent/demo.ts +773 -0
  41. package/src/subagent/fleet.test.ts +1758 -0
  42. package/src/subagent/fleet.ts +376 -0
  43. package/src/subagent/identity.test.ts +31 -0
  44. package/src/subagent/identity.ts +16 -0
  45. package/src/subagent/manager.test.ts +392 -0
  46. package/src/subagent/manager.ts +277 -0
  47. package/src/subagent/tools.ts +314 -0
  48. package/src/subagent/viewer.ts +305 -0
  49. package/src/types.ts +15 -0
  50. package/src/ui/border-view.ts +50 -0
  51. package/src/ui/review-pager.ts +146 -0
  52. package/src/ui/scroll-view.test.ts +190 -0
  53. package/src/ui/scroll-view.ts +155 -0
  54. package/src/utils/format.test.ts +76 -0
  55. package/src/utils/format.ts +67 -0
  56. package/src/utils/fs.ts +9 -0
  57. package/src/utils/markdown.test.ts +442 -0
  58. package/src/utils/markdown.ts +79 -0
  59. package/src/utils/messages.test.ts +436 -0
  60. package/src/utils/messages.ts +131 -0
  61. package/src/utils/model-ref.test.ts +42 -0
  62. package/src/utils/model-ref.ts +44 -0
  63. package/src/utils/state.test.ts +98 -0
  64. package/src/utils/state.ts +45 -0
  65. package/src/utils/tools.ts +48 -0
  66. package/src/utils/truncate.test.ts +41 -0
  67. package/src/utils/truncate.ts +59 -0
  68. package/tsconfig.json +24 -0
  69. package/vitest.config.ts +8 -0
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "singleQuote": true,
3
+ "trailingComma": "all",
4
+ "arrowParens": "always",
5
+ "semi": false,
6
+ "printWidth": 80
7
+ }
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`.
@@ -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,10 @@
1
+ ---
2
+ description: Explore the codebase
3
+ tools:
4
+ - read
5
+ - find
6
+ - grep
7
+ - bash_readonly
8
+ - web_fetch
9
+ - web_search
10
+ ---
@@ -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,10 @@
1
+ ---
2
+ description: Review code
3
+ tools:
4
+ - read
5
+ - find
6
+ - grep
7
+ - bash_readonly
8
+ - web_fetch
9
+ - web_search
10
+ ---
@@ -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,5 @@
1
+ allowBuilds:
2
+ '@google/genai': false
3
+ protobufjs: false
4
+ minimumReleaseAgeExclude:
5
+ - typebox@1.3.9
@@ -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
+ })