clouds-coder 2026.3.8__tar.gz → 2026.3.17__tar.gz

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.
@@ -71,9 +71,12 @@ DEFAULT_TIMEOUT_SECONDS = max(
71
71
  )
72
72
  DEFAULT_REQUEST_TIMEOUT = DEFAULT_TIMEOUT_SECONDS
73
73
  AUTO_CONTINUE_BUDGET_DEFAULT = 30
74
- AGENT_MAX_OUTPUT_TOKENS = 2200
74
+ AGENT_MAX_OUTPUT_TOKENS = 16384
75
+ OLLAMA_THINKING_TOOL_BUFFER = 4096
75
76
  WATCHDOG_INTENT_NO_TOOL_THRESHOLD = 2
76
77
  WATCHDOG_REPEAT_NO_TOOL_THRESHOLD = 2
78
+ WATCHDOG_INTENT_NO_TOOL_THRESHOLD_SINGLE = 4
79
+ WATCHDOG_REPEAT_NO_TOOL_THRESHOLD_SINGLE = 4
77
80
  WATCHDOG_STATE_STALL_THRESHOLD = 6
78
81
  WATCHDOG_CONTEXT_STALL_THRESHOLD = 2
79
82
  WATCHDOG_REPEAT_SIMILARITY_THRESHOLD = 0.85
@@ -202,7 +205,7 @@ TASK_LEVEL_POLICIES: dict[int, dict] = {
202
205
  "execution_mode": EXECUTION_MODE_SINGLE,
203
206
  "participants": ["developer"],
204
207
  "assigned_expert": "developer",
205
- "round_budget": 4,
208
+ "round_budget": 2,
206
209
  "requires_user_confirmation": False,
207
210
  "complexity": "simple",
208
211
  },
@@ -211,7 +214,7 @@ TASK_LEVEL_POLICIES: dict[int, dict] = {
211
214
  "execution_mode": EXECUTION_MODE_SINGLE,
212
215
  "participants": ["developer"],
213
216
  "assigned_expert": "developer",
214
- "round_budget": 10,
217
+ "round_budget": 6,
215
218
  "requires_user_confirmation": False,
216
219
  "complexity": "simple",
217
220
  },
@@ -220,7 +223,7 @@ TASK_LEVEL_POLICIES: dict[int, dict] = {
220
223
  "execution_mode": EXECUTION_MODE_SYNC,
221
224
  "participants": ["explorer", "developer"],
222
225
  "assigned_expert": "developer",
223
- "round_budget": 12,
226
+ "round_budget": 10,
224
227
  "requires_user_confirmation": False,
225
228
  "complexity": "simple",
226
229
  },
@@ -229,7 +232,7 @@ TASK_LEVEL_POLICIES: dict[int, dict] = {
229
232
  "execution_mode": EXECUTION_MODE_SYNC,
230
233
  "participants": ["explorer", "developer", "reviewer"],
231
234
  "assigned_expert": "developer",
232
- "round_budget": 36,
235
+ "round_budget": 24,
233
236
  "requires_user_confirmation": False,
234
237
  "complexity": "complex",
235
238
  },
@@ -485,6 +488,30 @@ CODE_PREVIEW_EXTS = {
485
488
  ".svelte",
486
489
  ".gradle",
487
490
  ".properties",
491
+ # Fortran
492
+ ".f", ".f90", ".f95", ".f03", ".f08", ".for", ".fpp",
493
+ # Haskell
494
+ ".hs", ".lhs",
495
+ # Erlang / Elixir
496
+ ".erl", ".hrl", ".ex", ".exs",
497
+ # OCaml
498
+ ".ml", ".mli",
499
+ # HDL
500
+ ".vhd", ".vhdl", ".v", ".sv",
501
+ # Assembly
502
+ ".asm", ".s",
503
+ # Infra / Schema
504
+ ".proto", ".tf", ".tfvars", ".prisma", ".graphql", ".gql",
505
+ # Modern systems
506
+ ".zig", ".nim", ".jl", ".cr", ".d",
507
+ # Lisp family
508
+ ".clj", ".cljs", ".cljc", ".lisp", ".cl", ".el", ".rkt",
509
+ # Pascal
510
+ ".pas", ".pp",
511
+ # Shader
512
+ ".wgsl", ".glsl", ".hlsl",
513
+ # Misc
514
+ ".groovy", ".cmake", ".dockerfile",
488
515
  }
489
516
  CODE_PREVIEW_FILENAMES = {
490
517
  "dockerfile",
@@ -1310,6 +1337,117 @@ def trim(text: object, limit: int = MAX_TOOL_OUTPUT) -> str:
1310
1337
  s = str(text)
1311
1338
  return s if len(s) <= limit else s[:limit] + "\n...(truncated)"
1312
1339
 
1340
+
1341
+ def _fmt_export_ts(ts: float | int) -> str:
1342
+ v = float(ts or 0)
1343
+ if v <= 0:
1344
+ return ""
1345
+ try:
1346
+ from datetime import datetime
1347
+ return datetime.fromtimestamp(v).strftime("%Y-%m-%d %H:%M:%S")
1348
+ except Exception:
1349
+ return ""
1350
+
1351
+
1352
+ def _html_esc(text: str) -> str:
1353
+ return html.escape(str(text or ""))
1354
+
1355
+
1356
+ def _text_to_minimal_pdf(text: str) -> bytes:
1357
+ """纯 Python 最小 PDF 生成器,无外部依赖。"""
1358
+ raw = str(text or "")
1359
+ lines = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n")
1360
+ font_size = 10
1361
+ leading = font_size * 1.35
1362
+ margin_top, margin_bottom, margin_left, margin_right = 50, 50, 50, 50
1363
+ page_w, page_h = 595, 842 # A4
1364
+ usable_w = page_w - margin_left - margin_right
1365
+ max_chars_per_line = max(20, int(usable_w / (font_size * 0.52)))
1366
+ usable_h = page_h - margin_top - margin_bottom
1367
+ lines_per_page = max(1, int(usable_h / leading))
1368
+
1369
+ # 折行
1370
+ wrapped: list[str] = []
1371
+ for line in lines:
1372
+ if not line:
1373
+ wrapped.append("")
1374
+ continue
1375
+ while len(line) > max_chars_per_line:
1376
+ wrapped.append(line[:max_chars_per_line])
1377
+ line = line[max_chars_per_line:]
1378
+ wrapped.append(line)
1379
+
1380
+ # 分页
1381
+ pages: list[list[str]] = []
1382
+ for i in range(0, len(wrapped), lines_per_page):
1383
+ pages.append(wrapped[i:i + lines_per_page])
1384
+ if not pages:
1385
+ pages = [[""]]
1386
+
1387
+ def _pdf_escape(s: str) -> str:
1388
+ return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
1389
+
1390
+ def _safe_latin1(s: str) -> str:
1391
+ return s.encode("latin-1", errors="replace").decode("latin-1")
1392
+
1393
+ objects: list[bytes] = []
1394
+ offsets: list[int] = []
1395
+ buf = b"%PDF-1.4\n"
1396
+
1397
+ def add_obj(content: str) -> int:
1398
+ nonlocal buf
1399
+ idx = len(objects) + 1
1400
+ offsets.append(len(buf))
1401
+ obj_bytes = f"{idx} 0 obj\n{content}\nendobj\n".encode("latin-1")
1402
+ buf += obj_bytes
1403
+ objects.append(obj_bytes)
1404
+ return idx
1405
+
1406
+ catalog_id = add_obj("<< /Type /Catalog /Pages 2 0 R >>")
1407
+ add_obj("PAGES_PLACEHOLDER") # obj 2: placeholder, replaced after page generation
1408
+ font_id = add_obj("<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>")
1409
+
1410
+ page_ids: list[int] = []
1411
+ for page_lines in pages:
1412
+ stream_lines = [f"BT /F1 {font_size} Tf"]
1413
+ y = page_h - margin_top
1414
+ stream_lines.append(f"{margin_left} {y} Td")
1415
+ for pl in page_lines:
1416
+ safe = _safe_latin1(_pdf_escape(pl))
1417
+ stream_lines.append(f"({safe}) Tj")
1418
+ stream_lines.append(f"0 -{leading:.1f} Td")
1419
+ stream_lines.append("ET")
1420
+ stream = "\n".join(stream_lines)
1421
+ stream_bytes = stream.encode("latin-1")
1422
+ content_id = add_obj(f"<< /Length {len(stream_bytes)} >>\nstream\n{stream}\nendstream")
1423
+ page_id = add_obj(
1424
+ f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_w} {page_h}] "
1425
+ f"/Contents {content_id} 0 R /Resources << /Font << /F1 {font_id} 0 R >> >> >>"
1426
+ )
1427
+ page_ids.append(page_id)
1428
+
1429
+ kids = " ".join(f"{pid} 0 R" for pid in page_ids)
1430
+ pages_content = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_ids)} >>"
1431
+ pages_bytes = f"2 0 obj\n{pages_content}\nendobj\n".encode("latin-1")
1432
+ old_pages = objects[1]
1433
+ buf = buf.replace(b"2 0 obj\nPAGES_PLACEHOLDER\nendobj\n", pages_bytes)
1434
+ size_diff = len(pages_bytes) - len(old_pages)
1435
+ for i in range(2, len(offsets)):
1436
+ offsets[i] += size_diff
1437
+
1438
+ xref_offset = len(buf)
1439
+ buf += b"xref\n"
1440
+ buf += f"0 {len(objects) + 1}\n".encode("latin-1")
1441
+ buf += b"0000000000 65535 f \n"
1442
+ for off in offsets:
1443
+ buf += f"{off:010d} 00000 n \n".encode("latin-1")
1444
+ buf += b"trailer\n"
1445
+ buf += f"<< /Size {len(objects) + 1} /Root {catalog_id} 0 R >>\n".encode("latin-1")
1446
+ buf += b"startxref\n"
1447
+ buf += f"{xref_offset}\n".encode("latin-1")
1448
+ buf += b"%%EOF\n"
1449
+ return buf
1450
+
1313
1451
  def compress_text_blob(text: str) -> str:
1314
1452
  src = str(text or "")
1315
1453
  if not src:
@@ -2218,7 +2356,7 @@ def try_read_text(path: Path, max_bytes: int = 400_000) -> str | None:
2218
2356
  except Exception:
2219
2357
  return None
2220
2358
 
2221
- def make_unified_diff(path: str, old_text: str, new_text: str, max_lines: int = 400) -> str:
2359
+ def make_unified_diff(path: str, old_text: str, new_text: str, max_lines: int = 400) -> tuple[str, int, int]:
2222
2360
  old_lines = old_text.splitlines()
2223
2361
  new_lines = new_text.splitlines()
2224
2362
  diff = list(
@@ -2230,9 +2368,12 @@ def make_unified_diff(path: str, old_text: str, new_text: str, max_lines: int =
2230
2368
  lineterm="",
2231
2369
  )
2232
2370
  )
2233
- if len(diff) > max_lines:
2371
+ added = sum(1 for ln in diff if ln.startswith("+") and not ln.startswith("+++"))
2372
+ deleted = sum(1 for ln in diff if ln.startswith("-") and not ln.startswith("---"))
2373
+ if max_lines and len(diff) > max_lines:
2234
2374
  diff = diff[:max_lines] + [f"... diff truncated, total lines={len(diff)}"]
2235
- return "\n".join(diff) if diff else f"@@ no textual diff for {path}"
2375
+ text = "\n".join(diff) if diff else f"@@ no textual diff for {path}"
2376
+ return text, added, deleted
2236
2377
 
2237
2378
  def _skip_row(text: str) -> dict:
2238
2379
  msg = str(text or "").strip() or "⋮"
@@ -3435,6 +3576,7 @@ def _module_exists(name: str) -> bool:
3435
3576
 
3436
3577
  def detect_upload_parser_capabilities() -> dict:
3437
3578
  return {
3579
+ "pdfminer": _module_exists("pdfminer"),
3438
3580
  "openpyxl": _module_exists("openpyxl"),
3439
3581
  "xlrd": _module_exists("xlrd"),
3440
3582
  "python_docx": _module_exists("docx"),
@@ -3446,6 +3588,7 @@ def detect_upload_parser_capabilities() -> dict:
3446
3588
  "catdoc": bool(shutil.which("catdoc")),
3447
3589
  "catppt": bool(shutil.which("catppt")),
3448
3590
  "textutil": bool(shutil.which("textutil")),
3591
+ "playwright": _module_exists("playwright"),
3449
3592
  }
3450
3593
 
3451
3594
  def _render_cap_markdown(caps: dict) -> str:
@@ -4671,6 +4814,96 @@ SKILL_PROTOCOL_SPECS = [
4671
4814
  },
4672
4815
  ]
4673
4816
 
4817
+ # ---------------------------------------------------------------------------
4818
+ # Built-in skill guides (injected into SkillStore on reload)
4819
+ # ---------------------------------------------------------------------------
4820
+ _BUILTIN_SKILLS: dict[str, dict] = {
4821
+ "workspace-paths": {
4822
+ "description": "File path rules, virtual path mapping, relative path conventions",
4823
+ "body": (
4824
+ "# Workspace Paths Guide\n"
4825
+ "- Always use relative paths (e.g. src/main.py) for file tools.\n"
4826
+ "- Runtime maps relative paths to session absolute root automatically.\n"
4827
+ "- '/workspace/...' is a virtual alias for tool arguments only; never create OS-level /workspace.\n"
4828
+ "- When user references uploaded files, prioritize workspace uploads directory.\n"
4829
+ "- For shell commands, use relative paths or $PWD-based references.\n"
4830
+ ),
4831
+ },
4832
+ "task-management": {
4833
+ "description": "Todo/Task creation, updates, and best practices",
4834
+ "body": (
4835
+ "# Task Management Guide\n"
4836
+ "- For level 1-2 (simple) tasks: skip todo scaffolding, give direct response.\n"
4837
+ "- For level 3+ tasks: call TodoWrite early with 3-7 concise items, one marked in_progress.\n"
4838
+ "- Update todos only when plan or status actually changes. Avoid redundant calls.\n"
4839
+ "- If TodoWrite fails or repeats unchanged, use TodoWriteRescue with simple string items.\n"
4840
+ "- Use task_create/task_update/task_list for multi-step structured work.\n"
4841
+ ),
4842
+ },
4843
+ "tool-best-practices": {
4844
+ "description": "write_file vs edit_file, bash usage, error handling conventions",
4845
+ "body": (
4846
+ "# Tool Best Practices\n"
4847
+ "- Prefer write_file/edit_file for code changes (UI renders line-level diffs).\n"
4848
+ "- If write_file/edit_file fails due to malformed arguments, regenerate complete JSON.\n"
4849
+ "- If output looks truncated, split into smaller subtasks.\n"
4850
+ "- For shell commands: use bash_command for execution, not file tools.\n"
4851
+ "- Always end thinking sections with either a final answer or one tool call.\n"
4852
+ "- Never stop at thinking-only content without an action.\n"
4853
+ ),
4854
+ },
4855
+ "context-management": {
4856
+ "description": "Context compaction triggers, context_recall usage, step sizing",
4857
+ "body": (
4858
+ "# Context Management Guide\n"
4859
+ "- Context has a token upper bound; keep steps compact.\n"
4860
+ "- When <compact-resume> hint appears, inherit pending todos and continue immediately.\n"
4861
+ "- After compaction, use context_recall to fetch archived messages by segment_id/query.\n"
4862
+ "- Do not guess content that was compacted away—recall it first.\n"
4863
+ "- For large tasks, break into subtasks to avoid context overflow.\n"
4864
+ ),
4865
+ },
4866
+ "multi-agent-guide": {
4867
+ "description": "Blackboard read/write norms, ask_colleague usage, handoff format",
4868
+ "body": (
4869
+ "# Multi-Agent Guide\n"
4870
+ "- Use read_from_blackboard to check shared state before acting.\n"
4871
+ "- Use write_to_blackboard to record progress, artifacts, and blockers.\n"
4872
+ "- Use ask_colleague with structured intent for inter-agent communication:\n"
4873
+ " - explorer->developer: 'handoff' with research findings\n"
4874
+ " - developer->reviewer: 'review_request' with changed files list\n"
4875
+ " - reviewer->developer: 'fix_request' with concrete failure evidence\n"
4876
+ "- Handoffs should include enough context for the target agent to act independently.\n"
4877
+ "- Keep blackboard updates atomic and concise.\n"
4878
+ ),
4879
+ },
4880
+ "code-review-checklist": {
4881
+ "description": "Reviewer role checklist for code verification",
4882
+ "body": (
4883
+ "# Code Review Checklist\n"
4884
+ "1. Does the implementation match the original goal?\n"
4885
+ "2. Are there syntax errors? Run linting/type checks if available.\n"
4886
+ "3. Are there obvious logic bugs or edge cases missed?\n"
4887
+ "4. Do tests pass? If no tests exist, verify manually.\n"
4888
+ "5. Are there security issues (injection, XSS, hardcoded secrets)?\n"
4889
+ "6. Write review_feedback to blackboard with pass/fail and evidence.\n"
4890
+ "7. If fail: send fix_request via ask_colleague with specific issues.\n"
4891
+ ),
4892
+ },
4893
+ "finish-protocol": {
4894
+ "description": "When and how to call finish_current_task correctly",
4895
+ "body": (
4896
+ "# Finish Protocol\n"
4897
+ "- Call finish_current_task when all required work is complete.\n"
4898
+ "- Include a concise summary of what was done.\n"
4899
+ "- For multi-agent mode: finish triggers auto-summary from blackboard state.\n"
4900
+ "- Do not finish if there are known failing tests or unresolved blockers.\n"
4901
+ "- If todos have stale pending items but work is done, finish anyway—stale items are cleared automatically.\n"
4902
+ "- Summary format: list modified files, key changes, and validation status.\n"
4903
+ ),
4904
+ },
4905
+ }
4906
+
4674
4907
  class SkillStore:
4675
4908
  def __init__(self, skills_root: Path):
4676
4909
  self.skills_root = skills_root
@@ -5075,9 +5308,34 @@ class SkillStore:
5075
5308
  self._load_local_skills()
5076
5309
  self._load_manifest_providers()
5077
5310
  self._load_clawhub_autodetect()
5311
+ self._inject_builtin_skills()
5078
5312
  self.fingerprint = fp
5079
5313
  self.last_reload_ts = now
5080
5314
 
5315
+ def _inject_builtin_skills(self):
5316
+ """Register built-in skill guides for small-model support."""
5317
+ provider_id = "builtin"
5318
+ if not self._provider_exists(provider_id):
5319
+ self._register_provider(
5320
+ provider_id, "builtin", "1.0",
5321
+ "Built-in skill guides for agent operation",
5322
+ )
5323
+ for skill_name, data in _BUILTIN_SKILLS.items():
5324
+ key = f"{provider_id}:{skill_name}"
5325
+ if key in self.skills:
5326
+ continue
5327
+ self._register_skill(
5328
+ provider_id=provider_id,
5329
+ protocol="builtin",
5330
+ protocol_version="1.0",
5331
+ name=skill_name,
5332
+ description=data["description"],
5333
+ meta={"builtin": True},
5334
+ body=data["body"],
5335
+ skill_path="(builtin)",
5336
+ attachments=[],
5337
+ )
5338
+
5081
5339
  def descriptions(self, max_items: int = SKILL_PROMPT_MAX_ITEMS, max_chars: int = SKILL_PROMPT_MAX_CHARS) -> str:
5082
5340
  if not self.skills:
5083
5341
  return "(no skills)"
@@ -6486,11 +6744,14 @@ class OllamaClient:
6486
6744
  think: bool = False,
6487
6745
  on_thinking_chunk=None,
6488
6746
  ) -> dict:
6747
+ effective_max = max_tokens
6748
+ if tools:
6749
+ effective_max = max(max_tokens, max_tokens + OLLAMA_THINKING_TOOL_BUFFER)
6489
6750
  payload = {
6490
6751
  "model": self.model,
6491
6752
  "messages": req_messages,
6492
6753
  "stream": True,
6493
- "options": {"temperature": temperature, "num_predict": max_tokens},
6754
+ "options": {"temperature": temperature, "num_predict": effective_max},
6494
6755
  }
6495
6756
  if tools:
6496
6757
  payload["tools"] = tools
@@ -6503,6 +6764,7 @@ class OllamaClient:
6503
6764
  full_content: list[str] = []
6504
6765
  full_thinking: list[str] = []
6505
6766
  tool_calls: list[dict] = []
6767
+ done_reason = ""
6506
6768
  try:
6507
6769
  with urlopen(req, timeout=self.timeout) as resp:
6508
6770
  while True:
@@ -6542,6 +6804,7 @@ class OllamaClient:
6542
6804
  if tcs:
6543
6805
  tool_calls = self._normalize_tool_calls(tcs)
6544
6806
  if part.get("done"):
6807
+ done_reason = str(part.get("done_reason") or "").strip().lower()
6545
6808
  break
6546
6809
  except HTTPError as exc:
6547
6810
  text = exc.read().decode("utf-8", errors="replace")
@@ -6554,7 +6817,7 @@ class OllamaClient:
6554
6817
  "content": content,
6555
6818
  "thinking": thinking_content,
6556
6819
  "tool_calls": tool_calls,
6557
- "raw": {"streamed": True},
6820
+ "raw": {"streamed": True, "done_reason": done_reason},
6558
6821
  }
6559
6822
 
6560
6823
  def chat(
@@ -6618,11 +6881,14 @@ class OllamaClient:
6618
6881
  fallback_status = {0, 400, 404, 405, 500, 501, 502, 503, 504}
6619
6882
  if status not in fallback_status:
6620
6883
  raise
6884
+ effective_max_native = max_tokens
6885
+ if tools:
6886
+ effective_max_native = max(max_tokens, max_tokens + OLLAMA_THINKING_TOOL_BUFFER)
6621
6887
  native_payload = {
6622
6888
  "model": self.model,
6623
6889
  "messages": req_messages,
6624
6890
  "stream": False,
6625
- "options": {"temperature": temperature, "num_predict": max_tokens},
6891
+ "options": {"temperature": temperature, "num_predict": effective_max_native},
6626
6892
  }
6627
6893
  if tools:
6628
6894
  native_payload["tools"] = tools
@@ -6918,6 +7184,7 @@ class SessionState:
6918
7184
  arbiter_max_tokens: int = ARBITER_DEFAULT_MAX_TOKENS,
6919
7185
  arbiter_temperature: float = ARBITER_DEFAULT_TEMPERATURE,
6920
7186
  execution_mode: str = EXECUTION_MODE_SYNC,
7187
+ max_output_tokens: int = AGENT_MAX_OUTPUT_TOKENS,
6921
7188
  ui_language: str = DEFAULT_UI_LANGUAGE,
6922
7189
  js_lib_root: Path | None = None,
6923
7190
  owner_user_id: str = "",
@@ -6964,6 +7231,7 @@ class SessionState:
6964
7231
  self.multimodal_capability_cache: dict[str, dict] = {}
6965
7232
  self.failed_selections: list[str] = []
6966
7233
  self.todo = TodoManager()
7234
+ self.single_advance_prompt_enhance = False
6967
7235
  self.skills = SkillStore(skills_root)
6968
7236
  self.skill_load_cache: dict[str, dict] = {}
6969
7237
  self.skills_last_refresh_ts = 0.0
@@ -6973,8 +7241,9 @@ class SessionState:
6973
7241
  self.bus = MessageBus(self.root / "team" / "inbox", crypto)
6974
7242
  self.worktrees = WorktreeManager(self.id, self.tasks, self.root, crypto, repo_root)
6975
7243
  self.messages: list[dict] = []
6976
- self.contexts: dict[str, list[dict]] = {role: [] for role in AGENT_ROLES}
6977
- self.manager_context: list[dict] = []
7244
+ self.agent_messages: list[dict] = [] # unified agent context (replaces contexts + manager_context)
7245
+ self.contexts: dict[str, list[dict]] = {role: [] for role in AGENT_ROLES} # kept as view caches
7246
+ self.manager_context: list[dict] = [] # kept as view cache
6978
7247
  self.blackboard: dict = {}
6979
7248
  self.agent_bus_messages: list[dict] = []
6980
7249
  self.manager_routes: list[dict] = []
@@ -7055,6 +7324,7 @@ class SessionState:
7055
7324
  MIN_AGENT_ROUNDS,
7056
7325
  min(MAX_AGENT_ROUNDS_CAP, int(max_rounds or MAX_AGENT_ROUNDS)),
7057
7326
  )
