"""Plan / todo state — pure stdlib, no UI framework dependency. API: extract(text) → [(content, "open"|"done"), …] is_active(agent, messages=None) → plan mode on (stash OR per-session msg ref) resolve_path(agent, messages=None) → live plan.md path (or None) find_path_in_messages(messages) → most recent plan.md path mentioned current_step(messages) → latest `当前步骤:…` snippet (or "") summary(items) → (n_done, n_total) is_complete(items) → all done (or empty) Supported task-line shapes (all matched by `extract`): - [ ] foo ← bullet + open - [x] foo ← bullet + done 1. [✓] foo ← numbered + done 2. [✓ 2026-05-16] foo ← numbered + timestamped done, content after bracket 3. [✓ 已生成: foo] ← numbered + done with description *inside* bracket 4. [D][P] foo ← two marker groups (delegate + parallel), still open 5. [D] foo ← non-standard marker "D" → open (not done) """ from __future__ import annotations import os, re from typing import Any, Optional _DONE_CHARS = set("xX✓✔√☑") # Newline-insert before a bullet stuck to JSON debris (`{"content": "- [ ] …`). _GLUE_RE = re.compile(r"(? str: return _MD_EMPHASIS_RE.sub(lambda m: next(g for g in m.groups() if g is not None), s) def _has_done_glyph(marker: str) -> bool: return any(c in _DONE_CHARS for c in marker) def extract(text: str) -> list[tuple[str, str]]: if not text: return [] norm = text.replace("\\n", "\n") if "\\n" in text else text norm = _GLUE_RE.sub(r"\n\1", norm) found: dict[str, str] = {} for line in norm.splitlines(): head = _BULLET_RE.match(line) if not head: continue rest = line[head.end():] groups: list[str] = [] # Consume any number of consecutive `[...]` groups — covers `[D][P]` # task-type chains as well as the plain `[ ]` / `[x]` single form. while True: b = _BRACKET_RE.match(rest) if not b: break groups.append(b.group(1)) rest = rest[b.end():] if not groups: continue is_done = any(_has_done_glyph(g) for g in groups) inline = rest.strip() if inline: content = inline elif is_done: # `[✓ description]` shape — description lives inside the bracket # next to the glyph. Strip the glyph + optional timestamp. done_g = next(g for g in groups if _has_done_glyph(g)) content = _INLINE_STRIP_RE.sub("", done_g).strip() else: continue k = _strip_md(_DEBRIS_RE.sub("", content).strip()) if not k: continue status = "done" if is_done else "open" # Same content seen twice — done wins over open. if k not in found or status == "done": found[k] = status return list(found.items()) def _stashed_plan_path(agent) -> str: # First non-empty `working['in_plan_mode']` from (handler, agent). for src in (getattr(agent, "handler", None), agent): p = ((getattr(src, "working", None) or {}).get("in_plan_mode") or "").strip() if p: return p return "" def _resolve_stashed(p: str) -> Optional[str]: if not p: return None rel = p.lstrip("./\\") cwd = os.getcwd() for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)): if os.path.isfile(c) and os.path.getsize(c) > 0: return c return None # Strict per-session discovery — scan this session's own messages only. _PATH_RE = re.compile(r"""((?:\.\/)?(?:temp\/)?plan_[A-Za-z0-9_\-]+\/plan\.md)""") def _slice(messages, start_idx: int): if not messages: return [] if start_idx <= 0: return list(messages) return list(messages)[start_idx:] def find_path_in_messages(messages, start_idx: int = 0) -> Optional[str]: """Latest existing `plan_XXX/plan.md` referenced after `start_idx`. Items can be `ChatMessage`-like (`.content`) or plain strings; only paths that exist on disk are returned.""" sliced = _slice(messages, start_idx) if not sliced: return None for m in reversed(sliced): text = getattr(m, "content", None) if text is None: text = m if isinstance(m, str) else "" if not text or "plan.md" not in text: continue for hit in reversed(_PATH_RE.findall(text)): p = _resolve_stashed(hit.strip().strip("\"'")) if p: return p return None # Prefer concise `` narrative over the long plan-item echo; # treat `❌ 当前步骤:` as "step done", not "current step". _SUMMARY_STEP_RE = re.compile( r"[^<]*?当前步骤[::]\s*([^<\n]{1,160})", re.DOTALL) _STEP_RE = re.compile(r"📌\s*当前步骤[::]\s*([^\n。!!??]{1,160})") _DONE_STEP_RE = re.compile(r"❌\s*当前步骤[::]") def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str: """Latest `当前步骤:…` snippet; `` form preferred, `❌`-prefixed skipped. Trimmed to `max_len` chars so it fits the 5-row plan card.""" sliced = _slice(messages, start_idx) if not sliced: return "" def _clean(s: str) -> str: return _strip_md(re.sub(r"\s+", " ", s).strip().rstrip(" ::—-")) def _cap(s: str) -> str: s = _clean(s) if len(s) <= max_len: return s return s[:max_len - 1].rstrip() + "…" for m in reversed(sliced): text = getattr(m, "content", None) if text is None: text = m if isinstance(m, str) else "" if not text or "当前步骤" not in text: continue hits = _SUMMARY_STEP_RE.findall(text) if hits: return _cap(hits[-1]) for raw in reversed(_STEP_RE.findall(text)): if _DONE_STEP_RE.search(raw): continue return _cap(raw) return "" def is_active(agent, messages=None, start_idx: int = 0, restored_path: str = "") -> bool: """Plan mode is on. Primary: `working['in_plan_mode']`. Then `restored_path` — a path recovered from the transcript's structured `enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry); unlike the message scan it cannot be spoofed by a path typed in chat. Legacy fallback: a `plan_*/plan.md` referenced in this session's messages (no global scan) — only consulted when `messages` is passed.""" if _stashed_plan_path(agent): return True if restored_path and _resolve_stashed(restored_path): return True return find_path_in_messages(messages, start_idx) is not None def resolve_path(agent, messages=None, start_idx: int = 0, restored_path: str = "") -> Optional[str]: p = _resolve_stashed(_stashed_plan_path(agent)) if p: return p if restored_path: p = _resolve_stashed(restored_path) if p: return p return find_path_in_messages(messages, start_idx) def summary(items: list[tuple[str, str]]) -> tuple[int, int]: return sum(1 for _, st in items if st == "done"), len(items) def is_complete(items: list[tuple[str, str]]) -> bool: return not items or all(st == "done" for _, st in items) # --- Desktop bridge only (APIs above unchanged) --- _ENTER_PLAN_RE = re.compile(r"""enter_plan_mode\s*\(\s*["']([^"']+)["']""", re.I) def _msg_content(m) -> str: if isinstance(m, str): return m c = m.get("content") if isinstance(m, dict) else getattr(m, "content", None) return c if isinstance(c, str) else "" def plan_path_mention_in_messages(messages, start_idx: int = 0) -> Optional[str]: for m in reversed(_slice(messages, start_idx)): text = _msg_content(m) if not text: continue if "enter_plan_mode" in text and (hit := _ENTER_PLAN_RE.search(text)): return hit.group(1).strip().strip("\"'") if "plan.md" in text and (hits := _PATH_RE.findall(text)): return hits[-1].strip().strip("\"'") return None def _resolve_stashed_at(p: str, root: str) -> Optional[str]: if not p or not root: return None rel = p.lstrip("./\\") cwd = root.rstrip("/\\") for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)): if os.path.isfile(c) and os.path.getsize(c) > 0: return c return None def _find_path_at(messages, start_idx: int, root: str) -> Optional[str]: for m in reversed(_slice(messages, start_idx)): text = _msg_content(m) if not text or "plan.md" not in text: continue for hit in reversed(_PATH_RE.findall(text)): if p := _resolve_stashed_at(hit.strip().strip("\"'"), root): return p return None def default_session_plan_path(session_id: str) -> str: sid = (session_id or "sess").replace("/", "_") return f"temp/plan_{sid}/plan.md" def is_session_scoped_plan_path(path: str, session_id: str) -> bool: """Desktop: only bind/adopt paths under this session's plan_{id}/ tree.""" if not path: return False sid = (session_id or "sess").replace("/", "_") norm = path.replace("\\", "/").lstrip("./") return f"plan_{sid}/" in norm or norm.endswith(f"plan_{sid}/plan.md") def is_plan_preset_prompt(prompt: str) -> bool: p = (prompt or "").lower() return "plan_sop" in p or "plan 模式" in p or "plan mode" in p def bind_plan_session(sess: Any, prompt: str = "", path: Optional[str] = None) -> str: """Bind plan card to this session only (avoids sharing plan_demo/ across sessions).""" rel = (path or "").strip() or default_session_plan_path(getattr(sess, "id", "") or "") sess.plan_path = rel msgs = list(getattr(sess, "messages", []) or []) sess.plan_scan_baseline = len(msgs) return rel def sync_plan_path_from_text(sess: Any, text: str, root: str) -> None: """If agent emits enter_plan_mode(...), keep session bound path in sync.""" if not text: return m = _ENTER_PLAN_RE.search(text) if not m: return raw = m.group(1).strip().strip("\"'") if not raw: return sess.plan_path = raw.lstrip("./") def session_plan_active(sess: Any, agent, messages, start_idx: int, root: str) -> bool: """Desktop: active when this session has a bound plan_path (not global plan_*/ scan).""" bound = (getattr(sess, "plan_path", None) or "").strip() if not bound: return False if _stashed_plan_path(agent): return True if _resolve_stashed_at(bound, root): return True if getattr(sess, "status", "") == "running": return True # Plan preset bound this session — keep placeholder until plan.md appears or path changes if is_session_scoped_plan_path(bound, getattr(sess, "id", "")): return True return False def session_plan_resolve(bound: str, root: str) -> Optional[str]: if not bound: return None return _resolve_stashed_at(bound.strip(), root) def _desktop_resolve_plan_file(sess: Any, bound: str, root: str, agent, messages, base: int) -> Optional[str]: """Desktop read path: bound → agent stash → per-session message scan.""" path = session_plan_resolve(bound, root) if path: return path if agent and (stash := _stashed_plan_path(agent)): if p := _resolve_stashed_at(stash, root): return p return _find_path_at(messages, base, root) def desktop_plan_payload_from_session(sess: Any, ga_root: str = "") -> dict: """Per-session plan card — bound plan_path + tuiapp_v2-style item/placeholder/complete.""" raw = list(getattr(sess, "messages", []) or []) base = max(0, int(getattr(sess, "plan_scan_baseline", 0) or 0)) if base > len(raw): base = len(raw) root = (getattr(sess, "cwd", None) or ga_root or "").strip() agent = getattr(sess, "agent", None) partial = getattr(sess, "partial", None) if isinstance(partial, dict) and isinstance(partial.get("content"), str): sync_plan_path_from_text(sess, partial["content"], root) sid = getattr(sess, "id", "") or "" if not (getattr(sess, "plan_path", None) or "").strip(): mentioned = plan_path_mention_in_messages(raw, base) if mentioned and is_session_scoped_plan_path(mentioned, sid): sess.plan_path = mentioned.lstrip("./") else: return {"active": False} if not session_plan_active(sess, agent, raw, base, root): return {"active": False} bound = getattr(sess, "plan_path", "") or "" path = _desktop_resolve_plan_file(sess, bound, root, agent, raw, base) items = [] if path: try: items = [{"content": c, "status": st} for c, st in extract(open(path, encoding="utf-8", errors="replace").read())] except OSError: pass step_msgs = list(raw[base:]) if isinstance(p := getattr(sess, "partial", None), dict) and isinstance(c := p.get("content"), str) and c: step_msgs.append({"content": c}) step = current_step(step_msgs, start_idx=0) if not items: hp = getattr(sess, "plan_path", "") or _stashed_plan_path(agent) or "" hint = "/".join(hp.replace("\\", "/").rstrip("/").split("/")[-2:]) if hp else "plan.md" return {"active": True, "placeholder": True, "done": 0, "total": 0, "complete": False, "step": step, "pathHint": hint, "items": []} pairs = [(x["content"], x["status"]) for x in items] n_done, n_total = summary(pairs) return {"active": True, "placeholder": False, "done": n_done, "total": n_total, "complete": is_complete(pairs), "step": step, "items": items}