@researai/deepscientist 1.5.16 → 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 (82) hide show
  1. package/README.md +66 -23
  2. package/bin/ds.js +550 -19
  3. package/docs/en/00_QUICK_START.md +65 -5
  4. package/docs/en/01_SETTINGS_REFERENCE.md +1 -1
  5. package/docs/en/09_DOCTOR.md +14 -3
  6. package/docs/en/15_CODEX_PROVIDER_SETUP.md +12 -3
  7. package/docs/en/21_LOCAL_MODEL_BACKENDS_GUIDE.md +283 -0
  8. package/docs/en/91_DEVELOPMENT.md +237 -0
  9. package/docs/en/README.md +7 -3
  10. package/docs/zh/00_QUICK_START.md +54 -5
  11. package/docs/zh/01_SETTINGS_REFERENCE.md +1 -1
  12. package/docs/zh/09_DOCTOR.md +15 -4
  13. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +12 -3
  14. package/docs/zh/21_LOCAL_MODEL_BACKENDS_GUIDE.md +281 -0
  15. package/docs/zh/README.md +7 -3
  16. package/install.sh +46 -4
  17. package/package.json +2 -1
  18. package/pyproject.toml +1 -1
  19. package/src/deepscientist/__init__.py +1 -1
  20. package/src/deepscientist/bridges/connectors.py +8 -2
  21. package/src/deepscientist/codex_cli_compat.py +185 -72
  22. package/src/deepscientist/config/service.py +154 -6
  23. package/src/deepscientist/daemon/api/handlers.py +130 -25
  24. package/src/deepscientist/daemon/api/router.py +5 -0
  25. package/src/deepscientist/daemon/app.py +446 -22
  26. package/src/deepscientist/diagnostics/__init__.py +6 -0
  27. package/src/deepscientist/diagnostics/runner_failures.py +130 -0
  28. package/src/deepscientist/doctor.py +207 -3
  29. package/src/deepscientist/prompts/builder.py +22 -4
  30. package/src/deepscientist/quest/service.py +413 -13
  31. package/src/deepscientist/runners/codex.py +59 -14
  32. package/src/deepscientist/shared.py +19 -0
  33. package/src/prompts/contracts/shared_interaction.md +3 -2
  34. package/src/prompts/system.md +13 -0
  35. package/src/prompts/system_copilot.md +13 -0
  36. package/src/tui/package.json +1 -1
  37. package/src/ui/dist/assets/{AiManusChatView-COFACy7V.js → AiManusChatView-Bv-Z8YpU.js} +44 -44
  38. package/src/ui/dist/assets/{AnalysisPlugin-DnSm0GZn.js → AnalysisPlugin-BCKAfjba.js} +1 -1
  39. package/src/ui/dist/assets/{CliPlugin-CvwCmDQ5.js → CliPlugin-BCKcpc35.js} +4 -4
  40. package/src/ui/dist/assets/{CodeEditorPlugin-cOqSa0xq.js → CodeEditorPlugin-DbOfSJ8K.js} +1 -1
  41. package/src/ui/dist/assets/{CodeViewerPlugin-itb0tltR.js → CodeViewerPlugin-CbaFRrUU.js} +3 -3
  42. package/src/ui/dist/assets/{DocViewerPlugin-DqKkiCI6.js → DocViewerPlugin-DAjLVeQD.js} +3 -3
  43. package/src/ui/dist/assets/{GitCommitViewerPlugin-DVgNHBCS.js → GitCommitViewerPlugin-CIUqbUDO.js} +1 -1
  44. package/src/ui/dist/assets/{GitDiffViewerPlugin-DxL2ezFG.js → GitDiffViewerPlugin-CQACjoAA.js} +1 -1
  45. package/src/ui/dist/assets/{GitSnapshotViewer-B_RQm1YZ.js → GitSnapshotViewer-0r4nLPke.js} +1 -1
  46. package/src/ui/dist/assets/{ImageViewerPlugin-tHqlXY3n.js → ImageViewerPlugin-nBOmI2v_.js} +3 -3
  47. package/src/ui/dist/assets/{LabCopilotPanel-ClMbq5Yu.js → LabCopilotPanel-BHxOxF4z.js} +1 -1
  48. package/src/ui/dist/assets/{LabPlugin-L_SuE8ow.js → LabPlugin-BKoZGs95.js} +1 -1
  49. package/src/ui/dist/assets/{LatexPlugin-B495DTXC.js → LatexPlugin-ZwtV8pIp.js} +1 -1
  50. package/src/ui/dist/assets/{MarkdownViewerPlugin-DG28-61B.js → MarkdownViewerPlugin-DKqVfKyW.js} +3 -3
  51. package/src/ui/dist/assets/{MarketplacePlugin-BiOGT-Kj.js → MarketplacePlugin-BwxStZ9D.js} +1 -1
  52. package/src/ui/dist/assets/{NotebookEditor-C-4Kt1p9.js → NotebookEditor-BEQhaQbt.js} +1 -1
  53. package/src/ui/dist/assets/{NotebookEditor-CVsj8h_T.js → NotebookEditor-DB9N_T9q.js} +23 -23
  54. package/src/ui/dist/assets/{PdfLoader-CASDQmxJ.js → PdfLoader-eWBONbQP.js} +1 -1
  55. package/src/ui/dist/assets/{PdfMarkdownPlugin-BFhwoKsY.js → PdfMarkdownPlugin-D22YOZL3.js} +1 -1
  56. package/src/ui/dist/assets/{PdfViewerPlugin-DcOzU9vd.js → PdfViewerPlugin-c-RK9DLM.js} +3 -3
  57. package/src/ui/dist/assets/{SearchPlugin-CHj7M58O.js → SearchPlugin-CxF9ytAx.js} +1 -1
  58. package/src/ui/dist/assets/{TextViewerPlugin-CB4DYfWO.js → TextViewerPlugin-C5xqeeUH.js} +2 -2
  59. package/src/ui/dist/assets/{VNCViewer-CjlbyCB3.js → VNCViewer-BoLGLnHz.js} +1 -1
  60. package/src/ui/dist/assets/{bot-CFkZY-JP.js → bot-DREQOxzP.js} +1 -1
  61. package/src/ui/dist/assets/{chevron-up-Dq5ofbht.js → chevron-up-C9Qpx4DE.js} +1 -1
  62. package/src/ui/dist/assets/{code-DLC6G24T.js → code-WlFHE7z_.js} +1 -1
  63. package/src/ui/dist/assets/{file-content-Dv4LoZec.js → file-content-BZMz3RYp.js} +1 -1
  64. package/src/ui/dist/assets/{file-diff-panel-Denq-lC3.js → file-diff-panel-CQhw0jS2.js} +1 -1
  65. package/src/ui/dist/assets/{file-socket-Cu4Qln7Y.js → file-socket-CfQPKQKj.js} +1 -1
  66. package/src/ui/dist/assets/{git-commit-horizontal-BUh6G52n.js → git-commit-horizontal-DxZ8DCZh.js} +1 -1
  67. package/src/ui/dist/assets/{image-B9HUUddG.js → image-Bgl4VIyx.js} +1 -1
  68. package/src/ui/dist/assets/{index-Cgla8biy.css → index-BpV6lusQ.css} +1 -1
  69. package/src/ui/dist/assets/{index-Gbl53BNp.js → index-CBNVuWcP.js} +363 -363
  70. package/src/ui/dist/assets/{index-wQ7RIIRd.js → index-CwNu1aH4.js} +1 -1
  71. package/src/ui/dist/assets/{index-B2B1sg-M.js → index-DrUnlf6K.js} +1 -1
  72. package/src/ui/dist/assets/{index-DRyx7vAc.js → index-NW-h8VzN.js} +1 -1
  73. package/src/ui/dist/assets/{pdf-effect-queue-ZtnHFCAi.js → pdf-effect-queue-J8OnM0jE.js} +1 -1
  74. package/src/ui/dist/assets/{popover-DL6h35vr.js → popover-CLc0pPP8.js} +1 -1
  75. package/src/ui/dist/assets/{project-sync-CsX08Qno.js → project-sync-C9IdzdZW.js} +1 -1
  76. package/src/ui/dist/assets/{select-DvmXt1yY.js → select-Cs2PmzwL.js} +1 -1
  77. package/src/ui/dist/assets/{sigma-7jpXazui.js → sigma-ClKcHAXm.js} +1 -1
  78. package/src/ui/dist/assets/{trash-xA7kFt8i.js → trash-DwpbFr3w.js} +1 -1
  79. package/src/ui/dist/assets/{useCliAccess-DsMwDjOp.js → useCliAccess-NQ8m0Let.js} +1 -1
  80. package/src/ui/dist/assets/{wrap-text-CwMn-iqb.js → wrap-text-BC-Hltpd.js} +1 -1
  81. package/src/ui/dist/assets/{zoom-out-R-GWEhzS.js → zoom-out-E_gaeAxL.js} +1 -1
  82. package/src/ui/dist/index.html +2 -2
