@researai/deepscientist 1.5.1 → 1.5.3

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 (116) hide show
  1. package/README.md +69 -1
  2. package/bin/ds.js +2239 -153
  3. package/docs/en/00_QUICK_START.md +60 -20
  4. package/docs/en/01_SETTINGS_REFERENCE.md +20 -20
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +11 -11
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +10 -10
  7. package/docs/en/05_TUI_GUIDE.md +1 -1
  8. package/docs/en/09_DOCTOR.md +48 -4
  9. package/docs/en/90_ARCHITECTURE.md +4 -2
  10. package/docs/zh/00_QUICK_START.md +60 -20
  11. package/docs/zh/01_SETTINGS_REFERENCE.md +21 -21
  12. package/docs/zh/02_START_RESEARCH_GUIDE.md +19 -19
  13. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +10 -10
  14. package/docs/zh/05_TUI_GUIDE.md +1 -1
  15. package/docs/zh/09_DOCTOR.md +46 -4
  16. package/install.sh +125 -8
  17. package/package.json +2 -1
  18. package/pyproject.toml +1 -1
  19. package/src/deepscientist/__init__.py +6 -1
  20. package/src/deepscientist/artifact/service.py +553 -26
  21. package/src/deepscientist/bash_exec/monitor.py +23 -4
  22. package/src/deepscientist/bash_exec/runtime.py +3 -0
  23. package/src/deepscientist/bash_exec/service.py +132 -4
  24. package/src/deepscientist/bridges/base.py +10 -19
  25. package/src/deepscientist/channels/discord_gateway.py +25 -2
  26. package/src/deepscientist/channels/feishu_long_connection.py +41 -3
  27. package/src/deepscientist/channels/qq.py +524 -64
  28. package/src/deepscientist/channels/qq_gateway.py +22 -3
  29. package/src/deepscientist/channels/relay.py +429 -90
  30. package/src/deepscientist/channels/slack_socket.py +29 -5
  31. package/src/deepscientist/channels/telegram_polling.py +25 -2
  32. package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
  33. package/src/deepscientist/cli.py +27 -0
  34. package/src/deepscientist/config/models.py +6 -40
  35. package/src/deepscientist/config/service.py +165 -156
  36. package/src/deepscientist/connector_profiles.py +346 -0
  37. package/src/deepscientist/connector_runtime.py +88 -43
  38. package/src/deepscientist/daemon/api/handlers.py +65 -11
  39. package/src/deepscientist/daemon/api/router.py +4 -2
  40. package/src/deepscientist/daemon/app.py +772 -219
  41. package/src/deepscientist/doctor.py +69 -2
  42. package/src/deepscientist/gitops/diff.py +3 -0
  43. package/src/deepscientist/home.py +25 -2
  44. package/src/deepscientist/mcp/context.py +3 -1
  45. package/src/deepscientist/mcp/server.py +66 -7
  46. package/src/deepscientist/migration.py +114 -0
  47. package/src/deepscientist/prompts/builder.py +71 -3
  48. package/src/deepscientist/qq_profiles.py +186 -0
  49. package/src/deepscientist/quest/layout.py +1 -0
  50. package/src/deepscientist/quest/service.py +70 -12
  51. package/src/deepscientist/quest/stage_views.py +46 -0
  52. package/src/deepscientist/runners/codex.py +2 -0
  53. package/src/deepscientist/shared.py +44 -17
  54. package/src/prompts/connectors/lingzhu.md +3 -0
  55. package/src/prompts/connectors/qq.md +42 -2
  56. package/src/prompts/system.md +123 -10
  57. package/src/skills/analysis-campaign/SKILL.md +35 -6
  58. package/src/skills/baseline/SKILL.md +73 -32
  59. package/src/skills/decision/SKILL.md +4 -3
  60. package/src/skills/experiment/SKILL.md +28 -6
  61. package/src/skills/finalize/SKILL.md +5 -2
  62. package/src/skills/idea/SKILL.md +2 -2
  63. package/src/skills/intake-audit/SKILL.md +2 -2
  64. package/src/skills/rebuttal/SKILL.md +4 -2
  65. package/src/skills/review/SKILL.md +4 -2
  66. package/src/skills/scout/SKILL.md +2 -2
  67. package/src/skills/write/SKILL.md +2 -2
  68. package/src/tui/package.json +1 -1
  69. package/src/ui/dist/assets/{AiManusChatView-w5lF2Ttt.js → AiManusChatView-qzChi9uh.js} +67 -94
  70. package/src/ui/dist/assets/{AnalysisPlugin-DJOED79I.js → AnalysisPlugin-CcC_-UqN.js} +1 -1
  71. package/src/ui/dist/assets/{AutoFigurePlugin-DaG61Y0M.js → AutoFigurePlugin-DD8LkJLe.js} +5 -5
  72. package/src/ui/dist/assets/{CliPlugin-CV4LqUB_.js → CliPlugin-DJJFfVmW.js} +17 -110
  73. package/src/ui/dist/assets/{CodeEditorPlugin-DylfAea4.js → CodeEditorPlugin-CrjkHNLh.js} +8 -8
  74. package/src/ui/dist/assets/{CodeViewerPlugin-F7saY0LM.js → CodeViewerPlugin-obnD6G5R.js} +5 -5
  75. package/src/ui/dist/assets/{DocViewerPlugin-COP0c7jf.js → DocViewerPlugin-DB9SUQVd.js} +3 -3
  76. package/src/ui/dist/assets/{GitDiffViewerPlugin-CAS05pT9.js → GitDiffViewerPlugin-DZLlNlD2.js} +1 -1
  77. package/src/ui/dist/assets/{ImageViewerPlugin-Bco1CN_w.js → ImageViewerPlugin-BGwfDZ0Y.js} +5 -5
  78. package/src/ui/dist/assets/{LabCopilotPanel-CvMlCD99.js → LabCopilotPanel-dfLptQcR.js} +10 -10
  79. package/src/ui/dist/assets/{LabPlugin-BYankkE4.js → LabPlugin-CeGjAl3A.js} +1 -1
  80. package/src/ui/dist/assets/{LatexPlugin-LDSMR-t-.js → LatexPlugin-BBJ7kd1V.js} +7 -7
  81. package/src/ui/dist/assets/{MarkdownViewerPlugin-B7o80jgm.js → MarkdownViewerPlugin-DKZi7BcB.js} +4 -4
  82. package/src/ui/dist/assets/{MarketplacePlugin-CM6ZOcpC.js → MarketplacePlugin-C_k-9jD0.js} +3 -3
  83. package/src/ui/dist/assets/{NotebookEditor-Dc61cXmK.js → NotebookEditor-4R88_BMO.js} +1 -1
  84. package/src/ui/dist/assets/{PdfLoader-DWowuQwx.js → PdfLoader-DwEFQLrw.js} +1 -1
  85. package/src/ui/dist/assets/{PdfMarkdownPlugin-BsJM1q_a.js → PdfMarkdownPlugin-D-jdsqF8.js} +3 -3
  86. package/src/ui/dist/assets/{PdfViewerPlugin-DB2eEEFQ.js → PdfViewerPlugin-CmeBGDY0.js} +10 -10
  87. package/src/ui/dist/assets/{SearchPlugin-CraThSvt.js → SearchPlugin-Dlz2WKJ4.js} +1 -1
  88. package/src/ui/dist/assets/{Stepper-CgocRTPq.js → Stepper-ClOgzWM3.js} +1 -1
  89. package/src/ui/dist/assets/{TextViewerPlugin-B1JGhKtd.js → TextViewerPlugin-DDQWxibk.js} +4 -4
  90. package/src/ui/dist/assets/{VNCViewer-CclFC7FM.js → VNCViewer-CJXT0Nm8.js} +9 -9
  91. package/src/ui/dist/assets/{bibtex-D3IKsMl7.js → bibtex-DLr4Rtk4.js} +1 -1
  92. package/src/ui/dist/assets/{code-BP37Xx0p.js → code-DgKK408Y.js} +1 -1
  93. package/src/ui/dist/assets/{file-content-BAJSu-9r.js → file-content-6HBqQnvQ.js} +1 -1
  94. package/src/ui/dist/assets/{file-diff-panel-DUGeCTuy.js → file-diff-panel-Dhu0TbBM.js} +1 -1
  95. package/src/ui/dist/assets/{file-socket-CXc1Ojf7.js → file-socket-CP3iwVZG.js} +1 -1
  96. package/src/ui/dist/assets/{file-utils-2J21jt7M.js → file-utils-BsS-Aw68.js} +1 -1
  97. package/src/ui/dist/assets/{image-CMMmgvcn.js → image-ByeK-Zcv.js} +1 -1
  98. package/src/ui/dist/assets/{index-DmwmJmbW.js → index-BLjo5--a.js} +33610 -31016
  99. package/src/ui/dist/assets/{index-CWgMgpow.js → index-BdsE0uRz.js} +11 -11
  100. package/src/ui/dist/assets/{index-s7aHnNQ4.js → index-C-eX-N6A.js} +1 -1
  101. package/src/ui/dist/assets/{index-KGt-z-dD.css → index-CuQhlrR-.css} +2747 -2
  102. package/src/ui/dist/assets/{index-BaVumsQT.js → index-DyremSIv.js} +2 -2
  103. package/src/ui/dist/assets/{message-square-CQRfX0Am.js → message-square-DnagiLnc.js} +1 -1
  104. package/src/ui/dist/assets/{monaco-B4TbdsrF.js → monaco-4kBFeprs.js} +1 -1
  105. package/src/ui/dist/assets/{popover-B8Rokodk.js → popover-hRCXZzs2.js} +1 -1
  106. package/src/ui/dist/assets/{project-sync-D_i96KH4.js → project-sync-O_85YuP6.js} +1 -1
  107. package/src/ui/dist/assets/{sigma-D12PnzCN.js → sigma-DvKopSnL.js} +1 -1
  108. package/src/ui/dist/assets/{tooltip-B6YrI4aJ.js → tooltip-BmlPc6kc.js} +1 -1
  109. package/src/ui/dist/assets/{trash-Bc8jGp0V.js → trash-n-UvdZFR.js} +1 -1
  110. package/src/ui/dist/assets/{useCliAccess-mXVCYSZ-.js → useCliAccess-WDd3_wIh.js} +1 -1
  111. package/src/ui/dist/assets/{useFileDiffOverlay-Bg6b9H9K.js → useFileDiffOverlay-rXLIL2NF.js} +1 -1
  112. package/src/ui/dist/assets/{wrap-text-Drh5GEnL.js → wrap-text-qIYQ4a_W.js} +1 -1
  113. package/src/ui/dist/assets/{zoom-out-CJj9DZLn.js → zoom-out-fZXCEFsy.js} +1 -1
  114. package/src/ui/dist/index.html +2 -2
  115. package/uv.lock +1155 -0
  116. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +0 -2698