7327
+ self.max_output_tokens = max(256, int(max_output_tokens or AGENT_MAX_OUTPUT_TOKENS))
7058
7328
  self.max_run_seconds = normalize_timeout_seconds(
7059
7329
  max_run_seconds if max_run_seconds is not None else MAX_RUN_SECONDS,
7060
7330
  minimum=MIN_RUN_TIMEOUT_SECONDS,
@@ -8037,6 +8307,13 @@ class SessionState:
8037
8307
  if isinstance(row, dict):
8038
8308
  clean_manager_context.append(dict(row))
8039
8309
  self.manager_context = clean_manager_context[-400:]
8310
+ raw_agent_messages = raw.get("agent_messages", [])
8311
+ if isinstance(raw_agent_messages, list):
8312
+ clean_am: list[dict] = []
8313
+ for row in raw_agent_messages[-1200:]:
8314
+ if isinstance(row, dict):
8315
+ clean_am.append(dict(row))
8316
+ self.agent_messages = clean_am[-800:]
8040
8317
  raw_blackboard = raw.get("blackboard", {})
8041
8318
  self.blackboard = self._normalize_blackboard(raw_blackboard)
8042
8319
  raw_bus = raw.get("agent_bus_messages", [])
@@ -8159,6 +8436,7 @@ class SessionState:
8159
8436
  "active_agent_role": str(self.active_agent_role or ""),
8160
8437
  "contexts": {role: list(self.contexts.get(role, []))[-400:] for role in AGENT_ROLES},
8161
8438
  "manager_context": list(self.manager_context)[-400:],
8439
+ "agent_messages": list(self.agent_messages)[-800:],
8162
8440
  "blackboard": self._normalize_blackboard(self.blackboard),
8163
8441
  "agent_bus_messages": list(self.agent_bus_messages)[-240:],
8164
8442
  "manager_routes": list(self.manager_routes)[-240:],
@@ -8625,75 +8903,36 @@ class SessionState:
8625
8903
  research_hint = self._deep_research_boost_instruction()
8626
8904
  runtime_level = int(self.runtime_task_level or 0)
8627
8905
  runtime_mode = self._effective_execution_mode()
8628
- runtime_assigned = self._sanitize_agent_role(self.runtime_assigned_expert) or "developer"
8629
- runtime_participants = [
8630
- role for role in [self._sanitize_agent_role(x) for x in self.runtime_participants] if role
8631
- ][:3]
8632
- if runtime_level in {1, 2} or runtime_mode == EXECUTION_MODE_SINGLE:
8633
- todo_hint = (
8634
- "Current run is direct single-agent mode. "
8635
- "Do not create Todo/task scaffolding unless user explicitly asks for project management. "
8636
- "Prioritize direct final response or one concrete minimal tool action."
8637
- )
8638
- else:
8639
- todo_hint = (
8640
- "For non-trivial tasks, call TodoWrite early with 3-7 concise items "
8641
- "(exactly one in_progress), then update only when plan/status changes. "
8642
- "Avoid redundant TodoWrite calls. "
8643
- "If TodoWrite fails or repeats with no changes, call TodoWriteRescue with simple string items."
8644
- )
8645
- route_hint = (
8646
- f"Runtime manager classification: level={runtime_level or '-'}, mode={runtime_mode}, "
8647
- f"scale_preference={self.runtime_scale_preference or 'balanced'}, "
8648
- f"assigned_expert={runtime_assigned}, participants={','.join(runtime_participants) or runtime_assigned}. "
8649
- )
8650
- if int(self.runtime_round_budget or 0) <= 0:
8651
- budget_hint = (
8652
- "Runtime budget policy: unlimited tier budget; keep each step compact and action-oriented; "
8653
- "never stall on long planning text."
8654
- )
8655
- else:
8656
- budget_hint = (
8657
- f"Runtime budget policy: internal cadence budget={int(self.runtime_round_budget)} "
8658
- "for compact reasoning and fast handoffs. "
8659
- "Budget controls thought depth only and must not be used as an early-stop user-facing reason."
8660
- )
8906
+ budget = int(self.runtime_round_budget or 0)
8661
8907
  html_block = f"{html_hint}\n\n" if html_hint else ""
8662
8908
  research_block = f"{research_hint}\n\n" if research_hint else ""
8909
+ _is_single_no_enhance = (
8910
+ runtime_mode == EXECUTION_MODE_SINGLE
8911
+ and not self.single_advance_prompt_enhance
8912
+ )
8913
+ skill_hint = (
8914
+ "Use load_skill for workspace-paths and tool-best-practices if needed. "
8915
+ if _is_single_no_enhance
8916
+ else (
8917
+ "Use load_skill for guidance on specific topics "
8918
+ "(workspace-paths, task-management, tool-best-practices, context-management). "
8919
+ "If execution stalls, load_skill('execution-degradation-recovery'). "
8920
+ )
8921
+ )
8663
8922
  return (
8664
- f"You are a coding agent running in isolated session workspace {self.files_root}. "
8665
- f"Session absolute writable root is {self.files_root}. "
8666
- "For file tools, prefer relative paths like hello.txt; runtime will map them to the absolute session root. "
8667
- "The '/workspace/...' form is only a virtual alias for path arguments; never create OS-level /workspace in shell. "
8923
+ f"You are a coding agent. Workspace: {self.files_root}. "
8924
+ f"Task level={runtime_level}, mode={runtime_mode}, "
8925
+ f"budget={'unlimited' if budget <= 0 else budget}. "
8926
+ f"Context limit ~{self.context_token_upper_bound} tokens. "
8668
8927
  f"{_detect_os_shell_instruction()} "
8669
- "Use tools to inspect files, execute commands, and edit code safely. "
8670
- f"{route_hint}"
8671
- f"{budget_hint} "
8672
- f"{todo_hint} "
8673
- "When all required work is complete, call finish_current_task (or finish_task / mark_done) "
8674
- "to end execution cleanly "
8675
- "(especially when todos may still contain stale pending items). "
8676
- "If you output internal reasoning/thinking, always end that section and then output either "
8677
- "a final answer or one concrete tool call; never stop at thinking-only content. "
8678
- "For multi-step work use task_create/task_update/task_list as needed. "
8679
- "Use load_skill only when needed; use provider:name if skill name is ambiguous. "
8680
- "Loaded skill content is cached per session; do not repeatedly call load_skill for the same skill unless needed. "
8681
- "If execution stalls (no tool calls / repeated failures), load_skill('execution-degradation-recovery') and follow it. "
8682
- "Use list_skill_providers and list_skill_protocols to inspect dynamic backend skill integrations. "
8683
- "If user asks to save generated guidance/workflow as reusable skill, call write_skill to write SKILL.md under global ./skills. "
8684
- "When changing files, prefer write_file/edit_file so the UI can render line-level diffs. "
8685
- "If a write_file/edit_file tool call fails due malformed or truncated arguments, regenerate and resend the complete JSON arguments. "
8686
- "If output or tool arguments look truncated, split work into smaller subtasks and execute one subtask at a time. "
8687
- "If context has been compacted, call context_recall to fetch exact archived messages by segment_id/query before guessing. "
8688
- "When a <compact-resume> hint appears, inherit pending todos/tasks and continue exploration immediately. "
8689
- f"Current context upper bound is ~{self.context_token_upper_bound} tokens; keep steps compact to stay under this limit. "
8690
- "When user asks to modify uploaded content, prioritize files under the uploaded workspace paths.\n\n"
8691
- "If user asks to generate image/audio/video, use generate_media when active model capability supports it.\n\n"
8928
+ "Use tools to inspect, edit, and execute. "
8929
+ "Call finish_current_task when done. "
8930
+ f"{skill_hint}"
8692
8931
  f"{html_block}"
8693
8932
  f"{research_block}"
8694
8933
  f"{model_language_instruction(self.ui_language)}\n\n"
8695
- f"Uploaded files context:\n{uploads_ctx}\n\n"
8696
- f"Available skills:\n{self.skills.descriptions()}"
8934
+ f"Uploads:\n{uploads_ctx}\n\n"
8935
+ f"Skills:\n{self.skills.descriptions()}"
8697
8936
  )
8698
8937
 
8699
8938
  def _estimate_tokens(self) -> int:
@@ -9261,7 +9500,7 @@ class SessionState:
9261
9500
  if tool_calls:
9262
9501
  return False
9263
9502
 
9264
- near_limit = output_tokens >= int(AGENT_MAX_OUTPUT_TOKENS * 0.90)
9503
+ near_limit = output_tokens >= int(self.max_output_tokens * 0.90)
9265
9504
  # Mid-size outputs (e.g. planning text ending with a Chinese colon) should not be
9266
9505
  # treated as truncation unless close to max tokens or JSON-like unfinished payload.
9267
9506
  json_like_tail = bool(
@@ -9406,17 +9645,108 @@ class SessionState:
9406
9645
  task_ids = self._create_truncation_subtasks(reason)
9407
9646
  self._inject_truncation_rescue_hint(reason, output_tokens, task_ids)
9408
9647
 
9409
- def _microcompact(self):
9648
+ def _find_tool_name_by_id(self, messages: list[dict], tool_use_id: str) -> str:
9649
+ """Find tool name from preceding assistant message by tool_use_id."""
9650
+ if not tool_use_id:
9651
+ return ""
9652
+ for msg in reversed(messages):
9653
+ if msg.get("role") != "assistant":
9654
+ continue
9655
+ for tc in msg.get("tool_calls", []):
9656
+ if isinstance(tc, dict) and tc.get("id") == tool_use_id:
9657
+ fn = tc.get("function", {})
9658
+ return str(fn.get("name", "")) if isinstance(fn, dict) else ""
9659
+ content = msg.get("content")
9660
+ if isinstance(content, list):
9661
+ for block in content:
9662
+ if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id") == tool_use_id:
9663
+ return str(block.get("name", ""))
9664
+ return ""
9665
+
9666
+ def _microcompact(self, keep_recent: int = 3):
9667
+ """Replace old tool results with compact placeholders to save tokens.
9668
+
9669
+ Handles both OpenAI-style (role=tool) and Anthropic-style (tool_result blocks)
9670
+ messages. Keeps the most recent `keep_recent` tool interactions intact.
9671
+ """
9672
+ # Phase 1: OpenAI-style role=tool messages
9410
9673
  tool_messages = [m for m in self.messages if m.get("role") == "tool"]
9411
- if len(tool_messages) <= 3:
9674
+ if len(tool_messages) > keep_recent:
9675
+ kept = tool_messages[-keep_recent:]
9676
+ keep_ids = {id(x) for x in kept}
9677
+ for msg in self.messages:
9678
+ if msg.get("role") == "tool" and id(msg) not in keep_ids:
9679
+ content = msg.get("content", "")
9680
+ if isinstance(content, str) and len(content) > 120:
9681
+ msg["content"] = "[cleared by microcompact]"
9682
+
9683
+ # Phase 2: Anthropic-style tool_result blocks in user messages
9684
+ assistant_indices = [i for i, m in enumerate(self.messages) if m.get("role") == "assistant"]
9685
+ if len(assistant_indices) <= keep_recent:
9686
+ return
9687
+ cutoff = assistant_indices[-keep_recent]
9688
+ for i in range(cutoff):
9689
+ msg = self.messages[i]
9690
+ if msg.get("role") == "user" and isinstance(msg.get("content"), list):
9691
+ changed = False
9692
+ new_content = []
9693
+ for block in msg["content"]:
9694
+ if isinstance(block, dict) and block.get("type") == "tool_result":
9695
+ block_content = block.get("content", "")
9696
+ if isinstance(block_content, str) and len(block_content) > 120:
9697
+ tool_id = block.get("tool_use_id", "")
9698
+ tool_name = self._find_tool_name_by_id(self.messages[:i], tool_id) or "tool"
9699
+ new_content.append({
9700
+ "type": "tool_result",
9701
+ "tool_use_id": tool_id,
9702
+ "content": f"[Previous: used {tool_name}]",
9703
+ })
9704
+ changed = True
9705
+ else:
9706
+ new_content.append(block)
9707
+ else:
9708
+ new_content.append(block)
9709
+ if changed:
9710
+ msg["content"] = new_content
9711
+
9712
+ def _microcompact_agent_messages(self, messages: list[dict], keep_recent: int = 3):
9713
+ """Apply microcompact to an agent-specific message list (e.g. agent_messages filtered by role)."""
9714
+ tool_messages = [m for m in messages if m.get("role") == "tool"]
9715
+ if len(tool_messages) > keep_recent:
9716
+ kept = tool_messages[-keep_recent:]
9717
+ keep_ids = {id(x) for x in kept}
9718
+ for msg in messages:
9719
+ if msg.get("role") == "tool" and id(msg) not in keep_ids:
9720
+ content = msg.get("content", "")
9721
+ if isinstance(content, str) and len(content) > 120:
9722
+ msg["content"] = "[cleared by microcompact]"
9723
+ assistant_indices = [i for i, m in enumerate(messages) if m.get("role") == "assistant"]
9724
+ if len(assistant_indices) <= keep_recent:
9412
9725
  return
9413
- kept = tool_messages[-3:]
9414
- keep_ids = {id(x) for x in kept}
9415
- for msg in self.messages:
9416
- if msg.get("role") == "tool" and id(msg) not in keep_ids:
9417
- content = msg.get("content", "")
9418
- if isinstance(content, str) and len(content) > 120:
9419
- msg["content"] = "[cleared by microcompact]"
9726
+ cutoff = assistant_indices[-keep_recent]
9727
+ for i in range(cutoff):
9728
+ msg = messages[i]
9729
+ if msg.get("role") == "user" and isinstance(msg.get("content"), list):
9730
+ changed = False
9731
+ new_content = []
9732
+ for block in msg["content"]:
9733
+ if isinstance(block, dict) and block.get("type") == "tool_result":
9734
+ block_content = block.get("content", "")
9735
+ if isinstance(block_content, str) and len(block_content) > 120:
9736
+ tool_id = block.get("tool_use_id", "")
9737
+ tool_name = self._find_tool_name_by_id(messages[:i], tool_id) or "tool"
9738
+ new_content.append({
9739
+ "type": "tool_result",
9740
+ "tool_use_id": tool_id,
9741
+ "content": f"[Previous: used {tool_name}]",
9742
+ })
9743
+ changed = True
9744
+ else:
9745
+ new_content.append(block)
9746
+ else:
9747
+ new_content.append(block)
9748
+ if changed:
9749
+ msg["content"] = new_content
9420
9750
 
9421
9751
  def _estimate_messages_tokens(self, rows: list[dict]) -> int:
9422
9752
  try:
@@ -10090,6 +10420,17 @@ class SessionState:
10090
10420
  return data.decode("latin-1", errors="ignore")
10091
10421
 
10092
10422
  def _extract_pdf_text(self, pdf_path: Path) -> str:
10423
+ # 优先使用 pdfminer.six(纯 Python,无外部依赖)
10424
+ try:
10425
+ from pdfminer.high_level import extract_text
10426
+ text = extract_text(str(pdf_path))
10427
+ if text and text.strip():
10428
+ return text.strip()
10429
+ except ImportError:
10430
+ pass
10431
+ except Exception:
10432
+ pass
10433
+ # 降级:pdftotext CLI
10093
10434
  tool = shutil.which("pdftotext")
10094
10435
  if tool:
10095
10436
  try:
@@ -10103,6 +10444,7 @@ class SessionState:
10103
10444
  return r.stdout.strip()
10104
10445
  except Exception:
10105
10446
  pass
10447
+ # 最终降级:regex 提取
10106
10448
  try:
10107
10449
  raw = pdf_path.read_bytes()
10108
10450
  text = raw.decode("latin-1", errors="ignore")
@@ -10435,6 +10777,16 @@ class SessionState:
10435
10777
  lines.append(chunk)
10436
10778
  lines.append("</uploaded_excerpt>")
10437
10779
  remaining -= len(chunk)
10780
+ # 提示模型可直接读取 .parsed.md 文件获取完整解析文本
10781
+ item_kind = item.get("kind", "file")
10782
+ if item_kind not in ("text", "code"):
10783
+ wp = item.get("workspace_path", "")
10784
+ if wp:
10785
+ from pathlib import PurePosixPath
10786
+ stem = PurePosixPath(wp).stem
10787
+ parent = str(PurePosixPath(wp).parent)
10788
+ parsed_rel = f"{parent}/{stem}.parsed.md" if parent != "." else f"{stem}.parsed.md"
10789
+ lines.append(f" (parsed text available at: {parsed_rel} — use read_file to access full content)")
10438
10790
  return "\n".join(lines)
10439
10791
 
10440
10792
  def add_upload(self, filename: str, raw: bytes, mime: str = "") -> dict:
@@ -10445,10 +10797,42 @@ class SessionState:
10445
10797
  stored.write_bytes(raw)
10446
10798
  ext = stored.suffix.lower()
10447
10799
  text_like_ext = {
10448
- ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".c", ".cc", ".cpp", ".h", ".hpp",
10449
- ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".sh", ".sql", ".html",
10450
- ".css", ".json", ".yaml", ".yml", ".xml", ".toml", ".ini", ".cfg", ".md", ".txt",
10451
- ".ipynb", ".vue", ".svelte", ".cs", ".m", ".mm", ".r", ".pl", ".csv",
10800
+ ".py", ".pyi", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".java", ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".inl",
10801
+ ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".kts", ".scala", ".sh", ".bash", ".zsh", ".fish", ".sql", ".html", ".htm",
10802
+ ".css", ".sass", ".scss", ".less", ".styl", ".json", ".jsonc", ".yaml", ".yml", ".xml", ".xsd", ".xsl",
10803
+ ".toml", ".ini", ".cfg", ".conf", ".env", ".properties", ".md", ".mdx", ".txt", ".rst", ".log",
10804
+ ".ipynb", ".vue", ".svelte", ".cs", ".m", ".mm", ".r", ".pl", ".pm", ".csv", ".tsv",
10805
+ # Fortran
10806
+ ".f", ".f90", ".f95", ".f03", ".f08", ".for", ".fpp",
10807
+ # 更多语言
10808
+ ".zig", ".nim", ".v", ".d", ".ada", ".adb", ".ads",
10809
+ ".asm", ".s",
10810
+ ".bas", ".vb", ".vbs", ".vba",
10811
+ ".bat", ".cmd", ".ps1", ".psm1",
10812
+ ".clj", ".cljs", ".cljc", ".edn",
10813
+ ".coffee", ".cr", ".dart",
10814
+ ".dockerfile",
10815
+ ".erl", ".hrl", ".ex", ".exs",
10816
+ ".fs", ".fsi", ".fsx",
10817
+ ".gradle", ".groovy", ".gvy",
10818
+ ".hs", ".lhs",
10819
+ ".jl", ".lua",
10820
+ ".lisp", ".cl", ".el", ".scm", ".rkt",
10821
+ ".mk", ".cmake",
10822
+ ".ml", ".mli", ".nix",
10823
+ ".pas", ".pp", ".inc",
10824
+ ".pde", ".ino",
10825
+ ".proto", ".purs",
10826
+ ".raku", ".p6",
10827
+ ".sol",
10828
+ ".sv", ".svh", ".vh", ".vhd", ".vhdl",
10829
+ ".tcl", ".tk",
10830
+ ".tf", ".tfvars", ".hcl",
10831
+ ".tex", ".bib", ".sty", ".cls", ".typ",
10832
+ ".wat",
10833
+ ".diff", ".patch",
10834
+ ".graphql", ".gql",
10835
+ ".prisma", ".svg",
10452
10836
  }
10453
10837
  parsed_excerpt = ""
10454
10838
  kind = "binary"
@@ -10489,6 +10873,15 @@ class SessionState:
10489
10873
  workspace_target = self._upload_workspace_target(safe_name)
10490
10874
  workspace_target.parent.mkdir(parents=True, exist_ok=True)
10491
10875
  workspace_target.write_bytes(raw)
10876
+ # 当 parsed_excerpt 非空且原始文件不是纯文本时,保存解析结果为 .parsed.md
10877
+ parsed_target: Path | None = None
10878
+ if parsed_excerpt and kind not in ("text", "code"):
10879
+ parsed_target = workspace_target.parent / f"{workspace_target.stem}.parsed.md"
10880
+ try:
10881
+ header = f"# {safe_name}\n\n> Auto-parsed from uploaded {kind} file ({len(raw)} bytes)\n\n"
10882
+ parsed_target.write_text(header + parsed_excerpt, encoding="utf-8")
10883
+ except Exception:
10884
+ parsed_target = None # 解析文件保存失败不影响主流程
10492
10885
  workspace_rel = self._session_rel(workspace_target)
10493
10886
  meta = {
10494
10887
  "id": upload_id,
@@ -10507,6 +10900,9 @@ class SessionState:
10507
10900
  self.uploads = self.uploads[-80:]
10508
10901
  self.updated_at = now_ts()
10509
10902
  self._persist()
10903
+ if parsed_excerpt:
10904
+ bb_content = f"[upload:{safe_name}]\n{trim(parsed_excerpt, BLACKBOARD_MAX_TEXT - 200)}"
10905
+ self._blackboard_append_section("research_notes", "system", bb_content)
10510
10906
  self._emit(
10511
10907
  "upload",
10512
10908
  {
@@ -10514,7 +10910,10 @@ class SessionState:
10514
10910
  "workspace_path": workspace_rel,
10515
10911
  "kind": kind,
10516
10912
  "size": len(raw),
10517
- "summary": f"upload: {safe_name} -> {workspace_rel}",
10913
+ "summary": (
10914
+ f"upload: {safe_name} -> {workspace_rel}"
10915
+ + (f" (parsed text: {self._session_rel(parsed_target)})" if parsed_target else "")
10916
+ ),
10518
10917
  "preview": trim(parsed_excerpt, 500),
10519
10918
  },
10520
10919
  )
@@ -10611,6 +11010,12 @@ class SessionState:
10611
11010
  "completed",
10612
11011
  "finished",
10613
11012
  "all set",
11013
+ # 明确表示拒绝/无法完成也应视为终结
11014
+ "抱歉",
11015
+ "sorry",
11016
+ "无法",
11017
+ "cannot",
11018
+ "unable",
10614
11019
  ]
10615
11020
  if any(x in t for x in done_markers):
10616
11021
  return False
@@ -10692,6 +11097,17 @@ class SessionState:
10692
11097
  "that's all",
10693
11098
  "that is all",
10694
11099
  "as requested",
11100
+ # 明确表示无法完成的标记
11101
+ "抱歉,我无法",
11102
+ "无法直接获取",
11103
+ "无法完成",
11104
+ "cannot be done",
11105
+ "unable to",
11106
+ "not possible",
11107
+ "建议您通过",
11108
+ "建议你通过",
11109
+ "i cannot",
11110
+ "i'm unable",
10695
11111
  ]
10696
11112
  return any(x in t for x in done_markers)
10697
11113
 
@@ -11098,7 +11514,7 @@ class SessionState:
11098
11514
  return False
11099
11515
  if not str(thinking_text or "").strip():
11100
11516
  return False
11101
- threshold = int(max(1, AGENT_MAX_OUTPUT_TOKENS) * float(THINKING_BUDGET_FORCE_RATIO))
11517
+ threshold = int(max(1, self.max_output_tokens) * float(THINKING_BUDGET_FORCE_RATIO))
11102
11518
  return int(output_tokens or 0) >= max(1, threshold)
11103
11519
 
11104
11520
  def _is_thinking_only_dead_turn(self, text: str, thinking_text: str, tool_calls: list | None = None) -> bool:
@@ -12229,12 +12645,48 @@ class SessionState:
12229
12645
  fp = self._session_path(rel)
12230
12646
  content = fp.read_text(encoding="utf-8")
12231
12647
  if old_text not in content:
12232
- return f"Error: text not found in {rel}"
12648
+ diag = self._edit_mismatch_diagnostic(content, old_text)
12649
+ return f"Error: text not found in {rel}. {diag}"
12233
12650
  fp.write_text(content.replace(old_text, new_text, 1), encoding="utf-8")
12234
12651
  return f"Edited {rel}"
12235
12652
  except Exception as exc:
12236
12653
  return f"Error: {type(exc).__name__}: {exc}"
12237
12654
 
12655
+ def _edit_mismatch_diagnostic(self, content: str, old_text: str) -> str:
12656
+ """为 edit_file 匹配失败提供诊断信息"""
12657
+ lines = content.splitlines()
12658
+ first_line = old_text.strip().splitlines()[0].strip() if old_text.strip() else ""
12659
+ if not first_line:
12660
+ return "The old_text is empty or whitespace-only."
12661
+ # 搜索 old_text 的第一行在文件中的位置
12662
+ matches = []
12663
+ for i, line in enumerate(lines, 1):
12664
+ if first_line in line:
12665
+ matches.append(i)
12666
+ if matches:
12667
+ loc = ", ".join(str(m) for m in matches[:5])
12668
+ return (
12669
+ f"The first line of old_text was found at line(s) {loc}, "
12670
+ f"but the full multi-line match failed. "
12671
+ f"Likely cause: whitespace or indentation mismatch. "
12672
+ f"Tip: use read_file to get the exact content, then copy it precisely."
12673
+ )
12674
+ # 尝试空白规范化匹配
12675
+ norm_first = " ".join(first_line.split())
12676
+ for i, line in enumerate(lines, 1):
12677
+ norm_line = " ".join(line.split())
12678
+ if norm_first and norm_first in norm_line:
12679
+ return (
12680
+ f"A whitespace-normalized partial match was found near line {i}. "
12681
+ f"The old_text likely has wrong indentation or extra/missing spaces. "
12682
+ f"Use read_file to get exact content."
12683
+ )
12684
+ return (
12685
+ f"No match found. The file has {len(lines)} lines. "
12686
+ f"The old_text first line '{first_line[:60]}' does not appear in the file. "
12687
+ f"The content may have changed since last read. Use read_file to refresh."
12688
+ )
12689
+
12238
12690
  def _write_global_skill(self, args: dict) -> str:
12239
12691
  rel_raw = str(args.get("path", "") or "").strip().replace("\\", "/")
12240
12692
  if not rel_raw:
@@ -12861,15 +13313,13 @@ class SessionState:
12861
13313
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
12862
13314
  if bool(self.runtime_goal_reset_pending):
12863
13315
  return False, "goal-reset-pending"
12864
- approval = bb.get("approval", {}) if isinstance(bb.get("approval"), dict) else {}
12865
- if not bool(approval.get("approved", False)):
12866
- return False, "approval-false"
12867
- if self._approval_is_stale_for_latest_user(bb, latest_user_ts=latest_user_ts):
12868
- return False, "approval-stale-new-user-input"
12869
- if self._manager_has_error_log(bb):
12870
- return False, "blocking-error-log"
12871
- if not self._reviewer_final_summary_ready(bb):
12872
- return False, "reviewer-summary-missing"
13316
+ # Project todo gate: coding tasks must pass compile + test
13317
+ profile = self._ensure_blackboard_task_profile(bb)
13318
+ task_type = str(profile.get("task_type", "general") or "general")
13319
+ if task_type in ("simple_code", "engineering"):
13320
+ for todo in bb.get("project_todos", []):
13321
+ if todo.get("category") in ("compile_test", "min_test") and todo.get("status") != "completed":
13322
+ return False, f"project-todo-incomplete:{todo.get('category', '')}"
12873
13323
  return True, "ok"
12874
13324
 
12875
13325
  def _invalidate_stale_approval_if_needed(
@@ -13322,9 +13772,6 @@ class SessionState:
13322
13772
  "decomposer_output": trim(raw_text, 2000),
13323
13773
  }
13324
13774
  wd = self._normalize_watchdog_state(board.get("watchdog", {}))
13325
- wd["trigger_count"] = max(0, int(wd.get("trigger_count", 0) or 0)) + 1
13326
- wd["last_trigger_reason"] = trim(str(reason or "").strip(), 200)
13327
- wd["last_trigger_ts"] = float(now_ts())
13328
13775
  wd["intent_no_tool_streak"] = 0
13329
13776
  wd["repeat_no_tool_streak"] = 0
13330
13777
  board["watchdog"] = wd
@@ -13353,6 +13800,39 @@ class SessionState:
13353
13800
  )
13354
13801
  return True
13355
13802
 
13803
+ def _watchdog_escalate_to_single_developer(self, board: dict, *, reason: str = ""):
13804
+ """Watchdog 连续 stall 升级:强制降级到 Single+Developer 模式。"""
13805
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
13806
+ self.runtime_execution_mode = EXECUTION_MODE_SINGLE
13807
+ self.runtime_participants = ["developer"]
13808
+ self.runtime_assigned_expert = "developer"
13809
+ dq = self._normalize_decomposition_queue_state(bb.get("decomposition_queue", {}))
13810
+ dq["active"] = False
13811
+ bb["decomposition_queue"] = dq
13812
+ profile = self._ensure_blackboard_task_profile(bb)
13813
+ profile["execution_mode"] = EXECUTION_MODE_SINGLE
13814
+ profile["participants"] = ["developer"]
13815
+ profile["assigned_expert"] = "developer"
13816
+ bb["task_profile"] = profile
13817
+ self.blackboard = bb
13818
+ self._blackboard_touch()
13819
+ self._blackboard_history(
13820
+ "manager",
13821
+ trim(
13822
+ f"watchdog escalation: forced Single+Developer (trigger_count={int(bb.get('watchdog', {}).get('trigger_count', 0))}, reason={reason})",
13823
+ 520,
13824
+ ),
13825
+ )
13826
+ self._emit(
13827
+ "status",
13828
+ {
13829
+ "summary": (
13830
+ "watchdog escalation: multi-agent stall detected, "
13831
+ "downgrading to Single+Developer mode"
13832
+ )
13833
+ },
13834
+ )
13835
+
13356
13836
  def _watchdog_pick_executor_route(self, board: dict | None = None) -> tuple[dict, dict] | None:
13357
13837
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
13358
13838
  dq = self._normalize_decomposition_queue_state(bb.get("decomposition_queue", {}))
@@ -13542,10 +14022,13 @@ class SessionState:
13542
14022
  bb = self._ensure_blackboard()
13543
14023
  dq = self._normalize_decomposition_queue_state(bb.get("decomposition_queue", {}))
13544
14024
  trigger_reason = ""
14025
+ _is_single = self._effective_execution_mode() == EXECUTION_MODE_SINGLE
14026
+ _intent_th = WATCHDOG_INTENT_NO_TOOL_THRESHOLD_SINGLE if _is_single else WATCHDOG_INTENT_NO_TOOL_THRESHOLD
14027
+ _repeat_th = WATCHDOG_REPEAT_NO_TOOL_THRESHOLD_SINGLE if _is_single else WATCHDOG_REPEAT_NO_TOOL_THRESHOLD
13545
14028
  if not bool(dq.get("active", False)):
13546
- if int(wd.get("intent_no_tool_streak", 0) or 0) >= int(WATCHDOG_INTENT_NO_TOOL_THRESHOLD):
14029
+ if int(wd.get("intent_no_tool_streak", 0) or 0) >= int(_intent_th):
13547
14030
  trigger_reason = "intent-without-tool-call"
13548
- elif int(wd.get("repeat_no_tool_streak", 0) or 0) >= int(WATCHDOG_REPEAT_NO_TOOL_THRESHOLD):
14031
+ elif int(wd.get("repeat_no_tool_streak", 0) or 0) >= int(_repeat_th):
13549
14032
  trigger_reason = "repeated-no-tool-reply"
13550
14033
  elif (
13551
14034
  self._watchdog_context_near_limit()
@@ -13564,13 +14047,22 @@ class SessionState:
13564
14047
  except Exception:
13565
14048
  last_trigger_ts = 0.0
13566
14049
  if now_ts() - last_trigger_ts >= 1.0:
13567
- triggered = self._watchdog_activate_decomposition(
13568
- bb,
13569
- reason=trigger_reason,
13570
- role=role,
13571
- step=step,
13572
- pinned_selection=pinned_selection,
13573
- )
14050
+ wd["trigger_count"] = max(0, int(wd.get("trigger_count", 0) or 0)) + 1
14051
+ wd["last_trigger_reason"] = trim(str(trigger_reason or "").strip(), 200)
14052
+ wd["last_trigger_ts"] = float(now_ts())
14053
+ bb["watchdog"] = wd
14054
+ self.blackboard = bb
14055
+ self._blackboard_touch()
14056
+ if int(wd["trigger_count"]) >= 2:
14057
+ self._watchdog_escalate_to_single_developer(bb, reason=trigger_reason)
14058
+ else:
14059
+ triggered = self._watchdog_activate_decomposition(
14060
+ bb,
14061
+ reason=trigger_reason,
14062
+ role=role,
14063
+ step=step,
14064
+ pinned_selection=pinned_selection,
14065
+ )
13574
14066
  bb = self._ensure_blackboard()
13575
14067
  bb["watchdog"] = self._normalize_watchdog_state(bb.get("watchdog", wd))
13576
14068
  bb["decomposition_queue"] = self._normalize_decomposition_queue_state(bb.get("decomposition_queue", dq))
@@ -13705,6 +14197,7 @@ class SessionState:
13705
14197
  "text": "",
13706
14198
  "ts": 0.0,
13707
14199
  },
14200
+ "project_todos": [],
13708
14201
  "watchdog": self._new_watchdog_state(),
13709
14202
  "decomposition_queue": self._new_decomposition_queue_state(),
13710
14203
  }
@@ -13856,6 +14349,24 @@ class SessionState:
13856
14349
  "change_count": max(1, int(item.get("change_count", 1) or 1)),
13857
14350
  }
13858
14351
  board["code_artifacts"] = artifacts
14352
+ if not isinstance(bb_src_todos := src.get("project_todos"), list):
14353
+ board["project_todos"] = []
14354
+ else:
14355
+ clean_todos = []
14356
+ for pt in bb_src_todos[:20]:
14357
+ if not isinstance(pt, dict):
14358
+ continue
14359
+ clean_todos.append({
14360
+ "id": trim(str(pt.get("id", "") or ""), 20),
14361
+ "content": trim(str(pt.get("content", "") or ""), 400),
14362
+ "status": str(pt.get("status", "pending") or "pending") if str(pt.get("status", "pending") or "pending") in ("pending", "in_progress", "completed") else "pending",
14363
+ "category": trim(str(pt.get("category", "") or ""), 40),
14364
+ "created_at": float(pt.get("created_at", 0.0) or 0.0),
14365
+ "completed_at": float(pt.get("completed_at", 0.0) or 0.0) if pt.get("completed_at") else None,
14366
+ "completed_by": trim(str(pt.get("completed_by", "") or ""), 40),
14367
+ "evidence": trim(str(pt.get("evidence", "") or ""), 200),
14368
+ })
14369
+ board["project_todos"] = clean_todos
13859
14370
  board["watchdog"] = self._normalize_watchdog_state(src.get("watchdog", {}))
