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.
- package/CHANGELOG.md +31 -0
- package/README.md +5 -4
- package/dist/App.js +738 -79
- package/dist/adapters.js +30 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +212 -69
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -13
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/registry.js +1 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +1 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +2 -0
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +66 -13
- package/package.json +1 -1
package/dist/ui/live-tail.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/ui/markdown.js
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/dist/ui/palette.js
CHANGED
package/dist/ui/side-by-side.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/dist/ui/transcript.js
CHANGED
|
@@ -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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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 =
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
idx +=
|
|
125
|
+
items.push({ id: `turn-${idx}`, turn: next, label: turn });
|
|
126
|
+
idx += 2;
|
|
105
127
|
continue;
|
|
106
128
|
}
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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 (
|
|
865
|
-
// execute each tool locally, append {role:'tool'}
|
|
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/
|
|
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,
|
|
1225
|
+
// order, disjoint batches concurrently, results committed in call order).
|
|
1174
1226
|
//
|
|
1175
|
-
// Parallel-safe = batchable reads
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
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