patchbai 0.1.2__tar.gz → 0.2.0__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.
- {patchbai-0.1.2 → patchbai-0.2.0}/PKG-INFO +13 -3
- {patchbai-0.1.2 → patchbai-0.2.0}/README.md +12 -2
- patchbai-0.2.0/patchbai/__main__.py +32 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/manager.py +102 -9
- patchbai-0.2.0/patchbai/agents/permission_grants.py +98 -0
- patchbai-0.2.0/patchbai/agents/permission_inbox.py +91 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/session.py +26 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/state.py +1 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/app.py +134 -1
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/events.py +24 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/session.py +146 -5
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/agent_table.py +1 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/agent_transcript.py +28 -1
- patchbai-0.2.0/patchbai/widgets/permission_modal.py +185 -0
- patchbai-0.2.0/patchbai/widgets/permission_request_bar.py +90 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/pyproject.toml +1 -1
- patchbai-0.1.2/patchbai/__main__.py +0 -10
- {patchbai-0.1.2 → patchbai-0.2.0}/.gitignore +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/LICENSE +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/actions.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/activity/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/activity/log.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/child_tools.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/fake_sdk_adapter.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/request_inbox.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/sdk_adapter.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/sort.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/config.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/custom_widgets.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/defaults.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/engine.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/local_widgets.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/registry.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/spec.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/splitter.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/titles.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/formatting.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/tabs_tools.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/tools.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/agents_index.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/atomic.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/layout_store.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/layouts_store.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/orchestrator_sessions.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/paths.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/themes_store.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/transcript_store.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/workspace_store.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/theme/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/theme/engine.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/theme/spec.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/_file_lang.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/_terminal_keys.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/_terminal_render.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/activity_feed.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/change_cwd_screen.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/chrome.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/diff_viewer.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/file_editor.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/file_tree.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/file_viewer.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/history_screen.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/layout_switcher.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/log_tail.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/markdown.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/new_tab_screen.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/notebook.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/orchestrator_chat.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/resume_screen.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/rich_transcript.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/system_usage.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/terminal.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/theme_switcher.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/transcript_screen.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/workspace/__init__.py +0 -0
- {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/workspace/spec.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchbai
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A Textual TUI for managing multiple Claude Code agent sessions
|
|
5
5
|
Project-URL: Homepage, https://github.com/jimmymills/patchbai
|
|
6
6
|
Project-URL: Repository, https://github.com/jimmymills/patchbai
|
|
@@ -118,6 +118,15 @@ the ideas.
|
|
|
118
118
|
the actual `claude` CLI, or your shell, in any panel. Mode-C custom
|
|
119
119
|
widgets let the orchestrator ship Python at runtime when the curated
|
|
120
120
|
widget library isn't enough.
|
|
121
|
+
- **Approve tool calls without leaving the room.** When a child wants
|
|
122
|
+
to use a tool that isn't auto-approved, a modal pops in patchbai with
|
|
123
|
+
the tool name and full arguments. Approve once, deny once, always
|
|
124
|
+
allow this tool for any agent named X (persisted to disk), or always
|
|
125
|
+
deny. The agent's status flips to `awaiting permission` and the
|
|
126
|
+
request also surfaces inline in its `AgentTranscript` panel so you
|
|
127
|
+
can clear it without opening the modal. Launch with
|
|
128
|
+
`--bypass-permissions` for "trust everything"; flip mid-session with
|
|
129
|
+
`/bypass-permissions` and `/require-permissions`.
|
|
121
130
|
|
|
122
131
|
## Concept
|
|
123
132
|
|
|
@@ -167,6 +176,7 @@ exactly one panel — the agent can shrink it but cannot hide its own input.
|
|
|
167
176
|
| `Markdown` | Renders markdown from a string or file. |
|
|
168
177
|
| `Notebook` | Editable scratch buffer; persists to `<cwd>/.patchbai/scratch/<name>.md`. |
|
|
169
178
|
| `Terminal` | Real PTY — drop into `claude`, `$SHELL`, or any command. Opaque to the orchestrator. |
|
|
179
|
+
| `SystemUsage` | Compact CPU + RAM gauges with auto-refresh and threshold-colored bars. Uses `psutil` if installed; otherwise async `top` / `vm_stat` shell-out on macOS. |
|
|
170
180
|
|
|
171
181
|
The orchestrator can also register **custom widgets** by emitting Python
|
|
172
182
|
source in the `custom_widgets` block of a `set_layout` call. The source
|
|
@@ -242,6 +252,8 @@ metadata reference — lives in
|
|
|
242
252
|
| `/resume` | Open the resume picker for a past orchestrator session. |
|
|
243
253
|
| `/rename` | Rename the current session's title. |
|
|
244
254
|
| `/cd <path>` | Change the workspace cwd. |
|
|
255
|
+
| `/bypass-permissions` | Switch the running session to bypass-all-permissions mode (no modals). |
|
|
256
|
+
| `/require-permissions` | Switch back to permission-modal mode. |
|
|
245
257
|
| `/help` | Show the slash-command list. |
|
|
246
258
|
|
|
247
259
|
`ctrl-c` while the chat is focused interrupts the orchestrator without
|
|
@@ -566,8 +578,6 @@ the last good layout stays mounted.
|
|
|
566
578
|
- **Claude Agent SDK only.** The agent abstraction is designed to
|
|
567
579
|
accommodate other harnesses (Codex, Aider, Gemini CLI), but only the
|
|
568
580
|
Claude adapter ships.
|
|
569
|
-
- **No modal "approve this tool call?" UX.** Children inherit your
|
|
570
|
-
`~/.claude/settings.json` permissions, optionally narrowed at spawn.
|
|
571
581
|
|
|
572
582
|
## License
|
|
573
583
|
|
|
@@ -83,6 +83,15 @@ the ideas.
|
|
|
83
83
|
the actual `claude` CLI, or your shell, in any panel. Mode-C custom
|
|
84
84
|
widgets let the orchestrator ship Python at runtime when the curated
|
|
85
85
|
widget library isn't enough.
|
|
86
|
+
- **Approve tool calls without leaving the room.** When a child wants
|
|
87
|
+
to use a tool that isn't auto-approved, a modal pops in patchbai with
|
|
88
|
+
the tool name and full arguments. Approve once, deny once, always
|
|
89
|
+
allow this tool for any agent named X (persisted to disk), or always
|
|
90
|
+
deny. The agent's status flips to `awaiting permission` and the
|
|
91
|
+
request also surfaces inline in its `AgentTranscript` panel so you
|
|
92
|
+
can clear it without opening the modal. Launch with
|
|
93
|
+
`--bypass-permissions` for "trust everything"; flip mid-session with
|
|
94
|
+
`/bypass-permissions` and `/require-permissions`.
|
|
86
95
|
|
|
87
96
|
## Concept
|
|
88
97
|
|
|
@@ -132,6 +141,7 @@ exactly one panel — the agent can shrink it but cannot hide its own input.
|
|
|
132
141
|
| `Markdown` | Renders markdown from a string or file. |
|
|
133
142
|
| `Notebook` | Editable scratch buffer; persists to `<cwd>/.patchbai/scratch/<name>.md`. |
|
|
134
143
|
| `Terminal` | Real PTY — drop into `claude`, `$SHELL`, or any command. Opaque to the orchestrator. |
|
|
144
|
+
| `SystemUsage` | Compact CPU + RAM gauges with auto-refresh and threshold-colored bars. Uses `psutil` if installed; otherwise async `top` / `vm_stat` shell-out on macOS. |
|
|
135
145
|
|
|
136
146
|
The orchestrator can also register **custom widgets** by emitting Python
|
|
137
147
|
source in the `custom_widgets` block of a `set_layout` call. The source
|
|
@@ -207,6 +217,8 @@ metadata reference — lives in
|
|
|
207
217
|
| `/resume` | Open the resume picker for a past orchestrator session. |
|
|
208
218
|
| `/rename` | Rename the current session's title. |
|
|
209
219
|
| `/cd <path>` | Change the workspace cwd. |
|
|
220
|
+
| `/bypass-permissions` | Switch the running session to bypass-all-permissions mode (no modals). |
|
|
221
|
+
| `/require-permissions` | Switch back to permission-modal mode. |
|
|
210
222
|
| `/help` | Show the slash-command list. |
|
|
211
223
|
|
|
212
224
|
`ctrl-c` while the chat is focused interrupts the orchestrator without
|
|
@@ -531,8 +543,6 @@ the last good layout stays mounted.
|
|
|
531
543
|
- **Claude Agent SDK only.** The agent abstraction is designed to
|
|
532
544
|
accommodate other harnesses (Codex, Aider, Gemini CLI), but only the
|
|
533
545
|
Claude adapter ships.
|
|
534
|
-
- **No modal "approve this tool call?" UX.** Children inherit your
|
|
535
|
-
`~/.claude/settings.json` permissions, optionally narrowed at spawn.
|
|
536
546
|
|
|
537
547
|
## License
|
|
538
548
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from patchbai.app import PatchbaiApp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
prog="patchbai",
|
|
10
|
+
description="Multi-agent Textual TUI for Claude Agent SDK.",
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--bypass-permissions",
|
|
14
|
+
action="store_true",
|
|
15
|
+
help=(
|
|
16
|
+
"Run all sessions (orchestrator + child agents) with "
|
|
17
|
+
"permission_mode=bypassPermissions. Default behavior is to "
|
|
18
|
+
"ask for confirmation via a Textual modal before every "
|
|
19
|
+
"tool call."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
return parser.parse_args(argv)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
27
|
+
PatchbaiApp(bypass_permissions=args.bypass_permissions).run()
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
raise SystemExit(main())
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import dataclasses
|
|
2
3
|
import time
|
|
3
4
|
import uuid
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Callable
|
|
6
7
|
|
|
7
|
-
from claude_agent_sdk import
|
|
8
|
+
from claude_agent_sdk import (
|
|
9
|
+
CanUseTool,
|
|
10
|
+
ClaudeAgentOptions,
|
|
11
|
+
PermissionResultAllow,
|
|
12
|
+
PermissionResultDeny,
|
|
13
|
+
ToolPermissionContext,
|
|
14
|
+
)
|
|
8
15
|
|
|
9
16
|
from patchbai.agents.child_tools import build_child_mcp_server
|
|
17
|
+
from patchbai.agents.permission_grants import PermissionGrants
|
|
18
|
+
from patchbai.agents.permission_inbox import PermissionInbox
|
|
10
19
|
from patchbai.agents.request_inbox import RequestInbox
|
|
11
20
|
from patchbai.agents.sdk_adapter import SDKAdapter
|
|
12
21
|
from patchbai.agents.session import AgentSession
|
|
@@ -17,6 +26,8 @@ from patchbai.events import (
|
|
|
17
26
|
AgentStateChanged,
|
|
18
27
|
DirectMessageToAgent,
|
|
19
28
|
EventBus,
|
|
29
|
+
PermissionRequested,
|
|
30
|
+
PermissionResolved,
|
|
20
31
|
)
|
|
21
32
|
from patchbai.persistence.agents_index import AgentsIndex
|
|
22
33
|
from patchbai.persistence.transcript_store import AgentTranscript, TranscriptEntry
|
|
@@ -31,12 +42,15 @@ class AgentManager:
|
|
|
31
42
|
cwd: Path,
|
|
32
43
|
bus: EventBus,
|
|
33
44
|
adapter_factory: Callable[[], SDKAdapter],
|
|
45
|
+
permission_grants: PermissionGrants | None = None,
|
|
34
46
|
) -> None:
|
|
35
47
|
self._cwd = cwd
|
|
36
48
|
self._bus = bus
|
|
37
49
|
self._adapter_factory = adapter_factory
|
|
50
|
+
self._grants = permission_grants
|
|
38
51
|
self._sessions: dict[str, AgentSession] = {}
|
|
39
52
|
self._inboxes: dict[str, RequestInbox] = {}
|
|
53
|
+
self._perm_inboxes: dict[str, PermissionInbox] = {}
|
|
40
54
|
self._index = AgentsIndex(cwd=cwd)
|
|
41
55
|
# Any agent persisted as still-running belongs to a previous (dead)
|
|
42
56
|
# process. Flip those rows to ERROR so the AgentTable seed doesn't
|
|
@@ -107,27 +121,42 @@ class AgentManager:
|
|
|
107
121
|
self._inboxes[info.id] = RequestInbox(
|
|
108
122
|
on_pending_changed=_on_pending_changed,
|
|
109
123
|
)
|
|
124
|
+
|
|
125
|
+
def _on_perm_changed(count: int, _session=session) -> None:
|
|
126
|
+
if count > 0:
|
|
127
|
+
_session._mark_awaiting_permission()
|
|
128
|
+
else:
|
|
129
|
+
_session._mark_done_permission()
|
|
130
|
+
|
|
131
|
+
self._perm_inboxes[info.id] = PermissionInbox(
|
|
132
|
+
on_pending_changed=_on_perm_changed,
|
|
133
|
+
)
|
|
110
134
|
self._sessions[info.id] = session
|
|
111
135
|
return session
|
|
112
136
|
|
|
113
137
|
def _build_options(
|
|
114
138
|
self, info: AgentInfo, *, resume_session_id: str | None = None,
|
|
115
139
|
) -> ClaudeAgentOptions:
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
140
|
+
# Permission posture: presence of self._grants is the gate.
|
|
141
|
+
# - None → permission_mode="bypassPermissions" (preserves the
|
|
142
|
+
# original behavior; equivalent to launching with
|
|
143
|
+
# --bypass-permissions).
|
|
144
|
+
# - obj → drop bypass, attach can_use_tool that consults the
|
|
145
|
+
# grants store first and falls back to the modal flow.
|
|
122
146
|
child_mcp = build_child_mcp_server(
|
|
123
147
|
agent_id=info.id, bus=self._bus, inbox=self._inboxes[info.id],
|
|
124
148
|
)
|
|
125
149
|
opts = info.spawn_options or {}
|
|
126
150
|
kwargs: dict = {
|
|
127
151
|
"cwd": opts.get("cwd") or info.cwd,
|
|
128
|
-
"permission_mode": "bypassPermissions",
|
|
129
152
|
"mcp_servers": {"patchbai_child": child_mcp},
|
|
130
153
|
}
|
|
154
|
+
if self._grants is None:
|
|
155
|
+
kwargs["permission_mode"] = "bypassPermissions"
|
|
156
|
+
else:
|
|
157
|
+
kwargs["can_use_tool"] = self._make_can_use_tool(
|
|
158
|
+
agent_id=info.id, agent_name=info.name,
|
|
159
|
+
)
|
|
131
160
|
if opts.get("allowed_tools") is not None:
|
|
132
161
|
kwargs["allowed_tools"] = opts["allowed_tools"]
|
|
133
162
|
if opts.get("disallowed_tools") is not None:
|
|
@@ -140,6 +169,65 @@ class AgentManager:
|
|
|
140
169
|
kwargs["resume"] = resume_session_id
|
|
141
170
|
return ClaudeAgentOptions(**kwargs)
|
|
142
171
|
|
|
172
|
+
def _make_can_use_tool(self, *, agent_id: str, agent_name: str) -> CanUseTool:
|
|
173
|
+
bus = self._bus
|
|
174
|
+
grants = self._grants
|
|
175
|
+
get_perm_inbox = self._perm_inboxes.get
|
|
176
|
+
# 30 minutes — long enough to step away briefly, short enough that a
|
|
177
|
+
# forgotten prompt doesn't strand the session forever.
|
|
178
|
+
TIMEOUT_S = 30 * 60
|
|
179
|
+
|
|
180
|
+
async def callback(
|
|
181
|
+
tool_name: str,
|
|
182
|
+
tool_input: dict,
|
|
183
|
+
ctx: ToolPermissionContext,
|
|
184
|
+
):
|
|
185
|
+
assert grants is not None # invariant when callback is wired
|
|
186
|
+
decision = grants.lookup(agent_name=agent_name, tool_name=tool_name)
|
|
187
|
+
if decision == "allow":
|
|
188
|
+
return PermissionResultAllow()
|
|
189
|
+
if decision == "deny":
|
|
190
|
+
return PermissionResultDeny(message="denied by saved rule")
|
|
191
|
+
|
|
192
|
+
inbox = get_perm_inbox(agent_id)
|
|
193
|
+
if inbox is None:
|
|
194
|
+
return PermissionResultDeny(message="agent gone", interrupt=True)
|
|
195
|
+
request_id = inbox.register(
|
|
196
|
+
tool_name=tool_name, tool_input=tool_input,
|
|
197
|
+
title=getattr(ctx, "title", None),
|
|
198
|
+
description=getattr(ctx, "description", None),
|
|
199
|
+
)
|
|
200
|
+
bus.publish(PermissionRequested(
|
|
201
|
+
agent_id=agent_id, agent_name=agent_name,
|
|
202
|
+
request_id=request_id, tool_name=tool_name,
|
|
203
|
+
tool_input=tool_input,
|
|
204
|
+
title=getattr(ctx, "title", None),
|
|
205
|
+
description=getattr(ctx, "description", None),
|
|
206
|
+
))
|
|
207
|
+
try:
|
|
208
|
+
result = await inbox.wait(request_id, timeout_s=TIMEOUT_S)
|
|
209
|
+
except asyncio.CancelledError:
|
|
210
|
+
task = asyncio.current_task()
|
|
211
|
+
if task is not None and task.cancelling() > 0:
|
|
212
|
+
raise # real task cancellation — must propagate
|
|
213
|
+
bus.publish(PermissionResolved(
|
|
214
|
+
agent_id=agent_id, request_id=request_id,
|
|
215
|
+
behavior="cancelled",
|
|
216
|
+
))
|
|
217
|
+
return PermissionResultDeny(message="cancelled", interrupt=True)
|
|
218
|
+
except asyncio.TimeoutError:
|
|
219
|
+
bus.publish(PermissionResolved(
|
|
220
|
+
agent_id=agent_id, request_id=request_id, behavior="deny",
|
|
221
|
+
))
|
|
222
|
+
return PermissionResultDeny(message="timed out")
|
|
223
|
+
bus.publish(PermissionResolved(
|
|
224
|
+
agent_id=agent_id, request_id=request_id,
|
|
225
|
+
behavior="allow" if isinstance(result, PermissionResultAllow) else "deny",
|
|
226
|
+
))
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
return callback
|
|
230
|
+
|
|
143
231
|
def _on_session_id(self, agent_id: str, session_id: str) -> None:
|
|
144
232
|
# The first ResultMessage carries the SDK session id. Capture it on
|
|
145
233
|
# the persisted info so a fresh process can pass it back as resume=
|
|
@@ -196,6 +284,9 @@ class AgentManager:
|
|
|
196
284
|
async def kill(self, agent_id: str) -> None:
|
|
197
285
|
session = self._sessions.pop(agent_id, None)
|
|
198
286
|
self._inboxes.pop(agent_id, None)
|
|
287
|
+
perm_inbox = self._perm_inboxes.pop(agent_id, None)
|
|
288
|
+
if perm_inbox is not None:
|
|
289
|
+
perm_inbox.cancel_all()
|
|
199
290
|
if session is not None:
|
|
200
291
|
await session.stop()
|
|
201
292
|
|
|
@@ -213,6 +304,9 @@ class AgentManager:
|
|
|
213
304
|
def get_inbox(self, agent_id: str) -> RequestInbox | None:
|
|
214
305
|
return self._inboxes.get(agent_id)
|
|
215
306
|
|
|
307
|
+
def get_permission_inbox(self, agent_id: str) -> PermissionInbox | None:
|
|
308
|
+
return self._perm_inboxes.get(agent_id)
|
|
309
|
+
|
|
216
310
|
def set_archived(self, agent_id: str, *, archived: bool) -> None:
|
|
217
311
|
"""Toggle the archived flag for an agent. Persists to agents.json and
|
|
218
312
|
publishes AgentArchiveChanged so listeners (e.g., AgentTable) can
|
|
@@ -258,7 +352,6 @@ class AgentManager:
|
|
|
258
352
|
# resurrect it via SDK resume so the user's message lands on a real
|
|
259
353
|
# conversation. Schedule on the running loop because EventBus
|
|
260
354
|
# handlers must be sync.
|
|
261
|
-
import asyncio
|
|
262
355
|
try:
|
|
263
356
|
loop = asyncio.get_running_loop()
|
|
264
357
|
except RuntimeError:
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Persistent + session-scoped grant rules for tool permissions.
|
|
2
|
+
|
|
3
|
+
DESIGN TRADEOFF (revisit when convenient):
|
|
4
|
+
This module keys persistent grants by ``(agent_name, tool_name)``. The
|
|
5
|
+
agent_name is the literal "orchestrator" for the user's main session and
|
|
6
|
+
the user-supplied name for child agents. Respawning a child of the same
|
|
7
|
+
name reuses prior decisions — convenient in the common case, surprising
|
|
8
|
+
if a name is reused with different intent.
|
|
9
|
+
|
|
10
|
+
A future revision could swap the disk-backed lookup for an in-memory one
|
|
11
|
+
keyed by ``agent_id`` (scoped to one live spawn). The interface here is
|
|
12
|
+
intentionally narrow so the swap touches only this file.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from patchbai.persistence.atomic import write_json_atomic
|
|
21
|
+
from patchbai.persistence.paths import project_state_dir
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
Behavior = Literal["allow", "deny"]
|
|
26
|
+
Scope = Literal["persistent", "session"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _grants_path(cwd: Path) -> Path:
|
|
30
|
+
return project_state_dir(cwd) / "permission_grants.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PermissionGrants:
|
|
34
|
+
"""Disk-backed allow/deny rules keyed by (agent_name, tool_name).
|
|
35
|
+
|
|
36
|
+
`remember(scope="session")` rules live in-memory only and evaporate on
|
|
37
|
+
process exit. `remember(scope="persistent")` rules are serialized to
|
|
38
|
+
`<cwd>/.patchbai/permission_grants.json`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, cwd: Path) -> None:
|
|
42
|
+
self._cwd = Path(cwd)
|
|
43
|
+
self._disk: dict[tuple[str, str], Behavior] = {}
|
|
44
|
+
self._session: dict[tuple[str, str], Behavior] = {}
|
|
45
|
+
self._load_disk()
|
|
46
|
+
|
|
47
|
+
def lookup(self, *, agent_name: str, tool_name: str) -> Behavior | None:
|
|
48
|
+
# Disk wins over session — disk represents an explicit "always" the
|
|
49
|
+
# user chose earlier, session is "for this run." If both exist, the
|
|
50
|
+
# persistent decision is more authoritative.
|
|
51
|
+
key = (agent_name, tool_name)
|
|
52
|
+
return self._disk.get(key) or self._session.get(key)
|
|
53
|
+
|
|
54
|
+
def remember(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
agent_name: str,
|
|
58
|
+
tool_name: str,
|
|
59
|
+
behavior: Behavior,
|
|
60
|
+
scope: Scope = "persistent",
|
|
61
|
+
) -> None:
|
|
62
|
+
key = (agent_name, tool_name)
|
|
63
|
+
if scope == "persistent":
|
|
64
|
+
self._disk[key] = behavior
|
|
65
|
+
self._write_disk()
|
|
66
|
+
else:
|
|
67
|
+
self._session[key] = behavior
|
|
68
|
+
|
|
69
|
+
def clear(self) -> None:
|
|
70
|
+
self._disk.clear()
|
|
71
|
+
self._session.clear()
|
|
72
|
+
try:
|
|
73
|
+
_grants_path(self._cwd).unlink()
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def _load_disk(self) -> None:
|
|
78
|
+
path = _grants_path(self._cwd)
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
for entry in raw.get("grants", []):
|
|
84
|
+
key = (entry["agent_name"], entry["tool_name"])
|
|
85
|
+
self._disk[key] = entry["behavior"]
|
|
86
|
+
except (json.JSONDecodeError, OSError, KeyError, TypeError):
|
|
87
|
+
log.exception("permission_grants.json unreadable; starting empty")
|
|
88
|
+
self._disk.clear()
|
|
89
|
+
|
|
90
|
+
def _write_disk(self) -> None:
|
|
91
|
+
data = {
|
|
92
|
+
"version": 1,
|
|
93
|
+
"grants": [
|
|
94
|
+
{"agent_name": a, "tool_name": t, "behavior": b}
|
|
95
|
+
for (a, t), b in sorted(self._disk.items())
|
|
96
|
+
],
|
|
97
|
+
}
|
|
98
|
+
write_json_atomic(_grants_path(self._cwd), data)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from claude_agent_sdk import PermissionResult
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class PendingPermission:
|
|
14
|
+
request_id: str
|
|
15
|
+
tool_name: str
|
|
16
|
+
tool_input: dict
|
|
17
|
+
title: str | None = None
|
|
18
|
+
description: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PermissionInbox:
|
|
22
|
+
"""Per-session registry of pending can_use_tool callbacks.
|
|
23
|
+
|
|
24
|
+
Sibling to RequestInbox: same register/wait/resolve shape, different
|
|
25
|
+
payload (PermissionResult vs str) and different blocker semantics — the
|
|
26
|
+
AgentSession flips into AWAITING_PERMISSION while count > 0.
|
|
27
|
+
|
|
28
|
+
`on_pending_changed`, if provided, is called synchronously after every
|
|
29
|
+
transition that changes the pending count.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
on_pending_changed: Callable[[int], None] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._records: dict[str, PendingPermission] = {}
|
|
38
|
+
self._futures: dict[str, asyncio.Future] = {}
|
|
39
|
+
self._on_pending_changed = on_pending_changed
|
|
40
|
+
|
|
41
|
+
def register(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
tool_name: str,
|
|
45
|
+
tool_input: dict,
|
|
46
|
+
title: str | None = None,
|
|
47
|
+
description: str | None = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
request_id = uuid.uuid4().hex[:12]
|
|
50
|
+
loop = asyncio.get_running_loop()
|
|
51
|
+
self._futures[request_id] = loop.create_future()
|
|
52
|
+
self._records[request_id] = PendingPermission(
|
|
53
|
+
request_id=request_id, tool_name=tool_name, tool_input=tool_input,
|
|
54
|
+
title=title, description=description,
|
|
55
|
+
)
|
|
56
|
+
self._notify()
|
|
57
|
+
return request_id
|
|
58
|
+
|
|
59
|
+
def resolve(self, request_id: str, result: PermissionResult) -> None:
|
|
60
|
+
future = self._futures.get(request_id)
|
|
61
|
+
if future is not None and not future.done():
|
|
62
|
+
future.set_result(result)
|
|
63
|
+
|
|
64
|
+
async def wait(self, request_id: str, *, timeout_s: float) -> PermissionResult:
|
|
65
|
+
future = self._futures.get(request_id)
|
|
66
|
+
if future is None:
|
|
67
|
+
raise KeyError(f"unknown request_id: {request_id}")
|
|
68
|
+
try:
|
|
69
|
+
return await asyncio.wait_for(future, timeout=timeout_s)
|
|
70
|
+
finally:
|
|
71
|
+
self._futures.pop(request_id, None)
|
|
72
|
+
self._records.pop(request_id, None)
|
|
73
|
+
self._notify()
|
|
74
|
+
|
|
75
|
+
def cancel_all(self) -> None:
|
|
76
|
+
"""Cancel every pending future. Used by AgentManager.kill /
|
|
77
|
+
OrchestratorSession.stop."""
|
|
78
|
+
for fut in self._futures.values():
|
|
79
|
+
if not fut.done():
|
|
80
|
+
fut.cancel()
|
|
81
|
+
|
|
82
|
+
def pending(self) -> list[PendingPermission]:
|
|
83
|
+
return list(self._records.values())
|
|
84
|
+
|
|
85
|
+
def _notify(self) -> None:
|
|
86
|
+
if self._on_pending_changed is None:
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
self._on_pending_changed(len(self._futures))
|
|
90
|
+
except Exception:
|
|
91
|
+
log.exception("PermissionInbox.on_pending_changed handler raised")
|
|
@@ -49,6 +49,7 @@ class AgentSession:
|
|
|
49
49
|
self._idle_event.set()
|
|
50
50
|
self._send_lock = asyncio.Lock()
|
|
51
51
|
self._pre_wait_state: AgentState | None = None
|
|
52
|
+
self._pre_perm_state: AgentState | None = None
|
|
52
53
|
|
|
53
54
|
@property
|
|
54
55
|
def session_id(self) -> str | None:
|
|
@@ -205,6 +206,31 @@ class AgentSession:
|
|
|
205
206
|
return
|
|
206
207
|
self._set_state(target)
|
|
207
208
|
|
|
209
|
+
def _mark_awaiting_permission(self) -> None:
|
|
210
|
+
"""Enter AWAITING_PERMISSION, snapshotting the prior state.
|
|
211
|
+
|
|
212
|
+
Idempotent. Composes with _mark_waiting: a session can transition
|
|
213
|
+
RUNNING → AWAITING_PERMISSION → WAITING → AWAITING_PERMISSION
|
|
214
|
+
→ RUNNING and end up where it started. Skipped if terminal.
|
|
215
|
+
"""
|
|
216
|
+
if self.info.state.is_terminal:
|
|
217
|
+
return
|
|
218
|
+
if self.info.state == AgentState.AWAITING_PERMISSION:
|
|
219
|
+
return
|
|
220
|
+
self._pre_perm_state = self.info.state
|
|
221
|
+
self._set_state(AgentState.AWAITING_PERMISSION)
|
|
222
|
+
|
|
223
|
+
def _mark_done_permission(self) -> None:
|
|
224
|
+
"""Exit AWAITING_PERMISSION, restoring the pre-permission state."""
|
|
225
|
+
if self.info.state != AgentState.AWAITING_PERMISSION:
|
|
226
|
+
self._pre_perm_state = None
|
|
227
|
+
return
|
|
228
|
+
target = self._pre_perm_state or AgentState.RUNNING
|
|
229
|
+
self._pre_perm_state = None
|
|
230
|
+
if target.is_terminal:
|
|
231
|
+
return
|
|
232
|
+
self._set_state(target)
|
|
233
|
+
|
|
208
234
|
def _set_state(self, new_state: AgentState) -> None:
|
|
209
235
|
old = self.info.state
|
|
210
236
|
if old == new_state:
|