atom-agent 1.3.0 → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +5 -5
  34. package/dist/ui/diff-view.js +16 -7
  35. package/dist/ui/diff.js +73 -51
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +6 -4
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +88 -27
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +9 -6
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/extensions.md +1 -1
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/examples/extensions/01-audit-gate.js +2 -2
  69. package/examples/extensions/02-notes-tool.js +2 -2
  70. package/examples/extensions/03-custom-command.js +2 -2
  71. package/package.json +3 -2
package/dist/todos.js ADDED
@@ -0,0 +1,241 @@
1
+ // Per-session todos (ticket 05): a task checklist scoped to each session
2
+ // that persists across compactions and restarts, so the agent's plan and
3
+ // progress are never lost when old chat text is summarized away.
4
+ //
5
+ // Storage: namespaced under the multi-session record's generic
6
+ // `metadata.todos` key (src/sessions.ts) via the existing
7
+ // updateSession/getSession APIs — no schema edits, no new files on disk.
8
+ // Todos live OUTSIDE compacted chat text, so compaction (summary+tail)
9
+ // can never summarize them away; the summary only carries a context
10
+ // backstop (see formatGoalForCompact), never the record of truth.
11
+ //
12
+ // Status vocabulary is exactly src/tools/todo.ts's
13
+ // ("pending" | "in_progress" | "completed") — matched, not reinvented.
14
+ // Runtime invariants (at most one in_progress, completed-stays-completed,
15
+ // all-completed clears) stay owned by the tools/todo.ts layer; this module
16
+ // validates SHAPE only, so a saved record always replays through
17
+ // todowriteTool cleanly.
18
+ //
19
+ // Totality (mirroring goal.ts): serialize/restore never throw. Old records
20
+ // without the todos key read as an empty list; a malformed todos value
21
+ // degrades to empty WITHOUT failing the session load (same posture as the
22
+ // goal field). The pure CRUD ops below throw Error on invalid input —
23
+ // those are programmer errors with explicit messages, never silent.
24
+ //
25
+ // Import budget: type-only import from ./tools/todo.js (erased at compile,
26
+ // zero runtime coupling) plus no value imports — this module never touches
27
+ // App, sessions, compact, config, or the tool registry.
28
+ // Namespace inside Session.metadata. Never read or write metadata.filediffs
29
+ // (ticket 06 owns it).
30
+ export const TODOS_METADATA_KEY = "todos";
31
+ const TODO_STATUSES = [
32
+ "pending",
33
+ "in_progress",
34
+ "completed",
35
+ ];
36
+ const TODO_PRIORITIES = ["high", "medium", "low"];
37
+ function isRecord(value) {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+ // Shape check mirroring todowriteTool's item validation (same vocabulary,
41
+ // same strictness): non-empty content, known status, known priority when
42
+ // present, string activeForm when present. Unknown extra keys are ignored.
43
+ // Returns a clean deep copy, or null when invalid.
44
+ export function validateTodoRecord(value) {
45
+ try {
46
+ if (!isRecord(value))
47
+ return null;
48
+ if (typeof value["content"] !== "string" || value["content"].length === 0) {
49
+ return null;
50
+ }
51
+ const status = value["status"];
52
+ if (status !== "pending" &&
53
+ status !== "in_progress" &&
54
+ status !== "completed") {
55
+ return null;
56
+ }
57
+ const clean = {
58
+ content: value["content"],
59
+ status,
60
+ };
61
+ if (value["priority"] !== undefined) {
62
+ const priority = value["priority"];
63
+ if (priority !== "high" && priority !== "medium" && priority !== "low") {
64
+ return null;
65
+ }
66
+ clean.priority = priority;
67
+ }
68
+ if (value["activeForm"] !== undefined) {
69
+ if (typeof value["activeForm"] !== "string")
70
+ return null;
71
+ if (value["activeForm"].length > 0)
72
+ clean.activeForm = value["activeForm"];
73
+ }
74
+ return clean;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function cleanTodoList(list) {
81
+ return list.map((t) => ({ ...t }));
82
+ }
83
+ function checkedIndex(length, index, verb) {
84
+ if (typeof index !== "number" ||
85
+ !Number.isFinite(index) ||
86
+ Math.floor(index) !== index) {
87
+ throw new Error(`${verb}: index must be an integer (got ${String(index)})`);
88
+ }
89
+ if (index < 1 || index > length) {
90
+ throw new Error(`${verb}: index ${index} out of range (list has ${length} item(s))`);
91
+ }
92
+ return index - 1;
93
+ }
94
+ // Append one item (1-based position = end). Throws Error on a malformed item.
95
+ export function createTodo(list, item) {
96
+ const clean = validateTodoRecord(item);
97
+ if (!clean) {
98
+ throw new Error(`createTodo: item must be {content: non-empty string, status: "pending" | "in_progress" | "completed", priority?: "high" | "medium" | "low", activeForm?: string}`);
99
+ }
100
+ return [...cleanTodoList(list), clean];
101
+ }
102
+ // Patch ONE item by 1-based index (status transitions flow through here —
103
+ // completeTodo below is the named common case). Unknown patch keys are
104
+ // ignored; an empty patch is a no-op copy. Throws Error on a bad index or
105
+ // an invalid patched value.
106
+ export function updateTodo(list, index, patch) {
107
+ const at = checkedIndex(list.length, index, "updateTodo");
108
+ const p = isRecord(patch) ? { ...patch } : {};
109
+ const next = { ...list[at] };
110
+ if (p["status"] !== undefined) {
111
+ const status = p["status"];
112
+ if (status !== "pending" &&
113
+ status !== "in_progress" &&
114
+ status !== "completed") {
115
+ throw new Error(`updateTodo: status must be one of "pending", "in_progress", "completed" (got ${JSON.stringify(status) ?? String(status)})`);
116
+ }
117
+ next.status = status;
118
+ }
119
+ if (p["content"] !== undefined) {
120
+ if (typeof p["content"] !== "string" || p["content"].length === 0) {
121
+ throw new Error(`updateTodo: content must be a non-empty string`);
122
+ }
123
+ next.content = p["content"];
124
+ }
125
+ if (p["priority"] !== undefined) {
126
+ const priority = p["priority"];
127
+ if (priority !== "high" && priority !== "medium" && priority !== "low") {
128
+ throw new Error(`updateTodo: priority must be one of "high", "medium", "low" (got ${JSON.stringify(priority) ?? String(priority)})`);
129
+ }
130
+ next.priority = priority;
131
+ }
132
+ if (p["activeForm"] !== undefined) {
133
+ if (typeof p["activeForm"] !== "string") {
134
+ throw new Error(`updateTodo: activeForm must be a string`);
135
+ }
136
+ if (p["activeForm"].length > 0) {
137
+ next.activeForm = p["activeForm"];
138
+ }
139
+ else {
140
+ delete next.activeForm;
141
+ }
142
+ }
143
+ const out = cleanTodoList(list);
144
+ out[at] = next;
145
+ return out;
146
+ }
147
+ // Mark one item completed by 1-based index. Throws Error on a bad index.
148
+ export function completeTodo(list, index) {
149
+ return updateTodo(list, index, { status: "completed" });
150
+ }
151
+ // Move the item at 1-based `from` to 1-based `to` (order = array order).
152
+ // Throws Error on a bad index.
153
+ export function reorderTodo(list, from, to) {
154
+ const fromAt = checkedIndex(list.length, from, "reorderTodo");
155
+ const toAt = checkedIndex(list.length, to, "reorderTodo");
156
+ if (fromAt === toAt)
157
+ return cleanTodoList(list);
158
+ const out = cleanTodoList(list);
159
+ const [moved] = out.splice(fromAt, 1);
160
+ out.splice(toAt, 0, moved);
161
+ return out;
162
+ }
163
+ // Serialize the live list for a session save: a deep copy (the save must
164
+ // never alias live state). Total: never throws; unserializable input reads
165
+ // as an empty list.
166
+ export function serializeTodosForPersist(list) {
167
+ try {
168
+ if (!Array.isArray(list))
169
+ return [];
170
+ const out = [];
171
+ for (const item of list) {
172
+ const clean = validateTodoRecord(item);
173
+ if (!clean)
174
+ return [];
175
+ const persisted = {
176
+ content: clean.content,
177
+ status: clean.status,
178
+ };
179
+ if (clean.priority !== undefined)
180
+ persisted.priority = clean.priority;
181
+ if (clean.activeForm !== undefined)
182
+ persisted.activeForm = clean.activeForm;
183
+ out.push(persisted);
184
+ }
185
+ return out;
186
+ }
187
+ catch {
188
+ return [];
189
+ }
190
+ }
191
+ // Restore a saved list: valid items come back verbatim (states intact, order
192
+ // intact); a missing key, a non-array, or ANY malformed item degrades to an
193
+ // empty list — never a throw, never a partial list (a half-restored plan is
194
+ // worse than a visibly empty one). Old records without the todos key land
195
+ // here safely.
196
+ export function restoreTodosFromPersist(value) {
197
+ try {
198
+ if (value === null || value === undefined)
199
+ return [];
200
+ if (!Array.isArray(value))
201
+ return [];
202
+ const out = [];
203
+ for (const item of value) {
204
+ const clean = validateTodoRecord(item);
205
+ if (!clean)
206
+ return [];
207
+ out.push(clean);
208
+ }
209
+ return out;
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ }
215
+ // Read the checklist out of a session record's metadata (the switch-restore
216
+ // path): absent or corrupt reads as []. Never throws.
217
+ export function readSessionTodos(metadata) {
218
+ try {
219
+ if (!isRecord(metadata))
220
+ return [];
221
+ return restoreTodosFromPersist(metadata[TODOS_METADATA_KEY]);
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ }
227
+ // Stamp the checklist into a metadata object for an updateSession patch (the
228
+ // per-turn persist path): every other key (extension state, filediffs, …)
229
+ // passes through untouched — only metadata.todos is set. Never throws.
230
+ export function withSessionTodos(metadata, list) {
231
+ try {
232
+ const base = isRecord(metadata)
233
+ ? { ...metadata }
234
+ : {};
235
+ base[TODOS_METADATA_KEY] = serializeTodosForPersist(list);
236
+ return base;
237
+ }
238
+ catch {
239
+ return { [TODOS_METADATA_KEY]: [] };
240
+ }
241
+ }
@@ -2,12 +2,13 @@
2
2
  // bytes first (see snapshots.ts); validation failures return before capture.
3
3
  import { promises as fsp } from "node:fs";
4
4
  import * as path from "node:path";
5
+ import { MEDIA_MAX_BYTES, mediaDescriptor, oversizeImageError, saveMedia, sniffImageMime, sniffPdf, unsupportedBinaryError, } from "../media.js";
5
6
  import { capturePriorBytes } from "../snapshots.js";
6
7
  import { contentHash, fingerprintKey, readFingerprints } from "./fingerprints.js";
7
8
  import { appendOverflow } from "./overflow.js";
8
9
  import { getCachedRead, invalidatePath, normalizeReadWindow, setCachedRead } from "./read-cache.js";
9
10
  import { invalidateListingsForFile } from "./dir-cache.js";
10
- import { err, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
11
+ import { err, invalidCall, READ_CHAR_CAP, READ_FILE_MAX_BYTES, resolveSandbox, truncateHead } from "./shared.js";
11
12
  // offset/limit are 1-based line numbers. Output capped at ~64KB.
12
13
  export async function readTool(args, cwd = process.cwd()) {
13
14
  try {
@@ -26,6 +27,62 @@ export async function readTool(args, cwd = process.cwd()) {
26
27
  const lines = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
27
28
  return `Directory listing for ${args.path}:\n${lines.join("\n")}`;
28
29
  }
30
+ // Vision-input peek FIRST: image magic bytes sit in the first 12 bytes,
31
+ // so every file is classified with one tiny read. Supported images
32
+ // (PNG/JPEG/GIF/WebP) take the media path with their own MEDIA_MAX_BYTES
33
+ // cap; PDF magic gets convert-first guidance at any size; everything
34
+ // else falls into the existing guarded text path untouched.
35
+ const fileSize = st.size ?? 0;
36
+ let peek = null;
37
+ try {
38
+ const fh = await fsp.open(r.abs, "r");
39
+ try {
40
+ const buf = Buffer.alloc(12);
41
+ const { bytesRead } = await fh.read(buf, 0, 12, 0);
42
+ peek = buf.subarray(0, bytesRead);
43
+ }
44
+ finally {
45
+ await fh.close();
46
+ }
47
+ }
48
+ catch {
49
+ // peek never breaks reads — null falls through to the text path
50
+ }
51
+ if (peek !== null) {
52
+ const mime = sniffImageMime(peek);
53
+ if (mime !== null) {
54
+ if (fileSize > MEDIA_MAX_BYTES)
55
+ return oversizeImageError(args.path, fileSize);
56
+ let raw;
57
+ try {
58
+ raw = await fsp.readFile(r.abs);
59
+ }
60
+ catch {
61
+ return err(`cannot read file: ${args.path}`);
62
+ }
63
+ const { id } = await saveMedia(raw, mime, args.path);
64
+ // Fingerprint on the utf8 decoding so a later edit compares
65
+ // consistently with editTool's own read+hash.
66
+ readFingerprints.set(fingerprintKey(r.abs), contentHash(raw.toString("utf8")));
67
+ return (`Image read successfully: ${args.path} (${mime}, ${raw.length} bytes, attached as vision input).\n` +
68
+ mediaDescriptor(id, mime, raw.length));
69
+ }
70
+ if (sniffPdf(peek)) {
71
+ return unsupportedBinaryError(args.path, "pdf", fileSize);
72
+ }
73
+ }
74
+ // OOM guard: never materialize a whole file past READ_FILE_MAX_BYTES
75
+ // (UTF-16 doubling + split/join copies can OOM the heap on one read).
76
+ // The size is known from the stat above, so this costs no extra I/O.
77
+ try {
78
+ const size = st.size ?? 0;
79
+ if (size > READ_FILE_MAX_BYTES) {
80
+ return err(`file too large to read (${size} bytes > 1MB): ${args.path}. Narrow with grep/glob first`);
81
+ }
82
+ }
83
+ catch {
84
+ // size check never breaks reads (the read below still applies its cap)
85
+ }
29
86
  // Read-cache fast path: same abs + window + unchanged mtime/size skips
30
87
  // disk I/O. The stored hash refreshes the stale-read fingerprint so
31
88
  // read→read→edit chains keep working without re-hashing.
@@ -48,32 +105,42 @@ export async function readTool(args, cwd = process.cwd()) {
48
105
  catch {
49
106
  return err(`cannot read file: ${args.path}`);
50
107
  }
51
- const hash = contentHash(text);
52
- readFingerprints.set(fingerprintKey(r.abs), hash);
53
- if (text.length === 0)
54
- return "";
55
- const offset = Math.max(1, Math.floor(args.offset ?? 1));
56
- const limit = Math.max(1, Math.floor(args.limit ?? Number.MAX_SAFE_INTEGER));
57
- const window = text.split("\n").slice(offset - 1, offset - 1 + limit);
58
- let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
59
- if (out.length > READ_CHAR_CAP) {
60
- const full = out;
61
- const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
62
- out = appendOverflow(t.head, t.note, "file output", full);
63
- }
64
- try {
65
- const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
66
- setCachedRead(r.abs, normOffset, normLimit, out, statInfo, hash);
67
- }
68
- catch {
69
- // cache store never breaks reads
70
- }
71
- return out;
108
+ return readTextResult(r.abs, args, text, st, normOffset, normLimit);
72
109
  }
73
110
  catch (e) {
74
111
  return err(e instanceof Error ? e.message : String(e));
75
112
  }
76
113
  }
114
+ // Shared text path for readTool: fingerprint + line window + 64KB cap +
115
+ // cache store. The small-file caller reuses its already-read bytes;
116
+ // the over-cap caller arrives here after the media peek.
117
+ function readTextResult(abs, args, text, st, normOffset, normLimit) {
118
+ const stat = st;
119
+ const hash = contentHash(text);
120
+ readFingerprints.set(fingerprintKey(abs), hash);
121
+ if (text.length === 0)
122
+ return "";
123
+ const offset = Math.max(1, Math.floor(args.offset ?? 1));
124
+ const limit = Math.max(1, Math.floor(args.limit ?? Number.MAX_SAFE_INTEGER));
125
+ const window = text.split("\n").slice(offset - 1, offset - 1 + limit);
126
+ let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
127
+ if (out.length > READ_CHAR_CAP) {
128
+ const full = out;
129
+ const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
130
+ out = appendOverflow(t.head, t.note, "file output", full);
131
+ }
132
+ try {
133
+ const statInfo = { mtimeMs: stat.mtimeMs ?? 0, size: stat.size ?? 0 };
134
+ const { offset: o, limit: l } = normOffset !== undefined && normLimit !== undefined
135
+ ? { offset: normOffset, limit: normLimit }
136
+ : normalizeReadWindow(args.offset, args.limit);
137
+ setCachedRead(abs, o, l, out, statInfo, hash);
138
+ }
139
+ catch {
140
+ // cache store never breaks reads
141
+ }
142
+ return out;
143
+ }
77
144
  export async function writeTool(args, cwd = process.cwd()) {
78
145
  try {
79
146
  const r = resolveSandbox(args?.path, cwd);
@@ -110,6 +177,19 @@ export async function editTool(args, cwd = process.cwd()) {
110
177
  }
111
178
  if (typeof args.newString !== "string")
112
179
  return err("newString must be a string");
180
+ // OOM guard (same rationale as readTool above): an edit materializes
181
+ // the whole file plus split/join copies, so refuse past the cap with
182
+ // guidance instead of risking the heap. Missing paths keep the legacy
183
+ // "no such file" error below.
184
+ try {
185
+ const st = await fsp.stat(r.abs);
186
+ if (st.isFile() && st.size > READ_FILE_MAX_BYTES) {
187
+ return err(`file too large to edit (${st.size} bytes > 1MB): ${args.path}. Use bash for targeted changes to huge files`);
188
+ }
189
+ }
190
+ catch {
191
+ // stat failure falls through to the read below (missing → its error)
192
+ }
113
193
  let text;
114
194
  try {
115
195
  text = await fsp.readFile(r.abs, "utf8");