@researai/deepscientist 1.5.15 → 1.5.17

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 (202) hide show
  1. package/README.md +385 -104
  2. package/bin/ds.js +1241 -110
  3. package/docs/en/00_QUICK_START.md +100 -19
  4. package/docs/en/01_SETTINGS_REFERENCE.md +34 -1
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
  6. package/docs/en/05_TUI_GUIDE.md +6 -0
  7. package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
  8. package/docs/en/09_DOCTOR.md +25 -8
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  10. package/docs/en/15_CODEX_PROVIDER_SETUP.md +37 -11
  11. package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  12. package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
  13. package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
  14. package/docs/en/21_LOCAL_MODEL_BACKENDS_GUIDE.md +283 -0
  15. package/docs/en/91_DEVELOPMENT.md +237 -0
  16. package/docs/en/README.md +24 -2
  17. package/docs/zh/00_QUICK_START.md +89 -19
  18. package/docs/zh/01_SETTINGS_REFERENCE.md +34 -1
  19. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  20. package/docs/zh/05_TUI_GUIDE.md +6 -0
  21. package/docs/zh/09_DOCTOR.md +26 -9
  22. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  23. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +37 -11
  24. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  25. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  26. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  27. package/docs/zh/21_LOCAL_MODEL_BACKENDS_GUIDE.md +281 -0
  28. package/docs/zh/README.md +24 -2
  29. package/install.sh +46 -4
  30. package/package.json +2 -1
  31. package/pyproject.toml +1 -1
  32. package/src/deepscientist/__init__.py +1 -1
  33. package/src/deepscientist/acp/envelope.py +6 -0
  34. package/src/deepscientist/artifact/service.py +647 -22
  35. package/src/deepscientist/bash_exec/service.py +234 -9
  36. package/src/deepscientist/bridges/connectors.py +8 -2
  37. package/src/deepscientist/cli.py +115 -19
  38. package/src/deepscientist/codex_cli_compat.py +367 -22
  39. package/src/deepscientist/config/models.py +2 -1
  40. package/src/deepscientist/config/service.py +183 -13
  41. package/src/deepscientist/daemon/api/handlers.py +255 -31
  42. package/src/deepscientist/daemon/api/router.py +9 -0
  43. package/src/deepscientist/daemon/app.py +1146 -105
  44. package/src/deepscientist/diagnostics/__init__.py +6 -0
  45. package/src/deepscientist/diagnostics/runner_failures.py +130 -0
  46. package/src/deepscientist/doctor.py +207 -3
  47. package/src/deepscientist/gitops/__init__.py +10 -1
  48. package/src/deepscientist/gitops/diff.py +129 -0
  49. package/src/deepscientist/gitops/service.py +4 -1
  50. package/src/deepscientist/mcp/server.py +39 -0
  51. package/src/deepscientist/prompts/builder.py +275 -34
  52. package/src/deepscientist/quest/layout.py +15 -2
  53. package/src/deepscientist/quest/service.py +707 -55
  54. package/src/deepscientist/quest/stage_views.py +6 -1
  55. package/src/deepscientist/runners/codex.py +143 -43
  56. package/src/deepscientist/shared.py +19 -0
  57. package/src/deepscientist/skills/__init__.py +2 -2
  58. package/src/deepscientist/skills/installer.py +196 -5
  59. package/src/deepscientist/skills/registry.py +66 -0
  60. package/src/prompts/connectors/qq.md +18 -8
  61. package/src/prompts/connectors/weixin.md +16 -6
  62. package/src/prompts/contracts/shared_interaction.md +14 -2
  63. package/src/prompts/system.md +23 -5
  64. package/src/prompts/system_copilot.md +56 -0
  65. package/src/skills/analysis-campaign/SKILL.md +1 -0
  66. package/src/skills/baseline/SKILL.md +8 -0
  67. package/src/skills/decision/SKILL.md +8 -0
  68. package/src/skills/experiment/SKILL.md +8 -0
  69. package/src/skills/figure-polish/SKILL.md +1 -0
  70. package/src/skills/finalize/SKILL.md +1 -0
  71. package/src/skills/idea/SKILL.md +1 -0
  72. package/src/skills/intake-audit/SKILL.md +8 -0
  73. package/src/skills/mentor/SKILL.md +217 -0
  74. package/src/skills/mentor/references/correction-rules.md +210 -0
  75. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  76. package/src/skills/mentor/references/persona-profile.md +138 -0
  77. package/src/skills/mentor/references/taste-profile.md +128 -0
  78. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  79. package/src/skills/mentor/references/work-profile.md +289 -0
  80. package/src/skills/mentor/references/workflow-profile.md +240 -0
  81. package/src/skills/optimize/SKILL.md +1 -0
  82. package/src/skills/rebuttal/SKILL.md +1 -0
  83. package/src/skills/review/SKILL.md +1 -0
  84. package/src/skills/scout/SKILL.md +8 -0
  85. package/src/skills/write/SKILL.md +1 -0
  86. package/src/tui/dist/app/AppContainer.js +19 -11
  87. package/src/tui/dist/index.js +4 -1
  88. package/src/tui/dist/lib/api.js +33 -3
  89. package/src/tui/package.json +1 -1
  90. package/src/ui/dist/assets/AiManusChatView-Bv-Z8YpU.js +204 -0
  91. package/src/ui/dist/assets/AnalysisPlugin-BCKAfjba.js +1 -0
  92. package/src/ui/dist/assets/CliPlugin-BCKcpc35.js +109 -0
  93. package/src/ui/dist/assets/CodeEditorPlugin-DbOfSJ8K.js +2 -0
  94. package/src/ui/dist/assets/CodeViewerPlugin-CbaFRrUU.js +270 -0
  95. package/src/ui/dist/assets/DocViewerPlugin-DAjLVeQD.js +7 -0
  96. package/src/ui/dist/assets/GitCommitViewerPlugin-CIUqbUDO.js +1 -0
  97. package/src/ui/dist/assets/GitDiffViewerPlugin-CQACjoAA.js +6 -0
  98. package/src/ui/dist/assets/GitSnapshotViewer-0r4nLPke.js +30 -0
  99. package/src/ui/dist/assets/ImageViewerPlugin-nBOmI2v_.js +26 -0
  100. package/src/ui/dist/assets/LabCopilotPanel-BHxOxF4z.js +14 -0
  101. package/src/ui/dist/assets/LabPlugin-BKoZGs95.js +22 -0
  102. package/src/ui/dist/assets/LatexPlugin-ZwtV8pIp.js +25 -0
  103. package/src/ui/dist/assets/MarkdownViewerPlugin-DKqVfKyW.js +128 -0
  104. package/src/ui/dist/assets/MarketplacePlugin-BwxStZ9D.js +13 -0
  105. package/src/ui/dist/assets/NotebookEditor-BEQhaQbt.js +81 -0
  106. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  107. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  108. package/src/ui/dist/assets/NotebookEditor-DB9N_T9q.js +361 -0
  109. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  110. package/src/ui/dist/assets/PdfLoader-eWBONbQP.js +16 -0
  111. package/src/ui/dist/assets/PdfMarkdownPlugin-D22YOZL3.js +1 -0
  112. package/src/ui/dist/assets/PdfViewerPlugin-c-RK9DLM.js +17 -0
  113. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  114. package/src/ui/dist/assets/SearchPlugin-CxF9ytAx.js +16 -0
  115. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  116. package/src/ui/dist/assets/TextViewerPlugin-C5xqeeUH.js +54 -0
  117. package/src/ui/dist/assets/VNCViewer-BoLGLnHz.js +11 -0
  118. package/src/ui/dist/assets/bot-DREQOxzP.js +6 -0
  119. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  120. package/src/ui/dist/assets/chevron-up-C9Qpx4DE.js +6 -0
  121. package/src/ui/dist/assets/code-WlFHE7z_.js +6 -0
  122. package/src/ui/dist/assets/file-content-BZMz3RYp.js +1 -0
  123. package/src/ui/dist/assets/file-diff-panel-CQhw0jS2.js +1 -0
  124. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  125. package/src/ui/dist/assets/file-socket-CfQPKQKj.js +1 -0
  126. package/src/ui/dist/assets/git-commit-horizontal-DxZ8DCZh.js +6 -0
  127. package/src/ui/dist/assets/image-Bgl4VIyx.js +6 -0
  128. package/src/ui/dist/assets/index-BpV6lusQ.css +33 -0
  129. package/src/ui/dist/assets/index-CBNVuWcP.js +2496 -0
  130. package/src/ui/dist/assets/index-CwNu1aH4.js +11 -0
  131. package/src/ui/dist/assets/index-DrUnlf6K.js +1 -0
  132. package/src/ui/dist/assets/index-NW-h8VzN.js +1 -0
  133. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  134. package/src/ui/dist/assets/pdf-effect-queue-J8OnM0jE.js +6 -0
  135. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  136. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  137. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  138. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  139. package/src/ui/dist/assets/popover-CLc0pPP8.js +1 -0
  140. package/src/ui/dist/assets/project-sync-C9IdzdZW.js +1 -0
  141. package/src/ui/dist/assets/select-Cs2PmzwL.js +11 -0
  142. package/src/ui/dist/assets/sigma-ClKcHAXm.js +6 -0
  143. package/src/ui/dist/assets/trash-DwpbFr3w.js +11 -0
  144. package/src/ui/dist/assets/useCliAccess-NQ8m0Let.js +1 -0
  145. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  146. package/src/ui/dist/assets/wrap-text-BC-Hltpd.js +11 -0
  147. package/src/ui/dist/assets/zoom-out-E_gaeAxL.js +11 -0
  148. package/src/ui/dist/index.html +5 -2
  149. package/src/ui/dist/assets/AiManusChatView-DDjbFnbt.js +0 -26597
  150. package/src/ui/dist/assets/AnalysisPlugin-Yb5IdmaU.js +0 -123
  151. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +0 -31037
  152. package/src/ui/dist/assets/CodeEditorPlugin-C4D2TIkU.js +0 -427
  153. package/src/ui/dist/assets/CodeViewerPlugin-BVoNZIvC.js +0 -905
  154. package/src/ui/dist/assets/DocViewerPlugin-CLChbllo.js +0 -278
  155. package/src/ui/dist/assets/GitDiffViewerPlugin-C4xeFyFQ.js +0 -2661
  156. package/src/ui/dist/assets/ImageViewerPlugin-OiMUAcLi.js +0 -500
  157. package/src/ui/dist/assets/LabCopilotPanel-BjD2ThQF.js +0 -4104
  158. package/src/ui/dist/assets/LabPlugin-DQPg-NrB.js +0 -2677
  159. package/src/ui/dist/assets/LatexPlugin-CI05XAV9.js +0 -1792
  160. package/src/ui/dist/assets/MarkdownViewerPlugin-DpeBLYZf.js +0 -308
  161. package/src/ui/dist/assets/MarketplacePlugin-DolE58Q2.js +0 -413
  162. package/src/ui/dist/assets/NotebookEditor-7Qm2rSWD.js +0 -4214
  163. package/src/ui/dist/assets/NotebookEditor-C1kWaxKi.js +0 -84873
  164. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  165. package/src/ui/dist/assets/PdfLoader-BfOHw8Zw.js +0 -25468
  166. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  167. package/src/ui/dist/assets/PdfMarkdownPlugin-BulDREv1.js +0 -409
  168. package/src/ui/dist/assets/PdfViewerPlugin-C-daaOaL.js +0 -3095
  169. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  170. package/src/ui/dist/assets/SearchPlugin-CjpaiJ3A.js +0 -741
  171. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  172. package/src/ui/dist/assets/TextViewerPlugin-BxIyqPQC.js +0 -472
  173. package/src/ui/dist/assets/VNCViewer-HAg9mF7M.js +0 -18821
  174. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  175. package/src/ui/dist/assets/bot-0DYntytV.js +0 -21
  176. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  177. package/src/ui/dist/assets/code-B20Slj_w.js +0 -17
  178. package/src/ui/dist/assets/file-content-DT24KFma.js +0 -377
  179. package/src/ui/dist/assets/file-diff-panel-DK13YPql.js +0 -92
  180. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  181. package/src/ui/dist/assets/file-socket-B4T2o4nR.js +0 -58
  182. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  183. package/src/ui/dist/assets/image-DSeR_sDS.js +0 -18
  184. package/src/ui/dist/assets/index-BrFje2Uk.js +0 -120
  185. package/src/ui/dist/assets/index-BwRJaoTl.js +0 -25
  186. package/src/ui/dist/assets/index-D_E4281X.js +0 -221322
  187. package/src/ui/dist/assets/index-DnYB3xb1.js +0 -159
  188. package/src/ui/dist/assets/index-G7AcWcMu.css +0 -12594
  189. package/src/ui/dist/assets/monaco-LExaAN3Y.js +0 -623
  190. package/src/ui/dist/assets/pdf-effect-queue-BJk5okWJ.js +0 -47
  191. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  192. package/src/ui/dist/assets/popover-D3Gg_FoV.js +0 -476
  193. package/src/ui/dist/assets/project-sync-C_ygLlVU.js +0 -297
  194. package/src/ui/dist/assets/select-CpAK6uWm.js +0 -1690
  195. package/src/ui/dist/assets/sigma-DEccaSgk.js +0 -22
  196. package/src/ui/dist/assets/square-check-big-uUfyVsbD.js +0 -17
  197. package/src/ui/dist/assets/trash-CXvwwSe8.js +0 -32
  198. package/src/ui/dist/assets/useCliAccess-Bnop4mgR.js +0 -957
  199. package/src/ui/dist/assets/useFileDiffOverlay-B8eUAX0I.js +0 -53
  200. package/src/ui/dist/assets/wrap-text-9vbOBpkW.js +0 -35
  201. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  202. package/src/ui/dist/assets/zoom-out-BgVMmOW4.js +0 -34
