From 0d11a946a563a3abb780c5d8c4209481f3b868f8 Mon Sep 17 00:00:00 2001 From: Richard Tang Date: Sun, 19 Apr 2026 17:21:06 -0700 Subject: [PATCH] feat: mark-colony-spawned for a session that created colony --- .../agent_loop/internals/compaction.py | 52 +++- core/framework/server/routes_execution.py | 283 ++++++++++++++++++ core/framework/server/routes_sessions.py | 2 + core/framework/server/session_manager.py | 21 ++ core/frontend/src/api/execution.ts | 24 ++ core/frontend/src/api/types.ts | 5 + core/frontend/src/components/ChatPanel.tsx | 73 ++++- core/frontend/src/pages/queen-dm.tsx | 77 +++++ 8 files changed, 526 insertions(+), 11 deletions(-) diff --git a/core/framework/agent_loop/internals/compaction.py b/core/framework/agent_loop/internals/compaction.py index 2fd723da..561c9249 100644 --- a/core/framework/agent_loop/internals/compaction.py +++ b/core/framework/agent_loop/internals/compaction.py @@ -371,6 +371,7 @@ async def llm_compact( char_limit: int = LLM_COMPACT_CHAR_LIMIT, max_depth: int = LLM_COMPACT_MAX_DEPTH, max_context_tokens: int = 128_000, + preserve_user_messages: bool = False, ) -> str: """Summarise *messages* with LLM, splitting recursively if too large. @@ -378,6 +379,11 @@ async def llm_compact( rejects the call with a context-length error, the messages are split in half and each half is summarised independently. Tool history is appended once at the top-level call (``_depth == 0``). + + When ``preserve_user_messages`` is True, the prompt and system message + are amplified to instruct the LLM to keep every user message verbatim + and in full — used by the manual /compact-and-fork endpoint where the + user wants their voice carried into the new session intact. """ from framework.agent_loop.conversation import extract_tool_call_history from framework.agent_loop.internals.tool_result_handler import is_context_too_large_error @@ -401,6 +407,7 @@ async def llm_compact( char_limit=char_limit, max_depth=max_depth, max_context_tokens=max_context_tokens, + preserve_user_messages=preserve_user_messages, ) else: prompt = build_llm_compaction_prompt( @@ -408,17 +415,30 @@ async def llm_compact( accumulator, formatted, max_context_tokens=max_context_tokens, + preserve_user_messages=preserve_user_messages, ) + if preserve_user_messages: + system_msg = ( + "You are a conversation compactor for an AI agent. " + "Write a detailed summary that allows the agent to " + "continue its work. CRITICAL: reproduce every user " + "message verbatim and in full inside the 'User Messages' " + "section — do not paraphrase, truncate, or merge them. " + "Assistant turns and tool results may be summarised, but " + "user input is sacred." + ) + else: + system_msg = ( + "You are a conversation compactor for an AI agent. " + "Write a detailed summary that allows the agent to " + "continue its work. Preserve user-stated rules, " + "constraints, and account/identity preferences verbatim." + ) summary_budget = max(1024, max_context_tokens // 2) try: response = await ctx.llm.acomplete( messages=[{"role": "user", "content": prompt}], - system=( - "You are a conversation compactor for an AI agent. " - "Write a detailed summary that allows the agent to " - "continue its work. Preserve user-stated rules, " - "constraints, and account/identity preferences verbatim." - ), + system=system_msg, max_tokens=summary_budget, ) summary = response.content @@ -437,6 +457,7 @@ async def llm_compact( char_limit=char_limit, max_depth=max_depth, max_context_tokens=max_context_tokens, + preserve_user_messages=preserve_user_messages, ) else: raise @@ -459,6 +480,7 @@ async def _llm_compact_split( char_limit: int = LLM_COMPACT_CHAR_LIMIT, max_depth: int = LLM_COMPACT_MAX_DEPTH, max_context_tokens: int = 128_000, + preserve_user_messages: bool = False, ) -> str: """Split messages in half and summarise each half independently.""" mid = max(1, len(messages) // 2) @@ -470,6 +492,7 @@ async def _llm_compact_split( char_limit=char_limit, max_depth=max_depth, max_context_tokens=max_context_tokens, + preserve_user_messages=preserve_user_messages, ) s2 = await llm_compact( ctx, @@ -479,6 +502,7 @@ async def _llm_compact_split( char_limit=char_limit, max_depth=max_depth, max_context_tokens=max_context_tokens, + preserve_user_messages=preserve_user_messages, ) return s1 + "\n\n" + s2 @@ -510,6 +534,7 @@ def build_llm_compaction_prompt( formatted_messages: str, *, max_context_tokens: int = 128_000, + preserve_user_messages: bool = False, ) -> str: """Build prompt for LLM compaction targeting 50% of token budget. @@ -539,6 +564,18 @@ def build_llm_compaction_prompt( target_chars = target_tokens * 4 node_ctx = "\n".join(ctx_lines) + user_messages_section = ( + "6. **User Messages** — Reproduce EVERY user message verbatim and " + "in full, in chronological order, each on its own line prefixed " + "with the message index (e.g. \"[U1] ...\"). Do NOT paraphrase, " + "summarise, merge, or omit any user message. Preserve markdown, " + "code fences, whitespace, and punctuation exactly as the user " + "wrote them.\n" + if preserve_user_messages + else "6. **User Messages** — Preserve ALL user-stated rules, constraints, " + "identity preferences, and account details verbatim.\n" + ) + return ( "You are compacting an AI agent's conversation history. " "The agent is still working and needs to continue.\n\n" @@ -559,8 +596,7 @@ def build_llm_compaction_prompt( "resolved. Include root causes so the agent doesn't repeat them.\n" "5. **Problem Solving Efforts** — Approaches tried, dead ends hit, " "and reasoning behind the current strategy.\n" - "6. **User Messages** — Preserve ALL user-stated rules, constraints, " - "identity preferences, and account details verbatim.\n" + f"{user_messages_section}" "7. **Pending Tasks** — Work remaining, outputs still needed, and " "any blockers.\n" "8. **Current Work** — The most recent action taken and the immediate " diff --git a/core/framework/server/routes_execution.py b/core/framework/server/routes_execution.py index bf5614b9..9fc492b7 100644 --- a/core/framework/server/routes_execution.py +++ b/core/framework/server/routes_execution.py @@ -217,6 +217,25 @@ async def handle_chat(request: web.Request) -> web.Response: logger.debug("[handle_chat] Session resolution failed: %s", err) return err + # Sessions that have spawned a colony are locked: the user must compact + + # fork into a fresh session before continuing the conversation. Frontend + # surfaces this as a button instead of the textarea, but enforce server- + # side too so the lock can't be bypassed by a stale tab or scripted call. + if getattr(session, "colony_spawned", False): + return web.json_response( + { + "error": "session_locked", + "reason": "colony_spawned", + "spawned_colony_name": getattr(session, "spawned_colony_name", None), + "message": ( + "This session is locked because a colony has been " + "spawned from it. Compact and start a new session " + "with the same queen to continue." + ), + }, + status=409, + ) + body = await request.json() message = body.get("message", "") display_message = body.get("display_message") @@ -663,6 +682,262 @@ async def handle_cancel_queen(request: web.Request) -> web.Response: return web.json_response({"cancelled": True}) +async def handle_mark_colony_spawned(request: web.Request) -> web.Response: + """POST /api/sessions/{session_id}/mark-colony-spawned -- lock the queen DM. + + Called by the frontend the first time the user clicks the + COLONY_CREATED system message. Persists ``colony_spawned: true`` and + ``spawned_colony_name`` into the queen session's ``meta.json`` so the + lock survives server restart, and caches the same on the live Session + object so subsequent /chat calls in this process can be rejected + immediately without disk I/O. + + Body: ``{"colony_name": "..."}`` + """ + from datetime import datetime as _dt + + session, err = resolve_session(request) + if err: + return err + + body = await request.json() if request.can_read_body else {} + colony_name = (body.get("colony_name") or "").strip() + if not colony_name: + return web.json_response({"error": "colony_name is required"}, status=400) + + queen_dir = getattr(session, "queen_dir", None) + if queen_dir is None: + return web.json_response( + {"error": "queen session directory is not set on this session"}, + status=503, + ) + + meta_path = queen_dir / "meta.json" + meta: dict = {} + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + meta = {} + + meta["colony_spawned"] = True + meta["spawned_colony_name"] = colony_name + meta["spawned_colony_at"] = _dt.now(UTC).isoformat() + + try: + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text(json.dumps(meta), encoding="utf-8") + except OSError as exc: + logger.exception("mark_colony_spawned: failed to persist meta.json") + return web.json_response({"error": f"failed to persist: {exc}"}, status=500) + + session.colony_spawned = True + session.spawned_colony_name = colony_name + + return web.json_response( + { + "session_id": session.id, + "colony_spawned": True, + "spawned_colony_name": colony_name, + } + ) + + +async def handle_compact_and_fork(request: web.Request) -> web.Response: + """POST /api/sessions/{session_id}/compact-and-fork -- compact + new session. + + The locked-by-colony-spawn UI calls this when the user clicks "compact + + start a new session with the same queen". The flow: + + 1. Read the queen's full conversation off disk. + 2. Run the LLM compactor with ``preserve_user_messages=True`` so every + user turn survives verbatim into the summary. + 3. Mint a fresh session ID, copy the old queen-session dir to the new + location, then replace the conversation parts with a single + summary message and reset the cursor. The new session starts with + a clean event log. + 4. Spin up a live session bound to the new dir using the same queen + identity. The OLD session stays alive but locked; the user + navigates to the new session in the response. + """ + import asyncio + import shutil + import time as _time + from datetime import datetime as _dt + + from framework.agent_loop.conversation import Message + from framework.agent_loop.internals.compaction import llm_compact + from framework.agent_loop.types import AgentContext + from framework.server.session_manager import ( + _generate_session_id, + _queen_session_dir, + ) + from framework.storage.conversation_store import FileConversationStore + + session, err = resolve_session(request) + if err: + return err + + queen_dir = getattr(session, "queen_dir", None) + if queen_dir is None or not queen_dir.exists(): + return web.json_response( + {"error": "queen session directory not found"}, + status=404, + ) + + queen_executor = getattr(session, "queen_executor", None) + if queen_executor is None: + return web.json_response({"error": "queen is not running"}, status=503) + queen_node = queen_executor.node_registry.get("queen") if queen_executor else None + queen_ctx: AgentContext | None = getattr(queen_node, "_last_ctx", None) if queen_node else None + if queen_ctx is None or queen_ctx.llm is None: + return web.json_response( + { + "error": ( + "queen context not yet stamped (no LLM available for " + "compaction). Send a message to the queen and retry." + ) + }, + status=503, + ) + + queen_name = session.queen_name or "default" + convs_dir = queen_dir / "conversations" + if not convs_dir.exists(): + return web.json_response( + {"error": "queen has no conversation history yet"}, + status=400, + ) + + src_store = FileConversationStore(convs_dir) + raw_parts = await src_store.read_parts() + messages: list[Message] = [] + for part in raw_parts: + try: + messages.append(Message.from_storage_dict(part)) + except (KeyError, TypeError): + # Skip malformed parts rather than failing the whole compaction; + # the summary will note the gap if needed. + logger.warning("compact_and_fork: skipping malformed part %r", part) + continue + if not messages: + return web.json_response( + {"error": "queen conversation is empty -- nothing to compact"}, + status=400, + ) + + # Run the LLM compactor with preserve_user_messages so the new session + # carries every user turn forward. Failures here are user-visible + # because the whole point of the action is the compacted summary. + try: + max_ctx_tokens = 180_000 + loop_cfg = getattr(queen_node, "_config", None) + if loop_cfg is not None and getattr(loop_cfg, "max_context_tokens", None): + max_ctx_tokens = int(loop_cfg.max_context_tokens) + summary = await llm_compact( + queen_ctx, + messages, + accumulator=None, + max_context_tokens=max_ctx_tokens, + preserve_user_messages=True, + ) + except Exception as exc: + logger.exception("compact_and_fork: llm_compact failed") + return web.json_response( + {"error": f"compaction failed: {exc}"}, + status=500, + ) + + new_session_id = _generate_session_id() + new_dir = _queen_session_dir(new_session_id, queen_name) + if new_dir.exists(): + # Defensively: same-second collision would clobber another session. + return web.json_response( + {"error": f"new session dir collision: {new_dir}"}, + status=500, + ) + + try: + await asyncio.to_thread(shutil.copytree, queen_dir, new_dir) + except OSError as exc: + logger.exception("compact_and_fork: copytree failed") + return web.json_response( + {"error": f"failed to fork session dir: {exc}"}, + status=500, + ) + + # Replace parts/ with the single compacted summary; clear partials and + # the event log so the new session presents a clean SSE replay. + new_parts_dir = new_dir / "conversations" / "parts" + new_partials_dir = new_dir / "conversations" / "partials" + new_events_path = new_dir / "events.jsonl" + + def _cleanup_forked_dir() -> None: + if new_parts_dir.exists(): + shutil.rmtree(new_parts_dir) + if new_partials_dir.exists(): + shutil.rmtree(new_partials_dir) + if new_events_path.exists(): + new_events_path.unlink() + + try: + await asyncio.to_thread(_cleanup_forked_dir) + except OSError: + logger.warning("compact_and_fork: cleanup of forked dir partial-failed", exc_info=True) + + # Write the summary as message seq 0 so the new queen wakes up with a + # single user-role primer that contains everything she needs. + summary_msg = Message(seq=0, role="user", content=summary) + dest_store = FileConversationStore(new_dir / "conversations") + await dest_store.write_part(0, summary_msg.to_storage_dict()) + await dest_store.write_cursor({"next_seq": 1}) + + # Update meta.json: clear the lock and record provenance. + new_meta_path = new_dir / "meta.json" + new_meta: dict = {} + if new_meta_path.exists(): + try: + new_meta = json.loads(new_meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + new_meta = {} + new_meta.pop("colony_spawned", None) + new_meta.pop("spawned_colony_name", None) + new_meta.pop("spawned_colony_at", None) + new_meta["queen_id"] = queen_name + new_meta["compacted_from"] = session.id + new_meta["compacted_at"] = _dt.now(UTC).isoformat() + new_meta["created_at"] = _time.time() + try: + new_meta_path.write_text(json.dumps(new_meta), encoding="utf-8") + except OSError: + logger.warning("compact_and_fork: failed to write new meta.json", exc_info=True) + + manager: Any = request.app["manager"] + try: + new_session = await manager.create_session( + session_id=None, + queen_resume_from=new_session_id, + queen_name=queen_name, + initial_phase="independent", + ) + except Exception as exc: + logger.exception("compact_and_fork: create_session failed for forked id %s", new_session_id) + return web.json_response( + {"error": f"failed to start forked session: {exc}"}, + status=500, + ) + + return web.json_response( + { + "new_session_id": new_session.id, + "queen_id": queen_name, + "compacted_from": session.id, + "summary_chars": len(summary), + "messages_compacted": len(messages), + } + ) + + async def handle_colony_spawn(request: web.Request) -> web.Response: """POST /api/sessions/{session_id}/colony-spawn -- fork queen session into a colony. @@ -1085,3 +1360,11 @@ def register_routes(app: web.Application) -> None: app.router.add_post("/api/sessions/{session_id}/replay", handle_replay) app.router.add_get("/api/sessions/{session_id}/goal-progress", handle_goal_progress) app.router.add_post("/api/sessions/{session_id}/colony-spawn", handle_colony_spawn) + app.router.add_post( + "/api/sessions/{session_id}/mark-colony-spawned", + handle_mark_colony_spawned, + ) + app.router.add_post( + "/api/sessions/{session_id}/compact-and-fork", + handle_compact_and_fork, + ) diff --git a/core/framework/server/routes_sessions.py b/core/framework/server/routes_sessions.py index 466fb2f6..46c09252 100644 --- a/core/framework/server/routes_sessions.py +++ b/core/framework/server/routes_sessions.py @@ -63,6 +63,8 @@ def _session_to_live_dict(session) -> dict: "queen_supports_images": supports_image_tool_results(queen_model) if queen_model else True, "queen_id": getattr(phase_state, "queen_id", None) if phase_state else None, "queen_name": (phase_state.queen_profile or {}).get("name") if phase_state else None, + "colony_spawned": getattr(session, "colony_spawned", False), + "spawned_colony_name": getattr(session, "spawned_colony_name", None), } diff --git a/core/framework/server/session_manager.py b/core/framework/server/session_manager.py index d32fcb43..8e5da53d 100644 --- a/core/framework/server/session_manager.py +++ b/core/framework/server/session_manager.py @@ -111,6 +111,12 @@ class Session: # tool unlocked. The mode is the canonical discriminator for storage # path, tool exposure, and SSE filtering — see the Phase 2 plan. mode: Literal["dm", "colony"] = "dm" + # Set to True after the user clicks the COLONY_CREATED system message + # in this DM. Locks the chat input — the user must compact+fork into a + # fresh session before continuing the conversation. Persisted in + # meta.json so the lock survives server restarts. + colony_spawned: bool = False + spawned_colony_name: str | None = None class SessionManager: @@ -1319,6 +1325,13 @@ class SessionManager: _new_meta["agent_path"] = str(session.worker_path) _existing_meta.update(_new_meta) _meta_path.write_text(json.dumps(_existing_meta), encoding="utf-8") + # Hydrate colony-spawned lock state from meta.json so the lock + # survives server restart / cold-resume into a live session. + if _existing_meta.get("colony_spawned") is True: + session.colony_spawned = True + _spawned_name = _existing_meta.get("spawned_colony_name") + if isinstance(_spawned_name, str): + session.spawned_colony_name = _spawned_name except OSError: pass @@ -1694,6 +1707,8 @@ class SessionManager: # Read extra metadata written at session start agent_name: str | None = None agent_path: str | None = None + colony_spawned: bool = False + spawned_colony_name: str | None = None meta_path = queen_dir / "meta.json" if meta_path.exists(): try: @@ -1701,6 +1716,10 @@ class SessionManager: agent_name = meta.get("agent_name") agent_path = meta.get("agent_path") created_at = meta.get("created_at") or created_at + colony_spawned = bool(meta.get("colony_spawned")) + _spawned = meta.get("spawned_colony_name") + if isinstance(_spawned, str): + spawned_colony_name = _spawned except (json.JSONDecodeError, OSError): pass @@ -1712,6 +1731,8 @@ class SessionManager: "created_at": created_at, "agent_name": agent_name, "agent_path": agent_path, + "colony_spawned": colony_spawned, + "spawned_colony_name": spawned_colony_name, } @staticmethod diff --git a/core/frontend/src/api/execution.ts b/core/frontend/src/api/execution.ts index e4d3d2da..2d08b291 100644 --- a/core/frontend/src/api/execution.ts +++ b/core/frontend/src/api/execution.ts @@ -74,4 +74,28 @@ export const executionApi = { `/sessions/${sessionId}/colony-spawn`, { colony_name: colonyName, task }, ), + + /** Lock a queen DM session because the user opened a spawned colony. + * After this call /chat returns 409 until compactAndFork creates a new session. + */ + markColonySpawned: (sessionId: string, colonyName: string) => + api.post<{ + session_id: string; + colony_spawned: boolean; + spawned_colony_name: string; + }>(`/sessions/${sessionId}/mark-colony-spawned`, { + colony_name: colonyName, + }), + + /** Compact the locked session and fork into a fresh session under the same queen. + * Returns the new session ID; the frontend should navigate the user to it. + */ + compactAndFork: (sessionId: string) => + api.post<{ + new_session_id: string; + queen_id: string; + compacted_from: string; + summary_chars: number; + messages_compacted: number; + }>(`/sessions/${sessionId}/compact-and-fork`), }; diff --git a/core/frontend/src/api/types.ts b/core/frontend/src/api/types.ts index 0739a495..c2a19715 100644 --- a/core/frontend/src/api/types.ts +++ b/core/frontend/src/api/types.ts @@ -20,6 +20,11 @@ export interface LiveSession { queen_id?: string | null; /** Selected queen display name (e.g. "Alexandra") */ queen_name?: string | null; + /** True after the user has clicked into a colony spawned from this DM — + * /chat returns 409 until the user compacts and forks into a new session. */ + colony_spawned?: boolean; + /** Name of the colony that locked this session (set with colony_spawned). */ + spawned_colony_name?: string | null; /** Present in 409 conflict responses when worker is still loading */ loading?: boolean; } diff --git a/core/frontend/src/components/ChatPanel.tsx b/core/frontend/src/components/ChatPanel.tsx index 1ef3f12b..1f2ce2aa 100644 --- a/core/frontend/src/components/ChatPanel.tsx +++ b/core/frontend/src/components/ChatPanel.tsx @@ -120,6 +120,22 @@ interface ChatPanelProps { queenProfileId?: string | null; /** Queen ID — used to display the queen's avatar photo in messages */ queenId?: string; + /** Called when the user clicks a `colony_link` system message. Receives + * the colony name. The parent should call markColonySpawned + flip + * ``colonySpawned`` to lock the input. The Link still navigates. */ + onColonyLinkClick?: (colonyName: string) => void; + /** When true, the composer is replaced with a "compact + new session" + * button — set by the parent after the user opens a spawned colony. */ + colonySpawned?: boolean; + /** Name of the colony that locked this DM (shown on the locked button). */ + spawnedColonyName?: string | null; + /** Display label for the queen on the locked button (e.g. "Charlotte"). */ + queenDisplayName?: string; + /** Called when the user clicks the locked-state button. Should compact + * the current session and navigate to the new one. */ + onCompactAndFork?: () => void; + /** When true, disable the compact-and-fork button (request in flight). */ + compactingAndForking?: boolean; } const queenColor = "hsl(45,95%,58%)"; @@ -482,12 +498,14 @@ const MessageBubble = memo( showQueenPhaseBadge = true, queenProfileId, queenAvatarUrl, + onColonyLinkClick, }: { msg: ChatMessage; queenPhase?: "independent" | "working" | "reviewing"; showQueenPhaseBadge?: boolean; queenProfileId?: string | null; queenAvatarUrl?: string | null; + onColonyLinkClick?: (colonyName: string) => void; }) { const isUser = msg.type === "user"; const isQueen = msg.role === "queen"; @@ -535,7 +553,9 @@ const MessageBubble = memo( if (msg.type === "colony_link") { // Rendered when the queen calls create_colony() and the backend // emits a COLONY_CREATED event. Gives the user a clickable card - // that navigates to the new colony page. + // that navigates to the new colony page. Clicking also locks the + // queen DM (mark-colony-spawned) so the user must compact + fork + // before continuing this conversation. let parsed: { colony_name?: string; is_new?: boolean; @@ -557,6 +577,11 @@ const MessageBubble = memo(
{ + if (colonyName && onColonyLinkClick) { + onColonyLinkClick(colonyName); + } + }} className="inline-flex items-center gap-2 text-xs font-medium text-primary bg-primary/10 hover:bg-primary/20 px-4 py-2 rounded-full border border-primary/20 transition-colors" > 🏛️ @@ -711,6 +736,12 @@ export default function ChatPanel({ initialDraft, queenProfileId, queenId, + onColonyLinkClick, + colonySpawned, + spawnedColonyName, + queenDisplayName, + onCompactAndFork, + compactingAndForking, }: ChatPanelProps) { const [input, setInput] = useState(""); const [pendingImages, setPendingImages] = useState([]); @@ -1151,6 +1182,7 @@ export default function ChatPanel({ showQueenPhaseBadge={showQueenPhaseBadge} queenProfileId={queenProfileId} queenAvatarUrl={queenAvatarUrl} + onColonyLinkClick={onColonyLinkClick} />
); @@ -1308,8 +1340,43 @@ export default function ChatPanel({ ); })()} - {/* Input area — question widget replaces textarea when a question is pending */} - {pendingQuestions && + {/* Input area — colony-spawned lock replaces everything; question widget + replaces textarea when a question is pending */} + {colonySpawned ? ( +
+
+

+ This conversation spawned colony{" "} + {spawnedColonyName ? ( + {spawnedColonyName} + ) : ( + "a colony" + )} + . To keep chatting with{" "} + {queenDisplayName || "this queen"}, compact this session and start + a fresh one. +

+ +
+
+ ) : pendingQuestions && pendingQuestions.length >= 2 && onMultiQuestionSubmit ? ( ( + null, + ); + const [compactingAndForking, setCompactingAndForking] = useState(false); const turnCounterRef = useRef(0); // Maps tool_use_id → the pill message ID and tool name that was created for it. @@ -85,6 +94,9 @@ export default function QueenDM() { setActiveToolCalls({}); setQueenPhase("independent"); setInitialDraft(null); + setColonySpawned(false); + setSpawnedColonyName(null); + setCompactingAndForking(false); turnCounterRef.current = 0; toolUseToPillRef.current = {}; queenIterTextRef.current = {}; @@ -305,6 +317,65 @@ export default function QueenDM() { }; }, [queenId, sessionId]); + // Hydrate the colony-spawned lock from the session detail whenever the + // session ID changes. The /sessions/{id} response carries colony_spawned + // (live) and the cold-info path returns the same field after a server + // restart, so the same fetch covers both states. + useEffect(() => { + if (!sessionId) return; + let cancelled = false; + sessionsApi + .get(sessionId) + .then((data) => { + if (cancelled) return; + const locked = Boolean( + (data as { colony_spawned?: boolean }).colony_spawned, + ); + const name = + (data as { spawned_colony_name?: string | null }) + .spawned_colony_name ?? null; + setColonySpawned(locked); + setSpawnedColonyName(name); + }) + .catch(() => { + // Non-fatal — lock simply won't activate until the user navigates back. + }); + return () => { + cancelled = true; + }; + }, [sessionId]); + + const handleColonyLinkClick = useCallback( + (colonyName: string) => { + if (!sessionId || !colonyName) return; + // Optimistically lock so the textarea swaps to the button before the + // user navigates back. Backend persists the same flag in meta.json so + // a refresh would re-hydrate the locked state anyway. + setColonySpawned(true); + setSpawnedColonyName(colonyName); + executionApi.markColonySpawned(sessionId, colonyName).catch(() => { + // Revert on failure so the user isn't stranded with no composer. + setColonySpawned(false); + setSpawnedColonyName(null); + }); + }, + [sessionId], + ); + + const handleCompactAndFork = useCallback(async () => { + if (!sessionId || compactingAndForking || !queenId) return; + setCompactingAndForking(true); + try { + const result = await executionApi.compactAndFork(sessionId); + // Navigate to the freshly-forked session for the same queen. Replacing + // the URL keeps the back button on the home/history page rather than + // bouncing back to the now-locked DM. + setSearchParams({ session: result.new_session_id }, { replace: true }); + } catch { + setCompactingAndForking(false); + } + }, [sessionId, compactingAndForking, queenId, setSearchParams]); + const handleColonySpawn = useCallback(async () => { if (!sessionId || spawning) return; const colony = cloneColonyName.trim(); @@ -856,6 +927,12 @@ export default function QueenDM() { initialDraft={initialDraft} queenProfileId={queenId ?? null} queenId={queenId} + onColonyLinkClick={handleColonyLinkClick} + colonySpawned={colonySpawned} + spawnedColonyName={spawnedColonyName} + queenDisplayName={queenName} + onCompactAndFork={handleCompactAndFork} + compactingAndForking={compactingAndForking} />