13860
14371
  board["decomposition_queue"] = self._normalize_decomposition_queue_state(
13861
14372
  src.get("decomposition_queue", {})
@@ -13876,6 +14387,7 @@ class SessionState:
13876
14387
  def _blackboard_reset_for_goal(self, goal: str):
13877
14388
  self.blackboard = self._new_blackboard(goal)
13878
14389
  self.manager_context = []
14390
+ self.agent_messages = [m for m in self.agent_messages if m.get("agent_role") != "manager"]
13879
14391
  self.manager_routes = []
13880
14392
  self._blackboard_history("manager", f"new goal accepted: {trim(goal, 300)}")
13881
14393
  self._sync_todos_from_blackboard(reason="goal-reset", board=self.blackboard)
@@ -14186,13 +14698,150 @@ class SessionState:
14186
14698
  row["activeForm"] = f"Pending ({label}): {text}"
14187
14699
  return rows
14188
14700
 
14701
+ # ── Project-based todo generation & status tracking ──────────────
14702
+
14703
+ def _generate_project_todos_from_profile(self, board: dict | None = None) -> list[dict]:
14704
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
14705
+ profile = self._ensure_blackboard_task_profile(bb)
14706
+ task_type = str(profile.get("task_type", "general") or "general")
14707
+ objective = trim(str(profile.get("direct_objective", "") or ""), 200)
14708
+
14709
+ if task_type == "simple_qa":
14710
+ return [{"content": f"回答: {objective}" if objective else "回答用户问题", "category": "implement"}]
14711
+
14712
+ if task_type in ("simple_code", "engineering"):
14713
+ return [
14714
+ {"content": "分析需求和项目结构", "category": "setup"},
14715
+ {"content": f"实现: {objective}" if objective else "实现编码任务", "category": "implement"},
14716
+ {"content": "编译/语法检查", "category": "compile_test"},
14717
+ {"content": "最小功能测试", "category": "min_test"},
14718
+ ]
14719
+
14720
+ if task_type == "research":
14721
+ return [
14722
+ {"content": f"调研: {objective}" if objective else "执行调研任务", "category": "implement"},
14723
+ {"content": "整理调研结果", "category": "review"},
14724
+ ]
14725
+
14726
+ return [{"content": f"执行: {objective}" if objective else "执行任务", "category": "implement"}]
14727
+
14728
+ def _init_project_todos(self, board: dict | None = None):
14729
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
14730
+ if bb.get("project_todos"):
14731
+ return
14732
+ raw = self._generate_project_todos_from_profile(bb)
14733
+ bb["project_todos"] = [
14734
+ {
14735
+ "id": f"pt:{i:03d}",
14736
+ "content": t["content"],
14737
+ "status": "pending",
14738
+ "category": t["category"],
14739
+ "created_at": float(now_ts()),
14740
+ "completed_at": None,
14741
+ "completed_by": "",
14742
+ "evidence": "",
14743
+ }
14744
+ for i, t in enumerate(raw)
14745
+ ]
14746
+ self.blackboard = bb
14747
+ self._blackboard_touch()
14748
+
14749
+ def _has_compile_pass_evidence(self, board: dict | None = None) -> bool:
14750
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
14751
+ logs = bb.get("execution_logs", []) if isinstance(bb.get("execution_logs"), list) else []
14752
+ if not logs:
14753
+ return False
14754
+ positive = ("compiled successfully", "build successful", "0 error", "编译成功",
14755
+ "syntax ok", "no errors", "build succeeded", "compilation successful")
14756
+ negative = ("error:", "fatal error", "syntax error", "compile error", "build failed")
14757
+ for entry in reversed(logs[-6:]):
14758
+ txt = str((entry or {}).get("content", "") or "").lower() if isinstance(entry, dict) else str(entry or "").lower()
14759
+ if not txt:
14760
+ continue
14761
+ if any(neg in txt for neg in negative):
14762
+ continue
14763
+ if any(pos in txt for pos in positive):
14764
+ return True
14765
+ return False
14766
+
14767
+ def _has_test_pass_evidence(self, board: dict | None = None) -> bool:
14768
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
14769
+ logs = bb.get("execution_logs", []) if isinstance(bb.get("execution_logs"), list) else []
14770
+ feedback = bb.get("review_feedback", []) if isinstance(bb.get("review_feedback"), list) else []
14771
+ positive = ("test passed", "tests passed", "测试通过", "运行正常",
14772
+ "all tests pass", "ok", "passed", "test succeeded")
14773
+ negative = ("failed", "error", "failure", "测试失败")
14774
+ combined = list(logs[-6:]) + list(feedback[-4:])
14775
+ for entry in reversed(combined[-8:]):
14776
+ txt = str((entry or {}).get("content", "") or "").lower() if isinstance(entry, dict) else str(entry or "").lower()
14777
+ if not txt:
14778
+ continue
14779
+ if any(neg in txt for neg in negative):
14780
+ continue
14781
+ if any(pos in txt for pos in positive):
14782
+ return True
14783
+ return False
14784
+
14785
+ def _update_project_todo_status(self, board: dict | None = None):
14786
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
14787
+ todos = bb.get("project_todos", [])
14788
+ if not todos:
14789
+ return
14790
+ code_count = len(bb.get("code_artifacts", {}) or {})
14791
+ research_count = len(bb.get("research_notes", []) or [])
14792
+ feedback_pass = self._manager_feedback_passed_from_blackboard(bb)
14793
+
14794
+ for todo in todos:
14795
+ if todo.get("status") == "completed":
14796
+ continue
14797
+ cat = todo.get("category", "")
14798
+ if cat == "setup" and (research_count > 0 or code_count > 0):
14799
+ todo.update(status="completed", completed_at=float(now_ts()), evidence="结构已分析")
14800
+ elif cat == "implement" and code_count > 0:
14801
+ todo.update(status="completed", completed_at=float(now_ts()),
14802
+ completed_by="developer", evidence=f"{code_count} 文件已产出")
14803
+ elif cat == "compile_test" and self._has_compile_pass_evidence(bb):
14804
+ todo.update(status="completed", completed_at=float(now_ts()), evidence="编译通过")
14805
+ elif cat == "min_test" and self._has_test_pass_evidence(bb):
14806
+ todo.update(status="completed", completed_at=float(now_ts()), evidence="测试通过")
14807
+ elif cat == "review" and feedback_pass:
14808
+ todo.update(status="completed", completed_at=float(now_ts()), evidence="审查通过")
14809
+
14810
+ if not any(t.get("status") == "in_progress" for t in todos):
14811
+ for t in todos:
14812
+ if t.get("status") == "pending":
14813
+ t["status"] = "in_progress"
14814
+ break
14815
+
14816
+ bb["project_todos"] = todos
14817
+ self.blackboard = bb
14818
+
14819
+ def _todo_project_rows_from_blackboard(self, board: dict | None = None) -> list[dict]:
14820
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
14821
+ todos = bb.get("project_todos", [])
14822
+ if not todos:
14823
+ return self._todo_owner_rows_from_blackboard(bb)
14824
+ rows = []
14825
+ for todo in todos:
14826
+ s = todo.get("status", "pending")
14827
+ c = todo.get("content", "")
14828
+ ev = todo.get("evidence", "")
14829
+ af = {
14830
+ "in_progress": f"Working on: {c}",
14831
+ "completed": f"Done: {c}" + (f" ({ev})" if ev else ""),
14832
+ }.get(s, f"Pending: {c}")
14833
+ rows.append({"key": f"bb:proj:{todo.get('id', '')}", "content": c, "status": s, "activeForm": af})
14834
+ return rows
14835
+
14189
14836
  def _sync_todos_from_blackboard(self, reason: str = "", board: dict | None = None):
14190
14837
  if bool(self.runtime_reclassify_required):
14191
14838
  return
14192
14839
  if not self._is_multi_agent_mode():
14193
14840
  return
14194
14841
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
14195
- system_rows = self._todo_owner_rows_from_blackboard(bb)
14842
+ self._init_project_todos(bb)
14843
+ self._update_project_todo_status(bb)
14844
+ system_rows = self._todo_project_rows_from_blackboard(bb)
14196
14845
  existing = self.todo.snapshot()
14197
14846
  non_system_rows: list[dict] = []
14198
14847
  for row in existing:
@@ -14200,18 +14849,17 @@ class SessionState:
14200
14849
  continue
14201
14850
  key = str(row.get("key", "") or "").strip()
14202
14851
  owner = str(row.get("owner", "") or "").strip().lower()
14203
- if key.startswith("bb:owner:") or key.startswith("bb:node:") or owner in {"manager", "explorer", "developer", "reviewer"}:
14852
+ if key.startswith(("bb:owner:", "bb:node:", "bb:proj:")) or owner in {"manager", "explorer", "developer", "reviewer"}:
14204
14853
  continue
14205
14854
  non_system_rows.append(dict(row))
14206
14855
  remaining_cap = max(0, 20 - len(system_rows))
14207
14856
  merged = list(system_rows) + non_system_rows[:remaining_cap]
14208
14857
  try:
14209
14858
  todo_out = self.todo.update(merged)
14210
- except Exception as exc:
14211
- self._emit("status", {"summary": f"owner todo sync skipped: {trim(str(exc), 180)}"})
14859
+ except Exception:
14212
14860
  return
14213
14861
  if todo_out != "No todo changes." and reason:
14214
- self._emit("status", {"summary": f"owner todos synced ({trim(reason, 120)})"})
14862
+ self._emit("status", {"summary": f"project todos synced ({trim(reason, 120)})"})
14215
14863
 
14216
14864
  def _blackboard_set_status(self, status: str, note: str = ""):
14217
14865
  board = self._ensure_blackboard()
@@ -14233,6 +14881,43 @@ class SessionState:
14233
14881
  self._blackboard_touch()
14234
14882
  self._sync_todos_from_blackboard(reason="approved", board=board)
14235
14883
 
14884
+ def _auto_summary_on_finish(self) -> str:
14885
+ """Generate concise summary from blackboard state when run ends."""
14886
+ bb = self._ensure_blackboard()
14887
+ artifacts = bb.get("code_artifacts", {}) if isinstance(bb.get("code_artifacts"), dict) else {}
14888
+ logs = bb.get("execution_logs", []) if isinstance(bb.get("execution_logs"), list) else []
14889
+ feedback = bb.get("review_feedback", []) if isinstance(bb.get("review_feedback"), list) else []
14890
+ summary_parts = []
14891
+ if artifacts:
14892
+ file_list = ", ".join(list(artifacts.keys())[:10])
14893
+ summary_parts.append(f"Modified files: {file_list}")
14894
+ if feedback:
14895
+ last_fb = feedback[-1] if feedback else {}
14896
+ fb_content = str(last_fb.get("content", "") or "") if isinstance(last_fb, dict) else str(last_fb)
14897
+ if fb_content:
14898
+ summary_parts.append(f"Review: {trim(fb_content, 200)}")
14899
+ if logs:
14900
+ recent_logs = logs[-3:]
14901
+ log_strs = []
14902
+ for log_entry in recent_logs:
14903
+ if isinstance(log_entry, dict):
14904
+ log_strs.append(trim(str(log_entry.get("content", "") or ""), 80))
14905
+ elif isinstance(log_entry, str):
14906
+ log_strs.append(trim(log_entry, 80))
14907
+ if log_strs:
14908
+ summary_parts.append(f"Logs: {'; '.join(log_strs)}")
14909
+ summary = "; ".join(summary_parts) or "Task completed."
14910
+ bb["status"] = "COMPLETED"
14911
+ bb["approval"] = {
14912
+ "approved": True,
14913
+ "by": "auto",
14914
+ "note": summary,
14915
+ "ts": float(now_ts()),
14916
+ }
14917
+ self._blackboard_touch()
14918
+ self._emit("status", {"summary": f"[auto-summary] {trim(summary, 400)}"})
14919
+ return summary
14920
+
14236
14921
  def _blackboard_read_state_markdown(self, max_items: int = 6) -> str:
14237
14922
  board = self._ensure_blackboard()
14238
14923
  profile = self._ensure_blackboard_task_profile(board)
@@ -14347,6 +15032,23 @@ class SessionState:
14347
15032
  lines.append("- (none)")
14348
15033
  _render_tail("Recent Execution Logs", board.get("execution_logs", []))
14349
15034
  _render_tail("Recent Review Feedback", board.get("review_feedback", []))
15035
+
15036
+ proj_todos = board.get("project_todos", [])
15037
+ if proj_todos:
15038
+ lines.append("\n### Project Tasks")
15039
+ for pt in proj_todos:
15040
+ s = pt.get("status", "pending")
15041
+ c = trim(str(pt.get("content", "") or ""), 200)
15042
+ ev = trim(str(pt.get("evidence", "") or ""), 100)
15043
+ if s == "completed":
15044
+ mark = "[x]"
15045
+ elif s == "in_progress":
15046
+ mark = "[>]"
15047
+ else:
15048
+ mark = "[ ]"
15049
+ suffix = f" — {ev}" if ev else ""
15050
+ lines.append(f"- {mark} {c}{suffix}")
15051
+
14350
15052
  return "\n".join(lines)
14351
15053
 
14352
15054
  def _manager_route_tools(self) -> list[dict]:
@@ -14612,7 +15314,7 @@ class SessionState:
14612
15314
  "judgement": trim(str(profile.get("reason", "") or "manager fallback classification"), 200),
14613
15315
  "round_budget": int(policy.get("round_budget", profile.get("round_budget", self.max_agent_rounds)) or 0),
14614
15316
  "direct_objective": trim(str(profile.get("direct_objective", "") or ""), 800),
14615
- "execution_mode": str(policy.get("execution_mode", EXECUTION_MODE_SYNC)),
15317
+ "execution_mode": (EXECUTION_MODE_SINGLE if normalize_execution_mode(self.execution_mode, default="") == EXECUTION_MODE_SINGLE else str(policy.get("execution_mode", EXECUTION_MODE_SYNC))),
14616
15318
  "participants": participants,
14617
15319
  "assigned_expert": assigned,
14618
15320
  "requires_user_confirmation": bool(requires_confirmation),
@@ -14622,7 +15324,7 @@ class SessionState:
14622
15324
  }
14623
15325
 
14624
15326
  def _manager_classification_system_prompt(self) -> str:
14625
- return (
15327
+ base = (
14626
15328
  "You are Manager. Classify the latest user request by semantic intent, not by keyword templates. "
14627
15329
  "Decide whether this latest turn should inherit the previous blackboard/task state. "
14628
15330
  "Set inherit_previous_state=true only for genuine follow-up/continuation/refinement of the same ongoing work; "
@@ -14645,6 +15347,12 @@ class SessionState:
14645
15347
  "Use low confidence only when semantic ambiguity is substantial, then set low_confidence_reason briefly. "
14646
15348
  f"{model_language_instruction(self.ui_language)}"
14647
15349
  )
15350
+ if normalize_execution_mode(self.execution_mode, default="") == EXECUTION_MODE_SINGLE:
15351
+ base += (
15352
+ " NOTE: User has configured Single-agent mode. "
15353
+ "Favor level 1-2 for straightforward tasks; only assign level 3+ when genuine multi-step complexity demands it."
15354
+ )
15355
+ return base
14648
15356
 
14649
15357
  def _apply_runtime_task_decision(self, goal_text: str, decision: dict):
14650
15358
  row = dict(decision or {})
@@ -14666,7 +15374,12 @@ class SessionState:
14666
15374
  if level not in TASK_LEVEL_CHOICES:
14667
15375
  level = 3
14668
15376
  policy = dict(TASK_LEVEL_POLICIES.get(level, TASK_LEVEL_POLICIES[3]))
14669
- mode = str(policy.get("execution_mode", EXECUTION_MODE_SYNC))
15377
+ policy_mode = str(policy.get("execution_mode", EXECUTION_MODE_SYNC))
15378
+ config_mode = normalize_execution_mode(self.execution_mode, default="")
15379
+ if config_mode == EXECUTION_MODE_SINGLE:
15380
+ mode = EXECUTION_MODE_SINGLE
15381
+ else:
15382
+ mode = policy_mode
14670
15383
  assigned = self._sanitize_agent_role(
14671
15384
  row.get("assigned_expert", policy.get("assigned_expert", "developer"))
14672
15385
  ) or self._sanitize_agent_role(policy.get("assigned_expert", "developer")) or "developer"
@@ -14697,6 +15410,9 @@ class SessionState:
14697
15410
  except Exception:
14698
15411
  budget_raw = int(policy.get("round_budget", self.max_agent_rounds) or self.max_agent_rounds)
14699
15412
  round_budget = max(1, min(int(self.max_agent_rounds or MAX_AGENT_ROUNDS), int(budget_raw)))
15413
+ if mode == EXECUTION_MODE_SINGLE and policy_mode != EXECUTION_MODE_SINGLE:
15414
+ policy_budget = int(policy.get("round_budget", 10) or 10)
15415
+ round_budget = min(round_budget, max(4, policy_budget // 2))
14700
15416
  requires_confirmation = bool(row.get("requires_user_confirmation", policy.get("requires_user_confirmation", False)))
14701
15417
  if level == 5 and self._looks_like_positive_confirmation(goal_text):
14702
15418
  requires_confirmation = False
@@ -14875,8 +15591,14 @@ class SessionState:
14875
15591
  "When user preference is clear, prioritize it over your default plan. "
14876
15592
  "Remember: budget controls internal thought depth/round compactness, not early stop messaging. "
14877
15593
  "Also decide inherit_previous_state by semantic continuity with prior blackboard state. "
14878
- "If this is pure continuation, keep current runtime policy unchanged."
15594
+ "If this is pure continuation, keep current runtime policy unchanged.\n"
15595
+ f"User configured execution mode: {self.execution_mode}\n"
14879
15596
  )
15597
+ if normalize_execution_mode(self.execution_mode, default="") == EXECUTION_MODE_SINGLE:
15598
+ prompt += (
15599
+ "IMPORTANT: User has configured Single-agent mode. "
15600
+ "Prefer level 1-2 for simple tasks. Only escalate to level 3+ if truly complex.\n"
15601
+ )
14880
15602
  with self.lock:
14881
15603
  self.current_phase = "manager:classify:model-call"
14882
15604
  self.current_tool_name = ""
@@ -14959,6 +15681,22 @@ class SessionState:
14959
15681
  self._apply_runtime_task_decision(goal, decision)
14960
15682
  return dict(decision or {})
14961
15683
 
15684
+ def _project_todo_hint_for_manager(self) -> str:
15685
+ bb = self._ensure_blackboard()
15686
+ todos = bb.get("project_todos", [])
15687
+ if not todos:
15688
+ return ""
15689
+ pending = [t for t in todos if t.get("status") != "completed"]
15690
+ if not pending:
15691
+ return "All project tasks completed. Route to finish. "
15692
+ cur = pending[0]
15693
+ cat = cur.get("category", "")
15694
+ if cat == "compile_test":
15695
+ return "NEXT: compile/syntax check required. Route to Developer for build. "
15696
+ if cat == "min_test":
15697
+ return "NEXT: minimal test required. Route to Developer to run tests. "
15698
+ return f"NEXT: {trim(str(cur.get('content', '') or ''), 120)}. "
15699
+
14962
15700
  def _manager_system_prompt(self) -> str:
14963
15701
  board = self._ensure_blackboard()
14964
15702
  profile = self._ensure_blackboard_task_profile(board)
@@ -14966,41 +15704,30 @@ class SessionState:
14966
15704
  budget = self._blackboard_round_budget(board)
14967
15705
  level = int(profile.get("task_level", self.runtime_task_level or 0) or 0)
14968
15706
  mode = normalize_execution_mode(profile.get("execution_mode", self._effective_execution_mode()), default=self._effective_execution_mode())
14969
- scale_preference = str(profile.get("scale_preference", self.runtime_scale_preference) or "balanced")
14970
- if scale_preference not in TASK_SCALE_PREFERENCES:
14971
- scale_preference = "balanced"
14972
- participants = profile.get("participants", self.runtime_participants)
14973
- if not isinstance(participants, list):
14974
- participants = []
14975
- participant_text = ",".join(
14976
- [role for role in [self._sanitize_agent_role(x) for x in participants] if role][:3]
14977
- ) or "-"
15707
+ task_type = str(profile.get("task_type", "general") or "general").strip().lower()
15708
+ coding_hint = ""
15709
+ if task_type in ("simple_code", "engineering"):
15710
+ coding_hint = (
15711
+ "CODING TASK: skip lengthy exploration/design phases. "
15712
+ "Route to Developer early for implementation. "
15713
+ "Explorer should only be used for specific file/API lookups, not broad analysis. "
15714
+ )
15715
+ project_todo_hint = self._project_todo_hint_for_manager()
14978
15716
  return (
14979
- "You are Manager in a blackboard-driven multi-agent coding system. "
14980
- "Do not write code and do not call worker tools directly. "
14981
- f"Session absolute writable root is {self.files_root}; instruct workers to use relative paths, "
14982
- "and treat '/workspace/...' only as a virtual alias for tool arguments. "
14983
- "Read blackboard state and delegate one short next timeslice only. "
14984
- "Use route_to_next_agent exactly once each turn. "
14985
- "Before delegating, classify task level/type/complexity by your own judgement, "
14986
- "and include task_level/task_type/complexity/judgement/(optional)round_budget/direct_objective "
14987
- "in route_to_next_agent arguments whenever possible. "
14988
- "When concrete execution is required, set is_mandatory=true so worker must call at least one tool "
14989
- "instead of replying with suggestion-only text. "
14990
- "Round budget is an internal cadence control to reduce overthinking and idle loops. "
14991
- "Never use budget as an early-stop reason shown to user before task completion. "
14992
- "Decision policy: missing facts/API -> explorer; implementation/update -> developer; "
14993
- "verification/gap check -> reviewer; only choose finish when review is approved and no blocking logs remain. "
14994
- "Prefer Manager+AgentBus co-management: when fresh agentbus handoff is available and aligned, "
14995
- "follow that handoff to reduce orchestration latency instead of re-planning from scratch. "
14996
- "If finish is blocked by missing final summary after review approval, instruct Reviewer to hand off Explorer "
14997
- "via agentbus (intent=final_summary_request) instead of silently ending. "
14998
- f"Current task level={level or '-'}, mode={mode}, scale_preference={scale_preference}, participants={participant_text}, "
14999
- f"Current task profile: type={profile.get('task_type','general')}, "
15000
- f"complexity={profile.get('complexity','simple')}, "
15001
- f"progress={progress}, round_budget={'unlimited' if int(budget) <= 0 else int(budget)}, "
15717
+ "You are Manager in a multi-agent coding system. "
15718
+ "Read blackboard, delegate one short timeslice via route_to_next_agent. "
15719
+ "Policy: missing facts->explorer, implementation->developer, verification->reviewer, "
15720
+ "all done->finish. Set is_mandatory=true when concrete execution is required. "
15721
+ "Role capabilities: "
15722
+ "Explorer=read-only (bash/read_file/search/blackboard, NO write_file/edit_file); "
15723
+ "Developer=all tools (write_file/edit_file/bash/read_file/etc); "
15724
+ "Reviewer=read+verify (bash/read_file/finish_task, NO write_file/edit_file). "
15725
+ "NEVER delegate file-writing tasks to Explorer or Reviewer. "
15726
+ f"{coding_hint}"
15727
+ f"{project_todo_hint}"
15728
+ f"Level={level}, mode={mode}, progress={progress}, "
15729
+ f"budget={'unlimited' if int(budget) <= 0 else int(budget)}, "
15002
15730
  f"objective={trim(str(profile.get('direct_objective','') or ''), 220)}. "
15003
- "Avoid assigning the same agent more than two consecutive turns unless strictly required. "
15004
15731
  f"{model_language_instruction(self.ui_language)}"
15005
15732
  )
15006
15733
 
@@ -15243,6 +15970,24 @@ class SessionState:
15243
15970
  "reason": "approval-blocked-by-error",
15244
15971
  "source": "fallback",
15245
15972
  }
15973
+ if finish_gate_reason.startswith("project-todo-incomplete:"):
15974
+ missing_cat = finish_gate_reason.split(":", 1)[-1] if ":" in finish_gate_reason else ""
15975
+ if missing_cat == "compile_test":
15976
+ return {
15977
+ "target": "developer",
15978
+ "instruction": "编译/语法检查尚未完成。请编译项目并确认无错误。",
15979
+ "reason": "project-todo-compile-pending",
15980
+ "source": "fallback",
15981
+ "is_mandatory": True,
15982
+ }
15983
+ if missing_cat == "min_test":
15984
+ return {
15985
+ "target": "developer",
15986
+ "instruction": "最小功能测试尚未完成。请运行基本测试验证核心功能。",
15987
+ "reason": "project-todo-test-pending",
15988
+ "source": "fallback",
15989
+ "is_mandatory": True,
15990
+ }
15246
15991
  if task_type == "simple_qa":
15247
15992
  dev_text = self._latest_agent_assistant_text("developer")
15248
15993
  if dev_text:
@@ -15263,6 +16008,29 @@ class SessionState:
15263
16008
  "reason": "simple-qa-direct-answer",
15264
16009
  "source": "fallback",
15265
16010
  }
16011
+ # ── 通用 endpoint 检测:非 simple_qa 的 developer 结论性回复也能触发 finish ──
16012
+ if task_type != "simple_qa":
16013
+ dev_text = self._latest_agent_assistant_text("developer")
16014
+ if dev_text:
16015
+ done_probe = self._detect_endpoint_intent(dev_text, None)
16016
+ if bool(done_probe.get("matched", False)) and not has_error_log:
16017
+ return {
16018
+ "target": "finish",
16019
+ "instruction": "Developer has provided a conclusive response; stop now.",
16020
+ "reason": "general-endpoint-detected",
16021
+ "source": "fallback",
16022
+ }
16023
+ # 通用检查:如果最近的 assistant 消息是结论性回复,且没有待办事项,直接 finish
16024
+ if not has_error_log:
16025
+ for _role in ("developer", "explorer", "reviewer"):
16026
+ _last = self._latest_agent_assistant_text(_role)
16027
+ if _last and self._looks_like_conclusive_reply(_last) and not self.todo.has_open_items():
16028
+ return {
16029
+ "target": "finish",
16030
+ "instruction": "Agent already provided a conclusive reply with no open tasks; stop now.",
16031
+ "reason": "conclusive-reply-detected",
16032
+ "source": "fallback",
16033
+ }
15266
16034
  if complexity == "simple" and task_type == "simple_code":
15267
16035
  if has_error_log:
15268
16036
  return {
@@ -15398,6 +16166,24 @@ class SessionState:
15398
16166
  if str(row.get("task_type", "") or "").strip().lower() == "simple_qa":
15399
16167
  return row
15400
16168
  target = str(row.get("target", "") or "").strip().lower()
16169
+ task_type_low = str(row.get("task_type", "") or "").strip().lower()
16170
+ if task_type_low in ("simple_code", "engineering") and target == "explorer":
16171
+ board = self._ensure_blackboard()
16172
+ progress = self._manager_progress_state(board)
16173
+ if progress in ("initializing", "in_progress"):
16174
+ explorer_count = sum(
16175
+ 1 for x in self.manager_routes[-8:]
16176
+ if str(x.get("target", "") or "").strip().lower() == "explorer"
16177
+ )
16178
+ if explorer_count >= 2:
16179
+ row["target"] = "developer"
16180
+ row["instruction"] = (
16181
+ "Coding task: Explorer has been used enough. "
16182
+ "Start implementation now using write_file/edit_file."
16183
+ )
16184
+ row["reason"] = f"{row.get('reason', '')}|coding-fast-track->developer"
16185
+ row["source"] = "anti-stall"
16186
+ return row
15401
16187
  if target not in AGENT_ROLES:
15402
16188
  return row
15403
16189
  recent = [str(x.get("target", "") or "").strip().lower() for x in self.manager_routes[-4:]]
@@ -15492,6 +16278,11 @@ class SessionState:
15492
16278
  participants[-1] = target
15493
16279
  else:
15494
16280
  target = participants[0]
16281
+ # ── Single 模式硬约束:无论 executor_mode_flag 如何,只允许 assigned_expert ──
16282
+ if mode == EXECUTION_MODE_SINGLE:
16283
+ participants = [assigned_expert]
16284
+ if target in AGENT_ROLES and target != assigned_expert:
16285
+ target = assigned_expert
15495
16286
  instruction = trim(str(row.get("instruction", "") or "").strip(), 1200)
15496
16287
  if not instruction:
15497
16288
  instruction = "Proceed with one concrete next step and report evidence."
@@ -15548,6 +16339,24 @@ class SessionState:
15548
16339
  feedback_pass = self._manager_feedback_passed_from_blackboard(board)
15549
16340
  summary_attempts = int(board.get("manager_summary_attempts", 0) or 0)
15550
16341
  force_finish_override = False
16342
+ # ── 结论性回复截断:当 Agent 已回复结论且无待办/无错误时,强制 finish ──
16343
+ if target in AGENT_ROLES and target != "finish":
16344
+ for _check_role in ("developer", "explorer", "reviewer"):
16345
+ _last_text = self._latest_agent_assistant_text(_check_role)
16346
+ if (
16347
+ _last_text
16348
+ and self._looks_like_conclusive_reply(_last_text)
16349
+ and not self.todo.has_open_items()
16350
+ and not self._manager_has_error_log(board)
16351
+ ):
16352
+ target = "finish"
16353
+ instruction = (
16354
+ f"Agent '{_check_role}' already provided a conclusive reply. "
16355
+ "No open tasks remain. Finishing now."
16356
+ )
16357
+ row["reason"] = f"conclusive-reply-override:{_check_role}"
16358
+ row["source"] = "policy"
16359
+ break
15551
16360
  if bool((board.get("approval", {}) or {}).get("approved", False)) and can_finish_from_approval:
15552
16361
  target = "finish"
15553
16362
  if not instruction:
@@ -15616,6 +16425,44 @@ class SessionState:
15616
16425
  "Do not finish yet. Latest execution logs still contain blocking errors. "
15617
16426
  "Resolve errors and provide verifiable evidence."
15618
16427
  )
