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.
Files changed (82) hide show
  1. {patchbai-0.1.2 → patchbai-0.2.0}/PKG-INFO +13 -3
  2. {patchbai-0.1.2 → patchbai-0.2.0}/README.md +12 -2
  3. patchbai-0.2.0/patchbai/__main__.py +32 -0
  4. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/manager.py +102 -9
  5. patchbai-0.2.0/patchbai/agents/permission_grants.py +98 -0
  6. patchbai-0.2.0/patchbai/agents/permission_inbox.py +91 -0
  7. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/session.py +26 -0
  8. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/state.py +1 -0
  9. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/app.py +134 -1
  10. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/events.py +24 -0
  11. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/session.py +146 -5
  12. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/agent_table.py +1 -0
  13. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/agent_transcript.py +28 -1
  14. patchbai-0.2.0/patchbai/widgets/permission_modal.py +185 -0
  15. patchbai-0.2.0/patchbai/widgets/permission_request_bar.py +90 -0
  16. {patchbai-0.1.2 → patchbai-0.2.0}/pyproject.toml +1 -1
  17. patchbai-0.1.2/patchbai/__main__.py +0 -10
  18. {patchbai-0.1.2 → patchbai-0.2.0}/.gitignore +0 -0
  19. {patchbai-0.1.2 → patchbai-0.2.0}/LICENSE +0 -0
  20. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/__init__.py +0 -0
  21. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/actions.py +0 -0
  22. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/activity/__init__.py +0 -0
  23. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/activity/log.py +0 -0
  24. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/__init__.py +0 -0
  25. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/child_tools.py +0 -0
  26. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/fake_sdk_adapter.py +0 -0
  27. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/request_inbox.py +0 -0
  28. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/sdk_adapter.py +0 -0
  29. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/agents/sort.py +0 -0
  30. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/config.py +0 -0
  31. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/__init__.py +0 -0
  32. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/custom_widgets.py +0 -0
  33. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/defaults.py +0 -0
  34. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/engine.py +0 -0
  35. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/local_widgets.py +0 -0
  36. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/registry.py +0 -0
  37. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/spec.py +0 -0
  38. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/splitter.py +0 -0
  39. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/layout/titles.py +0 -0
  40. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/__init__.py +0 -0
  41. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/formatting.py +0 -0
  42. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/tabs_tools.py +0 -0
  43. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/orchestrator/tools.py +0 -0
  44. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/__init__.py +0 -0
  45. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/agents_index.py +0 -0
  46. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/atomic.py +0 -0
  47. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/layout_store.py +0 -0
  48. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/layouts_store.py +0 -0
  49. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/orchestrator_sessions.py +0 -0
  50. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/paths.py +0 -0
  51. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/themes_store.py +0 -0
  52. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/transcript_store.py +0 -0
  53. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/persistence/workspace_store.py +0 -0
  54. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/theme/__init__.py +0 -0
  55. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/theme/engine.py +0 -0
  56. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/theme/spec.py +0 -0
  57. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/__init__.py +0 -0
  58. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/_file_lang.py +0 -0
  59. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/_terminal_keys.py +0 -0
  60. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/_terminal_render.py +0 -0
  61. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/activity_feed.py +0 -0
  62. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/change_cwd_screen.py +0 -0
  63. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/chrome.py +0 -0
  64. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/diff_viewer.py +0 -0
  65. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/file_editor.py +0 -0
  66. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/file_tree.py +0 -0
  67. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/file_viewer.py +0 -0
  68. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/history_screen.py +0 -0
  69. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/layout_switcher.py +0 -0
  70. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/log_tail.py +0 -0
  71. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/markdown.py +0 -0
  72. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/new_tab_screen.py +0 -0
  73. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/notebook.py +0 -0
  74. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/orchestrator_chat.py +0 -0
  75. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/resume_screen.py +0 -0
  76. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/rich_transcript.py +0 -0
  77. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/system_usage.py +0 -0
  78. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/terminal.py +0 -0
  79. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/theme_switcher.py +0 -0
  80. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/widgets/transcript_screen.py +0 -0
  81. {patchbai-0.1.2 → patchbai-0.2.0}/patchbai/workspace/__init__.py +0 -0
  82. {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.1.2
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 ClaudeAgentOptions
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
- # Bypass permissions for now: there's no Textual modal to render
117
- # the SDK's permission prompts in plan 2, so the child would hang.
118
- # The orchestrator can still narrow what a child may do via the
119
- # allowed_tools / disallowed_tools args on the spawn_agent MCP tool.
120
- # A proper can_use_tool callback that pops a Textual approval modal
121
- # is plan-3 work.
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:
@@ -6,6 +6,7 @@ class AgentState(str, Enum):
6
6
  IDLE = "idle"
7
7
  RUNNING = "running"
8
8
  WAITING = "waiting"
9
+ AWAITING_PERMISSION = "awaiting_permission"
9
10
  DONE = "done"
10
11
  ERROR = "error"
11
12