@@ -5,10 +5,10 @@ from collections import deque
5
5
  from contextlib import contextmanager
6
6
  from datetime import UTC, datetime, timedelta
7
7
  import hashlib
8
- import subprocess
9
8
  import json
10
9
  import mimetypes
11
10
  import re
11
+ import shutil
12
12
  import threading
13
13
  import time
14
14
  from pathlib import Path, PurePosixPath
@@ -27,7 +27,7 @@ from ..file_lock import advisory_file_lock
27
27
  from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas, list_commit_canvas
28
28
  from ..home import repo_root
29
29
  from ..registries import BaselineRegistry
30
- from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
30
+ from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, run_command_bytes, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
31
31
  from ..skills import SkillInstaller
32
32
  from ..web_search import extract_web_search_payload
33
33
  from .layout import (
@@ -322,6 +322,12 @@ class QuestService:
322
322
  def _quest_root(self, quest_id: str) -> Path:
323
323
  return self.quests_root / quest_id
324
324
 
325
+ def _require_initialized_quest_root(self, quest_id: str) -> Path:
326
+ quest_root = self._quest_root(quest_id)
327
+ if not quest_root.exists() or not self._quest_yaml_path(quest_root).exists():
328
+ raise FileNotFoundError(f"Unknown quest `{quest_id}`.")
329
+ return quest_root
330
+
325
331
  def _normalized_binding_sources(self, sources: list[Any] | None) -> list[str]:
326
332
  local_present = False
327
333
  external_source: str | None = None
@@ -2782,6 +2788,86 @@ class QuestService:
2782
2788
  self._initialize_runtime_files(quest_root)
2783
2789
  return self.snapshot(quest_id)
2784
2790
 
2791
+ def repair_orphaned_quest_scaffold(
2792
+ self,
2793
+ quest_id: str,
2794
+ *,
2795
+ title: str | None = None,
2796
+ goal: str | None = None,
2797
+ runner: str = "codex",
2798
+ ) -> dict[str, Any]:
2799
+ quest_root = self._quest_root(quest_id)
2800
+ if not quest_root.exists():
2801
+ raise FileNotFoundError(f"Unknown quest `{quest_id}`.")
2802
+ quest_yaml_path = self._quest_yaml_path(quest_root)
2803
+ if quest_yaml_path.exists():
2804
+ raise FileExistsError(f"Quest `{quest_id}` already has a scaffold.")
2805
+
2806
+ restored_goal = str(goal or f"Recovered quest {quest_id}").strip() or f"Recovered quest {quest_id}"
2807
+ restored_title = str(title or quest_id).strip() or quest_id
2808
+
2809
+ for relative in QUEST_DIRECTORIES:
2810
+ ensure_dir(quest_root / relative)
2811
+
2812
+ write_yaml(
2813
+ quest_yaml_path,
2814
+ initial_quest_yaml(
2815
+ quest_id,
2816
+ restored_goal,
2817
+ quest_root,
2818
+ runner,
2819
+ title=restored_title,
2820
+ ),
2821
+ )
2822
+ write_text(
2823
+ quest_root / "brief.md",
2824
+ "\n".join(
2825
+ [
2826
+ "# Quest Brief",
2827
+ "",
2828
+ "## Recovery Note",
2829
+ "",
2830
+ "This quest scaffold was recreated because the core quest files were missing.",
2831
+ "Existing runtime traces under `.ds/` were preserved.",
2832
+ "",
2833
+ "## Goal",
2834
+ "",
2835
+ restored_goal,
2836
+ "",
2837
+ ]
2838
+ ),
2839
+ )
2840
+ write_text(
2841
+ quest_root / "plan.md",
2842
+ "\n".join(
2843
+ [
2844
+ "# Plan",
2845
+ "",
2846
+ "- [ ] Inspect preserved runtime traces under `.ds/`",
2847
+ "- [ ] Re-establish the baseline context",
2848
+ "- [ ] Recreate any missing durable files or artifacts",
2849
+ "",
2850
+ ]
2851
+ ),
2852
+ )
2853
+ write_text(
2854
+ quest_root / "status.md",
2855
+ "# Status\n\nRecovered scaffold. Review preserved runtime state before continuing.\n",
2856
+ )
2857
+ write_text(
2858
+ quest_root / "SUMMARY.md",
2859
+ "# Summary\n\nRecovered quest scaffold. Original top-level quest files were missing.\n",
2860
+ )
2861
+ write_text(quest_root / ".gitignore", gitignore())
2862
+ self._write_active_user_requirements(
2863
+ quest_root,
2864
+ latest_requirement=None,
2865
+ )
2866
+ if not (quest_root / ".git").exists():
2867
+ init_repo(quest_root)
2868
+ self._initialize_runtime_files(quest_root)
2869
+ return self.snapshot(quest_id)
2870
+
2785
2871
  def list_quests(self) -> list[dict]:
2786
2872
  items: list[dict] = []
2787
2873
  if not self.quests_root.exists():
@@ -2879,7 +2965,7 @@ class QuestService:
2879
2965
  )
