my-pi-agent 0.1.0

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 (141) hide show
  1. package/README.md +318 -0
  2. package/package.json +45 -0
  3. package/pyproject.toml +50 -0
  4. package/src/my_agent_core/__init__.py +123 -0
  5. package/src/my_agent_core/agent.py +441 -0
  6. package/src/my_agent_core/background.py +121 -0
  7. package/src/my_agent_core/context.py +505 -0
  8. package/src/my_agent_core/events.py +153 -0
  9. package/src/my_agent_core/extensions/__init__.py +9 -0
  10. package/src/my_agent_core/extensions/core.py +197 -0
  11. package/src/my_agent_core/hooks.py +130 -0
  12. package/src/my_agent_core/loop.py +709 -0
  13. package/src/my_agent_core/main.py +134 -0
  14. package/src/my_agent_core/memory.py +241 -0
  15. package/src/my_agent_core/message_queue.py +110 -0
  16. package/src/my_agent_core/plugins.py +212 -0
  17. package/src/my_agent_core/registry.py +186 -0
  18. package/src/my_agent_core/session/__init__.py +79 -0
  19. package/src/my_agent_core/session/entries.py +197 -0
  20. package/src/my_agent_core/session/jsonl.py +60 -0
  21. package/src/my_agent_core/session/memory.py +137 -0
  22. package/src/my_agent_core/session/session.py +400 -0
  23. package/src/my_agent_core/session/storage.py +245 -0
  24. package/src/my_agent_core/session/store.py +131 -0
  25. package/src/my_agent_core/session/tree.py +86 -0
  26. package/src/my_agent_core/skills.py +149 -0
  27. package/src/my_agent_core/subagent_tasks.py +170 -0
  28. package/src/my_agent_core/subagents.py +148 -0
  29. package/src/my_agent_core/task_store.py +248 -0
  30. package/src/my_agent_core/tool_history.py +189 -0
  31. package/src/my_agent_core/tools/__init__.py +5 -0
  32. package/src/my_agent_core/tools/builtin/__init__.py +5 -0
  33. package/src/my_agent_core/tools/builtin/task.py +30 -0
  34. package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
  35. package/src/my_agent_core/tools/core.py +239 -0
  36. package/src/my_agent_llm/__init__.py +45 -0
  37. package/src/my_agent_llm/auth/__init__.py +46 -0
  38. package/src/my_agent_llm/auth/antigravity.py +209 -0
  39. package/src/my_agent_llm/auth/manager.py +259 -0
  40. package/src/my_agent_llm/auth/quota.py +56 -0
  41. package/src/my_agent_llm/auth/schema.py +94 -0
  42. package/src/my_agent_llm/client.py +116 -0
  43. package/src/my_agent_llm/config.py +17 -0
  44. package/src/my_agent_llm/events.py +84 -0
  45. package/src/my_agent_llm/models.py +195 -0
  46. package/src/my_agent_llm/providers/__init__.py +4 -0
  47. package/src/my_agent_llm/providers/_base.py +94 -0
  48. package/src/my_agent_llm/providers/anthropic.py +298 -0
  49. package/src/my_agent_llm/providers/antigravity.py +480 -0
  50. package/src/my_agent_llm/providers/deepseek.py +196 -0
  51. package/src/my_agent_llm/providers/openai.py +364 -0
  52. package/src/my_agent_llm/providers/registry.py +16 -0
  53. package/src/my_agent_llm/stream.py +218 -0
  54. package/src/my_coding_agent/__init__.py +66 -0
  55. package/src/my_coding_agent/agent.py +208 -0
  56. package/src/my_coding_agent/cli.py +78 -0
  57. package/src/my_coding_agent/file_reference.py +80 -0
  58. package/src/my_coding_agent/macro.py +408 -0
  59. package/src/my_coding_agent/mcp.py +243 -0
  60. package/src/my_coding_agent/mutation_queue.py +37 -0
  61. package/src/my_coding_agent/paths.py +119 -0
  62. package/src/my_coding_agent/permissions.py +84 -0
  63. package/src/my_coding_agent/prompt.py +54 -0
  64. package/src/my_coding_agent/rpc_server.py +2817 -0
  65. package/src/my_coding_agent/settings.py +126 -0
  66. package/src/my_coding_agent/tools/__init__.py +55 -0
  67. package/src/my_coding_agent/tools/base.py +58 -0
  68. package/src/my_coding_agent/tools/bash.py +206 -0
  69. package/src/my_coding_agent/tools/edit.py +226 -0
  70. package/src/my_coding_agent/tools/find.py +118 -0
  71. package/src/my_coding_agent/tools/grep.py +177 -0
  72. package/src/my_coding_agent/tools/ls.py +112 -0
  73. package/src/my_coding_agent/tools/read.py +113 -0
  74. package/src/my_coding_agent/tools/write.py +72 -0
  75. package/tui/README.md +27 -0
  76. package/tui/bin/my-agent.js +98 -0
  77. package/tui/dist/app.d.ts +41 -0
  78. package/tui/dist/app.js +110 -0
  79. package/tui/dist/bridge/event-translator.d.ts +92 -0
  80. package/tui/dist/bridge/event-translator.js +216 -0
  81. package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
  82. package/tui/dist/bridge/kernel-bridge.js +132 -0
  83. package/tui/dist/client.d.ts +63 -0
  84. package/tui/dist/client.js +239 -0
  85. package/tui/dist/components/assistant-message.d.ts +19 -0
  86. package/tui/dist/components/assistant-message.js +90 -0
  87. package/tui/dist/components/compaction-summary-message.d.ts +19 -0
  88. package/tui/dist/components/compaction-summary-message.js +46 -0
  89. package/tui/dist/components/custom-editor.d.ts +18 -0
  90. package/tui/dist/components/custom-editor.js +56 -0
  91. package/tui/dist/components/dynamic-border.d.ts +9 -0
  92. package/tui/dist/components/dynamic-border.js +14 -0
  93. package/tui/dist/components/footer.d.ts +39 -0
  94. package/tui/dist/components/footer.js +199 -0
  95. package/tui/dist/components/header.d.ts +4 -0
  96. package/tui/dist/components/header.js +21 -0
  97. package/tui/dist/components/keys.d.ts +5 -0
  98. package/tui/dist/components/keys.js +12 -0
  99. package/tui/dist/components/login-selector.d.ts +26 -0
  100. package/tui/dist/components/login-selector.js +181 -0
  101. package/tui/dist/components/logout-selector.d.ts +19 -0
  102. package/tui/dist/components/logout-selector.js +88 -0
  103. package/tui/dist/components/model-selector.d.ts +40 -0
  104. package/tui/dist/components/model-selector.js +268 -0
  105. package/tui/dist/components/session-selector.d.ts +54 -0
  106. package/tui/dist/components/session-selector.js +393 -0
  107. package/tui/dist/components/settings-selector.d.ts +24 -0
  108. package/tui/dist/components/settings-selector.js +146 -0
  109. package/tui/dist/components/status-indicator.d.ts +25 -0
  110. package/tui/dist/components/status-indicator.js +60 -0
  111. package/tui/dist/components/theme-selector.d.ts +14 -0
  112. package/tui/dist/components/theme-selector.js +77 -0
  113. package/tui/dist/components/thinking-selector.d.ts +21 -0
  114. package/tui/dist/components/thinking-selector.js +128 -0
  115. package/tui/dist/components/tool-execution.d.ts +31 -0
  116. package/tui/dist/components/tool-execution.js +206 -0
  117. package/tui/dist/components/tree-selector.d.ts +40 -0
  118. package/tui/dist/components/tree-selector.js +173 -0
  119. package/tui/dist/components/user-message-selector.d.ts +21 -0
  120. package/tui/dist/components/user-message-selector.js +103 -0
  121. package/tui/dist/components/user-message.d.ts +5 -0
  122. package/tui/dist/components/user-message.js +15 -0
  123. package/tui/dist/index.d.ts +11 -0
  124. package/tui/dist/index.js +11 -0
  125. package/tui/dist/interactive/chat-viewport.d.ts +19 -0
  126. package/tui/dist/interactive/chat-viewport.js +41 -0
  127. package/tui/dist/interactive/components.d.ts +1 -0
  128. package/tui/dist/interactive/components.js +1 -0
  129. package/tui/dist/interactive/interactive-mode.d.ts +89 -0
  130. package/tui/dist/interactive/interactive-mode.js +1625 -0
  131. package/tui/dist/interactive/theme.d.ts +1 -0
  132. package/tui/dist/interactive/theme.js +1 -0
  133. package/tui/dist/interactive/tui-renderer.d.ts +8 -0
  134. package/tui/dist/interactive/tui-renderer.js +10 -0
  135. package/tui/dist/protocol.d.ts +78 -0
  136. package/tui/dist/protocol.js +1 -0
  137. package/tui/dist/theme/dark.json +54 -0
  138. package/tui/dist/theme/light.json +71 -0
  139. package/tui/dist/theme/theme.d.ts +20 -0
  140. package/tui/dist/theme/theme.js +86 -0
  141. package/tui/package.json +25 -0
