sahya-code 1.0.0__py3-none-any.whl

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 (239) hide show
  1. sahya_code/CHANGELOG.md +108 -0
  2. sahya_code/__init__.py +29 -0
  3. sahya_code/__main__.py +30 -0
  4. sahya_code/acp/AGENTS.md +92 -0
  5. sahya_code/acp/__init__.py +13 -0
  6. sahya_code/acp/convert.py +128 -0
  7. sahya_code/acp/kaos.py +291 -0
  8. sahya_code/acp/mcp.py +46 -0
  9. sahya_code/acp/server.py +457 -0
  10. sahya_code/acp/session.py +496 -0
  11. sahya_code/acp/tools.py +167 -0
  12. sahya_code/acp/types.py +13 -0
  13. sahya_code/acp/version.py +45 -0
  14. sahya_code/agents/default/agent.yaml +36 -0
  15. sahya_code/agents/default/coder.yaml +25 -0
  16. sahya_code/agents/default/explore.yaml +46 -0
  17. sahya_code/agents/default/plan.yaml +30 -0
  18. sahya_code/agents/default/system.md +160 -0
  19. sahya_code/agents/okabe/agent.yaml +22 -0
  20. sahya_code/agentspec.py +160 -0
  21. sahya_code/app.py +540 -0
  22. sahya_code/approval_runtime/__init__.py +29 -0
  23. sahya_code/approval_runtime/models.py +42 -0
  24. sahya_code/approval_runtime/runtime.py +189 -0
  25. sahya_code/auth/__init__.py +5 -0
  26. sahya_code/auth/oauth.py +804 -0
  27. sahya_code/auth/platforms.py +293 -0
  28. sahya_code/background/__init__.py +36 -0
  29. sahya_code/background/agent_runner.py +209 -0
  30. sahya_code/background/ids.py +19 -0
  31. sahya_code/background/manager.py +580 -0
  32. sahya_code/background/models.py +105 -0
  33. sahya_code/background/store.py +196 -0
  34. sahya_code/background/summary.py +66 -0
  35. sahya_code/background/worker.py +209 -0
  36. sahya_code/cli/__init__.py +932 -0
  37. sahya_code/cli/__main__.py +8 -0
  38. sahya_code/cli/_lazy_group.py +222 -0
  39. sahya_code/cli/export.py +74 -0
  40. sahya_code/cli/info.py +62 -0
  41. sahya_code/cli/mcp.py +349 -0
  42. sahya_code/cli/plugin.py +302 -0
  43. sahya_code/cli/toad.py +73 -0
  44. sahya_code/cli/vis.py +38 -0
  45. sahya_code/cli/web.py +80 -0
  46. sahya_code/config.py +391 -0
  47. sahya_code/constant.py +33 -0
  48. sahya_code/exception.py +43 -0
  49. sahya_code/hooks/__init__.py +4 -0
  50. sahya_code/hooks/config.py +34 -0
  51. sahya_code/hooks/engine.py +310 -0
  52. sahya_code/hooks/events.py +190 -0
  53. sahya_code/hooks/runner.py +89 -0
  54. sahya_code/llm.py +306 -0
  55. sahya_code/metadata.py +79 -0
  56. sahya_code/notifications/__init__.py +33 -0
  57. sahya_code/notifications/llm.py +77 -0
  58. sahya_code/notifications/manager.py +145 -0
  59. sahya_code/notifications/models.py +50 -0
  60. sahya_code/notifications/notifier.py +41 -0
  61. sahya_code/notifications/store.py +99 -0
  62. sahya_code/notifications/wire.py +21 -0
  63. sahya_code/plugin/__init__.py +124 -0
  64. sahya_code/plugin/manager.py +153 -0
  65. sahya_code/plugin/tool.py +173 -0
  66. sahya_code/prompts/__init__.py +6 -0
  67. sahya_code/prompts/compact.md +73 -0
  68. sahya_code/prompts/init.md +21 -0
  69. sahya_code/py.typed +0 -0
  70. sahya_code/session.py +293 -0
  71. sahya_code/session_state.py +42 -0
  72. sahya_code/share.py +28 -0
  73. sahya_code/skill/__init__.py +311 -0
  74. sahya_code/skill/flow/__init__.py +99 -0
  75. sahya_code/skill/flow/d2.py +482 -0
  76. sahya_code/skill/flow/mermaid.py +266 -0
  77. sahya_code/skills/kimi-cli-help/SKILL.md +55 -0
  78. sahya_code/skills/skill-creator/SKILL.md +367 -0
  79. sahya_code/soul/__init__.py +288 -0
  80. sahya_code/soul/agent.py +423 -0
  81. sahya_code/soul/approval.py +171 -0
  82. sahya_code/soul/compaction.py +189 -0
  83. sahya_code/soul/context.py +239 -0
  84. sahya_code/soul/denwarenji.py +39 -0
  85. sahya_code/soul/dynamic_injection.py +66 -0
  86. sahya_code/soul/dynamic_injections/__init__.py +0 -0
  87. sahya_code/soul/dynamic_injections/plan_mode.py +238 -0
  88. sahya_code/soul/dynamic_injections/yolo_mode.py +41 -0
  89. sahya_code/soul/message.py +92 -0
  90. sahya_code/soul/sahyasoul.py +1220 -0
  91. sahya_code/soul/slash.py +285 -0
  92. sahya_code/soul/toolset.py +610 -0
  93. sahya_code/subagents/__init__.py +21 -0
  94. sahya_code/subagents/builder.py +42 -0
  95. sahya_code/subagents/core.py +86 -0
  96. sahya_code/subagents/git_context.py +170 -0
  97. sahya_code/subagents/models.py +54 -0
  98. sahya_code/subagents/output.py +71 -0
  99. sahya_code/subagents/registry.py +28 -0
  100. sahya_code/subagents/runner.py +370 -0
  101. sahya_code/subagents/store.py +148 -0
  102. sahya_code/tools/AGENTS.md +5 -0
  103. sahya_code/tools/__init__.py +105 -0
  104. sahya_code/tools/agent/__init__.py +276 -0
  105. sahya_code/tools/agent/description.md +41 -0
  106. sahya_code/tools/ask_user/__init__.py +154 -0
  107. sahya_code/tools/ask_user/description.md +19 -0
  108. sahya_code/tools/background/__init__.py +318 -0
  109. sahya_code/tools/background/list.md +10 -0
  110. sahya_code/tools/background/output.md +11 -0
  111. sahya_code/tools/background/stop.md +8 -0
  112. sahya_code/tools/display.py +46 -0
  113. sahya_code/tools/dmail/__init__.py +38 -0
  114. sahya_code/tools/dmail/dmail.md +17 -0
  115. sahya_code/tools/file/__init__.py +30 -0
  116. sahya_code/tools/file/glob.md +17 -0
  117. sahya_code/tools/file/glob.py +156 -0
  118. sahya_code/tools/file/grep.md +5 -0
  119. sahya_code/tools/file/grep_local.py +524 -0
  120. sahya_code/tools/file/plan_mode.py +45 -0
  121. sahya_code/tools/file/read.md +14 -0
  122. sahya_code/tools/file/read.py +189 -0
  123. sahya_code/tools/file/read_media.md +24 -0
  124. sahya_code/tools/file/read_media.py +215 -0
  125. sahya_code/tools/file/replace.md +7 -0
  126. sahya_code/tools/file/replace.py +193 -0
  127. sahya_code/tools/file/utils.py +257 -0
  128. sahya_code/tools/file/write.md +5 -0
  129. sahya_code/tools/file/write.py +175 -0
  130. sahya_code/tools/plan/__init__.py +325 -0
  131. sahya_code/tools/plan/description.md +25 -0
  132. sahya_code/tools/plan/enter.py +183 -0
  133. sahya_code/tools/plan/enter_description.md +30 -0
  134. sahya_code/tools/plan/heroes.py +277 -0
  135. sahya_code/tools/shell/__init__.py +235 -0
  136. sahya_code/tools/shell/bash.md +35 -0
  137. sahya_code/tools/shell/powershell.md +30 -0
  138. sahya_code/tools/test.py +55 -0
  139. sahya_code/tools/think/__init__.py +21 -0
  140. sahya_code/tools/think/think.md +1 -0
  141. sahya_code/tools/todo/__init__.py +33 -0
  142. sahya_code/tools/todo/set_todo_list.md +15 -0
  143. sahya_code/tools/utils.py +199 -0
  144. sahya_code/tools/web/__init__.py +4 -0
  145. sahya_code/tools/web/fetch.md +1 -0
  146. sahya_code/tools/web/fetch.py +173 -0
  147. sahya_code/tools/web/search.md +1 -0
  148. sahya_code/tools/web/search.py +146 -0
  149. sahya_code/ui/__init__.py +0 -0
  150. sahya_code/ui/acp/__init__.py +99 -0
  151. sahya_code/ui/print/__init__.py +167 -0
  152. sahya_code/ui/print/visualize.py +185 -0
  153. sahya_code/ui/shell/__init__.py +991 -0
  154. sahya_code/ui/shell/approval_panel.py +481 -0
  155. sahya_code/ui/shell/console.py +105 -0
  156. sahya_code/ui/shell/debug.py +190 -0
  157. sahya_code/ui/shell/echo.py +17 -0
  158. sahya_code/ui/shell/export_import.py +111 -0
  159. sahya_code/ui/shell/keyboard.py +300 -0
  160. sahya_code/ui/shell/mcp_status.py +111 -0
  161. sahya_code/ui/shell/oauth.py +143 -0
  162. sahya_code/ui/shell/placeholders.py +530 -0
  163. sahya_code/ui/shell/prompt.py +2124 -0
  164. sahya_code/ui/shell/question_panel.py +586 -0
  165. sahya_code/ui/shell/replay.py +210 -0
  166. sahya_code/ui/shell/setup.py +212 -0
  167. sahya_code/ui/shell/slash.py +716 -0
  168. sahya_code/ui/shell/startup.py +32 -0
  169. sahya_code/ui/shell/task_browser.py +486 -0
  170. sahya_code/ui/shell/update.py +217 -0
  171. sahya_code/ui/shell/usage.py +281 -0
  172. sahya_code/ui/shell/visualize.py +1497 -0
  173. sahya_code/ui/theme.py +238 -0
  174. sahya_code/utils/__init__.py +0 -0
  175. sahya_code/utils/aiohttp.py +24 -0
  176. sahya_code/utils/aioqueue.py +72 -0
  177. sahya_code/utils/broadcast.py +37 -0
  178. sahya_code/utils/changelog.py +108 -0
  179. sahya_code/utils/clipboard.py +169 -0
  180. sahya_code/utils/datetime.py +37 -0
  181. sahya_code/utils/diff.py +135 -0
  182. sahya_code/utils/editor.py +91 -0
  183. sahya_code/utils/environment.py +58 -0
  184. sahya_code/utils/envvar.py +12 -0
  185. sahya_code/utils/export.py +696 -0
  186. sahya_code/utils/frontmatter.py +50 -0
  187. sahya_code/utils/io.py +27 -0
  188. sahya_code/utils/logging.py +124 -0
  189. sahya_code/utils/media_tags.py +29 -0
  190. sahya_code/utils/message.py +24 -0
  191. sahya_code/utils/path.py +140 -0
  192. sahya_code/utils/proctitle.py +33 -0
  193. sahya_code/utils/pyinstaller.py +32 -0
  194. sahya_code/utils/rich/__init__.py +33 -0
  195. sahya_code/utils/rich/columns.py +99 -0
  196. sahya_code/utils/rich/diff_render.py +436 -0
  197. sahya_code/utils/rich/markdown.py +900 -0
  198. sahya_code/utils/rich/markdown_sample.md +108 -0
  199. sahya_code/utils/rich/markdown_sample_short.md +2 -0
  200. sahya_code/utils/rich/syntax.py +114 -0
  201. sahya_code/utils/server.py +121 -0
  202. sahya_code/utils/signals.py +43 -0
  203. sahya_code/utils/slashcmd.py +124 -0
  204. sahya_code/utils/string.py +22 -0
  205. sahya_code/utils/subprocess_env.py +73 -0
  206. sahya_code/utils/term.py +168 -0
  207. sahya_code/utils/typing.py +20 -0
  208. sahya_code/vis/__init__.py +0 -0
  209. sahya_code/vis/api/__init__.py +5 -0
  210. sahya_code/vis/api/sessions.py +692 -0
  211. sahya_code/vis/api/statistics.py +209 -0
  212. sahya_code/vis/api/system.py +19 -0
  213. sahya_code/vis/app.py +175 -0
  214. sahya_code/web/__init__.py +5 -0
  215. sahya_code/web/api/__init__.py +15 -0
  216. sahya_code/web/api/config.py +208 -0
  217. sahya_code/web/api/open_in.py +197 -0
  218. sahya_code/web/api/sessions.py +1392 -0
  219. sahya_code/web/app.py +412 -0
  220. sahya_code/web/auth.py +191 -0
  221. sahya_code/web/models.py +98 -0
  222. sahya_code/web/runner/__init__.py +5 -0
  223. sahya_code/web/runner/messages.py +57 -0
  224. sahya_code/web/runner/process.py +745 -0
  225. sahya_code/web/runner/worker.py +87 -0
  226. sahya_code/web/store/__init__.py +1 -0
  227. sahya_code/web/store/sessions.py +517 -0
  228. sahya_code/wire/__init__.py +148 -0
  229. sahya_code/wire/file.py +151 -0
  230. sahya_code/wire/jsonrpc.py +263 -0
  231. sahya_code/wire/protocol.py +2 -0
  232. sahya_code/wire/root_hub.py +27 -0
  233. sahya_code/wire/serde.py +26 -0
  234. sahya_code/wire/server.py +1029 -0
  235. sahya_code/wire/types.py +674 -0
  236. sahya_code-1.0.0.dist-info/METADATA +158 -0
  237. sahya_code-1.0.0.dist-info/RECORD +239 -0
  238. sahya_code-1.0.0.dist-info/WHEEL +4 -0
  239. sahya_code-1.0.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,108 @@
