@researai/deepscientist 1.5.11 → 1.5.12

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 (102) hide show
  1. package/README.md +8 -8
  2. package/bin/ds.js +358 -61
  3. package/docs/en/00_QUICK_START.md +35 -3
  4. package/docs/en/01_SETTINGS_REFERENCE.md +11 -0
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +68 -4
  6. package/docs/en/09_DOCTOR.md +28 -3
  7. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +21 -2
  8. package/docs/en/15_CODEX_PROVIDER_SETUP.md +284 -0
  9. package/docs/en/README.md +4 -0
  10. package/docs/zh/00_QUICK_START.md +34 -2
  11. package/docs/zh/01_SETTINGS_REFERENCE.md +11 -0
  12. package/docs/zh/02_START_RESEARCH_GUIDE.md +69 -3
  13. package/docs/zh/09_DOCTOR.md +28 -1
  14. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +21 -2
  15. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +285 -0
  16. package/docs/zh/README.md +4 -1
  17. package/package.json +1 -1
  18. package/pyproject.toml +1 -1
  19. package/src/deepscientist/__init__.py +1 -1
  20. package/src/deepscientist/bash_exec/monitor.py +7 -5
  21. package/src/deepscientist/bash_exec/service.py +84 -21
  22. package/src/deepscientist/channels/local.py +3 -3
  23. package/src/deepscientist/channels/qq.py +7 -7
  24. package/src/deepscientist/channels/relay.py +7 -7
  25. package/src/deepscientist/channels/weixin_ilink.py +90 -19
  26. package/src/deepscientist/config/models.py +1 -0
  27. package/src/deepscientist/config/service.py +121 -20
  28. package/src/deepscientist/daemon/app.py +314 -6
  29. package/src/deepscientist/doctor.py +1 -5
  30. package/src/deepscientist/mcp/server.py +124 -3
  31. package/src/deepscientist/prompts/builder.py +113 -11
  32. package/src/deepscientist/quest/service.py +247 -31
  33. package/src/deepscientist/runners/codex.py +121 -22
  34. package/src/deepscientist/runners/runtime_overrides.py +6 -0
  35. package/src/deepscientist/shared.py +33 -14
  36. package/src/prompts/connectors/qq.md +2 -1
  37. package/src/prompts/connectors/weixin.md +2 -1
  38. package/src/prompts/contracts/shared_interaction.md +4 -1
  39. package/src/prompts/system.md +59 -9
  40. package/src/skills/analysis-campaign/SKILL.md +46 -6
  41. package/src/skills/analysis-campaign/references/campaign-plan-template.md +21 -8
  42. package/src/skills/baseline/SKILL.md +1 -1
  43. package/src/skills/decision/SKILL.md +1 -1
  44. package/src/skills/experiment/SKILL.md +1 -1
  45. package/src/skills/finalize/SKILL.md +1 -1
  46. package/src/skills/idea/SKILL.md +1 -1
  47. package/src/skills/intake-audit/SKILL.md +1 -1
  48. package/src/skills/rebuttal/SKILL.md +74 -1
  49. package/src/skills/rebuttal/references/response-letter-template.md +55 -11
  50. package/src/skills/review/SKILL.md +118 -1
  51. package/src/skills/review/references/experiment-todo-template.md +23 -0
  52. package/src/skills/review/references/review-report-template.md +16 -0
  53. package/src/skills/review/references/revision-log-template.md +4 -0
  54. package/src/skills/scout/SKILL.md +1 -1
  55. package/src/skills/write/SKILL.md +168 -7
  56. package/src/skills/write/references/paper-experiment-matrix-template.md +131 -0
  57. package/src/tui/package.json +1 -1
  58. package/src/ui/dist/assets/{AiManusChatView-D0mTXG4-.js → AiManusChatView-CnJcXynW.js} +12 -12
  59. package/src/ui/dist/assets/{AnalysisPlugin-Db0cTXxm.js → AnalysisPlugin-DeyzPEhV.js} +1 -1
  60. package/src/ui/dist/assets/{CliPlugin-DrV8je02.js → CliPlugin-CB1YODQn.js} +9 -9
  61. package/src/ui/dist/assets/{CodeEditorPlugin-QXMSCH71.js → CodeEditorPlugin-B-xicq1e.js} +8 -8
  62. package/src/ui/dist/assets/{CodeViewerPlugin-7hhtWj_E.js → CodeViewerPlugin-DT54ysXa.js} +5 -5
  63. package/src/ui/dist/assets/{DocViewerPlugin-BWMSnRJe.js → DocViewerPlugin-DQtKT-VD.js} +3 -3
  64. package/src/ui/dist/assets/{GitDiffViewerPlugin-7J9h9Vy_.js → GitDiffViewerPlugin-hqHbCfnv.js} +20 -20
  65. package/src/ui/dist/assets/{ImageViewerPlugin-CHJl_0lr.js → ImageViewerPlugin-OcVo33jV.js} +5 -5
  66. package/src/ui/dist/assets/{LabCopilotPanel-1qSow1es.js → LabCopilotPanel-DdGwhEUV.js} +11 -11
  67. package/src/ui/dist/assets/{LabPlugin-eQpPPCEp.js → LabPlugin-Ciz1gDaX.js} +2 -2
  68. package/src/ui/dist/assets/{LatexPlugin-BwRfi89Z.js → LatexPlugin-BhmjNQRC.js} +37 -11
  69. package/src/ui/dist/assets/{MarkdownViewerPlugin-836PVQWV.js → MarkdownViewerPlugin-BzdVH9Bx.js} +4 -4
  70. package/src/ui/dist/assets/{MarketplacePlugin-C2y_556i.js → MarketplacePlugin-DmyHspXt.js} +3 -3
  71. package/src/ui/dist/assets/{NotebookEditor-DIX7Mlzu.js → NotebookEditor-BMXKrDRk.js} +1 -1
  72. package/src/ui/dist/assets/{NotebookEditor-BRzJbGsn.js → NotebookEditor-BTVYRGkm.js} +11 -11
  73. package/src/ui/dist/assets/{PdfLoader-DzRaTAlq.js → PdfLoader-CvcjJHXv.js} +1 -1
  74. package/src/ui/dist/assets/{PdfMarkdownPlugin-DZUfIUnp.js → PdfMarkdownPlugin-DW2ej8Vk.js} +2 -2
  75. package/src/ui/dist/assets/{PdfViewerPlugin-BwtICzue.js → PdfViewerPlugin-CmlDxbhU.js} +10 -10
  76. package/src/ui/dist/assets/{SearchPlugin-DHeIAMsx.js → SearchPlugin-DAjQZPSv.js} +1 -1
  77. package/src/ui/dist/assets/{TextViewerPlugin-C3tCmFox.js → TextViewerPlugin-C-nVAZb_.js} +5 -5
  78. package/src/ui/dist/assets/{VNCViewer-CQsKVm3t.js → VNCViewer-D7-dIYon.js} +10 -10
  79. package/src/ui/dist/assets/{bot-BEA2vWuK.js → bot-C_G4WtNI.js} +1 -1
  80. package/src/ui/dist/assets/{code-XfbSR8K2.js → code-Cd7WfiWq.js} +1 -1
  81. package/src/ui/dist/assets/{file-content-BjxNaIfy.js → file-content-B57zsL9y.js} +1 -1
  82. package/src/ui/dist/assets/{file-diff-panel-D_lLVQk0.js → file-diff-panel-DVoheLFq.js} +1 -1
  83. package/src/ui/dist/assets/{file-socket-D9x_5vlY.js → file-socket-B5kXFxZP.js} +1 -1
  84. package/src/ui/dist/assets/{image-BhWT33W1.js → image-LLOjkMHF.js} +1 -1
  85. package/src/ui/dist/assets/{index-Dqj-Mjb4.css → index-BQG-1s2o.css} +40 -2
  86. package/src/ui/dist/assets/{index--c4iXtuy.js → index-C3r2iGrp.js} +12 -12
  87. package/src/ui/dist/assets/{index-DZTZ8mWP.js → index-CLQauncb.js} +911 -120
  88. package/src/ui/dist/assets/{index-PJbSbPTy.js → index-Dxa2eYMY.js} +1 -1
  89. package/src/ui/dist/assets/{index-BDxipwrC.js → index-hOUOWbW2.js} +2 -2
  90. package/src/ui/dist/assets/{monaco-K8izTGgo.js → monaco-BGGAEii3.js} +1 -1
  91. package/src/ui/dist/assets/{pdf-effect-queue-DfBors6y.js → pdf-effect-queue-DlEr1_y5.js} +1 -1
  92. package/src/ui/dist/assets/{popover-yFK1J4fL.js → popover-CWJbJuYY.js} +1 -1
  93. package/src/ui/dist/assets/{project-sync-PENr2zcz.js → project-sync-CRJiucYO.js} +18 -4
  94. package/src/ui/dist/assets/{select-CAbJDfYv.js → select-CoHB7pvH.js} +2 -2
  95. package/src/ui/dist/assets/{sigma-DEuYJqTl.js → sigma-D5aJWR8J.js} +1 -1
  96. package/src/ui/dist/assets/{square-check-big-omoSUmcd.js → square-check-big-DUK_mnkS.js} +1 -1
  97. package/src/ui/dist/assets/{trash--F119N47.js → trash-ChU3SEE3.js} +1 -1
  98. package/src/ui/dist/assets/{useCliAccess-D31UR23I.js → useCliAccess-BrJBV3tY.js} +1 -1
  99. package/src/ui/dist/assets/{useFileDiffOverlay-BH6KcMzq.js → useFileDiffOverlay-C2OQaVWc.js} +1 -1
  100. package/src/ui/dist/assets/{wrap-text-CZ613PM5.js → wrap-text-C7Qqh-om.js} +1 -1
  101. package/src/ui/dist/assets/{zoom-out-BgDLAv3z.js → zoom-out-rtX0FKya.js} +1 -1
  102. package/src/ui/dist/index.html +2 -2
