jeo-code 0.7.5 → 0.7.7
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 +34 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +3 -1
- package/src/agent/AGENTS.md +1 -0
- package/src/agent/engine.ts +5 -2
- package/src/agent/process-reaper.ts +200 -0
- package/src/agent/tools.ts +40 -8
- package/src/ai/think-tags.ts +30 -0
- package/src/cli.ts +4 -1
- package/src/commands/launch/input.ts +143 -6
- package/src/commands/launch/mentions.ts +95 -6
- package/src/commands/launch.ts +33 -6
- package/src/commands/ralplan.ts +19 -6
- package/src/tui/clipboard.ts +33 -7
- package/src/tui/components/autocomplete.ts +4 -1
- package/src/tui/components/markdown-table.ts +36 -14
- package/src/util/whats-new.ts +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ 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.7.7] - 2026-06-23
|
|
10
|
+
_Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Process-group reaper for backgrounded subprocesses (`src/agent/process-reaper.ts`).** `bashTool` now spawns each command as its own process-group leader (`Bun.spawn` `detached:true` on POSIX), so a backgrounded grandchild — `npm run dev &`, `next dev &`, a daemon, `nohup …` — joins that group and the whole subtree is reaped with a single `process.kill(-pgid)` at the turn boundary, **without ever touching jeo's own group**. This closes the leak where a reparented `next-server` survived every turn and the resident set climbed monotonically across a session. Reaping is precise (only groups jeo spawned, never a name-based `pkill`), O(1) (one group signal, no process-table scan), and deterministic (closed at the exact turn that creates it). A `unref`'d periodic sweep is a belt-and-suspenders net for turns that abort before cleanup; opt out with `JEO_KEEP_BACKGROUND=1`, tune the sweep with `JEO_REAP_INTERVAL_MS`.
|
|
14
|
+
- **Recursive fuzzy `@path` mention search (`src/commands/launch/mentions.ts`).** A bare `@frag` now walks the project tree (bounded by depth/scan/result caps, skipping `node_modules`/`.git`/build/VCS dirs) and ranks hits basename-prefix → substring → subsequence, then shallowest/shortest/alphabetical; a `@dir/` prefix lists that single directory. Autocomplete returns the matcher's pool as-is instead of re-filtering by strict prefix.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- **ralplan draft passes route to their per-role configured model.** `runRalplanEngine` now resolves `planner`/`architect`/`critic` subagent models from config and threads each through `callRole`, so the Planner/Architect/Critic drafting + revision calls use their own model; the consensus critic gate continues to resolve its model independently.
|
|
18
|
+
- **OSC 52 clipboard copy exposes a configurable size cap and honest skip signal.** `copyTextToClipboard` reads `JEO_OSC52_MAX` for the base64 payload cap and returns `osc52SkippedTooLarge` when the remote (SSH/tmux) clipboard was skipped for an oversized payload, so the caller can warn instead of silently dropping the remote path; tmux DCS passthrough wrapping is documented.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Salvaged answers and `done` reasons strip leaked reasoning/tool-call markup.** `stripLeakedReasoningTags` (`src/ai/think-tags.ts`) removes `<think>…</think>` blocks, an unmatched `</think>` reasoning prefix, stray `<tool_call>`/`<parameter>`/`antml:*` scaffolding, and Harmony `<|channel|>` markers from a model's final visible text, so API-entered models that emit inline scaffolding no longer surface raw tags in the salvaged prose answer or the `done` reason.
|
|
22
|
+
- **Bracketed paste is ANSI-safe and idle-merged.** Pasted ANSI-colored terminal output is stripped of escape sequences and stray C0 control bytes across every paste path (`stripPasteEscapes`/`pasteEscapeLength`) so it no longer corrupts the input box; a partial paste marker split across stdin chunks is carried (`pasteMarkerTailLength`/`endsInPaste`); and the dropped-end-marker fallback is now **idle-based** (`pasteIdleDecision`, 250ms since the last buffered line) so a large paste streaming in over >250ms is never cut mid-paste.
|
|
23
|
+
|
|
24
|
+
### Verified
|
|
25
|
+
- `bun run typecheck` clean; touched suites pass — `test/process-reaper.test.ts`, `test/ralplan-draft-model.test.ts`, `test/prompt-key-filter.test.ts`, `test/launch-mentions.test.ts`, `test/clipboard.test.ts`, `test/think-tags.test.ts`, `test/engine-salvage.test.ts` (90 pass / 0 fail) covering group-kill target selection, alive/grace-window reaping, recursive fuzzy mention ranking, escaped-pipe-free paste sanitizing + partial-marker carry + idle merge, the OSC 52 cap/skip signal, per-role ralplan routing, and leaked-tag stripping on salvaged output.
|
|
26
|
+
|
|
27
|
+
## [0.7.6] - 2026-06-23
|
|
28
|
+
_Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release._
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **Cross-target standalone-binary release matrix (incl. Windows `.exe`).** New `scripts/ci-release-build-binaries.ts` compiles `src/cli.ts` for every release target via Bun's `--compile --target bun-<triple>`; since jeo has no native addons/workers, all targets (`darwin-arm64`/`-x64`, `linux-x64`/`-arm64`, `win32-x64`) cross-compile from a single Linux runner. Driven by `bun run build:binaries` / `build:win` (new `package.json` scripts) with `--targets <ids|all>`, `RELEASE_TARGETS` env, and `--dry-run` support. A new `release-binaries` job in `.github/workflows/npm-publish.yml` builds the full matrix, uploads them as a workflow artifact, and on a `release` event runs `gh release upload <tag> dist/jeo-* --clobber` to attach them.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- **The running version's CHANGELOG is embedded into the compiled binary.** `loadBundledChangelog` (in `src/util/whats-new.ts`) now imports `CHANGELOG.md` with `{ type: "text" }`, so the release notes ship inside the `bun build --compile` single binary — where `import.meta.dir` resolves to the bunfs virtual root and the old two-levels-up on-disk path does not exist. The on-disk read remains as a dev fallback only when the embed is empty.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- **The in-TUI markdown table renderer no longer breaks a cell on an escaped `\|`.** `markdown-table.ts` now distinguishes a real cell separator from a literal pipe: `hasUnescapedPipe` drives row detection, `splitCells` walks the row character-by-character (turning `\|` into a literal `|` inside the cell instead of a column break and never stripping an escaped trailing pipe), and `isDelimiterRow` reuses that same escape-aware split. A table whose cells contain `\|` now renders with the correct column count.
|
|
38
|
+
|
|
39
|
+
### Verified
|
|
40
|
+
- `bun run typecheck` clean; the touched suites pass — `test/markdown-table.test.ts` and `test/release-binaries.test.ts` (14 pass / 0 fail) cover escaped-pipe cell splitting/delimiter detection, the Windows `.exe` target shape, `--targets`/env/`all` resolution, the per-target compile invocation, and the workflow's build+upload of every target.
|
|
41
|
+
|
|
42
|
+
|
|
9
43
|
## [0.7.5] - 2026-06-23
|
|
10
44
|
_Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs._
|
|
11
45
|
|
package/README.ja.md
CHANGED
|
@@ -193,11 +193,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
193
193
|
## 変更履歴 (Changelog)
|
|
194
194
|
|
|
195
195
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
196
|
+
- **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
|
|
197
|
+
- **[0.7.6]** (2026-06-23) — Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release.
|
|
196
198
|
- **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
|
|
197
199
|
- **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
|
|
198
200
|
- **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
|
|
199
|
-
- **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
200
|
-
- **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
201
201
|
|
|
202
202
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
203
203
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -193,11 +193,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
193
193
|
## 변경 이력 (Changelog)
|
|
194
194
|
|
|
195
195
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
196
|
+
- **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
|
|
197
|
+
- **[0.7.6]** (2026-06-23) — Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release.
|
|
196
198
|
- **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
|
|
197
199
|
- **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
|
|
198
200
|
- **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
|
|
199
|
-
- **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
200
|
-
- **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
201
201
|
|
|
202
202
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
203
203
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -195,11 +195,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
195
195
|
## Changelog
|
|
196
196
|
|
|
197
197
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
198
|
+
- **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
|
|
199
|
+
- **[0.7.6]** (2026-06-23) — Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release.
|
|
198
200
|
- **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
|
|
199
201
|
- **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
|
|
200
202
|
- **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
|
|
201
|
-
- **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
202
|
-
- **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
203
203
|
|
|
204
204
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
205
205
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -193,11 +193,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
193
193
|
## 更新日志 (Changelog)
|
|
194
194
|
|
|
195
195
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
196
|
+
- **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
|
|
197
|
+
- **[0.7.6]** (2026-06-23) — Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release.
|
|
196
198
|
- **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
|
|
197
199
|
- **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
|
|
198
200
|
- **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
|
|
199
|
-
- **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
200
|
-
- **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
201
201
|
|
|
202
202
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
203
203
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jeo-code",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"description": "Clean, highly optimized AI coding agent using spec-first loop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.ts",
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"start": "bun src/cli.ts",
|
|
47
47
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
48
48
|
"build": "bun build src/cli.ts --compile --outfile dist/jeo",
|
|
49
|
+
"build:binaries": "bun scripts/ci-release-build-binaries.ts",
|
|
50
|
+
"build:win": "bun scripts/ci-release-build-binaries.ts --targets win32-x64",
|
|
49
51
|
"pack:check": "npm pack --dry-run",
|
|
50
52
|
"publish:npm": "npm publish --access public --registry https://registry.npmjs.org/",
|
|
51
53
|
"changelog:sync": "bun scripts/sync-changelog.ts",
|
package/src/agent/AGENTS.md
CHANGED
|
@@ -25,6 +25,7 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
25
25
|
| `output-minimizer.ts` | Brief description of purpose |
|
|
26
26
|
| `output-util.ts` | Brief description of purpose |
|
|
27
27
|
| `plan.ts` | Subagent planning structures and validation |
|
|
28
|
+
| `process-reaper.ts` | Background-process reaper: spawns bash in its own process group (POSIX detached) so backgrounded grandchildren (`next dev &`, daemons) are reaped by group at turn end instead of orphaning and accumulating (`JEO_KEEP_BACKGROUND=1` opts out, `JEO_REAP_INTERVAL_MS` tunes the periodic safety-net sweep) |
|
|
28
29
|
| `seed.ts` | Brief description of purpose |
|
|
29
30
|
| `session.ts` | Session context building, compaction, and history management |
|
|
30
31
|
| `state.ts` | File-backed state and session persistence (`.jeo/state/`) |
|
package/src/agent/engine.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSigna
|
|
|
24
24
|
import { historyTokens, trimToolResultsInPlace } from "./compaction";
|
|
25
25
|
import { jeoEnv } from "../util/env";
|
|
26
26
|
import { GUARD_LIMITS, isVerificationSignal, repeatHint, classifyDoneGate } from "./loop-guards";
|
|
27
|
+
import { stripLeakedReasoningTags } from "../ai/think-tags";
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
async function invokeCallLlm(history: Message[], options: {
|
|
@@ -644,7 +645,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
644
645
|
parseFailures++;
|
|
645
646
|
if (trimmed && (!trimmed.includes("{") || parseFailures > MAX_PARSE_BOUNCES)) {
|
|
646
647
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
|
|
647
|
-
|
|
648
|
+
// Strip leaked <think>/<parameter>/Harmony scaffolding some API-entered
|
|
649
|
+
// models emit inline so the salvaged answer doesn't surface raw tags.
|
|
650
|
+
return finish({ done: true, steps: step, doneReason: stripLeakedReasoningTags(trimmed) || trimmed });
|
|
648
651
|
}
|
|
649
652
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
|
|
650
653
|
history.push({
|
|
@@ -760,7 +763,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
760
763
|
continue;
|
|
761
764
|
}
|
|
762
765
|
}
|
|
763
|
-
return finish({ done: true, steps: step, doneReason: (toolCalls[0].arguments?.reason as string) ?? "" });
|
|
766
|
+
return finish({ done: true, steps: step, doneReason: stripLeakedReasoningTags((toolCalls[0].arguments?.reason as string) ?? "") });
|
|
764
767
|
}
|
|
765
768
|
|
|
766
769
|
// Anti-spin guard, checked BEFORE execution: a repeated identical step must
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-process reaper for the bash tool (src/agent/tools.ts:bashTool).
|
|
3
|
+
*
|
|
4
|
+
* THE LEAK IT CLOSES
|
|
5
|
+
* ------------------
|
|
6
|
+
* When a model command backgrounds a long-lived grandchild — `npm run dev &`,
|
|
7
|
+
* `next dev &`, a daemon, `nohup …` — the bash SHELL exits immediately but the
|
|
8
|
+
* grandchild is reparented to init (PID 1) and keeps running. jeo's bash tool
|
|
9
|
+
* kills the shell, not the subtree, so on Linux that grandchild survives every
|
|
10
|
+
* turn. Each `next dev` restart that doesn't kill its predecessor leaves another
|
|
11
|
+
* live `next-server` holding hundreds of MB; across a session the resident set
|
|
12
|
+
* climbs monotonically — the reported "next-server 메모리 점유율이 점점 높아진다".
|
|
13
|
+
*
|
|
14
|
+
* THE FIX (more efficient than name-based periodic `pkill`)
|
|
15
|
+
* --------------------------------------------------------
|
|
16
|
+
* bashTool now spawns each command as its OWN process-group leader (Bun.spawn
|
|
17
|
+
* `detached:true` ⇒ pgid == shell pid on POSIX). A backgrounded grandchild joins
|
|
18
|
+
* that group, so the whole subtree is reachable by a single `process.kill(-pgid)`
|
|
19
|
+
* WITHOUT touching jeo (which lives in a different group). Reaping is therefore:
|
|
20
|
+
* - PRECISE — only groups jeo itself spawned, never a `pkill -f next-server`
|
|
21
|
+
* that could nuke a dev server the user runs in another terminal;
|
|
22
|
+
* - O(1) — one group signal, no full process-table scan per sweep;
|
|
23
|
+
* - DETERMINISTIC — the leak is closed at the exact turn boundary that creates
|
|
24
|
+
* it, so steady-state cost is zero (the periodic sweep below is a
|
|
25
|
+
* belt-and-suspenders net for turns that abort before cleanup).
|
|
26
|
+
*
|
|
27
|
+
* This module is the pure, injectable core: `killSpec` decides the signal target
|
|
28
|
+
* and `BackgroundReaper` tracks groups + reaps the orphaned ones. All OS effects
|
|
29
|
+
* (`kill`, the clock) are injected so the selection logic is unit-testable.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { jeoEnv } from "../util/env";
|
|
33
|
+
|
|
34
|
+
export type KillFn = (pid: number, signal?: string | number) => void;
|
|
35
|
+
|
|
36
|
+
/** A spawned shell tracked for reaping. `grouped` is true when it leads its own
|
|
37
|
+
* process group (POSIX detached spawn) and can be signalled as `-pid`. */
|
|
38
|
+
export interface ReapTarget {
|
|
39
|
+
/** Shell pid; equals the process-group id when `grouped` (detached) is true. */
|
|
40
|
+
pid: number;
|
|
41
|
+
/** Spawned as a process-group leader (POSIX detached) — group-killable. */
|
|
42
|
+
grouped: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The argument to pass to `process.kill`: the NEGATED pgid to signal the whole
|
|
47
|
+
* group (reaping backgrounded grandchildren) when the shell leads its own group,
|
|
48
|
+
* or the bare pid otherwise. A group-kill is only safe when `grouped` — signalling
|
|
49
|
+
* `-pid` of a non-leader would hit jeo's own group and self-terminate the agent;
|
|
50
|
+
* Windows has no POSIX process groups, so it always falls back to the bare pid.
|
|
51
|
+
*/
|
|
52
|
+
export function killSpec(target: ReapTarget, platform: NodeJS.Platform = process.platform): number {
|
|
53
|
+
if (target.grouped && platform !== "win32") return -target.pid;
|
|
54
|
+
return target.pid;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface GroupRecord extends ReapTarget {
|
|
58
|
+
/** Short human label (the command head) for diagnostics. */
|
|
59
|
+
label: string;
|
|
60
|
+
registeredAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ReaperDeps {
|
|
64
|
+
/** Inject for tests; defaults to process.kill. Signal `0` is a liveness probe. */
|
|
65
|
+
kill?: KillFn;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
platform?: NodeJS.Platform;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tracks process groups jeo's bash tool spawned and reaps the ones still alive.
|
|
72
|
+
* Registration happens when a command leaves a lingering background grandchild;
|
|
73
|
+
* `reap()` SIGKILLs each registered group that is still alive and forgets it.
|
|
74
|
+
*/
|
|
75
|
+
export class BackgroundReaper {
|
|
76
|
+
private groups = new Map<number, GroupRecord>();
|
|
77
|
+
private readonly kill: KillFn;
|
|
78
|
+
private readonly now: () => number;
|
|
79
|
+
private readonly platform: NodeJS.Platform;
|
|
80
|
+
|
|
81
|
+
constructor(deps: ReaperDeps = {}) {
|
|
82
|
+
this.kill = deps.kill ?? ((pid, signal) => process.kill(pid, signal as NodeJS.Signals));
|
|
83
|
+
this.now = deps.now ?? Date.now;
|
|
84
|
+
this.platform = deps.platform ?? process.platform;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get size(): number {
|
|
88
|
+
return this.groups.size;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Track a spawned shell's group as a reap candidate (idempotent per pid). */
|
|
92
|
+
register(target: ReapTarget, label = ""): void {
|
|
93
|
+
if (!Number.isInteger(target.pid) || target.pid <= 1) return; // never 0/-1/1 (init)
|
|
94
|
+
this.groups.set(target.pid, { ...target, label: label.slice(0, 80), registeredAt: this.now() });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Forget a group (e.g. the command cleaned itself up). */
|
|
98
|
+
unregister(pid: number): void {
|
|
99
|
+
this.groups.delete(pid);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True if any process in the target's group is still alive (signal-0 probe). */
|
|
103
|
+
private isAlive(rec: ReapTarget): boolean {
|
|
104
|
+
try {
|
|
105
|
+
this.kill(killSpec(rec, this.platform), 0);
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false; // ESRCH ⇒ nothing left in the group
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Best-effort SIGKILL of one tracked group; returns true if a signal landed. */
|
|
113
|
+
reapOne(pid: number): boolean {
|
|
114
|
+
const rec = this.groups.get(pid);
|
|
115
|
+
if (!rec) return false;
|
|
116
|
+
this.groups.delete(pid);
|
|
117
|
+
if (!this.isAlive(rec)) return false;
|
|
118
|
+
try {
|
|
119
|
+
this.kill(killSpec(rec, this.platform), "SIGKILL");
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reap tracked groups. Default: only groups still alive (the leaked orphans).
|
|
128
|
+
* `olderThanMs` limits reaping to groups registered before now-olderThanMs, so a
|
|
129
|
+
* brand-new turn's background child gets a grace window before the periodic sweep
|
|
130
|
+
* claims it. Returns the pids that received a kill.
|
|
131
|
+
*/
|
|
132
|
+
reap(opts: { olderThanMs?: number } = {}): number[] {
|
|
133
|
+
const reaped: number[] = [];
|
|
134
|
+
const cutoff = opts.olderThanMs != null ? this.now() - opts.olderThanMs : Infinity;
|
|
135
|
+
for (const [pid, rec] of [...this.groups]) {
|
|
136
|
+
if (rec.registeredAt > cutoff) continue;
|
|
137
|
+
this.groups.delete(pid);
|
|
138
|
+
if (!this.isAlive(rec)) continue;
|
|
139
|
+
try {
|
|
140
|
+
this.kill(killSpec(rec, this.platform), "SIGKILL");
|
|
141
|
+
reaped.push(pid);
|
|
142
|
+
} catch {
|
|
143
|
+
/* group vanished between probe and kill — fine */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return reaped;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Drop all tracking without signalling (used when reaping is disabled). */
|
|
150
|
+
clear(): void {
|
|
151
|
+
this.groups.clear();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Process-group spawning + background reaping are on unless opted out. */
|
|
156
|
+
export function isBackgroundReapEnabled(env: Record<string, string | undefined> = process.env): boolean {
|
|
157
|
+
return jeoEnv("KEEP_BACKGROUND", env) !== "1";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Periodic-sweep cadence; 0/invalid disables the timer (deterministic reap still runs). */
|
|
161
|
+
export function reapIntervalMs(env: Record<string, string | undefined> = process.env): number {
|
|
162
|
+
const raw = jeoEnv("REAP_INTERVAL_MS", env);
|
|
163
|
+
if (raw == null) return 30_000;
|
|
164
|
+
const n = Number(raw);
|
|
165
|
+
return Number.isFinite(n) && n >= 0 ? n : 30_000;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Shared session-scoped reaper used by the bash tool. */
|
|
169
|
+
export const backgroundReaper = new BackgroundReaper();
|
|
170
|
+
|
|
171
|
+
let sweeper: ReturnType<typeof setInterval> | undefined;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Lazily start the periodic safety-net sweep (idempotent). It reaps any tracked
|
|
175
|
+
* group that outlived a grace window — covering turns that abort before their
|
|
176
|
+
* deterministic finally-block reap runs. The timer is `unref`'d so it never keeps
|
|
177
|
+
* the process alive, and a 0 interval (or KEEP_BACKGROUND=1) skips it entirely.
|
|
178
|
+
*/
|
|
179
|
+
export function ensureBackgroundSweeper(
|
|
180
|
+
reaper: BackgroundReaper = backgroundReaper,
|
|
181
|
+
env: Record<string, string | undefined> = process.env,
|
|
182
|
+
): void {
|
|
183
|
+
if (sweeper || !isBackgroundReapEnabled(env)) return;
|
|
184
|
+
const interval = reapIntervalMs(env);
|
|
185
|
+
if (interval <= 0) return;
|
|
186
|
+
sweeper = setInterval(() => {
|
|
187
|
+
// Grace = one interval: a background child younger than this is left for its
|
|
188
|
+
// own turn's finally-block to reap, so the sweep only claims true orphans.
|
|
189
|
+
reaper.reap({ olderThanMs: interval });
|
|
190
|
+
}, interval);
|
|
191
|
+
(sweeper as { unref?: () => void }).unref?.();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Stop the periodic sweep (tests / shutdown). */
|
|
195
|
+
export function stopBackgroundSweeper(): void {
|
|
196
|
+
if (sweeper) {
|
|
197
|
+
clearInterval(sweeper);
|
|
198
|
+
sweeper = undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/agent/tools.ts
CHANGED
|
@@ -4,6 +4,13 @@ import * as path from "node:path";
|
|
|
4
4
|
import { readWorkflowStateStrict, type WorkflowState } from "./state";
|
|
5
5
|
import { jeoEnv } from "../util/env";
|
|
6
6
|
import { READ_OUTPUT_MAX } from "./tool-output";
|
|
7
|
+
import {
|
|
8
|
+
backgroundReaper,
|
|
9
|
+
ensureBackgroundSweeper,
|
|
10
|
+
isBackgroundReapEnabled,
|
|
11
|
+
killSpec,
|
|
12
|
+
type ReapTarget,
|
|
13
|
+
} from "./process-reaper";
|
|
7
14
|
|
|
8
15
|
/** Read the deep-interview lock; on corrupt state fail CLOSED (treat as active lock). */
|
|
9
16
|
async function readMutationLock(cwd: string): Promise<WorkflowState | null> {
|
|
@@ -634,7 +641,13 @@ export async function bashTool(
|
|
|
634
641
|
const safeEnv = env && !Array.isArray(env)
|
|
635
642
|
? Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v === "string")) as Record<string, string>
|
|
636
643
|
: undefined;
|
|
637
|
-
// Run the command using Bun's native spawn
|
|
644
|
+
// Run the command using Bun's native spawn. When background reaping is on
|
|
645
|
+
// (default, POSIX), spawn the shell as its OWN process-group leader so a
|
|
646
|
+
// backgrounded grandchild (`next dev &`, a daemon) joins that group and the
|
|
647
|
+
// whole subtree is reapable via `process.kill(-pid)` without hitting jeo.
|
|
648
|
+
const reapBackground = isBackgroundReapEnabled();
|
|
649
|
+
const grouped = reapBackground && process.platform !== "win32";
|
|
650
|
+
if (grouped) ensureBackgroundSweeper();
|
|
638
651
|
const proc = Bun.spawn(["bash", "-c", command], {
|
|
639
652
|
cwd: runCwd,
|
|
640
653
|
// Detach stdin (no inherited TTY): a command that reads stdin (`cat`, `read`,
|
|
@@ -643,9 +656,21 @@ export async function bashTool(
|
|
|
643
656
|
stdin: "ignore",
|
|
644
657
|
stdout: "pipe",
|
|
645
658
|
stderr: "pipe",
|
|
659
|
+
// Own process group ⇒ group-killable subtree (reaps backgrounded grandchildren).
|
|
660
|
+
...(grouped ? { detached: true } : {}),
|
|
646
661
|
// Inherit the parent env; merge caller-supplied (sanitized) vars on top.
|
|
647
662
|
...(safeEnv && Object.keys(safeEnv).length ? { env: { ...process.env, ...safeEnv } } : {}),
|
|
648
663
|
});
|
|
664
|
+
const reapTarget: ReapTarget = { pid: proc.pid, grouped };
|
|
665
|
+
// Signal the whole process group when grouped (so a `cmd &` grandchild dies
|
|
666
|
+
// too), else fall back to the bare shell pid. `force` ⇒ SIGKILL, else SIGTERM.
|
|
667
|
+
const killTree = (force: boolean): void => {
|
|
668
|
+
const sig = force ? "SIGKILL" : "SIGTERM";
|
|
669
|
+
if (grouped) {
|
|
670
|
+
try { process.kill(killSpec(reapTarget), sig); return; } catch { /* fall through */ }
|
|
671
|
+
}
|
|
672
|
+
try { force ? proc.kill(9) : proc.kill(); } catch {}
|
|
673
|
+
};
|
|
649
674
|
|
|
650
675
|
let timedOut = false;
|
|
651
676
|
let aborted = false;
|
|
@@ -674,14 +699,14 @@ export async function bashTool(
|
|
|
674
699
|
// SIGKILL step ALSO cut the readers: the old code killed the shell but never
|
|
675
700
|
// cancelled the readers, so an orphan-held pipe outlived the deadline and the
|
|
676
701
|
// turn hung forever (the reported freeze).
|
|
677
|
-
|
|
678
|
-
killTimer = setTimeout(() => {
|
|
702
|
+
killTree(false);
|
|
703
|
+
killTimer = setTimeout(() => { killTree(true); cancelReaders(); }, 3_000);
|
|
679
704
|
}, TIMEOUT_MS);
|
|
680
705
|
// Abort wiring: if the turn is cancelled, SIGKILL the child immediately AND cancel
|
|
681
706
|
// both pipe readers so the drain loops unwind at once.
|
|
682
707
|
const onAbort = () => {
|
|
683
708
|
aborted = true;
|
|
684
|
-
|
|
709
|
+
killTree(true);
|
|
685
710
|
cancelReaders();
|
|
686
711
|
};
|
|
687
712
|
if (signal) {
|
|
@@ -728,10 +753,17 @@ export async function bashTool(
|
|
|
728
753
|
clearTimeout(timer);
|
|
729
754
|
if (killTimer) clearTimeout(killTimer);
|
|
730
755
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
731
|
-
// Reap a still-alive
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
756
|
+
// Reap a still-alive shell (abort/timeout) AND any backgrounded grandchild it
|
|
757
|
+
// left behind. A `next dev &` / daemon survives the shell's exit by reparenting
|
|
758
|
+
// to init and would otherwise accumulate every turn (rising next-server RSS);
|
|
759
|
+
// group-SIGKILL claims the whole subtree. Register first so the periodic sweep
|
|
760
|
+
// retries if this signal raced the reparent. Skipped when KEEP_BACKGROUND=1.
|
|
761
|
+
if (proc.exitCode === null && proc.signalCode === null) {
|
|
762
|
+
killTree(true);
|
|
763
|
+
} else if (grouped && reapBackground) {
|
|
764
|
+
backgroundReaper.register(reapTarget, command);
|
|
765
|
+
backgroundReaper.reapOne(proc.pid);
|
|
766
|
+
}
|
|
735
767
|
cancelReaders();
|
|
736
768
|
}
|
|
737
769
|
const stdout = await stdoutPromise;
|
package/src/ai/think-tags.ts
CHANGED
|
@@ -82,3 +82,33 @@ export function createThinkSplitter(onReasoning?: (delta: string) => void): Thin
|
|
|
82
82
|
|
|
83
83
|
return { push, flush };
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strip leaked reasoning / tool-call markup from a model's FINAL visible text
|
|
88
|
+
* (a salvaged prose answer or a `done` reason). Some API-entered models emit
|
|
89
|
+
* XML/Harmony-style scaffolding — `<think>…</think>`, `<parameter>…</parameter>`,
|
|
90
|
+
* `<tool_call>`, `<|channel|>` markers — inside their plain-text reply. The
|
|
91
|
+
* streaming {@link createThinkSplitter} only removes *matched* think pairs seen
|
|
92
|
+
* mid-stream; an unmatched `</think>` (a model that begins already "inside" its
|
|
93
|
+
* reasoning) or stray parameter tags leak through into the answer. This is a
|
|
94
|
+
* whole-text cleanup, safe to run once on the final string: text with no such
|
|
95
|
+
* markup is returned trimmed-but-otherwise-unchanged.
|
|
96
|
+
*/
|
|
97
|
+
export function stripLeakedReasoningTags(text: string): string {
|
|
98
|
+
let s = text;
|
|
99
|
+
// 1) Drop balanced <think>…</think> blocks.
|
|
100
|
+
s = s.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, "");
|
|
101
|
+
// 2) An unmatched closing </think> implies an implicit reasoning prefix the
|
|
102
|
+
// splitter never saw an opener for: drop everything up to and including the
|
|
103
|
+
// LAST such close tag, keeping only the answer that follows it.
|
|
104
|
+
const lastClose = s.toLowerCase().lastIndexOf("</think>");
|
|
105
|
+
if (lastClose !== -1) s = s.slice(lastClose + "</think>".length);
|
|
106
|
+
// 3) Remove stray tool-call / parameter scaffolding tags (keep their inner text).
|
|
107
|
+
s = s.replace(
|
|
108
|
+
/<\/?(?:think|tool_call|tool_response|tool_result|parameter|invoke|function|function_call|antml:[a-z_]+)\b[^>]*>/gi,
|
|
109
|
+
"",
|
|
110
|
+
);
|
|
111
|
+
// 4) Remove Harmony channel markers like <|channel|>, <|message|>, <|end|>.
|
|
112
|
+
s = s.replace(/<\|[^|>]*\|>/g, "");
|
|
113
|
+
return s.trim();
|
|
114
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -35,5 +35,8 @@ try {
|
|
|
35
35
|
const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
|
|
36
36
|
if (code !== 0) process.exit(code);
|
|
37
37
|
} catch (err) {
|
|
38
|
-
|
|
38
|
+
// Service-readiness: never surface a raw stack trace to users; clean error + non-zero exit.
|
|
39
|
+
restoreTerminalState();
|
|
40
|
+
process.stderr.write(`error: ${(err as Error)?.message ?? String(err)}\n`);
|
|
41
|
+
process.exit(1);
|
|
39
42
|
}
|
|
@@ -231,6 +231,51 @@ export function rewriteCursorCombos(plain: string): string {
|
|
|
231
231
|
return out;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/** Byte length of a terminal ESCAPE SEQUENCE beginning at `s[i]`, else 0 — used to STRIP
|
|
235
|
+
* escapes embedded in PASTED text (copied colored terminal output carries `\x1b[31m`-style
|
|
236
|
+
* SGR codes; left intact they reach readline as literal escapes that corrupt the input box,
|
|
237
|
+
* or as leftover `[31m` garbage text). Covers CSI (`ESC [ … final-byte`), OSC/DCS/PM/APC/SOS
|
|
238
|
+
* (`ESC ] | P | ^ | _ | X … ST|BEL`), and the two-byte `ESC <char>` form. A sequence split
|
|
239
|
+
* across the end of the segment consumes the remaining tail. */
|
|
240
|
+
export function pasteEscapeLength(s: string, i: number): number {
|
|
241
|
+
if (s[i] !== "\u001b") return 0;
|
|
242
|
+
const c = s[i + 1];
|
|
243
|
+
if (c === undefined) return 1; // lone trailing ESC
|
|
244
|
+
if (c === "[") { // CSI: params/intermediates until a final byte 0x40–0x7e
|
|
245
|
+
let j = i + 2;
|
|
246
|
+
while (j < s.length && !(s[j]! >= "\u0040" && s[j]! <= "\u007e")) j++;
|
|
247
|
+
return (j < s.length ? j + 1 : s.length) - i;
|
|
248
|
+
}
|
|
249
|
+
if (c === "]" || c === "P" || c === "^" || c === "_" || c === "X") { // OSC/DCS/PM/APC/SOS → ST or BEL
|
|
250
|
+
let j = i + 2;
|
|
251
|
+
while (j < s.length) {
|
|
252
|
+
if (s[j] === "\u0007") return j + 1 - i; // BEL
|
|
253
|
+
if (s[j] === "\u001b" && s[j + 1] === "\\") return j + 2 - i; // ST
|
|
254
|
+
j++;
|
|
255
|
+
}
|
|
256
|
+
return s.length - i;
|
|
257
|
+
}
|
|
258
|
+
return 2; // two-byte ESC <char>
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Remove every terminal escape sequence AND stray C0 control byte (except `\t`/`\n`/`\r`,
|
|
262
|
+
* which carry layout meaning the caller handles) from a chunk of PASTED text, so copied
|
|
263
|
+
* ANSI-colored output drops to its plain characters instead of corrupting the prompt.
|
|
264
|
+
* Shared by every paste path (the idle key-filter, the live-turn drain, mid-turn capture)
|
|
265
|
+
* so they sanitize identically. */
|
|
266
|
+
export function stripPasteEscapes(s: string): string {
|
|
267
|
+
let out = "";
|
|
268
|
+
let i = 0;
|
|
269
|
+
while (i < s.length) {
|
|
270
|
+
const esc = pasteEscapeLength(s, i);
|
|
271
|
+
if (esc > 0) { i += esc; continue; }
|
|
272
|
+
const ch = s[i]!;
|
|
273
|
+
if (ch === "\n" || ch === "\r" || ch === "\t" || ch >= " ") out += ch;
|
|
274
|
+
i += 1;
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
234
279
|
export interface PromptInputQueue {
|
|
235
280
|
pendingLines: string[];
|
|
236
281
|
partial: string;
|
|
@@ -267,12 +312,13 @@ function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
|
|
|
267
312
|
}
|
|
268
313
|
|
|
269
314
|
/** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
|
|
270
|
-
* partial stays editable, and
|
|
271
|
-
*
|
|
315
|
+
* partial stays editable, and any ANSI escape sequence from copied terminal output is
|
|
316
|
+
* STRIPPED (via `stripPasteEscapes`) instead of being interpreted/leaked as keystrokes.
|
|
317
|
+
* Shares the sanitizer with the idle key-filter so every paste path behaves identically. */
|
|
272
318
|
function feedPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
273
319
|
if (!body) return false;
|
|
274
320
|
let accepted = false;
|
|
275
|
-
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
321
|
+
const normalized = stripPasteEscapes(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
276
322
|
for (const ch of Array.from(normalized)) {
|
|
277
323
|
if (ch === "\n") {
|
|
278
324
|
state.pastedLines.push(state.partial);
|
|
@@ -335,7 +381,7 @@ function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolea
|
|
|
335
381
|
|
|
336
382
|
function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
337
383
|
if (!body) return false;
|
|
338
|
-
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
384
|
+
const normalized = stripPasteEscapes(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
339
385
|
const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
|
|
340
386
|
if (!flattened) return false;
|
|
341
387
|
state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
|
|
@@ -532,9 +578,46 @@ export interface PromptKeyFilterEnv {
|
|
|
532
578
|
}
|
|
533
579
|
|
|
534
580
|
/** Carried across chunks: a bracketed paste can span several stdin chunks, so the
|
|
535
|
-
* in-paste flag must survive between filter calls.
|
|
581
|
+
* in-paste flag must survive between filter calls. `carry` holds a trailing byte run
|
|
582
|
+
* that MIGHT be the start of a split escape sequence — a bracketed-paste marker
|
|
583
|
+
* (`\x1b[200~`/`\x1b[201~`) or a `\r` that could be the front of a `\r\n` — sliced off
|
|
584
|
+
* the end of one chunk and re-prepended to the next so a marker straddling a stdin read
|
|
585
|
+
* boundary is never half-interpreted (the "긴 텍스트 붙여넣으면 `00~`/`[201~` 쓰레기가
|
|
586
|
+
* 새거나 중간에 제출됨" corruption). Concatenation is always re-parsed, so carrying is
|
|
587
|
+
* safe even when the tail turns out NOT to be a marker. */
|
|
536
588
|
export interface PromptKeyFilterState {
|
|
537
589
|
inPaste: boolean;
|
|
590
|
+
carry?: string;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Length of the longest trailing run of `data` that is a PROPER prefix of a
|
|
594
|
+
* bracketed-paste marker (so it could be a marker split across a stdin chunk boundary).
|
|
595
|
+
* Returns 0 when the tail cannot begin a marker. Both markers share the `\x1b[20` stem,
|
|
596
|
+
* so e.g. a trailing `\x1b`, `\x1b[`, `\x1b[2`, `\x1b[20`, `\x1b[200`, or `\x1b[201` is
|
|
597
|
+
* held back for the next chunk; a full marker (handled in the main loop) is not. */
|
|
598
|
+
export function pasteMarkerTailLength(data: string): number {
|
|
599
|
+
const markers = [PASTE_START, PASTE_END] as const;
|
|
600
|
+
const max = Math.min(Math.max(PASTE_START.length, PASTE_END.length) - 1, data.length);
|
|
601
|
+
for (let k = max; k >= 1; k--) {
|
|
602
|
+
const tail = data.slice(data.length - k);
|
|
603
|
+
for (const m of markers) if (m.length > k && m.startsWith(tail)) return k;
|
|
604
|
+
}
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Whether `data` (already free of any trailing partial marker) leaves the filter INSIDE
|
|
609
|
+
* a bracketed paste, given the entry state — by replaying only the paste toggles. Used to
|
|
610
|
+
* decide if a trailing lone `\r` is a CRLF/CR split inside paste body (carry it) versus a
|
|
611
|
+
* real Enter at the prompt (must NOT be carried). */
|
|
612
|
+
export function endsInPaste(data: string, startInPaste: boolean): boolean {
|
|
613
|
+
let inPaste = startInPaste;
|
|
614
|
+
let i = 0;
|
|
615
|
+
while (i < data.length) {
|
|
616
|
+
if (!inPaste && data.startsWith(PASTE_START, i)) { inPaste = true; i += PASTE_START.length; continue; }
|
|
617
|
+
if (inPaste && data.startsWith(PASTE_END, i)) { inPaste = false; i += PASTE_END.length; continue; }
|
|
618
|
+
i += 1;
|
|
619
|
+
}
|
|
620
|
+
return inPaste;
|
|
538
621
|
}
|
|
539
622
|
|
|
540
623
|
export interface PromptKeyFilterResult {
|
|
@@ -561,6 +644,24 @@ export function filterPromptInputChunk(
|
|
|
561
644
|
env: PromptKeyFilterEnv,
|
|
562
645
|
state: PromptKeyFilterState,
|
|
563
646
|
): PromptKeyFilterResult {
|
|
647
|
+
// Re-attach any byte run carried from the previous chunk (a split paste marker or a
|
|
648
|
+
// dangling `\r`), then hold back a fresh trailing partial so a bracketed-paste marker —
|
|
649
|
+
// or a `\r\n` straddling this read boundary — is never half-interpreted. Carrying is
|
|
650
|
+
// safe: the concatenation is re-parsed next call, so a tail that turns out NOT to be a
|
|
651
|
+
// marker is simply emitted one chunk later.
|
|
652
|
+
data = (state.carry ?? "") + data;
|
|
653
|
+
state.carry = "";
|
|
654
|
+
const markerTail = pasteMarkerTailLength(data);
|
|
655
|
+
if (markerTail > 0) {
|
|
656
|
+
state.carry = data.slice(data.length - markerTail);
|
|
657
|
+
data = data.slice(0, data.length - markerTail);
|
|
658
|
+
} else if (data.endsWith("\r") && endsInPaste(data, state.inPaste)) {
|
|
659
|
+
// A lone trailing `\r` INSIDE a paste may be the front of a CRLF split across chunks —
|
|
660
|
+
// carry it so `\r\n` folds to ONE sentinel instead of two (no spurious blank line).
|
|
661
|
+
// Outside a paste a trailing `\r` is Enter (submit) and must pass through untouched.
|
|
662
|
+
state.carry = "\r";
|
|
663
|
+
data = data.slice(0, data.length - 1);
|
|
664
|
+
}
|
|
564
665
|
// Empty-line Backspace guard: a standalone Backspace with an empty buffer is a no-op
|
|
565
666
|
// that some Bun readline builds turn into a spurious `close` (hard exit) — drop it
|
|
566
667
|
// before it reaches readline. Forwarded normally inside a paste or with text present.
|
|
@@ -575,7 +676,17 @@ export function filterPromptInputChunk(
|
|
|
575
676
|
if (state.inPaste) {
|
|
576
677
|
if (data.startsWith("\r\n", i)) { out += MULTILINE_SENTINEL; i += 2; continue; }
|
|
577
678
|
if (data[i] === "\n" || data[i] === "\r") { out += MULTILINE_SENTINEL; i += 1; continue; }
|
|
578
|
-
|
|
679
|
+
// Pasted DATA: drop whole ANSI escape sequences (e.g. the `\x1b[31m` color codes in
|
|
680
|
+
// copied terminal output, which would otherwise reach readline as literal escapes —
|
|
681
|
+
// or leftover `[31m` text — and corrupt the box), then keep printable text + tabs and
|
|
682
|
+
// drop any remaining C0 control byte. Shares `pasteEscapeLength`/the same keep rule as
|
|
683
|
+
// `stripPasteEscapes` so every paste path sanitizes identically. Surrogate halves
|
|
684
|
+
// (emoji) are >= " " and pass through intact.
|
|
685
|
+
const esc = pasteEscapeLength(data, i);
|
|
686
|
+
if (esc > 0) { i += esc; continue; }
|
|
687
|
+
const ch = data[i]!;
|
|
688
|
+
if (ch === "\t" || ch >= " ") out += ch;
|
|
689
|
+
i += 1; continue;
|
|
579
690
|
}
|
|
580
691
|
const mouse = matchMouseReport(data, i);
|
|
581
692
|
if (mouse > 0) { i += mouse; continue; }
|
|
@@ -611,4 +722,30 @@ export function filterPromptInputChunk(
|
|
|
611
722
|
out += data[i]; i += 1;
|
|
612
723
|
}
|
|
613
724
|
return { out, drop: false };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ── Paste-merge idle gate (large-paste truncation fix) ───────────────────────────
|
|
728
|
+
/** Default idle window for the bracketed-paste merge fallback, in ms. */
|
|
729
|
+
export const PASTE_MERGE_IDLE_MS = 250;
|
|
730
|
+
|
|
731
|
+
/** Decide the paste-merge fallback when the `201~` end-marker may have been dropped.
|
|
732
|
+
*
|
|
733
|
+
* A multi-line bracketed paste is buffered line-by-line and only flushed as ONE
|
|
734
|
+
* message once paste-end arrives. Terminals occasionally drop the end-marker, so
|
|
735
|
+
* `launch.ts` also arms an idle fallback. The OLD fallback was a single fixed
|
|
736
|
+
* 250ms timer measured from the FIRST line — a large paste streaming in over more
|
|
737
|
+
* than 250ms tripped it mid-paste and got truncated (the rest leaked as separate
|
|
738
|
+
* prompts). This is idle-based instead: `idleMs` is the gap since the LAST buffered
|
|
739
|
+
* line, so a steadily-arriving paste keeps resetting the clock and is never cut;
|
|
740
|
+
* the fallback fires only after the stream has genuinely gone quiet.
|
|
741
|
+
*
|
|
742
|
+
* Returns `fire: true` when the idle gap has reached the threshold (flush now), else
|
|
743
|
+
* `waitMs` = how long to wait before re-checking. Pure, so the timing policy is
|
|
744
|
+
* unit-testable without a live readline/PTY. */
|
|
745
|
+
export function pasteIdleDecision(
|
|
746
|
+
idleMs: number,
|
|
747
|
+
thresholdMs: number = PASTE_MERGE_IDLE_MS,
|
|
748
|
+
): { fire: boolean; waitMs: number } {
|
|
749
|
+
if (idleMs >= thresholdMs) return { fire: true, waitMs: 0 };
|
|
750
|
+
return { fire: false, waitMs: thresholdMs - idleMs };
|
|
614
751
|
}
|
|
@@ -2,17 +2,103 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { tokenize } from "../../tui/components/autocomplete";
|
|
4
4
|
|
|
5
|
+
/** Directories never descended into / surfaced by `@path` recursive search. */
|
|
6
|
+
const IGNORE_DIRS = new Set([
|
|
7
|
+
"node_modules", ".git", "dist", "build", "out", "coverage",
|
|
8
|
+
".next", ".cache", ".turbo", ".svelte-kit", "vendor", "target",
|
|
9
|
+
".jeo", ".omc", ".omx", ".gjc",
|
|
10
|
+
]);
|
|
11
|
+
const MAX_RESULTS = 50;
|
|
12
|
+
/** Hard cap on directory entries visited per query (keeps per-keystroke cost bounded). */
|
|
13
|
+
const MAX_SCAN = 8000;
|
|
14
|
+
/** Deepest directory level the recursive walk descends to. */
|
|
15
|
+
const MAX_DEPTH = 10;
|
|
16
|
+
|
|
17
|
+
/** Fuzzy match tier for `name` against an already-lowercased `frag`.
|
|
18
|
+
* 0 = prefix, 1 = substring, 2 = subsequence (chars in order), -1 = no match. */
|
|
19
|
+
function fuzzyRank(name: string, fragLower: string): number {
|
|
20
|
+
if (!fragLower) return 0;
|
|
21
|
+
const n = name.toLowerCase();
|
|
22
|
+
if (n.startsWith(fragLower)) return 0;
|
|
23
|
+
if (n.includes(fragLower)) return 1;
|
|
24
|
+
let i = 0;
|
|
25
|
+
for (let j = 0; j < n.length && i < fragLower.length; j++) {
|
|
26
|
+
if (n[j] === fragLower[i]) i++;
|
|
27
|
+
}
|
|
28
|
+
return i === fragLower.length ? 2 : -1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RecursiveHit {
|
|
32
|
+
rel: string;
|
|
33
|
+
isDir: boolean;
|
|
34
|
+
rank: number;
|
|
35
|
+
depth: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Recursively search the project tree under `cwd` for entries whose basename
|
|
39
|
+
* fuzzy-matches `frag`. Ignores hidden entries and well-known build/VCS dirs,
|
|
40
|
+
* bounded by depth / scan / result caps. Pure given the filesystem: never throws
|
|
41
|
+
* (unreadable directories are skipped). Returns relative POSIX paths, directories
|
|
42
|
+
* keeping a trailing `/`, ranked basename-prefix → substring → subsequence, then
|
|
43
|
+
* shallowest / shortest / alphabetical. */
|
|
44
|
+
function searchRecursive(cwd: string, frag: string): string[] {
|
|
45
|
+
const fragLower = frag.toLowerCase();
|
|
46
|
+
const hits: RecursiveHit[] = [];
|
|
47
|
+
let scanned = 0;
|
|
48
|
+
const stack: { abs: string; rel: string; depth: number }[] = [{ abs: cwd, rel: "", depth: 0 }];
|
|
49
|
+
while (stack.length > 0) {
|
|
50
|
+
const { abs, rel, depth } = stack.pop()!;
|
|
51
|
+
let entries: fs.Dirent[];
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (++scanned > MAX_SCAN) break;
|
|
59
|
+
if (entry.name.startsWith(".")) continue;
|
|
60
|
+
const isDir = entry.isDirectory();
|
|
61
|
+
if (isDir && IGNORE_DIRS.has(entry.name)) continue;
|
|
62
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
63
|
+
const rank = fuzzyRank(entry.name, fragLower);
|
|
64
|
+
if (rank >= 0) hits.push({ rel: childRel, isDir, rank, depth });
|
|
65
|
+
if (isDir && depth + 1 <= MAX_DEPTH) {
|
|
66
|
+
stack.push({ abs: path.join(abs, entry.name), rel: childRel, depth: depth + 1 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (scanned > MAX_SCAN) break;
|
|
70
|
+
}
|
|
71
|
+
hits.sort((a, b) =>
|
|
72
|
+
a.rank - b.rank ||
|
|
73
|
+
a.depth - b.depth ||
|
|
74
|
+
a.rel.length - b.rel.length ||
|
|
75
|
+
a.rel.localeCompare(b.rel));
|
|
76
|
+
return hits.slice(0, MAX_RESULTS).map(h => (h.isDir ? `${h.rel}/` : h.rel));
|
|
77
|
+
}
|
|
78
|
+
|
|
5
79
|
/** Filesystem completion for an `@path` mention prefix, relative to `cwd`.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
80
|
+
*
|
|
81
|
+
* - A bare fragment with no slash (e.g. `@loop`) triggers a recursive, fuzzy
|
|
82
|
+
* project-wide search so nested files surface without typing each directory
|
|
83
|
+
* level (`@loop` → `src/agent/loop.ts`).
|
|
84
|
+
* - A path containing a slash (or ending in `/`) lists that one directory,
|
|
85
|
+
* filtering the basename fragment fuzzily (prefix/substring/subsequence).
|
|
86
|
+
*
|
|
87
|
+
* Directories keep a trailing `/` so the next Tab descends; hidden entries are
|
|
88
|
+
* dropped; results are capped at 50. Pure given `cwd`: never throws (an
|
|
89
|
+
* unreadable directory yields no matches). */
|
|
11
90
|
export function mentionPaths(cwd: string, prefix: string): string[] {
|
|
12
91
|
const norm = prefix.replace(/\\/g, "/");
|
|
13
92
|
const wantsDirChildren = norm.endsWith("/");
|
|
93
|
+
|
|
94
|
+
// Bare non-empty fragment → recursive fuzzy search across the project.
|
|
95
|
+
if (!wantsDirChildren && norm.length > 0 && !norm.includes("/")) {
|
|
96
|
+
return searchRecursive(cwd, norm);
|
|
97
|
+
}
|
|
98
|
+
|
|
14
99
|
const dirPart = wantsDirChildren ? norm.slice(0, -1) : path.posix.dirname(norm) === "." ? "" : path.posix.dirname(norm);
|
|
15
100
|
const namePart = wantsDirChildren ? "" : path.posix.basename(norm);
|
|
101
|
+
const fragLower = namePart.toLowerCase();
|
|
16
102
|
const absDir = path.resolve(cwd, dirPart || ".");
|
|
17
103
|
let entries: fs.Dirent[] = [];
|
|
18
104
|
try {
|
|
@@ -22,9 +108,12 @@ export function mentionPaths(cwd: string, prefix: string): string[] {
|
|
|
22
108
|
}
|
|
23
109
|
return entries
|
|
24
110
|
.filter(entry => !entry.name.startsWith("."))
|
|
25
|
-
.filter(entry =>
|
|
111
|
+
.filter(entry => fuzzyRank(entry.name, fragLower) >= 0)
|
|
26
112
|
.sort((a, b) => {
|
|
27
113
|
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
114
|
+
const ra = fuzzyRank(a.name, fragLower);
|
|
115
|
+
const rb = fuzzyRank(b.name, fragLower);
|
|
116
|
+
if (ra !== rb) return ra - rb;
|
|
28
117
|
return a.name.localeCompare(b.name);
|
|
29
118
|
})
|
|
30
119
|
.slice(0, 50)
|
package/src/commands/launch.ts
CHANGED
|
@@ -149,6 +149,8 @@ import {
|
|
|
149
149
|
classifyMidTurnLine,
|
|
150
150
|
decideCtrlC,
|
|
151
151
|
filterPromptInputChunk,
|
|
152
|
+
pasteIdleDecision,
|
|
153
|
+
PASTE_MERGE_IDLE_MS,
|
|
152
154
|
} from "./launch/input";
|
|
153
155
|
import {
|
|
154
156
|
gatedStdout,
|
|
@@ -1556,12 +1558,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1556
1558
|
// PASTE-MERGE: a multi-line bracketed paste must arrive as ONE message, not split into
|
|
1557
1559
|
// one command per line. Lines that fire WHILE a paste is open are buffered here instead
|
|
1558
1560
|
// of run individually; promptInput joins them with the trailing residual on resolve.
|
|
1559
|
-
// `endWaiters` wake that merge the moment the 201~ paste-end marker arrives.
|
|
1560
|
-
|
|
1561
|
+
// `endWaiters` wake that merge the moment the 201~ paste-end marker arrives. `lastActivityAt`
|
|
1562
|
+
// tracks the last buffered line so the dropped-marker fallback is IDLE-based (never cuts a
|
|
1563
|
+
// large paste still streaming in — see pasteIdleDecision).
|
|
1564
|
+
const pasteMerge: { buf: string[]; endWaiters: Array<() => void>; lastActivityAt: number } = { buf: [], endWaiters: [], lastActivityAt: 0 };
|
|
1561
1565
|
let pasteLineFired = false; // the line that resolved rl.question came from inside a paste
|
|
1562
1566
|
if (process.stdin.isTTY) {
|
|
1563
1567
|
const pasteKeypressHandler = (_ch: string, key: { name?: string } | undefined) => {
|
|
1564
|
-
if (key?.name === "paste-start") { promptPasteActive = true; pasteMerge.buf = []; }
|
|
1568
|
+
if (key?.name === "paste-start") { promptPasteActive = true; pasteMerge.buf = []; pasteMerge.lastActivityAt = Date.now(); }
|
|
1565
1569
|
else if (key?.name === "paste-end") {
|
|
1566
1570
|
promptPasteActive = false;
|
|
1567
1571
|
for (const w of pasteMerge.endWaiters.splice(0)) w();
|
|
@@ -1578,6 +1582,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1578
1582
|
if (promptPasteActive) {
|
|
1579
1583
|
// Inside a bracketed paste: buffer the line, never run it on its own.
|
|
1580
1584
|
pasteMerge.buf.push(l);
|
|
1585
|
+
pasteMerge.lastActivityAt = Date.now();
|
|
1581
1586
|
pasteLineFired = true;
|
|
1582
1587
|
return;
|
|
1583
1588
|
}
|
|
@@ -1642,10 +1647,24 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1642
1647
|
if (promptPasteActive) {
|
|
1643
1648
|
await new Promise<void>(resolve => {
|
|
1644
1649
|
if (!promptPasteActive) { resolve(); return; }
|
|
1645
|
-
|
|
1646
|
-
|
|
1650
|
+
let done = false;
|
|
1651
|
+
const finish = () => { if (done) return; done = true; resolve(); };
|
|
1652
|
+
// Woken immediately by the 201~ paste-end marker (endWaiters).
|
|
1653
|
+
pasteMerge.endWaiters.push(finish);
|
|
1654
|
+
// Fallback for a DROPPED end-marker: idle-based, so a large paste still
|
|
1655
|
+
// streaming in keeps resetting the clock and is never truncated. Only a
|
|
1656
|
+
// genuinely quiet stream (no new line for PASTE_MERGE_IDLE_MS) flushes.
|
|
1657
|
+
const tick = () => {
|
|
1658
|
+
if (done) return;
|
|
1659
|
+
if (!promptPasteActive) { finish(); return; }
|
|
1660
|
+
const { fire, waitMs } = pasteIdleDecision(Date.now() - pasteMerge.lastActivityAt);
|
|
1661
|
+
if (fire) { finish(); return; }
|
|
1662
|
+
setTimeout(tick, waitMs);
|
|
1663
|
+
};
|
|
1664
|
+
setTimeout(tick, PASTE_MERGE_IDLE_MS);
|
|
1647
1665
|
});
|
|
1648
1666
|
}
|
|
1667
|
+
|
|
1649
1668
|
const residual = (rl as unknown as { line?: string }).line ?? "";
|
|
1650
1669
|
// Clear the residual so it does NOT prefill (and re-submit) the next prompt.
|
|
1651
1670
|
try { rl.write(null, { ctrl: true, name: "u" }); } catch { /* best-effort */ }
|
|
@@ -3327,10 +3346,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3327
3346
|
if (copied.osc52 || copied.local) {
|
|
3328
3347
|
const via = [copied.local ? "system tool" : "", copied.osc52 ? "OSC52" : ""].filter(Boolean).join(" + ");
|
|
3329
3348
|
console.log(`(transcript copied to clipboard via ${via} — ${text.length} chars)`);
|
|
3349
|
+
// OSC52 is the only path that reaches the OUTER terminal over SSH/tmux. If it was
|
|
3350
|
+
// skipped for size, a remote paste won't find the text — say so (and how to lift it).
|
|
3351
|
+
if (copied.osc52SkippedTooLarge) {
|
|
3352
|
+
console.log("(note: too large for the OSC52 remote-clipboard path — copied via the local tool only; over SSH/tmux this won't reach your machine. Set JEO_OSC52_MAX higher if your terminal accepts larger writes.)");
|
|
3353
|
+
}
|
|
3330
3354
|
} else {
|
|
3331
3355
|
console.log(text);
|
|
3332
|
-
console.log(
|
|
3356
|
+
console.log(copied.osc52SkippedTooLarge
|
|
3357
|
+
? "(transcript too large for OSC52 and no local clipboard tool — printed above)"
|
|
3358
|
+
: "(no clipboard path available — transcript printed above)");
|
|
3333
3359
|
}
|
|
3360
|
+
|
|
3334
3361
|
} catch (err) {
|
|
3335
3362
|
console.log(`! dump failed: ${(err as Error).message}`);
|
|
3336
3363
|
}
|
package/src/commands/ralplan.ts
CHANGED
|
@@ -148,9 +148,21 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
148
148
|
const ARCHITECT = `You are the ARCHITECT. Review the Planner's draft for technical feasibility, correct file targets, directory structure, and any missing setup/wiring/test steps. Return an improved plan (same shape).\n` + SCHEMA_SPEC;
|
|
149
149
|
const CRITIC = `You are the CRITIC. Finalize the plan: remove vague or redundant steps, make each step actionable and independently verifiable, and ensure the acceptance criteria are covered. Return the final plan (same shape).\n` + SCHEMA_SPEC;
|
|
150
150
|
|
|
151
|
+
// Drafting passes honor the SAME per-role model config the rest of jeo uses
|
|
152
|
+
// (resolveSubagentModel → config.subagents[role].model, else defaultModel).
|
|
153
|
+
// Previously every pass ran on the default/primary model, ignoring a user's
|
|
154
|
+
// configured planner/architect tiers; routing each pass to its role's model
|
|
155
|
+
// lets a cheaper/faster draft tier cut planning latency with NO default change
|
|
156
|
+
// (unset roles still resolve to defaultModel). The repo-grounded consensus gate
|
|
157
|
+
// (runConsensusCriticGate) resolves critic's model independently and is untouched.
|
|
158
|
+
const config = await readGlobalConfig();
|
|
159
|
+
const PLANNER_MODEL = resolveSubagentModel("planner", config);
|
|
160
|
+
const ARCHITECT_MODEL = resolveSubagentModel("architect", config);
|
|
161
|
+
const CRITIC_MODEL = resolveSubagentModel("critic", config);
|
|
162
|
+
|
|
151
163
|
try {
|
|
152
|
-
const callRole = async (systemPrompt: string, userContent: string): Promise<string> => {
|
|
153
|
-
const raw = await callLlm([{ role: "user" as const, content: userContent }], { systemPrompt });
|
|
164
|
+
const callRole = async (systemPrompt: string, userContent: string, model?: string): Promise<string> => {
|
|
165
|
+
const raw = await callLlm([{ role: "user" as const, content: userContent }], { systemPrompt, model });
|
|
154
166
|
return raw.replace(/```yaml|```/g, "").trim();
|
|
155
167
|
};
|
|
156
168
|
const isValidPlan = (yaml: string): boolean => {
|
|
@@ -175,7 +187,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
175
187
|
if (opts.onProgress) {
|
|
176
188
|
opts.onProgress({ skill: "ralplan", phase: "planning", detail: "Planner drafting" });
|
|
177
189
|
}
|
|
178
|
-
const draft = await callRole(PLANNER, `Crystallized spec (seed.yaml):\n\n${seedContent}
|
|
190
|
+
const draft = await callRole(PLANNER, `Crystallized spec (seed.yaml):\n\n${seedContent}`, PLANNER_MODEL);
|
|
179
191
|
|
|
180
192
|
if (opts.signal?.aborted) {
|
|
181
193
|
return { ok: false, reason: "aborted" };
|
|
@@ -185,7 +197,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
185
197
|
if (opts.onProgress) {
|
|
186
198
|
opts.onProgress({ skill: "ralplan", phase: "planning", detail: "Architect reviewing" });
|
|
187
199
|
}
|
|
188
|
-
const reviewed = await callRole(ARCHITECT, `Crystallized spec (seed.yaml):\n\n${seedContent}\n\nPlanner's draft plan:\n\n${draft}\n\nReturn the improved plan
|
|
200
|
+
const reviewed = await callRole(ARCHITECT, `Crystallized spec (seed.yaml):\n\n${seedContent}\n\nPlanner's draft plan:\n\n${draft}\n\nReturn the improved plan.`, ARCHITECT_MODEL);
|
|
189
201
|
|
|
190
202
|
if (opts.signal?.aborted) {
|
|
191
203
|
return { ok: false, reason: "aborted" };
|
|
@@ -195,7 +207,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
195
207
|
if (opts.onProgress) {
|
|
196
208
|
opts.onProgress({ skill: "ralplan", phase: "planning", detail: "Critic finalizing" });
|
|
197
209
|
}
|
|
198
|
-
let cleanPlan = await callRole(CRITIC, `Crystallized spec (seed.yaml):\n\n${seedContent}\n\nArchitect's plan:\n\n${reviewed}\n\nReturn the final, critiqued plan
|
|
210
|
+
let cleanPlan = await callRole(CRITIC, `Crystallized spec (seed.yaml):\n\n${seedContent}\n\nArchitect's plan:\n\n${reviewed}\n\nReturn the final, critiqued plan.`, CRITIC_MODEL);
|
|
199
211
|
|
|
200
212
|
if (opts.signal?.aborted) {
|
|
201
213
|
return { ok: false, reason: "aborted" };
|
|
@@ -209,7 +221,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
209
221
|
let planValid = true;
|
|
210
222
|
if (!isValidPlan(cleanPlan)) {
|
|
211
223
|
log("[ralplan] Final plan did not match the required shape; requesting a corrected plan…");
|
|
212
|
-
cleanPlan = await callRole(CRITIC, `Your previous output was not valid for the required schema. Fix it (roles MUST be one of executor|planner|architect|critic).\n\n${SCHEMA_SPEC}\n\nPlan to fix:\n\n${cleanPlan}
|
|
224
|
+
cleanPlan = await callRole(CRITIC, `Your previous output was not valid for the required schema. Fix it (roles MUST be one of executor|planner|architect|critic).\n\n${SCHEMA_SPEC}\n\nPlan to fix:\n\n${cleanPlan}`, CRITIC_MODEL);
|
|
213
225
|
if (!isValidPlan(cleanPlan)) {
|
|
214
226
|
const fallback = [reviewed, draft].find(isValidPlan);
|
|
215
227
|
if (fallback) {
|
|
@@ -253,6 +265,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
253
265
|
CRITIC,
|
|
254
266
|
`The consensus critic returned [ITERATE] on the plan with this justification:\n\n${gate.detail}\n\n` +
|
|
255
267
|
`Revise the plan to address every point.\n\n${SCHEMA_SPEC}\n\nCurrent plan:\n\n${cleanPlan}`,
|
|
268
|
+
CRITIC_MODEL,
|
|
256
269
|
);
|
|
257
270
|
if (isValidPlan(revised)) {
|
|
258
271
|
cleanPlan = revised;
|
package/src/tui/clipboard.ts
CHANGED
|
@@ -27,6 +27,20 @@
|
|
|
27
27
|
* this we skip OSC 52 and rely on the local clipboard tool instead. */
|
|
28
28
|
export const OSC52_MAX_BASE64 = 100_000;
|
|
29
29
|
|
|
30
|
+
/** Resolve the effective OSC 52 base64-payload cap. `JEO_OSC52_MAX` overrides the
|
|
31
|
+
* conservative {@link OSC52_MAX_BASE64} default for terminals/tmux configured to
|
|
32
|
+
* accept larger clipboard writes (e.g. tmux `set -g set-clipboard on` with a raised
|
|
33
|
+
* `buffer-limit`, or a terminal without xterm's ~100KB selection limit). A value
|
|
34
|
+
* `<= 0` disables the cap entirely (Infinity); an unparseable value keeps the
|
|
35
|
+
* default. Pure (env injected) so it is testable without touching the host. */
|
|
36
|
+
export function osc52MaxBase64(env: Record<string, string | undefined> = process.env): number {
|
|
37
|
+
const raw = env.JEO_OSC52_MAX;
|
|
38
|
+
if (raw === undefined || raw.trim() === "") return OSC52_MAX_BASE64;
|
|
39
|
+
const n = Number(raw);
|
|
40
|
+
if (!Number.isFinite(n)) return OSC52_MAX_BASE64;
|
|
41
|
+
return n <= 0 ? Number.POSITIVE_INFINITY : n;
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
/**
|
|
31
45
|
* Resolve the local system-clipboard WRITE command for a platform, or null when
|
|
32
46
|
* no tool is available. Pure (the `which` probe is injected) so it is testable
|
|
@@ -69,18 +83,20 @@ export interface Osc52Options {
|
|
|
69
83
|
tmux?: boolean;
|
|
70
84
|
/** Clipboard selection: `c` = system clipboard (default), `p` = primary. */
|
|
71
85
|
clipboard?: "c" | "p";
|
|
86
|
+
/** Max base64 payload to emit (default {@link OSC52_MAX_BASE64}); larger returns "". */
|
|
87
|
+
maxBase64?: number;
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
/**
|
|
75
91
|
* Build the OSC 52 clipboard-SET escape for `text`, or "" when the base64 payload
|
|
76
|
-
* exceeds {@link OSC52_MAX_BASE64}
|
|
77
|
-
* `tmux` is set the whole sequence is wrapped
|
|
78
|
-
* ESC \`) with every inner ESC doubled, the tmux
|
|
79
|
-
* escape to the terminal underneath it.
|
|
92
|
+
* exceeds the cap (default {@link OSC52_MAX_BASE64}, overridable via `maxBase64`; the
|
|
93
|
+
* caller falls back to a local tool). When `tmux` is set the whole sequence is wrapped
|
|
94
|
+
* in DCS passthrough (`ESC P tmux ; … ESC \`) with every inner ESC doubled, the tmux
|
|
95
|
+
* contract for forwarding an escape to the terminal underneath it.
|
|
80
96
|
*/
|
|
81
97
|
export function osc52Sequence(text: string, opts: Osc52Options = {}): string {
|
|
82
98
|
const b64 = Buffer.from(text, "utf8").toString("base64");
|
|
83
|
-
if (b64.length > OSC52_MAX_BASE64) return "";
|
|
99
|
+
if (b64.length > (opts.maxBase64 ?? OSC52_MAX_BASE64)) return "";
|
|
84
100
|
const target = opts.clipboard ?? "c";
|
|
85
101
|
const inner = `\x1b]52;${target};${b64}\x07`;
|
|
86
102
|
if (!opts.tmux) return inner;
|
|
@@ -100,6 +116,8 @@ export interface CopyToClipboardDeps {
|
|
|
100
116
|
platform?: NodeJS.Platform;
|
|
101
117
|
/** True when running inside tmux (defaults to !!process.env.TMUX). */
|
|
102
118
|
insideTmux?: boolean;
|
|
119
|
+
/** Environment for resolving the OSC 52 cap (`JEO_OSC52_MAX`); defaults to process.env. */
|
|
120
|
+
env?: Record<string, string | undefined>;
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
export interface CopyResult {
|
|
@@ -107,6 +125,10 @@ export interface CopyResult {
|
|
|
107
125
|
osc52: boolean;
|
|
108
126
|
/** A local clipboard subprocess accepted the text. */
|
|
109
127
|
local: boolean;
|
|
128
|
+
/** OSC 52 was SKIPPED because the base64 payload exceeded the cap — the remote
|
|
129
|
+
* (SSH/tmux) clipboard was NOT updated; only a local tool, if any, ran. Lets the
|
|
130
|
+
* caller warn the user instead of silently dropping the remote path. */
|
|
131
|
+
osc52SkippedTooLarge: boolean;
|
|
110
132
|
}
|
|
111
133
|
|
|
112
134
|
/**
|
|
@@ -121,13 +143,17 @@ export async function copyTextToClipboard(text: string, deps: CopyToClipboardDep
|
|
|
121
143
|
const insideTmux = deps.insideTmux ?? !!process.env.TMUX;
|
|
122
144
|
const write = deps.write ?? ((s: string) => { try { process.stdout.write(s); } catch { /* terminal gone */ } });
|
|
123
145
|
const spawn = deps.spawn ?? ((cmd, opts) => Bun.spawn(cmd, opts) as ReturnType<NonNullable<CopyToClipboardDeps["spawn"]>>);
|
|
146
|
+
const maxBase64 = osc52MaxBase64(deps.env ?? process.env);
|
|
124
147
|
|
|
125
|
-
const result: CopyResult = { osc52: false, local: false };
|
|
148
|
+
const result: CopyResult = { osc52: false, local: false, osc52SkippedTooLarge: false };
|
|
126
149
|
|
|
127
|
-
const seq = osc52Sequence(text, { tmux: insideTmux });
|
|
150
|
+
const seq = osc52Sequence(text, { tmux: insideTmux, maxBase64 });
|
|
128
151
|
if (seq) {
|
|
129
152
|
write(seq);
|
|
130
153
|
result.osc52 = true;
|
|
154
|
+
} else {
|
|
155
|
+
// osc52Sequence only returns "" when the payload is over the cap.
|
|
156
|
+
result.osc52SkippedTooLarge = true;
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
const cmd = systemClipboardCopyCommand(platform, which);
|
|
@@ -114,8 +114,11 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
|
|
|
114
114
|
const token = trailingSpace ? "" : tokens[tokens.length - 1] ?? "";
|
|
115
115
|
if (token.startsWith("@")) {
|
|
116
116
|
const prefix = token.slice(1);
|
|
117
|
+
// `mentionPaths` already does the matching (recursive fuzzy for a bare
|
|
118
|
+
// fragment, single-dir listing when the prefix has a slash), so the pool
|
|
119
|
+
// is returned as-is rather than re-filtered by strict prefix.
|
|
117
120
|
const pool = (ctx.mentionPaths?.(prefix) ?? []).map(p => (p.startsWith("@") ? p : `@${p}`));
|
|
118
|
-
return { completions: dedupeCap(
|
|
121
|
+
return { completions: dedupeCap(pool), token, kind: "path" };
|
|
119
122
|
}
|
|
120
123
|
// `$skill` mention completion at ANY position in the line (mention-style;
|
|
121
124
|
// a leading `$name` is additionally the direct-invocation entrypoint).
|
|
@@ -12,25 +12,47 @@ export interface TableRenderOptions {
|
|
|
12
12
|
maxColWidth?: number;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** A line
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
return t.includes("|") && /\|/.test(t);
|
|
15
|
+
/** A line carries an *unescaped* `|` — a real cell separator, not a literal `\|`. */
|
|
16
|
+
function hasUnescapedPipe(line: string): boolean {
|
|
17
|
+
return /(?:^|[^\\])\|/.test(line);
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
/** A line is
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
if (!t.includes("-")) return false;
|
|
25
|
-
return t.split("|").every(cell => /^\s*:?-{1,}:?\s*$/.test(cell));
|
|
20
|
+
/** A line is a table row when it has at least one unescaped `|`. */
|
|
21
|
+
function isTableRow(line: string): boolean {
|
|
22
|
+
return hasUnescapedPipe(line.trim());
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
/** Split a markdown table row into trimmed cell strings
|
|
25
|
+
/** Split a markdown table row into trimmed cell strings, honoring escaped `\|`. */
|
|
29
26
|
function splitCells(line: string): string[] {
|
|
30
|
-
let
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
let s = line.trim();
|
|
28
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
29
|
+
// Only strip a trailing pipe that actually closes the row (not an escaped `\|`).
|
|
30
|
+
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
|
|
31
|
+
const cells: string[] = [];
|
|
32
|
+
let cur = "";
|
|
33
|
+
for (let i = 0; i < s.length; i++) {
|
|
34
|
+
const ch = s[i]!;
|
|
35
|
+
if (ch === "\\" && s[i + 1] === "|") {
|
|
36
|
+
cur += "|";
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (ch === "|") {
|
|
41
|
+
cells.push(cur);
|
|
42
|
+
cur = "";
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
cur += ch;
|
|
46
|
+
}
|
|
47
|
+
cells.push(cur);
|
|
48
|
+
return cells.map(c => c.trim());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A line is the GFM delimiter row, e.g. `| --- | :--: | ---: |`. */
|
|
52
|
+
function isDelimiterRow(line: string): boolean {
|
|
53
|
+
if (!isTableRow(line)) return false;
|
|
54
|
+
const cells = splitCells(line);
|
|
55
|
+
return cells.length > 0 && cells.every(cell => /^:?-{1,}:?$/.test(cell));
|
|
34
56
|
}
|
|
35
57
|
|
|
36
58
|
type Align = "left" | "center" | "right";
|
package/src/util/whats-new.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pkg from "../../package.json";
|
|
2
|
+
import changelogText from "../../CHANGELOG.md" with { type: "text" };
|
|
2
3
|
import { compareVersions } from "../commands/update";
|
|
3
4
|
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
@@ -198,9 +199,12 @@ export function renderWhatsNew(sections: ChangelogSection[], opts?: WhatsNewRend
|
|
|
198
199
|
|
|
199
200
|
/** Read the CHANGELOG.md that ships next to this package (running version's notes). */
|
|
200
201
|
export async function loadBundledChangelog(): Promise<string | null> {
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
202
|
+
// CHANGELOG.md is embedded at build time via the text import above, so the
|
|
203
|
+
// running version's notes are available offline AND inside the `--compile`
|
|
204
|
+
// single binary — where `import.meta.dir` resolves to the bunfs virtual root
|
|
205
|
+
// and the on-disk path below does not exist.
|
|
206
|
+
if (changelogText && changelogText.trim().length > 0) return changelogText;
|
|
207
|
+
// Dev fallback: read from the package root two levels up if the embed is empty.
|
|
204
208
|
const candidate = path.join(import.meta.dir, "..", "..", "CHANGELOG.md");
|
|
205
209
|
try {
|
|
206
210
|
return await readFile(candidate, "utf-8");
|