@researai/deepscientist 1.5.6 → 1.5.8

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 (158) hide show
  1. package/README.md +32 -0
  2. package/bin/ds.js +274 -18
  3. package/docs/en/07_MEMORY_AND_MCP.md +40 -3
  4. package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
  5. package/docs/zh/07_MEMORY_AND_MCP.md +40 -3
  6. package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
  7. package/install.sh +34 -0
  8. package/package.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/src/deepscientist/__init__.py +1 -1
  11. package/src/deepscientist/acp/envelope.py +1 -0
  12. package/src/deepscientist/artifact/metrics.py +813 -80
  13. package/src/deepscientist/artifact/schemas.py +1 -0
  14. package/src/deepscientist/artifact/service.py +1101 -99
  15. package/src/deepscientist/bash_exec/monitor.py +1 -1
  16. package/src/deepscientist/bash_exec/service.py +17 -9
  17. package/src/deepscientist/channels/qq.py +17 -0
  18. package/src/deepscientist/channels/relay.py +16 -0
  19. package/src/deepscientist/cli.py +1 -1
  20. package/src/deepscientist/config/models.py +12 -6
  21. package/src/deepscientist/config/service.py +75 -2
  22. package/src/deepscientist/connector_profiles.py +34 -6
  23. package/src/deepscientist/daemon/api/handlers.py +290 -15
  24. package/src/deepscientist/daemon/api/router.py +2 -0
  25. package/src/deepscientist/daemon/app.py +521 -23
  26. package/src/deepscientist/gitops/diff.py +6 -10
  27. package/src/deepscientist/mcp/server.py +188 -39
  28. package/src/deepscientist/prompts/builder.py +71 -22
  29. package/src/deepscientist/qq_profiles.py +19 -9
  30. package/src/deepscientist/quest/layout.py +1 -0
  31. package/src/deepscientist/quest/service.py +83 -34
  32. package/src/deepscientist/quest/stage_views.py +74 -29
  33. package/src/deepscientist/runners/codex.py +32 -14
  34. package/src/deepscientist/runners/runtime_overrides.py +46 -0
  35. package/src/deepscientist/skills/installer.py +7 -0
  36. package/src/prompts/connectors/qq.md +1 -1
  37. package/src/prompts/contracts/shared_interaction.md +14 -0
  38. package/src/prompts/system.md +134 -30
  39. package/src/skills/analysis-campaign/SKILL.md +34 -8
  40. package/src/skills/analysis-campaign/references/campaign-checklist-template.md +41 -0
  41. package/src/skills/analysis-campaign/references/campaign-plan-template.md +68 -0
  42. package/src/skills/baseline/SKILL.md +145 -32
  43. package/src/skills/baseline/references/baseline-checklist-template.md +57 -0
  44. package/src/skills/baseline/references/baseline-plan-template.md +105 -0
  45. package/src/skills/decision/SKILL.md +12 -8
  46. package/src/skills/experiment/SKILL.md +51 -9
  47. package/src/skills/experiment/references/main-experiment-checklist-template.md +52 -0
  48. package/src/skills/experiment/references/main-experiment-plan-template.md +79 -0
  49. package/src/skills/figure-polish/SKILL.md +1 -0
  50. package/src/skills/finalize/SKILL.md +3 -8
  51. package/src/skills/idea/SKILL.md +2 -8
  52. package/src/skills/intake-audit/SKILL.md +2 -8
  53. package/src/skills/rebuttal/SKILL.md +2 -8
  54. package/src/skills/review/SKILL.md +2 -8
  55. package/src/skills/scout/SKILL.md +2 -8
  56. package/src/skills/write/SKILL.md +52 -16
  57. package/src/skills/write/templates/DEEPSCIENTIST_NOTES.md +21 -0
  58. package/src/skills/write/templates/README.md +408 -0
  59. package/src/skills/write/templates/UPSTREAM_LICENSE.txt +21 -0
  60. package/src/skills/write/templates/aaai2026/README.md +534 -0
  61. package/src/skills/write/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  62. package/src/skills/write/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  63. package/src/skills/write/templates/aaai2026/aaai2026.bib +111 -0
  64. package/src/skills/write/templates/aaai2026/aaai2026.bst +1493 -0
  65. package/src/skills/write/templates/aaai2026/aaai2026.sty +315 -0
  66. package/src/skills/write/templates/acl/README.md +50 -0
  67. package/src/skills/write/templates/acl/acl.sty +312 -0
  68. package/src/skills/write/templates/acl/acl_latex.tex +377 -0
  69. package/src/skills/write/templates/acl/acl_lualatex.tex +101 -0
  70. package/src/skills/write/templates/acl/acl_natbib.bst +1940 -0
  71. package/src/skills/write/templates/acl/anthology.bib.txt +26 -0
  72. package/src/skills/write/templates/acl/custom.bib +70 -0
  73. package/src/skills/write/templates/acl/formatting.md +326 -0
  74. package/src/skills/write/templates/asplos2027/main.tex +459 -0
  75. package/src/skills/write/templates/asplos2027/references.bib +135 -0
  76. package/src/skills/write/templates/colm2025/README.md +3 -0
  77. package/src/skills/write/templates/colm2025/colm2025_conference.bib +11 -0
  78. package/src/skills/write/templates/colm2025/colm2025_conference.bst +1440 -0
  79. package/src/skills/write/templates/colm2025/colm2025_conference.sty +218 -0
  80. package/src/skills/write/templates/colm2025/colm2025_conference.tex +305 -0
  81. package/src/skills/write/templates/colm2025/fancyhdr.sty +485 -0
  82. package/src/skills/write/templates/colm2025/math_commands.tex +508 -0
  83. package/src/skills/write/templates/colm2025/natbib.sty +1246 -0
  84. package/src/skills/write/templates/iclr2026/fancyhdr.sty +485 -0
  85. package/src/skills/write/templates/iclr2026/iclr2026_conference.bib +24 -0
  86. package/src/skills/write/templates/iclr2026/iclr2026_conference.bst +1440 -0
  87. package/src/skills/write/templates/iclr2026/iclr2026_conference.sty +246 -0
  88. package/src/skills/write/templates/iclr2026/iclr2026_conference.tex +414 -0
  89. package/src/skills/write/templates/iclr2026/math_commands.tex +508 -0
  90. package/src/skills/write/templates/iclr2026/natbib.sty +1246 -0
  91. package/src/skills/write/templates/icml2026/algorithm.sty +79 -0
  92. package/src/skills/write/templates/icml2026/algorithmic.sty +201 -0
  93. package/src/skills/write/templates/icml2026/example_paper.bib +75 -0
  94. package/src/skills/write/templates/icml2026/example_paper.tex +662 -0
  95. package/src/skills/write/templates/icml2026/fancyhdr.sty +864 -0
  96. package/src/skills/write/templates/icml2026/icml2026.bst +1443 -0
  97. package/src/skills/write/templates/icml2026/icml2026.sty +767 -0
  98. package/src/skills/write/templates/neurips2025/Makefile +36 -0
  99. package/src/skills/write/templates/neurips2025/extra_pkgs.tex +53 -0
  100. package/src/skills/write/templates/neurips2025/main.tex +38 -0
  101. package/src/skills/write/templates/neurips2025/neurips.sty +382 -0
  102. package/src/skills/write/templates/nsdi2027/main.tex +426 -0
  103. package/src/skills/write/templates/nsdi2027/references.bib +151 -0
  104. package/src/skills/write/templates/nsdi2027/usenix-2020-09.sty +83 -0
  105. package/src/skills/write/templates/osdi2026/main.tex +429 -0
  106. package/src/skills/write/templates/osdi2026/references.bib +150 -0
  107. package/src/skills/write/templates/osdi2026/usenix-2020-09.sty +83 -0
  108. package/src/skills/write/templates/sosp2026/main.tex +532 -0
  109. package/src/skills/write/templates/sosp2026/references.bib +148 -0
  110. package/src/tui/package.json +1 -1
  111. package/src/ui/dist/assets/{AiManusChatView-BGLArZRn.js → AiManusChatView-m2FNtwbn.js} +110 -14
  112. package/src/ui/dist/assets/{AnalysisPlugin-BgDGSigG.js → AnalysisPlugin-BMTF8EGL.js} +1 -1
  113. package/src/ui/dist/assets/{AutoFigurePlugin-B65HD7L4.js → AutoFigurePlugin-DxPdMUNb.js} +5 -5
  114. package/src/ui/dist/assets/{CliPlugin-CUqgsFHC.js → CliPlugin-BEOWgxCI.js} +9 -9
  115. package/src/ui/dist/assets/{CodeEditorPlugin-CF5EdvaS.js → CodeEditorPlugin-BCXvjqmb.js} +8 -8
  116. package/src/ui/dist/assets/{CodeViewerPlugin-DEeU063D.js → CodeViewerPlugin-DaJcy3nD.js} +5 -5
  117. package/src/ui/dist/assets/{DocViewerPlugin-Df-FuDlZ.js → DocViewerPlugin-ByfeIq4K.js} +3 -3
  118. package/src/ui/dist/assets/{GitDiffViewerPlugin-RAnNaRxM.js → GitDiffViewerPlugin-Cksf3VZ-.js} +830 -86
  119. package/src/ui/dist/assets/{ImageViewerPlugin-DXJ0ZJGg.js → ImageViewerPlugin-CFz-OsTS.js} +5 -5
  120. package/src/ui/dist/assets/{LabCopilotPanel-BlO-sKsj.js → LabCopilotPanel-CJ1cJzoX.js} +10 -10
  121. package/src/ui/dist/assets/{LabPlugin-BajPZW5v.js → LabPlugin-BF3dVJwa.js} +1 -1
  122. package/src/ui/dist/assets/{LatexPlugin-F1OEol8D.js → LatexPlugin-DDkwZ6Sj.js} +7 -7
  123. package/src/ui/dist/assets/{MarkdownViewerPlugin-MhUupqwT.js → MarkdownViewerPlugin-HAuvurcT.js} +4 -4
  124. package/src/ui/dist/assets/{MarketplacePlugin-DxhIEsv0.js → MarketplacePlugin-BtoTYy2C.js} +3 -3
  125. package/src/ui/dist/assets/{index-B-2scqCJ.js → NotebookEditor-CSJYx7b-.js} +12 -155
  126. package/src/ui/dist/assets/{NotebookEditor-q7TkhewC.js → NotebookEditor-DQgRezm_.js} +1 -1
  127. package/src/ui/dist/assets/{PdfLoader-B8ZOTKFc.js → PdfLoader-DPa_-fv6.js} +1 -1
  128. package/src/ui/dist/assets/{PdfMarkdownPlugin-xFPvzvWh.js → PdfMarkdownPlugin-BZpXOEjm.js} +3 -3
  129. package/src/ui/dist/assets/{PdfViewerPlugin-EjEcsIB8.js → PdfViewerPlugin-BT8a6wGR.js} +10 -10
  130. package/src/ui/dist/assets/{SearchPlugin-ixY-1lgW.js → SearchPlugin-D_blveZi.js} +1 -1
  131. package/src/ui/dist/assets/{Stepper-gYFK2Pgz.js → Stepper-DH2k75Vo.js} +1 -1
  132. package/src/ui/dist/assets/{TextViewerPlugin-Cym6pv_n.js → TextViewerPlugin-Btx0M3hX.js} +4 -4
  133. package/src/ui/dist/assets/{VNCViewer-BPmIHcmK.js → VNCViewer-DImJO4rO.js} +9 -9
  134. package/src/ui/dist/assets/{bibtex-Btv6Wi7f.js → bibtex-B-Hqu0Sg.js} +1 -1
  135. package/src/ui/dist/assets/{code-BlG7g85c.js → code-BUfXGJSl.js} +1 -1
  136. package/src/ui/dist/assets/{file-content-DBT5OfTZ.js → file-content-VqamwI3X.js} +1 -1
  137. package/src/ui/dist/assets/{file-diff-panel-BWXYzqHk.js → file-diff-panel-C_wOoS7a.js} +1 -1
  138. package/src/ui/dist/assets/{file-socket-wDlx6byM.js → file-socket-D2bTuMVP.js} +1 -1
  139. package/src/ui/dist/assets/{file-utils-Ba3nJmH0.js → file-utils--zJCPN1i.js} +1 -1
  140. package/src/ui/dist/assets/{image-BwtCyguk.js → image-BZkGJ4mM.js} +1 -1
  141. package/src/ui/dist/assets/{index-CfRpE209.js → index-CxkvSeKw.js} +2 -2
  142. package/src/ui/dist/assets/{index-DcqvKzeJ.js → index-D9QIGcmc.js} +1 -1
  143. package/src/ui/dist/assets/{index-DpMZw8aM.css → index-DXZ1daiJ.css} +163 -34
  144. package/src/ui/dist/assets/index-DdRW6RMJ.js +159 -0
  145. package/src/ui/dist/assets/{index-Bz5AaWL7.js → index-DjggJovS.js} +2948 -1565
  146. package/src/ui/dist/assets/{message-square-BnlyWVH0.js → message-square-FUIPIhU2.js} +1 -1
  147. package/src/ui/dist/assets/{monaco-CXe0pAVe.js → monaco-DHMc7kKM.js} +1 -1
  148. package/src/ui/dist/assets/{popover-BCHmVhHj.js → popover-B85oCgCS.js} +1 -1
  149. package/src/ui/dist/assets/{project-sync-Brk6kaOD.js → project-sync-DOMCcPac.js} +1 -1
  150. package/src/ui/dist/assets/{sigma-D72eSUep.js → sigma-BO2rQrl3.js} +1 -1
  151. package/src/ui/dist/assets/{tooltip-BMWd0dqX.js → tooltip-B1OspAkx.js} +1 -1
  152. package/src/ui/dist/assets/{trash-BIt_eWIS.js → trash-BsVEH_dV.js} +1 -1
  153. package/src/ui/dist/assets/{useCliAccess-N1hkTRrR.js → useCliAccess-b8L6JuZm.js} +1 -1
  154. package/src/ui/dist/assets/{useFileDiffOverlay-DPRPv6rv.js → useFileDiffOverlay-BY7uA9hV.js} +1 -1
  155. package/src/ui/dist/assets/{wrap-text-E5-UheyP.js → wrap-text-BwyVuUIK.js} +1 -1
  156. package/src/ui/dist/assets/{zoom-out-D4TR-ZZ_.js → zoom-out-RDpLugQP.js} +1 -1
  157. package/src/ui/dist/index.html +5 -2
  158. /package/src/ui/dist/assets/{index-CccQYZjX.css → NotebookEditor-CccQYZjX.css} +0 -0
