@researai/deepscientist 1.5.2 → 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 (93) hide show
  1. package/README.md +22 -0
  2. package/bin/ds.js +384 -0
  3. package/docs/en/00_QUICK_START.md +22 -0
  4. package/docs/zh/00_QUICK_START.md +22 -0
  5. package/install.sh +120 -4
  6. package/package.json +1 -1
  7. package/pyproject.toml +1 -1
  8. package/src/deepscientist/__init__.py +1 -1
  9. package/src/deepscientist/artifact/service.py +1 -1
  10. package/src/deepscientist/bash_exec/monitor.py +23 -4
  11. package/src/deepscientist/bash_exec/runtime.py +3 -0
  12. package/src/deepscientist/bash_exec/service.py +132 -4
  13. package/src/deepscientist/bridges/base.py +10 -19
  14. package/src/deepscientist/channels/discord_gateway.py +25 -2
  15. package/src/deepscientist/channels/feishu_long_connection.py +41 -3
  16. package/src/deepscientist/channels/qq.py +524 -64
  17. package/src/deepscientist/channels/qq_gateway.py +22 -3
  18. package/src/deepscientist/channels/relay.py +429 -90
  19. package/src/deepscientist/channels/slack_socket.py +29 -5
  20. package/src/deepscientist/channels/telegram_polling.py +25 -2
  21. package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
  22. package/src/deepscientist/cli.py +27 -0
  23. package/src/deepscientist/config/models.py +6 -40
  24. package/src/deepscientist/config/service.py +164 -155
  25. package/src/deepscientist/connector_profiles.py +346 -0
  26. package/src/deepscientist/connector_runtime.py +88 -43
  27. package/src/deepscientist/daemon/api/handlers.py +47 -10
  28. package/src/deepscientist/daemon/api/router.py +2 -2
  29. package/src/deepscientist/daemon/app.py +682 -218
  30. package/src/deepscientist/mcp/server.py +60 -7
  31. package/src/deepscientist/migration.py +114 -0
  32. package/src/deepscientist/prompts/builder.py +30 -3
  33. package/src/deepscientist/qq_profiles.py +186 -0
  34. package/src/prompts/connectors/qq.md +42 -2
  35. package/src/prompts/system.md +85 -5
  36. package/src/skills/analysis-campaign/SKILL.md +11 -5
  37. package/src/skills/baseline/SKILL.md +66 -31
  38. package/src/skills/decision/SKILL.md +1 -1
  39. package/src/skills/experiment/SKILL.md +11 -5
  40. package/src/skills/finalize/SKILL.md +1 -1
  41. package/src/skills/idea/SKILL.md +1 -1
  42. package/src/skills/intake-audit/SKILL.md +1 -1
  43. package/src/skills/rebuttal/SKILL.md +1 -1
  44. package/src/skills/review/SKILL.md +1 -1
  45. package/src/skills/scout/SKILL.md +1 -1
  46. package/src/skills/write/SKILL.md +1 -1
  47. package/src/tui/package.json +1 -1
  48. package/src/ui/dist/assets/{AiManusChatView-CZpg376x.js → AiManusChatView-qzChi9uh.js} +14 -37
  49. package/src/ui/dist/assets/{AnalysisPlugin-CtHA22g3.js → AnalysisPlugin-CcC_-UqN.js} +1 -1
  50. package/src/ui/dist/assets/{AutoFigurePlugin-BSWmLMmF.js → AutoFigurePlugin-DD8LkJLe.js} +5 -5
  51. package/src/ui/dist/assets/{CliPlugin-CJ7jdm_s.js → CliPlugin-DJJFfVmW.js} +17 -110
  52. package/src/ui/dist/assets/{CodeEditorPlugin-DhInVGFf.js → CodeEditorPlugin-CrjkHNLh.js} +8 -8
  53. package/src/ui/dist/assets/{CodeViewerPlugin-D1n8S9r5.js → CodeViewerPlugin-obnD6G5R.js} +5 -5
  54. package/src/ui/dist/assets/{DocViewerPlugin-C4XM_kqk.js → DocViewerPlugin-DB9SUQVd.js} +3 -3
  55. package/src/ui/dist/assets/{GitDiffViewerPlugin-W6kS9r6v.js → GitDiffViewerPlugin-DZLlNlD2.js} +1 -1
  56. package/src/ui/dist/assets/{ImageViewerPlugin-DPeUx_Oz.js → ImageViewerPlugin-BGwfDZ0Y.js} +5 -5
  57. package/src/ui/dist/assets/{LabCopilotPanel-eAelUaub.js → LabCopilotPanel-dfLptQcR.js} +10 -10
  58. package/src/ui/dist/assets/{LabPlugin-BbOrBxKY.js → LabPlugin-CeGjAl3A.js} +1 -1
  59. package/src/ui/dist/assets/{LatexPlugin-C-HhkVXY.js → LatexPlugin-BBJ7kd1V.js} +7 -7
  60. package/src/ui/dist/assets/{MarkdownViewerPlugin-BDIzIBfh.js → MarkdownViewerPlugin-DKZi7BcB.js} +4 -4
  61. package/src/ui/dist/assets/{MarketplacePlugin-DAOJphwr.js → MarketplacePlugin-C_k-9jD0.js} +3 -3
  62. package/src/ui/dist/assets/{NotebookEditor-BsoMvDoU.js → NotebookEditor-4R88_BMO.js} +1 -1
  63. package/src/ui/dist/assets/{PdfLoader-fiC7RtHf.js → PdfLoader-DwEFQLrw.js} +1 -1
  64. package/src/ui/dist/assets/{PdfMarkdownPlugin-C5OxZBFK.js → PdfMarkdownPlugin-D-jdsqF8.js} +3 -3
  65. package/src/ui/dist/assets/{PdfViewerPlugin-CAbxQebk.js → PdfViewerPlugin-CmeBGDY0.js} +10 -10
  66. package/src/ui/dist/assets/{SearchPlugin-SE33Lb9B.js → SearchPlugin-Dlz2WKJ4.js} +1 -1
  67. package/src/ui/dist/assets/{Stepper-0Av7GfV7.js → Stepper-ClOgzWM3.js} +1 -1
  68. package/src/ui/dist/assets/{TextViewerPlugin-Daf2gJDI.js → TextViewerPlugin-DDQWxibk.js} +4 -4
  69. package/src/ui/dist/assets/{VNCViewer-BKrMUIOX.js → VNCViewer-CJXT0Nm8.js} +9 -9
  70. package/src/ui/dist/assets/{bibtex-JBdOEe45.js → bibtex-DLr4Rtk4.js} +1 -1
  71. package/src/ui/dist/assets/{code-B0TDFCZz.js → code-DgKK408Y.js} +1 -1
  72. package/src/ui/dist/assets/{file-content-3YtrSacz.js → file-content-6HBqQnvQ.js} +1 -1
  73. package/src/ui/dist/assets/{file-diff-panel-CJEg5OG1.js → file-diff-panel-Dhu0TbBM.js} +1 -1
  74. package/src/ui/dist/assets/{file-socket-CYQYdmB1.js → file-socket-CP3iwVZG.js} +1 -1
  75. package/src/ui/dist/assets/{file-utils-Cd1C9Ppl.js → file-utils-BsS-Aw68.js} +1 -1
  76. package/src/ui/dist/assets/{image-B33ctrvC.js → image-ByeK-Zcv.js} +1 -1
  77. package/src/ui/dist/assets/{index-BVXsmS7V.js → index-BLjo5--a.js} +9499 -8688
  78. package/src/ui/dist/assets/{index-BNQWqmJ2.js → index-BdsE0uRz.js} +11 -11
  79. package/src/ui/dist/assets/{index-9CLPVeZh.js → index-C-eX-N6A.js} +1 -1
  80. package/src/ui/dist/assets/{index-SwmFAld3.css → index-CuQhlrR-.css} +49 -2
  81. package/src/ui/dist/assets/{index-Buw_N1VQ.js → index-DyremSIv.js} +2 -2
  82. package/src/ui/dist/assets/{message-square-D0cUJ9yU.js → message-square-DnagiLnc.js} +1 -1
  83. package/src/ui/dist/assets/{monaco-UZLYkp2n.js → monaco-4kBFeprs.js} +1 -1
  84. package/src/ui/dist/assets/{popover-CTeiY-dK.js → popover-hRCXZzs2.js} +1 -1
  85. package/src/ui/dist/assets/{project-sync-Dbs01Xky.js → project-sync-O_85YuP6.js} +1 -1
  86. package/src/ui/dist/assets/{sigma-CM08S-xT.js → sigma-DvKopSnL.js} +1 -1
  87. package/src/ui/dist/assets/{tooltip-pDtzvU9p.js → tooltip-BmlPc6kc.js} +1 -1
  88. package/src/ui/dist/assets/{trash-YvPCP-da.js → trash-n-UvdZFR.js} +1 -1
  89. package/src/ui/dist/assets/{useCliAccess-Bavi74Ac.js → useCliAccess-WDd3_wIh.js} +1 -1
  90. package/src/ui/dist/assets/{useFileDiffOverlay-CVXY6oeg.js → useFileDiffOverlay-rXLIL2NF.js} +1 -1
  91. package/src/ui/dist/assets/{wrap-text-Cf4flRW7.js → wrap-text-qIYQ4a_W.js} +1 -1
  92. package/src/ui/dist/assets/{zoom-out-Hb0Z1YpT.js → zoom-out-fZXCEFsy.js} +1 -1
  93. package/src/ui/dist/index.html +2 -2
