atom-agent 1.0.0 → 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 (46) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +17 -16
  3. package/dist/App.js +1010 -77
  4. package/dist/adapters.js +108 -8
  5. package/dist/agent/gates.js +14 -1
  6. package/dist/agent/loop-guard.js +182 -0
  7. package/dist/agent/loop.js +781 -329
  8. package/dist/agent/normalize.js +151 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/compact.js +128 -2
  11. package/dist/env-block.js +43 -5
  12. package/dist/scheduler.js +101 -21
  13. package/dist/sessions.js +524 -0
  14. package/dist/system.js +89 -12
  15. package/dist/telemetry-dashboard.js +19 -1
  16. package/dist/telemetry.js +55 -0
  17. package/dist/tools/dir-cache.js +214 -0
  18. package/dist/tools/filesystem.js +43 -3
  19. package/dist/tools/read-cache.js +160 -0
  20. package/dist/tools/registry.js +80 -0
  21. package/dist/tools/ripgrep.js +256 -0
  22. package/dist/tools/search.js +147 -80
  23. package/dist/tools/shared.js +39 -0
  24. package/dist/tools/shell.js +26 -5
  25. package/dist/tools/todo.js +1 -1
  26. package/dist/tools/web.js +6 -6
  27. package/dist/tools.js +3 -0
  28. package/dist/ui/diff-panel.js +55 -0
  29. package/dist/ui/diff-view.js +117 -0
  30. package/dist/ui/diff.js +422 -0
  31. package/dist/ui/highlight.js +120 -0
  32. package/dist/ui/live-host.js +18 -0
  33. package/dist/ui/live-tail.js +9 -3
  34. package/dist/ui/markdown.js +26 -2
  35. package/dist/ui/modals.js +22 -5
  36. package/dist/ui/palette.js +12 -2
  37. package/dist/ui/side-by-side.js +144 -0
  38. package/dist/ui/status-bar.js +20 -4
  39. package/dist/ui/status-host.js +22 -0
  40. package/dist/ui/stream-store.js +48 -0
  41. package/dist/ui/theme.js +6 -0
  42. package/dist/ui/todo-panel.js +10 -2
  43. package/dist/ui/tool-inspector.js +7 -1
  44. package/dist/ui/transcript.js +105 -39
  45. package/dist/zen.js +97 -20
  46. package/package.json +1 -1
package/dist/adapters.js CHANGED
@@ -262,10 +262,80 @@ export function buildGeminiBody(history, _model, opts) {
262
262
  }
263
263
  return body;
264
264
  }