@@ -29,16 +29,24 @@ from ..channels.slack_socket import SlackSocketModeService
29
29
  from ..channels.telegram_polling import TelegramPollingService
30
30
  from ..channels.whatsapp_local_session import WhatsAppLocalSessionService
31
31
  from ..cloud import CloudLinkService
32
- from ..connector_profiles import PROFILEABLE_CONNECTOR_NAMES, connector_profile_label, list_connector_profiles, merge_connector_profile_config
32
+ from ..connector_profiles import (
33
+ CONNECTOR_PROFILE_SPECS,
34
+ PROFILEABLE_CONNECTOR_NAMES,
35
+ connector_profile_label,
36
+ list_connector_profiles,
37
+ merge_connector_profile_config,
38
+ normalize_connector_config,
39
+ )
33
40
  from ..connector_runtime import conversation_identity_key, format_conversation_id, normalize_conversation_id, parse_conversation_id
34
41
  from ..config import ConfigManager
42
+ from ..config.models import SYSTEM_CONNECTOR_NAMES
35
43
  from ..home import repo_root
36
44
  from ..memory import MemoryService
37
45
  from ..network import urlopen_with_proxy as urlopen
38
46
  from ..latex_runtime import QuestLatexService
39
47
  from ..prompts import PromptBuilder