2880
2966
 
2881
2967
  def summary_compact(self, quest_id: str) -> dict[str, Any]:
2882
- quest_root = self._quest_root(quest_id)
2968
+ quest_root = self._require_initialized_quest_root(quest_id)
2883
2969
  cache_key = f"compact:{self._cache_key_for_path(quest_root)}"
2884
2970
  state = self._compact_summary_state(quest_root)
2885
2971
  with self._snapshot_cache_lock:
@@ -3254,7 +3340,7 @@ class QuestService:
3254
3340
  return self._snapshot(quest_id)
3255
3341
 
3256
3342
  def _snapshot(self, quest_id: str) -> dict:
3257
- quest_root = self._quest_root(quest_id)
3343
+ quest_root = self._require_initialized_quest_root(quest_id)
3258
3344
  cache_key = f"snapshot:{self._cache_key_for_path(quest_root)}"
3259
3345
  state = self._snapshot_state(quest_root)
3260
3346
  with self._snapshot_cache_lock:
@@ -3824,6 +3910,7 @@ class QuestService:
3824
3910
  title: str | None = None,
3825
3911
  active_anchor: str | None = None,
3826
3912
  default_runner: str | None = None,
3913
+ workspace_mode: str | None = None,
3827
3914
  ) -> dict:
3828
3915
  quest_root = self._quest_root(quest_id)
