From c735601b0d0346cb8ee01f6ec6b95bfbe86fabbf Mon Sep 17 00:00:00 2001 From: Arsen Muk Date: Thu, 30 Jul 2026 14:38:56 +0200 Subject: [PATCH] Sessions: opt-in star->project-registration hook When sessions.star_project_hook is enabled (default off), a starred 0<->1 transition fires a one-shot internal agent turn in that same session so the agent can promote the session to a project (create its Task card) or archive it. Default off => no behavior change: with the flag off the guard short-circuits before any turn, so starring stays a pure retention pin. Mirrors the opt-in RetentionConfig pattern. - config.py: SessionsConfig.star_project_hook: bool = False (+ from_dict). - sessions.py: gate the hook on the flag; fire engine.run(..., internal=True) on transition. try/except keeps the PATCH working; the internal turn is not stored or shown in the UI. Co-Authored-By: Claude Opus 4.8 --- nerve/config.py | 2 ++ nerve/gateway/routes/sessions.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/nerve/config.py b/nerve/config.py index 5f3db986..df0d0f96 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -707,6 +707,7 @@ class SessionsConfig: memorize_interval_minutes: int = 30 # Background memorization sweep interval sticky_period_minutes: int = 120 # Reuse session if active within this window client_idle_timeout_minutes: int = 60 # Auto-disconnect clients idle longer than this (0 = disabled) + star_project_hook: bool = False # opt-in; fire an internal agent turn on star/unstar transition @classmethod def from_dict(cls, d: dict) -> SessionsConfig: @@ -718,6 +719,7 @@ def from_dict(cls, d: dict) -> SessionsConfig: memorize_interval_minutes=d.get("memorize_interval_minutes", 30), sticky_period_minutes=d.get("sticky_period_minutes", 120), client_idle_timeout_minutes=d.get("client_idle_timeout_minutes", 60), + star_project_hook=bool(d.get("star_project_hook", False)), ) diff --git a/nerve/gateway/routes/sessions.py b/nerve/gateway/routes/sessions.py index bd34ccd7..b156e62d 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -162,7 +162,12 @@ async def get_messages(session_id: str, limit: int = 500, user: dict = Depends(r @router.patch("/api/sessions/{session_id}") async def update_session(session_id: str, req: dict, user: dict = Depends(require_auth)): - """Update session fields (title, starred).""" + """Update session fields (title, starred). + + When ``sessions.star_project_hook`` is enabled (default off), a ``starred`` + 0<->1 transition fires a one-shot internal turn in that same session so the + agent can promote it to a project (create its Task card) or archive it. + """ deps = get_deps() session = await deps.db.get_session(session_id) if not session: @@ -174,8 +179,33 @@ async def update_session(session_id: str, req: dict, user: dict = Depends(requir fields["starred"] = 1 if req["starred"] else 0 if not fields: raise HTTPException(status_code=400, detail="No valid fields to update") + old_starred = int(session.get("starred") or 0) await deps.db.update_session_fields(session_id, fields) updated = await deps.db.get_session(session_id) + # Star = opt-in project registration (sessions.star_project_hook, default + # off). On a starred transition fire a one-shot internal turn. + if (get_config().sessions.star_project_hook and "starred" in fields + and deps.engine and fields["starred"] != old_starred): + promoted = fields["starred"] == 1 + trigger = ( + "[system:star-promote] This session was just STARRED, which registers it as a " + "PROJECT. Per the multi-session protocol: create or refresh this project's Task card " + "(the umbrella for this session and its children), register it in the task-heartbeat " + "registry, then stop. Keep that card updated at the end of every future turn." + if promoted else + "[system:star-unpromote] This session was just UNSTARRED, so it is no longer a " + "project. Archive its project Task (keep the ## Updates audit trail) and deregister it " + "from the task-heartbeat registry, then stop." + ) + try: + asyncio.create_task( + deps.engine.run( + session_id=session_id, user_message=trigger, + source="web", internal=True, + ) + ) + except Exception as e: # a hook failure must never break the PATCH + logger.warning("star-project hook failed for %s: %s", session_id, e) return updated