40
48
  from ..prompts.builder import STANDARD_SKILLS
41
- from ..qq_profiles import list_qq_profiles, merge_qq_profile_config
49
+ from ..qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
42
50
  from ..quest import QuestService
43
51
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
44
52
  from ..runtime_logs import JsonlLogger
@@ -73,7 +81,8 @@ class DaemonApp:
73
81
  self.daemon_managed_by = str(os.environ.get("DS_DAEMON_MANAGED_BY") or "manual").strip() or "manual"
74
82
  self.repo_root = repo_root()
75
83
  self.config_manager = ConfigManager(home)
76
- self.runners_config = self.config_manager.load_named("runners")
84
+ self.runtime_config = self.config_manager.load_runtime_config()
85
+ self.runners_config = self.config_manager.load_runners_config()
77
86
  self.connectors_config = self.config_manager.load_named_normalized("connectors")
78
87
  self.skill_installer = SkillInstaller(self.repo_root, home)
79
88
  self.quest_service = QuestService(home, skill_installer=self.skill_installer)
@@ -83,7 +92,7 @@ class DaemonApp:
83
92
  self.bash_exec_service = BashExecService(home)
84
93
  self.team_service = SingleTeamService(home)
85
94
  self.cloud_service = CloudLinkService(home)
86
- config = self.config_manager.load_named("config")
95
+ config = self.runtime_config
87
96
  skill_config = config.get("skills") if isinstance(config.get("skills"), dict) else {}
