jeo-code 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.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 -2
- package/src/agent/AGENTS.md +1 -0
- package/src/agent/engine.ts +5 -2
- package/src/agent/memory.ts +28 -12
- 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/commands/launch/input.ts +143 -6
- package/src/commands/launch/mentions.ts +95 -6
- package/src/commands/launch.ts +43 -8
- package/src/commands/ralplan.ts +19 -6
- package/src/commands/team.ts +2 -1
- package/src/tui/app.ts +19 -6
- package/src/tui/clipboard.ts +33 -7
- package/src/tui/components/autocomplete.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ 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.8] - 2026-06-24
|
|
10
|
+
_Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The detached memory-distill worker always terminates itself.** `runMemoryDistillCommand` now wraps its work in `try/finally` and calls an injected `exit` (defaulting to `process.exit`) on every path — success, broken payload, and missing payload. `distillSessionMemory` caps the LLM call with a `Promise.race` timeout, but the race only *rejects*; it never aborts the underlying fetch, so a stalled provider socket kept the child's event loop alive and the `jeo memory-distill` worker lingered forever — one orphan per session, each pinning the full transcript in RSS (the observed "jeo bun" CPU/memory creep). An explicit exit closes the socket and reclaims the process.
|
|
14
|
+
- **The live reasoning/output block re-wraps only its trailing window.** The wrap memo in `app.ts` keyed on the full (up-to-hundreds-of-KB) buffer, so every 120 ms tick and every stream delta copied + compared the whole growing string — O(len) per frame, O(len²) over a long reasoning/tool stream (the streaming slowdown). `liveBlockWrapKey` now slices the ≤16 KB tail first, then keys + wraps that: identical visible output, O(tail) per frame regardless of total size.
|
|
15
|
+
- **The team loop no longer reprints the todo guide every iteration.** `runTeamEngine` printed `formatRalphTodoGuide` once per task — O(N²) lines that climbed the embedded-terminal renderer's CPU on a long task list. The guide prints once up front; per-task progress lines carry the rest.
|
|
16
|
+
- **The readline caret stays aligned after an image attach.** Both the paste and Ctrl+V image-attach paths now call readline's internal `_refreshLine()` after swapping the `[image #n]` tag in (mirroring the slash/tab-completion accept paths), so the stale row/cursor model no longer offsets the real caret several columns on the next keystroke.
|
|
17
|
+
|
|
18
|
+
### Verified
|
|
19
|
+
- `bun run typecheck` clean; full suite green — 1914 pass / 0 fail across 229 files — including the new `runMemoryDistillCommand` always-exits assertions (success + no-payload paths) and the `liveBlockWrapKey` bounded-key / tail-collision tests.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## [0.7.7] - 2026-06-23
|
|
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._
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **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`.
|
|
27
|
+
- **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.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- **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.
|
|
31
|
+
- **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.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
### Verified
|
|
38
|
+
- `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.
|
|
39
|
+
|
|
9
40
|
## [0.7.6] - 2026-06-23
|
|
10
41
|
_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._
|
|
11
42
|
|
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.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
|
|
197
|
+
- **[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.
|
|
196
198
|
- **[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.
|
|
197
199
|
- **[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.
|
|
198
200
|
- **[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.
|
|
199
|
-
- **[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.
|
|
200
|
-
- **[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).
|
|
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.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
|
|
197
|
+
- **[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.
|
|
196
198
|
- **[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.
|
|
197
199
|
- **[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.
|
|
198
200
|
- **[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.
|
|
199
|
-
- **[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.
|
|
200
|
-
- **[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).
|
|
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.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
|
|
199
|
+
- **[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.
|
|
198
200
|
- **[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.
|
|
199
201
|
- **[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.
|
|
200
202
|
- **[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.
|
|
201
|
-
- **[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.
|
|
202
|
-
- **[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).
|
|
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.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
|
|
197
|
+
- **[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.
|
|
196
198
|
- **[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.
|
|
197
199
|
- **[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.
|
|
198
200
|
- **[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.
|
|
199
|
-
- **[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.
|
|
200
|
-
- **[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).
|
|
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.8",
|
|
4
4
|
"description": "Clean, highly optimized AI coding agent using spec-first loop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.ts",
|
|
@@ -64,5 +64,6 @@
|
|
|
64
64
|
"playwright": "^1.61.0",
|
|
65
65
|
"typescript": "^5.6.3",
|
|
66
66
|
"ws": "^8.21.0"
|
|
67
|
-
}
|
|
67
|
+
},
|
|
68
|
+
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
|
68
69
|
}
|
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
|
package/src/agent/memory.ts
CHANGED
|
@@ -876,21 +876,37 @@ export async function spawnDetachedDistill(
|
|
|
876
876
|
}
|
|
877
877
|
|
|
878
878
|
/** CLI worker for the detached child: payload → distill → cleanup. Silent by design. */
|
|
879
|
-
export async function runMemoryDistillCommand(
|
|
880
|
-
|
|
881
|
-
|
|
879
|
+
export async function runMemoryDistillCommand(
|
|
880
|
+
args: string[],
|
|
881
|
+
// Injected for tests; the REAL detached worker must terminate ITSELF. distillSessionMemory
|
|
882
|
+
// caps the LLM call with a Promise.race timeout, but the race only REJECTS — it never aborts
|
|
883
|
+
// the underlying fetch. A stalled provider socket therefore stays open, the worker's event
|
|
884
|
+
// loop never drains, and the `jeo memory-distill` child lingers forever — one orphan per
|
|
885
|
+
// session, each pinning the full transcript in RSS. That is the observed "jeo bun" pileup
|
|
886
|
+
// (CPU/memory creeping up over time). An explicit exit closes the socket and reclaims it.
|
|
887
|
+
exit: (code: number) => void = (code) => process.exit(code),
|
|
888
|
+
): Promise<void> {
|
|
882
889
|
try {
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
890
|
+
const payloadPath = (args[0] ?? "").trim();
|
|
891
|
+
if (!payloadPath) return;
|
|
892
|
+
try {
|
|
893
|
+
const payloadContent = await fs.readFile(payloadPath, "utf-8");
|
|
894
|
+
const payload = JSON.parse(payloadContent) as { model?: string; messages?: Message[] };
|
|
895
|
+
const bundleDir = path.join(process.cwd(), ".jeo", "memory");
|
|
896
|
+
await saveRawPayload(bundleDir, payload);
|
|
897
|
+
if (Array.isArray(payload.messages)) {
|
|
898
|
+
await distillSessionMemory(payload.messages, process.cwd(), { model: payload.model });
|
|
899
|
+
}
|
|
900
|
+
} catch {
|
|
901
|
+
// best-effort — a broken payload must not leave error noise in a detached child
|
|
902
|
+
} finally {
|
|
903
|
+
await fs.unlink(payloadPath).catch(() => {});
|
|
889
904
|
}
|
|
890
|
-
} catch {
|
|
891
|
-
// best-effort — a broken payload must not leave error noise in a detached child
|
|
892
905
|
} finally {
|
|
893
|
-
|
|
906
|
+
// Guaranteed on every path (incl. the no-payload early return and a distill timeout):
|
|
907
|
+
// never leave the detached worker alive waiting on a dangling fetch.
|
|
908
|
+
exit(0);
|
|
894
909
|
}
|
|
895
910
|
}
|
|
896
911
|
|
|
912
|
+
|
|
@@ -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
|
+
}
|
|
@@ -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 */ }
|
|
@@ -2631,7 +2650,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2631
2650
|
setImmediate(() => {
|
|
2632
2651
|
void (async () => {
|
|
2633
2652
|
try {
|
|
2634
|
-
const rli = rl as unknown as { line: string; cursor: number };
|
|
2653
|
+
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2635
2654
|
const before = rli.line;
|
|
2636
2655
|
const dropped = await attachImagePaths(before, pendingImages.length + 1);
|
|
2637
2656
|
if (dropped.images.length === 0 || dropped.text === before) return;
|
|
@@ -2639,6 +2658,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2639
2658
|
rli.line = dropped.text;
|
|
2640
2659
|
rli.cursor = dropped.text.length; // drop lands at the caret (line end in the common case)
|
|
2641
2660
|
typedLine = dropped.text;
|
|
2661
|
+
// Keep readline's internal screen model in step with the swapped-in tag so
|
|
2662
|
+
// the caret is not offset on the next keystroke (mirrors the Ctrl+V path).
|
|
2663
|
+
rli._refreshLine?.();
|
|
2642
2664
|
if (previewArmed) drawFooter(previewLines(typedLine, navIdx));
|
|
2643
2665
|
} finally {
|
|
2644
2666
|
pasteInFlight = false;
|
|
@@ -2700,12 +2722,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2700
2722
|
if (!img) return;
|
|
2701
2723
|
pendingImages.push(img);
|
|
2702
2724
|
const tag = `[image #${pendingImages.length}]`;
|
|
2703
|
-
const rli = rl as unknown as { line: string; cursor: number };
|
|
2725
|
+
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2704
2726
|
const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
|
|
2705
2727
|
const sep = rli.line.length > 0 && at > 0 && rli.line[at - 1] !== " " ? " " : "";
|
|
2706
2728
|
rli.line = rli.line.slice(0, at) + sep + tag + " " + rli.line.slice(at);
|
|
2707
2729
|
rli.cursor = at + sep.length + tag.length + 1;
|
|
2708
2730
|
typedLine = rli.line;
|
|
2731
|
+
// Sync readline's internal screen model to the injected tag (same as the
|
|
2732
|
+
// slash/tab completion accept paths): without it readline's stale row/cursor
|
|
2733
|
+
// model offsets the real caret several columns on the next keystroke — the
|
|
2734
|
+
// "caret pushed past the tag after attaching an image" shift.
|
|
2735
|
+
rli._refreshLine?.();
|
|
2709
2736
|
if (previewArmed) drawFooter(previewLines(typedLine, navIdx));
|
|
2710
2737
|
} finally {
|
|
2711
2738
|
pasteInFlight = false;
|
|
@@ -3327,10 +3354,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3327
3354
|
if (copied.osc52 || copied.local) {
|
|
3328
3355
|
const via = [copied.local ? "system tool" : "", copied.osc52 ? "OSC52" : ""].filter(Boolean).join(" + ");
|
|
3329
3356
|
console.log(`(transcript copied to clipboard via ${via} — ${text.length} chars)`);
|
|
3357
|
+
// OSC52 is the only path that reaches the OUTER terminal over SSH/tmux. If it was
|
|
3358
|
+
// skipped for size, a remote paste won't find the text — say so (and how to lift it).
|
|
3359
|
+
if (copied.osc52SkippedTooLarge) {
|
|
3360
|
+
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.)");
|
|
3361
|
+
}
|
|
3330
3362
|
} else {
|
|
3331
3363
|
console.log(text);
|
|
3332
|
-
console.log(
|
|
3364
|
+
console.log(copied.osc52SkippedTooLarge
|
|
3365
|
+
? "(transcript too large for OSC52 and no local clipboard tool — printed above)"
|
|
3366
|
+
: "(no clipboard path available — transcript printed above)");
|
|
3333
3367
|
}
|
|
3368
|
+
|
|
3334
3369
|
} catch (err) {
|
|
3335
3370
|
console.log(`! dump failed: ${(err as Error).message}`);
|
|
3336
3371
|
}
|
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/commands/team.ts
CHANGED
|
@@ -346,7 +346,8 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
346
346
|
const currentTask = teamState.pending_tasks[0];
|
|
347
347
|
log(`\n${categoryBadge("progress")} Current task: "${currentTask}"`);
|
|
348
348
|
const activeIndex = activeStepIndex(tasks.length, teamState.pending_tasks);
|
|
349
|
-
|
|
349
|
+
// Guide printed once above; per-task lines convey progress. No per-iteration
|
|
350
|
+
// reprint — it was O(N²) lines, climbing the embedded-terminal renderer's CPU.
|
|
350
351
|
|
|
351
352
|
if (opts.onProgress) {
|
|
352
353
|
opts.onProgress({ skill: "team", phase: "executing", detail: `Current task: ${currentTask}` });
|
package/src/tui/app.ts
CHANGED
|
@@ -126,6 +126,15 @@ export function tailForWrap(text: string, maxChars = FRAME_WRAP_TAIL_CHARS): str
|
|
|
126
126
|
return text.length > maxChars ? text.slice(text.length - maxChars) : text;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/** Memoization key for a live-block wrap (Thinking / tool Output). Derived from the
|
|
130
|
+
* TRAILING window only (see `tailForWrap`): the rendered rows depend solely on the
|
|
131
|
+
* tail, so keying on the full streamed buffer just made every 120ms tick and every
|
|
132
|
+
* delta copy + compare the whole growing string — O(len) per frame, i.e. O(len²) over
|
|
133
|
+
* a long stream. Bounding the key to the ≤16KB tail keeps per-frame cost O(window). */
|
|
134
|
+
export function liveBlockWrapKey(wrapW: number, text: string): string {
|
|
135
|
+
return `${wrapW}\u0000${tailForWrap(text)}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
129
138
|
/** Max lines of a committed reasoning block kept in scrollback (gjc-style collapse): a
|
|
130
139
|
* long chain-of-thought is clipped with a "+N more" hint so it never floods the ledger. */
|
|
131
140
|
export const THINKING_COMMIT_MAX_LINES = 12;
|
|
@@ -1300,14 +1309,18 @@ export class LaunchTui {
|
|
|
1300
1309
|
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1301
1310
|
if (!text.trim()) return [];
|
|
1302
1311
|
const wrapW = Math.max(8, cols - 2);
|
|
1303
|
-
// Memoize the wrap
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1312
|
+
// Memoize the wrap on the TRAILING window only. The block displays just its last few
|
|
1313
|
+
// wrapped rows, so wrap cost is already capped by `tailForWrap`. The cache KEY must be
|
|
1314
|
+
// bounded too: keying on the full (up-to-hundreds-of-KB) `text` made every 120ms tick
|
|
1315
|
+
// and every stream delta copy + compare the whole growing buffer — O(len) per frame, so
|
|
1316
|
+
// O(len²) over a long reasoning/tool stream (the streaming slowdown). Slice first, then
|
|
1317
|
+
// key + wrap the ≤16KB tail: identical visible output, O(tail) per frame regardless of
|
|
1318
|
+
// total size. Per-label slot (Thinking / Output) keyed by wrap width + tail.
|
|
1307
1319
|
let cache = this.liveBlockWrapCaches.get(cacheKey);
|
|
1308
1320
|
if (!cache) { cache = lastValueCache<string[]>(); this.liveBlockWrapCaches.set(cacheKey, cache); }
|
|
1309
|
-
const
|
|
1310
|
-
|
|
1321
|
+
const tail = tailForWrap(text);
|
|
1322
|
+
const wrapped = cache(liveBlockWrapKey(wrapW, text), () =>
|
|
1323
|
+
tail
|
|
1311
1324
|
.split("\n")
|
|
1312
1325
|
.flatMap(l => wrapTextWithAnsi(l, wrapW))
|
|
1313
1326
|
.filter(l => l.length > 0));
|
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).
|