jeo-code 0.6.9 → 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 +9 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/memory-okf.ts +275 -0
- package/src/agent/tools.ts +114 -63
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ 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
|
+
|
|
9
18
|
## [0.6.9] - 2026-06-16
|
|
10
19
|
_Live streaming blocks size to their content and the viewport instead of a fixed rectangle._
|
|
11
20
|
|
package/README.ja.md
CHANGED
|
@@ -158,11 +158,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
158
158
|
## 変更履歴 (Changelog)
|
|
159
159
|
|
|
160
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.
|
|
161
162
|
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
162
163
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
163
164
|
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
164
165
|
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
165
|
-
- **[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.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
168
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -158,11 +158,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
158
158
|
## 변경 이력 (Changelog)
|
|
159
159
|
|
|
160
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.
|
|
161
162
|
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
162
163
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
163
164
|
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
164
165
|
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
165
|
-
- **[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.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
168
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -158,11 +158,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
158
158
|
## Changelog
|
|
159
159
|
|
|
160
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.
|
|
161
162
|
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
162
163
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
163
164
|
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
164
165
|
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
165
|
-
- **[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.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
168
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -158,11 +158,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
158
158
|
## 更新日志 (Changelog)
|
|
159
159
|
|
|
160
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.
|
|
161
162
|
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
162
163
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
163
164
|
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
164
165
|
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
165
|
-
- **[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.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
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
|
|