3829
3916
  quest_yaml_path = self._quest_yaml_path(quest_root)
@@ -3832,6 +3919,8 @@ class QuestService:
3832
3919
 
3833
3920
  quest_data = self.read_quest_yaml(quest_root)
3834
3921
  changed = False
3922
+ research_state_updates: dict[str, Any] = {}
3923
+ runtime_state_updates: dict[str, Any] = {}
3835
3924
 
3836
3925
  if title is not None:
3837
3926
  normalized_title = str(title).strip()
@@ -3869,9 +3958,35 @@ class QuestService:
3869
3958
  quest_data["default_runner"] = normalized_runner
3870
3959
  changed = True
3871
3960
 
3961
+ if workspace_mode is not None:
3962
+ normalized_workspace_mode = str(workspace_mode).strip().lower()
3963
+ if normalized_workspace_mode not in {"copilot", "autonomous"}:
3964
+ raise ValueError("Unsupported workspace mode. Allowed values: copilot, autonomous.")
3965
+ startup_contract = (
3966
+ dict(quest_data.get("startup_contract") or {})
3967
+ if isinstance(quest_data.get("startup_contract"), dict)
3968
+ else {}
3969
+ )
3970
+ if str(startup_contract.get("workspace_mode") or "").strip().lower() != normalized_workspace_mode:
3971
+ startup_contract["workspace_mode"] = normalized_workspace_mode
3972
+ quest_data["startup_contract"] = startup_contract
3973
+ changed = True
3974
+ if str(self.read_research_state(quest_root).get("workspace_mode") or "").strip().lower() != normalized_workspace_mode:
3975
+ research_state_updates["workspace_mode"] = normalized_workspace_mode
3976
+ runtime_state_updates["continuation_policy"] = (
3977
+ "wait_for_user_or_resume" if normalized_workspace_mode == "copilot" else "auto"
3978
+ )
3979
+ runtime_state_updates["continuation_reason"] = (
3980
+ "copilot_mode" if normalized_workspace_mode == "copilot" else "autonomous_mode"
3981
+ )
3982
+
3872
3983
  if changed:
3873
3984
  quest_data["updated_at"] = utc_now()
3874
3985
  write_yaml(quest_yaml_path, quest_data)
3986
+ if research_state_updates:
3987
+ self.update_research_state(quest_root, **research_state_updates)
3988
+ if runtime_state_updates:
3989
+ self.update_runtime_state(quest_root=quest_root, **runtime_state_updates)
3875
3990
 
3876
3991
  return self.snapshot(quest_id)
3877
3992
 
@@ -4308,7 +4423,7 @@ class QuestService:
4308
4423
  return payload
4309
4424
 
4310
4425
  def list_documents(self, quest_id: str) -> list[dict]:
4311
- quest_root = self._quest_root(quest_id)
4426
+ quest_root = self._require_initialized_quest_root(quest_id)
4312
4427
  workspace_root = self.active_workspace_root(quest_root)
4313
4428
  documents = []
4314
4429
  for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
@@ -4362,7 +4477,7 @@ class QuestService:
4362
4477
  if revision:
4363
4478
  return self._revision_explorer(quest_id, revision=revision, mode=mode or "ref")
4364
4479
 
4365
- quest_root = self._quest_root(quest_id)
4480
+ quest_root = self._require_initialized_quest_root(quest_id)
4366
4481
  workspace_root = self.active_workspace_root(quest_root)
