agents: prefix conversation title with "done " after cleanup (#4449)

Completed conversations remain indistinguishable from active ones in the
conversation list after `conversation-done` reclaims their worktree,
branches, and Bazel output bases.

Update `.agents/skills/conversation-done/scripts/cleanup.py` and
`SKILL.md` to prepend `"done "` to the active conversation title (up to
70 characters, without duplicating an existing prefix) via the local
Language Server API once cleanup finishes without errors.
diff --git a/.agents/skills/conversation-done/SKILL.md b/.agents/skills/conversation-done/SKILL.md
index f11c78b..21b6285 100644
--- a/.agents/skills/conversation-done/SKILL.md
+++ b/.agents/skills/conversation-done/SKILL.md
@@ -22,9 +22,13 @@
 3. **Local Git Branch**: The feature branch associated with the worktree.
 4. **Remote Git Branch**: The feature branch pushed to the user's fork
    (e.g., `origin`).
+5. **Conversation Name**: Once cleanup completes without errors, the active
+   conversation title is prefixed with `"done "` (without duplicating an
+   existing `"done "` prefix, capped at 70 characters).
 
-This skill safely shuts down active build processes and reclaims these
-resources without removing the conversation transcript or chat history.
+This skill safely shuts down active build processes, reclaims these external
+resources, and marks the conversation title as `"done "` without removing the
+conversation transcript or chat history.
 
 ## Script Location
 
@@ -103,6 +107,8 @@
 * **Git Worktree**: Removed worktree directory path.
 * **Local Git Branch**: Deleted local branch name.
 * **Remote Git Branch**: Deleted remote branch (`<remote>/<branch>`).
+* **Conversation Name**: Updated conversation title (prefixed with `"done "`
+  after successful cleanup).
 
 If any resources were skipped, left intact via `--leave-*` flags, or not found,
 note that briefly in the summary.
diff --git a/.agents/skills/conversation-done/scripts/cleanup.py b/.agents/skills/conversation-done/scripts/cleanup.py
index 1496b1f..e68f3d8 100755
--- a/.agents/skills/conversation-done/scripts/cleanup.py
+++ b/.agents/skills/conversation-done/scripts/cleanup.py
@@ -32,15 +32,29 @@
 import asyncio
 import dataclasses
 import glob
+import json
 import os
 import shutil
 import signal
 import stat
 import subprocess
 import sys
+import urllib.error
+import urllib.request
 
 PROTECTED_BRANCHES = frozenset({"main", "master", "HEAD"})
 PROTECTED_REMOTES = frozenset({"upstream"})
+# Maximum conversation title length displayed by the UI.
+_CONVERSATION_TITLE_MAX_LENGTH = 70
+_DONE_TITLE_PREFIX = "done "
+# Local loopback Language Server RPC timeout in seconds.
+_LS_RPC_TIMEOUT_SECONDS = 5
+_GET_TRAJECTORIES_METHOD = (
+    "exa.language_server_pb.LanguageServerService/GetAllCascadeTrajectories"
+)
+_UPDATE_ANNOTATIONS_METHOD = (
+    "exa.language_server_pb.LanguageServerService/UpdateConversationAnnotations"
+)
 
 
 @dataclasses.dataclass
@@ -519,16 +533,17 @@
     leave_branch: bool = False,
     leave_remote: bool = False,
     dry_run: bool = False,
-) -> list[str]:
-    """Executes cleanup on a single target and returns deleted resource descriptions."""
+) -> tuple[list[str], bool]:
+    """Executes cleanup on a single target and returns deleted resources and success."""
     log(f"\nCleaning up target resources for: {target.worktree_path or target.branch}")
     deleted: list[str] = []
+    succeeded = True
 
     if target.is_main_worktree:
         log_error(
             "Target is the main repository! Refusing to clean up main repository."
         )
-        return deleted
+        return deleted, False
 
     # 1. Clean up Bazel output bases
     if not leave_bazel_output_base and target.bazel_output_bases:
@@ -538,6 +553,8 @@
             log(f"  - Output base: {ob} ({size_str})")
             if await shutdown_and_remove_bazel_output_base(ob, dry_run=dry_run):
                 deleted.append(f"Bazel output base: {ob} ({size_str})")
+            else:
+                succeeded = False
     elif leave_bazel_output_base:
         log("1. Leaving Bazel output base intact.")
     else:
@@ -548,6 +565,8 @@
         log("2. Cleaning up Git worktree:")
         if await remove_git_worktree(main_repo, target.worktree_path, dry_run=dry_run):
             deleted.append(f"Git worktree: {target.worktree_path}")
+        else:
+            succeeded = False
     elif leave_worktree:
         log("2. Leaving Git worktree directory on disk.")
     else:
@@ -564,6 +583,7 @@
             deleted.append(f"Local branch: {target.branch}")
         else:
             log_warn(f"  {msg}")
+            succeeded = False
     elif leave_branch:
         log("3. Leaving local branch intact.")
     else:
@@ -585,12 +605,89 @@
             deleted.append(f"Remote branch: {target.remote}/{target.branch}")
         else:
             log_warn(f"  {msg}")
+            succeeded = False
     elif leave_remote:
         log("4. Leaving remote branch intact.")
     elif target.branch and target.remote:
         log(f"4. Remote branch '{target.branch}' not present on '{target.remote}'.")
 
-    return deleted
+    return deleted, succeeded
+
+
+def format_done_title(current_title: str) -> str:
+    """Prepends 'done ' to a conversation title and truncates to max length."""
+    cleaned = " ".join(current_title.splitlines()).strip()
+    if not cleaned:
+        return _DONE_TITLE_PREFIX.strip()
+
+    if cleaned.lower().startswith(_DONE_TITLE_PREFIX):
+        prefixed = _DONE_TITLE_PREFIX + cleaned[len(_DONE_TITLE_PREFIX) :].lstrip()
+    else:
+        prefixed = f"{_DONE_TITLE_PREFIX}{cleaned}"
+
+    return prefixed[:_CONVERSATION_TITLE_MAX_LENGTH].rstrip()
+
+
+def prefix_conversation_title(
+    conversation_id: str,
+    ls_address: str,
+    csrf_header_value: str,
+    dry_run: bool,
+) -> None:
+    """Prepends 'done ' to the active conversation title via the Language Server."""
+    headers = {
+        "Content-Type": "application/json",
+        "x-codeium-csrf-token": csrf_header_value,
+    }
+    list_url = f"http://{ls_address}/{_GET_TRAJECTORIES_METHOD}"
+    list_req = urllib.request.Request(
+        list_url,
+        data=b"{}",
+        headers=headers,
+    )
+    try:
+        with urllib.request.urlopen(list_req, timeout=_LS_RPC_TIMEOUT_SECONDS) as resp:
+            payload = json.loads(resp.read().decode("utf-8"))
+    except (urllib.error.URLError, json.JSONDecodeError, OSError) as exc:
+        log_warn(f"Failed to query conversation title: {exc}")
+        return
+
+    summaries = payload.get("trajectorySummaries") or {}
+    entry = summaries.get(conversation_id) or {}
+    annotations = entry.get("annotations") or {}
+    current_title = annotations.get("title") or entry.get("summary") or ""
+    if not current_title:
+        return
+
+    new_title = format_done_title(current_title)
+    if new_title == current_title:
+        return
+
+    if dry_run:
+        log(f"  [Dry Run] Would rename conversation to: {new_title}")
+        return
+
+    update_url = f"http://{ls_address}/{_UPDATE_ANNOTATIONS_METHOD}"
+    update_body = json.dumps(
+        {
+            "cascadeId": conversation_id,
+            "annotations": {"title": new_title},
+            "mergeAnnotations": True,
+        }
+    ).encode("utf-8")
+    update_req = urllib.request.Request(
+        update_url,
+        data=update_body,
+        headers=headers,
+    )
+    try:
+        with urllib.request.urlopen(
+            update_req, timeout=_LS_RPC_TIMEOUT_SECONDS
+        ) as resp:
+            resp.read()
+        log(f"Renamed conversation to: {new_title}")
+    except (urllib.error.URLError, OSError) as exc:
+        log_warn(f"Failed to rename conversation: {exc}")
 
 
 async def list_resources(main_repo: str) -> None:
@@ -767,8 +864,9 @@
             return
 
     deleted_resources: list[str] = []
+    all_succeeded = True
     for target in targets:
-        deleted = await execute_cleanup_target(
+        deleted, succeeded = await execute_cleanup_target(
             main_repo,
             target,
             leave_bazel_output_base=args.leave_bazel_output_base,
@@ -778,6 +876,8 @@
             dry_run=args.dry_run,
         )
         deleted_resources.extend(deleted)
+        if not succeeded:
+            all_succeeded = False
 
     summary_header = (
         "\nResources that would be deleted (dry run):"
@@ -790,6 +890,19 @@
             log(f"  - {resource}")
     else:
         log("  None")
+
+    if all_succeeded:
+        conversation_id = os.environ.get("ANTIGRAVITY_CONVERSATION_ID", "").strip()
+        ls_address = os.environ.get("ANTIGRAVITY_LS_ADDRESS", "").strip()
+        csrf_header_value = os.environ.get("ANTIGRAVITY_CSRF_TOKEN", "").strip()
+        if conversation_id and ls_address and csrf_header_value:
+            prefix_conversation_title(
+                conversation_id=conversation_id,
+                ls_address=ls_address,
+                csrf_header_value=csrf_header_value,
+                dry_run=args.dry_run,
+            )
+
     log("\nCleanup completed.")
 
 
diff --git a/.agents/skills/conversation-done/scripts/cleanup_test.py b/.agents/skills/conversation-done/scripts/cleanup_test.py
old mode 100644
new mode 100755
index 79abfa9..52285ee
--- a/.agents/skills/conversation-done/scripts/cleanup_test.py
+++ b/.agents/skills/conversation-done/scripts/cleanup_test.py
@@ -107,7 +107,9 @@
         with mock.patch("cleanup.remove_git_worktree") as mock_rm_wt, mock.patch(
             "cleanup.log_error"
         ) as mock_log_err:
-            await cleanup.execute_cleanup_target(main_repo, target)
+            deleted, succeeded = await cleanup.execute_cleanup_target(main_repo, target)
+            self.assertEqual(deleted, [])
+            self.assertFalse(succeeded)
             mock_rm_wt.assert_not_called()
             mock_log_err.assert_called_with(
                 "Target is the main repository! Refusing to clean up main repository."
@@ -172,8 +174,11 @@
                 "Deleted remote branch 'feat1' from 'origin'.",
             ),
         ):
-            deleted = await cleanup.execute_cleanup_target("/repo/main", target)
+            deleted, succeeded = await cleanup.execute_cleanup_target(
+                "/repo/main", target
+            )
 
+        self.assertTrue(succeeded)
         self.assertEqual(
             deleted,
             [
@@ -184,6 +189,26 @@
             ],
         )
 
+    def test_format_done_title(self):
+        """Verifies prepending 'done ' without duplication and 70-char truncation."""
+        self.assertEqual(
+            cleanup.format_done_title("Fix flaky upload test"),
+            "done Fix flaky upload test",
+        )
+        self.assertEqual(
+            cleanup.format_done_title("done Fix flaky upload test"),
+            "done Fix flaky upload test",
+        )
+        self.assertEqual(
+            cleanup.format_done_title("DONE   Fix flaky upload test"),
+            "done Fix flaky upload test",
+        )
+        self.assertEqual(cleanup.format_done_title("   "), "done")
+        long_title = "a" * 80
+        formatted = cleanup.format_done_title(long_title)
+        self.assertEqual(len(formatted), 70)
+        self.assertTrue(formatted.startswith("done "))
+
 
 if __name__ == "__main__":
     unittest.main()