88
97
  self.skill_sync_summary = self.skill_installer.ensure_release_sync(
89
98
  installed_version=__version__,
@@ -138,9 +147,13 @@ class DaemonApp:
138
147
 
139
148
  def list_connector_statuses(self) -> list[dict[str, object]]:
140
149
  title_by_quest = self._quest_titles_by_id()
141
- items = [self._augment_connector_status(channel.status(), title_by_quest=title_by_quest) for channel in self.channels.values()]
150
+ items = [
151
+ self._augment_connector_status(channel.status(), title_by_quest=title_by_quest)
152
+ for name, channel in self.channels.items()
153
+ if name == "local" or self._is_connector_system_enabled(name)
154
+ ]
142
155
  lingzhu_config = self.connectors_config.get("lingzhu")
143
- if isinstance(lingzhu_config, dict):
156
+ if isinstance(lingzhu_config, dict) and self._is_connector_system_enabled("lingzhu"):
144
157
  items.append(self._augment_connector_status(self.config_manager.lingzhu_snapshot(lingzhu_config), title_by_quest=title_by_quest))
145
158
  return items
146
159
 
@@ -339,6 +352,8 @@ class DaemonApp:
339
352
  continue
340
353
  if connector_name not in self.channels:
341
354
  continue
355
+ if not self._is_connector_system_enabled(connector_name):
356
+ continue
342
357
  if parsed is not None and str(parsed.get("connector") or "").strip().lower() != connector_name:
343
358
  continue
344
359
  normalized_by_connector[connector_name] = {
@@ -452,19 +467,203 @@ class DaemonApp:
452
467
  b"Terminal attach token is invalid or expired.",
453
468
  )
454
469
  if runtime is None:
455
- return Response(
456
- 409,
457
- "Conflict",
458
- Headers({"Content-Type": "text/plain; charset=utf-8"}),
459
- b"Terminal runtime is no longer active.",
460
- )
470
+ try:
471
+ session = self.bash_exec_service.get_session(attach_token.quest_root, attach_token.bash_id)
472
+ except FileNotFoundError:
473
+ return Response(
474
+ 404,
475
+ "Not Found",
476
+ Headers({"Content-Type": "text/plain; charset=utf-8"}),
477
+ b"Terminal session is no longer available.",
478
+ )
479
+ status = str(session.get("status") or "").strip().lower()
480
+ kind = str(session.get("kind") or "").strip().lower()
481
+ if status in {"completed", "failed", "terminated"} or kind not in {"exec"}:
482
+ return Response(
483
+ 409,
484
+ "Conflict",
485
+ Headers({"Content-Type": "text/plain; charset=utf-8"}),
486
+ b"Terminal runtime is no longer active.",
487
+ )
461
488
  setattr(connection, "_ds_terminal_attach_token", token)
462
489
  return None
463
490
 
491
+ def _handle_logged_terminal_attach_connection(
492
+ self,
493
+ connection: ServerConnection,
494
+ *,
495
+ attach_token,
496
+ send_lock: threading.Lock,
497
+ ) -> None:
498
+ session = self.bash_exec_service.get_session(attach_token.quest_root, attach_token.bash_id)
499
+ stop_event = threading.Event()
500
+
501
+ with send_lock:
502
+ connection.send(
503
+ json.dumps(
504
+ {
505
+ "type": "ready",
506
+ "bash_id": attach_token.bash_id,
507
+ "status": session.get("status"),
508
+ "cwd": session.get("cwd"),
509
+ "workdir": session.get("workdir"),
510
+ },
511
+ ensure_ascii=False,
512
+ )
513
+ )
514
+
515
+ def _encode_exec_log_entry(entry: dict[str, Any]) -> bytes:
516
+ line = str(entry.get("line") or "")
517
+ stream = str(entry.get("stream") or "").strip().lower()
518
+ if line.startswith("__DS_PROGRESS__") or line.startswith("__DS_BASH_STATUS__"):
519
+ return b""
520
+ if line.startswith("__DS_BASH_CR__"):
521
+ payload = line[len("__DS_BASH_CR__") :].lstrip()
522
+ return f"\r\x1b[K{payload}".encode("utf-8", errors="replace")
523
+ if stream in {"prompt", "partial"}:
524
+ return line.encode("utf-8", errors="replace")
525
+ if stream == "system" and not line.strip():
526
+ return b""
527
+ return f"{line}\n".encode("utf-8", errors="replace")
528
+
529
+ def _relay_output() -> None:
530
+ last_seq = 0
531
+ try:
532
+ entries, meta = self.bash_exec_service.read_log_entries(
533
+ attach_token.quest_root,
534
+ attach_token.bash_id,
535
+ limit=2000,
536
+ order="asc",
537
+ )
538
+ if isinstance(meta.get("latest_seq"), int):
539
+ last_seq = int(meta["latest_seq"])
540
+ elif entries:
541
+ last_seq = max(int(item.get("seq") or 0) for item in entries)
542
+ for entry in entries:
543
+ payload = _encode_exec_log_entry(entry)
544
+ if not payload:
545
+ continue
546
+ with send_lock:
547
+ connection.send(payload)
548
+
549
+ while not stop_event.is_set():
550
+ entries, _meta = self.bash_exec_service.read_log_entries(
551
+ attach_token.quest_root,
552
+ attach_token.bash_id,
553
+ limit=400,
554
+ after_seq=last_seq,
555
+ order="asc",
556
+ )
557
+ for entry in entries:
558
+ last_seq = max(last_seq, int(entry.get("seq") or 0))
559
+ payload = _encode_exec_log_entry(entry)
560
+ if not payload:
561
+ continue
562
+ with send_lock:
563
+ connection.send(payload)
564
+ current = self.bash_exec_service.get_session(
565
+ attach_token.quest_root,
566
+ attach_token.bash_id,
567
+ )
568
+ status = str(current.get("status") or "").strip().lower()
569
+ if status in {"completed", "failed", "terminated"}:
570
+ with send_lock:
571
+ connection.send(
572
+ json.dumps(
573
+ {
574
+ "type": "exit",
575
+ "bash_id": attach_token.bash_id,
576
+ "status": current.get("status"),
577
+ "exit_code": current.get("exit_code"),
578
+ "stop_reason": current.get("stop_reason"),
579
+ "finished_at": current.get("finished_at"),
580
+ },
581
+ ensure_ascii=False,
582
+ )
583
+ )
584
+ return
585
+ time.sleep(TERMINAL_STREAM_IDLE_SLEEP_SECONDS)
586
+ except Exception as exc:
587
+ if stop_event.is_set():
588
+ return
589
+ try:
590
+ with send_lock:
591
+ connection.send(
592
+ json.dumps({"type": "error", "message": str(exc)}, ensure_ascii=False)
593
+ )
594
+ except Exception:
595
+ pass
596
+
597
+ relay_thread = threading.Thread(
598
+ target=_relay_output,
599
+ daemon=True,
600
+ name=f"exec-attach-{attach_token.bash_id}",
601
+ )
602
+ relay_thread.start()
603
+ try:
604
+ while True:
605
+ try:
606
+ message = connection.recv()
607
+ except ConnectionClosed:
608
+ break
609
+ if message is None:
610
+ break
611
+ if isinstance(message, bytes):
612
+ self.bash_exec_service.append_terminal_input(
613
+ attach_token.quest_root,
614
+ attach_token.bash_id,
615
+ data=message.decode("utf-8", errors="replace"),
616
+ source="web-pty",
617
+ )
618
+ continue
619
+ try:
620
+ payload = json.loads(message)
621
+ except json.JSONDecodeError:
622
+ continue
623
+ if not isinstance(payload, dict):
624
+ continue
625
+ message_type = str(payload.get("type") or "").strip().lower()
626
+ if message_type == "input":
627
+ self.bash_exec_service.append_terminal_input(
628
+ attach_token.quest_root,
629
+ attach_token.bash_id,
630
+ data=str(payload.get("data") or ""),
631
+ source="web-pty",
632
+ )
633
+ continue
634
+ if message_type == "binary_input":
635
+ raw = str(payload.get("data") or "")
636
+ if raw:
637
+ self.bash_exec_service.append_terminal_input(
638
+ attach_token.quest_root,
639
+ attach_token.bash_id,
640
+ data=base64.b64decode(raw).decode("utf-8", errors="replace"),
641
+ source="web-pty",
642
+ )
643
+ continue
644
+ if message_type == "resize":
645
+ cols = int(payload.get("cols") or 0)
646
+ rows = int(payload.get("rows") or 0)
647
+ self.bash_exec_service.resize_terminal_session(
648
+ attach_token.quest_root,
649
+ attach_token.bash_id,
650
+ cols=cols,
651
+ rows=rows,
652
+ )
653
+ continue
654
+ if message_type == "detach":
655
+ break
656
+ if message_type == "ping":
657
+ with send_lock:
658
+ connection.send(json.dumps({"type": "pong"}, ensure_ascii=False))
659
+ finally:
660
+ stop_event.set()
661
+ relay_thread.join(timeout=1)
662
+
464
663
  def _handle_terminal_attach_connection(self, connection: ServerConnection) -> None:
465
664
  token_value = str(getattr(connection, "_ds_terminal_attach_token", "") or "").strip()
466
665
  attach_token, runtime = self.bash_exec_service.consume_terminal_attach_token(token_value)
467
- if attach_token is None or runtime is None:
666
+ if attach_token is None:
468
667
  try:
469
668
  connection.close(code=1011, reason="terminal_attach_unavailable")
470
669
  except Exception:
@@ -472,6 +671,31 @@ class DaemonApp:
472
671
  return
473
672
 
474
673
  send_lock = threading.Lock()
674
+ if runtime is None:
675
+ try:
676
+ self._handle_logged_terminal_attach_connection(
677
+ connection,
678
+ attach_token=attach_token,
679
+ send_lock=send_lock,
680
+ )
681
+ except Exception as exc:
682
+ try:
683
+ with send_lock:
684
+ connection.send(
685
+ json.dumps(
686
+ {"type": "error", "message": str(exc)},
687
+ ensure_ascii=False,
688
+ )
689
+ )
690
+ except Exception:
691
+ pass
692
+ finally:
693
+ try:
694
+ connection.close()
695
+ except Exception:
696
+ pass
697
+ return
698
+
475
699
  client = TerminalClient(
476
700
  client_id=generate_id("tclient"),
477
701
  send_text=connection.send,
@@ -628,6 +852,43 @@ class DaemonApp:
628
852
  factory = get_channel_factory(name)
629
853
  return factory(home=self.home, app=self, config=self.connectors_config.get(name, {}))
630
854
 
855
+ def _system_enabled_connector_names(self) -> set[str]:
856
+ connectors = self.runtime_config.get("connectors") if isinstance(self.runtime_config.get("connectors"), dict) else {}
857
+ system_enabled = connectors.get("system_enabled") if isinstance(connectors.get("system_enabled"), dict) else {}
858
+ return {
859
+ name
860
+ for name in SYSTEM_CONNECTOR_NAMES
861
+ if bool(system_enabled.get(name, name == "qq"))
862
+ }
863
+
864
+ def _is_connector_system_enabled(self, connector_name: str) -> bool:
865
+ normalized = str(connector_name or "").strip().lower()
866
+ if normalized == "local":
867
+ return True
868
+ enabled = self._system_enabled_connector_names()
869
+ if normalized in enabled:
870
+ return True
871
+ if normalized in SYSTEM_CONNECTOR_NAMES:
872
+ return False
873
+ return True
874
+
875
+ def reload_runtime_config(self, *, restart_background: bool = True) -> dict[str, object]:
876
+ previous_enabled = self._system_enabled_connector_names()
877
+ self.runtime_config = self.config_manager.load_runtime_config()
878
+ logging_config = self.runtime_config.get("logging") if isinstance(self.runtime_config.get("logging"), dict) else {}
879
+ self.logger.level = str(logging_config.get("level") or "info").strip().lower() or "info"
880
+ enabled = self._system_enabled_connector_names()
881
+ restarted = False
882
+ if restart_background and self._server is not None and enabled != previous_enabled:
883
+ self._stop_background_connectors()
884
+ self._start_background_connectors()
885
+ restarted = True
886
+ return {
887
+ "ok": True,
888
+ "system_enabled_connectors": sorted(enabled),
889
+ "restarted_background_connectors": restarted,
890
+ }
891
+
631
892
  def reload_connectors_config(self, *, restart_background: bool = True) -> dict[str, object]:
632
893
  self.connectors_config = self.config_manager.load_named_normalized("connectors")
633
894
  register_builtin_channels(home=self.home, connectors_config=self.connectors_config)
@@ -643,13 +904,32 @@ class DaemonApp:
643
904
  return {
644
905
  "ok": True,
645
906
  "connectors": sorted(
646
- name for name, config in self.connectors_config.items() if not str(name).startswith("_") and isinstance(config, dict)
907
+ name
908
+ for name, config in self.connectors_config.items()
909
+ if not str(name).startswith("_")
910
+ and isinstance(config, dict)
911
+ and self._is_connector_system_enabled(str(name))
647
912
  ),
648
913
  }
649
914
 
915
+ def reload_runners_config(self) -> dict[str, object]:
916
+ self.runners_config = self.config_manager.load_runners_config()
917
+ codex_config = self.runners_config.get("codex", {})
918
+ if isinstance(codex_config, dict):
919
+ self.codex_runner.binary = str(codex_config.get("binary") or "codex")
920
+ return {
921
+ "ok": True,
922
+ "runners": sorted(name for name, config in self.runners_config.items() if isinstance(config, dict)),
923
+ "codex": {
924
+ "binary": self.codex_runner.binary,
925
+ "approval_policy": codex_config.get("approval_policy") if isinstance(codex_config, dict) else None,
926
+ "sandbox_mode": codex_config.get("sandbox_mode") if isinstance(codex_config, dict) else None,
927
+ "mcp_tool_timeout_sec": codex_config.get("mcp_tool_timeout_sec") if isinstance(codex_config, dict) else None,
928
+ },
929
+ }
930
+
650
931
  def _preferred_locale(self) -> str:
651
- config = self.config_manager.load_named("config")
652
- return str(config.get("default_locale") or "en-US").lower()
932
+ return str(self.runtime_config.get("default_locale") or "en-US").lower()
653
933
 
654
934
  def _polite_copy(self, *, zh: str, en: str) -> str:
655
935
  return zh if self._preferred_locale().startswith("zh") else en
@@ -718,7 +998,7 @@ class DaemonApp:
718
998
  exclude_conversation_id: str | None = None,
719
999
  preferred_connector_conversation_id: str | None = None,
720
1000
  requested_connector_bindings: list[dict[str, object]] | None = None,
721
- force_connector_rebind: bool = False,
1001
+ force_connector_rebind: bool = True,
722
1002
  requested_baseline_ref: dict[str, object] | None = None,
723
1003
  startup_contract: dict[str, object] | None = None,
724
1004
  ) -> dict:
@@ -2090,6 +2370,210 @@ class DaemonApp:
2090
2370
  channel = self._channel_with_bindings(connector_name)
2091
2371
  return channel.list_bindings()
2092
2372
 
2373
+ def delete_connector_profile(self, connector_name: str, profile_id: str) -> dict | tuple[int, dict]:
2374
+ normalized_connector = str(connector_name or "").strip().lower()
2375
+ normalized_profile_id = str(profile_id or "").strip()
2376
+ if not normalized_connector:
2377
+ return 400, {"ok": False, "message": "Connector name is required."}
2378
+ if not normalized_profile_id:
2379
+ return 400, {"ok": False, "message": "Profile id is required."}
2380
+ if normalized_connector != "qq" and normalized_connector not in PROFILEABLE_CONNECTOR_NAMES:
2381
+ return 400, {"ok": False, "message": f"Connector `{normalized_connector}` does not support profile deletion."}
2382
+
2383
+ connectors = self.config_manager.load_named_normalized("connectors")
2384
+ connector_config = connectors.get(normalized_connector)
2385
+ if not isinstance(connector_config, dict):
2386
+ return 404, {"ok": False, "message": f"Unknown connector `{normalized_connector}`."}
2387
+
2388
+ if normalized_connector == "qq":
2389
+ profiles = list_qq_profiles(connector_config)
2390
+ else:
2391
+ profiles = list_connector_profiles(normalized_connector, connector_config)
2392
+ if not profiles:
2393
+ return 404, {"ok": False, "message": f"Connector `{normalized_connector}` has no configured profiles."}
2394
+
2395
+ remaining_profiles = [
2396
+ dict(item)
2397
+ for item in profiles
2398
+ if str(item.get("profile_id") or "").strip() != normalized_profile_id
2399
+ ]
2400
+ if len(remaining_profiles) == len(profiles):
2401
+ return 404, {
2402
+ "ok": False,
2403
+ "message": f"Profile `{normalized_profile_id}` was not found under connector `{normalized_connector}`.",
2404
+ }
2405
+
2406
+ deleting_last_profile = len(remaining_profiles) == 0
2407
+ related_conversations = self._connector_profile_bound_conversations(
2408
+ normalized_connector,
2409
+ normalized_profile_id,
2410
+ include_all_if_single_profile=deleting_last_profile,
2411
+ )
2412
+ for conversation_id in related_conversations:
2413
+ self._unbind_connector_conversation_everywhere(normalized_connector, conversation_id)
2414
+
2415
+ next_connector_config = dict(connector_config)
2416
+ next_connector_config["profiles"] = remaining_profiles
2417
+ if deleting_last_profile:
2418
+ next_connector_config["enabled"] = False
2419
+ if normalized_connector == "qq":
2420
+ for key in ("app_id", "app_secret", "app_secret_env", "main_chat_id"):
2421
+ next_connector_config[key] = None
2422
+ else:
2423
+ profile_spec = CONNECTOR_PROFILE_SPECS.get(normalized_connector, {})
2424
+ for key in profile_spec.get("profile_fields", ()):
2425
+ if key in {"enabled", "transport", "mode"}:
2426
+ continue
2427
+ next_connector_config[key] = None
2428
+ if normalized_connector == "qq":
2429
+ connectors[normalized_connector] = normalize_qq_connector_config(next_connector_config)
2430
+ else:
2431
+ connectors[normalized_connector] = normalize_connector_config(normalized_connector, next_connector_config)
2432
+ save_result = self.config_manager.save_named_payload("connectors", connectors)
2433
+ if not bool(save_result.get("ok")):
2434
+ return 409, {
2435
+ "ok": False,
2436
+ "message": "Failed to persist connector profile deletion.",
2437
+ "errors": save_result.get("errors") or [],
2438
+ "warnings": save_result.get("warnings") or [],
2439
+ }
2440
+
2441
+ self._cleanup_connector_profile_runtime(
2442
+ normalized_connector,
2443
+ normalized_profile_id,
2444
+ clear_all=deleting_last_profile,
2445
+ )
2446
+ self.reload_connectors_config()
2447
+ snapshot = next(
2448
+ (item for item in self.list_connector_statuses() if str(item.get("name") or "").strip().lower() == normalized_connector),
2449
+ None,
2450
+ )
2451
+ return {
2452
+ "ok": True,
2453
+ "connector": normalized_connector,
2454
+ "profile_id": normalized_profile_id,
2455
+ "deleted": True,
2456
+ "deleted_bound_conversations": related_conversations,
2457
+ "remaining_profile_count": len(remaining_profiles),
2458
+ "snapshot": snapshot,
2459
+ }
2460
+
2461
+ def _connector_profile_bound_conversations(
2462
+ self,
2463
+ connector_name: str,
2464
+ profile_id: str,
2465
+ *,
2466
+ include_all_if_single_profile: bool = False,
2467
+ ) -> list[str]:
2468
+ normalized_connector = str(connector_name or "").strip().lower()
2469
+ normalized_profile_id = str(profile_id or "").strip()
2470
+ if not normalized_connector or not normalized_profile_id:
2471
+ return []
2472
+ try:
2473
+ channel = self._channel_with_bindings(normalized_connector)
2474
+ except Exception:
2475
+ return []
2476
+ conversations: list[str] = []
2477
+ seen: set[str] = set()
2478
+ for item in channel.list_bindings():
2479
+ if not isinstance(item, dict):
2480
+ continue
2481
+ conversation_id = str(item.get("conversation_id") or "").strip()
2482
+ if not conversation_id:
2483
+ continue
2484
+ item_profile_id = str(item.get("profile_id") or "").strip()
2485
+ if not include_all_if_single_profile and item_profile_id != normalized_profile_id:
2486
+ continue
2487
+ identity = conversation_identity_key(conversation_id)
2488
+ if identity in seen:
2489
+ continue
2490
+ seen.add(identity)
2491
+ conversations.append(conversation_id)
2492
+ return conversations
2493
+
2494
+ def _unbind_connector_conversation_everywhere(self, connector_name: str, conversation_id: str) -> bool:
2495
+ normalized_connector = str(connector_name or "").strip().lower()
2496
+ normalized_conversation_id = normalize_conversation_id(conversation_id)
2497
+ if not normalized_connector or not normalized_conversation_id:
2498
+ return False
2499
+ try:
2500
+ channel = self._channel_with_bindings(normalized_connector)
2501
+ except Exception:
2502
+ return False
2503
+ bound_quest_id = str(channel.resolve_bound_quest(normalized_conversation_id) or "").strip() or None
2504
+ removed = channel.unbind_conversation(
2505
+ normalized_conversation_id,
2506
+ quest_id=bound_quest_id,
2507
+ )
2508
+ if bound_quest_id:
2509
+ self.sessions.unbind(bound_quest_id, normalized_conversation_id)
2510
+ self.quest_service.unbind_source(bound_quest_id, normalized_conversation_id)
2511
+ return removed
2512
+
2513
+ def _cleanup_connector_profile_runtime(
2514
+ self,
2515
+ connector_name: str,
2516
+ profile_id: str,
2517
+ *,
2518
+ clear_all: bool = False,
2519
+ ) -> None:
2520
+ normalized_connector = str(connector_name or "").strip().lower()
2521
+ normalized_profile_id = str(profile_id or "").strip()
2522
+ if not normalized_connector or not normalized_profile_id:
2523
+ return
2524
+ connector_root = self.home / "logs" / "connectors" / normalized_connector
2525
+ profile_root = connector_root / "profiles" / normalized_profile_id
2526
+ shutil.rmtree(profile_root, ignore_errors=True)
2527
+
2528
+ def matches_profile(payload: object, *, conversation_id: str | None = None) -> bool:
2529
+ if clear_all:
2530
+ return True
2531
+ item = payload if isinstance(payload, dict) else {}
2532
+ item_profile_id = str(item.get("profile_id") or "").strip() if isinstance(item, dict) else ""
2533
+ if item_profile_id:
2534
+ return item_profile_id == normalized_profile_id
2535
+ parsed = parse_conversation_id(conversation_id or "")
2536
+ return str((parsed or {}).get("profile_id") or "").strip() == normalized_profile_id
2537
+
2538
+ bindings_path = connector_root / "bindings.json"
2539
+ bindings_payload = read_json(bindings_path, {"bindings": {}})
2540
+ binding_map = bindings_payload.get("bindings")
2541
+ if isinstance(binding_map, dict):
2542
+ filtered_bindings = {
2543
+ key: value
2544
+ for key, value in binding_map.items()
2545
+ if not matches_profile(value, conversation_id=str(key))
2546
+ }
2547
+ bindings_payload["bindings"] = filtered_bindings
2548
+ write_json(bindings_path, bindings_payload)
2549
+
2550
+ state_path = connector_root / "state.json"
2551
+ state_payload = read_json(state_path, {})
2552
+ if isinstance(state_payload, dict):
2553
+ for key in ("recent_conversations", "known_targets"):
2554
+ raw_items = state_payload.get(key)
2555
+ if isinstance(raw_items, list):
2556
+ state_payload[key] = [
2557
+ item
2558
+ for item in raw_items
2559
+ if not matches_profile(
2560
+ item,
2561
+ conversation_id=str((item or {}).get("conversation_id") or "") if isinstance(item, dict) else "",
2562
+ )
2563
+ ]
2564
+ if clear_all:
2565
+ state_payload["last_conversation_id"] = None
2566
+ else:
2567
+ last_conversation_id = str(state_payload.get("last_conversation_id") or "").strip()
2568
+ if last_conversation_id and matches_profile({}, conversation_id=last_conversation_id):
2569
+ state_payload["last_conversation_id"] = None
2570
+ write_json(state_path, state_payload)
2571
+
2572
+ if clear_all:
2573
+ runtime_path = connector_root / "runtime.json"
2574
+ if runtime_path.exists():
2575
+ write_json(runtime_path, {})
2576
+
2093
2577
  def preview_connector_binding_conflicts(
2094
2578
  self,
2095
2579
  requested_bindings: list[dict[str, object]] | None,
@@ -2439,6 +2923,13 @@ class DaemonApp:
2439
2923
  }
2440
2924
 
2441
2925
  def handle_connector_inbound(self, connector_name: str, body: dict) -> dict:
2926
+ if not self._is_connector_system_enabled(connector_name):
2927
+ return {
2928
+ "ok": True,
2929
+ "accepted": False,
2930
+ "reason": "system_disabled",
2931
+ "message": f"Connector `{connector_name}` is disabled at the system level.",
2932
+ }
2442
2933
  channel = self._channel_with_bindings(connector_name)
2443
2934
  ingested = channel.ingest(body)
2444
2935
  if not ingested.get("accepted", False):
@@ -3103,6 +3594,8 @@ class DaemonApp:
3103
3594
  for connector_name, channel in self.channels.items():
3104
3595
  if connector_name == "local":
3105
3596
  continue
3597
+ if not self._is_connector_system_enabled(connector_name):
3598
+ continue
3106
3599
  connector_config = self.connectors_config.get(connector_name, {})
3107
3600
  if not isinstance(connector_config, dict):
3108
3601
  continue
@@ -3296,6 +3789,8 @@ class DaemonApp:
3296
3789
  }