@@ -202,6 +202,18 @@ def _terminate_process(process: subprocess.Popen[bytes], process_group_id: int |
202
202
  process.kill()
203
203
 
204
204
 
205
+ def _terminate_process_force(process: subprocess.Popen[bytes], process_group_id: int | None) -> None:
206
+ if process.poll() is not None:
207
+ return
208
+ if isinstance(process_group_id, int) and process_group_id > 0:
209
+ try:
210
+ os.killpg(process_group_id, signal.SIGKILL)
211
+ except ProcessLookupError:
212
+ return
213
+ else:
214
+ process.kill()
215
+
216
+
205
217
  def _drain_buffer(
206
218
  buffer: str,
207
219
  append_line,
@@ -350,12 +362,15 @@ def run_monitor(session_dir: Path) -> int:
350
362
  },
351
363
  )
352
364
  progress = _parse_progress_marker(line)
365
+ output_updates: dict[str, Any] = {}
366
+ if stream not in {"system", "prompt"}:
367
+ output_updates = {"last_output_at": timestamp, "last_output_seq": seq}
353
368
  if progress is not None:
354
369
  progress.setdefault("ts", timestamp)
355
370
  _atomic_write_json(progress_path, progress)
356
- update_meta(last_progress=progress, latest_seq=seq)
371
+ update_meta(last_progress=progress, latest_seq=seq, **output_updates)
357
372
  else:
358
- update_meta(latest_seq=seq)
373
+ update_meta(latest_seq=seq, **output_updates)
359
374
 
360
375
  master_fd: int | None = None
361
376
  slave_fd: int | None = None
@@ -410,16 +425,20 @@ def run_monitor(session_dir: Path) -> int:
410
425
  if not stop_requested and stop_request_path.exists():
411
426
  request = read_json(stop_request_path, {}) or {}
412
427
  stop_reason = str(request.get("reason") or "user_stop").strip() or "user_stop"
428
+ force_stop = bool(request.get("force"))
413
429
  update_meta(
414
430
  status="terminating",
415
431
  stop_reason=stop_reason,
416
432
  stopped_by_user_id=str(request.get("user_id") or meta.get("stopped_by_user_id") or meta.get("agent_id") or "agent"),
417
433
  )
418
434
  append_line(
419
- f"Termination requested: {stop_reason}",
435
+ f"{'Force t' if force_stop else 'T'}ermination requested: {stop_reason}",
420
436
  stream="system",
421
437
  )
422
- _terminate_process(process, process_group_id)
438
+ if force_stop:
439
+ _terminate_process_force(process, process_group_id)
440
+ else:
441
+ _terminate_process(process, process_group_id)
423
442
  stop_requested = True
424
443
 
425
444
  if deadline is not None and time.monotonic() >= deadline and process.poll() is None and not stop_requested:
@@ -390,6 +390,9 @@ class TerminalRuntime:
390
390
  progress.setdefault("ts", timestamp)
391
391
  _atomic_write_json(self.meta_path.parent / "progress.json", progress)
392
392
  meta["last_progress"] = progress
393
+ if stream not in {"system", "prompt"}:
394
+ meta["last_output_at"] = timestamp
395
+ meta["last_output_seq"] = seq
393
396
  meta["latest_seq"] = seq
394
397
  meta["updated_at"] = timestamp
395
398
  _atomic_write_json(self.meta_path, meta)
@@ -11,6 +11,7 @@ import sys
11
11
  import tempfile
12
12
  import threading
13
13
  import time
14
+ from datetime import UTC, datetime
14
15
  from pathlib import Path
15
16
  from typing import Any
16
17
 
@@ -26,6 +27,7 @@ DEFAULT_LOG_TAIL_LIMIT = 200
26
27
  DEFAULT_POLL_INTERVAL_SECONDS = 0.35
