atom-agent 1.1.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.
@@ -1,9 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Live-tail leaf: the dynamic zone between the committed <Static>
3
+ // transcript and the modals — empty-state hints, the streaming answer
4
+ // draft, the transient thinking block, and the tool-call hint. Re-renders
5
+ // every tick by design (unlike TranscriptView/InputBox); all paint comes
6
+ // from ui/theme tokens. The streaming-markdown chunk owns this file next.
7
+ import React from "react";
2
8
  import { Box, Text } from "ink";
3
9
  import { activityText } from "./activity.js";
4
10
  import { MarkdownStream } from "./markdown.js";
5
11
  import { theme } from "./theme.js";
6
- export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking = true }) {
12
+ export const LiveTail = React.memo(function LiveTail({ isEmpty, sessionHint, emptySessionTitle, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking = true }) {
7
13
  // Held view (user scrolled up mid-turn): the growing draft/thinking blocks
8
14
  // are replaced by one static line so the frame stops gaining terminal
9
15
  // lines — the terminal stops yanking and scrollback stays readable. The
@@ -11,5 +17,5 @@ export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, to
11
17
  // status (tool hint, thinking tick) keeps updating in place: same line,
12
18
  // no growth, no yank.
13
19
  const freezeLive = held === true && busy;
14
- return (_jsxs(Box, { flexDirection: "column", marginY: theme.spacing.liveTailMarginY, children: [isEmpty ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, sessionHint && isEmpty ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, freezeLive ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 turn running \u00B7 End to follow"] })) : null, !freezeLive && draft ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownStream, { text: draft })] })) : null, !freezeLive && thinking && showThinking ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " ", thinking, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })] })) : null, busy && toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " ", activityText(toolHint), toolElapsedSecs !== null && toolElapsedSecs >= 2 ? (_jsxs(_Fragment, { children: [" ", theme.symbol.separator, " ", toolElapsedSecs, "s"] })) : (theme.symbol.ellipsis)] })) : null, !freezeLive && busy && !draft && !thinking && !toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workThinking, " Thinking", theme.symbol.ellipsis, " ", theme.symbol.separator, " ", elapsedSecs, "s"] })) : null] }));
15
- }
20
+ return (_jsxs(Box, { flexDirection: "column", marginY: theme.spacing.liveTailMarginY, children: [isEmpty ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, isEmpty && emptySessionTitle && emptySessionTitle.trim() ? (_jsxs(Text, { dimColor: true, children: ["Session: ", emptySessionTitle.trim()] })) : null, sessionHint && isEmpty ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, freezeLive ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 turn running \u00B7 End to follow"] })) : null, !freezeLive && draft ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownStream, { text: draft })] })) : null, !freezeLive && thinking && showThinking ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " ", thinking, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })] })) : null, busy && toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " ", activityText(toolHint), toolElapsedSecs !== null && toolElapsedSecs >= 2 ? (_jsxs(_Fragment, { children: [" ", theme.symbol.separator, " ", toolElapsedSecs, "s"] })) : (theme.symbol.ellipsis)] })) : null, !freezeLive && busy && !draft && !thinking && !toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workThinking, " Thinking", theme.symbol.ellipsis, " ", theme.symbol.separator, " ", elapsedSecs, "s"] })) : null] }));
21
+ });
@@ -1,4 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Zero-dependency markdown renderer for assistant transcript turns.
3
+ //
4
+ // Terminal-native hierarchy, no boxes: headings are bold, lists use one
5
+ // consistent bullet (nesting as 2-space indents, task lists as ballot
6
+ // boxes), code blocks are indentation + a dim language label (```/~~~
7
+ // fences, unclosed runs to end of input), tables are aligned columns with
8
+ // one dim separator row, links read as `text (url)`. Soft line breaks join
9
+ // (true markdown); blank lines separate paragraphs. Raw fences/bold-markers
10
+ // never leak: when the model emits plain text, it paints back byte-identical.
11
+ // Long code lines are never pre-wrapped or truncated — Ink wraps them and
12
+ // the source line stays intact for copy/paste; long table cells truncate
13
+ // with `…` so one cell never blows out the grid.
14
+ //
15
+ // Performance: parsed blocks are cached per exact input (bounded FIFO), so
16
+ // re-renders and long sessions never re-parse. Parsing is linear in input
17
+ // size; rendering stays one <Text> per run (no per-character nodes).
18
+ import React from "react";
2
19
  import { Box, Text } from "ink";
3
20
  import { theme } from "./theme.js";
4
21
  // Split `s` on inline-code spans first (code content is never formatted),
@@ -481,10 +498,17 @@ export function closeStreamingMarkers(s) {
481
498
  // block cursor riding the final run. Converges to MarkdownText byte-for-
482
499
  // byte once the stream completes (cursor aside), so commit never visually
483
500
  // jumps.
484
- export function MarkdownStream({ text }) {
501
+ //
502
+ // Memoized on `text` — the ONLY parse input (TABLE_MAX_COL is a fixed
503
+ // const, wrapping is Ink's job, the cursor glyph is a module const). A 1s
504
+ // busy tick re-renders the parent with identical text and must NOT reparse:
505
+ // same text bails here, changed text re-parses (one linear pass).
506
+ export const streamParseProbe = { count: 0 };
507
+ export const MarkdownStream = React.memo(function MarkdownStream({ text }) {
508
+ streamParseProbe.count += 1;
485
509
  const blocks = parseMarkdown(closeStreamingMarkers(text) + theme.symbol.cursorBar);
486
510
  return (_jsx(Box, { flexDirection: "column", children: blocks.map((b, k) => (_jsx(BlockView, { block: b, gap: k > 0 }, k))) }));
487
- }
511
+ });
488
512
  // Assistant body: full markdown when the text parses into structure,
489
513
  // byte-identical plain text otherwise (a single paragraph paints its runs;
490
514
  // with no formatting syntax those runs are the input verbatim).
@@ -18,6 +18,8 @@ const PALETTE_CATEGORIES = {
18
18
  "/clear": "Session",
19
19
  "/new": "Session",
20
20
  "/resume": "Session",
21
+ "/rename": "Session",
22
+ "/session": "Session",
21
23
  "/rewind": "Session",
22
24
  "/context": "Session",
23
25
  "/telemetry": "Session",
@@ -9,8 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // lines align; changed regions pop via the existing word-background +
10
10
  // add/del line-number treatment; syntax colors reused per cell.
11
11
  // - hunks only (configurable context in the engine) — never whole files.
12
- // - width-aware: panes split the measured terminal (useStdout, same
13
- // pattern as App's termColumns); long lines truncate per pane with …
12
+ // - width-aware: panes split the measured terminal (local useStdout, same
13
+ // pattern as StatusBarHost); long lines truncate per pane with …
14
14
  // (code-point safe); below NARROW_COLUMNS the view degrades to the
15
15
  // stacked unified DiffView instead of destroying the layout.
16
16
  // - computed once per mount (useMemo, keyed on inputs + pane width) and
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // Status-bar host: keeps the terminal-width subscription out of App.
3
+ //
4
+ // App used to call useStdout() in its own body to measure columns for the
5
+ // bar's fit-or-drop logic, coupling the whole App render to stdout changes.
6
+ // This memoized host owns that read instead: resizes re-render the bar
7
+ // alone. StatusBar itself is untouched (same props API); `columns` becomes
8
+ // an optional override (tests keep passing explicit widths, production
9
+ // measures). The 100 fallback matches StatusBar's own default.
10
+ import React from "react";
11
+ import { useStdout } from "ink";
12
+ import { StatusBar } from "./status-bar.js";
13
+ export const StatusBarHost = React.memo(function StatusBarHost(props) {
14
+ let measured;
15
+ try {
16
+ measured = useStdout()?.stdout?.columns;
17
+ }
18
+ catch {
19
+ measured = undefined;
20
+ }
21
+ return _jsx(StatusBar, { ...props, columns: props.columns ?? measured ?? 100 });
22
+ });
@@ -0,0 +1,48 @@
1
+ const EMPTY = { draft: null, thinking: null };
2
+ export function createStreamStore() {
3
+ let snapshot = EMPTY;
4
+ const listeners = new Set();
5
+ function emit() {
6
+ for (const cb of [...listeners]) {
7
+ try {
8
+ cb();
9
+ }
10
+ catch {
11
+ // A throwing listener must not break the remaining subscribers.
12
+ }
13
+ }
14
+ }
15
+ function assign(next) {
16
+ if (next.draft === snapshot.draft && next.thinking === snapshot.thinking)
17
+ return;
18
+ snapshot = next;
19
+ emit();
20
+ }
21
+ return {
22
+ getSnapshot: () => snapshot,
23
+ subscribe: (cb) => {
24
+ listeners.add(cb);
25
+ return () => {
26
+ listeners.delete(cb);
27
+ };
28
+ },
29
+ setDraft: (text) => {
30
+ if (text === snapshot.draft)
31
+ return;
32
+ assign({ draft: text, thinking: snapshot.thinking });
33
+ },
34
+ setThinking: (text) => {
35
+ if (text === snapshot.thinking)
36
+ return;
37
+ assign({ draft: snapshot.draft, thinking: text });
38
+ },
39
+ getDraft: () => snapshot.draft,
40
+ getThinking: () => snapshot.thinking,
41
+ clear: () => {
42
+ if (snapshot !== EMPTY) {
43
+ snapshot = EMPTY;
44
+ emit();
45
+ }
46
+ },
47
+ };
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { truncateHead } from "../tools/shared.js";
3
4
  import { theme } from "./theme.js";
4
5
  export const MAX_TOOL_RECORDS = 50;
5
6
  export const STORE_CHARS = 32768;
@@ -8,7 +9,12 @@ export const LIST_WINDOW = 15;
8
9
  export function createToolRecord(id, label, result, isError, ms) {
9
10
  const text = result ?? "";
10
11
  const truncated = text.length > STORE_CHARS;
11
- const stored = truncated ? text.slice(0, STORE_CHARS) : text;
12
+ // Line-aware store cap (issue 04): the stored head never ends mid-line.
13
+ // Single-giant-line inputs keep the hard cut (documented tail edge case),
14
+ // so over-cap single-line results still store exactly STORE_CHARS.
15
+ const stored = truncated
16
+ ? truncateHead(text, STORE_CHARS, "\n[truncated: stored output exceeded 32KB]").head
17
+ : text;
12
18
  return {
13
19
  id,
14
20
  label,
@@ -1,39 +1,37 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // Transcript leaves: the committed <Static> scrollback, its item renderer,
3
3
  // and the startup banner. Prop-driven + memoized (see comments) so App state
4
4
  // churn never repaints them. Turn is the display-transcript entry shape.
5
5
  // All paint comes from ui/theme tokens — no literal colors or glyphs here.
6
6
  import React from "react";
7
- import { Box, Text } from "ink";
7
+ import { Box, Static, Text } from "ink";
8
8
  import { SideBySideDiffView, TRANSCRIPT_DIFF_MAX_LINES } from "./side-by-side.js";
9
9
  import { ErrorCard, classifyToolError } from "./errors.js";
10
10
  import { MarkdownText, ToolLine } from "./markdown.js";
11
11
  import { theme } from "./theme.js";
12
- // Scrollback viewport: the committed transcript renders as a windowed
13
- // slice of turns in a live Box (NOT <Static> — Static is append-only with
14
- // no scroll API, so PgUp/Home/follow modes are impossible on it).
12
+ // Commit frontier model: the committed transcript prints to terminal
13
+ // scrollback ONCE via <Static> and is never rewritten (this is what keeps
14
+ // a full-page transcript from flashing on every keystroke Ink takes a
15
+ // clearTerminal + full-reprint path for fullscreen dynamic frames).
15
16
  //
16
- // Model: E = viewed end index (items visible: (E-WIN, E]). E === turns.length
17
- // means follow mode — new turns extend the view automatically. Any E < len
18
- // is manual mode: the view freezes while new turns accumulate below, and a
19
- // `↓ N new` indicator offers the jump back. Clamping makes list replacement
20
- // (/clear, /resume, /new) re-follow for free (E > len collapses to len).
21
- // Banner shows only when the window touches the top.
17
+ // Model: E = committed end index (null = follow: commit everything). Any
18
+ // E < len is manual mode — new turns accumulate below the frontier and a
19
+ // `↓ N new` indicator offers the jump back. Printed output can never
20
+ // retract, so E below the committed count holds back future commits only;
21
+ // deep history lives in terminal scrollback. Banner shows on fresh mounts.
22
+ // List replacement (/clear, /resume, /new, /rewind) bumps clearGen, which
23
+ // resets the Static buffer via the identity below.
22
24
  export const SCROLLBACK_WINDOW = 300;
23
25
  export const SCROLL_PAGE_ITEMS = 10;
24
- export function resolveViewport(len, end, win = SCROLLBACK_WINDOW) {
25
- const e = Math.max(0, Math.min(end ?? len, len));
26
- const follow = e >= len;
27
- return { start: Math.max(0, e - win), end: e, pending: len - e, follow };
28
- }
29
26
  export function applyScrollAction(end, len, action) {
30
27
  const e = end ?? len;
31
28
  switch (action.kind) {
32
29
  case "pageUp":
33
- // Short sessions (everything fits the window) have no window to move:
34
- // freeze at the bottom instead of no-op-ing, so PgUp always engages
35
- // the held view (live output stops growing; the terminal stops
36
- // yanking). Long sessions move the window up a page, as before.
30
+ // Freeze the commit frontier instead of no-op-ing, so PgUp always
31
+ // engages the held view (new output stops printing below; the live
32
+ // tail stops growing; the terminal stops yanking). Values below the
33
+ // already-committed count hold back future commits only printed
34
+ // output lives in terminal scrollback and can never retract.
37
35
  if (len <= SCROLLBACK_WINDOW)
38
36
  return len;
39
37
  return Math.max(Math.min(len, SCROLLBACK_WINDOW), e - SCROLL_PAGE_ITEMS);
@@ -84,30 +82,86 @@ export function renderTranscriptItem(item) {
84
82
  // Render-count probe for the timer-isolation test: incremented on every
85
83
  // TranscriptView render (a 1s timer tick must leave it unchanged).
86
84
  export const transcriptRenderProbe = { count: 0 };
87
- export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, end, windowSize, held, showThinking = true, }) {
88
- transcriptRenderProbe.count += 1;
89
- const render = renderItem ?? renderTranscriptItem;
90
- const win = windowSize ?? SCROLLBACK_WINDOW;
91
- const vp = resolveViewport(turns.length, end, win);
92
- // Pairing ([audit label, error detail] one card) runs over the VISIBLE
93
- // slice only pairing is positional, and off-window turns never mount.
94
- // Keys stay global (`turn-${idx}`) so scrolling never remounts rows.
95
- // Hidden thinking turns are skipped in place (same index stability).
96
- const body = [];
97
- for (let idx = vp.start; idx < vp.end; idx++) {
85
+ // Render-count probe for row isolation: incremented per mounted row paint
86
+ // (appending one turn must paint exactly one new row, never the window).
87
+ export const transcriptRowRenderProbe = { count: 0 };
88
+ // Committed turns are immutable once appended (diffs attach pre-commit in
89
+ // onToolActivity, never post-append), and keys stay global (`turn-${idx}`),
90
+ // so a row whose item identity is unchanged can skip rendering entirely.
91
+ // Custom compare: the body array is rebuilt per TranscriptView render with
92
+ // fresh wrappers around the SAME turn refs shallow compare would always
93
+ // miss, hence id + turn/label identity. An unstable render fn falls back to
94
+ // today's behavior (re-render) rather than going stale.
95
+ function transcriptRowEqual(a, b) {
96
+ return (a.render === b.render &&
97
+ a.item.id === b.item.id &&
98
+ a.item.turn === b.item.turn &&
99
+ a.item.label === b.item.label);
100
+ }
101
+ const TranscriptRow = React.memo(function TranscriptRow({ item, render }) {
102
+ transcriptRowRenderProbe.count += 1;
103
+ return _jsx(React.Fragment, { children: render(item) });
104
+ }, transcriptRowEqual);
105
+ // Monotonic static admission: convert record turns [from, to) into Static
106
+ // items, pairing adjacent [audit label, error detail] within the batch and
107
+ // permanently skipping hidden thinking turns. ALWAYS returns next ===
108
+ // clamped `to` (even when everything skips) so the frontier only moves
109
+ // forward — shrinking or reordering same-identity items would misalign
110
+ // Ink's append-only Static buffer and duplicate terminal scrollback.
111
+ // List replacements (/clear, /resume, /rewind) bump clearGen instead, which
112
+ // resets the buffer via the Static identity below.
113
+ export function admitStaticBatch(turns, from, to, showThinking) {
114
+ const end = Math.max(from, Math.min(to, turns.length));
115
+ const items = [];
116
+ let idx = from;
117
+ while (idx < end) {
98
118
  const turn = turns[idx];
99
- if (turn.thinking === true && !showThinking)
119
+ if (turn.thinking === true && !showThinking) {
120
+ idx += 1;
100
121
  continue;
101
- const next = idx + 1 < vp.end ? turns[idx + 1] : undefined;
122
+ }
123
+ const next = idx + 1 < end ? turns[idx + 1] : undefined;
102
124
  if (isAuditLabel(turn) && next !== undefined && next.role === "tool" && next.error === true) {
103
- body.push({ id: `turn-${idx}`, turn: next, label: turn });
104
- idx += 1;
125
+ items.push({ id: `turn-${idx}`, turn: next, label: turn });
126
+ idx += 2;
105
127
  continue;
106
128
  }
107
- body.push({ id: `turn-${idx}`, turn });
129
+ items.push({ id: `turn-${idx}`, turn });
130
+ idx += 1;
131
+ }
132
+ return { items, next: end };
133
+ }
134
+ export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, end, held, showThinking = true, }) {
135
+ transcriptRenderProbe.count += 1;
136
+ const render = renderItem ?? renderTranscriptItem;
137
+ const frontier = end ?? turns.length;
138
+ // Committed static state: full reset on clearGen (list replacements bump
139
+ // it — replacements must never reuse the buffer), suffix-only advance
140
+ // otherwise (setState-during-render derived-state pattern; the extra pass
141
+ // runs only when genuinely new items commit, never on ticks/keystrokes).
142
+ const [committed, setCommitted] = React.useState(() => {
143
+ const base = clearGen === 0 ? [{ id: "banner" }] : [];
144
+ const batch = admitStaticBatch(turns, 0, frontier, showThinking);
145
+ return { gen: clearGen, items: [...base, ...batch.items], next: batch.next };
146
+ });
147
+ if (committed.gen !== clearGen) {
148
+ const base = clearGen === 0 ? [{ id: "banner" }] : [];
149
+ const batch = admitStaticBatch(turns, 0, end ?? turns.length, showThinking);
150
+ setCommitted({ gen: clearGen, items: [...base, ...batch.items], next: batch.next });
151
+ }
152
+ else {
153
+ const batch = admitStaticBatch(turns, committed.next, frontier, showThinking);
154
+ if (batch.items.length > 0 || batch.next !== committed.next) {
155
+ setCommitted({
156
+ gen: clearGen,
157
+ items: [...committed.items, ...batch.items],
158
+ next: batch.next,
159
+ });
160
+ }
108
161
  }
109
- const items = clearGen === 0 && vp.start === 0 ? [{ id: "banner" }, ...body] : body;
110
- return (_jsxs(Box, { flexDirection: "column", children: [items.map((item) => (_jsx(React.Fragment, { children: render(item) }, item.id))), vp.pending > 0 ? (_jsxs(Text, { dimColor: true, children: ["\u2193 ", vp.pending, " new \u2014 End for latest"] })) : held ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 End to follow"] })) : null] }));
162
+ // Backlog below the committed frontier (frozen appends, not yet printed).
163
+ const pending = turns.length - committed.next;
164
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: committed.items, children: (item) => _jsx(TranscriptRow, { item: item, render: render }, item.id) }, clearGen), pending > 0 ? (_jsxs(Text, { dimColor: true, children: ["\u2193 ", pending, " new \u2014 End for latest"] })) : held ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 End to follow"] })) : null] }));
111
165
  });
112
166
  // Startup banner: the ATOM block-letter art, rendered once at launch inside
113
167
  // <Static> (scrollback, so it scrolls away naturally). FIGlet "ANSI Shadow"
package/dist/zen.js CHANGED
@@ -8,7 +8,7 @@ import * as path from "node:path";
8
8
  import { MAX_TOOL_STEPS, TOOL_DEFINITIONS, } from "./tools.js";
9
9
  import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
10
10
  import { discoverLocalProvider } from "./local-discovery.js";
11
- import { ANTHROPIC_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, } from "./adapters.js";
11
+ import { ANTHROPIC_VERSION, anthropicHeaders, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
12
12
  export { isStallError, readWithStall, sseStallTimeoutMs } from "./adapters.js";
13
13
  import { KILO_FALLBACK_MODELS, fetchKiloModelsWithStatus, normalizeKiloChatError, } from "./kilo.js";
14
14
  import { splitSystemHead } from "./prompt-cache.js";
@@ -337,11 +337,20 @@ export async function fetchModels(endpoint, apiKey) {
337
337
  // - Slots with an id but no name at [DONE] are dropped with an onWarning
338
338
  // message and never returned (keeps assistant/tool pairing valid).
339
339
  // - A stream that ends without [DONE] throws a truncation error.
340
+ // - A response flagged `finish_reason: "length"` (output limit cut the
341
+ // response off, so tool arguments are incomplete) does NOT throw: it
342
+ // returns normally with `truncated: true` so the loop can fail each carried
343
+ // tool call inline and continue the turn. Transport failures (aborted
344
+ // connections, stalls, empty replies) keep throwing.
340
345
  // - A stream silent longer than the stall budget (env ATOM_STALL_TIMEOUT_MS,
341
346
  // default 60s; the clock resets on every received chunk) throws a
342
347
  // Truncated-stream stall error — permanent, never retried, same contract
343
348
  // as a dead connection (verified live: free-tier routers can stall a
344
349
  // 200-OK stream mid-generation for minutes).
350
+ // - Queue comments (`: ...`) and keep-alives carry bytes but no model output:
351
+ // only `data:` payload lines refresh the data-silence clock, so minutes of
352
+ // `: KILO PROCESSING` while queued fail fast instead of hanging the turn
353
+ // (same budget, same permanent contract).
345
354
  // - A stream with zero "data:" lines is treated as a non-SSE JSON payload
346
355
  // (tolerance for bodies that are really single-shot JSON) and parsed as
347
356
  // choices[0].message like the non-streaming fallback.
@@ -361,9 +370,29 @@ export async function readSSEMessage(res, opts) {
361
370
  // reasoning label seen in any delta.
362
371
  let streamUsage;
363
372
  let streamReasoning;
373
+ // Output-limit flag (see contract above): set when any streamed choice
374
+ // reports `finish_reason: "length"`. Returned on the result — never thrown.
375
+ let lengthTruncated = false;
364
376
  // Accumulated thinking text (see onThinking): kept apart from fullText so
365
377
  // reasoning never leaks into the answer, history, or tool arguments.
366
378
  let fullThinking = "";
379
+ // Data-silence tracking (see throwIfDataStalled): timestamp of the last
380
+ // `data:` payload line. Queue comments (`: KILO PROCESSING`) and keep-alive
381
+ // comments carry bytes but no model output — they advance the raw stream
382
+ // but must NOT extend the stall budget (live-proven: minutes of comments
383
+ // while a free-tier request sits queued).
384
+ let lastDataAt = Date.now();
385
+ // Fail fast when the stream flows (or idles) with no model output: same
386
+ // permanent Truncated contract as a dead connection (passes through every
387
+ // catch below untouched), same env knob as the per-read byte race. Checked
388
+ // after each drained chunk — legitimately slow generations keep emitting
389
+ // `data:` lines, so only true silence trips it.
390
+ function throwIfDataStalled() {
391
+ const budget = sseStallTimeoutMs();
392
+ if (Date.now() - lastDataAt > budget) {
393
+ throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
394
+ }
395
+ }
367
396
  function announceStreaming() {
368
397
  if (!streamingAnnounced) {
369
398
  streamingAnnounced = true;
@@ -386,6 +415,7 @@ export async function readSSEMessage(res, opts) {
386
415
  if (!line.startsWith("data:"))
387
416
  return; // event:/id:/retry: ignored
388
417
  sawData = true;
418
+ lastDataAt = Date.now();
389
419
  let payload = line.slice("data:".length);
390
420
  if (payload.startsWith(" "))
391
421
  payload = payload.slice(1);
@@ -410,6 +440,10 @@ export async function readSSEMessage(res, opts) {
410
440
  }
411
441
  const choice = evt
412
442
  ?.choices?.[0];
443
+ // Output-limit marker rides on the choice, beside the delta — any chunk
444
+ // reporting it means the tool arguments below are incomplete.
445
+ if (choice?.finish_reason === "length")
446
+ lengthTruncated = true;
413
447
  const delta = (choice?.delta ?? choice?.message);
414
448
  if (typeof delta !== "object" || delta === null)
415
449
  return;
@@ -533,6 +567,7 @@ export async function readSSEMessage(res, opts) {
533
567
  rawText += text;
534
568
  buffer += text;
535
569
  drainBuffer();
570
+ throwIfDataStalled();
536
571
  if (sawDone) {
537
572
  try {
538
573
  await reader.cancel?.();
@@ -569,6 +604,7 @@ export async function readSSEMessage(res, opts) {
569
604
  rawText += text;
570
605
  buffer += text;
571
606
  drainBuffer();
607
+ throwIfDataStalled();
572
608
  if (sawDone)
573
609
  break;
574
610
  }
@@ -612,6 +648,10 @@ export async function readSSEMessage(res, opts) {
612
648
  throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
613
649
  }
614
650
  // Tolerance: a body with no SSE data lines is really single-shot JSON.
651
+ // (The data-silence bound applies only once real SSE traffic exists, so
652
+ // whole-body JSON payloads are never false-tripped by it.)
653
+ if (sawData)
654
+ throwIfDataStalled();
615
655
  if (!sawData) {
616
656
  const candidate = rawText.trim();
617
657
  if (candidate.length > 0) {
@@ -628,6 +668,8 @@ export async function readSSEMessage(res, opts) {
628
668
  content,
629
669
  tool_calls: calls.length > 0 ? calls : undefined,
630
670
  };
671
+ if (data?.choices?.[0]?.finish_reason === "length")
672
+ result.truncated = true;
631
673
  const usage = parseUsage(data?.usage);
632
674
  if (usage !== undefined)
633
675
  result.usage = usage;
@@ -675,6 +717,8 @@ export async function readSSEMessage(res, opts) {
675
717
  content: fullText.length > 0 ? fullText : null,
676
718
  tool_calls: calls.length > 0 ? calls : undefined,
677
719
  };
720
+ if (lengthTruncated)
721
+ result.truncated = true;
678
722
  if (streamUsage !== undefined)
679
723
  result.usage = streamUsage;
680
724
  if (streamReasoning !== undefined)
@@ -692,7 +736,10 @@ export async function readSSEMessage(res, opts) {
692
736
  // tool_calls the caller must execute, plus `usage`/`reasoning` only when
693
737
  // the response actually carried them (usage: top-level `usage` on JSON or
694
738
  // SSE final chunks; reasoning: message/delta reasoning metadata).
695
- // Throws on HTTP error, empty reply, or a truncated stream.
739
+ // Throws on HTTP error, empty reply, or a truncated stream (aborted
740
+ // connection / stall / missing [DONE]). A response flagged
741
+ // `finish_reason: "length"` instead returns normally with `truncated: true`
742
+ // (the loop fails its tool calls inline and continues).
696
743
  // - Network throws and HTTP 429/500/502/503/504 are retried up to
697
744
  // MAX_RETRIES (10) with 1s→2s→4s… backoff, honoring Retry-After capped
698
745
  // at 30s.
@@ -811,6 +858,8 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
811
858
  content,
812
859
  tool_calls: calls.length > 0 ? calls : undefined,
813
860
  };
861
+ if (data?.choices?.[0]?.finish_reason === "length")
862
+ result.truncated = true;
814
863
  const usage = parseUsage(data?.usage);
815
864
  if (usage !== undefined)
816
865
  result.usage = usage;
@@ -861,16 +910,19 @@ export async function chatCompletion(endpoint, apiKey, model, history, opts, err
861
910
  }
862
911
  // Agentic loop for one user turn: thin wrapper over the shared runLoopWithChat
863
912
  // core below (single loop implementation). Send → while the response carries
864
- // tool_calls (max MAX_TOOL_STEPS tool rounds), append the assistant message,
865
- // execute each tool locally, append {role:'tool'} results, resend.
913
+ // tool_calls (uncapped by default; explicit opts.maxSteps still caps), append
914
+ // the assistant message, execute each tool locally, append {role:'tool'}
915
+ // results, resend.
866
916
  // Streaming: each POST streams SSE tokens (onToken gets the growing text,
867
917
  // onPhase reports thinking|streaming|tool|retry|done, onToolDelta fires when
868
918
  // a tool name first appears mid-stream). A model that returns no tool_calls
869
919
  // ends the loop (graceful fallback for models without tool support). Tool
870
920
  // errors are results the model sees — NOTHING is rolled back here; only a
871
- // POST failure (HTTP/network/empty/truncated) throws (and the caller rolls
921
+ // POST failure (HTTP/network/empty/stalled-stream) throws (and the caller rolls
872
922
  // back the user turn, as before; the caller preserves any streamed partial
873
- // on display).
923
+ // on display). A length-truncated response (`finish_reason: "length"`) does
924
+ // not throw: the loop fails each carried tool call inline with a repair
925
+ // error and continues to the next model round.
874
926
  export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
875
927
  return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
876
928
  onToken: o?.onToken,
@@ -1166,16 +1218,17 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1166
1218
  }
1167
1219
  return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
1168
1220
  }
1169
- export { evaluateTurnEnd, isCodePath, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
1221
+ export { evaluateTurnEnd, isCodePath, MAX_TODO_ROUNDS, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
1170
1222
  // Parallel independent tool calls: batch PLANNING lives in src/scheduler.ts
1171
1223
  // (effect metadata + conflict rules, no per-tool branches); this module only
1172
1224
  // plans via planToolBatches below and executes (serial singletons in program
1173
- // order, read batches concurrently, results committed in call order).
1225
+ // order, disjoint batches concurrently, results committed in call order).
1174
1226
  //
1175
- // Parallel-safe = batchable reads only (see TOOL_EFFECTS in scheduler.ts).
1176
- // Excluded on purpose:
1177
- // - write/edit/bash mutate or spawn with an unbounded footprint (bash can
1178
- // touch anything, so no footprint check could clear it) always singletons;
1227
+ // Parallel-safe = batchable reads plus disjoint-file writes (see TOOL_EFFECTS
1228
+ // and canonicalFileKey in scheduler.ts). Approvals for batched writes resolve
1229
+ // serially in call order before any member executes. Excluded on purpose:
1230
+ // - bash mutates/spawns with an unbounded footprint (it can touch anything,
1231
+ // so no footprint check could clear it) — always a singleton;
1179
1232
  // - ask_question blocks on a UI modal (parallel prompts make no sense);
1180
1233
  // - todowrite/todo_update share module-global todo state (read-modify-write
1181
1234
  // races); todo_get is pure but sub-millisecond, so batching it buys
@@ -1190,7 +1243,7 @@ export function planToolBatches(calls) {
1190
1243
  }
1191
1244
  import { runLoopWithChat } from "./agent/loop.js";
1192
1245
  export { runLoopWithChat } from "./agent/loop.js";
1193
- export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
1246
+ export { DEFAULT_MAX_TOTAL_TOOL_CALLS, DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, emptyResponseFollowUp, isEmptyReplyError, MAX_EMPTY_ROUNDS, resolveMaxTotalToolCalls, resolveToolTimeoutMs, } from "./agent/loop.js";
1194
1247
  export async function runAgenticLoopForProvider(provider, apiKey, model, history, opts) {
1195
1248
  return runLoopWithChat((h, o) => chatCompletionForProvider(provider, apiKey, model, h, {
1196
1249
  onToken: o?.onToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atom-agent",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Agentic terminal coding assistant: multi-provider LLM loop with local file/shell/web tools in an Ink (React) TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",