4367
4482
  git_status = self._git_status_map(workspace_root)
4368
4483
 
@@ -4391,7 +4506,7 @@ class QuestService:
4391
4506
  def search_files(self, quest_id: str, term: str, limit: int = 50) -> dict[str, Any]:
4392
4507
  query = term.strip()
4393
4508
  normalized_query = query.casefold()
4394
- workspace_root = self.active_workspace_root(self._quest_root(quest_id))
4509
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
4395
4510
  resolved_limit = max(1, min(limit, 200))
4396
4511
  if not normalized_query:
4397
4512
  return {
@@ -4486,7 +4601,7 @@ class QuestService:
4486
4601
  }
4487
4602
 
4488
4603
  def open_document(self, quest_id: str, document_id: str) -> dict:
4489
- quest_root = self._quest_root(quest_id)
4604
+ quest_root = self._require_initialized_quest_root(quest_id)
4490
4605
  workspace_root = self.active_workspace_root(quest_root)
4491
4606
  if document_id.startswith("git::"):
4492
4607
  revision, relative = self._parse_git_document_id(document_id)
@@ -4551,7 +4666,7 @@ class QuestService:
4551
4666
  }
4552
4667
 
4553
4668
  def resolve_document(self, quest_id: str, document_id: str) -> tuple[Path, bool, str, str]:
4554
- quest_root = self._quest_root(quest_id)
4669
+ quest_root = self._require_initialized_quest_root(quest_id)
4555
4670
  workspace_root = self.active_workspace_root(quest_root)
4556
4671
  resolution_root = self._document_resolution_root(
4557
4672
  quest_root=quest_root,
@@ -4744,6 +4859,291 @@ class QuestService:
4744
4859
  "saved_at": utc_now(),
4745
4860
  }
4746
4861
 
