loom-agent 1.2.36 → 1.3.2

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 (39) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +38 -56
  3. package/bin/loom-bun.js +2 -10
  4. package/bin/loom.js +0 -10
  5. package/package.json +126 -126
  6. package/src/connectors/convex.js +356 -0
  7. package/src/connectors/deploy.js +390 -0
  8. package/src/connectors/index.js +23 -0
  9. package/src/connectors/supabase.js +279 -0
  10. package/src/core/agents.js +32 -2
  11. package/src/core/cli.js +30 -5
  12. package/src/core/permissions.js +12 -1
  13. package/src/core/plugin-cmd.js +1 -0
  14. package/src/core/sandbox.js +400 -0
  15. package/src/core/session-store.js +245 -167
  16. package/src/core/session.js +157 -11
  17. package/src/providers/anthropic.js +10 -5
  18. package/src/providers/openai-compat.js +12 -4
  19. package/src/tools/browser.js +204 -0
  20. package/src/tools/index.js +35 -1
  21. package/src/tui/App.tsx +103 -8
  22. package/src/tui/components/BreadcrumbBar.tsx +10 -9
  23. package/src/tui/components/ChatArea.tsx +3 -3
  24. package/src/tui/components/InputBar.tsx +22 -11
  25. package/src/tui/components/MdText.tsx +2 -1
  26. package/src/tui/components/Modals.tsx +191 -17
  27. package/src/tui/components/PermissionPopup.tsx +15 -12
  28. package/src/tui/components/Sidebar.tsx +6 -2
  29. package/src/tui/components/SplashScreen.tsx +17 -24
  30. package/src/tui/components/SubagentPanel.tsx +1 -1
  31. package/src/tui/components/ToastOverlay.tsx +23 -12
  32. package/src/tui/store.ts +38 -35
  33. package/src/tui/theme.ts +45 -30
  34. package/src/tui/tool-display.ts +13 -2
  35. package/docs/web.md +0 -205
  36. package/src/web/attach.js +0 -242
  37. package/src/web/graph-view.html +0 -262
  38. package/src/web/index.html +0 -824
  39. package/src/web/web-server.js +0 -535