3297
3790
 
3298
3791
  def _maybe_auto_bind_connector_conversation(self, connector_name: str, conversation_id: str) -> dict | None:
3792
+ if not self._is_connector_system_enabled(connector_name):
3793
+ return None
3299
3794
  connector_config = self.connectors_config.get(connector_name, {})
3300
3795
  if not isinstance(connector_config, dict):
3301
3796
  return None
@@ -3519,6 +4014,8 @@ class DaemonApp:
3519
4014
  )
3520
4015
 
3521
4016
  def _profiled_connector_configs(self, connector_name: str) -> list[tuple[str, str | None, dict[str, Any]]]:
4017
+ if not self._is_connector_system_enabled(connector_name):
4018
+ return []
3522
4019
  connector_config = self.connectors_config.get(connector_name, {})
3523
4020
  if not isinstance(connector_config, dict):
3524
4021
  return []
@@ -3536,7 +4033,7 @@ class DaemonApp:
3536
4033
 
3537
4034
  def _start_background_connectors(self) -> None:
3538
4035
  qq_config = self.connectors_config.get("qq", {})
3539
- if isinstance(qq_config, dict) and not self._qq_gateways:
4036
+ if self._is_connector_system_enabled("qq") and isinstance(qq_config, dict) and not self._qq_gateways:
3540
4037
  profiles = list_qq_profiles(qq_config)
