loom-agent 1.3.2 → 1.5.0
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.
- package/CHANGELOG.md +216 -10
- package/README.md +10 -8
- package/package.json +2 -2
- package/src/core/agents.js +14 -1
- package/src/core/cli.js +30 -2
- package/src/core/hooks.js +28 -14
- package/src/core/memory.js +54 -6
- package/src/core/permissions.js +17 -4
- package/src/core/plugin-cmd.js +88 -1
- package/src/core/sandbox.js +311 -75
- package/src/core/session.js +262 -35
- package/src/core/transcript-undo.js +74 -0
- package/src/core/update.js +116 -6
- package/src/mcp/connector-presets.js +105 -0
- package/src/providers/anthropic.js +122 -25
- package/src/providers/google.js +19 -11
- package/src/providers/nvidia.js +45 -33
- package/src/providers/openai-compat.js +59 -27
- package/src/providers/openai.js +42 -18
- package/src/providers/openrouter.js +56 -26
- package/src/tools/index.js +206 -35
- package/src/tui/App.tsx +250 -36
- package/src/tui/components/Modals.tsx +130 -26
- package/src/tui/mcp-presets.ts +4 -65
- package/src/tui/store.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased] — completion harness, specialist agents, headless browser, TUI refresh
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
- **Transcript undo / redo (`/undo`, `/redo`, `/undo-files`).** The
|
|
11
|
+
conversation-level counterpart to `/restore` (which rewinds the *file
|
|
12
|
+
tree*): each user turn now pushes a full transcript snapshot — UI messages,
|
|
13
|
+
session messages, and todos — onto a bounded stack (last 50 turns). `/undo`
|
|
14
|
+
steps the transcript back one turn and `/redo` steps forward again. When the
|
|
15
|
+
undone turn also created a restore point, `/undo-files` rolls the *working
|
|
16
|
+
tree* back to that same point so files match the rewound conversation. The
|
|
17
|
+
engine (`src/core/transcript-undo.js`) is a pure, unit-tested stack that
|
|
18
|
+
snapshots-and-clones so later mutation can't corrupt stored state, clears
|
|
19
|
+
the redo branch on a new action (like any editor), and returns the step
|
|
20
|
+
*reference* so a restore-point id created in a background `setTimeout` can
|
|
21
|
+
be stitched onto it without relying on a fragile stack index. Wired into
|
|
22
|
+
`processSlash` and registered in `SLASH_LIST`; the existing `<leader>u` /
|
|
23
|
+
`<leader>r` keybinds now resolve to working commands instead of falling
|
|
24
|
+
through to "Unknown command". Undo/redo persist via the normal
|
|
25
|
+
`saveSession` path, so a rewind survives restart.
|
|
26
|
+
- **Startup update check.** `updateCheck()` (previously a no-op stub) now
|
|
27
|
+
probes the npm registry once every 24 hours with a 4s timeout and, when a
|
|
28
|
+
strictly newer `loom-agent` is published, prints a single stderr notice
|
|
29
|
+
(`New version available: 1.5.0 → 1.6.0` + the upgrade command) before the
|
|
30
|
+
TUI starts. Non-blocking and failure-silent by design: offline, slow, or
|
|
31
|
+
malformed responses never delay startup, the throttle stamp lives in
|
|
32
|
+
`~/.loom/update-check.json`, and `LOOM_NO_UPDATE_CHECK=1` disables it
|
|
33
|
+
entirely. Version comparison is a small tuple compare (no new dependency).
|
|
34
|
+
Unit-tested (`update.test.js`, registered in `test:unit`).
|
|
35
|
+
- **`/export` — transcript as markdown.** Writes the conversation (roles,
|
|
36
|
+
message text, tool rows, error flags) to `.loom/exports/<session>.md`, the
|
|
37
|
+
plain-text counterpart to `/share` (HTML). Bound to the existing
|
|
38
|
+
`<leader>x` keybind, which previously resolved to a slash command with no
|
|
39
|
+
handler.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
- **Orchestrator now delegates by default.** The build agent's standing
|
|
43
|
+
instructions were rewritten to make fan-out the first move on any broad
|
|
44
|
+
SEARCH / ANALYSIS / OPTIMIZATION request ("find what causes X", "where is
|
|
45
|
+
Y", "how does Z work", "optimize W", "audit/investigate/locate"): it
|
|
46
|
+
splits the scope into independent questions and dispatches them as
|
|
47
|
+
**parallel `task` calls** (one `explore` subagent per disjoint area of the
|
|
48
|
+
codebase), then reads a file itself only to resolve a single ambiguous
|
|
49
|
+
line a subagent's report left open. This closes the gap where the agent
|
|
50
|
+
defaulted to reading files serially on its own. The plumbing already
|
|
51
|
+
existed (`task` tool, `runSubagent`, per-run cancellation, token rollup,
|
|
52
|
+
`Promise.all` execution) — this change makes using it the default rather
|
|
53
|
+
than an afterthought. Pinned by new assertions in `agents.test.js`.
|
|
54
|
+
- **Token optimization — prompt caching, cache-accurate pricing, and leaner
|
|
55
|
+
context (7 changes across the request path):**
|
|
56
|
+
- **OpenRouter/TokenRouter prompt caching:** the request body now sends
|
|
57
|
+
top-level `"cache_control": true`, so cache-capable models re-read the
|
|
58
|
+
cached prefix instead of re-paying full input cost each turn.
|
|
59
|
+
- **Cache-aware usage accounting + pricing:** `recordUsage` parses
|
|
60
|
+
`prompt_tokens_details.cached_tokens` / `cache_read_input_tokens` /
|
|
61
|
+
`cache_creation_input_tokens` / `cachedContentTokenCount` and prices
|
|
62
|
+
cached reads at **0.1×** and writes at **1.25×** the input rate instead
|
|
63
|
+
of the full rate. OpenAI/OpenRouter's cached count is treated as a
|
|
64
|
+
*subset* of the prompt total; Anthropic's is additive. Adds
|
|
65
|
+
`cacheReadTokens` / `cacheWriteTokens` session counters.
|
|
66
|
+
- **Cache-stable system prompt:** the per-turn skill block moved out of the
|
|
67
|
+
system string (injected into the last user message of a wire copy
|
|
68
|
+
instead), so the system prefix stays byte-identical across turns and
|
|
69
|
+
Anthropic's cache breakpoint keeps hitting even when the matched skill
|
|
70
|
+
changes.
|
|
71
|
+
- **Mode-specific system prompts:** chat and plan modes drop the skills
|
|
72
|
+
index, the todowrite mandate, and the tool-batching/behavior rules they
|
|
73
|
+
can't act on, cutting thousands of tokens per turn for tool-less modes.
|
|
74
|
+
- **Tool-result aging:** tool results older than the last 12 messages are
|
|
75
|
+
elided to a 2000/400 head/tail slice before they reach the provider
|
|
76
|
+
(kept verbatim in memory for the TUI), so a huge `read`/`grep` output
|
|
77
|
+
stops costing full input price every later call.
|
|
78
|
+
- **Slimmer tool schemas:** `mcp`, `task`, and `sandbox` descriptions
|
|
79
|
+
trimmed (package catalogs and per-agent rosters removed) — these ship in
|
|
80
|
+
every request's tool list.
|
|
81
|
+
- **Accurate `estimateTokens` / `/context`:** the estimate now includes the
|
|
82
|
+
per-turn agent/primary/sandbox/skill blocks and tool schemas, and the
|
|
83
|
+
`/context` command gained a `Schemas+turn` line so its total matches the
|
|
84
|
+
live window estimate.
|
|
85
|
+
|
|
9
86
|
### Security
|
|
10
87
|
- **All dependency advisories cleared — `npm audit` reports 0
|
|
11
88
|
vulnerabilities** (was 7: 4 critical, 1 high, 2 low):
|
|
@@ -24,21 +101,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
24
101
|
`npm audit fix`) — fixes the DoS/recursion advisories.
|
|
25
102
|
|
|
26
103
|
### Added
|
|
104
|
+
- **Full model-catalog refresh across every built-in provider (verified
|
|
105
|
+
2026-10-17).** NVIDIA rebuilt from the live authenticated
|
|
106
|
+
`GET /v1/models` registry (81 live endpoints, chat-capable IDs only —
|
|
107
|
+
embeddings/guard/parse/video models excluded): **54 models** including
|
|
108
|
+
Nemotron 3.5 Lightning/Super VL, DeepSeek V4.1 Flash, GLM-5.3/Flash,
|
|
109
|
+
Gemma 4, DiffusionGemma, Kimi K2.6, Step 5 Preview, Palmyra (creative/
|
|
110
|
+
fin/med), Riva Translate and GPT-OSS 20B; 14 stale IDs (Llama 3.1/3.2/3.3
|
|
111
|
+
instruct, Nemotron Super 49B, GLM-5.2, GPT-OSS 120B, MiniMax M3, …) removed.
|
|
112
|
+
Anthropic 3 → **14 models** (Opus 5.5/5/4.8/4.5, Fable 5.1, Sonnet
|
|
113
|
+
5.5/5/4.6/4.5, Haiku 5.5/4.5 + legacy dated snapshots). OpenAI 12 →
|
|
114
|
+
**31** (GPT-6 Astra/Sol/Luna, GPT-5.6/5.5/5.4 families incl. Pro and
|
|
115
|
+
Codex, o3/o4 legacy, GPT-4.1/4o). Google 7 → **15** (Gemini 4 Argon,
|
|
116
|
+
3.1 Pro Preview, 3.8→3.5 Flash lines, latest-aliases, Gemma 4 31B/26B
|
|
117
|
+
A4B). OpenRouter 11 → **42** curated live slugs (Claude 5.5, GPT-6/5.6,
|
|
118
|
+
Gemini 4/3.x, Kimi K3, Qwen3.5/Coder, GLM-5.3, Grok 4.7, Llama 4,
|
|
119
|
+
Mistral Large 4, plus verified `:free` tiers). Context windows and
|
|
120
|
+
$/1M prices cross-checked against models.dev and the OpenRouter public
|
|
121
|
+
catalog.
|
|
122
|
+
- **Provider resilience — retries with backoff and honest error classes
|
|
123
|
+
(`src/providers/anthropic.js`, `openai-compat.js`).** Anthropic requests
|
|
124
|
+
retry 429/529/5xx up to 4 tries with jitter, honoring `Retry-After`;
|
|
125
|
+
openai-compat retries 429/502/503/529 exponentially (first retry is
|
|
126
|
+
immediate unless `Retry-After` says otherwise). Provider errors now carry
|
|
127
|
+
`.status`/`.retryAfterMs` so the session loop can act on them: rate limits
|
|
128
|
+
**wait and retry** (honoring `Retry-After`, capped 30s) before falling back
|
|
129
|
+
to model switching, `isQuotaError` (402/insufficient balance) still
|
|
130
|
+
auto-switches models, and `isContextOverflowError` **compacts and retries
|
|
131
|
+
once per turn**. Mid-stream SSE `error` events and fully-truncated streams
|
|
132
|
+
now throw instead of silently yielding an empty answer; Anthropic
|
|
133
|
+
`stop_reason` maps through to `finish_reason` (`max_tokens`→`length`,
|
|
134
|
+
`tool_use`→`tool_calls`) and tool results carry `is_error` on both the wire
|
|
135
|
+
and the history row (`isError`).
|
|
136
|
+
- **Session-loop resilience + context hygiene (`src/core/session.js`).**
|
|
137
|
+
`_normalizeHistory()` slices every provider call from the first user row
|
|
138
|
+
(no more leading orphan assistant/tool rows), compaction summaries are
|
|
139
|
+
stored as **user-role rows** — openai-compat drops `role:'system'` history
|
|
140
|
+
rows, so the summary used to silently vanish before the model ever saw it —
|
|
141
|
+
`estimateTokens()` counts the system prompt and tool schemas,
|
|
142
|
+
`contextEstimate()` backs a live `/context` window, and auto-compaction
|
|
143
|
+
triggers at the context threshold mid-turn (once per turn). Failed tool
|
|
144
|
+
outcomes are marked `isError` in history.
|
|
145
|
+
- **Two new tools — `todoread` and `tasks`** — read the current todo list,
|
|
146
|
+
and list/output/kill background tasks (`bg:*` handles, auto-gone after
|
|
147
|
+
10min finished). Tool results are capped at **100k chars** (head + tail
|
|
148
|
+
with a `[…truncated N chars…]` marker) across text, errors, and MCP
|
|
149
|
+
payloads; `read` detects binaries and caps at 512KB / 2000 lines; `bash`
|
|
150
|
+
takes `timeoutMs` (default 60s) and is killed by Ctrl-C; `webfetch`
|
|
151
|
+
aborts with the session; `edit` gains a multi-edit `edits` array (each
|
|
152
|
+
entry enforces a unique match — the single `oldString` path keeps its
|
|
153
|
+
replace-first behavior).
|
|
154
|
+
- **MCP tools are permission-gated** — new `mcp` permission key (default
|
|
155
|
+
`ask`); patterns match the full tool name (`mcp__github__*`, exact names,
|
|
156
|
+
denies), and `todoread` maps to the `read` rule.
|
|
157
|
+
- **Project steering reads `AGENTS.md` (`src/core/memory.js`)** — global
|
|
158
|
+
LOOM.md plus cwd `LOOM.md`/`.loom/LOOM.md`/`AGENTS.md` and up to 3 parent
|
|
159
|
+
directories (per-file 12k, project-section 24k caps), with `CLAUDE.md` as
|
|
160
|
+
a fallback when no LOOM/AGENTS file exists.
|
|
161
|
+
- **Hooks: `userPromptSubmit` and `subagentStop` (`src/core/hooks.js`).**
|
|
162
|
+
`userPromptSubmit` runs before a prompt hits the model and can block the
|
|
163
|
+
prompt (`decision:"block"` or a non-zero exit with stderr) or inject
|
|
164
|
+
`<user-prompt-submit-hook>` context; `subagentStop` fires after a
|
|
165
|
+
subagent finishes; `postToolUse` now receives `tool_response` (capped 4k).
|
|
166
|
+
- **TUI/CLI:** a `/compact` slash command (also Ctrl+X c), `/context` shows
|
|
167
|
+
a live context-window estimate (tokens / window / %), background-task
|
|
168
|
+
completions surface as toasts, and per-turn autosave keeps saved sessions
|
|
169
|
+
current. `loom`'s `/compact` now uses the real `session.compact()`.
|
|
170
|
+
- **Easier connector installs — `loom add connector` + a visible "+ Add
|
|
171
|
+
connector…" row (`src/mcp/connector-presets.js`, `connectorAddLineCmd`).**
|
|
172
|
+
The connector preset catalog moved from the TUI into the core layer so the
|
|
173
|
+
shell and the TUI picker share one source. From the shell (or
|
|
174
|
+
`/connectors add`), a preset name plus `-e KEY=V` flags expands to the full
|
|
175
|
+
one-liner — `loom add connector supabase -e SUPABASE_ACCESS_TOKEN=xxx` —
|
|
176
|
+
and missing keys fail with the exact `-e` flag and where to get the token
|
|
177
|
+
instead of persisting a broken server. Leftover tokens append as trailing
|
|
178
|
+
args (`… supabase --project-ref abcdef`), and anything that is not a preset
|
|
179
|
+
name falls through to the generic `[-e KEY=V] <name> [--] <command>
|
|
180
|
+
[args...]` form. The `/connectors` and `/mcp` browsers now pin a "+ Add …"
|
|
181
|
+
row under the list (Enter opens the preset picker; `A` still works), and
|
|
182
|
+
the one-line add editor accepts a bare preset name.
|
|
27
183
|
- **Docker sandbox with a guided `/sandbox` wizard (`src/core/sandbox.js`,
|
|
28
184
|
`SandboxModal`).** `/sandbox` (or `/sandbox setup`) opens a three-step
|
|
29
185
|
modal: (1) Docker check — if missing, shows the platform install link with
|
|
30
186
|
an "open download page" action and remembers the pending install; (2) OS
|
|
31
187
|
pick — Alpine (~250 MB, apk), Debian Slim (~600 MB, apt) or Ubuntu
|
|
32
|
-
(~900 MB, apt); (3) live build with
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
188
|
+
(~900 MB, apt); (3) live build with a real progress UI: per-stage
|
|
189
|
+
checklist (pull → create → start → essentials → browser → ready), a
|
|
190
|
+
percentage bar driven by actual docker-pull layer bytes, and streamed
|
|
191
|
+
install logs. Setup is fully asynchronous — the TUI never freezes — and
|
|
192
|
+
the wizard can be esc'd while setup continues in the background. Setup
|
|
193
|
+
pulls the chosen `node:20-*` image, creates the single shared
|
|
194
|
+
`loom-sandbox` container with the project mounted at `/workspace`,
|
|
195
|
+
installs essentials (git, bash, curl, ripgrep, coreutils) plus a headless
|
|
196
|
+
Chromium where offered (best-effort). Isolation: project dir only, no host
|
|
197
|
+
network, no `--privileged`. The container idles in the background; the
|
|
198
|
+
dedicated **`sandbox` tool** runs one command inside it only when the
|
|
199
|
+
agent decides isolation is needed (see Changed). `LOOM_SANDBOX=0` is a
|
|
200
|
+
hard kill-switch. Saying "docker is ready" after installing reopens the
|
|
201
|
+
wizard at the OS step.
|
|
42
202
|
- **Drive-to-completion harness (`src/core/session.js`).** When the model ends
|
|
43
203
|
a turn with todos still pending/in_progress (the "stops between tasks"
|
|
44
204
|
failure), the loop injects a continuation listing the unfinished items — up
|
|
@@ -64,6 +224,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
64
224
|
reads allow, interactive click/fill/press ask.
|
|
65
225
|
|
|
66
226
|
### Fixed
|
|
227
|
+
- **Unit tests no longer read the developer's real `~/.loom` config**
|
|
228
|
+
(`src/core/session.test.js`, `agents.test.js`). `session.test.js` never set
|
|
229
|
+
`LOOM_CONFIG_DIR`, and the config-merge test in `agents.test.js` DELETED
|
|
230
|
+
the variable instead of restoring its temp dir — so once the local config
|
|
231
|
+
picked a dynamic models.dev provider (or any provider the test process
|
|
232
|
+
hadn't merged), every `new Session()` failed with `Unknown provider` and
|
|
233
|
+
the whole `bun run test:unit` stage went red (36 failures). Both files now
|
|
234
|
+
run hermetically against a temp config dir (restored/cleaned in
|
|
235
|
+
`afterAll`).
|
|
67
236
|
- **Agent loop no longer stops in the middle of work** (`src/core/session.js`
|
|
68
237
|
continuation guards — opencode/Claude-Code parity):
|
|
69
238
|
- **Truncation auto-continue.** `finish_reason=length` (output budget
|
|
@@ -82,6 +251,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
82
251
|
todos are surfaced to the UI whenever the guard engaged.
|
|
83
252
|
|
|
84
253
|
### Changed
|
|
254
|
+
- **Sandbox commands are now routed through Docker when the agent decides it
|
|
255
|
+
needs the sandbox** — instead of quietly running on the host:
|
|
256
|
+
- `bash` gained an opt-in **`sandbox: true`** parameter; the same command
|
|
257
|
+
then runs via `docker exec loom-sandbox sh -lc "<command>"`
|
|
258
|
+
(`workdir /workspace`). If the sandbox isn't set up the call FAILS with
|
|
259
|
+
the `/sandbox` setup hint — it never silently falls back to the host.
|
|
260
|
+
- The system prompt now states the exact routing (build-mode tool list +
|
|
261
|
+
a per-turn **"Sandbox (Docker, ready)"** block, resolved lazily so
|
|
262
|
+
`/sandbox setup|on|off` applies from the next turn): sandbox-intended
|
|
263
|
+
commands go through the `sandbox` tool or `bash {sandbox:true}`, ordinary
|
|
264
|
+
commands stay on the host.
|
|
265
|
+
- The `sandbox` tool description spells out the `docker exec … sh -lc`
|
|
266
|
+
shape, and the tool now flows through the same permission gate as bash
|
|
267
|
+
(dangerous commands prompt; user-approved calls carry `_approved`).
|
|
268
|
+
- **Sandbox is now opt-in per command, not a global router.** Enabling the
|
|
269
|
+
sandbox no longer hijacks the bash tool — every host command keeps running
|
|
270
|
+
on your machine (Windows included) exactly as before. Instead, a dedicated
|
|
271
|
+
**`sandbox` tool** appears; the agent calls it only when isolation is
|
|
272
|
+
genuinely needed (you asked for the sandbox, the command is risky, or a
|
|
273
|
+
Linux-only environment is required). Each call revives the container if it
|
|
274
|
+
stopped (e.g. after a Docker Desktop restart) and re-mounts the current
|
|
275
|
+
project, then runs just that one command via `docker exec`. The container
|
|
276
|
+
idles in the background between calls; `/sandbox off` disables the tool
|
|
277
|
+
(not the host), `/sandbox on` re-enables it. The setup wizard's build step
|
|
278
|
+
also became fully non-blocking: docker pull streams per-layer progress
|
|
279
|
+
into a live percentage bar, package installs stream their output, and the
|
|
280
|
+
TUI stays responsive throughout (esc lets setup finish in the background).
|
|
281
|
+
- **Every docker status check is now spawn-based (non-blocking).** The last
|
|
282
|
+
synchronous `execFileSync` calls — `detectDocker()` (wizard open, setup
|
|
283
|
+
start, "docker is ready" resume), `sandboxStatus()` (session start,
|
|
284
|
+
`/sandbox status`, `/sandbox on`), and `/sandbox rm` — blocked the whole
|
|
285
|
+
event loop for up to 10–30 s while Docker Desktop answered, freezing the
|
|
286
|
+
TUI (no cursor, no renders) and swallowing live progress updates. New
|
|
287
|
+
async twins (`detectDockerAsync`, `sandboxStatusAsync`, `sandboxPending`)
|
|
288
|
+
stream results back via promises; the wizard shows a spinner during the
|
|
289
|
+
check instead of hanging; the session-start pending reminder is now a
|
|
290
|
+
config-only read (zero docker calls).
|
|
85
291
|
- **Slash popup decluttered.** The autocomplete popup and command palette
|
|
86
292
|
now show a short one-line description per command — argument syntax is no
|
|
87
293
|
longer appended (it lives in `/help` and the README table). Duplicate
|
package/README.md
CHANGED
|
@@ -21,11 +21,11 @@ An AI-powered coding agent for the terminal with multi-provider support and a fu
|
|
|
21
21
|
- Full OpenTUI interface with slash commands, autocomplete, and ESC-to-interrupt
|
|
22
22
|
- Build / Plan / Chat modes
|
|
23
23
|
- OpenCode-style agents — primary agents (`build`/`plan`/`chat`) plus delegating subagents (`explore`, `scout`, `general`) invoked automatically via the `task` tool or manually via `@agent` mentions
|
|
24
|
-
- LOOM.md project memory
|
|
25
|
-
- Multi-provider: Anthropic, OpenAI, NVIDIA, Google Gemini, OpenRouter, Local (Ollama)
|
|
26
|
-
- Built-in tools: read, write, edit, bash, grep, glob, webfetch, todowrite
|
|
24
|
+
- LOOM.md / AGENTS.md project memory (cwd + parent directories)
|
|
25
|
+
- Multi-provider: Anthropic, OpenAI, NVIDIA, Google Gemini, OpenRouter, Local (Ollama) — with automatic retries (Retry-After aware), rate-limit waits, quota-driven model switching, and compact-and-retry on context overflow
|
|
26
|
+
- Built-in tools: read, write, edit (multi-edit), bash (timeouts + interrupt), grep, glob, webfetch, todowrite, todoread, tasks — results capped at 100k chars
|
|
27
27
|
- Agentic tool loop (multiple tool calls per turn)
|
|
28
|
-
- MCP (Model Context Protocol) server integration
|
|
28
|
+
- MCP (Model Context Protocol) server integration — MCP tools are permission-gated (`mcp` rule, default ask)
|
|
29
29
|
- Drag-to-copy selections, paste support
|
|
30
30
|
- Session memory, /undo, /redo, /compact, /reset, /fork
|
|
31
31
|
- One-shot print mode: `loom -p "query"`
|
|
@@ -33,6 +33,7 @@ An AI-powered coding agent for the terminal with multi-provider support and a fu
|
|
|
33
33
|
- OpenCode-style agent harness: main orchestrator agent delegates to specialist subagents (`research`, `coder`, `reviewer`, `tester`, `web`, `explore`, `scout`) via the `task` tool, and a drive-to-completion loop keeps working while todos are still open
|
|
34
34
|
- Headless browser tool (`browser`) — navigate, click, fill, read the DOM, and screenshot real pages from the `web` agent
|
|
35
35
|
- Usage & billing tracking per session + lifetime
|
|
36
|
+
- Hooks: `userPromptSubmit` (block or inject context before a prompt runs) and `subagentStop` in `.loom/hooks.json`
|
|
36
37
|
|
|
37
38
|
## Prerequisites
|
|
38
39
|
|
|
@@ -128,7 +129,7 @@ Zed (add to `~/.config/zed/settings.json`, then Command Palette → `agent: new
|
|
|
128
129
|
Full transport spec, JetBrains/Neovim configs, the protocol walkthrough, and a
|
|
129
130
|
self-test client (`node scripts/acp-smoke.js`) live in **[docs/acp.md](docs/acp.md)**.
|
|
130
131
|
|
|
131
|
-
## Slash Commands (
|
|
132
|
+
## Slash Commands (37 total)
|
|
132
133
|
|
|
133
134
|
| Command | Args | Description |
|
|
134
135
|
|---------|------|-------------|
|
|
@@ -154,16 +155,17 @@ self-test client (`node scripts/acp-smoke.js`) live in **[docs/acp.md](docs/acp.
|
|
|
154
155
|
| `/graph` | — | Memory graph |
|
|
155
156
|
| `/skills` | `install\|remove` | Manage skills |
|
|
156
157
|
| `/mcp` | `add\|remove\|toggle` | Manage MCP servers |
|
|
157
|
-
| `/connectors` | `add\|remove\|toggle` | Manage connectors |
|
|
158
|
+
| `/connectors` | `add\|remove\|toggle` | Manage connectors — presets (supabase, railway, vercel, …) or one-liners; also `loom add connector` |
|
|
158
159
|
| `/permissions` | `reset\|auto` | Permission rules |
|
|
159
160
|
| `/subagents` | — | Subagent runs |
|
|
160
|
-
| `/context` | — | Context window usage |
|
|
161
|
+
| `/context` | — | Context window usage (live estimate) |
|
|
162
|
+
| `/compact` | — | Summarize older messages to free context |
|
|
161
163
|
| `/think` | `off\|low\|medium\|high` | Thinking budget |
|
|
162
164
|
| `/approve` | — | Approve plan → Build |
|
|
163
165
|
| `/tasks` | — | Background tasks |
|
|
164
166
|
| `/share` | — | Export chat as HTML |
|
|
165
167
|
| `/worktree` | `<name>` | New git worktree |
|
|
166
|
-
| `/sandbox` | `setup\|status\|on\|off\|rm` | Docker sandbox |
|
|
168
|
+
| `/sandbox` | `setup\|status\|on\|off\|rm` | Docker sandbox (opt-in `sandbox` tool; host untouched) |
|
|
167
169
|
| `/style` | `[preset\|text]` | Output style preset |
|
|
168
170
|
| `/vim` | — | Toggle vim editing |
|
|
169
171
|
| `/remember` | `<fact>` | Save a fact to LOOM.md |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loom-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/toshalkumbhar8979-design/loomcode.git"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"dev": "node --watch src/index.js",
|
|
25
25
|
"setup": "node src/setup.js",
|
|
26
26
|
"test": "bun run src/tui/test-interactive.tsx && bun run test:unit && bun run lint:core",
|
|
27
|
-
"test:unit": "bun test src/core/agents.test.js src/core/hooks.test.js src/core/custom-commands.test.js src/core/background-tasks.test.js src/core/memory.test.js src/core/subagent-log.test.js src/core/session.test.js src/core/sandbox.test.js src/tools/index.test.js src/tools/security.test.js src/providers/providers.test.js src/providers/caching.test.js src/providers/caching-thinking.test.js src/providers/registry.test.js src/mcp/mcp-client.test.js src/mcp/mcp-manager.test.js src/core/session-store.test.js src/skills/skills-manager.test.js src/core/usage.test.js src/core/permissions.test.js src/core/format.test.js src/acp/acp-server.test.js src/tui/keybinds.test.ts",
|
|
27
|
+
"test:unit": "bun test src/core/agents.test.js src/core/hooks.test.js src/core/custom-commands.test.js src/core/background-tasks.test.js src/core/memory.test.js src/core/subagent-log.test.js src/core/session.test.js src/core/sandbox.test.js src/tools/index.test.js src/tools/security.test.js src/providers/providers.test.js src/providers/caching.test.js src/providers/caching-thinking.test.js src/providers/registry.test.js src/mcp/mcp-client.test.js src/mcp/mcp-manager.test.js src/core/session-store.test.js src/skills/skills-manager.test.js src/core/usage.test.js src/core/permissions.test.js src/core/format.test.js src/acp/acp-server.test.js src/tui/keybinds.test.ts src/core/transcript-undo.test.js src/core/update.test.js",
|
|
28
28
|
"lint:core": "node node_modules/typescript/bin/tsc -p tsconfig.core.json",
|
|
29
29
|
"smoke:acp": "node scripts/acp-smoke.js",
|
|
30
30
|
"tui": "bun bin/loom-tui.js",
|
package/src/core/agents.js
CHANGED
|
@@ -62,7 +62,7 @@ const BUILTIN_AGENTS = {
|
|
|
62
62
|
id: 'build', name: 'Build', mode: 'primary',
|
|
63
63
|
description: 'Main orchestrator agent: full development work with all tools, drives tasks to completion.',
|
|
64
64
|
tools: ['*'],
|
|
65
|
-
prompt:
|
|
65
|
+
prompt: "You are the MAIN ORCHESTRATOR agent. You DELEGATE heavy lifting to subagents via the task tool and synthesize their reports — you are rarely the one reading raw files. For any task that is a SEARCH, ANALYSIS, or broad OPTIMIZATION (\"find what causes X\", \"where is Y\", \"how does Z work\", \"optimize W\", \"audit/investigate/locate\"), FAN OUT FIRST: split the scope into independent questions and dispatch them as parallel task calls in a single message — one explore agent per disjoint area of the codebase (parallel calls run concurrently). Only read/grep a file YOURSELF when you need one specific line a subagent's report left ambiguous. Then: (1) for non-trivial multi-step work capture the plan as a todowrite list up front, (2) keep working until every item is completed or explicitly cancelled — never stop mid-task with a summary while todos remain, (3) dispatch implementation/verification packages too: coder for isolated implementation, reviewer for review, tester for tests, research for deep multi-source investigation, web for browser work, (4) verify your work (run tests/typecheck/lint the project actually uses) before declaring done, (5) finish with a terse summary of what changed and which subagent reports you folded in.",
|
|
66
66
|
},
|
|
67
67
|
plan: {
|
|
68
68
|
id: 'plan', name: 'Plan', mode: 'primary',
|
|
@@ -335,6 +335,19 @@ async function runSubagent(opts) {
|
|
|
335
335
|
const durationMs = Date.now() - t0;
|
|
336
336
|
const interrupted = !!resp.interrupted;
|
|
337
337
|
|
|
338
|
+
// subagentStop hook — fires when a subagent turn ends (success, error,
|
|
339
|
+
// interrupt). Fire-and-forget: a slow/broken hook must never wedge the
|
|
340
|
+
// parent turn.
|
|
341
|
+
try {
|
|
342
|
+
const hooks = require('./hooks');
|
|
343
|
+
Promise.resolve(hooks.runHook('subagentStop', {
|
|
344
|
+
agent: agent.id,
|
|
345
|
+
runId,
|
|
346
|
+
reason: resp.type || 'end',
|
|
347
|
+
interrupted,
|
|
348
|
+
})).catch(() => {});
|
|
349
|
+
} catch {}
|
|
350
|
+
|
|
338
351
|
if (parent) {
|
|
339
352
|
parent.tokensIn += child.tokensIn;
|
|
340
353
|
parent.tokensOut += child.tokensOut;
|
package/src/core/cli.js
CHANGED
|
@@ -304,8 +304,14 @@ class LoomCLI {
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
async cmdCompact() {
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
// Real compaction: summarize the older messages with the model (falls
|
|
308
|
+
// back to head-truncation when no provider/key is available).
|
|
309
|
+
const res = await this.session.compact();
|
|
310
|
+
if (res.compacted) {
|
|
311
|
+
console.log(`Compacted (${res.method}): removed ${res.removed} messages, ~${res.tokensAfter} tokens remain.`);
|
|
312
|
+
} else {
|
|
313
|
+
console.log('Nothing to compact — the conversation is still short.');
|
|
314
|
+
}
|
|
309
315
|
}
|
|
310
316
|
|
|
311
317
|
cmdUndo() {
|
|
@@ -540,6 +546,7 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
540
546
|
console.log(' loom --auto Auto-approve permission prompts');
|
|
541
547
|
console.log(' loom acp ACP server (JSON-RPC over stdio, for editor plugins)');
|
|
542
548
|
console.log(' loom add mcp <spec> Add an MCP server (same syntax as /mcp add)');
|
|
549
|
+
console.log(' loom add connector <spec> Add a connector (preset like supabase, or one-liner)');
|
|
543
550
|
console.log(' loom -s <session-id> Resume a saved session');
|
|
544
551
|
console.log(' loom --version Show version');
|
|
545
552
|
console.log(' loom --help Show this help');
|
|
@@ -576,6 +583,27 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
576
583
|
process.exit(ok ? 0 : 1);
|
|
577
584
|
}
|
|
578
585
|
|
|
586
|
+
// loom add connector <spec> — same engine as /connectors add. A preset name
|
|
587
|
+
// (supabase, nextjs, railway, vercel, netlify, cloudflare) expands with
|
|
588
|
+
// -e KEY=V flags; anything else is the generic one-liner form. Exits 0 on
|
|
589
|
+
// success.
|
|
590
|
+
if (args[0] === 'add' && (args[1] === 'connector' || args[1] === 'connectors')) {
|
|
591
|
+
const spec = args.slice(2);
|
|
592
|
+
if (!spec.length) {
|
|
593
|
+
const { CONNECTOR_PRESETS } = require('../mcp/connector-presets');
|
|
594
|
+
console.log('Usage: loom add connector <preset> [-e KEY=V]...');
|
|
595
|
+
console.log(' or: loom add connector [-e KEY=V] <name> [--] <command> [args...]');
|
|
596
|
+
console.log('Presets: ' + CONNECTOR_PRESETS.map(p => p.id).join(', '));
|
|
597
|
+
console.log('Example: loom add connector supabase -e SUPABASE_ACCESS_TOKEN=xxx');
|
|
598
|
+
process.exit(1);
|
|
599
|
+
}
|
|
600
|
+
const { connectorAddLineCmd } = require('./plugin-cmd');
|
|
601
|
+
const msg = connectorAddLineCmd(spec.join(' '));
|
|
602
|
+
const ok = typeof msg === 'string' && msg.startsWith('Added');
|
|
603
|
+
console.log(ok ? msg : 'Error: ' + msg);
|
|
604
|
+
process.exit(ok ? 0 : 1);
|
|
605
|
+
}
|
|
606
|
+
|
|
579
607
|
if (sessionId && sIndex !== -1) {
|
|
580
608
|
const data = loadSession(sessionId);
|
|
581
609
|
if (data && data.messages && data.messages.length) {
|
package/src/core/hooks.js
CHANGED
|
@@ -2,27 +2,33 @@
|
|
|
2
2
|
// agent loop (Claude Code-style). Config lives in config.json:
|
|
3
3
|
//
|
|
4
4
|
// "hooks": {
|
|
5
|
+
// "userPromptSubmit": "node .loom/hooks/ctx.js", // may BLOCK the prompt or inject context
|
|
5
6
|
// "preToolUse": "node .loom/hooks/guard.js", // may BLOCK the tool call
|
|
6
|
-
// "postToolUse": "node .loom/hooks/audit.js", // informational
|
|
7
|
-
// "stop": "node .loom/hooks/notify.js"
|
|
7
|
+
// "postToolUse": "node .loom/hooks/audit.js", // informational (gets tool_response)
|
|
8
|
+
// "stop": "node .loom/hooks/notify.js", // fires when a turn ends
|
|
9
|
+
// "subagentStop": "node .loom/hooks/done.js" // fires when a subagent finishes
|
|
8
10
|
// }
|
|
9
11
|
//
|
|
10
|
-
// Contract: the command receives JSON on stdin ({ hook, tool, input
|
|
11
|
-
// pre/post, { hook,
|
|
12
|
+
// Contract: the command receives JSON on stdin ({ hook, tool, input,
|
|
13
|
+
// tool_response } for pre/post, { hook, prompt } for userPromptSubmit,
|
|
14
|
+
// { hook, reason } for stop/subagentStop) plus LOOM_HOOK / LOOM_TOOL /
|
|
12
15
|
// LOOM_TOOL_INPUT env vars (Windows one-liner friendly). Exit code non-zero,
|
|
13
|
-
// or stdout containing {"decision":"deny","reason":"..."}, blocks a
|
|
14
|
-
// preToolUse
|
|
15
|
-
//
|
|
16
|
+
// or stdout containing {"decision":"deny"|"block","reason":"..."}, blocks a
|
|
17
|
+
// preToolUse tool call or a userPromptSubmit prompt. userPromptSubmit stdout
|
|
18
|
+
// may also carry {"hookSpecificOutput":{"additionalContext":"..."}} (or a
|
|
19
|
+
// top-level additionalContext) which is appended to the user's message.
|
|
20
|
+
// A 10s timeout counts as a failure-to-run (allowed through) rather than a
|
|
21
|
+
// deny — hooks must not wedge the loop.
|
|
16
22
|
const { spawn } = require('child_process');
|
|
17
23
|
const { loadConfig } = require('../config/settings');
|
|
18
24
|
|
|
19
|
-
/** @typedef {'preToolUse'|'postToolUse'|'stop'} HookName */
|
|
25
|
+
/** @typedef {'preToolUse'|'postToolUse'|'stop'|'userPromptSubmit'|'subagentStop'} HookName */
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* Run one configured hook.
|
|
23
29
|
* @param {HookName} name
|
|
24
|
-
* @param {{ tool?: string, input?: object, reason?: string }} payload
|
|
25
|
-
* @returns {Promise<{ blocked: boolean, reason?: string, ran: boolean }>}
|
|
30
|
+
* @param {{ tool?: string, input?: object, reason?: string, prompt?: string, tool_response?: string, agent?: string, runId?: string, interrupted?: boolean }} payload
|
|
31
|
+
* @returns {Promise<{ blocked: boolean, reason?: string, ran: boolean, additionalContext?: string }>}
|
|
26
32
|
*/
|
|
27
33
|
async function runHook(name, payload) {
|
|
28
34
|
let cmd = '';
|
|
@@ -61,20 +67,28 @@ async function runHook(name, payload) {
|
|
|
61
67
|
child.on('error', () => { clearTimeout(timer); done({ blocked: false, ran: false }); });
|
|
62
68
|
child.on('close', (code) => {
|
|
63
69
|
clearTimeout(timer);
|
|
64
|
-
// Structured deny wins over exit codes; plain non-zero also blocks
|
|
70
|
+
// Structured deny wins over exit codes; plain non-zero also blocks
|
|
71
|
+
// preToolUse and userPromptSubmit.
|
|
65
72
|
let denyReason = null;
|
|
73
|
+
let additionalContext = null;
|
|
66
74
|
const m = out.match(/\{[\s\S]*\}/);
|
|
67
75
|
if (m) {
|
|
68
76
|
try {
|
|
69
77
|
const j = JSON.parse(m[0]);
|
|
70
|
-
if (j && j.decision === 'deny'
|
|
78
|
+
if (j && (j.decision === 'deny' || j.decision === 'block')) {
|
|
79
|
+
denyReason = String(j.reason || 'blocked by hook');
|
|
80
|
+
}
|
|
81
|
+
const ctxText = j && (j.additionalContext
|
|
82
|
+
|| (j.hookSpecificOutput && j.hookSpecificOutput.additionalContext));
|
|
83
|
+
if (ctxText) additionalContext = String(ctxText);
|
|
71
84
|
} catch {}
|
|
72
85
|
}
|
|
73
|
-
|
|
86
|
+
const blockable = name === 'preToolUse' || name === 'userPromptSubmit';
|
|
87
|
+
if (blockable && (denyReason || code !== 0)) {
|
|
74
88
|
done({ blocked: true, reason: denyReason || ('hook exited ' + code), ran: true });
|
|
75
89
|
return;
|
|
76
90
|
}
|
|
77
|
-
done({ blocked: false, ran: true });
|
|
91
|
+
done({ blocked: false, ran: true, additionalContext: additionalContext || undefined });
|
|
78
92
|
});
|
|
79
93
|
});
|
|
80
94
|
}
|
package/src/core/memory.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// Memory — layered
|
|
2
|
-
// Layers: global (~/.loom/LOOM.md) then project
|
|
3
|
-
//
|
|
1
|
+
// Memory — layered steering files injected into every session's system prompt.
|
|
2
|
+
// Layers: global (~/.loom/LOOM.md), then project steering: LOOM.md and
|
|
3
|
+
// AGENTS.md in <cwd> plus up to 3 parent directories (monorepo repos carry
|
|
4
|
+
// steering at the root). CLAUDE.md loads only when the cwd has neither
|
|
5
|
+
// LOOM.md nor AGENTS.md (back-compat shim). Any file may pull in others with
|
|
6
|
+
// `@relative/path.md` lines (depth-capped, cycle-safe).
|
|
4
7
|
const fs = require('fs');
|
|
5
8
|
const path = require('path');
|
|
6
9
|
const os = require('os');
|
|
@@ -8,6 +11,10 @@ const os = require('os');
|
|
|
8
11
|
const MAX_IMPORT_DEPTH = 3;
|
|
9
12
|
// Cap each layer so a runaway memory file can't eat the context window.
|
|
10
13
|
const MAX_CHARS_PER_LAYER = 12000;
|
|
14
|
+
// Hard cap for the whole project section (4 dirs × 2 filenames worst case).
|
|
15
|
+
const MAX_CHARS_PROJECT = 24000;
|
|
16
|
+
// How many directories up from cwd carry steering files.
|
|
17
|
+
const MAX_PARENT_DEPTH = 3;
|
|
11
18
|
|
|
12
19
|
function globalMemoryPath() {
|
|
13
20
|
const base = process.env.LOOM_CONFIG_DIR || path.join(os.homedir(), '.loom');
|
|
@@ -40,6 +47,35 @@ function loadFile(file, depth, seen) {
|
|
|
40
47
|
return out.join('\n').trim();
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Collect project steering files: cwd + up to MAX_PARENT_DEPTH parents,
|
|
52
|
+
* LOOM.md then AGENTS.md per directory, nearest first. Returns
|
|
53
|
+
* [{ file, body }] with imports expanded.
|
|
54
|
+
* @returns {Array<{file: string, body: string}>}
|
|
55
|
+
*/
|
|
56
|
+
function collectProjectSteering() {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
let dir = process.cwd();
|
|
60
|
+
for (let depth = 0; depth <= MAX_PARENT_DEPTH; depth++) {
|
|
61
|
+
const names = depth === 0
|
|
62
|
+
? ['LOOM.md', '.loom/LOOM.md', 'AGENTS.md']
|
|
63
|
+
: ['LOOM.md', 'AGENTS.md'];
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
const p = path.join(dir, name);
|
|
66
|
+
const abs = path.resolve(p);
|
|
67
|
+
if (seen.has(abs)) continue;
|
|
68
|
+
seen.add(abs);
|
|
69
|
+
const body = loadFile(p, 0, new Set());
|
|
70
|
+
if (body) chunks.push({ file: abs, body });
|
|
71
|
+
}
|
|
72
|
+
const parent = path.dirname(dir);
|
|
73
|
+
if (parent === dir) break;
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
return chunks;
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
/**
|
|
44
80
|
* Build the full memory block: "## Global memory" + "## Project memory"
|
|
45
81
|
* sections with imports expanded. Empty string when no memory exists.
|
|
@@ -49,9 +85,21 @@ function loadMemory() {
|
|
|
49
85
|
const parts = [];
|
|
50
86
|
const g = loadFile(globalMemoryPath(), 0, new Set());
|
|
51
87
|
if (g) parts.push('## Global memory\n\n' + g.slice(0, MAX_CHARS_PER_LAYER));
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
|
|
88
|
+
let chunks = collectProjectSteering();
|
|
89
|
+
// CLAUDE.md parity: only when no LOOM.md/AGENTS.md steering exists in cwd
|
|
90
|
+
// or its parents — a repo that already steers via LOOM/AGENTS wins.
|
|
91
|
+
if (!chunks.length) {
|
|
92
|
+
const p = path.join(process.cwd(), '.claude', 'CLAUDE.md');
|
|
93
|
+
const body = loadFile(p, 0, new Set());
|
|
94
|
+
if (body) chunks.push({ file: p, body });
|
|
95
|
+
}
|
|
96
|
+
if (chunks.length) {
|
|
97
|
+
const content = chunks
|
|
98
|
+
.map((c) => '### ' + c.file + '\n\n' + c.body.slice(0, MAX_CHARS_PER_LAYER))
|
|
99
|
+
.join('\n\n')
|
|
100
|
+
.slice(0, MAX_CHARS_PROJECT);
|
|
101
|
+
parts.push('## Project memory\n\n' + content);
|
|
102
|
+
}
|
|
55
103
|
return parts.join('\n\n');
|
|
56
104
|
}
|
|
57
105
|
|