@@ -0,0 +1,2817 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import inspect
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import sys
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any, TextIO
16
+
17
+ from dotenv import find_dotenv, load_dotenv
18
+
19
+ from my_agent_core.events import (
20
+ AgentEnd,
21
+ AgentStart,
22
+ ContextCompacted,
23
+ Event,
24
+ MessageEnd,
25
+ MessageStart,
26
+ MessageUpdate,
27
+ ToolExecutionEnd,
28
+ ToolExecutionStart,
29
+ ToolExecutionUpdate,
30
+ ToolsChanged,
31
+ TurnEnd,
32
+ TurnStart,
33
+ )
34
+ from my_agent_core.session import Session, SessionInfoEntry
35
+ from my_agent_core.session.entries import (
36
+ BranchSummaryEntry,
37
+ CompactionEntry,
38
+ LabelEntry,
39
+ LeafEntry,
40
+ MessageEntry,
41
+ ModelChangeEntry,
42
+ ThinkingLevelChangeEntry,
43
+ )
44
+ from my_agent_core.session.tree import lowest_common_ancestor
45
+ from my_agent_core.skills import SkillManager
46
+ from my_agent_core.tool_history import repair_tool_history
47
+ from my_agent_llm import LLM, Config, Message
48
+ from my_agent_llm.auth.manager import AuthManager
49
+ from my_agent_llm.auth.schema import ApiKeyCredential, OAuthCredential
50
+ from my_coding_agent.agent import CodingAgent
51
+ from my_coding_agent.macro import MacroEngine
52
+ from my_coding_agent.paths import AgentPaths
53
+ from my_coding_agent.permissions import PermissionGate
54
+ from my_coding_agent.prompt import build_default_coding_prompt
55
+ from my_coding_agent.settings import Settings, load_settings, save_settings
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ if sys.platform == "win32":
60
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
61
+ reconfigure_fn = getattr(stream, "reconfigure", None)
62
+ if callable(reconfigure_fn):
63
+ reconfigure_fn(encoding="utf-8", errors="replace")
64
+
65
+
66
+ def uuid7_str() -> str:
67
+ """生成符合 RFC 9562 规范的 UUIDv7 字符串(基于毫秒时间戳保序)。"""
68
+ try:
69
+ timestamp_ms = int(time.time() * 1000)
70
+ except Exception:
71
+ timestamp_ms = 0
72
+ rand = int.from_bytes(os.urandom(10), "big")
73
+ uuid_int = (
74
+ ((timestamp_ms & 0xFFFFFFFFFFFF) << 80)
75
+ | (0x7 << 76)
76
+ | (((rand >> 62) & 0x0FFF) << 64)
77
+ | (0x2 << 62)
78
+ | (rand & 0x3FFFFFFFFFFFFFFF)
79
+ )
80
+ return str(uuid.UUID(int=uuid_int))
81
+
82
+
83
+ def serialize_message(m: Message) -> dict[str, Any]:
84
+ """将内部 Message 实体转为标准 JSON 字典,保留 role、content 与关键 metadata (tool_calls / tool_call_id 等)。"""
85
+ md: dict[str, Any] = {}
86
+ if m.metadata:
87
+ for k, v in m.metadata.items():
88
+ if k == "tool_calls" and isinstance(v, list):
89
+ serialized_tcs = []
90
+ for tc in v:
91
+ if hasattr(tc, "model_dump"):
92
+ serialized_tcs.append(tc.model_dump())
93
+ elif isinstance(tc, dict):
94
+ serialized_tcs.append(tc)
95
+ else:
96
+ serialized_tcs.append(str(tc))
97
+ md[k] = serialized_tcs
98
+ elif hasattr(v, "model_dump"):
99
+ md[k] = v.model_dump()
100
+ elif isinstance(v, (str, int, float, bool, list, dict)) or v is None:
101
+ md[k] = v
102
+ else:
103
+ md[k] = str(v)
104
+ return {
105
+ "role": m.role,
106
+ "content": m.content,
107
+ "metadata": md,
108
+ }
109
+
110
+
111
+ def serialize_event(event: Event, stats: dict[str, Any] | None = None) -> dict[str, Any]:
112
+ """将 Python 内部不可变事实事件序列化为对标 Pi AgentEvent 规范的 JSON 字典。"""
113
+ if isinstance(event, AgentStart):
114
+ return {
115
+ "type": "agent_start",
116
+ "system_prompt": event.system_prompt,
117
+ "user_input": event.user_input,
118
+ }
119
+ elif isinstance(event, AgentEnd):
120
+ res: dict[str, Any] = {
121
+ "type": "agent_end",
122
+ "iterations": event.iterations,
123
+ "stop_reason": event.stop_reason,
124
+ "final_text": event.final_text or "",
125
+ }
126
+ if stats:
127
+ res.update(stats)
128
+ return res
129
+ elif isinstance(event, TurnStart):
130
+ return {
131
+ "type": "turn_start",
132
+ "iteration": event.iteration,
133
+ }
134
+ elif isinstance(event, TurnEnd):
135
+ res = {
136
+ "type": "turn_end",
137
+ }
138
+ if stats:
139
+ res.update(stats)
140
+ return res
141
+ elif isinstance(event, MessageStart):
142
+ msg = event.message
143
+ return {
144
+ "type": "message_start",
145
+ "message": {
146
+ "role": msg.role,
147
+ "content": msg.content or "",
148
+ },
149
+ }
150
+ elif isinstance(event, MessageUpdate):
151
+ msg = event.message
152
+ chunk: Any = event.chunk
153
+ delta_text = ""
154
+ delta_thinking = ""
155
+ if chunk is not None:
156
+ delta_text = getattr(chunk, "content", None) or getattr(chunk, "text", "") or ""
157
+ reasoning = getattr(chunk, "reasoning_content", None)
158
+ if not reasoning and getattr(chunk, "metadata", None) and isinstance(chunk.metadata, dict):
159
+ reasoning = chunk.metadata.get("reasoning_content")
160
+ delta_thinking = str(reasoning) if reasoning else ""
161
+
162
+ return {
163
+ "type": "message_update",
164
+ "message": {
165
+ "role": msg.role,
166
+ "content": msg.content or "",
167
+ },
168
+ "delta": delta_text,
169
+ "reasoning_delta": delta_thinking,
170
+ }
171
+ elif isinstance(event, MessageEnd):
172
+ msg = event.message
173
+ meta = getattr(msg, "metadata", None) or {}
174
+ usage = meta.get("usage")
175
+ out: dict[str, Any] = {
176
+ "type": "message_end",
177
+ "message": {
178
+ "role": msg.role,
179
+ "content": msg.content or "",
180
+ "metadata": meta,
181
+ },
182
+ }
183
+ if usage:
184
+ out["usage"] = usage
185
+ if stats:
186
+ out.update(stats)
187
+ return out
188
+ elif isinstance(event, ToolExecutionStart):
189
+ return {
190
+ "type": "tool_execution_start",
191
+ "toolCallId": event.tool_call_id,
192
+ "toolName": event.tool_name,
193
+ "args": event.args,
194
+ }
195
+ elif isinstance(event, ToolExecutionUpdate):
196
+ return {
197
+ "type": "tool_execution_update",
198
+ "toolCallId": event.tool_call_id,
199
+ "toolName": event.tool_name,
200
+ "partialResult": event.partial_result,
201
+ }
202
+ elif isinstance(event, ToolExecutionEnd):
203
+ return {
204
+ "type": "tool_execution_end",
205
+ "toolCallId": event.tool_call_id,
206
+ "toolName": event.tool_name,
207
+ "result": event.result,
208
+ "isError": event.is_error,
209
+ }
210
+ elif isinstance(event, ContextCompacted):
211
+ return {
212
+ "type": "context_compacted",
213
+ "tokensBefore": event.tokens_before,
214
+ "tokensAfter": event.tokens_after,
215
+ "summarizedCount": event.summarized_count,
216
+ }
217
+ elif isinstance(event, ToolsChanged):
218
+ return {
219
+ "type": "tools_changed",
220
+ "action": event.action,
221
+ "name": event.name,
222
+ }
223
+
224
+ return {"type": type(event).__name__.lower()}
225
+
226
+
227
+ def resolve_model_context_window(model_name: str) -> int:
228
+ """归一化解析模型的实际最大上下文窗口大小(Tokens)。"""
229
+ m = (model_name or "").lower()
230
+ if m.startswith("gemini-"):
231
+ return 1048576
232
+ if "opus" in m:
233
+ return 250000
234
+ if "sonnet" in m:
235
+ return 200000
236
+ if "gpt-4o" in m:
237
+ return 128000
238
+ if "deepseek" in m:
239
+ return 1000000 if ("v4" in m or "flash" in m) else 64000
240
+ return 128000
241
+
242
+
243
+ KNOWN_MODEL_CATALOG: list[dict[str, Any]] = [
244
+ # OpenAI
245
+ {"id": "gpt-4o", "provider": "openai", "name": "GPT-4o", "contextWindow": 128000},
246
+ {"id": "gpt-4o-mini", "provider": "openai", "name": "GPT-4o mini", "contextWindow": 128000},
247
+ {"id": "o1", "provider": "openai", "name": "o1", "contextWindow": 200000},
248
+ {"id": "o3-mini", "provider": "openai", "name": "o3-mini", "contextWindow": 200000},
249
+ # Anthropic
250
+ {"id": "claude-3-5-sonnet-20241022", "provider": "anthropic", "name": "Claude 3.5 Sonnet", "contextWindow": 200000},
251
+ {"id": "claude-3-5-haiku-20241022", "provider": "anthropic", "name": "Claude 3.5 Haiku", "contextWindow": 200000},
252
+ {"id": "claude-3-opus-20240229", "provider": "anthropic", "name": "Claude 3 Opus", "contextWindow": 200000},
253
+ ]
254
+
255
+
256
+ def discover_deepseek_models_remote(
257
+ base_url: str = "https://api.deepseek.com", timeout: float = 5.0
258
+ ) -> list[dict[str, Any]]:
259
+ """主动向 DeepSeek 官方或兼容端点的 /models 接口发起探测,实时获取当前账户可用的最新模型列表。"""
260
+ try:
261
+ import httpx
262
+ from my_agent_llm.auth.manager import AuthManager
263
+
264
+ paths = AgentPaths()
265
+ auth_mgr = AuthManager(auth_path=paths.auth_path)
266
+ cred = auth_mgr.get_credential("deepseek")
267
+ api_key = None
268
+ if isinstance(cred, ApiKeyCredential):
269
+ api_key = cred.resolve_key()
270
+ elif isinstance(cred, OAuthCredential):
271
+ api_key = cred.access
272
+ if not api_key:
273
+ api_key = os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY")
274
+ if not api_key:
275
+ return []
276
+
277
+ target_base = os.environ.get("OPENAI_BASE_URL") or base_url
278
+ target_base_clean = target_base.rstrip("/")
279
+ models_url = f"{target_base_clean}/models"
280
+
281
+ res = httpx.get(
282
+ models_url,
283
+ headers={"Authorization": f"Bearer {api_key}"},
284
+ timeout=timeout,
285
+ )
286
+ if res.status_code != 200:
287
+ return []
288
+
289
+ data = res.json()
290
+ models_data = data.get("data", [])
291
+ if not isinstance(models_data, list) or not models_data:
292
+ return []
293
+
294
+ models: list[dict[str, Any]] = []
295
+ for item in models_data:
296
+ m_id = item.get("id")
297
+ if not m_id:
298
+ continue
299
+ ctx = 1000000 if ("v4" in m_id or "flash" in m_id) else 64000
300
+ name = m_id
301
+ if m_id == "deepseek-chat":
302
+ name = "DeepSeek-V3"
303
+ elif m_id == "deepseek-reasoner":
304
+ name = "DeepSeek-R1"
305
+ elif m_id == "deepseek-flash":
306
+ name = "DeepSeek-Flash"
307
+ elif m_id == "deepseek-v4-pro":
308
+ name = "DeepSeek-V4 Pro"
309
+
310
+ models.append(
311
+ {
312
+ "id": m_id,
313
+ "provider": "deepseek",
314
+ "name": name,
315
+ "contextWindow": ctx,
316
+ }
317
+ )
318
+
319
+ if models:
320
+ cache_file = Path.home() / ".my-pi-agent" / "deepseek-model-catalog.json"
321
+ try:
322
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
323
+ try:
324
+ checked_at_ts = int(time.time() * 1000)
325
+ except Exception:
326
+ checked_at_ts = 0
327
+ cache_data = {"version": 1, "checkedAt": checked_at_ts, "models": models}
328
+ cache_file.write_text(json.dumps(cache_data, ensure_ascii=False, indent=2), encoding="utf-8")
329
+ except Exception as exc:
330
+ logger.debug("写入 deepseek 缓存异常: %s", exc)
331
+
332
+ return models
333
+ except Exception as exc:
334
+ logger.debug("DeepSeek 远程动态模型探测失败: %s", exc)
335
+ return []
336
+
337
+
338
+ def get_deepseek_catalog(force: bool = False) -> list[dict[str, Any]]:
339
+ """纯动态获取 DeepSeek 模型目录(带 4 小时本地缓存与远程动态探测)。"""
340
+ try:
341
+ now_ms = int(time.time() * 1000)
342
+ except Exception:
343
+ now_ms = 0
344
+
345
+ cache_file = Path.home() / ".my-pi-agent" / "deepseek-model-catalog.json"
346
+ if cache_file.exists():
347
+ try:
348
+ raw_text = cache_file.read_text(encoding="utf-8")
349
+ data = json.loads(raw_text)
350
+ checked_at = data.get("checkedAt", 0)
351
+ if not force and checked_at > 0 and (now_ms - checked_at < 4 * 60 * 60 * 1000):
352
+ cached = data.get("models", [])
353
+ if cached:
354
+ return cached
355
+ except Exception as exc:
356
+ logger.debug("读取本地 deepseek 缓存异常: %s", exc)
357
+
358
+ remote = discover_deepseek_models_remote()
359
+ if remote:
360
+ return remote
361
+
362
+ # 仅当完全没有网络且没有本地缓存时的静态离线兜底
363
+ return [
364
+ {"id": "deepseek-chat", "provider": "deepseek", "name": "DeepSeek-V3", "contextWindow": 64000},
365
+ {"id": "deepseek-reasoner", "provider": "deepseek", "name": "DeepSeek-R1", "contextWindow": 64000},
366
+ ]
367
+
368
+
369
+ def _antigravity_model_rank(model_id: str) -> tuple[int, int, str]:
370
+ """对标 pi-antigravity grouping.ts 的 comparePublicModels 优先级排序。"""
371
+ mid = model_id.lower()
372
+ version = 0
373
+ m = re.match(r"^gemini-(\d+)(?:\.(\d+))?", mid)
374
+ if m:
375
+ try:
376
+ v_major = int(m.group(1))
377
+ v_minor = int(m.group(2) or 0)
378
+ version = v_major * 1000 + v_minor
379
+ except (ValueError, TypeError):
380
+ version = 0
381
+
382
+ if "flash" in mid and "pro" not in mid:
383
+ return (0, -version, mid)
384
+ if mid.startswith("claude-opus"):
385
+ return (1, 0, mid)
386
+ if mid.startswith("claude-sonnet"):
387
+ return (2, 0, mid)
388
+ if mid.startswith("claude-"):
389
+ return (3, 0, mid)
390
+ if "pro" in mid:
391
+ return (4, -version, mid)
392
+ if mid.startswith("gemini-"):
393
+ return (5, -version, mid)
394
+ if mid.startswith("gpt-oss"):
395
+ return (6, 0, mid)
396
+ return (7, 0, mid)
397
+
398
+
399
+ ANTIGRAVITY_CACHE_TTL_MS = 4 * 60 * 60 * 1000 # 4 小时刷新一次,对标 pi-antigravity
400
+
401
+
402
+ def discover_antigravity_models_remote(timeout: float = 6.0) -> list[dict[str, Any]]:
403
+ """主动向 Google Cloud Code Assist 专有接口发起 fetchAvailableModels 探测,并按 pi-antigravity 规范完成规约折叠。"""
404
+ try:
405
+ import httpx
406
+ from my_agent_llm.auth.antigravity import ANTIGRAVITY_USER_AGENT, AntigravityAuthResolver
407
+
408
+ resolver = AntigravityAuthResolver()
409
+ creds = resolver.resolve_credentials()
410
+ if not creds:
411
+ return []
412
+
413
+ headers = {
414
+ "Authorization": f"Bearer {creds.access_token}",
415
+ "Content-Type": "application/json",
416
+ "User-Agent": ANTIGRAVITY_USER_AGENT,
417
+ }
418
+ body = {"project": creds.project_id}
419
+
420
+ endpoints = [
421
+ "https://daily-cloudcode-pa.googleapis.com",
422
+ "https://cloudcode-pa.googleapis.com",
423
+ ]
424
+
425
+ raw_models: dict[str, Any] = {}
426
+ for ep in endpoints:
427
+ try:
428
+ res = httpx.post(
429
+ f"{ep}/v1internal:fetchAvailableModels",
430
+ headers=headers,
431
+ json=body,
432
+ timeout=timeout,
433
+ )
434
+ if res.status_code == 200:
435
+ raw_models = res.json().get("models", {})
436
+ if raw_models:
437
+ break
438
+ except Exception:
439
+ continue
440
+
441
+ if not raw_models:
442
+ return []
443
+
444
+ # 归约折叠算法 (严格对齐 pi-antigravity 的 grouping.ts)
445
+ runtime_aliases = {
446
+ "gemini-3-flash-agent": "gemini-3.5-flash",
447
+ "gemini-pro-agent": "gemini-3.1-pro",
448
+ }
449
+ thinking_suffixes = [
450
+ "-extra-low",
451
+ "-extra-high",
452
+ "-thinking",
453
+ "-minimal",
454
+ "-medium",
455
+ "-high",
456
+ "-low",
457
+ "-tiered",
458
+ ]
459
+
460
+ public_groups: dict[str, dict[str, Any]] = {}
461
+ for runtime_id, info in raw_models.items():
462
+ if not re.match(r"^(gemini-|claude-|gpt-oss-)", runtime_id, re.I):
463
+ continue
464
+ if (
465
+ any(runtime_id.startswith(p) for p in ["chat_", "tab_", "MODEL_"])
466
+ or "image" in runtime_id
467
+ or "2.5" in runtime_id
468
+ ):
469
+ continue
470
+
471
+ public_id = runtime_aliases.get(runtime_id)
472
+ if not public_id:
473
+ cleaned = runtime_id
474
+ for sfx in thinking_suffixes:
475
+ if cleaned.endswith(sfx):
476
+ cleaned = cleaned[: -len(sfx)]
477
+ break
478
+ public_id = cleaned
479
+
480
+ if public_id not in public_groups:
481
+ display_name = info.get("displayName") or public_id
482
+ clean_name = re.sub(
483
+ r"\s*\((?:extra\s*low|extra\s*high|low|medium|high|minimal|thinking)\)\s*$",
484
+ "",
485
+ display_name,
486
+ flags=re.I,
487
+ ).strip()
488
+ ctx_window = (
489
+ 1048576
490
+ if public_id.startswith("gemini-")
491
+ else (250000 if "opus" in public_id else (200000 if "sonnet" in public_id else 131072))
492
+ )
493
+ public_groups[public_id] = {
494
+ "id": public_id,
495
+ "provider": "antigravity",
496
+ "name": f"{clean_name} (Antigravity)" if "Antigravity" not in clean_name else clean_name,
497
+ "contextWindow": ctx_window,
498
+ }
499
+
500
+ models = list(public_groups.values())
501
+ models.sort(key=lambda x: _antigravity_model_rank(x["id"]))
502
+
503
+ # 写入持久化缓存
504
+ cache_file = Path.home() / ".my-pi-agent" / "antigravity-model-catalog.json"
505
+ try:
506
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
507
+ try:
508
+ checked_at_ts = int(time.time() * 1000)
509
+ except Exception:
510
+ checked_at_ts = 0
511
+ cache_data = {
512
+ "version": 1,
513
+ "checkedAt": checked_at_ts,
514
+ "models": models,
515
+ }
516
+ cache_file.write_text(json.dumps(cache_data, ensure_ascii=False, indent=2), encoding="utf-8")
517
+ except Exception as exc:
518
+ logger.debug("写入本地 antigravity-model-catalog.json 异常: %s", exc)
519
+
520
+ return models
521
+ except Exception as exc:
522
+ logger.debug("Antigravity 远程动态模型探测失败: %s", exc)
523
+ return []
524
+
525
+
526
+ def get_antigravity_catalog(force: bool = False) -> list[dict[str, Any]]:
527
+ """动态获取对标 pi-antigravity 的公共模型目录。
528
+
529
+ 1. 优先检查本地缓存(~/.my-pi-agent/ 或 ~/.pi/agent/),若在 4 小时有效期内且未强制刷新,直接返回;
530
+ 2. 若缓存缺失或已过期,主动发起远程 fetchAvailableModels 探测并更新缓存;
531
+ 3. 若网络或探测失败,回退至 pi-antigravity 官方 ANTIGRAVITY_MODELS 权威静态表。
532
+ """
533
+ try:
534
+ now_ms = int(time.time() * 1000)
535
+ except Exception:
536
+ now_ms = 0
537
+
538
+ # 1. 优先从本地缓存加载 (4小时TTL)
539
+ for cache_path in [
540
+ Path.home() / ".my-pi-agent" / "antigravity-model-catalog.json",
541
+ Path.home() / ".pi" / "agent" / "antigravity-model-catalog.json",
542
+ ]:
543
+ if cache_path.exists():
544
+ try:
545
+ raw_text = cache_path.read_text(encoding="utf-8")
546
+ data = json.loads(raw_text)
547
+ checked_at = data.get("checkedAt", 0)
548
+ if not force and checked_at > 0 and (now_ms - checked_at < ANTIGRAVITY_CACHE_TTL_MS):
549
+ models = []
550
+ seen = set()
551
+ for m in data.get("models", []):
552
+ mid = m.get("id")
553
+ if not mid or mid in seen:
554
+ continue
555
+ if (
556
+ any(mid.startswith(p) for p in ["chat_", "tab_", "MODEL_"])
557
+ or "image" in mid
558
+ or "2.5" in mid
559
+ ):
560
+ continue
561
+ seen.add(mid)
562
+ models.append(
563
+ {
564
+ "id": mid,
565
+ "provider": "antigravity",
566
+ "name": m.get("name") or f"{mid} (Antigravity)",
567
+ "contextWindow": m.get("contextWindow", 1048576),
568
+ }
569
+ )
570
+ if models:
571
+ models.sort(key=lambda x: _antigravity_model_rank(x["id"]))
572
+ return models
573
+ except Exception as exc:
574
+ logger.debug("读取本地缓存 %s 异常: %s", cache_path, exc)
575
+
576
+ # 2. 尝试远程动态探测
577
+ remote_models = discover_antigravity_models_remote()
578
+ if remote_models:
579
+ return remote_models
580
+
581
+ # 3. pi-antigravity 官方 ANTIGRAVITY_MODELS 权威静态表
582
+ fallback = [
583
+ {
584
+ "id": "gemini-3.8-flash",
585
+ "provider": "antigravity",
586
+ "name": "Gemini 3.8 Flash (Antigravity)",
587
+ "contextWindow": 1048576,
588
+ },
589
+ {
590
+ "id": "gemini-3.7-flash",
591
+ "provider": "antigravity",
592
+ "name": "Gemini 3.7 Flash (Antigravity)",
593
+ "contextWindow": 1048576,
594
+ },
595
+ {
596
+ "id": "gemini-3.6-flash",
597
+ "provider": "antigravity",
598
+ "name": "Gemini 3.6 Flash (Antigravity)",
599
+ "contextWindow": 1048576,
600
+ },
601
+ {
602
+ "id": "gemini-3.5-flash",
603
+ "provider": "antigravity",
604
+ "name": "Gemini 3.5 Flash (Antigravity)",
605
+ "contextWindow": 1048576,
606
+ },
607
+ {
608
+ "id": "gemini-3.1-pro",
609
+ "provider": "antigravity",
610
+ "name": "Gemini 3.1 Pro (Antigravity)",
611
+ "contextWindow": 1048576,
612
+ },
613
+ {
614
+ "id": "claude-opus-4-6",
615
+ "provider": "antigravity",
616
+ "name": "Claude Opus 4.6 (Antigravity)",
617
+ "contextWindow": 250000,
618
+ },
619
+ {
620
+ "id": "claude-sonnet-4-6",
621
+ "provider": "antigravity",
622
+ "name": "Claude Sonnet 4.6 (Antigravity)",
623
+ "contextWindow": 200000,
624
+ },
625
+ {
626
+ "id": "gpt-oss-120b",
627
+ "provider": "antigravity",
628
+ "name": "GPT-OSS 120B (Antigravity)",
629
+ "contextWindow": 131072,
630
+ },
631
+ ]
632
+ fallback.sort(key=lambda x: _antigravity_model_rank(x["id"]))
633
+ return fallback
634
+
635
+
636
+ class RpcServer:
637
+ """标准 stdio JSON-RPC 2.0 服务端,将 Python 无头 CodingAgent 连接至 Node 前端。"""
638
+
639
+ def __init__(
640
+ self,
641
+ stdin: TextIO | None = None,
642
+ stdout: TextIO | None = None,
643
+ stderr: TextIO | None = None,
644
+ agent: CodingAgent | None = None,
645
+ llm: Any | None = None,
646
+ paths: AgentPaths | None = None,
647
+ ):
648
+ self.stdin = stdin or sys.stdin
649
+ self.stdout = stdout or sys.stdout
650
+ self.stderr = stderr or sys.stderr
651
+ self.agent = agent
652
+ self.llm = llm
653
+ self.paths = paths
654
+ self.macro_engine: MacroEngine = MacroEngine(
655
+ workspace=agent.workspace if agent else None,
656
+ paths=paths,
657
+ )
658
+ self.auth_mgr: AuthManager | None = AuthManager(auth_path=paths.auth_path) if paths else None
659
+ self.settings: Settings | None = None
660
+ self.is_shutting_down = False
661
+ self._write_lock = threading.Lock()
662
+ self._background_tasks: set[asyncio.Task[Any]] = set()
663
+ self.session_usage: dict[str, Any] = {
664
+ "input": 0,
665
+ "output": 0,
666
+ "cacheRead": 0,
667
+ "cacheWrite": 0,
668
+ "total": 0,
669
+ "cost": 0.0,
670
+ "latestCacheHitRate": 0.0,
671
+ }
672
+
673
+ def emit_json(self, payload: dict[str, Any]) -> None:
674
+ """向 stdout 写入单行 JSON 并强制 flush。"""
675
+ line = json.dumps(payload, ensure_ascii=False)
676
+ with self._write_lock:
677
+ try:
678
+ self.stdout.write(line + "\n")
679
+ self.stdout.flush()
680
+ except UnicodeEncodeError:
681
+ # 编码兜底:若当前宿主 stdout 不支持特定 Unicode 字符,使用 ASCII 转义输出
682
+ ascii_line = json.dumps(payload, ensure_ascii=True)
683
+ self.stdout.write(ascii_line + "\n")
684
+ self.stdout.flush()
685
+
686
+ def send_notification(self, method: str, params: dict[str, Any]) -> None:
687
+ """向客户端发送单向通知 (如 event)。"""
688
+ self.emit_json(
689
+ {
690
+ "jsonrpc": "2.0",
691
+ "method": method,
692
+ "params": params,
693
+ }
694
+ )
695
+
696
+ def send_response(
697
+ self,
698
+ req_id: int | str,
699
+ result: Any = None,
700
+ error: dict[str, Any] | None = None,
701
+ ) -> dict[str, Any]:
702
+ """构造并发送 RPC 响应,同时返回字典便于单元测试断言。"""
703
+ resp: dict[str, Any] = {
704
+ "jsonrpc": "2.0",
705
+ "id": req_id,
706
+ }
707
+ if error is not None:
708
+ resp["error"] = error
709
+ else:
710
+ resp["result"] = result or {}
711
+
712
+ self.emit_json(resp)
713
+ return resp
714
+
715
+ def _compute_session_usage(self, session: Session, model_name: str) -> dict[str, Any]:
716
+ """对标 Pi 规范,从会话历史中提取所有 Assistant 消息的 usage 累加统计。"""
717
+ totals: dict[str, Any] = {
718
+ "input": 0,
719
+ "output": 0,
720
+ "cacheRead": 0,
721
+ "cacheWrite": 0,
722
+ "total": 0,
723
+ "cost": 0.0,
724
+ "latestCacheHitRate": 0.0,
725
+ }
726
+ for msg in session.get_full_history_messages():
727
+ if msg.role == "assistant" and msg.metadata:
728
+ usage = msg.metadata.get("usage")
729
+ if usage and isinstance(usage, dict):
730
+ prompt_tok = usage.get("prompt_tokens") or usage.get("input") or 0
731
+ comp_tok = usage.get("completion_tokens") or usage.get("output") or 0
732
+ cache_read = (
733
+ usage.get("cache_read_tokens") or usage.get("cache_read") or usage.get("cacheRead") or 0
734
+ )
735
+ cache_write = (
736
+ usage.get("cache_write_tokens") or usage.get("cache_write") or usage.get("cacheWrite") or 0
737
+ )
738
+ total_tok = usage.get("total_tokens") or usage.get("total") or (prompt_tok + comp_tok)
739
+
740
+ totals["input"] += prompt_tok
741
+ totals["output"] += comp_tok
742
+ totals["cacheRead"] += cache_read
743
+ totals["cacheWrite"] += cache_write
744
+ totals["total"] += total_tok
745
+
746
+ total_prompt = prompt_tok + cache_read + cache_write
747
+ if total_prompt > 0 and cache_read > 0:
748
+ totals["latestCacheHitRate"] = (cache_read / total_prompt) * 100.0
749
+
750
+ model_lower = (model_name or "").lower()
751
+ if "gemini" in model_lower:
752
+ totals["cost"] += (prompt_tok * 0.1 + comp_tok * 0.4 + cache_read * 0.025) / 1000000.0
753
+ elif "claude" in model_lower:
754
+ if "opus" in model_lower:
755
+ totals["cost"] += (prompt_tok * 15.0 + comp_tok * 75.0 + cache_read * 1.5) / 1000000.0
756
+ else:
757
+ totals["cost"] += (prompt_tok * 3.0 + comp_tok * 15.0 + cache_read * 0.3) / 1000000.0
758
+ elif "deepseek" in model_lower:
759
+ totals["cost"] += (prompt_tok * 0.14 + comp_tok * 0.28 + cache_read * 0.014) / 1000000.0
760
+ elif "gpt-4o" in model_lower:
761
+ totals["cost"] += (prompt_tok * 2.5 + comp_tok * 10.0 + cache_read * 1.25) / 1000000.0
762
+
763
+ # 对标 Pi 原厂 calculateContextTokens:提取最后一次推理生效的 Context 大小
764
+ last_context_tokens = 0
765
+ history_msgs = session.get_full_history_messages()
766
+ for msg in reversed(history_msgs):
767
+ if msg.role == "assistant" and msg.metadata:
768
+ usage = msg.metadata.get("usage")
769
+ if usage and isinstance(usage, dict):
770
+ prompt_tok = usage.get("prompt_tokens") or usage.get("input") or 0
771
+ comp_tok = usage.get("completion_tokens") or usage.get("output") or 0
772
+ cache_read = (
773
+ usage.get("cache_read_tokens") or usage.get("cache_read") or usage.get("cacheRead") or 0
774
+ )
775
+ cache_write = (
776
+ usage.get("cache_write_tokens") or usage.get("cache_write") or usage.get("cacheWrite") or 0
777
+ )
778
+ tot = prompt_tok + comp_tok + cache_read + cache_write
779
+ if tot > 0:
780
+ last_context_tokens = tot
781
+ break
782
+
783
+ if not last_context_tokens and history_msgs:
784
+ total_chars = sum(len(m.content or "") for m in history_msgs)
785
+ last_context_tokens = max(1, total_chars // 4)
786
+
787
+ totals["contextTokens"] = last_context_tokens
788
+ totals["cacheHitRate"] = totals["latestCacheHitRate"]
789
+
790
+ return totals
791
+
792
+ def _resolve_initial_llm(
793
+ self,
794
+ workspace_path: Path,
795
+ explicit_model: str | None,
796
+ settings: Settings,
797
+ auth_mgr: AuthManager,
798
+ ) -> LLM | None:
799
+ """根据启动参数、工作区配置与凭证中心探测构造底层 LLM 客户端。"""
800
+ model_name = explicit_model or settings.default_model
801
+ provider = None
802
+ if model_name and "/" in model_name:
803
+ provider, model_name = model_name.split("/", 1)
804
+ elif model_name and model_name.startswith("gemini-"):
805
+ provider = "antigravity"
806
+ elif model_name and ("deepseek" in model_name):
807
+ provider = (
808
+ "deepseek"
809
+ if (os.environ.get("DEEPSEEK_API_KEY") or auth_mgr.get_credential("deepseek"))
810
+ else ("openai" if explicit_model else None)
811
+ )
812
+ elif model_name and ("gpt-" in model_name or "o1" in model_name or "o3" in model_name):
813
+ provider = "openai"
814
+ elif model_name and ("claude-" in model_name):
815
+ provider = "anthropic"
816
+
817
+ api_key = None
818
+ base_url = None
819
+ if not provider:
820
+ if os.environ.get("OPENAI_API_KEY") or auth_mgr.get_credential("openai"):
821
+ provider = "openai"
822
+ model_name = explicit_model or os.environ.get("OPENAI_MODEL") or "gpt-4o"
823
+ elif os.environ.get("DEEPSEEK_API_KEY") or auth_mgr.get_credential("deepseek"):
824
+ provider = "deepseek"
825
+ model_name = explicit_model or os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat"
826
+ elif os.environ.get("ANTHROPIC_API_KEY") or auth_mgr.get_credential("anthropic"):
827
+ provider = "anthropic"
828
+ model_name = explicit_model or os.environ.get("ANTHROPIC_MODEL") or "claude-3-5-sonnet-20241022"
829
+ else:
830
+ from my_agent_llm.auth.antigravity import AntigravityAuthResolver
831
+
832
+ resolver = AntigravityAuthResolver(workspace=workspace_path)
833
+ if resolver.resolve_credentials() is not None or auth_mgr.get_credential("antigravity") is not None:
834
+ provider = "antigravity"
835
+ model_name = explicit_model or "gemini-3.8-flash"
836
+ else:
837
+ provider = settings.default_provider or "openai"
838
+ model_name = explicit_model or "gpt-4o"
839
+
840
+ if provider:
841
+ # 1. 优先从全局凭据中心 (~/.my-pi-agent/auth.json) 加载
842
+ cred = auth_mgr.get_credential(provider)
843
+ if cred is not None:
844
+ if isinstance(cred, ApiKeyCredential):
845
+ api_key = cred.resolve_key()
846
+ if not base_url and cred.base_url:
847
+ base_url = cred.base_url
848
+ elif isinstance(cred, OAuthCredential):
849
+ api_key = cred.access
850
+
851
+ # 2. 次选本地环境变量与 .env 兜底
852
+ if not api_key:
853
+ api_key = os.environ.get(f"{provider.upper()}_API_KEY")
854
+ base_url = os.environ.get(f"{provider.upper()}_BASE_URL")
855
+ if provider == "openai":
856
+ api_key = api_key or os.environ.get("OPENAI_API_KEY")
857
+ base_url = base_url or os.environ.get("OPENAI_BASE_URL")
858
+ elif provider == "antigravity":
859
+ api_key = (
860
+ api_key or os.environ.get("ANTIGRAVITY_ACCESS_TOKEN") or os.environ.get("GOOGLE_ACCESS_TOKEN")
861
+ )
862
+
863
+ if not api_key and provider != "antigravity":
864
+ api_key = os.environ.get("OPENAI_API_KEY")
865
+ if not base_url and provider == "openai":
866
+ base_url = os.environ.get("OPENAI_BASE_URL")
867
+
868
+ try:
869
+ return LLM(config=Config(provider=provider, model=model_name, api_key=api_key, base_url=base_url))
870
+ except Exception:
871
+ return None
872
+
873
+ async def _handle_initialize(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
874
+ workspace_path = Path(params.get("workspace", ".")).resolve()
875
+ paths = self.paths or AgentPaths()
876
+ paths.ensure_directories()
877
+ self.paths = paths
878
+ settings = load_settings(paths, cwd=workspace_path)
879
+ self.settings = settings
880
+ auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
881
+ self.auth_mgr = auth_mgr
882
+
883
+ explicit_model = params.get("model")
884
+ mode = params.get("mode", settings.default_permission_mode)
885
+
886
+ if (workspace_path / ".env").exists():
887
+ load_dotenv(workspace_path / ".env", override=False)
888
+
889
+ llm = self.llm
890
+ if llm is None:
891
+ llm = self._resolve_initial_llm(workspace_path, explicit_model, settings, auth_mgr)
892
+
893
+ session_file = paths.default_session_path(workspace_path)
894
+ session_file.parent.mkdir(parents=True, exist_ok=True)
895
+
896
+ target_session: Session | None = None
897
+ should_continue = bool(params.get("continue_session", False) or params.get("continue", False))
898
+ resume_param = params.get("resume")
899
+ if resume_param and isinstance(resume_param, str) and resume_param != "true":
900
+ s_dir = paths.project_session_dir(workspace_path)
901
+ for cand in [s_dir / f"{resume_param}.jsonl", s_dir / resume_param, Path(resume_param)]:
902
+ if cand.exists():
903
+ try:
904
+ target_session = Session.load(cand)
905
+ break
906
+ except Exception:
907
+ continue
908
+ elif should_continue:
909
+ s_dir = paths.project_session_dir(workspace_path)
910
+ jsonl_files = sorted(s_dir.glob("*.jsonl"), key=lambda p: os.path.getmtime(p), reverse=True)
911
+ if jsonl_files:
912
+ try:
913
+ target_session = Session.load(jsonl_files[0])
914
+ except Exception:
915
+ target_session = None
916
+
917
+ if target_session is None:
918
+ if session_file.exists() and not bool(params.get("new_session", False)):
919
+ try:
920
+ target_session = Session.load(session_file)
921
+ except Exception:
922
+ target_session = Session(path=session_file, cwd=str(workspace_path))
923
+ else:
924
+ target_session = Session(path=session_file, cwd=str(workspace_path))
925
+
926
+ if bool(params.get("no_session", False)):
927
+ target_session.save = lambda: None # type: ignore[method-assign]
928
+
929
+ gate = PermissionGate(mode=mode) if mode else None
930
+ self.agent = CodingAgent(
931
+ workspace=workspace_path,
932
+ llm=llm,
933
+ session=target_session,
934
+ permission_gate=gate,
935
+ )
936
+
937
+ if initial_name := params.get("name"):
938
+ name_str = str(initial_name).strip()
939
+ if name_str:
940
+ self.agent.session.metadata["name"] = name_str
941
+ self.agent.session.metadata["title"] = name_str
942
+ info_entry = SessionInfoEntry(
943
+ name=name_str,
944
+ title=name_str,
945
+ cwd=str(workspace_path),
946
+ parent_id=self.agent.session.tree.current_id,
947
+ )
948
+ self.agent.session.store.append_entry(info_entry)
949
+
950
+ if initial_thinking := params.get("thinking"):
951
+ thinking_str = str(initial_thinking).strip().lower()
952
+ valid_levels = {"off", "minimal", "low", "medium", "high", "xhigh", "max"}
953
+ if thinking_str in valid_levels:
954
+ setattr(self.agent, "thinking_level", thinking_str)
955
+ t_entry = ThinkingLevelChangeEntry(
956
+ thinking_level=thinking_str,
957
+ parent_id=self.agent.session.tree.current_id,
958
+ )
959
+ self.agent.session.append_entry(t_entry)
960
+
961
+ self.macro_engine = MacroEngine(workspace=workspace_path, paths=paths)
962
+
963
+ messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
964
+
965
+ actual_model = getattr(getattr(self.agent.agent.llm, "config", None), "model", "default")
966
+ actual_provider = getattr(getattr(self.agent.agent.llm, "config", None), "provider", "default")
967
+ ctx_win = resolve_model_context_window(actual_model)
968
+ self.session_usage = self._compute_session_usage(target_session, actual_model)
969
+
970
+ return self.send_response(
971
+ req_id,
972
+ result={
973
+ "status": "ok",
974
+ "workspace": str(workspace_path),
975
+ "model": actual_model,
976
+ "provider": actual_provider,
977
+ "context_window": ctx_win,
978
+ "usage": self.session_usage,
979
+ "thinking_level": getattr(self.agent, "thinking_level", "off"),
980
+ "session_id": target_session.id,
981
+ "session_file": str(target_session.path) if target_session.path else "",
982
+ "session_name": target_session.metadata.get("name")
983
+ or target_session.metadata.get("title")
984
+ or target_session.id,
985
+ "messages": messages_repr,
986
+ },
987
+ )
988
+
989
+ async def _handle_prompt(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
990
+ if not self.agent:
991
+ return self.send_response(
992
+ req_id,
993
+ error={"code": -32001, "message": "Agent not initialized"},
994
+ )
995
+ if getattr(self.agent.agent, "llm", None) is None:
996
+ return self.send_response(
997
+ req_id,
998
+ error={
999
+ "code": -32002,
1000
+ "message": "未检测到有效模型凭据。请在当前项目 .env 文件中配置 OPENAI_API_KEY 或 DEEPSEEK_API_KEY,或输入 /login 绑定 Key。",
1001
+ },
1002
+ )
1003
+
1004
+ text = params.get("text", "")
1005
+ async for event in self.agent.run_stream(text):
1006
+ if isinstance(event, MessageEnd) and event.message and event.message.role == "assistant":
1007
+ meta = event.message.metadata or {}
1008
+ usage = meta.get("usage")
1009
+ if usage and isinstance(usage, dict):
1010
+ prompt_tok = usage.get("prompt_tokens") or usage.get("input") or 0
1011
+ comp_tok = usage.get("completion_tokens") or usage.get("output") or 0
1012
+ cache_read = (
1013
+ usage.get("cache_read_tokens") or usage.get("cache_read") or usage.get("cacheRead") or 0
1014
+ )
1015
+ cache_write = (
1016
+ usage.get("cache_write_tokens") or usage.get("cache_write") or usage.get("cacheWrite") or 0
1017
+ )
1018
+ total_tok = usage.get("total_tokens") or usage.get("total") or (prompt_tok + comp_tok)
1019
+
1020
+ self.session_usage["input"] += prompt_tok
1021
+ self.session_usage["output"] += comp_tok
1022
+ self.session_usage["cacheRead"] += cache_read
1023
+ self.session_usage["cacheWrite"] += cache_write
1024
+ self.session_usage["total"] += total_tok
1025
+ self.session_usage["contextTokens"] = prompt_tok + comp_tok + cache_read + cache_write
1026
+
1027
+ total_prompt = prompt_tok + cache_read + cache_write
1028
+ if total_prompt > 0 and cache_read > 0:
1029
+ hit_rate = (cache_read / total_prompt) * 100.0
1030
+ self.session_usage["latestCacheHitRate"] = hit_rate
1031
+ self.session_usage["cacheHitRate"] = hit_rate
1032
+
1033
+ model_id = getattr(self.agent.agent, "model", "") or ""
1034
+ model_lower = model_id.lower()
1035
+ if "gemini" in model_lower:
1036
+ self.session_usage["cost"] += (
1037
+ prompt_tok * 0.1 + comp_tok * 0.4 + cache_read * 0.025
1038
+ ) / 1000000.0
1039
+ elif "claude" in model_lower:
1040
+ if "opus" in model_lower:
1041
+ self.session_usage["cost"] += (
1042
+ prompt_tok * 15.0 + comp_tok * 75.0 + cache_read * 1.5
1043
+ ) / 1000000.0
1044
+ else:
1045
+ self.session_usage["cost"] += (
1046
+ prompt_tok * 3.0 + comp_tok * 15.0 + cache_read * 0.3
1047
+ ) / 1000000.0
1048
+ elif "deepseek" in model_lower:
1049
+ self.session_usage["cost"] += (
1050
+ prompt_tok * 0.14 + comp_tok * 0.28 + cache_read * 0.014
1051
+ ) / 1000000.0
1052
+ elif "gpt-4o" in model_lower:
1053
+ self.session_usage["cost"] += (
1054
+ prompt_tok * 2.5 + comp_tok * 10.0 + cache_read * 1.25
1055
+ ) / 1000000.0
1056
+
1057
+ model_name = getattr(self.agent.agent, "model", "") if self.agent else ""
1058
+ ctx_win = resolve_model_context_window(model_name)
1059
+ context_tok = 0
1060
+ ctx_inst = getattr(self.agent, "_ctx", None) or getattr(getattr(self.agent, "agent", None), "_ctx", None)
1061
+ if ctx_inst is not None:
1062
+ context_tok = getattr(ctx_inst, "total_tokens", 0) or getattr(ctx_inst, "last_token_count", 0)
1063
+ if not context_tok:
1064
+ context_tok = self.session_usage["total"]
1065
+
1066
+ stats = {
1067
+ "usage": {
1068
+ "input": self.session_usage["input"],
1069
+ "output": self.session_usage["output"],
1070
+ "cacheRead": self.session_usage["cacheRead"],
1071
+ "cacheWrite": self.session_usage["cacheWrite"],
1072
+ "cacheHitRate": self.session_usage["latestCacheHitRate"],
1073
+ "total": self.session_usage["total"],
1074
+ "contextTokens": context_tok,
1075
+ "cost": self.session_usage["cost"],
1076
+ },
1077
+ "contextWindow": ctx_win,
1078
+ }
1079
+
1080
+ serialized = serialize_event(event, stats=stats)
1081
+ self.send_notification("event", serialized)
1082
+
1083
+ return self.send_response(req_id, result={"status": "completed"})
1084
+
1085
+ def _handle_login(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1086
+ provider = params.get("provider", "").lower().strip()
1087
+ key = params.get("key", "").strip()
1088
+
1089
+ paths = self.paths or AgentPaths()
1090
+ paths.ensure_directories()
1091
+ self.paths = paths
1092
+ auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
1093
+ self.auth_mgr = auth_mgr
1094
+
1095
+ # 1. 特殊处理 antigravity:自动关联本地 pi-antigravity 的 auth.json,免 Web 登录
1096
+ if provider == "antigravity":
1097
+ home = Path(os.environ.get("USERPROFILE") or os.environ.get("HOME") or "~").expanduser()
1098
+ pi_auth_candidates = [
1099
+ paths.auth_path,
1100
+ home / ".my-pi-agent" / "auth.json",
1101
+ home / ".pi" / "agent" / "auth.json",
1102
+ home / ".pi" / "auth.json",
1103
+ ]
1104
+ synced_cred: dict[str, Any] | None = None
1105
+ source_file: Path | None = None
1106
+ for p in pi_auth_candidates:
1107
+ if p.exists():
1108
+ try:
1109
+ data = json.loads(p.read_text(encoding="utf-8"))
1110
+ if isinstance(data, dict):
1111
+ entry = data.get("antigravity") or data.get("google-antigravity")
1112
+ if entry and isinstance(entry, dict):
1113
+ synced_cred = entry
1114
+ source_file = p
1115
+ break
1116
+ except Exception:
1117
+ continue
1118
+
1119
+ if synced_cred:
1120
+ target_auth = paths.auth_path
1121
+ target_auth.parent.mkdir(parents=True, exist_ok=True)
1122
+ current_data: dict[str, Any] = {}
1123
+ if target_auth.exists():
1124
+ try:
1125
+ current_data = json.loads(target_auth.read_text(encoding="utf-8"))
1126
+ except Exception:
1127
+ current_data = {}
1128
+ current_data["antigravity"] = synced_cred
1129
+ target_auth.write_text(json.dumps(current_data, indent=2, ensure_ascii=False), encoding="utf-8")
1130
+
1131
+ access_tok = synced_cred.get("access") or synced_cred.get("access_token")
1132
+ if access_tok:
1133
+ os.environ["ANTIGRAVITY_ACCESS_TOKEN"] = str(access_tok)
1134
+ email_info = f" (Google 账号: {synced_cred.get('email')})" if synced_cred.get("email") else ""
1135
+ return self.send_response(
1136
+ req_id,
1137
+ result={
1138
+ "status": "ok",
1139
+ "message": f"✓ 已成功同步并绑定 pi-antigravity 认证凭据 ({source_file}{email_info}),无需网页登录!",
1140
+ },
1141
+ )
1142
+ elif key:
1143
+ os.environ["ANTIGRAVITY_ACCESS_TOKEN"] = key
1144
+ auth_mgr.set_api_key(provider="antigravity", key=key)
1145
+ return self.send_response(
1146
+ req_id,
1147
+ result={
1148
+ "status": "ok",
1149
+ "message": "✓ 已成功绑定 Antigravity 凭据至凭据中心!",
1150
+ },
1151
+ )
1152
+ else:
1153
+ return self.send_response(
1154
+ req_id,
1155
+ result={
1156
+ "status": "error",
1157
+ "message": "未在 ~/.my-pi-agent/auth.json 或 ~/.pi/agent/auth.json 中检测到 antigravity 凭据。请将包含 antigravity 字段的 auth.json 放置到上述路径,或直接在登录框中粘贴 Access Token。",
1158
+ },
1159
+ )
1160
+
1161
+ # 2. 其它提供商(如 deepseek, openai, anthropic):配置 API Key
1162
+ if not provider or not key:
1163
+ return self.send_response(
1164
+ req_id,
1165
+ result={
1166
+ "status": "info",
1167
+ "message": "请使用: /login <provider> <key>,例如: /login deepseek sk-xxxx 或 /login openai sk-xxxx",
1168
+ },
1169
+ )
1170
+
1171
+ key_name = f"{provider.upper()}_API_KEY"
1172
+ os.environ[key_name] = key
1173
+ auth_mgr.set_api_key(provider=provider, key=key)
1174
+
1175
+ return self.send_response(
1176
+ req_id,
1177
+ result={
1178
+ "status": "ok",
1179
+ "message": f"✓ 已成功绑定 {provider} API Key 至全局凭据中心 (~/.my-pi-agent/auth.json)!",
1180
+ },
1181
+ )
1182
+
1183
+ def _handle_steer(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1184
+ if not self.agent:
1185
+ return self.send_response(
1186
+ req_id,
1187
+ error={"code": -32001, "message": "Agent not initialized"},
1188
+ )
1189
+ msg = params.get("message", "")
1190
+ self.agent.steer(msg)
1191
+ return self.send_response(req_id, result={"status": "ok"})
1192
+
1193
+ def _handle_followup(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1194
+ if not self.agent:
1195
+ return self.send_response(
1196
+ req_id,
1197
+ error={"code": -32001, "message": "Agent not initialized"},
1198
+ )
1199
+ msg = params.get("message", "")
1200
+ self.agent.follow_up(msg)
1201
+ return self.send_response(req_id, result={"status": "ok"})
1202
+
1203
+ def _handle_abort(self, req_id: Any) -> dict[str, Any]:
1204
+ if self.agent:
1205
+ self.agent.abort()
1206
+ return self.send_response(req_id, result={"status": "ok"})
1207
+
1208
+ def _handle_session_name(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1209
+ if not self.agent:
1210
+ return self.send_response(
1211
+ req_id,
1212
+ error={"code": -32001, "message": "Agent not initialized"},
1213
+ )
1214
+ name = params.get("name", "").strip()
1215
+ if not name:
1216
+ return self.send_response(
1217
+ req_id,
1218
+ error={"code": -32602, "message": "Missing 'name' parameter"},
1219
+ )
1220
+
1221
+ entry = SessionInfoEntry(
1222
+ name=name,
1223
+ title=name,
1224
+ cwd=str(self.agent.workspace),
1225
+ parent_id=self.agent.session.tree.current_id,
1226
+ )
1227
+ self.agent.session.store.append_entry(entry)
1228
+
1229
+ return self.send_response(
1230
+ req_id,
1231
+ result={
1232
+ "status": "ok",
1233
+ "name": name,
1234
+ },
1235
+ )
1236
+
1237
+ def _handle_session_list(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1238
+ paths = self.paths or AgentPaths()
1239
+ workspace_path = Path(self.agent.workspace if self.agent else params.get("workspace", ".")).resolve()
1240
+ all_projects = bool(params.get("all_projects", False))
1241
+
1242
+ target_dirs: list[Path] = []
1243
+ if all_projects:
1244
+ if paths.sessions_dir.exists():
1245
+ target_dirs = [d for d in paths.sessions_dir.iterdir() if d.is_dir()]
1246
+ else:
1247
+ proj_dir = paths.project_session_dir(workspace_path)
1248
+ if proj_dir.exists():
1249
+ target_dirs = [proj_dir]
1250
+
1251
+ sessions_meta: list[dict[str, Any]] = []
1252
+ for s_dir in target_dirs:
1253
+ for f in s_dir.glob("*.jsonl"):
1254
+ try:
1255
+ with open(f, encoding="utf-8") as fh:
1256
+ line1 = fh.readline()
1257
+ if not line1:
1258
+ continue
1259
+ header = json.loads(line1)
1260
+ sid = header.get("id") or f.stem
1261
+ cwd_val = header.get("cwd", "")
1262
+ created_val = header.get("createdAt") or header.get("created_at") or os.path.getctime(f)
1263
+ meta_obj = header.get("metadata") or {}
1264
+ s_name = (
1265
+ header.get("name") or header.get("title") or meta_obj.get("name") or meta_obj.get("title")
1266
+ )
1267
+ parent_session = (
1268
+ header.get("parentSession")
1269
+ or header.get("parent_session")
1270
+ or header.get("parentSessionId")
1271
+ or header.get("parent_session_id")
1272
+ or meta_obj.get("parent_session_path")
1273
+ or meta_obj.get("parent_session_id")
1274
+ or meta_obj.get("parentSession")
1275
+ )
1276
+ first_msg_text = None
1277
+ msg_count = 0
1278
+ for line in fh:
1279
+ line_str = line.strip()
1280
+ if not line_str:
1281
+ continue
1282
+ try:
1283
+ entry_data = json.loads(line_str)
1284
+ etype = entry_data.get("type")
1285
+ if etype == "message":
1286
+ msg_count += 1
1287
+ if first_msg_text is None and entry_data.get("message", {}).get("role") == "user":
1288
+ content = entry_data.get("message", {}).get("content", "")
1289
+ if isinstance(content, str) and content.strip():
1290
+ first_msg_text = content.strip().splitlines()[0][:60]
1291
+ elif etype in ("session_info", "sessionInfo"):
1292
+ latest_name = entry_data.get("name") or entry_data.get("title")
1293
+ if latest_name:
1294
+ s_name = latest_name
1295
+ except Exception:
1296
+ continue
1297
+
1298
+ modified_val = os.path.getmtime(f)
1299
+ sessions_meta.append(
1300
+ {
1301
+ "id": sid,
1302
+ "name": s_name or first_msg_text or sid,
1303
+ "first_message": first_msg_text,
1304
+ "path": str(f.resolve()),
1305
+ "cwd": cwd_val,
1306
+ "modified": modified_val,
1307
+ "created_at": created_val,
1308
+ "message_count": msg_count,
1309
+ "parent_session": parent_session,
1310
+ "parent_session_path": parent_session,
1311
+ }
1312
+ )
1313
+ except Exception:
1314
+ continue
1315
+
1316
+ sessions_meta.sort(key=lambda s: s.get("modified", 0), reverse=True)
1317
+ return self.send_response(
1318
+ req_id,
1319
+ result={
1320
+ "status": "ok",
1321
+ "sessions": sessions_meta,
1322
+ },
1323
+ )
1324
+
1325
+ def _handle_session_resume(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1326
+ session_id = params.get("session_id") or params.get("id") or params.get("path") or params.get("session_file")
1327
+ if not session_id:
1328
+ return self.send_response(
1329
+ req_id,
1330
+ error={"code": -32602, "message": "Missing session_id parameter"},
1331
+ )
1332
+
1333
+ paths = self.paths or AgentPaths()
1334
+ workspace_path = Path(self.agent.workspace if self.agent else params.get("workspace", ".")).resolve()
1335
+ session_dir = paths.project_session_dir(workspace_path)
1336
+
1337
+ target_file: Path | None = None
1338
+ cand = Path(session_id)
1339
+ if cand.is_file():
1340
+ cand_resolved = cand.resolve()
1341
+ if cand_resolved.is_relative_to(paths.sessions_dir) or cand_resolved.is_relative_to(workspace_path):
1342
+ target_file = cand_resolved
1343
+ elif "/" not in session_id and "\\" not in session_id and (session_dir / f"{session_id}.jsonl").is_file():
1344
+ target_file = session_dir / f"{session_id}.jsonl"
1345
+ elif "/" not in session_id and "\\" not in session_id and (session_dir / session_id).is_file():
1346
+ target_file = session_dir / session_id
1347
+ else:
1348
+ matches: list[Path] = []
1349
+ if session_dir.exists():
1350
+ for f in session_dir.glob("*.jsonl"):
1351
+ try:
1352
+ with open(f, encoding="utf-8") as fh:
1353
+ first_line = fh.readline()
1354
+ if first_line:
1355
+ header = json.loads(first_line)
1356
+ fid = header.get("id", "")
1357
+ if (
1358
+ fid == session_id
1359
+ or fid.startswith(session_id)
1360
+ or f.stem == session_id
1361
+ or f.stem.startswith(session_id)
1362
+ ):
1363
+ matches.append(f)
1364
+ except Exception:
1365
+ continue
1366
+
1367
+ if not matches and paths.sessions_dir.exists():
1368
+ for s_dir in paths.sessions_dir.iterdir():
1369
+ if not s_dir.is_dir() or s_dir == session_dir:
1370
+ continue
1371
+ for f in s_dir.glob("*.jsonl"):
1372
+ try:
1373
+ with open(f, encoding="utf-8") as fh:
1374
+ first_line = fh.readline()
1375
+ if first_line:
1376
+ header = json.loads(first_line)
1377
+ fid = header.get("id", "")
1378
+ if (
1379
+ fid == session_id
1380
+ or fid.startswith(session_id)
1381
+ or f.stem == session_id
1382
+ or f.stem.startswith(session_id)
1383
+ ):
1384
+ matches.append(f)
1385
+ except Exception:
1386
+ continue
1387
+
1388
+ if len(matches) == 1:
1389
+ target_file = matches[0]
1390
+ elif len(matches) > 1:
1391
+ return self.send_response(
1392
+ req_id,
1393
+ error={
1394
+ "code": -32003,
1395
+ "message": f"Ambiguous session_id '{session_id}': {[m.name for m in matches]}",
1396
+ },
1397
+ )
1398
+
1399
+ if target_file is None or not target_file.is_file():
1400
+ return self.send_response(
1401
+ req_id,
1402
+ error={"code": -32004, "message": f"Session file not found for '{session_id}'"},
1403
+ )
1404
+
1405
+ new_session = Session.load(target_file)
1406
+ mode = getattr(self.settings, "default_permission_mode", None)
1407
+ gate = self.agent.permission_gate if self.agent else (PermissionGate(mode=mode) if mode else None)
1408
+ llm = self.agent.agent.llm if self.agent else self.llm
1409
+ self.agent = CodingAgent(
1410
+ workspace=workspace_path,
1411
+ llm=llm,
1412
+ session=new_session,
1413
+ permission_gate=gate,
1414
+ )
1415
+
1416
+ messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
1417
+
1418
+ actual_model = getattr(self.agent.agent, "model", "default")
1419
+ ctx_win = resolve_model_context_window(actual_model)
1420
+ self.session_usage = self._compute_session_usage(new_session, actual_model)
1421
+
1422
+ return self.send_response(
1423
+ req_id,
1424
+ result={
1425
+ "status": "ok",
1426
+ "session_id": new_session.id,
1427
+ "session_name": new_session.metadata.get("name") or new_session.metadata.get("title") or new_session.id,
1428
+ "session_file": str(target_file),
1429
+ "cwd": new_session.cwd,
1430
+ "model": actual_model,
1431
+ "context_window": ctx_win,
1432
+ "usage": self.session_usage,
1433
+ "messages": messages_repr,
1434
+ },
1435
+ )
1436
+
1437
+ def _handle_session_delete(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1438
+ """删除指定历史会话文件(严格对标 Pi 规范,禁止删除当前正在使用的活跃会话)。"""
1439
+ session_id = params.get("session_id") or params.get("id") or params.get("path") or params.get("session_file")
1440
+ if not session_id:
1441
+ return self.send_response(
1442
+ req_id,
1443
+ error={"code": -32602, "message": "Missing session_id parameter"},
1444
+ )
1445
+
1446
+ # 检查是否为当前正在运行的活跃会话
1447
+ current_id = self.agent.session.id if self.agent and self.agent.session else None
1448
+ current_path = (
1449
+ str(self.agent.session.path) if self.agent and self.agent.session and self.agent.session.path else ""
1450
+ )
1451
+
1452
+ if session_id == current_id or session_id == current_path:
1453
+ return self.send_response(
1454
+ req_id,
1455
+ error={"code": -32005, "message": "Cannot delete the currently active session"},
1456
+ )
1457
+
1458
+ paths = self.paths or AgentPaths()
1459
+ workspace_path = Path(self.agent.workspace if self.agent else params.get("workspace", ".")).resolve()
1460
+ session_dir = paths.project_session_dir(workspace_path)
1461
+
1462
+ target_file: Path | None = None
1463
+ cand = Path(session_id)
1464
+ if cand.is_file():
1465
+ cand_resolved = cand.resolve()
1466
+ if cand_resolved.is_relative_to(paths.sessions_dir) or cand_resolved.is_relative_to(workspace_path):
1467
+ target_file = cand_resolved
1468
+ elif "/" not in session_id and "\\" not in session_id and (session_dir / f"{session_id}.jsonl").is_file():
1469
+ target_file = session_dir / f"{session_id}.jsonl"
1470
+ elif "/" not in session_id and "\\" not in session_id and (session_dir / session_id).is_file():
1471
+ target_file = session_dir / session_id
1472
+ else:
1473
+ if session_dir.exists():
1474
+ for f in session_dir.glob("*.jsonl"):
1475
+ if f.stem == session_id or f.stem.startswith(session_id):
1476
+ target_file = f
1477
+ break
1478
+ if target_file is None and paths.sessions_dir.exists():
1479
+ for s_dir in paths.sessions_dir.iterdir():
1480
+ if s_dir.is_dir():
1481
+ for f in s_dir.glob("*.jsonl"):
1482
+ if f.stem == session_id or f.stem.startswith(session_id):
1483
+ target_file = f
1484
+ break
1485
+ if target_file is not None:
1486
+ break
1487
+
1488
+ if target_file is None or not target_file.is_file():
1489
+ return self.send_response(
1490
+ req_id,
1491
+ error={"code": -32004, "message": f"Session file not found for '{session_id}'"},
1492
+ )
1493
+
1494
+ if current_path and target_file.resolve() == Path(current_path).resolve():
1495
+ return self.send_response(
1496
+ req_id,
1497
+ error={"code": -32005, "message": "Cannot delete the currently active session"},
1498
+ )
1499
+
1500
+ try:
1501
+ target_file.unlink()
1502
+ return self.send_response(
1503
+ req_id,
1504
+ result={
1505
+ "status": "ok",
1506
+ "deleted": str(target_file),
1507
+ },
1508
+ )
1509
+ except Exception as exc:
1510
+ return self.send_response(
1511
+ req_id,
1512
+ error={"code": -32000, "message": f"Failed to delete session file: {exc}"},
1513
+ )
1514
+
1515
+ def _handle_session_history(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1516
+ if not self.agent:
1517
+ return self.send_response(
1518
+ req_id,
1519
+ error={"code": -32001, "message": "Agent not initialized"},
1520
+ )
1521
+ messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
1522
+ return self.send_response(
1523
+ req_id,
1524
+ result={
1525
+ "status": "ok",
1526
+ "session_id": self.agent.session.id,
1527
+ "session_name": self.agent.session.metadata.get("name") or self.agent.session.id,
1528
+ "messages": messages_repr,
1529
+ },
1530
+ )
1531
+
1532
+ def _handle_session_stats(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1533
+ """对标 Pi 官方 getSessionStats(),汇总会话全局 Message/Token/Cost 统计。"""
1534
+ if not self.agent or not self.agent.session:
1535
+ return self.send_response(
1536
+ req_id,
1537
+ error={"code": -32001, "message": "Agent not initialized"},
1538
+ )
1539
+
1540
+ session = self.agent.session
1541
+ entries = list(session.tree.entries.values())
1542
+ session_file = str(session.path) if session.path else "In-memory"
1543
+ session_id = session.id
1544
+ session_name = session.metadata.get("name") or session.metadata.get("title")
1545
+
1546
+ user_messages = 0
1547
+ assistant_messages = 0
1548
+ tool_calls = 0
1549
+ tool_results = 0
1550
+ total_messages = 0
1551
+
1552
+ input_tokens = 0
1553
+ output_tokens = 0
1554
+ cache_read_tokens = 0
1555
+ cache_write_tokens = 0
1556
+ total_cost = 0.0
1557
+
1558
+ usage_by_key: dict[str, dict[str, Any]] = {}
1559
+ prev_prompt_tokens = 0
1560
+ prev_reported_cache = False
1561
+ missed_tokens_total = 0
1562
+ missed_cost_total = 0.0
1563
+ miss_count_total = 0
1564
+
1565
+ for msg in session.get_full_history_messages():
1566
+ role = msg.role
1567
+ total_messages += 1
1568
+ if role == "user":
1569
+ user_messages += 1
1570
+ elif role == "tool":
1571
+ tool_results += 1
1572
+ elif role == "assistant":
1573
+ assistant_messages += 1
1574
+ if msg.metadata and msg.metadata.get("tool_calls"):
1575
+ tool_calls += len(msg.metadata["tool_calls"])
1576
+
1577
+ usage = msg.metadata.get("usage") if msg.metadata else None
1578
+ if usage and isinstance(usage, dict):
1579
+ try:
1580
+ in_t = int(usage.get("prompt_tokens") or usage.get("input") or 0)
1581
+ out_t = int(usage.get("completion_tokens") or usage.get("output") or 0)
1582
+ cr_t = int(
1583
+ usage.get("cache_read_tokens") or usage.get("cache_read") or usage.get("cacheRead") or 0
1584
+ )
1585
+ cw_t = int(
1586
+ usage.get("cache_write_tokens") or usage.get("cache_write") or usage.get("cacheWrite") or 0
1587
+ )
1588
+ except (ValueError, TypeError):
1589
+ in_t, out_t, cr_t, cw_t = 0, 0, 0, 0
1590
+
1591
+ input_tokens += in_t
1592
+ output_tokens += out_t
1593
+ cache_read_tokens += cr_t
1594
+ cache_write_tokens += cw_t
1595
+
1596
+ model_str = getattr(msg, "model", None) or self.agent.agent.model or "default"
1597
+ provider_str = getattr(msg, "provider", None) or getattr(self.agent.agent.llm, "provider", "model")
1598
+ breakdown_key = model_str if "/" in model_str else f"{provider_str}/{model_str}"
1599
+
1600
+ step_cost = 0.0
1601
+ m_lower = breakdown_key.lower()
1602
+ if "gemini" in m_lower:
1603
+ step_cost = (in_t * 0.1 + out_t * 0.4 + cr_t * 0.025) / 1000000.0
1604
+ elif "claude" in m_lower:
1605
+ if "opus" in m_lower:
1606
+ step_cost = (in_t * 15.0 + out_t * 75.0 + cr_t * 1.5) / 1000000.0
1607
+ else:
1608
+ step_cost = (in_t * 3.0 + out_t * 15.0 + cr_t * 0.3) / 1000000.0
1609
+ elif "deepseek" in m_lower:
1610
+ step_cost = (in_t * 0.14 + out_t * 0.28 + cr_t * 0.014) / 1000000.0
1611
+ elif "gpt-4o" in m_lower:
1612
+ step_cost = (in_t * 2.5 + out_t * 10.0 + cr_t * 1.25) / 1000000.0
1613
+ else:
1614
+ step_cost = (in_t * 1.0 + out_t * 3.0 + cr_t * 0.5) / 1000000.0
1615
+
1616
+ total_cost += step_cost
1617
+
1618
+ if breakdown_key not in usage_by_key:
1619
+ usage_by_key[breakdown_key] = {"key": breakdown_key, "cost": 0.0, "tokens": 0}
1620
+ usage_by_key[breakdown_key]["cost"] += step_cost
1621
+ usage_by_key[breakdown_key]["tokens"] += in_t + out_t + cr_t + cw_t
1622
+
1623
+ prompt_t = in_t + cr_t + cw_t
1624
+ if prev_prompt_tokens > 0 and (cr_t > 0 or prev_reported_cache):
1625
+ missed = min(prev_prompt_tokens, prompt_t) - cr_t
1626
+ if missed > 1000:
1627
+ missed_tokens_total += missed
1628
+ miss_count_total += 1
1629
+ missed_cost_total += (missed * 0.1) / 1000000.0
1630
+
1631
+ prev_prompt_tokens = prompt_t
1632
+ prev_reported_cache = cr_t > 0 or cw_t > 0
1633
+
1634
+ for entry in entries:
1635
+ if getattr(entry, "type", "") in ("compaction", "branch_summary"):
1636
+ summary_usage = getattr(entry, "usage", None) or getattr(entry, "metadata", {}).get("usage")
1637
+ if summary_usage and isinstance(summary_usage, dict):
1638
+ try:
1639
+ in_t = int(summary_usage.get("prompt_tokens") or summary_usage.get("input") or 0)
1640
+ out_t = int(summary_usage.get("completion_tokens") or summary_usage.get("output") or 0)
1641
+ except (ValueError, TypeError):
1642
+ in_t, out_t = 0, 0
1643
+ c_cost = (in_t * 0.5 + out_t * 1.5) / 1000000.0
1644
+ key = "Tools/summaries"
1645
+ if key not in usage_by_key:
1646
+ usage_by_key[key] = {"key": key, "cost": 0.0, "tokens": 0}
1647
+ usage_by_key[key]["cost"] += c_cost
1648
+ usage_by_key[key]["tokens"] += in_t + out_t
1649
+ total_cost += c_cost
1650
+
1651
+ breakdown_list = sorted(usage_by_key.values(), key=lambda x: x["cost"], reverse=True)
1652
+
1653
+ stats = {
1654
+ "sessionFile": session_file,
1655
+ "sessionId": session_id,
1656
+ "sessionName": session_name,
1657
+ "totalMessages": total_messages,
1658
+ "userMessages": user_messages,
1659
+ "assistantMessages": assistant_messages,
1660
+ "toolCalls": tool_calls,
1661
+ "toolResults": tool_results,
1662
+ "tokens": {
1663
+ "input": input_tokens,
1664
+ "output": output_tokens,
1665
+ "cacheRead": cache_read_tokens,
1666
+ "cacheWrite": cache_write_tokens,
1667
+ "total": input_tokens + output_tokens + cache_read_tokens + cache_write_tokens,
1668
+ },
1669
+ "cost": total_cost,
1670
+ "usageBreakdown": breakdown_list,
1671
+ "cacheWaste": {
1672
+ "missedTokens": missed_tokens_total,
1673
+ "missedCost": missed_cost_total,
1674
+ "missCount": miss_count_total,
1675
+ },
1676
+ }
1677
+
1678
+ return self.send_response(req_id, result={"status": "ok", "stats": stats})
1679
+
1680
+ async def _handle_session_compact(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1681
+ if not self.agent:
1682
+ return self.send_response(
1683
+ req_id,
1684
+ error={"code": -32001, "message": "Agent not initialized"},
1685
+ )
1686
+ self.agent.abort()
1687
+ instructions = params.get("instructions")
1688
+ await self.agent.compact(instructions=instructions)
1689
+
1690
+ info = self.agent.agent.context_manager.pending_compaction
1691
+ tokens_before = info.tokens_before if info else 0
1692
+ tokens_after = info.tokens_after if info else 0
1693
+ summary = info.summary if info else ""
1694
+ self.session_usage["contextTokens"] = tokens_after
1695
+
1696
+ return self.send_response(
1697
+ req_id,
1698
+ result={
1699
+ "status": "ok",
1700
+ "tokens_before": tokens_before,
1701
+ "tokens_after": tokens_after,
1702
+ "summary": summary,
1703
+ },
1704
+ )
1705
+
1706
+ def _handle_session_new(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1707
+ if not self.agent:
1708
+ return self.send_response(
1709
+ req_id,
1710
+ error={"code": -32001, "message": "Agent not initialized"},
1711
+ )
1712
+
1713
+ self.agent.abort()
1714
+ workspace_path = Path(self.agent.workspace).resolve()
1715
+ paths = self.paths or AgentPaths()
1716
+ session_dir = paths.project_session_dir(workspace_path)
1717
+ session_id = uuid7_str()
1718
+ session_file = session_dir / f"{session_id}.jsonl"
1719
+
1720
+ new_session = Session(path=session_file, cwd=str(workspace_path))
1721
+ new_session.id = session_id
1722
+ mode = getattr(self.settings, "default_permission_mode", None)
1723
+ gate = self.agent.permission_gate or (PermissionGate(mode=mode) if mode else None)
1724
+
1725
+ self.agent = CodingAgent(
1726
+ workspace=workspace_path,
1727
+ llm=self.agent.agent.llm,
1728
+ session=new_session,
1729
+ permission_gate=gate,
1730
+ )
1731
+
1732
+ return self.send_response(
1733
+ req_id,
1734
+ result={
1735
+ "status": "ok",
1736
+ "session_id": session_id,
1737
+ "session_file": str(session_file),
1738
+ "messages": [],
1739
+ },
1740
+ )
1741
+
1742
+ def _handle_session_tree(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1743
+ if not self.agent:
1744
+ return self.send_response(
1745
+ req_id,
1746
+ error={"code": -32001, "message": "Agent not initialized"},
1747
+ )
1748
+
1749
+ session = self.agent.session
1750
+ entries = list(session.tree.entries.values())
1751
+ active_leaf_id = session.tree.current_id
1752
+ root_id = session.tree.root_id
1753
+
1754
+ active_path_ids: set[str] = set()
1755
+ if active_leaf_id and active_leaf_id in session.tree.entries:
1756
+ active_path_ids = {e.id for e in session.tree.get_current_path()}
1757
+
1758
+ parent_ids = {e.parent_id for e in entries if e.parent_id is not None}
1759
+
1760
+ nodes: list[dict[str, Any]] = []
1761
+ for entry in entries:
1762
+ eid = entry.id
1763
+ pid = entry.parent_id
1764
+ etype = getattr(entry, "type", "message")
1765
+ role = getattr(entry, "role", etype)
1766
+
1767
+ preview = ""
1768
+ if isinstance(entry, MessageEntry):
1769
+ msg = entry.message
1770
+ content = msg.content or ""
1771
+ if msg.metadata and msg.metadata.get("tool_calls"):
1772
+ tc_names = [
1773
+ tc.get("function", {}).get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
1774
+ for tc in msg.metadata.get("tool_calls", [])
1775
+ ]
1776
+ prefix = f"[Tool Call: {', '.join(filter(None, tc_names))}]"
1777
+ preview = f"{prefix} {content}".strip() if content else prefix
1778
+ else:
1779
+ preview = content
1780
+ elif isinstance(entry, CompactionEntry):
1781
+ preview = entry.summary
1782
+ elif isinstance(entry, BranchSummaryEntry):
1783
+ preview = entry.summary
1784
+ elif isinstance(entry, SessionInfoEntry):
1785
+ preview = entry.name or entry.title or ""
1786
+ elif isinstance(entry, ModelChangeEntry):
1787
+ preview = f"Model: {entry.model}"
1788
+ elif isinstance(entry, ThinkingLevelChangeEntry):
1789
+ preview = f"Thinking: {entry.thinking_level}"
1790
+ elif isinstance(entry, LabelEntry):
1791
+ preview = f"Label: {entry.label}"
1792
+ elif isinstance(entry, LeafEntry):
1793
+ preview = f"Leaf: {entry.leaf_id}"
1794
+ else:
1795
+ preview = str(getattr(entry, "content", "") or getattr(entry, "summary", "") or "")
1796
+
1797
+ if len(preview) > 200:
1798
+ preview = preview[:200] + "..."
1799
+
1800
+ nodes.append(
1801
+ {
1802
+ "id": eid,
1803
+ "parent_id": pid,
1804
+ "role": role,
1805
+ "type": etype,
1806
+ "preview": preview,
1807
+ "is_leaf": eid not in parent_ids,
1808
+ "is_active": eid in active_path_ids,
1809
+ "timestamp": getattr(entry, "timestamp", 0.0),
1810
+ }
1811
+ )
1812
+
1813
+ return self.send_response(
1814
+ req_id,
1815
+ result={
1816
+ "status": "ok",
1817
+ "nodes": nodes,
1818
+ "tree": nodes,
1819
+ "active_leaf_id": active_leaf_id,
1820
+ "root_id": root_id,
1821
+ },
1822
+ )
1823
+
1824
+ async def _handle_session_branch(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1825
+ if not self.agent:
1826
+ return self.send_response(
1827
+ req_id,
1828
+ error={"code": -32001, "message": "Agent not initialized"},
1829
+ )
1830
+
1831
+ target_id = params.get("target_id") or params.get("node_id") or params.get("entry_id") or params.get("id")
1832
+ if not target_id:
1833
+ return self.send_response(
1834
+ req_id,
1835
+ error={"code": -32602, "message": "Missing 'target_id' parameter"},
1836
+ )
1837
+
1838
+ session = self.agent.session
1839
+ if target_id not in session.tree.entries:
1840
+ return self.send_response(
1841
+ req_id,
1842
+ error={"code": -32004, "message": f"Entry '{target_id}' not found in session"},
1843
+ )
1844
+
1845
+ target_entry = session.tree.entries[target_id]
1846
+ is_user = getattr(target_entry, "role", None) == "user" or (
1847
+ isinstance(target_entry, MessageEntry) and target_entry.message.role == "user"
1848
+ )
1849
+
1850
+ self.agent.abort()
1851
+ old_leaf_id = session.tree.current_id
1852
+ summarize = bool(params.get("summarize", False))
1853
+ branch_summary_text = ""
1854
+
1855
+ if is_user:
1856
+ new_leaf_id = target_entry.parent_id
1857
+ editor_text = (
1858
+ getattr(target_entry, "content", "")
1859
+ or (target_entry.message.content if isinstance(target_entry, MessageEntry) else "")
1860
+ or ""
1861
+ )
1862
+ else:
1863
+ new_leaf_id = target_id
1864
+ editor_text = ""
1865
+
1866
+ if new_leaf_id is not None and session.compaction_floor is not None:
1867
+ if not session._after_floor(new_leaf_id):
1868
+ return self.send_response(
1869
+ req_id,
1870
+ error={
1871
+ "code": -32005,
1872
+ "message": f"Cannot branch past compaction floor {session.compaction_floor}: entry {new_leaf_id} is prior to compacted history",
1873
+ },
1874
+ )
1875
+ elif new_leaf_id is None and session.compaction_floor is not None:
1876
+ return self.send_response(
1877
+ req_id,
1878
+ error={
1879
+ "code": -32005,
1880
+ "message": f"Cannot branch past compaction floor {session.compaction_floor}: root is prior to compacted history",
1881
+ },
1882
+ )
1883
+
1884
+ if summarize and old_leaf_id and old_leaf_id != new_leaf_id:
1885
+ old_path = session.tree.get_path_to_entry(old_leaf_id)
1886
+ new_path_ids = (
1887
+ {e.id for e in session.tree.get_path_to_entry(new_leaf_id)}
1888
+ if new_leaf_id and new_leaf_id in session.tree.entries
1889
+ else set()
1890
+ )
1891
+ abandoned_entries = [e for e in old_path if e.id not in new_path_ids]
1892
+
1893
+ if abandoned_entries:
1894
+ lca = lowest_common_ancestor(session.tree.entries, old_leaf_id, new_leaf_id) if new_leaf_id else None
1895
+ summary_prompt = (
1896
+ "Please concisely summarize the key decisions, code changes, and exploration from this abandoned conversation branch in 1-2 sentences:\n"
1897
+ + "\n".join(
1898
+ f"{getattr(e, 'role', 'entry')}: {getattr(e, 'content', '')}"
1899
+ for e in abandoned_entries
1900
+ if hasattr(e, "content") or hasattr(e, "message")
1901
+ )
1902
+ )
1903
+ try:
1904
+ llm = self.agent.agent.llm
1905
+ resp = await llm.achat([Message(role="user", content=summary_prompt)])
1906
+ branch_summary_text = getattr(resp, "content", "") or "Branch summary"
1907
+ except Exception:
1908
+ branch_summary_text = "Branch exploration summary"
1909
+
1910
+ summary_entry = BranchSummaryEntry(
1911
+ parent_id=new_leaf_id,
1912
+ summary=branch_summary_text,
1913
+ details={
1914
+ "abandoned_from": old_leaf_id,
1915
+ "abandoned_count": len(abandoned_entries),
1916
+ "lca": lca,
1917
+ },
1918
+ )
1919
+ session.tree.entries[summary_entry.id] = summary_entry
1920
+ session.tree.current_id = summary_entry.id
1921
+ session.save()
1922
+ new_leaf_id = summary_entry.id
1923
+
1924
+ if not (summarize and branch_summary_text):
1925
+ if new_leaf_id is None:
1926
+ session.tree.current_id = None
1927
+ session.save()
1928
+ else:
1929
+ session.tree.current_id = new_leaf_id
1930
+ session.save()
1931
+
1932
+ system = [m for m in self.agent.agent.messages if m.role == "system"]
1933
+ restored = system + session.get_full_history_messages()
1934
+ self.agent.agent.messages = list(repair_tool_history(restored).messages)
1935
+
1936
+ messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
1937
+
1938
+ res_payload: dict[str, Any] = {
1939
+ "status": "ok",
1940
+ "leaf_id": new_leaf_id,
1941
+ "editor_text": editor_text,
1942
+ "session_id": session.id,
1943
+ "messages": messages_repr,
1944
+ }
1945
+ if branch_summary_text:
1946
+ res_payload["branch_summary"] = branch_summary_text
1947
+
1948
+ return self.send_response(req_id, result=res_payload)
1949
+
1950
+ def _handle_session_fork(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
1951
+ if not self.agent:
1952
+ return self.send_response(
1953
+ req_id,
1954
+ error={"code": -32001, "message": "Agent not initialized"},
1955
+ )
1956
+
1957
+ entry_id = params.get("entry_id") or params.get("id") or params.get("target_id")
1958
+ if not entry_id:
1959
+ return self.send_response(
1960
+ req_id,
1961
+ error={"code": -32602, "message": "Missing 'entry_id' parameter"},
1962
+ )
1963
+
1964
+ session = self.agent.session
1965
+ if entry_id not in session.tree.entries:
1966
+ return self.send_response(
1967
+ req_id,
1968
+ error={"code": -32004, "message": f"Entry '{entry_id}' not found in session"},
1969
+ )
1970
+
1971
+ target_entry = session.tree.entries[entry_id]
1972
+ prompt_text = (
1973
+ getattr(target_entry, "content", "")
1974
+ or (target_entry.message.content if isinstance(target_entry, MessageEntry) else "")
1975
+ or ""
1976
+ )
1977
+
1978
+ cutoff_id = target_entry.parent_id
1979
+ path_entries = (
1980
+ session.tree.get_path_to_entry(cutoff_id) if cutoff_id and cutoff_id in session.tree.entries else []
1981
+ )
1982
+
1983
+ self.agent.abort()
1984
+ workspace_path = Path(self.agent.workspace).resolve()
1985
+ paths = self.paths or AgentPaths()
1986
+ session_dir = paths.project_session_dir(workspace_path)
1987
+ new_session_id = uuid7_str()
1988
+ new_session_file = session_dir / f"{new_session_id}.jsonl"
1989
+
1990
+ new_session = Session(path=new_session_file, cwd=str(workspace_path))
1991
+ new_session.id = new_session_id
1992
+ new_session.metadata["parent_session_id"] = session.id
1993
+ new_session.metadata["parent_session_path"] = str(session.path) if session.path else ""
1994
+ new_session.metadata["parentSession"] = str(session.path) if session.path else session.id
1995
+ new_session.metadata["forked_from_entry_id"] = entry_id
1996
+ if prompt_text:
1997
+ fork_title = prompt_text.strip().splitlines()[0][:60]
1998
+ new_session.metadata["name"] = fork_title
1999
+ new_session.metadata["title"] = fork_title
2000
+
2001
+ for entry in path_entries:
2002
+ if isinstance(entry, MessageEntry):
2003
+ new_session.add_message(entry.role, entry.content, **(entry.metadata or {}))
2004
+ elif isinstance(entry, CompactionEntry):
2005
+ new_compaction = CompactionEntry(
2006
+ parent_id=new_session.tree.current_id,
2007
+ summary=entry.summary,
2008
+ replaces_entry_ids=list(entry.replaces_entry_ids),
2009
+ metadata=dict(entry.metadata),
2010
+ )
2011
+ new_session.append_entry(new_compaction)
2012
+ elif isinstance(entry, BranchSummaryEntry):
2013
+ new_bs = BranchSummaryEntry(
2014
+ parent_id=new_session.tree.current_id,
2015
+ summary=entry.summary,
2016
+ details=dict(entry.details),
2017
+ )
2018
+ new_session.append_entry(new_bs)
2019
+ else:
2020
+ copied = entry.model_copy(update={"parent_id": new_session.tree.current_id})
2021
+ new_session.append_entry(copied)
2022
+
2023
+ new_session.save()
2024
+
2025
+ mode = getattr(self.settings, "default_permission_mode", None)
2026
+ gate = self.agent.permission_gate or (PermissionGate(mode=mode) if mode else None)
2027
+ self.agent = CodingAgent(
2028
+ workspace=workspace_path,
2029
+ llm=self.agent.agent.llm,
2030
+ session=new_session,
2031
+ permission_gate=gate,
2032
+ )
2033
+
2034
+ messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
2035
+
2036
+ return self.send_response(
2037
+ req_id,
2038
+ result={
2039
+ "status": "ok",
2040
+ "new_session_id": new_session_id,
2041
+ "session_file": str(new_session_file),
2042
+ "prompt_text": prompt_text,
2043
+ "messages": messages_repr,
2044
+ },
2045
+ )
2046
+
2047
+ def _handle_session_clone(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2048
+ if not self.agent:
2049
+ return self.send_response(
2050
+ req_id,
2051
+ error={"code": -32001, "message": "Agent not initialized"},
2052
+ )
2053
+
2054
+ session = self.agent.session
2055
+ active_leaf_id = session.tree.current_id
2056
+ path_entries = session.tree.get_current_path() if active_leaf_id else []
2057
+
2058
+ self.agent.abort()
2059
+ workspace_path = Path(self.agent.workspace).resolve()
2060
+ paths = self.paths or AgentPaths()
2061
+ session_dir = paths.project_session_dir(workspace_path)
2062
+ new_session_id = uuid7_str()
2063
+ new_session_file = session_dir / f"{new_session_id}.jsonl"
2064
+
2065
+ new_session = Session(path=new_session_file, cwd=str(workspace_path))
2066
+ new_session.id = new_session_id
2067
+ new_session.metadata["parent_session_id"] = session.id
2068
+ new_session.metadata["parent_session_path"] = str(session.path) if session.path else ""
2069
+ new_session.metadata["parentSession"] = str(session.path) if session.path else session.id
2070
+ cur_name = session.metadata.get("name") or session.metadata.get("title")
2071
+ clone_title = f"{cur_name} (clone)" if cur_name else f"Clone of {session.id[:8]}"
2072
+ new_session.metadata["name"] = clone_title
2073
+ new_session.metadata["title"] = clone_title
2074
+ if active_leaf_id:
2075
+ new_session.metadata["cloned_from_leaf_id"] = active_leaf_id
2076
+
2077
+ for entry in path_entries:
2078
+ if isinstance(entry, MessageEntry):
2079
+ new_session.add_message(entry.role, entry.content, **(entry.metadata or {}))
2080
+ elif isinstance(entry, CompactionEntry):
2081
+ new_compaction = CompactionEntry(
2082
+ parent_id=new_session.tree.current_id,
2083
+ summary=entry.summary,
2084
+ replaces_entry_ids=list(entry.replaces_entry_ids),
2085
+ metadata=dict(entry.metadata),
2086
+ )
2087
+ new_session.append_entry(new_compaction)
2088
+ elif isinstance(entry, BranchSummaryEntry):
2089
+ new_bs = BranchSummaryEntry(
2090
+ parent_id=new_session.tree.current_id,
2091
+ summary=entry.summary,
2092
+ details=dict(entry.details),
2093
+ )
2094
+ new_session.append_entry(new_bs)
2095
+ else:
2096
+ copied = entry.model_copy(update={"parent_id": new_session.tree.current_id})
2097
+ new_session.append_entry(copied)
2098
+
2099
+ new_session.save()
2100
+
2101
+ mode = getattr(self.settings, "default_permission_mode", None)
2102
+ gate = self.agent.permission_gate or (PermissionGate(mode=mode) if mode else None)
2103
+ self.agent = CodingAgent(
2104
+ workspace=workspace_path,
2105
+ llm=self.agent.agent.llm,
2106
+ session=new_session,
2107
+ permission_gate=gate,
2108
+ )
2109
+
2110
+ messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
2111
+
2112
+ return self.send_response(
2113
+ req_id,
2114
+ result={
2115
+ "status": "ok",
2116
+ "new_session_id": new_session_id,
2117
+ "session_file": str(new_session_file),
2118
+ "messages": messages_repr,
2119
+ },
2120
+ )
2121
+
2122
+ async def _handle_shell_exec(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2123
+ if not self.agent:
2124
+ return self.send_response(
2125
+ req_id,
2126
+ error={"code": -32001, "message": "Agent not initialized"},
2127
+ )
2128
+ command = params.get("command", "").strip()
2129
+ if not command:
2130
+ return self.send_response(
2131
+ req_id,
2132
+ error={"code": -32602, "message": "Missing 'command' parameter"},
2133
+ )
2134
+ exclude_from_context = bool(params.get("exclude_from_context", False))
2135
+ try:
2136
+ timeout = float(params.get("timeout", 60.0))
2137
+ except (ValueError, TypeError):
2138
+ timeout = 60.0
2139
+
2140
+ cwd = Path(self.agent.workspace)
2141
+ res = await asyncio.to_thread(
2142
+ self.macro_engine.execute_shell,
2143
+ command=command,
2144
+ cwd=cwd,
2145
+ exclude_from_context=exclude_from_context,
2146
+ timeout=timeout,
2147
+ )
2148
+
2149
+ if not exclude_from_context:
2150
+ output_str = res.get("output", "")
2151
+ if output_str:
2152
+ formatted = f"Ran `{command}`\n```text\n{output_str}\n```"
2153
+ else:
2154
+ formatted = f"Ran `{command}`\n```text\n```"
2155
+
2156
+ self.agent.session.add_message(
2157
+ role="user",
2158
+ content=formatted,
2159
+ metadata={
2160
+ "type": "bashExecution",
2161
+ "customType": "bashExecution",
2162
+ "command": command,
2163
+ "exit_code": res.get("exit_code"),
2164
+ "exclude_from_context": False,
2165
+ },
2166
+ )
2167
+
2168
+ return self.send_response(
2169
+ req_id,
2170
+ result={
2171
+ "status": "ok",
2172
+ "output": res.get("output", ""),
2173
+ "exit_code": res.get("exit_code", 0),
2174
+ },
2175
+ )
2176
+
2177
+ def _handle_macro_expand(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2178
+ if self.agent:
2179
+ self.macro_engine.workspace = Path(self.agent.workspace)
2180
+ text = str(params.get("text", ""))
2181
+ skills_dir_param = params.get("skills_dir")
2182
+ prompts_dir_param = params.get("prompts_dir")
2183
+
2184
+ skills_dir = Path(skills_dir_param) if skills_dir_param else None
2185
+ prompts_dir = Path(prompts_dir_param) if prompts_dir_param else None
2186
+
2187
+ expanded_text, is_expanded = self.macro_engine.expand_macro(
2188
+ text,
2189
+ skills_dir=skills_dir,
2190
+ prompts_dir=prompts_dir,
2191
+ )
2192
+
2193
+ return self.send_response(
2194
+ req_id,
2195
+ result={
2196
+ "status": "ok",
2197
+ "text": expanded_text,
2198
+ "expanded": is_expanded,
2199
+ "expanded_text": expanded_text,
2200
+ },
2201
+ )
2202
+
2203
+ def _handle_model_switch(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2204
+ if not self.agent:
2205
+ return self.send_response(
2206
+ req_id,
2207
+ error={"code": -32001, "message": "Agent not initialized"},
2208
+ )
2209
+
2210
+ raw_model = params.get("model", "")
2211
+ if not raw_model or not isinstance(raw_model, str) or not raw_model.strip():
2212
+ return self.send_response(
2213
+ req_id,
2214
+ error={"code": -32602, "message": "Missing 'model' parameter"},
2215
+ )
2216
+ raw_model = raw_model.strip()
2217
+
2218
+ provider = params.get("provider")
2219
+ if isinstance(provider, str):
2220
+ provider = provider.strip() or None
2221
+
2222
+ if "/" in raw_model:
2223
+ prov_part, model_name = raw_model.split("/", 1)
2224
+ provider = provider or prov_part.strip()
2225
+ model_name = model_name.strip()
2226
+ else:
2227
+ model_name = raw_model
2228
+ if not provider:
2229
+ if model_name.startswith("gemini-"):
2230
+ provider = "antigravity"
2231
+ elif "deepseek" in model_name:
2232
+ provider = "deepseek"
2233
+ elif "gpt-" in model_name or "o1" in model_name or "o3" in model_name:
2234
+ provider = "openai"
2235
+ elif "claude-" in model_name:
2236
+ provider = "anthropic"
2237
+ else:
2238
+ current_llm = getattr(self.agent.agent, "llm", None)
2239
+ current_config = getattr(current_llm, "config", None)
2240
+ if current_config and hasattr(current_config, "provider"):
2241
+ provider = current_config.provider
2242
+ elif self.settings and self.settings.default_provider:
2243
+ provider = self.settings.default_provider
2244
+ else:
2245
+ provider = "openai"
2246
+
2247
+ # 更新 Agent 当前模型标识
2248
+ self.agent.agent.model = model_name
2249
+
2250
+ llm_inst = getattr(self.agent.agent, "llm", None)
2251
+ if hasattr(llm_inst, "config"):
2252
+ paths = self.paths or AgentPaths()
2253
+ auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
2254
+ api_key = None
2255
+ base_url = None
2256
+ if provider:
2257
+ # 1. 优先从全局凭据中心读取
2258
+ cred = auth_mgr.get_credential(provider)
2259
+ if cred is not None:
2260
+ if isinstance(cred, ApiKeyCredential):
2261
+ api_key = cred.resolve_key()
2262
+ if cred.base_url:
2263
+ base_url = cred.base_url
2264
+ elif isinstance(cred, OAuthCredential):
2265
+ api_key = cred.access
2266
+
2267
+ # 2. 次选环境变量兜底
2268
+ if not api_key:
2269
+ api_key = os.environ.get(f"{provider.upper()}_API_KEY")
2270
+ base_url = os.environ.get(f"{provider.upper()}_BASE_URL")
2271
+ try:
2272
+ new_config = Config(
2273
+ provider=provider or "openai",
2274
+ model=model_name,
2275
+ api_key=api_key or "placeholder",
2276
+ base_url=base_url,
2277
+ )
2278
+ self.agent.agent.llm = LLM(config=new_config)
2279
+ except Exception:
2280
+ pass
2281
+ elif llm_inst is not None and hasattr(llm_inst, "model"):
2282
+ setattr(llm_inst, "model", model_name)
2283
+
2284
+ # 向 Session 追加 ModelChangeEntry
2285
+ entry = ModelChangeEntry(
2286
+ model=model_name,
2287
+ provider=provider,
2288
+ parent_id=self.agent.session.tree.current_id,
2289
+ )
2290
+ self.agent.session.append_entry(entry)
2291
+
2292
+ # 若 persist=True,更新并持久化 settings.json
2293
+ persist = bool(params.get("persist", False))
2294
+ if persist:
2295
+ paths = self.paths or AgentPaths()
2296
+ if self.settings is None:
2297
+ self.settings = load_settings(paths)
2298
+ self.settings.default_model = model_name
2299
+ if provider:
2300
+ self.settings.default_provider = provider
2301
+ save_settings(self.settings, paths.settings_path)
2302
+
2303
+ return self.send_response(
2304
+ req_id,
2305
+ result={
2306
+ "status": "ok",
2307
+ "model": model_name,
2308
+ "provider": provider,
2309
+ },
2310
+ )
2311
+
2312
+ def _handle_thinking_set(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2313
+ if not self.agent:
2314
+ return self.send_response(
2315
+ req_id,
2316
+ error={"code": -32001, "message": "Agent not initialized"},
2317
+ )
2318
+
2319
+ level = params.get("level", "")
2320
+ if not level or not isinstance(level, str):
2321
+ return self.send_response(
2322
+ req_id,
2323
+ error={"code": -32602, "message": "Missing 'level' parameter"},
2324
+ )
2325
+ level = level.strip().lower()
2326
+
2327
+ valid_levels = {"off", "minimal", "low", "medium", "high", "xhigh", "max"}
2328
+ if level not in valid_levels:
2329
+ return self.send_response(
2330
+ req_id,
2331
+ error={
2332
+ "code": -32602,
2333
+ "message": f"Invalid thinking level '{level}'. Must be one of: {', '.join(sorted(valid_levels))}",
2334
+ },
2335
+ )
2336
+
2337
+ entry = ThinkingLevelChangeEntry(
2338
+ thinking_level=level,
2339
+ parent_id=self.agent.session.tree.current_id,
2340
+ )
2341
+ self.agent.session.append_entry(entry)
2342
+
2343
+ setattr(self.agent, "thinking_level", level)
2344
+
2345
+ persist = bool(params.get("persist", False))
2346
+ if persist:
2347
+ paths = self.paths or AgentPaths()
2348
+ if self.settings is None:
2349
+ self.settings = load_settings(paths)
2350
+ self.settings.default_thinking_level = level # type: ignore[assignment]
2351
+ save_settings(self.settings, paths.settings_path)
2352
+
2353
+ return self.send_response(
2354
+ req_id,
2355
+ result={
2356
+ "status": "ok",
2357
+ "level": level,
2358
+ },
2359
+ )
2360
+
2361
+ def _get_configured_providers(self) -> set[str]:
2362
+ configured: set[str] = set()
2363
+ paths = self.paths or AgentPaths()
2364
+ auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
2365
+
2366
+ openai_base_url = os.environ.get("OPENAI_BASE_URL", "").strip().lower()
2367
+ is_deepseek_proxy = "deepseek" in openai_base_url
2368
+
2369
+ for p in ["deepseek", "openai", "anthropic", "antigravity"]:
2370
+ if auth_mgr.get_credential(p) is not None:
2371
+ configured.add(p)
2372
+ continue
2373
+ if p == "openai" and is_deepseek_proxy:
2374
+ continue
2375
+ key_name = "ANTIGRAVITY_ACCESS_TOKEN" if p == "antigravity" else f"{p.upper()}_API_KEY"
2376
+ if os.environ.get(key_name):
2377
+ configured.add(p)
2378
+ continue
2379
+ if p == "deepseek" and is_deepseek_proxy and os.environ.get("OPENAI_API_KEY"):
2380
+ configured.add("deepseek")
2381
+ continue
2382
+ if p == "antigravity":
2383
+ try:
2384
+ from my_agent_llm.auth.antigravity import AntigravityAuthResolver
2385
+
2386
+ ws = Path(self.agent.workspace if self.agent else ".").resolve()
2387
+ resolver = AntigravityAuthResolver(workspace=ws)
2388
+ if resolver.resolve_credentials() is not None:
2389
+ configured.add("antigravity")
2390
+ except Exception:
2391
+ pass
2392
+
2393
+ return configured
2394
+
2395
+ def _handle_models_list(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2396
+ scope = params.get("scope", "configured")
2397
+ configured_providers = self._get_configured_providers()
2398
+
2399
+ models = []
2400
+ for item in KNOWN_MODEL_CATALOG:
2401
+ prov = item["provider"]
2402
+ is_configured = prov in configured_providers
2403
+ if scope == "all" or is_configured:
2404
+ models.append(
2405
+ {
2406
+ **item,
2407
+ "is_configured": is_configured,
2408
+ }
2409
+ )
2410
+
2411
+ # 动态补充对标 pi-antigravity 的模型目录
2412
+ is_antigravity_configured = "antigravity" in configured_providers
2413
+ if scope == "all" or is_antigravity_configured:
2414
+ for item in get_antigravity_catalog():
2415
+ models.append(
2416
+ {
2417
+ **item,
2418
+ "is_configured": is_antigravity_configured,
2419
+ }
2420
+ )
2421
+
2422
+ # 动态补充对标 DeepSeek 的模型目录
2423
+ is_deepseek_configured = "deepseek" in configured_providers
2424
+ if scope == "all" or is_deepseek_configured:
2425
+ for item in get_deepseek_catalog():
2426
+ models.append(
2427
+ {
2428
+ **item,
2429
+ "is_configured": is_deepseek_configured,
2430
+ }
2431
+ )
2432
+
2433
+ # 动态补充环境变量中显式配置的自定义模型 (例如 OPENAI_MODEL=deepseek-flash 或 DEEPSEEK_MODEL)
2434
+ custom_model = os.environ.get("OPENAI_MODEL") or os.environ.get("DEEPSEEK_MODEL")
2435
+ if custom_model and not any(m["id"] == custom_model for m in models):
2436
+ prov = (
2437
+ "deepseek"
2438
+ if "deepseek" in configured_providers
2439
+ else ("openai" if "openai" in configured_providers else "default")
2440
+ )
2441
+ if prov in configured_providers or scope == "all":
2442
+ models.insert(
2443
+ 0,
2444
+ {
2445
+ "id": custom_model,
2446
+ "provider": prov,
2447
+ "name": custom_model,
2448
+ "contextWindow": 64000 if prov == "deepseek" else 128000,
2449
+ "is_configured": prov in configured_providers,
2450
+ },
2451
+ )
2452
+
2453
+ curr = "default"
2454
+ if self.agent:
2455
+ curr = getattr(self.agent, "model", None) or getattr(getattr(self.agent, "agent", None), "model", "default")
2456
+ return self.send_response(
2457
+ req_id,
2458
+ result={
2459
+ "status": "ok",
2460
+ "scope": scope,
2461
+ "configured_providers": sorted(list(configured_providers)),
2462
+ "models": models,
2463
+ "current_model": curr,
2464
+ },
2465
+ )
2466
+
2467
+ def _handle_auth_logout(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2468
+ provider = params.get("provider", "")
2469
+ if not provider or not isinstance(provider, str) or not provider.strip():
2470
+ return self.send_response(
2471
+ req_id,
2472
+ error={"code": -32602, "message": "Missing 'provider' parameter"},
2473
+ )
2474
+ prov = provider.strip().lower()
2475
+
2476
+ paths = self.paths or AgentPaths()
2477
+ auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
2478
+ self.auth_mgr = auth_mgr
2479
+
2480
+ profile = params.get("profile")
2481
+ if isinstance(profile, str):
2482
+ profile = profile.strip() or None
2483
+
2484
+ removed = auth_mgr.remove_credential(prov, profile=profile)
2485
+
2486
+ return self.send_response(
2487
+ req_id,
2488
+ result={
2489
+ "status": "ok",
2490
+ "provider": prov,
2491
+ "removed": removed,
2492
+ },
2493
+ )
2494
+
2495
+ def _handle_resource_reload(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2496
+ paths = self.paths or AgentPaths()
2497
+ workspace_path = Path(self.agent.workspace if self.agent else ".").resolve()
2498
+
2499
+ # 1. 重载 settings
2500
+ self.settings = load_settings(paths, cwd=workspace_path)
2501
+
2502
+ # 2. 重载项目指导文件与系统提示词
2503
+ new_prompt = build_default_coding_prompt(workspace_path)
2504
+ if self.agent:
2505
+ self.agent.agent._system_prompt = new_prompt
2506
+
2507
+ mem_store = getattr(self.agent.agent, "memory_store", None)
2508
+ mem_prompt = (
2509
+ mem_store.format_all_for_system_prompt()
2510
+ if mem_store is not None and hasattr(mem_store, "format_all_for_system_prompt")
2511
+ else None
2512
+ )
2513
+ skill_prompt = (
2514
+ self.agent.agent.skill_manager.format_prompt() if hasattr(self.agent.agent, "skill_manager") else ""
2515
+ )
2516
+ subagent_prompt = (
2517
+ self.agent.agent.subagent_manager.format_prompt()
2518
+ if hasattr(self.agent.agent, "subagent_manager")
2519
+ else ""
2520
+ )
2521
+ parts = [p for p in (new_prompt, skill_prompt, subagent_prompt, mem_prompt) if p]
2522
+ new_sys_content = "\n\n".join(parts)
2523
+
2524
+ if self.agent.agent.messages and self.agent.agent.messages[0].role == "system":
2525
+ self.agent.agent.messages[0] = Message(role="system", content=new_sys_content)
2526
+ elif parts:
2527
+ self.agent.agent.messages.insert(0, Message(role="system", content=new_sys_content))
2528
+
2529
+ # 3. 重载 skills
2530
+ skill_dirs = [
2531
+ paths.skills_dir,
2532
+ paths.project_skills_dir(workspace_path),
2533
+ paths.project_agents_skills_dir(workspace_path),
2534
+ ]
2535
+ skill_mgr = SkillManager(dirs=skill_dirs)
2536
+ if self.agent and hasattr(self.agent.agent, "skill_manager"):
2537
+ self.agent.agent.skill_manager = skill_mgr
2538
+ skill_count = len(skill_mgr.skills)
2539
+
2540
+ # 4. 统计 templates
2541
+ template_count = 0
2542
+ for p_dir in (
2543
+ paths.prompts_dir,
2544
+ paths.project_agent_dir(workspace_path) / "prompts",
2545
+ workspace_path / ".agents" / "prompts",
2546
+ ):
2547
+ if p_dir.exists():
2548
+ template_count += len(list(p_dir.glob("*.md")))
2549
+
2550
+ summary = f"Reloaded settings, project context, {skill_count} skills, and {template_count} prompt templates."
2551
+ return self.send_response(
2552
+ req_id,
2553
+ result={
2554
+ "status": "ok",
2555
+ "summary": summary,
2556
+ "skills_count": skill_count,
2557
+ "templates_count": template_count,
2558
+ },
2559
+ )
2560
+
2561
+ def _handle_settings_get(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2562
+ paths = self.paths or AgentPaths()
2563
+ ws = Path(self.agent.workspace if self.agent else ".").resolve()
2564
+ if self.settings is None:
2565
+ self.settings = load_settings(paths, cwd=ws)
2566
+
2567
+ return self.send_response(
2568
+ req_id,
2569
+ result={
2570
+ "status": "ok",
2571
+ "settings": self.settings.model_dump(),
2572
+ "paths": {
2573
+ "global_settings": str(paths.settings_path),
2574
+ "project_settings": str(paths.project_settings_path(ws)),
2575
+ },
2576
+ },
2577
+ )
2578
+
2579
+ def _handle_settings_set(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2580
+ paths = self.paths or AgentPaths()
2581
+ ws = Path(self.agent.workspace if self.agent else ".").resolve()
2582
+ if self.settings is None:
2583
+ self.settings = load_settings(paths, cwd=ws)
2584
+
2585
+ scope = params.get("scope", "global")
2586
+ target_path = paths.project_settings_path(ws) if scope == "project" else paths.settings_path
2587
+ target_path.parent.mkdir(parents=True, exist_ok=True)
2588
+
2589
+ updated_fields: dict[str, Any] = {}
2590
+ for key in [
2591
+ "default_model",
2592
+ "default_provider",
2593
+ "default_thinking_level",
2594
+ "default_permission_mode",
2595
+ "theme",
2596
+ "auto_compact",
2597
+ ]:
2598
+ if key in params:
2599
+ val = params[key]
2600
+ setattr(self.settings, key, val)
2601
+ updated_fields[key] = val
2602
+
2603
+ save_settings(self.settings, target_path)
2604
+
2605
+ return self.send_response(
2606
+ req_id,
2607
+ result={
2608
+ "status": "ok",
2609
+ "updated": updated_fields,
2610
+ "settings": self.settings.model_dump(),
2611
+ },
2612
+ )
2613
+
2614
+ def _handle_trust_set(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
2615
+ paths = self.paths or AgentPaths()
2616
+ paths.home.mkdir(parents=True, exist_ok=True)
2617
+ trust_file = paths.home / "trust.json"
2618
+
2619
+ workspace_path = Path(self.agent.workspace if self.agent else ".").resolve()
2620
+ target_dir = Path(params.get("path", workspace_path)).resolve()
2621
+
2622
+ if bool(params.get("parent", False)):
2623
+ target_dir = target_dir.parent
2624
+
2625
+ trusted = bool(params.get("trusted", True))
2626
+
2627
+ trust_data: dict[str, bool] = {}
2628
+ if trust_file.exists():
2629
+ try:
2630
+ content = trust_file.read_text(encoding="utf-8")
2631
+ parsed = json.loads(content)
2632
+ if isinstance(parsed, dict):
2633
+ trust_data = parsed
2634
+ except Exception:
2635
+ trust_data = {}
2636
+
2637
+ trust_data[str(target_dir)] = trusted
2638
+
2639
+ tmp_file = trust_file.with_name(f"{trust_file.name}.tmp")
2640
+ tmp_file.write_text(json.dumps(trust_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
2641
+ tmp_file.replace(trust_file)
2642
+
2643
+ decision = "trusted" if trusted else "untrusted"
2644
+ return self.send_response(
2645
+ req_id,
2646
+ result={
2647
+ "status": "ok",
2648
+ "path": str(target_dir),
2649
+ "trusted": trusted,
2650
+ "decision": decision,
2651
+ },
2652
+ )
2653
+
2654
+ async def _handle_shutdown(self, req_id: Any) -> dict[str, Any]:
2655
+ self.is_shutting_down = True
2656
+ if self.agent and hasattr(self.agent, "close_mcp"):
2657
+ res = self.agent.close_mcp()
2658
+ if inspect.isawaitable(res):
2659
+ await res
2660
+ return self.send_response(req_id, result={"status": "ok"})
2661
+
2662
+ async def handle_request(self, req: dict[str, Any]) -> dict[str, Any]:
2663
+ """分发并处理单个 RPC 请求。"""
2664
+ req_id = req.get("id", 0)
2665
+ method = req.get("method", "")
2666
+ params = req.get("params", {})
2667
+
2668
+ try:
2669
+ if method == "initialize":
2670
+ return await self._handle_initialize(req_id, params)
2671
+ elif method == "prompt":
2672
+ return await self._handle_prompt(req_id, params)
2673
+ elif method == "login":
2674
+ return self._handle_login(req_id, params)
2675
+ elif method == "steer":
2676
+ return self._handle_steer(req_id, params)
2677
+ elif method == "followup":
2678
+ return self._handle_followup(req_id, params)
2679
+ elif method == "abort":
2680
+ return self._handle_abort(req_id)
2681
+ elif method == "session_name":
2682
+ return self._handle_session_name(req_id, params)
2683
+ elif method == "session_list":
2684
+ return self._handle_session_list(req_id, params)
2685
+ elif method == "session_resume":
2686
+ return self._handle_session_resume(req_id, params)
2687
+ elif method == "session_delete":
2688
+ return self._handle_session_delete(req_id, params)
2689
+ elif method == "session_history":
2690
+ return self._handle_session_history(req_id, params)
2691
+ elif method == "session_stats":
2692
+ return self._handle_session_stats(req_id, params)
2693
+ elif method == "session_compact":
2694
+ return await self._handle_session_compact(req_id, params)
2695
+ elif method == "session_new":
2696
+ return self._handle_session_new(req_id, params)
2697
+ elif method == "session_tree":
2698
+ return self._handle_session_tree(req_id, params)
2699
+ elif method == "session_branch":
2700
+ return await self._handle_session_branch(req_id, params)
2701
+ elif method == "session_fork":
2702
+ return self._handle_session_fork(req_id, params)
2703
+ elif method == "session_clone":
2704
+ return self._handle_session_clone(req_id, params)
2705
+ elif method == "shell_exec":
2706
+ return await self._handle_shell_exec(req_id, params)
2707
+ elif method == "macro_expand":
2708
+ return self._handle_macro_expand(req_id, params)
2709
+ elif method == "models_list":
2710
+ return self._handle_models_list(req_id, params)
2711
+ elif method == "model_switch":
2712
+ return self._handle_model_switch(req_id, params)
2713
+ elif method == "thinking_set":
2714
+ return self._handle_thinking_set(req_id, params)
2715
+ elif method == "auth_logout":
2716
+ return self._handle_auth_logout(req_id, params)
2717
+ elif method == "resource_reload":
2718
+ return self._handle_resource_reload(req_id, params)
2719
+ elif method == "settings_get":
2720
+ return self._handle_settings_get(req_id, params)
2721
+ elif method == "settings_set":
2722
+ return self._handle_settings_set(req_id, params)
2723
+ elif method == "trust_set":
2724
+ return self._handle_trust_set(req_id, params)
2725
+ elif method == "shutdown":
2726
+ return await self._handle_shutdown(req_id)
2727
+ else:
2728
+ return self.send_response(
2729
+ req_id,
2730
+ error={"code": -32601, "message": f"Method '{method}' not found"},
2731
+ )
2732
+ except Exception as e:
2733
+ return self.send_response(
2734
+ req_id,
2735
+ error={"code": -32000, "message": str(e)},
2736
+ )
2737
+
2738
+ async def run_forever(self) -> None:
2739
+ """主服务循环,以异步方式按行消费 stdin 并处理请求。"""
2740
+ while not self.is_shutting_down:
2741
+ try:
2742
+ line = await asyncio.to_thread(self.stdin.readline)
2743
+ except Exception:
2744
+ break
2745
+
2746
+ if not line:
2747
+ # 管道关闭 (EOF)
2748
+ break
2749
+
2750
+ line_str = line.strip()
2751
+ if not line_str:
2752
+ continue
2753
+
2754
+ try:
2755
+ req = json.loads(line_str)
2756
+ except json.JSONDecodeError:
2757
+ self.send_response(
2758
+ 0,
2759
+ error={"code": -32700, "message": "Parse error (invalid JSON)"},
2760
+ )
2761
+ continue
2762
+
2763
+ # 并发派发请求,保证在 prompt 执行期间仍可实时处理 abort / steer / followup
2764
+ task = asyncio.create_task(self.handle_request(req))
2765
+ self._background_tasks.add(task)
2766
+ task.add_done_callback(self._background_tasks.discard)
2767
+
2768
+ if self._background_tasks:
2769
+ await asyncio.gather(*list(self._background_tasks), return_exceptions=True)
2770
+
2771
+
2772
+ async def main() -> None:
2773
+ load_dotenv(find_dotenv(usecwd=True))
2774
+ parser = argparse.ArgumentParser(description="my-coding-agent stdio JSON-RPC server")
2775
+ parser.add_argument("-w", "--workspace", default=".", help="工作区路径")
2776
+ parser.add_argument("-m", "--model", default=None, help="LLM 模型标识符")
2777
+ parser.add_argument("-c", "--continue", dest="continue_session", action="store_true", help="续接最近一次会话")
2778
+ parser.add_argument("-r", "--resume", nargs="?", const=True, default=None, help="恢复指定会话或打开选择器")
2779
+ parser.add_argument("-n", "--name", default=None, help="为当前会话命名")
2780
+ parser.add_argument("--thinking", default=None, help="思考深度等级")
2781
+ parser.add_argument("--no-session", action="store_true", help="内存无痕模式")
2782
+ parser.add_argument("--new-session", action="store_true", help="强制开启新会话")
2783
+ args = parser.parse_args()
2784
+
2785
+ server = RpcServer()
2786
+ # 如果指定了启动工作区或模型,先行执行预初始化
2787
+ if (
2788
+ args.workspace != "."
2789
+ or args.model is not None
2790
+ or args.continue_session
2791
+ or args.resume is not None
2792
+ or args.name is not None
2793
+ or args.thinking is not None
2794
+ or args.no_session
2795
+ ):
2796
+ await server.handle_request(
2797
+ {
2798
+ "jsonrpc": "2.0",
2799
+ "id": 0,
2800
+ "method": "initialize",
2801
+ "params": {
2802
+ "workspace": args.workspace,
2803
+ "model": args.model,
2804
+ "continue_session": args.continue_session,
2805
+ "resume": args.resume,
2806
+ "name": args.name,
2807
+ "thinking": args.thinking,
2808
+ "no_session": args.no_session,
2809
+ },
2810
+ }
2811
+ )
2812
+
2813
+ await server.run_forever()
2814
+
2815
+
2816
+ if __name__ == "__main__":
2817
+ asyncio.run(main())