@@ -234,10 +234,11 @@ class QuestStageViewBuilder:
234
234
 
235
235
  def build(self) -> dict[str, Any]:
236
236
  selection_type = str(self.selection.get("selection_type") or "").strip()
237
+ explicit_stage_key = str(self.selection.get("stage_key") or "").strip()
237
238
  self.stage_key = self._resolve_effective_stage_key()
238
239
  if selection_type == "idea_candidate":
239
240
  return self._build_idea_candidate()
240
- if selection_type == "branch_node" and self.stage_key not in {"experiment", "analysis", "paper"}:
241
+ if selection_type == "branch_node" and not explicit_stage_key:
241
242
  return self._build_branch()
242
243
  if self.stage_key == "baseline":
243
244
  return self._build_baseline()
@@ -1288,11 +1289,15 @@ class QuestStageViewBuilder:
1288
1289
  for item in self.artifacts
1289
1290
  if self._branch_matches(self._payload(item), allow_parent=True, include_unscoped=False)
1290
1291
  ]
1292
+ latest_branch_payload = self._payload(branch_items[-1] if branch_items else {})
1291
1293
  note = (
1292
1294
  str(
1293
1295
  latest_experiment_payload.get("summary")
1294
1296
  or latest_idea_payload.get("summary")
1295
1297
  or latest_idea_payload.get("reason")
1298
+ or latest_branch_payload.get("summary")
1299
+ or latest_branch_payload.get("message")
1300
+ or latest_branch_payload.get("reason")
1296
1301
  or self.trace.get("summary")
1297
1302
  or self.selection.get("summary")
1298
1303
  or ""
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import signal
6
- import shutil
7
6
  import subprocess
8
7
  import sys
9
8
  import threading
@@ -11,12 +10,18 @@ from pathlib import Path
11
10
  from typing import Any
12
11
 
13
12
  from ..artifact import ArtifactService
14
- from ..codex_cli_compat import adapt_profile_only_provider_config, normalize_codex_reasoning_effort
13
+ from ..codex_cli_compat import (
14
+ active_provider_metadata_from_home,
15
+ materialize_codex_runtime_home,
16
+ normalize_codex_reasoning_effort,
17
+ provider_profile_metadata_from_home,
18
+ )
15
19
  from ..config import ConfigManager
16
20
  from ..gitops import export_git_graph
21
+ from ..process_control import process_session_popen_kwargs
17
22
  from ..prompts import PromptBuilder
18
23
  from ..runtime_logs import JsonlLogger
19
- from ..shared import append_jsonl, ensure_dir, generate_id, read_text, read_yaml, resolve_runner_binary, utc_now, write_json, write_text
24
+ from ..shared import append_jsonl, ensure_dir, generate_id, read_yaml, resolve_runner_binary, utc_now, write_json, write_text
20
25
  from ..web_search import extract_web_search_payload
21
26
  from .base import RunRequest, RunResult
22
27
 
@@ -69,6 +74,12 @@ _BUILTIN_MCP_TOOL_APPROVALS: dict[str, tuple[str, ...]] = {
69
74
  ),
70
75
  }
71
76
 
77
+ _PROVIDER_ENV_CONFLICT_KEYS = (
78
+ "OPENAI_API_KEY",
79
+ "OPENAI_BASE_URL",
80
+ )
81
+ _CHAT_WIRE_TOOL_CALL_GUARD_MARKER = "## Codex Chat-Wire Tool Call Compatibility"
82
+
72
83
 
73
84
  def _compact_text(value: object, *, limit: int = 1200) -> str:
74
85
  if value is None:
@@ -195,7 +206,9 @@ def _iter_event_texts(event: dict[str, Any]) -> list[str]:
195
206
  if isinstance(value, str) and value.strip():
196
207
  texts.append(value)
197
208
  delta = event.get("delta")
198
- if isinstance(delta, dict):
209
+ if isinstance(delta, str) and delta.strip():
210
+ texts.append(delta)
211
+ elif isinstance(delta, dict):
199
212
  for key in ("text", "content"):
200
213
  value = delta.get(key)
201
214
  if isinstance(value, str) and value.strip():
@@ -222,6 +235,36 @@ def _web_search_text_payload(item: dict[str, Any]) -> str:
222
235
  return _compact_text(payload, limit=2400)
223
236
 
224
237
 
238
+ def _message_stream_id(event: dict[str, Any], item: dict[str, Any], *, run_id: str, kind: str) -> str:
239
+ for value in (
240
+ event.get("stream_id"),
241
+ item.get("stream_id"),
242
+ event.get("message_id"),
243
+ item.get("message_id"),
244
+ event.get("item_id"),
245
+ item.get("id"),
246
+ event.get("output_item_id"),
247
+ event.get("response_id"),
248
+ ):
249
+ if value:
250
+ return str(value)
251
+ normalized_kind = str(kind or "message").strip().lower() or "message"
252
+ return f"{run_id}:{normalized_kind}"
253
+
254
+
255
+ def _message_id(event: dict[str, Any], item: dict[str, Any], *, stream_id: str) -> str:
256
+ for value in (
257
+ event.get("message_id"),
258
+ item.get("message_id"),
259
+ event.get("item_id"),
260
+ item.get("id"),
261
+ event.get("output_item_id"),
262
+ ):
263
+ if value:
264
+ return str(value)
265
+ return stream_id
266
+
267
+
225
268
  def _message_events(
226
269
  event: dict[str, Any],
227
270
  *,
@@ -238,6 +281,8 @@ def _message_events(
238
281
 
239
282
  if item_type == "agent_message":
240
283
  texts = _dedupe_texts(_iter_event_texts(event))
284
+ stream_id = _message_stream_id(event, item, run_id=run_id, kind="assistant")
285
+ message_id = _message_id(event, item, stream_id=stream_id)
241
286
  for text in texts:
242
287
  quest_events.append(
243
288
  {
@@ -248,6 +293,8 @@ def _message_events(
248
293
  "source": "codex",
249
294
  "skill_id": skill_id,
250
295
  "text": text,
296
+ "stream_id": stream_id,
297
+ "message_id": message_id,
251
298
  "created_at": created_at,
252
299
  }
253
300
  )
@@ -255,6 +302,8 @@ def _message_events(
255
302
 
256
303
  if item_type in {"reasoning", "reasoning_summary"} or "reasoning" in event_type:
257
304
  texts = _dedupe_texts(_iter_event_texts(event))
305
+ stream_id = _message_stream_id(event, item, run_id=run_id, kind=item_type or "reasoning")
306
+ message_id = _message_id(event, item, stream_id=stream_id)
258
307
  for text in texts:
259
308
  quest_events.append(
260
309
  {
@@ -265,6 +314,8 @@ def _message_events(
265
314
  "source": "codex",
266
315
  "skill_id": skill_id,
267
316
  "text": text,
317
+ "stream_id": stream_id,
318
+ "message_id": message_id,
268
319
  "kind": item_type or "reasoning",
269
320
  "created_at": created_at,
270
321
  }
@@ -278,6 +329,8 @@ def _message_events(
278
329
  return [], []
279
330
 
280
331
  texts = _dedupe_texts(_iter_event_texts(event))
332
+ stream_id = _message_stream_id(event, item, run_id=run_id, kind="assistant")
333
+ message_id = _message_id(event, item, stream_id=stream_id)
281
334
  for text in texts:
282
335
  quest_events.append(
283
336
  {
@@ -288,6 +341,8 @@ def _message_events(
288
341
  "source": "codex",
289
342
  "skill_id": skill_id,
290
343
  "text": text,
344
+ "stream_id": stream_id,
345
+ "message_id": message_id,
291
346
  "created_at": created_at,
292
347
  }
293
348
  )
@@ -679,6 +734,18 @@ class CodexRunner:
679
734
  self._process_lock = threading.Lock()
680
735
  self._active_processes: dict[str, subprocess.Popen[str]] = {}
681
736
 
737
+ @staticmethod
738
+ def _subprocess_popen_kwargs(*, workspace_root: Path, env: dict[str, str]) -> dict[str, Any]:
739
+ return {
740
+ "cwd": str(workspace_root),
741
+ "env": env,
742
+ "stdin": subprocess.PIPE,
743
+ "stdout": subprocess.PIPE,
744
+ "stderr": subprocess.PIPE,
745
+ "text": True,
746
+ **process_session_popen_kwargs(hide_window=True),
747
+ }
748
+
682
749
  def run(self, request: RunRequest) -> RunResult:
683
750
  workspace_root = request.worktree_root or request.quest_root
684
751
  run_root = ensure_dir(request.quest_root / ".ds" / "runs" / request.run_id)
@@ -694,6 +761,7 @@ class CodexRunner:
694
761
  turn_mode=request.turn_mode,
695
762
  retry_context=request.retry_context,
696
763
  )
764
+ prompt = self._apply_chat_wire_tool_call_guard(prompt, runner_config=runner_config)
697
765
  write_text(run_root / "prompt.md", prompt)
698
766
 
699
767
  codex_home = self._prepare_project_codex_home(
@@ -729,6 +797,7 @@ class CodexRunner:
729
797
  continue
730
798
  env[env_key] = env_value
731
799
  env["CODEX_HOME"] = str(codex_home)
800
+ env = self._sanitize_provider_env(env, runner_config=runner_config)
732
801
  env["DEEPSCIENTIST_HOME"] = str(self.home)
733
802
  env["DS_HOME"] = str(self.home)
734
803
  env["DS_QUEST_ID"] = request.quest_id
@@ -743,18 +812,7 @@ class CodexRunner:
743
812
  env["DS_CONVERSATION_ID"] = f"quest:{request.quest_id}"
744
813
  env["DS_AGENT_ROLE"] = request.skill_id
745
814
  env["DS_TEAM_MODE"] = "single"
746
- popen_kwargs: dict[str, Any] = {
747
- "cwd": str(workspace_root),
748
- "env": env,
749
- "stdin": subprocess.PIPE,
750
- "stdout": subprocess.PIPE,
751
- "stderr": subprocess.PIPE,
752
- "text": True,
753
- }
754
- if os.name == "nt" and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
755
- popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP")
756
- else:
757
- popen_kwargs["start_new_session"] = True
815
+ popen_kwargs = self._subprocess_popen_kwargs(workspace_root=workspace_root, env=env)
758
816
  process = subprocess.Popen(command, **popen_kwargs)
759
817
  with self._process_lock:
760
818
  self._active_processes[request.quest_id] = process
@@ -975,6 +1033,8 @@ class CodexRunner:
975
1033
  resolved_runner_config = runner_config if isinstance(runner_config, dict) else self._load_runner_config()
976
1034
  profile = str(resolved_runner_config.get("profile") or "").strip()
977
1035
  normalized_model = str(request.model or "").strip()
1036
+ if profile and normalized_model.lower() not in {"", "inherit", "default", "codex-default"}:
1037
+ normalized_model = "inherit"
978
1038
  command = [
979
1039
  resolved_binary or self.binary,
980
1040
  "--search",
@@ -1010,6 +1070,46 @@ class CodexRunner:
1010
1070
  command.append("-")
1011
1071
  return command
1012
1072
 
1073
+ def _apply_chat_wire_tool_call_guard(
1074
+ self,
1075
+ prompt: str,
1076
+ *,
1077
+ runner_config: dict[str, Any] | None = None,
1078
+ ) -> str:
1079
+ prompt_text = str(prompt or "")
1080
+ if not prompt_text or _CHAT_WIRE_TOOL_CALL_GUARD_MARKER in prompt_text:
1081
+ return prompt_text
1082
+
1083
+ resolved_runner_config = runner_config if isinstance(runner_config, dict) else self._load_runner_config()
1084
+ profile = str(resolved_runner_config.get("profile") or "").strip()
1085
+ if not profile:
1086
+ return prompt_text
1087
+ config_home = str(resolved_runner_config.get("config_dir") or os.environ.get("CODEX_HOME") or "").strip()
1088
+ if not config_home:
1089
+ return prompt_text
1090
+
1091
+ metadata = active_provider_metadata_from_home(config_home, profile=profile or None)
1092
+ wire_api = str(metadata.get("wire_api") or "").strip().lower()
1093
+ if wire_api != "chat":
1094
+ return prompt_text
1095
+
1096
+ provider = str(metadata.get("provider") or "").strip() or "unknown"
1097
+ guard_lines = [
1098
+ _CHAT_WIRE_TOOL_CALL_GUARD_MARKER,
1099
+ f"active_provider_profile: {profile}",
1100
+ f"active_provider_name: {provider}",
1101
+ "active_provider_wire_api: chat",
1102
+ "single_tool_call_per_turn_rule: emit at most one tool call in each assistant message.",
1103
+ "tool_call_serialization_rule: after each tool result, decide whether to make the next tool call or produce the answer.",
1104
+ "no_batched_mcp_rule: never bundle multiple `artifact.*`, `memory.*`, or `bash_exec.*` calls into the same response, even when the reads look independent.",
1105
+ "no_immediate_repeat_rule: if a tool already returned the information needed for the current subtask, do not immediately call that same tool again; move to the next tool or answer.",
1106
+ "state_recovery_preference_rule: on a fresh quest turn, prefer `artifact.get_quest_state`, `artifact.read_quest_documents`, and `memory.list_recent` to recover context before reaching for `bash_exec`.",
1107
+ "bash_exec_after_context_rule: use `bash_exec` only after you know the exact command you need and why the `artifact` / `memory` path is insufficient.",
1108
+ "tool_call_json_rule: every tool call must contain exactly one complete JSON object argument with no trailing characters.",
1109
+ ]
1110
+ guard_block = "\n".join(guard_lines)
1111
+ return f"{prompt_text.rstrip()}\n\n{guard_block}\n"
1112
+
1013
1113
  def _prepare_project_codex_home(
1014
1114
  self,
1015
1115
  workspace_root: Path,
@@ -1019,36 +1119,16 @@ class CodexRunner:
1019
1119
  run_id: str,
1020
1120
  runner_config: dict[str, Any] | None = None,
1021
1121
  ) -> Path:
1022
- target = ensure_dir(workspace_root / ".codex")
1122
+ target = ensure_dir(workspace_root / ".ds" / "codex-home")
1023
1123
  resolved_runner_config = runner_config if isinstance(runner_config, dict) else self._load_runner_config()
1024
1124
  configured_home = str(resolved_runner_config.get("config_dir") or os.environ.get("CODEX_HOME") or str(Path.home() / ".codex"))
1025
1125
  profile = str(resolved_runner_config.get("profile") or "").strip()
1026
- source = Path(configured_home).expanduser()
1027
- for filename in ("config.toml", "auth.json"):
1028
- source_path = source / filename
1029
- target_path = target / filename
1030
- if not source_path.exists():
1031
- continue
1032
- if source_path.resolve() == target_path.resolve():
1033
- continue
1034
- shutil.copy2(source_path, target_path)
1035
- config_path = target / "config.toml"
1036
- if profile and config_path.exists():
1037
- adapted_text, _ = adapt_profile_only_provider_config(read_text(config_path), profile=profile)
1038
- write_text(config_path, adapted_text)
1039
- ensure_dir(target / "skills")
1040
- quest_skills_root = quest_root / ".codex" / "skills"
1041
- if quest_skills_root.exists():
1042
- for source_path in sorted(quest_skills_root.rglob("*")):
1043
- relative = source_path.relative_to(quest_skills_root)
1044
- target_path = target / "skills" / relative
1045
- if source_path.is_dir():
1046
- ensure_dir(target_path)
1047
- continue
1048
- if source_path.resolve() == target_path.resolve():
1049
- continue
1050
- ensure_dir(target_path.parent)
1051
- shutil.copy2(source_path, target_path)
1126
+ materialize_codex_runtime_home(
1127
+ source_home=configured_home,
1128
+ target_home=target,
1129
+ profile=profile,
1130
+ quest_codex_root=quest_root / ".codex",
1131
+ )
1052
1132
  self._inject_built_in_mcp(
1053
1133
  target,
1054
1134
  quest_root=quest_root,
@@ -1157,3 +1237,23 @@ class CodexRunner:
1157
1237
  except (TypeError, ValueError):
1158
1238
  return None
1159
1239
  return timeout if timeout > 0 else None
1240
+
1241
+ @staticmethod
1242
+ def _sanitize_provider_env(
1243
+ env: dict[str, str],
1244
+ *,
1245
+ runner_config: dict[str, Any] | None = None,
1246
+ ) -> dict[str, str]:
1247
+ resolved_runner_config = runner_config if isinstance(runner_config, dict) else {}
1248
+ profile = str(resolved_runner_config.get("profile") or "").strip()
1249
+ config_home = str(resolved_runner_config.get("config_dir") or env.get("CODEX_HOME") or "").strip()
1250
+ if not config_home:
1251
+ return env
1252
+ metadata = active_provider_metadata_from_home(config_home, profile=profile or None)
1253
+ requires_openai_auth = metadata.get("requires_openai_auth")
1254
+ if requires_openai_auth is not False:
1255
+ return env
1256
+ sanitized = dict(env)
1257
+ for key in _PROVIDER_ENV_CONFLICT_KEYS:
1258
+ sanitized.pop(key, None)
1259
+ return sanitized
@@ -13,6 +13,8 @@ from pathlib import Path
13
13
  from typing import Any, Iterator
14
14
  from uuid import uuid4
15
15
 
16
+ from .process_control import process_session_popen_kwargs
17
+
16
18
  try:
17
19
  import yaml
18
20
  except ModuleNotFoundError as exc: # pragma: no cover
@@ -168,6 +170,23 @@ def run_command(
168
170
  check=check,
169
171
  text=True,
170
172
  capture_output=True,
173
+ **process_session_popen_kwargs(hide_window=True, new_process_group=False),
174
+ )
175
+
176
+
177
+ def run_command_bytes(
178
+ args: list[str],
179
+ *,
180
+ cwd: Path | None = None,
181
+ check: bool = True,
182
+ ) -> subprocess.CompletedProcess[bytes]:
183
+ return subprocess.run(
184
+ args,
185
+ cwd=str(cwd) if cwd else None,
186
+ check=check,
187
+ text=False,
188
+ capture_output=True,
189
+ **process_session_popen_kwargs(hide_window=True, new_process_group=False),
171
190
  )
172
191
 
173
192
 
@@ -1,4 +1,4 @@
1
1
  from .installer import SkillInstaller
2
- from .registry import SkillBundle, discover_skill_bundles
2
+ from .registry import SkillBundle, companion_skill_ids, discover_skill_bundles, stage_skill_ids
3
3
 
4
- __all__ = ["SkillBundle", "SkillInstaller", "discover_skill_bundles"]
4
+ __all__ = ["SkillBundle", "SkillInstaller", "discover_skill_bundles", "stage_skill_ids", "companion_skill_ids"]
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
4
+ import re
3
5
  import shutil
4
6
  from pathlib import Path
5
7
  from uuid import uuid4
@@ -8,6 +10,10 @@ from ..memory.frontmatter import load_markdown_document
8
10
  from ..shared import ensure_dir, read_json, utc_now, write_json
9
11
  from .registry import discover_skill_bundles
10
12
 
13
+ _PROMPT_SYNC_STATE_FILENAME = ".deepscientist-prompt-sync.json"
14
+ _PROMPT_VERSIONS_DIRNAME = "prompt_versions"
15
+ _PROMPT_VERSIONS_INDEX_FILENAME = "index.json"
16
+
11
17
 
12
18
  class SkillInstaller:
13
19
  def __init__(self, repo_root: Path, home: Path) -> None:
@@ -40,9 +46,9 @@ class SkillInstaller:
40
46
  "notes": [],
41
47
  }
42
48
 
43
- def sync_quest(self, quest_root: Path) -> dict:
49
+ def sync_quest(self, quest_root: Path, *, installed_version: str | None = None) -> dict:
50
+ prompt_sync = self.sync_quest_prompts(quest_root, installed_version=installed_version)
44
51
  prompts_root = ensure_dir(quest_root / ".codex" / "prompts")
45
- self._sync_prompt_tree(prompts_root)
46
52
  codex_root = ensure_dir(quest_root / ".codex" / "skills")
47
53
  claude_root = ensure_dir(quest_root / ".claude" / "agents")
48
54
  copied_codex: list[str] = []
@@ -61,12 +67,13 @@ class SkillInstaller:
61
67
  self._prune_bundle_targets(claude_root, expected_claude)
62
68
  return {
63
69
  "prompts": [str(path) for path in sorted(prompts_root.rglob("*")) if path.is_file()],
70
+ "prompt_sync": prompt_sync,
64
71
  "codex": copied_codex,
65
72
  "claude": copied_claude,
66
73
  "notes": [],
67
74
  }
68
75
 
69
- def sync_existing_quests(self) -> dict:
76
+ def sync_existing_quests(self, *, installed_version: str | None = None) -> dict:
70
77
  quests_root = self.home / "quests"
71
78
  synced: list[dict[str, object]] = []
72
79
  if not quests_root.exists():
@@ -79,13 +86,15 @@ class SkillInstaller:
79
86
  continue
80
87
  if not (quest_root / "quest.yaml").exists():
81
88
  continue
82
- result = self.sync_quest(quest_root)
89
+ result = self.sync_quest(quest_root, installed_version=installed_version)
83
90
  synced.append(
84
91
  {
85
92
  "quest_id": quest_root.name,
86
93
  "quest_root": str(quest_root),
87
94
  "codex_count": len(result.get("codex") or []),
88
95
  "claude_count": len(result.get("claude") or []),
96
+ "prompt_backup_id": (result.get("prompt_sync") or {}).get("backup_id"),
97
+ "prompt_fingerprint": (result.get("prompt_sync") or {}).get("prompt_fingerprint"),
89
98
  }
90
99
  )
91
100
  return {
@@ -127,11 +136,193 @@ class SkillInstaller:
127
136
  summary["global"] = self.sync_global()
128
137
  summary["global_synced"] = True
129
138
  if sync_existing_quests_enabled:
130
- summary["existing_quests"] = self.sync_existing_quests()
139
+ summary["existing_quests"] = self.sync_existing_quests(installed_version=normalized_version)
131
140
  summary["existing_quests_synced"] = True
132
141
  self._write_release_sync_state(summary)
133
142
  return summary
134
143
 
144
+ def sync_quest_prompts(
145
+ self,
146
+ quest_root: Path,
147
+ *,
148
+ installed_version: str | None = None,
149
+ ) -> dict[str, object]:
150
+ prompts_root = ensure_dir(quest_root / ".codex" / "prompts")
151
+ source_root = self.repo_root / "src" / "prompts"
152
+ normalized_version = self._normalized_installed_version(installed_version)
153
+ previous_state = self._read_prompt_sync_state(prompts_root)
154
+ current_fingerprint = self._prompt_tree_fingerprint(prompts_root, exclude_state_file=True)
155
+ source_fingerprint = self._prompt_tree_fingerprint(source_root, exclude_state_file=False)
156
+ backup_id: str | None = None
157
+ updated = False
158
+
159
+ if current_fingerprint != source_fingerprint:
160
+ if current_fingerprint:
161
+ backup_id = self._backup_prompt_tree(
162
+ quest_root,
163
+ prompts_root=prompts_root,
164
+ installed_version=str(previous_state.get("installed_version") or normalized_version),
165
+ prompt_fingerprint=current_fingerprint,
166
+ )
167
+ self._sync_prompt_tree(prompts_root)
168
+ updated = True
169
+
170
+ prompt_state = {
171
+ "installed_version": normalized_version,
172
+ "prompt_fingerprint": self._prompt_tree_fingerprint(prompts_root, exclude_state_file=True),
173
+ "synced_at": utc_now(),
174
+ "backup_id": backup_id,
175
+ "source_root": str(source_root),
176
+ }
177
+ write_json(self._prompt_sync_state_path(prompts_root), prompt_state)
178
+ return {
179
+ "updated": updated,
180
+ "backup_id": backup_id,
181
+ "prompt_fingerprint": prompt_state["prompt_fingerprint"],
182
+ "installed_version": normalized_version,
183
+ "source_root": str(source_root),
184
+ "active_root": str(prompts_root),
185
+ "versions_root": str(self._prompt_versions_root(quest_root)),
186
+ }
187
+
188
+ def list_prompt_versions(self, quest_root: Path) -> list[dict[str, object]]:
189
+ payload = read_json(self._prompt_versions_index_path(quest_root), {})
190
+ versions = payload.get("versions") if isinstance(payload.get("versions"), list) else []
191
+ return [dict(item) for item in versions if isinstance(item, dict)]
192
+
193
+ def resolve_prompt_version_root(self, quest_root: Path, selection: str) -> Path | None:
194
+ normalized = str(selection or "").strip()
195
+ if not normalized:
196
+ return None
197
+ exact_root = self._prompt_versions_root(quest_root) / normalized
198
+ if exact_root.exists():
199
+ return exact_root
200
+ candidates = [
201
+ dict(item)
202
+ for item in self.list_prompt_versions(quest_root)
203
+ if str(item.get("installed_version") or "").strip() == normalized
204
+ ]
205
+ if not candidates:
206
+ return None
207
+ candidates.sort(key=lambda item: str(item.get("created_at") or ""))
208
+ selected_path = Path(str(candidates[-1].get("path") or "")).expanduser()
209
+ return selected_path if selected_path.exists() else None
210
+
211
+ @staticmethod
212
+ def _normalized_installed_version(installed_version: str | None) -> str:
213
+ normalized = str(installed_version or "").strip()
214
+ if normalized:
215
+ return normalized
216
+ from .. import __version__
217
+
218
+ return str(__version__ or "").strip() or "unknown"
219
+
220
+ def _backup_prompt_tree(
221
+ self,
222
+ quest_root: Path,
223
+ *,
224
+ prompts_root: Path,
225
+ installed_version: str,
226
+ prompt_fingerprint: str,
227
+ ) -> str:
228
+ versions_root = ensure_dir(self._prompt_versions_root(quest_root))
229
+ backup_id = ""
230
+ target_root: Path | None = None
231
+ for _attempt in range(8):
232
+ backup_id = self._unique_prompt_backup_id(
233
+ versions_root,
234
+ installed_version=installed_version,
235
+ prompt_fingerprint=prompt_fingerprint,
236
+ )
237
+ target_root = versions_root / backup_id
238
+ try:
239
+ shutil.copytree(prompts_root, target_root)
240
+ break
241
+ except FileExistsError:
242
+ # Another sync run may have created the same backup directory between
243
+ # name selection and copy. Regenerate a fresh id and retry.
244
+ continue
245
+ else:
246
+ raise FileExistsError(
247
+ f"Failed to allocate a unique prompt backup directory under `{versions_root}` after multiple attempts."
248
+ )
249
+ assert target_root is not None
250
+ entry = {
251
+ "backup_id": backup_id,
252
+ "installed_version": str(installed_version or "").strip() or "unknown",
253
+ "prompt_fingerprint": prompt_fingerprint,
254
+ "created_at": utc_now(),
255
+ "path": str(target_root),
256
+ }
257
+ versions = self.list_prompt_versions(quest_root)
258
+ versions = [item for item in versions if str(item.get("backup_id") or "").strip() != backup_id]
259
+ versions.append(entry)
260
+ versions.sort(key=lambda item: str(item.get("created_at") or ""))
261
+ write_json(self._prompt_versions_index_path(quest_root), {"versions": versions})
262
+ return backup_id
263
+
264
+ @staticmethod
265
+ def _prompt_versions_root(quest_root: Path) -> Path:
266
+ return ensure_dir(quest_root / ".codex" / _PROMPT_VERSIONS_DIRNAME)
267
+
268
+ @staticmethod
269
+ def _prompt_versions_index_path(quest_root: Path) -> Path:
270
+ return SkillInstaller._prompt_versions_root(quest_root) / _PROMPT_VERSIONS_INDEX_FILENAME
271
+
272
+ @staticmethod
273
+ def _prompt_sync_state_path(prompts_root: Path) -> Path:
274
+ return prompts_root / _PROMPT_SYNC_STATE_FILENAME
275
+
276
+ def _read_prompt_sync_state(self, prompts_root: Path) -> dict[str, object]:
277
+ payload = read_json(self._prompt_sync_state_path(prompts_root), {})
278
+ return payload if isinstance(payload, dict) else {}
279
+
280
+ @staticmethod
281
+ def _sanitize_prompt_label(value: str) -> str:
282
+ normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value or "").strip()).strip("-")
283
+ return normalized or "unknown"
284
+
285
+ def _unique_prompt_backup_id(
286
+ self,
287
+ versions_root: Path,
288
+ *,
289
+ installed_version: str,
290
+ prompt_fingerprint: str,
291
+ ) -> str:
292
+ version_label = self._sanitize_prompt_label(installed_version)
293
+ timestamp_label = self._sanitize_prompt_label(
294
+ utc_now().replace(":", "").replace("+00:00", "Z")
295
+ )
296
+ fingerprint_label = (str(prompt_fingerprint or "").strip() or "unknown")[:12]
297
+ base = f"{version_label}__prompts-{fingerprint_label}__{timestamp_label}"
298
+ candidate = base
299
+ counter = 2
300
+ while (versions_root / candidate).exists():
301
+ candidate = f"{base}__{counter}"
302
+ counter += 1
303
+ return candidate
304
+
305
+ @staticmethod
306
+ def _prompt_tree_fingerprint(root: Path, *, exclude_state_file: bool) -> str:
307
+ if not root.exists():
308
+ return ""
309
+ files = [
310
+ path
311
+ for path in sorted(root.rglob("*"))
312
+ if path.is_file()
313
+ and not (exclude_state_file and path.name == _PROMPT_SYNC_STATE_FILENAME)
314
+ ]
315
+ if not files:
316
+ return ""
317
+ hasher = hashlib.sha256()
318
+ for path in files:
319
+ relative = path.relative_to(root).as_posix()
320
+ hasher.update(relative.encode("utf-8"))
321
+ hasher.update(b"\0")
322
+ hasher.update(hashlib.sha256(path.read_bytes()).hexdigest().encode("ascii"))
323
+ hasher.update(b"\0")
324
+ return hasher.hexdigest()
325
+
135
326
  def _sync_claude_projection(self, bundle, target_root: Path) -> Path:
136
327
  target = target_root / f"deepscientist-{bundle.skill_id}.md"
137
328
  if bundle.claude_md and bundle.claude_md.exists():