loom-code 0.1.1__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 (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """loom-code — a loomflow-native terminal coding agent.
2
+
3
+ The entire agent brain is loomflow:
4
+
5
+ * ``Agent`` + ``ReAct`` — the agent loop
6
+ * ``living_plan=True`` — the task tracker (Claude Code's TodoWrite)
7
+ * ``LocalDiskWorkspace`` — per-project memory + the self-improvement
8
+ loop (citation tracking + relevance-aware recall)
9
+ * ``read`` / ``write`` / ``edit`` / ``bash`` / ``grep`` / ``find``
10
+ / ``ls`` builtin tools — the file-and-shell kernel
11
+ * ``StandardPermissions`` + ``approval_handler`` — the safety gate
12
+ * ``Agent.stream()`` — streaming output
13
+
14
+ This package is ONLY the terminal shell: REPL, ``rich`` rendering,
15
+ slash commands, project detection, the diff-approval prompt. If
16
+ agent-loop / memory / tool-dispatch logic ever shows up here, that
17
+ is a bug — it means loomflow is missing something and the fix
18
+ belongs in the framework, not here. loom-code is the dogfood test
19
+ that keeps loomflow honest.
20
+ """
21
+
22
+ __version__ = "0.1.1"
@@ -0,0 +1,119 @@
1
+ """Post-commit hook runner — debounced indexer refresh.
2
+
3
+ Invoked by ``.git/hooks/post-commit`` (installed by
4
+ ``loom_code.git_hook.install``). Counts commits since the last
5
+ refresh per indexer; when the threshold is hit (5 by default),
6
+ runs the indexer's incremental update.
7
+
8
+ Designed to FAIL SILENT. A git hook crashing has the same UX
9
+ cost as a broken commit — we'd rather skip a refresh than make
10
+ ``git commit`` look broken. All exception handling is broad and
11
+ mute; the worst case is "graph stayed stale one more commit."
12
+
13
+ Why debounce: graphify rebuilds + loominit structural rebuilds
14
+ are fast (5-15s on typical projects) but not free. Running them
15
+ on every single commit during a heavy dev session (10+ commits/
16
+ hour) is wasteful. Every-5-commits keeps the indexes "close
17
+ enough" without burning cycles.
18
+
19
+ Invoked as::
20
+
21
+ python -m loom_code._post_commit <project_root>
22
+
23
+ Backgrounded by the shell hook so it doesn't delay the commit.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import sys
29
+ from collections.abc import Callable
30
+ from pathlib import Path
31
+
32
+ # Refresh threshold — commits since last refresh before triggering
33
+ # the indexer's incremental rebuild. Empirically tuned: every
34
+ # commit is wasteful, every 20 is too stale to be useful, 5 sits
35
+ # in the goldilocks zone for typical dev pace.
36
+ _THRESHOLD = 5
37
+
38
+
39
+ def main() -> int:
40
+ if len(sys.argv) < 2:
41
+ return 0
42
+ project_root = Path(sys.argv[1])
43
+ loom_dir = project_root / ".loom"
44
+ if not loom_dir.is_dir():
45
+ return 0
46
+
47
+ # Graphify: incremental rebuild via the package's own
48
+ # ``--update`` flag (re-extracts only changed files, merges
49
+ # into the existing graph). Only runs if graphify has been
50
+ # set up at least once for this project.
51
+ graphify_dir = loom_dir / "graphify"
52
+ if (graphify_dir / "graph.json").is_file():
53
+ _maybe_refresh(
54
+ counter_file=graphify_dir / "_commits_since_refresh.txt",
55
+ refresh_fn=lambda: _refresh_graphify(project_root, graphify_dir),
56
+ )
57
+
58
+ return 0
59
+
60
+
61
+ def _maybe_refresh(
62
+ *, counter_file: Path, refresh_fn: Callable[[], None]
63
+ ) -> None:
64
+ """Increment the counter; if it crosses the threshold, run
65
+ the refresh and reset. Errors are swallowed — better to skip
66
+ a refresh than break the commit."""
67
+ try:
68
+ count = (
69
+ int(counter_file.read_text())
70
+ if counter_file.is_file()
71
+ else 0
72
+ )
73
+ except (ValueError, OSError):
74
+ count = 0
75
+ count += 1
76
+ if count >= _THRESHOLD:
77
+ try:
78
+ refresh_fn()
79
+ counter_file.write_text("0")
80
+ except Exception: # noqa: BLE001 — never break a commit
81
+ # Leave counter at threshold; next commit will retry.
82
+ pass
83
+ else:
84
+ try:
85
+ counter_file.write_text(str(count))
86
+ except OSError:
87
+ pass
88
+
89
+
90
+ def _refresh_graphify(project_root: Path, graphify_dir: Path) -> None:
91
+ """Re-run the graphify extract → build → cluster → persist
92
+ pipeline in-process via the shared ``graphify_build_impl``
93
+ helper that the ``@tool`` wrapper + ``/loominit`` already use.
94
+
95
+ Single source of truth means three things stay in sync: the
96
+ submodule-import shim that dodges graphify's ``__getattr__``
97
+ namespace shadowing, the git-ls-files fast path that skips
98
+ walking ``.venv`` / ``node_modules``, and the exact tree-sitter
99
+ / Leiden / JSON pipeline. Bypassing it here is what caused this
100
+ function to silently fail on every commit before: it called
101
+ ``graphify.extract(files)`` (a submodule, not a function),
102
+ passed ``[extraction]`` (build_from_json wants a dict), and
103
+ dropped the ``communities`` arg to ``to_json``.
104
+
105
+ Capped via subprocess from the shell hook (5 min, see the hook
106
+ wrapper) so a hung extraction can't block git indefinitely."""
107
+ # graphify_dir kept in the signature for the caller's existing
108
+ # path math; the impl writes to the same `.loom/graphify/graph.json`
109
+ # via its own ``_graph_path`` helper, so we don't need to use it.
110
+ _ = graphify_dir
111
+ import anyio
112
+
113
+ from .skills.graphify.tools import graphify_build_impl
114
+
115
+ anyio.run(graphify_build_impl, project_root)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ sys.exit(main())
loom_code/agent.py ADDED
@@ -0,0 +1,544 @@
1
+ """Builds the loomflow team that powers loom-code.
2
+
3
+ This is the one place loom-code wires loomflow primitives
4
+ together. Everything here is configuration — no agent-loop logic,
5
+ no tool implementations, no memory logic. If this file ever grows
6
+ real behaviour, that behaviour belongs in loomflow.
7
+
8
+ loom-code is a single ``Team.supervisor``. The coordinator is a
9
+ READ-ONLY tech lead: it has ``read``/``grep``/``ls``/``find``/
10
+ ``web_fetch`` to understand the code and answer questions, plus a
11
+ ``delegate`` tool — but NO writer/exec tools. So it plans, tracks,
12
+ and manages, and hands every change (writes/edits) to ``coder`` and
13
+ every test-run to ``reviewer``, with ``explorer``/``auditor`` for
14
+ investigation (see :mod:`loom_code.workers`). Removing the writer
15
+ kernel from the coordinator is deliberate: with it, the model just
16
+ grinds edits itself and leaves the workers idle. ``Team.supervisor``
17
+ returns a plain ``Agent``, so the rest of loom-code (REPL, CLI,
18
+ renderer) treats it exactly like any agent.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Awaitable, Callable
24
+ from importlib.resources import files as _pkg_files
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from loomflow import Agent
29
+ from loomflow.team import Team
30
+ from loomflow.tools import find_tool, ls_tool, read_tool
31
+ from loomflow.workspace import LocalDiskWorkspace
32
+
33
+ from .code_index import codebase_search_tool
34
+ from .credentials import patient_retry_policy_for
35
+ from .extensions import Extensions, safe_role_name
36
+ from .file_tools import loom_read_tool
37
+ from .grep_tool import enhanced_grep_tool as grep_tool
38
+ from .hooks import attach_tool_hooks
39
+ from .lsp_tools import lsp_tools
40
+ from .project import Project
41
+ from .prompts import build_unified_coordinator_instructions
42
+ from .rules import remember_rule_tool
43
+ from .trust import discover_trusted
44
+ from .web_fetch import web_fetch_tool
45
+ from .workers import (
46
+ BUILTIN_WORKER_NAMES,
47
+ SUMMARY_THRESHOLD_CHARS,
48
+ _build_coder,
49
+ build_custom_worker,
50
+ build_workers,
51
+ )
52
+
53
+
54
+ # Bundled skills shipped with loom-code. Each entry is a directory
55
+ # under ``loom_code/skills/`` with a ``SKILL.md`` + optional
56
+ # ``tools.py``. The framework's SkillRegistry discovers them on
57
+ # Agent construction; the agent sees a 50-token (name + description)
58
+ # entry for each, and calls ``load_skill(name)`` to materialise the
59
+ # body + tools when relevant. Cheap baseline — no LLM cost unless
60
+ # the agent actually loads a skill.
61
+ def _bundled_skill_paths() -> list[Path]:
62
+ """Return absolute Paths to every shipped skill directory.
63
+ Uses ``importlib.resources`` so the lookup works whether
64
+ loom-code is installed editable, as a wheel, or zipped."""
65
+ root = _pkg_files("loom_code.skills")
66
+ out: list[Path] = []
67
+ for entry in root.iterdir(): # type: ignore[attr-defined]
68
+ if entry.is_dir() and (entry / "SKILL.md").is_file():
69
+ out.append(Path(str(entry)))
70
+ return out
71
+
72
+ # loom-code keeps its per-project state under <root>/.loom/ —
73
+ # the workspace notebook and the sqlite memory db both live here.
74
+ # Mirrors how Claude Code uses .claude/ and Pi uses .pi/.
75
+ LOOM_DIR = ".loom"
76
+
77
+ # Default model. Overridable via --model / the /model command.
78
+ DEFAULT_MODEL = "gpt-4.1-mini"
79
+
80
+
81
+ def _is_openai_model(model: str) -> bool:
82
+ """True when ``model`` is served by OpenAI (so its embeddings are
83
+ paid for by the same key). Used to pin the memory embedder to the
84
+ selected chat model's provider — OpenAI models get OpenAI
85
+ embeddings, everything else (Claude, Gemini, local) uses the
86
+ zero-key ``hash`` embedder so recall never makes a cross-provider
87
+ OpenAI call. Anthropic / Gemini / Ollama have no embeddings API we
88
+ use, so the test is simply 'is this an OpenAI chat model'."""
89
+ m = model.lower()
90
+ # OpenAI chat models: gpt-*, the o-series (o1/o3/o4...), and the
91
+ # ``openai/`` litellm prefix. Anthropic/Gemini/etc. never match.
92
+ return (
93
+ m.startswith(("gpt-", "gpt", "o1", "o3", "o4", "openai/", "chatgpt"))
94
+ or m in {"o1", "o3", "o4"}
95
+ )
96
+
97
+
98
+ def build_agent(
99
+ project: Project,
100
+ *,
101
+ model: str = DEFAULT_MODEL,
102
+ approval_handler: Callable[..., Awaitable[bool]] | None = None,
103
+ max_turns: int = 100,
104
+ web_backend: str | None = None,
105
+ max_stop_hook_iterations: int = 2,
106
+ snip_window: int = 8,
107
+ auto_compact: bool = True,
108
+ tool_result_summarizer: str | None = None,
109
+ extensions: Extensions | None = None,
110
+ effort: str | None = None,
111
+ sandbox: bool = False,
112
+ sandbox_allow_network: bool = False,
113
+ operator: bool = False,
114
+ run_until: str | dict[str, Any] | None = None,
115
+ ) -> tuple[Agent, LocalDiskWorkspace]:
116
+ """Wire the loom-code agent for a given project.
117
+
118
+ Returns ``(coordinator, workspace)`` — the coordinator is a
119
+ single ``Team.supervisor`` Agent; the caller needs the workspace
120
+ handle to drive the self-improvement loop
121
+ (``attribute_outcome`` after a run, ``prune`` for retention).
122
+
123
+ The coordinator is READ-ONLY: it holds ``read``/``grep``/``ls``/
124
+ ``find``/``web_fetch`` (+ ``delegate``) to understand the code
125
+ and answer questions, but has NO ``write``/``edit``/``bash`` —
126
+ so it CANNOT make changes itself and MUST delegate every
127
+ mutation to a worker. It rides loomflow's ``delegate`` →
128
+ ``SubagentInvocation`` path, which streams worker events to the
129
+ parent and rolls up their token cost. (Giving the coordinator
130
+ the writer kernel was tried and reverted: the model just ground
131
+ edits itself and never delegated, leaving the roster idle.)
132
+
133
+ The whole brain in one builder call:
134
+
135
+ * **workers** — the delegate roster (:func:`build_workers`):
136
+ ``coder`` (the ONLY writer — full file-and-shell kernel), plus
137
+ read-only ``explorer`` / ``auditor`` / ``reviewer``. Custom
138
+ ``.loom/agents/*.md`` join as additional delegate workers.
139
+ * **coordinator** — ``Team.supervisor`` with read-only ``tools=``;
140
+ owns the living plan; plans, tracks, and delegates all writes
141
+ to ``coder`` and verification to ``reviewer``.
142
+ * **living_plan** — on the coordinator; mirrors to the
143
+ workspace so plans persist across sessions.
144
+ * **workspace** — ``<root>/.loom/notebook`` — shared notebook,
145
+ wired onto the coordinator AND every worker.
146
+ * **memory** — ``sqlite:<root>/.loom/memory.db`` — episodes +
147
+ auto-extracted facts, persisted across sessions.
148
+
149
+ Because the coordinator now executes destructive tools itself,
150
+ it carries the permission gate + ``approval_handler`` (the
151
+ workers do too); tool hooks attach to the coordinator AND every
152
+ worker. Prompt caching, persistent tool transcripts, snip
153
+ window and auto-compaction are all on, threaded through
154
+ ``Team.supervisor``'s forwarded Agent kwargs.
155
+ """
156
+ loom_dir = project.root / LOOM_DIR
157
+ loom_dir.mkdir(exist_ok=True)
158
+
159
+ workspace = LocalDiskWorkspace(str(loom_dir / "notebook"))
160
+ # Embedder follows the SELECTED chat model's provider. An OpenAI
161
+ # model uses OpenAI embeddings (the key is already present + funded
162
+ # for OpenAI users); every other provider (Claude, Gemini, local)
163
+ # uses the zero-key ``hash`` embedder so memory recall NEVER makes a
164
+ # cross-provider OpenAI call. Without this, loomflow's default
165
+ # embedder auto-picks OpenAI whenever OPENAI_API_KEY happens to be
166
+ # set — which crashed Claude-only runs with an OpenAI 429 during
167
+ # fact recall. Passing memory as a dict (not the ``sqlite:`` string)
168
+ # is what lets us pin the embedder.
169
+ embedder = "openai" if _is_openai_model(model) else "hash"
170
+ memory_cfg: dict[str, str] = {
171
+ "backend": "sqlite",
172
+ "path": str(loom_dir / "memory.db"),
173
+ "embedder": embedder,
174
+ }
175
+
176
+ # Bundled skills (graphify today, more later) computed before the
177
+ # workers + coordinator so the SAME list lands on every agent.
178
+ # Without skills on a worker, the coordinator delegating "build
179
+ # the graph" to coder fails: coder's tool host lacks the skill's
180
+ # tools. ``skill_paths`` append AFTER bundled so last-source-wins
181
+ # gives project > user > bundled.
182
+ bundled_skills = _bundled_skill_paths()
183
+ # User + project extensions (the ``.loom`` folder — skills,
184
+ # subagents, hooks). When NOT supplied (desktop sidecar, scripts,
185
+ # tests) we self-discover with a deny-by-default trust gate so an
186
+ # untrusted project's hooks aren't auto-run.
187
+ if extensions is None:
188
+ extensions = discover_trusted(project.root)
189
+ all_skills = bundled_skills + extensions.skill_paths
190
+
191
+ # Auto-compact threshold — 80% of the model's context window,
192
+ # computed before the workers so it lands on EVERY worker too
193
+ # (a long delegation otherwise grows past the window and 400s
194
+ # with context_length_exceeded). ``None`` disables compaction.
195
+ auto_compact_at_tokens: int | None = None
196
+ if auto_compact:
197
+ from loomflow.agent.auto_compact import context_window_for
198
+
199
+ from .credentials import context_window_override
200
+ # Prefer a known window for litellm-routed models (NVIDIA
201
+ # Nemotron, Groq Llama, ...) that context_window_for doesn't
202
+ # recognise — otherwise it returns a conservative 8192 and
203
+ # compaction fires far too early.
204
+ window = context_window_override(model) or context_window_for(model)
205
+ auto_compact_at_tokens = int(window * 0.8)
206
+
207
+ # Cheap same-provider sibling for low-stakes utility LLM calls —
208
+ # auto-compact summaries and per-result tool-output compression.
209
+ # Running those on the main coding model (Opus / GPT-4-class)
210
+ # wastes real money on summarisation; Haiku / gpt-4.1-mini do
211
+ # the job. ``None`` (no usable cheap sibling) falls back to the
212
+ # main model inside the framework.
213
+ from .credentials import cheap_model_for
214
+ cheap_model = cheap_model_for(model)
215
+ # Per-result tool-output compression for the read-only WORKERS
216
+ # only. The framework replaces the result IN-TURN (the agent sees
217
+ # the digest, never the verbatim output), so this is a last-resort
218
+ # bound against a single huge dump 400-ing a worker's run — snip
219
+ # is turn-count-based and auto-compact never fires inside a
220
+ # worker's single run. Excluded on purpose:
221
+ # * the CODER — needs verbatim ``read`` output to build
222
+ # exact-match ``edit`` old_strings;
223
+ # * the COORDINATOR — its ``delegate`` results ARE the worker
224
+ # briefings (digesting them loses the findings), and in
225
+ # operator mode it holds writer tools, hitting the same
226
+ # exact-match problem as the coder.
227
+ worker_summarizer = (
228
+ tool_result_summarizer
229
+ if tool_result_summarizer is not None
230
+ else cheap_model
231
+ )
232
+ summary_threshold = SUMMARY_THRESHOLD_CHARS
233
+
234
+ # MCP servers (trust-gated above, so only servers from a trusted
235
+ # repo or the user's own config survive). Built into one registry
236
+ # and handed to the coder — the sole writer/executor — so its tools
237
+ # join the coder's kernel. The registry connects lazily (on first
238
+ # tool use), so an unreachable server costs nothing until called.
239
+ # Stashed on the coordinator (``_mcp_registry``) so the REPL/sidecar
240
+ # can ``await registry.aclose()`` on exit.
241
+ mcp_registry: Any | None = None
242
+ if extensions.mcp_specs:
243
+ try:
244
+ from loomflow.mcp import MCPRegistry
245
+
246
+ mcp_registry = MCPRegistry(
247
+ [entry.spec for entry in extensions.mcp_specs]
248
+ )
249
+ except ImportError:
250
+ # ``mcp`` extra not installed — skip MCP rather than fail the
251
+ # build. (Discovery already degrades, but a user could pass
252
+ # pre-built Extensions; belt-and-suspenders.)
253
+ mcp_registry = None
254
+
255
+ workers = build_workers(
256
+ project,
257
+ model=model,
258
+ approval_handler=approval_handler,
259
+ web_backend=web_backend,
260
+ skills=all_skills,
261
+ auto_compact_at_tokens=auto_compact_at_tokens,
262
+ snip_window=snip_window,
263
+ tool_result_summarizer=worker_summarizer,
264
+ effort=effort,
265
+ mcp_registry=mcp_registry,
266
+ sandbox=sandbox,
267
+ sandbox_allow_network=sandbox_allow_network,
268
+ # Same embedder name memory uses (resolved above) so every
269
+ # worker's codebase_search hits the one shared index; the
270
+ # workspace handle fuses learned notes into results (Phase 1b).
271
+ embedder=embedder,
272
+ workspace=workspace,
273
+ )
274
+
275
+ # Custom .loom subagents join as delegate WORKERS. The coordinator
276
+ # reaches them through `delegate`, which keeps streaming + cost
277
+ # rollup (a raw agent-as-tool would lose both). A custom agent
278
+ # whose name collides with a builtin role is skipped — never let a
279
+ # dropped-in spec shadow the known roster, above all ``coder``.
280
+ for spec in extensions.agent_specs:
281
+ # loomflow worker names must be Python identifiers; Claude-
282
+ # Code-style names use hyphens (security-auditor -> ...).
283
+ role = safe_role_name(spec.name)
284
+ if role in BUILTIN_WORKER_NAMES or role in workers:
285
+ continue
286
+ workers[role] = build_custom_worker(
287
+ project,
288
+ spec,
289
+ model=model,
290
+ approval_handler=approval_handler,
291
+ skills=all_skills,
292
+ auto_compact_at_tokens=auto_compact_at_tokens,
293
+ snip_window=snip_window,
294
+ effort=effort,
295
+ )
296
+
297
+ # The coordinator is READ-ONLY: it reads to understand + answers
298
+ # questions, but has NO writer/exec tools — so it CANNOT grind
299
+ # edits itself and MUST delegate every change to ``coder`` (and
300
+ # test-runs to ``reviewer``). Removing the writer kernel is what
301
+ # stops the coordinator doing everything itself and leaving the
302
+ # worker roster idle — a prompt nudge alone didn't hold.
303
+ root = project.root
304
+ coordinator_tools: list[object] = [
305
+ # Policy-bounded read — reaches user-referenced files outside
306
+ # the project (grep/find/ls stay project-scoped).
307
+ loom_read_tool(root),
308
+ grep_tool(root),
309
+ # Semantic search — finds code by MEANING where grep needs the
310
+ # literal string. Embeds in the SAME space (``embedder``) as
311
+ # memory, so the index and the note store fuse (Phase 1b): the
312
+ # ``workspace`` handle makes every search blend code symbols
313
+ # with what we've LEARNED about them. The coordinator gets it
314
+ # to locate the right subsystem before delegating.
315
+ codebase_search_tool(root, embedder, workspace=workspace),
316
+ # LSP navigation (jedi) — go_to_definition / find_references /
317
+ # hover resolve symbols through imports + scope like an IDE,
318
+ # where grep only matches strings. Read-only; Python only.
319
+ *lsp_tools(root),
320
+ find_tool(root),
321
+ ls_tool(root),
322
+ web_fetch_tool(),
323
+ # Lets the coordinator persist a durable, user-stated rule to
324
+ # AGENTS.md (always-in-prompt next session) instead of trusting
325
+ # probabilistic recall. See loom_code.rules.
326
+ remember_rule_tool(root),
327
+ ]
328
+ if web_backend is not None:
329
+ from loomflow.tools import web_tool
330
+ coordinator_tools.append(web_tool(backend=web_backend))
331
+
332
+ # COMPUTER OPERATOR mode (/computer): the coordinator is the agent the
333
+ # user talks to, so in operator mode it must hold the ACTION tools
334
+ # DIRECTLY — not delegate to a worker (the bug that made browser tools
335
+ # unreachable). Add write/edit/bash (act on files + system) + native
336
+ # media/app tools + loom-code's OWN browser engine (page_open/observe/
337
+ # act/check — stable data-loom-id handles + overlay-safe acting +
338
+ # vision verify, replacing the Playwright MCP browser).
339
+ coordinator_instructions = build_unified_coordinator_instructions(project)
340
+ coordinator_tool_host: Any = coordinator_tools
341
+ if operator:
342
+ from pathlib import Path as _Path
343
+
344
+ from loomflow.tools import bash_tool, edit_tool, write_tool
345
+
346
+ from .browse import browse_tools
347
+ from .operator import build_operator_prompt, media_app_tools
348
+
349
+ # OPERATOR mode = "operate my whole computer". The default coding
350
+ # tools are rooted at the PROJECT and reject paths outside it
351
+ # (".. escapes workdir"), which breaks "create a file in
352
+ # Downloads" / "read my Documents". So in operator mode, root the
353
+ # file + shell tools at HOME — the user's actual machine, like a
354
+ # human at the keyboard. The approval gate still confirms every
355
+ # write/destructive action. Coding mode stays project-scoped.
356
+ home = str(_Path.home())
357
+ coordinator_tools.extend(
358
+ [
359
+ read_tool(home),
360
+ ls_tool(home),
361
+ find_tool(home),
362
+ write_tool(home),
363
+ edit_tool(home),
364
+ bash_tool(home, timeout=300.0),
365
+ *media_app_tools(),
366
+ *browse_tools(model=model),
367
+ ]
368
+ )
369
+ # build_operator_prompt() injects today's date so relative dates
370
+ # ("tomorrow") resolve correctly.
371
+ coordinator_instructions = build_operator_prompt()
372
+ # Compose the coordinator's tools with the MCP registry so the
373
+ # coordinator itself can call browser_* (and any other MCP) tools.
374
+ # In operator mode this is what makes browser control reachable by
375
+ # the agent the user talks to.
376
+ if mcp_registry is not None:
377
+ from loomflow.tools.registry import InProcessToolHost
378
+
379
+ from .mcp_host import McpAugmentedHost
380
+
381
+ coordinator_tool_host = McpAugmentedHost(
382
+ InProcessToolHost(coordinator_tools), mcp_registry
383
+ )
384
+
385
+ # ``max_stop_hook_iterations`` bounds the framework Ralph loop:
386
+ # while the LivingPlan still has todo/doing steps after the model
387
+ # stops, the StopHook re-prompts it to continue — up to this many
388
+ # times. The cap only bites when the agent is STUCK (a converging
389
+ # task drains its plan before hitting it), so keep it LOW: a high
390
+ # value just re-prompts a confused model into a re-planning spin
391
+ # (observed: 8 → the model re-plans + re-asks for input it already
392
+ # has, 650k tokens). Default 2 — one or two continuation nudges,
393
+ # then stop. In-turn persistence (the "own the run" prompt rule)
394
+ # is the primary mechanism; this is just a small safety net.
395
+ # ``/set_continue_cap`` tunes it per session.
396
+ # In operator mode the coordinator runs destructive/real-world tools
397
+ # itself (write/edit/bash/browser), so it needs the approval gate +
398
+ # permissions — exactly as the coder does in coding mode. In coding
399
+ # mode the coordinator stays read-only (gate lives on the workers).
400
+ _coord_extra: dict[str, Any] = {}
401
+ if operator:
402
+ from loomflow import StandardPermissions
403
+
404
+ _coord_extra["permissions"] = StandardPermissions()
405
+ _coord_extra["approval_handler"] = approval_handler
406
+ # /goal run-until-done loop. Passed ONLY when armed: ``run_until=``
407
+ # needs a loomflow newer than any released 0.10.x, and passing the
408
+ # kwarg unconditionally (even as None) would TypeError at startup
409
+ # on a PyPI install — /goal degrades to an error on old framework
410
+ # versions instead of bricking the whole CLI.
411
+ if run_until is not None:
412
+ _coord_extra["run_until"] = run_until
413
+ coordinator = Team.supervisor(
414
+ workers=workers,
415
+ instructions=coordinator_instructions,
416
+ tools=coordinator_tool_host,
417
+ model=model,
418
+ memory=memory_cfg,
419
+ workspace=workspace,
420
+ living_plan=True,
421
+ skills=all_skills,
422
+ max_turns=max_turns,
423
+ max_stop_hook_iterations=max_stop_hook_iterations,
424
+ prompt_caching=True,
425
+ tool_result_summarizer=tool_result_summarizer,
426
+ tool_result_summary_threshold=summary_threshold,
427
+ snip_window=snip_window,
428
+ auto_compact_at_tokens=auto_compact_at_tokens,
429
+ # Auto-compact summaries are low-stakes — run them on the
430
+ # cheap same-provider sibling instead of the coding model.
431
+ # ``None`` falls back to the main model inside the framework.
432
+ auto_compact_summariser=cheap_model,
433
+ effort=effort,
434
+ persist_tool_transcripts=True,
435
+ # Patient retry schedule on free-tier/litellm providers —
436
+ # None elsewhere keeps loomflow's default 3 attempts.
437
+ retry_policy=patient_retry_policy_for(model),
438
+ **_coord_extra,
439
+ )
440
+
441
+ # Tool hooks attach to every agent that touches the codebase. The
442
+ # coordinator only reads, but a user PreToolUse hook can match
443
+ # ``read``/``grep`` too, so keep it in the set; the workers (which
444
+ # write + run bash) are the main target. No-op when none declared.
445
+ for tool_agent in (coordinator, *workers.values()):
446
+ attach_tool_hooks(
447
+ tool_agent, extensions.hook_specs, cwd=project.root
448
+ )
449
+
450
+ # Stash the MCP registry on the coordinator so the REPL / sidecar can
451
+ # tear it down (``await coordinator._mcp_registry.aclose()``) on exit
452
+ # — mirrors how the worker registry is carried on the coordinator.
453
+ # ``None`` when no MCP servers were discovered (the common case).
454
+ coordinator._mcp_registry = mcp_registry # type: ignore[attr-defined]
455
+
456
+ return coordinator, workspace
457
+
458
+
459
+ def build_solo_agent(
460
+ project: Project,
461
+ *,
462
+ model: str = DEFAULT_MODEL,
463
+ approval_handler: Callable[..., Awaitable[bool]] | None = None,
464
+ web_backend: str | None = None,
465
+ snip_window: int = 8,
466
+ auto_compact: bool = True,
467
+ effort: str | None = None,
468
+ sandbox: bool = False,
469
+ sandbox_allow_network: bool = False,
470
+ extensions: Extensions | None = None,
471
+ ) -> Agent:
472
+ """The trivial-task FAST PATH: one coder-kernel agent, no team.
473
+
474
+ The supervisor topology earns its keep on multi-file features and
475
+ verification-worthy work — but it taxes a one-line fix with a full
476
+ delegation round-trip (coordinator reads → delegates → coder
477
+ re-reads → coordinator integrates): 2-3x the turns and model calls
478
+ of just doing it. The REPL routes obviously-small tasks here
479
+ instead (see ``Repl._route_turn``); everything real still goes
480
+ through the team.
481
+
482
+ Context is CONTINUOUS across routes: the solo agent shares the
483
+ team's memory (same ``.loom/memory.db``, same embedder pivot) and
484
+ notebook workspace, and the REPL runs it under the same
485
+ ``session_id`` — so a solo fix shows up in the team's history next
486
+ turn and vice versa. Approval gate + tool hooks apply exactly as
487
+ they do for the team's coder; permissions are identical. MCP
488
+ servers are NOT attached (an external-integration task isn't a
489
+ trivial fix — the router sends those to the team).
490
+ """
491
+ loom_dir = project.root / LOOM_DIR
492
+ loom_dir.mkdir(exist_ok=True)
493
+ workspace = LocalDiskWorkspace(str(loom_dir / "notebook"))
494
+ embedder = "openai" if _is_openai_model(model) else "hash"
495
+ memory_cfg: dict[str, str] = {
496
+ "backend": "sqlite",
497
+ "path": str(loom_dir / "memory.db"),
498
+ "embedder": embedder,
499
+ }
500
+ if extensions is None:
501
+ extensions = discover_trusted(project.root)
502
+ all_skills = _bundled_skill_paths() + extensions.skill_paths
503
+
504
+ auto_compact_at_tokens: int | None = None
505
+ if auto_compact:
506
+ from loomflow.agent.auto_compact import context_window_for
507
+
508
+ from .credentials import context_window_override
509
+ window = context_window_override(model) or context_window_for(model)
510
+ auto_compact_at_tokens = int(window * 0.8)
511
+
512
+ agent = _build_coder(
513
+ project,
514
+ model=model,
515
+ approval_handler=approval_handler,
516
+ has_web=web_backend is not None,
517
+ skills=all_skills,
518
+ auto_compact_at_tokens=auto_compact_at_tokens,
519
+ snip_window=snip_window,
520
+ effort=effort,
521
+ sandbox=sandbox,
522
+ sandbox_allow_network=sandbox_allow_network,
523
+ embedder=embedder,
524
+ workspace=workspace,
525
+ # Standalone — no parent to inherit memory/workspace from.
526
+ memory=memory_cfg,
527
+ attach_workspace=True,
528
+ # Shares the REPL session_id with the read-only coordinator —
529
+ # persisting writer transcripts would make the coordinator
530
+ # rehydrate history of "itself" editing (the grind failure).
531
+ persist_tool_transcripts=False,
532
+ )
533
+ if web_backend is not None:
534
+ from loomflow.tools import web_tool
535
+ agent.add_tool(web_tool(backend=web_backend)) # type: ignore[arg-type]
536
+ attach_tool_hooks(agent, extensions.hook_specs, cwd=project.root)
537
+ return agent
538
+
539
+
540
+ def loom_dir_for(root: Path) -> Path:
541
+ """Return (and create) the ``.loom`` dir for a project root."""
542
+ d = root / LOOM_DIR
543
+ d.mkdir(exist_ok=True)
544
+ return d