loom-agent 1.2.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.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,327 @@
1
+ const path = require('path');
2
+ const os = require('os');
3
+
4
+ const ADMIN_PATTERNS = [
5
+ { re: /\bsudo\b/, label: 'sudo detected' },
6
+ { re: /\brunas\b/, label: 'runas/administrator detected' },
7
+ { re: /\bdoas\b/, label: 'doas detected' },
8
+ { re: /\bchmod\s+[0-7]/, label: 'chmod with octal mode' },
9
+ { re: /\bchown\s+/, label: 'chown detected' },
10
+ { re: /\bshutdown\b/, label: 'shutdown command' },
11
+ { re: /\breboot\b/, label: 'reboot command' },
12
+ { re: /\bnpm\s+(install|i)\s+-g\b/, label: 'npm global install' },
13
+ { re: /\bkill\s+-9\b/, label: 'force kill (-9)' },
14
+ { re: /\bmkfs\b/, label: 'make filesystem (mkfs)' },
15
+ { re: /\bdd\s+if=/, label: 'disk destroy (dd)' },
16
+ ];
17
+
18
+ const DANGER_PATTERNS = [
19
+ { re: /\brm\s+-rf\b/, label: 'recursive force delete (rm -rf)' },
20
+ { re: /\bgit\s+push\s+--force\b/, label: 'force push to git remote' },
21
+ { re: /\bgit\s+reset\s+--hard\b/, label: 'hard git reset (destructive)' },
22
+ { re: /\bdocker\s+(rmi|system prune)\b/, label: 'destructive docker command' },
23
+ { re: /DROP\s+(TABLE|DATABASE)/i, label: 'SQL table/database drop' },
24
+ { re: /\bscp\b.*\blocalhost\b|\bscp\b.*\/etc\//, label: 'potentially dangerous scp' },
25
+ ];
26
+
27
+ // OpenCode-style permission keys and their defaults. Most tools default to
28
+ // allow; edit/task/skill/doom_loop/external_directory default to ask, and
29
+ // bash allows normal commands but STILL asks for dangerous ones (the
30
+ // ADMIN/DANGER heuristics below are enforced regardless of the tree, so
31
+ // "git push --force", "rm -rf", "sudo …", etc. always prompt unless the user
32
+ // saved an exact rule). read allows everything except .env files (denied by
33
+ // default — the built-in .*env rules below are part of the default tree, not
34
+ // user config).
35
+ const PERMISSION_KEYS = [
36
+ 'read', 'edit', 'glob', 'grep', 'bash', 'task', 'skill', 'lsp', 'question',
37
+ 'webfetch', 'websearch',
38
+ 'external_directory', 'doom_loop',
39
+ ];
40
+
41
+ const DEFAULT_PERMISSIONS = {
42
+ '*': 'allow',
43
+ bash: 'allow',
44
+ edit: 'ask',
45
+ task: 'ask',
46
+ skill: 'ask',
47
+ question: 'ask',
48
+ doom_loop: 'ask',
49
+ external_directory: 'ask',
50
+ read: {
51
+ '*': 'allow',
52
+ '*.env': 'deny',
53
+ '*.env.*': 'deny',
54
+ '*.env.example': 'allow',
55
+ },
56
+ };
57
+
58
+ // Tools that write files map onto the "edit" permission (OpenCode semantics:
59
+ // edit covers edit, write and patch).
60
+ const TOOL_TO_PERMISSION = {
61
+ read: 'read',
62
+ edit: 'edit',
63
+ write: 'edit',
64
+ glob: 'glob',
65
+ grep: 'grep',
66
+ bash: 'bash',
67
+ task: 'task',
68
+ skill: 'skill',
69
+ lsp: 'lsp',
70
+ question: 'question',
71
+ ask: 'question',
72
+ webfetch: 'webfetch',
73
+ websearch: 'websearch',
74
+ todowrite: null, // never prompts
75
+ mcp: null, // wired by the session (only the "add" action prompts)
76
+ };
77
+
78
+ function expandHome(p, home) {
79
+ const h = home || os.homedir();
80
+ if (p === '~') return h;
81
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(h, p.slice(2));
82
+ if (p.startsWith('$HOME/') || p.startsWith('$HOME\\')) return path.join(h, p.slice(6));
83
+ return p;
84
+ }
85
+
86
+ // '*' matches any run of chars (including /), '?' matches exactly one; all
87
+ // other characters are literal. Case-insensitive on Windows paths.
88
+ function wildcardToRegExp(pattern) {
89
+ const esc = (s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
90
+ const parts = [];
91
+ for (const ch of String(pattern)) {
92
+ if (ch === '*') parts.push('.*');
93
+ else if (ch === '?') parts.push('.');
94
+ else parts.push(esc(ch));
95
+ }
96
+ return new RegExp('^' + parts.join('') + '$', process.platform === 'win32' ? 'i' : '');
97
+ }
98
+
99
+ // Match `arg` against a permission node (string | object of pattern→action).
100
+ // Returns the action or null when nothing matched.
101
+ function matchNode(node, arg) {
102
+ if (node == null) return null;
103
+ if (typeof node === 'string') return normalizeAction(node);
104
+ if (typeof node !== 'object') return null;
105
+ let winner = null; // last match wins
106
+ const a = String(arg ?? '');
107
+ for (const [pat, action] of Object.entries(node)) {
108
+ // Normalize the expanded pattern the same way args are normalized
109
+ // (forward slashes) so ~/... patterns match on Windows too.
110
+ const expanded = expandHome(pat).replace(/\\/g, '/');
111
+ if (wildcardToRegExp(expanded).test(a)) winner = normalizeAction(action);
112
+ }
113
+ return winner;
114
+ }
115
+
116
+ function normalizeAction(a) {
117
+ if (a === 'never') return 'deny'; // legacy popup value
118
+ return a === 'allow' || a === 'ask' || a === 'deny' ? a : null;
119
+ }
120
+
121
+ // Normalize any path-like argument for matching: absolute, forward slashes.
122
+ function normPathArg(arg, cwd) {
123
+ if (!arg) return '';
124
+ let p = expandHome(String(arg));
125
+ if (!path.isAbsolute(p)) p = path.resolve(cwd || process.cwd(), p);
126
+ return p.replace(/\\/g, '/');
127
+ }
128
+
129
+ class PermissionManager {
130
+ constructor() {
131
+ this.sessionRules = new Map(); // legacy exact-command rules (popup picks)
132
+ this.pendingRequest = null;
133
+ this.onRuleChange = null; // session wires this to persist // popup choices into ~/.loom/config.json (config.permissionRules)
134
+ this.config = null; // loadConfig snapshot (config.permission, config.agent)
135
+ this.agentId = null; // current agent for agent-scoped rules
136
+ this.auto = false; // auto-approve mode (off by default)
137
+ this.cwd = process.cwd();
138
+ }
139
+
140
+ loadRules(rules) {
141
+ if (!rules || typeof rules !== 'object') return;
142
+ for (const [key, value] of Object.entries(rules)) {
143
+ const v = normalizeAction(value);
144
+ if (v) this.sessionRules.set(key, v);
145
+ }
146
+ }
147
+
148
+ loadConfig(cfg) {
149
+ this.config = cfg || null;
150
+ }
151
+
152
+ setAgent(agentId) { this.agentId = agentId || null; }
153
+ setAuto(on) { this.auto = !!on; }
154
+
155
+ _match(cmd, patterns) {
156
+ const lower = (cmd || '').toLowerCase();
157
+ for (const p of patterns) if (p.re.test(lower)) return p.label;
158
+ return null;
159
+ }
160
+
161
+ isAdmin(cmd) { return !!this._match(cmd, ADMIN_PATTERNS); }
162
+ isDangerous(cmd) { return !!this._match(cmd, DANGER_PATTERNS) || this.isAdmin(cmd); }
163
+ getDangerLabel(cmd) {
164
+ return this._match(cmd, ADMIN_PATTERNS) || this._match(cmd, DANGER_PATTERNS) || 'dangerous command';
165
+ }
166
+
167
+ getRule(key) { return this.sessionRules.get(key); }
168
+
169
+ setRule(key, value, persist) {
170
+ this.sessionRules.set(key, value);
171
+ if (persist && this.onRuleChange) this.onRuleChange(key, value);
172
+ }
173
+
174
+ clearRule(key) {
175
+ this.sessionRules.delete(key);
176
+ if (this.onRuleChange) this.onRuleChange(key, null);
177
+ }
178
+
179
+ checkRule(key) {
180
+ if (this.sessionRules.has('*')) return this.sessionRules.get('*');
181
+ if (this.sessionRules.has(key)) return this.sessionRules.get(key);
182
+ return null;
183
+ }
184
+
185
+ // Legacy pre-OpenCode check: an exact command string. Kept for callers that
186
+ // still think in commands (the bash session gate) — see resolve() for the
187
+ // general tool layer.
188
+ async check(cmd) {
189
+ const rule = this.checkRule(cmd);
190
+ if (rule) return rule === 'deny' ? 'deny' : rule;
191
+ if (this.isDangerous(cmd)) return 'ask';
192
+ return 'allow';
193
+ }
194
+
195
+ reset() { this.sessionRules.clear(); }
196
+
197
+ // ── OpenCode-style resolution ──────────────────────────────────────────
198
+
199
+ /**
200
+ * Resolve the action for (toolName, arg) given the current agent, config and
201
+ * session rules. arg is the command for bash, the file path for read/edit,
202
+ * the agent id for task, the skill name for skill, etc. Returns
203
+ * 'allow' | 'ask' | 'deny'.
204
+ */
205
+ resolve(toolName, arg) {
206
+ const key = TOOL_TO_PERMISSION[toolName] !== undefined ? TOOL_TO_PERMISSION[toolName] : toolName;
207
+ if (!key) return 'allow';
208
+
209
+ // Safety heuristics always run for dangerous shell commands — even when
210
+ // a rule would allow the rest (defense in depth; unavoidable on
211
+ // "git push --force" unless the user explicitly disabled them).
212
+ if (key === 'bash' && this.isDangerous(String(arg || ''))) {
213
+ // An exact session rule overrides heuristics ("Always allow" picked in
214
+ // the popup for this very command).
215
+ const exact = this.sessionRules.get(String(arg || ''));
216
+ if (exact === 'allow') return 'allow';
217
+ if (exact === 'deny') return 'deny';
218
+ return 'ask';
219
+ }
220
+
221
+ // 1. Legacy exact session rule (popup "Always allow"/"Never") — the
222
+ // strongest signal; the user picked it for this exact command/path.
223
+ const exact = this.sessionRules.get(String(arg ?? ''));
224
+ if (exact) return exact;
225
+ if (key === 'bash' && this.sessionRules.has('*')) return this.sessionRules.get('*');
226
+
227
+ const cfgPerm = this.config && this.config.permission;
228
+ // 2. Agent-scoped rules take precedence over the global permission tree.
229
+ const agentTree = this.agentId && this.config && this.config.agent && this.config.agent[this.agentId]
230
+ ? this.config.agent[this.agentId].permission
231
+ : null;
232
+ if (agentTree) {
233
+ const v = matchNode(agentTree[key] ?? agentTree['*'], arg);
234
+ if (v) return v;
235
+ }
236
+
237
+ // 3. Global permission tree from config.permission.
238
+ if (cfgPerm != null) {
239
+ const v = matchNode(typeof cfgPerm === 'string' ? cfgPerm : (cfgPerm[key] ?? cfgPerm['*']), arg);
240
+ if (v) return v;
241
+ }
242
+
243
+ // 4. Defaults.
244
+ const def = DEFAULT_PERMISSIONS[key];
245
+ const d = matchNode(def ?? DEFAULT_PERMISSIONS['*'], arg) ?? matchNode(DEFAULT_PERMISSIONS['*'], arg);
246
+ return d || 'allow';
247
+ }
248
+
249
+ /**
250
+ * External-directory check: resolvedPath is an absolute normalized path; if
251
+ * it lies outside the session cwd, consult permission.external_directory
252
+ * patterns (matched against the normalized absolute path). Returns
253
+ * 'allow' when inside or a pattern allowed it, otherwise the rule's action
254
+ * (default 'ask').
255
+ */
256
+ checkExternal(resolvedPath) {
257
+ const cwd = (this.cwd || process.cwd()).replace(/\\/g, '/');
258
+ const p = String(resolvedPath || '').replace(/\\/g, '/');
259
+ if (!p || p === cwd || p.startsWith(cwd.replace(/\/+$/, '') + '/')) return 'allow';
260
+ const rule = this.resolveKey('external_directory', p);
261
+ return rule;
262
+ }
263
+
264
+ /**
265
+ * Resolved rule for an arbitrary permission key (used by external_directory
266
+ * and doom_loop which aren't tool names).
267
+ */
268
+ resolveKey(permKey, arg) {
269
+ const cfgPerm = this.config && this.config.permission;
270
+ const agentTree = this.agentId && this.config && this.config.agent && this.config.agent[this.agentId]
271
+ ? this.config.agent[this.agentId].permission
272
+ : null;
273
+ if (agentTree) {
274
+ const v = matchNode(agentTree[permKey] ?? agentTree['*'], arg);
275
+ if (v) return v;
276
+ }
277
+ if (cfgPerm != null) {
278
+ const v = matchNode(typeof cfgPerm === 'string' ? cfgPerm : (cfgPerm[permKey] ?? cfgPerm['*']), arg);
279
+ if (v) return v;
280
+ }
281
+ const def = DEFAULT_PERMISSIONS[permKey];
282
+ const d = matchNode(def ?? DEFAULT_PERMISSIONS['*'], arg) ?? matchNode(DEFAULT_PERMISSIONS['*'], arg);
283
+ return d || 'allow';
284
+ }
285
+
286
+ /** The argument a permission key is matched against for a given tool call. */
287
+ permissionArg(toolName, input, cwdOverride) {
288
+ const cwd = cwdOverride || this.cwd || process.cwd();
289
+ const inp = input || {};
290
+ switch (toolName) {
291
+ case 'bash': return String(inp.command ?? inp.args ?? '');
292
+ case 'read': return normPathArg(inp.filePath || inp.path || '', cwd);
293
+ case 'edit':
294
+ case 'write': return normPathArg(inp.filePath || inp.path || '', cwd);
295
+ case 'glob': return String(inp.pattern ?? '');
296
+ case 'grep': return String(inp.pattern ?? '');
297
+ case 'task': return String(inp.agent ?? inp.subagent ?? inp.agentId ?? '');
298
+ case 'skill': return String(inp.name ?? inp.skill ?? '');
299
+ case 'lsp': return String(inp.query ?? '');
300
+ case 'question':
301
+ case 'ask': return String(inp.question ?? inp.text ?? '');
302
+ case 'webfetch': return String(inp.url ?? '');
303
+ case 'websearch': return String(inp.query ?? '');
304
+ default: return String(inp.command ?? inp.filePath ?? inp.path ?? inp.pattern ?? inp.url ?? '');
305
+ }
306
+ }
307
+ }
308
+
309
+ // Defense-in-depth label used by the tool layer: same heuristics as the
310
+ // permission manager, so tools can refuse destructive commands even when the
311
+ // session gate is bypassed (direct calls). Returns the label or null.
312
+ function commandRiskLabel(cmd) {
313
+ const lower = (cmd || '').toLowerCase();
314
+ for (const p of ADMIN_PATTERNS.concat(DANGER_PATTERNS)) {
315
+ if (p.re.test(lower)) return p.label;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ module.exports = {
321
+ }
322
+
323
+ module.exports = {
324
+ PermissionManager, ADMIN_PATTERNS, DANGER_PATTERNS, commandRiskLabel,
325
+ PERMISSION_KEYS, DEFAULT_PERMISSIONS, TOOL_TO_PERMISSION,
326
+ expandHome, wildcardToRegExp, matchNode, normPathArg,
327
+ };
@@ -0,0 +1,33 @@
1
+ const os = require('os');
2
+
3
+ let cache = null;
4
+
5
+ function detect() {
6
+ if (cache) return cache;
7
+ const p = os.platform();
8
+ const r = os.release();
9
+ const a = os.arch();
10
+ let wsl = false;
11
+ if (p === 'linux') {
12
+ try {
13
+ const low = r.toLowerCase();
14
+ if (low.includes('microsoft') || low.includes('wsl')) wsl = true;
15
+ } catch (e) {}
16
+ }
17
+ cache = { platform: p, release: r, arch: a, isWSL: wsl };
18
+ return cache;
19
+ }
20
+
21
+ function verifyCommand(cmd) {
22
+ const info = detect();
23
+ if (info.platform === 'win32') return 'cmd /c "' + cmd + '"';
24
+ return cmd;
25
+ }
26
+
27
+ function shellEnv() {
28
+ const info = detect();
29
+ if (info.platform === 'win32') return 'powershell';
30
+ return '/bin/bash';
31
+ }
32
+
33
+ module.exports = { detect, verifyCommand, shellEnv };