3541
4038
  encode_profile_id = len(profiles) > 1
3542
4039
  for profile in profiles:
@@ -3556,7 +4053,7 @@ class DaemonApp:
3556
4053
  )
3557
4054
  if gateway.start():
3558
4055
  self._qq_gateways[profile_id] = gateway
3559
- if not self._telegram_polling:
4056
+ if self._is_connector_system_enabled("telegram") and not self._telegram_polling:
3560
4057
  for profile_id, profile_label, profile_config in self._profiled_connector_configs("telegram"):
3561
4058
  polling = TelegramPollingService(
3562
4059
  home=self.home,
@@ -3574,7 +4071,7 @@ class DaemonApp:
3574
4071
  )
3575
4072
  if polling.start():
3576
4073
  self._telegram_polling[profile_id] = polling
3577
- if not self._slack_socket:
4074
+ if self._is_connector_system_enabled("slack") and not self._slack_socket:
3578
4075
  for profile_id, profile_label, profile_config in self._profiled_connector_configs("slack"):
3579
4076
  slack = SlackSocketModeService(
3580
4077
  home=self.home,
@@ -3592,7 +4089,7 @@ class DaemonApp:
3592
4089
  )
3593
4090
  if slack.start():
3594
4091
  self._slack_socket[profile_id] = slack