16428
+ elif finish_gate_reason.startswith("project-todo-incomplete:"):
16429
+ missing_cat = finish_gate_reason.split(":", 1)[-1] if ":" in finish_gate_reason else ""
16430
+ target = "developer"
16431
+ if missing_cat == "compile_test":
16432
+ instruction = (
16433
+ "编译/语法检查尚未完成。请编译项目并确认无错误。"
16434
+ "Run build/compile and confirm zero errors before finishing."
16435
+ )
16436
+ elif missing_cat == "min_test":
16437
+ instruction = (
16438
+ "最小功能测试尚未完成。请运行基本测试验证核心功能。"
16439
+ "Run minimal tests to verify core functionality before finishing."
16440
+ )
16441
+ else:
16442
+ instruction = (
16443
+ "Project todo not yet completed. Execute the pending step and report evidence."
16444
+ )
16445
+ # Force-finish fallback: if blocked > 3 cycles, mark as done to avoid deadloop
16446
+ summary_attempts = int(board.get("manager_summary_attempts", 0) or 0)
16447
+ if summary_attempts >= 3:
16448
+ force_finish_override = True
16449
+ target = "finish"
16450
+ for pt in board.get("project_todos", []):
16451
+ if pt.get("category") in ("compile_test", "min_test") and pt.get("status") != "completed":
16452
+ pt.update(status="completed", completed_at=float(now_ts()),
16453
+ evidence="force-finish fallback")
16454
+ self.blackboard = board
16455
+ instruction = (
16456
+ "Compile/test gate exceeded retry limit. Force finishing with available evidence. "
16457
+ "Generate final summary and finish now."
16458
+ )
16459
+ row["reason"] = "finish-blocked-project-todo-force-close"
16460
+ row["source"] = "policy"
16461
+ else:
16462
+ board["manager_summary_attempts"] = summary_attempts + 1
16463
+ self.blackboard = board
16464
+ row["reason"] = f"finish-blocked-{missing_cat}"
16465
+ row["source"] = "policy"
15619
16466
  else:
15620
16467
  has_outputs = bool(code_count > 0 or research_count > 0)
15621
16468
  if board_status == "COMPLETED" and has_outputs:
@@ -15848,7 +16695,7 @@ class SessionState:
15848
16695
  },
15849
16696
  }
15850
16697
  ]
15851
- self.manager_context.append(
16698
+ self._append_manager_context(
15852
16699
  {
15853
16700
  "role": "system",
15854
16701
  "content": (
@@ -15858,7 +16705,6 @@ class SessionState:
15858
16705
  "ts": now_ts(),
15859
16706
  }
15860
16707
  )
15861
- self.manager_context = self.manager_context[-400:]
15862
16708
  self._emit(
15863
16709
  "status",
15864
16710
  {
@@ -15898,7 +16744,7 @@ class SessionState:
15898
16744
  },
15899
16745
  }
15900
16746
  ]
15901
- self.manager_context.append(
16747
+ self._append_manager_context(
15902
16748
  {
15903
16749
  "role": "system",
15904
16750
  "content": (
@@ -15908,7 +16754,6 @@ class SessionState:
15908
16754
  "ts": now_ts(),
15909
16755
  }
15910
16756
  )
15911
- self.manager_context = self.manager_context[-400:]
15912
16757
  self._emit(
15913
16758
  "status",
15914
16759
  {
@@ -15926,8 +16771,8 @@ class SessionState:
15926
16771
  "Return only one route_to_next_agent call.\n\n"
15927
16772
  f"{self._blackboard_read_state_markdown(max_items=6)}"
15928
16773
  )
15929
- self.manager_context.append({"role": "user", "content": prompt, "ts": now_ts()})
15930
- self.manager_context = self.manager_context[-400:]
16774
+ self._append_manager_context({"role": "user", "content": prompt, "ts": now_ts()})
16775
+ self._microcompact_agent_messages(self.manager_context)
15931
16776
  with self.lock:
15932
16777
  self.current_phase = "manager:model-call"
15933
16778
  self.current_tool_name = ""
@@ -15963,8 +16808,7 @@ class SessionState:
15963
16808
  }
15964
16809
  for tc in tool_calls
15965
16810
  ]
15966
- self.manager_context.append(assistant)
15967
- self.manager_context = self.manager_context[-400:]
16811
+ self._append_manager_context(assistant)
15968
16812
  route_only_tool_calls = False
15969
16813
  if isinstance(tool_calls, list) and tool_calls:
15970
16814
  tool_names = [
@@ -16241,6 +17085,9 @@ class SessionState:
16241
17085
  "agent_role": role_key,
16242
17086
  }
16243
17087
  self.contexts[role_key] = [executor_seed]
17088
+ # Also filter old messages for this role from agent_messages and add the seed
17089
+ self.agent_messages = [m for m in self.agent_messages if m.get("agent_role") != role_key]
17090
+ self.agent_messages.append(executor_seed)
16244
17091
  self._emit(
16245
17092
  "status",
16246
17093
  {
@@ -16259,11 +17106,22 @@ class SessionState:
16259
17106
  max_len=1400,
16260
17107
  )
16261
17108
  language_note = embedded_policy or self._agent_language_policy_note()
17109
+ role_capability_note = {
17110
+ "explorer": "YOUR TOOLS: read-only (bash/read_file/search/blackboard). You CANNOT write_file or edit_file.",
17111
+ "developer": "YOUR TOOLS: all tools available (write_file/edit_file/bash/read_file/etc).",
17112
+ "reviewer": "YOUR TOOLS: read+verify (bash/read_file/finish_task). You CANNOT write_file or edit_file.",
17113
+ }.get(role_key, "")
17114
+ if role_key == "explorer":
17115
+ tool_examples = "bash/read_file/read_from_blackboard"
17116
+ elif role_key == "reviewer":
17117
+ tool_examples = "bash/read_file/finish_task"
17118
+ else:
17119
+ tool_examples = "write_file/edit_file/bash/read_file"
16262
17120
  mandatory_note = (
16263
17121
  (
16264
17122
  "MANDATORY EXECUTION: this delegate is hard-push. "
16265
17123
  "You must call at least one concrete tool in this turn "
16266
- "(e.g. write_file/edit_file/bash/read_file) and produce verifiable progress. "
17124
+ f"(e.g. {tool_examples}) and produce verifiable progress. "
16267
17125
  "Suggestion-only text reply is forbidden."
16268
17126
  )
16269
17127
  if bool(is_mandatory)
@@ -16293,6 +17151,7 @@ class SessionState:
16293
17151
  f"{mandatory_note}\n"
16294
17152
  f"{executor_note}\n"
16295
17153
  f"{collaboration_note}\n"
17154
+ f"{role_capability_note}\n"
16296
17155
  "</manager-delegate>\n"
16297
17156
  "<blackboard-state>\n"
16298
17157
  f"{trim(board_md, 6000)}\n"
@@ -16802,10 +17661,17 @@ class SessionState:
16802
17661
  role_key = self._sanitize_agent_role(role)
16803
17662
  if not role_key:
16804
17663
  return self.messages
17664
+ # Build filtered view from unified agent_messages
17665
+ filtered = [
17666
+ m for m in self.agent_messages
17667
+ if m.get("agent_role") == role_key
17668
+ or m.get("agent_role") == "shared"
17669
+ or (m.get("role") == "user" and not m.get("agent_role"))
17670
+ ]
17671
+ # Update legacy cache for backward compatibility (serialization, etc.)
16805
17672
  if not isinstance(self.contexts, dict):
16806
17673
  self.contexts = {r: [] for r in AGENT_ROLES}
16807
- if role_key not in self.contexts or not isinstance(self.contexts.get(role_key), list):
16808
- self.contexts[role_key] = []
17674
+ self.contexts[role_key] = filtered[-400:]
16809
17675
  return self.contexts[role_key]
16810
17676
 
16811
17677
  def _append_agent_context_message(self, role: str, message: dict, *, mirror_to_global: bool = False) -> dict:
@@ -16814,11 +17680,10 @@ class SessionState:
16814
17680
  row["agent_role"] = role_key
16815
17681
  if "ts" not in row:
16816
17682
  row["ts"] = now_ts()
16817
- ctx = self._agent_context(role_key)
16818
- ctx.append(row)
16819
- if len(ctx) > 400:
16820
- self.contexts[role_key] = ctx[-400:]
16821
- ctx = self.contexts[role_key]
17683
+ # Write to unified agent_messages
17684
+ self.agent_messages.append(row)
17685
+ if len(self.agent_messages) > 1200:
17686
+ self.agent_messages = self.agent_messages[-800:]
16822
17687
  if mirror_to_global:
16823
17688
  mirror = dict(row)
16824
17689
  if "tool_calls" in mirror and isinstance(mirror.get("tool_calls"), list):
@@ -16838,6 +17703,19 @@ class SessionState:
16838
17703
  self.messages = self.messages[-400:]
16839
17704
  return row
16840
17705
 
17706
+ def _append_manager_context(self, message: dict):
17707
+ """Append to manager_context and agent_messages in sync."""
17708
+ row = dict(message or {})
17709
+ if "agent_role" not in row:
17710
+ row["agent_role"] = "manager"
17711
+ if "ts" not in row:
17712
+ row["ts"] = now_ts()
17713
+ self.manager_context.append(row)
17714
+ self.manager_context = self.manager_context[-400:]
17715
+ self.agent_messages.append(row)
17716
+ if len(self.agent_messages) > 1200:
17717
+ self.agent_messages = self.agent_messages[-800:]
17718
+
16841
17719
  def _agent_display_name(self, role: str) -> str:
16842
17720
  return AGENT_ROLE_LABELS.get(self._sanitize_agent_role(role), str(role or "").strip().title() or "Agent")
16843
17721
 
@@ -16935,52 +17813,69 @@ class SessionState:
16935
17813
  )
16936
17814
  return envelope
16937
17815
 
17816
+ def _drain_agentbus_fast_route(self) -> dict | None:
17817
+ """Check agent_bus_messages for an unprocessed handoff that can skip manager.
17818
+
17819
+ Returns route dict with 'to' and 'payload' if a valid fast-route is found,
17820
+ otherwise returns None (fall back to manager delegation).
17821
+ """
17822
+ if not self.agent_bus_messages:
17823
+ return None
17824
+ now = now_ts()
17825
+ valid_intents = {
17826
+ "handoff", "review_request", "fix_request",
17827
+ "final_summary_request", "implementation_ready",
17828
+ }
17829
+ for env in reversed(self.agent_bus_messages[-20:]):
17830
+ if not isinstance(env, dict):
17831
+ continue
17832
+ if env.get("_fast_routed"):
17833
+ continue
17834
+ intent = str(env.get("intent", "") or "").strip().lower()
17835
+ if intent not in valid_intents:
17836
+ continue
17837
+ to_role = self._sanitize_agent_role(env.get("to", ""))
17838
+ if not to_role or to_role not in AGENT_ROLES:
17839
+ continue
17840
+ age = max(0.0, now - float(env.get("ts", 0.0) or 0.0))
17841
+ if age > 180.0:
17842
+ continue
17843
+ env["_fast_routed"] = True
17844
+ payload = trim(str(env.get("payload", "") or ""), 1400)
17845
+ from_role = str(env.get("from", "") or "")
17846
+ self._emit(
17847
+ "status",
17848
+ {
17849
+ "summary": (
17850
+ f"agentbus fast-route: {from_role}->{to_role} "
17851
+ f"intent={intent} (skipping manager)"
17852
+ )
17853
+ },
17854
+ )
17855
+ return {
17856
+ "to": to_role,
17857
+ "payload": payload,
17858
+ "intent": intent,
17859
+ "from": from_role,
17860
+ "env_id": env.get("id", ""),
17861
+ }
17862
+ return None
17863
+
16938
17864
  def _agent_role_system_prompt(self, role: str) -> str:
16939
17865
  role_key = self._sanitize_agent_role(role) or "developer"
16940
17866
  base = (
16941
- f"You are {self._agent_display_name(role_key)} in a blackboard-driven multi-agent coding system. "
16942
- f"Workspace root: {self.files_root}. "
16943
- "**You are in a restricted container; your virtual root is /workspace. "
16944
- "Never use or access absolute host paths such as /Users/...** "
16945
- f"Session absolute writable root is {self.files_root}. "
16946
- "Use relative file paths (for example hello.txt); runtime maps them to session absolute paths. "
16947
- "If '/workspace/...' appears, treat it as a virtual alias only; never create OS-level /workspace in shell. "
16948
- f"{_detect_os_shell_instruction()} "
16949
- "You must stay within your role boundary and use only provided tools. "
16950
- "Use read_from_blackboard/write_to_blackboard to keep the shared state accurate. "
16951
- "When communicating with other agents, use ask_colleague with structured intent/content. "
16952
- "Always keep outputs concise and action-oriented. "
17867
+ f"You are {self._agent_display_name(role_key)} in a multi-agent coding system. "
17868
+ f"Workspace: {self.files_root}. Use relative paths. "
17869
+ "Use blackboard for shared state, ask_colleague for inter-agent communication. "
17870
+ "Keep outputs concise and action-oriented. "
17871
+ "Use load_skill for detailed guidance (multi-agent-guide, code-review-checklist, finish-protocol). "
16953
17872
  f"{model_language_instruction(self.ui_language)} "
16954
17873
  )
16955
17874
  if role_key == "explorer":
16956
- return (
16957
- base
16958
- + "Role objective: analyze user goals, inspect codebase, and produce actionable research notes. "
16959
- + "Prefer read/search/check commands; avoid direct large code modifications. "
16960
- + "When new evidence appears, write concise research updates to blackboard and hand off actionable insights. "
16961
- + "Proactively use ask_colleague when your findings can unblock developer/reviewer immediately. "
16962
- + "If reviewer sends final_summary_request, produce final wrap-up summary from blackboard evidence and finish."
16963
- )
17875
+ return base + "Role: analyze goals, inspect codebase, produce research notes. Prefer read/search. "
16964
17876
  if role_key == "reviewer":
16965
- return (
16966
- base
16967
- + "Role objective: verify developer output against goal, run checks/tests, and issue pass/fix decisions. "
16968
- + "If gaps remain, send fix_request to developer with concrete failure evidence and write review_feedback to blackboard. "
16969
- + "If manager requests final summary, first call read_from_blackboard "
16970
- + "(sections: code_artifacts, execution_logs, review_feedback, status), then generate a structured summary "
16971
- + "covering changes, validation evidence, and residual risks/next steps. "
16972
- + "When finishing, pass this summary in finish_task.summary; empty or vague summary is invalid. "
16973
- + "If you cannot produce summary from current evidence, hand off Explorer via ask_colleague "
16974
- + "intent=final_summary_request with explicit missing evidence."
16975
- )
16976
- return (
16977
- base
16978
- + "Role objective: implement code changes based on explorer/reviewer inputs. "
16979
- + "Perform concrete file edits and command execution. "
16980
- + "Continuously record progress and blockers to blackboard. "
16981
- + "When blocked or uncertain, immediately call ask_colleague to explorer/reviewer with focused intent. "
16982
- + "When implementation batch is ready, send review_request to reviewer via ask_colleague."
16983
- )
17877
+ return base + "Role: verify output, run checks, issue pass/fix decisions. Write review_feedback to blackboard. "
17878
+ return base + "Role: implement code changes, execute tools, record progress to blackboard. "
16984
17879
 
16985
17880
  def _seed_multi_agent_contexts_if_needed(self, user_text: str = ""):
16986
17881
  if not self._is_multi_agent_mode():
@@ -17005,16 +17900,17 @@ class SessionState:
17005
17900
  mirror_to_global=False,
17006
17901
  )
17007
17902
  if not self.manager_context:
17008
- self.manager_context = [
17009
- {
17010
- "role": "system",
17011
- "content": (
17012
- "Manager context initialized. Delegate by reading blackboard and assigning short slices.\n"
17013
- f"{language_note}"
17014
- ),
17015
- "ts": now_ts(),
17016
- }
17017
- ]
17903
+ init_msg = {
17904
+ "role": "system",
17905
+ "content": (
17906
+ "Manager context initialized. Delegate by reading blackboard and assigning short slices.\n"
17907
+ f"{language_note}"
17908
+ ),
17909
+ "ts": now_ts(),
17910
+ "agent_role": "manager",
17911
+ }
17912
+ self.manager_context = [init_msg]
17913
+ self.agent_messages.append(init_msg)
17018
17914
  if not self._agent_context("developer"):
17019
17915
  self._append_agent_context_message(
17020
17916
  "developer",
@@ -17706,11 +18602,9 @@ class SessionState:
17706
18602
  self._emit("status", {"summary": summary})
17707
18603
  out = f"{out}\n{summary}"
17708
18604
  after_text = try_read_text(fp, max_bytes=CODE_PREVIEW_STAGE_MAX_BYTES) or ""
17709
- diff = make_unified_diff(rel, before_text, after_text)
18605
+ diff, added, deleted = make_unified_diff(rel, before_text, after_text)
17710
18606
  numbered = make_numbered_diff(before_text, after_text)
17711
18607
  numbered_text = render_numbered_diff_text(numbered)
17712
- added = sum(1 for ln in diff.splitlines() if ln.startswith("+") and not ln.startswith("+++"))
17713
- deleted = sum(1 for ln in diff.splitlines() if ln.startswith("-") and not ln.startswith("---"))
17714
18608
  code_stage = self._record_code_preview_stage(
17715
18609
  rel_path=rel,
17716
18610
  before_text=before_text,
@@ -17759,11 +18653,9 @@ class SessionState:
17759
18653
  self._emit("status", {"summary": summary})
17760
18654
  out = f"{out}\n{summary}"
17761
18655
  after_text = try_read_text(fp, max_bytes=CODE_PREVIEW_STAGE_MAX_BYTES) or ""
17762
- diff = make_unified_diff(rel, before_text, after_text)
18656
+ diff, added, deleted = make_unified_diff(rel, before_text, after_text)
17763
18657
  numbered = make_numbered_diff(before_text, after_text)
17764
18658
  numbered_text = render_numbered_diff_text(numbered)
17765
- added = sum(1 for ln in diff.splitlines() if ln.startswith("+") and not ln.startswith("+++"))
17766
- deleted = sum(1 for ln in diff.splitlines() if ln.startswith("-") and not ln.startswith("---"))
17767
18659
  code_stage = self._record_code_preview_stage(
17768
18660
  rel_path=rel,
17769
18661
  before_text=before_text,
@@ -18340,6 +19232,7 @@ class SessionState:
18340
19232
  ctx = self._agent_context(role_key)
18341
19233
  if not ctx:
18342
19234
  return {"status": "skip", "reason": "empty-context", "role": role_key}
19235
+ self._microcompact_agent_messages(ctx)
18343
19236
  with self.lock:
18344
19237
  self.current_phase = f"agent:{role_key}:model-call"
18345
19238
  self.current_tool_name = ""
@@ -18348,7 +19241,7 @@ class SessionState:
18348
19241
  ctx,
18349
19242
  tools=self._tools_for_agent(role_key),
18350
19243
  system=self._agent_role_system_prompt(role_key),
18351
- max_tokens=AGENT_MAX_OUTPUT_TOKENS,
19244
+ max_tokens=self.max_output_tokens,
18352
19245
  think=False,
18353
19246
  stream_thinking=False,
18354
19247
  on_thinking_chunk=self._append_live_thinking,
@@ -18388,7 +19281,7 @@ class SessionState:
18388
19281
  for tc in tool_calls
18389
19282
  ]
18390
19283
  self._append_agent_context_message(role_key, assistant, mirror_to_global=True)
18391
- if text.strip() or thinking_text:
19284
+ if (text.strip() or thinking_text) and not tool_calls:
18392
19285
  emit_text = text if text.strip() else "[thinking-only output]"
18393
19286
  self._emit_agent_message(role_key, emit_text, summary=f"{self._agent_display_name(role_key)} response")
18394
19287
  if not tool_calls:
@@ -18694,10 +19587,23 @@ class SessionState:
18694
19587
  media_inputs_pool=media_inputs_pool,
18695
19588
  media_seen_ts_by_role=media_seen_ts_by_role,
18696
19589
  )
18697
- route = self._manager_delegate_turn(
18698
- pinned_selection=pinned_selection,
18699
- media_inputs_round=manager_media_inputs,
18700
- )
19590
+ # AgentBus fast-route: skip manager if a valid worker handoff is pending
19591
+ bus_route = self._drain_agentbus_fast_route()
19592
+ if bus_route:
19593
+ target = bus_route["to"]
19594
+ instruction = trim(str(bus_route.get("payload", "") or ""), 1400)
19595
+ route = {
19596
+ "target": target,
19597
+ "instruction": instruction,
19598
+ "source": "agentbus-direct",
19599
+ "is_mandatory": False,
19600
+ "executor_mode": False,
19601
+ }
19602
+ else:
19603
+ route = self._manager_delegate_turn(
19604
+ pinned_selection=pinned_selection,
19605
+ media_inputs_round=manager_media_inputs,
19606
+ )
18701
19607
  target = str(route.get("target", "") or "").strip().lower()
18702
19608
  instruction = trim(str(route.get("instruction", "") or "").strip(), 1400)
18703
19609
  if compact_mode and target in AGENT_ROLES:
@@ -18738,6 +19644,23 @@ class SessionState:
18738
19644
  media_inputs_round=role_media_inputs,
18739
19645
  )
18740
19646
  self._blackboard_update_from_worker_step(role, step)
19647
+ # ── Agent turn 结束后的终止检测:结论性回复 + 无待办 + 无错误 → 自动 finish ──
19648
+ agent_text = self._latest_agent_assistant_text(role)
19649
+ if (
19650
+ agent_text
19651
+ and self._looks_like_conclusive_reply(agent_text)
19652
+ and not self.todo.has_open_items()
19653
+ and not self._manager_has_error_log(self._ensure_blackboard())
19654
+ ):
19655
+ self._blackboard_mark_approved(
19656
+ f"conclusive reply from {role}: auto-finish", role
19657
+ )
19658
+ self._mark_all_done_silently(f"conclusive reply from {role}")
19659
+ self._emit(
19660
+ "status",
19661
+ {"summary": f"agent '{role}' gave conclusive reply; finishing run"},
19662
+ )
19663
+ break
18741
19664
  board_after = self._ensure_blackboard()
18742
19665
  board_after_fp = self._watchdog_state_fingerprint(board_after)
18743
19666
  wd_event = self._watchdog_process_worker_step(
@@ -18786,6 +19709,7 @@ class SessionState:
18786
19709
  },
18787
19710
  )
18788
19711
  continue
19712
+ self._auto_summary_on_finish()
18789
19713
  self._mark_all_done_silently(note)
18790
19714
  self._emit(
18791
19715
  "status",
@@ -18847,7 +19771,9 @@ class SessionState:
18847
19771
  )
18848
19772
  break
18849
19773
  else:
18850
- self._emit("error", {"summary": f"max loop reached ({self.max_agent_rounds})"})
19774
+ summary = self._auto_summary_on_finish()
19775
+ self._mark_all_done_silently(f"budget exhausted: {summary}")
19776
+ self._emit("status", {"summary": f"Budget exhausted ({self.max_agent_rounds} rounds). {trim(summary, 300)}"})
18851
19777
 
18852
19778
  def _multi_agent_worker(self, *, pinned_selection: str):
18853
19779
  mode = self._effective_execution_mode()
@@ -18980,7 +19906,9 @@ class SessionState:
18980
19906
  sync_index += 1
18981
19907
  continue
18982
19908
  else:
18983
- self._emit("error", {"summary": f"max loop reached ({self.max_agent_rounds})"})
19909
+ summary = self._auto_summary_on_finish()
19910
+ self._mark_all_done_silently(f"budget exhausted: {summary}")
19911
+ self._emit("status", {"summary": f"Budget exhausted ({self.max_agent_rounds} rounds). {trim(summary, 300)}"})
18984
19912
 
18985
19913
  def _agent_worker(self):
18986
19914
  single_role = "developer"
@@ -19205,7 +20133,7 @@ class SessionState:
19205
20133
  self.messages,
19206
20134
  tools=TOOLS,
19207
20135
  system=self._system_prompt(),
19208
- max_tokens=AGENT_MAX_OUTPUT_TOKENS,
20136
+ max_tokens=self.max_output_tokens,
19209
20137
  think=False,
19210
20138
  stream_thinking=False,
19211
20139
  on_thinking_chunk=self._append_live_thinking,
@@ -19339,7 +20267,7 @@ class SessionState:
19339
20267
  for tc in tool_calls
19340
20268
  ]
19341
20269
  self.messages.append(assistant)
19342
- if text.strip() or thinking_text:
20270
+ if (text.strip() or thinking_text) and not tool_calls:
19343
20271
  emit_text = text if text.strip() else "[thinking-only output]"
19344
20272
  emit_summary = "assistant message" if text.strip() else "assistant thinking-only message"