package/install.sh CHANGED
@@ -258,8 +258,8 @@ prune_tree() {
258
258
  }
259
259
 
260
260
  build_ui() {
261
- if [ -f "$1/src/ui/dist/index.html" ]; then
262
- print_step "Using prebuilt web UI bundle from source tree"
261
+ if should_use_prebuilt_bundle "$1/src/ui" "$1/src/ui/dist" "index.html" "${DEEPSCIENTIST_FORCE_UI_BUILD:-}"; then
262
+ print_step "Using up-to-date web UI bundle from source tree"
263
263
  return
264
264
  fi
265
265
  print_step "Building web UI in install tree"
@@ -274,8 +274,16 @@ install_root_runtime() {
274
274
  }
275
275
 
276
276
  build_tui() {
277
- if [ -f "$1/src/tui/dist/index.js" ] || [ -f "$1/src/tui/dist/index.cjs" ] || [ -d "$1/src/tui/dist/components" ]; then
278
- print_step "Using prebuilt TUI bundle from source tree"
277
+ local tui_entry=""
278
+ if [ -f "$1/src/tui/dist/index.js" ]; then
279
+ tui_entry="index.js"
280
+ elif [ -f "$1/src/tui/dist/index.cjs" ]; then
281
+ tui_entry="index.cjs"
282
+ elif [ -d "$1/src/tui/dist/components" ]; then
283
+ tui_entry="components"
284
+ fi
285
+ if [ -n "$tui_entry" ] && should_use_prebuilt_bundle "$1/src/tui" "$1/src/tui/dist" "$tui_entry" "${DEEPSCIENTIST_FORCE_TUI_BUILD:-}"; then
286
+ print_step "Using up-to-date TUI bundle from source tree"
279
287
  return
280
288
  fi
281
289
  print_step "Building TUI in install tree"
@@ -284,6 +292,107 @@ build_tui() {
284
292
  npm --prefix "$1/src/tui" prune --omit=dev --no-audit --no-fund
285
293
  }
286
294
 
295
+ truthy_env() {
296
+ case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
297
+ 1|true|yes|on)
298
+ return 0
299
+ ;;
300
+ *)
301
+ return 1
302
+ ;;
303
+ esac
304
+ }
305
+
306
+ should_use_prebuilt_bundle() {
307
+ local source_root="$1"
308
+ local dist_root="$2"
309
+ local dist_entry="$3"
310
+ local force_value="${4:-}"
311
+
312
+ if truthy_env "$force_value"; then
313
+ return 1
314
+ fi
315
+
316
+ if [ ! -e "$dist_root/$dist_entry" ]; then
317
+ return 1
318
+ fi
319
+
320
+ local freshness_output=""
321
+ if command -v python3 >/dev/null 2>&1; then
322
+ freshness_output="$(python3 - "$source_root" "$dist_root" <<'PY'
323
+ from pathlib import Path
324
+ import sys
325
+
326
+ source_root = Path(sys.argv[1])
327
+ dist_root = Path(sys.argv[2])
328
+ ignore_names = {"dist", "node_modules", ".git", "__pycache__"}
329
+
330
+ if not source_root.exists() or not dist_root.exists():
331
+ print("stale")
332
+ raise SystemExit(0)
333
+
334
+ def iter_files(root: Path):
335
+ for path in root.rglob("*"):
336
+ if not path.is_file():
337
+ continue
338
+ if any(part in ignore_names for part in path.relative_to(root).parts):
339
+ continue
340
+ yield path
341
+
342
+ source_mtime = 0.0
343
+ for path in iter_files(source_root):
344
+ source_mtime = max(source_mtime, path.stat().st_mtime)
345
+
346
+ dist_mtime = 0.0
347
+ for path in dist_root.rglob("*"):
348
+ if not path.is_file():
349
+ continue
350
+ dist_mtime = max(dist_mtime, path.stat().st_mtime)
351
+
352
+ print("fresh" if dist_mtime >= source_mtime else "stale")
353
+ PY
354
+ )"
355
+ elif command -v python >/dev/null 2>&1; then
356
+ freshness_output="$(python - "$source_root" "$dist_root" <<'PY'
357
+ from pathlib import Path
358
+ import sys
359
+
360
+ source_root = Path(sys.argv[1])
361
+ dist_root = Path(sys.argv[2])
362
+ ignore_names = {"dist", "node_modules", ".git", "__pycache__"}
363
+
364
+ if not source_root.exists() or not dist_root.exists():
365
+ print("stale")
366
+ raise SystemExit(0)
367
+
368
+ def iter_files(root: Path):
369
+ for path in root.rglob("*"):
370
+ if not path.is_file():
371
+ continue
372
+ if any(part in ignore_names for part in path.relative_to(root).parts):
373
+ continue
374
+ yield path
375
+
376
+ source_mtime = 0.0
377
+ for path in iter_files(source_root):
378
+ source_mtime = max(source_mtime, path.stat().st_mtime)
379
+
380
+ dist_mtime = 0.0
381
+ for path in dist_root.rglob("*"):
382
+ if not path.is_file():
383
+ continue
384
+ dist_mtime = max(dist_mtime, path.stat().st_mtime)
385
+
386
+ print("fresh" if dist_mtime >= source_mtime else "stale")
387
+ PY
388
+ )"
389
+ else
390
+ return 1
391
+ fi
392
+
393
+ [ "$freshness_output" = "fresh" ]
394
+ }
395
+
287
396
  write_install_wrappers() {
288
397
  local target="$1"
289
398
  mkdir -p "$target/bin"
@@ -292,6 +401,10 @@ write_install_wrappers() {
292
401
  #!/usr/bin/env bash
293
402
  set -euo pipefail
294
403
  SCRIPT_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
404
+ HOME_DIR="\$(cd "\$SCRIPT_DIR/../.." && pwd)"
405
+ if [ -z "\${DEEPSCIENTIST_HOME:-}" ]; then
406
+ export DEEPSCIENTIST_HOME="\$HOME_DIR"
407
+ fi
295
408
  NODE_BIN="\${DEEPSCIENTIST_NODE:-node}"
296
409
  exec "\$NODE_BIN" "\$SCRIPT_DIR/ds.js" "\$@"
297
410
  EOF
@@ -308,6 +421,9 @@ write_global_wrapper() {
308
421
  cat >"$target_path" <<EOF
309
422
  #!/usr/bin/env bash
310
423
  set -euo pipefail
424
+ if [ -z "\${DEEPSCIENTIST_HOME:-}" ]; then
425
+ export DEEPSCIENTIST_HOME="$BASE_DIR"
426
+ fi
311
427
  exec "$INSTALL_DIR/bin/$command_name" "\$@"
312
428
  EOF
313
429
  chmod +x "$target_path"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@researai/deepscientist",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "Local-first research operating system with a Python runtime and npm launcher",
5
5
  "license": "MIT",
6
6
  "files": [
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepscientist"
7
- version = "1.5.2"
7
+ version = "1.5.3"
8
8
  description = "DeepScientist Core skeleton"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,4 +5,4 @@ __all__ = ["__version__"]
5
5
  try:
6
6
  __version__ = _package_version("deepscientist")
7
7
  except PackageNotFoundError: # pragma: no cover - source checkout fallback
8
- __version__ = "1.5.2"
8
+ __version__ = "1.5.3"
@@ -5187,7 +5187,7 @@ class ArtifactService:
5187
5187
  return targets
5188
5188
 
5189
5189
  def _connectors_config(self) -> dict[str, Any]:
5190
- return ConfigManager(self.home).load_named("connectors")
5190
+ return ConfigManager(self.home).load_named_normalized("connectors")
5191
5191
 
5192
5192
  @staticmethod
5193
5193
  def _delivery_policy(connectors: dict[str, Any]) -> str:
@@ -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