27
28
  TERMINAL_STATUSES = {"completed", "failed", "terminated"}
28
29
  DEFAULT_TERMINAL_SESSION_ID = "terminal-main"
30
+ BASH_WATCHDOG_AFTER_SECONDS = 1800
29
31
  INPUT_ESCAPE_SEQUENCE_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-_]")
30
32
 
31
33
 
@@ -89,6 +91,48 @@ def _parse_progress_marker(line: str) -> dict[str, Any] | None:
89
91
  return payload
90
92
 
91
93
 
94
+ def _parse_timestamp(value: object) -> datetime | None:
95
+ normalized = _normalize_string(value)
96
+ if not normalized:
97
+ return None
98
+ try:
99
+ parsed = datetime.fromisoformat(normalized)
100
+ except ValueError:
101
+ return None
102
+ if parsed.tzinfo is None:
103
+ parsed = parsed.replace(tzinfo=UTC)
104
+ return parsed.astimezone(UTC)
105
+
106
+
107
+ def _age_seconds(value: object, *, now: datetime | None = None) -> int | None:
108
+ parsed = _parse_timestamp(value)
109
+ if parsed is None:
110
+ return None
111
+ current = now or datetime.now(UTC)
112
+ return max(0, int((current - parsed).total_seconds()))
113
+
114
+
115
+ def _latest_timestamp(*values: object) -> str | None:
116
+ latest_raw: str | None = None
117
+ latest_dt: datetime | None = None
118
+ for value in values:
119
+ normalized = _normalize_string(value)
120
+ parsed = _parse_timestamp(normalized)
121
+ if parsed is None:
122
+ continue
123
+ if latest_dt is None or parsed >= latest_dt:
124
+ latest_dt = parsed
125
+ latest_raw = normalized
126
+ return latest_raw
127
+
128
+
129
+ def _compact_command(command: object, *, max_length: int = 140) -> str:
130
+ normalized = " ".join(str(command or "").split())
131
+ if len(normalized) <= max_length:
132
+ return normalized
133
+ return normalized[: max(0, max_length - 3)].rstrip() + "..."
134
+
135
+
92
136
  class BashExecService:
93
137
  def __init__(self, home: Path) -> None:
94
138
  self.home = home
@@ -224,8 +268,45 @@ class BashExecService:
224
268
  payload["status"] = _coerce_session_status(payload.get("status"))
225
269
  payload["last_progress"] = payload.get("last_progress") or read_json(self.progress_path(quest_root, str(payload.get("bash_id") or "")), None)
226
270
  payload["kind"] = str(payload.get("kind") or "exec")
271
+ payload = self._enrich_watchdog_fields(payload)
227
272
  return payload
228
273
 
274
+ @staticmethod
275
+ def _enrich_watchdog_fields(payload: dict[str, Any]) -> dict[str, Any]:
276
+ current = datetime.now(UTC)
277
+ last_progress = payload.get("last_progress")
278
+ last_progress_at = None
279
+ if isinstance(last_progress, dict):
280
+ last_progress_at = _normalize_string(last_progress.get("ts")) or None
281
+ last_output_at = _normalize_string(payload.get("last_output_at")) or None
282
+ latest_signal_at = _latest_timestamp(last_output_at, last_progress_at, payload.get("started_at"))
283
+ payload["last_progress_at"] = last_progress_at
284
+ payload["run_age_seconds"] = _age_seconds(payload.get("started_at"), now=current)
285
+ payload["status_age_seconds"] = _age_seconds(payload.get("updated_at"), now=current)
286
+ payload["silent_seconds"] = _age_seconds(last_output_at or payload.get("started_at"), now=current)
287
+ payload["progress_age_seconds"] = _age_seconds(last_progress_at, now=current)
288
+ payload["latest_signal_at"] = latest_signal_at
289
+ payload["signal_age_seconds"] = _age_seconds(latest_signal_at, now=current)
290
+ payload["watchdog_after_seconds"] = BASH_WATCHDOG_AFTER_SECONDS
291
+ payload["watchdog_overdue"] = (
292
+ payload.get("status") in {"running", "terminating"}
293
+ and isinstance(payload.get("signal_age_seconds"), int)
294
+ and int(payload["signal_age_seconds"]) >= BASH_WATCHDOG_AFTER_SECONDS
295
+ )
296
+ return payload
297
+
298
+ @staticmethod
299
+ def format_history_line(session: dict[str, Any]) -> str:
300
+ timestamp = (
301
+ _normalize_string(session.get("started_at"))
302
+ or _normalize_string(session.get("updated_at"))
303
+ or _normalize_string(session.get("finished_at"))
304
+ or "unknown-time"
305
+ )
306
+ command = _compact_command(session.get("command"))
307
+ bash_id = _normalize_string(session.get("bash_id") or session.get("id")) or "unknown-id"
308
+ return f"{timestamp} | {command} | {bash_id}"
309
+
229
310
  @staticmethod
