Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/leapflow/cli/commands/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,20 @@ async def handle_input(text: str) -> None:
_apply_daemon_runtime_metadata({"host_backend": payload["result"]})
_update_status()

# A config mutation changes runtime state the status bar shows. The TUI
# is a separate process, so its cached model/context values only move
# when the daemon reports them back; without this the bar keeps showing
# the model captured at startup even though the change already applies.
if str(payload.get("view")) == "config" and payload.get("changed_keys"):
runtime_update: dict[str, Any] = {}
if payload.get("model"):
runtime_update["llm_model"] = payload["model"]
if payload.get("llm_context_length") is not None:
runtime_update["llm_context_length"] = payload["llm_context_length"]
if runtime_update:
_apply_daemon_runtime_metadata(runtime_update)
_update_status()

if str(payload.get("view")) == "dashboard":
if payload.get("mode") == "open":
await _open_dashboard(payload)
Expand Down
5 changes: 5 additions & 0 deletions src/leapflow/cli/commands/slash_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,12 @@ def _config_mutation_payload(ctx: "Context", service: Any, result: Any) -> dict[
"changed_keys": list(result.changed_keys),
"warnings": list(getattr(result, "warnings", ()) or ()),
"reloaded": reloaded,
# Runtime values the status bar renders. A daemon-mode TUI is a separate
# process and cannot see the reload, so it needs them echoed back here;
# context length travels with the model because switching models usually
# changes it too.
"model": ctx.settings.llm_model,
"llm_context_length": int(getattr(ctx.settings, "llm_context_length", 0) or 0),
}


Expand Down
12 changes: 12 additions & 0 deletions src/leapflow/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,13 @@ async def initialize_critical(self) -> None:
from leapflow.tools.registry_bootstrap import set_memory_manager
set_memory_manager(self.memory)

# ── Config tools: bind this Context so a config write reloads the live
# session, the same way `/config set` does. Without it the write lands on
# disk while the in-process settings keep the old value, and an immediate
# read-back looks like the write failed.
from leapflow.tools.config_tools import set_config_context
set_config_context(self)

# ── Gateway server (late-bound tool wiring) ──
from leapflow.gateway.server import GatewayServer
from leapflow.gateway.router import GatewayRouter
Expand Down Expand Up @@ -1341,6 +1348,11 @@ async def _on_gateway_event_with_bridge(event: object) -> None:
self.gateway_server.discover_manifests()
set_gateway_server(self.gateway_server)
set_gateway_approval_gate(self._approval_orchestrator)
# Config writes are gated too: several writable keys weaken safety
# machinery (guardrail.enabled, confirm.default_level, codegen.sandbox),
# so config_set must not be able to disable its own supervision.
from leapflow.tools.config_tools import set_config_approval_gate
set_config_approval_gate(self._approval_orchestrator)

self._register_gateway_normalizers(settings)

Expand Down
12 changes: 11 additions & 1 deletion src/leapflow/daemon/_service_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,22 @@ def memory_entry_to_dict(entry: MemoryEntry) -> dict[str, Any]:
# ── Engine / context metadata ────────────────────────────────────────

def engine_context_metadata(engine: Any | None, settings: Any) -> dict[str, Any]:
"""Return safe context-budget metadata for daemon status and stream events."""
"""Return safe context-budget metadata for daemon status and stream events.

``llm_model`` rides along because the TUI is a separate process: its status
bar seeds the model name at startup and can only learn about a change from
metadata the daemon sends back. Deriving it here means every status/stream
path reports the model actually in use, including after a mid-turn
``config_set``, instead of relying on a one-off change notification.
"""
context_length = max(0, int(getattr(settings, "llm_context_length", 0) or 0))
metadata: dict[str, Any] = {
"llm_context_length": context_length,
"context_used": 0,
}
model = str(getattr(settings, "llm_model", "") or "")
if model:
metadata["llm_model"] = model
if engine is None:
return metadata
metadata["context_used"] = max(0, int(getattr(engine, "context_token_count", 0) or 0))
Expand Down
4 changes: 4 additions & 0 deletions src/leapflow/daemon/approval_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def install_gate(self, ctx: Any, service: Any) -> None:
from leapflow.security.approval import SessionAwareGate
from leapflow.security.actions import ActionDescriptor
from leapflow.security.orchestrator import ApprovalOrchestrator
from leapflow.tools.config_tools import set_config_approval_gate
from leapflow.tools.gateway_tool import set_gateway_approval_gate
from leapflow.tools.registry_bootstrap import set_file_read_gate, set_file_write_gate
from leapflow.tools.shell_tools import set_approval_gate
Expand All @@ -44,6 +45,9 @@ def install_gate(self, ctx: Any, service: Any) -> None:
ctx._approval_orchestrator = orchestrator
set_approval_gate(orchestrator)
set_gateway_approval_gate(orchestrator)
# Config writes go through the same daemon-side approval path, so a
# daemon session cannot change settings unattended either.
set_config_approval_gate(orchestrator)

class _FileReadGate:
def __init__(self) -> None:
Expand Down
17 changes: 14 additions & 3 deletions src/leapflow/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,11 +511,22 @@ def workspace_config_path(self, workspace_root: Path) -> Path:
def workspace_manifest_path(self, workspace_root: Path) -> Path:
return workspace_root / ".leapflow" / "workspace.yaml"

def write_workspace_manifest(self, workspace_root: Path) -> Path:
"""Write the workspace-local manifest and return its path."""
def write_workspace_manifest(self, workspace_root: Path) -> Path | None:
"""Write the workspace-local manifest; return its path, or ``None`` if skipped.

Skipped when the workspace's ``.leapflow`` directory *is* the LeapFlow
home (i.e. the workspace root is the home's parent, typically ``$HOME``).
Writing there would drop a workspace marker into the global home
alongside ``config/``, ``profiles/`` and ``secrets/`` — the two
directories collapse onto one path and the layout stops being readable.
The profile-side manifest (addressed by ``workspace_id``) is written
unconditionally by the caller, so nothing is lost by skipping this copy.
"""
workspace_root = workspace_root.expanduser().resolve()
workspace_id = workspace_id_for_path(workspace_root)
path = self.workspace_manifest_path(workspace_root)
if path.parent.resolve() == self.root.resolve():
return None
workspace_id = workspace_id_for_path(workspace_root)
now = datetime.now(timezone.utc).isoformat()
existing: dict[str, Any] = {}
if path.exists():
Expand Down
Loading
Loading