xdog-coding 2.1.2__tar.gz → 2.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 (100) hide show
  1. xdog_coding-2.2.0/PKG-INFO +95 -0
  2. xdog_coding-2.2.0/README.md +67 -0
  3. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/pyproject.toml +4 -4
  4. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/cli/args.py +10 -1
  5. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/cli/config_selector.py +10 -1
  6. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/config.py +9 -0
  7. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/agent_session.py +36 -3
  8. xdog_coding-2.2.0/src/xdog/coding/core/permissions.py +275 -0
  9. xdog_coding-2.2.0/src/xdog/coding/core/remote_session.py +39 -0
  10. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/sdk.py +41 -11
  11. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/slash_commands.py +6 -3
  12. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/main.py +8 -2
  13. xdog_coding-2.2.0/src/xdog/coding/modes/interactive/components/assistant_message.py +63 -0
  14. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/interactive/components/chat_log.py +64 -27
  15. xdog_coding-2.2.0/src/xdog/coding/modes/interactive/components/custom_editor.py +19 -0
  16. xdog_coding-2.2.0/src/xdog/coding/modes/interactive/components/editor_layout.py +5 -0
  17. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/interactive/components/footer.py +29 -2
  18. xdog_coding-2.2.0/src/xdog/coding/modes/interactive/components/permission_prompt.py +91 -0
  19. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/interactive/components/tool_execution.py +119 -64
  20. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/interactive/components/user_message.py +2 -1
  21. xdog_coding-2.2.0/src/xdog/coding/modes/interactive/interactive_mode.py +967 -0
  22. xdog_coding-2.2.0/src/xdog/coding/modes/interactive/tool_renderers.py +24 -0
  23. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/print_mode.py +49 -0
  24. xdog_coding-2.2.0/src/xdog/coding/modes/rpc/rpc_mode.py +231 -0
  25. xdog_coding-2.2.0/tests/fixtures/terminal_client.py +83 -0
  26. xdog_coding-2.2.0/tests/test_cli_args.py +44 -0
  27. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_cli_config_selector.py +3 -2
  28. xdog_coding-2.2.0/tests/test_compact_layout.py +471 -0
  29. xdog_coding-2.2.0/tests/test_footer_context.py +40 -0
  30. xdog_coding-2.2.0/tests/test_interactive_mode.py +875 -0
  31. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_messages.py +27 -5
  32. xdog_coding-2.2.0/tests/test_permissions.py +121 -0
  33. xdog_coding-2.2.0/tests/test_rpc_mode.py +93 -0
  34. xdog_coding-2.2.0/tests/test_sdk_session_state.py +63 -0
  35. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_session_smoke.py +23 -0
  36. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_slash_commands.py +23 -0
  37. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_slash_skills.py +1 -1
  38. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_system_prompt.py +2 -0
  39. xdog_coding-2.2.0/tests/test_terminal_acceptance.py +261 -0
  40. xdog_coding-2.1.2/PKG-INFO +0 -55
  41. xdog_coding-2.1.2/README.md +0 -27
  42. xdog_coding-2.1.2/src/xdog/coding/modes/interactive/components/assistant_message.py +0 -22
  43. xdog_coding-2.1.2/src/xdog/coding/modes/interactive/components/custom_editor.py +0 -260
  44. xdog_coding-2.1.2/src/xdog/coding/modes/interactive/interactive_mode.py +0 -543
  45. xdog_coding-2.1.2/src/xdog/coding/modes/rpc/rpc_mode.py +0 -187
  46. xdog_coding-2.1.2/tests/test_cli_args.py +0 -23
  47. xdog_coding-2.1.2/tests/test_footer_context.py +0 -18
  48. xdog_coding-2.1.2/tests/test_interactive_mode.py +0 -145
  49. xdog_coding-2.1.2/tests/test_rpc_mode.py +0 -10
  50. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/.gitignore +0 -0
  51. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/LICENSE +0 -0
  52. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/LICENSE-EXCEPTION.md +0 -0
  53. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/__init__.py +0 -0
  54. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/cli/__init__.py +0 -0
  55. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/cli/file_processor.py +0 -0
  56. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/cli/initial_message.py +0 -0
  57. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/cli/list_models.py +0 -0
  58. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/cli/session_picker.py +0 -0
  59. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/__init__.py +0 -0
  60. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/bash_executor.py +0 -0
  61. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/compaction/__init__.py +0 -0
  62. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/compaction/compaction.py +0 -0
  63. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/compaction/utils.py +0 -0
  64. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/defaults.py +0 -0
  65. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/event_bus.py +0 -0
  66. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/exec_utils.py +0 -0
  67. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/extensions/__init__.py +0 -0
  68. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/extensions/loader.py +0 -0
  69. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/extensions/runner.py +0 -0
  70. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/extensions/types.py +0 -0
  71. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/keybindings.py +0 -0
  72. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/messages.py +0 -0
  73. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/prompt_templates.py +0 -0
  74. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/resource_loader.py +0 -0
  75. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/session_manager.py +0 -0
  76. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/settings_manager.py +0 -0
  77. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/system_prompt.py +0 -0
  78. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/timings.py +0 -0
  79. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/core/tools/__init__.py +0 -0
  80. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/__init__.py +0 -0
  81. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/interactive/__init__.py +0 -0
  82. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/interactive/components/__init__.py +0 -0
  83. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/interactive/theme.py +0 -0
  84. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/modes/rpc/__init__.py +0 -0
  85. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/py.typed +0 -0
  86. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/utils/__init__.py +0 -0
  87. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/utils/frontmatter.py +0 -0
  88. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/utils/git.py +0 -0
  89. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/utils/mime.py +0 -0
  90. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/utils/shell.py +0 -0
  91. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/src/xdog/coding/utils/sleep.py +0 -0
  92. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_agent_session.py +0 -0
  93. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_cli_file_processor.py +0 -0
  94. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_coding_config.py +0 -0
  95. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_event_bus.py +0 -0
  96. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_prompt_templates.py +0 -0
  97. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_resource_loader.py +0 -0
  98. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_session_manager.py +0 -0
  99. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_settings_manager.py +0 -0
  100. {xdog_coding-2.1.2 → xdog_coding-2.2.0}/tests/test_timings.py +0 -0
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: xdog-coding
3
+ Version: 2.2.0
4
+ Summary: Interactive coding agent CLI
5
+ Project-URL: Homepage, https://xdog.942295.xyz
6
+ Project-URL: Repository, https://github.com/litterzhang/xdog
7
+ Project-URL: Documentation, https://xdog.942295.xyz
8
+ Project-URL: Issues, https://github.com/litterzhang/xdog/issues
9
+ Author: HugeMan
10
+ License-Expression: AGPL-3.0-or-later
11
+ License-File: LICENSE
12
+ License-File: LICENSE-EXCEPTION.md
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: click>=8.1.0
22
+ Requires-Dist: pydantic>=2.0
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: xdog-agent>=2.2.0
25
+ Requires-Dist: xdog-ai[openai]>=2.2.0
26
+ Requires-Dist: xdog-tui>=2.2.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # xdog-coding
30
+
31
+ **Interactive coding-agent CLI.**
32
+
33
+ A terminal coding agent with session management, built on
34
+ [`xdog-agent`](https://pypi.org/project/xdog-agent/) and the
35
+ [`xdog-tui`](https://pypi.org/project/xdog-tui/) rendering layer.
36
+
37
+ ```bash
38
+ uv run xdog-coding
39
+ ```
40
+
41
+ ## Interactive terminal
42
+
43
+ The interface uses native scrollback, one status row, and a shared multiline
44
+ editor. Enter submits or queues; Shift/Alt+Enter inserts a newline. Ctrl+O opens
45
+ recent tool/reasoning details: Left/Right chooses an entry and Page Up/Page Down
46
+ scrolls. Escape closes autocomplete/details first, then cancels active work and
47
+ restores queued text ahead of the current draft. Ctrl+Z suspends on POSIX.
48
+
49
+ Permission prompts take priority. Up/Down chooses an action, Enter confirms,
50
+ Escape denies that call, and Page Up/Page Down scrolls the command summary.
51
+ The editor draft stays intact when panels open or the terminal resizes.
52
+ See [terminal verification](../../docs/tui-pi-parity.md); remote coding and
53
+ fullscreen application modes are excluded.
54
+
55
+ ## Tool permissions
56
+
57
+ Potentially mutating tool calls are gated immediately before execution. By
58
+ default, read-only filesystem operations and `current_time` run automatically;
59
+ `bash`, filesystem writes/edits/deletes, and unknown extension tools ask first.
60
+
61
+ ```bash
62
+ xdog-coding --permission-mode ask # default: ask for dangerous calls
63
+ xdog-coding --permission-mode ask-all # ask before every tool
64
+ xdog-coding --permission-mode allow-all # trusted unattended automation
65
+ xdog-coding --permission-mode deny # allow read-only calls only
66
+ ```
67
+
68
+ Interactive approvals offer **Allow once**, **Allow for this session** (the exact
69
+ same call only), and **Deny**. Non-interactive runs fail closed when no approver
70
+ is available unless `allow-all` was explicitly selected.
71
+
72
+ The same setting can be stored in `~/.config/xdog/coding/settings.json`:
73
+
74
+ ```json
75
+ {"permission_mode": "ask"}
76
+ ```
77
+
78
+ Permission prompts are an execution gate, not an OS sandbox: an approved shell
79
+ command still has the permissions of the `xdog-coding` process.
80
+
81
+ ## Part of xdog
82
+
83
+ This package is one piece of [xdog](https://github.com/litterzhang/xdog), a
84
+ local-first toolkit for building, running and scheduling LLM workflows. The
85
+ centrepiece is [`xdog-flow`](https://pypi.org/project/xdog-flow/) — a typed
86
+ workflow format and compiler.
87
+
88
+ Documentation: **https://xdog.942295.xyz**
89
+
90
+ ## Licence
91
+
92
+ Copyright (c) 2026 HugeMan <942295.xyz>
93
+
94
+ GNU Affero General Public License v3.0 or later — see
95
+ [LICENSE](https://github.com/litterzhang/xdog/blob/main/LICENSE).
@@ -0,0 +1,67 @@
1
+ # xdog-coding
2
+
3
+ **Interactive coding-agent CLI.**
4
+
5
+ A terminal coding agent with session management, built on
6
+ [`xdog-agent`](https://pypi.org/project/xdog-agent/) and the
7
+ [`xdog-tui`](https://pypi.org/project/xdog-tui/) rendering layer.
8
+
9
+ ```bash
10
+ uv run xdog-coding
11
+ ```
12
+
13
+ ## Interactive terminal
14
+
15
+ The interface uses native scrollback, one status row, and a shared multiline
16
+ editor. Enter submits or queues; Shift/Alt+Enter inserts a newline. Ctrl+O opens
17
+ recent tool/reasoning details: Left/Right chooses an entry and Page Up/Page Down
18
+ scrolls. Escape closes autocomplete/details first, then cancels active work and
19
+ restores queued text ahead of the current draft. Ctrl+Z suspends on POSIX.
20
+
21
+ Permission prompts take priority. Up/Down chooses an action, Enter confirms,
22
+ Escape denies that call, and Page Up/Page Down scrolls the command summary.
23
+ The editor draft stays intact when panels open or the terminal resizes.
24
+ See [terminal verification](../../docs/tui-pi-parity.md); remote coding and
25
+ fullscreen application modes are excluded.
26
+
27
+ ## Tool permissions
28
+
29
+ Potentially mutating tool calls are gated immediately before execution. By
30
+ default, read-only filesystem operations and `current_time` run automatically;
31
+ `bash`, filesystem writes/edits/deletes, and unknown extension tools ask first.
32
+
33
+ ```bash
34
+ xdog-coding --permission-mode ask # default: ask for dangerous calls
35
+ xdog-coding --permission-mode ask-all # ask before every tool
36
+ xdog-coding --permission-mode allow-all # trusted unattended automation
37
+ xdog-coding --permission-mode deny # allow read-only calls only
38
+ ```
39
+
40
+ Interactive approvals offer **Allow once**, **Allow for this session** (the exact
41
+ same call only), and **Deny**. Non-interactive runs fail closed when no approver
42
+ is available unless `allow-all` was explicitly selected.
43
+
44
+ The same setting can be stored in `~/.config/xdog/coding/settings.json`:
45
+
46
+ ```json
47
+ {"permission_mode": "ask"}
48
+ ```
49
+
50
+ Permission prompts are an execution gate, not an OS sandbox: an approved shell
51
+ command still has the permissions of the `xdog-coding` process.
52
+
53
+ ## Part of xdog
54
+
55
+ This package is one piece of [xdog](https://github.com/litterzhang/xdog), a
56
+ local-first toolkit for building, running and scheduling LLM workflows. The
57
+ centrepiece is [`xdog-flow`](https://pypi.org/project/xdog-flow/) — a typed
58
+ workflow format and compiler.
59
+
60
+ Documentation: **https://xdog.942295.xyz**
61
+
62
+ ## Licence
63
+
64
+ Copyright (c) 2026 HugeMan <942295.xyz>
65
+
66
+ GNU Affero General Public License v3.0 or later — see
67
+ [LICENSE](https://github.com/litterzhang/xdog/blob/main/LICENSE).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xdog-coding"
7
- version = "2.1.2"
7
+ version = "2.2.0"
8
8
  description = "Interactive coding agent CLI"
9
9
  requires-python = ">=3.12"
10
10
  classifiers = [
@@ -21,9 +21,9 @@ readme = "README.md"
21
21
  license = "AGPL-3.0-or-later"
22
22
  license-files = ["LICENSE", "LICENSE-EXCEPTION.md"]
23
23
  dependencies = [
24
- "xdog-ai>=2.1.2",
25
- "xdog-agent>=2.1.2",
26
- "xdog-tui>=2.1.2",
24
+ "xdog-ai[openai]>=2.2.0",
25
+ "xdog-agent>=2.2.0",
26
+ "xdog-tui>=2.2.0",
27
27
  "pyyaml>=6.0",
28
28
  "click>=8.1.0",
29
29
  "pydantic>=2.0",
@@ -60,10 +60,16 @@ from xdog.coding.cli.session_picker import pick_session_command
60
60
  )
61
61
  @click.option(
62
62
  "--thinking-level",
63
- type=click.Choice(["none", "normal", "deep", "ultrathink"]),
63
+ type=click.Choice(["off", "minimal", "low", "medium", "high", "xhigh"]),
64
64
  default=None,
65
65
  help="Thinking / reasoning level.",
66
66
  )
67
+ @click.option(
68
+ "--permission-mode",
69
+ type=click.Choice(["ask", "ask-all", "allow-all", "deny"]),
70
+ default=None,
71
+ help="Tool permission policy.",
72
+ )
67
73
  @click.option(
68
74
  "--list-models",
69
75
  is_flag=True,
@@ -99,6 +105,7 @@ def cli(
99
105
  working_dir: Path | None,
100
106
  config_path: Path | None,
101
107
  thinking_level: str | None,
108
+ permission_mode: str | None,
102
109
  list_models: bool,
103
110
  pick_session: bool,
104
111
  rpc: bool,
@@ -124,6 +131,8 @@ def cli(
124
131
  overrides["model"] = model
125
132
  if thinking_level is not None:
126
133
  overrides["thinking_level"] = thinking_level
134
+ if permission_mode is not None:
135
+ overrides["permission_mode"] = permission_mode
127
136
 
128
137
  # Defer to the main entry-point for actual execution
129
138
  from xdog.coding.main import run_agent
@@ -57,10 +57,19 @@ def config_selector(config_path: Path | None = None) -> GlobalConfig:
57
57
  cfg = cfg.model_copy(update={"default_model": new_model})
58
58
 
59
59
  # Thinking level
60
- new_thinking = input(f"Thinking level [{cfg.thinking_level}] (off/normal/high): ").strip()
60
+ new_thinking = input(
61
+ f"Thinking level [{cfg.thinking_level}] (off/minimal/low/medium/high/xhigh): "
62
+ ).strip()
61
63
  if new_thinking:
62
64
  cfg = cfg.model_copy(update={"thinking_level": new_thinking})
63
65
 
66
+ # Tool permissions
67
+ new_permissions = input(
68
+ f"Permission mode [{cfg.permission_mode}] (ask/ask-all/allow-all/deny): "
69
+ ).strip()
70
+ if new_permissions:
71
+ cfg = cfg.model_copy(update={"permission_mode": new_permissions})
72
+
64
73
  # Custom instructions
65
74
  if cfg.custom_instructions:
66
75
  print(f"\nCurrent custom instructions: {cfg.custom_instructions[:80]}...")
@@ -172,6 +172,7 @@ class GlobalConfig(BaseModel):
172
172
 
173
173
  default_model: str = ""
174
174
  thinking_level: str = "normal"
175
+ permission_mode: str = "ask"
175
176
  extensions: list[str] = Field(default_factory=list)
176
177
  allowed_tools: list[str] = Field(default_factory=list)
177
178
  custom_instructions: str = ""
@@ -207,6 +208,7 @@ class ProjectConfig(BaseModel):
207
208
 
208
209
  model: str | None = None
209
210
  thinking_level: str | None = None
211
+ permission_mode: str | None = None
210
212
  allowed_tools: list[str] = Field(default_factory=list)
211
213
  custom_instructions: str = ""
212
214
  extensions: list[str] = Field(default_factory=list)
@@ -233,6 +235,7 @@ class RuntimeConfig:
233
235
 
234
236
  model: str
235
237
  thinking_level: str
238
+ permission_mode: str
236
239
  allowed_tools: tuple[str, ...]
237
240
  custom_instructions: str
238
241
  extensions: tuple[str, ...]
@@ -253,6 +256,11 @@ class RuntimeConfig:
253
256
  pcfg = project_cfg or ProjectConfig()
254
257
  model = ov.get("model") or pcfg.model or global_cfg.default_model
255
258
  thinking = ov.get("thinking_level") or pcfg.thinking_level or global_cfg.thinking_level
259
+ permission_mode = (
260
+ ov.get("permission_mode")
261
+ or pcfg.permission_mode
262
+ or global_cfg.permission_mode
263
+ )
256
264
 
257
265
  merged_tools = list(global_cfg.allowed_tools) + list(pcfg.allowed_tools)
258
266
  merged_ext = list(global_cfg.extensions) + list(pcfg.extensions)
@@ -266,6 +274,7 @@ class RuntimeConfig:
266
274
  return cls(
267
275
  model=model,
268
276
  thinking_level=thinking,
277
+ permission_mode=permission_mode,
269
278
  allowed_tools=tuple(merged_tools),
270
279
  custom_instructions="\n\n".join(instructions_parts),
271
280
  extensions=tuple(merged_ext),
@@ -24,6 +24,7 @@ from xdog.ai.types import (
24
24
  from xdog.coding.core.defaults import COMPACTION_THRESHOLD_RATIO, MAX_CONTEXT_TOKENS
25
25
  from xdog.coding.core.event_bus import EventBus, get_event_bus
26
26
  from xdog.coding.core.messages import dicts_to_messages, messages_to_dicts
27
+ from xdog.coding.core.permissions import PermissionManager
27
28
  from xdog.coding.core.session_manager import SessionData, SessionManager
28
29
  from xdog.coding.core.settings_manager import SettingsManager
29
30
  from xdog.coding.core.system_prompt import build_system_prompt
@@ -47,6 +48,9 @@ class AgentSession:
47
48
  tool_registry: Any # kept for backward compat, may be None
48
49
  bash: Any # kept for backward compat, may be None
49
50
  working_dir: Path
51
+ context_window: int = MAX_CONTEXT_TOKENS
52
+ max_prompt_tokens: int = 0
53
+ permissions: PermissionManager = field(default_factory=PermissionManager)
50
54
  event_bus: EventBus = field(default_factory=get_event_bus)
51
55
  _unsubscribe: Any = field(default=None, init=False, repr=False)
52
56
  #: Slugs of skills whose instructions are currently in the system prompt.
@@ -59,6 +63,9 @@ class AgentSession:
59
63
  if self.session_data.settings:
60
64
  self.settings.load_session_settings(self.session_data.settings)
61
65
 
66
+ # Sessions constructed outside the SDK still receive enforcement.
67
+ self.agent.set_before_tool_call(self.permissions.before_tool_call)
68
+
62
69
  # Subscribe to agent events for persistence and event bus forwarding
63
70
  self._unsubscribe = self.agent.subscribe(self._on_agent_event)
64
71
 
@@ -80,6 +87,11 @@ class AgentSession:
80
87
  def is_streaming(self) -> bool:
81
88
  return self.agent.state.is_streaming
82
89
 
90
+ @property
91
+ def context_limit(self) -> int:
92
+ """Prompt limit used for compaction; falls back to the model window."""
93
+ return self.max_prompt_tokens or self.context_window or MAX_CONTEXT_TOKENS
94
+
83
95
  # -- Public API --
84
96
 
85
97
  @property
@@ -176,17 +188,35 @@ class AgentSession:
176
188
  """Cancel the current agent turn."""
177
189
  self.agent.abort()
178
190
 
191
+ def cancel(self) -> None:
192
+ """Cancel active work and discard agent-level queued messages."""
193
+ self.agent.clear_all_queues()
194
+ self.permissions.deny_all()
195
+ self.agent.abort()
196
+
179
197
  # -- Model / thinking --
180
198
 
181
199
  def set_model(self, model: str) -> None:
182
- """Switch the active model."""
200
+ """Switch the active model and refresh its provider-reported limits."""
183
201
  self.agent.set_model(model)
202
+ try:
203
+ import xdog.ai as ai
204
+
205
+ model_info = ai.provider("copilot").model(model)
206
+ if model_info is not None:
207
+ self.context_window = model_info.context_window or MAX_CONTEXT_TOKENS
208
+ self.max_prompt_tokens = model_info.max_prompt_tokens
209
+ except Exception:
210
+ logger.debug("could not refresh model limits for %s", model, exc_info=True)
211
+ self.settings.set_session_model(model)
184
212
  self._persist()
185
213
 
186
214
  def set_thinking_level(self, level: ThinkingLevel | None) -> None:
187
- """Switch the thinking/reasoning level."""
215
+ """Switch and persist the thinking/reasoning level."""
188
216
  from dataclasses import replace
217
+
189
218
  self.agent.set_options(replace(self.agent.options, thinking=level))
219
+ self.settings.set_session_thinking(level or "off")
190
220
  self._persist()
191
221
 
192
222
  # -- Compaction --
@@ -222,7 +252,7 @@ class AgentSession:
222
252
  char_count += 200
223
253
 
224
254
  estimated_tokens = char_count / 4
225
- threshold = MAX_CONTEXT_TOKENS * COMPACTION_THRESHOLD_RATIO
255
+ threshold = self.context_limit * COMPACTION_THRESHOLD_RATIO
226
256
 
227
257
  if estimated_tokens > threshold:
228
258
  await self._run_compaction()
@@ -257,6 +287,7 @@ class AgentSession:
257
287
  return False
258
288
 
259
289
  self.session_data = loaded
290
+ self.permissions.clear_session_rules()
260
291
  restored_messages = list(self.session_data.messages)
261
292
  self.agent.replace_messages(restored_messages)
262
293
 
@@ -335,6 +366,7 @@ class AgentSession:
335
366
  RuntimeConfig(
336
367
  model=self.agent.state.model or "unknown",
337
368
  thinking_level=str(self.agent.options.thinking or "normal"),
369
+ permission_mode=self.permissions.mode,
338
370
  allowed_tools=tuple(t.name for t in self.agent.state.tools),
339
371
  custom_instructions=self.settings.custom_instructions,
340
372
  extensions=(),
@@ -372,6 +404,7 @@ class AgentSession:
372
404
 
373
405
  def dispose(self) -> None:
374
406
  """Clean up resources."""
407
+ self.permissions.close()
375
408
  if self._unsubscribe:
376
409
  self._unsubscribe()
377
410
  self._unsubscribe = None
@@ -0,0 +1,275 @@
1
+ """Runtime permission policy and approval broker for coding-agent tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ import threading
9
+ import uuid
10
+ from collections.abc import Callable, Mapping
11
+ from dataclasses import dataclass
12
+ from typing import Any, Literal, cast
13
+
14
+ from xdog.agent import BeforeToolCallContext, BeforeToolCallResult
15
+
16
+ PermissionMode = Literal["ask", "ask-all", "allow-all", "deny"]
17
+ PermissionDecision = Literal["allow_once", "allow_session", "deny"]
18
+ PermissionRequestHandler = Callable[["PermissionRequest"], None]
19
+
20
+ _PERMISSION_MODES: tuple[PermissionMode, ...] = ("ask", "ask-all", "allow-all", "deny")
21
+ _ALLOW_DECISIONS = ("allow_once", "allow_session")
22
+ _READ_ONLY_FS_ACTIONS = frozenset({"read", "ls", "grep", "find"})
23
+ _SECRET_KEY = re.compile(r"(?:password|passwd|secret|token|authorization|api[_-]?key|credential)", re.I)
24
+ _MAX_DISPLAY_STRING = 2_000
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class PermissionRequest:
29
+ """A tool call waiting for a decision from a user-facing approver."""
30
+
31
+ id: str
32
+ tool_name: str
33
+ arguments: dict[str, Any]
34
+ summary: str
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class _PendingApproval:
39
+ loop: asyncio.AbstractEventLoop
40
+ future: asyncio.Future[PermissionDecision]
41
+ rule_key: str
42
+
43
+
44
+ def normalize_permission_mode(raw: object) -> PermissionMode:
45
+ """Return a valid mode, failing closed for malformed configuration."""
46
+ if isinstance(raw, str) and raw in _PERMISSION_MODES:
47
+ return raw
48
+ return "deny"
49
+
50
+
51
+ def tool_call_requires_approval(
52
+ mode: PermissionMode,
53
+ tool_name: str,
54
+ arguments: Mapping[str, Any],
55
+ ) -> bool:
56
+ """Classify whether a call needs an external decision under *mode*."""
57
+ if mode == "allow-all":
58
+ return False
59
+ if mode == "ask-all":
60
+ return True
61
+
62
+ # `deny` shares the safe read-only allowlist with `ask`; everything else is
63
+ # blocked without prompting.
64
+ if tool_name == "current_time":
65
+ return False
66
+ if tool_name == "filesystem":
67
+ action = arguments.get("action")
68
+ return not isinstance(action, str) or action not in _READ_ONLY_FS_ACTIONS
69
+ return True
70
+
71
+
72
+ def _redact(value: Any, *, key: str = "") -> Any:
73
+ if key and _SECRET_KEY.search(key):
74
+ return "<redacted>"
75
+ if isinstance(value, Mapping):
76
+ return {str(k): _redact(v, key=str(k)) for k, v in value.items()}
77
+ if isinstance(value, (list, tuple)):
78
+ return [_redact(item) for item in value]
79
+ if isinstance(value, str) and len(value) > _MAX_DISPLAY_STRING:
80
+ omitted = len(value) - _MAX_DISPLAY_STRING
81
+ return f"{value[:_MAX_DISPLAY_STRING]}… <{omitted} chars omitted>"
82
+ return value
83
+
84
+
85
+ def _request_arguments(tool_name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
86
+ """Return redacted, bounded arguments suitable for terminals and RPC."""
87
+ displayed = cast(dict[str, Any], _redact(dict(arguments)))
88
+ if tool_name == "filesystem":
89
+ content = arguments.get("content")
90
+ if isinstance(content, str):
91
+ displayed["content"] = f"<{len(content)} characters>"
92
+ return displayed
93
+
94
+
95
+ def _summary(tool_name: str, arguments: Mapping[str, Any]) -> str:
96
+ if tool_name == "bash":
97
+ command = str(arguments.get("command", "")).strip()
98
+ if len(command) > 500:
99
+ command = command[:500] + "…"
100
+ return f"Run shell command:\n{command}" if command else "Run a shell command"
101
+ if tool_name == "filesystem":
102
+ action = str(arguments.get("action", "unknown"))
103
+ path = str(arguments.get("path", ""))
104
+ if action == "write":
105
+ size = len(str(arguments.get("content", "")))
106
+ return f"Write {size} characters to {path or '(unknown path)'}"
107
+ if action == "edit":
108
+ return f"Edit {path or '(unknown path)'}"
109
+ if action == "delete":
110
+ return f"Delete {path or '(unknown path)'}"
111
+ return f"Filesystem {action}: {path}".rstrip()
112
+ try:
113
+ rendered = json.dumps(_redact(dict(arguments)), ensure_ascii=False, sort_keys=True)
114
+ except (TypeError, ValueError):
115
+ rendered = repr(arguments)
116
+ return f"Run {tool_name}: {rendered}"
117
+
118
+
119
+ def _rule_key(tool_name: str, arguments: Mapping[str, Any]) -> str:
120
+ """Build a conservative exact-call session rule key."""
121
+ try:
122
+ encoded = json.dumps(dict(arguments), ensure_ascii=False, sort_keys=True, default=repr)
123
+ except (TypeError, ValueError):
124
+ encoded = repr(sorted((str(k), repr(v)) for k, v in arguments.items()))
125
+ return f"{tool_name}\0{encoded}"
126
+
127
+
128
+ class PermissionManager:
129
+ """Enforce tool policy and bridge async tool calls to a UI approver.
130
+
131
+ The hook runs in the agent's asyncio loop, which is a background thread in
132
+ interactive mode. Request handlers may run on that thread, but decisions may
133
+ be submitted from any thread; :meth:`resolve` schedules completion on the
134
+ future's owning event loop.
135
+ """
136
+
137
+ def __init__(self, mode: object = "ask") -> None:
138
+ self._mode = normalize_permission_mode(mode)
139
+ self._handler: PermissionRequestHandler | None = None
140
+ self._pending: dict[str, _PendingApproval] = {}
141
+ self._session_rules: set[str] = set()
142
+ self._lock = threading.RLock()
143
+ self._closed = False
144
+
145
+ @property
146
+ def mode(self) -> PermissionMode:
147
+ return self._mode
148
+
149
+ @property
150
+ def has_approver(self) -> bool:
151
+ with self._lock:
152
+ return self._handler is not None and not self._closed
153
+
154
+ def set_mode(self, mode: object) -> None:
155
+ self._mode = normalize_permission_mode(mode)
156
+
157
+ def set_request_handler(self, handler: PermissionRequestHandler | None) -> None:
158
+ with self._lock:
159
+ self._handler = handler
160
+
161
+ def clear_session_rules(self) -> None:
162
+ with self._lock:
163
+ self._session_rules.clear()
164
+
165
+ async def before_tool_call(
166
+ self,
167
+ ctx: BeforeToolCallContext,
168
+ cancel: asyncio.Event | None = None,
169
+ ) -> BeforeToolCallResult | None:
170
+ """Agent hook that permits, waits for approval, or blocks a tool call."""
171
+ args = dict(ctx.args) if isinstance(ctx.args, Mapping) else {}
172
+ tool_name = ctx.tool_call.name
173
+
174
+ if not tool_call_requires_approval(self._mode, tool_name, args):
175
+ return None
176
+
177
+ rule_key = _rule_key(tool_name, args)
178
+ with self._lock:
179
+ if rule_key in self._session_rules:
180
+ return None
181
+ handler = self._handler
182
+ closed = self._closed
183
+
184
+ if self._mode == "deny":
185
+ return BeforeToolCallResult(
186
+ block=True,
187
+ reason="Tool call denied by permission policy",
188
+ )
189
+ if closed or handler is None:
190
+ return BeforeToolCallResult(
191
+ block=True,
192
+ reason="Tool call requires approval, but no permission approver is available",
193
+ )
194
+
195
+ request = PermissionRequest(
196
+ id=uuid.uuid4().hex,
197
+ tool_name=tool_name,
198
+ arguments=_request_arguments(tool_name, args),
199
+ summary=_summary(tool_name, args),
200
+ )
201
+ loop = asyncio.get_running_loop()
202
+ future: asyncio.Future[PermissionDecision] = loop.create_future()
203
+ pending = _PendingApproval(loop=loop, future=future, rule_key=rule_key)
204
+ with self._lock:
205
+ if self._closed:
206
+ return BeforeToolCallResult(block=True, reason="Permission manager is closed")
207
+ self._pending[request.id] = pending
208
+
209
+ cancel_task: asyncio.Task[PermissionDecision] | None = None
210
+ try:
211
+ handler(request)
212
+ if cancel is not None:
213
+ async def _wait_for_cancel() -> PermissionDecision:
214
+ await cancel.wait()
215
+ return "deny"
216
+
217
+ cancel_task = asyncio.create_task(_wait_for_cancel())
218
+ waiters: set[asyncio.Future[PermissionDecision]] = {future, cancel_task}
219
+ done, _ = await asyncio.wait(
220
+ waiters,
221
+ return_when=asyncio.FIRST_COMPLETED,
222
+ )
223
+ if cancel_task in done and future not in done:
224
+ return BeforeToolCallResult(
225
+ block=True,
226
+ reason="Tool approval was cancelled",
227
+ )
228
+ decision = await future
229
+ except Exception as exc:
230
+ return BeforeToolCallResult(
231
+ block=True,
232
+ reason=f"Could not obtain tool approval: {exc}",
233
+ )
234
+ finally:
235
+ if cancel_task is not None and not cancel_task.done():
236
+ cancel_task.cancel()
237
+ with self._lock:
238
+ self._pending.pop(request.id, None)
239
+
240
+ if decision == "allow_session":
241
+ with self._lock:
242
+ self._session_rules.add(rule_key)
243
+ if decision in _ALLOW_DECISIONS:
244
+ return None
245
+ return BeforeToolCallResult(block=True, reason="User denied this tool call")
246
+
247
+ def resolve(self, request_id: str, decision: PermissionDecision) -> bool:
248
+ """Resolve a pending request from any thread. Return False if unknown."""
249
+ if decision not in ("allow_once", "allow_session", "deny"):
250
+ return False
251
+ with self._lock:
252
+ pending = self._pending.get(request_id)
253
+ if pending is None:
254
+ return False
255
+
256
+ def _set_result() -> None:
257
+ if not pending.future.done():
258
+ pending.future.set_result(decision)
259
+
260
+ pending.loop.call_soon_threadsafe(_set_result)
261
+ return True
262
+
263
+ def deny_all(self) -> None:
264
+ """Deny every pending request without closing the manager."""
265
+ with self._lock:
266
+ request_ids = list(self._pending)
267
+ for request_id in request_ids:
268
+ self.resolve(request_id, "deny")
269
+
270
+ def close(self) -> None:
271
+ """Fail all pending requests and reject future interactive approvals."""
272
+ with self._lock:
273
+ self._closed = True
274
+ self._handler = None
275
+ self.deny_all()