4862
+ @staticmethod
4863
+ def _normalize_workspace_relative_path(
4864
+ relative: str | None,
4865
+ *,
4866
+ field_name: str,
4867
+ allow_root: bool = True,
4868
+ ) -> str | None:
4869
+ if relative is None:
4870
+ if allow_root:
4871
+ return None
4872
+ raise ValueError(f"`{field_name}` is required.")
4873
+ raw = str(relative).strip().replace("\\", "/")
4874
+ if not raw:
4875
+ if allow_root:
4876
+ return None
4877
+ raise ValueError(f"`{field_name}` is required.")
4878
+ normalized = raw.lstrip("/").rstrip("/")
4879
+ if normalized in {"", "."}:
4880
+ if allow_root:
4881
+ return None
4882
+ raise ValueError(f"`{field_name}` must point to a workspace entry.")
4883
+ return normalized
4884
+
4885
+ @staticmethod
4886
+ def _normalize_workspace_entry_name(name: str | None, *, field_name: str) -> str:
4887
+ raw = str(name or "").strip().replace("\\", "/")
4888
+ if not raw:
4889
+ raise ValueError(f"`{field_name}` is required.")
4890
+ if "/" in raw:
4891
+ raise ValueError(f"`{field_name}` must be a single path segment.")
4892
+ candidate = Path(raw).name
4893
+ if candidate != raw or candidate in {"", ".", ".."}:
4894
+ raise ValueError(f"`{field_name}` must be a valid file or folder name.")
4895
+ if candidate == ".git":
4896
+ raise ValueError("`.git` cannot be created or renamed from the explorer.")
4897
+ return candidate
4898
+
4899
+ @staticmethod
4900
+ def _normalize_workspace_path_list(paths: Any, *, field_name: str) -> list[str]:
4901
+ if not isinstance(paths, list) or not paths:
4902
+ raise ValueError(f"`{field_name}` must be a non-empty list.")
4903
+ normalized: list[str] = []
4904
+ seen: set[str] = set()
4905
+ for raw in paths:
4906
+ item = QuestService._normalize_workspace_relative_path(
4907
+ raw,
4908
+ field_name=field_name,
4909
+ allow_root=False,
4910
+ )
4911
+ if not item or item in seen:
4912
+ continue
4913
+ seen.add(item)
4914
+ normalized.append(item)
4915
+ if not normalized:
4916
+ raise ValueError(f"`{field_name}` must include at least one valid path.")
4917
+ return normalized
4918
+
4919
+ @staticmethod
4920
+ def _filter_nested_workspace_paths(paths: list[str]) -> list[str]:
4921
+ kept: list[str] = []
4922
+ for path in paths:
4923
+ if any(path == parent or path.startswith(f"{parent}/") for parent in kept):
4924
+ continue
4925
+ kept.append(path)
4926
+ return kept
4927
+
4928
+ def _workspace_entry_payload(self, workspace_root: Path, path: Path) -> dict:
4929
+ if path.is_dir():
4930
+ return self._directory_node(
4931
+ workspace_root,
4932
+ path=path,
4933
+ children=[],
4934
+ git_status={},
4935
+ changed_paths={},
4936
+ )
4937
+ payload = self._file_node(
4938
+ workspace_root,
4939
+ path=path,
4940
+ git_status={},
4941
+ changed_paths={},
4942
+ )
4943
+ if payload is None:
4944
+ raise FileNotFoundError(f"Unknown workspace entry `{path}`.")
4945
+ return payload
4946
+
4947
+ def create_workspace_folder(
4948
+ self,
4949
+ quest_id: str,
4950
+ *,
4951
+ name: str | None,
4952
+ parent_path: str | None = None,
4953
+ ) -> dict:
4954
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
4955
+ normalized_parent = self._normalize_workspace_relative_path(
4956
+ parent_path,
4957
+ field_name="parent_path",
4958
+ allow_root=True,
4959
+ )
4960
+ folder_name = self._normalize_workspace_entry_name(name, field_name="name")
4961
+ parent = resolve_within(workspace_root, normalized_parent) if normalized_parent else workspace_root
4962
+ if not parent.exists() or not parent.is_dir():
4963
+ raise FileNotFoundError(
4964
+ f"Unknown destination folder `{normalized_parent or '.'}`."
4965
+ )
4966
+ target = resolve_within(parent, folder_name)
4967
+ if target.exists():
4968
+ raise FileExistsError(
4969
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
4970
+ )
4971
+ ensure_dir(target)
4972
+ return {
4973
+ "ok": True,
4974
+ "quest_id": quest_id,
4975
+ "parent_path": normalized_parent,
4976
+ "item": self._workspace_entry_payload(workspace_root, target),
4977
+ "saved_at": utc_now(),
4978
+ }
4979
+
4980
+ def upload_workspace_file(
4981
+ self,
4982
+ quest_id: str,
4983
+ *,
4984
+ file_name: str | None,
4985
+ content: bytes,
4986
+ mime_type: str | None = None,
4987
+ parent_path: str | None = None,
4988
+ ) -> dict:
4989
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
4990
+ normalized_parent = self._normalize_workspace_relative_path(
4991
+ parent_path,
4992
+ field_name="parent_path",
4993
+ allow_root=True,
4994
+ )
4995
+ safe_name = self._normalize_workspace_entry_name(file_name, field_name="file_name")
4996
+ parent = resolve_within(workspace_root, normalized_parent) if normalized_parent else workspace_root
4997
+ if not parent.exists() or not parent.is_dir():
4998
+ raise FileNotFoundError(
4999
+ f"Unknown destination folder `{normalized_parent or '.'}`."
5000
+ )
5001
+ target = resolve_within(parent, safe_name)
5002
+ if target.exists():
5003
+ raise FileExistsError(
5004
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
5005
+ )
5006
+ ensure_dir(target.parent)
5007
+ target.write_bytes(content)
5008
+ payload = self._workspace_entry_payload(workspace_root, target)
5009
+ guessed_mime = mimetypes.guess_type(target.name)[0] or mime_type or "application/octet-stream"
5010
+ payload["mime_type"] = guessed_mime
5011
+ return {
5012
+ "ok": True,
5013
+ "quest_id": quest_id,
5014
+ "parent_path": normalized_parent,
5015
+ "item": payload,
5016
+ "saved_at": utc_now(),
5017
+ }
5018
+
5019
+ def rename_workspace_entry(
5020
+ self,
5021
+ quest_id: str,
5022
+ *,
5023
+ path: str | None,
5024
+ new_name: str | None,
5025
+ ) -> dict:
5026
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5027
+ normalized_path = self._normalize_workspace_relative_path(
5028
+ path,
5029
+ field_name="path",
5030
+ allow_root=False,
5031
+ )
5032
+ source = resolve_within(workspace_root, normalized_path)
5033
+ if not source.exists():
5034
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5035
+ safe_name = self._normalize_workspace_entry_name(new_name, field_name="new_name")
5036
+ target = resolve_within(source.parent, safe_name)
5037
+ if target.exists() and target != source:
5038
+ raise FileExistsError(
5039
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
5040
+ )
5041
+ if target != source:
5042
+ source.rename(target)
5043
+ payload = self._workspace_entry_payload(workspace_root, target)
5044
+ return {
5045
+ "ok": True,
5046
+ "quest_id": quest_id,
5047
+ "previous_path": normalized_path,
5048
+ "item": payload,
5049
+ "saved_at": utc_now(),
5050
+ }
5051
+
5052
+ def move_workspace_entries(
5053
+ self,
5054
+ quest_id: str,
5055
+ *,
5056
+ paths: Any,
5057
+ target_parent_path: str | None = None,
5058
+ ) -> dict:
5059
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5060
+ normalized_paths = self._filter_nested_workspace_paths(
5061
+ self._normalize_workspace_path_list(paths, field_name="paths")
5062
+ )
5063
+ normalized_target_parent = self._normalize_workspace_relative_path(
5064
+ target_parent_path,
5065
+ field_name="target_parent_path",
5066
+ allow_root=True,
5067
+ )
5068
+ target_parent = (
5069
+ resolve_within(workspace_root, normalized_target_parent)
5070
+ if normalized_target_parent
5071
+ else workspace_root
5072
+ )
5073
+ if not target_parent.exists() or not target_parent.is_dir():
5074
+ raise FileNotFoundError(
5075
+ f"Unknown destination folder `{normalized_target_parent or '.'}`."
5076
+ )
5077
+
5078
+ moves: list[tuple[str, Path, Path]] = []
5079
+ destination_keys: set[str] = set()
5080
+ target_parent_resolved = target_parent.resolve()
5081
+ for normalized_path in normalized_paths:
5082
+ source = resolve_within(workspace_root, normalized_path)
5083
+ if not source.exists():
5084
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5085
+ source_resolved = source.resolve()
5086
+ if source_resolved == target_parent_resolved or source_resolved in target_parent_resolved.parents:
5087
+ raise ValueError(
5088
+ f"`{normalized_path}` cannot be moved into itself or one of its descendants."
5089
+ )
5090
+ destination = resolve_within(target_parent, source.name)
5091
+ if destination.exists() and destination.resolve() != source_resolved:
5092
+ raise FileExistsError(
5093
+ f"`{destination.relative_to(workspace_root).as_posix()}` already exists."
5094
+ )
5095
+ destination_key = str(destination.resolve())
5096
+ if destination_key in destination_keys and destination != source:
5097
+ raise FileExistsError(
5098
+ f"`{destination.relative_to(workspace_root).as_posix()}` would conflict with another moved entry."
5099
+ )
5100
+ destination_keys.add(destination_key)
5101
+ moves.append((normalized_path, source, destination))
5102
+
5103
+ items: list[dict] = []
5104
+ for _normalized_path, source, destination in moves:
5105
+ if destination != source:
5106
+ source.rename(destination)
5107
+ items.append(self._workspace_entry_payload(workspace_root, destination))
5108
+ return {
5109
+ "ok": True,
5110
+ "quest_id": quest_id,
5111
+ "target_parent_path": normalized_target_parent,
5112
+ "items": items,
5113
+ "saved_at": utc_now(),
5114
+ }
5115
+
5116
+ def delete_workspace_entries(
5117
+ self,
5118
+ quest_id: str,
5119
+ *,
5120
+ paths: Any,
5121
+ ) -> dict:
5122
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5123
+ normalized_paths = self._filter_nested_workspace_paths(
5124
+ self._normalize_workspace_path_list(paths, field_name="paths")
5125
+ )
5126
+ sources: list[Path] = []
5127
+ items: list[dict] = []
5128
+ for normalized_path in normalized_paths:
5129
+ source = resolve_within(workspace_root, normalized_path)
5130
+ if not source.exists():
5131
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5132
+ sources.append(source)
5133
+ items.append(self._workspace_entry_payload(workspace_root, source))
5134
+
5135
+ for source in sorted(sources, key=lambda item: len(item.parts), reverse=True):
5136
+ if source.is_dir():
5137
+ shutil.rmtree(source)
5138
+ else:
5139
+ source.unlink()
5140
+ return {
5141
+ "ok": True,
5142
+ "quest_id": quest_id,
5143
+ "items": items,
5144
+ "saved_at": utc_now(),
5145
+ }
5146
+
4747
5147
  def _revision_explorer(self, quest_id: str, *, revision: str, mode: str) -> dict:
4748
5148
  quest_root = self._quest_root(quest_id)
4749
5149
  if not self._git_revision_exists(quest_root, revision):
@@ -4936,6 +5336,8 @@ class QuestService:
4936
5336
  }
4937
5337
 
4938
5338
  def _initialize_runtime_files(self, quest_root: Path) -> None:
5339
+ if not self._quest_yaml_path(quest_root).exists():
5340
+ raise FileNotFoundError(f"Unknown quest `{quest_root.name}`.")
4939
5341
  queue_path = self._message_queue_path(quest_root)
4940
5342
  if not queue_path.exists():
4941
5343
  write_json(queue_path, self._default_message_queue())
@@ -5835,12 +6237,10 @@ class QuestService:
5835
6237
 
5836
6238
  @staticmethod
5837
6239
  def _read_git_bytes(quest_root: Path, revision: str, relative: str) -> bytes:
5838
- result = subprocess.run(
6240
+ result = run_command_bytes(
5839
6241
  ["git", "show", f"{revision}:{relative}"],
5840
- cwd=str(quest_root),
6242
+ cwd=quest_root,
5841
6243
  check=False,
5842
- text=False,
5843
- capture_output=True,
5844
6244
  )
5845
6245
  if result.returncode != 0:
5846
6246
  raise FileNotFoundError(f"File `{relative}` does not exist at `{revision}`.")
@@ -11,12 +11,14 @@ from typing import Any
11
11
 
12
12
  from ..artifact import ArtifactService
13
13
  from ..codex_cli_compat import (
14
+ active_provider_metadata_from_home,
14
15
  materialize_codex_runtime_home,
15
16
  normalize_codex_reasoning_effort,
16
17
  provider_profile_metadata_from_home,
17
18
  )
18
19
  from ..config import ConfigManager
19
20
  from ..gitops import export_git_graph
21
+ from ..process_control import process_session_popen_kwargs
20
22
  from ..prompts import PromptBuilder
21
23
  from ..runtime_logs import JsonlLogger
22
24
  from ..shared import append_jsonl, ensure_dir, generate_id, read_yaml, resolve_runner_binary, utc_now, write_json, write_text
@@ -76,6 +78,7 @@ _PROVIDER_ENV_CONFLICT_KEYS = (
76
78
  "OPENAI_API_KEY",
77
79
  "OPENAI_BASE_URL",
78
80
  )
81
+ _CHAT_WIRE_TOOL_CALL_GUARD_MARKER = "## Codex Chat-Wire Tool Call Compatibility"
79
82
 
80
83
 
81
84
  def _compact_text(value: object, *, limit: int = 1200) -> str:
@@ -731,6 +734,18 @@ class CodexRunner:
731
734
  self._process_lock = threading.Lock()
732
735
  self._active_processes: dict[str, subprocess.Popen[str]] = {}
733
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
+
734
749
  def run(self, request: RunRequest) -> RunResult:
735
750
  workspace_root = request.worktree_root or request.quest_root
736
751
  run_root = ensure_dir(request.quest_root / ".ds" / "runs" / request.run_id)
@@ -746,6 +761,7 @@ class CodexRunner:
746
761
  turn_mode=request.turn_mode,
747
762
  retry_context=request.retry_context,
748
763
  )