@@ -1,14 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import base64
4
+ from collections import deque
5
+ import faulthandler
4
6
  import json
5
7
  import mimetypes
6
8
  import os
7
9
  import re
10
+ import signal
8
11
  import shutil
9
12
  import subprocess
13
+ import sys
10
14
  import threading
11
15
  import time
16
+ import traceback
12
17
  from datetime import UTC, datetime, timedelta
13
18
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
19
  from pathlib import Path
@@ -67,7 +72,7 @@ from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, n
67
72
  from ..quest import QuestService
68
73
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
69
74
  from ..runtime_logs import JsonlLogger
70
- from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_text, resolve_within, run_command, slugify, utc_now, which, write_json
75
+ from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, resolve_within, run_command, slugify, utc_now, which, write_json
71
76
  from ..skills import SkillInstaller
72
77
  from ..team import SingleTeamService
73
78
  from ..connector.weixin_support import (
@@ -87,6 +92,7 @@ from websockets.sync.server import Server as WebSocketServer
87
92
  from websockets.sync.server import ServerConnection, serve as websocket_serve
88
93
 
89
94
  TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
95
+ _AUTO_CONTINUE_DELAY_SECONDS = 0.2
90
96
  CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
91
97
  CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
92
98
  CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
@@ -94,6 +100,8 @@ CODEX_RETRY_DEFAULT_MAX_BACKOFF_SEC = 1800.0
94
100
  LEGACY_CODEX_RETRY_INITIAL_BACKOFF_SEC = 1.0
95
101
  LEGACY_CODEX_RETRY_BACKOFF_MULTIPLIER = 2.0
96
102
  LEGACY_CODEX_RETRY_MAX_BACKOFF_SEC = 8.0
103
+ _CRASH_AUTO_RESUME_COOLDOWN = timedelta(minutes=10)
104
+ _CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS = 2
97
105
  _LINGZHU_SHORT_COMMAND_DIRECT_MAP = {
98
106
  "帮助": "help",
99
107
  "列表": "list",
@@ -188,6 +196,9 @@ class DaemonApp:
188
196
  self._feishu_long_connection: dict[str, FeishuLongConnectionService] = {}
189
197
  self._whatsapp_local_session: dict[str, WhatsAppLocalSessionService] = {}
190
198
  self._weixin_login_sessions: dict[str, dict[str, Any]] = {}
199
+ self._process_hooks_installed = False
200
+ self._faulthandler_stream = None
201
+ self._recovered_quest_ids: set[str] = set()
191
202
  self.handlers = ApiHandlers(self)
192
203
 
193
204
  def list_connector_statuses(self) -> list[dict[str, object]]:
@@ -378,6 +389,262 @@ class DaemonApp:
378
389
  "available_connectors": available_connectors,
379
390
  }
380
391
 
392
+ def _log_unhandled_exception(
393
+ self,
394
+ *,
395
+ event_type: str,
396
+ exc_type: type[BaseException],
397
+ exc_value: BaseException,
398
+ exc_traceback: Any,
399
+ thread_name: str | None = None,
400
+ ) -> None:
401
+ try:
402
+ traceback_text = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
403
+ except Exception:
404
+ traceback_text = str(exc_value or "")
405
+ payload: dict[str, Any] = {
406
+ "exception_type": getattr(exc_type, "__name__", str(exc_type)),
407
+ "message": str(exc_value),
408
+ "traceback": traceback_text,
409
+ "pid": os.getpid(),
410
+ }
411
+ if thread_name:
412
+ payload["thread_name"] = thread_name
413
+ self.logger.log("error", event_type, **payload)
414
+
415
+ def _handle_process_signal(self, signame: str) -> None:
416
+ self.logger.log(
417
+ "warning",
418
+ "daemon.signal_received",
419
+ signal=signame,
420
+ pid=os.getpid(),
421
+ )
422
+ self.request_shutdown(source=f"signal:{str(signame).lower()}")
423
+
424
+ def _install_process_observability(self) -> None:
425
+ if self._process_hooks_installed:
426
+ return
427
+ self._process_hooks_installed = True
428
+ faulthandler_path = self.home / "logs" / "daemon-faulthandler.log"
429
+ try:
430
+ ensure_dir(faulthandler_path.parent)
431
+ self._faulthandler_stream = open(faulthandler_path, "a", encoding="utf-8")
432
+ faulthandler.enable(file=self._faulthandler_stream)
433
+ except Exception as exc:
434
+ self.logger.log("warning", "daemon.faulthandler_enable_failed", error=str(exc))
435
+
436
+ def _sys_excepthook(exc_type: type[BaseException], exc_value: BaseException, exc_traceback: Any) -> None:
437
+ self._log_unhandled_exception(
438
+ event_type="daemon.unhandled_exception",
439
+ exc_type=exc_type,
440
+ exc_value=exc_value,
441
+ exc_traceback=exc_traceback,
442
+ )
443
+
444
+ def _thread_excepthook(args: threading.ExceptHookArgs) -> None:
445
+ self._log_unhandled_exception(
446
+ event_type="daemon.thread_exception",
447
+ exc_type=args.exc_type,
448
+ exc_value=args.exc_value,
449
+ exc_traceback=args.exc_traceback,
450
+ thread_name=getattr(args.thread, "name", None),
451
+ )
452
+
453
+ sys.excepthook = _sys_excepthook
454
+ threading.excepthook = _thread_excepthook
455
+ for signame in ("SIGTERM", "SIGHUP", "SIGINT"):
456
+ signum = getattr(signal, signame, None)
457
+ if signum is None:
458
+ continue
459
+ try:
460
+ signal.signal(signum, lambda _signum, _frame, _signame=signame: self._handle_process_signal(_signame))
461
+ except Exception as exc:
462
+ self.logger.log(
463
+ "warning",
464
+ "daemon.signal_handler_install_failed",
465
+ signal=signame,
466
+ error=str(exc),
467
+ )
468
+ self.logger.log("info", "daemon.process_hooks_installed", pid=os.getpid())
469
+
470
+ def _resume_reconciled_quests(self) -> list[dict[str, Any]]:
471
+ resumed: list[dict[str, Any]] = []
472
+ for item in self.reconciled_quests:
473
+ if not isinstance(item, dict):
474
+ continue
475
+ quest_id = str(item.get("quest_id") or "").strip()
476
+ if not quest_id or quest_id in self._recovered_quest_ids:
477
+ continue
478
+ if not bool(item.get("recoverable")):
479
+ continue
480
+ recent_attempts = self._recent_crash_auto_resume_count(quest_id)
481
+ if recent_attempts >= _CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS:
482
+ self._record_auto_resume_suppressed(
483
+ quest_id=quest_id,
484
+ previous_status=str(item.get("previous_status") or "").strip() or None,
485
+ abandoned_run_id=str(item.get("abandoned_run_id") or "").strip() or None,
486
+ last_transition_at=str(item.get("last_transition_at") or "").strip() or None,
487
+ recent_attempts=recent_attempts,
488
+ )
489
+ continue
490
+ try:
491
+ resume_payload = self.resume_quest(quest_id, source="auto:daemon-recovery")
492
+ snapshot = (
493
+ dict(resume_payload.get("snapshot") or {})
494
+ if isinstance(resume_payload.get("snapshot"), dict)
495
+ else self.quest_service.snapshot(quest_id)
496
+ )
497
+ reason = (
498
+ "queued_user_messages"
499
+ if int(snapshot.get("pending_user_message_count") or 0) > 0
500
+ else "auto_continue"
501
+ )
502
+ scheduled = self.schedule_turn(quest_id, reason=reason)
503
+ event = {
504
+ "event_id": generate_id("evt"),
505
+ "type": "quest.runtime_auto_resumed",
506
+ "quest_id": quest_id,
507
+ "previous_status": item.get("previous_status"),
508
+ "abandoned_run_id": item.get("abandoned_run_id"),
509
+ "last_transition_at": item.get("last_transition_at"),
510
+ "reason": reason,
511
+ "scheduled": bool(scheduled.get("scheduled")),
512
+ "started": bool(scheduled.get("started")),
513
+ "queued": bool(scheduled.get("queued")),
514
+ "created_at": utc_now(),
515
+ }
516
+ append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", event)
517
+ self.logger.log(
518
+ "warning",
519
+ "quest.runtime_auto_resumed",
520
+ quest_id=quest_id,
521
+ previous_status=item.get("previous_status"),
522
+ abandoned_run_id=item.get("abandoned_run_id"),
523
+ last_transition_at=item.get("last_transition_at"),
524
+ reason=reason,
525
+ scheduled=bool(scheduled.get("scheduled")),
526
+ started=bool(scheduled.get("started")),
527
+ queued=bool(scheduled.get("queued")),
528
+ )
529
+ self._recovered_quest_ids.add(quest_id)
530
+ resumed.append(
531
+ {
532
+ "quest_id": quest_id,
533
+ "previous_status": item.get("previous_status"),
534
+ "abandoned_run_id": item.get("abandoned_run_id"),
535
+ "last_transition_at": item.get("last_transition_at"),
536
+ "reason": reason,
537
+ "scheduled": dict(scheduled),
538
+ }
539
+ )
540
+ except Exception as exc:
541
+ self.logger.log(
542
+ "warning",
543
+ "quest.runtime_auto_resume_failed",
544
+ quest_id=quest_id,
545
+ previous_status=item.get("previous_status"),
546
+ abandoned_run_id=item.get("abandoned_run_id"),
547
+ error=str(exc),
548
+ )
549
+ return resumed
550
+
551
+ @staticmethod
552
+ def _parse_event_timestamp(value: Any) -> datetime | None:
553
+ normalized = str(value or "").strip()
554
+ if not normalized:
555
+ return None
556
+ candidate = normalized.replace("Z", "+00:00")
557
+ try:
558
+ parsed = datetime.fromisoformat(candidate)
559
+ except ValueError:
560
+ return None
561
+ if parsed.tzinfo is None:
562
+ parsed = parsed.replace(tzinfo=UTC)
563
+ return parsed.astimezone(UTC)
564
+
565
+ def _recent_crash_auto_resume_count(self, quest_id: str) -> int:
566
+ quest_root = self.home / "quests" / quest_id
567
+ events = read_jsonl_tail(quest_root / ".ds" / "events.jsonl", 400)
568
+ now = datetime.now(UTC)
569
+ count = 0
570
+ for event in reversed(events[-200:]):
571
+ if str(event.get("type") or "").strip() != "quest.runtime_auto_resumed":
572
+ continue
573
+ parsed = self._parse_event_timestamp(event.get("created_at"))
574
+ if parsed is None:
575
+ continue
576
+ if now - parsed > _CRASH_AUTO_RESUME_COOLDOWN:
577
+ continue
578
+ count += 1
579
+ return count
580
+
581
+ def _record_auto_resume_suppressed(
582
+ self,
583
+ *,
584
+ quest_id: str,
585
+ previous_status: str | None,
586
+ abandoned_run_id: str | None,
587
+ last_transition_at: str | None,
588
+ recent_attempts: int,
589
+ ) -> None:
590
+ summary = self._polite_copy(
591
+ zh=(
592
+ "检测到 quest 在短时间内连续异常恢复,系统已暂时停止自动继续运行,"
593
+ "以避免进入重复崩溃循环。请先检查运行环境或 runner 侧问题,再手动恢复。"
594
+ ),
595
+ en=(
596
+ "DeepScientist detected repeated crash recovery attempts in a short window, "
597
+ "so automatic continuation has been paused to avoid a crash loop. "
598
+ "Inspect the runtime or runner path before resuming manually."
599
+ ),
600
+ )
601
+ payload = {
602
+ "event_id": generate_id("evt"),
603
+ "type": "quest.runtime_auto_resume_suppressed",
604
+ "quest_id": quest_id,
605
+ "previous_status": previous_status,
606
+ "abandoned_run_id": abandoned_run_id,
607
+ "last_transition_at": last_transition_at,
608
+ "recent_attempts": recent_attempts,
609
+ "cooldown_minutes": int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
610
+ "summary": summary,
611
+ "created_at": utc_now(),
612
+ }
613
+ append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", payload)
614
+ self.logger.log(
615
+ "warning",
616
+ "quest.runtime_auto_resume_suppressed",
617
+ quest_id=quest_id,
618
+ previous_status=previous_status,
619
+ abandoned_run_id=abandoned_run_id,
620
+ last_transition_at=last_transition_at,
621
+ recent_attempts=recent_attempts,
622
+ cooldown_minutes=int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
623
+ )
624
+ self.quest_service.append_message(
625
+ quest_id,
626
+ role="assistant",
627
+ content=summary,
628
+ source="system-control",
629
+ )
630
+ self._relay_quest_message_to_bound_connectors(
631
+ quest_id,
632
+ message=summary,
633
+ kind="progress",
634
+ response_phase="control",
635
+ importance="warning",
636
+ attachments=[
637
+ {
638
+ "kind": "quest_control",
639
+ "action": "auto_resume_suppressed",
640
+ "previous_status": previous_status,
641
+ "abandoned_run_id": abandoned_run_id,
642
+ "recent_attempts": recent_attempts,
643
+ "cooldown_minutes": int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
644
+ }
645
+ ],
646
+ )
647
+
381
648
  def _normalize_requested_connector_bindings(
382
649
  self,
383
650
  requested_connector_bindings: list[dict[str, object]] | None,
@@ -1361,6 +1628,7 @@ class DaemonApp:
1361
1628
  status=str(snapshot.get("status") or next_status),
1362
1629
  interrupted=False,
1363
1630
  summary=summary,
1631
+ automated=source.startswith("auto:"),
1364
1632
  )
1365
1633
  notice = self._announce_control_state(
1366
1634
  quest_id,
@@ -1425,6 +1693,7 @@ class DaemonApp:
1425
1693
  message = self._control_notice_message(
1426
1694
  quest_id,
1427
1695
  action=action,
1696
+ source=source,
1428
1697
  snapshot=snapshot,
1429
1698
  interrupted=interrupted,
1430
1699
  cancelled_pending_user_message_count=cancelled_pending_user_message_count,
@@ -1485,6 +1754,7 @@ class DaemonApp:
1485
1754
  quest_id: str,
1486
1755
  *,
1487
1756
  action: str,
1757
+ source: str,
1488
1758
  snapshot: dict,
1489
1759
  interrupted: bool,
1490
1760
  cancelled_pending_user_message_count: int,
@@ -1503,6 +1773,13 @@ class DaemonApp:
1503
1773
  en="The current Git branch and worktree were kept intact, and the quest will continue from the existing research context.",
1504
1774
  ),
1505
1775
  ]
1776
+ if source.startswith("auto:daemon-recovery"):
1777
+ lines.append(
1778
+ self._polite_copy(
1779
+ zh="检测到 daemon 曾异常退出;当前 quest 已在自动恢复后继续运行。",
1780
+ en="The daemon exited unexpectedly before; this quest has now been recovered automatically and will continue.",
1781
+ )
1782
+ )
1506
1783
  elif action == "pause":
1507
1784
  lines = [
1508
1785
  self._polite_copy(
@@ -2156,8 +2433,12 @@ class DaemonApp:
2156
2433
  snapshot = self.quest_service.snapshot(quest_id)
2157
2434
  quest_root = Path(snapshot["quest_root"])
2158
2435
  workspace_root = Path(str(snapshot.get("current_workspace_root") or snapshot.get("quest_root") or quest_root))
2159
- quest_events = read_jsonl(quest_root / ".ds" / "events.jsonl")
2160
- run_events = [event for event in quest_events if str(event.get("run_id") or "").strip() == failed_run_id][-120:]
2436
+ run_events_window: deque[dict[str, Any]] = deque(maxlen=120)
2437
+ for event in iter_jsonl(quest_root / ".ds" / "events.jsonl"):
2438
+ if str(event.get("run_id") or "").strip() != failed_run_id:
2439
+ continue
2440
+ run_events_window.append(event)
2441
+ run_events = list(run_events_window)
2161
2442
 
2162
2443
  recent_messages: list[str] = []
2163
2444
  tool_progress: list[dict[str, str]] = []
@@ -2317,12 +2598,29 @@ class DaemonApp:
2317
2598
  if int(snapshot.get("pending_user_message_count") or 0) > 0:
2318
2599
  self.schedule_turn(quest_id, reason="queued_user_messages")
2319
2600
  else:
2320
- self.schedule_turn(quest_id, reason="auto_continue")
2601
+ self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
2321
2602
  return
2322
2603
  if int(snapshot.get("pending_user_message_count") or 0) > 0:
2323
2604
  self.schedule_turn(quest_id, reason="queued_user_messages")
2324
2605
  return
2325
- self.schedule_turn(quest_id, reason="auto_continue")
2606
+ self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
2607
+
2608
+ def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
2609
+ def _delayed() -> None:
2610
+ time.sleep(max(0.0, delay_seconds))
2611
+ if self._turn_stop_requested(quest_id):
2612
+ return
2613
+ snapshot = self.quest_service.snapshot(quest_id)
2614
+ status = str(snapshot.get("status") or snapshot.get("runtime_status") or "").strip().lower()
2615
+ if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
2616
+ return
2617
+ self.schedule_turn(quest_id, reason=reason)
2618
+
2619
+ threading.Thread(
2620
+ target=_delayed,
2621
+ daemon=True,
2622
+ name=f"deepscientist-turn-delay-{quest_id}",
2623
+ ).start()
2326
2624
 
2327
2625
  def _relay_quest_message_to_bound_connectors(
2328
2626
  self,
@@ -5737,6 +6035,7 @@ class DaemonApp:
5737
6035
  return
5738
6036
 
5739
6037
  def serve(self, host: str, port: int) -> None:
6038
+ self._install_process_observability()
5740
6039
  app = self
5741
6040
 
5742
6041
  class RequestHandler(BaseHTTPRequestHandler):
@@ -5887,11 +6186,20 @@ class DaemonApp:
5887
6186
  self._shutdown_requested.clear()
5888
6187
  self._start_terminal_attach_server(host, port)
5889
6188
  self._start_background_connectors()
6189
+ self._resume_reconciled_quests()
5890
6190
  print(f"DeepScientist daemon listening on http://{host}:{port}")
5891
6191
  try:
5892
6192
  server.serve_forever()
5893
6193
  except KeyboardInterrupt:
5894
- pass
6194
+ self.logger.log("warning", "daemon.keyboard_interrupt", pid=os.getpid())
6195
+ except BaseException as exc:
6196
+ self._log_unhandled_exception(
6197
+ event_type="daemon.serve_crashed",
6198
+ exc_type=type(exc),
6199
+ exc_value=exc,
6200
+ exc_traceback=exc.__traceback__,
6201
+ )
6202
+ raise
5895
6203
  finally:
5896
6204
  self._stop_background_connectors()
5897
6205
  self._stop_terminal_attach_server()
@@ -263,11 +263,7 @@ def _check_codex(config_manager: ConfigManager) -> dict[str, Any]:
263
263
  ok=False,
264
264
  summary="Codex CLI is not available to DeepScientist.",
265
265
  errors=[f"Runner binary `{binary}` could not be resolved."],
266
- guidance=[
267
- "Run `npm install -g @researai/deepscientist` again so the bundled Codex dependency is installed.",
268
- "If `codex` is still missing, install it explicitly with `npm install -g @openai/codex`.",
269
- "Then run `codex --login` (or `codex`) once and complete login.",
270
- ],
266
+ guidance=config_manager._codex_missing_binary_guidance(codex_cfg),
271
267
  details={"binary": binary},
272
268
  )
273
269
 
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ from collections import deque
5
+ from pathlib import Path
4
6
  from typing import Any
5
7
 
6
8
  from mcp.server.fastmcp import FastMCP
@@ -95,6 +97,125 @@ def _build_default_bash_log_payload(log_text: str) -> dict[str, Any]:
95
97
  }
96
98
 
97
99
 
100
+ def _stream_bash_log_summary(path: Path) -> tuple[list[str], int, list[str]]:
101
+ total = 0
102
+ full_lines: list[str] = []
103
+ head_lines: list[str] = []
104
+ tail_lines: deque[str] = deque(maxlen=DEFAULT_INLINE_BASH_LOG_TAIL_LINES)
105
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
106
+ for raw_line in handle:
107
+ line = raw_line.rstrip("\n")
108
+ total += 1
109
+ if total <= DEFAULT_INLINE_BASH_LOG_LINE_LIMIT:
110
+ full_lines.append(line)
111
+ continue
112
+ if total == DEFAULT_INLINE_BASH_LOG_LINE_LIMIT + 1:
113
+ head_lines = full_lines[:DEFAULT_INLINE_BASH_LOG_HEAD_LINES]
114
+ tail_lines.extend(full_lines[-DEFAULT_INLINE_BASH_LOG_TAIL_LINES :])
115
+ full_lines = []
116
+ tail_lines.append(line)
117
+ return full_lines, total, list(head_lines or tail_lines)
118
+
119
+
120
+ def _build_default_bash_log_payload_from_path(path: Path) -> dict[str, Any]:
121
+ if not path.exists():
122
+ return {
123
+ "log": "",
124
+ "log_line_count": 0,
125
+ "log_truncated": False,
126
+ }
127
+ full_lines, total, preview_seed = _stream_bash_log_summary(path)
128
+ if total <= DEFAULT_INLINE_BASH_LOG_LINE_LIMIT:
129
+ return {
130
+ "log": _join_bash_log_lines(full_lines),
131
+ "log_line_count": total,
132
+ "log_truncated": False,
133
+ }
134
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
135
+ tail_lines: deque[str] = deque(maxlen=DEFAULT_INLINE_BASH_LOG_TAIL_LINES)
136
+ for raw_line in handle:
137
+ tail_lines.append(raw_line.rstrip("\n"))
138
+ omitted = total - DEFAULT_INLINE_BASH_LOG_HEAD_LINES - DEFAULT_INLINE_BASH_LOG_TAIL_LINES
139
+ marker = (
140
+ f"[... omitted {omitted} lines from the middle of this log. {LONG_BASH_LOG_HINT}]"
141
+ )
142
+ preview_lines = preview_seed[:DEFAULT_INLINE_BASH_LOG_HEAD_LINES] + [marker] + list(tail_lines)
143
+ return {
144
+ "log": _join_bash_log_lines(preview_lines),
145
+ "log_line_count": total,
146
+ "log_truncated": True,
147
+ "log_preview_head_lines": DEFAULT_INLINE_BASH_LOG_HEAD_LINES,
148
+ "log_preview_tail_lines": DEFAULT_INLINE_BASH_LOG_TAIL_LINES,
149
+ "log_preview_omitted_lines": omitted,
150
+ "log_read_hint": LONG_BASH_LOG_HINT,
151
+ }
152
+
153
+
154
+ def _build_bash_log_window_from_path(path: Path, *, start: int | None = None, tail: int | None = None) -> dict[str, Any]:
155
+ if not path.exists():
156
+ return {
157
+ "log": "",
158
+ "log_line_count": 0,
159
+ "log_windowed": True,
160
+ "line_start": 1,
161
+ "line_end": 0,
162
+ "line_limit": _normalize_bash_log_window_size(tail),
163
+ "returned_line_count": 0,
164
+ "has_more_before": False,
165
+ "has_more_after": False,
166
+ "log_read_hint": LONG_BASH_LOG_HINT,
167
+ }
168
+ line_limit = _normalize_bash_log_window_size(tail)
169
+ if start is not None:
170
+ requested_start = max(1, int(start))
171
+ selected: list[str] = []
172
+ total = 0
173
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
174
+ for raw_line in handle:
175
+ total += 1
176
+ if total < requested_start:
177
+ continue
178
+ if len(selected) < line_limit:
179
+ selected.append(raw_line.rstrip("\n"))
180
+ returned_count = len(selected)
181
+ line_start = requested_start if total else 1
182
+ line_end = requested_start + returned_count - 1 if returned_count else requested_start - 1
183
+ return {
184
+ "log": _join_bash_log_lines(selected),
185
+ "log_line_count": total,
186
+ "log_windowed": True,
187
+ "line_start": line_start,
188
+ "line_end": line_end,
189
+ "line_limit": line_limit,
190
+ "returned_line_count": returned_count,
191
+ "has_more_before": line_start > 1,
192
+ "has_more_after": line_end < total,
193
+ "log_read_hint": LONG_BASH_LOG_HINT,
194
+ }
195
+
196
+ tail_lines: deque[str] = deque(maxlen=line_limit)
197
+ total = 0
198
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
199
+ for raw_line in handle:
200
+ total += 1
201
+ tail_lines.append(raw_line.rstrip("\n"))
202
+ returned_count = len(tail_lines)
203
+ line_start = max(1, total - returned_count + 1) if total else 1
204
+ line_end = total
205
+ return {
206
+ "log": _join_bash_log_lines(list(tail_lines)),
207
+ "log_line_count": total,
208
+ "log_windowed": True,
209
+ "line_start": line_start,
210
+ "line_end": line_end,
211
+ "line_limit": line_limit,
212
+ "returned_line_count": returned_count,
213
+ "has_more_before": line_start > 1,
214
+ "has_more_after": False,
215
+ "log_read_hint": LONG_BASH_LOG_HINT,
216
+ }
217
+
218
+
98
219
  def build_memory_server(context: McpContext) -> FastMCP:
99
220
  service = MemoryService(context.home)
100
221
  server = FastMCP(
@@ -880,8 +1001,8 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
880
1001
  export_log_to=export_log_to,
881
1002
  )
882
1003
  payload.update(
883
- _build_bash_log_window(
884
- service.read_terminal_log(quest_root, bash_id),
1004
+ _build_bash_log_window_from_path(
1005
+ service.terminal_log_path(quest_root, bash_id),
885
1006
  start=start,
886
1007
  tail=tail if tail is not None else tail_limit,
887
1008
  )
@@ -921,7 +1042,7 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
921
1042
  export_log=export_log,
922
1043
  export_log_to=export_log_to,
923
1044
  )
924
- payload.update(_build_default_bash_log_payload(service.read_terminal_log(quest_root, bash_id)))
1045
+ payload.update(_build_default_bash_log_payload_from_path(service.terminal_log_path(quest_root, bash_id)))
925
1046
  return payload
926
1047
  if normalized_mode == "kill":
927
1048
  bash_id = service.resolve_session_id(quest_root, id)