3595
- if not self._discord_gateway:
4092
+ if self._is_connector_system_enabled("discord") and not self._discord_gateway:
3596
4093
  for profile_id, profile_label, profile_config in self._profiled_connector_configs("discord"):
3597
4094
  discord = DiscordGatewayService(
3598
4095
  home=self.home,
@@ -3610,7 +4107,7 @@ class DaemonApp:
3610
4107
  )
3611
4108
  if discord.start():
3612
4109
  self._discord_gateway[profile_id] = discord
3613
- if not self._feishu_long_connection:
4110
+ if self._is_connector_system_enabled("feishu") and not self._feishu_long_connection:
3614
4111
  for profile_id, profile_label, profile_config in self._profiled_connector_configs("feishu"):
3615
4112
  feishu = FeishuLongConnectionService(
3616
4113
  home=self.home,
@@ -3628,7 +4125,7 @@ class DaemonApp:
3628
4125
  )
3629
4126
  if feishu.start():
3630
4127
  self._feishu_long_connection[profile_id] = feishu
3631
- if not self._whatsapp_local_session:
4128
+ if self._is_connector_system_enabled("whatsapp") and not self._whatsapp_local_session:
3632
4129
  for profile_id, profile_label, profile_config in self._profiled_connector_configs("whatsapp"):
3633
4130
  whatsapp = WhatsAppLocalSessionService(
3634
4131
  home=self.home,
@@ -4259,6 +4756,7 @@ class DaemonApp:
4259
4756
  "git_commit",
4260
4757
  "git_diff_file",
4261
4758
  "git_commit_file",
4759
+ "file_change_diff",
4262
4760
  "explorer",
4263
4761
  "quest_search",
4264
4762
  "node_traces",