764
+ prompt = self._apply_chat_wire_tool_call_guard(prompt, runner_config=runner_config)
749
765
  write_text(run_root / "prompt.md", prompt)
750
766
 
751
767
  codex_home = self._prepare_project_codex_home(
@@ -796,18 +812,7 @@ class CodexRunner:
796
812
  env["DS_CONVERSATION_ID"] = f"quest:{request.quest_id}"
797
813
  env["DS_AGENT_ROLE"] = request.skill_id
798
814
  env["DS_TEAM_MODE"] = "single"
799
- popen_kwargs: dict[str, Any] = {
800
- "cwd": str(workspace_root),
801
- "env": env,
802
- "stdin": subprocess.PIPE,
803
- "stdout": subprocess.PIPE,
804
- "stderr": subprocess.PIPE,
805
- "text": True,
806
- }
807
- if os.name == "nt" and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
808
- popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP")
809
- else:
810
- popen_kwargs["start_new_session"] = True
815
+ popen_kwargs = self._subprocess_popen_kwargs(workspace_root=workspace_root, env=env)
811
816
  process = subprocess.Popen(command, **popen_kwargs)
812
817
  with self._process_lock:
813
818
  self._active_processes[request.quest_id] = process
@@ -1065,6 +1070,46 @@ class CodexRunner:
1065
1070
  command.append("-")
1066
1071
  return command
1067
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
+
1068
1113
  def _prepare_project_codex_home(
1069
1114
  self,
1070
1115
  workspace_root: Path,
@@ -1202,9 +1247,9 @@ class CodexRunner:
1202
1247
  resolved_runner_config = runner_config if isinstance(runner_config, dict) else {}
1203
1248
  profile = str(resolved_runner_config.get("profile") or "").strip()
1204
1249
  config_home = str(resolved_runner_config.get("config_dir") or env.get("CODEX_HOME") or "").strip()
1205
- if not profile or not config_home:
1250
+ if not config_home:
1206
1251
  return env
1207
- metadata = provider_profile_metadata_from_home(config_home, profile=profile)
1252
+ metadata = active_provider_metadata_from_home(config_home, profile=profile or None)
1208
1253
  requires_openai_auth = metadata.get("requires_openai_auth")
1209
1254
  if requires_openai_auth is not False:
1210
1255
  return env