loom-agent 1.3.3 → 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 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,6 +101,85 @@ 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
@@ -68,6 +224,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
68
224
  reads allow, interactive click/fill/press ask.
69
225
 
70
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`).
71
236
  - **Agent loop no longer stops in the middle of work** (`src/core/session.js`
72
237
  continuation guards — opencode/Claude-Code parity):
73
238
  - **Truncation auto-continue.** `finish_reason=length` (output budget
@@ -86,6 +251,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
86
251
  todos are surfaced to the UI whenever the guard engaged.
87
252
 
88
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`).
89
268
  - **Sandbox is now opt-in per command, not a global router.** Enabling the
90
269
  sandbox no longer hijacks the bash tool — every host command keeps running
91
270
  on your machine (Windows included) exactly as before. Instead, a dedicated
@@ -99,6 +278,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
278
  also became fully non-blocking: docker pull streams per-layer progress
100
279
  into a live percentage bar, package installs stream their output, and the
101
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).
102
291
  - **Slash popup decluttered.** The autocomplete popup and command palette
103
292
  now show a short one-line description per command — argument syntax is no
104
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 file
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 (36 total)
132
+ ## Slash Commands (37 total)
132
133
 
133
134
  | Command | Args | Description |
134
135
  |---------|------|-------------|
@@ -154,10 +155,11 @@ 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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loom-agent",
3
- "version": "1.3.3",
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",
@@ -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: 'You are the MAIN ORCHESTRATOR agent. For non-trivial, multi-step work: (1) 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) delegate independent/parallelizable packages via the task tool (research for deep investigation, coder for isolated implementation, reviewer for review, tester for tests, web for browser work, explore for fast code search), (4) verify your work (run tests/typecheck/lint the project actually uses) before declaring done, (5) finish with a terse summary of what changed.',
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
- this.session.messages = this.session.messages.slice(-10);
308
- console.log('Conversation compacted - keeping last 10 messages.');
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" // fires when a turn ends
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 } for
11
- // pre/post, { hook, reason } for stop) plus LOOM_HOOK / LOOM_TOOL /
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 hook's tool call. A 10s timeout counts as a failure-to-run
15
- // (allowed through) rather than a deny — hooks must not wedge the loop.
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 preToolUse.
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') denyReason = String(j.reason || 'blocked by hook');
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
- if (name === 'preToolUse' && (denyReason || code !== 0)) {
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
  }
@@ -1,6 +1,9 @@
1
- // Memory — layered LOOM.md files injected into every session's system prompt.
2
- // Layers: global (~/.loom/LOOM.md) then project (<cwd>/LOOM.md). Any file may
3
- // pull in others with `@relative/path.md` lines (depth-capped, cycle-safe).
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 p = loadFile(path.join(process.cwd(), 'LOOM.md'), 0, new Set());
53
- if (!p) p = loadFile(path.join(process.cwd(), '.loom', 'LOOM.md'), 0, new Set());
54
- if (p) parts.push('## Project memory\n\n' + p.slice(0, MAX_CHARS_PER_LAYER));
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
 
@@ -34,7 +34,7 @@ const DANGER_PATTERNS = [
34
34
  // user config).
35
35
  const PERMISSION_KEYS = [
36
36
  'read', 'edit', 'glob', 'grep', 'bash', 'task', 'skill', 'lsp', 'question',
37
- 'webfetch', 'websearch', 'browser',
37
+ 'webfetch', 'websearch', 'browser', 'mcp',
38
38
  'external_directory', 'doom_loop',
39
39
  ];
40
40
 
@@ -45,6 +45,11 @@ const DEFAULT_PERMISSIONS = {
45
45
  task: 'ask',
46
46
  skill: 'ask',
47
47
  question: 'ask',
48
+ // MCP tools can do anything their server exposes (send emails, modify
49
+ // cloud infra, run queries) — prompt by default. Patterns match the full
50
+ // tool name (mcp__<server>__<tool>), so the user can allow one server or
51
+ // tool precisely: {"permission": {"mcp": {"mcp__github__*": "allow"}}}.
52
+ mcp: 'ask',
48
53
  doom_loop: 'ask',
49
54
  external_directory: 'ask',
50
55
  read: {
@@ -73,6 +78,7 @@ const TOOL_TO_PERMISSION = {
73
78
  glob: 'glob',
74
79
  grep: 'grep',
75
80
  bash: 'bash',
81
+ sandbox: 'bash', // same command gate as bash (danger heuristics apply)
76
82
  task: 'task',
77
83
  skill: 'skill',
78
84
  lsp: 'lsp',
@@ -82,7 +88,9 @@ const TOOL_TO_PERMISSION = {
82
88
  websearch: 'websearch',
83
89
  browser: 'browser',
84
90
  todowrite: null, // never prompts
85
- mcp: null, // wired by the session (only the "add" action prompts)
91
+ todoread: 'read', // read-only todo inspection
92
+ tasks: null, // list/output/kill of the session's own background tasks
93
+ mcp: null, // wired by the session (mcp__* tools + the "add" action prompt)
86
94
  };
87
95
 
88
96
  function expandHome(p, home) {
@@ -213,7 +221,11 @@ class PermissionManager {
213
221
  * 'allow' | 'ask' | 'deny'.
214
222
  */
215
223
  resolve(toolName, arg) {
216
- const key = TOOL_TO_PERMISSION[toolName] !== undefined ? TOOL_TO_PERMISSION[toolName] : toolName;
224
+ // mcp__<server>__<tool> names resolve through the 'mcp' permission key
225
+ // (patterns match the full tool name).
226
+ const key = (toolName && toolName.indexOf('mcp__') === 0)
227
+ ? 'mcp'
228
+ : (TOOL_TO_PERMISSION[toolName] !== undefined ? TOOL_TO_PERMISSION[toolName] : toolName);
217
229
  if (!key) return 'allow';
218
230
 
219
231
  // Safety heuristics always run for dangerous shell commands — even when
@@ -298,7 +310,8 @@ class PermissionManager {
298
310
  const cwd = cwdOverride || this.cwd || process.cwd();
299
311
  const inp = input || {};
300
312
  switch (toolName) {
301
- case 'bash': return String(inp.command ?? inp.args ?? '');
313
+ case 'bash':
314
+ case 'sandbox': return String(inp.command ?? inp.args ?? '');
302
315
  case 'read': return normPathArg(inp.filePath || inp.path || '', cwd);
303
316
  case 'edit':
304
317
  case 'write': return normPathArg(inp.filePath || inp.path || '', cwd);