jeo-code 0.6.8 → 0.6.10
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 +16 -0
- package/README.ja.md +2 -6
- package/README.ko.md +2 -6
- package/README.md +2 -6
- package/README.zh.md +2 -6
- package/package.json +1 -1
- package/src/agent/memory-okf.ts +275 -0
- package/src/agent/tools.ts +114 -63
- package/src/tui/app.ts +28 -28
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.6.10] - 2026-06-16
|
|
10
|
+
_OKF memory-format foundation and a hardened bashTool subprocess drain._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **OKF (Open Knowledge Format) v0.1 foundation** — a standalone schema/format layer for jeo memory: YAML-frontmatter parse/serialize with extension-key round-trip, concept-ID computation, and a tolerant v0.1 conformance validator (`src/agent/memory-okf.ts`). Sprint 01 only — it does not touch the existing distill/inject pipeline yet, and adds zero native dependencies. Design notes live under `docs/okf_mem/`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **bashTool subprocess draining hardened.** A shared `drainPipe` reader plus reader-cancellation, a SIGTERM→SIGKILL kill timer, and a brief pipe-linger ensure stdout/stderr fully drain before teardown — no file-descriptor or child-process leak across the normal, timed-out, and abandoned lifecycles (the subprocess-leak probe stays at baseline).
|
|
17
|
+
|
|
18
|
+
## [0.6.9] - 2026-06-16
|
|
19
|
+
_Live streaming blocks size to their content and the viewport instead of a fixed rectangle._
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **`Thinking` / tool `Output` live blocks size to their content.** The dimmed streaming trace and tool-output tail are now rendered by a single `renderLiveBlock` helper that shows only the most-recent lines, capped at ~30% of the terminal height — instead of a fixed blank-padded rectangle. A short stream no longer leaves dead "hole" rows, and a short terminal keeps the rows the heartbeat needs.
|
|
23
|
+
- Dropped the rounded-icon header image from the READMEs (the hero image and title stay).
|
|
24
|
+
|
|
9
25
|
## [0.6.8] - 2026-06-16
|
|
10
26
|
_OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs._
|
|
11
27
|
|
package/README.ja.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="assets/icon-rounded-256.png" alt="jeo-code icon" width="128" />
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<p align="center">
|
|
6
2
|
<img src="assets/hero.png" alt="jeo-code 自律コーディングエージェントのヒーローイラスト" width="100%" />
|
|
7
3
|
</p>
|
|
@@ -162,11 +158,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
162
158
|
## 変更履歴 (Changelog)
|
|
163
159
|
|
|
164
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
162
|
+
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
165
163
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
166
164
|
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
167
165
|
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
168
|
-
- **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
|
|
169
|
-
- **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
|
|
170
166
|
|
|
171
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
172
168
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="assets/icon-rounded-256.png" alt="jeo-code icon" width="128" />
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<p align="center">
|
|
6
2
|
<img src="assets/hero.png" alt="jeo-code 자율 코딩 에이전트 히어로 일러스트" width="100%" />
|
|
7
3
|
</p>
|
|
@@ -162,11 +158,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
162
158
|
## 변경 이력 (Changelog)
|
|
163
159
|
|
|
164
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
162
|
+
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
165
163
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
166
164
|
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
167
165
|
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
168
|
-
- **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
|
|
169
|
-
- **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
|
|
170
166
|
|
|
171
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
172
168
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="assets/icon-rounded-256.png" alt="jeo-code icon" width="128" />
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<p align="center">
|
|
6
2
|
<img src="assets/hero.png" alt="jeo-code autonomous coding-agent hero illustration" width="100%" />
|
|
7
3
|
</p>
|
|
@@ -162,11 +158,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
162
158
|
## Changelog
|
|
163
159
|
|
|
164
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
162
|
+
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
165
163
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
166
164
|
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
167
165
|
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
168
|
-
- **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
|
|
169
|
-
- **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
|
|
170
166
|
|
|
171
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
172
168
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="assets/icon-rounded-256.png" alt="jeo-code icon" width="128" />
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<p align="center">
|
|
6
2
|
<img src="assets/hero.png" alt="jeo-code 自主编码代理主视觉插图" width="100%" />
|
|
7
3
|
</p>
|
|
@@ -162,11 +158,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
162
158
|
## 更新日志 (Changelog)
|
|
163
159
|
|
|
164
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
162
|
+
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
165
163
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
166
164
|
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
167
165
|
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
168
|
-
- **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
|
|
169
|
-
- **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
|
|
170
166
|
|
|
171
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
172
168
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKF (Open Knowledge Format) v0.1 foundation for jeo memory — Sprint 01.
|
|
3
|
+
*
|
|
4
|
+
* Pure schema/format layer: YAML-frontmatter parse/serialize (extension keys
|
|
5
|
+
* round-trip preserved), concept-ID computation, and a tolerant v0.1
|
|
6
|
+
* conformance validator. This module deliberately does NOT touch the existing
|
|
7
|
+
* `memory.ts` distill/inject pipeline (Sprints 02–05 build on this contract).
|
|
8
|
+
*
|
|
9
|
+
* Design contract: docs/okf_mem/sprint-01-format-foundation/index.md
|
|
10
|
+
* Spec digest: docs/okf_mem/concepts/okf-spec-digest.md
|
|
11
|
+
*
|
|
12
|
+
* Why a bespoke YAML subset (not a dep): OKF frontmatter is intentionally tiny
|
|
13
|
+
* — flat `key: value` pairs with scalars and inline `[a, b]` lists. A focused
|
|
14
|
+
* parser keeps jeo's zero-native-dependency promise and gives us exact
|
|
15
|
+
* round-trip control over extension keys (e.g. `confidence`, `last_verified`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** jeo's agreed `type` vocabulary. OKF requires no central registry, but our
|
|
19
|
+
* producers/consumers share these values so injection can filter/prioritize.
|
|
20
|
+
* Unknown types are TOLERATED by the validator (lenient consumption). */
|
|
21
|
+
export const JEO_TYPES = [
|
|
22
|
+
"RepoFact",
|
|
23
|
+
"Command",
|
|
24
|
+
"Gotcha",
|
|
25
|
+
"UserPreference",
|
|
26
|
+
"Reference",
|
|
27
|
+
] as const;
|
|
28
|
+
export type JeoType = (typeof JEO_TYPES)[number];
|
|
29
|
+
|
|
30
|
+
/** Reserved OKF filenames — never concept documents. */
|
|
31
|
+
export const RESERVED_FILES = ["index.md", "log.md"] as const;
|
|
32
|
+
|
|
33
|
+
/** A parsed frontmatter value: scalar or inline list of strings. */
|
|
34
|
+
export type FrontmatterValue = string | number | boolean | string[];
|
|
35
|
+
/** Ordered frontmatter map (JS string-key insertion order is preserved). */
|
|
36
|
+
export type Frontmatter = Record<string, FrontmatterValue>;
|
|
37
|
+
|
|
38
|
+
export interface ParsedConcept {
|
|
39
|
+
/** Frontmatter map, key order preserved. Empty when no frontmatter block. */
|
|
40
|
+
frontmatter: Frontmatter;
|
|
41
|
+
/** Markdown body after the closing `---` (leading newline trimmed). */
|
|
42
|
+
body: string;
|
|
43
|
+
/** True when a `---`-delimited frontmatter block was found and parsed. */
|
|
44
|
+
hasFrontmatter: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Concept identity ────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** Concept ID = bundle-relative path with `.md` stripped and separators
|
|
50
|
+
* normalized to `/` (OKF rule: `commands/bun-test.md` → `commands/bun-test`). */
|
|
51
|
+
export function conceptId(bundleRelativePath: string): string {
|
|
52
|
+
const norm = bundleRelativePath.replace(/\\/g, "/").replace(/^\.?\/+/, "");
|
|
53
|
+
return norm.replace(/\.md$/i, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Basename of a bundle-relative path (handles both separators). */
|
|
57
|
+
function basename(p: string): string {
|
|
58
|
+
const norm = p.replace(/\\/g, "/");
|
|
59
|
+
const i = norm.lastIndexOf("/");
|
|
60
|
+
return i === -1 ? norm : norm.slice(i + 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when a path's filename is an OKF reserved file (`index.md`/`log.md`). */
|
|
64
|
+
export function isReservedFile(bundleRelativePath: string): boolean {
|
|
65
|
+
const base = basename(bundleRelativePath).toLowerCase();
|
|
66
|
+
return (RESERVED_FILES as readonly string[]).includes(base);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** kebab-case slug for a concept filename: lowercased, non-alphanumerics → `-`,
|
|
70
|
+
* collapsed and trimmed. Empty input yields `untitled`. */
|
|
71
|
+
export function slugify(title: string): string {
|
|
72
|
+
const slug = title
|
|
73
|
+
.normalize("NFKD")
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
76
|
+
.replace(/^-+|-+$/g, "")
|
|
77
|
+
.replace(/-{2,}/g, "-");
|
|
78
|
+
return slug || "untitled";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Frontmatter parse / serialize (round-trip preserving) ────────────────────
|
|
82
|
+
|
|
83
|
+
function unquote(raw: string): string {
|
|
84
|
+
const s = raw.trim();
|
|
85
|
+
if (s.length >= 2 && ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'"))) {
|
|
86
|
+
return s.slice(1, -1);
|
|
87
|
+
}
|
|
88
|
+
return s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parse a single scalar token into the narrowest faithful JS value. */
|
|
92
|
+
function parseScalar(raw: string): FrontmatterValue {
|
|
93
|
+
const s = raw.trim();
|
|
94
|
+
if (s.length === 0) return "";
|
|
95
|
+
// Quoted strings are ALWAYS strings (preserve e.g. okf_version "0.1").
|
|
96
|
+
if ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'")) {
|
|
97
|
+
return unquote(s);
|
|
98
|
+
}
|
|
99
|
+
if (s === "true") return true;
|
|
100
|
+
if (s === "false") return false;
|
|
101
|
+
// Integer or float (no leading zeros like 0.1 issue — that's a valid float).
|
|
102
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) {
|
|
103
|
+
const n = Number(s);
|
|
104
|
+
if (Number.isFinite(n)) return n;
|
|
105
|
+
}
|
|
106
|
+
return s;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse one frontmatter value, supporting inline `[a, b, c]` lists. */
|
|
110
|
+
function parseValue(raw: string): FrontmatterValue {
|
|
111
|
+
const s = raw.trim();
|
|
112
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
113
|
+
const inner = s.slice(1, -1).trim();
|
|
114
|
+
if (inner === "") return [];
|
|
115
|
+
return inner.split(",").map(item => unquote(item.trim()));
|
|
116
|
+
}
|
|
117
|
+
return parseScalar(s);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse an OKF concept document into `{ frontmatter, body, hasFrontmatter }`.
|
|
122
|
+
* Tolerant: a document without a `---` block yields an empty frontmatter and
|
|
123
|
+
* the whole text as body (conformance is judged separately by the validator).
|
|
124
|
+
*/
|
|
125
|
+
export function parseConcept(text: string): ParsedConcept {
|
|
126
|
+
const lines = text.split("\n");
|
|
127
|
+
// A frontmatter block must START at line 0 with a bare `---`.
|
|
128
|
+
if (lines[0]?.trim() !== "---") {
|
|
129
|
+
return { frontmatter: {}, body: text, hasFrontmatter: false };
|
|
130
|
+
}
|
|
131
|
+
let end = -1;
|
|
132
|
+
for (let i = 1; i < lines.length; i++) {
|
|
133
|
+
if (lines[i]?.trim() === "---") {
|
|
134
|
+
end = i;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (end === -1) {
|
|
139
|
+
// Opening `---` with no close: not a valid block — treat as bodyless content.
|
|
140
|
+
return { frontmatter: {}, body: text, hasFrontmatter: false };
|
|
141
|
+
}
|
|
142
|
+
const frontmatter: Frontmatter = {};
|
|
143
|
+
for (let i = 1; i < end; i++) {
|
|
144
|
+
const line = lines[i] ?? "";
|
|
145
|
+
const trimmed = line.trim();
|
|
146
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
147
|
+
const colon = line.indexOf(":");
|
|
148
|
+
if (colon === -1) continue; // skip malformed lines tolerantly
|
|
149
|
+
const key = line.slice(0, colon).trim();
|
|
150
|
+
if (!key) continue;
|
|
151
|
+
frontmatter[key] = parseValue(line.slice(colon + 1));
|
|
152
|
+
}
|
|
153
|
+
const body = lines.slice(end + 1).join("\n").replace(/^\n+/, "");
|
|
154
|
+
return { frontmatter, body, hasFrontmatter: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** True when a string scalar must be quoted to round-trip as a string
|
|
158
|
+
* (i.e. would otherwise parse back as a number/bool, or has fragile edges). */
|
|
159
|
+
function needsQuoting(s: string): boolean {
|
|
160
|
+
if (s === "") return true;
|
|
161
|
+
if (s === "true" || s === "false") return true;
|
|
162
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return true;
|
|
163
|
+
if (s !== s.trim()) return true; // leading/trailing whitespace
|
|
164
|
+
if (/[:#\[\]{}",']/.test(s)) return true;
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function serializeScalar(v: string | number | boolean): string {
|
|
169
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
170
|
+
return needsQuoting(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function serializeValue(v: FrontmatterValue): string {
|
|
174
|
+
if (Array.isArray(v)) {
|
|
175
|
+
return `[${v.map(item => (needsQuoting(item) ? `"${item.replace(/"/g, '\\"')}"` : item)).join(", ")}]`;
|
|
176
|
+
}
|
|
177
|
+
return serializeScalar(v);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Serialize frontmatter + body back into an OKF concept document.
|
|
182
|
+
* Key order is preserved; `serialize(parse(x))` is idempotent for documents
|
|
183
|
+
* this module produced. Body gets exactly one blank line after the block.
|
|
184
|
+
*/
|
|
185
|
+
export function serializeConcept(frontmatter: Frontmatter, body: string): string {
|
|
186
|
+
const fmLines = Object.entries(frontmatter).map(([k, v]) => `${k}: ${serializeValue(v)}`);
|
|
187
|
+
const block = ["---", ...fmLines, "---"].join("\n");
|
|
188
|
+
const trimmedBody = body.replace(/^\n+/, "");
|
|
189
|
+
return trimmedBody ? `${block}\n\n${trimmedBody}` : `${block}\n`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── OKF v0.1 conformance ─────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export interface BundleFile {
|
|
195
|
+
/** Bundle-relative path, e.g. `commands/bun-test.md`. */
|
|
196
|
+
path: string;
|
|
197
|
+
/** Raw file contents. */
|
|
198
|
+
content: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface ConformanceIssue {
|
|
202
|
+
path: string;
|
|
203
|
+
/** `error` fails conformance; `warning` is a lenient-guide hint only. */
|
|
204
|
+
level: "error" | "warning";
|
|
205
|
+
message: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ConformanceReport {
|
|
209
|
+
conformant: boolean;
|
|
210
|
+
issues: ConformanceIssue[];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const ISO_DATE_HEADING = /^#{1,6}\s+(\d{4}-\d{2}-\d{2})/;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Validate one file against OKF v0.1 + jeo conventions.
|
|
217
|
+
*
|
|
218
|
+
* Errors (reject conformance):
|
|
219
|
+
* - non-reserved `.md` missing a parseable frontmatter block.
|
|
220
|
+
* - frontmatter with missing or empty `type`.
|
|
221
|
+
* - `log.md` date heading that is not ISO 8601 `YYYY-MM-DD`.
|
|
222
|
+
*
|
|
223
|
+
* Warnings (lenient guides — never reject):
|
|
224
|
+
* - unknown `type` value.
|
|
225
|
+
* - missing recommended fields (`title`, `description`).
|
|
226
|
+
*/
|
|
227
|
+
export function validateFile(file: BundleFile): ConformanceIssue[] {
|
|
228
|
+
const issues: ConformanceIssue[] = [];
|
|
229
|
+
const base = basename(file.path).toLowerCase();
|
|
230
|
+
|
|
231
|
+
if (base === "log.md") {
|
|
232
|
+
// Reserved: if present, date headings must be ISO 8601.
|
|
233
|
+
for (const line of file.content.split("\n")) {
|
|
234
|
+
const m = line.match(/^#{1,6}\s+\S+/);
|
|
235
|
+
if (!m) continue;
|
|
236
|
+
// Only enforce on headings that look like dates; tolerate prose headings.
|
|
237
|
+
if (/^#{1,6}\s+\d/.test(line) && !ISO_DATE_HEADING.test(line)) {
|
|
238
|
+
issues.push({ path: file.path, level: "error", message: `log.md date heading not ISO 8601 (YYYY-MM-DD): "${line.trim()}"` });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return issues;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (base === "index.md") {
|
|
245
|
+
// Reserved: index.md needs no frontmatter; nothing to reject.
|
|
246
|
+
return issues;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Concept document.
|
|
250
|
+
const parsed = parseConcept(file.content);
|
|
251
|
+
if (!parsed.hasFrontmatter) {
|
|
252
|
+
issues.push({ path: file.path, level: "error", message: "concept document missing YAML frontmatter block" });
|
|
253
|
+
return issues;
|
|
254
|
+
}
|
|
255
|
+
const type = parsed.frontmatter.type;
|
|
256
|
+
if (typeof type !== "string" || type.trim() === "") {
|
|
257
|
+
issues.push({ path: file.path, level: "error", message: "frontmatter `type` is required and must be non-empty" });
|
|
258
|
+
} else if (!(JEO_TYPES as readonly string[]).includes(type)) {
|
|
259
|
+
issues.push({ path: file.path, level: "warning", message: `unknown type "${type}" (tolerated; not in jeo vocabulary)` });
|
|
260
|
+
}
|
|
261
|
+
if (!parsed.frontmatter.title) {
|
|
262
|
+
issues.push({ path: file.path, level: "warning", message: "missing recommended field `title`" });
|
|
263
|
+
}
|
|
264
|
+
if (!parsed.frontmatter.description) {
|
|
265
|
+
issues.push({ path: file.path, level: "warning", message: "missing recommended field `description`" });
|
|
266
|
+
}
|
|
267
|
+
return issues;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Validate a whole bundle. Conformant iff there are zero `error`-level issues
|
|
271
|
+
* (warnings never reject — OKF's tolerant consumption model). */
|
|
272
|
+
export function validateBundle(files: BundleFile[]): ConformanceReport {
|
|
273
|
+
const issues = files.flatMap(validateFile);
|
|
274
|
+
return { conformant: !issues.some(i => i.level === "error"), issues };
|
|
275
|
+
}
|
package/src/agent/tools.ts
CHANGED
|
@@ -587,6 +587,31 @@ export async function editTool(
|
|
|
587
587
|
}
|
|
588
588
|
}
|
|
589
589
|
|
|
590
|
+
/** Drain a readable byte stream to a string, cancel-safe. We read via an explicit
|
|
591
|
+
* reader (NOT `new Response(stream).text()`, whose read is uncancellable) so a caller
|
|
592
|
+
* can cancel() to force the in-flight read to settle — essential when a SIGKILLed
|
|
593
|
+
* process leaves a backgrounded grandchild holding the pipe's write end open, so it
|
|
594
|
+
* never hits EOF (proven by scripts/subproc-probe.ts ABANDON mode). An optional
|
|
595
|
+
* (caller-throttled) onChunk drives the live output view. */
|
|
596
|
+
async function drainPipe(
|
|
597
|
+
r: ReadableStreamDefaultReader<Uint8Array>,
|
|
598
|
+
onChunk?: (partial: string) => void,
|
|
599
|
+
): Promise<string> {
|
|
600
|
+
const dec = new TextDecoder();
|
|
601
|
+
let out = "";
|
|
602
|
+
try {
|
|
603
|
+
for (;;) {
|
|
604
|
+
const { done, value } = await r.read();
|
|
605
|
+
if (done) break;
|
|
606
|
+
out += dec.decode(value, { stream: true });
|
|
607
|
+
onChunk?.(out);
|
|
608
|
+
}
|
|
609
|
+
out += dec.decode();
|
|
610
|
+
onChunk?.(out);
|
|
611
|
+
} catch { /* cancelled reader surfaces here; return what we have */ }
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
|
|
590
615
|
export async function bashTool(
|
|
591
616
|
command: string,
|
|
592
617
|
cwd: string = process.cwd(),
|
|
@@ -612,6 +637,10 @@ export async function bashTool(
|
|
|
612
637
|
// Run the command using Bun's native spawn
|
|
613
638
|
const proc = Bun.spawn(["bash", "-c", command], {
|
|
614
639
|
cwd: runCwd,
|
|
640
|
+
// Detach stdin (no inherited TTY): a command that reads stdin (`cat`, `read`,
|
|
641
|
+
// a REPL, an interactive prompt) then gets immediate EOF and exits instead of
|
|
642
|
+
// blocking the whole turn forever waiting for input that never comes.
|
|
643
|
+
stdin: "ignore",
|
|
615
644
|
stdout: "pipe",
|
|
616
645
|
stderr: "pipe",
|
|
617
646
|
// Inherit the parent env; merge caller-supplied (sanitized) vars on top.
|
|
@@ -621,90 +650,93 @@ export async function bashTool(
|
|
|
621
650
|
let timedOut = false;
|
|
622
651
|
let aborted = false;
|
|
623
652
|
const TIMEOUT_MS = timeoutMs;
|
|
653
|
+
// Grace after the SHELL exits before we force the pipe readers closed: a finished
|
|
654
|
+
// command's buffered tail flushes far inside this window; only a backgrounded
|
|
655
|
+
// grandchild that inherited the pipe (`cmd &`, daemons, `nohup`) keeps it open
|
|
656
|
+
// past it — and that one must not stall the turn.
|
|
657
|
+
const PIPE_LINGER_MS = 500;
|
|
624
658
|
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
659
|
+
let stdoutReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
660
|
+
let stderrReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
661
|
+
// Force EOF on both pipe readers. The drain loops `await r.read()`, which NEVER
|
|
662
|
+
// returns `done` while ANY process holds the write end open — a SIGKILLed shell
|
|
663
|
+
// can leave a backgrounded grandchild clutching the FD, so killing the shell is
|
|
664
|
+
// not enough (proven by scripts/subproc-probe.ts ABANDON mode). cancel() resolves
|
|
665
|
+
// the in-flight read immediately, unwinding the drains so the call returns instead
|
|
666
|
+
// of hanging forever. Cheap to call repeatedly; harmless before the readers exist.
|
|
667
|
+
const cancelReaders = () => {
|
|
668
|
+
try { stdoutReader?.cancel(); } catch {}
|
|
669
|
+
try { stderrReader?.cancel(); } catch {}
|
|
670
|
+
};
|
|
625
671
|
const timer = setTimeout(() => {
|
|
626
672
|
timedOut = true;
|
|
627
|
-
// Graceful first (SIGTERM), then force-kill (SIGKILL) if it ignores it.
|
|
673
|
+
// Graceful first (SIGTERM), then force-kill (SIGKILL) if it ignores it. At the
|
|
674
|
+
// SIGKILL step ALSO cut the readers: the old code killed the shell but never
|
|
675
|
+
// cancelled the readers, so an orphan-held pipe outlived the deadline and the
|
|
676
|
+
// turn hung forever (the reported freeze).
|
|
628
677
|
try { proc.kill(); } catch {}
|
|
629
|
-
killTimer = setTimeout(() => { try { proc.kill(9); } catch {} }, 3_000);
|
|
678
|
+
killTimer = setTimeout(() => { try { proc.kill(9); } catch {} cancelReaders(); }, 3_000);
|
|
630
679
|
}, TIMEOUT_MS);
|
|
631
680
|
// Abort wiring: if the turn is cancelled, SIGKILL the child immediately AND cancel
|
|
632
|
-
// both pipe readers so the drain loops
|
|
633
|
-
// explicitly (rather than `for await` / `new Response`, whose hidden iterator locks
|
|
634
|
-
// we cannot cancel): cancel() resolves the in-flight read({ done:true }) immediately,
|
|
635
|
-
// unwinding each loop even when the killed child's pipe is slow to hit EOF. Cancelling
|
|
636
|
-
// stderr also prevents a hang — after kill(9) its pipe never sees EOF, so awaiting an
|
|
637
|
-
// uncancellable Response would block forever. Without all this the child is orphaned,
|
|
638
|
-
// holding two pipe FDs (proven by scripts/subproc-probe.ts ABANDON mode: +1 fd & +1
|
|
639
|
-
// child per call).
|
|
640
|
-
let stdoutReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
641
|
-
let stderrReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
681
|
+
// both pipe readers so the drain loops unwind at once.
|
|
642
682
|
const onAbort = () => {
|
|
643
683
|
aborted = true;
|
|
644
684
|
try { proc.kill(9); } catch {}
|
|
645
|
-
|
|
646
|
-
try { stderrReader?.cancel(); } catch {}
|
|
685
|
+
cancelReaders();
|
|
647
686
|
};
|
|
648
687
|
if (signal) {
|
|
649
688
|
if (signal.aborted) onAbort();
|
|
650
689
|
else signal.addEventListener("abort", onAbort, { once: true });
|
|
651
690
|
}
|
|
652
691
|
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
): Promise<string> => {
|
|
659
|
-
const dec = new TextDecoder();
|
|
660
|
-
let out = "";
|
|
661
|
-
try {
|
|
662
|
-
for (;;) {
|
|
663
|
-
if (aborted) break;
|
|
664
|
-
const { done, value } = await r.read();
|
|
665
|
-
if (done) break;
|
|
666
|
-
out += dec.decode(value, { stream: true });
|
|
667
|
-
onChunk?.(out);
|
|
668
|
-
}
|
|
669
|
-
out += dec.decode();
|
|
670
|
-
onChunk?.(out);
|
|
671
|
-
} catch { /* cancelled reader surfaces here; return what we have */ }
|
|
672
|
-
return out;
|
|
673
|
-
};
|
|
692
|
+
// Start BOTH drains concurrently so neither pipe can fill its 64KB buffer and
|
|
693
|
+
// deadlock the child (a child blocks on write() once an unread pipe fills up).
|
|
694
|
+
// cancelReaders() (abort/timeout) unwinds an in-flight read, so no aborted-flag
|
|
695
|
+
// poll is needed inside the shared drainPipe.
|
|
696
|
+
stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
674
697
|
stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
675
|
-
|
|
676
|
-
|
|
698
|
+
let lastEmit = 0;
|
|
699
|
+
const stdoutPromise = drainPipe(
|
|
700
|
+
stdoutReader,
|
|
701
|
+
onProgress
|
|
702
|
+
? (partial) => {
|
|
703
|
+
// Throttle the live sink to ~80ms.
|
|
704
|
+
const now = Date.now();
|
|
705
|
+
if (now - lastEmit >= 80) { lastEmit = now; onProgress(partial); }
|
|
706
|
+
}
|
|
707
|
+
: undefined,
|
|
708
|
+
).catch(() => "");
|
|
709
|
+
const stderrPromise = drainPipe(stderrReader).catch(() => "");
|
|
710
|
+
|
|
677
711
|
try {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
682
|
-
stdout = await drainAll(stdoutReader, (partial) => {
|
|
683
|
-
const now = Date.now();
|
|
684
|
-
if (now - lastEmit >= 80) { lastEmit = now; onProgress(partial); }
|
|
685
|
-
});
|
|
686
|
-
onProgress(stdout);
|
|
687
|
-
} else if (!aborted) {
|
|
688
|
-
stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
689
|
-
stdout = await drainAll(stdoutReader);
|
|
690
|
-
}
|
|
712
|
+
// Wait for the SHELL to exit. Bounded: the timeout path SIGTERMs then SIGKILLs,
|
|
713
|
+
// so even a foreground command that blocks (e.g. on /dev/tty) cannot exceed
|
|
714
|
+
// TIMEOUT_MS (+3s grace).
|
|
691
715
|
if (!aborted) await proc.exited;
|
|
692
|
-
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
716
|
+
// The shell exited, but a backgrounded grandchild can keep the pipes open
|
|
717
|
+
// indefinitely; without forcing them closed the drains hang the whole turn.
|
|
718
|
+
// Give a finished command's tail a brief grace to flush, then cut the readers
|
|
719
|
+
// in the finally below — so a daemon-spawning command returns ~PIPE_LINGER_MS
|
|
720
|
+
// after the shell exits instead of stalling until the timeout.
|
|
721
|
+
if (!aborted && !timedOut) {
|
|
722
|
+
await Promise.race([
|
|
723
|
+
Promise.all([stdoutPromise, stderrPromise]),
|
|
724
|
+
new Promise<void>(resolve => setTimeout(resolve, PIPE_LINGER_MS)),
|
|
725
|
+
]);
|
|
726
|
+
}
|
|
696
727
|
} finally {
|
|
697
728
|
clearTimeout(timer);
|
|
698
729
|
if (killTimer) clearTimeout(killTimer);
|
|
699
730
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
731
|
+
// Reap a still-alive child (normal exit, abort, or timeout) so no orphaned
|
|
732
|
+
// process survives, then force any pipe still held open (backgrounded
|
|
733
|
+
// grandchild) closed so the awaits below cannot hang.
|
|
703
734
|
if (proc.exitCode === null && proc.signalCode === null) { try { proc.kill(9); } catch {} }
|
|
704
|
-
|
|
705
|
-
await stderrPromise;
|
|
735
|
+
cancelReaders();
|
|
706
736
|
}
|
|
737
|
+
const stdout = await stdoutPromise;
|
|
707
738
|
const stderr = await stderrPromise;
|
|
739
|
+
if (onProgress) onProgress(stdout);
|
|
708
740
|
|
|
709
741
|
let output = [stdout, stderr].filter(Boolean).join("\n");
|
|
710
742
|
const MAX_OUTPUT = 100_000;
|
|
@@ -740,22 +772,41 @@ async function spawnTextWithTimeout(
|
|
|
740
772
|
cwd: string,
|
|
741
773
|
timeoutMs = 60_000,
|
|
742
774
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
|
|
743
|
-
|
|
775
|
+
// stdin detached so a command that reads stdin gets EOF instead of blocking.
|
|
776
|
+
const proc = Bun.spawn(cmd, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
744
777
|
let timedOut = false;
|
|
745
778
|
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
779
|
+
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
780
|
+
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
781
|
+
const cancelReaders = () => {
|
|
782
|
+
try { stdoutReader.cancel(); } catch {}
|
|
783
|
+
try { stderrReader.cancel(); } catch {}
|
|
784
|
+
};
|
|
785
|
+
const stdoutPromise = drainPipe(stdoutReader);
|
|
786
|
+
const stderrPromise = drainPipe(stderrReader);
|
|
746
787
|
const timer = setTimeout(() => {
|
|
747
788
|
timedOut = true;
|
|
748
789
|
try { proc.kill(); } catch {}
|
|
749
|
-
killTimer = setTimeout(() => { try { proc.kill(9); } catch {} }, 3_000);
|
|
790
|
+
killTimer = setTimeout(() => { try { proc.kill(9); } catch {} cancelReaders(); }, 3_000);
|
|
750
791
|
}, timeoutMs);
|
|
751
792
|
try {
|
|
752
793
|
await proc.exited;
|
|
794
|
+
// Brief grace for a finished command's tail to flush, then cut any pipe still held
|
|
795
|
+
// open by a backgrounded grandchild so the awaits below cannot hang the turn.
|
|
796
|
+
if (!timedOut) {
|
|
797
|
+
await Promise.race([
|
|
798
|
+
Promise.all([stdoutPromise, stderrPromise]),
|
|
799
|
+
new Promise<void>(resolve => setTimeout(resolve, 500)),
|
|
800
|
+
]);
|
|
801
|
+
}
|
|
753
802
|
} finally {
|
|
754
803
|
clearTimeout(timer);
|
|
755
804
|
if (killTimer) clearTimeout(killTimer);
|
|
805
|
+
if (proc.exitCode === null && proc.signalCode === null) { try { proc.kill(9); } catch {} }
|
|
806
|
+
cancelReaders();
|
|
756
807
|
}
|
|
757
|
-
const stdout = await
|
|
758
|
-
const stderr = await
|
|
808
|
+
const stdout = await stdoutPromise;
|
|
809
|
+
const stderr = await stderrPromise;
|
|
759
810
|
return { stdout, stderr, exitCode: proc.exitCode, timedOut };
|
|
760
811
|
}
|
|
761
812
|
|
package/src/tui/app.ts
CHANGED
|
@@ -1178,6 +1178,30 @@ export class LaunchTui {
|
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
1180
|
|
|
1181
|
+
/** Build a live, DIMMED streaming block (the `Thinking` reasoning trace or a tool's
|
|
1182
|
+
* `Output` tail). Sized to its ACTUAL content and the terminal height — no fixed
|
|
1183
|
+
* blank-row padding. The old fixed-height region (`ROWS` constant, blank-padded at
|
|
1184
|
+
* the top) reserved a constant rectangle: a short stream left dead rows that read as
|
|
1185
|
+
* a torn "hole", and on a short terminal it stole rows the heartbeat needed. Now the
|
|
1186
|
+
* block shows only the most-recent lines, capped at ~30% of the screen height (a
|
|
1187
|
+
* ceiling guards a tall terminal), so it grows with the stream and shrinks with the
|
|
1188
|
+
* viewport. Returns [] when there is nothing to show. */
|
|
1189
|
+
private renderLiveBlock(label: string, text: string, cols: number, rows: number, ceiling: number): string[] {
|
|
1190
|
+
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1191
|
+
if (!text.trim()) return [];
|
|
1192
|
+
const wrapW = Math.max(8, cols - 2);
|
|
1193
|
+
const wrapped = tailForWrap(text)
|
|
1194
|
+
.split("\n")
|
|
1195
|
+
.flatMap(l => wrapTextWithAnsi(l, wrapW))
|
|
1196
|
+
.filter(l => l.length > 0);
|
|
1197
|
+
if (wrapped.length === 0) return [];
|
|
1198
|
+
const cap = Math.max(3, Math.min(ceiling, Math.floor(rows * 0.3)));
|
|
1199
|
+
const out: string[] = [sectionLabel(label, Math.max(8, cols), { color: this.theme.color, unicode: this.unicode })];
|
|
1200
|
+
for (const l of wrapped.slice(-cap)) out.push(dim(` ${l}`));
|
|
1201
|
+
out.push("");
|
|
1202
|
+
return out;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1181
1205
|
/** Render the Ctrl+O panel inside the live frame. `maxRows` includes borders. */
|
|
1182
1206
|
private renderHistoryPanel(width: number, maxRows: number): string[] {
|
|
1183
1207
|
if (!this.historyLines || maxRows < 4) return [];
|
|
@@ -1294,42 +1318,18 @@ export class LaunchTui {
|
|
|
1294
1318
|
// as a DIMMED, bounded block above the status line. It is transient — flushed
|
|
1295
1319
|
// UN-dimmed into scrollback once the model commits to a tool/reply (onAssistant),
|
|
1296
1320
|
// so the in-progress trace stays shaded while the final record reads in normal text.
|
|
1321
|
+
// Height is sized to the content and the viewport (renderLiveBlock), not a fixed
|
|
1322
|
+
// rectangle, so a short trace leaves no padded "hole" and a short terminal is spared.
|
|
1297
1323
|
const liveThink = this.streamingThought.trim() || this.streamingReasoning.trim();
|
|
1298
1324
|
if (isThinking && liveThink) {
|
|
1299
|
-
|
|
1300
|
-
const wrapped = tailForWrap(liveThink)
|
|
1301
|
-
.split("\n")
|
|
1302
|
-
.flatMap(l => wrapTextWithAnsi(l, wrapW))
|
|
1303
|
-
.filter(l => l.length > 0);
|
|
1304
|
-
// FIXED reserved height (bottom-anchored, blank-padded at top): once present the
|
|
1305
|
-
// block's row count is CONSTANT, so streaming content never changes the frame
|
|
1306
|
-
// height. The per-100ms height thrash that desynced the differential renderer
|
|
1307
|
-
// (duplicate model bar) is gone; height now toggles only at lifecycle boundaries.
|
|
1308
|
-
const ROWS = 6;
|
|
1309
|
-
const shown = wrapped.slice(-ROWS);
|
|
1310
|
-
tail.push(sectionLabel("Thinking", Math.max(8, cols), { color: this.theme.color, unicode: this.unicode }));
|
|
1311
|
-
for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
|
|
1312
|
-
for (const l of shown) tail.push(dim(` ${l}`));
|
|
1313
|
-
tail.push("");
|
|
1325
|
+
tail.push(...this.renderLiveBlock("Thinking", liveThink, cols, rows, 6));
|
|
1314
1326
|
}
|
|
1315
1327
|
|
|
1316
1328
|
// Live tool output (gjc-style streaming bash stdout): while a tool runs, its
|
|
1317
1329
|
// output arrives via onToolProgress and is shown as a DIMMED, bounded tail block.
|
|
1318
1330
|
// It is transient — cleared on result, when the formatted forge card takes over.
|
|
1319
1331
|
if (this.runningTool && this.liveToolOutput.trim()) {
|
|
1320
|
-
|
|
1321
|
-
const wrapped = tailForWrap(this.liveToolOutput)
|
|
1322
|
-
.split("\n")
|
|
1323
|
-
.flatMap(l => wrapTextWithAnsi(l, wrapW))
|
|
1324
|
-
.filter(l => l.length > 0);
|
|
1325
|
-
// FIXED reserved height (see thinking block): constant rows while a tool streams,
|
|
1326
|
-
// so cumulative stdout growth does not thrash the frame height.
|
|
1327
|
-
const ROWS = 8;
|
|
1328
|
-
const shown = wrapped.slice(-ROWS);
|
|
1329
|
-
tail.push(sectionLabel("Output", Math.max(8, cols), { color: this.theme.color, unicode: this.unicode }));
|
|
1330
|
-
for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
|
|
1331
|
-
for (const l of shown) tail.push(dim(` ${l}`));
|
|
1332
|
-
tail.push("");
|
|
1332
|
+
tail.push(...this.renderLiveBlock("Output", this.liveToolOutput, cols, rows, 8));
|
|
1333
1333
|
}
|
|
1334
1334
|
|
|
1335
1335
|
// Live status field: unboxed thinking line + compact metrics row. The model's
|