1
+ # Changelog
2
+
3
+ All notable changes to Sahya Code will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-04-01
9
+
10
+ ### Added
11
+ - Initial release based on kimi-cli v1.28.0
12
+ - Custom LiteLLM endpoint support (https://llm.nexiant.ai)
13
+ - API key authentication via SAHYA_API_KEY environment variable
14
+ - Full rebranding from Kimi Code CLI to Sahya Code
15
+ - Pre-configured OpenAI-compatible provider for LiteLLM proxy
16
+ - Support for SAHYA_BASE_URL environment variable
17
+
18
+ ### Changed
19
+ - **Package name:** `kimi-cli` → `sahya-code`
20
+ - **Module name:** `kimi_cli` → `sahya_code`
21
+ - **CLI command:** `kimi` → `sahya`
22
+ - **Config directory:** `~/.local/share/kimi` → `~/.local/share/sahya-code`
23
+ - **Log file:** `kimi.log` → `sahya.log`
24
+ - **Environment variable prefix:** `KIMI_*` → `SAHYA_*`
25
+ - **Main class:** `KimiCLI` → `SahyaCode`
26
+ - **Soul class:** `KimiSoul` → `SahyaSoul`
27
+ - **User agent:** `KimiCLI/*` → `SahyaCode/*`
28
+ - **Application name:** "Kimi Code CLI" → "Sahya Code"
29
+
30
+ ### Configuration
31
+
32
+ #### Default Provider
33
+ - Type: `openai_legacy` (OpenAI-compatible API for LiteLLM)
34
+ - Endpoint: `https://llm.nexiant.ai`
35
+ - Default Model: `kimi-k2.5`
36
+ - Authentication: API key via `SAHYA_API_KEY`
37
+
38
+ #### Environment Variables
39
+ - `SAHYA_API_KEY` - API key for authentication (required)
40
+ - `SAHYA_BASE_URL` - Endpoint URL override (optional)
41
+ - `SAHYA_SHARE_DIR` - Custom share directory (optional)
42
+ - `SAHYA_CACHE_DIR` - Custom cache directory (optional)
43
+
44
+ #### Config File Location
45
+ - Default: `~/.local/share/sahya-code/config.toml`
46
+ - Format: TOML (JSON also supported)
47
+
48
+ ### Removed
49
+ - Kimi-specific default configurations
50
+ - Moonshot AI-specific provider defaults
51
+ - Kimi-specific environment variable fallbacks
52
+
53
+ ### Dependencies
54
+ Same as kimi-cli v1.28.0:
55
+ - Python >= 3.12
56
+ - kosong[contrib] == 0.47.0
57
+ - pydantic == 2.12.5
58
+ - typer == 0.21.1
59
+ - And other dependencies (see pyproject.toml)
60
+
61
+ ## Original kimi-cli History
62
+
63
+ For complete history of the original project, see:
64
+ https://github.com/MoonshotAI/kimi-cli/blob/main/CHANGELOG.md
65
+
66
+ ---
67
+
68
+ ## Migration Guide
69
+
70
+ ### From kimi-cli to sahya-code
71
+
72
+ 1. **Uninstall kimi-cli:**
73
+ ```bash
74
+ pip uninstall kimi-cli
75
+ ```
76
+
77
+ 2. **Install sahya-code:**
78
+ ```bash
79
+ pip install sahya-code
80
+ ```
81
+
82
+ 3. **Update environment variables:**
83
+ ```bash
84
+ # Old
85
+ export KIMI_API_KEY="..."
86
+
87
+ # New
88
+ export SAHYA_API_KEY="..."
89
+ ```
90
+
91
+ 4. **Migrate configuration:**
92
+ ```bash
93
+ # Copy old config (optional)
94
+ mkdir -p ~/.local/share/sahya-code
95
+ cp ~/.local/share/kimi/config.toml ~/.local/share/sahya-code/config.toml
96
+
97
+ # Update config values
98
+ sed -i '' 's/kimi/sahya/g' ~/.local/share/sahya-code/config.toml
99
+ ```
100
+
101
+ 5. **Update aliases:**
102
+ ```bash
103
+ # Old
104
+ alias ai='kimi'
105
+
106
+ # New
107
+ alias ai='sahya'
108
+ ```
sahya_code/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+
6
+ class _LazyLogger:
7
+ """Import loguru only when logging is actually used."""
8
+
9
+ def __init__(self) -> None:
10
+ self._logger: Any | None = None
11
+
12
+ def _get(self) -> Any:
13
+ if self._logger is None:
14
+ from loguru import logger as real_logger
15
+
16
+ # Disable logging by default for library usage.
17
+ # Application entry points (e.g., sahya_code.cli) should call logger.enable("sahya_code")
18
+ # to enable logging.
19
+ real_logger.disable("sahya_code")
20
+ self._logger = real_logger
21
+ return self._logger
22
+
23
+ def __getattr__(self, name: str) -> Any:
24
+ return getattr(self._get(), name)
25
+
26
+
27
+ logger = cast(Any, _LazyLogger())
28
+
29
+ __all__ = ["logger"]
sahya_code/__main__.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+
7
+
8
+ def _prog_name() -> str:
9
+ return Path(sys.argv[0]).name or "sahya"
10
+
11
+
12
+ def main(argv: Sequence[str] | None = None) -> int | str | None:
13
+ args = list(sys.argv[1:] if argv is None else argv)
14
+
15
+ if len(args) == 1 and args[0] in {"--version", "-V"}:
16
+ from sahya_code.constant import get_version
17
+
18
+ print(f"sahya, version {get_version()}")
19
+ return 0
20
+
21
+ from sahya_code.cli import cli
22
+
23
+ try:
24
+ return cli(args=args, prog_name=_prog_name())
25
+ except SystemExit as exc:
26
+ return exc.code
27
+
28
+
29
+ if __name__ == "__main__":
30
+ raise SystemExit(main())
@@ -0,0 +1,92 @@
1
+ # ACP Integration Notes (kimi-cli)
2
+
3
+ ## Protocol summary (ACP overview)
4
+ - ACP is JSON-RPC 2.0 with request/response methods plus one-way notifications.
5
+ - Typical flow: `initialize` -> optional `authenticate` -> `session/new` or `session/load`
6
+ -> `session/prompt`
7
+ with `session/update` notifications and optional `session/cancel`.
8
+ - Clients provide `session/request_permission` and optional terminal/filesystem methods.
9
+ - All ACP file paths must be absolute; line numbers are 1-based.
10
+
11
+ ## Entry points and server modes
12
+ - **Single-session server**: `KimiCLI.run_acp()` uses `ACP` -> `ACPServerSingleSession`.
13
+ - Code: `src/kimi_cli/app.py`, `src/kimi_cli/ui/acp/__init__.py`.
14
+ - Used when running CLI with `--acp` UI mode.
15
+ - **Multi-session server**: `acp_main()` runs `ACPServer` with `use_unstable_protocol=True`.
16
+ - Code: `src/kimi_cli/acp/__init__.py`, `src/kimi_cli/acp/server.py`.
17
+ - Exposed via the `kimi acp` command in `src/kimi_cli/cli/__init__.py`.
18
+
19
+ ## Capabilities advertised
20
+ - `prompt_capabilities`: `embedded_context=False`, `image=True`, `audio=False`.
21
+ - `mcp_capabilities`: `http=True`, `sse=False`.
22
+ - Single-session: `load_session=False`, no session list capabilities.
23
+ - Multi-session: `load_session=True`, `session_capabilities.list` supported.
24
+ - `auth_methods=[]` (no authentication methods advertised).
25
+
26
+ ## Session lifecycle (implemented behavior)
27
+ - `session/new`
28
+ - Multi-session: creates a persisted `Session`, builds `KimiCLI`, stores `ACPSession`.
29
+ - Single-session: wraps the existing `Soul` into a `Wire` loop and creates `ACPSession`.
30
+ - Both send `AvailableCommandsUpdate` for slash commands on session creation.
31
+ - MCP servers passed by ACP are converted via `acp_mcp_servers_to_mcp_config`.
32
+ - `session/load`
33
+ - Multi-session only: loads by `Session.find`, then builds `KimiCLI` and `ACPSession`.
34
+ - No history replay yet (TODO).
35
+ - Single-session: not implemented.
36
+ - `session/list`
37
+ - Multi-session only: lists sessions via `Session.list`, no pagination.
38
+ - Single-session: not implemented.
39
+ - `session/prompt`
40
+ - Uses `ACPSession.prompt()` to stream updates and produce a `stop_reason`.
41
+ - Stop reasons: `end_turn`, `max_turn_requests`, `cancelled`.
42
+ - `session/cancel`
43
+ - Sets the per-turn cancel event to stop the prompt.
44
+
45
+ ## Streaming updates and content mapping
46
+ - Text chunks -> `AgentMessageChunk`.
47
+ - Think chunks -> `AgentThoughtChunk`.
48
+ - Tool calls:
49
+ - Start -> `ToolCallStart` with JSON args as text content.
50
+ - Streaming args -> `ToolCallProgress` with updated title/args.
51
+ - Results -> `ToolCallProgress` with `completed` or `failed`.
52
+ - Tool call IDs are prefixed with turn ID to avoid collisions across turns.
53
+ - Plan updates:
54
+ - `TodoDisplayBlock` is converted into `AgentPlanUpdate`.
55
+ - Available commands:
56
+ - `AvailableCommandsUpdate` is sent right after session creation.
57
+
58
+ ## Prompt/content conversion
59
+ - Incoming prompt blocks:
60
+ - Supported: `TextContentBlock`, `ImageContentBlock` (converted to data URL).
61
+ - Unsupported types are logged and ignored.
62
+ - Tool result display blocks:
63
+ - `DiffDisplayBlock` -> `FileEditToolCallContent`.
64
+ - `HideOutputDisplayBlock` suppresses tool output in ACP (used by terminal tool).
65
+
66
+ ## Tool integration and permission flow
67
+ - ACP sessions use `ACPKaos` to route filesystem reads/writes through ACP clients.
68
+ - If the client advertises `terminal` capability, the `Shell` tool is replaced by an
69
+ ACP-backed `Terminal` tool.
70
+ - Uses ACP `terminal/create`, waits for exit, streams `TerminalToolCallContent`,
71
+ then releases the terminal handle.
72
+ - Approval requests in the core tool system are bridged to ACP
73
+ `session/request_permission` with allow-once/allow-always/reject options.
74
+
75
+ ## Current gaps / not implemented
76
+ - `authenticate` method (not used by current Zed ACP client).
77
+ - `session/set_mode` and `session/set_model` (no multi-mode/model switching in kimi-cli).
78
+ - `ext_method` / `ext_notification` for custom ACP extensions are stubbed.
79
+ - Single-session server does not implement `session/load` or `session/list`.
80
+
81
+ ## Filesystem (ACP client-backed)
82
+ - When the client advertises `fs.readTextFile` / `fs.writeTextFile`, `ACPKaos` routes
83
+ reads and writes through ACP `fs/*` methods.
84
+ - `ReadFile` uses `KaosPath.read_lines`, which `ACPKaos` implements via ACP reads.
85
+ - `ReadMediaFile` uses `KaosPath.read_bytes` to load image/video payloads through ACP reads.
86
+ - `WriteFile` uses `KaosPath.read_text/write_text/append_text` and still generates diffs
87
+ and approvals in the tool layer.
88
+
89
+ ## Zed-specific notes (as of current integration)
90
+ - Zed does not currently call `authenticate`.
91
+ - Zed’s external agent server session management is not yet available, so
92
+ `session/load` is not exercised in practice.
@@ -0,0 +1,13 @@
1
+ def acp_main() -> None:
2
+ """Entry point for the multi-session ACP server."""
3
+ import asyncio
4
+
5
+ import acp
6
+
7
+ from sahya_code.acp.server import ACPServer
8
+ from sahya_code.app import enable_logging
9
+ from sahya_code.utils.logging import logger
10
+
11
+ enable_logging()
12
+ logger.info("Starting ACP server on stdio")
13
+ asyncio.run(acp.run_agent(ACPServer(), use_unstable_protocol=True))
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import acp
4
+
5
+ from sahya_code.acp.types import ACPContentBlock
6
+ from sahya_code.utils.logging import logger
7
+ from sahya_code.wire.types import (
8
+ ContentPart,
9
+ DiffDisplayBlock,
10
+ DisplayBlock,
11
+ ImageURLPart,
12
+ TextPart,
13
+ ToolReturnValue,
14
+ )
15
+
16
+
17
+ def acp_blocks_to_content_parts(prompt: list[ACPContentBlock]) -> list[ContentPart]:
18
+ content: list[ContentPart] = []
19
+ for block in prompt:
20
+ match block:
21
+ case acp.schema.TextContentBlock():
22
+ content.append(TextPart(text=block.text))
23
+ case acp.schema.ImageContentBlock():
24
+ content.append(
25
+ ImageURLPart(
26
+ image_url=ImageURLPart.ImageURL(
27
+ url=f"data:{block.mime_type};base64,{block.data}"
28
+ )
29
+ )
30
+ )
31
+ case acp.schema.EmbeddedResourceContentBlock():
32
+ resource = block.resource
33
+ if isinstance(resource, acp.schema.TextResourceContents):
34
+ uri = resource.uri
35
+ text = resource.text
36
+ content.append(TextPart(text=f"<resource uri={uri!r}>\n{text}\n</resource>"))
37
+ else:
38
+ logger.warning(
39
+ "Unsupported embedded resource type: {type}",
40
+ type=type(resource).__name__,
41
+ )
42
+ case acp.schema.ResourceContentBlock():
43
+ # ResourceContentBlock is a link reference without inline content;
44
+ # include the URI so the model is at least aware of the reference.
45
+ content.append(
46
+ TextPart(text=f"<resource_link uri={block.uri!r} name={block.name!r} />")
47
+ )
48
+ case _:
49
+ logger.warning("Unsupported prompt content block: {block}", block=block)
50
+ return content
51
+
52
+
53
+ def display_block_to_acp_content(
54
+ block: DisplayBlock,
55
+ ) -> acp.schema.FileEditToolCallContent | None:
56
+ if isinstance(block, DiffDisplayBlock):
57
+ return acp.schema.FileEditToolCallContent(
58
+ type="diff",
59
+ path=block.path,
60
+ old_text=block.old_text,
61
+ new_text=block.new_text,
62
+ )
63
+
64
+ return None
65
+
66
+
67
+ def tool_result_to_acp_content(
68
+ tool_ret: ToolReturnValue,
69
+ ) -> list[
70
+ acp.schema.ContentToolCallContent
71
+ | acp.schema.FileEditToolCallContent
72
+ | acp.schema.TerminalToolCallContent
73
+ ]:
74
+ from sahya_code.acp.tools import HideOutputDisplayBlock
75
+
76
+ def _to_acp_content(
77
+ part: ContentPart,
78
+ ) -> (
79
+ acp.schema.ContentToolCallContent
80
+ | acp.schema.FileEditToolCallContent
81
+ | acp.schema.TerminalToolCallContent
82
+ ):
83
+ if isinstance(part, TextPart):
84
+ return acp.schema.ContentToolCallContent(
85
+ type="content", content=acp.schema.TextContentBlock(type="text", text=part.text)
86
+ )
87
+ logger.warning("Unsupported content part in tool result: {part}", part=part)
88
+ return acp.schema.ContentToolCallContent(
89
+ type="content",
90
+ content=acp.schema.TextContentBlock(type="text", text=f"[{part.__class__.__name__}]"),
91
+ )
92
+
93
+ def _to_text_block(text: str) -> acp.schema.ContentToolCallContent:
94
+ return acp.schema.ContentToolCallContent(
95
+ type="content", content=acp.schema.TextContentBlock(type="text", text=text)
96
+ )
97
+
98
+ contents: list[
99
+ acp.schema.ContentToolCallContent
100
+ | acp.schema.FileEditToolCallContent
101
+ | acp.schema.TerminalToolCallContent
102
+ ] = []
103
+
104
+ for block in tool_ret.display:
105
+ if isinstance(block, HideOutputDisplayBlock):
106
+ # return early to indicate no output should be shown
107
+ return []
108
+
109
+ content = display_block_to_acp_content(block)
110
+ if content is not None:
111
+ contents.append(content)
112
+ # TODO: better concatenation of `display` blocks and `output`?
113
+
114
+ output = tool_ret.output
115
+ if isinstance(output, str):
116
+ if output:
117
+ contents.append(_to_text_block(output))
118
+ else:
119
+ # NOTE: At the moment, ToolReturnValue.output is either a string or a
120
+ # list of ContentPart. We avoid an unnecessary isinstance() check here
121
+ # to keep pyright happy while still handling list outputs.
122
+ contents.extend(_to_acp_content(part) for part in output)
123
+
124
+ if not contents and tool_ret.message:
125
+ # Fallback to the `message` for LLM if there's no other content
126
+ contents.append(_to_text_block(tool_ret.message))
127
+
128
+ return contents
sahya_code/acp/kaos.py ADDED
@@ -0,0 +1,291 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator, Iterable, Mapping
5
+ from contextlib import suppress
6
+ from typing import Literal
7
+
8
+ import acp
9
+ from kaos import AsyncReadable, AsyncWritable, Kaos, KaosProcess, StatResult, StrOrKaosPath
10
+ from kaos.local import local_kaos
11
+ from kaos.path import KaosPath
12
+
13
+ _DEFAULT_TERMINAL_OUTPUT_LIMIT = 50_000
14
+ _DEFAULT_POLL_INTERVAL = 0.2
15
+ _TRUNCATION_NOTICE = "[acp output truncated]\n"
16
+
17
+
18
+ class _NullWritable:
19
+ def can_write_eof(self) -> bool:
20
+ return False
21
+
22
+ def close(self) -> None:
23
+ return None
24
+
25
+ async def drain(self) -> None:
26
+ return None
27
+
28
+ def is_closing(self) -> bool:
29
+ return False
30
+
31
+ async def wait_closed(self) -> None:
32
+ return None
33
+
34
+ def write(self, data: bytes) -> None:
35
+ return None
36
+
37
+ def writelines(self, data: Iterable[bytes], /) -> None:
38
+ return None
39
+
40
+ def write_eof(self) -> None:
41
+ return None
42
+
43
+
44
+ class ACPProcess:
45
+ """KAOS process adapter for ACP terminal execution."""
46
+
47
+ def __init__(
48
+ self,
49
+ client: acp.Client,
50
+ session_id: str,
51
+ terminal_id: str,
52
+ *,
53
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
54
+ ) -> None:
55
+ self._client = client
56
+ self._session_id = session_id
57
+ self._terminal_id = terminal_id
58
+ self._poll_interval = poll_interval
59
+ self._stdin = _NullWritable()
60
+ self._stdout = asyncio.StreamReader()
61
+ self._stderr = asyncio.StreamReader()
62
+ self.stdin: AsyncWritable = self._stdin
63
+ self.stdout: AsyncReadable = self._stdout
64
+ # ACP does not expose stderr separately; keep stderr empty.
65
+ self.stderr: AsyncReadable = self._stderr
66
+ self._returncode: int | None = None
67
+ self._last_output = ""
68
+ self._truncation_noted = False
69
+ self._exit_future: asyncio.Future[int] = asyncio.get_running_loop().create_future()
70
+ self._poll_task = asyncio.create_task(self._poll_output())
71
+
72
+ @property
73
+ def pid(self) -> int:
74
+ return -1
75
+
76
+ @property
77
+ def returncode(self) -> int | None:
78
+ return self._returncode
79
+
80
+ async def wait(self) -> int:
81
+ return await self._exit_future
82
+
83
+ async def kill(self) -> None:
84
+ await self._client.kill_terminal(
85
+ session_id=self._session_id,
86
+ terminal_id=self._terminal_id,
87
+ )
88
+
89
+ def _feed_output(self, output_response: acp.schema.TerminalOutputResponse) -> None:
90
+ output = output_response.output
91
+ reset = output_response.truncated or (
92
+ self._last_output and not output.startswith(self._last_output)
93
+ )
94
+ if reset and self._last_output and not self._truncation_noted:
95
+ self._stdout.feed_data(_TRUNCATION_NOTICE.encode("utf-8"))
96
+ self._truncation_noted = True
97
+
98
+ delta = output if reset else output[len(self._last_output) :]
99
+ if delta:
100
+ self._stdout.feed_data(delta.encode("utf-8", "replace"))
101
+ self._last_output = output
102
+
103
+ @staticmethod
104
+ def _normalize_exit_code(exit_code: int | None) -> int:
105
+ return 1 if exit_code is None else exit_code
106
+
107
+ async def _poll_output(self) -> None:
108
+ exit_task = asyncio.create_task(
109
+ self._client.wait_for_terminal_exit(
110
+ session_id=self._session_id,
111
+ terminal_id=self._terminal_id,
112
+ )
113
+ )
114
+ exit_code: int | None = None
115
+ try:
116
+ while True:
117
+ if exit_task.done():
118
+ exit_response = exit_task.result()
119
+ exit_code = exit_response.exit_code
120
+ break
121
+
122
+ output_response = await self._client.terminal_output(
123
+ session_id=self._session_id,
124
+ terminal_id=self._terminal_id,
125
+ )
126
+ self._feed_output(output_response)
127
+ if output_response.exit_status:
128
+ exit_code = output_response.exit_status.exit_code
129
+ try:
130
+ exit_response = await exit_task
131
+ exit_code = exit_response.exit_code or exit_code
132
+ except Exception:
133
+ pass
134
+ break
135
+
136
+ await asyncio.sleep(self._poll_interval)
137
+
138
+ final_output = await self._client.terminal_output(
139
+ session_id=self._session_id,
140
+ terminal_id=self._terminal_id,
141
+ )
142
+ self._feed_output(final_output)
143
+ except Exception as exc:
144
+ error_note = f"[acp terminal error] {exc}\n"
145
+ self._stdout.feed_data(error_note.encode("utf-8", "replace"))
146
+ if exit_code is None:
147
+ exit_code = 1
148
+ finally:
149
+ if not exit_task.done():
150
+ exit_task.cancel()
151
+ with suppress(Exception):
152
+ await exit_task
153
+ self._returncode = self._normalize_exit_code(exit_code)
154
+ self._stdout.feed_eof()
155
+ self._stderr.feed_eof()
156
+ if not self._exit_future.done():
157
+ self._exit_future.set_result(self._returncode)
158
+ with suppress(Exception):
159
+ await self._client.release_terminal(
160
+ session_id=self._session_id,
161
+ terminal_id=self._terminal_id,
162
+ )
163
+
164
+
165
+ class ACPKaos:
166
+ """KAOS backend that routes supported operations through ACP."""
167
+
168
+ name: str = "acp"
169
+
170
+ def __init__(
171
+ self,
172
+ client: acp.Client,
173
+ session_id: str,
174
+ client_capabilities: acp.schema.ClientCapabilities | None,
175
+ fallback: Kaos | None = None,
176
+ *,
177
+ output_byte_limit: int | None = _DEFAULT_TERMINAL_OUTPUT_LIMIT,
178
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
179
+ ) -> None:
180
+ self._client = client
181
+ self._session_id = session_id
182
+ self._fallback = fallback or local_kaos
183
+ fs = client_capabilities.fs if client_capabilities else None
184
+ self._supports_read = bool(fs and fs.read_text_file)
185
+ self._supports_write = bool(fs and fs.write_text_file)
186
+ self._supports_terminal = bool(client_capabilities and client_capabilities.terminal)
187
+ self._output_byte_limit = output_byte_limit
188
+ self._poll_interval = poll_interval
189
+
190
+ def pathclass(self):
191
+ return self._fallback.pathclass()
192
+
193
+ def normpath(self, path: StrOrKaosPath) -> KaosPath:
194
+ return self._fallback.normpath(path)
195
+
196
+ def gethome(self) -> KaosPath:
197
+ return self._fallback.gethome()
198
+
199
+ def getcwd(self) -> KaosPath:
200
+ return self._fallback.getcwd()
201
+
202
+ async def chdir(self, path: StrOrKaosPath) -> None:
203
+ await self._fallback.chdir(path)
204
+
205
+ async def stat(self, path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult:
206
+ return await self._fallback.stat(path, follow_symlinks=follow_symlinks)
207
+
208
+ def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]:
209
+ return self._fallback.iterdir(path)
210
+
211
+ def glob(
212
+ self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True
213
+ ) -> AsyncGenerator[KaosPath]:
214
+ return self._fallback.glob(path, pattern, case_sensitive=case_sensitive)
215
+
216
+ async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes:
217
+ return await self._fallback.readbytes(path, n=n)
218
+
219
+ async def readtext(
220
+ self,
221
+ path: StrOrKaosPath,
222
+ *,
223
+ encoding: str = "utf-8",
224
+ errors: Literal["strict", "ignore", "replace"] = "strict",
225
+ ) -> str:
226
+ abs_path = self._abs_path(path)
227
+ if not self._supports_read:
228
+ return await self._fallback.readtext(abs_path, encoding=encoding, errors=errors)
229
+ response = await self._client.read_text_file(path=abs_path, session_id=self._session_id)
230
+ return response.content
231
+
232
+ async def readlines(
233
+ self,
234
+ path: StrOrKaosPath,
235
+ *,
236
+ encoding: str = "utf-8",
237
+ errors: Literal["strict", "ignore", "replace"] = "strict",
238
+ ) -> AsyncGenerator[str]:
239
+ text = await self.readtext(path, encoding=encoding, errors=errors)
240
+ for line in text.splitlines(keepends=True):
241
+ yield line
242
+
243
+ async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int:
244
+ return await self._fallback.writebytes(path, data)
245
+
246
+ async def writetext(
247
+ self,
248
+ path: StrOrKaosPath,
249
+ data: str,
250
+ *,
251
+ mode: Literal["w", "a"] = "w",
252
+ encoding: str = "utf-8",
253
+ errors: Literal["strict", "ignore", "replace"] = "strict",
254
+ ) -> int:
255
+ abs_path = self._abs_path(path)
256
+ if mode == "a":
257
+ if self._supports_read and self._supports_write:
258
+ existing = await self.readtext(abs_path, encoding=encoding, errors=errors)
259
+ await self._client.write_text_file(
260
+ path=abs_path,
261
+ content=existing + data,
262
+ session_id=self._session_id,
263
+ )
264
+ return len(data)
265
+ return await self._fallback.writetext(
266
+ abs_path, data, mode="a", encoding=encoding, errors=errors
267
+ )
268
+
269
+ if not self._supports_write:
270
+ return await self._fallback.writetext(
271
+ abs_path, data, mode=mode, encoding=encoding, errors=errors
272
+ )
273
+
274
+ await self._client.write_text_file(
275
+ path=abs_path,
276
+ content=data,
277
+ session_id=self._session_id,
278
+ )
279
+ return len(data)
280
+
281
+ async def mkdir(
282
+ self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False
283
+ ) -> None:
284
+ await self._fallback.mkdir(path, parents=parents, exist_ok=exist_ok)
285
+
286
+ async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess:
287
+ return await self._fallback.exec(*args, env=env)
288
+
289
+ def _abs_path(self, path: StrOrKaosPath) -> str:
290
+ kaos_path = path if isinstance(path, KaosPath) else KaosPath(path)
291
+ return str(kaos_path.canonical())