@@ -1,167 +1,245 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const os = require('os');
4
-
5
- const SESSION_SCHEMA_VERSION = 2;
6
-
7
- const LOOM_DIR = path.join(os.homedir(), '.loom');
8
- const SESSIONS_DIR = path.join(LOOM_DIR, 'sessions');
9
-
10
- // Re-evaluated on every call so tests can isolate with LOOM_CONFIG_DIR.
11
- function sessionsDir() {
12
- return path.join(process.env.LOOM_CONFIG_DIR || LOOM_DIR, 'sessions');
13
- }
14
-
15
- function ensureDir() {
16
- const d = sessionsDir();
17
- if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
18
- }
19
-
20
- function convId() {
21
- return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
22
- }
23
-
24
- // Coerce whatever came off disk (hand-edited, corrupt, older schema) into the
25
- // guaranteed current shape: { id, createdAt, schemaVersion, messages } with
26
- // every message having { role, content } while preserving any extra fields.
27
- function normalizeSession(data) {
28
- if (!data || typeof data !== 'object') return null;
29
- const raw = Array.isArray(data.messages) ? data.messages : [];
30
- const messages = raw.map((m) => {
31
- if (!m || typeof m !== 'object') return { role: 'user', content: '' };
32
- const { role, content, ...rest } = m;
33
- return { role: role || 'user', content: typeof content === 'undefined' ? '' : content, ...rest };
34
- });
35
- return {
36
- id: typeof data.id === 'string' ? data.id : null,
37
- createdAt: typeof data.createdAt === 'string' ? data.createdAt : null,
38
- schemaVersion: typeof data.schemaVersion === 'number' ? data.schemaVersion : 1,
39
- messages,
40
- };
41
- }
42
-
43
- // Session IDs become file names — keep them strictly alphanumeric to rule out
44
- // path traversal via loadSession/deleteSession (e.g. "../../secrets").
45
- const ID_RX = /^[a-zA-Z0-9_-]{3,128}$/;
46
- function isValidSessionId(id) {
47
- return typeof id === 'string' && ID_RX.test(id);
48
- }
49
-
50
- function saveSession(session) {
51
- ensureDir();
52
- const id = isValidSessionId(session.conversationId) ? session.conversationId : convId();
53
- const now = new Date().toISOString();
54
- // Overwriting an existing session keeps its original createdAt (it is the
55
- // same conversation); updatedAt always reflects this write.
56
- let createdAt = now;
57
- const existingFile = path.join(sessionsDir(), id + '.json');
58
- if (fs.existsSync(existingFile)) {
59
- try {
60
- const prev = JSON.parse(fs.readFileSync(existingFile, 'utf8'));
61
- if (prev && typeof prev.createdAt === 'string') createdAt = prev.createdAt;
62
- } catch {}
63
- }
64
- const data = {
65
- id,
66
- schemaVersion: SESSION_SCHEMA_VERSION,
67
- createdAt,
68
- updatedAt: now,
69
- messages: session.messages || [],
70
- provider: session.config ? session.config.provider : null,
71
- model: session.config && session.config.model ? session.config.model[session.config.provider] : null,
72
- };
73
- const f = path.join(sessionsDir(), id + '.json');
74
- fs.writeFileSync(f, JSON.stringify(data, null, 2));
75
- return { id, file: f };
76
- }
77
-
78
- function listSessions() {
79
- ensureDir();
80
- const files = fs.readdirSync(sessionsDir()).filter((f) => f.endsWith('.json'));
81
- const list = files.map((f) => {
82
- const p = path.join(sessionsDir(), f);
83
- try {
84
- const data = JSON.parse(fs.readFileSync(p, 'utf8'));
85
- let mtime = null;
86
- try { mtime = fs.statSync(p).mtime.toISOString(); } catch {}
87
- return {
88
- id: data.id || f.replace('.json', ''),
89
- createdAt: data.createdAt,
90
- updatedAt: data.updatedAt,
91
- mtime,
92
- messageCount: (data.messages || []).length,
93
- provider: data.provider,
94
- model: data.model,
95
- };
96
- } catch {
97
- let mtime = null;
98
- try { mtime = fs.statSync(p).mtime.toISOString(); } catch {}
99
- return { id: f.replace('.json', ''), createdAt: null, updatedAt: null, mtime, messageCount: 0 };
100
- }
101
- });
102
- // Newest first by the last write time (updatedAt, else createdAt, else the
103
- // file's own mtime). Sorting by file NAME put legacy "share-*" exports on
104
- // top forever ("s" > "m"), burying the real, recent conversations.
105
- const stamp = (s) => s.updatedAt || s.createdAt || s.mtime || '';
106
- return list.filter((s) => isValidSessionId(s.id)).sort((a, b) => (stamp(b) < stamp(a) ? -1 : stamp(b) > stamp(a) ? 1 : 0));
107
- }
108
-
109
- function loadSession(id) {
110
- if (!isValidSessionId(id)) return null;
111
- const f = path.join(sessionsDir(), id + '.json');
112
- if (!fs.existsSync(f)) return null;
113
- try {
114
- const raw = JSON.parse(fs.readFileSync(f, 'utf8'));
115
- const norm = normalizeSession(raw);
116
- if (!norm) return null;
117
- norm.id = norm.id || id;
118
- norm.file = f;
119
- return norm;
120
- } catch {
121
- return null;
122
- }
123
- }
124
-
125
- function deleteSession(id) {
126
- if (!isValidSessionId(id)) return false;
127
- const f = path.join(sessionsDir(), id + '.json');
128
- if (fs.existsSync(f)) {
129
- fs.unlinkSync(f);
130
- return true;
131
- }
132
- return false;
133
- }
134
-
135
- function exportChat(session, format) {
136
- const type = format || 'md';
137
- const msgs = session.messages || [];
138
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
139
- const file = path.join(process.cwd(), 'loom-chat-' + ts + '.' + type);
140
- if (type === 'md') {
141
- const lines = ['# Loom Code Chat Export', '', '**Date:** ' + new Date().toLocaleString(), ''];
142
- for (const m of msgs) {
143
- const role = m.role || 'unknown';
144
- lines.push('### ' + role.charAt(0).toUpperCase() + role.slice(1));
145
- lines.push('');
146
- lines.push(m.content || '(empty)');
147
- lines.push('');
148
- }
149
- fs.writeFileSync(file, lines.join('\n'));
150
- } else {
151
- const exportLines = msgs.map((m) => m.role + ': ' + (m.content || ''));
152
- fs.writeFileSync(file, exportLines.join('\n'));
153
- }
154
- return file;
155
- }
156
-
157
- module.exports = {
158
- saveSession,
159
- listSessions,
160
- loadSession,
161
- deleteSession,
162
- exportChat,
163
- normalizeSession,
164
- isValidSessionId,
165
- SESSIONS_DIR,
166
- SESSION_SCHEMA_VERSION,
167
- };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const SESSION_SCHEMA_VERSION = 3;
6
+
7
+ const LOOM_DIR = path.join(os.homedir(), '.loom');
8
+
9
+ // Re-evaluated on every call so tests can isolate with LOOM_CONFIG_DIR.
10
+ // Layout is claude-code/opencode style: one folder per session inside the
11
+ // PROJECT's .loom/ directory:
12
+ //
13
+ // <project>/.loom/sessions/<id>/session.json metadata
14
+ // <project>/.loom/sessions/<id>/messages.jsonl one message per line
15
+ //
16
+ // Flat legacy files (~/.loom/sessions/<id>.json, schema v1/v2) stay
17
+ // readable; the next saveSession migrates them into the project folder.
18
+ function baseDir() {
19
+ if (process.env.LOOM_CONFIG_DIR) return path.join(process.env.LOOM_CONFIG_DIR, 'sessions');
20
+ return path.join(process.cwd(), '.loom', 'sessions');
21
+ }
22
+
23
+ // The pre-v3 global store. Same as baseDir() under LOOM_CONFIG_DIR (tests),
24
+ // the user's old ~/.loom/sessions otherwise.
25
+ function legacyDir() {
26
+ if (process.env.LOOM_CONFIG_DIR) return path.join(process.env.LOOM_CONFIG_DIR, 'sessions');
27
+ return path.join(LOOM_DIR, 'sessions');
28
+ }
29
+
30
+ function ensureDir() {
31
+ const d = baseDir();
32
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
33
+ }
34
+
35
+ function convId() {
36
+ return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
37
+ }
38
+
39
+ // Session IDs become folder names — keep them strictly alphanumeric to rule
40
+ // out path traversal via loadSession/deleteSession (e.g. "../../secrets").
41
+ const ID_RX = /^[a-zA-Z0-9_-]{3,128}$/;
42
+ function isValidSessionId(id) {
43
+ return typeof id === 'string' && ID_RX.test(id);
44
+ }
45
+
46
+ // Coerce whatever came off disk (hand-edited, corrupt, older schema) into the
47
+ // guaranteed current shape: { id, createdAt, schemaVersion, messages } with
48
+ // every message having { role, content } while preserving any extra fields.
49
+ // Snake-case tool fields from old saves are aliased to camelCase so a resumed
50
+ // session can always rebuild a valid provider payload (toolCallId is sent as
51
+ // tool_call_id / tool_use_id — losing it 400s on strict providers).
52
+ function normalizeSession(data) {
53
+ if (!data || typeof data !== 'object') return null;
54
+ const raw = Array.isArray(data.messages) ? data.messages : [];
55
+ const messages = raw.map((m) => {
56
+ if (!m || typeof m !== 'object') return { role: 'user', content: '' };
57
+ const { role, content, ...rest } = m;
58
+ const out = { role: role || 'user', content: typeof content === 'undefined' ? '' : content, ...rest };
59
+ if (!out.toolCalls && Array.isArray(rest.tool_calls)) out.toolCalls = rest.tool_calls;
60
+ if (!out.toolCallId && rest.tool_call_id) out.toolCallId = rest.tool_call_id;
61
+ return out;
62
+ });
63
+ return {
64
+ id: typeof data.id === 'string' ? data.id : null,
65
+ createdAt: typeof data.createdAt === 'string' ? data.createdAt : null,
66
+ schemaVersion: typeof data.schemaVersion === 'number' ? data.schemaVersion : 1,
67
+ messages,
68
+ };
69
+ }
70
+
71
+ function sessionFolder(id) {
72
+ return path.join(baseDir(), id);
73
+ }
74
+
75
+ function readFolderSession(id) {
76
+ const dir = sessionFolder(id);
77
+ const metaFile = path.join(dir, 'session.json');
78
+ if (!fs.existsSync(metaFile)) return null;
79
+ try {
80
+ const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
81
+ const messages = [];
82
+ const msgFile = path.join(dir, 'messages.jsonl');
83
+ if (fs.existsSync(msgFile)) {
84
+ const lines = fs.readFileSync(msgFile, 'utf8').split('\n');
85
+ for (const line of lines) {
86
+ const t = line.trim();
87
+ if (!t) continue;
88
+ try { messages.push(JSON.parse(t)); } catch {}
89
+ }
90
+ }
91
+ const norm = normalizeSession({ ...meta, messages });
92
+ if (!norm) return null;
93
+ norm.id = norm.id || id;
94
+ norm.file = metaFile;
95
+ norm.dir = dir;
96
+ return norm;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function readFlatSession(dir, id) {
103
+ const f = path.join(dir, id + '.json');
104
+ if (!fs.existsSync(f)) return null;
105
+ try {
106
+ const raw = JSON.parse(fs.readFileSync(f, 'utf8'));
107
+ const norm = normalizeSession(raw);
108
+ if (!norm) return null;
109
+ norm.id = norm.id || id;
110
+ norm.file = f;
111
+ return norm;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function saveSession(session) {
118
+ ensureDir();
119
+ const id = isValidSessionId(session.conversationId) ? session.conversationId : convId();
120
+ const now = new Date().toISOString();
121
+ // Overwriting an existing session keeps its original createdAt (it is the
122
+ // same conversation); updatedAt always reflects this write.
123
+ let createdAt = now;
124
+ const prev = readFolderSession(id) || readFlatSession(baseDir(), id) || readFlatSession(legacyDir(), id);
125
+ if (prev && typeof prev.createdAt === 'string') createdAt = prev.createdAt;
126
+ const messages = session.messages || [];
127
+ const data = {
128
+ id,
129
+ schemaVersion: SESSION_SCHEMA_VERSION,
130
+ createdAt,
131
+ updatedAt: now,
132
+ provider: session.config ? session.config.provider : null,
133
+ model: session.config && session.config.model ? session.config.model[session.config.provider] : null,
134
+ messageCount: messages.length,
135
+ };
136
+ const dir = sessionFolder(id);
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ const metaFile = path.join(dir, 'session.json');
139
+ // Write messages first, metadata last: a crash mid-save leaves the old
140
+ // session.json pointing at a complete messages file, never the reverse.
141
+ const msgFile = path.join(dir, 'messages.jsonl');
142
+ const tmp = msgFile + '.tmp';
143
+ fs.writeFileSync(tmp, messages.map((m) => JSON.stringify(m)).join('\n') + (messages.length ? '\n' : ''));
144
+ fs.renameSync(tmp, msgFile);
145
+ fs.writeFileSync(metaFile, JSON.stringify(data, null, 2));
146
+ return { id, file: metaFile, dir };
147
+ }
148
+
149
+ function listDir(dir, fromFolder) {
150
+ let names;
151
+ try { names = fs.readdirSync(dir); } catch { return []; }
152
+ const out = [];
153
+ for (const name of names) {
154
+ if (fromFolder) {
155
+ const entry = readFolderSession(name);
156
+ if (entry && isValidSessionId(entry.id)) out.push(entry);
157
+ } else if (name.endsWith('.json')) {
158
+ const id = name.replace(/\.json$/, '');
159
+ const entry = readFlatSession(dir, id);
160
+ if (entry && isValidSessionId(entry.id)) out.push(entry);
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
166
+ function listSessions() {
167
+ ensureDir();
168
+ // Newest first by the last write time (updatedAt, else createdAt). Folder
169
+ // sessions shadow same-id flat files (a migrated session must not appear
170
+ // twice). Legacy ~/.loom/sessions files stay listed so old history remains
171
+ // reachable after upgrading to the per-project folder layout.
172
+ const byId = new Map();
173
+ for (const s of listDir(legacyDir(), false)) byId.set(s.id, s);
174
+ for (const s of listDir(baseDir(), false)) byId.set(s.id, s);
175
+ for (const s of listDir(baseDir(), true)) byId.set(s.id, s);
176
+ const list = [...byId.values()].map((data) => {
177
+ let mtime = null;
178
+ try { mtime = fs.statSync(data.file).mtime.toISOString(); } catch {}
179
+ return {
180
+ id: data.id,
181
+ createdAt: data.createdAt,
182
+ updatedAt: data.updatedAt,
183
+ mtime,
184
+ messageCount: data.messageCount || (data.messages || []).length,
185
+ provider: data.provider,
186
+ model: data.model,
187
+ };
188
+ });
189
+ const stamp = (s) => s.updatedAt || s.createdAt || s.mtime || '';
190
+ return list.sort((a, b) => (stamp(b) < stamp(a) ? -1 : stamp(b) > stamp(a) ? 1 : 0));
191
+ }
192
+
193
+ function loadSession(id) {
194
+ if (!isValidSessionId(id)) return null;
195
+ return readFolderSession(id) || readFlatSession(baseDir(), id) || readFlatSession(legacyDir(), id);
196
+ }
197
+
198
+ function deleteSession(id) {
199
+ if (!isValidSessionId(id)) return false;
200
+ let removed = false;
201
+ const dir = sessionFolder(id);
202
+ if (fs.existsSync(dir)) {
203
+ try { fs.rmSync(dir, { recursive: true, force: true }); removed = true; } catch {}
204
+ }
205
+ for (const d of new Set([baseDir(), legacyDir()])) {
206
+ const f = path.join(d, id + '.json');
207
+ if (fs.existsSync(f)) {
208
+ try { fs.unlinkSync(f); removed = true; } catch {}
209
+ }
210
+ }
211
+ return removed;
212
+ }
213
+
214
+ function exportChat(session, format) {
215
+ const type = format || 'md';
216
+ const msgs = session.messages || [];
217
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
218
+ const file = path.join(process.cwd(), 'loom-chat-' + ts + '.' + type);
219
+ if (type === 'md') {
220
+ const lines = ['# Loom Code Chat Export', '', '**Date:** ' + new Date().toLocaleString(), ''];
221
+ for (const m of msgs) {
222
+ const role = m.role || 'unknown';
223
+ lines.push('### ' + role.charAt(0).toUpperCase() + role.slice(1));
224
+ lines.push('');
225
+ lines.push(m.content || '(empty)');
226
+ lines.push('');
227
+ }
228
+ fs.writeFileSync(file, lines.join('\n'));
229
+ } else {
230
+ const exportLines = msgs.map((m) => m.role + ': ' + (m.content || ''));
231
+ fs.writeFileSync(file, exportLines.join('\n'));
232
+ }
233
+ return file;
234
+ }
235
+
236
+ module.exports = {
237
+ saveSession,
238
+ listSessions,
239
+ loadSession,
240
+ deleteSession,
241
+ exportChat,
242
+ normalizeSession,
243
+ isValidSessionId,
244
+ SESSION_SCHEMA_VERSION,
245
+ };