265
+ // ---- SSE stall timeout (live-proven: a 200-OK stream can stop emitting
266
+ // bytes mid-generation — e.g. free-tier routers stalling on tool-heavy
267
+ // requests — and hang the turn until the socket dies minutes later) ----
268
+ //
269
+ // Every `reader.read()` / iterator step races this clock; silence longer
270
+ // than the budget fails the turn LOUDLY with a permanent Truncated-stream
271
+ // error (same contract as a dead connection: the caller rolls back, the App
272
+ // keeps the streamed partial, the user resends). The clock resets on every
273
+ // received chunk — slow models are fine, dead sockets are not.
274
+ //
275
+ // Budget: env ATOM_STALL_TIMEOUT_MS when a finite value > 0 (max-clamped to
276
+ // 5min; an explicitly tiny value is the operator's choice, and lets tests
277
+ // use millisecond budgets), else the 60s default. The hung read is left to
278
+ // settle — callers cancel/release the reader on the way out as before.
279
+ export const DEFAULT_SSE_STALL_TIMEOUT_MS = 60_000;
280
+ export const MAX_SSE_STALL_TIMEOUT_MS = 300_000;
281
+ export function sseStallTimeoutMs() {
282
+ const raw = process.env.ATOM_STALL_TIMEOUT_MS;
283
+ if (raw !== undefined) {
284
+ const n = Number(raw.trim());
285
+ if (Number.isFinite(n) && n > 0)
286
+ return Math.min(Math.floor(n), MAX_SSE_STALL_TIMEOUT_MS);
287
+ }
288
+ return DEFAULT_SSE_STALL_TIMEOUT_MS;
289
+ }
290
+ export function isStallError(e) {
291
+ return e instanceof Error && e.message.startsWith("Truncated stream from model (stall:");
292
+ }
293
+ export async function readWithStall(read, ms) {
294
+ const limit = typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? Math.floor(ms) : sseStallTimeoutMs();
295
+ let timer = null;
296
+ try {
297
+ const pending = read();
298
+ const timeout = new Promise((_, reject) => {
299
+ timer = setTimeout(() => {
300
+ reject(new Error(`Truncated stream from model (stall: no bytes for ${limit}ms before [DONE]).`));
301
+ }, limit);
302
+ // An unref'd timer must never hold the process open for a settled read.
303
+ try {
304
+ timer.unref?.();
305
+ }
306
+ catch {
307
+ // ignore — environments without unref (browsers) proceed regardless
308
+ }
309
+ });
310
+ return await Promise.race([pending, timeout]);
311
+ }
312
+ finally {
313
+ if (timer)
314
+ clearTimeout(timer);
315
+ }
316
+ }
265
317
  async function collectSSEText(res) {
266
318
  const body = res.body;
267
319
  const decoder = new TextDecoder();
268
320
  let rawText = "";
321
+ // Data-silence tracking (mirrors zen.readSSEMessage): queue comments and
322
+ // keep-alives carry bytes but no model output, so only chunks containing a
323
+ // `data:` line start refresh the clock. The tail window catches a marker
324
+ // split across chunk boundaries.
325
+ let lastDataAt = Date.now();
326
+ let tail = "";
327
+ const noteChunk = (chunkText) => {
328
+ const joined = tail + chunkText;
329
+ if (/(?:^|\n)data:/.test(joined))
330
+ lastDataAt = Date.now();
331
+ tail = joined.slice(-8);
332
+ };
333
+ const throwIfDataStalled = () => {
334
+ const budget = sseStallTimeoutMs();
335
+ if (Date.now() - lastDataAt > budget) {
336
+ throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
337
+ }
338
+ };
269
339
  if (body == null)
270
340
  return { rawText, events: [] };
271
341
  try {
@@ -275,18 +345,31 @@ async function collectSSEText(res) {
275
345
  for (;;) {
276
346
  let chunk;
277
347
  try {
278
- chunk = await reader.read();
348
+ chunk = await readWithStall(() => reader.read());
279
349
  }
280
350
  catch (e) {
351
+ if (isStallError(e)) {
352
+ // Free the dead socket on the way out, then surface the stall
353
+ // unchanged (permanent Truncated contract — never retried).
354
+ try {
355
+ await reader.cancel?.();
356
+ }
357
+ catch {
358
+ // ignore cancel errors
359
+ }
360
+ throw e;
361
+ }
281
362
  throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
282
363
  }
283
364
  if (chunk.done)
284
365
  break;
285
366
  const v = chunk.value;
286
- rawText +=
287
- typeof v === "string"
288
- ? v
289
- : decoder.decode(v, { stream: true });
367
+ const textPart = typeof v === "string"
368
+ ? v
369
+ : decoder.decode(v, { stream: true });
370
+ rawText += textPart;
371
+ noteChunk(textPart);
372
+ throwIfDataStalled();
290
373
  }
291
374
  }
292
375
  finally {
@@ -299,11 +382,28 @@ async function collectSSEText(res) {
299
382
  }
300
383
  }
301
384
  else if (typeof body[Symbol.asyncIterator] === "function") {
302
- for await (const v of body) {
303
- rawText +=
304
- typeof v === "string"
385
+ const it = body[Symbol.asyncIterator]();
386
+ try {
387
+ for (;;) {
388
+ const step = await readWithStall(() => it.next());
389
+ if (step.done)
390
+ break;
391
+ const v = step.value;
392
+ const textPart = typeof v === "string"
305
393
  ? v
306
394
  : decoder.decode(v, { stream: true });
395
+ rawText += textPart;
396
+ noteChunk(textPart);
397
+ throwIfDataStalled();
398
+ }
399
+ }
400
+ finally {
401
+ try {
402
+ await it.return?.();
403
+ }
404
+ catch {
405
+ // ignore — the stream is over either way
406
+ }
307
407
  }
308
408
  }
309
409
  else {
@@ -19,6 +19,15 @@ export function todoCompletionGate(finalText, ctx) {
19
19
  finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) — resolve with todo_update/todowrite before ending the turn:\n${items})`,
20
20
  };
21
21
  }
22
+ // Guard cycles are bounded (like the verification gate): a model that
23
+ // keeps answering without resolving todos ends with a blocked statement
24
+ // instead of looping forever. Normal flows resolve within a round or two.
25
+ if ((ctx.todoRounds ?? 0) >= MAX_TODO_ROUNDS) {
26
+ return {
27
+ action: "end",
28
+ finalText: `${finalText}${finalText ? "\n" : ""}(blocked: ${open.length} open todo(s) remain after ${MAX_TODO_ROUNDS} guard rounds — resolve with todo_update/todowrite before ending the turn:\n${items})`,
29
+ };
30
+ }
22
31
  return {
23
32
  action: "continue",
24
33
  assistantText: finalText,
@@ -30,11 +39,15 @@ export function todoCompletionGate(finalText, ctx) {
30
39
  // report — the system prompt forbids unverified finishes, so the runtime
31
40
  // must not terminate while just labeling): the attempt is recorded and a
32
41
  // verification follow-up re-enters the loop, exactly like the todo guard.
33
- // Two bounded exits: spent step budget, or MAX_VERIFY_ROUNDS nag cycles
42
+ // Two bounded exits: an explicit step budget spent, or MAX_VERIFY_ROUNDS nag
34
43
  // without a passing run — both end with an explicit labeled statement naming
35
44
  // what is unverified and why the loop stopped. Turns with no code writes
36
45
  // (questions, docs, explanations, read-only work) are unaffected.
37
46
  export const MAX_VERIFY_ROUNDS = 3;
47
+ // Todo-guard continues before the turn ends blocked: a model that keeps
48
+ // answering final text without resolving open todos is sent back at most
49
+ // this many times. Mirrors MAX_VERIFY_ROUNDS so no gate can spin forever.
50
+ export const MAX_TODO_ROUNDS = 3;
38
51
  // Source-code extensions whose writes require a passing verification run.
39
52
  // Curated heuristic boundary (not a parser): docs, configs, data, and
40
53
  // extensionless files never arm the gate, so a README edit finishes clean.
@@ -0,0 +1,182 @@
1
+ // Loop-guard: repetition/runaway detection + error-streak recovery for the
2
+ // agentic loop. Pure state machines, no I/O, never throw.
3
+ //
4
+ // Why this exists: turns are uncapped by default, so a model stuck calling
5
+ // `read <same path>` forever burns POSTs without end. The guard spots the
6
+ // pattern early (consecutive identical signatures) and the loop nudges the
7
+ // model toward a different approach with a bounded follow-up — then stops
8
+ // hard if the pattern survives the nudges. Error streaks get the same
9
+ // treatment: ending on 3+ unaddressed `Error:` results is almost always
10
+ // premature, so the loop asks for a fix-forward attempt before accepting
11
+ // final text.
12
+ //
13
+ // Repetition intervention is OPT-IN (maxRepeatedCalls set by the caller;
14
+ // unset = track-only for stats). Error-streak recovery defaults to 3
15
+ // (single errors still end normally — the model may be reporting a blocker).
16
+ //
17
+ // All thresholds clamp to sane minima; every method is safe to call with any
18
+ // input.
19
+ // Tools whose identical repeats are legitimate polling, never runaway:
20
+ // bash_output re-polls the same taskId while a background task runs (each
21
+ // poll can return growing output). Excluded calls still break other tools'
22
+ // consecutive streaks — a poll between two identical reads means the reads
23
+ // were not consecutive.
24
+ export const POLLING_TOOLS = new Set(["bash_output"]);
25
+ export class RepetitionGuard {
26
+ maxConsecutive;
27
+ maxTotal;
28
+ maxNudges;
29
+ consecutiveSig = null;
30
+ consecutiveCount = 0;
31
+ totals = new Map();
32
+ nudges = 0;
33
+ excluded;
34
+ hits = 0;
35
+ constructor(opts = {}) {
36
+ const mc = opts.maxRepeatedCalls;
37
+ this.maxConsecutive =
38
+ typeof mc === "number" && Number.isFinite(mc) ? Math.max(2, Math.floor(mc)) : null;
39
+ const mt = opts.maxTotalRepeats;
40
+ this.maxTotal =
41
+ typeof mt === "number" && Number.isFinite(mt)
42
+ ? Math.max(2, Math.floor(mt))
43
+ : this.maxConsecutive !== null
44
+ ? this.maxConsecutive * 3
45
+ : null;
46
+ const mn = opts.maxNudges;
47
+ this.maxNudges =
48
+ typeof mn === "number" && Number.isFinite(mn) ? Math.max(1, Math.floor(mn)) : 2;
49
+ this.excluded = new Set(POLLING_TOOLS);
50
+ try {
51
+ if (opts.excludedTools) {
52
+ for (const t of opts.excludedTools) {
53
+ if (typeof t === "string" && t.length > 0)
54
+ this.excluded.add(t);
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ // custom exclusions are best-effort; defaults still apply
60
+ }
61
+ }
62
+ isExcluded(toolName) {
63
+ return typeof toolName === "string" && this.excluded.has(toolName);
64
+ }
65
+ note(signature, toolName) {
66
+ const sig = typeof signature === "string" ? signature : String(signature ?? "");
67
+ const name = typeof toolName === "string" && toolName.length > 0
68
+ ? toolName
69
+ : sig.includes(" ")
70
+ ? sig.slice(0, sig.indexOf(" "))
71
+ : sig;
72
+ // Polling tools never count: they still break other tools' streaks (a
73
+ // poll between two identical reads means the reads were not consecutive).
74
+ if (this.isExcluded(name)) {
75
+ this.resetStreak();
76
+ return { signature: sig, consecutive: 0, total: this.totals.get(sig) ?? 0, intervened: false, excluded: true };
77
+ }
78
+ const total = (this.totals.get(sig) ?? 0) + 1;
79
+ this.totals.set(sig, total);
80
+ if (this.consecutiveSig === sig)
81
+ this.consecutiveCount += 1;
82
+ else {
83
+ this.consecutiveSig = sig;
84
+ this.consecutiveCount = 1;
85
+ }
86
+ const intervened = this.shouldIntervene();
87
+ if (intervened)
88
+ this.hits += 1;
89
+ return { signature: sig, consecutive: this.consecutiveCount, total, intervened, excluded: false };
90
+ }
91
+ shouldIntervene() {
92
+ if (this.maxConsecutive === null)
93
+ return false;
94
+ if (this.consecutiveCount >= this.maxConsecutive)
95
+ return true;
96
+ if (this.maxTotal !== null && this.consecutiveSig !== null) {
97
+ if ((this.totals.get(this.consecutiveSig) ?? 0) >= this.maxTotal)
98
+ return true;
99
+ }
100
+ return false;
101
+ }
102
+ // Nudge budget: true while guidance follow-ups remain (each consumes one).
103
+ // When exhausted the caller stops hard — the pattern survived coaching.
104
+ consumeNudge() {
105
+ if (this.nudges >= this.maxNudges)
106
+ return false;
107
+ this.nudges += 1;
108
+ return true;
109
+ }
110
+ get nudgeCount() {
111
+ return this.nudges;
112
+ }
113
+ get hitCount() {
114
+ return this.hits;
115
+ }
116
+ resetStreak() {
117
+ this.consecutiveSig = null;
118
+ this.consecutiveCount = 0;
119
+ }
120
+ }
121
+ export function repetitionFollowUp(signature, consecutive) {
122
+ const short = signature.length > 160 ? `${signature.slice(0, 160)}…` : signature;
123
+ return (`(loop guard: the identical tool call repeated ${consecutive}× consecutively (${short}). ` +
124
+ `The current approach is not making progress — try a different tool, different arguments, ` +
125
+ `or report the blocker with its evidence instead of retrying the same call.)`);
126
+ }
127
+ export function repetitionStopNotice(signature, consecutive) {
128
+ const short = signature.length > 160 ? `${signature.slice(0, 160)}…` : signature;
129
+ return (`(stopped: identical tool call repeated ${consecutive}× (${short}) — ` +
130
+ `loop-guard halted the runaway instead of burning the remaining tool budget.)`);
131
+ }
132
+ // Error-streak tracker: consecutive `Error:` results. The loop asks whether
133
+ // final text should be accepted (streak < threshold → yes) or nudged once
134
+ // (streak >= threshold → continue, bounded per turn by the caller).
135
+ export class ErrorStreakTracker {
136
+ threshold;
137
+ streak = 0;
138
+ nudges = 0;
139
+ constructor(threshold) {
140
+ this.threshold =
141
+ typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
142
+ ? Math.floor(threshold)
143
+ : threshold === 0
144
+ ? 0
145
+ : 3;
146
+ }
147
+ get enabled() {
148
+ return this.threshold > 0;
149
+ }
150
+ noteResult(isError) {
151
+ if (isError)
152
+ this.streak += 1;
153
+ else
154
+ this.streak = 0;
155
+ }
156
+ noteResults(results) {
157
+ for (const e of results)
158
+ this.noteResult(e === true);
159
+ }
160
+ get current() {
161
+ return this.streak;
162
+ }
163
+ // True when final text should be held for a fix-forward attempt. Consumes
164
+ // one nudge per true (the caller bounds total nudges per turn).
165
+ shouldHoldFinal(maxNudgesPerTurn) {
166
+ if (!this.enabled || this.streak < this.threshold)
167
+ return false;
168
+ if (this.nudges >= maxNudgesPerTurn)
169
+ return false;
170
+ this.nudges += 1;
171
+ return true;
172
+ }
173
+ reset() {
174
+ this.streak = 0;
175
+ }
176
+ }
177
+ export function errorStreakFollowUp(streak) {
178
+ return (`(recovery: the last ${streak} tool result(s) were errors and the turn tried to end. ` +
179
+ `Do not end on unaddressed failures — read the error text, fix the arguments or replan ` +
180
+ `around the failure, and continue with tool calls. If it cannot be fixed, end by naming ` +
181
+ `the blocker with its evidence.)`);
182
+ }