230
311
  def _summary_session_payload(meta: dict[str, Any]) -> dict[str, Any]:
231
312
  return {
@@ -233,6 +314,7 @@ class BashExecService:
233
314
  "command": meta.get("command"),
234
315
  "kind": meta.get("kind") or "exec",
235
316
  "label": meta.get("label"),
317
+ "comment": meta.get("comment"),
236
318
  "workdir": meta.get("workdir"),
237
319
  "status": _coerce_session_status(meta.get("status")),
238
320
  "exit_code": meta.get("exit_code"),
@@ -241,6 +323,8 @@ class BashExecService:
241
323
  "finished_at": meta.get("finished_at"),
242
324
  "updated_at": meta.get("updated_at"),
243
325
  "last_progress": meta.get("last_progress"),
326
+ "last_output_at": meta.get("last_output_at"),
327
+ "last_output_seq": meta.get("last_output_seq"),
244
328
  }
245
329
 
246
330
  @staticmethod
@@ -469,6 +553,7 @@ class BashExecService:
469
553
  *,
470
554
  limit: int = DEFAULT_LOG_TAIL_LIMIT,
471
555
  before_seq: int | None = None,
556
+ after_seq: int | None = None,
472
557
  order: str = "asc",
473
558
  ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
474
559
  if not self.meta_path(quest_root, bash_id).exists():
@@ -487,10 +572,13 @@ class BashExecService:
487
572
  else:
488
573
  time.sleep(0.03)
489
574
  entries = read_jsonl(self.log_path(quest_root, bash_id))
575
+ latest_seq = int(entries[-1].get("seq") or 0) if entries else 0
490
576
  normalized_before = before_seq if isinstance(before_seq, int) and before_seq > 0 else None
577
+ normalized_after = after_seq if isinstance(after_seq, int) and after_seq >= 0 else None
578
+ if normalized_after is not None:
579
+ entries = [entry for entry in entries if int(entry.get("seq") or 0) > normalized_after]
491
580
  if normalized_before is not None:
492
581
  entries = [entry for entry in entries if int(entry.get("seq") or 0) < normalized_before]
493
- latest_seq = int(entries[-1].get("seq") or 0) if entries else 0
494
582
  normalized_limit = max(1, limit)
495
583
  truncated = len(entries) > normalized_limit
496
584
  selected = entries[-normalized_limit:]
@@ -501,6 +589,8 @@ class BashExecService:
501
589
  "tail_limit": normalized_limit,
502
590
  "tail_start_seq": tail_start_seq if truncated else tail_start_seq,
503
591
  "latest_seq": latest_seq or None,
592
+ "after_seq": normalized_after,
593
+ "before_seq": normalized_before,
504
594
  }
505
595
  return selected, meta
506
596
 
@@ -521,7 +611,15 @@ class BashExecService:
521
611
  return session
522
612
  time.sleep(max(0.1, poll_interval))
523
613
 
524
- def request_stop(self, quest_root: Path, bash_id: str, *, reason: str | None = None, user_id: str | None = None) -> dict[str, Any]:
614
+ def request_stop(
615
+ self,
616
+ quest_root: Path,
617
+ bash_id: str,
618
+ *,
619
+ reason: str | None = None,
620
+ user_id: str | None = None,
621
+ force: bool = False,
622
+ ) -> dict[str, Any]:
525
623
  session = self.get_session(quest_root, bash_id)
526
624
  status = _normalize_string(session.get("status")).lower()
527
625
  if status in TERMINAL_STATUSES:
@@ -530,6 +628,7 @@ class BashExecService:
530
628
  "reason": _normalize_string(reason) or "user_stop",
531
629
  "user_id": _normalize_string(user_id) or _normalize_string(session.get("agent_id")) or "agent",
532
630
  "requested_at": utc_now(),
631
+ "force": bool(force),
533
632
  }
534
633
  _atomic_write_json(self.stop_request_path(quest_root, bash_id), request_payload)
535
634
  meta = read_json(self.meta_path(quest_root, bash_id), {})
@@ -540,12 +639,18 @@ class BashExecService:
540
639
  self._write_meta(quest_root, bash_id, meta)
541
640
  runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
542
641
  if runtime is not None:
543
- runtime.stop(reason=request_payload["reason"], force=False)
642
+ runtime.stop(reason=request_payload["reason"], force=bool(force))
544
643
  else:
545
644
  process_group_id = meta.get("process_group_id")
645
+ process_pid = meta.get("process_pid")
546
646
  if isinstance(process_group_id, int) and process_group_id > 0:
547
647
  try:
548
- os.killpg(process_group_id, signal.SIGTERM)
648
+ os.killpg(process_group_id, signal.SIGKILL if force else signal.SIGTERM)
649
+ except ProcessLookupError:
650
+ pass
651
+ elif isinstance(process_pid, int) and process_pid > 0:
652
+ try:
653
+ os.kill(process_pid, signal.SIGKILL if force else signal.SIGTERM)
549
654
  except ProcessLookupError:
550
655
  pass
551
656
  return self._session_payload(quest_root, meta)
@@ -561,6 +666,7 @@ class BashExecService:
561
666
  workdir_display: str,
562
667
  timeout_seconds: int | None,
563
668
  env_keys: list[str],
669
+ comment: str | dict[str, Any] | None = None,
564
670
  kind: str = "exec",
565
671
  ) -> dict[str, Any]:
566
672
  quest_root = context.require_quest_root().resolve()
@@ -582,6 +688,7 @@ class BashExecService:
582
688
  "agent_instance_id": agent_instance_id,
583
689
  "started_by_user_id": started_by_user_id,
584
690
  "stopped_by_user_id": None,
691
+ "comment": comment,
585
692
  "command": command,
586
693
  "workdir": workdir_display,
587
694
  "cwd": str(cwd),
@@ -592,6 +699,8 @@ class BashExecService:
592
699
  "exit_code": None,
593
700
  "stop_reason": None,
594
701
  "last_progress": None,
702
+ "last_output_at": None,
703
+ "last_output_seq": None,
595
704
  "started_at": timestamp,
596
705
  "finished_at": None,
597
706
  "updated_at": timestamp,
@@ -638,6 +747,7 @@ class BashExecService:
638
747
  workdir: str | None = None,
639
748
  env: dict[str, Any] | None = None,
640
749
  timeout_seconds: int | None = None,
750
+ comment: str | dict[str, Any] | None = None,
641
751
  ) -> dict[str, Any]:
642
752
  if not _normalize_string(command):
643
753
  raise ValueError("command_required")
@@ -656,6 +766,7 @@ class BashExecService:
656
766
  workdir_display=workdir_display,
657
767
  timeout_seconds=timeout_seconds,
658
768
  env_keys=sorted(env_payload),
769
+ comment=comment,
659
770
  kind="exec",
660
771
  )
661
772
  self.terminal_log_path(quest_root, bash_id).touch()
@@ -668,6 +779,7 @@ class BashExecService:
668
779
  "bash_id": bash_id,
669
780
  "quest_id": meta["quest_id"],
670
781
  "command": command,
782
+ "comment": comment,
671
783
  "workdir": workdir_display,
672
784
  "mode": mode,
673
785
  "started_at": meta["started_at"],
@@ -725,6 +837,8 @@ class BashExecService:
725
837
  "exit_code": None,
726
838
  "stop_reason": None,
727
839
  "last_progress": None,
840
+ "last_output_at": None,
841
+ "last_output_seq": None,
728
842
  "started_at": timestamp,
729
843
  "finished_at": None,
730
844
  "updated_at": timestamp,
@@ -1054,6 +1168,9 @@ class BashExecService:
1054
1168
  "bash_id": session["bash_id"],
1055
1169
  "log_path": session.get("log_path"),
1056
1170
  "status": session.get("status"),
1171
+ "kind": session.get("kind"),
1172
+ "comment": session.get("comment"),
1173
+ "label": session.get("label"),
1057
1174
  "command": session.get("command"),
1058
1175
  "workdir": session.get("workdir"),
1059
1176
  "started_at": session.get("started_at"),
@@ -1061,6 +1178,17 @@ class BashExecService:
1061
1178
  "exit_code": session.get("exit_code"),
1062
1179
  "stop_reason": session.get("stop_reason"),
1063
1180
  "last_progress": session.get("last_progress"),
1181
+ "last_progress_at": session.get("last_progress_at"),
1182
+ "last_output_at": session.get("last_output_at"),
1183
+ "last_output_seq": session.get("last_output_seq"),
1184
+ "run_age_seconds": session.get("run_age_seconds"),
1185
+ "status_age_seconds": session.get("status_age_seconds"),
1186
+ "silent_seconds": session.get("silent_seconds"),
1187
+ "progress_age_seconds": session.get("progress_age_seconds"),
1188
+ "latest_signal_at": session.get("latest_signal_at"),
1189
+ "signal_age_seconds": session.get("signal_age_seconds"),
1190
+ "watchdog_after_seconds": session.get("watchdog_after_seconds"),
1191
+ "watchdog_overdue": session.get("watchdog_overdue"),
1064
1192
  }
1065
1193
  if include_log:
1066
1194
  result["log"] = self.read_terminal_log(quest_root, str(session["bash_id"]))
@@ -10,6 +10,8 @@ from urllib.error import URLError
10
10
  from urllib.parse import parse_qs
11
11
  from urllib.request import Request, urlopen
12
12
 
13
+ from ..connector_runtime import parse_conversation_id
14
+
13
15
 
14
16
  @dataclass
15
17
  class BridgeWebhookResult:
@@ -52,19 +54,6 @@ class BaseConnectorBridge:
52
54
  }
53
55
 
54
56
  def deliver(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any] | None:
55
- relay_url = str(config.get("relay_url") or "").strip()
56
- if relay_url:
57
- envelope = {
58
- "bridge_version": "deepscientist-connector-bridge/v1",
59
- "connector": self.name,
60
- "payload": self.format_outbound(payload, config),
61
- "normalized_payload": payload,
62
- }
63
- headers = {"Content-Type": "application/json; charset=utf-8"}
64
- token = str(config.get("relay_auth_token") or "").strip()
65
- if token:
66
- headers["Authorization"] = f"Bearer {token}"
67
- return self._post_json(relay_url, envelope, headers=headers)
68
57
  return self.deliver_direct(payload, config)
69
58
 
70
59
  def deliver_direct(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any] | None:
@@ -72,18 +61,20 @@ class BaseConnectorBridge:
72
61
 
73
62
  @staticmethod
74
63
  def extract_target(conversation_id: Any) -> dict[str, str]:
75
- raw = str(conversation_id or "").strip()
76
- parts = raw.split(":", 2)
77
- if len(parts) == 3:
64
+ parsed = parse_conversation_id(conversation_id)
65
+ if parsed is not None:
78
66
  return {
79
- "connector": parts[0],
80
- "chat_type": parts[1],
81
- "chat_id": parts[2],
67
+ "connector": str(parsed.get("connector") or ""),
68
+ "chat_type": str(parsed.get("chat_type") or ""),
69
+ "chat_id": str(parsed.get("chat_id") or ""),
70
+ "profile_id": str(parsed.get("profile_id") or ""),
82
71
  }
72
+ raw = str(conversation_id or "").strip()
83
73
  return {
84
74
  "connector": "",
85
75
  "chat_type": "",
86
76
  "chat_id": raw,
77
+ "profile_id": "",
87
78
  }
88
79
 
89
80
  @staticmethod
@@ -10,6 +10,7 @@ from urllib.request import Request, urlopen
10
10
  from websockets.exceptions import ConnectionClosed
11
11
  from websockets.sync.client import connect as websocket_connect
12
12
 
13
+ from ..connector_runtime import format_conversation_id
13
14
  from ..shared import read_json, utc_now, write_json
14
15
 
15
16
 
@@ -24,11 +25,17 @@ class DiscordGatewayService:
24
25
  config: dict[str, Any],
25
26
  on_event: Callable[[dict[str, Any]], None],
26
27
  log: Callable[[str, str], None] | None = None,
28
+ profile_id: str | None = None,
29
+ profile_label: str | None = None,
30
+ encode_profile_id: bool = False,
27
31
  ) -> None:
28
32
  self.home = home
29
33
  self.config = config
30
34
  self.on_event = on_event
31
35
  self.log = log or self._default_log
36
+ self.profile_id = str(profile_id or "").strip() or None
37
+ self.profile_label = str(profile_label or "").strip() or None
38
+ self._encode_profile_id = bool(encode_profile_id and self.profile_id)
32
39
  self._thread: threading.Thread | None = None
33
40
  self._stop_event = threading.Event()
34
41
  self._heartbeat_stop = threading.Event()
@@ -37,6 +44,8 @@ class DiscordGatewayService:
37
44
  self._seq: int | None = None
38
45
  self._bot_user_id: str | None = None
39
46
  self._root = home / "logs" / "connectors" / "discord"
47
+ if self.profile_id:
48
+ self._root = self._root / "profiles" / self.profile_id
40
49
  self._runtime_path = self._root / "runtime.json"
41
50
 
42
51
  def start(self) -> bool:
@@ -71,7 +80,7 @@ class DiscordGatewayService:
71
80
  self._thread = threading.Thread(
72
81
  target=self._run,
73
82
  daemon=True,
74
- name="deepscientist-discord-gateway",
83
+ name=f"deepscientist-discord-gateway-{self.profile_id or 'default'}",
75
84
  )
76
85
  self._thread.start()
77
86
  return True
@@ -288,12 +297,22 @@ class DiscordGatewayService:
288
297
  "sender_id": sender_id,
289
298
  "sender_name": str(author.get("global_name") or author.get("username") or sender_id).strip(),
290
299
  "message_id": str(data.get("id") or "").strip(),
291
- "conversation_id": f"discord:{chat_type}:{channel_id}",
300
+ "conversation_id": self._conversation_id(chat_type, channel_id),
301
+ "profile_id": self.profile_id,
302
+ "profile_label": self.profile_label,
292
303
  "text": normalized_text,
293
304
  "mentioned": mentioned,
294
305
  "raw_event": data,
295
306
  }
296
307
 
308
+ def _conversation_id(self, chat_type: str, chat_id: str) -> str:
309
+ return format_conversation_id(
310
+ "discord",
311
+ chat_type,
312
+ chat_id,
313
+ profile_id=self.profile_id if self._encode_profile_id else None,
314
+ )
315
+
297
316
  @staticmethod
298
317
  def _strip_bot_mention(text: str, bot_user_id: str) -> str:
299
318
  cleaned = str(text or "").strip()
