bareagent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
bareagent/main.py ADDED
@@ -0,0 +1,4205 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import atexit
5
+ import json
6
+ import logging
7
+ import os
8
+ import signal
9
+ import sys
10
+ import threading
11
+ import time
12
+ import tomllib
13
+ from collections.abc import Callable
14
+ from collections.abc import Set as AbstractSet
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from dataclasses import asdict, dataclass, field, replace
17
+ from datetime import datetime
18
+ from functools import partial
19
+ from pathlib import Path
20
+ from types import SimpleNamespace
21
+ from typing import Any, Literal, cast
22
+
23
+ from bareagent.concurrency.background import BackgroundManager
24
+ from bareagent.concurrency.scheduler import Scheduler, SchedulerError
25
+ from bareagent.core.config_paths import DEFAULT_CONFIG_PATH, local_config_path
26
+ from bareagent.core.context import PLAN_MODE_DIRECTIVE, assemble_system_prompt
27
+ from bareagent.core.fileutil import (
28
+ atomic_write_text,
29
+ generate_random_id,
30
+ is_tool_result_message,
31
+ )
32
+ from bareagent.core.fileutil import (
33
+ optional_string as _coerce_optional_string,
34
+ )
35
+ from bareagent.core.goal import (
36
+ DEFAULT_MAX_TURNS,
37
+ GoalOutcome,
38
+ GoalState,
39
+ Verdict,
40
+ build_evaluator_prompt,
41
+ parse_goal_command,
42
+ run_goal_loop,
43
+ )
44
+ from bareagent.core.handlers.bash import run_bash
45
+ from bareagent.core.handlers.goal import GOAL_VERDICT_TOOL_SCHEMA, run_goal_verdict
46
+ from bareagent.core.handlers.plan import (
47
+ EXIT_PLAN_MODE_TOOL_SCHEMA,
48
+ PlanDecision,
49
+ run_exit_plan_mode,
50
+ )
51
+ from bareagent.core.handlers.skill import SKILL_CREATE_TOOL_SCHEMA, run_skill_create
52
+ from bareagent.core.handlers.subagent_send import SUBAGENT_SEND_TOOL_SCHEMA, run_subagent_send
53
+ from bareagent.core.handlers.workflow import (
54
+ WORKFLOW_TOOL_SCHEMA,
55
+ run_workflow_tool,
56
+ validate_workflow_input,
57
+ )
58
+ from bareagent.core.loop import LLMCallError, agent_loop
59
+ from bareagent.core.retry import RetryPolicy
60
+ from bareagent.core.tools import get_handlers, get_tools
61
+ from bareagent.core.workflow import (
62
+ DEFAULT_MAX_CONCURRENCY,
63
+ DEFAULT_MAX_NODES,
64
+ NodeResult,
65
+ NodeStatus,
66
+ WorkflowNode,
67
+ build_node_prompt,
68
+ )
69
+ from bareagent.core.workflow_registry import (
70
+ DEFAULT_MAX_RUNS,
71
+ RunStatus,
72
+ WorkflowRegistry,
73
+ WorkflowRun,
74
+ )
75
+ from bareagent.debug.interaction_log import InteractionLogger
76
+ from bareagent.hooks import (
77
+ HookConfigError,
78
+ HookEngine,
79
+ HooksConfig,
80
+ parse_hooks_config,
81
+ )
82
+ from bareagent.lsp import (
83
+ LanguageServerManager,
84
+ LSPConfig,
85
+ LSPError,
86
+ parse_lsp_config,
87
+ )
88
+ from bareagent.mcp import MCPCallError, MCPConfig, MCPError, MCPManager, parse_mcp_config
89
+ from bareagent.mcp.registry import _flatten_content as _mcp_flatten_content
90
+ from bareagent.memory.compact import Compactor
91
+ from bareagent.memory.conversation_io import parse_import, render_markdown, to_export_json
92
+ from bareagent.memory.embedding import build_embedder
93
+ from bareagent.memory.persistent import (
94
+ MemoryManager,
95
+ build_forget_instruction,
96
+ build_remember_instruction,
97
+ resolve_memory_root,
98
+ )
99
+ from bareagent.memory.token_tracker import TokenTracker
100
+ from bareagent.memory.transcript import TranscriptManager
101
+ from bareagent.permission.guard import (
102
+ PermissionGuard,
103
+ PermissionMode,
104
+ permission_rule_subject,
105
+ )
106
+ from bareagent.permission.rules import parse_permission_rules
107
+ from bareagent.planning.agent_types import BUILTIN_AGENT_TYPES, DEFAULT_AGENT_TYPE
108
+ from bareagent.planning.skill_gen import SkillGenConfig, SkillGenerator, render_reflection_prompt
109
+ from bareagent.planning.skill_store import (
110
+ SkillStore,
111
+ SkillStoreError,
112
+ resolve_generated_skills_root,
113
+ )
114
+ from bareagent.planning.skills import LOAD_SKILL_TOOL_SCHEMAS, SkillLoader, resolve_skills_dir
115
+ from bareagent.planning.subagent import run_subagent
116
+ from bareagent.planning.subagent_registry import ResumableContext, SubagentRegistry
117
+ from bareagent.planning.tasks import TaskManager
118
+ from bareagent.planning.todo import TodoManager
119
+ from bareagent.provider.base import (
120
+ VALID_CACHE_TTLS,
121
+ VALID_THINKING_MODES,
122
+ BaseLLMProvider,
123
+ CacheConfig,
124
+ ThinkingConfig,
125
+ )
126
+ from bareagent.provider.factory import _resolve_api_key, create_provider
127
+ from bareagent.provider.setup import run_setup_wizard
128
+ from bareagent.team.autonomous import AutonomousAgent
129
+ from bareagent.team.mailbox import Message, MessageBus
130
+ from bareagent.team.manager import TeammateManager
131
+ from bareagent.team.protocols import Protocol, ProtocolFSM, decode_protocol_content
132
+ from bareagent.ui.console import AgentConsole
133
+
134
+ _log = logging.getLogger(__name__)
135
+
136
+ VALID_PERMISSION_MODES = {m.value for m in PermissionMode}
137
+ VALID_SUBAGENT_TYPES = set(BUILTIN_AGENT_TYPES)
138
+ MAIN_AGENT_NAME = "main"
139
+ DEFAULT_API_KEY_ENV_BY_PROVIDER = {
140
+ "anthropic": "ANTHROPIC_API_KEY",
141
+ "openai": "OPENAI_API_KEY",
142
+ "deepseek": "DEEPSEEK_API_KEY",
143
+ }
144
+ _SESSION_ID_TIMESTAMP_FORMAT = "%Y%m%d-%H%M%S-%f"
145
+
146
+
147
+ @dataclass(slots=True)
148
+ class ProviderConfig:
149
+ name: str
150
+ model: str
151
+ api_key_env: str
152
+ api_key: str | None = None
153
+ base_url: str | None = None
154
+ wire_api: str | None = None
155
+
156
+
157
+ @dataclass(slots=True)
158
+ class PermissionConfig:
159
+ mode: str
160
+ allow: list[str]
161
+ deny: list[str]
162
+
163
+
164
+ @dataclass(slots=True)
165
+ class UIConfig:
166
+ stream: bool
167
+ theme: str
168
+
169
+
170
+ @dataclass(slots=True)
171
+ class SubagentConfig:
172
+ max_depth: int
173
+ default_type: str
174
+ # Soft cap on resumable foreground subagent contexts held in the
175
+ # session-scoped registry; registering past it evicts the oldest. Config-only
176
+ # (no env override), restart-required.
177
+ max_resumable: int = 20
178
+
179
+
180
+ @dataclass(slots=True)
181
+ class DebugConfig:
182
+ enabled: bool = False
183
+ log_dir: str = ".logs"
184
+ viewer_port: int = 8321
185
+ pretty: bool = True
186
+
187
+
188
+ @dataclass(slots=True)
189
+ class TracingConfig:
190
+ langfuse: bool = False
191
+ opentelemetry: bool = False
192
+ content_enabled: bool = True
193
+
194
+
195
+ @dataclass(slots=True)
196
+ class MemoryConfig:
197
+ enabled: bool = True
198
+ # Memory root. Empty -> per-project default under ~/.bareagent/projects/.
199
+ dir: str = ""
200
+ # Max lines of MEMORY.md injected into the system prompt at session start.
201
+ max_index_lines: int = 200
202
+ # Number of relevant memories recalled and injected each turn
203
+ # (0 = disable recall, keeping only the session-start index injection).
204
+ recall_k: int = 5
205
+ # Semantic recall (task 06-08): off by default keeps the lexical behavior
206
+ # byte-identical. When on, recall ranks by embedding cosine similarity and
207
+ # fails open to lexical when the backend is unavailable. ``embedding_backend``
208
+ # is ``openai`` (reuses the openai client / a configurable embeddings
209
+ # endpoint) or ``local`` (fastembed, the ``[embeddings]`` extra). An empty
210
+ # model resolves to the backend default. The openai base_url / api_key fall
211
+ # back to the session provider's when left empty. All restart-required.
212
+ semantic_recall: bool = False
213
+ embedding_backend: str = "openai"
214
+ embedding_model: str = ""
215
+ embedding_base_url: str = ""
216
+ embedding_api_key: str = ""
217
+
218
+
219
+ @dataclass(slots=True)
220
+ class CostConfig:
221
+ # Per-model price overrides keyed by model id. Each entry is a
222
+ # ``{"input": <usd-per-million>, "output": <usd-per-million>}`` dict that
223
+ # overrides/extends the built-in Claude default prices in token_tracker.py.
224
+ prices: dict[str, dict[str, float]] = field(default_factory=dict)
225
+
226
+
227
+ @dataclass(slots=True)
228
+ class RetryConfig:
229
+ # Mirrors src/core/retry.py:RetryPolicy (same field names + defaults). The
230
+ # app layer owns LLM retries exclusively (SDK clients use max_retries=0).
231
+ enabled: bool = True
232
+ max_attempts: int = 3 # total attempts (incl. first), <=1 disables retries
233
+ base_delay_sec: float = 1.0
234
+ max_delay_sec: float = 30.0
235
+ multiplier: float = 2.0
236
+ jitter: bool = True
237
+
238
+
239
+ @dataclass(slots=True)
240
+ class SkillsConfig:
241
+ # Experiential skill generation (task 06-01-experiential-skill-gen): after a
242
+ # complex multi-turn task the agent auto-drafts a reusable skill into a
243
+ # pending area for the user to promote with /skill keep.
244
+ auto_generate: bool = True
245
+ # Double-AND trigger thresholds (cumulative since session start / last draft).
246
+ min_tool_calls: int = 5
247
+ min_user_replies: int = 3
248
+ # Soft cap on pending drafts (oldest pruned beyond this; <=0 disables).
249
+ max_pending: int = 10
250
+ # Generated-skills root override. Empty -> per-project default under
251
+ # ~/.bareagent/projects/<slug>/skills/.
252
+ dir: str = ""
253
+
254
+
255
+ @dataclass(slots=True)
256
+ class GoalConfig:
257
+ # Goal completion loop (task 06-06-goal-completion-loop): /goal <condition>
258
+ # drives turns until an isolated evaluator judges the condition met.
259
+ # Turn-budget safety valve; an inline `--max-turns N` overrides per invocation.
260
+ max_turns: int = DEFAULT_MAX_TURNS
261
+ # Optional cheaper model for the per-turn evaluator. Empty -> reuse the
262
+ # session provider/model (no extra client, works for any provider).
263
+ evaluator_model: str = ""
264
+
265
+
266
+ @dataclass(slots=True)
267
+ class WorkflowConfig:
268
+ # Deterministic workflow orchestration (task
269
+ # 06-06-workflow-deterministic-orchestration): the LLM authors a static DAG of
270
+ # subagent nodes via the main-loop-only ``workflow`` tool; independent nodes
271
+ # run concurrently. ``enabled=false`` short-circuits the whole feature (the
272
+ # tool is never installed). Honors ``BAREAGENT_WORKFLOW_ENABLED``.
273
+ enabled: bool = True
274
+ # Max nodes that may run concurrently (each node is a full subagent).
275
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY
276
+ # Ceiling on declared nodes per workflow; guards the thread pool against an
277
+ # oversized DAG.
278
+ max_nodes: int = DEFAULT_MAX_NODES
279
+ # Default token ceiling per run when the ``workflow`` call omits
280
+ # ``token_budget``; 0 = unlimited. The per-call field overrides this. Honors
281
+ # ``BAREAGENT_WORKFLOW_DEFAULT_TOKEN_BUDGET``.
282
+ default_token_budget: int = 0
283
+ # FIFO cap on retained run records (panel history + resume source); evicting
284
+ # the oldest beyond this. Honors ``BAREAGENT_WORKFLOW_MAX_RUNS``.
285
+ max_runs: int = DEFAULT_MAX_RUNS
286
+
287
+
288
+ @dataclass(slots=True)
289
+ class TeamConfig:
290
+ # Multi-agent teammate coordination (task 06-06-team-subsystem-completion).
291
+ # ``poll_interval`` is how long an idle teammate daemon waits between
292
+ # task-scan wakeups (it also wakes immediately on incoming mail via the
293
+ # mailbox condition variable). ``response_timeout`` is how long a blocking
294
+ # ``team_send`` waits for a teammate's reply before returning a timeout note.
295
+ # ``memory_enabled`` (task 06-08-team-stateful-memory) makes a teammate carry
296
+ # conversational memory across *requests* (a per-teammate Compactor is injected
297
+ # to bound growth); off restores the old per-request stateless behavior.
298
+ # All three are baked into spawned teammates / send calls at boot ->
299
+ # restart-required.
300
+ poll_interval: float = 1.0
301
+ response_timeout: float = 60.0
302
+ memory_enabled: bool = True
303
+
304
+
305
+ @dataclass(slots=True)
306
+ class Config:
307
+ provider: ProviderConfig
308
+ permission: PermissionConfig
309
+ ui: UIConfig
310
+ subagent: SubagentConfig
311
+ thinking: ThinkingConfig
312
+ debug: DebugConfig
313
+ tracing: TracingConfig
314
+ path: Path
315
+ mcp: MCPConfig
316
+ lsp: LSPConfig
317
+ # Defaulted so existing Config(...) constructions (tests, fixtures) keep
318
+ # working without passing memory explicitly.
319
+ memory: MemoryConfig = field(default_factory=MemoryConfig)
320
+ cost: CostConfig = field(default_factory=CostConfig)
321
+ hooks: HooksConfig = field(default_factory=HooksConfig)
322
+ retry: RetryConfig = field(default_factory=RetryConfig)
323
+ # Prompt caching (Anthropic explicit cache_control breakpoints). Defined in
324
+ # provider.base so the factory/provider share one type, mirroring thinking.
325
+ cache: CacheConfig = field(default_factory=CacheConfig)
326
+ skills: SkillsConfig = field(default_factory=SkillsConfig)
327
+ goal: GoalConfig = field(default_factory=GoalConfig)
328
+ workflow: WorkflowConfig = field(default_factory=WorkflowConfig)
329
+ team: TeamConfig = field(default_factory=TeamConfig)
330
+
331
+
332
+ # Dotted config paths that ``/reload`` can hot-apply to live runtime objects.
333
+ # Anything else that changes on disk is reported as "requires restart" because
334
+ # it was baked into a manager/client/provider at boot (see CLAUDE.md ROADMAP 4.3).
335
+ _HOT_RELOAD_PATHS = frozenset(
336
+ {
337
+ "ui.theme",
338
+ "permission.mode",
339
+ "permission.allow",
340
+ "permission.deny",
341
+ }
342
+ )
343
+
344
+
345
+ @dataclass(slots=True)
346
+ class ConfigChange:
347
+ """A single changed config leaf, identified by its dotted path."""
348
+
349
+ path: str # dotted, e.g. "ui.theme"
350
+ old: Any
351
+ new: Any
352
+
353
+
354
+ @dataclass(slots=True)
355
+ class ReloadReport:
356
+ """Classification of a config diff into hot (applied) vs restart-required."""
357
+
358
+ hot: list[ConfigChange] # hot-reloadable and will be applied
359
+ restart: list[ConfigChange] # changed but only reported (needs restart)
360
+
361
+ @property
362
+ def changed(self) -> bool:
363
+ return bool(self.hot or self.restart)
364
+
365
+
366
+ def _flatten_config(data: dict[str, Any]) -> dict[str, Any]:
367
+ """Flatten a top-level ``asdict(Config)`` mapping into dotted-path leaves.
368
+
369
+ Each top-level Config field (provider/permission/ui/...) is a nested
370
+ dataclass that ``asdict`` rendered as a dict, so we descend exactly one
371
+ level to produce ``section.field`` leaves. Whatever sits at the second level
372
+ (scalar, ``list`` like ``permission.allow``, or ``dict`` like ``cost.prices``)
373
+ is a single leaf compared wholesale — order changes in a list count as a
374
+ change. ``path`` is a scalar and stays a top-level leaf.
375
+ """
376
+ leaves: dict[str, Any] = {}
377
+ for key, value in data.items():
378
+ if isinstance(value, dict):
379
+ for sub_key, sub_value in value.items():
380
+ leaves[f"{key}.{sub_key}"] = sub_value
381
+ else:
382
+ leaves[key] = value
383
+ return leaves
384
+
385
+
386
+ def _diff_config_for_reload(old: Config, new: Config) -> ReloadReport:
387
+ """Diff two configs and classify each changed leaf as hot vs restart.
388
+
389
+ Pure function (no side effects) so it can be unit tested. The ``path`` field
390
+ (a resolved filesystem path, not a config knob) is skipped entirely.
391
+ """
392
+ old_flat = _flatten_config(asdict(old))
393
+ new_flat = _flatten_config(asdict(new))
394
+
395
+ hot: list[ConfigChange] = []
396
+ restart: list[ConfigChange] = []
397
+ for dotted in sorted(set(old_flat) | set(new_flat)):
398
+ if dotted == "path":
399
+ continue
400
+ old_value = old_flat.get(dotted)
401
+ new_value = new_flat.get(dotted)
402
+ if old_value == new_value:
403
+ continue
404
+ change = ConfigChange(path=dotted, old=old_value, new=new_value)
405
+ if dotted in _HOT_RELOAD_PATHS:
406
+ hot.append(change)
407
+ else:
408
+ restart.append(change)
409
+ return ReloadReport(hot=hot, restart=restart)
410
+
411
+
412
+ def _config_mtimes(config: Config) -> dict[str, float]:
413
+ """Best-effort mtimes of config.toml + its .local sibling.
414
+
415
+ Missing files are skipped (so creating/deleting the local override is itself
416
+ a detectable change). Used by the passive on-prompt change detector.
417
+ """
418
+ main_path = config.path
419
+ local_path = local_config_path(main_path)
420
+ mtimes: dict[str, float] = {}
421
+ for path in (main_path, local_path):
422
+ try:
423
+ mtimes[str(path)] = os.stat(path).st_mtime
424
+ except OSError:
425
+ continue
426
+ return mtimes
427
+
428
+
429
+ def _add_common_arguments(parser: argparse.ArgumentParser) -> None:
430
+ parser.add_argument("--provider", help="Override the configured provider name.")
431
+ parser.add_argument("--model", help="Override the configured model name.")
432
+ parser.add_argument(
433
+ "--config",
434
+ type=Path,
435
+ help=(
436
+ "Path to the TOML config file. Defaults to BAREAGENT_CONFIG or the bundled config.toml."
437
+ ),
438
+ )
439
+
440
+
441
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
442
+ parser = argparse.ArgumentParser(prog="bareagent")
443
+ # Top-level flags stay usable with no subcommand so the existing
444
+ # ``bareagent --provider ... --model ...`` REPL invocation is unchanged.
445
+ _add_common_arguments(parser)
446
+ subparsers = parser.add_subparsers(dest="command")
447
+ init_parser = subparsers.add_parser(
448
+ "init",
449
+ help="Interactively configure a provider and write config.local.toml.",
450
+ )
451
+ # Allow ``bareagent init --config <path>`` to target a specific config file.
452
+ _add_common_arguments(init_parser)
453
+ return parser.parse_args(argv)
454
+
455
+
456
+ def _deep_merge(base: dict, override: dict) -> dict:
457
+ """Recursively merge *override* into *base* (returns a new dict)."""
458
+ merged = base.copy()
459
+ for key, value in override.items():
460
+ if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
461
+ merged[key] = _deep_merge(merged[key], value)
462
+ else:
463
+ merged[key] = value
464
+ return merged
465
+
466
+
467
+ def _read_config_file(config_path: Path) -> dict:
468
+ with config_path.open("rb") as file:
469
+ base = tomllib.load(file)
470
+ local_path = local_config_path(config_path)
471
+ if local_path.is_file():
472
+ with local_path.open("rb") as file:
473
+ local = tomllib.load(file)
474
+ return _deep_merge(base, local)
475
+ return base
476
+
477
+
478
+ def _resolve_string(
479
+ file_value: str,
480
+ env_name: str,
481
+ cli_value: str | None = None,
482
+ ) -> str:
483
+ if cli_value is not None:
484
+ return cli_value
485
+ return os.getenv(env_name, file_value)
486
+
487
+
488
+ def _resolve_bool(file_value: bool, env_name: str) -> bool:
489
+ raw_value = os.getenv(env_name)
490
+ if raw_value is None:
491
+ return file_value
492
+
493
+ normalized = raw_value.strip().lower()
494
+ if normalized in {"1", "true", "yes", "on"}:
495
+ return True
496
+ if normalized in {"0", "false", "no", "off"}:
497
+ return False
498
+ raise ValueError(f"{env_name} must be a boolean value, got: {raw_value}")
499
+
500
+
501
+ def _resolve_int(file_value: int, env_name: str) -> int:
502
+ raw_value = os.getenv(env_name)
503
+ if raw_value is None:
504
+ return file_value
505
+ return int(raw_value)
506
+
507
+
508
+ def _resolve_optional_string(file_value: str | None, env_name: str) -> str | None:
509
+ raw_value = os.getenv(env_name)
510
+ value = raw_value if raw_value is not None else file_value
511
+ if value in {None, ""}:
512
+ return None
513
+ return value
514
+
515
+
516
+ def _validate_mode(name: str, value: str, allowed: AbstractSet[str]) -> str:
517
+ if value not in allowed:
518
+ allowed_values = ", ".join(sorted(allowed))
519
+ raise ValueError(f"{name} must be one of: {allowed_values}")
520
+ return value
521
+
522
+
523
+ def _default_api_key_env(provider_name: str) -> str:
524
+ return DEFAULT_API_KEY_ENV_BY_PROVIDER.get(provider_name.lower(), "ANTHROPIC_API_KEY")
525
+
526
+
527
+ def resolve_config_path(config_path: Path | None) -> Path:
528
+ if config_path is not None:
529
+ return config_path.expanduser()
530
+
531
+ env_path = os.getenv("BAREAGENT_CONFIG")
532
+ if env_path:
533
+ return Path(env_path).expanduser()
534
+
535
+ return DEFAULT_CONFIG_PATH
536
+
537
+
538
+ def _has_usable_key(provider: ProviderConfig) -> bool:
539
+ """Return whether *provider* can resolve an API key without the wizard.
540
+
541
+ Mirrors :func:`bareagent.provider.factory._resolve_api_key`: an explicit
542
+ plaintext ``api_key`` wins, an ``sk-`` prefixed ``api_key_env`` is itself
543
+ the key, and otherwise the named environment variable must be populated.
544
+ """
545
+ if provider.api_key:
546
+ return True
547
+ api_key_env = provider.api_key_env or ""
548
+ if api_key_env.startswith("sk-"):
549
+ return True
550
+ return bool(api_key_env and os.getenv(api_key_env))
551
+
552
+
553
+ def _parse_cost_config(cost_raw: dict) -> CostConfig:
554
+ """Parse the ``[cost]`` / ``[cost.prices]`` config section.
555
+
556
+ Each ``[cost.prices."<model-id>"]`` table is coerced into a
557
+ ``{"input": float, "output": float}`` dict (USD per million tokens).
558
+ Malformed or incomplete entries are skipped so a bad override never crashes
559
+ boot — the model simply shows token counts without a ``$`` estimate.
560
+ """
561
+ prices_raw = cost_raw.get("prices", {})
562
+ prices: dict[str, dict[str, float]] = {}
563
+ if isinstance(prices_raw, dict):
564
+ for model, entry in prices_raw.items():
565
+ if not isinstance(entry, dict):
566
+ continue
567
+ try:
568
+ prices[str(model)] = {
569
+ "input": float(entry["input"]),
570
+ "output": float(entry["output"]),
571
+ }
572
+ except (KeyError, TypeError, ValueError):
573
+ continue
574
+ return CostConfig(prices=prices)
575
+
576
+
577
+ def _parse_retry_config(retry_raw: dict) -> RetryConfig:
578
+ """Parse the ``[retry]`` config section.
579
+
580
+ Each field is parsed defensively — a malformed value falls back to the
581
+ default rather than crashing boot (mirrors ``_parse_cost_config``).
582
+ ``enabled`` / ``max_attempts`` honor env overrides
583
+ (``BAREAGENT_RETRY_ENABLED`` / ``BAREAGENT_RETRY_MAX_ATTEMPTS``); the
584
+ remaining fields are config-only.
585
+ """
586
+ defaults = RetryConfig()
587
+ try:
588
+ enabled = _resolve_bool(
589
+ bool(retry_raw.get("enabled", defaults.enabled)),
590
+ "BAREAGENT_RETRY_ENABLED",
591
+ )
592
+ except (TypeError, ValueError):
593
+ enabled = defaults.enabled
594
+ try:
595
+ max_attempts = _resolve_int(
596
+ int(retry_raw.get("max_attempts", defaults.max_attempts)),
597
+ "BAREAGENT_RETRY_MAX_ATTEMPTS",
598
+ )
599
+ except (TypeError, ValueError):
600
+ max_attempts = defaults.max_attempts
601
+ try:
602
+ base_delay_sec = float(retry_raw.get("base_delay_sec", defaults.base_delay_sec))
603
+ except (TypeError, ValueError):
604
+ base_delay_sec = defaults.base_delay_sec
605
+ try:
606
+ max_delay_sec = float(retry_raw.get("max_delay_sec", defaults.max_delay_sec))
607
+ except (TypeError, ValueError):
608
+ max_delay_sec = defaults.max_delay_sec
609
+ try:
610
+ multiplier = float(retry_raw.get("multiplier", defaults.multiplier))
611
+ except (TypeError, ValueError):
612
+ multiplier = defaults.multiplier
613
+ try:
614
+ jitter = bool(retry_raw.get("jitter", defaults.jitter))
615
+ except (TypeError, ValueError):
616
+ jitter = defaults.jitter
617
+ return RetryConfig(
618
+ enabled=enabled,
619
+ max_attempts=max_attempts,
620
+ base_delay_sec=base_delay_sec,
621
+ max_delay_sec=max_delay_sec,
622
+ multiplier=multiplier,
623
+ jitter=jitter,
624
+ )
625
+
626
+
627
+ def _build_retry_policy(retry_config: RetryConfig) -> RetryPolicy:
628
+ return RetryPolicy(
629
+ enabled=retry_config.enabled,
630
+ max_attempts=retry_config.max_attempts,
631
+ base_delay_sec=retry_config.base_delay_sec,
632
+ max_delay_sec=retry_config.max_delay_sec,
633
+ multiplier=retry_config.multiplier,
634
+ jitter=retry_config.jitter,
635
+ )
636
+
637
+
638
+ def _parse_cache_config(cache_raw: dict) -> CacheConfig:
639
+ """Parse the ``[cache]`` config section (defensive, never crashes boot).
640
+
641
+ ``enabled`` honors the ``BAREAGENT_CACHE_ENABLED`` env override (mirrors
642
+ ``[retry]``); ``ttl`` is config-only and falls back to ``"5m"`` for any
643
+ value outside ``{"5m", "1h"}``. Only the Anthropic provider acts on this.
644
+ """
645
+ defaults = CacheConfig()
646
+ try:
647
+ enabled = _resolve_bool(
648
+ bool(cache_raw.get("enabled", defaults.enabled)),
649
+ "BAREAGENT_CACHE_ENABLED",
650
+ )
651
+ except (TypeError, ValueError):
652
+ enabled = defaults.enabled
653
+ ttl_raw = str(cache_raw.get("ttl", defaults.ttl)).strip().lower()
654
+ ttl = cast(Literal["5m", "1h"], ttl_raw) if ttl_raw in VALID_CACHE_TTLS else defaults.ttl
655
+ return CacheConfig(enabled=enabled, ttl=ttl)
656
+
657
+
658
+ def _parse_skills_config(skills_raw: dict) -> SkillsConfig:
659
+ """Parse the ``[skills]`` config section (defensive, never crashes boot).
660
+
661
+ ``auto_generate`` honors ``BAREAGENT_SKILLS_AUTO_GENERATE`` (mirrors
662
+ ``[retry]``/``[cache]``); the rest are config-only and fall back per field.
663
+ Note ``BAREAGENT_SKILLS_DIR`` is a *separate* knob for the repo canon dir
664
+ (``resolve_skills_dir``), so the generated-root override here is config-only.
665
+ """
666
+ defaults = SkillsConfig()
667
+ try:
668
+ auto_generate = _resolve_bool(
669
+ bool(skills_raw.get("auto_generate", defaults.auto_generate)),
670
+ "BAREAGENT_SKILLS_AUTO_GENERATE",
671
+ )
672
+ except (TypeError, ValueError):
673
+ auto_generate = defaults.auto_generate
674
+
675
+ def _int_field(key: str, fallback: int) -> int:
676
+ try:
677
+ return int(skills_raw.get(key, fallback))
678
+ except (TypeError, ValueError):
679
+ return fallback
680
+
681
+ return SkillsConfig(
682
+ auto_generate=auto_generate,
683
+ min_tool_calls=_int_field("min_tool_calls", defaults.min_tool_calls),
684
+ min_user_replies=_int_field("min_user_replies", defaults.min_user_replies),
685
+ max_pending=_int_field("max_pending", defaults.max_pending),
686
+ dir=str(skills_raw.get("dir", defaults.dir)),
687
+ )
688
+
689
+
690
+ def _build_skillgen_config(skills: SkillsConfig) -> SkillGenConfig:
691
+ """Adapt the user-facing ``SkillsConfig`` to the pure ``SkillGenConfig``."""
692
+ return SkillGenConfig(
693
+ enabled=skills.auto_generate,
694
+ min_tool_calls=skills.min_tool_calls,
695
+ min_user_replies=skills.min_user_replies,
696
+ )
697
+
698
+
699
+ def _parse_goal_config(goal_raw: dict) -> GoalConfig:
700
+ """Parse the ``[goal]`` config section (defensive, never crashes boot).
701
+
702
+ ``max_turns`` honors ``BAREAGENT_GOAL_MAX_TURNS`` (mirrors ``[retry]``);
703
+ ``evaluator_model`` is config-only. A malformed value falls back to the
704
+ default per field.
705
+ """
706
+ defaults = GoalConfig()
707
+ try:
708
+ max_turns = _resolve_int(
709
+ int(goal_raw.get("max_turns", defaults.max_turns)),
710
+ "BAREAGENT_GOAL_MAX_TURNS",
711
+ )
712
+ except (TypeError, ValueError):
713
+ max_turns = defaults.max_turns
714
+ if max_turns < 1:
715
+ max_turns = defaults.max_turns
716
+ return GoalConfig(
717
+ max_turns=max_turns,
718
+ evaluator_model=str(goal_raw.get("evaluator_model", defaults.evaluator_model)).strip(),
719
+ )
720
+
721
+
722
+ def _parse_workflow_config(workflow_raw: dict) -> WorkflowConfig:
723
+ """Parse the ``[workflow]`` config section (defensive, never crashes boot).
724
+
725
+ ``enabled`` honors ``BAREAGENT_WORKFLOW_ENABLED``; the integer caps are
726
+ config-only and fall back to their default when missing / malformed / < 1.
727
+ """
728
+ defaults = WorkflowConfig()
729
+ try:
730
+ enabled = _resolve_bool(
731
+ bool(workflow_raw.get("enabled", defaults.enabled)),
732
+ "BAREAGENT_WORKFLOW_ENABLED",
733
+ )
734
+ except (TypeError, ValueError):
735
+ enabled = defaults.enabled
736
+
737
+ def _positive_int(key: str, fallback: int) -> int:
738
+ try:
739
+ value = int(workflow_raw.get(key, fallback))
740
+ except (TypeError, ValueError):
741
+ return fallback
742
+ return value if value >= 1 else fallback
743
+
744
+ # default_token_budget allows 0 (unlimited), so it is parsed as non-negative
745
+ # rather than positive; env override mirrors the enabled knob.
746
+ try:
747
+ default_budget = _resolve_int(
748
+ int(workflow_raw.get("default_token_budget", defaults.default_token_budget)),
749
+ "BAREAGENT_WORKFLOW_DEFAULT_TOKEN_BUDGET",
750
+ )
751
+ except (TypeError, ValueError):
752
+ default_budget = defaults.default_token_budget
753
+ if default_budget < 0:
754
+ default_budget = defaults.default_token_budget
755
+
756
+ try:
757
+ max_runs = _resolve_int(
758
+ int(workflow_raw.get("max_runs", defaults.max_runs)),
759
+ "BAREAGENT_WORKFLOW_MAX_RUNS",
760
+ )
761
+ except (TypeError, ValueError):
762
+ max_runs = defaults.max_runs
763
+ if max_runs < 1:
764
+ max_runs = defaults.max_runs
765
+
766
+ return WorkflowConfig(
767
+ enabled=enabled,
768
+ max_concurrency=_positive_int("max_concurrency", defaults.max_concurrency),
769
+ max_nodes=_positive_int("max_nodes", defaults.max_nodes),
770
+ default_token_budget=default_budget,
771
+ max_runs=max_runs,
772
+ )
773
+
774
+
775
+ def _parse_team_config(team_raw: dict) -> TeamConfig:
776
+ """Parse the ``[team]`` config section (defensive, never crashes boot).
777
+
778
+ ``poll_interval`` / ``response_timeout`` are config-only positive floats; a
779
+ missing / malformed / <= 0 value falls back to its default. ``memory_enabled``
780
+ honors the ``BAREAGENT_TEAM_MEMORY_ENABLED`` env override (mirrors retry /
781
+ cache / workflow ``enabled`` knobs).
782
+ """
783
+ defaults = TeamConfig()
784
+
785
+ def _positive_float(key: str, fallback: float) -> float:
786
+ try:
787
+ value = float(team_raw.get(key, fallback))
788
+ except (TypeError, ValueError):
789
+ return fallback
790
+ return value if value > 0 else fallback
791
+
792
+ try:
793
+ memory_enabled = _resolve_bool(
794
+ bool(team_raw.get("memory_enabled", defaults.memory_enabled)),
795
+ "BAREAGENT_TEAM_MEMORY_ENABLED",
796
+ )
797
+ except (TypeError, ValueError):
798
+ memory_enabled = defaults.memory_enabled
799
+
800
+ return TeamConfig(
801
+ poll_interval=_positive_float("poll_interval", defaults.poll_interval),
802
+ response_timeout=_positive_float("response_timeout", defaults.response_timeout),
803
+ memory_enabled=memory_enabled,
804
+ )
805
+
806
+
807
+ def _build_goal_provider(
808
+ config: Config,
809
+ session_provider: BaseLLMProvider,
810
+ ) -> BaseLLMProvider:
811
+ """Provider for the goal evaluator: a cheaper model if configured, else reuse.
812
+
813
+ ``[goal] evaluator_model`` empty -> reuse the session provider (no extra
814
+ client). Otherwise build a sibling provider with that model via the factory
815
+ (same provider family / credentials). On any build failure, warn and fall
816
+ back to the session provider so a bad model id never blocks ``/goal``.
817
+ """
818
+ model = config.goal.evaluator_model.strip()
819
+ if not model:
820
+ return session_provider
821
+ try:
822
+ eval_config = replace(config, provider=replace(config.provider, model=model))
823
+ return create_provider(eval_config)
824
+ except Exception as exc: # noqa: BLE001 - never block /goal on evaluator setup
825
+ _log.warning(
826
+ "Goal evaluator provider build failed (%s); reusing session provider.",
827
+ exc,
828
+ )
829
+ return session_provider
830
+
831
+
832
+ def load_config(
833
+ config_path: Path,
834
+ *,
835
+ provider_override: str | None = None,
836
+ model_override: str | None = None,
837
+ ) -> Config:
838
+ raw_config = _read_config_file(config_path)
839
+ provider_raw = raw_config.get("provider", {})
840
+ permission_raw = raw_config.get("permission", {})
841
+ ui_raw = raw_config.get("ui", {})
842
+ subagent_raw = raw_config.get("subagent", {})
843
+ thinking_raw = raw_config.get("thinking", {})
844
+ debug_raw = raw_config.get("debug", {})
845
+ tracing_raw = raw_config.get("tracing", {})
846
+ allow_rules, deny_rules = parse_permission_rules(raw_config)
847
+ configured_provider_name = str(provider_raw.get("name", "anthropic"))
848
+ provider_name = _resolve_string(
849
+ configured_provider_name,
850
+ "BAREAGENT_PROVIDER",
851
+ provider_override,
852
+ )
853
+ default_api_key_env = _default_api_key_env(provider_name)
854
+ configured_api_key_env = provider_raw.get("api_key_env")
855
+ api_key_env_default = (
856
+ configured_api_key_env
857
+ if configured_api_key_env and provider_name == configured_provider_name
858
+ else default_api_key_env
859
+ )
860
+
861
+ provider = ProviderConfig(
862
+ name=provider_name,
863
+ model=_resolve_string(
864
+ provider_raw.get("model", "claude-sonnet-4-20250514"),
865
+ "BAREAGENT_MODEL",
866
+ model_override,
867
+ ),
868
+ api_key_env=_resolve_string(
869
+ api_key_env_default,
870
+ "BAREAGENT_API_KEY_ENV",
871
+ ),
872
+ api_key=_resolve_optional_string(
873
+ provider_raw.get("api_key"),
874
+ "BAREAGENT_API_KEY",
875
+ ),
876
+ base_url=_resolve_optional_string(
877
+ provider_raw.get("base_url"),
878
+ "BAREAGENT_BASE_URL",
879
+ ),
880
+ wire_api=_resolve_optional_string(
881
+ provider_raw.get("wire_api"),
882
+ "BAREAGENT_WIRE_API",
883
+ ),
884
+ )
885
+ permission = PermissionConfig(
886
+ mode=_validate_mode(
887
+ "permission.mode",
888
+ _resolve_string(
889
+ permission_raw.get("mode", "default"),
890
+ "BAREAGENT_PERMISSION_MODE",
891
+ ),
892
+ VALID_PERMISSION_MODES,
893
+ ),
894
+ allow=allow_rules,
895
+ deny=deny_rules,
896
+ )
897
+ ui = UIConfig(
898
+ stream=_resolve_bool(ui_raw.get("stream", True), "BAREAGENT_UI_STREAM"),
899
+ theme=_resolve_string(ui_raw.get("theme", "dark"), "BAREAGENT_UI_THEME"),
900
+ )
901
+ try:
902
+ subagent_max_resumable = int(subagent_raw.get("max_resumable", 20))
903
+ except (TypeError, ValueError):
904
+ subagent_max_resumable = 20
905
+ if subagent_max_resumable < 1:
906
+ subagent_max_resumable = 20
907
+ subagent = SubagentConfig(
908
+ max_depth=_resolve_int(
909
+ int(subagent_raw.get("max_depth", 3)),
910
+ "BAREAGENT_SUBAGENT_MAX_DEPTH",
911
+ ),
912
+ default_type=_validate_mode(
913
+ "subagent.default_type",
914
+ _resolve_string(
915
+ str(subagent_raw.get("default_type", DEFAULT_AGENT_TYPE)),
916
+ "BAREAGENT_SUBAGENT_DEFAULT_TYPE",
917
+ ),
918
+ VALID_SUBAGENT_TYPES,
919
+ ),
920
+ max_resumable=subagent_max_resumable,
921
+ )
922
+ thinking = ThinkingConfig(
923
+ mode=cast(
924
+ Literal["enabled", "adaptive", "disabled"],
925
+ _validate_mode(
926
+ "thinking.mode",
927
+ _resolve_string(
928
+ thinking_raw.get("mode", "adaptive"),
929
+ "BAREAGENT_THINKING_MODE",
930
+ ),
931
+ VALID_THINKING_MODES,
932
+ ),
933
+ ),
934
+ budget_tokens=_resolve_int(
935
+ int(thinking_raw.get("budget_tokens", 10000)),
936
+ "BAREAGENT_THINKING_BUDGET_TOKENS",
937
+ ),
938
+ )
939
+ debug = DebugConfig(
940
+ enabled=_resolve_bool(
941
+ bool(debug_raw.get("enabled", False)),
942
+ "BAREAGENT_DEBUG",
943
+ ),
944
+ log_dir=_resolve_string(
945
+ str(debug_raw.get("log_dir", ".logs")),
946
+ "BAREAGENT_DEBUG_LOG_DIR",
947
+ ),
948
+ viewer_port=_resolve_int(
949
+ int(debug_raw.get("viewer_port", 8321)),
950
+ "BAREAGENT_DEBUG_VIEWER_PORT",
951
+ ),
952
+ pretty=_resolve_bool(
953
+ bool(debug_raw.get("pretty", True)),
954
+ "BAREAGENT_DEBUG_PRETTY",
955
+ ),
956
+ )
957
+ tracing = TracingConfig(
958
+ langfuse=_resolve_bool(
959
+ bool(tracing_raw.get("langfuse", False)),
960
+ "BAREAGENT_TRACING_LANGFUSE",
961
+ ),
962
+ opentelemetry=_resolve_bool(
963
+ bool(tracing_raw.get("opentelemetry", False)),
964
+ "BAREAGENT_TRACING_OPENTELEMETRY",
965
+ ),
966
+ content_enabled=_resolve_bool(
967
+ bool(tracing_raw.get("content_enabled", True)),
968
+ "BAREAGENT_CONTENT_TRACING_ENABLED",
969
+ ),
970
+ )
971
+
972
+ mcp_raw = raw_config.get("mcp", {})
973
+ try:
974
+ mcp_config = parse_mcp_config({"mcp": mcp_raw} if isinstance(mcp_raw, dict) else {})
975
+ except MCPError as exc:
976
+ print(f"Warning: invalid [mcp] config, MCP disabled ({exc})")
977
+ mcp_config = MCPConfig()
978
+
979
+ lsp_raw = raw_config.get("lsp", {})
980
+ try:
981
+ lsp_config = parse_lsp_config({"lsp": lsp_raw} if isinstance(lsp_raw, dict) else {})
982
+ except LSPError as exc:
983
+ print(f"Warning: invalid [lsp] config, LSP disabled ({exc})")
984
+ lsp_config = LSPConfig()
985
+
986
+ hooks_raw = raw_config.get("hooks", [])
987
+ try:
988
+ hooks_config = parse_hooks_config(
989
+ {"hooks": hooks_raw} if isinstance(hooks_raw, list) else {}
990
+ )
991
+ except HookConfigError as exc:
992
+ print(f"Warning: invalid [[hooks]] config, hooks disabled ({exc})")
993
+ hooks_config = HooksConfig()
994
+ for skipped_reason in hooks_config.skipped:
995
+ print(f"Warning: {skipped_reason}")
996
+
997
+ cost_raw = raw_config.get("cost", {})
998
+ cost_config = _parse_cost_config(cost_raw if isinstance(cost_raw, dict) else {})
999
+
1000
+ retry_raw = raw_config.get("retry", {})
1001
+ retry_config = _parse_retry_config(retry_raw if isinstance(retry_raw, dict) else {})
1002
+
1003
+ cache_raw = raw_config.get("cache", {})
1004
+ cache_config = _parse_cache_config(cache_raw if isinstance(cache_raw, dict) else {})
1005
+
1006
+ skills_raw = raw_config.get("skills", {})
1007
+ skills_config = _parse_skills_config(skills_raw if isinstance(skills_raw, dict) else {})
1008
+
1009
+ goal_raw = raw_config.get("goal", {})
1010
+ goal_config = _parse_goal_config(goal_raw if isinstance(goal_raw, dict) else {})
1011
+
1012
+ workflow_raw = raw_config.get("workflow", {})
1013
+ workflow_config = _parse_workflow_config(workflow_raw if isinstance(workflow_raw, dict) else {})
1014
+
1015
+ team_raw = raw_config.get("team", {})
1016
+ team_config = _parse_team_config(team_raw if isinstance(team_raw, dict) else {})
1017
+
1018
+ memory_raw = raw_config.get("memory", {})
1019
+ memory_config = MemoryConfig(
1020
+ enabled=_resolve_bool(
1021
+ bool(memory_raw.get("enabled", True)),
1022
+ "BAREAGENT_MEMORY_ENABLED",
1023
+ ),
1024
+ dir=_resolve_string(
1025
+ str(memory_raw.get("dir", "")),
1026
+ "BAREAGENT_MEMORY_DIR",
1027
+ ),
1028
+ max_index_lines=_resolve_int(
1029
+ int(memory_raw.get("max_index_lines", 200)),
1030
+ "BAREAGENT_MEMORY_MAX_INDEX_LINES",
1031
+ ),
1032
+ recall_k=_resolve_int(
1033
+ int(memory_raw.get("recall_k", 5)),
1034
+ "BAREAGENT_MEMORY_RECALL_K",
1035
+ ),
1036
+ semantic_recall=_resolve_bool(
1037
+ bool(memory_raw.get("semantic_recall", False)),
1038
+ "BAREAGENT_MEMORY_SEMANTIC_RECALL",
1039
+ ),
1040
+ embedding_backend=str(memory_raw.get("embedding_backend", "openai")).strip()
1041
+ or "openai",
1042
+ embedding_model=str(memory_raw.get("embedding_model", "")).strip(),
1043
+ embedding_base_url=str(memory_raw.get("embedding_base_url", "")).strip(),
1044
+ embedding_api_key=str(memory_raw.get("embedding_api_key", "")).strip(),
1045
+ )
1046
+
1047
+ return Config(
1048
+ provider=provider,
1049
+ permission=permission,
1050
+ ui=ui,
1051
+ subagent=subagent,
1052
+ thinking=thinking,
1053
+ debug=debug,
1054
+ tracing=tracing,
1055
+ path=config_path.resolve(),
1056
+ mcp=mcp_config,
1057
+ lsp=lsp_config,
1058
+ memory=memory_config,
1059
+ cost=cost_config,
1060
+ hooks=hooks_config,
1061
+ retry=retry_config,
1062
+ cache=cache_config,
1063
+ skills=skills_config,
1064
+ goal=goal_config,
1065
+ workflow=workflow_config,
1066
+ team=team_config,
1067
+ )
1068
+
1069
+
1070
+ _NAG_REMINDER_PREFIX = "<nag-reminder>"
1071
+ _MEMORY_RECALL_PREFIX = "<memory-recall>"
1072
+ _PLAN_DIRECTIVE_PREFIX = "<plan-mode>"
1073
+
1074
+
1075
+ def _initial_messages(
1076
+ workspace: Path,
1077
+ skill_summary: str = "",
1078
+ memory_context: str = "",
1079
+ ) -> list[dict[str, Any]]:
1080
+ return [
1081
+ {
1082
+ "role": "system",
1083
+ "content": assemble_system_prompt(
1084
+ workspace,
1085
+ skill_summary=skill_summary,
1086
+ memory_context=memory_context,
1087
+ ),
1088
+ }
1089
+ ]
1090
+
1091
+
1092
+ def _build_memory_manager(
1093
+ config: Config,
1094
+ workspace_path: Path,
1095
+ ui_console: AgentConsole,
1096
+ ) -> MemoryManager | None:
1097
+ """Build the persistent memory manager, or None when disabled/unavailable."""
1098
+ if not config.memory.enabled:
1099
+ return None
1100
+ try:
1101
+ root = resolve_memory_root(workspace_path, config.memory.dir)
1102
+ embedder = _build_memory_embedder(config, ui_console)
1103
+ return MemoryManager(
1104
+ root,
1105
+ max_index_lines=config.memory.max_index_lines,
1106
+ embedder=embedder,
1107
+ )
1108
+ except OSError as exc:
1109
+ ui_console.print_error(f"Persistent memory disabled (cannot open store): {exc}")
1110
+ return None
1111
+
1112
+
1113
+ def _build_memory_embedder(config: Config, ui_console: AgentConsole):
1114
+ """Build the semantic-recall embedder, or None (fail-open to lexical recall).
1115
+
1116
+ Off unless ``[memory] semantic_recall`` is set. The openai backend's base_url
1117
+ / api_key fall back to the session provider's when unset; ``build_embedder``
1118
+ returns None on any failure (missing fastembed extra, missing key, unknown
1119
+ backend) so recall silently degrades to lexical.
1120
+ """
1121
+ memory = config.memory
1122
+ if not memory.semantic_recall:
1123
+ return None
1124
+ backend = (memory.embedding_backend or "openai").strip().lower()
1125
+ if backend == "openai":
1126
+ api_key = memory.embedding_api_key
1127
+ if not api_key:
1128
+ # _resolve_api_key raises when the provider config has no key at
1129
+ # all; semantic recall is fail-open, so swallow that and let
1130
+ # build_embedder degrade to None rather than crash boot.
1131
+ try:
1132
+ api_key = _resolve_api_key(config.provider)
1133
+ except ValueError:
1134
+ api_key = ""
1135
+ embedder = build_embedder(
1136
+ "openai",
1137
+ memory.embedding_model,
1138
+ base_url=memory.embedding_base_url or config.provider.base_url or None,
1139
+ api_key=api_key,
1140
+ )
1141
+ else:
1142
+ embedder = build_embedder(backend, memory.embedding_model)
1143
+ if embedder is None:
1144
+ ui_console.print_status(
1145
+ "Semantic recall requested but the embedding backend is unavailable; "
1146
+ "using lexical recall."
1147
+ )
1148
+ return embedder
1149
+
1150
+
1151
+ def _memory_context(memory_manager: MemoryManager | None) -> str:
1152
+ return memory_manager.system_prompt_section() if memory_manager is not None else ""
1153
+
1154
+
1155
+ def _refresh_nag_reminder(
1156
+ messages: list[dict[str, Any]],
1157
+ nag_reminder: str | None,
1158
+ ) -> None:
1159
+ messages[:] = [
1160
+ message
1161
+ for message in messages
1162
+ if not (
1163
+ message.get("role") == "system"
1164
+ and isinstance(message.get("content"), str)
1165
+ and str(message["content"]).startswith(_NAG_REMINDER_PREFIX)
1166
+ )
1167
+ ]
1168
+ if not nag_reminder:
1169
+ return
1170
+
1171
+ nag_message = {
1172
+ "role": "system",
1173
+ "content": f"{_NAG_REMINDER_PREFIX}\n{nag_reminder.strip()}\n</nag-reminder>",
1174
+ }
1175
+ for index in range(len(messages) - 1, -1, -1):
1176
+ msg = messages[index]
1177
+ if msg.get("role") == "user" and not is_tool_result_message(msg):
1178
+ messages.insert(index + 1, nag_message)
1179
+ return
1180
+
1181
+ messages.append(nag_message)
1182
+
1183
+
1184
+ def _refresh_memory_recall(
1185
+ messages: list[dict[str, Any]],
1186
+ memory_manager: MemoryManager | None,
1187
+ recall_k: int,
1188
+ ) -> None:
1189
+ """Drop the stale recall block and inject one for the latest user turn.
1190
+
1191
+ Mirrors :func:`_refresh_nag_reminder`: a single ``<memory-recall>`` system
1192
+ message lives just after the most recent genuine user message, refreshed on
1193
+ every agent-loop iteration so ``/remember``, ``/forget`` and ordinary turns
1194
+ all pick up the latest lexically-relevant memories.
1195
+ """
1196
+ messages[:] = [
1197
+ message
1198
+ for message in messages
1199
+ if not (
1200
+ message.get("role") == "system"
1201
+ and isinstance(message.get("content"), str)
1202
+ and str(message["content"]).startswith(_MEMORY_RECALL_PREFIX)
1203
+ )
1204
+ ]
1205
+ if memory_manager is None or recall_k <= 0:
1206
+ return
1207
+
1208
+ query: str | None = None
1209
+ insert_index: int | None = None
1210
+ for index in range(len(messages) - 1, -1, -1):
1211
+ msg = messages[index]
1212
+ if msg.get("role") == "user" and not is_tool_result_message(msg):
1213
+ content = msg.get("content")
1214
+ if isinstance(content, str):
1215
+ query = content
1216
+ insert_index = index
1217
+ break
1218
+ if query is None or insert_index is None:
1219
+ return
1220
+
1221
+ section = memory_manager.recall_section(query, recall_k)
1222
+ if not section:
1223
+ return
1224
+
1225
+ messages.insert(insert_index + 1, {"role": "system", "content": section})
1226
+
1227
+
1228
+ def _refresh_plan_directive(
1229
+ messages: list[dict[str, Any]],
1230
+ permission: PermissionGuard,
1231
+ ) -> None:
1232
+ """Drop any stale plan-mode directive and re-inject it while in PLAN mode.
1233
+
1234
+ Mirrors :func:`_refresh_nag_reminder`. Because ``compact`` runs at the top
1235
+ of every agent-loop iteration (``loop.py``), approving a plan mid-loop flips
1236
+ ``permission.mode`` and the *next* iteration strips this block automatically
1237
+ -- no stale plan guidance lingers once execution begins.
1238
+ """
1239
+ messages[:] = [
1240
+ message
1241
+ for message in messages
1242
+ if not (
1243
+ message.get("role") == "system"
1244
+ and isinstance(message.get("content"), str)
1245
+ and str(message["content"]).startswith(_PLAN_DIRECTIVE_PREFIX)
1246
+ )
1247
+ ]
1248
+ if permission.mode != PermissionMode.PLAN:
1249
+ return
1250
+
1251
+ directive = {
1252
+ "role": "system",
1253
+ "content": f"{_PLAN_DIRECTIVE_PREFIX}\n{PLAN_MODE_DIRECTIVE}\n</plan-mode>",
1254
+ }
1255
+ for index in range(len(messages) - 1, -1, -1):
1256
+ msg = messages[index]
1257
+ if msg.get("role") == "user" and not is_tool_result_message(msg):
1258
+ messages.insert(index + 1, directive)
1259
+ return
1260
+
1261
+ messages.append(directive)
1262
+
1263
+
1264
+ def _build_loop_compact(
1265
+ compact_fn: object,
1266
+ todo_manager: TodoManager,
1267
+ memory_manager: MemoryManager | None = None,
1268
+ recall_k: int = 0,
1269
+ permission: PermissionGuard | None = None,
1270
+ ):
1271
+ def _compact(
1272
+ messages: list[dict[str, Any]],
1273
+ force: bool = False,
1274
+ ) -> None:
1275
+ _refresh_nag_reminder(messages, todo_manager.get_nag_reminder())
1276
+ _refresh_memory_recall(messages, memory_manager, recall_k)
1277
+ if permission is not None:
1278
+ _refresh_plan_directive(messages, permission)
1279
+ compact_fn(messages, force=force) # type: ignore[misc]
1280
+
1281
+ get_session_id = getattr(compact_fn, "get_session_id", None)
1282
+ if callable(get_session_id):
1283
+ _compact.get_session_id = get_session_id # type: ignore[attr-defined]
1284
+
1285
+ set_session_id = getattr(compact_fn, "set_session_id", None)
1286
+ if callable(set_session_id):
1287
+ _compact.set_session_id = set_session_id # type: ignore[attr-defined]
1288
+
1289
+ return _compact
1290
+
1291
+
1292
+ _PERMISSION_SLASH = {
1293
+ "/default": PermissionMode.DEFAULT,
1294
+ "/auto": PermissionMode.AUTO,
1295
+ "/plan": PermissionMode.PLAN,
1296
+ "/bypass": PermissionMode.BYPASS,
1297
+ }
1298
+ _MODE_CYCLE = [
1299
+ PermissionMode.DEFAULT,
1300
+ PermissionMode.AUTO,
1301
+ PermissionMode.PLAN,
1302
+ PermissionMode.BYPASS,
1303
+ ]
1304
+ _MODE_DESCRIPTIONS = {
1305
+ PermissionMode.DEFAULT: "Write operations require confirmation",
1306
+ PermissionMode.AUTO: "Safe commands auto-approved",
1307
+ PermissionMode.PLAN: "Read-only mode",
1308
+ PermissionMode.BYPASS: "No confirmation required",
1309
+ }
1310
+ _SLASH_COMMANDS = [
1311
+ "/help",
1312
+ "/exit",
1313
+ "/clear",
1314
+ "/new",
1315
+ "/compact",
1316
+ *_PERMISSION_SLASH,
1317
+ "/mode",
1318
+ "/theme",
1319
+ "/sessions",
1320
+ "/resume",
1321
+ "/export",
1322
+ "/import",
1323
+ "/cost",
1324
+ "/goal",
1325
+ "/loop",
1326
+ "/workflows",
1327
+ "/log",
1328
+ "/team",
1329
+ "/mcp",
1330
+ "/mcp:",
1331
+ "/lsp",
1332
+ "/reload",
1333
+ "/remember",
1334
+ "/forget",
1335
+ "/skill",
1336
+ ]
1337
+ _HELP_TEXT = (
1338
+ "Available commands:\n"
1339
+ " /help Show this help message\n"
1340
+ " /exit Exit BareAgent\n"
1341
+ " /clear Clear screen and start new conversation\n"
1342
+ " /new Start a new conversation\n"
1343
+ " /compact Compress conversation context\n"
1344
+ " /default Switch to DEFAULT permission mode\n"
1345
+ " /auto Switch to AUTO permission mode\n"
1346
+ " /plan Switch to PLAN permission mode\n"
1347
+ " /bypass Switch to BYPASS permission mode\n"
1348
+ " /mode Interactive permission mode selection\n"
1349
+ " /theme Switch color theme (catppuccin-mocha, dracula, nord, tokyo-night, gruvbox)\n"
1350
+ " /sessions List saved sessions\n"
1351
+ " /resume Resume a previous session\n"
1352
+ " /export Export conversation (markdown default | json) [path]\n"
1353
+ " /import Import a conversation file (.json/.jsonl) into a new session\n"
1354
+ " /cost Show token usage and estimated cost for this session\n"
1355
+ " /goal Drive the agent until a condition is met "
1356
+ "(/goal [--max-turns N] <condition>); respects current permission mode\n"
1357
+ " /loop Schedule a shell command to repeat every N seconds "
1358
+ "(list|cancel <id>|clear); runs WITHOUT permission prompts\n"
1359
+ " /workflows List/inspect workflow runs (list | <run-id> | clear)\n"
1360
+ " /log Debug log viewer (status|serve|open|<seq>)\n"
1361
+ " /team Manage team agents (list | spawn | send | shutdown | register | review)\n"
1362
+ " /mcp Manage MCP servers (status | list | reload <name>)\n"
1363
+ " /mcp: Invoke an MCP prompt (e.g. /mcp:server:prompt key=value)\n"
1364
+ " /lsp Manage LSP servers (status | list | reload <language>)\n"
1365
+ " /reload Reload config.toml (theme + permission hot-apply; others need restart)\n"
1366
+ " /remember Save information to persistent memory (/remember <text>)\n"
1367
+ " /forget Remove information from persistent memory (/forget <text>)\n"
1368
+ " /skill Manage generated skills (list | keep <name> | discard <name>)"
1369
+ )
1370
+
1371
+
1372
+ def _build_permission_guard(config: Config) -> PermissionGuard:
1373
+ guard = PermissionGuard(PermissionMode(config.permission.mode))
1374
+ guard.allow_rules = list(config.permission.allow)
1375
+ guard.deny_rules = list(config.permission.deny)
1376
+ return guard
1377
+
1378
+
1379
+ def _format_config_change(change: ConfigChange) -> str:
1380
+ return f"{change.path} {change.old!r}→{change.new!r}"
1381
+
1382
+
1383
+ def _dispatch_reload_command(
1384
+ *,
1385
+ config: Config,
1386
+ permission: PermissionGuard,
1387
+ ui_console: AgentConsole,
1388
+ ) -> None:
1389
+ """Re-read config from disk and hot-apply the theme + permission subset.
1390
+
1391
+ All-or-nothing failure safety: if ``load_config`` raises (bad TOML, validation
1392
+ failure) the current live config is left untouched. Hot-reloadable changes
1393
+ (``ui.theme`` + ``permission.{mode,allow,deny}``) are applied to the live
1394
+ runtime objects *and* mirrored back into ``config`` so later reads stay
1395
+ consistent; everything else is only reported as "requires restart".
1396
+ """
1397
+ from bareagent.ui.theme import format_unknown_theme, get_theme
1398
+
1399
+ try:
1400
+ new_config = load_config(config.path)
1401
+ except Exception as exc:
1402
+ ui_console.print_error(
1403
+ f"Reload failed ({type(exc).__name__}: {exc}). Keeping current config."
1404
+ )
1405
+ return
1406
+
1407
+ report = _diff_config_for_reload(config, new_config)
1408
+ if not report.changed:
1409
+ ui_console.print_status("Config unchanged.")
1410
+ return
1411
+
1412
+ applied: list[str] = []
1413
+ for change in report.hot:
1414
+ if change.path == "ui.theme":
1415
+ tm = get_theme()
1416
+ if tm.switch(new_config.ui.theme):
1417
+ ui_console.set_theme(tm)
1418
+ config.ui.theme = new_config.ui.theme
1419
+ applied.append(_format_config_change(change))
1420
+ else:
1421
+ ui_console.print_error(format_unknown_theme(new_config.ui.theme))
1422
+ elif change.path == "permission.mode":
1423
+ try:
1424
+ permission.mode = PermissionMode(new_config.permission.mode)
1425
+ except ValueError:
1426
+ ui_console.print_error(
1427
+ f"Invalid permission.mode {new_config.permission.mode!r}; skipped."
1428
+ )
1429
+ continue
1430
+ config.permission.mode = new_config.permission.mode
1431
+ applied.append(_format_config_change(change))
1432
+ elif change.path == "permission.allow":
1433
+ permission.allow_rules = list(new_config.permission.allow)
1434
+ config.permission.allow = list(new_config.permission.allow)
1435
+ applied.append(_format_config_change(change))
1436
+ elif change.path == "permission.deny":
1437
+ permission.deny_rules = list(new_config.permission.deny)
1438
+ config.permission.deny = list(new_config.permission.deny)
1439
+ applied.append(_format_config_change(change))
1440
+
1441
+ if applied:
1442
+ ui_console.print_status("Reloaded: " + ", ".join(applied))
1443
+ if report.restart:
1444
+ restart_summary = ", ".join(_format_config_change(change) for change in report.restart)
1445
+ ui_console.print_status(
1446
+ f"Changed but requires restart: {restart_summary} (restart BareAgent to apply)"
1447
+ )
1448
+
1449
+
1450
+ def _build_permission_allow_rule(
1451
+ tool_name: str,
1452
+ tool_input: dict[str, Any],
1453
+ ) -> str | None:
1454
+ normalized_tool = tool_name.strip().lower()
1455
+ subject = permission_rule_subject(normalized_tool, tool_input)
1456
+ if not subject:
1457
+ return None
1458
+ if "\n" in subject or "\r" in subject:
1459
+ encoded_subject = json.dumps(subject, ensure_ascii=False)
1460
+ return f"{normalized_tool}(prefix_json:{encoded_subject})"
1461
+ return f"{normalized_tool}(prefix:{subject}*)"
1462
+
1463
+
1464
+ def _persist_permission_allow_rule(
1465
+ permission: PermissionGuard,
1466
+ tool_name: str,
1467
+ tool_input: dict[str, Any],
1468
+ ) -> None:
1469
+ rule = _build_permission_allow_rule(tool_name, tool_input)
1470
+ if rule is None or rule in permission.allow_rules:
1471
+ return
1472
+ permission.allow_rules.append(rule)
1473
+
1474
+
1475
+ def _install_stdio_permission_prompt(
1476
+ permission: PermissionGuard,
1477
+ ui_console: AgentConsole,
1478
+ ) -> None:
1479
+ if not sys.stdin.isatty():
1480
+ return
1481
+
1482
+ def _ask(call: Any) -> bool:
1483
+ preview_input = _build_permission_ask_payload(permission, call.name, call.input)
1484
+ allowed = ui_console.ask_permission(call.name, preview_input)
1485
+ choice = ui_console.consume_permission_choice()
1486
+ if allowed and choice == "always":
1487
+ _persist_permission_allow_rule(permission, call.name, call.input)
1488
+ return allowed
1489
+
1490
+ permission._ask_user_fn = _ask
1491
+
1492
+
1493
+ def _build_permission_ask_payload(
1494
+ permission: PermissionGuard,
1495
+ tool_name: str,
1496
+ tool_input: Any,
1497
+ ) -> Any:
1498
+ """Truncate oversized top-level string fields when asking about an MCP tool.
1499
+
1500
+ Non-MCP tools fall back to the raw input dict so existing rendering and
1501
+ permission rules stay unchanged. For MCP tools the guard's
1502
+ ``format_preview`` rule (256 chars per top-level string) is applied by
1503
+ rebuilding the dict — the console layer keeps doing the JSON pretty-print.
1504
+ """
1505
+ if not isinstance(tool_input, dict):
1506
+ return tool_input
1507
+ normalized = tool_name.strip().lower()
1508
+ if not normalized.startswith("mcp__"):
1509
+ return tool_input
1510
+ # Reuse the guard's truncation rule by parsing the formatted JSON back into
1511
+ # a dict — keeps the truncation logic in one place.
1512
+ try:
1513
+ truncated = json.loads(permission.format_preview(tool_name, tool_input))
1514
+ except (TypeError, ValueError):
1515
+ return tool_input
1516
+ if not isinstance(truncated, dict):
1517
+ return tool_input
1518
+ return truncated
1519
+
1520
+
1521
+ def _generate_session_id(
1522
+ transcript_mgr: TranscriptManager,
1523
+ *,
1524
+ reserved_ids: set[str] | None = None,
1525
+ ) -> str:
1526
+ known_session_ids = set(transcript_mgr.list_sessions())
1527
+ if reserved_ids:
1528
+ known_session_ids.update(session_id for session_id in reserved_ids if session_id)
1529
+
1530
+ while True:
1531
+ candidate = (
1532
+ f"{datetime.now().strftime(_SESSION_ID_TIMESTAMP_FORMAT)}-{generate_random_id(6)}"
1533
+ )
1534
+ if candidate not in known_session_ids:
1535
+ return candidate
1536
+
1537
+
1538
+ def _switch_session_mailbox(
1539
+ workspace_path: Path,
1540
+ session_id: str,
1541
+ *,
1542
+ current_bus: MessageBus | None = None,
1543
+ ) -> tuple[MessageBus, str | None]:
1544
+ if current_bus is not None:
1545
+ _broadcast_team_shutdown(current_bus)
1546
+
1547
+ message_bus = MessageBus(workspace_path / ".mailbox" / session_id)
1548
+ message_bus.ensure_mailbox(MAIN_AGENT_NAME)
1549
+ return message_bus, message_bus.latest_message_id(MAIN_AGENT_NAME)
1550
+
1551
+
1552
+ def _get_compact_session_id(compact_fn: object) -> str:
1553
+ getter = getattr(compact_fn, "get_session_id", None)
1554
+ if callable(getter):
1555
+ return str(getter())
1556
+ return "default"
1557
+
1558
+
1559
+ def _set_compact_session_id(compact_fn: object, session_id: str) -> None:
1560
+ setter = getattr(compact_fn, "set_session_id", None)
1561
+ if callable(setter):
1562
+ setter(session_id)
1563
+
1564
+
1565
+ def _save_transcript_snapshot(
1566
+ transcript_mgr: TranscriptManager,
1567
+ messages: list[dict[str, Any]],
1568
+ compact_fn: object,
1569
+ ) -> None:
1570
+ transcript_mgr.save(messages, _get_compact_session_id(compact_fn))
1571
+
1572
+
1573
+ def _resolve_debug_log_dir(workspace_path: Path, config: Config) -> Path:
1574
+ log_dir = Path(config.debug.log_dir).expanduser()
1575
+ if not log_dir.is_absolute():
1576
+ log_dir = workspace_path / log_dir
1577
+ return log_dir
1578
+
1579
+
1580
+ def _build_interaction_logger(
1581
+ config: Config,
1582
+ workspace_path: Path,
1583
+ session_id: str,
1584
+ ) -> InteractionLogger | None:
1585
+ if not config.debug.enabled:
1586
+ return None
1587
+
1588
+ return InteractionLogger(
1589
+ log_dir=_resolve_debug_log_dir(workspace_path, config),
1590
+ session_id=session_id,
1591
+ pretty=config.debug.pretty,
1592
+ )
1593
+
1594
+
1595
+ def _set_interaction_logger_session(
1596
+ interaction_logger: InteractionLogger | None,
1597
+ session_id: str,
1598
+ ) -> None:
1599
+ if interaction_logger is None:
1600
+ return
1601
+ interaction_logger.session_id = session_id
1602
+
1603
+
1604
+ def _configure_tracing(
1605
+ config: Config,
1606
+ *,
1607
+ session_id: str = "default",
1608
+ interaction_logger: InteractionLogger | None = None,
1609
+ ) -> None:
1610
+ from bareagent.tracing.setup import configure_tracing
1611
+
1612
+ configure_tracing(
1613
+ config.tracing,
1614
+ session_id=session_id,
1615
+ interaction_logger=interaction_logger,
1616
+ )
1617
+
1618
+
1619
+ def _debug_viewer_url(config: Config) -> str:
1620
+ return f"http://127.0.0.1:{config.debug.viewer_port}"
1621
+
1622
+
1623
+ def _format_log_status(
1624
+ config: Config,
1625
+ interaction_logger: InteractionLogger,
1626
+ viewer_server: object | None,
1627
+ ) -> str:
1628
+ interactions = interaction_logger.list_interactions(interaction_logger.session_id)
1629
+ total_tokens = sum(
1630
+ int(interaction.get("input_tokens", 0) or 0) + int(interaction.get("output_tokens", 0) or 0)
1631
+ for interaction in interactions
1632
+ )
1633
+ lines = [
1634
+ "Debug logging: enabled",
1635
+ f"Log dir: {config.debug.log_dir}",
1636
+ f"Current session: {interaction_logger.session_id}",
1637
+ f"Interactions: {len(interactions)}",
1638
+ f"Total tokens: {total_tokens}",
1639
+ f"Sessions: {len(interaction_logger.list_sessions())}",
1640
+ ]
1641
+ if viewer_server is None:
1642
+ lines.append("Viewer: not running (use /log serve)")
1643
+ else:
1644
+ lines.append(f"Viewer: {_debug_viewer_url(config)}")
1645
+ return "\n".join(lines)
1646
+
1647
+
1648
+ def _format_log_interaction_summary(
1649
+ seq: int,
1650
+ interaction: dict[str, object],
1651
+ ) -> str:
1652
+ request = interaction.get("request")
1653
+ response = interaction.get("response")
1654
+ if not request and not response:
1655
+ return f"Interaction #{seq} not found."
1656
+
1657
+ response_data = response if isinstance(response, dict) else {}
1658
+ tool_calls = response_data.get("tool_calls", [])
1659
+ thinking = str(response_data.get("thinking", "") or "").strip()
1660
+ lines = [
1661
+ f"Interaction #{seq}:",
1662
+ f" Input tokens: {response_data.get('input_tokens', '?')}",
1663
+ f" Output tokens: {response_data.get('output_tokens', '?')}",
1664
+ f" Duration: {response_data.get('duration_ms', '?')}ms",
1665
+ f" Tool calls: {len(tool_calls) if isinstance(tool_calls, list) else 0}",
1666
+ ]
1667
+ if response_data.get("error"):
1668
+ lines.append(f" Error: {response_data['error']}")
1669
+ if thinking:
1670
+ preview = thinking[:100]
1671
+ if len(thinking) > 100:
1672
+ preview += "..."
1673
+ lines.append(f" Thinking: {preview}")
1674
+ return "\n".join(lines)
1675
+
1676
+
1677
+ def _start_debug_viewer(
1678
+ interaction_logger: InteractionLogger,
1679
+ config: Config,
1680
+ ) -> object:
1681
+ from bareagent.debug.web_viewer import start_viewer
1682
+
1683
+ viewer_server, _ = start_viewer(
1684
+ interaction_logger,
1685
+ port=config.debug.viewer_port,
1686
+ )
1687
+ return viewer_server
1688
+
1689
+
1690
+ def _handle_log_command(
1691
+ text: str,
1692
+ *,
1693
+ config: Config,
1694
+ interaction_logger: InteractionLogger | None,
1695
+ viewer_server: object | None,
1696
+ print_status: Callable[[str], None],
1697
+ ) -> object | None:
1698
+ _, _, log_arg = text.partition(" ")
1699
+ log_cmd = log_arg.strip()
1700
+
1701
+ if interaction_logger is None:
1702
+ print_status(
1703
+ "Debug logging is disabled. Set [debug] enabled = true in config.toml "
1704
+ "or BAREAGENT_DEBUG=1"
1705
+ )
1706
+ return viewer_server
1707
+
1708
+ if not log_cmd or log_cmd == "status":
1709
+ print_status(_format_log_status(config, interaction_logger, viewer_server))
1710
+ return viewer_server
1711
+
1712
+ if log_cmd in {"serve", "open"}:
1713
+ if viewer_server is None:
1714
+ try:
1715
+ viewer_server = _start_debug_viewer(interaction_logger, config)
1716
+ except OSError as exc:
1717
+ print_status(f"Failed to start debug viewer: {exc}")
1718
+ return viewer_server
1719
+ print_status(f"Debug viewer started at {_debug_viewer_url(config)}")
1720
+ elif log_cmd == "serve":
1721
+ print_status(f"Viewer already running at {_debug_viewer_url(config)}")
1722
+
1723
+ if log_cmd == "open":
1724
+ import webbrowser
1725
+
1726
+ url = _debug_viewer_url(config)
1727
+ webbrowser.open(url)
1728
+ print_status(f"Opening {url} in browser...")
1729
+ return viewer_server
1730
+
1731
+ try:
1732
+ seq = int(log_cmd)
1733
+ except ValueError:
1734
+ print_status("Usage: /log [status|serve|open|<seq>]")
1735
+ return viewer_server
1736
+
1737
+ interaction = interaction_logger.get_interaction(
1738
+ interaction_logger.session_id,
1739
+ seq,
1740
+ )
1741
+ print_status(_format_log_interaction_summary(seq, interaction))
1742
+ return viewer_server
1743
+
1744
+
1745
+ def _build_handlers(
1746
+ *,
1747
+ workspace_path: Path,
1748
+ todo_manager: TodoManager,
1749
+ task_manager: TaskManager | None,
1750
+ skill_loader: SkillLoader,
1751
+ provider: BaseLLMProvider,
1752
+ tools: list[dict[str, object]],
1753
+ permission: PermissionGuard,
1754
+ bg_manager: BackgroundManager,
1755
+ messages: list[dict[str, Any]],
1756
+ config: Config,
1757
+ runtime_id: str,
1758
+ teammate_manager: TeammateManager,
1759
+ message_bus: MessageBus,
1760
+ spawned_agents: dict[str, AutonomousAgent],
1761
+ agent_name: str,
1762
+ mcp_manager: MCPManager | None = None,
1763
+ lsp_manager: LanguageServerManager | None = None,
1764
+ memory_manager: MemoryManager | None = None,
1765
+ system_prompt_override: str | None = None,
1766
+ subagent_registry: SubagentRegistry | None = None,
1767
+ ) -> dict[str, Callable[..., Any]]:
1768
+ system_prompt = system_prompt_override or _extract_system_prompt(messages)
1769
+ team_handlers = _make_team_handlers(
1770
+ config=config,
1771
+ workspace_path=workspace_path,
1772
+ todo_manager=todo_manager,
1773
+ task_manager=task_manager,
1774
+ skill_loader=skill_loader,
1775
+ permission=permission,
1776
+ bg_manager=bg_manager,
1777
+ tools=tools,
1778
+ runtime_id=runtime_id,
1779
+ teammate_manager=teammate_manager,
1780
+ message_bus=message_bus,
1781
+ spawned_agents=spawned_agents,
1782
+ agent_name=agent_name,
1783
+ )
1784
+ return get_handlers(
1785
+ workspace_path,
1786
+ todo_manager=todo_manager,
1787
+ task_manager=task_manager,
1788
+ skill_loader=skill_loader,
1789
+ provider=provider,
1790
+ tools=tools,
1791
+ permission=permission,
1792
+ bg_manager=bg_manager,
1793
+ subagent_system_prompt=system_prompt,
1794
+ subagent_max_depth=config.subagent.max_depth,
1795
+ subagent_default_type=config.subagent.default_type,
1796
+ team_handlers=team_handlers,
1797
+ mcp_manager=mcp_manager,
1798
+ lsp_manager=lsp_manager,
1799
+ memory_manager=memory_manager,
1800
+ subagent_retry_policy=_build_retry_policy(config.retry),
1801
+ subagent_registry=subagent_registry,
1802
+ )
1803
+
1804
+
1805
+ def _load_task_manager(
1806
+ workspace_path: Path,
1807
+ agent_console: AgentConsole,
1808
+ ) -> TaskManager | None:
1809
+ try:
1810
+ return TaskManager(workspace_path / ".tasks.json")
1811
+ except Exception as exc:
1812
+ agent_console.print_error(
1813
+ f"Failed to load task file {workspace_path / '.tasks.json'}: {exc}"
1814
+ )
1815
+ return None
1816
+
1817
+
1818
+ def _load_teammate_manager(
1819
+ workspace_path: Path,
1820
+ agent_console: AgentConsole,
1821
+ ) -> TeammateManager:
1822
+ team_file = workspace_path / ".team.json"
1823
+ try:
1824
+ return TeammateManager(team_file)
1825
+ except Exception as exc:
1826
+ agent_console.print_error(f"Failed to load team file {team_file}: {exc}")
1827
+ return TeammateManager.create_empty(team_file)
1828
+
1829
+
1830
+ def _extract_system_prompt(messages: list[dict[str, Any]]) -> str:
1831
+ for message in messages:
1832
+ if message.get("role") != "system":
1833
+ continue
1834
+ content = message.get("content")
1835
+ if isinstance(content, str):
1836
+ return content
1837
+ return ""
1838
+
1839
+
1840
+ def _make_team_handlers(
1841
+ *,
1842
+ config: Config,
1843
+ workspace_path: Path,
1844
+ todo_manager: TodoManager,
1845
+ task_manager: TaskManager | None,
1846
+ skill_loader: SkillLoader,
1847
+ permission: PermissionGuard,
1848
+ bg_manager: BackgroundManager,
1849
+ tools: list[dict[str, object]],
1850
+ runtime_id: str,
1851
+ teammate_manager: TeammateManager,
1852
+ message_bus: MessageBus,
1853
+ spawned_agents: dict[str, AutonomousAgent],
1854
+ agent_name: str,
1855
+ ) -> dict[str, Callable[..., Any]]:
1856
+ provider_factory = _make_teammate_provider_factory(config)
1857
+
1858
+ def _teammate_task_id(teammate_name: str) -> str:
1859
+ return f"team:{runtime_id}:{teammate_name}"
1860
+
1861
+ def _team_list() -> list[dict[str, object]]:
1862
+ return [
1863
+ {
1864
+ **teammate.to_dict(),
1865
+ # Source of truth is the live background thread, not the
1866
+ # spawned_agents dict (which never prunes crashed/finished
1867
+ # teammates).
1868
+ "running": bg_manager.is_running(_teammate_task_id(teammate.name)),
1869
+ }
1870
+ for teammate in teammate_manager.list()
1871
+ ]
1872
+
1873
+ def _team_send(to_agent: str, content: str) -> str:
1874
+ normalized_target = to_agent.strip()
1875
+ if normalized_target != MAIN_AGENT_NAME:
1876
+ teammate_manager.get(normalized_target)
1877
+ message_id = message_bus.send(
1878
+ Message(
1879
+ id="",
1880
+ from_agent=agent_name,
1881
+ to_agent=normalized_target,
1882
+ content=content,
1883
+ msg_type="request",
1884
+ timestamp="",
1885
+ )
1886
+ )
1887
+ # The main agent has no autonomous responder, so never block on it.
1888
+ if normalized_target == MAIN_AGENT_NAME:
1889
+ return f"Sent message {message_id} to {normalized_target}."
1890
+ # A teammate that is not running will never reply; return now instead of
1891
+ # waiting out the full timeout. (If it is spawned later, a reply would
1892
+ # surface via the mailbox drain on a subsequent turn.)
1893
+ if not bg_manager.is_running(_teammate_task_id(normalized_target)):
1894
+ return (
1895
+ f"Sent message {message_id} to {normalized_target}, but it is not "
1896
+ "running. Spawn it first (team_spawn / /team spawn) to get a reply."
1897
+ )
1898
+ # Block for the reply and hand it back to the caller. Mark the response
1899
+ # delivered so the polling drain does not surface it to the LLM twice.
1900
+ timeout = config.team.response_timeout
1901
+ response = ProtocolFSM(message_bus, agent_name).wait_response(
1902
+ message_id, timeout=timeout
1903
+ )
1904
+ if response is None:
1905
+ return (
1906
+ f"Sent message {message_id} to {normalized_target}; no reply within "
1907
+ f"{timeout:.0f}s. It may still be working -- a late reply will "
1908
+ "surface on a later turn."
1909
+ )
1910
+ message_bus.mark_delivered(response.id)
1911
+ _, body = decode_protocol_content(response.content)
1912
+ return f"Reply from {normalized_target}: {body}"
1913
+
1914
+ def _team_spawn(name: str) -> str:
1915
+ teammate_name = name.strip()
1916
+ agent_instance = teammate_manager.spawn(teammate_name, provider_factory)
1917
+ message_bus.ensure_mailbox(teammate_name)
1918
+ teammate_permission = permission.clone(fail_closed=True)
1919
+ agent_handlers = _build_handlers(
1920
+ workspace_path=workspace_path,
1921
+ todo_manager=todo_manager,
1922
+ task_manager=task_manager,
1923
+ skill_loader=skill_loader,
1924
+ provider=agent_instance.provider,
1925
+ tools=tools,
1926
+ permission=teammate_permission,
1927
+ bg_manager=bg_manager,
1928
+ messages=[],
1929
+ config=config,
1930
+ runtime_id=runtime_id,
1931
+ teammate_manager=teammate_manager,
1932
+ message_bus=message_bus,
1933
+ spawned_agents=spawned_agents,
1934
+ agent_name=teammate_name,
1935
+ system_prompt_override=agent_instance.system_prompt,
1936
+ )
1937
+ # Conversational memory across requests (task 06-08): inject a per-teammate
1938
+ # Compactor (its own provider, no transcript persistence) so the growing
1939
+ # history stays bounded. Disabled -> no-op compaction + stateless behavior.
1940
+ memory_enabled = config.team.memory_enabled
1941
+ teammate_compact_fn = None
1942
+ if memory_enabled:
1943
+ teammate_compact_fn = Compactor(
1944
+ provider=agent_instance.provider,
1945
+ transcript_mgr=None,
1946
+ session_id=f"team:{teammate_name}",
1947
+ )
1948
+ autonomous_agent = AutonomousAgent(
1949
+ name=agent_instance.name,
1950
+ provider=agent_instance.provider,
1951
+ tools=tools,
1952
+ handlers=agent_handlers,
1953
+ bus=message_bus,
1954
+ task_manager=task_manager,
1955
+ permission=teammate_permission,
1956
+ system_prompt=agent_instance.system_prompt,
1957
+ poll_interval=config.team.poll_interval,
1958
+ compact_fn=teammate_compact_fn,
1959
+ memory_enabled=memory_enabled,
1960
+ )
1961
+ try:
1962
+ bg_manager.submit(_teammate_task_id(teammate_name), autonomous_agent.run)
1963
+ except ValueError:
1964
+ return f"Teammate {teammate_name} is already running."
1965
+ spawned_agents[teammate_name] = autonomous_agent
1966
+ return f"Spawned teammate {teammate_name} ({agent_instance.role})"
1967
+
1968
+ def _team_shutdown(name: str) -> str:
1969
+ teammate_name = name.strip()
1970
+ if not teammate_name:
1971
+ return "Error: teammate name must not be empty."
1972
+ if not bg_manager.is_running(_teammate_task_id(teammate_name)):
1973
+ spawned_agents.pop(teammate_name, None)
1974
+ return f"Teammate {teammate_name} is not running."
1975
+ # SHUTDOWN is honored regardless of msg_type (checked before the request
1976
+ # filter in AutonomousAgent._handle_messages); wait_for_message wakes the
1977
+ # daemon immediately so it stops promptly.
1978
+ ProtocolFSM(message_bus, agent_name).request(
1979
+ teammate_name, Protocol.SHUTDOWN, "Stop requested."
1980
+ )
1981
+ spawned_agents.pop(teammate_name, None)
1982
+ return f"Sent shutdown to teammate {teammate_name}."
1983
+
1984
+ def _team_register(
1985
+ name: str = "",
1986
+ role: str = "",
1987
+ system_prompt: str = "",
1988
+ provider: str = "",
1989
+ model: str = "",
1990
+ ) -> str:
1991
+ # Build a sparse provider override; an empty config inherits the session
1992
+ # provider (the teammate provider factory fills the gaps).
1993
+ provider_config: dict[str, Any] = {}
1994
+ provider_name = (provider or "").strip()
1995
+ model_id = (model or "").strip()
1996
+ if provider_name:
1997
+ provider_config["name"] = provider_name
1998
+ if model_id:
1999
+ provider_config["model"] = model_id
2000
+ try:
2001
+ teammate = teammate_manager.register(
2002
+ name, role, system_prompt, provider_config=provider_config or None
2003
+ )
2004
+ except ValueError as exc:
2005
+ return f"Error: {exc}"
2006
+ return (
2007
+ f"Registered teammate {teammate.name} ({teammate.role}). "
2008
+ "Spawn it with team_spawn to start it."
2009
+ )
2010
+
2011
+ def _team_request_review(to_agent: str, plan: str) -> str:
2012
+ normalized_target = to_agent.strip()
2013
+ if not normalized_target:
2014
+ return "Error: to_agent must not be empty."
2015
+ if not isinstance(plan, str) or not plan.strip():
2016
+ return "Error: plan must not be empty."
2017
+ # The main agent has no autonomous responder, so it cannot review.
2018
+ if normalized_target == MAIN_AGENT_NAME:
2019
+ return "Cannot request review from the main agent (no autonomous responder)."
2020
+ try:
2021
+ teammate_manager.get(normalized_target)
2022
+ except ValueError:
2023
+ return (
2024
+ f"Error: unknown teammate {normalized_target}. "
2025
+ "Register it first with team_register."
2026
+ )
2027
+ # A teammate that is not running will never reply; return now instead of
2028
+ # waiting out the full timeout (mirrors team_send).
2029
+ if not bg_manager.is_running(_teammate_task_id(normalized_target)):
2030
+ return (
2031
+ f"Teammate {normalized_target} is not running. Spawn it first "
2032
+ "(team_spawn / /team spawn) to request a review."
2033
+ )
2034
+ # Send a PLAN_APPROVAL protocol request (the receiving teammate wraps it as
2035
+ # a plan-review prompt) and block for the verdict, deduping the reply so the
2036
+ # mailbox drain does not surface it to the LLM twice.
2037
+ fsm = ProtocolFSM(message_bus, agent_name)
2038
+ message_id = fsm.request(normalized_target, Protocol.PLAN_APPROVAL, plan)
2039
+ timeout = config.team.response_timeout
2040
+ response = fsm.wait_response(message_id, timeout=timeout)
2041
+ if response is None:
2042
+ return (
2043
+ f"Sent review request {message_id} to {normalized_target}; no verdict "
2044
+ f"within {timeout:.0f}s. It may still be reviewing -- a late reply will "
2045
+ "surface on a later turn."
2046
+ )
2047
+ message_bus.mark_delivered(response.id)
2048
+ _, verdict = decode_protocol_content(response.content)
2049
+ return f"Review verdict from {normalized_target}: {verdict}"
2050
+
2051
+ return {
2052
+ "team_list": _team_list,
2053
+ "team_send": _team_send,
2054
+ "team_spawn": _team_spawn,
2055
+ "team_shutdown": _team_shutdown,
2056
+ "team_register": _team_register,
2057
+ "team_request_review": _team_request_review,
2058
+ }
2059
+
2060
+
2061
+ def _make_teammate_provider_factory(config: Config):
2062
+ def _factory(provider_overrides: dict[str, object]) -> BaseLLMProvider:
2063
+ provider_name = str(provider_overrides.get("name", config.provider.name)).strip()
2064
+ resolved_provider_name = provider_name or config.provider.name
2065
+ inherited_api_key_env = (
2066
+ config.provider.api_key_env
2067
+ if resolved_provider_name == config.provider.name
2068
+ else _default_api_key_env(resolved_provider_name)
2069
+ )
2070
+ api_key_env = (
2071
+ str(
2072
+ provider_overrides.get(
2073
+ "api_key_env",
2074
+ inherited_api_key_env,
2075
+ )
2076
+ ).strip()
2077
+ or inherited_api_key_env
2078
+ )
2079
+ provider_config = ProviderConfig(
2080
+ name=resolved_provider_name,
2081
+ model=str(provider_overrides.get("model", config.provider.model)).strip()
2082
+ or config.provider.model,
2083
+ api_key_env=api_key_env,
2084
+ base_url=_coerce_optional_string(
2085
+ provider_overrides.get("base_url", config.provider.base_url)
2086
+ ),
2087
+ wire_api=_coerce_optional_string(
2088
+ provider_overrides.get("wire_api", config.provider.wire_api)
2089
+ ),
2090
+ )
2091
+ return create_provider(
2092
+ SimpleNamespace(
2093
+ provider=provider_config,
2094
+ thinking=ThinkingConfig(
2095
+ mode=config.thinking.mode,
2096
+ budget_tokens=config.thinking.budget_tokens,
2097
+ ),
2098
+ cache=CacheConfig(
2099
+ enabled=config.cache.enabled,
2100
+ ttl=config.cache.ttl,
2101
+ ),
2102
+ )
2103
+ )
2104
+
2105
+ return _factory
2106
+
2107
+
2108
+ def _handle_team_command(
2109
+ text: str,
2110
+ ui_console: AgentConsole,
2111
+ *,
2112
+ teammate_manager: TeammateManager,
2113
+ team_handlers: dict[str, Callable[..., Any]],
2114
+ ) -> None:
2115
+ _, _, raw_args = text.partition(" ")
2116
+ parts = raw_args.split(" ", 2) if raw_args else []
2117
+ subcommand = parts[0] if parts else "list"
2118
+
2119
+ try:
2120
+ if subcommand == "list":
2121
+ teammates = team_handlers["team_list"]() # type: ignore[index, operator]
2122
+ if not teammates:
2123
+ ui_console.print_status("No teammates registered.")
2124
+ return
2125
+ for teammate in teammates:
2126
+ name = str(teammate.get("name", "unknown"))
2127
+ role = str(teammate.get("role", ""))
2128
+ running = "running" if teammate.get("running") else "idle"
2129
+ ui_console.console.print(f"{name} [{running}] - {role}")
2130
+ return
2131
+
2132
+ if subcommand == "spawn" and len(parts) >= 2:
2133
+ result = team_handlers["team_spawn"](parts[1]) # type: ignore[index, operator]
2134
+ ui_console.print_status(str(result))
2135
+ return
2136
+
2137
+ if subcommand == "send" and len(parts) >= 3:
2138
+ target = parts[1].strip()
2139
+ content = parts[2].strip()
2140
+ if not target or not content:
2141
+ raise ValueError("Usage: /team send <name> <message>")
2142
+ result = team_handlers["team_send"](target, content) # type: ignore[index, operator]
2143
+ ui_console.print_status(str(result))
2144
+ return
2145
+
2146
+ if subcommand == "shutdown" and len(parts) >= 2:
2147
+ result = team_handlers["team_shutdown"](parts[1]) # type: ignore[index, operator]
2148
+ ui_console.print_status(str(result))
2149
+ return
2150
+
2151
+ if subcommand == "register":
2152
+ # name + role + system_prompt (the prompt is free text with spaces).
2153
+ reg_parts = raw_args.split(" ", 3)
2154
+ if len(reg_parts) < 4 or not reg_parts[1].strip() or not reg_parts[3].strip():
2155
+ raise ValueError("Usage: /team register <name> <role> <system_prompt>")
2156
+ result = team_handlers["team_register"]( # type: ignore[index, operator]
2157
+ reg_parts[1], reg_parts[2], reg_parts[3]
2158
+ )
2159
+ ui_console.print_status(str(result))
2160
+ return
2161
+
2162
+ if subcommand == "review" and len(parts) >= 3:
2163
+ target = parts[1].strip()
2164
+ plan = parts[2].strip()
2165
+ if not target or not plan:
2166
+ raise ValueError("Usage: /team review <name> <plan>")
2167
+ result = team_handlers["team_request_review"](target, plan) # type: ignore[index, operator]
2168
+ ui_console.print_status(str(result))
2169
+ return
2170
+ except Exception as exc:
2171
+ ui_console.print_error(str(exc))
2172
+ return
2173
+
2174
+ ui_console.print_status(
2175
+ "Usage: /team list | /team spawn <name> | /team send <name> <message> "
2176
+ "| /team shutdown <name> | /team register <name> <role> <system_prompt> "
2177
+ "| /team review <name> <plan>"
2178
+ )
2179
+
2180
+
2181
+ _MCP_PROMPT_USAGE = "Usage: /mcp:<server>:<prompt> [key=value ...]"
2182
+ _MCP_COMMAND_USAGE = "Usage: /mcp <status|list|reload <name>>"
2183
+
2184
+
2185
+ def _dispatch_mcp_command(
2186
+ text: str,
2187
+ *,
2188
+ mcp_manager: MCPManager,
2189
+ ui_console: AgentConsole,
2190
+ ) -> None:
2191
+ """Handle the space-prefixed ``/mcp <subcommand>`` REPL command.
2192
+
2193
+ Returns nothing — feedback goes through ``ui_console``. Unknown
2194
+ subcommands or missing args become ``print_error`` lines and never raise.
2195
+ """
2196
+ tokens = text.split()
2197
+ if len(tokens) <= 1:
2198
+ ui_console.print_status(_MCP_COMMAND_USAGE)
2199
+ return
2200
+ sub = tokens[1]
2201
+ if sub == "status":
2202
+ rows = mcp_manager.summarize()
2203
+ if not rows:
2204
+ ui_console.print_status("(no MCP servers configured)")
2205
+ return
2206
+ for row in rows:
2207
+ resources_label = "resources" if row["has_resources"] else "no-resources"
2208
+ ui_console.print_status(
2209
+ f"{row['name']}: {row['status']} "
2210
+ f"[{row['tool_count']} tools, "
2211
+ f"{resources_label}, "
2212
+ f"{row['prompt_count']} prompts]"
2213
+ )
2214
+ return
2215
+ if sub == "list":
2216
+ any_server = False
2217
+ for name, client in mcp_manager.iter_running_clients():
2218
+ any_server = True
2219
+ ui_console.print_status(f"[{name}]")
2220
+ cached_tools = getattr(client, "_tools_cache", None) or []
2221
+ for tool in cached_tools:
2222
+ tool_name = tool.get("name") if isinstance(tool, dict) else None
2223
+ if not tool_name:
2224
+ continue
2225
+ ui_console.print_status(f" mcp__{name}__{tool_name}")
2226
+ if client.has_capability("resources"):
2227
+ ui_console.print_status(f" mcp__{name}__resource_list")
2228
+ ui_console.print_status(f" mcp__{name}__resource_read")
2229
+ cached_prompts = getattr(client, "_prompts", None) or []
2230
+ for prompt in cached_prompts:
2231
+ prompt_name = prompt.get("name") if isinstance(prompt, dict) else None
2232
+ if not prompt_name:
2233
+ continue
2234
+ ui_console.print_status(f" /mcp:{name}:{prompt_name}")
2235
+ if not any_server:
2236
+ ui_console.print_status("(no MCP servers running)")
2237
+ return
2238
+ if sub == "reload":
2239
+ if len(tokens) < 3:
2240
+ ui_console.print_error("Usage: /mcp reload <name>")
2241
+ return
2242
+ target = tokens[2]
2243
+ try:
2244
+ mcp_manager.reload(target)
2245
+ except MCPError as exc:
2246
+ ui_console.print_error(f"reload {target!r} failed: {exc}")
2247
+ ui_console.print_error(f"Server {target!r} is now UNHEALTHY.")
2248
+ return
2249
+ except Exception as exc:
2250
+ ui_console.print_error(f"reload {target!r} failed: {exc}")
2251
+ ui_console.print_error(f"Server {target!r} is now UNHEALTHY.")
2252
+ return
2253
+ ui_console.print_status(f"Server {target!r} reloaded.")
2254
+ return
2255
+ ui_console.print_error(f"Unknown /mcp subcommand: {sub}. Use status, list, or reload.")
2256
+
2257
+
2258
+ def _parse_mcp_prompt_command(text: str) -> tuple[str, str, dict[str, str]] | None:
2259
+ """Parse ``/mcp:<server>:<prompt> [k=v ...]`` into (server, prompt, args).
2260
+
2261
+ Returns ``None`` if the command is malformed; logging is the caller's job
2262
+ so callers can surface UI feedback consistently.
2263
+ """
2264
+ if not text.startswith("/mcp:"):
2265
+ return None
2266
+ rest = text[len("/mcp:") :]
2267
+ head, _, tail = rest.partition(" ")
2268
+ if ":" not in head:
2269
+ return None
2270
+ server_name, prompt_name = head.split(":", 1)
2271
+ server_name = server_name.strip()
2272
+ prompt_name = prompt_name.strip()
2273
+ if not server_name or not prompt_name:
2274
+ return None
2275
+ arguments: dict[str, str] = {}
2276
+ for tok in tail.split():
2277
+ if "=" not in tok:
2278
+ _log.warning("Ignoring malformed /mcp: argument %r (expected key=value)", tok)
2279
+ continue
2280
+ k, _sep, v = tok.partition("=")
2281
+ if not k:
2282
+ _log.warning("Ignoring malformed /mcp: argument %r (empty key)", tok)
2283
+ continue
2284
+ arguments[k] = v
2285
+ return server_name, prompt_name, arguments
2286
+
2287
+
2288
+ def _dispatch_mcp_prompt(
2289
+ text: str,
2290
+ *,
2291
+ mcp_manager: MCPManager,
2292
+ messages: list[dict[str, Any]],
2293
+ ui_console: AgentConsole,
2294
+ ) -> bool:
2295
+ """Handle a ``/mcp:`` slash command. Returns True if messages were appended.
2296
+
2297
+ On success the parsed ``prompts/get`` result is converted into transcript
2298
+ messages and appended; the caller is then expected to trigger the next
2299
+ ``agent_loop()`` iteration just like a normal user input.
2300
+ """
2301
+ parsed = _parse_mcp_prompt_command(text)
2302
+ if parsed is None:
2303
+ ui_console.print_error(_MCP_PROMPT_USAGE)
2304
+ return False
2305
+ server_name, prompt_name, arguments = parsed
2306
+
2307
+ client = mcp_manager.get_client(server_name)
2308
+ if client is None:
2309
+ ui_console.print_error(f"Error: MCP server {server_name!r} is not running")
2310
+ return False
2311
+ if not client.has_capability("prompts"):
2312
+ ui_console.print_error(f"Error: server {server_name!r} does not support prompts")
2313
+ return False
2314
+
2315
+ try:
2316
+ result = client.get_prompt(prompt_name, arguments)
2317
+ except MCPCallError as exc:
2318
+ ui_console.print_error(str(exc))
2319
+ return False
2320
+ except Exception as exc: # pragma: no cover — defensive
2321
+ ui_console.print_error(f"Error: {type(exc).__name__}: {exc}")
2322
+ return False
2323
+
2324
+ raw_messages = result.get("messages") if isinstance(result, dict) else None
2325
+ if not isinstance(raw_messages, list) or not raw_messages:
2326
+ ui_console.print_error(
2327
+ f"Error: prompt {prompt_name!r} from {server_name!r} returned no messages"
2328
+ )
2329
+ return False
2330
+
2331
+ appended = False
2332
+ for entry in raw_messages:
2333
+ if not isinstance(entry, dict):
2334
+ continue
2335
+ role = entry.get("role")
2336
+ if role not in {"user", "assistant"}:
2337
+ continue
2338
+ content = entry.get("content")
2339
+ if isinstance(content, list):
2340
+ blocks = content
2341
+ elif isinstance(content, dict):
2342
+ blocks = [content]
2343
+ elif isinstance(content, str):
2344
+ blocks = [{"type": "text", "text": content}]
2345
+ else:
2346
+ blocks = []
2347
+ text_body = _mcp_flatten_content(blocks)
2348
+ messages.append({"role": role, "content": text_body})
2349
+ appended = True
2350
+
2351
+ return appended
2352
+
2353
+
2354
+ def _drain_team_mailbox(
2355
+ ui_console: AgentConsole,
2356
+ *,
2357
+ message_bus: MessageBus,
2358
+ since: str | None,
2359
+ sink: list[str] | None = None,
2360
+ ) -> str | None:
2361
+ """Surface new main-mailbox messages to the console and (optionally) the LLM.
2362
+
2363
+ Messages already delivered out of band (a blocking ``team_send`` that
2364
+ returned the reply to the caller) are skipped so the LLM never sees them
2365
+ twice. When ``sink`` is provided, each surfaced message's text is appended to
2366
+ it; the REPL prepends those onto the next user turn so late / unsolicited
2367
+ teammate replies enter the conversation without breaking role alternation.
2368
+ """
2369
+ messages = message_bus.receive(MAIN_AGENT_NAME, since_id=since)
2370
+ if not messages:
2371
+ return since
2372
+
2373
+ for message in messages:
2374
+ if message_bus.was_delivered(message.id):
2375
+ continue
2376
+ protocol, content = decode_protocol_content(message.content)
2377
+ label = f"Team {message.msg_type} from {message.from_agent}"
2378
+ if protocol is not None:
2379
+ label = f"{label} [{protocol.value}]"
2380
+ ui_console.print_status(f"{label}: {content}")
2381
+ if sink is not None:
2382
+ sink.append(f"[{label}] {content}")
2383
+
2384
+ return messages[-1].id
2385
+
2386
+
2387
+ def _drain_workflow_results(
2388
+ ui_console: AgentConsole,
2389
+ *,
2390
+ registry: WorkflowRegistry,
2391
+ sink: list[str],
2392
+ ) -> None:
2393
+ """Inject finished background workflows' full summaries into the next turn.
2394
+
2395
+ Mirrors ``_drain_team_mailbox``: ``take_undelivered`` returns each finished,
2396
+ not-yet-delivered run exactly once (marking it delivered), so a background
2397
+ workflow's full aggregated summary reaches the LLM precisely once and never
2398
+ twice. The summary is appended to ``sink`` (the REPL prepends it onto the next
2399
+ user turn, keeping role alternation intact) untruncated -- the generic
2400
+ background-task notification path skips ``wf-`` ids precisely so this drain is
2401
+ the single delivery channel. Sync runs are marked delivered but not injected:
2402
+ they already returned their summary as the tool result.
2403
+ """
2404
+ for run in registry.take_undelivered():
2405
+ if not run.background:
2406
+ continue
2407
+ counts = run.counts()
2408
+ ui_console.print_status(
2409
+ f"Workflow {run.run_id} finished "
2410
+ f"({counts['done']} done, {counts['failed']} failed, {counts['skipped']} skipped)."
2411
+ )
2412
+ body = run.summary.strip() or "(no summary)"
2413
+ sink.append(f'<workflow-result run="{run.run_id}">\n{body}\n</workflow-result>')
2414
+
2415
+
2416
+ def _broadcast_team_shutdown(message_bus: MessageBus) -> None:
2417
+ ProtocolFSM(message_bus, MAIN_AGENT_NAME).broadcast(
2418
+ Protocol.SHUTDOWN,
2419
+ "BareAgent main session is shutting down.",
2420
+ )
2421
+
2422
+
2423
+ def _read_stdio_input() -> str:
2424
+ prompt = "bareagent> " if sys.stdin.isatty() and sys.stdout.isatty() else ""
2425
+ return input(prompt)
2426
+
2427
+
2428
+ def _cycle_permission_mode(permission: PermissionGuard) -> PermissionMode:
2429
+ current_index = _MODE_CYCLE.index(permission.mode)
2430
+ next_mode = _MODE_CYCLE[(current_index + 1) % len(_MODE_CYCLE)]
2431
+ permission.mode = next_mode
2432
+ return next_mode
2433
+
2434
+
2435
+ def _build_stdio_read_fn(
2436
+ workspace_path: Path,
2437
+ permission: PermissionGuard,
2438
+ ) -> Callable[[], str]:
2439
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
2440
+ return _read_stdio_input
2441
+
2442
+ try:
2443
+ from bareagent.ui.prompt import AgentPrompt
2444
+
2445
+ agent_prompt = AgentPrompt(
2446
+ commands=list(_SLASH_COMMANDS),
2447
+ history_file=workspace_path / ".bareagent_history",
2448
+ get_mode_label=lambda: permission.mode.value.upper(),
2449
+ cycle_mode=lambda: _cycle_permission_mode(permission).value.upper(),
2450
+ )
2451
+ return agent_prompt.read_input
2452
+ except Exception:
2453
+ return lambda: input(f"[{permission.mode.value.upper()}] bareagent> ")
2454
+
2455
+
2456
+ def _clear_stdio_screen(ui_console: AgentConsole) -> None:
2457
+ if getattr(ui_console.console, "is_terminal", False):
2458
+ ui_console.console.clear(home=True)
2459
+
2460
+
2461
+ def _print_stdio_user_message(ui_console: AgentConsole, text: str) -> None:
2462
+ if not text.strip():
2463
+ return
2464
+
2465
+ from bareagent.ui.theme import get_theme
2466
+
2467
+ ui_console.console.print(
2468
+ f"> {text}",
2469
+ style=f"bold {get_theme().palette.accent}",
2470
+ markup=False,
2471
+ )
2472
+
2473
+
2474
+ def _replay_stdio_transcript(
2475
+ messages: list[dict[str, Any]],
2476
+ ui_console: AgentConsole,
2477
+ ) -> None:
2478
+ tool_name_by_id: dict[str, str] = {}
2479
+
2480
+ for message in messages:
2481
+ role = message.get("role")
2482
+ content = message.get("content")
2483
+
2484
+ if role == "system":
2485
+ continue
2486
+
2487
+ if role == "user":
2488
+ if isinstance(content, str):
2489
+ _print_stdio_user_message(ui_console, content)
2490
+ continue
2491
+ if isinstance(content, list):
2492
+ for block in content:
2493
+ if not isinstance(block, dict):
2494
+ continue
2495
+ if block.get("type") != "tool_result":
2496
+ continue
2497
+ tool_name = tool_name_by_id.get(
2498
+ str(block.get("tool_use_id", "")),
2499
+ "unknown",
2500
+ )
2501
+ ui_console.print_tool_result(tool_name, block.get("content", ""))
2502
+ continue
2503
+
2504
+ if role != "assistant":
2505
+ continue
2506
+
2507
+ if isinstance(content, str):
2508
+ ui_console.print_assistant(content)
2509
+ continue
2510
+ if not isinstance(content, list):
2511
+ continue
2512
+
2513
+ text_parts: list[str] = []
2514
+ for block in content:
2515
+ if not isinstance(block, dict):
2516
+ continue
2517
+ if block.get("type") == "text":
2518
+ text_value = str(block.get("text", ""))
2519
+ if text_value:
2520
+ text_parts.append(text_value)
2521
+ continue
2522
+ if block.get("type") != "tool_use":
2523
+ continue
2524
+
2525
+ tool_name = str(block.get("name", "unknown"))
2526
+ tool_id = str(block.get("id", ""))
2527
+ if tool_id:
2528
+ tool_name_by_id[tool_id] = tool_name
2529
+
2530
+ if text_parts:
2531
+ ui_console.print_assistant("\n".join(text_parts))
2532
+ text_parts = []
2533
+ ui_console.print_tool_call(tool_name, block.get("input", {}))
2534
+
2535
+ if text_parts:
2536
+ ui_console.print_assistant("\n".join(text_parts))
2537
+
2538
+
2539
+ def _handle_mode_selection_stdio(
2540
+ permission: PermissionGuard,
2541
+ ui_console: AgentConsole,
2542
+ ) -> None:
2543
+ lines = ["Permission modes:"]
2544
+ for idx, mode in enumerate(_MODE_CYCLE, 1):
2545
+ marker = "*" if mode == permission.mode else " "
2546
+ lines.append(f" {marker} {idx}) {mode.value:<10} {_MODE_DESCRIPTIONS[mode]}")
2547
+ ui_console.print_status("\n".join(lines))
2548
+ ui_console.print_status(f"Select [1-{len(_MODE_CYCLE)}] on the next prompt.")
2549
+ valid_choices = {str(i) for i in range(1, len(_MODE_CYCLE) + 1)}
2550
+ try:
2551
+ choice = _read_stdio_input().strip()
2552
+ except (EOFError, KeyboardInterrupt):
2553
+ ui_console.print_status("Mode selection cancelled.")
2554
+ return
2555
+
2556
+ if choice in valid_choices:
2557
+ old = permission.mode
2558
+ permission.mode = _MODE_CYCLE[int(choice) - 1]
2559
+ ui_console.print_status(f"Permission mode: {old.value} → {permission.mode.value}")
2560
+ return
2561
+
2562
+ ui_console.print_status("Invalid choice, mode unchanged.")
2563
+
2564
+
2565
+ def _make_plan_approval(
2566
+ permission: PermissionGuard,
2567
+ ui_console: AgentConsole,
2568
+ ) -> Callable[[str], PlanDecision]:
2569
+ """Build the ``exit_plan_mode`` approval callback.
2570
+
2571
+ Renders the proposed plan, then prompts the user for a three-way decision
2572
+ (approve -> DEFAULT, approve+auto -> AUTO, reject -> stay in PLAN). On reject
2573
+ it collects an optional free-text reason fed back to the model. This wiring
2574
+ layer owns the permission-mode flip because it holds both the
2575
+ ``PermissionGuard`` and the UI console; the handler stays pure.
2576
+ """
2577
+
2578
+ def _approve(plan: str) -> PlanDecision:
2579
+ # Defensive: the directive is injected only in PLAN mode, so a
2580
+ # well-behaved model won't call exit_plan_mode otherwise. If it does,
2581
+ # report a no-op rather than silently flipping an unrelated mode.
2582
+ if permission.mode != PermissionMode.PLAN:
2583
+ return PlanDecision("noop")
2584
+ # Approving a plan elevates the permission mode, which is a form of
2585
+ # approval -- a fail-closed guard must never grant it (error-handling.md).
2586
+ # This path only ever holds the main guard (fail_closed=False), but the
2587
+ # check keeps the invariant if the wiring ever changes.
2588
+ if getattr(permission, "fail_closed", False) or not sys.stdin.isatty():
2589
+ return PlanDecision("unavailable")
2590
+
2591
+ ui_console.print_status("Proposed plan:")
2592
+ ui_console.print_assistant(plan)
2593
+ ui_console.print_status(
2594
+ "Approve this plan?\n"
2595
+ " 1) Approve -- switch to DEFAULT (writes still confirmed)\n"
2596
+ " 2) Approve & auto-accept -- switch to AUTO\n"
2597
+ " 3) Reject -- stay in plan mode and revise"
2598
+ )
2599
+ try:
2600
+ choice = _read_stdio_input().strip()
2601
+ except (EOFError, KeyboardInterrupt):
2602
+ ui_console.print_status("Plan approval cancelled; staying in plan mode.")
2603
+ return PlanDecision("reject")
2604
+
2605
+ if choice == "1":
2606
+ _switch_mode_after_approval(permission, ui_console, PermissionMode.DEFAULT)
2607
+ return PlanDecision("approve-default")
2608
+ if choice == "2":
2609
+ _switch_mode_after_approval(permission, ui_console, PermissionMode.AUTO)
2610
+ return PlanDecision("approve-auto")
2611
+
2612
+ # Anything else counts as reject. Collect an optional reason so the model
2613
+ # can revise with guidance instead of guessing.
2614
+ ui_console.print_status("Plan rejected. Optionally enter a reason (blank to skip):")
2615
+ try:
2616
+ reason = _read_stdio_input().strip()
2617
+ except (EOFError, KeyboardInterrupt):
2618
+ reason = ""
2619
+ return PlanDecision("reject", reason)
2620
+
2621
+ return _approve
2622
+
2623
+
2624
+ def _switch_mode_after_approval(
2625
+ permission: PermissionGuard,
2626
+ ui_console: AgentConsole,
2627
+ new_mode: PermissionMode,
2628
+ ) -> None:
2629
+ old = permission.mode
2630
+ permission.mode = new_mode
2631
+ ui_console.print_status(f"Permission mode: {old.value} -> {new_mode.value}")
2632
+
2633
+
2634
+ def _install_plan_handler(
2635
+ handlers: dict[str, Any],
2636
+ plan_approval: Callable[[str], PlanDecision],
2637
+ ) -> None:
2638
+ """Register the exit_plan_mode handler on a (re)built handler dict.
2639
+
2640
+ Called after every ``_build_handlers`` in the main loop -- session switches
2641
+ (/new, /compact, /resume, /import) rebuild ``handlers`` from scratch and
2642
+ would otherwise drop the main-loop-only exit_plan_mode handler, leaving its
2643
+ schema in ``tools`` with no handler behind it.
2644
+ """
2645
+ handlers["exit_plan_mode"] = partial(run_exit_plan_mode, approve_fn=plan_approval)
2646
+
2647
+
2648
+ def _run_node_batch(
2649
+ thunks: list[Callable[[], NodeResult]],
2650
+ max_concurrency: int,
2651
+ ) -> list[NodeResult]:
2652
+ """Run a batch of (never-raising) node thunks concurrently, preserving order.
2653
+
2654
+ A single node skips the thread-pool overhead. ``KeyboardInterrupt`` (delivered
2655
+ to this main thread while blocked on results) cancels pending nodes and tears
2656
+ the executor down without waiting, then propagates so the workflow tool call
2657
+ aborts cleanly (in-flight nodes finish in the background). Worker threads only
2658
+ run ``run_subagent`` (which is silent -- no console / messages), mirroring how
2659
+ the ``/loop`` scheduler keeps execution off the REPL-owned UI thread.
2660
+ """
2661
+ if not thunks:
2662
+ return []
2663
+ if len(thunks) == 1:
2664
+ return [thunks[0]()]
2665
+
2666
+ workers = max(1, min(max_concurrency, len(thunks)))
2667
+ executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="wf-node")
2668
+ futures = [executor.submit(thunk) for thunk in thunks]
2669
+ try:
2670
+ return [future.result() for future in futures]
2671
+ except KeyboardInterrupt:
2672
+ for future in futures:
2673
+ future.cancel()
2674
+ executor.shutdown(wait=False, cancel_futures=True)
2675
+ raise
2676
+ finally:
2677
+ executor.shutdown(wait=False)
2678
+
2679
+
2680
+ def _install_workflow_handler(
2681
+ handlers: dict[str, Any],
2682
+ *,
2683
+ enabled: bool,
2684
+ provider: BaseLLMProvider,
2685
+ base_tools: list[dict[str, Any]],
2686
+ permission: PermissionGuard,
2687
+ bg_manager: BackgroundManager,
2688
+ console: AgentConsole,
2689
+ retry_policy: RetryPolicy,
2690
+ max_depth: int,
2691
+ default_agent_type: str,
2692
+ max_concurrency: int,
2693
+ max_nodes: int,
2694
+ registry: WorkflowRegistry,
2695
+ default_token_budget: int,
2696
+ ) -> None:
2697
+ """Install the main-loop-only ``workflow`` handler on a (re)built handler dict.
2698
+
2699
+ Like ``_install_plan_handler``, this is re-run after every ``_build_handlers``
2700
+ (session switches rebuild ``handlers`` from scratch). It binds the node
2701
+ executor to *this* handler dict so nodes inherit the current tools/handlers;
2702
+ ``run_subagent`` filters the main-loop-only tools (``workflow`` itself,
2703
+ ``exit_plan_mode``) back out per node. Disabled config -> no install (the
2704
+ schema is also withheld from ``loop_tools``), so the feature fully short
2705
+ -circuits. Node subagents run fail-closed (no prompts from worker threads),
2706
+ the same unattended stance as background subagents and ``/loop``.
2707
+
2708
+ Each call registers a :class:`WorkflowRun` so the ``/workflows`` panel and
2709
+ ``resume`` can see it. ``run_in_background`` runs the whole DAG in a daemon
2710
+ thread (the result is injected later by ``_drain_workflow_results``);
2711
+ ``resume_from`` reuses a prior run's unchanged nodes; ``token_budget`` (with
2712
+ the per-node ``TokenTracker``s summed under a lock) caps the run at layer
2713
+ boundaries.
2714
+ """
2715
+ if not enabled:
2716
+ return
2717
+ node_handlers = handlers
2718
+
2719
+ def handler(**kwargs: Any) -> str:
2720
+ validated = validate_workflow_input(kwargs.get("nodes"), max_nodes=max_nodes)
2721
+ if isinstance(validated, str):
2722
+ return validated # immediate feedback for a malformed DAG (sync or bg)
2723
+ spec = validated
2724
+
2725
+ tool_budget = kwargs.get("token_budget")
2726
+ valid_budget = (
2727
+ isinstance(tool_budget, int) and not isinstance(tool_budget, bool) and tool_budget > 0
2728
+ )
2729
+ effective_budget = int(tool_budget) if valid_budget else default_token_budget
2730
+ resume_from = kwargs.get("resume_from")
2731
+ background = bool(kwargs.get("run_in_background"))
2732
+
2733
+ run_id = registry.generate_id()
2734
+ registry.start(run_id, spec, background=background, token_budget=effective_budget)
2735
+
2736
+ # Per-run token accounting. Each node uses its OWN TokenTracker (thread
2737
+ # -local, no shared mutation); its total is folded into a locked
2738
+ # accumulator after the node returns, so the layer-boundary budget check
2739
+ # reads a consistent figure even with nodes running concurrently.
2740
+ budget_lock = threading.Lock()
2741
+ spent = [0]
2742
+
2743
+ def tokens_spent() -> int:
2744
+ with budget_lock:
2745
+ return spent[0]
2746
+
2747
+ def execute_node(node: WorkflowNode, upstream: dict[str, NodeResult]) -> str:
2748
+ node_permission: Any = permission
2749
+ if isinstance(permission, PermissionGuard):
2750
+ node_permission = permission.clone(fail_closed=True)
2751
+ node_tracker = TokenTracker()
2752
+ output = run_subagent(
2753
+ provider=provider,
2754
+ task=build_node_prompt(node, upstream),
2755
+ tools=base_tools,
2756
+ handlers=node_handlers,
2757
+ permission=node_permission,
2758
+ max_depth=max_depth,
2759
+ agent_type=node.agent_type,
2760
+ bg_manager=bg_manager,
2761
+ default_agent_type=default_agent_type,
2762
+ retry_policy=retry_policy,
2763
+ token_tracker=node_tracker,
2764
+ )
2765
+ with budget_lock:
2766
+ spent[0] += node_tracker.total_tokens
2767
+ current = spent[0]
2768
+ registry.set_tokens(run_id, current)
2769
+ return output
2770
+
2771
+ # Background nodes run off the REPL thread, so progress must NOT touch the
2772
+ # console (thread-safety rule, see Scheduler); the panel reflects status
2773
+ # instead. Sync runs keep the live console progress.
2774
+ progress = (lambda _message: None) if background else console.print_status
2775
+
2776
+ def drive() -> str:
2777
+ summary = run_workflow_tool(
2778
+ spec=spec,
2779
+ resume_from=resume_from if isinstance(resume_from, str) else None,
2780
+ token_budget=effective_budget,
2781
+ execute_node=execute_node,
2782
+ map_concurrent=lambda thunks: _run_node_batch(thunks, max_concurrency),
2783
+ on_progress=progress,
2784
+ on_node_status=lambda node_id, result: registry.update_node(
2785
+ run_id, node_id, result
2786
+ ),
2787
+ resolve_prior=registry.get_for_resume,
2788
+ tokens_spent=tokens_spent,
2789
+ max_nodes=max_nodes,
2790
+ )
2791
+ registry.finish(run_id, summary=summary, tokens_spent=tokens_spent())
2792
+ return summary
2793
+
2794
+ if background:
2795
+
2796
+ def background_runner() -> str:
2797
+ try:
2798
+ return drive()
2799
+ except Exception as exc: # noqa: BLE001 - surface, don't lose, a bg crash
2800
+ registry.finish(
2801
+ run_id,
2802
+ summary=f"Error: workflow crashed: {type(exc).__name__}: {exc}",
2803
+ tokens_spent=tokens_spent(),
2804
+ status=RunStatus.FAILED,
2805
+ error=f"{type(exc).__name__}: {exc}",
2806
+ )
2807
+ return ""
2808
+
2809
+ bg_manager.submit(run_id, background_runner)
2810
+ return (
2811
+ f"Workflow {run_id} started in the background ({len(spec.nodes)} node(s)). "
2812
+ "Watch it with /workflows; its result will be delivered when it finishes."
2813
+ )
2814
+
2815
+ return drive() + f"\n\n[workflow run id: {run_id}]"
2816
+
2817
+ handlers["workflow"] = handler
2818
+
2819
+
2820
+ def _install_subagent_send_handler(
2821
+ handlers: dict[str, Any],
2822
+ *,
2823
+ registry: SubagentRegistry,
2824
+ ) -> None:
2825
+ """Install the main-loop-only ``subagent_send`` handler on a (re)built dict.
2826
+
2827
+ Like ``_install_workflow_handler`` / ``_install_plan_handler``, this re-runs
2828
+ after every ``_build_handlers`` (session switches rebuild ``handlers`` from
2829
+ scratch). Everything needed to re-enter ``agent_loop`` lives in the stored
2830
+ ``ResumableContext`` (provider / tools / handlers / permission / compactor /
2831
+ turn budget / retry policy), so the closure only needs the registry. The
2832
+ schema is also kept out of the base ``tools`` and listed in
2833
+ ``MAIN_LOOP_ONLY_TOOLS``, so no sub-agent ever sees this tool.
2834
+ """
2835
+
2836
+ def run_loop(context: ResumableContext) -> str:
2837
+ return agent_loop(
2838
+ provider=context.provider,
2839
+ messages=context.messages,
2840
+ tools=context.tools,
2841
+ handlers=context.handlers,
2842
+ permission=context.permission,
2843
+ compact_fn=context.compact_fn,
2844
+ bg_manager=None,
2845
+ max_iterations=context.max_turns,
2846
+ retry_policy=context.retry_policy,
2847
+ )
2848
+
2849
+ def handler(agent_id: str = "", message: str = "", **_: Any) -> str:
2850
+ return run_subagent_send(
2851
+ agent_id,
2852
+ message,
2853
+ registry=registry,
2854
+ run_loop=run_loop,
2855
+ )
2856
+
2857
+ handlers["subagent_send"] = handler
2858
+
2859
+
2860
+ def _install_mcp_cleanup(mcp_manager: MCPManager) -> None:
2861
+ """Register exit-time + SIGTERM hooks so MCP subprocesses are reaped.
2862
+
2863
+ ``atexit`` catches ``return`` from ``main()`` and any ``sys.exit()``;
2864
+ the SIGTERM handler converts a polite termination into ``sys.exit(130)``
2865
+ so ``atexit`` actually fires (raw SIGTERM bypasses it). SIGINT is
2866
+ intentionally left alone — prompt-toolkit + the existing
2867
+ ``KeyboardInterrupt`` handling in the REPL loop already cover Ctrl+C.
2868
+ """
2869
+ atexit.register(mcp_manager.close_all)
2870
+ try:
2871
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(130))
2872
+ except (ValueError, OSError): # pragma: no cover — non-main thread / unsupported OS
2873
+ pass
2874
+
2875
+
2876
+ def _install_lsp_cleanup(lsp_manager: LanguageServerManager) -> None:
2877
+ """Register an idempotent atexit hook for the LSP manager.
2878
+
2879
+ Deliberately decoupled from :func:`_install_mcp_cleanup`: ``atexit`` fires
2880
+ callbacks LIFO regardless of registration order, and
2881
+ ``lsp_manager.close_all`` is guaranteed idempotent (see manager docstring)
2882
+ so duplicate registration from a future caller is safe. The SIGTERM
2883
+ handler is already installed by the MCP path; we rely on that to convert
2884
+ the signal into ``sys.exit(130)`` so this atexit hook actually fires.
2885
+ """
2886
+ atexit.register(lsp_manager.close_all)
2887
+
2888
+
2889
+ _LSP_COMMAND_USAGE = "Usage: /lsp <status|list|reload <language>>"
2890
+
2891
+
2892
+ def _dispatch_lsp_command(
2893
+ text: str,
2894
+ *,
2895
+ lsp_manager: LanguageServerManager,
2896
+ ui_console: AgentConsole,
2897
+ ) -> None:
2898
+ """Handle the space-prefixed ``/lsp <subcommand>`` REPL command.
2899
+
2900
+ Mirrors the ``/mcp`` command shape: ``status`` shows per-server health,
2901
+ ``list`` enumerates the ``lsp_*`` tools available right now (only for
2902
+ RUNNING servers), and ``reload <language>`` rebuilds one server.
2903
+ Feedback flows through ``ui_console``; the routine never raises.
2904
+ """
2905
+ tokens = text.split()
2906
+ if len(tokens) <= 1:
2907
+ ui_console.print_status(_LSP_COMMAND_USAGE)
2908
+ return
2909
+ sub = tokens[1]
2910
+ if sub == "status":
2911
+ rows = lsp_manager.summarize()
2912
+ if not rows:
2913
+ ui_console.print_status("(no LSP servers configured)")
2914
+ return
2915
+ for row in rows:
2916
+ extensions = ", ".join(row["extensions"]) or "-"
2917
+ line = (
2918
+ f"{row['language']}: {row['status']} [{row['tool_count']} tools, ext={extensions}]"
2919
+ )
2920
+ reason = row.get("reason") or ""
2921
+ if reason:
2922
+ line = f"{line} — {reason}"
2923
+ ui_console.print_status(line)
2924
+ return
2925
+ if sub == "list":
2926
+ any_server = False
2927
+ for language, _server in lsp_manager.iter_running():
2928
+ any_server = True
2929
+ ui_console.print_status(f"[{language}]")
2930
+ # The four Tier-1 tools are uniform across servers; list them so
2931
+ # users see exactly what the LLM has access to right now.
2932
+ for tool in (
2933
+ "lsp_outline",
2934
+ "lsp_definition",
2935
+ "lsp_references",
2936
+ "lsp_diagnostics",
2937
+ ):
2938
+ ui_console.print_status(f" {tool}")
2939
+ if not any_server:
2940
+ ui_console.print_status("(no LSP servers running)")
2941
+ return
2942
+ if sub == "reload":
2943
+ if len(tokens) < 3:
2944
+ ui_console.print_error("Usage: /lsp reload <language>")
2945
+ return
2946
+ target = tokens[2]
2947
+ try:
2948
+ lsp_manager.reload(target)
2949
+ except LSPError as exc:
2950
+ ui_console.print_error(f"reload {target!r} failed: {exc}")
2951
+ ui_console.print_error(f"LSP server {target!r} is now UNHEALTHY.")
2952
+ return
2953
+ except Exception as exc:
2954
+ ui_console.print_error(f"reload {target!r} failed: {exc}")
2955
+ ui_console.print_error(f"LSP server {target!r} is now UNHEALTHY.")
2956
+ return
2957
+ ui_console.print_status(f"LSP server {target!r} reloaded.")
2958
+ return
2959
+ ui_console.print_error(f"Unknown /lsp subcommand: {sub}. Use status, list, or reload.")
2960
+
2961
+
2962
+ _LOOP_COMMAND_USAGE = (
2963
+ "Usage:\n"
2964
+ " /loop <seconds> <command...> Schedule a command to repeat every N seconds\n"
2965
+ " /loop list List scheduled commands\n"
2966
+ " /loop cancel <job_id> Cancel one scheduled command\n"
2967
+ " /loop clear Cancel all scheduled commands\n"
2968
+ "Note: scheduled commands run WITHOUT permission prompts (no human to confirm)."
2969
+ )
2970
+
2971
+
2972
+ def _dispatch_loop_command(
2973
+ text: str,
2974
+ *,
2975
+ scheduler: Scheduler,
2976
+ ui_console: AgentConsole,
2977
+ ) -> None:
2978
+ """Handle the ``/loop`` REPL command for the cron-style scheduler.
2979
+
2980
+ Forms: ``/loop`` / ``/loop list`` (list), ``/loop <seconds> <command...>``
2981
+ (create), ``/loop cancel <job_id>`` (cancel one), ``/loop clear`` (cancel
2982
+ all). Scheduled commands run via the background pool WITHOUT permission
2983
+ confirmation, so the create path echoes that warning. Never raises.
2984
+ """
2985
+ rest = text[len("/loop") :].strip()
2986
+ if not rest or rest == "list":
2987
+ jobs = scheduler.list()
2988
+ if not jobs:
2989
+ ui_console.print_status(f"(no scheduled commands)\n{_LOOP_COMMAND_USAGE}")
2990
+ return
2991
+ for job in jobs:
2992
+ ui_console.print_status(
2993
+ f"{job.job_id}: every {job.interval_sec:g}s, runs={job.run_count} — {job.command}"
2994
+ )
2995
+ return
2996
+
2997
+ first, _, remainder = rest.partition(" ")
2998
+ if first == "cancel":
2999
+ job_id = remainder.strip()
3000
+ if not job_id:
3001
+ ui_console.print_error("Usage: /loop cancel <job_id>")
3002
+ return
3003
+ if scheduler.cancel(job_id):
3004
+ ui_console.print_status(f"Cancelled scheduled command: {job_id}")
3005
+ else:
3006
+ ui_console.print_error(f"No scheduled command found: {job_id}")
3007
+ return
3008
+ if first == "clear":
3009
+ scheduler.cancel_all()
3010
+ ui_console.print_status("Cancelled all scheduled commands.")
3011
+ return
3012
+
3013
+ # Create form: first token is the interval in seconds, the rest is the
3014
+ # command verbatim (keep internal spaces).
3015
+ try:
3016
+ interval_sec = float(first)
3017
+ except ValueError:
3018
+ ui_console.print_error(
3019
+ f"Invalid interval {first!r}: expected a number of seconds.\n{_LOOP_COMMAND_USAGE}"
3020
+ )
3021
+ return
3022
+ command = remainder.strip()
3023
+ if not command:
3024
+ ui_console.print_error(f"Missing command to schedule.\n{_LOOP_COMMAND_USAGE}")
3025
+ return
3026
+ try:
3027
+ job = scheduler.add(interval_sec, command)
3028
+ except SchedulerError as exc:
3029
+ ui_console.print_error(str(exc))
3030
+ return
3031
+ ui_console.print_status(
3032
+ f"Scheduled {job.job_id}: every {job.interval_sec:g}s — {job.command}\n"
3033
+ "Warning: this command runs WITHOUT permission confirmation in the background."
3034
+ )
3035
+
3036
+
3037
+ _WORKFLOWS_COMMAND_USAGE = "Usage: /workflows [list] | /workflows <run-id> | /workflows clear"
3038
+
3039
+
3040
+ def _humanize_age(seconds: float) -> str:
3041
+ """Render an elapsed-seconds duration compactly (e.g. ``5s``, ``3m``, ``1h2m``)."""
3042
+ seconds = max(0, int(seconds))
3043
+ if seconds < 60:
3044
+ return f"{seconds}s"
3045
+ minutes, sec = divmod(seconds, 60)
3046
+ if minutes < 60:
3047
+ return f"{minutes}m{sec}s" if sec else f"{minutes}m"
3048
+ hours, minutes = divmod(minutes, 60)
3049
+ return f"{hours}h{minutes}m" if minutes else f"{hours}h"
3050
+
3051
+
3052
+ def _format_workflow_run_line(run: WorkflowRun, now: float) -> str:
3053
+ """One-line panel summary of a run (pure; ``now`` injected for testability)."""
3054
+ counts = run.counts()
3055
+ parts = [f"{counts['done']} done"]
3056
+ if counts["reused"]:
3057
+ parts.append(f"{counts['reused']} reused")
3058
+ if counts["failed"]:
3059
+ parts.append(f"{counts['failed']} failed")
3060
+ if counts["skipped"]:
3061
+ parts.append(f"{counts['skipped']} skipped")
3062
+ if counts["running"]:
3063
+ parts.append(f"{counts['running']} running")
3064
+ total = len(run.spec.nodes)
3065
+ budget = f"/{run.token_budget}" if run.token_budget else ""
3066
+ flag = " [bg]" if run.background else ""
3067
+ age = _humanize_age(now - run.started_at)
3068
+ return (
3069
+ f"{run.run_id}: {run.status.value}{flag} — {total} node(s) "
3070
+ f"[{', '.join(parts)}], {run.tokens_spent}{budget} tok, {age} ago"
3071
+ )
3072
+
3073
+
3074
+ def _format_workflow_run_detail(run: WorkflowRun, now: float, *, preview: int = 200) -> str:
3075
+ """Multi-line per-node detail of one run (pure; ``now`` injected)."""
3076
+ lines = [_format_workflow_run_line(run, now)]
3077
+ for node in run.spec.nodes:
3078
+ result = run.results.get(node.id)
3079
+ if result is None:
3080
+ continue
3081
+ marker = "reused" if result.reused else result.status.value
3082
+ head = f" [{marker}] {node.id}"
3083
+ if node.phase:
3084
+ head += f" (phase: {node.phase})"
3085
+ if node.label:
3086
+ head += f" - {node.label}"
3087
+ lines.append(head)
3088
+ body = (result.output if result.status is NodeStatus.DONE else result.error).strip()
3089
+ if body:
3090
+ snippet = body[:preview] + ("..." if len(body) > preview else "")
3091
+ lines.append(f" {snippet}")
3092
+ return "\n".join(lines)
3093
+
3094
+
3095
+ def _dispatch_workflows_command(
3096
+ text: str,
3097
+ *,
3098
+ registry: WorkflowRegistry,
3099
+ ui_console: AgentConsole,
3100
+ ) -> None:
3101
+ """Handle the ``/workflows`` REPL command (panel; never raises).
3102
+
3103
+ Forms: ``/workflows`` / ``/workflows list`` (list this session's runs),
3104
+ ``/workflows <run-id>`` (per-node detail), ``/workflows clear`` (drop finished
3105
+ runs). Read-only over the in-memory registry -- no LLM, no permission gate.
3106
+ """
3107
+ rest = text[len("/workflows") :].strip()
3108
+ now = time.time()
3109
+ if not rest or rest == "list":
3110
+ runs = registry.snapshot()
3111
+ if not runs:
3112
+ ui_console.print_status(f"(no workflow runs)\n{_WORKFLOWS_COMMAND_USAGE}")
3113
+ return
3114
+ for run in runs:
3115
+ ui_console.print_status(_format_workflow_run_line(run, now))
3116
+ return
3117
+ if rest == "clear":
3118
+ removed = registry.clear_finished()
3119
+ ui_console.print_status(f"Cleared {removed} finished workflow run(s).")
3120
+ return
3121
+ run = registry.get(rest)
3122
+ if run is None:
3123
+ ui_console.print_error(f"No workflow run found: {rest}\n{_WORKFLOWS_COMMAND_USAGE}")
3124
+ return
3125
+ ui_console.print_status(_format_workflow_run_detail(run, now))
3126
+
3127
+
3128
+ def _run_skill_reflection(
3129
+ *,
3130
+ provider: Any,
3131
+ messages: list[dict[str, Any]],
3132
+ store: SkillStore,
3133
+ skill_loader: SkillLoader,
3134
+ console: AgentConsole,
3135
+ token_tracker: Any,
3136
+ permission: Any,
3137
+ max_pending: int,
3138
+ ) -> None:
3139
+ """Reflect on the just-finished session and draft (or evolve) a skill.
3140
+
3141
+ Runs an isolated ``agent_loop`` on a COPY of the conversation. ``skill_create``
3142
+ is the only write tool, so the real history / turn result stay clean and
3143
+ normal turns / sub-agents never see it. When generated skills already exist,
3144
+ the reflection additionally lists them as refinement targets and exposes
3145
+ read-only ``load_skill`` so the model can read one and supersede it with an
3146
+ improved same-name draft (self-evolution). Canon (repo) skill names are
3147
+ reserved so a generated skill never shadows them. The model may decline
3148
+ (reply "no skill"). Never raises — a reflection failure must not break the
3149
+ REPL.
3150
+ """
3151
+ console.print_status("Reflecting on this session to draft a reusable skill...")
3152
+ # Evolution candidates = generated *live* skills only (pending excluded by
3153
+ # the one-level glob; canon excluded by scanning the generated root alone).
3154
+ candidates = [(meta.skill_name, meta.description) for meta in SkillLoader(store.root).scan()]
3155
+ reserved_names = skill_loader.canon_skill_names()
3156
+ reflection_messages = list(messages)
3157
+ reflection_messages.append({"role": "user", "content": render_reflection_prompt(candidates)})
3158
+ draft_handlers: dict[str, Any] = {
3159
+ "skill_create": partial(run_skill_create, store=store, reserved_names=reserved_names)
3160
+ }
3161
+ draft_tools = [SKILL_CREATE_TOOL_SCHEMA]
3162
+ # Only offer the read tool when there is something to refine, so the no-skill
3163
+ # case stays byte-identical to the create-only behavior.
3164
+ if candidates:
3165
+ draft_tools = [SKILL_CREATE_TOOL_SCHEMA, *LOAD_SKILL_TOOL_SCHEMAS]
3166
+ draft_handlers["load_skill"] = skill_loader.load
3167
+ try:
3168
+ agent_loop(
3169
+ provider=provider,
3170
+ messages=reflection_messages,
3171
+ tools=draft_tools,
3172
+ handlers=draft_handlers,
3173
+ permission=permission,
3174
+ stream=False,
3175
+ console=console,
3176
+ max_iterations=6,
3177
+ token_tracker=token_tracker,
3178
+ skill_gen=None,
3179
+ )
3180
+ except (LLMCallError, KeyboardInterrupt):
3181
+ console.print_status("Skill reflection skipped (interrupted or LLM error).")
3182
+ return
3183
+ except Exception as exc: # never let reflection break the REPL
3184
+ console.print_error(f"Skill reflection failed: {type(exc).__name__}: {exc}")
3185
+ return
3186
+ removed = store.prune_pending(max_pending)
3187
+ pending = store.list_pending()
3188
+ if pending:
3189
+ console.print_status(
3190
+ "Pending skills: "
3191
+ + ", ".join(pending)
3192
+ + " — /skill keep <name> to keep, /skill discard <name> to drop."
3193
+ )
3194
+ if removed:
3195
+ console.print_status(f"Pruned old pending drafts: {', '.join(removed)}")
3196
+
3197
+
3198
+ def _run_goal_evaluator(
3199
+ *,
3200
+ provider: BaseLLMProvider,
3201
+ messages: list[dict[str, Any]],
3202
+ condition: str,
3203
+ console: AgentConsole,
3204
+ token_tracker: Any,
3205
+ permission: Any,
3206
+ ) -> Verdict:
3207
+ """Isolated evaluator: judge whether ``condition`` is met from the transcript.
3208
+
3209
+ Mirrors :func:`_run_skill_reflection`: runs ``agent_loop`` on a COPY of the
3210
+ messages with ``goal_verdict`` as the only tool, so real history / turn
3211
+ results stay clean. Never raises for ordinary failures — an LLM error or a
3212
+ missing verdict yields a malformed (= not met) verdict so the loop falls
3213
+ through to its ``max_turns`` guard rather than crashing. ``KeyboardInterrupt``
3214
+ propagates so the user can abort the whole goal loop.
3215
+ """
3216
+ sink: list[Verdict] = []
3217
+ eval_messages = list(messages)
3218
+ eval_messages.append({"role": "user", "content": build_evaluator_prompt(condition)})
3219
+ try:
3220
+ agent_loop(
3221
+ provider=provider,
3222
+ messages=eval_messages,
3223
+ tools=[GOAL_VERDICT_TOOL_SCHEMA],
3224
+ handlers={"goal_verdict": partial(run_goal_verdict, sink=sink)},
3225
+ permission=permission,
3226
+ stream=False,
3227
+ console=console,
3228
+ max_iterations=3,
3229
+ token_tracker=token_tracker,
3230
+ skill_gen=None,
3231
+ )
3232
+ except KeyboardInterrupt:
3233
+ raise
3234
+ except Exception as exc: # noqa: BLE001 - evaluator failure must not break the loop
3235
+ console.print_error(f"Goal evaluator failed: {type(exc).__name__}: {exc}")
3236
+ return Verdict(met=False, reason="evaluator error", malformed=True)
3237
+ if not sink:
3238
+ return Verdict(met=False, reason="evaluator returned no verdict", malformed=True)
3239
+ return sink[-1]
3240
+
3241
+
3242
+ def _drive_goal(
3243
+ command: Any,
3244
+ *,
3245
+ provider: BaseLLMProvider,
3246
+ evaluator_provider: BaseLLMProvider,
3247
+ messages: list[dict[str, Any]],
3248
+ loop_tools: list[dict[str, Any]],
3249
+ handlers: dict[str, Any],
3250
+ permission: PermissionGuard,
3251
+ compact_fn: Any,
3252
+ bg_manager: Any,
3253
+ config: Config,
3254
+ ui_console: AgentConsole,
3255
+ interaction_logger: Any,
3256
+ token_tracker: Any,
3257
+ hook_engine: Any,
3258
+ retry_policy: RetryPolicy,
3259
+ transcript_mgr: Any,
3260
+ ) -> None:
3261
+ """Run the synchronous self-driving goal loop for a parsed ``run`` command.
3262
+
3263
+ Each turn runs the real ``agent_loop`` (sharing the main loop's tools /
3264
+ handlers / permission), then an isolated evaluator decides whether to stop.
3265
+ The loop respects the current permission mode (never auto-escalates); in
3266
+ DEFAULT it warns that writes still prompt each turn. ``skill_gen`` is omitted
3267
+ so the inner turns never trigger skill reflection. Interrupts / LLM errors
3268
+ roll back the in-flight turn and abort the loop, leaving completed turns.
3269
+ """
3270
+ state = GoalState(condition=command.condition, max_turns=command.max_turns)
3271
+ if permission.mode == PermissionMode.DEFAULT:
3272
+ ui_console.print_status(
3273
+ "Goal set in DEFAULT mode: write operations still prompt each turn. "
3274
+ "Use /auto first for an unattended run."
3275
+ )
3276
+ ui_console.print_status(f"Goal: {state.condition} (max {state.max_turns} turns)")
3277
+
3278
+ def run_turn(prompt: str) -> None:
3279
+ snapshot = len(messages)
3280
+ messages.append({"role": "user", "content": prompt})
3281
+ try:
3282
+ agent_loop(
3283
+ provider=provider,
3284
+ messages=messages,
3285
+ tools=loop_tools,
3286
+ handlers=handlers,
3287
+ permission=permission,
3288
+ compact_fn=compact_fn,
3289
+ bg_manager=bg_manager,
3290
+ stream=config.ui.stream,
3291
+ console=ui_console,
3292
+ interaction_logger=interaction_logger,
3293
+ token_tracker=token_tracker,
3294
+ hook_engine=hook_engine,
3295
+ retry_policy=retry_policy,
3296
+ skill_gen=None,
3297
+ )
3298
+ _save_transcript_snapshot(transcript_mgr, messages, compact_fn)
3299
+ except (LLMCallError, KeyboardInterrupt):
3300
+ del messages[snapshot:]
3301
+ raise
3302
+
3303
+ def evaluate() -> Verdict:
3304
+ return _run_goal_evaluator(
3305
+ provider=evaluator_provider,
3306
+ messages=messages,
3307
+ condition=state.condition,
3308
+ console=ui_console,
3309
+ token_tracker=token_tracker,
3310
+ permission=permission,
3311
+ )
3312
+
3313
+ try:
3314
+ outcome, verdict = run_goal_loop(
3315
+ state,
3316
+ run_turn=run_turn,
3317
+ evaluate=evaluate,
3318
+ on_progress=ui_console.print_status,
3319
+ )
3320
+ except KeyboardInterrupt:
3321
+ ui_console.print_status(f"Goal aborted after {state.turns_used} turn(s).")
3322
+ return
3323
+ except LLMCallError:
3324
+ ui_console.print_error(
3325
+ f"Goal aborted: LLM call failed after {state.turns_used} turn(s)."
3326
+ )
3327
+ return
3328
+
3329
+ if outcome is GoalOutcome.MET:
3330
+ ui_console.print_status(f"Goal met after {state.turns_used} turn(s).")
3331
+ else:
3332
+ last_reason = verdict.reason if verdict else ""
3333
+ msg = f"Goal not met after {state.turns_used} turn(s) (max turns reached)."
3334
+ if last_reason:
3335
+ msg += f" Last evaluator note: {last_reason}"
3336
+ ui_console.print_status(msg)
3337
+
3338
+
3339
+ def _print_skill_list(store: SkillStore, loader: SkillLoader, console: AgentConsole) -> None:
3340
+ live = [meta.skill_name for meta in loader.scan()]
3341
+ live_set = set(live)
3342
+ pending = store.list_pending()
3343
+ lines = ["Loadable skills:"]
3344
+ lines += [f" - {name}" for name in live] or [" (none)"]
3345
+ lines.append("Pending drafts:")
3346
+ pending_lines = []
3347
+ for name in pending:
3348
+ # A pending draft whose name matches a loadable skill is a revision that
3349
+ # will REPLACE the live version on /skill keep (self-evolution).
3350
+ revision = f" (revision of live '{name}')" if name in live_set else ""
3351
+ pending_lines.append(
3352
+ f" - {name}{revision} (/skill keep {name} | /skill discard {name})"
3353
+ )
3354
+ lines += pending_lines or [" (none)"]
3355
+ console.print_status("\n".join(lines))
3356
+
3357
+
3358
+ def _dispatch_skill_command(
3359
+ text: str,
3360
+ *,
3361
+ store: SkillStore,
3362
+ loader: SkillLoader,
3363
+ console: AgentConsole,
3364
+ ) -> None:
3365
+ """Handle ``/skill`` (``list`` | ``keep <name>`` | ``discard <name>``).
3366
+
3367
+ Never raises — fails safe with an error line so a bad argument can't crash
3368
+ the REPL (same stance as the other ``_dispatch_*`` commands).
3369
+ """
3370
+ parts = text.split()
3371
+ sub = parts[1].lower() if len(parts) > 1 else "list"
3372
+ arg = parts[2] if len(parts) > 2 else ""
3373
+ try:
3374
+ if sub == "list":
3375
+ _print_skill_list(store, loader, console)
3376
+ return
3377
+ if sub == "keep":
3378
+ if not arg:
3379
+ console.print_error("Usage: /skill keep <name>")
3380
+ return
3381
+ console.print_status(store.promote(arg))
3382
+ # Refresh the cache so the promoted skill is loadable this session.
3383
+ loader.scan()
3384
+ return
3385
+ if sub == "discard":
3386
+ if not arg:
3387
+ console.print_error("Usage: /skill discard <name>")
3388
+ return
3389
+ console.print_status(store.discard(arg))
3390
+ return
3391
+ console.print_error(
3392
+ f"Unknown /skill subcommand {sub!r}. "
3393
+ "Use: /skill [list | keep <name> | discard <name>]."
3394
+ )
3395
+ except SkillStoreError as exc:
3396
+ console.print_error(f"Error: {exc}")
3397
+ except Exception as exc: # never raise out of a slash command
3398
+ console.print_error(f"/skill failed: {type(exc).__name__}: {exc}")
3399
+
3400
+
3401
+ def _dispatch_export_command(
3402
+ text: str,
3403
+ *,
3404
+ messages: list[dict[str, Any]],
3405
+ session_id: str,
3406
+ workspace_path: Path,
3407
+ ui_console: AgentConsole,
3408
+ ) -> None:
3409
+ """Handle the ``/export`` REPL command.
3410
+
3411
+ Forms: ``/export`` (markdown default), ``/export <format>`` and
3412
+ ``/export <format> <path>`` where format ∈ {markdown, md, json}; the first
3413
+ token, when not a known format, is treated as an explicit path with the
3414
+ default markdown format. Markdown skips system messages and omits thinking;
3415
+ JSON is a faithful self-contained wrapper. Defaults to
3416
+ ``.transcripts/exports/<session>_<ts>.{md,json}``. Runs without permission
3417
+ confirmation (user-initiated, infrastructure tier). Never raises.
3418
+ """
3419
+ try:
3420
+ rest = text[len("/export") :].strip()
3421
+ parts = rest.split(maxsplit=1)
3422
+ if parts and parts[0] in ("markdown", "md", "json"):
3423
+ fmt = "markdown" if parts[0] in ("markdown", "md") else "json"
3424
+ user_path = parts[1].strip() if len(parts) > 1 else ""
3425
+ else:
3426
+ fmt = "markdown"
3427
+ user_path = rest
3428
+
3429
+ ext = "json" if fmt == "json" else "md"
3430
+ if user_path:
3431
+ path = Path(user_path).expanduser()
3432
+ if not path.is_absolute():
3433
+ path = workspace_path / path
3434
+ else:
3435
+ timestamp = datetime.now().strftime(_SESSION_ID_TIMESTAMP_FORMAT)
3436
+ path = workspace_path / ".transcripts" / "exports" / f"{session_id}_{timestamp}.{ext}"
3437
+
3438
+ if fmt == "json":
3439
+ content = to_export_json(
3440
+ messages,
3441
+ session_id=session_id,
3442
+ exported_at=datetime.now().isoformat(),
3443
+ )
3444
+ else:
3445
+ content = render_markdown(messages, title=f"Conversation {session_id}")
3446
+
3447
+ path.parent.mkdir(parents=True, exist_ok=True)
3448
+ atomic_write_text(path, content)
3449
+ ui_console.print_status(f"Exported to {path}")
3450
+ except Exception as exc: # noqa: BLE001 - never crash the REPL on export
3451
+ ui_console.print_error(f"Export failed: {exc}")
3452
+
3453
+
3454
+ def _run_stdio_session(
3455
+ config: Config,
3456
+ provider: BaseLLMProvider,
3457
+ *,
3458
+ workspace: Path | None = None,
3459
+ agent_console: AgentConsole | None = None,
3460
+ ) -> int:
3461
+ from bareagent.ui.theme import init_theme
3462
+
3463
+ init_theme(config.ui.theme)
3464
+ ui_console = agent_console or AgentConsole()
3465
+ ui_console.set_theme()
3466
+ workspace_path = (workspace or Path.cwd()).resolve()
3467
+ transcript_mgr = TranscriptManager(workspace_path / ".transcripts")
3468
+ session_id = _generate_session_id(transcript_mgr)
3469
+ interaction_logger = _build_interaction_logger(
3470
+ config,
3471
+ workspace_path,
3472
+ session_id,
3473
+ )
3474
+ _configure_tracing(
3475
+ config,
3476
+ session_id=session_id,
3477
+ interaction_logger=interaction_logger,
3478
+ )
3479
+ viewer_server = None
3480
+ token_tracker = TokenTracker()
3481
+ todo_manager = TodoManager()
3482
+ task_manager = _load_task_manager(workspace_path, ui_console)
3483
+ bg_manager = BackgroundManager()
3484
+ scheduler = Scheduler(
3485
+ runner=partial(run_bash, cwd=workspace_path, raise_on_error=True),
3486
+ notifier=bg_manager,
3487
+ )
3488
+ teammate_manager = _load_teammate_manager(workspace_path, ui_console)
3489
+ # Experiential skill generation: generated skills live under a project-
3490
+ # isolated user-global root (separate from the repo's checked-in canon).
3491
+ # SkillLoader scans both (canon wins on name conflicts); SkillStore owns the
3492
+ # pending drafts + promotion; SkillGenerator owns the trigger decision.
3493
+ generated_skills_root = resolve_generated_skills_root(workspace_path, config.skills.dir)
3494
+ skill_store = SkillStore(generated_skills_root)
3495
+ skill_loader = SkillLoader(resolve_skills_dir(), generated_root=generated_skills_root)
3496
+ skillgen_config = _build_skillgen_config(config.skills)
3497
+ skill_generator = SkillGenerator(skillgen_config) if skillgen_config.enabled else None
3498
+ memory_manager = _build_memory_manager(config, workspace_path, ui_console)
3499
+ message_bus, main_mailbox_cursor = _switch_session_mailbox(
3500
+ workspace_path,
3501
+ session_id,
3502
+ )
3503
+ spawned_agents: dict[str, AutonomousAgent] = {}
3504
+ # Resumable foreground subagents (task 06-06): session-scoped, in-memory,
3505
+ # one instance for the REPL lifetime. Cleared on /new / /resume / /import /
3506
+ # /clear (mirroring spawned_agents) and preserved across /compact.
3507
+ subagent_registry = SubagentRegistry(config.subagent.max_resumable)
3508
+ # Workflow runs (task 06-08): session-scoped, in-memory, thread-safe store
3509
+ # backing the /workflows panel + resume. Same lifecycle as subagent_registry
3510
+ # / spawned_agents -- cleared on /new / /resume / /import / /clear, kept
3511
+ # across /compact.
3512
+ workflow_registry = WorkflowRegistry(config.workflow.max_runs)
3513
+ # Late / unsolicited teammate replies surfaced by the mailbox drain are
3514
+ # buffered here and prepended onto the next user turn (keeps role
3515
+ # alternation intact). Blocking team_send replies bypass this -- they return
3516
+ # straight to the LLM as the tool result.
3517
+ pending_team_messages: list[str] = []
3518
+ # Finished background workflows' full summaries, buffered the same way and
3519
+ # prepended onto the next user turn (see _drain_workflow_results). Same
3520
+ # lifecycle as pending_team_messages.
3521
+ pending_workflow_messages: list[str] = []
3522
+ messages = _initial_messages(
3523
+ workspace_path,
3524
+ skill_summary=skill_loader.get_skill_list_prompt(),
3525
+ memory_context=_memory_context(memory_manager),
3526
+ )
3527
+ mcp_manager = MCPManager(config.mcp, console=ui_console, notifier=bg_manager)
3528
+ mcp_manager.start_all()
3529
+ _install_mcp_cleanup(mcp_manager)
3530
+ lsp_manager = LanguageServerManager(
3531
+ config.lsp,
3532
+ console=ui_console,
3533
+ repository_root=str(workspace_path),
3534
+ notifier=bg_manager,
3535
+ )
3536
+ lsp_manager.start_all()
3537
+ _install_lsp_cleanup(lsp_manager)
3538
+ tools = get_tools(mcp_manager, lsp_manager)
3539
+ permission = _build_permission_guard(config)
3540
+ _install_stdio_permission_prompt(permission, ui_console)
3541
+ read_fn = _build_stdio_read_fn(workspace_path, permission)
3542
+ base_compact_fn = Compactor(
3543
+ provider=provider,
3544
+ transcript_mgr=transcript_mgr,
3545
+ session_id=session_id,
3546
+ )
3547
+ compact_fn = _build_loop_compact(
3548
+ base_compact_fn,
3549
+ todo_manager,
3550
+ memory_manager=memory_manager,
3551
+ recall_k=config.memory.recall_k,
3552
+ permission=permission,
3553
+ )
3554
+ # Hooks only fire in the main loop; sub-agents never receive the engine.
3555
+ hook_engine = HookEngine(config.hooks, console=ui_console)
3556
+ # Sub-agents *do* inherit the retry policy (D6) so background agents weather
3557
+ # transient failures too; threaded through _build_handlers -> get_handlers.
3558
+ retry_policy = _build_retry_policy(config.retry)
3559
+ # Provider for the /goal completion evaluator: a cheaper model if configured,
3560
+ # else the session provider (built once; reused across goal turns).
3561
+ goal_evaluator_provider = _build_goal_provider(config, provider)
3562
+ handlers = _build_handlers(
3563
+ workspace_path=workspace_path,
3564
+ todo_manager=todo_manager,
3565
+ task_manager=task_manager,
3566
+ skill_loader=skill_loader,
3567
+ provider=provider,
3568
+ tools=tools,
3569
+ permission=permission,
3570
+ bg_manager=bg_manager,
3571
+ messages=messages,
3572
+ config=config,
3573
+ runtime_id=session_id,
3574
+ teammate_manager=teammate_manager,
3575
+ message_bus=message_bus,
3576
+ spawned_agents=spawned_agents,
3577
+ agent_name=MAIN_AGENT_NAME,
3578
+ mcp_manager=mcp_manager,
3579
+ lsp_manager=lsp_manager,
3580
+ memory_manager=memory_manager,
3581
+ subagent_registry=subagent_registry,
3582
+ )
3583
+
3584
+ # Plan-mode workflow: exit_plan_mode is a main-loop-only tool. ``tools`` stays
3585
+ # the canonical base list fed to every _build_handlers call (sub-agent and
3586
+ # teammate closures inherit it, so they never see exit_plan_mode); the
3587
+ # augmented ``loop_tools`` is fed ONLY to the top-level agent_loop calls. Its
3588
+ # handler is installed on the live dict (and re-installed after every session
3589
+ # switch below, since those rebuild ``handlers`` from scratch). Defense in
3590
+ # depth: filter_tools also strips MAIN_LOOP_ONLY_TOOLS for every agent type
3591
+ # and filter_handlers drops the orphaned handler. ``plan_approval`` closes
3592
+ # over the permission guard + console, so the same callback is reused on every
3593
+ # rebuild.
3594
+ plan_approval = _make_plan_approval(permission, ui_console)
3595
+ loop_tools = [*tools, EXIT_PLAN_MODE_TOOL_SCHEMA]
3596
+ # Workflow orchestration (task 06-06): ``workflow`` is a main-loop-only tool
3597
+ # like ``exit_plan_mode`` -- its schema joins ``loop_tools`` (never the base
3598
+ # ``tools`` fed to sub-agent closures) and its handler is (re)installed after
3599
+ # every ``_build_handlers`` via ``install_workflow_handler``. When disabled the
3600
+ # schema is withheld and the install is a no-op, so the feature short-circuits.
3601
+ if config.workflow.enabled:
3602
+ loop_tools.append(WORKFLOW_TOOL_SCHEMA)
3603
+ # subagent_send (task 06-06): main-loop-only continuation tool. Always on
3604
+ # (no config gate); schema joins loop_tools only, handler re-installed after
3605
+ # every _build_handlers below. The registry instance is stable for the REPL
3606
+ # lifetime, so the bound install closure stays valid across rebuilds.
3607
+ loop_tools.append(SUBAGENT_SEND_TOOL_SCHEMA)
3608
+ install_subagent_send_handler = partial(
3609
+ _install_subagent_send_handler,
3610
+ registry=subagent_registry,
3611
+ )
3612
+ install_workflow_handler = partial(
3613
+ _install_workflow_handler,
3614
+ enabled=config.workflow.enabled,
3615
+ provider=provider,
3616
+ base_tools=tools,
3617
+ permission=permission,
3618
+ bg_manager=bg_manager,
3619
+ console=ui_console,
3620
+ retry_policy=retry_policy,
3621
+ max_depth=config.subagent.max_depth,
3622
+ default_agent_type=config.subagent.default_type,
3623
+ max_concurrency=config.workflow.max_concurrency,
3624
+ max_nodes=config.workflow.max_nodes,
3625
+ registry=workflow_registry,
3626
+ default_token_budget=config.workflow.default_token_budget,
3627
+ )
3628
+ _install_plan_handler(handlers, plan_approval)
3629
+ install_workflow_handler(handlers)
3630
+ install_subagent_send_handler(handlers)
3631
+
3632
+ ui_console.console.print(
3633
+ f"BareAgent REPL ({config.provider.name}/{config.provider.model})",
3634
+ style="bold cyan",
3635
+ )
3636
+ ui_console.print_status(
3637
+ f"Permission mode: {permission.mode.value}. Type /help to see available commands."
3638
+ )
3639
+
3640
+ # Passive config-change detection (ROADMAP 4.3): record the config files'
3641
+ # mtimes once at startup so we only nudge the user about *new* edits.
3642
+ last_config_mtimes = _config_mtimes(config)
3643
+
3644
+ try:
3645
+ while True:
3646
+ main_mailbox_cursor = _drain_team_mailbox(
3647
+ ui_console,
3648
+ message_bus=message_bus,
3649
+ since=main_mailbox_cursor,
3650
+ sink=pending_team_messages,
3651
+ )
3652
+ current_config_mtimes = _config_mtimes(config)
3653
+ if current_config_mtimes != last_config_mtimes:
3654
+ last_config_mtimes = current_config_mtimes
3655
+ ui_console.print_status("config changed on disk — type /reload to apply")
3656
+ try:
3657
+ user_input = read_fn()
3658
+ except (KeyboardInterrupt, EOFError):
3659
+ _broadcast_team_shutdown(message_bus)
3660
+ ui_console.print_status("\nExiting BareAgent.")
3661
+ return 0
3662
+
3663
+ text = user_input.strip()
3664
+ if not text:
3665
+ continue
3666
+ if text == "/exit":
3667
+ _broadcast_team_shutdown(message_bus)
3668
+ ui_console.print_status("Exiting BareAgent.")
3669
+ return 0
3670
+ if text == "/help":
3671
+ ui_console.print_status(_HELP_TEXT)
3672
+ continue
3673
+ if text in ("/clear", "/new"):
3674
+ if text == "/clear":
3675
+ _clear_stdio_screen(ui_console)
3676
+ messages[:] = _initial_messages(
3677
+ workspace_path,
3678
+ skill_summary=skill_loader.get_skill_list_prompt(),
3679
+ memory_context=_memory_context(memory_manager),
3680
+ )
3681
+ todo_manager.reset()
3682
+ token_tracker.reset()
3683
+ if skill_generator is not None:
3684
+ skill_generator.reset()
3685
+ new_session_id = _generate_session_id(
3686
+ transcript_mgr,
3687
+ reserved_ids={_get_compact_session_id(compact_fn)},
3688
+ )
3689
+ _set_compact_session_id(compact_fn, new_session_id)
3690
+ _set_interaction_logger_session(interaction_logger, new_session_id)
3691
+ message_bus, main_mailbox_cursor = _switch_session_mailbox(
3692
+ workspace_path,
3693
+ new_session_id,
3694
+ current_bus=message_bus,
3695
+ )
3696
+ spawned_agents = {}
3697
+ pending_team_messages.clear()
3698
+ pending_workflow_messages.clear()
3699
+ subagent_registry.clear()
3700
+ workflow_registry.clear()
3701
+ handlers = _build_handlers(
3702
+ workspace_path=workspace_path,
3703
+ todo_manager=todo_manager,
3704
+ task_manager=task_manager,
3705
+ skill_loader=skill_loader,
3706
+ provider=provider,
3707
+ tools=tools,
3708
+ permission=permission,
3709
+ bg_manager=bg_manager,
3710
+ messages=messages,
3711
+ config=config,
3712
+ runtime_id=new_session_id,
3713
+ teammate_manager=teammate_manager,
3714
+ message_bus=message_bus,
3715
+ spawned_agents=spawned_agents,
3716
+ agent_name=MAIN_AGENT_NAME,
3717
+ mcp_manager=mcp_manager,
3718
+ lsp_manager=lsp_manager,
3719
+ memory_manager=memory_manager,
3720
+ subagent_registry=subagent_registry,
3721
+ )
3722
+ _install_plan_handler(handlers, plan_approval)
3723
+ install_workflow_handler(handlers)
3724
+ install_subagent_send_handler(handlers)
3725
+ ui_console.print_status("New conversation started.")
3726
+ continue
3727
+ if text == "/compact":
3728
+ compact_fn(messages, force=True)
3729
+ _save_transcript_snapshot(transcript_mgr, messages, compact_fn)
3730
+ ui_console.print_status("Context compaction finished.")
3731
+ handlers = _build_handlers(
3732
+ workspace_path=workspace_path,
3733
+ todo_manager=todo_manager,
3734
+ task_manager=task_manager,
3735
+ skill_loader=skill_loader,
3736
+ provider=provider,
3737
+ tools=tools,
3738
+ permission=permission,
3739
+ bg_manager=bg_manager,
3740
+ messages=messages,
3741
+ config=config,
3742
+ runtime_id=_get_compact_session_id(compact_fn),
3743
+ teammate_manager=teammate_manager,
3744
+ message_bus=message_bus,
3745
+ spawned_agents=spawned_agents,
3746
+ agent_name=MAIN_AGENT_NAME,
3747
+ mcp_manager=mcp_manager,
3748
+ lsp_manager=lsp_manager,
3749
+ memory_manager=memory_manager,
3750
+ subagent_registry=subagent_registry,
3751
+ )
3752
+ _install_plan_handler(handlers, plan_approval)
3753
+ install_workflow_handler(handlers)
3754
+ install_subagent_send_handler(handlers)
3755
+ continue
3756
+ if text == "/sessions":
3757
+ sessions = transcript_mgr.list_sessions()
3758
+ if not sessions:
3759
+ ui_console.print_status("No saved sessions.")
3760
+ else:
3761
+ for saved_session in sessions:
3762
+ ui_console.console.print(saved_session)
3763
+ continue
3764
+ if text == "/resume" or text.startswith("/resume "):
3765
+ _, _, raw_session_id = text.partition(" ")
3766
+ requested_session = raw_session_id.strip() or None
3767
+ try:
3768
+ restored_messages = transcript_mgr.resume(requested_session)
3769
+ except FileNotFoundError as exc:
3770
+ ui_console.print_error(str(exc))
3771
+ continue
3772
+ messages[:] = restored_messages
3773
+ token_tracker.reset()
3774
+ resumed_session = requested_session or transcript_mgr.get_latest_session()
3775
+ if resumed_session is not None:
3776
+ _set_compact_session_id(compact_fn, resumed_session)
3777
+ _set_interaction_logger_session(
3778
+ interaction_logger,
3779
+ resumed_session,
3780
+ )
3781
+ message_bus, main_mailbox_cursor = _switch_session_mailbox(
3782
+ workspace_path,
3783
+ resumed_session,
3784
+ current_bus=message_bus,
3785
+ )
3786
+ spawned_agents = {}
3787
+ pending_team_messages.clear()
3788
+ pending_workflow_messages.clear()
3789
+ subagent_registry.clear()
3790
+ workflow_registry.clear()
3791
+ handlers = _build_handlers(
3792
+ workspace_path=workspace_path,
3793
+ todo_manager=todo_manager,
3794
+ task_manager=task_manager,
3795
+ skill_loader=skill_loader,
3796
+ provider=provider,
3797
+ tools=tools,
3798
+ permission=permission,
3799
+ bg_manager=bg_manager,
3800
+ messages=messages,
3801
+ config=config,
3802
+ runtime_id=_get_compact_session_id(compact_fn),
3803
+ teammate_manager=teammate_manager,
3804
+ message_bus=message_bus,
3805
+ spawned_agents=spawned_agents,
3806
+ agent_name=MAIN_AGENT_NAME,
3807
+ mcp_manager=mcp_manager,
3808
+ lsp_manager=lsp_manager,
3809
+ memory_manager=memory_manager,
3810
+ subagent_registry=subagent_registry,
3811
+ )
3812
+ _install_plan_handler(handlers, plan_approval)
3813
+ install_workflow_handler(handlers)
3814
+ install_subagent_send_handler(handlers)
3815
+ _replay_stdio_transcript(messages, ui_console)
3816
+ ui_console.print_status(f"Resumed session: {resumed_session}")
3817
+ continue
3818
+ if text == "/export" or text.startswith("/export "):
3819
+ _dispatch_export_command(
3820
+ text,
3821
+ messages=messages,
3822
+ session_id=_get_compact_session_id(compact_fn),
3823
+ workspace_path=workspace_path,
3824
+ ui_console=ui_console,
3825
+ )
3826
+ continue
3827
+ if text == "/import" or text.startswith("/import "):
3828
+ _, _, raw_path = text.partition(" ")
3829
+ import_path = raw_path.strip()
3830
+ if not import_path:
3831
+ ui_console.print_error("Usage: /import <path-to-.json-or-.jsonl>")
3832
+ continue
3833
+ p = Path(import_path).expanduser()
3834
+ try:
3835
+ raw_text = p.read_text(encoding="utf-8")
3836
+ except OSError as exc:
3837
+ ui_console.print_error(f"Cannot read {p}: {exc}")
3838
+ continue
3839
+ try:
3840
+ imported_messages = parse_import(raw_text)
3841
+ except ValueError as exc:
3842
+ ui_console.print_error(f"Invalid conversation file: {exc}")
3843
+ continue
3844
+ # Validation passed: only now mutate state (fail-safe — any
3845
+ # failure above already continued with zero changes).
3846
+ messages[:] = imported_messages
3847
+ token_tracker.reset()
3848
+ new_sid = _generate_session_id(
3849
+ transcript_mgr,
3850
+ reserved_ids={_get_compact_session_id(compact_fn)},
3851
+ )
3852
+ _set_compact_session_id(compact_fn, new_sid)
3853
+ _set_interaction_logger_session(interaction_logger, new_sid)
3854
+ message_bus, main_mailbox_cursor = _switch_session_mailbox(
3855
+ workspace_path,
3856
+ new_sid,
3857
+ current_bus=message_bus,
3858
+ )
3859
+ spawned_agents = {}
3860
+ pending_team_messages.clear()
3861
+ pending_workflow_messages.clear()
3862
+ subagent_registry.clear()
3863
+ workflow_registry.clear()
3864
+ handlers = _build_handlers(
3865
+ workspace_path=workspace_path,
3866
+ todo_manager=todo_manager,
3867
+ task_manager=task_manager,
3868
+ skill_loader=skill_loader,
3869
+ provider=provider,
3870
+ tools=tools,
3871
+ permission=permission,
3872
+ bg_manager=bg_manager,
3873
+ messages=messages,
3874
+ config=config,
3875
+ runtime_id=new_sid,
3876
+ teammate_manager=teammate_manager,
3877
+ message_bus=message_bus,
3878
+ spawned_agents=spawned_agents,
3879
+ agent_name=MAIN_AGENT_NAME,
3880
+ mcp_manager=mcp_manager,
3881
+ lsp_manager=lsp_manager,
3882
+ memory_manager=memory_manager,
3883
+ subagent_registry=subagent_registry,
3884
+ )
3885
+ _install_plan_handler(handlers, plan_approval)
3886
+ install_workflow_handler(handlers)
3887
+ install_subagent_send_handler(handlers)
3888
+ _replay_stdio_transcript(messages, ui_console)
3889
+ _save_transcript_snapshot(transcript_mgr, messages, compact_fn)
3890
+ ui_console.print_status(
3891
+ f"Imported {len(messages)} messages into new session: {new_sid}"
3892
+ )
3893
+ continue
3894
+ if text == "/cost":
3895
+ ui_console.print_status(token_tracker.summary(config.cost.prices))
3896
+ continue
3897
+ if text == "/goal" or text.startswith("/goal "):
3898
+ goal_cmd = parse_goal_command(
3899
+ text[len("/goal") :], default_max_turns=config.goal.max_turns
3900
+ )
3901
+ if goal_cmd.action == "run":
3902
+ _drive_goal(
3903
+ goal_cmd,
3904
+ provider=provider,
3905
+ evaluator_provider=goal_evaluator_provider,
3906
+ messages=messages,
3907
+ loop_tools=loop_tools,
3908
+ handlers=handlers,
3909
+ permission=permission,
3910
+ compact_fn=compact_fn,
3911
+ bg_manager=bg_manager,
3912
+ config=config,
3913
+ ui_console=ui_console,
3914
+ interaction_logger=interaction_logger,
3915
+ token_tracker=token_tracker,
3916
+ hook_engine=hook_engine,
3917
+ retry_policy=retry_policy,
3918
+ transcript_mgr=transcript_mgr,
3919
+ )
3920
+ elif goal_cmd.action == "error":
3921
+ ui_console.print_error(goal_cmd.message)
3922
+ else: # usage
3923
+ ui_console.print_status(goal_cmd.message)
3924
+ continue
3925
+ if text == "/loop" or text.startswith("/loop "):
3926
+ _dispatch_loop_command(text, scheduler=scheduler, ui_console=ui_console)
3927
+ continue
3928
+ if text == "/workflows" or text.startswith("/workflows "):
3929
+ _dispatch_workflows_command(
3930
+ text, registry=workflow_registry, ui_console=ui_console
3931
+ )
3932
+ continue
3933
+ if text == "/log" or text.startswith("/log "):
3934
+ viewer_server = _handle_log_command(
3935
+ text,
3936
+ config=config,
3937
+ interaction_logger=interaction_logger,
3938
+ viewer_server=viewer_server,
3939
+ print_status=ui_console.print_status,
3940
+ )
3941
+ continue
3942
+ if text in _PERMISSION_SLASH:
3943
+ old = permission.mode
3944
+ permission.mode = _PERMISSION_SLASH[text]
3945
+ ui_console.print_status(f"Permission mode: {old.value} → {permission.mode.value}")
3946
+ continue
3947
+ if text == "/mode":
3948
+ _handle_mode_selection_stdio(permission, ui_console)
3949
+ continue
3950
+ if text == "/theme" or text.startswith("/theme "):
3951
+ from bareagent.ui.theme import (
3952
+ format_theme_list,
3953
+ format_unknown_theme,
3954
+ get_theme,
3955
+ )
3956
+
3957
+ _, _, theme_arg = text.partition(" ")
3958
+ theme_name = theme_arg.strip()
3959
+ tm = get_theme()
3960
+ if not theme_name:
3961
+ ui_console.print_status(format_theme_list(tm))
3962
+ continue
3963
+ if tm.switch(theme_name):
3964
+ ui_console.set_theme(tm)
3965
+ ui_console.print_status(f"Theme switched to: {theme_name}")
3966
+ continue
3967
+ ui_console.print_error(format_unknown_theme(theme_name))
3968
+ continue
3969
+ if text == "/team" or text.startswith("/team "):
3970
+ _handle_team_command(
3971
+ text,
3972
+ ui_console,
3973
+ teammate_manager=teammate_manager,
3974
+ team_handlers=handlers,
3975
+ )
3976
+ continue
3977
+ if text == "/mcp" or (text.startswith("/mcp ") and not text.startswith("/mcp:")):
3978
+ _dispatch_mcp_command(
3979
+ text,
3980
+ mcp_manager=mcp_manager,
3981
+ ui_console=ui_console,
3982
+ )
3983
+ continue
3984
+ if text == "/lsp" or text.startswith("/lsp "):
3985
+ _dispatch_lsp_command(
3986
+ text,
3987
+ lsp_manager=lsp_manager,
3988
+ ui_console=ui_console,
3989
+ )
3990
+ continue
3991
+ if text == "/skill" or text.startswith("/skill "):
3992
+ _dispatch_skill_command(
3993
+ text,
3994
+ store=skill_store,
3995
+ loader=skill_loader,
3996
+ console=ui_console,
3997
+ )
3998
+ continue
3999
+ if text == "/reload":
4000
+ _dispatch_reload_command(
4001
+ config=config,
4002
+ permission=permission,
4003
+ ui_console=ui_console,
4004
+ )
4005
+ last_config_mtimes = _config_mtimes(config)
4006
+ continue
4007
+ if text.startswith("/mcp:"):
4008
+ snapshot_len = len(messages)
4009
+ appended = _dispatch_mcp_prompt(
4010
+ text,
4011
+ mcp_manager=mcp_manager,
4012
+ messages=messages,
4013
+ ui_console=ui_console,
4014
+ )
4015
+ if not appended:
4016
+ continue
4017
+ # Re-render the injected user turn(s) so the screen matches the
4018
+ # transcript before agent_loop runs.
4019
+ _replay_stdio_transcript(messages[snapshot_len:], ui_console)
4020
+ if messages[-1].get("role") != "user":
4021
+ # Trailing assistant message — no LLM call needed; prompt for input.
4022
+ ui_console.print_status("Prompt injected. Type your follow-up to continue.")
4023
+ continue
4024
+ try:
4025
+ agent_loop(
4026
+ provider=provider,
4027
+ messages=messages,
4028
+ tools=loop_tools,
4029
+ handlers=handlers,
4030
+ permission=permission,
4031
+ compact_fn=compact_fn,
4032
+ bg_manager=bg_manager,
4033
+ stream=config.ui.stream,
4034
+ console=ui_console,
4035
+ interaction_logger=interaction_logger,
4036
+ token_tracker=token_tracker,
4037
+ hook_engine=hook_engine,
4038
+ retry_policy=retry_policy,
4039
+ )
4040
+ _save_transcript_snapshot(transcript_mgr, messages, compact_fn)
4041
+ main_mailbox_cursor = _drain_team_mailbox(
4042
+ ui_console,
4043
+ message_bus=message_bus,
4044
+ since=main_mailbox_cursor,
4045
+ sink=pending_team_messages,
4046
+ )
4047
+ except LLMCallError:
4048
+ del messages[snapshot_len:]
4049
+ ui_console.print_error("LLM call failed, please try again.")
4050
+ except KeyboardInterrupt:
4051
+ del messages[snapshot_len:]
4052
+ ui_console.print_status("Agent loop interrupted.")
4053
+ continue
4054
+ if text == "/remember" or text.startswith("/remember "):
4055
+ if memory_manager is None:
4056
+ ui_console.print_error(
4057
+ "Persistent memory is disabled (enable [memory] in config)."
4058
+ )
4059
+ continue
4060
+ _, _, remember_arg = text.partition(" ")
4061
+ # Rewrite into an LLM instruction and fall through to the
4062
+ # normal user-turn handling below, which runs agent_loop.
4063
+ text = build_remember_instruction(remember_arg.strip())
4064
+ elif text == "/forget" or text.startswith("/forget "):
4065
+ if memory_manager is None:
4066
+ ui_console.print_error(
4067
+ "Persistent memory is disabled (enable [memory] in config)."
4068
+ )
4069
+ continue
4070
+ _, _, forget_arg = text.partition(" ")
4071
+ text = build_forget_instruction(forget_arg.strip())
4072
+
4073
+ # Surface any finished background workflows into the buffer (idempotent;
4074
+ # also prints a console status line) before consuming it.
4075
+ _drain_workflow_results(
4076
+ ui_console, registry=workflow_registry, sink=pending_workflow_messages
4077
+ )
4078
+
4079
+ # Prepend any buffered late/unsolicited teammate replies and finished
4080
+ # background-workflow summaries onto this user turn so the LLM sees
4081
+ # them (without injecting standalone user messages that would break
4082
+ # role alternation).
4083
+ pending_context = pending_team_messages + pending_workflow_messages
4084
+ if pending_context:
4085
+ prepended = "\n".join(pending_context)
4086
+ pending_team_messages.clear()
4087
+ pending_workflow_messages.clear()
4088
+ text = f"{prepended}\n\n{text}" if text else prepended
4089
+
4090
+ messages.append({"role": "user", "content": text})
4091
+ snapshot_len = len(messages) - 1
4092
+ try:
4093
+ agent_loop(
4094
+ provider=provider,
4095
+ messages=messages,
4096
+ tools=loop_tools,
4097
+ handlers=handlers,
4098
+ permission=permission,
4099
+ compact_fn=compact_fn,
4100
+ bg_manager=bg_manager,
4101
+ stream=config.ui.stream,
4102
+ console=ui_console,
4103
+ interaction_logger=interaction_logger,
4104
+ token_tracker=token_tracker,
4105
+ hook_engine=hook_engine,
4106
+ retry_policy=retry_policy,
4107
+ skill_gen=skill_generator,
4108
+ )
4109
+ _save_transcript_snapshot(transcript_mgr, messages, compact_fn)
4110
+ # Experiential skill generation: when this turn pushed the
4111
+ # cumulative activity past both thresholds, reflect on the
4112
+ # session and draft a reusable skill (isolated extra LLM call).
4113
+ # Reset first so the trigger does not re-fire every later turn.
4114
+ if skill_generator is not None and skill_generator.should_draft():
4115
+ skill_generator.reset()
4116
+ _run_skill_reflection(
4117
+ provider=provider,
4118
+ messages=messages,
4119
+ store=skill_store,
4120
+ skill_loader=skill_loader,
4121
+ console=ui_console,
4122
+ token_tracker=token_tracker,
4123
+ permission=permission,
4124
+ max_pending=config.skills.max_pending,
4125
+ )
4126
+ main_mailbox_cursor = _drain_team_mailbox(
4127
+ ui_console,
4128
+ message_bus=message_bus,
4129
+ since=main_mailbox_cursor,
4130
+ sink=pending_team_messages,
4131
+ )
4132
+ except LLMCallError:
4133
+ del messages[snapshot_len:]
4134
+ ui_console.print_error("LLM call failed, please try again.")
4135
+ except KeyboardInterrupt:
4136
+ del messages[snapshot_len:]
4137
+ ui_console.print_status("Agent loop interrupted.")
4138
+ finally:
4139
+ try:
4140
+ scheduler.cancel_all()
4141
+ except Exception:
4142
+ pass
4143
+ try:
4144
+ mcp_manager.close_all()
4145
+ except Exception:
4146
+ pass
4147
+ try:
4148
+ lsp_manager.close_all()
4149
+ except Exception:
4150
+ pass
4151
+
4152
+
4153
+ def main(argv: list[str] | None = None) -> int:
4154
+ args = parse_args(argv)
4155
+ config_path = resolve_config_path(args.config)
4156
+
4157
+ if getattr(args, "command", None) == "init":
4158
+ return 0 if run_setup_wizard(config_path=config_path) else 1
4159
+
4160
+ try:
4161
+ config = load_config(
4162
+ config_path,
4163
+ provider_override=args.provider,
4164
+ model_override=args.model,
4165
+ )
4166
+ except FileNotFoundError:
4167
+ print(f"Config file not found: {config_path}")
4168
+ return 1
4169
+ except (tomllib.TOMLDecodeError, ValueError) as exc:
4170
+ print(f"Failed to load config: {exc}")
4171
+ return 1
4172
+
4173
+ # First-run convenience: when no usable API key is configured and we are on
4174
+ # an interactive terminal, drop into the same setup wizard rather than
4175
+ # failing later in ``create_provider``. Non-TTY runs keep the existing
4176
+ # fail-fast behaviour below.
4177
+ provider_config = getattr(config, "provider", None)
4178
+ if (
4179
+ isinstance(provider_config, ProviderConfig)
4180
+ and not _has_usable_key(provider_config)
4181
+ and sys.stdin.isatty()
4182
+ ):
4183
+ print("No usable API key detected. Entering interactive setup...")
4184
+ if run_setup_wizard(config_path=config_path):
4185
+ try:
4186
+ config = load_config(
4187
+ config_path,
4188
+ provider_override=args.provider,
4189
+ model_override=args.model,
4190
+ )
4191
+ except (FileNotFoundError, tomllib.TOMLDecodeError, ValueError) as exc:
4192
+ print(f"Failed to reload config after setup: {exc}")
4193
+ return 1
4194
+
4195
+ try:
4196
+ provider = create_provider(config)
4197
+ except ValueError as exc:
4198
+ print(f"Failed to initialize provider: {exc}")
4199
+ return 1
4200
+
4201
+ return _run_stdio_session(config, provider)
4202
+
4203
+
4204
+ if __name__ == "__main__":
4205
+ raise SystemExit(main())