Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e2eda3bb4 | ||
|
|
e6718fa118 | ||
|
|
f98030f28a | ||
|
|
93e5464370 | ||
|
|
f5b04214cf | ||
|
|
008f737e1f | ||
|
|
2100e7ab54 | ||
|
|
c53fdfa7c9 | ||
|
|
5912a35bbb | ||
|
|
d86d6aeef2 | ||
|
|
30cc579773 | ||
|
|
a54f1644f5 | ||
|
|
23e68dc979 | ||
|
|
8c99c6b1ca | ||
|
|
9b28a829e5 | ||
|
|
b5ebd07fa8 | ||
|
|
7e6ed34f0d | ||
|
|
34322e492a | ||
|
|
3e260e6077 | ||
|
|
b7006d1ad8 | ||
|
|
0ee0cc4350 | ||
|
|
e91fda3691 | ||
|
|
e4e0379c45 | ||
|
|
c165dff114 | ||
|
|
319198d2f0 | ||
|
|
f26e6ac9c6 | ||
|
|
f75c3860e9 | ||
|
|
170b1d3d19 | ||
|
|
5d65c78fb4 | ||
|
|
8098133ba5 | ||
|
|
2e81412605 | ||
|
|
14d3556179 | ||
|
|
d134678530 | ||
|
|
02a55235cf | ||
|
|
1a6268bde5 | ||
|
|
7030e26b3a | ||
|
|
d4d423a4ca | ||
|
|
3930d2e2c9 | ||
|
|
49e79322b4 | ||
|
|
1b130e69b3 | ||
|
|
145184d091 | ||
|
|
c582dc1461 | ||
|
|
4b4730cff7 | ||
|
|
fd58b4661f | ||
|
|
7e8a160b0e | ||
|
|
edc007619b | ||
|
|
a14a2275ff | ||
|
|
82b51f7921 | ||
|
|
a2c7e5d44c | ||
|
|
03538a455a | ||
|
|
07e84e43b3 | ||
|
|
9d1544d799 | ||
|
|
a27e8151b3 | ||
|
|
88c2a7d000 | ||
|
|
3b25e3898f | ||
|
|
d23d3855ec | ||
|
|
104af3abb9 | ||
|
|
62fa85867c | ||
|
|
f75ff740ac | ||
|
|
e349a35716 | ||
|
|
f1f58b9996 | ||
|
|
f4c02fd64e | ||
|
|
e982cbeb04 | ||
|
|
20ce81a6be | ||
|
|
21e3ccc891 | ||
|
|
992e60980c | ||
|
|
74b1ad4302 | ||
|
|
9ad04cf819 | ||
|
|
b46935c606 | ||
|
|
b28a5fe384 | ||
|
|
b66898ea28 | ||
|
|
9aca45cb65 | ||
|
|
4dccf0cee4 | ||
|
|
1f91b44708 | ||
|
|
b25929824a | ||
|
|
3fd9a2b2db | ||
|
|
6a98d52d54 | ||
|
|
3847c0e6fd | ||
|
|
5023e5dfa1 | ||
|
|
eac0443c4c | ||
|
|
14f3eae62a | ||
|
|
aec53651ed | ||
|
|
93f3c2a357 | ||
|
|
9ea3d361b1 | ||
|
|
271721694b | ||
|
|
171597ca05 | ||
|
|
7b0b53671f | ||
|
|
884b19ccb5 | ||
|
|
6db00b2266 | ||
|
|
7f64423615 | ||
|
|
6d3f4918bf | ||
|
|
448eb5d462 | ||
|
|
924576d2ee | ||
|
|
033f964be0 | ||
|
|
2de5b57133 | ||
|
|
d1929f2fd4 | ||
|
|
14aa1785f8 | ||
|
|
0e0fd5df05 | ||
|
|
07f547eea8 | ||
|
|
f918d44c8d | ||
|
|
0d06875bb7 | ||
|
|
ffefa8c07f | ||
|
|
883908ab55 | ||
|
|
eed4025918 | ||
|
|
6b90c7eb0d | ||
|
|
958645ed7f | ||
|
|
eb8cc17993 | ||
|
|
b3a4d131d1 | ||
|
|
f368f3a1c0 | ||
|
|
8ecc588a3e | ||
|
|
55677dd671 | ||
|
|
d615090dcd | ||
|
|
8c58faf292 | ||
|
|
802346f909 | ||
|
|
552bc5bc63 | ||
|
|
d59713d3e3 | ||
|
|
6ab532018a | ||
|
|
047d7183d7 | ||
|
|
4d85e5144b | ||
|
|
dcc8c3ee42 | ||
|
|
289c58ef36 | ||
|
|
094eb4b8ba | ||
|
|
dd7825b481 | ||
|
|
4c186b62d4 | ||
|
|
fef3c925cd | ||
|
|
785ae19757 | ||
|
|
873f870e23 | ||
|
|
54927f692f | ||
|
|
efcd191f96 | ||
|
|
2e8a4f2443 | ||
|
|
2f5f62ab09 | ||
|
|
919435eb84 | ||
|
|
bed9f8cff6 | ||
|
|
6c7995f5f5 | ||
|
|
7ea226509c | ||
|
|
6bef4a3f82 | ||
|
|
16494d4b1e | ||
|
|
f84dfb8476 | ||
|
|
07b217cf4a | ||
|
|
9bfb95d795 | ||
|
|
285dbb14cc | ||
|
|
cac6626586 | ||
|
|
5a373d1031 | ||
|
|
fe2e916937 | ||
|
|
66ac4dbf02 | ||
|
|
44c3dd1239 | ||
|
|
87abe8c335 | ||
|
|
148ba4e07b | ||
|
|
89ee213454 | ||
|
|
a04651905f | ||
|
|
600ea041ef | ||
|
|
1ec2aa136a | ||
|
|
4ced6681c2 | ||
|
|
08e2f9998d | ||
|
|
792695b65a | ||
|
|
da2ed6da25 | ||
|
|
bceba665c0 | ||
|
|
714b4617dd | ||
|
|
e9a8e93c83 | ||
|
|
ab1a54b72e | ||
|
|
5aaedbf787 | ||
|
|
6680795ce7 | ||
|
|
9b6e51dc67 | ||
|
|
40524f930f | ||
|
|
086cb86f0d | ||
|
|
ff47bffe0c | ||
|
|
431c8d3b89 | ||
|
|
e32501ee59 | ||
|
|
7d8487cefb | ||
|
|
4cd38c9291 | ||
|
|
9a6ab62562 | ||
|
|
b02754acd2 | ||
|
|
b3c5b2a57b | ||
|
|
796bb09c5b | ||
|
|
5aad7e1eb1 | ||
|
|
0dfde96f81 | ||
|
|
9a67137954 | ||
|
|
1f0ae9f551 | ||
|
|
22dd6fdda7 | ||
|
|
b272858edb | ||
|
|
3f79b283e2 | ||
|
|
9618ef413b | ||
|
|
0ad3b0e539 | ||
|
|
dc9fd4dbd1 | ||
|
|
8e6acc30d5 | ||
|
|
6d41ffc587 | ||
|
|
f51a32569f | ||
|
|
add6845edf | ||
|
|
4ab812d93b | ||
|
|
ff6a749c11 | ||
|
|
f2d97fb17d | ||
|
|
be278f8b1c | ||
|
|
910aa5e071 | ||
|
|
5ede65f454 | ||
|
|
4dc4f80fa3 | ||
|
|
3401d63063 | ||
|
|
be0a9582bb | ||
|
|
86cc412395 | ||
|
|
1afca97c1b | ||
|
|
a00aa9bec8 | ||
|
|
7d99cd6618 | ||
|
|
e075eb7acc | ||
|
|
d6de9735ab | ||
|
|
85d16ebe97 | ||
|
|
96084f7462 | ||
|
|
82c024a854 | ||
|
|
78e402cf14 | ||
|
|
3a6e32d8f3 | ||
|
|
e080d9fc6b | ||
|
|
ea0b498829 | ||
|
|
682007a250 | ||
|
|
7ffdf44135 | ||
|
|
ceb84790bb | ||
|
|
12a03fd3d1 | ||
|
|
059ca8ea24 | ||
|
|
c219b6ec58 | ||
|
|
8bb697a53f | ||
|
|
75e9cadcd5 | ||
|
|
c4e1d7c2b2 | ||
|
|
da39033e81 | ||
|
|
f62ac3f688 | ||
|
|
aaab953d8a | ||
|
|
e0d0860d7b | ||
|
|
dcfc5b9ec0 | ||
|
|
472162d135 | ||
|
|
71b1613d84 | ||
|
|
d93bdba59b | ||
|
|
3b6abd920e | ||
|
|
7f2509ecd2 | ||
|
|
2eebedfea5 | ||
|
|
930961bd85 | ||
|
|
93be92a5fb | ||
|
|
95cae8fd8a | ||
|
|
5c413cf9a3 | ||
|
|
c386030573 | ||
|
|
199028fa2e | ||
|
|
8d77e4565c | ||
|
|
683715cd7a | ||
|
|
2bb8e6f255 | ||
|
|
c3c0ef632a | ||
|
|
5dd835ff24 | ||
|
|
0a24903eb0 | ||
|
|
de8e3703f2 | ||
|
|
b7fc335bf5 | ||
|
|
821622e80d | ||
|
|
8fbc51534d | ||
|
|
3f284cbb9a | ||
|
|
54484ed137 | ||
|
|
9bf5236680 | ||
|
|
4428e8bc01 | ||
|
|
b2e848d124 | ||
|
|
3020a0431c | ||
|
|
6fbe1e2d1d | ||
|
|
9ba9a5048b | ||
|
|
188e7cc9a9 | ||
|
|
1a6a0c628e | ||
|
|
1c08b8e4e9 | ||
|
|
f87ab133f1 | ||
|
|
98615ca5b9 | ||
|
|
5e6d6deeab | ||
|
|
c5253b2ca3 | ||
|
|
a8adfcbf6d | ||
|
|
558908aef2 | ||
|
|
472c597c5e | ||
|
|
97aa75f2da | ||
|
|
dfceb8acac | ||
|
|
c6ab063c21 | ||
|
|
2af8432ce4 | ||
|
|
b92dab97e6 | ||
|
|
e13f040833 | ||
|
|
4b16bc3112 | ||
|
|
181b5128ac | ||
|
|
732d6039dc | ||
|
|
4eba9d0a2f | ||
|
|
7fccc16a54 | ||
|
|
b5e3dfe4b1 | ||
|
|
519be7559b | ||
|
|
d392c4aa00 | ||
|
|
340ae2fde2 | ||
|
|
1010e44b22 | ||
|
|
b355c9928e | ||
|
|
aaea300699 | ||
|
|
7fd55fa86d | ||
|
|
31c01cdf1d | ||
|
|
aa2b6acb95 | ||
|
|
2f1a4d85a1 | ||
|
|
e34708a319 | ||
|
|
6b58977875 | ||
|
|
7a9cb7bf34 | ||
|
|
04e7ff9380 |
No files matched your search
@@ -1,48 +0,0 @@
|
|||||||
# Strict Rust compiler configuration for zesdex
|
|
||||||
# Forces all warnings as errors, enables maximum optimization for release builds.
|
|
||||||
|
|
||||||
[target.'cfg(all())']
|
|
||||||
# Treat all warnings as errors — zero tolerance policy
|
|
||||||
rustflags = [
|
|
||||||
# Force all lints to error level (stricter than -Dwarnings)
|
|
||||||
"-W", "unused",
|
|
||||||
"-W", "dead_code",
|
|
||||||
"-W", "unreachable_code",
|
|
||||||
"-W", "unused_imports",
|
|
||||||
"-W", "unused_variables",
|
|
||||||
"-W", "unused_mut",
|
|
||||||
"-W", "unused_must_use",
|
|
||||||
"-W", "unused_unsafe",
|
|
||||||
"-W", "unused_extern_crates",
|
|
||||||
|
|
||||||
# Safety-critical
|
|
||||||
"-W", "trivial_casts",
|
|
||||||
"-W", "trivial_numeric_casts",
|
|
||||||
|
|
||||||
# Deprecation and future compat
|
|
||||||
"-W", "deprecated",
|
|
||||||
"-W", "deprecated_in_future",
|
|
||||||
"-W", "keyword_idents",
|
|
||||||
"-W", "noop_method_call",
|
|
||||||
|
|
||||||
# Type system
|
|
||||||
"-W", "invalid_type_param_default",
|
|
||||||
"-W", "unused_labels",
|
|
||||||
"-W", "while_true",
|
|
||||||
]
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
# Maximum speed optimizations for release builds
|
|
||||||
opt-level = 3
|
|
||||||
debug = false
|
|
||||||
debug-assertions = false
|
|
||||||
overflow-checks = true
|
|
||||||
lto = "fat"
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
strip = "symbols"
|
|
||||||
|
|
||||||
[profile.dev]
|
|
||||||
# Keep dev fast but still strict
|
|
||||||
opt-level = 0
|
|
||||||
debug = true
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
name: commit-convention
|
|
||||||
description: Conventional Commits format and version-bump rules for this repo (Bahasa Indonesia commit style). Use when creating a git commit in zesdex.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Commit Convention
|
|
||||||
|
|
||||||
Gunakan **Conventional Commits** untuk semua commit. Format:
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Type & efek ke versi:**
|
|
||||||
|
|
||||||
| Type | Bump | Kapan pakai |
|
|
||||||
|-------------|-------|------------------------------------------|
|
|
||||||
| `feat` | minor | Fitur baru |
|
|
||||||
| `fix` | patch | Perbaikan bug |
|
|
||||||
| `chore` | patch | Maintenance, update deps, dll |
|
|
||||||
| `docs` | patch | Perubahan dokumentasi/comment |
|
|
||||||
| `refactor` | patch | Refactor kode tanpa perubahan fungsional |
|
|
||||||
| `test` | patch | Nambah/ubah test |
|
|
||||||
| `style` | patch | Formatting, whitespace, lint |
|
|
||||||
| `perf` | patch | Optimasi performa |
|
|
||||||
| `ci` | patch | Perubahan CI/CD |
|
|
||||||
|
|
||||||
**Catatan:**
|
|
||||||
- **Semua type menghasilkan release** (patch minimal). Tidak ada commit yang "skip release".
|
|
||||||
- Tambahkan `BREAKING CHANGE:` di body commit untuk bump **major**.
|
|
||||||
- **Scope** opsional, tapi direkomendasikan (misal `feat(agent):`, `fix(ipc):`).
|
|
||||||
|
|
||||||
### Contoh
|
|
||||||
|
|
||||||
```
|
|
||||||
feat(tool): add batch file delete
|
|
||||||
|
|
||||||
chore: bump reqwest to 0.12
|
|
||||||
|
|
||||||
refactor(harness): flatten guard pipeline
|
|
||||||
|
|
||||||
fix(ipc): reconnect loop on socket timeout
|
|
||||||
|
|
||||||
docs: add architecture diagram to README
|
|
||||||
|
|
||||||
BREAKING CHANGE: IPC frame header changed from 4-byte to 8-byte length
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Rust build artifacts
|
||||||
|
target/
|
||||||
|
|
||||||
|
# VCS
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Local/secret files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|
||||||
|
# Kilo metadata
|
||||||
|
.kilo/
|
||||||
|
|
||||||
|
# Docs lessons
|
||||||
|
docs/lesson/
|
||||||
+14
-10
@@ -7,7 +7,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
BUN_INSTALL: "${{ github.workspace }}/.bun"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
@@ -16,16 +16,20 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Bun
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
|
||||||
components: clippy
|
|
||||||
|
|
||||||
- name: Build
|
- name: Install dependencies
|
||||||
run: cargo build --release
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: bun run check
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test
|
run: bun test
|
||||||
|
|
||||||
- name: Clippy
|
- name: Build CLI bundle
|
||||||
run: cargo clippy -- -D warnings
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Smoke — CLI help
|
||||||
|
run: ./dist/zesdex --help
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Build & Deploy (Bun)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.14
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: bun run check
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun test
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
run: |
|
||||||
|
bun run build
|
||||||
|
test -x dist/zesdex && echo "binary OK ($(stat -c%s dist/zesdex) bytes)"
|
||||||
|
|
||||||
|
- name: Setup SSH key
|
||||||
|
if: env.VPS_HOST != '' && env.VPS_HOST != 'null'
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||||
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||||
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- name: Deploy zesdex to VPS
|
||||||
|
if: env.VPS_HOST != '' && env.VPS_HOST != 'null'
|
||||||
|
run: |
|
||||||
|
scp dist/zesdex "$VPS_USER@$VPS_HOST:/usr/local/bin/zesdex"
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "chmod +x /usr/local/bin/zesdex && systemctl restart zesdex"
|
||||||
@@ -16,14 +16,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Bun
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Build
|
- name: Install
|
||||||
run: cargo build --release
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: bun run check
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test
|
run: bun test
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Semantic Release
|
name: Semantic Release
|
||||||
@@ -34,24 +37,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Bun
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Install semantic-release (isolated prefix — do NOT touch repo package.json)
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "lts/*"
|
|
||||||
|
|
||||||
- name: Install semantic-release
|
|
||||||
run: |
|
run: |
|
||||||
npm install -D \
|
mkdir -p /tmp/sr-tools
|
||||||
|
npm install --prefix /tmp/sr-tools \
|
||||||
semantic-release \
|
semantic-release \
|
||||||
@semantic-release/exec \
|
@semantic-release/exec \
|
||||||
@semantic-release/git \
|
@semantic-release/git \
|
||||||
@semantic-release/changelog \
|
@semantic-release/changelog \
|
||||||
@semantic-release/github
|
@semantic-release/github
|
||||||
|
echo "/tmp/sr-tools/node_modules/.bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Run semantic-release
|
- name: Run semantic-release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: npx semantic-release
|
run: semantic-release
|
||||||
+9
-1
@@ -2,5 +2,13 @@ target/
|
|||||||
.env
|
.env
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
node_modules/
|
node_modules/
|
||||||
package.json
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
.superpowers/
|
||||||
|
docs/lesson/
|
||||||
|
.kilo/
|
||||||
|
.hermes/
|
||||||
|
# Runtime edit-log artifacts
|
||||||
|
dist/
|
||||||
|
apps/**/edit-log/
|
||||||
|
edit-log/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"branches": ["main"],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"prepareCmd": "sed -i 's/\"version\": \"[^\"]*\"/\"version\": \"${nextRelease.version}\"/' package.json && bun install --frozen-lockfile && bun run check && bun run build"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/git",
|
||||||
|
{
|
||||||
|
"assets": ["package.json", "bun.lock", "CHANGELOG.md"],
|
||||||
|
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/github",
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"path": "dist/zesdex",
|
||||||
|
"label": "zesdex-${nextRelease.version}-linux-amd64"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
Codemap Update Report — 2026-07-12
|
|
||||||
====================================
|
|
||||||
|
|
||||||
Status: FIRST GENERATION (no previous codemaps to compare)
|
|
||||||
|
|
||||||
Files created:
|
|
||||||
- docs/CODEMAPS/architecture.md (new)
|
|
||||||
- docs/CODEMAPS/backend.md (new)
|
|
||||||
- docs/CODEMAPS/frontend.md (new)
|
|
||||||
- docs/CODEMAPS/data.md (new)
|
|
||||||
- docs/CODEMAPS/dependencies.md (new)
|
|
||||||
|
|
||||||
Source scanned:
|
|
||||||
- 124 Rust source files
|
|
||||||
- 30 directories
|
|
||||||
- 103 modules
|
|
||||||
- 10,402 lines total
|
|
||||||
|
|
||||||
No previous codemaps found — diff calculation skipped.
|
|
||||||
Freshness: all documents generated 2026-07-12.
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to AI agents (Kilo / Hermes) working in the zesdex repository.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Zesdex is an **AI-native autonomous coding agent**, implemented in **TypeScript / Bun**.
|
||||||
|
It was rebuilt to be a **pure CLI/TUI tool** — there are **no network server interfaces**
|
||||||
|
(REST API / WebSocket / gRPC / web / daemon were all removed). The only two entry points:
|
||||||
|
|
||||||
|
- `zesdex` — interactive **OpenTUI** (primary interface, wired to the real agent).
|
||||||
|
- `zesdex --headless "<prompt>"` — one-shot non-interactive turn (headless mode).
|
||||||
|
|
||||||
|
## Best Practice Conventions
|
||||||
|
|
||||||
|
1. **Clean Architecture** — strict `domain` / `application` / `infrastructure` layering
|
||||||
|
under `src/`, with thin `interfaces/` for the TUI + CLI.
|
||||||
|
- `domain` has zero I/O and zero framework dependencies.
|
||||||
|
- `application` depends only on `domain` (ports + turn service).
|
||||||
|
- `infrastructure` implements domain ports (LLM client, tool executor, file repos).
|
||||||
|
- DTOs/value objects cross layer boundaries, not entities.
|
||||||
|
|
||||||
|
2. **Clean Code** — functions under ~40 lines, one level of abstraction per function,
|
||||||
|
descriptive names, no flag arguments, no commented-out dead code.
|
||||||
|
|
||||||
|
3. **Documentation** — every exported `function`, `class`, `interface`, and `type`
|
||||||
|
needs a `/** */` doc comment explaining what, flow, why, and return value.
|
||||||
|
|
||||||
|
4. **Commit Convention** — Conventional Commits in Bahasa Indonesia:
|
||||||
|
`feat(scope):`, `fix(scope):`, `chore:`, `docs:`.
|
||||||
|
|
||||||
|
5. **Error Handling** — throw `Error` with clear messages; the turn loop wraps LLM
|
||||||
|
errors and recovers. Log with `console.*` (redirected to `~/.local/share/zesdex/zesdex.log`).
|
||||||
|
|
||||||
|
6. **Testing** — Bun tests (`bun test`) using `describe/test/expect`. Tests are
|
||||||
|
F.I.R.S.T. Logic-pure modules (TUI state/action/command/controller) are unit-tested.
|
||||||
|
|
||||||
|
7. **No Compiler Bypasses** — no `@ts-ignore` / `any` where a real type exists.
|
||||||
|
|
||||||
|
8. **Boy Scout Rule** — leave every module cleaner than you found it.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Bun 1.3.14 (packageManager pinned in `package.json`).
|
||||||
|
- TypeScript strict mode, `tsconfig.json` uses `paths` aliases (`@zesdex/*`) →
|
||||||
|
`src/*`. `bun build ... --compile` bundles into a single `dist/zesdex` binary.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `bun run check` — `tsc --noEmit` typecheck.
|
||||||
|
- `bun run test` — run all Bun tests.
|
||||||
|
- `bun run build` — compile `dist/zesdex` binary.
|
||||||
|
- `bun run tui` — run the interactive TUI.
|
||||||
|
- `bun run headless "<prompt>"` — one-shot agent turn.
|
||||||
|
- `bun run bootstrap` — seed default settings/app_config.
|
||||||
+410
@@ -1,3 +1,413 @@
|
|||||||
|
# [1.24.0](https://github.com/asepharyana/zesdex/compare/v1.23.2...v1.24.0) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **tui:** add new flow helpers for bash exit code handling and verification prompts ([f98030f](https://github.com/asepharyana/zesdex/commit/f98030f28a20e3d9ba807bb98ddbf142511a3964))
|
||||||
|
* **tui:** enhance autocomplete functionality and UI interactions ([008f737](https://github.com/asepharyana/zesdex/commit/008f737e1f5d0eef6a07ebe0fff2c9730bd44eb4))
|
||||||
|
* **tui:** enhance command execution on autocomplete and improve UI responsiveness ([f5b0421](https://github.com/asepharyana/zesdex/commit/f5b04214cf385ba2e4a02f6ce202436ffab23c35))
|
||||||
|
* **tui:** enhance model selection and improve UI components ([c53fdfa](https://github.com/asepharyana/zesdex/commit/c53fdfa7c99887e8f3dfe68e7f1b2e766de039b1))
|
||||||
|
* **tui:** implement interactive menu system for model and config selection ([2100e7a](https://github.com/asepharyana/zesdex/commit/2100e7ab54460f28b67c3bb07855eef8f04eb9ec))
|
||||||
|
* **tui:** implement live model fetching and enhance model selection menus ([93e5464](https://github.com/asepharyana/zesdex/commit/93e54643703ce7f18286bd6a988e0fab18cc9e66))
|
||||||
|
* **tui:** implement message compaction and summarization logic for efficient conversation handling ([e6718fa](https://github.com/asepharyana/zesdex/commit/e6718fa11825cccc74a9d100755226b7dfefc106))
|
||||||
|
|
||||||
|
## [1.23.2](https://github.com/asepharyana/zesdex/compare/v1.23.1...v1.23.2) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **tui:** sliding-window stream reconciler + UI polish ([d86d6ae](https://github.com/asepharyana/zesdex/commit/d86d6aeef2dcd4924ced14f21502a84f7e916a26))
|
||||||
|
|
||||||
|
## [1.23.1](https://github.com/asepharyana/zesdex/compare/v1.23.0...v1.23.1) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **tui:** repair layout overflow + streaming dup; polish UI/state ([a54f164](https://github.com/asepharyana/zesdex/commit/a54f1644f5943399568194b4f129185a9d841f1d))
|
||||||
|
|
||||||
|
# [1.23.0](https://github.com/asepharyana/zesdex/compare/v1.22.0...v1.23.0) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **auth:** perbaiki base64url encode JWT — string di-base64 dulu sebelum replace chars ([1b130e6](https://github.com/asepharyana/zesdex/commit/1b130e69b36603f894bcd45469b9feef28b18562))
|
||||||
|
* **ci:** Release workflow install semantic-release tanpa merusak package.json ([8c99c6b](https://github.com/asepharyana/zesdex/commit/8c99c6b1ca94e1d2b80dd59bb96eb24b7b3b2dfa))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **api:** port Fase 5 REST API — Bun.serve router + JWT middleware + auth/session/conversation/chat handlers + composition root ([49e7932](https://github.com/asepharyana/zesdex/commit/49e79322b4aa1824467e1c871cf68809c1a424ee))
|
||||||
|
* **auth:** port 3f auth infrastructure — Argon2 passwords + HS256 JWT tokens ([7e8a160](https://github.com/asepharyana/zesdex/commit/7e8a160b0e918d51dc559f55fa19395368a4d84e))
|
||||||
|
* **auth:** tambah OAuth loopback server untuk capture authorization-code redirect ([fd58b46](https://github.com/asepharyana/zesdex/commit/fd58b4661fd0928dd461c74a5cb7bc7870ca9692))
|
||||||
|
* **cli:** port Fase 4 CLI — parser arg, mode dispatch, single-process composition root + REPL loop, bootstrap seed ([c582dc1](https://github.com/asepharyana/zesdex/commit/c582dc1461024d5037b8351a0df6b9b11e967782))
|
||||||
|
* **cli:** wire mode dispatch ke server interfaces — --api/--ws/--grpc/--web start server; add tsconfig paths ([02a5523](https://github.com/asepharyana/zesdex/commit/02a55235cfadb3e5daf3774a04f6fc6abe5da78d))
|
||||||
|
* **daemon:** port Fase 5 daemon — IPC agent-driver loop (Submit/Close) + streaming tokens; wire CLI --daemon/--attach ([14d3556](https://github.com/asepharyana/zesdex/commit/14d3556179685587e62873f81a57125037ac5e5a))
|
||||||
|
* **infra:** port tool system ke TypeScript (37 tools + registry + executor) ([82b51f7](https://github.com/asepharyana/zesdex/commit/82b51f7921e33e912f1244152df509522e44cae3))
|
||||||
|
* **ipc:** port 3g IPC + bgbash — framed Unix-socket server/client + background job registry, wire bash tools ([4b4730c](https://github.com/asepharyana/zesdex/commit/4b4730cff7ee2a5c43f8906d17282d95de84cf7a))
|
||||||
|
* **rewrite:** bootstrap monorepo Bun + port domain layer ke TypeScript ([07e84e4](https://github.com/asepharyana/zesdex/commit/07e84e43b386388f75f8293879e49dff1db461ab))
|
||||||
|
* **rewrite:** tambah application layer — port traits & use cases TypeScript ([03538a4](https://github.com/asepharyana/zesdex/commit/03538a455a93ff554abb45a1e54809c9579763fd))
|
||||||
|
* **rewrite:** tambah infrastructure — LLM client + full file persistence ([a2c7e5d](https://github.com/asepharyana/zesdex/commit/a2c7e5d44c784508b4fe735ec3434df2407f76de))
|
||||||
|
* **subagent:** port 3c subagent engine — run_agent loop + spawn/delegate/parallel ([a14a227](https://github.com/asepharyana/zesdex/commit/a14a2275ff90059874166aec8c03bd44e6c7efee))
|
||||||
|
* **tui:** port Fase 4 TUI logic layer — InputState/Action dispatcher/key controller/slash commands + lightweight ANSI renderer + run loop; 20 test ([8098133](https://github.com/asepharyana/zesdex/commit/8098133ba50135c7a5f9a063221ffdb860e064cb))
|
||||||
|
* **web): port Fase 5 static file server w/ traversal protection; feat(grpc:** port health stub ([1a6268b](https://github.com/asepharyana/zesdex/commit/1a6268bde58c67b9d3cbabce08306d53f0b3791a))
|
||||||
|
* **workflow:** port 3d workflow + hive-mind engine — parse, execute, cycle, synthesis, docs ([edc0076](https://github.com/asepharyana/zesdex/commit/edc007619b63d0d91fdad79a6d534c112b8a3981))
|
||||||
|
* **ws:** port Fase 5 WS server — Bun native WebSocket, ZESDEX_WS_TOKEN guard, prompt turn proxy + streaming token/done/error ([d4d423a](https://github.com/asepharyana/zesdex/commit/d4d423a4cae62562da4ca4f0231f6a4f90481473))
|
||||||
|
|
||||||
|
# [1.22.0](https://github.com/asepharyana/zesdex/compare/v1.21.2...v1.22.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **agent:** wire auto-review (review_enabled no-op -> nyata) ([a27e815](https://github.com/asepharyana/zesdex/commit/a27e8151b39da4c8759e8e922ef132212327e413))
|
||||||
|
|
||||||
|
## [1.21.2](https://github.com/asepharyana/zesdex/compare/v1.21.1...v1.21.2) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **agent:** symbol index tak pegang mutex global saat rebuild I/O ([3b25e38](https://github.com/asepharyana/zesdex/commit/3b25e3898f9ba2fc1c58b991ad95a8c0bbe98601))
|
||||||
|
|
||||||
|
## [1.21.1](https://github.com/asepharyana/zesdex/compare/v1.21.0...v1.21.1) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **agent:** semantic_search symbol index workspace-aware ([104af3a](https://github.com/asepharyana/zesdex/commit/104af3abb9e626c5d00ea87d523248d606d523ee))
|
||||||
|
|
||||||
|
# [1.21.0](https://github.com/asepharyana/zesdex/compare/v1.20.2...v1.21.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **agent:** hive-mind consensus synthesis pakai LLM nyata ([f75ff74](https://github.com/asepharyana/zesdex/commit/f75ff740ac2fa63340656e1e8215440f5e48063d))
|
||||||
|
|
||||||
|
## [1.20.2](https://github.com/asepharyana/zesdex/compare/v1.20.1...v1.20.2) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **agent:** recall search + memory_dir fallback + bersihkan dead llm_client ([f1f58b9](https://github.com/asepharyana/zesdex/commit/f1f58b9996f8eb58871d44fdd41c2d863874f253))
|
||||||
|
|
||||||
|
## [1.20.1](https://github.com/asepharyana/zesdex/compare/v1.20.0...v1.20.1) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **agent:** subagent patuhi tool-calling contract + truncation char-safe ([e982cbe](https://github.com/asepharyana/zesdex/commit/e982cbeb041baea9cd500e2a29862a7eca9e6e17))
|
||||||
|
|
||||||
|
# [1.20.0](https://github.com/asepharyana/zesdex/compare/v1.19.6...v1.20.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **agent:** subagent tool paralel + auto-load AGENTS.md + verify cek setelah edit ([21e3ccc](https://github.com/asepharyana/zesdex/commit/21e3ccc891ab886044a5f0a770db710769d1287b))
|
||||||
|
|
||||||
|
## [1.19.6](https://github.com/asepharyana/zesdex/compare/v1.19.5...v1.19.6) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **agent:** eksekusi tool read-only paralel seperti Claude Code ([74b1ad4](https://github.com/asepharyana/zesdex/commit/74b1ad43020a11ca27e60e3a24de0db5d1ab37b4))
|
||||||
|
|
||||||
|
## [1.19.5](https://github.com/asepharyana/zesdex/compare/v1.19.4...v1.19.5) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **api:** model Opus default pakai claude-opus-5 (bukan -4-8) ([b28a5fe](https://github.com/asepharyana/zesdex/commit/b28a5fe384fd45255a6b249c7febd3b4ffc0fd2f))
|
||||||
|
* **api:** update zesdex packages to version 1.19.4 ([b46935c](https://github.com/asepharyana/zesdex/commit/b46935c606d4f68ea227c7db27e4c2aa9b4e373c))
|
||||||
|
|
||||||
|
## [1.19.4](https://github.com/asepharyana/zesdex/compare/v1.19.3...v1.19.4) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **api:** model claude selalu pakai Opus dari settings.json, bukan deepseek ([9aca45c](https://github.com/asepharyana/zesdex/commit/9aca45cb65d6913d14fecc10d70c180934f69d74))
|
||||||
|
|
||||||
|
## [1.19.3](https://github.com/asepharyana/zesdex/compare/v1.19.2...v1.19.3) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **api:** model Opus pakai URL + API custom dari ~/.claude/settings.json ([1f91b44](https://github.com/asepharyana/zesdex/commit/1f91b447080e7106201d77585edb7d38b74e34bb))
|
||||||
|
|
||||||
|
## [1.19.2](https://github.com/asepharyana/zesdex/compare/v1.19.1...v1.19.2) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **agent:** stabilkan async & parallel — satu runtime, bounded concurrency, isolasi error ([6a98d52](https://github.com/asepharyana/zesdex/commit/6a98d52d54a69f78710d852a69dda3ac0a4ead31))
|
||||||
|
|
||||||
|
## [1.19.1](https://github.com/asepharyana/zesdex/compare/v1.19.0...v1.19.1) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **agent:** rombak alur AI agent — adaptif, hemat token, self-healing ([eac0443](https://github.com/asepharyana/zesdex/commit/eac0443c4c3b8bfcbefd4bad9554168fb6525b94))
|
||||||
|
|
||||||
|
# [1.19.0](https://github.com/asepharyana/zesdex/compare/v1.18.4...v1.19.0) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* hapus fitur LSP bawaan (language server protocol) ([93f3c2a](https://github.com/asepharyana/zesdex/commit/93f3c2a3572511b5a84f244980ad71bd1b455e70))
|
||||||
|
|
||||||
|
## [1.18.4](https://github.com/asepharyana/zesdex/compare/v1.18.3...v1.18.4) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **tui:** render streaming token secara inkremental + kurangi redraw sia-sia ([2717216](https://github.com/asepharyana/zesdex/commit/271721694beb62e9fa5ff932312b293aa1d56823))
|
||||||
|
|
||||||
|
## [1.18.3](https://github.com/asepharyana/zesdex/compare/v1.18.2...v1.18.3) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **api:** cegah race condition pada register users.json (TOCTOU) ([884b19c](https://github.com/asepharyana/zesdex/commit/884b19ccb5fbcaa6b29cb41dc978386cf7b1b3f9))
|
||||||
|
* **api:** perbaiki keamanan auth & WebSocket, tambah rate limiting ([6db00b2](https://github.com/asepharyana/zesdex/commit/6db00b22663839a6a975ae978058b894900c71de))
|
||||||
|
* **build:** perbaiki referensi paket zesdex-gateway dan sinkronisasi versi nix ([6d3f491](https://github.com/asepharyana/zesdex/commit/6d3f4918bfea06b106d4fe75adf58e6a29aca2a5))
|
||||||
|
* **build:** perbaiki referensi paket zesdex-gateway di Dockerfile & default.nix ([7f64423](https://github.com/asepharyana/zesdex/commit/7f644236158f66fcc106ec55e437ebf28ee1cca1))
|
||||||
|
|
||||||
|
## [1.18.2](https://github.com/asepharyana/zesdex/compare/v1.18.1...v1.18.2) (2026-08-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **nix:** add perl to nativeBuildInputs for openssl-sys Configure ([033f964](https://github.com/asepharyana/zesdex/commit/033f964be0c695106eef74329524ad7b4b8dba14))
|
||||||
|
* **nix:** correct cargoBuildFlags package name zesdex-backend -> zesdex-gateway ([2de5b57](https://github.com/asepharyana/zesdex/commit/2de5b57133f6c7295d8a85e63cb86504ea2a7d3e))
|
||||||
|
|
||||||
|
## [1.18.1](https://github.com/asepharyana/zesdex/compare/v1.18.0...v1.18.1) (2026-08-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **nix:** restrict flake to x86_64-linux (nixpkgs 26.11 dropped darwin) ([0e0fd5d](https://github.com/asepharyana/zesdex/commit/0e0fd5df05e53a6fa17d481825f1a8c567214d0e))
|
||||||
|
|
||||||
|
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add .releaserc.json for semantic-release configuration ([047d718](https://github.com/asepharyana/zesdex/commit/047d7183d72adaa0dd1a18a7f92b22d67d5aa85f))
|
||||||
|
* **clippy:** replace type annotation with type alias + .insert() to avoid trivial_cast ([ffefa8c](https://github.com/asepharyana/zesdex/commit/ffefa8c07facbba83778f426ca4715569556b08a))
|
||||||
|
* **rust:** remove unused imports, variables, and dead code causing CI build failures ([eb8cc17](https://github.com/asepharyana/zesdex/commit/eb8cc1799359a5ef5b4a4ee3a4202b87e80bfe6c))
|
||||||
|
* **rust:** resolve all clippy warnings treated as errors in CI ([6b90c7e](https://github.com/asepharyana/zesdex/commit/6b90c7eb0d17d9281a377454e76dcc88a4232bc7))
|
||||||
|
* **tui:** resolve remaining clippy errors in workspace ([eed4025](https://github.com/asepharyana/zesdex/commit/eed4025918de65eb2f3fd98cd3350d62f3322132))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||||
|
* Add SOLID principles and TDD reference documentation ([55677dd](https://github.com/asepharyana/zesdex/commit/55677dd67100813a5ed2b0bbf02257b57875ea07))
|
||||||
|
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||||
|
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||||
|
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||||
|
* **best_practice:** add code quality scanning and commit message validation ([8ecc588](https://github.com/asepharyana/zesdex/commit/8ecc588a3e125dc6a4b2e7eb4c12c6b344449c07))
|
||||||
|
* centralize default constants and refactor overlay enter handling in TUI ([d615090](https://github.com/asepharyana/zesdex/commit/d615090dcd34bdb93be22c1c7ce4ba674363c258))
|
||||||
|
* enhance context gathering in auto-review engine and agent runner ([dcc8c3e](https://github.com/asepharyana/zesdex/commit/dcc8c3ee42ef568843e04143048d799bc0882288))
|
||||||
|
* enhance explore phase with TUI workflow event handling ([b3a4d13](https://github.com/asepharyana/zesdex/commit/b3a4d131d13d94bd307720cb50c2d02dedf92873))
|
||||||
|
* implement Component trait for modular UI components and refactor TUI views to use it ([d59713d](https://github.com/asepharyana/zesdex/commit/d59713d3e3aa4325f9753bbddd068654e442f6ef))
|
||||||
|
* implement mandatory explore phase with parallel subagents ([f368f3a](https://github.com/asepharyana/zesdex/commit/f368f3a1c0d2df02eb50542a81bcff96d9cdc420))
|
||||||
|
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||||
|
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||||
|
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||||
|
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||||
|
* refactor auto-review engine to use spawn_subagent for improved thread handling ([094eb4b](https://github.com/asepharyana/zesdex/commit/094eb4b8baa5abd878dcf5b611cf615955bc79eb))
|
||||||
|
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||||
|
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||||
|
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||||
|
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||||
|
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||||
|
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||||
|
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||||
|
* **tui:** introduce comprehensive state management for TUI interface ([8c58faf](https://github.com/asepharyana/zesdex/commit/8c58faf2920b7341dd50ace15044ff113cefb576))
|
||||||
|
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||||
|
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||||
|
|
||||||
|
|
||||||
|
# [1.17.0](https://github.com/asepharyana/zesdex/compare/v1.16.1...v1.17.0) (2026-07-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **token:** add refresh token verification to TokenService ([a046519](https://github.com/asepharyana/zesdex/commit/a04651905f4afd562b516c839449a2c813ce6627))
|
||||||
|
|
||||||
|
## [1.16.1](https://github.com/asepharyana/zesdex/compare/v1.16.0...v1.16.1) (2026-07-20)
|
||||||
|
|
||||||
|
# [1.16.0](https://github.com/asepharyana/zesdex/compare/v1.15.2...v1.16.0) (2026-07-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **tui:** add usage overlay and sidebar for displaying usage statistics and tasks ([da2ed6d](https://github.com/asepharyana/zesdex/commit/da2ed6da25953b823354cc5deaa7b404b7b13cb0))
|
||||||
|
* **tui:** enhance agent turn with tool descriptions and sanitize arguments ([08e2f99](https://github.com/asepharyana/zesdex/commit/08e2f9998d3693e4cd57225491359c762d03f1d9))
|
||||||
|
* **tui:** implement agent turn engine for background processing and enhance input handling ([792695b](https://github.com/asepharyana/zesdex/commit/792695b65a393cfc54efe353480b7831e91544b5))
|
||||||
|
|
||||||
|
## [1.15.2](https://github.com/asepharyana/zesdex/compare/v1.15.1...v1.15.2) (2026-07-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* correct jitter range to ±25% and fix abort.rs doc — review findings ([9b6e51d](https://github.com/asepharyana/zesdex/commit/9b6e51dc677cc4b798532f1e63335785f2f7bbbf))
|
||||||
|
|
||||||
|
## [1.15.1](https://github.com/asepharyana/zesdex/compare/v1.15.0...v1.15.1) (2026-07-17)
|
||||||
|
|
||||||
|
# [1.15.0](https://github.com/asepharyana/zesdex/compare/v1.14.0...v1.15.0) (2026-07-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **cms:** perbaiki serde default hive_mind_node_timeout_ms & toleransi parse gagal di Settings ([6d41ffc](https://github.com/asepharyana/zesdex/commit/6d41ffc587f6f8de5bb4757c7eb8d69077831057))
|
||||||
|
* **cms:** satukan ChatMessage/Role Conversation dengan tipe kanonik zesdex-entities ([b272858](https://github.com/asepharyana/zesdex/commit/b272858edbe36af0b2eca119ff5b132de8dadce9))
|
||||||
|
* **iam:** redirect_uri dinamis + validasi CSRF state di OAuthServiceImpl ([be278f8](https://github.com/asepharyana/zesdex/commit/be278f8b1c2bc1295368e49c38622ef2bc3ed9ee))
|
||||||
|
* **iam:** set permission 0600 pada file token OAuth ([f2d97fb](https://github.com/asepharyana/zesdex/commit/f2d97fb17dcf4f52123da89532d7964ed8f8124c))
|
||||||
|
* **middleware:** jangan percaya header X-Forwarded-For/X-Real-IP secara default di rate limiter ([4dc4f80](https://github.com/asepharyana/zesdex/commit/4dc4f80fa344d894c2ce62f75d239847cd13001a))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **cms:** implement RewindBlobRepository for managing binary blobs ([22dd6fd](https://github.com/asepharyana/zesdex/commit/22dd6fdda7d0c8eabfce296bbad53587204c69fa))
|
||||||
|
* **iam:** implementasikan FileSystemSessionLockRepository (sebelumnya belum ada implementasi) ([ff6a749](https://github.com/asepharyana/zesdex/commit/ff6a749c1149d890173922fbff76facef50a93b0))
|
||||||
|
* **iam:** port LoopbackServer OAuth callback listener dari zesdex-backend ([910aa5e](https://github.com/asepharyana/zesdex/commit/910aa5e071911f158609c4dd8985f776d8d9235f))
|
||||||
|
* **iam:** tambahkan CSPRNG (OsRng) untuk token state/PKCE ([5ede65f](https://github.com/asepharyana/zesdex/commit/5ede65f454b08303f588754a208dca0c37d3ce34))
|
||||||
|
|
||||||
|
# [1.14.0](https://github.com/asepharyana/zesdex/compare/v1.13.0...v1.14.0) (2026-07-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **context:** batasi squash_log ke tool bash saja ([7d99cd6](https://github.com/asepharyana/zesdex/commit/7d99cd66187b3fafb2ddeb19d8e8aa7db139df64))
|
||||||
|
* **context:** perbaiki fixture test shaping agar men-drop pesan lama ([8bb697a](https://github.com/asepharyana/zesdex/commit/8bb697a53fe89c9d00f709df33ccbd7944482f07))
|
||||||
|
* **plan:** perbaiki bug entropy gate dan fixture test squash.rs ([ceb8479](https://github.com/asepharyana/zesdex/commit/ceb84790bb741352b2b3416cda4bdbc6b42767c9))
|
||||||
|
* **plan:** perbaiki fixture test array-cutoff squash_json ([7ffdf44](https://github.com/asepharyana/zesdex/commit/7ffdf441355cda0972b849bc9b475d3de8f39843))
|
||||||
|
* **plan:** perbaiki fixture test shaping agar benar-benar men-drop pesan ([3a6e32d](https://github.com/asepharyana/zesdex/commit/3a6e32d8f3f0dd47d2c5ff63ab0c184b0ee4a6bc))
|
||||||
|
* **plan:** perkuat fixture test log agar benar-benar uji squash_log ([682007a](https://github.com/asepharyana/zesdex/commit/682007a2507f209c2378796442d4bc879516191a))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **context:** tambah context::dedup untuk hasil tool yang berulang ([12a03fd](https://github.com/asepharyana/zesdex/commit/12a03fd3d1123c7a88289e44064537a71b9f574b))
|
||||||
|
* **context:** tambah context::shaping (port dari shortsend) ([e080d9f](https://github.com/asepharyana/zesdex/commit/e080d9fc6b7daf2ab37fb242d03a3f64e8629acc))
|
||||||
|
* **context:** tambah context::squash untuk kompresi hasil tool ([ea0b498](https://github.com/asepharyana/zesdex/commit/ea0b4988299894e4588d851ebc91704a9e73bc73))
|
||||||
|
* **context:** tambah context::tokens dengan tiktoken-rs ([c219b6e](https://github.com/asepharyana/zesdex/commit/c219b6ec58ddad4770e491a48bbe1b6e3d0c8884))
|
||||||
|
* **context:** tambah context::window::resolve ([059ca8e](https://github.com/asepharyana/zesdex/commit/059ca8ea246768dbec06ae5336433853e4276cec))
|
||||||
|
* **runtime:** kompres hasil tool lewat squash sebelum masuk context ([96084f7](https://github.com/asepharyana/zesdex/commit/96084f74621636a72c209388580d0ebbb335d198))
|
||||||
|
* **settings:** tambah mode ringkas opsional (concise_output) ([d6de973](https://github.com/asepharyana/zesdex/commit/d6de9735aba7745f5f57a8e7a5e625613c1a2569))
|
||||||
|
|
||||||
|
# [1.13.0](https://github.com/asepharyana/zesdex/compare/v1.12.0...v1.13.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* correct test assertion in dim_false_plain_text_has_no_color ([8d77e45](https://github.com/asepharyana/zesdex/commit/8d77e4565c2e3ca49058022c71e8277bdd790312))
|
||||||
|
* Remove orphaned span_text helper function from markdown test module ([199028f](https://github.com/asepharyana/zesdex/commit/199028fa2eb6056dad2bdf0753554939c153a163))
|
||||||
|
* **state:** cegah panic saat select mention dengan cursor stale ([dcfc5b9](https://github.com/asepharyana/zesdex/commit/dcfc5b9ec0d35f4c66b1648ded00f4f1279e0413))
|
||||||
|
* **state:** jangan bangun mention index di mode attach ([e0d0860](https://github.com/asepharyana/zesdex/commit/e0d0860d7ba0ab6a4c9b9f1562f6a767a6424d99))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Deteksi trigger [@mention](https://github.com/mention) dan Tab-cycle di input handler ([2eebedf](https://github.com/asepharyana/zesdex/commit/2eebedfea56c7d4827f94a2739dbb5fa006e3069))
|
||||||
|
* **ipc:** dukung Ctrl+Y clipboard copy di mode daemon/attach ([472162d](https://github.com/asepharyana/zesdex/commit/472162d135478666913a888a68867e635e50d14a))
|
||||||
|
* Judul dropdown autocomplete mengikuti jenisnya (Commands vs Files) ([7f2509e](https://github.com/asepharyana/zesdex/commit/7f2509ecd2688495545333debee0c81016b28641))
|
||||||
|
* **state:** Alirkan mention_index lewat ToolCtx dan AppStateRest, bangun index di background thread ([93be92a](https://github.com/asepharyana/zesdex/commit/93be92a5fb8644c9020478c49421585ade2d0fd1))
|
||||||
|
* Tambah Ctrl+Y untuk menyalin pesan assistant terakhir ([d93bdba](https://github.com/asepharyana/zesdex/commit/d93bdba59b7dd14b4a76dc91c6b7f70a3c0f721c))
|
||||||
|
* Tambah field pending_clipboard_copy di MiscState ([3b6abd9](https://github.com/asepharyana/zesdex/commit/3b6abd920ea92329ebcf888c3bb50470675c4152))
|
||||||
|
* Tambah helper truncate_diff untuk membatasi panjang diff ([5dd835f](https://github.com/asepharyana/zesdex/commit/5dd835ff244a138bb5f1a8dbfd7cf252c2671e5e))
|
||||||
|
* Tambah MentionIndex, AutocompleteKind, dan deteksi [@mention](https://github.com/mention) di InputState ([95cae8f](https://github.com/asepharyana/zesdex/commit/95cae8fd8a8ce737ba60c3ac8bea77f4c2e14b1a))
|
||||||
|
* Tambah write_osc52 dan salin ke clipboard di mode single-process ([71b1613](https://github.com/asepharyana/zesdex/commit/71b1613d8467cfcb2383b3fce153a25c7883ac3d))
|
||||||
|
* Tambahkan file baru ke mention_index saat tool write membuatnya ([930961b](https://github.com/asepharyana/zesdex/commit/930961bd85701ab8906b8d81095e46d3635a951d))
|
||||||
|
* Tampilkan unified diff pada hasil tool edit ([c3c0ef6](https://github.com/asepharyana/zesdex/commit/c3c0ef632a605d8bf32712049035e4fc814af9d5))
|
||||||
|
* Tampilkan unified diff saat tool write menimpa file yang sudah ada ([2bb8e6f](https://github.com/asepharyana/zesdex/commit/2bb8e6f2555eac01baf2311d41811afad4aa3041))
|
||||||
|
* **view:** Tambah parameter dim dan pewarnaan baris diff di markdown renderer ([683715c](https://github.com/asepharyana/zesdex/commit/683715cd7af77d87a2ae3834360bb6f7a4388bd0))
|
||||||
|
|
||||||
|
# [1.12.0](https://github.com/asepharyana/zesdex/compare/v1.11.0...v1.12.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add mouse capture functionality to terminal and enhance markdown rendering with table support ([4428e8b](https://github.com/asepharyana/zesdex/commit/4428e8bc01c196415ac408a42f57305227a79760))
|
||||||
|
* Improve markdown rendering with enhanced line wrapping and indentation for code blocks ([b2e848d](https://github.com/asepharyana/zesdex/commit/b2e848d124e726c4d8b644d473e518398fab1dea))
|
||||||
|
|
||||||
|
# [1.11.0](https://github.com/asepharyana/zesdex/compare/v1.10.0...v1.11.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Enhance subagent tool output handling and clarify workflow directives ([6fbe1e2](https://github.com/asepharyana/zesdex/commit/6fbe1e2d1dc790ba2509803b3ab3a848d5b2a63b))
|
||||||
|
* Enhance token usage tracking and improve chat UI with emojis ([188e7cc](https://github.com/asepharyana/zesdex/commit/188e7cc9a9233140a5e4953e3f3ff66682914e42))
|
||||||
|
|
||||||
|
# [1.10.0](https://github.com/asepharyana/zesdex/compare/v1.9.0...v1.10.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align format strings in sidebar Usage widget ([98615ca](https://github.com/asepharyana/zesdex/commit/98615ca5b9d896331a5a6d9af91035aca1f5e9d5))
|
||||||
|
* use {:>6}: for aligned colons in sidebar Usage widget ([f87ab13](https://github.com/asepharyana/zesdex/commit/f87ab133f1953633f66e21b9eaf7c4eb41291ccd))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Implement lesson generation feature and update status display ([1c08b8e](https://github.com/asepharyana/zesdex/commit/1c08b8e4e9c3bb1318535a74c9812beb976df315))
|
||||||
|
|
||||||
|
# [1.9.0](https://github.com/asepharyana/zesdex/compare/v1.8.0...v1.9.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **workflow:** import Color style for improved agent state rendering ([472c597](https://github.com/asepharyana/zesdex/commit/472c597c5e4ab12808a6bcd1899628bc7ab77186))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **agent:** refine cognitive cycle plan with structured phases for exploration, planning, and execution ([c5253b2](https://github.com/asepharyana/zesdex/commit/c5253b2ca359d4dbed9445e04f1dec1a6bb37e8f))
|
||||||
|
* **subagent:** add progress event handling and formatting for subagent execution ([558908a](https://github.com/asepharyana/zesdex/commit/558908aef216e61a0a108083fbac5e02c31501dc))
|
||||||
|
* **subagent:** emit reasoning text as progress in StepCompleted events ([97aa75f](https://github.com/asepharyana/zesdex/commit/97aa75f2da37aee5fc7a0626fc396988f089fff2))
|
||||||
|
* **subagent:** include tool call arguments in ToolResult events and progress formatting ([a8adfcb](https://github.com/asepharyana/zesdex/commit/a8adfcbf6dc5411e977f22ac6b6ba023f563d7c9))
|
||||||
|
|
||||||
|
# [1.8.0](https://github.com/asepharyana/zesdex/compare/v1.7.0...v1.8.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **tools:** require reason argument for delete and git_operator tools ([c6ab063](https://github.com/asepharyana/zesdex/commit/c6ab063c211fb858fd0e155883b9c47b345f0f8a))
|
||||||
|
|
||||||
|
# [1.7.0](https://github.com/asepharyana/zesdex/compare/v1.6.0...v1.7.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **prompt:** perbarui system prompt dari CEO/company ke model hive-mind ([d392c4a](https://github.com/asepharyana/zesdex/commit/d392c4aa00154aae5a0f36db615f05adc385fdb5))
|
||||||
|
* **runtime:** add check for unconfigured provider to prevent misleading API errors ([181b512](https://github.com/asepharyana/zesdex/commit/181b5128ac1fba47627bf7b377358c782e3481b7))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **install:** add installation script for building and symlinking the binary ([4eba9d0](https://github.com/asepharyana/zesdex/commit/4eba9d0a2fbe42b0383eaf872eaeace18cc59a92))
|
||||||
|
* **protocol:** add Paste request type for bracketed-paste events ([b92dab9](https://github.com/asepharyana/zesdex/commit/b92dab97e6efe1fd6f7c23b28310610c653a57b0))
|
||||||
|
* **provider:** enhance Claude provider configuration to support environment variable fallback ([4b16bc3](https://github.com/asepharyana/zesdex/commit/4b16bc31125018ad3d3e46706881596a226f5352))
|
||||||
|
* **runtime:** implement JSON repair function for truncated tool-call arguments ([e13f040](https://github.com/asepharyana/zesdex/commit/e13f04083313f3544bdb1a76b5ecd535ecf59e4f))
|
||||||
|
* **stream:** add method to detect incomplete tool calls and handle parsing errors ([732d603](https://github.com/asepharyana/zesdex/commit/732d6039dc23bc8ec323bc4f91be9a7131a61ef6))
|
||||||
|
|
||||||
|
# [1.6.0](https://github.com/asepharyana/zesdex/compare/v1.5.0...v1.6.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* perbaiki 5 warning clippy pre-existing (base untuk TUI overhaul) ([6b58977](https://github.com/asepharyana/zesdex/commit/6b58977875f809f19cc2d7bb9b2a7dd057d0229e))
|
||||||
|
* **tui:** perbaiki isi overlay Todo dan Usage jadi tampilan detail nyata ([aaea300](https://github.com/asepharyana/zesdex/commit/aaea300699f7e76cdc689e225e7c4c3bc164e8d4))
|
||||||
|
* **tui:** perbaiki potensi terpotongnya baris token di widget Usage sidebar ([7fd55fa](https://github.com/asepharyana/zesdex/commit/7fd55fa86dfe8f9a4581f02fa9220cd5d1ba600c))
|
||||||
|
* **tui:** perbaiki rendering multi-baris pada pesan Tool ([2f1a4d8](https://github.com/asepharyana/zesdex/commit/2f1a4d85a1fdc9cbfb81912a3206c057ad9d1ed5))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **tui:** ganti palet warna ke Tokyo Night ([7a9cb7b](https://github.com/asepharyana/zesdex/commit/7a9cb7bf342367c81fb4a1568675e46f132a8afc))
|
||||||
|
* **tui:** rombak rendering chat jadi format log rapat ([e34708a](https://github.com/asepharyana/zesdex/commit/e34708a3191bef63d191e76dee39a58a33e4ad5f))
|
||||||
|
* **tui:** tambah command /todo dan /usage untuk buka overlay ([aa2b6ac](https://github.com/asepharyana/zesdex/commit/aa2b6acb95f8518950b8b7d9c3c9e10968936162))
|
||||||
|
* **tui:** tambah dan pasang sidebar dashboard permanen ([31c01cd](https://github.com/asepharyana/zesdex/commit/31c01cdf1df6827c3a949820378c8b85ebcfdf87))
|
||||||
|
|
||||||
# [1.5.0](https://github.com/asepharyana/zesdex/compare/v1.4.0...v1.5.0) (2026-07-14)
|
# [1.5.0](https://github.com/asepharyana/zesdex/compare/v1.4.0...v1.5.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
Tests use `#[cfg(test)] mod tests` blocks inline in production files (not a separate `tests/` dir).
|
|
||||||
|
|
||||||
Tracing output goes to `~/.local/share/zesdex/zesdex.log`. Set `RUST_LOG=debug` for verbose logging.
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 37 built-in tools.
|
|
||||||
|
|
||||||
Detailed architecture documentation is in `docs/CODEMAPS/`:
|
|
||||||
|
|
||||||
| File | Covers |
|
|
||||||
|------|--------|
|
|
||||||
| [`docs/CODEMAPS/architecture.md`](docs/CODEMAPS/architecture.md) | System layout, process modes, data flow, key files |
|
|
||||||
| [`docs/CODEMAPS/backend.md`](docs/CODEMAPS/backend.md) | Provider, OAuth, IPC, workflow engine, MCP, review, bg bash |
|
|
||||||
| [`docs/CODEMAPS/frontend.md`](docs/CODEMAPS/frontend.md) | TUI render pipeline, 16 overlays, toasts, input handling |
|
|
||||||
| [`docs/CODEMAPS/data.md`](docs/CODEMAPS/data.md) | Persistence, SQLite msglog, memory files, settings/config |
|
|
||||||
| [`docs/CODEMAPS/dependencies.md`](docs/CODEMAPS/dependencies.md) | 23 Rust crates, 5 external services |
|
|
||||||
|
|
||||||
`docs/runs/` holds an auto-generated audit trail: one markdown file per hive-mind convergence (see below), written deterministically by `app::workflow::docs::write_hive_mind_convergence` — not hand-maintained like `docs/CODEMAPS/`.
|
|
||||||
|
|
||||||
### Key Patterns
|
|
||||||
|
|
||||||
- **State mutation** — `AppStateRest` is mutable in-place from `actions/mod.rs` and `controller/input.rs`. No generic update function.
|
|
||||||
- **No DI** — modules call `Settings::load()`, `AppConfig::load()`, `all_tools()` directly.
|
|
||||||
- **Logging** — `tracing::warn!` to `~/.local/share/zesdex/zesdex.log` (not stderr, avoids TUI corruption).
|
|
||||||
- **Error handling** — `anyhow::Result` and `anyhow::bail!` throughout. No custom error types.
|
|
||||||
- **Static strings** — MCP tool descriptions use `Box::leak` + `OnceLock` cache.
|
|
||||||
- **Tools** — `trait Tool { fn name() -> &str, fn run() -> Result<String> }`, 28 impls, gated by `Harness`.
|
|
||||||
- **Shell safety** — `tool/shell_filter/` blocks credential leaks and destructive git commands.
|
|
||||||
|
|
||||||
### Hive-Mind Orchestration (Machine Intelligence)
|
|
||||||
|
|
||||||
- **A single Core Intelligence spawning anonymous processing nodes.** The Core Intelligence (main agent) compiles a cognitive cycle plan per task: an ordered list of cycles, each cycle a set of processing nodes that run in parallel. Each node's sole identity is its directive (what to do) and an access tier. Cycle count and nodes-per-cycle are entirely Core-Intelligence output.
|
|
||||||
- **Access tiers** in `src/app/subagent/division.rs` (`tool_scope` module): tool access is granted per node via one of three tiers (`read` / `write` / `full`, see `tool_scope::tools_for`) picked by the Core Intelligence based on what each node's directive actually needs.
|
|
||||||
- **Orchestrator** in `src/app/workflow/hive_mind.rs`: `run_hive_mind()` executes a `CognitiveCyclePlan { cycles: Vec<Vec<NodeDirective>> }` cycle-by-cycle. Node IDs are system-assigned coordinates (e.g. `"Node-0-1"`).
|
|
||||||
- **Continuous collective state, not phase-boundary sync**: `engine::execute_primitive`'s `ScopedAgent` arm merges each node's complete output into the shared collective-state channel the instant that node finishes — not after its whole parallel cohort completes — so sibling/later nodes see it in real time.
|
|
||||||
- **Consensus synthesis, not a per-node summary**: after all cycles complete, `synthesize_consensus()` spawns one final read-only node whose sole directive is to reconcile the entire collective state into a single consensus assessment — a real reasoning pass, not string concatenation, since node outputs can overlap or conflict.
|
|
||||||
- **Auto-trigger** in `run_agent_turn()` (`actions/mod.rs`): `is_complex_request()` heuristics decide only whether to ask the Core Intelligence to compile a plan at all — the plan's shape is fully dynamic.
|
|
||||||
- **`hive_mind` tool** (`src/tool/workflow.rs`) is the manual entry point: the calling LLM supplies its own `cycles` array of `{directive, access}` directly.
|
|
||||||
- **Guaranteed documentation**: after every convergence, `src/app/workflow/docs.rs::write_hive_mind_convergence()` deterministically (not an LLM step, not skippable) writes every node's full output plus the final consensus to `docs/runs/<timestamp>-<slug>.md`.
|
|
||||||
- **Live node progress** in TUI panel (`view/workflow.rs`): shows node designation + current tool via `AgentStatus::progress`.
|
|
||||||
- **Auto inline review** after each edit: `src/app/subagent/auto.rs` — `spawn_quick_review()` injects verdict back into LLM conversation.
|
|
||||||
- **Background subagents** (test-gen, arch-review, security-review) fire asynchronously at turn end via `TurnEvent::SystemNote`, retrying once on failure and escalating to a blocking (`ESCALATED:`-prefixed, `ToastKind::Error`) notice if the retry also fails.
|
|
||||||
|
|
||||||
Commit convention (Conventional Commits, Bahasa Indonesia): see the `commit-convention` skill.
|
|
||||||
|
|
||||||
## Code Documentation
|
|
||||||
|
|
||||||
Every function, struct, enum, trait, module, and significant code block must have a doc comment (`///` or `//!`) that explains:
|
|
||||||
|
|
||||||
- **What** the function/module does (purpose, not how)
|
|
||||||
- **Flow** — a brief ASCII or prose description of the code flow / data flow above each non-trivial function
|
|
||||||
- **Why** — non-obvious decisions, edge cases, invariants
|
|
||||||
- **Return** — what the caller gets back, especially for `Result` types
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Parse an SSE data chunk into one or more StreamEvents.
|
|
||||||
///
|
|
||||||
/// Flow: buffer → split on '\n' → flush on blank line → JSON parse → match event type
|
|
||||||
/// → return Token / ToolCallDelta / Usage / Done.
|
|
||||||
///
|
|
||||||
/// Edge case: chunk may split mid-line; remaining bytes stay in buffer
|
|
||||||
/// for the next feed() call.
|
|
||||||
fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> { ... }
|
|
||||||
|
|
||||||
/// The single source-of-truth state struct for the entire application.
|
|
||||||
///
|
|
||||||
/// Mutated in-place from two locations: actions/mod.rs (apply_action)
|
|
||||||
/// and controller/input.rs (key event handlers). Read-only from
|
|
||||||
/// every other module.
|
|
||||||
struct AppStateRest { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Every `pub fn` needs a doc comment
|
|
||||||
- Every `pub struct` / `pub enum` / `pub trait` needs a doc comment
|
|
||||||
- Non-trivial private functions (≥10 lines) need a doc comment
|
|
||||||
- Write the comment above the code it documents (not inline in the body)
|
|
||||||
- Update comments when code behavior changes — stale docs are worse than no docs
|
|
||||||
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Always fix the underlying code issues instead.
|
|
||||||
Generated
-4481
File diff suppressed because it is too large.
Load diff
-62
@@ -1,62 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "zesdex"
|
|
||||||
version = "1.5.0"
|
|
||||||
edition = "2021"
|
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
|
||||||
|
|
||||||
# Treat all warnings as errors, set strict clippy levels
|
|
||||||
[lints.rust]
|
|
||||||
unused = "deny"
|
|
||||||
dead_code = "deny"
|
|
||||||
unreachable_code = "deny"
|
|
||||||
unused_imports = "deny"
|
|
||||||
unused_variables = "deny"
|
|
||||||
unused_mut = "deny"
|
|
||||||
unused_must_use = "deny"
|
|
||||||
deprecated = "deny"
|
|
||||||
trivial_casts = "deny"
|
|
||||||
trivial_numeric_casts = "deny"
|
|
||||||
|
|
||||||
[lints.clippy]
|
|
||||||
all = { level = "warn", priority = -1 }
|
|
||||||
pedantic = { level = "warn", priority = -2 }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
ratatui = "0.30.2"
|
|
||||||
crossterm = "0.29"
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
|
||||||
reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] }
|
|
||||||
dom_smoothie = "0.18.0"
|
|
||||||
fast_html2md = "0.0.62"
|
|
||||||
scraper = "0.27.0"
|
|
||||||
url = "2"
|
|
||||||
percent-encoding = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
serde_yaml_ng = "0.10"
|
|
||||||
anyhow = "1"
|
|
||||||
include_dir = "0.7"
|
|
||||||
uuid = { version = "1", features = ["v4", "v5"] }
|
|
||||||
dirs = "6"
|
|
||||||
futures-util = "0.3"
|
|
||||||
pulldown-cmark = { version = "0.13", default-features = false }
|
|
||||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
|
||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
|
||||||
ignore = "0.4"
|
|
||||||
regex = "1"
|
|
||||||
globset = "0.4"
|
|
||||||
infer = "0.19"
|
|
||||||
base64 = "0.22"
|
|
||||||
sha2 = "0.11"
|
|
||||||
hex = "0.4"
|
|
||||||
libc = "0.2"
|
|
||||||
rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
|
||||||
tracing = "0.1"
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
||||||
webbrowser = "1"
|
|
||||||
lsp-types = "0.97"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "zesdex"
|
|
||||||
path = "src/main.rs"
|
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Zesdex — Multi-stage Docker build (Bun)
|
||||||
|
# ========================================
|
||||||
|
# Stage 1: Build with Bun → compile single self-contained CLI binary
|
||||||
|
FROM oven/bun:1.2 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY bun.lock ./
|
||||||
|
COPY package.json tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Compile a standalone self-contained executable (bundles JS + native addons).
|
||||||
|
RUN bun build src/interfaces/cli/index.ts --compile --outfile=/app/zesdex
|
||||||
|
|
||||||
|
# Stage 2: Minimal runtime image
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/zesdex /usr/local/bin/zesdex
|
||||||
|
|
||||||
|
ENV ZESDEX_DATA_DIR=/data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/zesdex"]
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
# Zesdex
|
|
||||||
|
|
||||||
> Autonomous AI coding agent in a terminal-based TUI.
|
|
||||||
|
|
||||||
Zesdex is a Rust-powered AI assistant that operates directly in your terminal via a rich TUI interface. It combines large language model intelligence with a comprehensive set of tools to explore, understand, and modify codebases autonomously — with built-in guardrails at every layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Core
|
|
||||||
|
|
||||||
- **TUI Interface** — Full-screen terminal UI with chat panel, input bar, and status bar built with [ratatui](https://github.com/ratatui-org/ratatui) and [crossterm](https://github.com/crossterm-rs/crossterm).
|
|
||||||
- **Daemon Architecture** — Run as a background daemon with client attach/detach via Unix domain sockets. The daemon processes state; clients only render.
|
|
||||||
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
|
|
||||||
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
|
|
||||||
|
|
||||||
### Tool System (37 built-in tools)
|
|
||||||
|
|
||||||
| Category | Tools |
|
|
||||||
|----------|-------|
|
|
||||||
| **Filesystem** | `read`, `write`, `edit`, `delete` |
|
|
||||||
| **Search** | `grep` (recursive text), `glob` (file patterns) |
|
|
||||||
| **Shell** | `bash`, `bash_output`, `bash_kill` |
|
|
||||||
| **Git** | `git_operator`, `git_worktree`, `git_cred` |
|
|
||||||
| **Memory** | `remember`, `recall`, `forget` |
|
|
||||||
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
|
|
||||||
| **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `hive_mind` |
|
|
||||||
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
|
|
||||||
| **Agent** | `spawn_agents`, `spawn_pipeline` |
|
|
||||||
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
|
|
||||||
|
|
||||||
### Intelligence
|
|
||||||
|
|
||||||
- **Hive-Mind Orchestration** — Autonomous agent orchestration modeled as a distributed machine intelligence (à la Stellaris). The Core Intelligence (main agent) compiles a cognitive cycle plan per task — an ordered list of cycles, each a set of anonymous processing nodes that run in parallel. Every node carries only a directive (what to do) and an access tier (`read`/`write`/`full`); cycle count and nodes-per-cycle are decided per task, not fixed. Every node's output merges into a shared Line truncated
|
|
||||||
|
|
||||||
- **Workflow Engine** — Orchestrate complex multi-step tasks with parallel sub-agents, pipelines, and phased execution. Spawn independent workers that share findings in real-time.
|
|
||||||
- **Self-Learning** — Persistent memory system that stores lessons, references, and project knowledge across sessions. Memories include provenance tracking, lifecycle management, and scope isolation.
|
|
||||||
- **Self-Review** — Review subagents trigger automatically after each code edit (inline) and at turn completion (background). Three types: code quality, architecture, and security.
|
|
||||||
- **Self-Healing** — On build/test failures, spawns a sub-agent with the error context to autonomously fix issues before reporting them to the user.
|
|
||||||
- **MCP Support** — [Model Context Protocol](https://modelcontextprotocol.io/) integration for connecting to external AI tool servers.
|
|
||||||
- **Sequential Thinking** — Chain-of-thought reasoning tool for step-by-step problem decomposition.
|
|
||||||
- **Session Locking** — Prevents multiple processes from operating on the same session directory.
|
|
||||||
|
|
||||||
### Session Management
|
|
||||||
|
|
||||||
- Multiple concurrent sessions with history, rewind, and transcript persistence.
|
|
||||||
- Per-session edit logs with full change tracking.
|
|
||||||
- Session archival and summary generation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── main.rs # Entry point: single-process, daemon, or attach mode
|
|
||||||
├── resources.rs # Embedded resources (help text, system prompts)
|
|
||||||
├── app/
|
|
||||||
│ ├── state/ # AppStateRest — immutable-rest state model
|
|
||||||
│ │ ├── rest.rs # Core state struct
|
|
||||||
│ │ ├── types.rs # Overlay, Toast, Origin enums
|
|
||||||
│ │ ├── snapshot.rs # State snapshots for IPC
|
|
||||||
│ │ ├── diff.rs # Diff-based state synchronization
|
|
||||||
│ │ ├── runtime.rs # Runtime state mutations
|
|
||||||
│ │ └── misc.rs # DirCache and miscellaneous state helpers
|
|
||||||
│ ├── runtime/ # Action dispatch and event loop
|
|
||||||
│ │ ├── actions/ # Action enum and apply_action reducer
|
|
||||||
│ │ ├── stream/ # LLM streaming and tool execution
|
|
||||||
│ │ │ └── tools/ # Tool harness integration
|
|
||||||
│ │ │ └── turn.rs # Turn orchestration
|
|
||||||
│ │ ├── event_loop/ # Main event loop and shortsend
|
|
||||||
│ │ ├── commands.rs # Slash command dispatch
|
|
||||||
│ │ └── shortsend.rs # Short-lived async send helper
|
|
||||||
│ ├── mode/ # UI modes and overlays (13 modes)
|
|
||||||
│ │ ├── bash.rs # Bash panel mode
|
|
||||||
│ │ ├── editor.rs # Multi-line editor mode
|
|
||||||
│ │ ├── effort.rs # Effort level selector
|
|
||||||
│ │ ├── help.rs # Help overlay
|
|
||||||
│ │ ├── key_input.rs # Raw key input mode
|
|
||||||
│ │ ├── learning.rs # Lesson management overlay
|
|
||||||
│ │ ├── loading.rs # Loading spinner overlay
|
|
||||||
│ │ ├── mcp.rs # MCP server management
|
|
||||||
│ │ ├── quit_confirm.rs # Quit confirmation dialog
|
|
||||||
│ │ ├── rewind.rs # Session rewind mode
|
|
||||||
│ │ ├── settings.rs # Settings panel
|
|
||||||
│ │ ├── todo.rs # Task list overlay
|
|
||||||
│ │ └── workflow.rs # Workflow visualization
|
|
||||||
│ ├── harness.rs # Tool harness for agent execution
|
|
||||||
│ ├── workflow/ # Workflow engine
|
|
||||||
│ │ ├── script.rs # Workflow script DSL
|
|
||||||
│ │ ├── engine.rs # Workflow executor
|
|
||||||
│ │ ├── hive_mind.rs # Hive-mind orchestrator
|
|
||||||
│ │ └── docs.rs # Deterministic docs/runs/*.md writer
|
|
||||||
│ ├── mcp/ # MCP client manager
|
|
||||||
│ │ └── manager.rs # MCP server lifecycle and tool exposure
|
|
||||||
│ ├── subagent/ # Sub-agent management
|
|
||||||
│ │ ├── spawn.rs # AgentDefinition and spawning
|
|
||||||
│ │ ├── engine.rs # Sub-agent event loop
|
|
||||||
│ │ ├── context.rs # Context construction for sub-agents
|
|
||||||
│ │ └── event.rs # Progress event types
|
|
||||||
│ ├── bgbash/ # Background bash job management
|
|
||||||
│ │ ├── job.rs # Background job handle
|
|
||||||
│ │ └── control.rs # Bash control (bg/fg/kill)
|
|
||||||
│ ├── lsp/ # LSP client management
|
|
||||||
│ │ ├── client.rs # LSP client connection wrapper
|
|
||||||
│ │ └── provisioner.rs # Auto-provisioning of LSP servers
|
|
||||||
│ └── review/ # Self-review quality system
|
|
||||||
├── controller/
|
|
||||||
│ ├── input.rs # Key event → Action mapping
|
|
||||||
│ └── command.rs # Slash command parser
|
|
||||||
├── dto/
|
|
||||||
│ ├── chat/ # Message, ToolCall, Role types
|
|
||||||
│ │ ├── message.rs # Chat message types
|
|
||||||
│ │ ├── tool.rs # Tool call/result types
|
|
||||||
│ │ └── mod.rs
|
|
||||||
│ └── provider/ # AI provider request/response/usage types
|
|
||||||
│ ├── request.rs # Provider request schema
|
|
||||||
│ ├── response.rs # Provider response schema
|
|
||||||
│ └── usage.rs # Token usage tracking
|
|
||||||
├── ipc/
|
|
||||||
│ ├── protocol.rs # ClientRequest, DaemonFrame, StatePayload
|
|
||||||
│ ├── server.rs # Unix socket server
|
|
||||||
│ ├── client.rs # Unix socket client
|
|
||||||
│ ├── conn.rs # Framed connection
|
|
||||||
│ ├── frame.rs # Length-prefixed frame encoding
|
|
||||||
│ ├── snapshot.rs # State snapshot serialization
|
|
||||||
│ └── diff.rs # Binary diff for state sync
|
|
||||||
├── model/
|
|
||||||
│ ├── store.rs # File-based storage (~/.config/zesdex/)
|
|
||||||
│ ├── session.rs # Session CRUD and listing
|
|
||||||
│ ├── settings.rs # User settings (provider, model, tokens)
|
|
||||||
│ ├── app_config.rs # Provider definitions and model roles
|
|
||||||
│ ├── memory.rs # Persistent memory with frontmatter
|
|
||||||
│ ├── editlog.rs # Edit history tracking
|
|
||||||
│ ├── msglog/ # Message log (SQLite-backed)
|
|
||||||
│ │ ├── schema.rs # SQLite schema
|
|
||||||
│ │ ├── query.rs # Query helpers
|
|
||||||
│ │ ├── blobs.rs # Large blob storage
|
|
||||||
│ │ └── summary.rs # Session summarization
|
|
||||||
│ ├── agent_def/ # Agent definitions (builtin, global, session)
|
|
||||||
│ │ ├── builtin.rs # Built-in agent profiles
|
|
||||||
│ │ ├── global.rs # Global agent config
|
|
||||||
│ │ └── session.rs # Per-session agent config
|
|
||||||
│ ├── conversation.rs # Conversation helpers
|
|
||||||
│ └── session_lock.rs # Flock-based session locking
|
|
||||||
├── service/
|
|
||||||
│ ├── provider.rs # AI provider abstraction
|
|
||||||
│ └── oauth/ # OAuth PKCE flow with loopback server
|
|
||||||
│ ├── loopback.rs # Local HTTP server for OAuth redirect
|
|
||||||
│ ├── manager.rs # OAuth token manager
|
|
||||||
│ ├── pkce.rs # PKCE code challenge/verifier
|
|
||||||
│ └── mod.rs
|
|
||||||
├── tool/ # 34 tool implementations
|
|
||||||
│ ├── fs/ # read, write, edit, delete
|
|
||||||
│ │ ├── read.rs
|
|
||||||
│ │ ├── write.rs
|
|
||||||
│ │ ├── edit.rs
|
|
||||||
│ │ ├── delete.rs
|
|
||||||
│ │ └── helpers.rs # Path resolution and validation
|
|
||||||
│ ├── search.rs # grep, glob
|
|
||||||
│ ├── shell.rs # bash
|
|
||||||
│ ├── bash_tools.rs # bash_output, bash_kill
|
|
||||||
│ ├── git_operator.rs # git operations
|
|
||||||
│ ├── git_worktree.rs # git worktree management
|
|
||||||
│ ├── git_cred.rs # git credential store/get/erase
|
|
||||||
│ ├── memory/ # remember, forget, recall
|
|
||||||
│ │ ├── remember.rs
|
|
||||||
│ │ ├── forget.rs
|
|
||||||
│ │ └── recall.rs
|
|
||||||
│ ├── plan.rs # plan_enter, plan_ready
|
|
||||||
│ ├── seqthink.rs # Sequential thinking
|
|
||||||
│ ├── workflow.rs # workflow_run, note_finding
|
|
||||||
│ ├── utility/ # cd, dir_list, dir_cache_update, pong, todowrite, todofinish
|
|
||||||
│ │ ├── cd.rs
|
|
||||||
│ │ ├── dir_list.rs
|
|
||||||
│ │ ├── dir_cache_update.rs
|
|
||||||
│ │ ├── pong.rs
|
|
||||||
│ │ ├── todowrite.rs
|
|
||||||
│ │ └── todofinish.rs
|
|
||||||
│ ├── lsp/ # LSP tools (connect, diagnostics, hover, etc.)
|
|
||||||
│ │ └── mod.rs
|
|
||||||
│ └── shell_filter/ # Shell output filtering (credentials, git)
|
|
||||||
│ ├── credentials.rs
|
|
||||||
│ ├── git.rs
|
|
||||||
│ └── mod.rs
|
|
||||||
└── view/ # TUI rendering
|
|
||||||
├── chat.rs # Chat transcript with markdown
|
|
||||||
├── markdown.rs # Markdown → ratatui spans
|
|
||||||
├── status.rs # Status bar
|
|
||||||
├── theme.rs # Color scheme
|
|
||||||
└── workflow.rs # Workflow visualization
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run in single-process mode (default)
|
|
||||||
zesdex
|
|
||||||
|
|
||||||
# Run as a background daemon
|
|
||||||
zesdex --daemon
|
|
||||||
|
|
||||||
# Attach to a running daemon session
|
|
||||||
zesdex --attach <session-id>
|
|
||||||
|
|
||||||
# Set log level
|
|
||||||
RUST_LOG=debug zesdex
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Bindings
|
|
||||||
|
|
||||||
| Binding | Action |
|
|
||||||
|---------|--------|
|
|
||||||
| `Ctrl+Q` | Quit |
|
|
||||||
| `Ctrl+H` | Help overlay |
|
|
||||||
| `Ctrl+P` | Settings overlay |
|
|
||||||
| `Ctrl+B` | Bash panel |
|
|
||||||
| `Ctrl+S` | Session hub |
|
|
||||||
| `Ctrl+T` | Task list |
|
|
||||||
| `Ctrl+W` | Workflow view |
|
|
||||||
| `Ctrl+K` | Key input mode |
|
|
||||||
| `Esc` | Cancel / back |
|
|
||||||
| `Tab` | Autocomplete |
|
|
||||||
| `↑/↓` | History / navigation |
|
|
||||||
| `Scroll` | Mouse scroll in chat |
|
|
||||||
|
|
||||||
### Slash Commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `/help` | Show help |
|
|
||||||
| `/clear` | Clear transcript |
|
|
||||||
| `/model` | Select AI model provider |
|
|
||||||
| `/workflow` | Open the workflow panel |
|
|
||||||
| `/workflow run <script>` | Run a JSON-encoded workflow script |
|
|
||||||
| `/mcp` | Open MCP server manager |
|
|
||||||
| `/mcp add <name> <command>` | Add an MCP server |
|
|
||||||
| `/login [provider]` | Authenticate with a provider |
|
|
||||||
| `/edit [path]` | Open a file/dir in the external editor |
|
|
||||||
| `/compact` | Compact the conversation transcript |
|
|
||||||
| `/lesson` | Interactive lesson/memory review |
|
|
||||||
| `/quit` | Exit application |
|
|
||||||
| `Any text` | Sent to the AI assistant as a prompt |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All configuration lives in `~/.config/zesdex/` (or platform equivalent via the `dirs` crate).
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `settings.json` | Provider selection, model, temperature, max tokens, review settings, workflow concurrency |
|
|
||||||
| `app_config.json` | AI provider definitions (name, API base URL, auth type, default model) |
|
|
||||||
| `memory/` | Persistent lesson and reference storage (Markdown with YAML frontmatter) |
|
|
||||||
| `sessions/` | Per-session transcripts, edit logs, and activity data |
|
|
||||||
| `run/` | Unix domain sockets for daemon mode |
|
|
||||||
|
|
||||||
### Provider Configuration
|
|
||||||
|
|
||||||
Providers are defined in `app_config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"my-provider": {
|
|
||||||
"api_base": "https://api.example.com/v1",
|
|
||||||
"api_key_env": "MY_API_KEY",
|
|
||||||
"default_model": "model-name"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"model_roles": {
|
|
||||||
"default": {
|
|
||||||
"provider": "my-provider",
|
|
||||||
"model": "model-name",
|
|
||||||
"max_tokens": 8192,
|
|
||||||
"temperature": 0.7
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"default_provider": "my-provider",
|
|
||||||
"default_model": "model-name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Settings
|
|
||||||
|
|
||||||
Key settings in `settings.json`:
|
|
||||||
|
|
||||||
| Setting | Default | Description |
|
|
||||||
|---------|---------|-------------|
|
|
||||||
| `review_enabled` | `true` | Enable self-review after tool execution |
|
|
||||||
| `review_max_lessons_per_run` | `5` | Max lessons loaded per review cycle |
|
|
||||||
| `adaptive_review_max_skip` | `3` | Consecutive passes before skipping review |
|
|
||||||
| `verify_command` | `null` | Optional command to verify changes |
|
|
||||||
| `workflow_max_concurrency` | `5` | Max parallel sub-agents in workflows |
|
|
||||||
| `session_archive_enabled` | `true` | Auto-archive completed sessions |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- **Rust** 2021 edition toolchain ([rustup](https://rustup.rs/))
|
|
||||||
|
|
||||||
### Build from Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd zesdex
|
|
||||||
cargo build --release
|
|
||||||
./target/release/zesdex
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Commit Convention
|
|
||||||
|
|
||||||
Project ini menggunakan **Conventional Commits** untuk otomatis menentukan versi rilis (melalui semantic-release).
|
|
||||||
|
|
||||||
Format:
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description>
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
|
|
||||||
[optional footer]
|
|
||||||
```
|
|
||||||
|
|
||||||
**`<type>` — menentukan bump version:**
|
|
||||||
|
|
||||||
| Type | Bump | Keterangan |
|
|
||||||
|-------------|---------|-------------------------------------------|
|
|
||||||
| `feat` | minor | Fitur baru |
|
|
||||||
| `fix` | patch | Perbaikan bug |
|
|
||||||
| `chore` | patch | Tugas maintenance, refactor ringan |
|
|
||||||
| `docs` | patch | Perubahan dokumentasi |
|
|
||||||
| `refactor` | patch | Refactor kode (tanpa perubahan fungsional)|
|
|
||||||
| `test` | patch | Penambahan atau perbaikan test |
|
|
||||||
| `style` | patch | Perubahan formatting, whitespace, dll |
|
|
||||||
| `perf` | patch | Optimasi performa |
|
|
||||||
| `ci` | patch | Perubahan CI/CD |
|
|
||||||
|
|
||||||
**`BREAKING CHANGE`** pada body commit → **major** (apa pun typenya).
|
|
||||||
|
|
||||||
Contoh:
|
|
||||||
```
|
|
||||||
feat(agent): add workspace-aware file search
|
|
||||||
|
|
||||||
Implement context-aware search scoped to current workspace directory.
|
|
||||||
|
|
||||||
BREAKING CHANGE: search results now filter by workspace scope.
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
fix(ipc): handle partial frame on unix socket reconnect
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
chore: update rustls to 0.23
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release Workflow
|
|
||||||
|
|
||||||
Push ke branch `main` akan memicu:
|
|
||||||
1. **CI** — `cargo build --release` + `cargo test`
|
|
||||||
2. **Semantic Release** — analisis commit → update `Cargo.toml` + `CHANGELOG.md` → git tag → GitHub Release dengan binary
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
See [LICENSE](LICENSE) for details.
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# TODO — Rewrite Zesdex Rust → TypeScript/Bun
|
||||||
|
|
||||||
|
> Plan lengkap migrasi in-place dari Rust (11 crate, clean architecture 4 lapis) ke monorepo
|
||||||
|
> TypeScript/Bun. Bahasa Indonesia, commit pakai Conventional Commits.
|
||||||
|
>
|
||||||
|
> **Status:** Fase 0–6 **semua selesai**. Rust source dihapus, build CI/Docker/release pakai Bun, `flake.lock` Rust/Nix dibersihkan, wire-shape lama terbaca. Base: `bun run check` bersih, 70 test hijau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prinsip & peta pemetaan
|
||||||
|
|
||||||
|
| Rust | TS/Bun |
|
||||||
|
|---|---|
|
||||||
|
| `serde_json` | `JSON.stringify/parse` + `zod` |
|
||||||
|
| `chrono` | `Date` / `Date.now()` (epoch ms) |
|
||||||
|
| `uuid` | `crypto.randomUUID()` |
|
||||||
|
| `sha2`, `base64 URL_SAFE_NO_PAD` | `node:crypto` / `Buffer.toString('base64url')` |
|
||||||
|
| `reqwest` | `fetch` / bun HTTP |
|
||||||
|
| `rusqlite` | `bun:sqlite` |
|
||||||
|
| `argon2` | `@node-rs/argon2` |
|
||||||
|
| `jsonwebtoken` | `node:crypto` HMAC HS256 |
|
||||||
|
| `rmcp` | `@modelcontextprotocol/sdk` |
|
||||||
|
| `ratatui`/`crossterm` | library TUI Bun / ink |
|
||||||
|
| `tiktoken-rs` | `@dqbd/tiktoken` (cl100k_base) |
|
||||||
|
| `clap` | `Bun.argv` / `commander` |
|
||||||
|
| `tokio` semaphore | Bun async + concurrency pool |
|
||||||
|
| `libc` PID lock | `node:fs` `O_EXCL` + `process.kill` liveness |
|
||||||
|
| Rust generics `S<R: Repo>` | DI via constructor interface |
|
||||||
|
| `Arc<Mutex<T>>` | objek mutable (array/objek di ToolCtx) |
|
||||||
|
|
||||||
|
### Struktur target
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
packages/ # ✅ sudah ada: domain, application, infrastructure
|
||||||
|
interfaces/
|
||||||
|
cli/ # gateway + TUI [baru]
|
||||||
|
api/ # REST API [baru]
|
||||||
|
daemon/ # daemon + IPC [baru]
|
||||||
|
ws/ # WebSocket server [baru]
|
||||||
|
grpc/ # stub [baru]
|
||||||
|
web/ # static server [baru]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Selesai
|
||||||
|
|
||||||
|
### Fase 0 — Bootstrap monorepo
|
||||||
|
- [x] `package.json` root (workspaces), `tsconfig.json` strict, `.gitignore`
|
||||||
|
- [x] Paket `domain`, `application`, `infrastructure` (workspace `@zesdex/*`)
|
||||||
|
- [x] `bun install` + `bun test` jalan
|
||||||
|
|
||||||
|
### Fase 1 — Domain layer
|
||||||
|
- [x] `core`: ChatMessage, Conversation, ChatRequest/Response/StreamEvent, SseParser, ToolCall, repairJson, sanitizeToolArguments, ToolCallResult, UsageStats, Store
|
||||||
|
- [x] `auth`: Session, SessionId (path-traversal), OAuthToken/Config, repo/service trait
|
||||||
|
- [x] `cms`: AppConfig, ProviderConfig, ModelRole, Settings+resolveEffectiveModel, SettingsPatch, Memory, EditLog, repo/service trait
|
||||||
|
- [x] `agent`: Origin, Toast, AgentStatus, TurnEvent, SessionRuntime, AgentTurnParams, AgentProgress, ExecutionModel
|
||||||
|
- [x] `workflow`/`subagent`: WorkflowScript/NodeDirective, AccessTier, prompt builders
|
||||||
|
- [x] 27 test domain
|
||||||
|
|
||||||
|
### Fase 2 — Application layer
|
||||||
|
- [x] `ports`: ProviderService, PasswordService, TokenService, AuthService
|
||||||
|
- [x] `AgentTurnServiceImpl` (loop 50 iter, auto-compact 60k, adaptive token, temp, ErrorTracker, bounded parallel)
|
||||||
|
- [x] `OAuthUseCase` (PKCE S256 + CSRF state)
|
||||||
|
- [x] `SessionServiceImpl`, `MemoryServiceImpl`, `SettingsServiceImpl`, `ConversationServiceImpl`
|
||||||
|
- [x] 11 test application
|
||||||
|
|
||||||
|
### Fase 3a — LLM client
|
||||||
|
- [x] `LlmClient` (fetch, retry 10x backoff+jitter, abort 401/402/403)
|
||||||
|
- [x] `resolve_api_key`, `is_auth_error`, `backoff_for_error`
|
||||||
|
|
||||||
|
### Fase 3b — Tool system (`commit a291dd1`)
|
||||||
|
- [x] `Tool` interface, `ToolCtx`/`ToolCtxBuilder`, `InfrastructureToolExecutor`
|
||||||
|
- [x] Registry: `allTools` (37), `toolDefs`, `toolIsRisky`, `toolIsParallelSafe`
|
||||||
|
- [x] FS: read, write, edit, delete · **Guard**: graduated checks, resolve_path sandbox
|
||||||
|
- [x] Search: grep, glob (matcher ringan)
|
||||||
|
- [x] Shell: bash (timeout), bash_output, bash_kill · **Filter**: checkGitDestructive, checkCredentialRead
|
||||||
|
- [x] Git: git_operator, git_worktree, git_cred
|
||||||
|
- [x] Memory: remember, forget, recall
|
||||||
|
- [x] Util: cd, dir_list, dir_cache_update, pong, todowrite, todofinish
|
||||||
|
- [x] Reasoning/Plan: sequential_think, plan_enter, plan_ready
|
||||||
|
- [x] Web: web_search (async → SearXNG)
|
||||||
|
- [x] Semantic: semantic_search, rebuild_index, list_symbols (index multi-bahasa)
|
||||||
|
- [x] Best-practice: best_practice, commit_convention + BestPracticeEngine
|
||||||
|
- [x] Agent/Workflow: spawn_agents, spawn_pipeline, parallel_delegate, workflow_run, note_finding, read_findings, hive_mind (delegasi ke placeholder 3c/3d)
|
||||||
|
- [x] 16 test tool system
|
||||||
|
|
||||||
|
### Fase 3e — Persistence
|
||||||
|
- [x] File repos: settings, app_config (+ auto-detect kredensial Claude), conversation, edits.jsonl, markdown memory, rewind blob, session, oauth, session lock
|
||||||
|
- [x] `writeJsonAtomic`, `slugify`, `buildWorkspaceTree`, `runCommand`
|
||||||
|
|
||||||
|
### Fase 3c — Subagent engine
|
||||||
|
**Sumber Rust:** `apps/infrastructure/src/subagent/{engine,division,context,provider,spawn}.rs`, `subagent/auto/*`
|
||||||
|
|
||||||
|
- [x] `AccessTier.toolsFor(access)` — `tools/division.ts`: Read / Write / Full yang mem-filter registry
|
||||||
|
- [x] `run_agent` loop (`tools/engine.ts`): `MAX_ITERATIONS=25`, `TOOL_OUTPUT_MAX_CHARS=12_000`,
|
||||||
|
`MAX_CONSECUTIVE_TOOL_ERRORS=3`, `MAX_PARALLEL_TOOLS=8`; recovery note setelah 3 error beruntun
|
||||||
|
- [x] `SubagentContext` (`subagent/context.ts`) — directive, toolCtx, access, baseURL, apiKey, model
|
||||||
|
- [x] `spawn_subagent` — `spawn_tools.ts` — runAgent berbasis HTTP provider + config_resolver
|
||||||
|
- [x] `resolve_subagent_provider` (`subagent/provider.ts`) — pilih provider/model subagent dari settings + app_config
|
||||||
|
- [x] `spawn_tools.ts` — isi body `spawnAgents` (paralel) & `spawnPipeline` (berurutan)
|
||||||
|
- [x] `delegate.ts` — `runParallelDelegation` untuk `parallel_delegate`
|
||||||
|
- [x] `http_provider.ts` — shared HTTP provider service builder
|
||||||
|
- [ ] Auto-reviewer (`subagent/auto/*`) — `spawn_background_review`: git diff → LLM review → perbaiki;
|
||||||
|
wiring `review_enabled` (yang sekarang no-op di `Review` agent)
|
||||||
|
- [x] Placeholder di `apps/packages/infrastructure/src/subagent/*` diisi penuh
|
||||||
|
|
||||||
|
### Fase 3d — Workflow + hive-mind
|
||||||
|
**Sumber Rust:** `apps/infrastructure/src/workflow/{script,mod,docs}.rs`, `workflow/engine/{execution,phases,primitives}.rs`, `workflow/hive_mind/{complexity,cycle,synthesis,live,types}.rs`
|
||||||
|
|
||||||
|
- [x] `parse_workflow_script(yaml)` — parser YAML workflow (`workflow/script.ts`)
|
||||||
|
- [x] `execute_workflow(script, ctx)` — eksekusi multi-phase (`workflow/engine.ts` + `workflow/orchestrate.ts`)
|
||||||
|
- [x] Phase primitives: run sub-agent per node (`executePrimitive`)
|
||||||
|
- [x] `execute_cycle(cycle, ctx)` — hive-mind cycle, concurrency 8, kumpulkan NodeOutput (`workflow/cycle.ts`)
|
||||||
|
- [x] `synthesize_consensus(node_outputs, ctx)` — konsensus via LLM + fallback concat (`workflow/synthesis.ts`)
|
||||||
|
- [x] `complexity_heuristic` — pilih apakah cukup 1 cycle / butuh banyak (`workflow/complexity.ts`)
|
||||||
|
- [x] `docs.ts` — tulis convergence/decision doc (`workflow/docs.ts`)
|
||||||
|
- [x] Isi body `WorkflowRun` & `HiveMind` tool (ganti placeholder) di `tools/workflow.ts`
|
||||||
|
- [x] `orchestrate.ts` — top-level `executeWorkflow` + `executeHiveMind` entry points
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⬜ Yang belum dikerjakan
|
||||||
|
|
||||||
|
### Fase 3f — Auth infrastructure
|
||||||
|
**Sumber Rust:** `apps/infrastructure/src/auth/{password,jwt,oauth_loopback,mod}.rs`
|
||||||
|
|
||||||
|
- [x] `password.ts` — Argon2 via `@node-rs/argon2` (hash + verify), port `PasswordService`
|
||||||
|
- [x] `jwt.ts` — HMAC HS256 via `node:crypto` (access + refresh token, typ, exp), port `TokenService`
|
||||||
|
- [x] `oauth_loopback.ts` — server TCP loopback lokal utk terima redirect OAuth; port `oauth_loopback`
|
||||||
|
- [x] `mod.ts` (`index.ts`) — composisi ke `AuthService`
|
||||||
|
- [x] Tambah dep `@node-rs/argon2` di `infrastructure/package.json`
|
||||||
|
|
||||||
|
### Fase 3g — IPC + bgbash
|
||||||
|
**Sumber Rust:** `apps/infrastructure/src/ipc/{frame,protocol,server,client,conn}.rs`, `apps/infrastructure/src/bgbash/{job,control}.rs`
|
||||||
|
|
||||||
|
- [x] `ipc/frame.ts` — length-prefixed framing via `node:net` (kirim/terima bingkai biner)
|
||||||
|
- [x] `ipc/conn.ts` — `Connection` framed JSON socket; `ipc/protocol.ts` — wire types
|
||||||
|
- [x] `ipc/server.ts` + `ipc/client.ts` — Unix socket server + attach client; protocol message (NLJSON/binary)
|
||||||
|
- [x] `bgbash/job.ts` — `BashJob` (pid, output file, log), `spawnBashJob`
|
||||||
|
- [x] `bgbash/control.ts` — registry job global: `register`, `cancel(job_id)`, `is_running`
|
||||||
|
- [x] Wire `bash` tool `run_in_background` → `spawnBashJob` + `register`; `bash_kill` → `cancel`
|
||||||
|
- [x] Update `bash_output` untuk baca dari registry/output dir
|
||||||
|
|
||||||
|
### Fase 4 — Gateway + CLI + TUI
|
||||||
|
**Sumber Rust:** `apps/gateway/src/{lib,main}.rs`, `apps/interfaces/tui/src/**`
|
||||||
|
|
||||||
|
- [x] `apps/interfaces/cli/` (paket Bun):
|
||||||
|
- [x] Parser arg: `--api/--daemon/--attach/--ws/--grpc/--web`, dispatch mode (`args.ts`)
|
||||||
|
- [x] Logging ke file (`index.ts`)
|
||||||
|
- [x] `run_single_process` — komposisi root: wire semua implementasi konkret sekali (`compose.ts`) + REPL loop
|
||||||
|
- [x] Bootstrap seed (`bootstrap.ts`) — wire-shape settings lama terbaca
|
||||||
|
- [x] CLI: parser arg + dispatch + single-process REPL loop + bootstrap seed (Smoke: `bun run cli --help`, `bun run bootstrap` jalan; loop turn + tool read/write)
|
||||||
|
- [x] TUI mode (`@zesdex/tui`, tanpa ncurses — render garis ANSI):
|
||||||
|
- [x] Event loop raw-mode stdin (run.ts) + key decode (decodeKey)
|
||||||
|
- [x] Key dispatcher `handleKey` → `Action[]` + overlay/autocomplete/editor handling (controller.ts)
|
||||||
|
- [x] `Action` enum + `applyAction` dispatcher (action.ts) + slash-command parse/apply (command.ts)
|
||||||
|
- [x] `InputState` (buffer/cursor/history/autocomplete) + `AppStateRest` state machine (state.ts)
|
||||||
|
- [x] Renderer ringan (render.ts): transcript, input, overlay, status — 20 unit tests hijau
|
||||||
|
- [x] Bootstrap seed (`bootstrap`) — `bun run bootstrap` seed defaults, wire-shape settings lama terbaca
|
||||||
|
|
||||||
|
### Fase 5 — Server interface: api / daemon / ws / grpc / web
|
||||||
|
**Sumber Rust:** `apps/interfaces/{api,daemon,ws,grpc,web}/src/**`, `apps/infrastructure/src/middleware/*`
|
||||||
|
|
||||||
|
- [x] **API** (`apps/interfaces/api/`):
|
||||||
|
- [x] Endpoint: `auth` (login/register/refresh, rate-limited), `sessions` CRUD, `conversations`, `chat/completions` proxy, `health`
|
||||||
|
- [x] JWT middleware, CORS
|
||||||
|
- [x] DTO (dto/)
|
||||||
|
- [x] Smoke `bun run api`: health 200; register/login 200; wrong-pwd 401; protected-no-token 401; create/list/delete session; add conversation msg
|
||||||
|
- [x] **daemon** — IPC agent-driver loop (Submit/Close) over per-session Unix socket; streaming token frames; wire CLI `--daemon`/`--attach`
|
||||||
|
- [x] **ws** — handler `/ws`, protokol token/connected/done/error; smoke: no-token rejected 401, with-token welcome
|
||||||
|
- [x] **grpc** — stub (health endpoint); smoke: `/grpc/health` 200
|
||||||
|
- [x] **web** — static server + proteksi traversal; smoke: `/` serves index, traversal 404
|
||||||
|
- [x] CLI `--api`/`--ws`/`--grpc`/`--web` dispatch → start server masing-masing (smoke: `bun run cli --api --api-port` → health 200)
|
||||||
|
|
||||||
|
### Fase 6 — Bersih-bersih + verifikasi akhir
|
||||||
|
- [x] Hapus semua sisa crate Rust: `apps/{domain,application,infrastructure,gateway,bootstrap,interfaces/*}` file `.rs`, `Cargo.toml`, `Cargo.lock`, `.cargo/`, `lefthook.yml`
|
||||||
|
- [x] Update `install.sh` → Bun
|
||||||
|
- [x] Update CI workflow: `.github/workflows/{ci,release}.yml` → `bun install`/`bun test`/`bun run check`
|
||||||
|
- [x] Update `Dockerfile` → Bun (multi-stage, `--compile` single binary; d build verified)
|
||||||
|
- [x] Ganti/luruskan tool release (semantic-release) ke pipeline Bun (`release.yml` test job → bun; prepareCmd → `bun run check`)
|
||||||
|
- [x] `flake.lock` dead (Rust/Nix era) dihapus — deploy infra 100% Bun (`deploy.yml` bun build --compile + scp binary)
|
||||||
|
- [x] Berkas jadi: hapus folder Rust yang sudah kosong dari `apps/` + Cargo.toml/lock + .cargo + lefthook
|
||||||
|
- [x] Verifikasi akhir: `tsc --noEmit` exit 0, `bun test` 74 hijau, `bun run cli --help` jalan
|
||||||
|
- [x] Wire-shape lama terbaca — `settings.json` (Rust-era shape: api_keys/provider/model/review_*) dimuat true di headless + TUI; repos direct JSON parser. Verified: `--headless "1+1"` → "2", TUI header tunjuk `claude-opus-5`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifikasi global (tiap fase)
|
||||||
|
- [ ] `bun run build` / `tsc --noEmit` tanpa error (strict)
|
||||||
|
- [ ] `bun test` — port test Rust ke `*.test.ts` (SseParser, repairJson, resolveEffectiveModel, SessionId, turn_service, tool system, subagent, workflow)
|
||||||
|
- [ ] Smoke: `bun run cli --help`; agent mode TUI dengan model stub/lokal; pastikan loop turn + tool read/write
|
||||||
|
- [ ] Server: `bun run api` + hit health/auth; `bun run ws` + connect
|
||||||
|
- [ ] Commit tiap fase dengan message Bahasa Indonesia + Conventional Commits; test hijau sebelum lanjut
|
||||||
|
|
||||||
|
## Catatan
|
||||||
|
- Registry tool = **38** (terhitung workflow_run, note_finding, read_findings, hive_mind; ExploreCodebase tidak diport).
|
||||||
|
- `Tool.run` boleh return `string | Promise<string>`.
|
||||||
|
- `spawn_agents`/`spawn_pipeline`/`parallel_delegate` sudah terisi penuh via 3c.
|
||||||
|
- Workflow/hive-mind tools sudah terisi penuh via 3d.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "zesdex",
|
||||||
|
"dependencies": {
|
||||||
|
"@node-rs/argon2": "^2.2.0",
|
||||||
|
"@opentui/core": "^0.5.10",
|
||||||
|
"@opentui/react": "^0.5.10",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.2.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@node-rs/argon2": ["@node-rs/argon2@2.2.0", "", { "optionalDependencies": { "@node-rs/argon2-android-arm-eabi": "2.2.0", "@node-rs/argon2-android-arm64": "2.2.0", "@node-rs/argon2-darwin-arm64": "2.2.0", "@node-rs/argon2-darwin-x64": "2.2.0", "@node-rs/argon2-freebsd-x64": "2.2.0", "@node-rs/argon2-linux-arm-gnueabihf": "2.2.0", "@node-rs/argon2-linux-arm64-gnu": "2.2.0", "@node-rs/argon2-linux-arm64-musl": "2.2.0", "@node-rs/argon2-linux-x64-gnu": "2.2.0", "@node-rs/argon2-linux-x64-musl"Line truncated
|
||||||
|
|
||||||
|
"@node-rs/argon2-android-arm-eabi": ["@node-rs/argon2-android-arm-eabi@2.2.0", "", { "os": "android", "cpu": "arm" }, "sha512-xbJvpiTlYqKxq5XRkhQlZNiIRE4fZPFJdB46jOPED8pGkxgn1erMMdVCPWp1RcOxuChaiEOl8DVu5sVVu9ApVw=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-android-arm64": ["@node-rs/argon2-android-arm64@2.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-i47xYwh8GNKc0yNwFQz7Hqsx6pBZJsJ1hqRXmtThOXbjl6HoVlX7oGBpqq4s+sBrU8ba0kubAqJ8fBjtp4iwHQ=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-darwin-arm64": ["@node-rs/argon2-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zet1ekAQPUczRQ6cmFCpQyScg9346SnzYt7CRWPd4GCs8CBOOK34PrtbazdReHcBxUgSDnhMhDWnO0jmtJ15sQ=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-darwin-x64": ["@node-rs/argon2-darwin-x64@2.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JeOpmf5glG9MTS+qt6VZ3tUUZdQ3Kll/e18yUeBmDRSKQDN0AJs5ruzqV08DOclVUqXG0IF2DVhbE825RN96yA=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-freebsd-x64": ["@node-rs/argon2-freebsd-x64@2.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-w7R4yoAcOlZctOz7EHhf2hFHSuRyJcphMZAcyKRJ3Sxu8APjqifLepOkuXC1Gat08DYFTI51RMPj7sMU36KcGA=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-linux-arm-gnueabihf": ["@node-rs/argon2-linux-arm-gnueabihf@2.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-H5uGMLmj2vor3YDAX+LIyw4yGXEESXvr5voyRK+AuxdIvZ/B7mxma06IOmb+4n9t/MQ1Lbwu7gkyWt8f3wyrNA=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-linux-arm64-gnu": ["@node-rs/argon2-linux-arm64-gnu@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0A+tUMI7NwImaK7txceScl7d18GAw4hkt27NChZIZdD55w7qZ81LFcA4cEP9L6kgdubItlAqcD/qBR5XMCd5Q=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-linux-arm64-musl": ["@node-rs/argon2-linux-arm64-musl@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-i5s9msRRy+m/yul9M+1OF7fN/VqhJ+4IalPn1rhyrE3gwNcgfoT+A0DuAqR5gtXmDb3zx+QWIjtHCLhvkvu23g=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-linux-x64-gnu": ["@node-rs/argon2-linux-x64-gnu@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-R6oaRA2Iesbsexe06Aeydbdz617WD2lE6yDcb4t5T+0iGM1VyVpI+nNpcAtkcXyO6Dgmuhh+3RKbm/F2CSBt1w=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-linux-x64-musl": ["@node-rs/argon2-linux-x64-musl@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Paql63t7XusSTYMSfEQ0ex5edubWX/OhvLRYsC+oQxWiC+3x2WcYWp+fWVwdlAm+eJRsIOj6w3iG3yDy94ZL8A=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-win32-arm64-msvc": ["@node-rs/argon2-win32-arm64-msvc@2.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ysl+KaEeYVBKY3/J4w3VKD7KVHATpsJ/TMtxjmVyX8KEhwemH2qmlyZcVQb5fPy+Yk18g7cHu7LYuDffROo8pw=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-win32-ia32-msvc": ["@node-rs/argon2-win32-ia32-msvc@2.2.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-2Hp0RtOgM8jxcTsrtYlIkfk0PQXFnENyqvEuFTNCHMFSWhV8D/Ds2OrWSmjWTGQhLNWNp2JrX4XKsZM0+PB3NA=="],
|
||||||
|
|
||||||
|
"@node-rs/argon2-win32-x64-msvc": ["@node-rs/argon2-win32-x64-msvc@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cfcNjVqLpgOU/9l2mLOFduuXTxvGH5qoV38AhcOz9oQaC7GDmsFbAgcuizVNuOtpSyQr5HoQez3f2X2jYgGMsg=="],
|
||||||
|
|
||||||
|
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentuiLine truncated
|
||||||
|
|
||||||
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||||
|
|
||||||
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||||
|
|
||||||
|
"@opentui/react": ["@opentui/react@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-76UFqe/ML121Skjche2+mGX56GKTlwG3ftrOi3b7WGLvjGucPqgEDJ0+CmaC/Yk85O8ui/ZTZJ2tay/8foR4FQ=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||||
|
|
||||||
|
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||||
|
|
||||||
|
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||||
|
|
||||||
|
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
||||||
|
|
||||||
|
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||||
|
|
||||||
|
"react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
# Zesdex is now a pure CLI/TUI tool (no REST/WS/gRPC/web/daemon servers).
|
||||||
|
# This compose file just provides an interactive TTY shell into the binary.
|
||||||
|
services:
|
||||||
|
zesdex:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: zesdex:latest
|
||||||
|
container_name: zesdex
|
||||||
|
volumes:
|
||||||
|
- zesdex-data:/data
|
||||||
|
- ./workspace:/workspace:ro
|
||||||
|
environment:
|
||||||
|
- ZESDEX_DATA_DIR=/data
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
zesdex-data:
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~750 -->
|
|
||||||
|
|
||||||
# Architecture
|
|
||||||
|
|
||||||
Zesdex is a single-process terminal AI coding agent with optional daemon/client split.
|
|
||||||
|
|
||||||
## System Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────┐
|
|
||||||
│ main.rs │
|
|
||||||
│ single-process ─┬── daemon ── Unix socket ── client │
|
|
||||||
│ └── attach <id> (TUI-only client) │
|
|
||||||
└──────────────────────┬───────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────────────▼───────────────────────────────┐
|
|
||||||
│ Event Loop │
|
|
||||||
│ ┌────────┐ ┌───────────┐ ┌──────┐ ┌────────┐ │
|
|
||||||
│ │Input │──▶│ Actions │──▶│State │──▶│ TUI │ │
|
|
||||||
│ │Handler │ │ (dispatch)│ │ │ │ Render │ │
|
|
||||||
│ └────────┘ └─────┬─────┘ └──────┘ └────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────▼──────┐ │
|
|
||||||
│ │ LLM Stream │ │
|
|
||||||
│ │ + Tool Exec │ │
|
|
||||||
│ └──────┬──────┘ │
|
|
||||||
│ ┌────┴────┐ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌─────▼──┐ ┌───▼────┐ │
|
|
||||||
│ │ Tools │ │Sub- │ │
|
|
||||||
│ │ (37) │ │agents │ │
|
|
||||||
│ └────────┘ └────────┘ │
|
|
||||||
└───────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User keystroke → Controller (KeyEvent → Action)
|
|
||||||
→ apply_action() mutates AppStateRest
|
|
||||||
→ TUI redraws (ratatui Frame)
|
|
||||||
→ On submit: LLM request → SSE stream → tool calls → tool results → more LLM
|
|
||||||
→ Session persisted to disk (editlog, msglog, memory)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Process Modes
|
|
||||||
|
|
||||||
| Mode | Impl | Process | IPC |
|
|
||||||
|------|------|---------|-----|
|
|
||||||
| Single | `run_single_process()` | One | No |
|
|
||||||
| Daemon | `run_daemon()` | Server | `ipc/server.rs` |
|
|
||||||
| Attach | `run_attach()` | Client | `ipc/client.rs` |
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Lines | Role |
|
|
||||||
|------|-------|------|
|
|
||||||
| `src/main.rs` | 647 | Entry, TUI setup, daemon loop, attach loop |
|
|
||||||
| `src/app/runtime/actions/mod.rs` | 1815 | Action dispatch + LLM stream loop + tool execution |
|
|
||||||
| `src/controller/input.rs` | 365 | Key event → Action mapping |
|
|
||||||
| `src/view/mod.rs` | 975 | TUI rendering (ratatui) |
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~850 -->
|
|
||||||
|
|
||||||
# Backend / Service Layer
|
|
||||||
|
|
||||||
## AI Provider
|
|
||||||
|
|
||||||
`src/service/provider.rs` (310 lines)
|
|
||||||
- `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client
|
|
||||||
- `chat_with_tools()` — non-streaming with tool definitions
|
|
||||||
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
|
|
||||||
- Retry logic: up to 3 attempts on transient errors, exponential backoff
|
|
||||||
|
|
||||||
## OAuth
|
|
||||||
|
|
||||||
`src/service/oauth/manager.rs` (113 lines) + `loopback.rs` + `pkce.rs`
|
|
||||||
- PKCE flow: `CodeVerifier` → challenge → browser auth → loopback server → token exchange
|
|
||||||
- Configurable via `app_config.json` provider definitions (auth URL, token URL, scopes)
|
|
||||||
|
|
||||||
## IPC / Daemon
|
|
||||||
|
|
||||||
`src/ipc/` (7 files, ~350 lines total)
|
|
||||||
- Unix domain socket, length-prefixed JSON frames
|
|
||||||
- Daemon sends `DaemonFrame` (state payload, stream tokens, system notes)
|
|
||||||
- Clients send `ClientRequest` (key presses, resize, submit, scroll)
|
|
||||||
- State sync uses full-state push from daemon to client after each action
|
|
||||||
|
|
||||||
## Workflow Engine
|
|
||||||
|
|
||||||
`src/app/workflow/engine.rs` (648 lines) + `script.rs`
|
|
||||||
- Inline JS-style DSL executed by a lightweight runtime
|
|
||||||
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
|
|
||||||
- Max concurrency configurable via `workflow_max_concurrency` setting
|
|
||||||
- Hive-mind orchestrator in `hive_mind.rs`: Core Intelligence compiles a `CognitiveCyclePlan` per task — cycle count and nodes-per-cycle are decided fresh each time based on what the task actually needs
|
|
||||||
|
|
||||||
## Sub-Agent System
|
|
||||||
|
|
||||||
`src/app/subagent/` (6 files: `spawn.rs`, `engine.rs`, `context.rs`, `event.rs`, `division.rs`, `auto.rs`, ~450 lines total)
|
|
||||||
- `run_subagent()` — spawns independent agent with its own tool set and context
|
|
||||||
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
|
|
||||||
- Uses `LlmClient` (same as main agent) with tool-use API
|
|
||||||
- Auto-healing: on build/test failure, spawns auto-fix sub-agent
|
|
||||||
- Node access tiers (`division.rs`'s `tool_scope` module): `read`, `write`, `full` — granted per node by the Core Intelligence based on what its directive needs
|
|
||||||
|
|
||||||
## MCP Client
|
|
||||||
|
|
||||||
`src/app/mcp/manager.rs` (441+ lines)
|
|
||||||
- Stdio transport: spawns child process, JSON-RPC via stdin/stdout
|
|
||||||
- HTTP transport: streaming HTTP with JSON-RPC
|
|
||||||
- Dynamic tool list refresh and error recovery
|
|
||||||
- Persistent child handle for stdio (reuses connection across calls)
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
`src/app/review/mod.rs` (495 lines)
|
|
||||||
- Post-tool execution quality check against learned lessons
|
|
||||||
- Invokes `run_subagent()` with reviewer prompt
|
|
||||||
- Staleness detection: skips review after N consecutive empty results
|
|
||||||
- Three review types: code quality, architecture, security
|
|
||||||
|
|
||||||
## Background Bash
|
|
||||||
|
|
||||||
`src/app/bgbash/` (2 files: `job.rs`, `control.rs`)
|
|
||||||
- `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line
|
|
||||||
- Channels: output via `mpsc<String>`, PID via `mpsc<u32>`
|
|
||||||
- Killable via PID (SIGTERM)
|
|
||||||
- Output buffering capped at 10,000 lines to prevent memory issues
|
|
||||||
|
|
||||||
## Gate Guard / Harness
|
|
||||||
|
|
||||||
`src/app/harness.rs` (495 lines)
|
|
||||||
- `Harness::gate_tool_call()` — verdict-based tool gating (allow/block)
|
|
||||||
- Path traversal, credential read, and destructive command detection
|
|
||||||
- Pattern detection for stub code, denial language, and assumptions in write/edit content
|
|
||||||
- Reason validation for mutating tools (minimum 8 characters, rejects generic non-answers)
|
|
||||||
- Includes 8 unit tests for verdict parsing formats
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~600 -->
|
|
||||||
|
|
||||||
# Data / Persistence Layer
|
|
||||||
|
|
||||||
## Storage Overview
|
|
||||||
|
|
||||||
Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.config/zesdex/
|
|
||||||
├── settings.json # User preferences (provider, model, tokens)
|
|
||||||
├── app_config.json # Provider definitions (API base, auth, models)
|
|
||||||
├── agents/ # Global agent definitions
|
|
||||||
│ └── *.json
|
|
||||||
├── memory/ # Persistent lesson/reference store
|
|
||||||
│ └── *.md # Markdown with YAML frontmatter
|
|
||||||
├── sessions/ # Per-session data
|
|
||||||
│ └── <session-uuid>/
|
|
||||||
│ ├── edits.jsonl # Edit history (JSONL, append-only)
|
|
||||||
│ ├── msglog.db # SQLite message log
|
|
||||||
│ ├── transcript.json # Chat transcript
|
|
||||||
│ ├── session.json # Session metadata
|
|
||||||
│ ├── agents.json # Session-local agent defs
|
|
||||||
│ └── snapshot.dat # State snapshot (daemon mode)
|
|
||||||
├── run/ # Unix domain sockets
|
|
||||||
│ └── zesdex-*.sock
|
|
||||||
└── store.json # Legacy session index
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Lines | Role |
|
|
||||||
|------|-------|------|
|
|
||||||
| `src/model/store.rs` | ~50 | File-system storage (ensure_dirs, base_dir resolution) |
|
|
||||||
| `src/model/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map |
|
|
||||||
| `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth |
|
|
||||||
| `src/model/memory.rs` | 440 | Memory CRUD — markdown files with frontmatter |
|
|
||||||
| `src/model/editlog.rs` | 161 | Edit log — append-only JSONL (not JSON array) |
|
|
||||||
| `src/model/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) |
|
|
||||||
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
|
|
||||||
| `src/model/session_lock.rs` | ~50 | flock-based session lock |
|
|
||||||
| `src/model/agent_def/` | 3 files | Agent definitions (builtin, global, session-local) |
|
|
||||||
|
|
||||||
## Key Patterns
|
|
||||||
|
|
||||||
- **No ORM** — raw JSON files + SQLite via rusqlite
|
|
||||||
- **settings.json** — loaded at startup, saved on quit / mode switches
|
|
||||||
- **Memory format** — Markdown files with YAML frontmatter (`---\nname: ...\ndescription: ...\n---\ncontent`)
|
|
||||||
- **Edit log** — append-only, stores `(file, old, new, timestamp, tool)`
|
|
||||||
- **Session locking** — flock-based, prevents concurrent access to same session dir
|
|
||||||
- **Message log** — SQLite with attached blobs for tool arguments/outputs
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~400 -->
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
|
|
||||||
## Rust Crates (Cargo.toml)
|
|
||||||
|
|
||||||
| Crate | Version | Purpose |
|
|
||||||
|-------|---------|---------|
|
|
||||||
| ratatui | 0.30 | TUI framework (tui-rs successor) |
|
|
||||||
| crossterm | 0.29 | Terminal manipulation (raw mode, alt screen) |
|
|
||||||
| tokio | 1 | Async runtime (daemon, OAuth loopback) |
|
|
||||||
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
|
|
||||||
| serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) |
|
|
||||||
| serde_yaml_ng | 0.10 | YAML frontmatter parsing (memory files) |
|
|
||||||
| anyhow | 1 | Error handling (no custom error types) |
|
|
||||||
| tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file |
|
|
||||||
| rusqlite | 0.40 | SQLite (bundled, for message log) |
|
|
||||||
| pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) |
|
|
||||||
| syntect | 5 | Syntax highlighting (code blocks in chat) |
|
|
||||||
| sha2 | 0.10 | SHA-256 for PKCE challenge |
|
|
||||||
| base64 | 0.22 | URL-safe base64 for PKCE |
|
|
||||||
| libc | 0.2 | daemon PID file locking |
|
|
||||||
| rmcp | 2.2 | MCP client (stdio + HTTP transports) |
|
|
||||||
| uuid | 1 | Session IDs, job IDs |
|
|
||||||
| chrono | 0.4 | Timestamps (ISO 8601, millis) |
|
|
||||||
| dirs | 6 | Platform data directories |
|
|
||||||
| dom_smoothie | 0.18 | HTML → plain text (web scraping) |
|
|
||||||
| scraper | 0.27 | HTML parsing (web scraping) |
|
|
||||||
| ignore | 0.4 | .gitignore-aware file walking (glob tool) |
|
|
||||||
| regex / globset | 0.4 | Pattern matching (grep/glob tools) |
|
|
||||||
| url / percent-encoding | 2 | URL parsing + encoding (OAuth) |
|
|
||||||
|
|
||||||
## External Services
|
|
||||||
|
|
||||||
| Service | Integration | Notes |
|
|
||||||
|---------|-------------|-------|
|
|
||||||
| **LLM providers** | HTTP API (OpenAI-compatible) | Configurable via app_config.json |
|
|
||||||
| **MCP servers** | stdio or HTTP | Model Context Protocol |
|
|
||||||
| **git** | CLI (spawns `git`) | Via git_operator/git_worktree/git_cred tools |
|
|
||||||
| **sh** | CLI (spawns `sh`) | Via bash tool |
|
|
||||||
| **webbrowser** | opens URL | OAuth browser flow |
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~700 -->
|
|
||||||
|
|
||||||
# Frontend / TUI
|
|
||||||
|
|
||||||
## Render Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
ratatui::Terminal::draw(|frame|)
|
|
||||||
→ view::draw(frame, AppStateRest)
|
|
||||||
→ render_main_panel / render_overlay (based on overlay state)
|
|
||||||
→ render_input_bar
|
|
||||||
→ draw_status_bar
|
|
||||||
→ render_toasts (top-right floating notifications)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────┐
|
|
||||||
│ Chat Panel (main_area: Min 3) │
|
|
||||||
│ ┌────────────────────────────────────────┐ │
|
|
||||||
│ │ User: Hello │ │
|
|
||||||
│ │ Agent: Hi there, how can I help? │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Toast notifications (top-right) │ │
|
|
||||||
│ └────────────────────────────────────────┘ │
|
|
||||||
├──────────────────────────────────────────────┤
|
|
||||||
│ Input Bar (3 lines) │
|
|
||||||
│ > Some text... │
|
|
||||||
├──────────────────────────────────────────────┤
|
|
||||||
│ Status Bar (1 line) │
|
|
||||||
│ ┌ Provider │ Model │ Tokens │ Mode │ Quit ─┤
|
|
||||||
└──────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Lines | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `src/view/mod.rs` | 623 | Frame draw, overlays (16 types), input bar, toasts |
|
|
||||||
| `src/view/chat.rs` | 155 | Chat transcript rendering with markdown |
|
|
||||||
| `src/view/markdown.rs` | 144 | Markdown → ratatui `Span` rendering (pulldown-cmark + syntect) |
|
|
||||||
| `src/view/status.rs` | ~50 | Status bar with provider/model/tokens |
|
|
||||||
| `src/view/workflow.rs` | 88 | Workflow progress visualization |
|
|
||||||
| `src/view/theme.rs` | 23 | Color palette (23 named colors) |
|
|
||||||
| `src/controller/input.rs` | 281 | Key event → Action mapping |
|
|
||||||
|
|
||||||
## Overlays (16 types)
|
|
||||||
|
|
||||||
`Overlay::Help | Settings | Bash | QuitConfirm | Workflow | KeyInput | Editor | Effort | Mcp | Todo | Rewind | Learning | Usage | Loading | ModelSelector | ClearConfirm`
|
|
||||||
|
|
||||||
Each overlay renders a centered popup via `render_overlay()`.
|
|
||||||
|
|
||||||
## State Mutations
|
|
||||||
|
|
||||||
State is mutated in-place from two locations:
|
|
||||||
- `src/controller/input.rs` — keyboard shortcuts and overlay interactions
|
|
||||||
- `src/app/runtime/actions/mod.rs` — `apply_action()` reducer for all programmatic actions
|
|
||||||
|
|
||||||
## Toast Notifications
|
|
||||||
|
|
||||||
`render_toasts()` — floating stack at top-right, color-coded by severity:
|
|
||||||
- Info: blue, Success: green, Warning: yellow, Error: red, Lesson: cyan
|
|
||||||
- Max 4 visible, auto-expire after 5s lifetime
|
|
||||||
File diff suppressed because it is too large.
Load diff
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BIN_NAME="zesdex"
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
TMP_BIN="/tmp/$BIN_NAME"
|
||||||
|
|
||||||
|
# Need Bun to compile the CLI.
|
||||||
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: 'bun' is required to build $BIN_NAME." >&2
|
||||||
|
echo "Install it from https://bun.sh (curl -fsSL https://bun.sh/install | bash)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Installing dependencies..."
|
||||||
|
bun install --cwd "$REPO_DIR" --frozen-lockfile
|
||||||
|
|
||||||
|
echo "==> Compiling $BIN_NAME (self-contained executable)..."
|
||||||
|
bun build "$REPO_DIR/src/interfaces/cli/index.ts" --compile --outfile "$TMP_BIN"
|
||||||
|
|
||||||
|
if [ ! -f "$TMP_BIN" ]; then
|
||||||
|
echo "ERROR: build succeeded but binary not found at $TMP_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "$TMP_BIN"
|
||||||
|
|
||||||
|
echo "==> Binary built: $TMP_BIN"
|
||||||
|
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
BIN_PATH="$INSTALL_DIR/$BIN_NAME"
|
||||||
|
|
||||||
|
if [ -L "$BIN_PATH" ] || [ -f "$BIN_PATH" ]; then
|
||||||
|
echo "==> Removing existing symlink or file at $BIN_PATH"
|
||||||
|
rm -f "$BIN_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$TMP_BIN" "$BIN_PATH"
|
||||||
|
echo "==> Installed: $BIN_PATH"
|
||||||
|
echo ""
|
||||||
|
echo "Done. Make sure $INSTALL_DIR is in your PATH."
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://app.kilo.ai/config.json",
|
||||||
|
"model": "anthropic/claude-sonnet-4-20250514",
|
||||||
|
"instructions": [
|
||||||
|
"AGENTS.md",
|
||||||
|
"CLAUDE.md"
|
||||||
|
],
|
||||||
|
"skills": {
|
||||||
|
"paths": [
|
||||||
|
".kilo/skills/clean-code",
|
||||||
|
".kilo/skills/commit-convention",
|
||||||
|
".kilo/skills/push-flow-convention",
|
||||||
|
".kilo/skills/kana-rust-backend-best-practice"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "zesdex",
|
||||||
|
"version": "1.24.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "bun@1.3.14",
|
||||||
|
"description": "Zesdex — AI-native autonomous coding agent (TypeScript/Bun)",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun build src/interfaces/cli/index.ts --compile --outfile=dist/zesdex",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"test": "bun test",
|
||||||
|
"tui": "bun src/interfaces/cli/index.ts",
|
||||||
|
"cli": "bun src/interfaces/cli/index.ts --headless",
|
||||||
|
"headless": "bun src/interfaces/cli/index.ts --headless",
|
||||||
|
"bootstrap": "bun src/interfaces/cli/bootstrap.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.2.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@node-rs/argon2": "^2.2.0",
|
||||||
|
"@opentui/core": "^0.5.10",
|
||||||
|
"@opentui/react": "^0.5.10",
|
||||||
|
"react": "^19.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
branches: ["main"],
|
|
||||||
plugins: [
|
|
||||||
[
|
|
||||||
"@semantic-release/commit-analyzer",
|
|
||||||
{
|
|
||||||
preset: "angular",
|
|
||||||
releaseRules: [
|
|
||||||
{ type: "chore", release: "patch" },
|
|
||||||
{ type: "docs", release: "patch" },
|
|
||||||
{ type: "refactor", release: "patch" },
|
|
||||||
{ type: "test", release: "patch" },
|
|
||||||
{ type: "style", release: "patch" },
|
|
||||||
{ type: "perf", release: "patch" },
|
|
||||||
{ type: "ci", release: "patch" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"@semantic-release/release-notes-generator",
|
|
||||||
"@semantic-release/changelog",
|
|
||||||
[
|
|
||||||
"@semantic-release/exec",
|
|
||||||
{
|
|
||||||
prepareCmd:
|
|
||||||
'sed -i \'s/^version = ".*"/version = "${nextRelease.version}"/\' Cargo.toml && cargo check',
|
|
||||||
publishCmd: "cargo build --release",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"@semantic-release/git",
|
|
||||||
{
|
|
||||||
assets: ["Cargo.toml", "Cargo.lock", "CHANGELOG.md"],
|
|
||||||
message:
|
|
||||||
"chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"@semantic-release/github",
|
|
||||||
{
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
path: "target/release/zesdex",
|
|
||||||
label: "zesdex-${nextRelease.version}-linux-amd64",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
You are an architecture reviewer for Zesdex. Review the project's architecture for consistency, maintainability, and adherence to the existing design patterns.
|
|
||||||
|
|
||||||
You have read-only access. Use read/grep/glob to inspect the codebase.
|
|
||||||
|
|
||||||
Review scope:
|
|
||||||
1. Check that new/modified code follows the project's established architecture patterns (module structure, dependency direction, layering).
|
|
||||||
2. Check for architectural issues: circular dependencies, leaky abstractions, misplaced responsibilities, excessive coupling.
|
|
||||||
3. Check that error handling, logging, and state management patterns are consistent.
|
|
||||||
4. Check that public APIs and type signatures are coherent and well-designed.
|
|
||||||
5. Flag any structural changes that would cause maintenance burden or violate separation of concerns.
|
|
||||||
|
|
||||||
Output: a concise 3-5 line architectural assessment. Only flag real architectural concerns, not style issues.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
You are a code quality reviewer for Zesdex. Review the specified file for correctness, bugs, and adherence to best practices.
|
|
||||||
|
|
||||||
CRITICAL: Never ignore pre-existing errors, warnings, or technical debt.
|
|
||||||
|
|
||||||
You have read-only access. Use the read tool to inspect the file.
|
|
||||||
|
|
||||||
Review guidelines:
|
|
||||||
1. Check for placeholders, stubs, or incomplete logic (no todo!(), unimplemented!(), FIXME, pass, or dead code).
|
|
||||||
2. Check for logic errors: null/panic paths, off-by-one errors, race conditions, unhandled edge cases.
|
|
||||||
3. Check naming and structure consistency with the existing codebase patterns.
|
|
||||||
4. Check that the implementation matches the apparent intent.
|
|
||||||
5. Check for linter bypasses: Ensure that compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) are NEVER used to silence warnings or skip linter checks. Reject them.
|
|
||||||
|
|
||||||
Output: a concise 2-4 line verdict. If you find issues, be specific about what and where.
|
|
||||||
Skip if the file is trivial (config, tests with no logic changes).
|
|
||||||
Only mention real issues — do not nitpick style.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
You are a security reviewer for Zesdex. Check modified code for security vulnerabilities and unsafe patterns.
|
|
||||||
|
|
||||||
You have read-only access. Use read/grep/glob to inspect the codebase.
|
|
||||||
|
|
||||||
Review for:
|
|
||||||
1. Injection vulnerabilities (command injection, path traversal, SQL injection, XSS).
|
|
||||||
2. Unsafe file operations (symlink races, temporary file handling, path validation).
|
|
||||||
3. Credential/secret handling (hardcoded secrets, insecure storage, logging of sensitive data).
|
|
||||||
4. Authentication/authorization gaps (missing checks, privilege escalation, session handling).
|
|
||||||
5. Unsafe deserialization or external input processing.
|
|
||||||
6. Race conditions in security-critical paths.
|
|
||||||
7. Dependency on known-vulnerable patterns.
|
|
||||||
|
|
||||||
Output: a concise 2-4 line security assessment. If no issues found, state that clearly.
|
|
||||||
Only flag genuine security concerns — not theoretical or cosmetic issues.
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
You are Zesdex Corp — an AI software engineering company structured like an organization with specialized divisions.
|
|
||||||
|
|
||||||
## YOUR ROLE: CEO (Main Agent)
|
|
||||||
|
|
||||||
You are the Chief Executive Officer. You do NOT do everything yourself. Your job is to:
|
|
||||||
1. **Understand** the user's request
|
|
||||||
2. **Delegate** to the appropriate divisions via the company pipeline
|
|
||||||
3. **Review** results and deliver the final response
|
|
||||||
|
|
||||||
## COMPANY DIVISIONS
|
|
||||||
|
|
||||||
You have 5 specialized divisions. Each runs autonomously as a subagent pipeline:
|
|
||||||
|
|
||||||
### 1. Strategy Division (Planner)
|
|
||||||
- **Role**: Chief Architect — creates complete plans with mermaid diagrams
|
|
||||||
- **Always starts every complex task**: architecture overview, data flow diagrams, file-by-file breakdown, step-by-step implementation order
|
|
||||||
- **Output**: detailed plan with diagrams saved to findings
|
|
||||||
|
|
||||||
### 2. Engineering Division (Implementer)
|
|
||||||
- **Role**: Implementation Team — writes production code following the plan
|
|
||||||
- **Reads the Strategy plan first, then implements one file at a time**
|
|
||||||
- **Output**: working code with LSP diagnostics verification
|
|
||||||
|
|
||||||
### 3. Quality Division (Tester)
|
|
||||||
- **Role**: QA Team — reviews code correctness and writes comprehensive tests
|
|
||||||
- **Two phases**: review for bugs/anti-patterns, then write and run tests
|
|
||||||
- **Output**: test files, review verdict, test results
|
|
||||||
|
|
||||||
### 4. Security Division (Auditor)
|
|
||||||
- **Role**: Security Team — audits for vulnerabilities
|
|
||||||
- **Checks**: injection, credentials, auth gaps, race conditions
|
|
||||||
- **Output**: security assessment report
|
|
||||||
|
|
||||||
### 5. Documentation Division (Documenter)
|
|
||||||
- **Role**: Docs Team — updates README, architecture docs, inline documentation
|
|
||||||
- **Output**: updated documentation or confirmation none needed
|
|
||||||
|
|
||||||
## PIPELINE FLOW (How Work Gets Done)
|
|
||||||
|
|
||||||
```
|
|
||||||
User Request
|
|
||||||
↓
|
|
||||||
[CEO: You] evaluate complexity
|
|
||||||
│
|
|
||||||
├── COMPLEX task → run_company_pipeline:
|
|
||||||
│ 1. Strategy Division → Plan + Diagrams
|
|
||||||
│ (architecture, data flow, file breakdown)
|
|
||||||
│ 2. Engineering Division → Implementation
|
|
||||||
│ (one file at a time, build-check each)
|
|
||||||
│ 3. Quality Division → Review + Tests
|
|
||||||
│ (correctness check, test suite)
|
|
||||||
│ 4. Security Division → Security Audit
|
|
||||||
│ (vulnerability scan)
|
|
||||||
│ 5. Documentation Division → Docs Update
|
|
||||||
│ (README, inline docs)
|
|
||||||
│
|
|
||||||
└── SIMPLE task → run_company_pipeline_quick:
|
|
||||||
1. Strategy → Plan + Diagrams (brief)
|
|
||||||
2. Engineering → Implementation
|
|
||||||
3. Quality → Review + Tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### When to use full pipeline vs quick:
|
|
||||||
- **Full pipeline** (5 divisions): new features, multi-file refactors, architecture changes, system integration
|
|
||||||
- **Quick pipeline** (3 divisions): single-file changes, minor features, bug fixes with no security implications
|
|
||||||
|
|
||||||
## EXECUTION RULES
|
|
||||||
|
|
||||||
1. **ALWAYS start with the pipeline**. For ANY non-trivial task, delegate to divisions. Do NOT start coding directly.
|
|
||||||
2. **Use `spawn_agents`** only for truly independent parallel tasks that don't need planning
|
|
||||||
3. **Use `workflow_run`** for the company pipeline: construct a Pipeline[Strategy, Engineering, Quality, Security, Documentation]
|
|
||||||
4. **Track progress** in todo.md using todowrite/todofinish
|
|
||||||
5. **Review division outputs** — after the pipeline completes, read the findings and summarize for the user
|
|
||||||
6. **Auto inline reviews** fire after each Engineering write/edit — pay attention to `[Auto inline review]` feedback
|
|
||||||
7. **Background subagents** (test gen, arch review, security review) fire asynchronously at turn end
|
|
||||||
|
|
||||||
## TOOLS
|
|
||||||
|
|
||||||
Available tools are described in system-tools.txt section. Key tools for orchestration:
|
|
||||||
- `workflow_run` — run a full WorkflowScript (Pipeline of divisions)
|
|
||||||
- `spawn_agents` — parallel fan-out (for independent subtasks)
|
|
||||||
- `spawn_pipeline` — sequential pipeline (for dependent stages)
|
|
||||||
|
|
||||||
## QUALITY STANDARDS
|
|
||||||
|
|
||||||
- Zero placeholders, stubs, or incomplete logic
|
|
||||||
- Fix pre-existing errors/warnings immediately
|
|
||||||
- After changes, run builds and tests
|
|
||||||
- Use LSP diagnostics after each file edit
|
|
||||||
- Every code path must be fully implemented and deterministic
|
|
||||||
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Fix the underlying code issues instead.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
You are an overengineering, perfectionist, and diligent programmer who does not prioritize efficiency and does not assume or guess anything, so everything must be based on data. You have access to the following tools. Use them to accomplish the user's request.
|
|
||||||
|
|
||||||
CRITICAL: Never ignore pre-existing errors, warnings, or technical debt. Fix them immediately. YAGNI is rejected — overengineering for correctness and robustness is the standard.
|
|
||||||
For simple operations (read, grep, write small edits) use tools directly.
|
|
||||||
For complex multi-step tasks that would benefit from parallel analysis or
|
|
||||||
independent verification, use workflow_run to orchestrate sub-agents.
|
|
||||||
|
|
||||||
Core tools:
|
|
||||||
- read(path) — Read file contents. Use when you need to inspect code.
|
|
||||||
- grep(pattern, path?) — Search for a pattern in files.
|
|
||||||
- glob(pattern) — List files matching a glob pattern.
|
|
||||||
- write(path, content, reason) — Write content to a file. Reason is required.
|
|
||||||
- edit(path, old, new, replace_all?, reason) — Replace text in a file. Reason is required.
|
|
||||||
- delete(path) — Delete a file or empty directory.
|
|
||||||
- bash(command) — Run a shell command. Use for builds, tests, git ops.
|
|
||||||
- bash_output(job_id) — Poll output of a background bash job.
|
|
||||||
- bash_kill(job_id) — Kill a background bash job.
|
|
||||||
- cd(path) — Change working directory.
|
|
||||||
- dir_list(path) — List directory contents.
|
|
||||||
- dir_cache_update() — Refresh the directory cache.
|
|
||||||
- pong(message?) — Simple connectivity check. Echoes back the message.
|
|
||||||
|
|
||||||
Git tools:
|
|
||||||
- git_operator(args, confirm_destructive?) — Run git commands. Some destructive
|
|
||||||
operations (force-push, reset --hard, branch -D) require confirm_destructive=true.
|
|
||||||
- git_worktree(args) — Manage git worktrees.
|
|
||||||
- git_cred(operation) — Manage git credentials.
|
|
||||||
|
|
||||||
|
|
||||||
Memory & Planning:
|
|
||||||
- remember(name, description, content, kind) — Save to memory (kind: project | reference | lesson | feedback).
|
|
||||||
- recall(name?) — Read a specific memory entry, or list all if name is omitted.
|
|
||||||
- forget(name) — Remove a memory entry.
|
|
||||||
- plan_enter(plan, sign_off) — Enter plan mode (provide a step-by-step plan and sign-off message).
|
|
||||||
- plan_ready(confirmation) — Signal that you are ready to execute the approved plan.
|
|
||||||
- seqthink(thought) — Record a chain-of-thought step.
|
|
||||||
- todowrite(task) — Append a task to the session todo list.
|
|
||||||
- todofinish(task_index?) — Mark a task (or all if omitted) as finished in todo.md.
|
|
||||||
|
|
||||||
Workflow (USE THESE AUTOMATICALLY for multi-part tasks — no user prompt needed):
|
|
||||||
- spawn_agents(agents, max_concurrency?) — Run a list of prompts as PARALLEL subagents.
|
|
||||||
Each agent is fully autonomous with all tools. Returns combined results.
|
|
||||||
USE THIS when tasks are independent of each other.
|
|
||||||
Example: spawn_agents(["refactor auth module", "refactor payment module"])
|
|
||||||
- spawn_pipeline(stages) — Run prompts as SEQUENTIAL pipeline stages.
|
|
||||||
Each stage can call note_finding() to pass data to later stages.
|
|
||||||
USE THIS when stage N needs output from stage N-1.
|
|
||||||
Example: spawn_pipeline(["research the bug", "write the fix", "write tests"])
|
|
||||||
- workflow_run(script, args) — Advanced: execute a JSON-encoded WorkflowScript
|
|
||||||
with full Agent/Parallel/Pipeline/Phase control. Prefer spawn_agents/spawn_pipeline.
|
|
||||||
- note_finding(text) — Share a finding with sibling agents in the same workflow run.
|
|
||||||
|
|
||||||
Language Server Protocol (LSP) tools:
|
|
||||||
- lsp_connect(name, command, args?, language_id) — Start an LSP server for a
|
|
||||||
programming language (e.g. 'rust-analyzer' for Rust, 'typescript-language-server --stdio' for TypeScript).
|
|
||||||
Rust, TypeScript, Go, and Java servers are auto-provisioned at startup, so this is
|
|
||||||
primarily for adding servers for other languages.
|
|
||||||
- lsp_diagnostics(server?, path, text) — Get compiler errors, warnings, and hints
|
|
||||||
for a file from the LSP server. The server param can be omitted to use the auto-detected
|
|
||||||
server for the file's language.
|
|
||||||
- lsp_hover(server, path, line, column) — Get type signatures, documentation,
|
|
||||||
and hover information at a cursor position.
|
|
||||||
- lsp_completion(server, path, line, column) — Get code completion suggestions
|
|
||||||
at a cursor position.
|
|
||||||
- lsp_definition(server, path, line, column) — Find the definition location of
|
|
||||||
a symbol at the cursor.
|
|
||||||
- lsp_references(server, path, line, column) — Find all references to a symbol
|
|
||||||
across the project.
|
|
||||||
- lsp_disconnect(name) — Disconnect from a running LSP server.
|
|
||||||
|
|
||||||
LSP auto-provisioning runs at startup for Rust (rust-analyzer), TypeScript
|
|
||||||
(typescript-language-server), Go (gopls), and Java (jdtls).
|
|
||||||
|
|
||||||
Each write/edit call MUST include a non-empty reason argument explaining
|
|
||||||
why the change is being made. This is enforced deterministically.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
You are a test-generation specialist for Zesdex. Write comprehensive tests for recently modified production code.
|
|
||||||
|
|
||||||
You have read-write access. Use read/grep/glob to understand the existing code and test patterns, then use write to create test files.
|
|
||||||
|
|
||||||
Guidelines:
|
|
||||||
1. Read the modified source file first to understand its API and behavior.
|
|
||||||
2. Look at existing test files in the same directory to match naming conventions and style — check for `mod tests` or `*_test.rs` / `*_spec.*` patterns.
|
|
||||||
3. Cover: happy path, edge cases, error conditions, and any existing regression scenarios.
|
|
||||||
4. Use the same testing framework and patterns as the existing test suite.
|
|
||||||
5. Place tests in the correct location (inline `#[cfg(test)] mod tests { ... }` for Rust, `__tests__/` for JS, etc.).
|
|
||||||
6. Do NOT modify the source file — only add or update test files.
|
|
||||||
7. Run the tests after writing to verify they pass.
|
|
||||||
|
|
||||||
Output: a one-line summary of what tests were written and whether they pass.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
|
||||||
//! Global registry of running background bash jobs, and control operations
|
|
||||||
//! (output polling, kill) exposed to the rest of the app.
|
|
||||||
//!
|
|
||||||
//! Flow: a process-wide `Mutex<HashMap<String, BashJob>>` (lazily built via
|
|
||||||
//! `OnceLock`) holds every job spawned via `bgbash::job::spawn_bash_job` →
|
|
||||||
//! `bash_output` drains new lines for a given job id → `bash_kill` removes
|
|
||||||
//! a job from the map and signals its child process.
|
|
||||||
//!
|
|
||||||
//! Why: a single static map (rather than storing jobs in `AppStateRest`)
|
|
||||||
//! lets background jobs outlive the borrow of any particular state mutation
|
|
||||||
//! and be looked up by id from tool calls issued at arbitrary points.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use super::job::BashJob;
|
|
||||||
|
|
||||||
/// Lazily-initialised, process-wide registry of background bash jobs keyed
|
|
||||||
/// by job id.
|
|
||||||
///
|
|
||||||
/// Return: a reference to the static `Mutex<HashMap<...>>`, created on
|
|
||||||
/// first access.
|
|
||||||
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
|
|
||||||
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new();
|
|
||||||
JOBS.get_or_init(|| Mutex::new(HashMap::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain any newly available output lines from a background bash job.
|
|
||||||
///
|
|
||||||
/// Flow: look up the job by id → repeatedly call `try_read_line()` until it
|
|
||||||
/// returns `None` → collect into a Vec.
|
|
||||||
///
|
|
||||||
/// Why: non-blocking; a job that hasn't produced new output yields no lines
|
|
||||||
/// rather than blocking the caller.
|
|
||||||
///
|
|
||||||
/// Return: `Some(lines)` if at least one new line was read, `None` if the
|
|
||||||
/// job doesn't exist, the lock is poisoned, or there was nothing new to read.
|
|
||||||
pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
|
||||||
let mut map = bash_jobs_map().lock().ok()?;
|
|
||||||
let job = map.get_mut(id)?;
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
while let Some(line) = job.try_read_line() {
|
|
||||||
lines.push(line);
|
|
||||||
}
|
|
||||||
if lines.is_empty() { None } else { Some(lines) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Terminate a running background bash job and remove it from the registry.
|
|
||||||
///
|
|
||||||
/// Flow: remove the job from the map → if it has a valid child PID, send
|
|
||||||
/// `SIGTERM` to it (unix only) → return.
|
|
||||||
///
|
|
||||||
/// Why: removing from the map first means a concurrent lookup can no longer
|
|
||||||
/// see the job even if the signal delivery is delayed.
|
|
||||||
///
|
|
||||||
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
|
|
||||||
/// with that id exists.
|
|
||||||
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
|
||||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
|
|
||||||
let job = map.remove(id);
|
|
||||||
match job {
|
|
||||||
Some(job) => {
|
|
||||||
// Actually terminate the child process via its PID
|
|
||||||
if job.child_pid > 0 {
|
|
||||||
#[cfg(unix)]
|
|
||||||
unsafe {
|
|
||||||
libc::kill(job.child_pid as i32, libc::SIGTERM);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
None => anyhow::bail!("bash job '{id}' not found"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
//! Background bash job spawning and non-blocking output polling.
|
|
||||||
//!
|
|
||||||
//! Flow: `spawn_bash_job` forks a detached OS thread that execs the command
|
|
||||||
//! via `sh -c`, streams stdout lines back over an `mpsc` channel, and sends
|
|
||||||
//! an `__exit:<code>` sentinel when the child terminates → callers poll the
|
|
||||||
//! returned `BashJob` with `try_read_line()` to drain output without
|
|
||||||
//! blocking the TUI event loop.
|
|
||||||
//!
|
|
||||||
//! Why: running bash commands on a detached thread with a channel (rather
|
|
||||||
//! than synchronously) lets the TUI stay responsive while long-running
|
|
||||||
//! shell commands execute in the background.
|
|
||||||
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::thread;
|
|
||||||
use std::io::BufRead;
|
|
||||||
|
|
||||||
/// Maximum number of output lines buffered in memory per background job.
|
|
||||||
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
|
|
||||||
/// `10_000` lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most
|
|
||||||
/// command output. The stderr drain thread also uses the same limit.
|
|
||||||
const MAX_OUTPUT_LINES: usize = 10_000;
|
|
||||||
|
|
||||||
/// Handle to a bash command running in a detached background thread.
|
|
||||||
///
|
|
||||||
/// Why: output is streamed over a bounded mpsc channel rather than buffered
|
|
||||||
/// synchronously, so the TUI can poll for new lines without blocking.
|
|
||||||
/// The bounded channel prevents OOM from fast producers (e.g. `yes`).
|
|
||||||
pub struct BashJob {
|
|
||||||
pub id: String,
|
|
||||||
pub child_pid: u32,
|
|
||||||
pub output_rx: mpsc::Receiver<String>,
|
|
||||||
pub exit_code: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a shell command in a background thread and return a handle to it.
|
|
||||||
///
|
|
||||||
/// Flow: spawn a thread → thread execs `sh -c <command>` with piped
|
|
||||||
/// stdout/stderr → thread sends the child PID back over a channel →
|
|
||||||
/// thread streams stdout lines to `output_tx` → on exit, sends an
|
|
||||||
/// `__exit:<code>` sentinel line.
|
|
||||||
///
|
|
||||||
/// Why: the PID is sent back before the command finishes so `bash_kill` can
|
|
||||||
/// terminate it mid-run; sentinel-prefixed strings (`__error:`, `__exit:`)
|
|
||||||
/// let `try_read_line` distinguish control messages from real output on the
|
|
||||||
/// same channel without a separate enum.
|
|
||||||
///
|
|
||||||
/// Return: a `BashJob` with a freshly generated id, the child PID (0 if the
|
|
||||||
/// spawn failed before the PID was sent), and the receiving end of the
|
|
||||||
/// output channel.
|
|
||||||
pub fn spawn_bash_job(command: String) -> BashJob {
|
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
|
||||||
let (output_tx, output_rx) = mpsc::sync_channel::<String>(MAX_OUTPUT_LINES);
|
|
||||||
let (pid_tx, pid_rx) = mpsc::channel::<u32>();
|
|
||||||
let cmd = command;
|
|
||||||
let id_for_log = id.clone();
|
|
||||||
let thread_id = id.clone();
|
|
||||||
|
|
||||||
// Spawn a named thread for easier debugging. If Builder::spawn fails
|
|
||||||
// (e.g. OS resource limit), fall back to unnameable thread::spawn.
|
|
||||||
let thread_name = format!("bgbash-{}", &thread_id[..8.min(thread_id.len())]);
|
|
||||||
if thread::Builder::new().name(thread_name).spawn({
|
|
||||||
// Clone everything the closure captures so we can also pass it
|
|
||||||
// to the fallback thread without moving.
|
|
||||||
let cmd = cmd.clone();
|
|
||||||
let output_tx = output_tx.clone();
|
|
||||||
let pid_tx = pid_tx.clone();
|
|
||||||
let id_for_log = id_for_log.clone();
|
|
||||||
move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log)
|
|
||||||
}).is_err()
|
|
||||||
{
|
|
||||||
tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log);
|
|
||||||
thread::spawn(move || {
|
|
||||||
spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let child_pid = pid_rx.recv().unwrap_or(0);
|
|
||||||
|
|
||||||
BashJob {
|
|
||||||
id,
|
|
||||||
child_pid,
|
|
||||||
output_rx,
|
|
||||||
exit_code: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Core bash-thread logic extracted into a free function so it can be
|
|
||||||
/// spawned from both the named Builder and the unnamed fallback without
|
|
||||||
/// double-moving the closure.
|
|
||||||
fn spawn_bash_thread_body(
|
|
||||||
cmd: &str,
|
|
||||||
output_tx: &std::sync::mpsc::SyncSender<String>,
|
|
||||||
pid_tx: &std::sync::mpsc::Sender<u32>,
|
|
||||||
id_for_log: &str,
|
|
||||||
) {
|
|
||||||
let mut child = match Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(cmd)
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = output_tx.try_send(format!("__error:{e}"));
|
|
||||||
let _ = output_tx.try_send("__exit:-1".to_string());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send the child PID back to the caller so bash_kill can terminate it
|
|
||||||
let _ = pid_tx.send(child.id());
|
|
||||||
|
|
||||||
// Drain stderr on a separate thread to prevent deadlock when
|
|
||||||
// the child produces more than ~64 KB of stderr after closing
|
|
||||||
// stdout (the pipe buffer fills and the child blocks on write,
|
|
||||||
// while the parent thread waits for the child to exit).
|
|
||||||
// Stderr lines are now prefixed with "[stderr] " and sent through
|
|
||||||
// the output channel so users can see error diagnostics from
|
|
||||||
// background jobs.
|
|
||||||
let stderr_tx = output_tx.clone();
|
|
||||||
let _stderr_drain = child.stderr.take().map(|stderr| {
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let reader = std::io::BufReader::new(stderr);
|
|
||||||
for line in reader.lines().map_while(Result::ok) {
|
|
||||||
if stderr_tx.try_send(format!("[stderr] {line}")).is_err() {
|
|
||||||
tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(stderr_tx);
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(stdout) = child.stdout.take() {
|
|
||||||
let reader = std::io::BufReader::new(stdout);
|
|
||||||
for line in reader.lines().map_while(Result::ok) {
|
|
||||||
if output_tx.try_send(line).is_err() {
|
|
||||||
tracing::debug!(
|
|
||||||
"[bgbash:{}] output buffer full ({} lines), discarding remaining output",
|
|
||||||
id_for_log, MAX_OUTPUT_LINES,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let status = child.wait();
|
|
||||||
let code = status.ok().and_then(|s| s.code());
|
|
||||||
let _ = output_tx.try_send(format!("__exit:{}", code.unwrap_or(-1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BashJob {
|
|
||||||
/// Non-blocking poll for the next output line from the job's channel.
|
|
||||||
///
|
|
||||||
/// Flow: `try_recv` the channel → if it's an `__exit:<code>` sentinel,
|
|
||||||
/// record `exit_code` and return `None` instead of surfacing it as
|
|
||||||
/// output → otherwise return the line.
|
|
||||||
///
|
|
||||||
/// Return: `Some(line)` for real output, `None` if there's nothing
|
|
||||||
/// available yet or the job just finished (exit code recorded as a
|
|
||||||
/// side effect).
|
|
||||||
pub fn try_read_line(&mut self) -> Option<String> {
|
|
||||||
match self.output_rx.try_recv() {
|
|
||||||
Ok(line) => {
|
|
||||||
if line.starts_with("__exit:") {
|
|
||||||
self.exit_code = line.strip_prefix("__exit:").and_then(|s| s.parse().ok());
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
//! Background bash: run shell commands off the main thread, poll their
|
|
||||||
//! output non-blockingly, and terminate them on demand.
|
|
||||||
|
|
||||||
pub mod control;
|
|
||||||
pub mod job;
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
//! Tool-call gating: decides whether a risky tool call is allowed to run
|
|
||||||
//! before it executes. Implements hooks-style pre-checks for write/edit/delete
|
|
||||||
//! and bash tools so the agent cannot silently introduce stubs, denial
|
|
||||||
//! patterns, assumption language, or destructive commands.
|
|
||||||
|
|
||||||
/// Outcome of gating a tool call: whether it's allowed to run.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum Verdict {
|
|
||||||
Allow,
|
|
||||||
Block(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gatekeeper that decides whether a tool call may proceed before execution.
|
|
||||||
pub struct Harness;
|
|
||||||
|
|
||||||
/// Stub / placeholder / denial / assumption patterns that should never reach
|
|
||||||
/// a file in real code. Detected in write/edit content and bash heredocs.
|
|
||||||
const STUB_PATTERNS: &[&str] = &[
|
|
||||||
"todo!()",
|
|
||||||
"todo!(",
|
|
||||||
"unimplemented!()",
|
|
||||||
"unimplemented!(",
|
|
||||||
"todo_macro",
|
|
||||||
"FIXME",
|
|
||||||
"fixme:",
|
|
||||||
"XXX:",
|
|
||||||
"PLACEHOLDER",
|
|
||||||
"REPLACE_ME",
|
|
||||||
"stub_value",
|
|
||||||
"stub_function",
|
|
||||||
"fake_response",
|
|
||||||
"fake_data",
|
|
||||||
"not implemented",
|
|
||||||
"not yet implemented",
|
|
||||||
"to be implemented",
|
|
||||||
"to be done",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Language patterns indicating the AI is denying responsibility or
|
|
||||||
/// punting the work ("I'll skip this", "for now just", etc).
|
|
||||||
const DENIAL_PATTERNS: &[&str] = &[
|
|
||||||
"// skip",
|
|
||||||
"// skipping",
|
|
||||||
"// skipping for now",
|
|
||||||
"// for now just",
|
|
||||||
"// punt",
|
|
||||||
"// punted",
|
|
||||||
"// hack:",
|
|
||||||
"// hacky",
|
|
||||||
"// hack workaround",
|
|
||||||
"// workaround:",
|
|
||||||
"// cba",
|
|
||||||
"// later",
|
|
||||||
"// do later",
|
|
||||||
"// ignore for now",
|
|
||||||
"// disable",
|
|
||||||
"// disabled",
|
|
||||||
"// bypass",
|
|
||||||
"// quick fix",
|
|
||||||
"// temp fix",
|
|
||||||
"// temporary fix",
|
|
||||||
"// temp:",
|
|
||||||
"// temporary:",
|
|
||||||
"// noop",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Assumption-language patterns: words/phrases that indicate the code is
|
|
||||||
/// reasoning based on guesswork rather than data.
|
|
||||||
const ASSUMPTION_PATTERNS: &[&str] = &[
|
|
||||||
"// assume",
|
|
||||||
"// assuming",
|
|
||||||
"// probably",
|
|
||||||
"// maybe",
|
|
||||||
"// might",
|
|
||||||
"// should work",
|
|
||||||
"// hopefully",
|
|
||||||
"// guess",
|
|
||||||
"// i think",
|
|
||||||
"// should be fine",
|
|
||||||
"// should be",
|
|
||||||
"// likely",
|
|
||||||
"// ought to",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Network-exfiltration and credential-disclosure patterns for bash.
|
|
||||||
const EXFIL_PATTERNS: &[&str] = &[
|
|
||||||
"curl ", "wget ", "nc -e ", "ncat ", "/dev/tcp/",
|
|
||||||
"base64 -d |", "base64 --decode |",
|
|
||||||
"openssl s_client", "ssh -R ",
|
|
||||||
"scp /", "rsync /",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Substrings of well-known credential / secret files that bash must not read.
|
|
||||||
const SENSITIVE_PATH_PATTERNS: &[&str] = &[
|
|
||||||
".ssh/id_rsa",
|
|
||||||
".ssh/id_ed25519",
|
|
||||||
".ssh/authorized_keys",
|
|
||||||
".aws/credentials",
|
|
||||||
".aws/config",
|
|
||||||
".netrc",
|
|
||||||
".pypirc",
|
|
||||||
".npmrc",
|
|
||||||
".kube/config",
|
|
||||||
".docker/config.json",
|
|
||||||
".gnupg/",
|
|
||||||
"/etc/shadow",
|
|
||||||
"/etc/passwd",
|
|
||||||
"/proc/self/environ",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Minimum character length of a `reason` argument to be considered meaningful.
|
|
||||||
const MIN_REASON_LEN: usize = 8;
|
|
||||||
|
|
||||||
impl Harness {
|
|
||||||
/// Decide whether a tool call is allowed to execute.
|
|
||||||
///
|
|
||||||
/// Flow: ALL tools are gated (not just risky ones), closing the bypass
|
|
||||||
/// for MCP tools (which are never in the risky list). Basic path
|
|
||||||
/// traversal and reason validation applies to any tool with a `path`
|
|
||||||
/// argument. Heavy content scanning (stub/denial/assumption/exfiltration)
|
|
||||||
/// only applies to risky tools. MCP tools (mcp__ prefix) are treated
|
|
||||||
/// as risky because their behaviour is unknown.
|
|
||||||
///
|
|
||||||
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
|
|
||||||
#[allow(clippy::too_many_lines, clippy::unnecessary_debug_formatting)]
|
|
||||||
pub fn gate_tool_call(
|
|
||||||
tool_name: &str,
|
|
||||||
args: &serde_json::Value,
|
|
||||||
workspace_roots: &[&std::path::Path],
|
|
||||||
) -> Verdict {
|
|
||||||
|
|
||||||
let is_risky = crate::tool::tool_is_risky(tool_name);
|
|
||||||
let is_mcp = tool_name.starts_with("mcp__");
|
|
||||||
|
|
||||||
// ── Universal checks applied to EVERY tool ──
|
|
||||||
|
|
||||||
// Path traversal: check ANY tool that accepts a path argument,
|
|
||||||
// not just write/edit/delete, so tools like read, MCP tools,
|
|
||||||
// and future tools are also protected.
|
|
||||||
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
|
||||||
if path.contains("..") {
|
|
||||||
return Verdict::Block(
|
|
||||||
"path traversal detected in 'path' argument".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !workspace_roots.is_empty() {
|
|
||||||
let abs_check = std::path::PathBuf::from(path);
|
|
||||||
if abs_check.is_absolute()
|
|
||||||
&& !workspace_roots.iter().any(|r| abs_check.starts_with(r))
|
|
||||||
{
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"absolute path '{path}' is outside all workspace roots"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workspace-root validation for output path.
|
|
||||||
if let Some(out_path) = Self::find_output_path(tool_name, args) {
|
|
||||||
if !workspace_roots.is_empty()
|
|
||||||
&& !out_path.starts_with("/tmp")
|
|
||||||
&& !out_path.is_absolute()
|
|
||||||
{
|
|
||||||
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
|
|
||||||
if !allowed {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"output path '{out_path:?}' is outside all workspace roots"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Risky / MCP tool checks ──
|
|
||||||
// Non-risky, non-MCP tools (read, grep, glob, recall, etc.) are
|
|
||||||
// allowed after universal checks above.
|
|
||||||
if !is_risky && !is_mcp {
|
|
||||||
return Verdict::Allow;
|
|
||||||
}
|
|
||||||
|
|
||||||
// File-mutating tools: write / edit / delete
|
|
||||||
if matches!(tool_name, "write" | "edit" | "delete") {
|
|
||||||
match Self::validate_reason(tool_name, args) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(msg) => return Verdict::Block(msg),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// write / edit content must not contain stubs, denial language, or
|
|
||||||
// assumption language.
|
|
||||||
if matches!(tool_name, "write" | "edit") {
|
|
||||||
if let Some(content) = Self::extract_content(tool_name, args) {
|
|
||||||
if let Some(pat) = Self::first_match(&content, STUB_PATTERNS) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"content contains stub/placeholder pattern '{pat}'; \
|
|
||||||
production code must be fully implemented — \
|
|
||||||
replace the stub with a real implementation"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(pat) = Self::first_match(&content, DENIAL_PATTERNS) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"content contains denial/punt pattern '{pat}'; \
|
|
||||||
implement the change properly instead of skipping"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(pat) = Self::first_match(&content, ASSUMPTION_PATTERNS) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"content contains assumption pattern '{pat}'; \
|
|
||||||
verify against data/tests instead of guessing"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bash: destructive patterns, exfiltration (ALL commands checked,
|
|
||||||
// no safe-command whitelist), sensitive-path reads.
|
|
||||||
if tool_name == "bash" {
|
|
||||||
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
if cmd.contains("..") {
|
|
||||||
return Verdict::Block(
|
|
||||||
"path traversal detected in bash command".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Exfiltration patterns are checked on EVERY bash command,
|
|
||||||
// regardless of prefix. The safe-command whitelist was removed
|
|
||||||
// because it could be bypassed with command chaining.
|
|
||||||
for pat in EXFIL_PATTERNS {
|
|
||||||
if cmd.contains(pat) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"potential data-exfiltration command blocked (matched '{pat}')"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for pat in SENSITIVE_PATH_PATTERNS {
|
|
||||||
if cmd.contains(pat) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"refused to read/write sensitive path '{pat}'"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let dangerous_patterns = [
|
|
||||||
"rm -rf /", "rm -rf --no-preserve-root",
|
|
||||||
"rm -rf ~", "rm -fr /", "mkfs.", "dd if=",
|
|
||||||
":(){", "> /dev/sda", "chmod -R 000 /",
|
|
||||||
"shutdown ", "poweroff ", "reboot ", "halt ",
|
|
||||||
];
|
|
||||||
for pat in &dangerous_patterns {
|
|
||||||
if cmd.contains(pat) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"destructive command pattern blocked: {pat}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also scan heredocs / -c / inline content for stub/denial
|
|
||||||
// language (e.g. `bash -c 'echo todo!()'`)
|
|
||||||
if let Some(pat) = Self::first_match(cmd, STUB_PATTERNS) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"bash command contains stub pattern '{pat}'"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// git_operator: require a non-trivial reason as well.
|
|
||||||
if tool_name == "git_operator" {
|
|
||||||
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
|
|
||||||
if reason.trim().len() < MIN_REASON_LEN {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"git_operator requires a non-trivial 'reason' \
|
|
||||||
(>= {MIN_REASON_LEN} chars) explaining the operation"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Verdict::Block(
|
|
||||||
"git_operator requires a 'reason' argument explaining the operation"
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCP tools: unknown behaviour — require a reason if they take
|
|
||||||
// arguments, to discourage lazy invocations.
|
|
||||||
if is_mcp {
|
|
||||||
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
|
|
||||||
if reason.trim().len() < MIN_REASON_LEN {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"MCP tool '{tool_name}' requires a non-trivial 'reason' \
|
|
||||||
(>= {MIN_REASON_LEN} chars) explaining why it is needed"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
|
|
||||||
// Only require reason when there are meaningful arguments
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"MCP tool '{tool_name}' requires a 'reason' argument \
|
|
||||||
explaining the operation"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Verdict::Allow
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the `reason` argument for a mutating tool.
|
|
||||||
///
|
|
||||||
/// Flow: require the field to exist and be a non-empty string ≥
|
|
||||||
/// `MIN_REASON_LEN` chars after trimming.
|
|
||||||
///
|
|
||||||
/// Why: hook-style gates force the agent to articulate the *why* of
|
|
||||||
/// every change, which both deters lazy writes and produces a useful
|
|
||||||
/// audit trail in the edit log.
|
|
||||||
fn validate_reason(tool_name: &str, args: &serde_json::Value) -> Result<(), String> {
|
|
||||||
let reason = match args.get("reason") {
|
|
||||||
None => {
|
|
||||||
return Err(format!(
|
|
||||||
"{tool_name} requires a non-empty 'reason' argument \
|
|
||||||
explaining why the change is being made"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Some(v) => match v.as_str() {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
return Err(format!(
|
|
||||||
"{tool_name} 'reason' must be a string"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let trimmed = reason.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err(format!(
|
|
||||||
"{tool_name} 'reason' must not be empty"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if trimmed.len() < MIN_REASON_LEN {
|
|
||||||
return Err(format!(
|
|
||||||
"{tool_name} 'reason' must be at least {MIN_REASON_LEN} chars \
|
|
||||||
(got {}) — explain WHY, not just WHAT",
|
|
||||||
trimmed.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Reject generic non-answers
|
|
||||||
let lower = trimmed.to_lowercase();
|
|
||||||
let non_answers = [
|
|
||||||
"fix", "update", "change", "edit", "modify",
|
|
||||||
"implement", "add", "remove", "delete",
|
|
||||||
"make it work", "make work", "test", "wip", "tbd",
|
|
||||||
];
|
|
||||||
if non_answers.iter().any(|n| lower == *n) {
|
|
||||||
return Err(format!(
|
|
||||||
"{tool_name} 'reason' '{trimmed}' is too generic — \
|
|
||||||
describe what changes and why (e.g. 'switch to Result<T> for \
|
|
||||||
safer error propagation per user request')"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the textual content of a write/edit call, if any.
|
|
||||||
fn extract_content(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
|
||||||
match tool_name {
|
|
||||||
"write" => args.get("content").and_then(|v| v.as_str()).map(String::from),
|
|
||||||
"edit" => {
|
|
||||||
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
Some(format!("{old}\n{new}"))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the first pattern (case-insensitive substring) that matches
|
|
||||||
/// `text`, or `None` if no pattern matched.
|
|
||||||
fn first_match(text: &str, patterns: &'static [&'static str]) -> Option<&'static str> {
|
|
||||||
let lower = text.to_lowercase();
|
|
||||||
let iter: std::slice::Iter<'static, &'static str> = patterns.iter();
|
|
||||||
iter.copied().find(|p| lower.contains(&p.to_lowercase()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract a candidate output path from a tool call, if one exists.
|
|
||||||
fn find_output_path(tool_name: &str, args: &serde_json::Value) -> Option<std::path::PathBuf> {
|
|
||||||
match tool_name {
|
|
||||||
"write" | "edit" | "delete" | "read" => {
|
|
||||||
args.get("path").and_then(|v| v.as_str()).map(std::path::PathBuf::from)
|
|
||||||
}
|
|
||||||
"bash" => {
|
|
||||||
let cmd = args.get("command").and_then(|v| v.as_str())?;
|
|
||||||
let lower = cmd.to_lowercase();
|
|
||||||
for prefix in &["cp ", "mv ", "install ", "ln -s ", "cat >", "cat >>"] {
|
|
||||||
if let Some(rest) = lower.strip_prefix(prefix) {
|
|
||||||
if let Some(target) = rest.split_whitespace().last() {
|
|
||||||
if !target.starts_with('-') {
|
|
||||||
return Some(std::path::PathBuf::from(target));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Harness {
|
|
||||||
fn default() -> Self {
|
|
||||||
Harness
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
fn parse_verdict(text: &str) -> Option<Verdict> {
|
|
||||||
let trimmed = text.trim();
|
|
||||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
|
||||||
if let Some(verdict) = v.get("verdict").and_then(|v| v.as_str()) {
|
|
||||||
return match verdict.to_lowercase().as_str() {
|
|
||||||
"allow" => Some(Verdict::Allow),
|
|
||||||
"block" => Some(Verdict::Block(
|
|
||||||
v.get("reason").and_then(|r| r.as_str()).unwrap_or("blocked").to_string()
|
|
||||||
)),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for line in trimmed.lines() {
|
|
||||||
let l = line.trim().to_lowercase();
|
|
||||||
if l.starts_with("verdict: allow") {
|
|
||||||
return Some(Verdict::Allow);
|
|
||||||
}
|
|
||||||
if l.starts_with("verdict: block") {
|
|
||||||
let reason = line.split_once(':').map_or("blocked", |x| x.1).trim().to_string();
|
|
||||||
return Some(Verdict::Block(reason));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if trimmed.to_lowercase().contains("allow") {
|
|
||||||
return Some(Verdict::Allow);
|
|
||||||
}
|
|
||||||
if trimmed.to_lowercase().contains("block") {
|
|
||||||
return Some(Verdict::Block("blocked by classifier".to_string()));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_gate_tool_non_risky_always_allows() {
|
|
||||||
let roots: &[&std::path::Path] = &[];
|
|
||||||
let result = Harness::gate_tool_call("read", &json!({"path": "test.txt"}), roots);
|
|
||||||
assert_eq!(result, Verdict::Allow);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_verdict_json_allow() {
|
|
||||||
let v = parse_verdict(r#"{"verdict": "allow"}"#);
|
|
||||||
assert_eq!(v, Some(Verdict::Allow));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_verdict_json_block() {
|
|
||||||
let v = parse_verdict(r#"{"verdict": "block", "reason": "dangerous operation"}"#);
|
|
||||||
assert_eq!(v, Some(Verdict::Block("dangerous operation".to_string())));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_verdict_text_allow() {
|
|
||||||
let v = parse_verdict("Verdict: Allow");
|
|
||||||
assert_eq!(v, Some(Verdict::Allow));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_verdict_text_block() {
|
|
||||||
let v = parse_verdict("Verdict: Block - this operation is not allowed");
|
|
||||||
assert!(matches!(v, Some(Verdict::Block(_))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_verdict_fallback_allow() {
|
|
||||||
let v = parse_verdict("I think we should allow this operation");
|
|
||||||
assert_eq!(v, Some(Verdict::Allow));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_verdict_fallback_block() {
|
|
||||||
let v = parse_verdict("This request should be blocked");
|
|
||||||
assert!(matches!(v, Some(Verdict::Block(_))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_verdict_unparseable() {
|
|
||||||
let v = parse_verdict("completely unrelated text with no keywords");
|
|
||||||
assert_eq!(v, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
use std::io::{BufRead, BufReader, Read, Write};
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
const LSP_INIT_TIMEOUT_MS: u64 = 60_000;
|
|
||||||
const LSP_CALL_TIMEOUT_MS: u64 = 30_000;
|
|
||||||
const LSP_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000;
|
|
||||||
|
|
||||||
pub struct LspClient {
|
|
||||||
stdin: std::process::ChildStdin,
|
|
||||||
stdout: BufReader<std::process::ChildStdout>,
|
|
||||||
next_id: u64,
|
|
||||||
server_capabilities: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file_path_to_uri(path: &str) -> String {
|
|
||||||
let abs_path = std::path::Path::new(path);
|
|
||||||
let abs_path = if abs_path.is_relative() {
|
|
||||||
match std::env::current_dir() {
|
|
||||||
Ok(cwd) => cwd.join(path),
|
|
||||||
Err(_) => abs_path.to_path_buf(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
abs_path.to_path_buf()
|
|
||||||
};
|
|
||||||
let canonical = abs_path.canonicalize().unwrap_or(abs_path);
|
|
||||||
let path_str = canonical.to_string_lossy();
|
|
||||||
if cfg!(windows) {
|
|
||||||
let path_str = path_str.replace('\\', "/");
|
|
||||||
if path_str.starts_with('/') {
|
|
||||||
format!("file://{path_str}")
|
|
||||||
} else {
|
|
||||||
format!("file:///{path_str}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
format!("file://{path_str}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspClient {
|
|
||||||
pub fn spawn(command: &str, args: &[String]) -> anyhow::Result<Self> {
|
|
||||||
let mut cmd = Command::new(command);
|
|
||||||
cmd.args(args);
|
|
||||||
cmd.stdin(Stdio::piped());
|
|
||||||
cmd.stdout(Stdio::piped());
|
|
||||||
cmd.stderr(Stdio::piped());
|
|
||||||
|
|
||||||
let mut child = cmd.spawn()
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{command}': {e}"))?;
|
|
||||||
|
|
||||||
let stdin = child.stdin.take()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
|
|
||||||
let stdout = BufReader::new(child.stdout.take()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?);
|
|
||||||
|
|
||||||
let mut client = LspClient {
|
|
||||||
stdin,
|
|
||||||
stdout,
|
|
||||||
next_id: 0,
|
|
||||||
server_capabilities: Value::Null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let init_params = json!({
|
|
||||||
"processId": std::process::id(),
|
|
||||||
"clientInfo": {
|
|
||||||
"name": "zesdex",
|
|
||||||
"version": "0.1.0"
|
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"textDocument": {
|
|
||||||
"synchronization": {
|
|
||||||
"dynamicRegistration": true,
|
|
||||||
"willSave": false,
|
|
||||||
"willSaveWaitUntil": false,
|
|
||||||
"didSave": false
|
|
||||||
},
|
|
||||||
"hover": {
|
|
||||||
"dynamicRegistration": true,
|
|
||||||
"contentFormat": ["plaintext", "markdown"]
|
|
||||||
},
|
|
||||||
"completion": {
|
|
||||||
"dynamicRegistration": true,
|
|
||||||
"completionItem": {
|
|
||||||
"snippetSupport": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"definition": {
|
|
||||||
"dynamicRegistration": true
|
|
||||||
},
|
|
||||||
"references": {
|
|
||||||
"dynamicRegistration": true
|
|
||||||
},
|
|
||||||
"documentSymbol": {
|
|
||||||
"dynamicRegistration": true,
|
|
||||||
"hierarchicalDocumentSymbolSupport": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"workspace": {
|
|
||||||
"workspaceFolders": true
|
|
||||||
},
|
|
||||||
"general": {
|
|
||||||
"positionEncodings": ["utf-16"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = client.call_with_timeout("initialize", &init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
|
|
||||||
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
|
|
||||||
|
|
||||||
client.notify("initialized", &json!({}))?;
|
|
||||||
|
|
||||||
Ok(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn server_capabilities(&self) -> &Value {
|
|
||||||
&self.server_capabilities
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
|
|
||||||
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_with_timeout(&mut self, method: &str, params: &Value, timeout: Duration) -> anyhow::Result<Value> {
|
|
||||||
self.next_id += 1;
|
|
||||||
let id = self.next_id;
|
|
||||||
let req = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": id,
|
|
||||||
"method": method,
|
|
||||||
"params": params
|
|
||||||
});
|
|
||||||
self.send_frame(&req)?;
|
|
||||||
self.read_response(id, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> {
|
|
||||||
let req = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"method": method,
|
|
||||||
"params": params
|
|
||||||
});
|
|
||||||
self.send_frame(&req)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
|
|
||||||
let body = serde_json::to_string(msg)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
|
|
||||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
|
||||||
self.stdin.write_all(header.as_bytes())
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {e}"))?;
|
|
||||||
self.stdin.write_all(body.as_bytes())
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {e}"))?;
|
|
||||||
self.stdin.flush()
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_response(&mut self, expected_id: u64, timeout: Duration) -> anyhow::Result<Value> {
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
if Instant::now() > deadline {
|
|
||||||
anyhow::bail!("LSP call timed out after {}ms", timeout.as_millis());
|
|
||||||
}
|
|
||||||
let frame = self.read_frame()?;
|
|
||||||
if frame.get("id") == Some(&json!(expected_id)) {
|
|
||||||
if let Some(err) = frame.get("error") {
|
|
||||||
let code = err.get("code").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
|
||||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
|
|
||||||
anyhow::bail!("LSP error {code}: {msg}");
|
|
||||||
}
|
|
||||||
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_notification(&mut self, method: &str, timeout: Duration) -> anyhow::Result<Value> {
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
if Instant::now() > deadline {
|
|
||||||
anyhow::bail!("timed out waiting for LSP notification '{method}'");
|
|
||||||
}
|
|
||||||
let frame = self.read_frame()?;
|
|
||||||
if frame.get("method") == Some(&json!(method)) {
|
|
||||||
return Ok(frame.get("params").cloned().unwrap_or(Value::Null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_frame(&mut self) -> anyhow::Result<Value> {
|
|
||||||
let mut content_length: Option<usize> = None;
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
match self.stdout.read_line(&mut line) {
|
|
||||||
Ok(0) => anyhow::bail!("LSP server closed the connection"),
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => anyhow::bail!("LSP read error: {e}"),
|
|
||||||
}
|
|
||||||
let trimmed = line.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
|
|
||||||
// Cap Content-Length at 64 MiB to prevent OOM from a
|
|
||||||
// malicious or misconfigured LSP server (CWE-400).
|
|
||||||
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
|
|
||||||
let length: usize = len_str.trim().parse::<usize>()
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
|
|
||||||
if length > MAX_CONTENT_LENGTH {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
content_length = Some(length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let length = content_length
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?;
|
|
||||||
|
|
||||||
let mut body = vec![0u8; length];
|
|
||||||
self.stdout.read_exact(&mut body)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
|
|
||||||
|
|
||||||
let json_str = String::from_utf8(body)
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {e}"))?;
|
|
||||||
|
|
||||||
serde_json::from_str(&json_str)
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
|
||||||
self.notify("textDocument/didOpen", &json!({
|
|
||||||
"textDocument": {
|
|
||||||
"uri": uri,
|
|
||||||
"languageId": language_id,
|
|
||||||
"version": version,
|
|
||||||
"text": text
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
|
||||||
self.notify("textDocument/didChange", &json!({
|
|
||||||
"textDocument": {
|
|
||||||
"uri": uri,
|
|
||||||
"version": version
|
|
||||||
},
|
|
||||||
"contentChanges": [{
|
|
||||||
"text": text
|
|
||||||
}]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
|
|
||||||
self.notify("textDocument/didClose", &json!({
|
|
||||||
"textDocument": {
|
|
||||||
"uri": uri
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
|
||||||
self.call("textDocument/hover", &json!({
|
|
||||||
"textDocument": { "uri": uri },
|
|
||||||
"position": { "line": line, "character": character }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
|
||||||
self.call("textDocument/completion", &json!({
|
|
||||||
"textDocument": { "uri": uri },
|
|
||||||
"position": { "line": line, "character": character }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
|
||||||
self.call("textDocument/definition", &json!({
|
|
||||||
"textDocument": { "uri": uri },
|
|
||||||
"position": { "line": line, "character": character }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
|
||||||
self.call("textDocument/references", &json!({
|
|
||||||
"textDocument": { "uri": uri },
|
|
||||||
"position": { "line": line, "character": character },
|
|
||||||
"context": {
|
|
||||||
"includeDeclaration": true
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
|
|
||||||
self.call("textDocument/documentSymbol", &json!({
|
|
||||||
"textDocument": { "uri": uri }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn collect_diagnostics(
|
|
||||||
&mut self,
|
|
||||||
uri: &str,
|
|
||||||
language_id: &str,
|
|
||||||
text: &str,
|
|
||||||
) -> anyhow::Result<Value> {
|
|
||||||
self.did_open(uri, language_id, 1, text)?;
|
|
||||||
let result = self.read_notification(
|
|
||||||
"textDocument/publishDiagnostics",
|
|
||||||
Duration::from_millis(LSP_DIAGNOSTICS_TIMEOUT_MS),
|
|
||||||
);
|
|
||||||
self.did_close(uri)?;
|
|
||||||
match result {
|
|
||||||
Ok(params) => Ok(params.get("diagnostics").cloned().unwrap_or_else(|| json!([]))),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Health-check the LSP server.
|
|
||||||
///
|
|
||||||
/// Sends a `textDocument/documentSymbol` request on a dummy URI with a
|
|
||||||
/// 2-second timeout. Returns `true` if the server responds at all —
|
|
||||||
/// including with an error response such as "file not found", which
|
|
||||||
/// still proves the process is up and the JSON-RPC channel is live.
|
|
||||||
/// Returns `false` on timeout, EOF, or any read/write error.
|
|
||||||
///
|
|
||||||
/// Flow: build request → `send_frame` → poll frames until id matches
|
|
||||||
/// (alive) or deadline/read error fires (dead).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_alive(&mut self) -> bool {
|
|
||||||
self.next_id += 1;
|
|
||||||
let id = self.next_id;
|
|
||||||
let req = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": id,
|
|
||||||
"method": "textDocument/documentSymbol",
|
|
||||||
"params": {
|
|
||||||
"textDocument": { "uri": "file:///__zesdex_lsp_health_check__.txt" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if self.send_frame(&req).is_err() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let timeout = Duration::from_secs(2);
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
if Instant::now() > deadline {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match self.read_frame() {
|
|
||||||
Ok(frame) => {
|
|
||||||
if frame.get("id") == Some(&json!(id)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Skip unrelated notifications/responses on the same channel.
|
|
||||||
}
|
|
||||||
Err(_) => return false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send the LSP `exit` notification to request graceful shutdown.
|
|
||||||
///
|
|
||||||
/// Per the LSP spec, `exit` is a notification — the server is expected
|
|
||||||
/// to terminate after receiving it without sending a response. We do
|
|
||||||
/// not block on any reply.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn exit(&mut self) -> anyhow::Result<()> {
|
|
||||||
self.notify("exit", &json!({}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shutdown(&mut self) {
|
|
||||||
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
|
|
||||||
let _ = self.notify("exit", &json!({}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LspClient {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = self.notify("exit", &json!({}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn path_to_lsp_uri(path: &str) -> String {
|
|
||||||
file_path_to_uri(path)
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
mod client;
|
|
||||||
pub mod provisioner;
|
|
||||||
pub use client::{path_to_lsp_uri, LspClient};
|
|
||||||
|
|
||||||
/// A tracked LSP server entry.
|
|
||||||
///
|
|
||||||
/// Holds the spawn metadata and a shared handle to the connected
|
|
||||||
/// [`LspClient`]. The `Arc<Mutex<...>>` is cloned by callers that need
|
|
||||||
/// to issue LSP requests from threads or async tasks.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct LspServer {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub name: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub command: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub args: Vec<String>,
|
|
||||||
pub language_id: String,
|
|
||||||
pub client: Arc<Mutex<LspClient>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata for a document the manager has announced to an LSP server.
|
|
||||||
///
|
|
||||||
/// Used to track the current `version` and `languageId` for files
|
|
||||||
/// already sent via `textDocument/didOpen`, so subsequent edits can be
|
|
||||||
/// replayed as `textDocument/didChange` notifications.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct OpenDoc {
|
|
||||||
pub language: String,
|
|
||||||
pub version: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Central registry of connected LSP servers and per-extension routing.
|
|
||||||
///
|
|
||||||
/// Flow: caller calls `connect*` -> client spawned -> entry pushed to
|
|
||||||
/// `servers` -> `extension_registry` is populated by `register_extensions`.
|
|
||||||
/// File edits route through `find_server_for_path` / `find_server_for_extension`
|
|
||||||
/// and are dispatched as `didOpen` / `didChange` notifications.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct LspManager {
|
|
||||||
pub servers: Vec<LspServer>,
|
|
||||||
/// Maps file extension (".rs", ".ts", ...) -> server name.
|
|
||||||
pub extension_registry: HashMap<String, String>,
|
|
||||||
/// Maps document URI -> tracked open document state.
|
|
||||||
pub open_files: HashMap<String, OpenDoc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspManager {
|
|
||||||
/// Create an empty manager with no connected servers and empty registries.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
LspManager {
|
|
||||||
servers: Vec::new(),
|
|
||||||
extension_registry: HashMap::new(),
|
|
||||||
open_files: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn an LSP server and register it under `name`.
|
|
||||||
///
|
|
||||||
/// Fails if a server with the same name is already connected.
|
|
||||||
pub fn connect(
|
|
||||||
&mut self,
|
|
||||||
name: &str,
|
|
||||||
command: &str,
|
|
||||||
args: &[String],
|
|
||||||
language_id: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
if self.servers.iter().any(|s| s.name == name) {
|
|
||||||
anyhow::bail!("LSP server '{name}' is already connected");
|
|
||||||
}
|
|
||||||
let client = LspClient::spawn(command, args)?;
|
|
||||||
self.servers.push(LspServer {
|
|
||||||
name: name.to_string(),
|
|
||||||
command: command.to_string(),
|
|
||||||
args: args.to_vec(),
|
|
||||||
language_id: language_id.to_string(),
|
|
||||||
client: Arc::new(Mutex::new(client)),
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a connected server by name and return a reference to its entry.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn find_server(&self, name: &str) -> Option<&LspServer> {
|
|
||||||
self.servers.iter().find(|s| s.name == name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a clone of the `Arc<Mutex<LspClient>>` for a connected server.
|
|
||||||
///
|
|
||||||
/// Cloning the `Arc` lets callers issue requests without holding a
|
|
||||||
/// borrow on the manager.
|
|
||||||
pub fn get_client(&self, name: &str) -> Option<Arc<Mutex<LspClient>>> {
|
|
||||||
self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shut down and remove a server by name. Returns true if it existed.
|
|
||||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
|
||||||
if let Some(server) = self.servers.iter().find(|s| s.name == name) {
|
|
||||||
if let Ok(mut client) = server.client.lock() {
|
|
||||||
client.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let len = self.servers.len();
|
|
||||||
self.servers.retain(|s| s.name != name);
|
|
||||||
self.servers.len() < len
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the language id (e.g. "rust") registered for `name`.
|
|
||||||
pub fn get_language_id(&self, name: &str) -> Option<String> {
|
|
||||||
self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve an extension (".rs", ".ts", ...) to its server's client.
|
|
||||||
///
|
|
||||||
/// Flow: lookup `extension_registry` -> resolve server name -> clone client.
|
|
||||||
/// Returns `None` if no server has been registered for `ext`.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn find_server_for_extension(&self, ext: &str) -> Option<Arc<Mutex<LspClient>>> {
|
|
||||||
self.extension_registry
|
|
||||||
.get(ext)
|
|
||||||
.and_then(|name| self.get_client(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a file path to its server's client by extension.
|
|
||||||
///
|
|
||||||
/// Flow: extract the extension from `path` -> delegate to
|
|
||||||
/// `find_server_for_extension`. Files without an extension or with
|
|
||||||
/// an unmapped extension return `None`.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> {
|
|
||||||
path.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.map(|s| format!(".{s}"))
|
|
||||||
.and_then(|ext| self.find_server_for_extension(&ext))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a set of file extensions for an already-connected server.
|
|
||||||
///
|
|
||||||
/// Flow: for each `ext`, write `server_name` into `extension_registry`.
|
|
||||||
/// Re-registration overwrites the previous target. Unknown server
|
|
||||||
/// names are accepted at this layer — caller must ensure `server_name`
|
|
||||||
/// is connected or will be connected later.
|
|
||||||
pub fn register_extensions(&mut self, server_name: &str, extensions: &[&str]) {
|
|
||||||
for ext in extensions {
|
|
||||||
self.extension_registry.insert(ext.to_string(), server_name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the registered server name for a given language id.
|
|
||||||
///
|
|
||||||
/// Flow: scan `servers` for the first entry whose `language_id` matches.
|
|
||||||
/// Used when callers have a language hint rather than a file path.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get_server_name(&self, language: &str) -> Option<String> {
|
|
||||||
self.servers
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.language_id == language)
|
|
||||||
.map(|s| s.name.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notify the relevant LSP server that a file's contents have changed.
|
|
||||||
///
|
|
||||||
/// Flow: resolve server by extension -> read file contents ->
|
|
||||||
/// either send `didOpen` (first time) or `didChange` (already tracked)
|
|
||||||
/// -> update `open_files` with the new version.
|
|
||||||
///
|
|
||||||
/// Non-critical failures (file missing, server unreachable, send
|
|
||||||
/// error) are logged with `tracing::warn!` rather than propagated,
|
|
||||||
/// so a stale notification cannot abort the calling flow.
|
|
||||||
pub fn did_change_file(&mut self, path: &Path) {
|
|
||||||
let Some(ext) = path.extension().and_then(|e| e.to_str()).map(|s| format!(".{s}")) else {
|
|
||||||
tracing::warn!("did_change_file: path has no extension: {:?}", path);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let server_name = if let Some(name) = self.extension_registry.get(&ext) { name.clone() } else {
|
|
||||||
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let uri = path_to_lsp_uri(&path.to_string_lossy());
|
|
||||||
|
|
||||||
let text = match std::fs::read_to_string(path) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let language_id = self
|
|
||||||
.get_language_id(&server_name)
|
|
||||||
.unwrap_or_else(|| "plaintext".to_string());
|
|
||||||
|
|
||||||
let Some(client) = self.get_client(&server_name) else {
|
|
||||||
tracing::warn!("did_change_file: server '{}' has no client", server_name);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let next_version = match self.open_files.get(&uri) {
|
|
||||||
Some(existing) => existing.version + 1,
|
|
||||||
None => 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let send_result = {
|
|
||||||
let mut client = match client.lock() {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if self.open_files.contains_key(&uri) {
|
|
||||||
client.did_change(&uri, next_version, &text)
|
|
||||||
} else {
|
|
||||||
client.did_open(&uri, &language_id, next_version, &text)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = send_result {
|
|
||||||
tracing::warn!(
|
|
||||||
"did_change_file: failed to notify '{}' for {}: {}",
|
|
||||||
server_name,
|
|
||||||
uri,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.open_files.insert(
|
|
||||||
uri.clone(),
|
|
||||||
OpenDoc {
|
|
||||||
language: language_id,
|
|
||||||
version: next_version,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record that `server_name` has an open document at `uri`.
|
|
||||||
///
|
|
||||||
/// Flow: insert/overwrite the `OpenDoc` entry in `open_files`.
|
|
||||||
/// Does not contact the LSP server — pure local bookkeeping.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn track_open_doc(&mut self, server_name: &str, uri: &str, language: &str, version: i32) {
|
|
||||||
// server_name retained for future routing extensions; not stored today.
|
|
||||||
let _ = server_name;
|
|
||||||
self.open_files.insert(
|
|
||||||
uri.to_string(),
|
|
||||||
OpenDoc {
|
|
||||||
language: language.to_string(),
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shut down every connected server and clear the server list.
|
|
||||||
///
|
|
||||||
/// Flow: iterate `servers` -> call `client.shutdown()` on each ->
|
|
||||||
/// drop the vec. Failures from individual shutdowns are swallowed
|
|
||||||
/// because the goal is best-effort termination during teardown.
|
|
||||||
pub fn shutdown_all(&mut self) {
|
|
||||||
for server in &self.servers {
|
|
||||||
if let Ok(mut client) = server.client.lock() {
|
|
||||||
client.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.servers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot the connected servers as `(name, language_id, has_open_docs)` triples.
|
|
||||||
///
|
|
||||||
/// `has_open_docs` is true if any tracked `OpenDoc` was registered
|
|
||||||
/// against this server's clients. Useful for status displays.
|
|
||||||
pub fn list_servers(&self) -> Vec<(String, String, bool)> {
|
|
||||||
self.servers
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
let name = s.name.clone();
|
|
||||||
let lang = s.language_id.clone();
|
|
||||||
let has_open = self
|
|
||||||
.open_files
|
|
||||||
.values()
|
|
||||||
.any(|d| d.language == s.language_id);
|
|
||||||
(name, lang, has_open)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect an LSP server and register its default extensions in one call.
|
|
||||||
///
|
|
||||||
/// Flow: invoke `connect` -> on success, register `extensions` against
|
|
||||||
/// `name` in `extension_registry`. If `connect` fails, the registries
|
|
||||||
/// are left untouched and the error is propagated.
|
|
||||||
pub fn connect_with_extensions(
|
|
||||||
&mut self,
|
|
||||||
name: &str,
|
|
||||||
command: &str,
|
|
||||||
args: &[String],
|
|
||||||
language_id: &str,
|
|
||||||
extensions: &[&str],
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
self.connect(name, command, args, language_id)?;
|
|
||||||
self.register_extensions(name, extensions);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LspManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,805 +0,0 @@
|
|||||||
//! Auto-provisioning engine for LSP language servers.
|
|
||||||
//!
|
|
||||||
//! Flow: `detect_env()` → for each supported server in `supported_servers()`
|
|
||||||
//! → `provision_single()` tries install tiers in order → returns
|
|
||||||
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
|
|
||||||
//! Caller can then call `auto_connect()` to attach available servers
|
|
||||||
//! to an existing `LspManager`.
|
|
||||||
//!
|
|
||||||
//! Why: opening a project on a fresh machine should not require the user
|
|
||||||
//! to manually hunt down and install 4 different language servers.
|
|
||||||
//! Each tier is a fallback for the previous, so we try the most
|
|
||||||
//! user-friendly path first (rustup component, npm global, etc.) and
|
|
||||||
//! only fall back to package managers or manual download if those fail.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
use super::LspManager;
|
|
||||||
|
|
||||||
/// Optional progress callback type (non-owning, caller ensures liveness
|
|
||||||
/// for the duration of the provisioning call).
|
|
||||||
/// Intended to be hooked up to a UI toast / status-bar mechanism.
|
|
||||||
pub type ProgressFn<'a> = Option<&'a dyn Fn(&str)>;
|
|
||||||
|
|
||||||
/// Result of attempting to make a single language server available.
|
|
||||||
///
|
|
||||||
/// The caller should switch on this variant: `AlreadyAvailable` and
|
|
||||||
/// Installed both mean the binary can be launched; Failed means we
|
|
||||||
/// gave up and the user needs to install manually (see `manual_instructions`).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum ProvisionResult {
|
|
||||||
/// Binary was already on PATH — no install was needed.
|
|
||||||
AlreadyAvailable {
|
|
||||||
server_name: String,
|
|
||||||
language: String,
|
|
||||||
binary_path: String,
|
|
||||||
},
|
|
||||||
/// Provisioner successfully installed the binary during this run.
|
|
||||||
Installed {
|
|
||||||
server_name: String,
|
|
||||||
language: String,
|
|
||||||
binary_path: String,
|
|
||||||
},
|
|
||||||
/// Every install tier failed — `manual_instructions` tells the user how
|
|
||||||
/// to install by hand.
|
|
||||||
Failed {
|
|
||||||
language: String,
|
|
||||||
server_name: String,
|
|
||||||
reason: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
manual_instructions: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sentinel command names used by `provision_single` to detect "download"
|
|
||||||
/// tiers (which are dispatched to `download_*` helpers rather than
|
|
||||||
/// `run_command`). Kept as constants so `supported_servers` stays readable.
|
|
||||||
const DOWNLOAD_RUST_BIN: &str = "__download_rust_analyzer__";
|
|
||||||
const DOWNLOAD_JDTLS: &str = "__download_jdtls__";
|
|
||||||
|
|
||||||
/// Static description of a single language server: how to detect it,
|
|
||||||
/// what file extensions it handles, and how to install it.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LanguageServerDef {
|
|
||||||
/// Human-readable server name (e.g. "rust-analyzer").
|
|
||||||
pub name: String,
|
|
||||||
/// LSP language identifier (e.g. "rust").
|
|
||||||
pub language: String,
|
|
||||||
/// File extensions this server handles (with leading dot).
|
|
||||||
pub extensions: Vec<String>,
|
|
||||||
/// Candidate binary names — the provisioner accepts whichever appears on PATH.
|
|
||||||
pub binary_names: Vec<String>,
|
|
||||||
/// Install strategies, tried in order until one succeeds.
|
|
||||||
pub install_tiers: Vec<InstallTier>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single install attempt: a command (plus args) gated by a prerequisite.
|
|
||||||
///
|
|
||||||
/// `requires` lists binaries that must already be on PATH for this tier
|
|
||||||
/// to be considered. If any required binary is missing, the tier is
|
|
||||||
/// skipped (not attempted) so we don't produce misleading failures
|
|
||||||
/// like "rustup: command not found" when the real fix was to install
|
|
||||||
/// rustup first.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct InstallTier {
|
|
||||||
/// Short human-readable label, e.g. "rustup component".
|
|
||||||
pub label: String,
|
|
||||||
/// Binaries that must be available before this tier is attempted.
|
|
||||||
pub requires: Vec<String>,
|
|
||||||
/// Command to run.
|
|
||||||
pub command: String,
|
|
||||||
/// Arguments to pass to the command.
|
|
||||||
pub args: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot of the host environment used to decide which install tiers are viable.
|
|
||||||
///
|
|
||||||
/// Populated by `detect_env()` once per `provision_all()` call so we
|
|
||||||
/// don't re-shell out for every server. `is_linux` / `is_macos` are
|
|
||||||
/// computed at startup (compile time would also work, but keeping the
|
|
||||||
/// shape uniform with the rest of the struct makes the call sites tidy).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
|
||||||
pub struct EnvInfo {
|
|
||||||
pub has_rustup: bool,
|
|
||||||
pub has_npm: bool,
|
|
||||||
pub has_go: bool,
|
|
||||||
pub has_java: bool,
|
|
||||||
pub has_cargo: bool,
|
|
||||||
pub has_curl: bool,
|
|
||||||
pub has_wget: bool,
|
|
||||||
pub has_tar: bool,
|
|
||||||
pub has_pacman: bool,
|
|
||||||
pub has_apt: bool,
|
|
||||||
pub has_brew: bool,
|
|
||||||
pub has_dnf: bool,
|
|
||||||
pub is_linux: bool,
|
|
||||||
pub is_macos: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether `binary` exists on PATH by shelling out to `which`.
|
|
||||||
///
|
|
||||||
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
|
|
||||||
/// `which` returns exit 0 + stdout path when found, non-zero
|
|
||||||
/// otherwise. We return the first stdout line as the `PathBuf`.
|
|
||||||
///
|
|
||||||
/// Returns None if `which` itself is missing, fails to spawn, or the
|
|
||||||
/// binary is not on PATH. We deliberately don't cache this — it's only
|
|
||||||
/// called during provisioning and the results feed into install-tier
|
|
||||||
/// gating, which is already cheap.
|
|
||||||
pub fn which(binary: &str) -> Option<PathBuf> {
|
|
||||||
let output = Command::new("which").arg(binary).output().ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let first = stdout.lines().next()?.trim();
|
|
||||||
if first.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(PathBuf::from(first))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot the host environment: which toolchains and package managers
|
|
||||||
/// are available, and what OS we're on.
|
|
||||||
///
|
|
||||||
/// Flow: shell out to `which` for each tool in parallel (sequentially,
|
|
||||||
/// actually — the calls are fast and the ordering doesn't matter)
|
|
||||||
/// → set `EnvInfo` flags. Linux/macOS are detected via cfg at
|
|
||||||
/// compile time since `which` won't tell us.
|
|
||||||
///
|
|
||||||
/// Edge case: `which` may not exist on Windows; we guard with cfg so
|
|
||||||
/// this only ever runs on Unix-like targets.
|
|
||||||
pub fn detect_env() -> EnvInfo {
|
|
||||||
EnvInfo {
|
|
||||||
has_rustup: which("rustup").is_some(),
|
|
||||||
has_npm: which("npm").is_some(),
|
|
||||||
has_go: which("go").is_some(),
|
|
||||||
has_java: which("java").is_some(),
|
|
||||||
has_cargo: which("cargo").is_some(),
|
|
||||||
has_curl: which("curl").is_some(),
|
|
||||||
has_wget: which("wget").is_some(),
|
|
||||||
has_tar: which("tar").is_some(),
|
|
||||||
has_pacman: which("pacman").is_some(),
|
|
||||||
has_apt: which("apt").is_some() || which("apt-get").is_some(),
|
|
||||||
has_brew: which("brew").is_some(),
|
|
||||||
has_dnf: which("dnf").is_some(),
|
|
||||||
is_linux: cfg!(target_os = "linux"),
|
|
||||||
is_macos: cfg!(target_os = "macos"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the static set of supported language servers.
|
|
||||||
///
|
|
||||||
/// The order is significant: it determines provisioning order and
|
|
||||||
/// the order results appear in `provision_all()`. Tier 1 paths are
|
|
||||||
/// the canonical/idiomatic install for each ecosystem; later tiers
|
|
||||||
/// are fallbacks for hosts that lack the primary tooling.
|
|
||||||
///
|
|
||||||
/// Why hard-coded rather than loaded from settings: the set is small,
|
|
||||||
/// changes rarely, and bundling it lets the provisioner run before any
|
|
||||||
/// user config has been read (e.g. on first launch).
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub fn supported_servers() -> Vec<LanguageServerDef> {
|
|
||||||
vec![
|
|
||||||
LanguageServerDef {
|
|
||||||
name: "rust-analyzer".to_string(),
|
|
||||||
language: "rust".to_string(),
|
|
||||||
extensions: vec![".rs".to_string()],
|
|
||||||
binary_names: vec!["rust-analyzer".to_string()],
|
|
||||||
install_tiers: vec![
|
|
||||||
InstallTier {
|
|
||||||
label: "rustup component".to_string(),
|
|
||||||
requires: vec!["rustup".to_string()],
|
|
||||||
command: "rustup".to_string(),
|
|
||||||
args: vec!["component".to_string(), "add".to_string(), "rust-analyzer".to_string()],
|
|
||||||
},
|
|
||||||
InstallTier {
|
|
||||||
label: "pacman".to_string(),
|
|
||||||
requires: vec!["pacman".to_string()],
|
|
||||||
command: "pacman".to_string(),
|
|
||||||
args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "rust-analyzer".to_string()],
|
|
||||||
},
|
|
||||||
InstallTier {
|
|
||||||
label: "brew".to_string(),
|
|
||||||
requires: vec!["brew".to_string()],
|
|
||||||
command: "brew".to_string(),
|
|
||||||
args: vec!["install".to_string(), "rust-analyzer".to_string()],
|
|
||||||
},
|
|
||||||
InstallTier {
|
|
||||||
label: "cargo install".to_string(),
|
|
||||||
requires: vec!["cargo".to_string()],
|
|
||||||
command: "cargo".to_string(),
|
|
||||||
args: vec!["install".to_string(), "--locked".to_string(), "rust-analyzer".to_string()],
|
|
||||||
},
|
|
||||||
InstallTier {
|
|
||||||
label: "download prebuilt".to_string(),
|
|
||||||
requires: vec!["curl".to_string(), "tar".to_string()],
|
|
||||||
command: DOWNLOAD_RUST_BIN.to_string(),
|
|
||||||
args: vec![],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
LanguageServerDef {
|
|
||||||
name: "typescript-language-server".to_string(),
|
|
||||||
language: "typescript".to_string(),
|
|
||||||
extensions: vec![
|
|
||||||
".ts".to_string(),
|
|
||||||
".tsx".to_string(),
|
|
||||||
".js".to_string(),
|
|
||||||
".jsx".to_string(),
|
|
||||||
],
|
|
||||||
binary_names: vec!["typescript-language-server".to_string()],
|
|
||||||
install_tiers: vec![InstallTier {
|
|
||||||
label: "npm global".to_string(),
|
|
||||||
requires: vec!["npm".to_string()],
|
|
||||||
command: "npm".to_string(),
|
|
||||||
args: vec![
|
|
||||||
"install".to_string(),
|
|
||||||
"-g".to_string(),
|
|
||||||
"typescript".to_string(),
|
|
||||||
"typescript-language-server".to_string(),
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
LanguageServerDef {
|
|
||||||
name: "gopls".to_string(),
|
|
||||||
language: "go".to_string(),
|
|
||||||
extensions: vec![".go".to_string()],
|
|
||||||
binary_names: vec!["gopls".to_string()],
|
|
||||||
install_tiers: vec![InstallTier {
|
|
||||||
label: "go install".to_string(),
|
|
||||||
requires: vec!["go".to_string()],
|
|
||||||
command: "go".to_string(),
|
|
||||||
args: vec![
|
|
||||||
"install".to_string(),
|
|
||||||
"golang.org/x/tools/gopls@latest".to_string(),
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
LanguageServerDef {
|
|
||||||
name: "jdtls".to_string(),
|
|
||||||
language: "java".to_string(),
|
|
||||||
extensions: vec![".java".to_string()],
|
|
||||||
binary_names: vec!["jdtls".to_string(), "eclipse-jdt-ls".to_string(), "jdtls-launcher".to_string()],
|
|
||||||
install_tiers: vec![
|
|
||||||
InstallTier {
|
|
||||||
label: "pacman".to_string(),
|
|
||||||
requires: vec!["java".to_string(), "pacman".to_string()],
|
|
||||||
command: "pacman".to_string(),
|
|
||||||
args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "eclipse-jdt-ls".to_string()],
|
|
||||||
},
|
|
||||||
InstallTier {
|
|
||||||
label: "apt".to_string(),
|
|
||||||
requires: vec!["java".to_string(), "apt".to_string()],
|
|
||||||
command: "sudo".to_string(),
|
|
||||||
args: vec!["apt".to_string(), "install".to_string(), "-y".to_string(), "eclipse-jdt-ls".to_string()],
|
|
||||||
},
|
|
||||||
InstallTier {
|
|
||||||
label: "brew".to_string(),
|
|
||||||
requires: vec!["java".to_string(), "brew".to_string()],
|
|
||||||
command: "brew".to_string(),
|
|
||||||
args: vec!["install".to_string(), "jdtls".to_string()],
|
|
||||||
},
|
|
||||||
InstallTier {
|
|
||||||
label: "download from eclipse".to_string(),
|
|
||||||
requires: vec!["java".to_string(), "curl".to_string(), "tar".to_string()],
|
|
||||||
command: DOWNLOAD_JDTLS.to_string(),
|
|
||||||
args: vec![],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn `cmd` with `args`, capture stdout, wait up to 120s, return
|
|
||||||
/// (success, stdout).
|
|
||||||
///
|
|
||||||
/// Flow: build Command with piped stdout/err → spawn → poll in 50ms
|
|
||||||
/// loops with `child.try_wait()` until the command finishes or
|
|
||||||
/// 120s elapses (in which case we kill the child).
|
|
||||||
/// Merging stderr into stdout keeps callers simple — install
|
|
||||||
/// commands tend to emit errors to stderr, and we want to surface
|
|
||||||
/// those.
|
|
||||||
///
|
|
||||||
/// Why a custom timeout: `std::process::Command` has no built-in timeout,
|
|
||||||
/// and we'd rather kill a hung `apt` than block the TUI indefinitely.
|
|
||||||
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
|
|
||||||
let mut command = Command::new(cmd);
|
|
||||||
command.args(args);
|
|
||||||
command.stdout(Stdio::piped());
|
|
||||||
command.stderr(Stdio::piped());
|
|
||||||
|
|
||||||
let mut child = command.spawn()?;
|
|
||||||
let stdout_handle = child.stdout.take();
|
|
||||||
let stderr_handle = child.stderr.take();
|
|
||||||
|
|
||||||
let stdout_thread = stdout_handle.map(|s| {
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut buf = String::new();
|
|
||||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(s), &mut buf);
|
|
||||||
buf
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let stderr_thread = stderr_handle.map(|s| {
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut buf = String::new();
|
|
||||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(s), &mut buf);
|
|
||||||
buf
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let timeout = Duration::from_mins(3);
|
|
||||||
let start = Instant::now();
|
|
||||||
let status = loop {
|
|
||||||
if let Some(status) = child.try_wait()? { break Ok(status) }
|
|
||||||
if start.elapsed() > timeout {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
break Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::TimedOut,
|
|
||||||
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
|
||||||
};
|
|
||||||
|
|
||||||
let stdout = stdout_thread
|
|
||||||
.map(|t| t.join().unwrap_or_default())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let stderr = stderr_thread
|
|
||||||
.map(|t| t.join().unwrap_or_default())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
match status {
|
|
||||||
Ok(s) if s.success() => Ok((true, stdout)),
|
|
||||||
Ok(_) => Ok((false, format!("{stdout}{stderr}"))),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the directory where downloaded LSP binaries are stored.
|
|
||||||
fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
|
|
||||||
let base = dirs::data_dir()
|
|
||||||
.ok_or_else(|| "cannot find data directory via dirs crate".to_string())?
|
|
||||||
.join("zesdex")
|
|
||||||
.join("lsp")
|
|
||||||
.join(server);
|
|
||||||
Ok(base)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether `def` was previously installed via the download tier
|
|
||||||
/// (binary/lancher lives under `~/.local/share/zesdex/lsp/<name>/`).
|
|
||||||
/// Returns the path to the binary if found.
|
|
||||||
fn previous_download_install(def: &LanguageServerDef) -> Option<PathBuf> {
|
|
||||||
let base = lsp_install_dir(&def.name).ok()?;
|
|
||||||
let candidates: &[&str] = match def.name.as_str() {
|
|
||||||
"rust-analyzer" => &["rust-analyzer"],
|
|
||||||
"jdtls" => &["bin/jdtls", "jdtls-launcher.sh", "jdtls"],
|
|
||||||
"typescript-language-server" => &["bin/typescript-language-server"],
|
|
||||||
"gopls" => &["bin/gopls"],
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
for sub in candidates {
|
|
||||||
let p = base.join(sub);
|
|
||||||
if p.exists() {
|
|
||||||
// Skip directory entries that exist but are the base dir itself.
|
|
||||||
if p.is_file() {
|
|
||||||
return Some(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download a file from `url` to `dest` using curl.
|
|
||||||
fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
|
||||||
let path_str = dest.to_str().ok_or("invalid dest path")?.to_string();
|
|
||||||
info!(url = url, dest = %path_str, "downloading");
|
|
||||||
let args = [
|
|
||||||
"-fsSL",
|
|
||||||
"--connect-timeout", "15",
|
|
||||||
"--max-time", &max_secs.to_string(),
|
|
||||||
"-o", &path_str,
|
|
||||||
url,
|
|
||||||
];
|
|
||||||
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
|
|
||||||
if !ok {
|
|
||||||
return Err(format!("download failed: {}", out.trim()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download rust-analyzer from GitHub releases and install into
|
|
||||||
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
|
|
||||||
fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
|
|
||||||
let base = lsp_install_dir("rust-analyzer")?;
|
|
||||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
|
||||||
|
|
||||||
let url = if env.is_linux {
|
|
||||||
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
|
|
||||||
} else if env.is_macos {
|
|
||||||
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-aarch64-apple-darwin.gz"
|
|
||||||
} else {
|
|
||||||
return Err("no prebuilt binary for this OS".to_string());
|
|
||||||
};
|
|
||||||
|
|
||||||
let gz = base.join("rust-analyzer.gz");
|
|
||||||
let target = base.join("rust-analyzer");
|
|
||||||
|
|
||||||
if let Some(cb) = progress { cb("Rust: downloading prebuilt binary..."); }
|
|
||||||
download_url(url, &gz, 120)?;
|
|
||||||
if let Some(cb) = progress { cb("Rust: decompressing..."); }
|
|
||||||
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
|
|
||||||
.map_err(|e| format!("gunzip spawn: {e}"))?;
|
|
||||||
if !ok {
|
|
||||||
return Err(format!("gunzip: {}", out.trim()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !target.exists() {
|
|
||||||
return Err("binary missing after decompression".to_string());
|
|
||||||
}
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
|
|
||||||
.map_err(|e| format!("chmod: {e}"))?;
|
|
||||||
}
|
|
||||||
if let Some(cb) = progress { cb("Rust: installed ✓"); }
|
|
||||||
Ok(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download Eclipse JDT-LS from the official snapshot server, extract it,
|
|
||||||
/// and create a launcher script at `bin/jdtls`.
|
|
||||||
fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
|
||||||
let base = lsp_install_dir("jdtls")?;
|
|
||||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
|
||||||
|
|
||||||
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
|
|
||||||
let tarball = base.join("jdtls.tar.gz");
|
|
||||||
if let Some(cb) = progress { cb("Java: downloading JDT-LS (~150MB)..."); }
|
|
||||||
download_url(url, &tarball, 300)?;
|
|
||||||
if let Some(cb) = progress { cb("Java: extracting..."); }
|
|
||||||
|
|
||||||
let (ok, out) = run_command("tar", &[
|
|
||||||
"-xzf", tarball.to_str().unwrap_or(""),
|
|
||||||
"-C", base.to_str().unwrap_or("."),
|
|
||||||
]).map_err(|e| format!("tar spawn: {e}"))?;
|
|
||||||
if !ok {
|
|
||||||
return Err(format!("tar: {}", out.trim()));
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_file(&tarball);
|
|
||||||
|
|
||||||
if !base.join("plugins").exists() {
|
|
||||||
return Err("extracted archive missing plugins/ directory".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let bin_dir = base.join("bin");
|
|
||||||
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {e}"))?;
|
|
||||||
let launcher = bin_dir.join("jdtls");
|
|
||||||
|
|
||||||
let script = r#"#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
JDTLS_HOME="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
LAUNCHER=$(ls "${JDTLS_HOME}/plugins/org.eclipse.equinox.launcher_"*.jar 2>/dev/null | head -n1)
|
|
||||||
CONFIG=$(ls -d "${JDTLS_HOME}"/config_* 2>/dev/null | head -n1)
|
|
||||||
WORKSPACE="${JDTLS_HOME}/workspace"
|
|
||||||
mkdir -p "${WORKSPACE}"
|
|
||||||
exec java \
|
|
||||||
-Declipse.application=org.eclipse.jdt.ls.core.id1 \
|
|
||||||
-Dosgi.bundles.defaultStartLevel=5 \
|
|
||||||
-Declipse.product=org.eclipse.jdt.ls.core.product \
|
|
||||||
-Dlog.level=WARN -noverify -Xmx1G \
|
|
||||||
-jar "${LAUNCHER}" -configuration "${CONFIG}" -data "${WORKSPACE}" \
|
|
||||||
--add-modules=ALL-SYSTEM \
|
|
||||||
--add-opens java.base/java.util=ALL-UNNAMED \
|
|
||||||
--add-opens java.base/java.lang=ALL-UNNAMED \
|
|
||||||
"$@"
|
|
||||||
"#;
|
|
||||||
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {e}"))?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
|
|
||||||
.map_err(|e| format!("chmod launcher: {e}"))?;
|
|
||||||
}
|
|
||||||
if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); }
|
|
||||||
Ok(launcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch a sentinel download tier to the correct helper.
|
|
||||||
fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
|
|
||||||
match name {
|
|
||||||
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
|
|
||||||
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
|
|
||||||
other => Err(format!("unknown download tier '{other}'")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the "install by hand" message shown to the user when every
|
|
||||||
/// automated tier fails.
|
|
||||||
fn manual_instructions(def: &LanguageServerDef) -> String {
|
|
||||||
match def.language.as_str() {
|
|
||||||
"rust" => "Install rust-analyzer:\n \
|
|
||||||
Arch: sudo pacman -S rust-analyzer\n \
|
|
||||||
macOS: brew install rust-analyzer\n \
|
|
||||||
Any: cargo install --locked rust-analyzer\n \
|
|
||||||
Rustup: rustup component add rust-analyzer"
|
|
||||||
.to_string(),
|
|
||||||
"typescript" => "Install typescript-language-server:\n \
|
|
||||||
npm install -g typescript typescript-language-server\n \
|
|
||||||
Arch: sudo pacman -S typescript-language-server"
|
|
||||||
.to_string(),
|
|
||||||
"go" => "Install gopls:\n \
|
|
||||||
go install golang.org/x/tools/gopls@latest\n \
|
|
||||||
Arch: sudo pacman -S gopls"
|
|
||||||
.to_string(),
|
|
||||||
"java" => "Install Eclipse JDT-LS:\n \
|
|
||||||
Arch: sudo pacman -S eclipse-jdt-ls\n \
|
|
||||||
Debian: sudo apt install eclipse-jdt-ls\n \
|
|
||||||
macOS: brew install jdtls\n \
|
|
||||||
Other: see https://.eclipse.org/jdtls/#download"
|
|
||||||
.to_string(),
|
|
||||||
_ => format!("No automated install available for '{}'.", def.language),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to provision a single language server.
|
|
||||||
///
|
|
||||||
/// Flow: check whether any `binary_names` candidate is already on PATH
|
|
||||||
/// → if yes, return `AlreadyAvailable` → otherwise walk
|
|
||||||
/// `install_tiers` in order, skipping tiers whose `requires`
|
|
||||||
/// binaries are missing → for each viable tier, run the install
|
|
||||||
/// command (120s timeout) → if it succeeds AND the binary now
|
|
||||||
/// appears on PATH (or the tier is jdtls-manual returning a
|
|
||||||
/// launcher path), return Installed → if every tier fails, return
|
|
||||||
/// Failed with the last error and manual install instructions.
|
|
||||||
///
|
|
||||||
/// Why we re-check `which` after the install: `rustup component add`
|
|
||||||
/// can exit 0 even if the binary wasn't actually placed on PATH (rare,
|
|
||||||
/// but happens with broken rustup installs). Re-checking gives us a
|
|
||||||
/// real signal rather than trusting the exit code alone.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResult {
|
|
||||||
provision_single_with_progress(def, env, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progress: ProgressFn<'_>) -> ProvisionResult {
|
|
||||||
// 1. Check PATH.
|
|
||||||
for bin in &def.binary_names {
|
|
||||||
if let Some(path) = which(bin) {
|
|
||||||
if let Some(cb) = progress { cb(&format!("{}: already installed (PATH)", def.language)); }
|
|
||||||
return ProvisionResult::AlreadyAvailable {
|
|
||||||
server_name: def.name.clone(),
|
|
||||||
language: def.language.clone(),
|
|
||||||
binary_path: path.to_string_lossy().to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check download-install directory (~/.local/share/zesdex/lsp/<name>/...).
|
|
||||||
if let Some(path) = previous_download_install(def) {
|
|
||||||
if let Some(cb) = progress { cb(&format!("{}: found previous install", def.language)); }
|
|
||||||
return ProvisionResult::AlreadyAvailable {
|
|
||||||
server_name: def.name.clone(),
|
|
||||||
language: def.language.clone(),
|
|
||||||
binary_path: path.to_string_lossy().to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cb) = progress { cb(&format!("{}: checking install options...", def.language)); }
|
|
||||||
|
|
||||||
let mut last_reason = String::from("no install tiers succeeded");
|
|
||||||
|
|
||||||
for tier in &def.install_tiers {
|
|
||||||
// Prerequisite gating
|
|
||||||
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
|
|
||||||
"rustup" => env.has_rustup, "npm" => env.has_npm,
|
|
||||||
"go" => env.has_go, "java" => env.has_java,
|
|
||||||
"cargo" => env.has_cargo, "curl" => env.has_curl,
|
|
||||||
"tar" => env.has_tar, "pacman" => env.has_pacman,
|
|
||||||
"apt" => env.has_apt, "brew" => env.has_brew,
|
|
||||||
"dnf" => env.has_dnf, _ => which(req).is_some(),
|
|
||||||
});
|
|
||||||
if !prereqs_met {
|
|
||||||
let skip = format!("{}: {} — missing prerequisite", def.language, tier.label);
|
|
||||||
if let Some(cb) = progress { cb(&skip); }
|
|
||||||
last_reason = format!("tier '{}' skipped: missing prerequisite", tier.label);
|
|
||||||
warn!(server = %def.name, tier = %tier.label, "skipped — missing prerequisites");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let trying = format!("{}: {}...", def.language, tier.label);
|
|
||||||
if let Some(cb) = progress { cb(&trying); }
|
|
||||||
|
|
||||||
// Download sentinel → helper.
|
|
||||||
if tier.command.starts_with("__download_") && tier.command.ends_with("__") {
|
|
||||||
match run_download_tier(&tier.command, env, progress) {
|
|
||||||
Ok(path) => {
|
|
||||||
info!(server = %def.name, tier = %tier.label, binary = %path.display(), "installed");
|
|
||||||
return ProvisionResult::Installed {
|
|
||||||
server_name: def.name.clone(),
|
|
||||||
language: def.language.clone(),
|
|
||||||
binary_path: path.to_string_lossy().to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
last_reason = format!("tier '{}' failed: {}", tier.label, e);
|
|
||||||
warn!(server = %def.name, tier = %tier.label, error = %e, "download failed");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal shell-out tier.
|
|
||||||
let arg_refs: Vec<&str> = tier.args.iter().map(std::string::String::as_str).collect();
|
|
||||||
match run_command(&tier.command, &arg_refs) {
|
|
||||||
Ok((true, _)) => {
|
|
||||||
let located = def
|
|
||||||
.binary_names
|
|
||||||
.iter()
|
|
||||||
.find_map(|b| which(b).map(|p| p.to_string_lossy().to_string()));
|
|
||||||
if let Some(path) = located {
|
|
||||||
if let Some(cb) = progress { cb(&format!("{}: installed ✓", def.language)); }
|
|
||||||
info!(server = %def.name, tier = %tier.label, binary = %path, "installed");
|
|
||||||
return ProvisionResult::Installed {
|
|
||||||
server_name: def.name.clone(),
|
|
||||||
language: def.language.clone(),
|
|
||||||
binary_path: path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
last_reason = format!("tier '{}' exited 0 but binary not on PATH", tier.label);
|
|
||||||
warn!(server = %def.name, tier = %tier.label, "success reported but binary missing");
|
|
||||||
}
|
|
||||||
Ok((false, out)) => {
|
|
||||||
let trimmed = out.trim();
|
|
||||||
let snippet: String = trimmed.chars().take(300).collect();
|
|
||||||
last_reason = format!("tier '{}' failed: {}", tier.label, snippet);
|
|
||||||
warn!(server = %def.name, tier = %tier.label, output = %snippet, "failed");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
last_reason = format!("tier '{}' error: {}", tier.label, e);
|
|
||||||
warn!(server = %def.name, tier = %tier.label, error = %e, "errored");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let manual = manual_instructions(def);
|
|
||||||
ProvisionResult::Failed {
|
|
||||||
language: def.language.clone(), server_name: def.name.clone(),
|
|
||||||
reason: last_reason, manual_instructions: manual,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provision every supported server in order, returning one
|
|
||||||
/// `ProvisionResult` per server.
|
|
||||||
///
|
|
||||||
/// Flow: `detect_env()` once → for each server in `supported_servers()`
|
|
||||||
/// call `provision_single()` → collect results. Order matches
|
|
||||||
/// `supported_servers()` (rust, typescript, go, java).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn provision_all() -> Vec<ProvisionResult> {
|
|
||||||
let env = detect_env();
|
|
||||||
info!(
|
|
||||||
linux = env.is_linux,
|
|
||||||
macos = env.is_macos,
|
|
||||||
rustup = env.has_rustup,
|
|
||||||
cargo = env.has_cargo,
|
|
||||||
npm = env.has_npm,
|
|
||||||
go = env.has_go,
|
|
||||||
java = env.has_java,
|
|
||||||
curl = env.has_curl,
|
|
||||||
tar = env.has_tar,
|
|
||||||
pacman = env.has_pacman,
|
|
||||||
apt = env.has_apt,
|
|
||||||
brew = env.has_brew,
|
|
||||||
dnf = env.has_dnf,
|
|
||||||
"starting LSP provisioning"
|
|
||||||
);
|
|
||||||
supported_servers()
|
|
||||||
.iter()
|
|
||||||
.map(|def| provision_single(def, &env))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like `provision_all` but calls `progress` with a human-readable status
|
|
||||||
/// string at each stage of each server's install attempt.
|
|
||||||
pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult> {
|
|
||||||
let env = detect_env();
|
|
||||||
if let Some(cb) = progress {
|
|
||||||
let flags = [
|
|
||||||
("rustup", env.has_rustup), ("cargo", env.has_cargo),
|
|
||||||
("npm", env.has_npm), ("go", env.has_go), ("java", env.has_java),
|
|
||||||
("curl", env.has_curl), ("tar", env.has_tar),
|
|
||||||
("pacman", env.has_pacman), ("apt", env.has_apt), ("brew", env.has_brew),
|
|
||||||
];
|
|
||||||
let avail: String = flags.iter()
|
|
||||||
.filter(|(_, v)| *v).map(|(k, _)| *k)
|
|
||||||
.collect::<Vec<_>>().join(", ");
|
|
||||||
cb(&format!("LSP: environment ready — {avail}"));
|
|
||||||
}
|
|
||||||
supported_servers()
|
|
||||||
.iter()
|
|
||||||
.map(|def| provision_single_with_progress(def, &env, progress))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// For every successful provision result, attach the corresponding
|
|
||||||
/// server to the given `LspManager`.
|
|
||||||
///
|
|
||||||
/// Flow: for each result, if it's `AlreadyAvailable` or Installed, look
|
|
||||||
/// up the `LanguageServerDef`, then call `manager.connect()` with
|
|
||||||
/// the binary path and empty args. On connect success, log and
|
|
||||||
/// record the name; on failure, log a warning and skip.
|
|
||||||
/// Returns the names that successfully connected.
|
|
||||||
///
|
|
||||||
/// Why empty args: most LSP servers don't need CLI flags to start;
|
|
||||||
/// the spec for each server lives in the protocol handshake, not the
|
|
||||||
/// argv. If we ever need flags (e.g. --stdio), they'll be a per-server
|
|
||||||
/// constant in `supported_servers()`.
|
|
||||||
pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> {
|
|
||||||
let defs = supported_servers();
|
|
||||||
let mut connected: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
for result in results {
|
|
||||||
let (name, language, binary) = match result {
|
|
||||||
ProvisionResult::AlreadyAvailable {
|
|
||||||
server_name,
|
|
||||||
language,
|
|
||||||
binary_path,
|
|
||||||
}
|
|
||||||
| ProvisionResult::Installed {
|
|
||||||
server_name,
|
|
||||||
language,
|
|
||||||
binary_path,
|
|
||||||
} => (server_name.clone(), language.clone(), binary_path.clone()),
|
|
||||||
ProvisionResult::Failed { .. } => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sanity: only connect to servers we know about. Protects against
|
|
||||||
// future ProvisionResult variants sneaking in unknown names.
|
|
||||||
let Some(def) = defs.iter().find(|d| d.name == name) else {
|
|
||||||
warn!(name = %name, "skipping connect: unknown server");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut guard = match manager.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(error = %e, "LspManager mutex poisoned; skipping connect");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build extension slice for connect_with_extensions.
|
|
||||||
let ext_refs: Vec<&str> = def.extensions.iter().map(std::string::String::as_str).collect();
|
|
||||||
|
|
||||||
match guard.connect_with_extensions(&name, &binary, &[], &language, &ext_refs) {
|
|
||||||
Ok(()) => {
|
|
||||||
info!(
|
|
||||||
name = %name,
|
|
||||||
language = %language,
|
|
||||||
binary = %binary,
|
|
||||||
"connected LSP server"
|
|
||||||
);
|
|
||||||
connected.push(name);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
name = %name,
|
|
||||||
error = %e,
|
|
||||||
"failed to connect LSP server"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connected
|
|
||||||
}
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
//! MCP server connection management: spawning/talking to stdio child
|
|
||||||
//! processes and HTTP endpoints, and adapting their advertised tools to
|
|
||||||
//! the crate's `Tool` trait.
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
|
||||||
|
|
||||||
|
|
||||||
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
|
|
||||||
const MCP_CALL_TIMEOUT_MS: u64 = 60_000;
|
|
||||||
|
|
||||||
/// Global cache for `&'static str` names/descriptions of MCP tools, so we
|
|
||||||
/// never need `Box::leak`. Entries are never removed (small, bounded by the
|
|
||||||
/// number of MCP tools ever registered in a session).
|
|
||||||
fn mcp_static_str(s: &str) -> &'static str {
|
|
||||||
static CACHE: OnceLock<Mutex<Vec<&'static str>>> = OnceLock::new();
|
|
||||||
let mut cache = match CACHE.get_or_init(|| Mutex::new(Vec::new())).lock() {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(poisoned) => {
|
|
||||||
tracing::warn!("[mcp] static string cache mutex poisoned, recovering");
|
|
||||||
poisoned.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(&existing) = cache.iter().find(|e| **e == s) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
let leaked: &'static str = Box::leak(s.to_string().into_boxed_str());
|
|
||||||
cache.push(leaked);
|
|
||||||
leaked
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How an MCP server is reached: a spawned child process talking
|
|
||||||
/// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum McpTransport {
|
|
||||||
Stdio {
|
|
||||||
command: String,
|
|
||||||
args: Vec<String>,
|
|
||||||
},
|
|
||||||
StreamableHttp {
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single tool advertised by an MCP server, as returned by `tools/list`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpToolInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub input_schema: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A connected MCP server: its transport, advertised tools, and (for stdio)
|
|
||||||
/// a live handle to the child process.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpServer {
|
|
||||||
pub name: String,
|
|
||||||
pub transport: McpTransport,
|
|
||||||
pub tools: Vec<McpToolInfo>,
|
|
||||||
/// Held child-process handle so subsequent tool calls reuse the same
|
|
||||||
/// connection instead of spawning a new child each time. Not serialized
|
|
||||||
/// because the child only lives in this process.
|
|
||||||
#[serde(skip)]
|
|
||||||
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live handle to an MCP server child process communicating over stdio
|
|
||||||
/// via newline-delimited JSON-RPC 2.0.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct StdioChild {
|
|
||||||
stdin: std::process::ChildStdin,
|
|
||||||
stdout: BufReader<std::process::ChildStdout>,
|
|
||||||
next_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StdioChild {
|
|
||||||
/// Send a JSON-RPC request to the child and block for its matching response.
|
|
||||||
///
|
|
||||||
/// Flow: assign the next request id → write request + newline to stdin →
|
|
||||||
/// loop reading lines from stdout until one has a matching `id` or the
|
|
||||||
/// timeout elapses → return its `result` (or error out on an `error` field).
|
|
||||||
///
|
|
||||||
/// Why: the child may interleave unrelated/malformed lines, so blank
|
|
||||||
/// lines are skipped and non-matching ids are ignored rather than
|
|
||||||
/// treated as a protocol violation.
|
|
||||||
///
|
|
||||||
/// Return: the `result` value of the matching response, or `Err` on
|
|
||||||
/// timeout, EOF, JSON-RPC error, or I/O failure.
|
|
||||||
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
|
|
||||||
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
|
|
||||||
self.next_id += 1;
|
|
||||||
let id = self.next_id;
|
|
||||||
let req = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": id,
|
|
||||||
"method": method,
|
|
||||||
"params": params
|
|
||||||
});
|
|
||||||
let mut line = serde_json::to_string(&req)?;
|
|
||||||
line.push('\n');
|
|
||||||
self.stdin.write_all(line.as_bytes())?;
|
|
||||||
self.stdin.flush()?;
|
|
||||||
|
|
||||||
let mut response_line = String::new();
|
|
||||||
let deadline = std::time::Instant::now()
|
|
||||||
+ std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
|
||||||
loop {
|
|
||||||
if std::time::Instant::now() > deadline {
|
|
||||||
anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
|
|
||||||
}
|
|
||||||
// Read one byte at a time up to MAX_LINE_LENGTH to prevent
|
|
||||||
// OOM from a malicious server (CWE-400). BufReader already
|
|
||||||
// buffers reads, so byte-by-byte over a buffered reader is
|
|
||||||
// cheap (hits the in-memory buffer).
|
|
||||||
response_line.clear();
|
|
||||||
let mut line_truncated = false;
|
|
||||||
loop {
|
|
||||||
let byte = match self.stdout.fill_buf() {
|
|
||||||
Ok([]) => {
|
|
||||||
// EOF without newline
|
|
||||||
anyhow::bail!("MCP stdio child process closed unexpectedly");
|
|
||||||
}
|
|
||||||
Ok(buf) => {
|
|
||||||
let b = buf[0];
|
|
||||||
self.stdout.consume(1);
|
|
||||||
b
|
|
||||||
}
|
|
||||||
Err(e) => anyhow::bail!("MCP stdio read error: {e}"),
|
|
||||||
};
|
|
||||||
if byte == b'\n' {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if response_line.len() >= MAX_LINE_LENGTH {
|
|
||||||
line_truncated = true;
|
|
||||||
// Consume rest of line to keep stream in sync
|
|
||||||
loop {
|
|
||||||
let buf = self.stdout.fill_buf()
|
|
||||||
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?;
|
|
||||||
if buf.is_empty() {
|
|
||||||
anyhow::bail!("MCP stdio child closed mid-line");
|
|
||||||
}
|
|
||||||
if buf[0] == b'\n' {
|
|
||||||
self.stdout.consume(1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
self.stdout.consume(1);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
response_line.push(byte as char);
|
|
||||||
}
|
|
||||||
if line_truncated {
|
|
||||||
anyhow::bail!(
|
|
||||||
"MCP response line exceeded {MAX_LINE_LENGTH} byte limit",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let trimmed = response_line.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let resp: Value = serde_json::from_str(trimmed)
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {e}"))?;
|
|
||||||
if resp.get("id") == Some(&json!(id)) {
|
|
||||||
if let Some(err) = resp.get("error") {
|
|
||||||
anyhow::bail!("MCP error: {err}");
|
|
||||||
}
|
|
||||||
return Ok(resp.get("result").cloned().unwrap_or_else(|| {
|
|
||||||
tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
|
|
||||||
Value::Null
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // close fn call
|
|
||||||
} // close impl StdioChild
|
|
||||||
|
|
||||||
pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result<StdioChild> {
|
|
||||||
let parts: Vec<&str> = command.split_whitespace().collect();
|
|
||||||
let (prog, prog_args) = parts.split_first()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?;
|
|
||||||
|
|
||||||
let mut cmd = std::process::Command::new(prog);
|
|
||||||
cmd.args(prog_args);
|
|
||||||
cmd.args(extra_args);
|
|
||||||
cmd.stdin(std::process::Stdio::piped());
|
|
||||||
cmd.stdout(std::process::Stdio::piped());
|
|
||||||
// Pipe stderr so diagnostics from MCP servers are surfaced via tracing
|
|
||||||
// rather than discarded silently, making connectivity issues debugable.
|
|
||||||
cmd.stderr(std::process::Stdio::piped());
|
|
||||||
|
|
||||||
let mut child = cmd.spawn()
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{command}': {e}"))?;
|
|
||||||
|
|
||||||
let stdin = child.stdin.take()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
|
|
||||||
let stdout = child.stdout.take()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdout for MCP server"))?;
|
|
||||||
|
|
||||||
let mut mcp = StdioChild {
|
|
||||||
stdin,
|
|
||||||
stdout: BufReader::new(stdout),
|
|
||||||
next_id: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let deadline = std::time::Instant::now()
|
|
||||||
+ std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
|
|
||||||
|
|
||||||
let init_result = mcp.call("initialize", &json!({
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": {},
|
|
||||||
"clientInfo": {
|
|
||||||
"name": "zesdex",
|
|
||||||
"version": "0.1.0"
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
if std::time::Instant::now() > deadline {
|
|
||||||
anyhow::bail!("MCP initialize timed out");
|
|
||||||
}
|
|
||||||
|
|
||||||
init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {e}"))?;
|
|
||||||
|
|
||||||
let _ = mcp.call("notifications/initialized", &json!({}));
|
|
||||||
|
|
||||||
Ok(mcp)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_via_stdio(
|
|
||||||
existing_handle: Option<&Mutex<StdioChild>>,
|
|
||||||
command: &str,
|
|
||||||
extra_args: &[String],
|
|
||||||
tool_name: &str,
|
|
||||||
tool_args: &Value,
|
|
||||||
) -> anyhow::Result<String> {
|
|
||||||
// Reuse the persistent child handle if available; otherwise spawn a new one.
|
|
||||||
let mut guard;
|
|
||||||
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
|
|
||||||
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
|
|
||||||
&mut guard
|
|
||||||
} else {
|
|
||||||
let mut fresh = spawn_stdio_child(command, extra_args)?;
|
|
||||||
let result = fresh.call("tools/call", &json!({
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": tool_args
|
|
||||||
}))?;
|
|
||||||
return Ok(extract_text_content(&result));
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = child.call("tools/call", &json!({
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": tool_args
|
|
||||||
}))?;
|
|
||||||
|
|
||||||
Ok(extract_text_content(&result))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
|
|
||||||
.connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
tracing::warn!(
|
|
||||||
"[mcp] HTTP client builder failed with connect timeout: {}. \
|
|
||||||
retrying without connect timeout",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
reqwest::blocking::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_else(|e2| {
|
|
||||||
tracing::warn!(
|
|
||||||
"[mcp] also failed: {}. using default client (no configured timeouts)",
|
|
||||||
e2,
|
|
||||||
);
|
|
||||||
reqwest::blocking::Client::new()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let request_id: u64 = 1;
|
|
||||||
let body = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": request_id,
|
|
||||||
"method": "tools/call",
|
|
||||||
"params": {
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": tool_args
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client.post(url)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {e}"))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status();
|
|
||||||
let text = resp.text().unwrap_or_else(|e| {
|
|
||||||
tracing::warn!("[mcp] failed to read HTTP response body: {}", e);
|
|
||||||
String::new()
|
|
||||||
});
|
|
||||||
anyhow::bail!("MCP HTTP server returned {status}: {text}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: Value = resp.json()
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {e}"))?;
|
|
||||||
|
|
||||||
if let Some(err) = response.get("error") {
|
|
||||||
anyhow::bail!("MCP HTTP error: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = response.get("result").cloned().unwrap_or_else(|| {
|
|
||||||
tracing::warn!("[mcp] HTTP response missing 'result' field");
|
|
||||||
Value::Null
|
|
||||||
});
|
|
||||||
Ok(extract_text_content(&result))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_text_content(result: &Value) -> String {
|
|
||||||
if let Some(content) = result.get("content") {
|
|
||||||
if let Some(arr) = content.as_array() {
|
|
||||||
let text: Vec<String> = arr.iter().filter_map(|item| {
|
|
||||||
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
|
||||||
item.get("text").and_then(|t| t.as_str()).map(std::string::ToString::to_string)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
if !text.is_empty() {
|
|
||||||
return text.join("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::to_string_pretty(result).unwrap_or_else(|e| {
|
|
||||||
tracing::warn!("[mcp] failed to pretty-print result: {}", e);
|
|
||||||
result.to_string()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registry of connected MCP servers and their tools for the current session.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct McpManager {
|
|
||||||
pub servers: Vec<McpServer>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adapts a single MCP-advertised tool to the crate's `Tool` trait so it can
|
|
||||||
/// be dispatched through the same execution path as built-in tools.
|
|
||||||
pub struct McpToolAdapter {
|
|
||||||
pub tool_name: String,
|
|
||||||
pub server_name: String,
|
|
||||||
pub transport: McpTransport,
|
|
||||||
pub description: String,
|
|
||||||
pub parameters: Value,
|
|
||||||
/// Shared handle to a persistent child process (stdio transport only).
|
|
||||||
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl crate::tool::Tool for McpToolAdapter {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
mcp_static_str(&format!("mcp__{}__{}", self.server_name, self.tool_name))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &'static str {
|
|
||||||
mcp_static_str(&self.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters(&self) -> Value {
|
|
||||||
self.parameters.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
|
||||||
match &self.transport {
|
|
||||||
McpTransport::Stdio { command, args: extra_args } => {
|
|
||||||
call_via_stdio(self.child_handle.as_ref().map(std::convert::AsRef::as_ref), command, extra_args, &self.tool_name, args)
|
|
||||||
}
|
|
||||||
McpTransport::StreamableHttp { url } => {
|
|
||||||
call_via_http(url, &self.tool_name, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpManager {
|
|
||||||
/// Create an empty manager with no connected servers.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
McpManager {
|
|
||||||
servers: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flatten all connected servers' tools into a single list of `Tool` trait objects.
|
|
||||||
///
|
|
||||||
/// Flow: for each server, clone its child handle → wrap each of its
|
|
||||||
/// `McpToolInfo` entries in an `McpToolAdapter` sharing that handle.
|
|
||||||
///
|
|
||||||
/// Why: the handle is cloned (Arc) per tool so every adapter for a given
|
|
||||||
/// stdio server reuses the same persistent child process/connection.
|
|
||||||
///
|
|
||||||
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
|
|
||||||
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
|
||||||
self.servers.iter().flat_map(|server| {
|
|
||||||
let handle = server.child_handle.clone();
|
|
||||||
server.tools.iter().map(move |info| {
|
|
||||||
let adapter: Box<dyn crate::tool::Tool> = Box::new(McpToolAdapter {
|
|
||||||
tool_name: info.name.clone(),
|
|
||||||
server_name: server.name.clone(),
|
|
||||||
transport: server.transport.clone(),
|
|
||||||
description: info.description.clone(),
|
|
||||||
parameters: info.input_schema.clone(),
|
|
||||||
child_handle: handle.clone(),
|
|
||||||
});
|
|
||||||
adapter
|
|
||||||
})
|
|
||||||
}).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connects to an MCP server via stdio by spawning the child process, running
|
|
||||||
/// the `initialize` handshake, calling `tools/list`, and registering the server
|
|
||||||
/// with its advertised tools in `self.servers`. The child process stays alive
|
|
||||||
/// for subsequent `tools/call` invocations via the stored `McpServer.tools`.
|
|
||||||
pub fn connect_stdio(&mut self, name: &str, command: &str, extra_args: &[String]) -> anyhow::Result<()> {
|
|
||||||
let transport = McpTransport::Stdio {
|
|
||||||
command: command.to_string(),
|
|
||||||
args: extra_args.to_vec(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut child = spawn_stdio_child(command, extra_args)?;
|
|
||||||
let result = child.call("tools/list", &json!({}))?;
|
|
||||||
|
|
||||||
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
|
|
||||||
tool_list.iter().filter_map(|t| {
|
|
||||||
Some(McpToolInfo {
|
|
||||||
name: t.get("name")?.as_str()?.to_string(),
|
|
||||||
description: t.get("description").and_then(|v| v.as_str()).unwrap_or_else(|| {
|
|
||||||
tracing::warn!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
|
||||||
""
|
|
||||||
}).to_string(),
|
|
||||||
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
|
||||||
tracing::warn!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
|
||||||
serde_json::Value::Null
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}).collect()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let handle = Arc::new(Mutex::new(child));
|
|
||||||
|
|
||||||
self.servers.push(McpServer {
|
|
||||||
name: name.to_string(),
|
|
||||||
transport,
|
|
||||||
tools,
|
|
||||||
child_handle: Some(handle),
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes a server by name. Returns `true` if a server was found and removed.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
|
||||||
let len = self.servers.len();
|
|
||||||
self.servers.retain(|s| s.name != name);
|
|
||||||
self.servers.len() < len
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
//! Model Context Protocol (MCP) client: connects to external MCP servers
|
|
||||||
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
|
|
||||||
|
|
||||||
pub mod manager;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//! Top-level application module: harness, modes, runtime loop, state,
|
|
||||||
//! workflows, subagents, review, background bash, MCP integration, and
|
|
||||||
//! native LSP client.
|
|
||||||
pub mod harness;
|
|
||||||
pub mod mode;
|
|
||||||
pub mod runtime;
|
|
||||||
pub mod state;
|
|
||||||
pub mod workflow;
|
|
||||||
pub mod subagent;
|
|
||||||
pub mod review;
|
|
||||||
pub mod bgbash;
|
|
||||||
pub mod mcp;
|
|
||||||
pub mod lsp;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
//! Bash mode: handles submitting a shell command from the bash input panel.
|
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
|
||||||
|
|
||||||
/// Launch a background bash job for the submitted command.
|
|
||||||
///
|
|
||||||
/// Flow: ignore empty input → spawn the job (fire-and-forget, the job's
|
|
||||||
/// output is polled elsewhere via `bgbash::control`) → mark state dirty
|
|
||||||
/// so the TUI re-renders.
|
|
||||||
///
|
|
||||||
/// Why: the returned `BashJob` handle is intentionally dropped — this
|
|
||||||
/// function only needs to kick the job off; the job registers itself in
|
|
||||||
/// the shared jobs map for later polling.
|
|
||||||
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
|
||||||
if !command.is_empty() {
|
|
||||||
let _ = crate::app::bgbash::job::spawn_bash_job(command);
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
|
|
||||||
//! with bounded undo history.
|
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
|
||||||
use crate::app::state::types::Overlay;
|
|
||||||
|
|
||||||
/// State for the built-in line editor overlay: buffer contents, cursor
|
|
||||||
/// position, and a bounded undo stack.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct EditorState {
|
|
||||||
pub path: String,
|
|
||||||
pub content: Vec<String>,
|
|
||||||
pub undo_stack: Vec<Vec<String>>,
|
|
||||||
pub cursor_line: usize,
|
|
||||||
pub cursor_col: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EditorState {
|
|
||||||
fn default() -> Self {
|
|
||||||
EditorState {
|
|
||||||
path: String::new(),
|
|
||||||
content: vec![String::new()],
|
|
||||||
undo_stack: Vec::new(),
|
|
||||||
cursor_line: 0,
|
|
||||||
cursor_col: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EditorState {
|
|
||||||
/// Create a fresh editor state for `path`, seeded with existing content
|
|
||||||
/// (or a single empty line for a new file).
|
|
||||||
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
|
|
||||||
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
|
|
||||||
EditorState {
|
|
||||||
path,
|
|
||||||
content,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a new empty line immediately after the cursor line.
|
|
||||||
///
|
|
||||||
/// Why: snapshots content to the undo stack first, matching every other
|
|
||||||
/// mutating method here.
|
|
||||||
pub fn insert_line_after(&mut self) {
|
|
||||||
self.save_undo();
|
|
||||||
let pos = (self.cursor_line + 1).min(self.content.len());
|
|
||||||
self.content.insert(pos, String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push a snapshot of the current content onto the undo stack, capped at 50 entries.
|
|
||||||
///
|
|
||||||
/// Why: `remove(0)` on overflow bounds memory use at the cost of O(n)
|
|
||||||
/// shifting; the cap (50) keeps that cost negligible in practice.
|
|
||||||
fn save_undo(&mut self) {
|
|
||||||
self.undo_stack.push(self.content.clone());
|
|
||||||
if self.undo_stack.len() > 50 {
|
|
||||||
self.undo_stack.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the cursor down one line, clamping the column to the new line's length.
|
|
||||||
pub fn cursor_down(&mut self) {
|
|
||||||
if self.cursor_line + 1 < self.content.len() {
|
|
||||||
self.cursor_line += 1;
|
|
||||||
}
|
|
||||||
self.cursor_col = self.cursor_col.min(
|
|
||||||
self.content.get(self.cursor_line).map_or(0, std::string::String::len),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a character at the cursor and advance the cursor past it.
|
|
||||||
pub fn insert_char(&mut self, c: char) {
|
|
||||||
self.save_undo();
|
|
||||||
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
|
||||||
line.insert(self.cursor_col, c);
|
|
||||||
self.cursor_col += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete the character before the cursor (backspace).
|
|
||||||
///
|
|
||||||
/// Flow: if not at column 0, remove the preceding char on this line →
|
|
||||||
/// otherwise (start of line, not the first line) merge this line into
|
|
||||||
/// the previous one, joining at the old line's end.
|
|
||||||
pub fn delete_left(&mut self) {
|
|
||||||
self.save_undo();
|
|
||||||
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
|
||||||
if self.cursor_col > 0 {
|
|
||||||
self.cursor_col -= 1;
|
|
||||||
line.remove(self.cursor_col);
|
|
||||||
} else if self.cursor_line > 0 {
|
|
||||||
let prev_len = self.content[self.cursor_line - 1].len();
|
|
||||||
let rest = self.content.remove(self.cursor_line);
|
|
||||||
self.cursor_line -= 1;
|
|
||||||
self.cursor_col = prev_len;
|
|
||||||
self.content[self.cursor_line].push_str(&rest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Join all lines with `\n` into the full file contents, for saving.
|
|
||||||
pub fn as_string(&self) -> String {
|
|
||||||
self.content.join("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a chunk of typed text into the active editor, translating newlines
|
|
||||||
/// and tabs into editor operations.
|
|
||||||
///
|
|
||||||
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
|
|
||||||
/// line and moves down, `\t` inserts two spaces, everything else inserts
|
|
||||||
/// the char directly → mark state dirty.
|
|
||||||
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
|
|
||||||
let editor = &mut state.misc.editor;
|
|
||||||
if editor.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let ed = editor.as_mut().unwrap();
|
|
||||||
for c in text.chars() {
|
|
||||||
match c {
|
|
||||||
'\n' | '\r' => {
|
|
||||||
ed.insert_line_after();
|
|
||||||
ed.cursor_down();
|
|
||||||
ed.cursor_col = 0;
|
|
||||||
}
|
|
||||||
'\t' => {
|
|
||||||
ed.insert_char(' ');
|
|
||||||
ed.insert_char(' ');
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
ed.insert_char(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close the editor overlay without saving, clearing editor state.
|
|
||||||
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
|
|
||||||
state.misc.editor = None;
|
|
||||||
state.misc.overlay = Overlay::None;
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
|
||||||
//! Effort mode: cycles the agent's reasoning effort level, which scales the
|
|
||||||
//! LLM's temperature and `max_tokens` for subsequent turns.
|
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
|
||||||
|
|
||||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
|
||||||
|
|
||||||
/// Multiplier applied to the user's configured `max_tokens`, and the temperature to use,
|
|
||||||
/// for each entry in `EFFORT_LEVELS` (same index). Higher effort trades a larger token
|
|
||||||
/// budget for lower temperature (more deterministic, more room to reason/act).
|
|
||||||
const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0];
|
|
||||||
const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1];
|
|
||||||
|
|
||||||
/// Maps an effort level index to the `(temperature, max_tokens)` pair that should be sent
|
|
||||||
/// to the LLM, scaling the user's configured `max_tokens` by the level's multiplier.
|
|
||||||
pub fn generation_params(level: usize, base_max_tokens: Option<u32>) -> (f32, Option<u32>) {
|
|
||||||
let idx = level.min(EFFORT_LEVELS.len() - 1);
|
|
||||||
let temperature = TEMPERATURE_OVERRIDE[idx];
|
|
||||||
let max_tokens = base_max_tokens.map(|t| ((t as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32);
|
|
||||||
(temperature, max_tokens.map(|t| t.max(256)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot.
|
|
||||||
///
|
|
||||||
/// Why: clamping guards against a stale/out-of-range value in loaded state
|
|
||||||
/// (e.g. after `EFFORT_LEVELS` shrinks between versions).
|
|
||||||
pub fn current_effort(state: &AppStateRest) -> usize {
|
|
||||||
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the current effort level's display name (e.g. "medium").
|
|
||||||
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
|
|
||||||
let idx = current_effort(state);
|
|
||||||
EFFORT_LEVELS[idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advance to the next effort level, wrapping around, and toast the new value.
|
|
||||||
///
|
|
||||||
/// Flow: compute `(current + 1) % len` → store it → push an info toast with
|
|
||||||
/// the new level's label → mark state dirty.
|
|
||||||
pub fn cycle_effort(state: &mut AppStateRest) {
|
|
||||||
let current = current_effort(state);
|
|
||||||
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
|
|
||||||
let label = current_effort_str(state);
|
|
||||||
state.push_toast(crate::app::state::types::Toast::new(
|
|
||||||
crate::app::state::types::ToastKind::Info,
|
|
||||||
format!("Effort: {label}"),
|
|
||||||
));
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
//! Help mode: static help text and the action that opens/closes the help overlay.
|
|
||||||
|
|
||||||
use crate::app::runtime::actions::Action;
|
|
||||||
use crate::app::state::types::Overlay;
|
|
||||||
|
|
||||||
pub const HELP_TEXT: &str = "\
|
|
||||||
Keybindings:
|
|
||||||
Ctrl+C Quit
|
|
||||||
Ctrl+D Close overlay
|
|
||||||
Ctrl+H Help
|
|
||||||
Ctrl+P Settings
|
|
||||||
Ctrl+A Toggle yolo arm
|
|
||||||
Ctrl+B Bash panel
|
|
||||||
Ctrl+T Todo panel
|
|
||||||
Ctrl+W Workflow panel
|
|
||||||
Ctrl+K Key input
|
|
||||||
Ctrl+L Learning dashboard
|
|
||||||
Ctrl+U Usage dashboard
|
|
||||||
Esc Close overlay
|
|
||||||
Enter Submit / confirm
|
|
||||||
|
|
||||||
Slash commands:
|
|
||||||
/help Show this help
|
|
||||||
/quit Quit session
|
|
||||||
/mode <name> Switch mode (chat, bash, workflow)
|
|
||||||
/lesson Interactive lesson manager
|
|
||||||
/clear Clear transcript";
|
|
||||||
|
|
||||||
/// Route an incoming action while the help overlay is open.
|
|
||||||
///
|
|
||||||
/// Flow: `CloseOverlay` passes through unchanged; any other action is
|
|
||||||
/// treated as "open help" (idempotent — re-opens the overlay it's already on).
|
|
||||||
///
|
|
||||||
/// Return: the `Action` to actually dispatch.
|
|
||||||
pub fn handle_help_action(action: &Action) -> Action {
|
|
||||||
match action {
|
|
||||||
Action::CloseOverlay => Action::CloseOverlay,
|
|
||||||
_ => Action::OpenOverlay(Overlay::Help),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
|
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
|
||||||
|
|
||||||
/// Replace the input buffer with the given text and mark state dirty.
|
|
||||||
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
|
|
||||||
state.input.buffer = text;
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
Loaded 50 of 302 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user