19345
20273
  self._emit(
@@ -19497,6 +20425,8 @@ class SessionState:
19497
20425
  fault_counter = 0
19498
20426
  last_fault_reason = ""
19499
20427
  self._prune_runtime_retry_hints()
20428
+ if self.todo.has_open_items():
20429
+ self._mark_all_done_silently("single-mode endpoint exit")
19500
20430
  self._emit(
19501
20431
  "status",
19502
20432
  {
@@ -19514,6 +20444,8 @@ class SessionState:
19514
20444
  fault_counter = 0
19515
20445
  last_fault_reason = ""
19516
20446
  self._prune_runtime_retry_hints()
20447
+ if self.todo.has_open_items():
20448
+ self._mark_all_done_silently("single-mode conclusive/substantial exit")
19517
20449
  self._emit(
19518
20450
  "status",
19519
20451
  {
@@ -19579,6 +20511,9 @@ class SessionState:
19579
20511
  },
19580
20512
  )
19581
20513
  continue
20514
+ # 对简单查询(非工程任务)限制自动继续预算
20515
+ if auto_continue_budget > 8 and not self._is_long_running_engineering_context():
20516
+ auto_continue_budget = min(auto_continue_budget, 8)
19582
20517
  can_continue = auto_continue_budget > 0 and (
19583
20518
  todo_blocking or self._looks_like_incomplete_reply(text)
19584
20519
  )
@@ -20080,7 +21015,9 @@ class SessionState:
20080
21015
  if stop_due_to_repeated_tool_loop or stop_due_to_hard_break or stop_due_to_finish_task:
20081
21016
  break
20082
21017
  else:
20083
- self._emit("error", {"summary": f"max loop reached ({self.max_agent_rounds})"})
21018
+ summary = self._auto_summary_on_finish()
21019
+ self._mark_all_done_silently(f"budget exhausted: {summary}")
21020
+ self._emit("status", {"summary": f"Budget exhausted ({self.max_agent_rounds} rounds). {trim(summary, 300)}"})
20084
21021
  except CircuitBreakerTriggered as exc:
20085
21022
  note = trim(str(exc), 320) or "Circuit breaker triggered."
20086
21023
  self._emit("status", {"summary": f"hard-stop: {note}"})
@@ -20097,6 +21034,10 @@ class SessionState:
20097
21034
  except Exception as exc:
20098
21035
  self._emit("error", {"summary": f"agent error: {exc}", "trace": traceback.format_exc()})
20099
21036
  finally:
21037
+ if self.todo.has_open_items() and not self.cancel_requested:
21038
+ _last = self._latest_agent_assistant_text(single_role) or ""
21039
+ if self._looks_like_conclusive_reply(_last):
21040
+ self._mark_all_done_silently(f"single-mode conclusive exit by {single_role}")
20100
21041
  dropped_pending_inputs = 0
20101
21042
  removed_runtime_hints = 0
20102
21043
  with self.lock:
@@ -20446,6 +21387,107 @@ class SessionState:
20446
21387
  bio.seek(0)
20447
21388
  return bio.read()
20448
21389
 
21390
+ def export_conversation_md(self) -> str:
21391
+ snap = self.snapshot()
21392
+ title = snap.get("title") or snap.get("id") or "Session"
21393
+ lines = [
21394
+ f"# {title}",
21395
+ "",
21396
+ f"- Session: `{snap.get('id', '')}`",
21397
+ f"- Model: `{snap.get('model', '')}`",
21398
+ f"- Created: {_fmt_export_ts(snap.get('created_at', 0))}",
21399
+ "",
21400
+ "---",
21401
+ "",
21402
+ ]
21403
+ for row in snap.get("conversation_feed", []):
21404
+ role = str(row.get("role", "system"))
21405
+ ts = row.get("ts", 0)
21406
+ time_str = _fmt_export_ts(ts)
21407
+ text = str(row.get("text", ""))
21408
+ thinking = str(row.get("thinking", "") or "")
21409
+ row_type = str(row.get("type", "message"))
21410
+ agent = str(row.get("agent_role", "") or "")
21411
+ header = f"**[{role}]**"
21412
+ if agent:
21413
+ header += f" _{agent}_"
21414
+ if row_type not in ("message", ""):
21415
+ header += f" `{row_type}`"
21416
+ if time_str:
21417
+ header += f" <sub>{time_str}</sub>"
21418
+ lines.append(header)
21419
+ lines.append("")
21420
+ if thinking:
21421
+ lines.append("<details><summary>thinking</summary>")
21422
+ lines.append("")
21423
+ lines.append(thinking)
21424
+ lines.append("")
21425
+ lines.append("</details>")
21426
+ lines.append("")
21427
+ if text:
21428
+ lines.append(text)
21429
+ lines.append("")
21430
+ lines.append("---")
21431
+ lines.append("")
21432
+ return "\n".join(lines)
21433
+
21434
+ def export_conversation_pdf(self) -> bytes:
21435
+ md_text = self.export_conversation_md()
21436
+ return _text_to_minimal_pdf(md_text)
21437
+
21438
+ def _conversation_to_html(self) -> str:
21439
+ snap = self.snapshot()
21440
+ title = _html_esc(snap.get("title") or snap.get("id") or "Session")
21441
+ model = _html_esc(snap.get("model", ""))
21442
+ rows_html = []
21443
+ for row in snap.get("conversation_feed", []):
21444
+ role = str(row.get("role", "system"))
21445
+ ts = row.get("ts", 0)
21446
+ time_str = _fmt_export_ts(ts)
21447
+ text = str(row.get("text", ""))
21448
+ thinking = str(row.get("thinking", "") or "")
21449
+ bg = "#e8f4fd" if role == "user" else ("#f0f0f0" if role == "assistant" else "#fff9e6")
21450
+ block = f'<div style="background:{bg};border-radius:8px;padding:10px 14px;margin:6px 0">'
21451
+ block += f'<div style="font-weight:bold;font-size:13px;color:#555;margin-bottom:4px">[{_html_esc(role)}] {_html_esc(time_str)}</div>'
21452
+ if thinking:
21453
+ block += f'<details style="margin-bottom:6px"><summary style="color:#888;font-size:12px">thinking</summary><pre style="white-space:pre-wrap;font-size:12px;color:#666">{_html_esc(thinking)}</pre></details>'
21454
+ if text:
21455
+ block += f'<pre style="white-space:pre-wrap;font-size:13px;margin:0">{_html_esc(text)}</pre>'
21456
+ block += '</div>'
21457
+ rows_html.append(block)
21458
+ body = "\n".join(rows_html)
21459
+ return f"""<!DOCTYPE html>
21460
+ <html><head><meta charset="utf-8"><title>{title}</title>
21461
+ <style>body{{font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:860px;margin:0 auto;padding:20px;background:#fff}}
21462
+ h1{{font-size:20px;margin-bottom:4px}}
21463
+ .meta{{color:#888;font-size:13px;margin-bottom:16px}}</style></head>
21464
+ <body><h1>{title}</h1><div class="meta">Model: {model}</div>
21465
+ {body}
21466
+ </body></html>"""
21467
+
21468
+ def export_conversation_image(self) -> bytes:
21469
+ html_content = self._conversation_to_html()
21470
+ try:
21471
+ from playwright.sync_api import sync_playwright
21472
+ with sync_playwright() as p:
21473
+ browser = p.chromium.launch()
21474
+ page = browser.new_page(viewport={"width": 860, "height": 800})
21475
+ page.set_content(html_content)
21476
+ page.wait_for_load_state("networkidle")
21477
+ img = page.screenshot(full_page=True, type="png")
21478
+ browser.close()
21479
+ return img
21480
+ except ImportError:
21481
+ pass
21482
+ except Exception:
21483
+ pass
21484
+ try:
21485
+ import imgkit
21486
+ return imgkit.from_string(html_content, False, options={"width": "860", "encoding": "UTF-8"})
21487
+ except ImportError:
21488
+ pass
21489
+ raise RuntimeError("Image export requires playwright or imgkit. Install: pip install playwright && playwright install chromium")
21490
+
20449
21491
  class SessionManager:
20450
21492
  def __init__(
20451
21493
  self,
@@ -20471,6 +21513,7 @@ class SessionManager:
20471
21513
  arbiter_max_tokens: int = ARBITER_DEFAULT_MAX_TOKENS,
20472
21514
  arbiter_temperature: float = ARBITER_DEFAULT_TEMPERATURE,
20473
21515
  execution_mode: str = EXECUTION_MODE_SYNC,
21516
+ max_output_tokens: int = AGENT_MAX_OUTPUT_TOKENS,
20474
21517
  run_finished_callback=None,
20475
21518
  ):
20476
21519
  self.root = root
@@ -20493,6 +21536,7 @@ class SessionManager:
20493
21536
  MIN_AGENT_ROUNDS,
20494
21537
  min(MAX_AGENT_ROUNDS_CAP, int(max_rounds or MAX_AGENT_ROUNDS)),
20495
21538
  )
21539
+ self.max_output_tokens = max(256, int(max_output_tokens or AGENT_MAX_OUTPUT_TOKENS))
20496
21540
  self.max_run_seconds = normalize_timeout_seconds(
20497
21541
  max_run_seconds if max_run_seconds is not None else MAX_RUN_SECONDS,
20498
21542
  minimum=MIN_RUN_TIMEOUT_SECONDS,
@@ -20506,6 +21550,7 @@ class SessionManager:
20506
21550
  self.arbiter_max_tokens = max(24, min(256, int(arbiter_max_tokens or ARBITER_DEFAULT_MAX_TOKENS)))
20507
21551
  self.arbiter_temperature = max(0.0, min(1.0, float(arbiter_temperature if arbiter_temperature is not None else ARBITER_DEFAULT_TEMPERATURE)))
20508
21552
  self.execution_mode = normalize_execution_mode(execution_mode, default=EXECUTION_MODE_SYNC)
21553
+ self.single_advance_prompt_enhance = False
20509
21554
  env_ok, env_tags, _ = probe_ollama_environment(ollama_base)
20510
21555
  self.ollama_env_available = bool(env_ok)
20511
21556
  self.ollama_env_tags: list[str] = list(env_tags)
@@ -20772,6 +21817,7 @@ class SessionManager:
20772
21817
  min(1.0, float(self.arbiter_temperature if self.arbiter_temperature is not None else ARBITER_DEFAULT_TEMPERATURE)),
20773
21818
  )
20774
21819
  sess.execution_mode = normalize_execution_mode(self.execution_mode, default=EXECUTION_MODE_SYNC)
21820
+ sess.single_advance_prompt_enhance = bool(self.single_advance_prompt_enhance)
20775
21821
  sess._apply_active_profile()
20776
21822
  sess.updated_at = now_ts()
20777
21823
  sess._persist()
@@ -20839,6 +21885,7 @@ class SessionManager:
20839
21885
  arbiter_max_tokens=self.arbiter_max_tokens,
20840
21886
  arbiter_temperature=self.arbiter_temperature,
20841
21887
  execution_mode=self.execution_mode,
21888
+ max_output_tokens=self.max_output_tokens,
20842
21889
  ui_language=self.user_language,
20843
21890
  js_lib_root=self.js_lib_root,
20844
21891
  owner_user_id=self.user_id,
@@ -20879,6 +21926,7 @@ class SessionManager:
20879
21926
  arbiter_max_tokens=self.arbiter_max_tokens,
20880
21927
  arbiter_temperature=self.arbiter_temperature,
20881
21928
  execution_mode=self.execution_mode,
21929
+ max_output_tokens=self.max_output_tokens,
20882
21930
  ui_language=self.user_language,
20883
21931
  js_lib_root=self.js_lib_root,
20884
21932
  owner_user_id=self.user_id,
@@ -21291,7 +22339,7 @@ window.MathJax={
21291
22339
  <button id="applyModelBtn" class="subtle">Apply Model</button>
21292
22340
  <button id="importConfigBtn" class="subtle">Upload LLM.config.json</button>
21293
22341
  <input id="configInput" type="file" accept=".json,application/json" style="display:none">
21294
- <a id="downloadBtn" href="#" target="_blank" rel="noreferrer">Open Skills Studio</a>
22342
+ <a id="downloadBtn" href="#">Open Skills Studio</a>
21295
22343
  </div>
21296
22344
  </header>
21297
22345
  <div class="status-cards" id="topStats"></div>
@@ -21320,7 +22368,15 @@ window.MathJax={
21320
22368
  <button id="interruptBtn" class="subtle">Interrupt</button>
21321
22369
  <button id="compactBtn" class="subtle">Compact</button>
21322
22370
  <button id="refreshBtn" class="subtle">Refresh</button>
21323
- <a id="downloadSessionBtn" class="subtle disabled" href="#">Export Session</a>
22371
+ <div class="export-dropdown" style="position:relative;display:inline-block">
22372
+ <button id="exportMenuBtn" class="subtle">Export ▾</button>
22373
+ <div id="exportMenu" style="display:none;position:absolute;bottom:100%;left:0;background:#fff;border:1px solid var(--line);border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.12);z-index:999;min-width:160px;padding:4px 0;margin-bottom:4px">
22374
+ <a id="downloadSessionBtn" class="export-item" href="#" style="display:block;padding:6px 14px;text-decoration:none;color:#333;font-size:13px">Export ZIP</a>
22375
+ <a id="exportMdBtn" class="export-item" href="#" style="display:block;padding:6px 14px;text-decoration:none;color:#333;font-size:13px">Export Markdown</a>
22376
+ <a id="exportPdfBtn" class="export-item" href="#" style="display:block;padding:6px 14px;text-decoration:none;color:#333;font-size:13px">Export PDF</a>
22377
+ <a id="exportPngBtn" class="export-item" href="#" style="display:block;padding:6px 14px;text-decoration:none;color:#333;font-size:13px">Export Image</a>
22378
+ </div>
22379
+ </div>
21324
22380
  <div id="ctxLive" class="ctx-live" title="Remaining context budget">
21325
22381
  <span class="ctx-live-dot"></span>
21326
22382
  <span id="ctxLiveText" class="mono">ctx_left=-</span>
@@ -21394,6 +22450,7 @@ button,a{border:1px solid var(--line);padding:10px 14px;border-radius:12px;backg
21394
22450
  button:hover,a:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(15,27,45,.08)}
21395
22451
  #sendBtn,#newSessionBtn{background:linear-gradient(135deg,var(--brand),var(--brand2));color:#fff;border:0}
21396
22452
  .subtle{background:#f6f8fa}
22453
+ .export-item:hover{background:#f0f4f8}
21397
22454
  .actions select{padding:10px 12px;border-radius:12px;border:1px solid var(--line);background:#fff;min-width:160px}
21398
22455
  .think-switch{display:flex;align-items:center;gap:6px;border:1px solid var(--line);padding:8px 10px;border-radius:12px;background:#fff;font-weight:600}
21399
22456
  .danger{color:var(--warn);border-color:#f3c0c0}
@@ -21508,7 +22565,7 @@ main{display:grid;grid-template-columns:minmax(220px,260px) minmax(520px,920px)
21508
22565
  .msg-md blockquote{margin:.5rem 0;padding:.4rem .6rem;border-left:3px solid #9db8e8;background:#eef4ff;border-radius:6px;color:#27446f}
21509
22566
  .msg-md .md-inline-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.86em;padding:1px 5px;border-radius:5px;background:#e8eef8;border:1px solid #d3dded;color:#1e3a5f;white-space:pre}
21510
22567
  .msg-md .md-code-lang{display:inline-block;margin:.3rem 0 0;padding:2px 8px;border:1px solid #cfd9ea;border-bottom:0;border-radius:8px 8px 0 0;background:#f3f7fd;color:#3b4f6d;font-size:.75rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
21511
- .msg-md .md-code{margin:0 0 .55rem;max-width:100%;overflow:auto;padding:8px;border:1px solid #dfe6ef;border-radius:0 8px 8px 8px;background:#fff;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem;line-height:1.4;white-space:pre}
22568
+ .msg-md .md-code{margin:0 0 .55rem;max-width:100%;overflow:auto;padding:8px;border:1px solid #dfe6ef;border-radius:0 8px 8px 8px;background:#fff;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem;line-height:1.4;white-space:pre;overscroll-behavior:contain;scrollbar-gutter:stable}
21512
22569
  .msg-md .md-table{margin:.5rem 0;border-collapse:collapse;max-width:100%;width:100%;display:block;overflow:auto;background:#fff}
21513
22570
  .msg-md .md-table th,.msg-md .md-table td{border:1px solid #d7e0ec;padding:6px 8px;text-align:left;vertical-align:top;white-space:nowrap}
21514
22571
  .msg-md .md-table th{background:#f5f8fc;font-weight:700}
@@ -21618,7 +22675,7 @@ h3{font-size:.96rem;margin:10px 0 6px}
21618
22675
 
21619
22676
  APP_JS = """const S={sessions:[],activeId:null,snap:null,es:null,esId:'',skills:[],tools:[],providers:[],protocols:[],config:null,models:[],modelOptions:[],previewBySession:{},fileExplorerBySession:{},previewNonce:0,refreshTimer:null,refreshInFlight:false,pendingSnapshot:false,pendingFullSnapshot:false,scheduledFullSnapshot:false,sessionPollTimer:null,renderStateInFlight:false,lastRenderStatePullAt:0,lastFeedSig:'',lastBoardsSig:'',lastSessionsSig:'',lastVisibilityState:document.visibilityState||'visible',staticMode:false,frozen:false,bootRendered:false,panelHtml:{},follow:{chat:true,sessionList:false,todos:false,tasks:false,activity:true,commands:true,diffs:true,catalog:true,fileExplorer:false},lastEventSeq:0,lastDeltaTs:0,deltaGapCount:0,deltaWatchdogTimer:null,deltaWatchdogStalls:0,deltaWatchdogSeq:0,deltaRenderRaf:0,deltaRenderChat:false,deltaRenderBoards:false,deltaRenderSessions:false,mathObserver:null,mathRoot:null,mdWorker:null,mdWorkerUrl:'',mdReqSeq:0,mdPending:Object.create(null),diffCenterDisabled:Object.create(null),previewCenterDisabled:Object.create(null),diffCenteredDone:Object.create(null),previewCenteredDone:Object.create(null)};
21620
22677
  const MD_CACHE=new Map();
21621
- const MD_CACHE_MAX=420;
22678
+ const MD_CACHE_MAX=280;
21622
22679
  const STATIC_UI=((new URLSearchParams(location.search)).get('static_ui')==='1');
21623
22680
  const SNAPSHOT_DELAY_VISIBLE_MS=300;
21624
22681
  const SNAPSHOT_DELAY_HIDDEN_MS=2400;
@@ -21633,30 +22690,30 @@ const CHAT_SCROLL_RENDER_THROTTLE_MS=70;
21633
22690
  const CHAT_SCROLL_SYNC_DEBOUNCE_MS=260;
21634
22691
  const CHAT_SCROLL_SETTLE_MS=620;
21635
22692
  const CHAT_SCROLL_SETTLE_EPS_PX=1;
21636
- const DELTA_MAX_FEED=420;
21637
- const DELTA_MAX_MESSAGES=420;
21638
- const DELTA_MAX_ACTIVITY=100;
21639
- const DELTA_MAX_OPERATIONS=220;
22693
+ const DELTA_MAX_FEED=300;
22694
+ const DELTA_MAX_MESSAGES=300;
22695
+ const DELTA_MAX_ACTIVITY=80;
22696
+ const DELTA_MAX_OPERATIONS=160;
21640
22697
  const DELTA_MAX_UPLOADS=40;
21641
22698
  const DELTA_WATCHDOG_INTERVAL_MS=1800;
21642
22699
  const DELTA_WATCHDOG_STALL_MS=9000;
21643
22700
  const MARKDOWN_WORKER_MIN_CHARS=800;
21644
22701
  const MARKDOWN_WORKER_MAX_PENDING=96;
21645
22702
  const MARKDOWN_WORKER_REQ_TTL_MS=45000;
21646
- const CHAT_VIRT={heights:Object.create(null),heightVersion:0,avgHeight:140,overscanPx:400,maxCacheKeys:1200,poolByKind:Object.create(null),poolSize:0,poolMax:180};
22703
+ const CHAT_VIRT={heights:Object.create(null),heightVersion:0,avgHeight:140,overscanPx:400,maxCacheKeys:600,poolByKind:Object.create(null),poolSize:0,poolMax:180};
21647
22704
  const RENDER_EVT_TYPES=new Set(['render_frame','render_bridge']);
21648
- const RENDER_QUEUE_MAX=140;
22705
+ const RENDER_QUEUE_MAX=80;
21649
22706
  const RENDER_META_MIN_INTERVAL_MS=180;
21650
22707
  const RENDER={queue:[],raf:0,canvas:null,ctx:null,lastSeq:0,lastPaintAt:0,lastMetaAt:0,lastSummary:'',hideTimer:0,imgTicket:0};
21651
22708
  const CODE_PREVIEW_VIRT_THRESHOLD=1800;
21652
22709
  const CODE_PREVIEW_VIRT_EST_ROW_PX=24;
21653
22710
  const CODE_PREVIEW_VIRT_OVERSCAN=160;
21654
- const CODE_PREVIEW_EXTS=new Set(['.py','.pyi','.js','.mjs','.cjs','.ts','.tsx','.jsx','.java','.c','.cc','.cpp','.cxx','.h','.hh','.hpp','.hxx','.go','.rs','.rb','.php','.swift','.kt','.kts','.scala','.sh','.bash','.zsh','.fish','.ps1','.bat','.sql','.json','.jsonc','.yaml','.yml','.toml','.ini','.cfg','.conf','.xml','.xsd','.xsl','.cs','.m','.mm','.r','.pl','.lua','.dart','.vue','.svelte','.gradle','.properties']);
22711
+ const CODE_PREVIEW_EXTS=new Set(['.py','.pyi','.js','.mjs','.cjs','.ts','.tsx','.jsx','.java','.c','.cc','.cpp','.cxx','.h','.hh','.hpp','.hxx','.go','.rs','.rb','.php','.swift','.kt','.kts','.scala','.sh','.bash','.zsh','.fish','.ps1','.bat','.sql','.json','.jsonc','.yaml','.yml','.toml','.ini','.cfg','.conf','.xml','.xsd','.xsl','.cs','.m','.mm','.r','.pl','.lua','.dart','.vue','.svelte','.gradle','.properties','.f','.f90','.f95','.f03','.f08','.for','.fpp','.hs','.lhs','.erl','.hrl','.ex','.exs','.ml','.mli','.vhd','.vhdl','.v','.sv','.asm','.s','.proto','.tf','.tfvars','.prisma','.graphql','.gql','.zig','.nim','.jl','.cr','.d','.clj','.cljs','.cljc','.lisp','.cl','.el','.rkt','.pas','.pp','.wgsl','.glsl','.hlsl','.groovy','.cmake','.dockerfile']);
21655
22712
  const CODE_PREVIEW_FILENAMES=new Set(['dockerfile','makefile','cmakelists.txt','justfile','gemfile','rakefile','pipfile','requirements.txt']);
21656
- const CODE_LANG_BY_EXT={'.py':'python','.pyi':'python','.js':'javascript','.mjs':'javascript','.cjs':'javascript','.ts':'typescript','.tsx':'typescript','.jsx':'javascript','.java':'java','.c':'c','.cc':'cpp','.cpp':'cpp','.cxx':'cpp','.h':'c','.hh':'cpp','.hpp':'cpp','.hxx':'cpp','.go':'go','.rs':'rust','.rb':'ruby','.php':'php','.swift':'swift','.kt':'kotlin','.kts':'kotlin','.scala':'scala','.sh':'shell','.bash':'shell','.zsh':'shell','.fish':'shell','.ps1':'shell','.bat':'shell','.sql':'sql','.json':'json','.jsonc':'json','.yaml':'yaml','.yml':'yaml','.toml':'toml','.ini':'ini','.cfg':'ini','.conf':'ini','.xml':'xml','.xsd':'xml','.xsl':'xml','.cs':'csharp','.m':'objectivec','.mm':'objectivec','.r':'r','.pl':'perl','.lua':'lua','.dart':'dart','.vue':'javascript','.svelte':'javascript','.gradle':'groovy','.properties':'ini'};
22713
+ const CODE_LANG_BY_EXT={'.py':'python','.pyi':'python','.js':'javascript','.mjs':'javascript','.cjs':'javascript','.ts':'typescript','.tsx':'typescript','.jsx':'javascript','.java':'java','.c':'c','.cc':'cpp','.cpp':'cpp','.cxx':'cpp','.h':'c','.hh':'cpp','.hpp':'cpp','.hxx':'cpp','.go':'go','.rs':'rust','.rb':'ruby','.php':'php','.swift':'swift','.kt':'kotlin','.kts':'kotlin','.scala':'scala','.sh':'shell','.bash':'shell','.zsh':'shell','.fish':'shell','.ps1':'shell','.bat':'shell','.sql':'sql','.json':'json','.jsonc':'json','.yaml':'yaml','.yml':'yaml','.toml':'toml','.ini':'ini','.cfg':'ini','.conf':'ini','.xml':'xml','.xsd':'xml','.xsl':'xml','.cs':'csharp','.m':'objectivec','.mm':'objectivec','.r':'r','.pl':'perl','.lua':'lua','.dart':'dart','.vue':'javascript','.svelte':'javascript','.gradle':'groovy','.properties':'ini','.f':'fortran','.f90':'fortran','.f95':'fortran','.f03':'fortran','.f08':'fortran','.for':'fortran','.fpp':'fortran','.hs':'haskell','.lhs':'haskell','.erl':'erlang','.hrl':'erlang','.ex':'elixir','.exs':'elixir','.ml':'ocaml','.mli':'ocaml','.vhd':'vhdl','.vhdl':'vhdl','.v':'verilog','.sv':'verilog','.asm':'asm','.s':'asm','.proto':'protobuf','.tf':'hcl','.tfvars':'hcl','.zig':'zig','.nim':'nim','.jl':'julia','.cr':'crystal','.d':'dlang','.clj':'clojure','.cljs':'clojure','.cljc':'clojure','.lisp':'lisp','.cl':'lisp','.el':'lisp','.rkt':'racket','.pas':'pascal','.pp':'pascal','.wgsl':'wgsl','.glsl':'glsl','.hlsl':'hlsl','.groovy':'groovy','.cmake':'cmake','.dockerfile':'shell','.prisma':'prisma','.graphql':'graphql','.gql':'graphql'};
21657
22714
  const CODE_LANG_BY_NAME={'dockerfile':'shell','makefile':'makefile','cmakelists.txt':'cmake','justfile':'makefile','gemfile':'ruby','rakefile':'ruby','pipfile':'ini','requirements.txt':'ini'};
21658
22715
  const CODE_LITERAL_WORDS=new Set(['true','false','null','undefined','none','nil']);
21659
- const CODE_KEYWORDS={default:new Set(['if','else','for','while','switch','case','break','continue','return','function','class','import','export','from','try','catch','finally','throw','new','const','let','var','public','private','protected','static','async','await']),python:new Set(['def','class','if','elif','else','for','while','try','except','finally','raise','return','yield','import','from','as','with','pass','break','continue','lambda','global','nonlocal','assert','del','in','is','not','and','or','async','await']),javascript:new Set(['function','class','if','else','for','while','do','switch','case','break','continue','return','try','catch','finally','throw','new','this','const','let','var','import','export','from','default','extends','super','async','await','typeof','instanceof','in','of']),typescript:new Set(['interface','type','enum','implements','readonly','namespace','declare','keyof','infer','satisfies','as','extends','public','private','protected','abstract','override','function','class','if','else','for','while','switch','case','break','continue','return','try','catch','finally','throw','const','let','var','import','export','from','async','await']),java:new Set(['class','interface','enum','extends','implements','public','private','protected','static','final','abstract','volatile','synchronized','if','else','for','while','switch','case','break','continue','return','try','catch','finally','throw','new','package','import','instanceof','this','super','void']),c:new Set(['if','else','for','while','switch','case','break','continue','return','typedef','struct','union','enum','static','const','volatile','extern','inline','sizeof','#include','#define']),cpp:new Set(['if','else','for','while','switch','case','break','continue','return','class','struct','namespace','template','typename','public','private','protected','virtual','override','const','static','auto','constexpr','using','new','delete','this','throw','try','catch','#include','#define']),go:new Set(['package','import','func','type','struct','interface','map','chan','go','defer','select','if','else','for','switch','case','break','continue','return','fallthrough','range','const','var']),rust:new Set(['fn','let','mut','impl','trait','struct','enum','match','if','else','for','while','loop','break','continue','return','pub','use','mod','crate','self','super','where','async','await','move']),ruby:new Set(['def','class','module','if','elsif','else','unless','case','when','for','while','until','begin','rescue','ensure','return','yield','super','self','require','include','extend','end']),php:new Set(['function','class','interface','trait','public','private','protected','static','if','elseif','else','for','foreach','while','switch','case','break','continue','return','try','catch','finally','throw','namespace','use','new']),swift:new Set(['func','class','struct','enum','protocol','extension','if','else','guard','for','while','switch','case','break','continue','return','defer','do','catch','throw','try','import','let','var']),kotlin:new Set(['fun','class','interface','object','data','sealed','enum','if','else','when','for','while','do','break','continue','return','try','catch','throw','import','package','val','var','companion']),scala:new Set(['def','class','trait','object','case','if','else','for','while','match','break','continue','return','try','catch','throw','import','package','val','var','extends','with']),shell:new Set(['if','then','else','fi','for','do','done','while','case','esac','function','return','break','continue','export','local','readonly','in']),sql:new Set(['select','from','where','group','by','order','insert','into','values','update','set','delete','join','left','right','inner','outer','on','create','alter','drop','table','view','index','and','or','not','as','limit']),json:new Set([]),yaml:new Set([]),toml:new Set([]),ini:new Set([]),xml:new Set([]),csharp:new Set(['namespace','class','interface','struct','enum','public','private','protected','internal','static','readonly','const','if','else','for','foreach','while','switch','case','break','continue','return','using','new','this','base','async','await']),objectivec:new Set(['@interface','@implementation','@property','@synthesize','@end','if','else','for','while','switch','case','break','continue','return','#import']),r:new Set(['if','else','for','while','repeat','break','next','function','return','library']),perl:new Set(['if','elsif','else','for','foreach','while','last','next','sub','my','our','use','package','return']),lua:new Set(['if','then','else','elseif','end','for','while','repeat','until','break','function','local','return']),dart:new Set(['class','enum','extension','if','else','for','while','switch','case','break','continue','return','import','library','part','new','const','final','var','async','await']),groovy:new Set(['class','interface','trait','if','else','for','while','switch','case','break','continue','return','def','import','package','new']),makefile:new Set(['include','ifeq','ifneq','ifdef','ifndef','else','endif']),cmake:new Set(['if','else','elseif','endif','foreach','endforeach','while','endwhile','function','endfunction','macro','endmacro','set','add_executable','add_library'])};
22716
+ const CODE_KEYWORDS={default:new Set(['if','else','for','while','switch','case','break','continue','return','function','class','import','export','from','try','catch','finally','throw','new','const','let','var','public','private','protected','static','async','await']),python:new Set(['def','class','if','elif','else','for','while','try','except','finally','raise','return','yield','import','from','as','with','pass','break','continue','lambda','global','nonlocal','assert','del','in','is','not','and','or','async','await']),javascript:new Set(['function','class','if','else','for','while','do','switch','case','break','continue','return','try','catch','finally','throw','new','this','const','let','var','import','export','from','default','extends','super','async','await','typeof','instanceof','in','of']),typescript:new Set(['interface','type','enum','implements','readonly','namespace','declare','keyof','infer','satisfies','as','extends','public','private','protected','abstract','override','function','class','if','else','for','while','switch','case','break','continue','return','try','catch','finally','throw','const','let','var','import','export','from','async','await']),java:new Set(['class','interface','enum','extends','implements','public','private','protected','static','final','abstract','volatile','synchronized','if','else','for','while','switch','case','break','continue','return','try','catch','finally','throw','new','package','import','instanceof','this','super','void']),c:new Set(['if','else','for','while','switch','case','break','continue','return','typedef','struct','union','enum','static','const','volatile','extern','inline','sizeof','#include','#define']),cpp:new Set(['if','else','for','while','switch','case','break','continue','return','class','struct','namespace','template','typename','public','private','protected','virtual','override','const','static','auto','constexpr','using','new','delete','this','throw','try','catch','#include','#define']),go:new Set(['package','import','func','type','struct','interface','map','chan','go','defer','select','if','else','for','switch','case','break','continue','return','fallthrough','range','const','var']),rust:new Set(['fn','let','mut','impl','trait','struct','enum','match','if','else','for','while','loop','break','continue','return','pub','use','mod','crate','self','super','where','async','await','move']),ruby:new Set(['def','class','module','if','elsif','else','unless','case','when','for','while','until','begin','rescue','ensure','return','yield','super','self','require','include','extend','end']),php:new Set(['function','class','interface','trait','public','private','protected','static','if','elseif','else','for','foreach','while','switch','case','break','continue','return','try','catch','finally','throw','namespace','use','new']),swift:new Set(['func','class','struct','enum','protocol','extension','if','else','guard','for','while','switch','case','break','continue','return','defer','do','catch','throw','try','import','let','var']),kotlin:new Set(['fun','class','interface','object','data','sealed','enum','if','else','when','for','while','do','break','continue','return','try','catch','throw','import','package','val','var','companion']),scala:new Set(['def','class','trait','object','case','if','else','for','while','match','break','continue','return','try','catch','throw','import','package','val','var','extends','with']),shell:new Set(['if','then','else','fi','for','do','done','while','case','esac','function','return','break','continue','export','local','readonly','in']),sql:new Set(['select','from','where','group','by','order','insert','into','values','update','set','delete','join','left','right','inner','outer','on','create','alter','drop','table','view','index','and','or','not','as','limit']),json:new Set([]),yaml:new Set([]),toml:new Set([]),ini:new Set([]),xml:new Set([]),csharp:new Set(['namespace','class','interface','struct','enum','public','private','protected','internal','static','readonly','const','if','else','for','foreach','while','switch','case','break','continue','return','using','new','this','base','async','await']),objectivec:new Set(['@interface','@implementation','@property','@synthesize','@end','if','else','for','while','switch','case','break','continue','return','#import']),r:new Set(['if','else','for','while','repeat','break','next','function','return','library']),perl:new Set(['if','elsif','else','for','foreach','while','last','next','sub','my','our','use','package','return']),lua:new Set(['if','then','else','elseif','end','for','while','repeat','until','break','function','local','return']),dart:new Set(['class','enum','extension','if','else','for','while','switch','case','break','continue','return','import','library','part','new','const','final','var','async','await']),groovy:new Set(['class','interface','trait','if','else','for','while','switch','case','break','continue','return','def','import','package','new']),makefile:new Set(['include','ifeq','ifneq','ifdef','ifndef','else','endif']),cmake:new Set(['if','else','elseif','endif','foreach','endforeach','while','endwhile','function','endfunction','macro','endmacro','set','add_executable','add_library']),fortran:new Set(['program','module','subroutine','function','end','use','implicit','none','integer','real','character','logical','complex','dimension','allocatable','intent','in','out','inout','do','if','then','else','elseif','endif','call','return','write','read','format','type','class','interface','contains','allocate','deallocate']),haskell:new Set(['module','where','import','qualified','as','hiding','data','type','newtype','class','instance','deriving','if','then','else','case','of','let','in','do','return','where','infixl','infixr','infix','forall','default']),erlang:new Set(['module','export','import','if','case','of','end','receive','after','when','fun','try','catch','throw','begin','andalso','orelse','not','band','bor','bxor','bnot','bsl','bsr']),elixir:new Set(['def','defp','defmodule','defmacro','defstruct','defprotocol','defimpl','if','else','unless','case','cond','do','end','fn','when','with','for','raise','rescue','import','use','alias','require']),ocaml:new Set(['let','in','if','then','else','match','with','fun','function','type','module','struct','sig','end','open','val','rec','and','or','not','begin','do','done','for','while','to','downto','mutable','ref']),vhdl:new Set(['library','use','entity','architecture','is','of','begin','end','signal','variable','constant','port','in','out','inout','process','if','then','else','elsif','case','when','for','generate','component','generic','map']),verilog:new Set(['module','endmodule','input','output','inout','wire','reg','assign','always','begin','end','if','else','case','endcase','for','while','parameter','localparam','initial','posedge','negedge','task','function']),asm:new Set([]),protobuf:new Set(['syntax','package','import','option','message','enum','service','rpc','returns','repeated','optional','required','map','oneof','reserved','extend']),hcl:new Set(['resource','data','variable','output','locals','module','provider','terraform','backend','required_providers','for_each','count','depends_on','lifecycle','dynamic','content','block']),zig:new Set(['const','var','fn','pub','return','if','else','for','while','break','continue','switch','struct','enum','union','error','defer','errdefer','try','catch','import','comptime','inline','test','unreachable']),nim:new Set(['proc','func','method','type','var','let','const','if','elif','else','case','of','for','while','break','continue','return','import','include','from','object','ref','ptr','template','macro','iterator','yield','discard']),julia:new Set(['function','end','if','elseif','else','for','while','break','continue','return','module','using','import','export','struct','mutable','abstract','type','const','let','do','begin','try','catch','finally','throw','macro','quote']),crystal:new Set(['def','class','module','struct','enum','if','elsif','else','unless','case','when','while','until','for','do','end','return','yield','begin','rescue','ensure','require','include','extend','abstract','private','protected']),dlang:new Set(['module','import','class','struct','enum','interface','if','else','for','foreach','while','do','switch','case','default','break','continue','return','void','auto','const','immutable','static','public','private','protected','override','template','mixin','alias']),clojure:new Set(['def','defn','defmacro','fn','let','if','cond','case','do','loop','recur','for','doseq','when','when-not','ns','require','use','import','try','catch','throw','finally']),lisp:new Set(['defun','defmacro','defvar','defparameter','defconstant','let','let*','if','cond','case','when','unless','lambda','progn','loop','do','dolist','dotimes','setq','setf','funcall','apply','require','provide']),racket:new Set(['define','lambda','let','let*','letrec','if','cond','case','when','unless','begin','do','for','for/list','for/hash','match','struct','class','require','provide','module','import','export']),pascal:new Set(['program','unit','uses','interface','implementation','begin','end','var','const','type','procedure','function','if','then','else','for','to','downto','while','repeat','until','case','of','with','record','class','array','set','nil']),wgsl:new Set(['fn','var','let','const','struct','if','else','for','while','loop','break','continue','return','switch','case','default','override','enable','type','alias','discard','continuing','fallthrough']),glsl:new Set(['void','float','int','bool','vec2','vec3','vec4','mat2','mat3','mat4','sampler2D','uniform','varying','attribute','in','out','inout','if','else','for','while','do','break','continue','return','struct','const','precision','highp','mediump','lowp']),hlsl:new Set(['float','float2','float3','float4','int','bool','void','struct','cbuffer','Texture2D','SamplerState','if','else','for','while','do','break','continue','return','in','out','inout','uniform','static','const','register','semantic']),prisma:new Set(['model','enum','datasource','generator','type','relation','default','unique','id','map','index','ignore','updatedAt']),graphql:new Set(['type','query','mutation','subscription','input','enum','interface','union','scalar','schema','fragment','on','directive','extend','implements'])};
21660
22717
  S.staticMode=STATIC_UI;
21661
22718
  const COMPACT_AUTO_REFRESH_COUNT=3;
21662
22719
  const COMPACT_AUTO_REFRESH_INTERVAL_MS=260;
@@ -21820,18 +22877,18 @@ function renderCtxLive(snap){const box=E('ctxLive');const textEl=E('ctxLiveText'
21820
22877
  function showCompactToast(text){let el=document.querySelector('.compact-toast');if(!el){el=document.createElement('div');el.className='compact-toast';document.body.appendChild(el)}el.textContent=text;el.classList.add('show');if(el._t)clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),2800)}
21821
22878
  function parseCompactReason(data){const direct=String(data?.reason||'').trim();if(direct)return direct;const s=String(data?.summary||'');const m=s.match(/context compacted \\(([^)]*)\\)/);return m?String(m[1]||'').trim():''}
21822
22879
  function isRenderRuntimeEventType(evtType){return RENDER_EVT_TYPES.has(String(evtType||''))}
21823
- function pullRenderState(id,force=false){const sid=String(id||S.activeId||'').trim();if(!sid||S.renderStateInFlight)return;const now=Date.now();if(!force&&now-Number(S.lastRenderStatePullAt||0)<1200)return;S.lastRenderStatePullAt=now;S.renderStateInFlight=true;api('/api/sessions/'+sid+'/render-state').then(st=>{if(!st||typeof st!=='object')return;const frame=st?.frame;if(frame&&typeof frame==='object'){_renderBridgeEnqueue(frame);return}const seq=Number(st?.seq||0);if(seq<=0)return;const kind=String(st?.last_kind||st?.latest?.kind||'generic');const latest=st?.latest||{};const lines=Number(latest?.lines||0);const points=Number(latest?.points||0);_renderBridgeShow();_renderBridgeUpdateMeta(`render seq=${seq} · kind=${kind} · lines=${lines} · points=${points}`,true);_renderBridgeHideLater(90000)}).catch(()=>{}).finally(()=>{S.renderStateInFlight=false})}
22880
+ function pullRenderState(id,force=false){const sid=String(id||S.activeId||'').trim();if(!sid||S.renderStateInFlight)return;const now=Date.now();if(!force&&now-Number(S.lastRenderStatePullAt||0)<1200)return;S.lastRenderStatePullAt=now;S.renderStateInFlight=true;api('/api/sessions/'+sid+'/render-state').then(st=>{if(!st||typeof st!=='object')return;const frame=st?.frame;if(frame&&typeof frame==='object'){_renderBridgeEnqueue(frame);return}const seq=Number(st?.seq||0);if(seq<=0)return;const kind=String(st?.last_kind||st?.latest?.kind||'generic');const latest=st?.latest||{};const lines=Number(latest?.lines||0);const points=Number(latest?.points||0);_renderBridgeShow();_renderBridgeUpdateMeta(`render seq=${seq} · kind=${kind} · lines=${lines} · points=${points}`,true);_renderBridgeHideLater(30000)}).catch(()=>{}).finally(()=>{S.renderStateInFlight=false})}
21824
22881
  function _renderBridgeShow(){const wrap=E('renderBridge');if(!wrap)return null;wrap.classList.remove('hidden');if(RENDER.hideTimer){clearTimeout(RENDER.hideTimer);RENDER.hideTimer=0}return wrap}
21825
- function _renderBridgeHideLater(ms=90000){if(RENDER.hideTimer){clearTimeout(RENDER.hideTimer);RENDER.hideTimer=0}RENDER.hideTimer=setTimeout(()=>{const wrap=E('renderBridge');if(wrap)wrap.classList.add('hidden')},Math.max(4000,Number(ms)||90000))}
22882
+ function _renderBridgeHideLater(ms=30000){if(RENDER.hideTimer){clearTimeout(RENDER.hideTimer);RENDER.hideTimer=0}RENDER.hideTimer=setTimeout(()=>{const wrap=E('renderBridge');if(wrap)wrap.classList.add('hidden')},Math.max(4000,Number(ms)||30000))}
21826
22883
  function _renderBridgeEnsureCanvas(){const canvas=E('renderCanvas');if(!canvas)return null;const wrap=_renderBridgeShow();if(!wrap)return null;const ctx=(canvas.getContext&&canvas.getContext('2d'))?canvas.getContext('2d'):null;if(!ctx)return null;const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1));const rect=canvas.getBoundingClientRect();const w=Math.max(260,Math.floor(rect.width||canvas.clientWidth||canvas.width||960));const h=Math.max(120,Math.floor(rect.height||canvas.clientHeight||canvas.height||220));const bw=Math.max(260,Math.floor(w*dpr));const bh=Math.max(120,Math.floor(h*dpr));if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;ctx.setTransform(1,0,0,1,0,0);ctx.scale(dpr,dpr)}RENDER.canvas=canvas;RENDER.ctx=ctx;return{canvas,ctx,w,h}}
21827
22884
  function _renderBridgeSafeNum(v,def=0,min=-1e6,max=1e6){const n=Number(v);if(!Number.isFinite(n))return def;return Math.max(min,Math.min(max,n))}
21828
22885
  function _renderBridgeColor(raw,fallback='#1f6feb'){const s=String(raw||'').trim();if(/^#[0-9a-fA-F]{3,8}$/.test(s))return s;if(/^rgba?\\(([^)]+)\\)$/.test(s))return s;if(/^hsla?\\(([^)]+)\\)$/.test(s))return s;return fallback}
21829
22886
  function _renderBridgeUpdateMeta(text,force=false){const meta=E('renderMeta');if(!meta)return;const now=Date.now();if(!force&&now-Number(RENDER.lastMetaAt||0)<RENDER_META_MIN_INTERVAL_MS)return;const txt=String(text||'').trim();if(!txt)return;RENDER.lastMetaAt=now;RENDER.lastSummary=txt;meta.textContent=txt}
21830
22887
  function _renderBridgeDrawImage(frame,ctx,w,h){const b64=String(frame?.image_b64||'').trim();if(!b64)return;const mime=String(frame?.mime||'image/png').trim()||'image/png';const src=`data:${mime};base64,${b64}`;const ticket=Number(RENDER.imgTicket||0)+1;RENDER.imgTicket=ticket;const img=new Image();img.onload=()=>{if(ticket!==Number(RENDER.imgTicket||0))return;ctx.drawImage(img,0,0,w,h)};img.onerror=()=>{};img.src=src}
21831
- function _renderBridgeDrawFrame(frame){const ready=_renderBridgeEnsureCanvas();if(!ready)return;const {ctx,w,h}=ready;const srcW=Math.max(1,_renderBridgeSafeNum(frame?.width,0,0,8192));const srcH=Math.max(1,_renderBridgeSafeNum(frame?.height,0,0,8192));const sx=(srcW>0)?(w/srcW):1;const sy=(srcH>0)?(h/srcH):1;const clear=!!frame?.clear||!RENDER.lastPaintAt;if(clear){ctx.clearRect(0,0,w,h)}const bg=String(frame?.bg||'').trim();if(bg){ctx.fillStyle=_renderBridgeColor(bg,'#ffffff');ctx.fillRect(0,0,w,h)}if(String(frame?.image_b64||'').trim()){_renderBridgeDrawImage(frame,ctx,w,h)}const lines=Array.isArray(frame?.lines)?frame.lines:[];if(lines.length){for(const line of lines){const pts=Array.isArray(line?.points)?line.points:[];if(pts.length<2)continue;ctx.beginPath();const p0=pts[0];const x0=_renderBridgeSafeNum(Array.isArray(p0)?p0[0]:0,0)*sx;const y0=_renderBridgeSafeNum(Array.isArray(p0)?p0[1]:0,0)*sy;ctx.moveTo(x0,y0);for(let i=1;i<pts.length;i++){const p=pts[i];ctx.lineTo(_renderBridgeSafeNum(Array.isArray(p)?p[0]:0,0)*sx,_renderBridgeSafeNum(Array.isArray(p)?p[1]:0,0)*sy)}ctx.strokeStyle=_renderBridgeColor(line?.color,'#1f6feb');ctx.globalAlpha=_renderBridgeSafeNum(line?.alpha,1,0,1);ctx.lineWidth=_renderBridgeSafeNum(line?.width,1.6,0.1,60);ctx.stroke();ctx.globalAlpha=1}}const points=Array.isArray(frame?.points)?frame.points:[];if(points.length){for(const p of points){ctx.beginPath();ctx.arc(_renderBridgeSafeNum(p?.x,0)*sx,_renderBridgeSafeNum(p?.y,0)*sy,_renderBridgeSafeNum(p?.size,1.6,0.1,40),0,Math.PI*2);ctx.fillStyle=_renderBridgeColor(p?.color,'#1a7f64');ctx.globalAlpha=_renderBridgeSafeNum(p?.alpha,1,0,1);ctx.fill();ctx.globalAlpha=1}}const txt=String(frame?.text||'').trim();if(txt){ctx.fillStyle='#26364d';ctx.font='12px ui-monospace, SFMono-Regular, Menlo, monospace';ctx.fillText(txt.slice(0,240),8,18)}RENDER.lastPaintAt=Date.now();RENDER.lastSeq=Math.max(Number(RENDER.lastSeq||0),Number(frame?.seq||0));const lineCount=Array.isArray(frame?.lines)?frame.lines.length:0;const pointCount=Array.isArray(frame?.points)?frame.points.length:0;const kind=String(frame?.kind||'generic');_renderBridgeUpdateMeta(`render seq=${RENDER.lastSeq} · kind=${kind} · lines=${lineCount} · points=${pointCount}`,true);_renderBridgeHideLater(90000)}
22888
+ function _renderBridgeDrawFrame(frame){const ready=_renderBridgeEnsureCanvas();if(!ready)return;const {ctx,w,h}=ready;const srcW=Math.max(1,_renderBridgeSafeNum(frame?.width,0,0,8192));const srcH=Math.max(1,_renderBridgeSafeNum(frame?.height,0,0,8192));const sx=(srcW>0)?(w/srcW):1;const sy=(srcH>0)?(h/srcH):1;const clear=!!frame?.clear||!RENDER.lastPaintAt;if(clear){ctx.clearRect(0,0,w,h)}const bg=String(frame?.bg||'').trim();if(bg){ctx.fillStyle=_renderBridgeColor(bg,'#ffffff');ctx.fillRect(0,0,w,h)}if(String(frame?.image_b64||'').trim()){_renderBridgeDrawImage(frame,ctx,w,h)}const lines=Array.isArray(frame?.lines)?frame.lines:[];if(lines.length){for(const line of lines){const pts=Array.isArray(line?.points)?line.points:[];if(pts.length<2)continue;ctx.beginPath();const p0=pts[0];const x0=_renderBridgeSafeNum(Array.isArray(p0)?p0[0]:0,0)*sx;const y0=_renderBridgeSafeNum(Array.isArray(p0)?p0[1]:0,0)*sy;ctx.moveTo(x0,y0);for(let i=1;i<pts.length;i++){const p=pts[i];ctx.lineTo(_renderBridgeSafeNum(Array.isArray(p)?p[0]:0,0)*sx,_renderBridgeSafeNum(Array.isArray(p)?p[1]:0,0)*sy)}ctx.strokeStyle=_renderBridgeColor(line?.color,'#1f6feb');ctx.globalAlpha=_renderBridgeSafeNum(line?.alpha,1,0,1);ctx.lineWidth=_renderBridgeSafeNum(line?.width,1.6,0.1,60);ctx.stroke();ctx.globalAlpha=1}}const points=Array.isArray(frame?.points)?frame.points:[];if(points.length){for(const p of points){ctx.beginPath();ctx.arc(_renderBridgeSafeNum(p?.x,0)*sx,_renderBridgeSafeNum(p?.y,0)*sy,_renderBridgeSafeNum(p?.size,1.6,0.1,40),0,Math.PI*2);ctx.fillStyle=_renderBridgeColor(p?.color,'#1a7f64');ctx.globalAlpha=_renderBridgeSafeNum(p?.alpha,1,0,1);ctx.fill();ctx.globalAlpha=1}}const txt=String(frame?.text||'').trim();if(txt){ctx.fillStyle='#26364d';ctx.font='12px ui-monospace, SFMono-Regular, Menlo, monospace';ctx.fillText(txt.slice(0,240),8,18)}RENDER.lastPaintAt=Date.now();RENDER.lastSeq=Math.max(Number(RENDER.lastSeq||0),Number(frame?.seq||0));const lineCount=Array.isArray(frame?.lines)?frame.lines.length:0;const pointCount=Array.isArray(frame?.points)?frame.points.length:0;const kind=String(frame?.kind||'generic');_renderBridgeUpdateMeta(`render seq=${RENDER.lastSeq} · kind=${kind} · lines=${lineCount} · points=${pointCount}`,true);_renderBridgeHideLater(30000)}
21832
22889
  function _renderBridgeDrain(){RENDER.raf=0;if(!Array.isArray(RENDER.queue)||!RENDER.queue.length)return;const latest=RENDER.queue[RENDER.queue.length-1]||{};RENDER.queue.length=0;_renderBridgeDrawFrame(latest);if(RENDER.queue.length){RENDER.raf=requestAnimationFrame(_renderBridgeDrain)}}
21833
22890
  function _renderBridgeEnqueue(frame){if(!frame||typeof frame!=='object')return;_renderBridgeShow();if(!Array.isArray(RENDER.queue))RENDER.queue=[];RENDER.queue.push(frame);if(RENDER.queue.length>RENDER_QUEUE_MAX){RENDER.queue=RENDER.queue.slice(-Math.floor(RENDER_QUEUE_MAX*0.6))}const summary=String(frame?.summary||'').trim();if(summary){_renderBridgeUpdateMeta(summary,false)}if(!RENDER.raf){RENDER.raf=requestAnimationFrame(_renderBridgeDrain)}}
21834
- function _renderBridgeSyncFromSnapshot(snap){const rb=snap?.render_bridge||null;if(!rb||typeof rb!=='object')return;const seq=Number(rb?.seq||0);if(seq<=0)return;_renderBridgeShow();const kind=String(rb?.last_kind||rb?.latest?.kind||'generic');const latest=rb?.latest||{};const lines=Number(latest?.lines||0);const points=Number(latest?.points||0);_renderBridgeUpdateMeta(`render seq=${seq} · kind=${kind} · lines=${lines} · points=${points}`,true);_renderBridgeHideLater(90000)}
22891
+ function _renderBridgeSyncFromSnapshot(snap){const rb=snap?.render_bridge||null;if(!rb||typeof rb!=='object')return;const seq=Number(rb?.seq||0);if(seq<=0)return;_renderBridgeShow();const kind=String(rb?.last_kind||rb?.latest?.kind||'generic');const latest=rb?.latest||{};const lines=Number(latest?.lines||0);const points=Number(latest?.points||0);_renderBridgeUpdateMeta(`render seq=${seq} · kind=${kind} · lines=${lines} · points=${points}`,true);_renderBridgeHideLater(30000)}
21835
22892
  function _deltaEnsureSnapshot(){if(!S.snap||typeof S.snap!=='object')return false;if(!Array.isArray(S.snap.messages))S.snap.messages=[];if(!Array.isArray(S.snap.conversation_feed))S.snap.conversation_feed=[];if(!Array.isArray(S.snap.activity))S.snap.activity=[];if(!Array.isArray(S.snap.operations))S.snap.operations=[];if(!Array.isArray(S.snap.uploads))S.snap.uploads=[];if(!Array.isArray(S.snap.todos))S.snap.todos=[];if(!Array.isArray(S.snap.tasks))S.snap.tasks=[];return true}
21836
22893
  function _deltaPushLimited(arr,row,maxCount){if(!Array.isArray(arr))return;if(!row||typeof row!=='object')return;arr.push(row);const maxN=Math.max(20,Number(maxCount)||120);if(arr.length>maxN){arr.splice(0,arr.length-maxN)}}
21837
22894
  function _deltaAdoptAgentRole(data){if(!_deltaEnsureSnapshot())return'';const role=_chatVirtAgentRoleKey(data?.agent_role);if(!role)return'';S.snap.agent_active_role=role;return role}
@@ -21858,6 +22915,7 @@ function _deltaScheduleRender(flags={}){
21858
22915
  if(S.deltaRenderRaf)return;
21859
22916
  S.deltaRenderRaf=requestAnimationFrame(()=>{
21860
22917
  S.deltaRenderRaf=0;
22918
+ if(S.refreshInFlight)return;
21861
22919
  const needChat=!!S.deltaRenderChat;
21862
22920
  const needBoards=!!S.deltaRenderBoards;
21863
22921
  const needSessions=!!S.deltaRenderSessions;
@@ -22016,8 +23074,8 @@ function onRuntimeEvent(evt){
22016
23074
  if(seqState.gap)return{handled:true,needsSnapshot:true};
22017
23075
  const typ=String(evt.type||'');
22018
23076
  if(typ==='render_frame'){_renderBridgeEnqueue(evt.data||{});return{handled:true,needsSnapshot:false}}
22019
- if(typ==='render_bridge'){const d=evt.data||{};const summary=String(d?.summary||'').trim();if(summary){_renderBridgeShow();_renderBridgeUpdateMeta(summary,true);_renderBridgeHideLater(90000)}return{handled:true,needsSnapshot:false}}
22020
- if(typ==='compact'){scheduleCompactRefreshBurst(COMPACT_AUTO_REFRESH_COUNT);const reason=parseCompactReason(evt.data||{});if(reason==='auto'||reason.startsWith('truncation-rescue')){const pct=Number(evt.data?.context_left_percent_before);const left=Number(evt.data?.context_left_before);const limit=Number(evt.data?.context_limit_before);const pctTxt=Number.isFinite(pct)?pct.toFixed(1):'-';const leftTxt=Number.isFinite(left)&&Number.isFinite(limit)?`${left}/${limit}`:'-';showCompactToast(`${t('compact_auto')}:${pctTxt}% left (${leftTxt}) · delta-sync`)}_deltaAppendActivity(typ,evt.data||{},Number(evt?.ts||Date.now()/1000));_deltaScheduleRender({boards:true,sessions:true});return{handled:true,needsSnapshot:false}}
23077
+ if(typ==='render_bridge'){const d=evt.data||{};const summary=String(d?.summary||'').trim();if(summary){_renderBridgeShow();_renderBridgeUpdateMeta(summary,true);_renderBridgeHideLater(30000)}return{handled:true,needsSnapshot:false}}
23078
+ if(typ==='compact'){scheduleCompactRefreshBurst(COMPACT_AUTO_REFRESH_COUNT);const reason=parseCompactReason(evt.data||{});if(reason==='auto'||reason.startsWith('truncation-rescue')){const pct=Number(evt.data?.context_left_percent_before);const left=Number(evt.data?.context_left_before);const limit=Number(evt.data?.context_limit_before);const pctTxt=Number.isFinite(pct)?pct.toFixed(1):'-';const leftTxt=Number.isFinite(left)&&Number.isFinite(limit)?`${left}/${limit}`:'-';showCompactToast(`${t('compact_auto')}:${pctTxt}% left (${leftTxt}) · delta-sync`)}_deltaAppendActivity(typ,evt.data||{},Number(evt?.ts||Date.now()/1000));return{handled:true,needsSnapshot:false}}
22021
23079
  return _deltaApplyRuntimeEvent(evt);
22022
23080
  }
22023
23081
  function _deltaStartWatchdog(){
@@ -22047,7 +23105,7 @@ function _deltaStartWatchdog(){
22047
23105
  };
22048
23106
  S.deltaWatchdogTimer=setTimeout(tick,DELTA_WATCHDOG_INTERVAL_MS);
22049
23107
  }
22050
- function renderSkillsEntryLink(){const link=E('downloadBtn');if(!link)return;const host=location.hostname||'127.0.0.1';const enabled=Boolean(S.config?.skills_ui_enabled);const fromConfig=String(S.config?.skills_ui_url||'').trim();const skillsPort=Number(S.config?.skills_port||0);let href='#';if(enabled){if(fromConfig){href=fromConfig}else if(Number.isFinite(skillsPort)&&skillsPort>0){const currentPort=Number(location.port||0);if(!(currentPort&&skillsPort===currentPort)){href=`${location.protocol}//${host}:${skillsPort}`}}}const offline=(href==='#');link.href=href;link.classList.toggle('disabled',offline);link.textContent=offline?t('skills_offline'):t('open_skills');if(offline){link.removeAttribute('target');link.removeAttribute('rel')}else{link.setAttribute('target','_blank');link.setAttribute('rel','noreferrer')}}
23108
+ function renderSkillsEntryLink(){const link=E('downloadBtn');if(!link)return;const host=location.hostname||'127.0.0.1';const enabled=Boolean(S.config?.skills_ui_enabled);const fromConfig=String(S.config?.skills_ui_url||'').trim();const skillsPort=Number(S.config?.skills_port||0);let href='#';if(enabled){if(fromConfig){href=fromConfig}else if(Number.isFinite(skillsPort)&&skillsPort>0){const currentPort=Number(location.port||0);if(!(currentPort&&skillsPort===currentPort)){href=`${location.protocol}//${host}:${skillsPort}`}}}const offline=(href==='#');link.href=href;link.classList.toggle('disabled',offline);link.textContent=offline?t('skills_offline'):t('open_skills')}
22051
23109
  function tailSig(rows,count,mapper){const arr=Array.isArray(rows)?rows:[];if(!arr.length)return'';return arr.slice(Math.max(0,arr.length-count)).map(mapper).join('|')}
22052
23110
  function feedSignature(snap){const feed=Array.isArray(snap?.conversation_feed)?snap.conversation_feed:(Array.isArray(snap?.messages)?snap.messages:[]);const sig=tailSig(feed,8,row=>`${Number(row?.ts||0)}:${String(row?.role||'')}:${String(row?.agent_role||'')}:${String(row?.type||'')}:${String(row?.text||'').length}:${String(row?.thinking||'').length}:${String(row?.text||'').slice(-12)}:${String(row?.thinking||'').slice(-12)}`);const live=String(snap?.live_thinking||'');const runActive=snap?.live_run_notice_active?1:0;const runLabel=String(snap?.live_run_notice_label||'');const runStart=Number(snap?.live_run_notice_started_at||0);const truncText=String(snap?.live_truncation_text||'');const truncKind=String(snap?.live_truncation_kind||'');const truncTool=String(snap?.live_truncation_tool||'');const truncAttempts=Number(snap?.live_truncation_attempts||0);const truncTokens=Number(snap?.live_truncation_tokens||0);const truncActive=snap?.live_truncation_active?1:0;return `${feed.length}|${sig}|lt=${live.length}:${live.slice(-12)}|rn=${runActive}:${runStart}:${runLabel.slice(-12)}|tr=${truncActive}:${truncAttempts}:${truncTokens}:${truncKind.slice(-12)}:${truncTool.slice(-12)}:${truncText.length}`}
22053
23111
  function boardsSignature(snap){return [snap?.running?1:0,snap?.agent_phase||'',Number(snap?.agent_round_index||0),Number(snap?.queued_user_inputs_count||0),Number(snap?.truncation_count||0),Number(snap?.live_truncation_attempts||0),Number(snap?.live_truncation_tokens||0),snap?.live_truncation_active?1:0,Number(snap?.context_tokens_estimate||0),Number(snap?.context_left_tokens||0),Number(snap?.context_left_percent||0),Number(snap?.render_bridge?.seq||0),(snap?.todos||[]).length,(snap?.tasks||[]).length,(snap?.activity||[]).length,(snap?.operations||[]).length,(snap?.uploads||[]).length].join('|')}
@@ -22066,11 +23124,12 @@ function _scrollContainerToNodeCenter(container,target){
22066
23124
  }
22067
23125
  function _bindNestedScrollGuards(root){
22068
23126
  if(!root)return;
23127
+ const SEL='.msg-diff-shell,.msg-code-shell,.preview-code-scroll,.md-code';
22069
23128
  const nodes=[];
22070
- if(root.matches&&root.matches('.msg-diff-shell,.msg-code-shell,.preview-code-scroll')){
23129
+ if(root.matches&&root.matches(SEL)){
22071
23130
  nodes.push(root);
22072
23131
  }
22073
- for(const n of root.querySelectorAll('.msg-diff-shell,.msg-code-shell,.preview-code-scroll')){
23132
+ for(const n of root.querySelectorAll(SEL)){
22074
23133
  nodes.push(n);
22075
23134
  }
22076
23135
  const markManualCenterOff=(node)=>{
@@ -22087,6 +23146,17 @@ function _bindNestedScrollGuards(root){
22087
23146
  if(key)S.diffCenterDisabled[key]=1;
22088
23147
  }
22089
23148
  };
23149
+ const markChatUserScrolling=()=>{
23150
+ const chatEl=E('chat');
23151
+ if(!chatEl)return;
23152
+ const now=Date.now();
23153
+ chatEl._virtManualUnlockTs=Math.max(
23154
+ Number(chatEl._virtManualUnlockTs||0),
23155
+ now+CHAT_SCROLL_LOCK_MS
23156
+ );
23157
+ S.follow.chat=false;
23158
+ chatEl._virtAutoFollowPaused=true;
23159
+ };
22090
23160
  for(const node of nodes){
22091
23161
  if(!node||node._nestedScrollGuardBound)continue;
22092
23162
  node._nestedScrollGuardBound=true;
@@ -22097,16 +23167,38 @@ function _bindNestedScrollGuards(root){
22097
23167
  const maxLeft=Math.max(0,Number(node.scrollWidth||0)-Number(node.clientWidth||0));
22098
23168
  const top=Number(node.scrollTop||0);
22099
23169
  const left=Number(node.scrollLeft||0);
22100
- const canY=(dy<0&&top>0)||(dy>0&&top<maxTop);
22101
- const canX=(dx<0&&left>0)||(dx>0&&left<maxLeft);
23170
+ const canY=(dy<0&&top>0.5)||(dy>0&&top<maxTop-0.5);
23171
+ const canX=(dx<0&&left>0.5)||(dx>0&&left<maxLeft-0.5);
22102
23172
  markManualCenterOff(node);
22103
23173
  if(canY||canX){
22104
23174
  ev.stopPropagation();
23175
+ markChatUserScrolling();
22105
23176
  }
23177
+ },{passive:false});
23178
+ node.addEventListener('mousedown',()=>{markManualCenterOff(node);markChatUserScrolling();},{passive:true});
23179
+ node.addEventListener('touchstart',ev=>{
23180
+ markManualCenterOff(node);
23181
+ markChatUserScrolling();
23182
+ node._touchStartY=Number(ev.touches?.[0]?.clientY||0);
23183
+ node._touchStartX=Number(ev.touches?.[0]?.clientX||0);
22106
23184
  },{passive:true});
22107
- node.addEventListener('mousedown',()=>{markManualCenterOff(node);},{passive:true});
22108
- node.addEventListener('touchstart',()=>{markManualCenterOff(node);},{passive:true});
22109
- node.addEventListener('touchmove',ev=>{markManualCenterOff(node);ev.stopPropagation();},{passive:true});
23185
+ node.addEventListener('touchmove',ev=>{
23186
+ markManualCenterOff(node);
23187
+ const curY=Number(ev.touches?.[0]?.clientY||0);
23188
+ const curX=Number(ev.touches?.[0]?.clientX||0);
23189
+ const dy=curY-(node._touchStartY||0);
23190
+ const dx=curX-(node._touchStartX||0);
23191
+ const maxTop=Math.max(0,Number(node.scrollHeight||0)-Number(node.clientHeight||0));
23192
+ const maxLeft=Math.max(0,Number(node.scrollWidth||0)-Number(node.clientWidth||0));
23193
+ const top=Number(node.scrollTop||0);
23194
+ const left=Number(node.scrollLeft||0);
23195
+ const canY=(dy>0&&top>0.5)||(dy<0&&top<maxTop-0.5);
23196
+ const canX=(dx>0&&left>0.5)||(dx<0&&left<maxLeft-0.5);
23197
+ if(canY||canX){
23198
+ ev.stopPropagation();
23199
+ }
23200
+ markChatUserScrolling();
23201
+ },{passive:false});
22110
23202
  }
22111
23203
  }
22112
23204
  function _centerDiffShellToHotspot(root){
@@ -22136,8 +23228,7 @@ function _centerDiffShellToHotspot(root){
22136
23228
  const target=lines[bestCenter];
22137
23229
  if(!target)return;
22138
23230
  if(msgKey)shell.setAttribute('data-centered-key',msgKey);
22139
- const run=()=>{try{_scrollContainerToNodeCenter(shell,target);if(msgKey)S.diffCenteredDone[msgKey]=1;}catch(_){}};
22140
- if(typeof requestAnimationFrame==='function'){requestAnimationFrame(run)}else{run()}
23231
+ try{_scrollContainerToNodeCenter(shell,target);if(msgKey)S.diffCenteredDone[msgKey]=1;}catch(_){}
22141
23232
  }
22142
23233
  function splitTableRow(line){const src=String(line||'').trim().replace(/^\\|/,'').replace(/\\|$/,'');if(!src)return[];return src.split('|').map(x=>String(x||'').trim())}
22143
23234
  function isTableSeparator(line){const cells=splitTableRow(line);if(!cells.length)return false;return cells.every(cell=>/^:?-{3,}:?$/.test(cell))}
@@ -22509,7 +23600,7 @@ function _previewRenderStageSelector(tab,stages,selectedReq,payload=null){
22509
23600
  stat.textContent=`stage ${idx}/${total} · +${add}/-${del}${lineTail}`;
22510
23601
  }
22511
23602
  function _previewLangFromPath(path){const rel=normalizePreviewPath(path).toLowerCase();const name=rel.split('/').pop()||'';const dot=name.lastIndexOf('.');const ext=dot>=0?name.slice(dot):'';return CODE_LANG_BY_EXT[ext]||CODE_LANG_BY_NAME[name]||'default'}
22512
- function _codeLangConfig(lang){const v=String(lang||'default');if(v==='python'||v==='shell'||v==='ruby'||v==='yaml'||v==='toml'||v==='ini'||v==='r'||v==='perl'||v==='makefile'||v==='cmake')return{hashComment:true,slashComment:false,dashComment:false,blockComment:false,xmlComment:false,backtick:false};if(v==='sql')return{hashComment:false,slashComment:false,dashComment:true,blockComment:true,xmlComment:false,backtick:false};if(v==='xml')return{hashComment:false,slashComment:false,dashComment:false,blockComment:false,xmlComment:true,backtick:false};if(v==='json')return{hashComment:false,slashComment:false,dashComment:false,blockComment:false,xmlComment:false,backtick:false};return{hashComment:false,slashComment:true,dashComment:false,blockComment:true,xmlComment:false,backtick:true}}
23603
+ function _codeLangConfig(lang){const v=String(lang||'default');if(v==='python'||v==='shell'||v==='ruby'||v==='yaml'||v==='toml'||v==='ini'||v==='r'||v==='perl'||v==='makefile'||v==='cmake'||v==='nim'||v==='julia'||v==='elixir'||v==='crystal')return{hashComment:true,slashComment:false,dashComment:false,blockComment:false,xmlComment:false,backtick:false};if(v==='sql'||v==='haskell')return{hashComment:false,slashComment:false,dashComment:true,blockComment:true,xmlComment:false,backtick:false};if(v==='xml'||v==='vhdl')return{hashComment:false,slashComment:false,dashComment:true,blockComment:false,xmlComment:true,backtick:false};if(v==='json'||v==='fortran'||v==='asm'||v==='protobuf'||v==='prisma'||v==='graphql'||v==='wgsl')return{hashComment:false,slashComment:false,dashComment:false,blockComment:false,xmlComment:false,backtick:false};if(v==='lisp'||v==='clojure'||v==='racket')return{hashComment:false,slashComment:false,dashComment:false,blockComment:false,xmlComment:false,backtick:false};return{hashComment:false,slashComment:true,dashComment:false,blockComment:true,xmlComment:false,backtick:true}}
22513
23604
  function _codeWordSet(lang){return CODE_KEYWORDS[String(lang||'default')]||CODE_KEYWORDS.default}
22514
23605
  function _isWordStart(ch){return /[A-Za-z_$]/.test(ch)}
22515
23606
  function _isWordChar(ch){return /[A-Za-z0-9_$]/.test(ch)}
@@ -22913,8 +24004,7 @@ function _scrollCodePreviewToAnchor(body,anchorLine){
22913
24004
  target=body.querySelector('.code-row.code-add,.code-row.code-delete')||rows[0];
22914
24005
  }
22915
24006
  if(!target)return;
22916
- const run=()=>{try{_scrollContainerToNodeCenter(scrollWrap,target);if(previewKey)S.previewCenteredDone[previewKey]=1;}catch(_){}};
22917
- if(typeof requestAnimationFrame==='function'){requestAnimationFrame(run)}else{run()}
24007
+ try{_scrollContainerToNodeCenter(scrollWrap,target);if(previewKey)S.previewCenteredDone[previewKey]=1;}catch(_){}
22918
24008
  }
22919
24009
  async function _renderCodePreviewTab(tab,body,forceReload=false){
22920
24010
  const ticket=String(++S.previewNonce);
@@ -23032,7 +24122,7 @@ function _chatVirtRowKey(row,idx){const r=row||{};const txt=String(r.text||'');c
23032
24122
  function _chatVirtFormatElapsed(seconds){const sec=Math.max(0,Math.floor(Number(seconds)||0));const h=Math.floor(sec/3600);const m=Math.floor((sec%3600)/60);const s=sec%60;if(h>0)return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;return `${m}:${String(s).padStart(2,'0')}`}
23033
24123
  function _chatVirtLiveRunText(label,elapsed){return `${t('running')} · ${_chatVirtFormatElapsed(elapsed)}`}
23034
24124
  function _chatVirtStopRunTicker(chatEl){if(!chatEl)return;const timer=Number(chatEl._virtRunTicker||0);if(timer){clearInterval(timer);chatEl._virtRunTicker=0}}
23035
- function _chatVirtTickRunNotice(chatEl){if(!chatEl)return;const runActive=!!(S.snap?.running&&S.snap?.live_run_notice_active);if(!runActive){_chatVirtStopRunTicker(chatEl);return}const nodes=chatEl.querySelectorAll('.msg[data-run-live=\"1\"]');if(!nodes.length){_chatVirtStopRunTicker(chatEl);return}const now=(Date.now()/1000);for(const node of nodes){const pre=node.querySelector('pre');if(!pre)continue;const label=String(node.getAttribute('data-run-label')||'model call');const startedAt=Number(node.getAttribute('data-run-start')||0);const baseElapsed=Math.max(0,Number(node.getAttribute('data-run-elapsed')||0));const anchorTs=Number(node.getAttribute('data-run-anchor')||0);let elapsed=baseElapsed;if(startedAt>0){elapsed=Math.max(baseElapsed,now-startedAt)}else if(anchorTs>0){elapsed=Math.max(0,baseElapsed+(now-anchorTs))}const whole=Math.floor(elapsed);if(String(node.getAttribute('data-run-last-sec')||'')===String(whole))continue;node.setAttribute('data-run-last-sec',String(whole));pre.textContent=_chatVirtLiveRunText(label,elapsed)}}
24125
+ function _chatVirtTickRunNotice(chatEl){if(!chatEl)return;const runActive=!!(S.snap?.running&&S.snap?.live_run_notice_active);if(!runActive){_chatVirtStopRunTicker(chatEl);return}if(!chatEl._virtRunNodes||!chatEl._virtRunNodes.length){chatEl._virtRunNodes=Array.from(chatEl.querySelectorAll('.msg[data-run-live="1"]'))}const nodes=chatEl._virtRunNodes;if(!nodes.length){_chatVirtStopRunTicker(chatEl);return}const now=(Date.now()/1000);for(const node of nodes){const pre=node.querySelector('pre');if(!pre)continue;const label=String(node.getAttribute('data-run-label')||'model call');const startedAt=Number(node.getAttribute('data-run-start')||0);const baseElapsed=Math.max(0,Number(node.getAttribute('data-run-elapsed')||0));const anchorTs=Number(node.getAttribute('data-run-anchor')||0);let elapsed=baseElapsed;if(startedAt>0){elapsed=Math.max(baseElapsed,now-startedAt)}else if(anchorTs>0){elapsed=Math.max(0,baseElapsed+(now-anchorTs))}const whole=Math.floor(elapsed);if(String(node.getAttribute('data-run-last-sec')||'')===String(whole))continue;node.setAttribute('data-run-last-sec',String(whole));pre.textContent=_chatVirtLiveRunText(label,elapsed)}}
23036
24126
  function _chatVirtSyncRunTicker(chatEl){if(!chatEl)return;const hasRun=!!chatEl.querySelector('.msg[data-run-live=\"1\"]');if(!hasRun){_chatVirtStopRunTicker(chatEl);return}_chatVirtTickRunNotice(chatEl);if(!chatEl._virtRunTicker){chatEl._virtRunTicker=setInterval(()=>_chatVirtTickRunNotice(chatEl),1000)}}
23037
24127
  function _chatVirtCollectRows(){
23038
24128
  const feed=Array.isArray(S.snap?.conversation_feed)?S.snap.conversation_feed:(Array.isArray(S.snap?.messages)?S.snap.messages:[]);
@@ -23101,7 +24191,7 @@ function _chatVirtAcquireNode(kind){const key=String(kind||'text');const pool=_c
23101
24191
  function _chatVirtReleaseNode(node){
23102
24192
  if(!node)return;
23103
24193
  const key=String(node.getAttribute('data-pool-kind')||'text');
23104
- if(Number(CHAT_VIRT.poolSize||0)>=Number(CHAT_VIRT.poolMax||420))return;
24194
+ if(Number(CHAT_VIRT.poolSize||0)>=Number(CHAT_VIRT.poolMax||180))return;
23105
24195
  if(S.mathObserver){
23106
24196
  try{S.mathObserver.unobserve(node)}catch(_){}
23107
24197
  }
@@ -23316,6 +24406,70 @@ function _chatVirtBuildMessageNode(m){
23316
24406
  return d;
23317
24407
  }
23318
24408
  function _chatVirtFindWindow(rows,top,bottom){const startTarget=Math.max(0,top-CHAT_VIRT.overscanPx);const endTarget=Math.max(0,bottom+CHAT_VIRT.overscanPx);let start=0;let acc=0;while(start<rows.length){const h=_chatVirtEstimatedHeight(rows[start]);if((acc+h)>=startTarget)break;acc+=h;start+=1}let end=start;let accEnd=acc;while(end<rows.length&&accEnd<=endTarget){accEnd+=_chatVirtEstimatedHeight(rows[end]);end+=1}end=Math.min(rows.length,end+2);return {start,end,topOffset:acc,endOffset:accEnd}}
24409
+ function _chatVirtReuseWindow(chatEl,rows,top,bottom){
24410
+ if(!chatEl||!Array.isArray(rows)||!rows.length)return null;
24411
+ const prevRows=Array.isArray(chatEl._virtLastRows)?chatEl._virtLastRows:[];
24412
+ const prevStart=Number(chatEl._virtLastWinStart||-1);
24413
+ const prevEnd=Number(chatEl._virtLastWinEnd||-1);
24414
+ const prevTopOffset=Number(chatEl._virtLastTopOffset);
24415
+ const prevEndOffset=Number(chatEl._virtLastEndOffset);
24416
+ if(prevStart<0||prevEnd<=prevStart)return null;
24417
+ if(!Number.isFinite(prevTopOffset)||!Number.isFinite(prevEndOffset)||prevEndOffset<=prevTopOffset)return null;
24418
+ if(prevRows.length!==rows.length)return null;
24419
+ const prevFirstKey=String(prevRows[prevStart]?._vk||'');
24420
+ const nextFirstKey=String(rows[prevStart]?._vk||'');
24421
+ const prevLastKey=String(prevRows[Math.max(0,prevEnd-1)]?._vk||'');
24422
+ const nextLastKey=String(rows[Math.max(0,prevEnd-1)]?._vk||'');
24423
+ if(prevFirstKey!==nextFirstKey||prevLastKey!==nextLastKey)return null;
24424
+ const viewport=Math.max(0,bottom-top);
24425
+ const innerPad=Math.max(120,Math.min(Math.round(CHAT_VIRT.overscanPx*0.45),Math.round(viewport*0.35)));
24426
+ if((prevEndOffset-prevTopOffset)<(viewport+(innerPad*2)))return null;
24427
+ const safeTop=prevTopOffset+innerPad;
24428
+ const safeBottom=prevEndOffset-innerPad;
24429
+ if(top>=safeTop&&bottom<=safeBottom){
24430
+ return {start:prevStart,end:prevEnd,topOffset:prevTopOffset,endOffset:prevEndOffset};
24431
+ }
24432
+ return null;
24433
+ }
24434
+ function _chatVirtReleaseRendered(root){if(!root)return;for(const node of root.querySelectorAll('.msg[data-vk]')){_chatVirtReleaseNode(node)}}
24435
+ function _chatVirtFindRenderedNode(chatEl,key){
24436
+ if(!chatEl||!key)return null;
24437
+ for(const node of chatEl.querySelectorAll('.msg[data-vk]')){
24438
+ if(String(node.getAttribute('data-vk')||'')===String(key||''))return node;
24439
+ }
24440
+ return null;
24441
+ }
24442
+ function _chatVirtCaptureAnchor(chatEl){
24443
+ if(!chatEl)return null;
24444
+ const viewportTop=Number(chatEl.getBoundingClientRect().top||0);
24445
+ let fallback=null;
24446
+ for(const node of chatEl.querySelectorAll('.msg[data-vk]')){
24447
+ const key=String(node.getAttribute('data-vk')||'').trim();
24448
+ if(!key)continue;
24449
+ const rect=node.getBoundingClientRect();
24450
+ const top=Number(rect.top||0)-viewportTop;
24451
+ const bottom=Number(rect.bottom||0)-viewportTop;
24452
+ const anchor={key:key,offset:top};
24453
+ if(!fallback)fallback=anchor;
24454
+ if(bottom>1)return anchor;
24455
+ }
24456
+ return fallback;
24457
+ }
24458
+ function _chatVirtRestoreAnchor(chatEl,anchor){
24459
+ if(!chatEl||!anchor||!anchor.key)return false;
24460
+ const node=_chatVirtFindRenderedNode(chatEl,anchor.key);
24461
+ if(!node)return false;
24462
+ const viewportTop=Number(chatEl.getBoundingClientRect().top||0);
24463
+ const rect=node.getBoundingClientRect();
24464
+ const currentOffset=Number(rect.top||0)-viewportTop;
24465
+ const delta=currentOffset-Number(anchor.offset||0);
24466
+ if(Math.abs(delta)<0.75)return true;
24467
+ const maxTop=Math.max(0,Number(chatEl.scrollHeight||0)-Number(chatEl.clientHeight||0));
24468
+ const target=Math.max(0,Math.min(Number(chatEl.scrollTop||0)+delta,maxTop));
24469
+ if(Math.abs(target-Number(chatEl.scrollTop||0))<0.75)return true;
24470
+ chatEl.scrollTop=target;
24471
+ return true;
24472
+ }
23319
24473
  function _chatVirtBindScroll(chatEl){
23320
24474
  if(chatEl._virtBound)return;
23321
24475
  chatEl._virtBound=true;
@@ -23385,28 +24539,11 @@ function _chatVirtBindScroll(chatEl){
23385
24539
  const now=Date.now();
23386
24540
  chatEl._virtLastWheelTs=now;
23387
24541
  chatEl._virtLastWheelDy=dy;
23388
- chatEl._virtInputUnlockTs=Math.max(
23389
- Number(chatEl._virtInputUnlockTs||0),
23390
- now+CHAT_SCROLL_INPUT_LOCK_MS
23391
- );
23392
- const atBottomBefore=nearBottom(chatEl,6);
23393
24542
  if(dy<0){
23394
- markManual(CHAT_SCROLL_LOCK_MS);
23395
24543
  S.follow.chat=false;
23396
24544
  return;
23397
24545
  }
23398
- if(!atBottomBefore){
23399
- markManual(Math.round(CHAT_SCROLL_LOCK_MS*0.45));
23400
- S.follow.chat=false;
23401
- return;
23402
- }
23403
- S.follow.chat=true;
23404
- chatEl._virtManualUnlockTs=0;
23405
- chatEl._virtInputUnlockTs=0;
23406
- chatEl._virtTouchUnlockTs=0;
23407
- if(atBottomBefore){
23408
- chatEl._virtAutoFollowPaused=false;
23409
- }
24546
+ if(nearBottom(chatEl,6))S.follow.chat=true;
23410
24547
  },{passive:true});
23411
24548
  chatEl.addEventListener('mousedown',()=>{markManual(Math.round(CHAT_SCROLL_LOCK_MS*0.9))},{passive:true});
23412
24549
  chatEl.addEventListener('touchstart',()=>{markTouchStart(CHAT_TOUCH_SCROLL_LOCK_MS)},{passive:true});
@@ -23420,9 +24557,7 @@ function _chatVirtBindScroll(chatEl){
23420
24557
  chatEl._virtScrollDirection=(curTop>prevTop)?1:((curTop<prevTop)?-1:0);
23421
24558
  chatEl._virtLastScrollTop=curTop;
23422
24559
  const atBottom=nearBottom(chatEl,6);
23423
- const manualLock=Number(chatEl._virtManualUnlockTs||0)>now;
23424
- const recentUpIntent=(now-Number(chatEl._virtLastWheelTs||0))<220&&Number(chatEl._virtLastWheelDy||0)<0;
23425
- if(atBottom&&!manualLock&&!recentUpIntent){
24560
+ if(atBottom){
23426
24561
  S.follow.chat=true;
23427
24562
  chatEl._virtManualUnlockTs=0;
23428
24563
  chatEl._virtInputUnlockTs=0;
@@ -23433,9 +24568,6 @@ function _chatVirtBindScroll(chatEl){
23433
24568
  if(S.snap?.running){
23434
24569
  chatEl._virtAutoFollowPaused=true;
23435
24570
  }
23436
- if(!atBottom||recentUpIntent){
23437
- chatEl._virtManualUnlockTs=Math.max(Number(chatEl._virtManualUnlockTs||0),now+CHAT_SCROLL_LOCK_MS);
23438
- }
23439
24571
  }
23440
24572
  scheduleScrollRender();
23441
24573
  });
@@ -23453,12 +24585,9 @@ function renderChat(reason='snapshot'){
23453
24585
  if((!S.snap?.running)&&atBottomNow){
23454
24586
  c._virtAutoFollowPaused=false;
23455
24587
  }
23456
- const now=Date.now();
23457
- const manualLock=Number(c._virtManualUnlockTs||0)>now;
23458
- const autoPaused=Boolean(c._virtAutoFollowPaused);
23459
- const scrolling=_chatVirtIsUserScrolling(c);
23460
- const keep=first||(!manualLock&&!autoPaused&&!scrolling&&(atBottomNow||Boolean(S.follow.chat)));
24588
+ const keep=first||Boolean(S.follow.chat)||atBottomNow;
23461
24589
  const oldScrollTop=Number(c.scrollTop||0);
24590
+ const anchor=(!keep&&!first)?_chatVirtCaptureAnchor(c):null;
23462
24591
  const feedSig=String(S.lastFeedSig||feedSignature(S.snap||{}));
23463
24592
  let rows=[];
23464
24593
  if(reason==='scroll'&&Array.isArray(c._virtRowsCacheRows)&&String(c._virtRowsCacheSig||'')===feedSig){
@@ -23474,7 +24603,7 @@ function renderChat(reason='snapshot'){
23474
24603
  const prevWinEnd=Number(c._virtLastWinEnd||-1);
23475
24604
  const top=Math.max(0,c.scrollTop);
23476
24605
  const bottom=top+Math.max(0,c.clientHeight||0);
23477
- const win=_chatVirtFindWindow(rows,top,bottom);
24606
+ const win=((reason==='scroll')?_chatVirtReuseWindow(c,rows,top,bottom):null)||_chatVirtFindWindow(rows,top,bottom);
23478
24607
  const totalKey=`${feedSig}|hv=${Number(CHAT_VIRT.heightVersion||0)}|rows=${rows.length}`;
23479
24608
  let totalEstimated=0;
23480
24609
  if(reason==='scroll'&&String(c._virtTotalKey||'')===totalKey){
@@ -23592,19 +24721,19 @@ function renderChat(reason='snapshot'){
23592
24721
  CHAT_VIRT.heightVersion=Number(CHAT_VIRT.heightVersion||0)+1;
23593
24722
  }
23594
24723
  }
23595
- if(reason!=='scroll'){
23596
- const maxTop=Math.max(0,c.scrollHeight-c.clientHeight);
23597
- if(keep){
23598
- c.scrollTop=maxTop;
23599
- }else{
23600
- c.scrollTop=Math.max(0,Math.min(oldScrollTop,maxTop));
23601
- }
24724
+ const maxTop=Math.max(0,c.scrollHeight-c.clientHeight);
24725
+ if(keep){
24726
+ c.scrollTop=maxTop;
24727
+ }else if(!(anchor&&_chatVirtRestoreAnchor(c,anchor))){
24728
+ c.scrollTop=Math.max(0,Math.min(oldScrollTop,maxTop));
23602
24729
  }
23603
24730
  c._chatHasRendered=true;
23604
24731
  c._virtRendering=false;
23605
24732
  c._virtLastRows=rows;
23606
24733
  c._virtLastWinStart=win.start;
23607
24734
  c._virtLastWinEnd=win.end;
24735
+ c._virtLastTopOffset=Number(win.topOffset||0);
24736
+ c._virtLastEndOffset=Number(win.endOffset||0);
23608
24737
  if(hasHeightChange&&reason!=='scroll'){
23609
24738
  if(c._virtMeasureRaf)cancelAnimationFrame(c._virtMeasureRaf);
23610
24739
  c._virtMeasureRaf=requestAnimationFrame(()=>{c._virtMeasureRaf=0;renderChat('measure')});
@@ -23654,8 +24783,14 @@ refreshFileExplorer(false).catch(()=>{});
23654
24783
  const uploads=(S.snap?.uploads||[]).slice(-8).reverse();
23655
24784
  E('uploadList').innerHTML=uploads.map(u=>`<div>${esc(u.filename)} → ${esc(u.workspace_path||'')} (${esc(u.kind||'')}, ${esc(u.size||0)}B)</div>`).join('')||`<div>${esc(t('no_uploads'))}</div>`;
23656
24785
  const sessionZip=S.activeId?('/api/sessions/'+S.activeId+'/export.zip'):'#';
24786
+ const sessionMd=S.activeId?('/api/sessions/'+S.activeId+'/export.md'):'#';
24787
+ const sessionPdf=S.activeId?('/api/sessions/'+S.activeId+'/export.pdf'):'#';
24788
+ const sessionPng=S.activeId?('/api/sessions/'+S.activeId+'/export.png'):'#';
23657
24789
  const dl1=E('downloadSessionBtn');
23658
- if(S.activeId){dl1.classList.remove('disabled');dl1.href=sessionZip}else{dl1.classList.add('disabled');dl1.href='#'}
24790
+ const dlMd=E('exportMdBtn');
24791
+ const dlPdf=E('exportPdfBtn');
24792
+ const dlPng=E('exportPngBtn');
24793
+ if(S.activeId){dl1.href=sessionZip;if(dlMd)dlMd.href=sessionMd;if(dlPdf)dlPdf.href=sessionPdf;if(dlPng)dlPng.href=sessionPng}else{dl1.href='#';if(dlMd)dlMd.href='#';if(dlPdf)dlPdf.href='#';if(dlPng)dlPng.href='#'}
23659
24794
  renderSkillsEntryLink()}
23660
24795
  function _normalizeModelCatalog(cat){const src=(cat&&typeof cat==='object')?cat:{};const options=Array.isArray(src.options)?src.options.map(it=>{const row=(it&&typeof it==='object')?it:{};const sel=String(row.selection||'').trim();const mdl=String(row.model||'').trim();if(!sel&&!mdl)return null;const profileId=String(row.profile_id||'').trim()||'profile';const selection=sel||`${profileId}::${mdl}`;return{...row,selection,label:String(row.label||selection)}}).filter(Boolean):[];const models=Array.isArray(src.models)?src.models.map(x=>String(x||'').trim()).filter(Boolean):[];const selected=String(src.selected||'').trim();const thinking=('thinking'in src)?!!src.thinking:null;return{options,models,selected,thinking}}
23661
24796
  function _modelNameFromSelection(selection){const raw=String(selection||'').trim();if(!raw)return'';if(raw.includes('::')){const parts=raw.split('::',2);return String(parts[1]||parts[0]||'').trim()}return raw}
@@ -23753,7 +24888,7 @@ function _chatVirtDebounceWhileScrolling(chatEl,timerField,fn,delayMs=CHAT_SCROL
23753
24888
  if(!_chatVirtIsUserScrolling(chatEl))done();
23754
24889
  };
23755
24890
  chatEl[scrollEndField]=onScrollEnd;
23756
- try{chatEl.addEventListener('scrollend',onScrollEnd,{passive:true})}catch(_){}
24891
+ chatEl.addEventListener('scrollend',onScrollEnd,{once:true,passive:true});
23757
24892
  }
23758
24893
  }
23759
24894
  async function refreshSnapshot(opt={}){
@@ -23801,23 +24936,21 @@ async function refreshSnapshot(opt={}){
23801
24936
  const chatEl=E('chat');
23802
24937
  const scrolling=_chatVirtIsUserScrolling(chatEl);
23803
24938
  const feedSig=feedSignature(S.snap);
23804
- if(forceFull||feedSig!==S.lastFeedSig){
23805
- S.lastFeedSig=feedSig;
23806
- if(scrolling&&chatEl){
23807
- _chatVirtDebounceWhileScrolling(chatEl,'_virtScrollSyncTimer',()=>renderChat('snapshot'));
23808
- }else{
23809
- if(chatEl)_chatVirtCancelDebounce(chatEl,'_virtScrollSyncTimer');
23810
- renderChat();
23811
- }
23812
- }
23813
24939
  const boardSig=boardsSignature(S.snap);
23814
- if(forceFull||boardSig!==S.lastBoardsSig){
23815
- S.lastBoardsSig=boardSig;
24940
+ const needChat=forceFull||feedSig!==S.lastFeedSig;
24941
+ const needBoards=forceFull||boardSig!==S.lastBoardsSig;
24942
+ if(needChat)S.lastFeedSig=feedSig;
24943
+ if(needBoards)S.lastBoardsSig=boardSig;
24944
+ if(needChat||needBoards){
24945
+ const doRender=()=>{
24946
+ if(needChat)renderChat('snapshot');
24947
+ if(needBoards)renderBoards();
24948
+ };
23816
24949
  if(scrolling&&chatEl){
23817
- _chatVirtDebounceWhileScrolling(chatEl,'_virtBoardsSyncTimer',()=>renderBoards(),CHAT_SCROLL_SYNC_DEBOUNCE_MS+20);
24950
+ _chatVirtDebounceWhileScrolling(chatEl,'_virtScrollSyncTimer',doRender);
23818
24951
  }else{
23819
- if(chatEl)_chatVirtCancelDebounce(chatEl,'_virtBoardsSyncTimer');
23820
- renderBoards();
24952
+ if(chatEl){_chatVirtCancelDebounce(chatEl,'_virtScrollSyncTimer');_chatVirtCancelDebounce(chatEl,'_virtBoardsSyncTimer');}
24953
+ doRender();
23821
24954
  }
23822
24955
  }
23823
24956
  renderActivePreview(false);
@@ -23903,7 +25036,7 @@ async function compactNow(){if(!S.activeId)return;if(S.staticMode&&S.frozen)resu
23903
25036
  async function clearStaleTodos(){if(!S.activeId){showError(t('select_session_first'));return}if(S.staticMode&&S.frozen)resumeAutoUpdates();await api('/api/sessions/'+S.activeId+'/todos/clear-stale',{method:'POST'});S.lastDeltaTs=Date.now();if(!S.es||S.es.readyState===2){scheduleSnapshot({forceFull:false,delayMs:160,allowWhenFrozen:true})}}
23904
25037
  async function refreshAll(forceProbe=false){if(S.staticMode&&S.frozen){S.frozen=false;applyStaticUiClass()}S.config=await api('/api/config');renderLanguageControls();applyMainI18n();S.skills=await api('/api/skills');S.tools=await api('/api/tools');S.providers=await api('/api/skills/providers');S.protocols=await api('/api/skills/protocols');renderSkillsEntryLink();await refreshSessions();const mc=await loadModelCatalog(forceProbe);if(!applyModelCatalog(mc)){renderModelControls()}if(S.activeId)await refreshSnapshot({forceFull:true,allowWhenFrozen:true})}
23905
25038
  function bindClick(id,fn){const el=E(id);if(el)el.onclick=fn}
23906
- window.addEventListener('DOMContentLoaded',async()=>{for(const id of ['chat','sessionList','todos','tasks','activity','commands','diffs','fileExplorer','catalog']){const el=E(id);if(el){if(id==='chat'){continue}if(id==='sessionList'||id==='todos'||id==='tasks'){S.follow[id]=false;const mark=(lockMs=PANEL_SCROLL_ACTIVE_MS)=>{const now=Date.now();el._panelUserScrollTs=now;el._panelUserScrollLockTs=Math.max(Number(el._panelUserScrollLockTs||0),now+Math.max(PANEL_SCROLL_ACTIVE_MS,Number(lockMs)||PANEL_SCROLL_ACTIVE_MS))};el.addEventListener('wheel',()=>mark(PANEL_SCROLL_ACTIVE_MS+260),{passive:true});el.addEventListener('touchstart',()=>mark(PANEL_SCROLL_ACTIVE_MS+520),{passive:true});el.addEventListener('touchmove',()=>mark(PANEL_SCROLL_ACTIVE_MS+520),{passive:true});el.addEventListener('mousedown',()=>mark(PANEL_SCROLL_ACTIVE_MS+180),{passive:true});el.addEventListener('scroll',()=>mark(PANEL_SCROLL_ACTIVE_MS),{passive:true});continue}el.addEventListener('scroll',()=>{S.follow[id]=nearBottom(el)})}}const drop=E('uploadDrop');const fileInput=E('uploadInput');if(drop&&fileInput){drop.onclick=()=>fileInput.click();fileInput.onchange=()=>uploadFiles(fileInput.files).then(()=>{fileInput.value=''}).catch(err=>showError(err.message));for(const evt of ['dragenter','dragover']){drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.add('dragover')})}for(const evt of ['dragleave','dragend']){drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.remove('dragover')})}drop.addEventListener('drop',e=>{e.preventDefault();drop.classList.remove('dragover');uploadFiles(e.dataTransfer?.files||[]).catch(err=>showError(err.message))})}const configInput=E('configInput');if(configInput){configInput.onchange=()=>uploadLlmConfigFile(configInput.files&&configInput.files[0]).then(()=>{configInput.value=''}).catch(err=>showError(err.message||String(err)))}bindClick('newSessionBtn',createSession);bindClick('renameSessionBtn',renameSession);bindClick('deleteSessionBtn',deleteSession);bindClick('applyModelBtn',applyModel);bindClick('importConfigBtn',importDefaultConfig);bindClick('sendBtn',sendMessage);bindClick('interruptBtn',interruptRun);bindClick('compactBtn',compactNow);bindClick('clearStaleTodosBtn',clearStaleTodos);bindClick('refreshFilesBtn',()=>refreshFileExplorer(true));bindClick('refreshBtn',()=>refreshAll(true));bindClick('previewReloadBtn',()=>renderActivePreview(true));bindClick('previewCopyBtn',()=>copyPreviewCode());const langSel=E('langSelect');if(langSel){langSel.onchange=()=>setLanguage(langSel.value).catch(err=>showError(err.message||String(err)))}const promptEl=E('prompt');if(promptEl){promptEl.addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();sendMessage()}})}applyStaticUiClass();applyMainI18n();_bindPreviewCopyGuard();try{await refreshAll(false);if(!S.sessions.length)await createSession()}catch(err){showError(err.message||String(err))}_deltaStartWatchdog();scheduleSessionPoll(false);document.addEventListener('visibilitychange',()=>{const next=document.visibilityState||'visible';if(next===S.lastVisibilityState)return;S.lastVisibilityState=next;if(next==='hidden'){if(S.staticMode)freezeAutoUpdates();return}if(S.staticMode&&S.frozen)resumeAutoUpdates();scheduleSessionPoll(true);scheduleSnapshot({forceFull:false,delayMs:40,allowWhenFrozen:true})})})
25039
+ window.addEventListener('DOMContentLoaded',async()=>{for(const id of ['chat','sessionList','todos','tasks','activity','commands','diffs','fileExplorer','catalog']){const el=E(id);if(el){if(id==='chat'){continue}if(id==='sessionList'||id==='todos'||id==='tasks'){S.follow[id]=false;const mark=(lockMs=PANEL_SCROLL_ACTIVE_MS)=>{const now=Date.now();el._panelUserScrollTs=now;el._panelUserScrollLockTs=Math.max(Number(el._panelUserScrollLockTs||0),now+Math.max(PANEL_SCROLL_ACTIVE_MS,Number(lockMs)||PANEL_SCROLL_ACTIVE_MS))};el.addEventListener('wheel',()=>mark(PANEL_SCROLL_ACTIVE_MS+260),{passive:true});el.addEventListener('touchstart',()=>mark(PANEL_SCROLL_ACTIVE_MS+520),{passive:true});el.addEventListener('touchmove',()=>mark(PANEL_SCROLL_ACTIVE_MS+520),{passive:true});el.addEventListener('mousedown',()=>mark(PANEL_SCROLL_ACTIVE_MS+180),{passive:true});el.addEventListener('scroll',()=>mark(PANEL_SCROLL_ACTIVE_MS),{passive:true});continue}el.addEventListener('scroll',()=>{S.follow[id]=nearBottom(el)})}}const drop=E('uploadDrop');const fileInput=E('uploadInput');if(drop&&fileInput){drop.onclick=()=>fileInput.click();fileInput.onchange=()=>uploadFiles(fileInput.files).then(()=>{fileInput.value=''}).catch(err=>showError(err.message));for(const evt of ['dragenter','dragover']){drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.add('dragover')})}for(const evt of ['dragleave','dragend']){drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.remove('dragover')})}drop.addEventListener('drop',e=>{e.preventDefault();drop.classList.remove('dragover');uploadFiles(e.dataTransfer?.files||[]).catch(err=>showError(err.message))})}const configInput=E('configInput');if(configInput){configInput.onchange=()=>uploadLlmConfigFile(configInput.files&&configInput.files[0]).then(()=>{configInput.value=''}).catch(err=>showError(err.message||String(err)))}bindClick('newSessionBtn',createSession);bindClick('renameSessionBtn',renameSession);bindClick('deleteSessionBtn',deleteSession);bindClick('applyModelBtn',applyModel);bindClick('importConfigBtn',importDefaultConfig);bindClick('sendBtn',sendMessage);bindClick('interruptBtn',interruptRun);bindClick('compactBtn',compactNow);bindClick('clearStaleTodosBtn',clearStaleTodos);bindClick('refreshFilesBtn',()=>refreshFileExplorer(true));bindClick('refreshBtn',()=>refreshAll(true));bindClick('previewReloadBtn',()=>renderActivePreview(true));bindClick('previewCopyBtn',()=>copyPreviewCode());const exportMenuBtn=E('exportMenuBtn');const exportMenu=E('exportMenu');if(exportMenuBtn&&exportMenu){exportMenuBtn.addEventListener('click',e=>{e.stopPropagation();exportMenu.style.display=exportMenu.style.display==='none'?'block':'none'});document.addEventListener('click',()=>{exportMenu.style.display='none'});exportMenu.addEventListener('click',e=>{e.stopPropagation()});for(const a of exportMenu.querySelectorAll('.export-item')){a.addEventListener('click',()=>{exportMenu.style.display='none'})}}const langSel=E('langSelect');if(langSel){langSel.onchange=()=>setLanguage(langSel.value).catch(err=>showError(err.message||String(err)))}const promptEl=E('prompt');if(promptEl){promptEl.addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();sendMessage()}})}applyStaticUiClass();applyMainI18n();_bindPreviewCopyGuard();try{await refreshAll(false);if(!S.sessions.length)await createSession()}catch(err){showError(err.message||String(err))}_deltaStartWatchdog();scheduleSessionPoll(false);document.addEventListener('visibilitychange',()=>{const next=document.visibilityState||'visible';if(next===S.lastVisibilityState)return;S.lastVisibilityState=next;if(next==='hidden'){if(S.deltaWatchdogTimer){clearTimeout(S.deltaWatchdogTimer);S.deltaWatchdogTimer=null}if(S.sessionPollTimer){clearTimeout(S.sessionPollTimer);S.sessionPollTimer=null}if(S.staticMode)freezeAutoUpdates();return}if(S.staticMode&&S.frozen)resumeAutoUpdates();_deltaStartWatchdog();scheduleSessionPoll(true);scheduleSnapshot({forceFull:false,delayMs:40,allowWhenFrozen:true})})})
23907
25040
  """
23908
25041
 
23909
25042
  APP_TS = """type SessionSummary={id:string;title:string;running:boolean;updated_at:number;message_count:number};
@@ -23957,7 +25090,7 @@ SKILLS_INDEX_HTML = """<!doctype html>
23957
25090
  <select id="modelSelect"></select>
23958
25091
  <button id="applyModelBtn" class="subtle">Apply Model</button>
23959
25092
  <button id="refreshBtn" class="subtle">Refresh</button>
23960
- <a id="agentLink" href="#" target="_blank" rel="noreferrer">Open Agent UI</a>
25093
+ <a id="agentLink" href="#">Open Agent UI</a>
23961
25094
  </div>
23962
25095
  </header>
23963
25096
  <div class="status-cards" id="topStats"></div>
@@ -24251,9 +25384,9 @@ function pointToSegmentDistance(px,py,x1,y1,x2,y2){const dx=x2-x1,dy=y2-y1;const
24251
25384
  function findNearestEdgeIndexAt(px,py,threshold=14){const byId={};for(const n of (S.flow.nodes||[])){byId[n.id]=n}const z=getFlowZoom();let bestIdx=-1;let best=Number.POSITIVE_INFINITY;for(let i=0;i<(S.flow.edges||[]).length;i++){const e=S.flow.edges[i];const rp=resolveEdgeSidesAndPoints(e,byId);if(!rp)continue;const x1=(Number(rp.p1.x)||0)*z,y1=(Number(rp.p1.y)||0)*z,x2=(Number(rp.p2.x)||0)*z,y2=(Number(rp.p2.y)||0)*z;const dLine=pointToSegmentDistance(px,py,x1,y1,x2,y2);const dArrow=Math.hypot(px-x2,py-y2);const d=Math.min(dLine,dArrow);if(d<best){best=d;bestIdx=i}}if(best<=Math.max(6,Number(threshold)||14))return bestIdx;return -1}
24252
25385
  function beginLinkDrag(nodeId,side,ev){if(ev){ev.preventDefault();ev.stopPropagation()}const canvas=E('flowCanvas');if(!canvas)return;const rect=canvas.getBoundingClientRect();S.drag=null;S.pan=null;S.selectedNodeId=String(nodeId||'');S.linkDrag={fromId:String(nodeId||''),fromSide:normalizeSide(side)||'right',toX:(ev?ev.clientX:rect.left)-rect.left,toY:(ev?ev.clientY:rect.top)-rect.top};renderNodeEditor();renderFlow()}
24253
25386
  function renderFlow(){const canvas=E('flowCanvas');const svg=E('flowSvg');if(!canvas||!svg)return;const z=getFlowZoom();canvas.innerHTML='';svg.innerHTML='';const defs=document.createElementNS('http://www.w3.org/2000/svg','defs');const marker=document.createElementNS('http://www.w3.org/2000/svg','marker');marker.setAttribute('id','arrow');marker.setAttribute('viewBox','0 0 10 10');marker.setAttribute('refX','10');marker.setAttribute('refY','5');marker.setAttribute('markerWidth','7');marker.setAttribute('markerHeight','7');marker.setAttribute('orient','auto-start-reverse');const path=document.createElementNS('http://www.w3.org/2000/svg','path');path.setAttribute('d','M 0 0 L 10 5 L 0 10 z');path.setAttribute('fill','#7f95b8');marker.appendChild(path);defs.appendChild(marker);svg.appendChild(defs);const byId={};let baseW=1300,baseH=900;for(const n of S.flow.nodes){byId[n.id]=n;baseW=Math.max(baseW,(Number(n.x)||0)+230);baseH=Math.max(baseH,(Number(n.y)||0)+190)}const viewW=Math.max(320,Math.floor(baseW*z));const viewH=Math.max(220,Math.floor(baseH*z));canvas.style.minWidth=`${viewW}px`;canvas.style.minHeight=`${viewH}px`;svg.style.minWidth=`${viewW}px`;svg.style.minHeight=`${viewH}px`;svg.setAttribute('width',String(viewW));svg.setAttribute('height',String(viewH));const returnMap={};for(const e of (S.flow.edges||[])){const a=byId[e.from],b=byId[e.to];if(!a||!b)continue;let fs=edgeFromSide(e);let ts=edgeToSide(e);if(!fs||!ts){const autoSides=inferEdgeSides(a,b);if(!fs)fs=autoSides.from;if(!ts)ts=autoSides.to}const pa=nodePortPoint(a,fs);const pb=nodePortPoint(b,ts);const ca={x:pa.x*z,y:pa.y*z};const cb={x:pb.x*z,y:pb.y*z};const bi=edgeBidirectional(e);const line=document.createElementNS('http://www.w3.org/2000/svg','line');line.setAttribute('x1',String(ca.x));line.setAttribute('y1',String(ca.y));line.setAttribute('x2',String(cb.x));line.setAttribute('y2',String(cb.y));line.setAttribute('stroke','#7f95b8');line.setAttribute('stroke-width','1.8');if(bi)line.setAttribute('marker-start','url(#arrow)');line.setAttribute('marker-end','url(#arrow)');svg.appendChild(line);const lbl=document.createElementNS('http://www.w3.org/2000/svg','text');lbl.setAttribute('x',String((ca.x+cb.x)/2));lbl.setAttribute('y',String((ca.y+cb.y)/2-7));lbl.setAttribute('text-anchor','middle');lbl.setAttribute('class','flow-edge-label');lbl.textContent=edgePathLabel(e);svg.appendChild(lbl);if(bi){const n=edgeReturnN(e);const f=String(e.from||'').trim();const t=String(e.to||'').trim();if(f)returnMap[f]=Math.max(Number(returnMap[f]||0),n);if(t)returnMap[t]=Math.max(Number(returnMap[t]||0),n)}}if(S.linkDrag&&byId[S.linkDrag.fromId]){const src=nodePortPoint(byId[S.linkDrag.fromId],S.linkDrag.fromSide);const line=document.createElementNS('http://www.w3.org/2000/svg','line');line.setAttribute('x1',String(src.x*z));line.setAttribute('y1',String(src.y*z));line.setAttribute('x2',String(Number(S.linkDrag.toX)||src.x*z));line.setAttribute('y2',String(Number(S.linkDrag.toY)||src.y*z));line.setAttribute('class','flow-link-preview');svg.appendChild(line)}for(const n of S.flow.nodes){const d=document.createElement('div');d.className='flow-node'+(n.id===S.selectedNodeId?' active':'');d.style.left=((Number(n.x)||0)*z)+'px';d.style.top=((Number(n.y)||0)*z)+'px';d.style.transform=`scale(${z})`;d.style.transformOrigin='top left';d.innerHTML=`<div class=\"k\">${esc(n.type||'node')}</div><div class=\"t\">${esc(n.title||n.id)}</div><div class=\"c\">${esc(n.content||'')}</div>`;d.onmousedown=(ev)=>{ev.preventDefault();S.linkDrag=null;S.selectedNodeId=n.id;const zz=getFlowZoom();S.drag={id:n.id,dx:ev.clientX-((Number(n.x)||0)*zz),dy:ev.clientY-((Number(n.y)||0)*zz)};renderFlow();renderNodeEditor()};d.onclick=()=>{S.selectedNodeId=n.id;renderFlow();renderNodeEditor()};for(const side of FLOW_SIDES){const p=document.createElement('div');p.className='flow-port side-'+side+((S.linkDrag&&S.linkDrag.fromId===n.id&&S.linkDrag.fromSide===side)?' active':'');p.setAttribute('data-node-id',String(n.id));p.setAttribute('data-side',side);p.onmousedown=(ev)=>beginLinkDrag(n.id,side,ev);d.appendChild(p)}const backN=Number(returnMap[String(n.id)]||0);if(backN>0){const badge=document.createElement('div');badge.className='flow-return-badge';badge.textContent='n='+String(backN);d.appendChild(badge)}canvas.appendChild(d)}renderEdgeSelects();scheduleFlowWrapAdjust();updateFlowZoomUI()}
24254
- let flowWrapRaf=0;
25387
+ let flowWrapRaf=0;let flowWrapDebounce=0;
24255
25388
  function adjustFlowWrapHeight(){const wrap=E('flowWrap');const panel=document.querySelector('.skills-panel-center');const stage=wrap&&wrap.closest?wrap.closest('.flow-stage'):null;if(!wrap||!panel||!stage)return;const mobile=(window.matchMedia&&window.matchMedia('(max-width:1180px)').matches);if(mobile){stage.style.height='320px';stage.style.minHeight='320px';wrap.style.height='100%';return}const kids=Array.from(panel.children||[]);let used=0;for(const el of kids){if(el===stage||el===wrap)continue;const st=getComputedStyle(el);used+=el.offsetHeight+(parseFloat(st.marginTop)||0)+(parseFloat(st.marginBottom)||0)}const ps=getComputedStyle(panel);const gap=(parseFloat(ps.rowGap||ps.gap)||0);if(kids.length>1)used+=gap*(kids.length-1);const vh=Math.max(760,window.innerHeight||900);const maxPx=Math.floor(vh*0.58);let available=Math.floor(panel.clientHeight-used);if(!Number.isFinite(available))available=360;available=Math.max(280,Math.min(maxPx,available));stage.style.height=`${available}px`;stage.style.minHeight='260px';wrap.style.height='100%'}
24256
- function scheduleFlowWrapAdjust(){if(flowWrapRaf)cancelAnimationFrame(flowWrapRaf);flowWrapRaf=requestAnimationFrame(()=>{flowWrapRaf=0;adjustFlowWrapHeight()})}
25389
+ function scheduleFlowWrapAdjust(){if(flowWrapRaf)cancelAnimationFrame(flowWrapRaf);if(flowWrapDebounce)clearTimeout(flowWrapDebounce);flowWrapDebounce=setTimeout(()=>{flowWrapDebounce=0;flowWrapRaf=requestAnimationFrame(()=>{flowWrapRaf=0;adjustFlowWrapHeight()})},60)}
24257
25390
  function switchFlowPanel(mode){const m=(String(mode||'').toLowerCase()==='link')?'link':'node';const nodeBtn=E('flowTabNodeBtn');const linkBtn=E('flowTabLinkBtn');const nodePanel=E('flowPanelNode');const linkPanel=E('flowPanelLink');if(nodeBtn)nodeBtn.classList.toggle('active',m==='node');if(linkBtn)linkBtn.classList.toggle('active',m==='link');if(nodePanel)nodePanel.classList.toggle('active',m==='node');if(linkPanel)linkPanel.classList.toggle('active',m==='link');scheduleFlowWrapAdjust()}
24258
25391
  function renderEdgeSelects(){const ids=S.flow.nodes.map(n=>n.id);const render=id=>{const el=E(id);if(!el)return;const cur=el.value;el.innerHTML=ids.map(x=>`<option value=\"${esc(x)}\">${esc(x)}</option>`).join('');if(cur&&ids.includes(cur))el.value=cur};render('edgeFrom');render('edgeTo')}
24259
25392
  function renderNodeEditor(){const n=S.flow.nodes.find(x=>x.id===S.selectedNodeId)||null;if(!n)return;E('nodeTitle').value=n.title||'';E('nodeType').value=n.type||'process';E('nodeContent').value=n.content||''}
@@ -24320,6 +25453,7 @@ class AppContext:
24320
25453
  arbiter_max_tokens: int = ARBITER_DEFAULT_MAX_TOKENS,
24321
25454
  arbiter_temperature: float = ARBITER_DEFAULT_TEMPERATURE,
24322
25455
  execution_mode: str = EXECUTION_MODE_SYNC,
25456
+ max_output_tokens: int = AGENT_MAX_OUTPUT_TOKENS,
24323
25457
  max_user: int = 0,
24324
25458
  max_user_sessions: int = 0,
24325
25459
  ):
@@ -24364,6 +25498,7 @@ class AppContext:
24364
25498
  self.arbiter_max_tokens = max(24, min(256, int(arbiter_max_tokens or ARBITER_DEFAULT_MAX_TOKENS)))
24365
25499
  self.arbiter_temperature = max(0.0, min(1.0, float(arbiter_temperature if arbiter_temperature is not None else ARBITER_DEFAULT_TEMPERATURE)))
24366
25500
  self.execution_mode = normalize_execution_mode(execution_mode, default=EXECUTION_MODE_SYNC)
25501
+ self.max_output_tokens = max(256, int(max_output_tokens or AGENT_MAX_OUTPUT_TOKENS))
24367
25502
  self.skills_root = skills_root
24368
25503
  ensure_runtime_skills(self.skills_root)
24369
25504
  self.skills_store = SkillStore(self.skills_root)
@@ -24832,6 +25967,7 @@ class AppContext:
24832
25967
  self.arbiter_max_tokens,
24833
25968
  self.arbiter_temperature,
24834
25969
  self.execution_mode,
25970
+ self.max_output_tokens,
24835
25971
  run_finished_callback=self._on_session_run_finished,
24836
25972
  )
24837
25973
  self._session_mgrs[user_id] = mgr
@@ -24913,6 +26049,11 @@ class AppContext:
24913
26049
  active = dict(self.global_profiles.get(self.global_active_profile_id, {}))
24914
26050
  self._sync_global_ollama_defaults(active)
24915
26051
  self.thinking = False
26052
+ cfg_max_output_tokens = cfg.get("max_output_tokens")
26053
+ if cfg_max_output_tokens is not None:
26054
+ self.max_output_tokens = max(256, int(cfg_max_output_tokens))
26055
+ if "single_advance_prompt_enhance" in cfg:
26056
+ self.single_advance_prompt_enhance = bool(cfg["single_advance_prompt_enhance"])
24916
26057
 
24917
26058
  def normalized_profiles() -> tuple[dict[str, dict], str]:
24918
26059
  rows: dict[str, dict] = {}
@@ -26007,6 +27148,33 @@ class Handler(BaseHTTPRequestHandler):
26007
27148
  if not sess:
26008
27149
  return self._send_json({"error": "session not found"}, status=404)
26009
27150
  return self._send_bytes(sess.export_bundle(), "application/zip", f"{sess.id}_session_export.zip")
27151
+ m = re.match(r"^/api/sessions/([^/]+)/export\.md$", path)
27152
+ if m:
27153
+ sess = mgr.get(m.group(1))
27154
+ if not sess:
27155
+ return self._send_json({"error": "session not found"}, status=404)
27156
+ md = sess.export_conversation_md()
27157
+ return self._send_bytes(md.encode("utf-8"), "text/markdown; charset=utf-8", f"{sess.id}_conversation.md")
27158
+ m = re.match(r"^/api/sessions/([^/]+)/export\.pdf$", path)
27159
+ if m:
27160
+ sess = mgr.get(m.group(1))
27161
+ if not sess:
27162
+ return self._send_json({"error": "session not found"}, status=404)
27163
+ try:
27164
+ pdf = sess.export_conversation_pdf()
27165
+ return self._send_bytes(pdf, "application/pdf", f"{sess.id}_conversation.pdf")
27166
+ except Exception as exc:
27167
+ return self._send_json({"error": str(exc)}, status=500)
27168
+ m = re.match(r"^/api/sessions/([^/]+)/export\.png$", path)
27169
+ if m:
27170
+ sess = mgr.get(m.group(1))
27171
+ if not sess:
27172
+ return self._send_json({"error": "session not found"}, status=404)
27173
+ try:
27174
+ img = sess.export_conversation_image()
27175
+ return self._send_bytes(img, "image/png", f"{sess.id}_conversation.png")
27176
+ except Exception as exc:
27177
+ return self._send_json({"error": str(exc)}, status=500)
26010
27178
  return self._send_json({"error": "not found"}, status=404)
26011
27179
 
26012
27180
  def do_POST(self):
@@ -26640,6 +27808,12 @@ def main():
26640
27808
  default="",
26641
27809
  help="Agent execution mode (single|sequential|sync). Empty means read from startup config, then fallback to sync.",
26642
27810
  )
27811
+ parser.add_argument(
27812
+ "--max-output-tokens",
27813
+ default=AGENT_MAX_OUTPUT_TOKENS,
27814
+ type=int,
27815
+ help=f"Max output tokens per agent turn (default: {AGENT_MAX_OUTPUT_TOKENS}). Also configurable via config file key 'max_output_tokens'.",
27816
+ )
26643
27817
  parser.add_argument(
26644
27818
  "--max_user",
26645
27819
  default=None,
@@ -26858,6 +28032,7 @@ def main():
26858
28032
  or ""
26859
28033
  ).strip()
26860
28034
  resolved_execution_mode = normalize_execution_mode(raw_execution_mode, default=EXECUTION_MODE_SYNC)
28035
+ resolved_max_output_tokens = max(256, int(getattr(args, "max_output_tokens", AGENT_MAX_OUTPUT_TOKENS) or AGENT_MAX_OUTPUT_TOKENS))
26861
28036
  if raw_execution_mode:
26862
28037
  normalized_raw = str(raw_execution_mode).strip().lower()
26863
28038
  if normalized_raw != resolved_execution_mode:
@@ -26904,6 +28079,7 @@ def main():
26904
28079
  resolved_arbiter_max_tokens,
26905
28080
  resolved_arbiter_temperature,
26906
28081
  resolved_execution_mode,
28082
+ resolved_max_output_tokens,
26907
28083
  resolved_max_user,
26908
28084
  resolved_max_user_sessions,
26909
28085
  )