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 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.5",
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",
@@ -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/`) |
@@ -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
- return finish({ done: true, steps: step, doneReason: trimmed });
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
+ }
@@ -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
- try { proc.kill(); } catch {}
678
- killTimer = setTimeout(() => { try { proc.kill(9); } catch {} cancelReaders(); }, 3_000);
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
- try { proc.kill(9); } catch {}
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 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.
734
- if (proc.exitCode === null && proc.signalCode === null) { try { proc.kill(9); } catch {} }
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;
@@ -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
- fatal(err);
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 control bytes (incl. any stray ESC from copied ANSI
271
- * text) are dropped instead of being interpreted as keystrokes. */
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
- out += data[i]; i += 1; continue;
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
- * Lists the directory the prefix points at, filtering by the basename fragment,
7
- * directories first, hidden entries dropped, capped at 50 — directories keep a
8
- * trailing `/` so the next Tab descends. Pure given `cwd`: never throws (an
9
- * unreadable directory yields no matches). Extracted verbatim from the launch
10
- * REPL so it can be unit-tested in isolation. */
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 => !namePart || entry.name.toLowerCase().startsWith(namePart.toLowerCase()))
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)
@@ -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
- const pasteMerge: { buf: string[]; endWaiters: Array<() => void> } = { buf: [], endWaiters: [] };
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
- pasteMerge.endWaiters.push(resolve);
1646
- setTimeout(resolve, 250); // safety: never hang if the 201~ marker is dropped
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("(no clipboard path available — transcript printed above)");
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
  }
@@ -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;
@@ -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} (caller falls back to a local tool). When
77
- * `tmux` is set the whole sequence is wrapped in DCS passthrough (`ESC P tmux ; …
78
- * ESC \`) with every inner ESC doubled, the tmux contract for forwarding an
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(prefixHits(pool, token)), token, kind: "path" };
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 is a table row when it has at least one unescaped `|` and trims to start/contain cells. */
16
- function isTableRow(line: string): boolean {
17
- const t = line.trim();
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 the GFM delimiter row, e.g. `| --- | :--: | ---: |`. */
22
- function isDelimiterRow(line: string): boolean {
23
- const t = line.trim().replace(/^\||\|$/g, "");
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 (drop the outer pipes). */
25
+ /** Split a markdown table row into trimmed cell strings, honoring escaped `\|`. */
29
26
  function splitCells(line: string): string[] {
30
- let t = line.trim();
31
- if (t.startsWith("|")) t = t.slice(1);
32
- if (t.endsWith("|")) t = t.slice(0, -1);
33
- return t.split("|").map(c => c.trim());
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";
@@ -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
- // This module lives at src/util/whats-new.ts; CHANGELOG.md sits at the package
202
- // root, i.e. two levels up. Resolve against the module dir so it works from a
203
- // global install path just as from the repo.
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");