core/cli+memory: prompt on session workspace mismatch

When /resume or --session hits a different directory, ask keep (switch
tools root), update binding to current, or abort. Legacy unbound
sessions attach to the current workspace.
This commit is contained in:
2026-07-15 09:33:39 +08:00
parent ede7dcccd6
commit b80ebf783a
8 changed files with 305 additions and 20 deletions
+8 -2
View File
@@ -85,8 +85,14 @@ async def _run_chat(
)
click.secho(f"workspace: {state.workspace}", fg="bright_black")
if session_id is not None:
await state.resume_session(session_id)
click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)")
try:
await state.resume_session(session_id)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
click.echo(
f"resumed session {session_id} ({len(state.agent.messages)} messages) "
f"workspace={state.workspace}"
)
elif new_session:
await state.new_session()
click.echo(f"new session {state.session_id} (workspace={state.workspace})")
+34 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
import click
@@ -10,6 +10,8 @@ from plyngent.tools.process.pty_session import PtyManager
if TYPE_CHECKING:
from collections.abc import Mapping
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
def prompt_continue_limit(reason: str) -> bool:
"""Ask the user whether to raise a limit and continue (TTY)."""
@@ -34,6 +36,37 @@ def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> b
return False
def prompt_workspace_mismatch(
session_id: int,
session_workspace: str,
current_workspace: str,
) -> WorkspaceMismatchChoice:
"""Ask how to handle resuming a session bound to a different directory."""
click.echo()
click.secho(f"[workspace] session {session_id} is bound to a different directory:", fg="yellow")
click.echo(f" session: {session_workspace}")
click.echo(f" current: {current_workspace}")
click.echo(" k = keep session workspace (switch tools root to session path)")
click.echo(" u = update binding to current workspace")
click.echo(" a = abort resume")
with pause_task_cancel_for_prompt():
try:
raw = click.prompt(
"Choice",
type=click.Choice(["k", "u", "a"], case_sensitive=False),
default="k",
show_choices=True,
)
except (click.Abort, KeyboardInterrupt):
return "abort"
key = str(raw).strip().lower()
if key == "u":
return "rebind"
if key == "a":
return "abort"
return "keep"
def install_cli_limit_hooks() -> None:
"""Register interactive continue hooks for process-global tool limits."""
PtyManager.set_limit_continue_hook(prompt_continue_limit)
+5 -2
View File
@@ -30,7 +30,7 @@ Commands:
/history [n] Show last n messages in this session (default 20)
/sessions List sessions for this workspace
/new [name] Start a new session (bound to workspace)
/resume <id> Resume a session by id (must match workspace)
/resume <id> Resume a session by id (prompts if workspace differs)
/provider [name] Show or switch provider
/model [id] Show or switch model
/tools [on|off] Show or toggle tools
@@ -98,7 +98,10 @@ async def _cmd_resume(state: ReplState, arg: str) -> None:
except ValueError as exc:
click.echo(f"error: {exc}")
return
click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)")
click.echo(
f"resumed session {session_id} ({len(state.agent.messages)} messages) "
f"workspace={state.workspace}"
)
def _cmd_provider(state: ReplState, arg: str) -> None:
+37 -15
View File
@@ -88,16 +88,20 @@ class ReplState:
self.agent = self._make_agent()
self.agent.messages = messages
def _set_workspace(self, path: Path) -> None:
"""Update REPL + tool workspace root."""
resolved = path.expanduser().resolve()
if not resolved.is_dir():
msg = f"workspace is not a directory: {resolved}"
raise ValueError(msg)
self.workspace = resolved
_ = set_workspace_root(resolved)
def _apply_session_workspace(self, row: SessionRow) -> None:
"""Bind tools/REPL workspace to the session's directory when set."""
if not row.workspace:
return
path = Path(row.workspace).expanduser().resolve()
if not path.is_dir():
msg = f"session {row.sid} workspace is not a directory: {path}"
raise ValueError(msg)
self.workspace = path
_ = set_workspace_root(path)
self._set_workspace(Path(row.workspace))
async def new_session(self, name: str = "chat") -> None:
session = await self.memory.create_session(name=name, workspace=self.workspace)
@@ -106,18 +110,33 @@ class ReplState:
self.agent.pending_retry_text = None
async def resume_session(self, session_id: int) -> None:
"""Load a session; on workspace mismatch, prompt keep / rebind / abort."""
from plyngent.cli.limits import prompt_workspace_mismatch
row = await self.memory.get_session(session_id)
if row is None:
msg = f"session not found: {session_id}"
raise ValueError(msg)
expected = self._workspace_key()
if row.workspace is not None and row.workspace != expected:
msg = (
f"session {session_id} is bound to workspace {row.workspace!r}, "
f"not current {expected!r} (use matching --workspace or /resume from that dir)"
)
raise ValueError(msg)
self._apply_session_workspace(row)
current = self._workspace_key()
if row.workspace is None:
# Legacy unbound session: attach to the current workspace.
row = await self.memory.update_session_workspace(session_id, self.workspace)
elif row.workspace != current:
choice = prompt_workspace_mismatch(session_id, row.workspace, current)
if choice == "abort":
msg = "resume aborted"
raise ValueError(msg)
if choice == "rebind":
row = await self.memory.update_session_workspace(session_id, self.workspace)
else:
# keep: switch live workspace to the session binding
try:
self._set_workspace(Path(row.workspace))
except ValueError as exc:
msg = f"cannot keep session workspace: {exc}"
raise ValueError(msg) from exc
self.session_id = session_id
self.agent = self._make_agent()
await self.agent.load_history()
@@ -129,5 +148,8 @@ class ReplState:
await self.new_session(name=name)
return "new"
latest = max(sessions, key=lambda s: (s.updated_at, s.sid))
await self.resume_session(latest.sid)
# Same-workspace list: no mismatch prompt expected.
self.session_id = latest.sid
self.agent = self._make_agent()
await self.agent.load_history()
return "resume"
+18
View File
@@ -134,6 +134,24 @@ class MemoryStore:
stmt = stmt.where(Session.workspace == ws)
result = await session.execute(stmt)
return result.scalars().all()
async def update_session_workspace(
self,
sid: int,
workspace: str | Path | None,
) -> Session:
"""Set or clear the workspace binding for a session."""
ws = normalize_workspace(workspace)
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
row.workspace = ws
await session.commit()
await session.refresh(row)
return row
async def append_message(self, sid: int, message: AnyChatMessage) -> Message:
"""Append a chat message to a session with the next sequence number."""
data = msgspec.to_builtins(message)