@@ -354,6 +373,10 @@ class DiscordGatewayService:
354
373
  state = read_json(self._runtime_path, {}) or {}
355
374
  if not isinstance(state, dict):
356
375
  state = {}
376
+ if self.profile_id:
377
+ state["profile_id"] = self.profile_id
378
+ if self.profile_label:
379
+ state["profile_label"] = self.profile_label
357
380
  state.update(patch)
358
381
  write_json(self._runtime_path, state)
359
382
 
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import Any, Callable
10
10
 
11
11
  from ..bridges.connectors import FeishuConnectorBridge
12
+ from ..connector_runtime import format_conversation_id, parse_conversation_id
12
13
  from ..shared import read_json, utc_now, write_json
13
14
 
14
15
 
@@ -29,17 +30,25 @@ class FeishuLongConnectionService:
29
30
  config: dict[str, Any],
30
31
  on_event: Callable[[dict[str, Any]], None],
31
32
  log: Callable[[str, str], None] | None = None,
33
+ profile_id: str | None = None,
34
+ profile_label: str | None = None,
35
+ encode_profile_id: bool = False,
32
36
  ) -> None:
33
37
  self.home = home
34
38
  self.config = config
35
39
  self.on_event = on_event
36
40
  self.log = log or self._default_log
41
+ self.profile_id = str(profile_id or "").strip() or None
42
+ self.profile_label = str(profile_label or "").strip() or None
43
+ self._encode_profile_id = bool(encode_profile_id and self.profile_id)
37
44
  self._thread: threading.Thread | None = None
38
45
  self._stop_event = threading.Event()
39
46
  self._loop: asyncio.AbstractEventLoop | None = None
40
47
  self._async_stop: asyncio.Event | None = None
41
48
  self._client: Any = None
42
49
  self._root = home / "logs" / "connectors" / "feishu"
50
+ if self.profile_id:
51
+ self._root = self._root / "profiles" / self.profile_id
43
52
  self._runtime_path = self._root / "runtime.json"
44
53
 
45
54
  def start(self) -> bool:
@@ -85,7 +94,7 @@ class FeishuLongConnectionService:
85
94
  self._thread = threading.Thread(
86
95
  target=self._run,
87
96
  daemon=True,
88
- name="deepscientist-feishu-long-connection",
97
+ name=f"deepscientist-feishu-long-connection-{self.profile_id or 'default'}",
89
98
  )
90
99
  self._thread.start()
91
100
  return True
@@ -201,16 +210,41 @@ class FeishuLongConnectionService:
201
210
  config=self.config,
202
211
  )
203
212
  for event in result.events:
204
- self.on_event(event)
213
+ normalized = self._normalize_event(event)
214
+ self.on_event(normalized)
205
215
  self._write_state(
206
216
  connected=True,
207
217
  connection_state="connected",
208
218
  auth_state="ready",
209
219
  last_event_at=utc_now(),
210
- last_conversation_id=event.get("conversation_id"),
220
+ last_conversation_id=normalized.get("conversation_id"),
211
221
  updated_at=utc_now(),
212
222
  )
213
223
 
224
+ def _normalize_event(self, event: dict[str, Any]) -> dict[str, Any]:
225
+ normalized = dict(event)
226
+ chat_type = str(normalized.get("chat_type") or "direct").strip().lower() or "direct"
227
+ group_id = str(normalized.get("group_id") or "").strip()
228
+ direct_id = str(normalized.get("direct_id") or "").strip()
229
+ chat_id = group_id if chat_type == "group" else direct_id
230
+ if not chat_id:
231
+ parsed = parse_conversation_id(normalized.get("conversation_id"))
232
+ if parsed is not None:
233
+ chat_type = str(parsed.get("chat_type") or chat_type).strip().lower() or chat_type
234
+ chat_id = str(parsed.get("chat_id") or "").strip()
235
+ normalized["conversation_id"] = self._conversation_id(chat_type, chat_id or "unknown")
236
+ normalized["profile_id"] = self.profile_id
237
+ normalized["profile_label"] = self.profile_label
238
+ return normalized
239
+
240
+ def _conversation_id(self, chat_type: str, chat_id: str) -> str:
241
+ return format_conversation_id(
242
+ "feishu",
243
+ chat_type,
244
+ chat_id,
245
+ profile_id=self.profile_id if self._encode_profile_id else None,
246
+ )
247
+
214
248
  @staticmethod
215
249
  def _sdk_bundle() -> dict[str, Any] | None:
216
250
  try:
@@ -239,6 +273,10 @@ class FeishuLongConnectionService:
239
273
  state = read_json(self._runtime_path, {}) or {}
240
274
  if not isinstance(state, dict):
241
275
  state = {}
276
+ if self.profile_id:
277
+ state["profile_id"] = self.profile_id
278
+ if self.profile_label:
279
+ state["profile_label"] = self.profile_label
242
280
  state.update(patch)
243
281
  write_json(self._runtime_path, state)
244
282