jeo-code 0.12.21 → 0.12.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -1
- package/README.ja.md +15 -2
- package/README.ko.md +15 -2
- package/README.md +15 -2
- package/README.zh.md +15 -2
- package/package.json +2 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/config-schema.ts +1 -1
- package/src/agent/engine.ts +3 -0
- package/src/agent/hooks.ts +282 -32
- package/src/agent/sandbox/hands-source.text +528 -528
- package/src/agent/state.ts +27 -6
- package/src/cli/runner.ts +1 -1
- package/src/commands/doctor.ts +106 -1
- package/src/commands/launch.ts +27 -2
- package/src/commands/update.ts +284 -42
- package/src/util/shared-global.ts +380 -0
- package/src/util/shared-install-report.ts +315 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,28 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.12.23] - 2026-11-06
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`pre-prompt` and `turn-end` hook events** - `pre-prompt` runs once per typed prompt before the model sees it (Claude `UserPromptSubmit`): stdout text, or JSON `hookSpecificOutput.additionalContext` / `additionalContext` / `context`, is appended to the prompt message as a `[pre-prompt hook context]` block; the prompt card, history title and turn-end payload keep the user's own words. `turn-end` runs once per finished turn (Claude `Stop`) with `prompt`, `response` (also `last_assistant_message` / `assistant_output`), `done`, `steps` and `model`. Both are advisory (non-zero exit, timeout or abort prints a notice, never blocks; default timeouts 10 s / 30 s), skipped for `$skill` runs and messenger-literal sessions, and an `agy/` model skips `pre-prompt` (the official CLI owns its prompt) but runs `turn-end`.
|
|
16
|
+
- **User hook registry `~/.jeo/hooks.json`** - a hooks-only file (same shape as `.jeo/hooks.json`, follows `JEO_CONFIG_DIR`) for tools that install hooks, such as jeo-skills' OpenSpace prompt routing (`route-hooks.py install --host jeo`) and knowledge-pipeline ingesters, kept apart from `config.json` so credentials are never rewritten. It runs without `hooks.enabled: true`, ahead of configured hooks, and survives a project `.jeo/hooks.json` replacing the `config.json` list. Sandboxed sessions skip it. Entries with an unknown event, a missing `run` or a bad `timeoutMs` are dropped instead of failing the whole batch.
|
|
17
|
+
- **Structured `pre-tool` decisions** - a `pre-tool` hook that exits 0 may print `{"hookSpecificOutput":{"permissionDecision":"deny"|"ask","permissionDecisionReason":"…"}}` (also `decision: "block"` or `continue: false`) to veto the call; `ask` counts as deny because jeo has no hook approval prompt. `additionalContext` is appended to that tool's result. Plain stdout stays ignored.
|
|
18
|
+
- **Claude-protocol payload fields** - every host-session hook payload now carries `hook_event_name`, `session_id` and `cwd`, and tool events add `tool_name` / `tool_input`, beside the existing `event` / `tool` / `args`, so Claude Code and Codex hook scripts run unmodified. Sandboxed hooks keep the previous payload and never see the host `cwd` or session id.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **`hooks.enabled` is now an explicit kill switch** - `"hooks": { "enabled": false }` in `config.json` disables every hook, registry included. Left unset it still disables `config.json` and project hooks (a repo cannot enable its own hooks) but no longer silences the user registry. A project `.jeo/hooks.json` with `"enabled": false` opts that project out of everything.
|
|
22
|
+
|
|
23
|
+
## [0.12.22] - 2026-11-04
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **`jeo update` never changes other packages in the shared bun global** - before running any installer it checks the shared bun global (`$BUN_INSTALL` or `~/.bun`, `install/global`): if its `package.json` lists dependencies other than `jeo-code` and `bun.lock` is missing or does not cover all of them, the update is refused with exit 1, no file is changed, and the uncovered dependencies are named (`--json`: `installed:false` and an `error` mentioning the lockfile). After the install it compares the installed version of every other top-level dependency with the pre-install state; any difference fails the update and prints `name old -> new` plus the exact `bun install -g name@old` commands (never run). A clean update prints `shared global unchanged: N packages (...)`. `--json` gains `externalChanges` and `preflight` only when a shared global with other packages was inspected.
|
|
27
|
+
- **`--allow-unlocked-global`** - skips only the lockfile refusal; the postflight comparison still runs.
|
|
28
|
+
- **The npm fallback no longer creates stale second copies** - `npm install -g` is a fallback only when the active `jeo` lies under npm's global root; for a bun-global or unknown install a failed update ends with manual commands instead.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **`jeo doctor` shared install section** (human and `--json` `sharedInstall`, read-only, warnings only): `bun.lock` status (missing, incomplete with the uncovered dependencies, or ok), every `jeo` on PATH in PATH order with its version and a stale flag, leftovers (`package.json.*backup*` files and possible orphan packages), and the five largest packages plus the total size within a time and entry cap.
|
|
32
|
+
- **`bun run verify:shared-global`** - replays both updaters against an isolated copy of the real global: `jeo update` leaves every other package untouched, `jeopi update` leaves `jeo-code` untouched, and losing `bun.lock` is refused without a single package-manager call.
|
|
33
|
+
|
|
12
34
|
## [0.12.21] - 2026-11-04
|
|
13
35
|
|
|
14
36
|
### Fixed
|
|
@@ -2724,7 +2746,9 @@ _Initial release._
|
|
|
2724
2746
|
### Added
|
|
2725
2747
|
- Initial jeo-code agent and CLI.
|
|
2726
2748
|
|
|
2727
|
-
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.
|
|
2749
|
+
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.23...HEAD
|
|
2750
|
+
[0.12.23]: https://github.com/akillness/jeo-code/releases/tag/v0.12.23
|
|
2751
|
+
[0.12.22]: https://github.com/akillness/jeo-code/compare/v0.12.21...v0.12.22
|
|
2728
2752
|
[0.12.21]: https://github.com/akillness/jeo-code/releases/tag/v0.12.21
|
|
2729
2753
|
[0.12.20]: https://github.com/akillness/jeo-code/releases/tag/v0.12.20
|
|
2730
2754
|
[0.12.19]: https://github.com/akillness/jeo-code/releases/tag/v0.12.19
|
package/README.ja.md
CHANGED
|
@@ -218,6 +218,19 @@ jeo ultragoal
|
|
|
218
218
|
|
|
219
219
|
非ゼロ終了したフックの出力はモデルが読むツール結果に付加され(バッチ内で重複排除)、フックが赤のまま `done` を呼ぶとフック名付きでプッシュバックされます。
|
|
220
220
|
|
|
221
|
+
### プロンプト・ターンフックとユーザーレジストリ
|
|
222
|
+
|
|
223
|
+
ツールループのイベントに加えて、ターンごとに 1 回発火するイベントが 2 つあります:
|
|
224
|
+
|
|
225
|
+
| イベント | タイミング | ペイロード(stdin JSON) | フック出力 |
|
|
226
|
+
| -------- | ---------- | ---------------------- | ---------- |
|
|
227
|
+
| `pre-prompt` | 入力したプロンプトをモデルが見る前 | `{event, hook_event_name: "UserPromptSubmit", session_id, cwd, prompt}` | stdout のテキスト、または JSON の `hookSpecificOutput.additionalContext` / `additionalContext` / `context` が `[pre-prompt hook context]` ブロックとしてプロンプトメッセージの末尾に付加されます(プロンプトカードと履歴タイトルにはユーザー自身の言葉だけが残ります) |
|
|
228
|
+
| `turn-end` | 最終返信の直後 | `{event, hook_event_name: "Stop", session_id, cwd, prompt, response, last_assistant_message, done, steps, model}` | 無視 |
|
|
229
|
+
|
|
230
|
+
どちらも助言(advisory)扱いです: 非ゼロ終了・タイムアウト・中断は通知を出すだけでプロンプトをブロックしません(既定タイムアウト 10 秒 / 30 秒)。`$skill` 実行とメッセンジャー literal セッションはスキップされ、`agy/` モデルでは公式 CLI がプロンプトを管理するため `pre-prompt` はスキップされ、`turn-end` は実行されます。`pre-tool` フックは exit 0 で `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}` を出力して拒否(`ask` は deny 扱い)したり、`additionalContext` を出力してそのツール結果に付加したりできます。通常の stdout は引き続き無視されます。すべてのペイロードに `hook_event_name`、`session_id`、`cwd`(ツールイベントは `tool_name`/`tool_input` も)が含まれるため、Claude Code / Codex のフックスクリプトをそのまま使えます — ホストセッションのみで、サンドボックスのフックは従来のペイロードを受け取ります。
|
|
231
|
+
|
|
232
|
+
**ユーザーレジストリ.** `~/.jeo/hooks.json`(`.jeo/hooks.json` と同形式、`JEO_CONFIG_DIR` に従う)は、jeo-skills のプロンプトルーティングやナレッジパイプラインのようにフックを代わりに設置するツール向けのフック専用ファイルで、認証情報が書き換えられないよう `config.json` から分離されています。優先順位: `config.json` の `"hooks": { "enabled": false }` はすべてを無効にし、プロジェクト `.jeo/hooks.json` の `"enabled": false` はそのプロジェクトだけを除外し、それ以外は `hooks.enabled: true` なしでレジストリが設定済みフックより先に実行されます。プロジェクト自身の `hooks` リストは `config.json` のリストだけを置き換えレジストリは置き換えず、プロジェクト / `config.json` のフックは従来どおり `hooks.enabled: true` のときだけ実行されます。サンドボックスセッションはレジストリをスキップします。
|
|
233
|
+
|
|
221
234
|
## メモリフロー
|
|
222
235
|
|
|
223
236
|
`jeo` は `.jeo/memory/` 配下に **ローカルファースト・蒸留されたプロジェクトメモリ** を保持します(リモートバックエンドなし、ネイティブ依存ゼロ)。過去のセッションは [OKF](docs/okf_mem/) コンセプトバンドルへ蒸留され、次のセッションは関連性の高い予算内のスライスだけをシステムプロンプトへ再注入します — 指示ではなく DATA として堅牢化されます。`JEO_NO_MEMORY=1` ですべて無効化。
|
|
@@ -447,10 +460,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
447
460
|
|
|
448
461
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
449
462
|
- **[Unreleased]**
|
|
463
|
+
- **[0.12.23]** (2026-11-06)
|
|
464
|
+
- **[0.12.22]** (2026-11-04)
|
|
450
465
|
- **[0.12.21]** (2026-11-04)
|
|
451
466
|
- **[0.12.20]** (2026-11-04)
|
|
452
|
-
- **[0.12.19]** (2026-11-04)
|
|
453
|
-
- **[0.12.18]** (2026-11-04)
|
|
454
467
|
|
|
455
468
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
456
469
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -272,6 +272,19 @@ jeo ultragoal # 5. 최종 증거 기반 종
|
|
|
272
272
|
|
|
273
273
|
비정상 종료한 훅의 출력은 모델이 읽는 도구 결과에 첨부되고(배치 내 중복 제거), 훅이 빨간 채로 `done`을 부르면 훅 이름과 함께 푸시백됩니다.
|
|
274
274
|
|
|
275
|
+
### 프롬프트·턴 훅과 사용자 레지스트리
|
|
276
|
+
|
|
277
|
+
도구 루프 이벤트 외에 턴마다 한 번씩 발생하는 이벤트가 둘 더 있습니다:
|
|
278
|
+
|
|
279
|
+
| 이벤트 | 시점 | 페이로드(stdin JSON) | 훅 출력 |
|
|
280
|
+
| ------ | ---- | -------------------- | ------- |
|
|
281
|
+
| `pre-prompt` | 입력한 프롬프트를 모델이 보기 전 | `{event, hook_event_name: "UserPromptSubmit", session_id, cwd, prompt}` | stdout 텍스트 또는 JSON `hookSpecificOutput.additionalContext` / `additionalContext` / `context`가 `[pre-prompt hook context]` 블록으로 프롬프트 메시지 뒤에 붙습니다(프롬프트 카드와 히스토리 제목에는 사용자가 쓴 말만 남음) |
|
|
282
|
+
| `turn-end` | 최종 답변 직후 | `{event, hook_event_name: "Stop", session_id, cwd, prompt, response, last_assistant_message, done, steps, model}` | 무시 |
|
|
283
|
+
|
|
284
|
+
둘 다 권고(advisory)용입니다: 비정상 종료·타임아웃·중단은 알림만 출력하고 프롬프트를 막지 않습니다(기본 타임아웃 10초 / 30초). `$skill` 실행과 메신저 literal 세션은 건너뛰며, `agy/` 모델은 공식 CLI가 프롬프트를 소유하므로 `pre-prompt`는 건너뛰고 `turn-end`는 실행됩니다. `pre-tool` 훅은 exit 0으로 `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}`를 출력해 거부(`ask`는 deny로 처리)하거나 `additionalContext`를 출력해 해당 도구 결과에 덧붙일 수 있으며, 일반 stdout은 계속 무시됩니다. 모든 페이로드에 `hook_event_name`, `session_id`, `cwd`(도구 이벤트는 `tool_name`/`tool_input` 추가)가 들어 있어 Claude Code / Codex 훅 스크립트를 수정 없이 쓸 수 있습니다 — 호스트 세션 한정이며 샌드박스 훅은 기존 페이로드를 받습니다.
|
|
285
|
+
|
|
286
|
+
**사용자 레지스트리.** `~/.jeo/hooks.json`(`.jeo/hooks.json`과 같은 형식, `JEO_CONFIG_DIR`을 따름)은 jeo-skills의 프롬프트 라우팅·지식 파이프라인처럼 훅을 대신 설치하는 도구를 위한 훅 전용 파일이며, 자격 증명이 다시 쓰이지 않도록 `config.json`과 분리되어 있습니다. 우선순위: `config.json`의 `"hooks": { "enabled": false }`는 모든 훅을 끄고, 프로젝트 `.jeo/hooks.json`의 `"enabled": false`는 그 프로젝트만 제외하며, 그 외에는 `hooks.enabled: true` 없이도 레지스트리가 설정된 훅보다 먼저 실행됩니다. 프로젝트 자체 `hooks` 목록은 `config.json` 목록만 대체하고 레지스트리는 대체하지 않으며, 프로젝트 / `config.json` 훅은 여전히 `hooks.enabled: true`일 때만 실행됩니다. 샌드박스 세션은 레지스트리를 건너뜁니다.
|
|
287
|
+
|
|
275
288
|
## 메모리 흐름
|
|
276
289
|
|
|
277
290
|
`jeo`는 `.jeo/memory/` 아래에 **로컬 우선·증류된 프로젝트 메모리**를 둡니다(원격 백엔드 없음, 네이티브 의존성 0). 지난 세션은 [OKF](docs/okf_mem/) 개념 번들로 증류되고, 다음 세션은 관련성 높은 예산 한도 내 일부만 시스템 프롬프트로 다시 주입합니다 — 지시가 아닌 DATA로 강화 처리됩니다. `JEO_NO_MEMORY=1`로 전체 비활성화.
|
|
@@ -502,10 +515,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
502
515
|
|
|
503
516
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
504
517
|
- **[Unreleased]**
|
|
518
|
+
- **[0.12.23]** (2026-11-06)
|
|
519
|
+
- **[0.12.22]** (2026-11-04)
|
|
505
520
|
- **[0.12.21]** (2026-11-04)
|
|
506
521
|
- **[0.12.20]** (2026-11-04)
|
|
507
|
-
- **[0.12.19]** (2026-11-04)
|
|
508
|
-
- **[0.12.18]** (2026-11-04)
|
|
509
522
|
|
|
510
523
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
511
524
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -275,6 +275,19 @@ Enable hooks once globally (`"hooks": { "enabled": true }` in `~/.jeo/config.jso
|
|
|
275
275
|
|
|
276
276
|
Non-zero hook output is appended to the tool result the model reads (deduped per batch); a still-red hook triggers a `done` pushback naming the hook.
|
|
277
277
|
|
|
278
|
+
### Prompt and turn hooks, and the user registry
|
|
279
|
+
|
|
280
|
+
Two more events fire once per turn, beside the tool-loop events:
|
|
281
|
+
|
|
282
|
+
| Event | When | Payload (stdin JSON) | Hook output |
|
|
283
|
+
| ----- | ---- | -------------------- | ----------- |
|
|
284
|
+
| `pre-prompt` | before the model sees a typed prompt | `{event, hook_event_name: "UserPromptSubmit", session_id, cwd, prompt}` | stdout text, or JSON `hookSpecificOutput.additionalContext` / `additionalContext` / `context`, is appended to the prompt message as a `[pre-prompt hook context]` block (the prompt card and history title keep your own words) |
|
|
285
|
+
| `turn-end` | after the final reply | `{event, hook_event_name: "Stop", session_id, cwd, prompt, response, last_assistant_message, done, steps, model}` | ignored |
|
|
286
|
+
|
|
287
|
+
Both are advisory: a non-zero exit, timeout or abort prints a notice and never blocks the prompt (default timeouts 10 s / 30 s). `$skill` runs and messenger-literal sessions skip them; `pre-prompt` is skipped for an `agy/` model (the official CLI owns its prompt) while `turn-end` still runs. A `pre-tool` hook may also exit 0 and print `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}` to veto (`ask` counts as deny) or `additionalContext` to append to that tool's result; plain stdout stays ignored. Every payload also carries `hook_event_name`, `session_id` and `cwd` (tool events add `tool_name`/`tool_input`), so Claude Code / Codex hook scripts run unmodified — host sessions only; sandboxed hooks get the legacy payload.
|
|
288
|
+
|
|
289
|
+
**User registry.** `~/.jeo/hooks.json` (same shape as `.jeo/hooks.json`; follows `JEO_CONFIG_DIR`) is a hooks-only file for tools that install hooks for you, such as jeo-skills' prompt routing and knowledge pipeline, kept apart from `config.json` so credentials are never rewritten. Precedence: `"hooks": { "enabled": false }` in `config.json` disables everything; a project `.jeo/hooks.json` with `"enabled": false` opts that project out; otherwise the registry runs, with no `hooks.enabled: true` needed, ahead of configured hooks. A project's own `hooks` list still replaces the `config.json` list, never the registry, and project / `config.json` hooks still run only with `hooks.enabled: true`. Sandboxed sessions skip the registry.
|
|
290
|
+
|
|
278
291
|
## Memory flow
|
|
279
292
|
|
|
280
293
|
`jeo` keeps a **local-first, distilled project memory** under `.jeo/memory/` (no remote backend, zero native deps). Past sessions are distilled into an [OKF](docs/okf_mem/) concept bundle, and the next session injects only the relevant, budget-bounded slice back into the system prompt — hardened as DATA, never as instructions. Disable everything with `JEO_NO_MEMORY=1`.
|
|
@@ -491,10 +504,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
491
504
|
|
|
492
505
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
493
506
|
- **[Unreleased]**
|
|
507
|
+
- **[0.12.23]** (2026-11-06)
|
|
508
|
+
- **[0.12.22]** (2026-11-04)
|
|
494
509
|
- **[0.12.21]** (2026-11-04)
|
|
495
510
|
- **[0.12.20]** (2026-11-04)
|
|
496
|
-
- **[0.12.19]** (2026-11-04)
|
|
497
|
-
- **[0.12.18]** (2026-11-04)
|
|
498
511
|
|
|
499
512
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
500
513
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -218,6 +218,19 @@ jeo ultragoal
|
|
|
218
218
|
|
|
219
219
|
非零退出钩子的输出会附加到模型读取的工具结果中(批内去重);钩子未通过就调用 `done` 会收到带钩子名称的回推。
|
|
220
220
|
|
|
221
|
+
### 提示与回合钩子,以及用户注册表
|
|
222
|
+
|
|
223
|
+
除工具循环事件外,每个回合还会触发两个事件:
|
|
224
|
+
|
|
225
|
+
| 事件 | 时机 | 载荷(stdin JSON) | 钩子输出 |
|
|
226
|
+
| ---- | ---- | ---------------- | -------- |
|
|
227
|
+
| `pre-prompt` | 模型看到所输入提示之前 | `{event, hook_event_name: "UserPromptSubmit", session_id, cwd, prompt}` | stdout 文本,或 JSON 的 `hookSpecificOutput.additionalContext` / `additionalContext` / `context`,会以 `[pre-prompt hook context]` 块的形式追加到提示消息末尾(提示卡片和历史标题只保留你自己的话) |
|
|
228
|
+
| `turn-end` | 最终回复之后 | `{event, hook_event_name: "Stop", session_id, cwd, prompt, response, last_assistant_message, done, steps, model}` | 忽略 |
|
|
229
|
+
|
|
230
|
+
两者都是建议性的:非零退出、超时或中止只会打印通知,绝不阻止提示(默认超时 10 秒 / 30 秒)。`$skill` 运行和消息机器人 literal 会话会跳过它们;`agy/` 模型因官方 CLI 自行管理提示而跳过 `pre-prompt`,但仍运行 `turn-end`。`pre-tool` 钩子也可以 exit 0 并输出 `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}` 来否决(`ask` 视为 deny),或输出 `additionalContext` 追加到该工具的结果;普通 stdout 仍被忽略。所有载荷还带有 `hook_event_name`、`session_id` 和 `cwd`(工具事件另有 `tool_name`/`tool_input`),因此 Claude Code / Codex 的钩子脚本无需修改即可运行 —— 仅限主机会话;沙箱内的钩子收到旧版载荷。
|
|
231
|
+
|
|
232
|
+
**用户注册表。** `~/.jeo/hooks.json`(与 `.jeo/hooks.json` 格式相同,遵循 `JEO_CONFIG_DIR`)是供代你安装钩子的工具(如 jeo-skills 的提示路由和知识管道)使用的仅含钩子的文件,与 `config.json` 分离,因此凭据不会被重写。优先级:`config.json` 中的 `"hooks": { "enabled": false }` 会禁用一切;项目 `.jeo/hooks.json` 中的 `"enabled": false` 仅让该项目退出;否则注册表无需 `hooks.enabled: true` 即可运行,并排在已配置钩子之前。项目自己的 `hooks` 列表仍然只替换 `config.json` 的列表,绝不替换注册表,且项目 / `config.json` 钩子仍然只在 `hooks.enabled: true` 时运行。沙箱会话会跳过注册表。
|
|
233
|
+
|
|
221
234
|
## 内存流程
|
|
222
235
|
|
|
223
236
|
`jeo` 在 `.jeo/memory/` 下保存 **本地优先、蒸馏后的项目内存**(无远程后端,零原生依赖)。过往会话被蒸馏为 [OKF](docs/okf_mem/) 概念包,下一次会话仅把相关的、受预算约束的切片重新注入系统提示 —— 作为 DATA 而非指令加固。用 `JEO_NO_MEMORY=1` 完全禁用。
|
|
@@ -445,10 +458,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
445
458
|
|
|
446
459
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
447
460
|
- **[Unreleased]**
|
|
461
|
+
- **[0.12.23]** (2026-11-06)
|
|
462
|
+
- **[0.12.22]** (2026-11-04)
|
|
448
463
|
- **[0.12.21]** (2026-11-04)
|
|
449
464
|
- **[0.12.20]** (2026-11-04)
|
|
450
|
-
- **[0.12.19]** (2026-11-04)
|
|
451
|
-
- **[0.12.18]** (2026-11-04)
|
|
452
465
|
|
|
453
466
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
454
467
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jeo-code",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.23",
|
|
4
4
|
"description": "Clean, highly optimized AI coding agent using spec-first loop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"verify:sandbox": "bun scripts/verify-sandbox-isolation.ts",
|
|
58
58
|
"verify:browser": "bun scripts/verify-browser.ts",
|
|
59
59
|
"verify:discord": "bun scripts/verify-discord-integration.ts",
|
|
60
|
+
"verify:shared-global": "bun scripts/verify-shared-global-isolation.ts",
|
|
60
61
|
"verify:tmux": "bash scripts/tmux-verify.sh",
|
|
61
62
|
"dev:link": "bun scripts/dev-link.ts link",
|
|
62
63
|
"dev:doctor": "bun scripts/dev-link.ts doctor"
|
package/src/agent/AGENTS.md
CHANGED
|
@@ -26,7 +26,7 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
26
26
|
| `config-schema.ts` | Brief description of purpose |
|
|
27
27
|
| `context-files.ts` | Brief description of purpose |
|
|
28
28
|
| `engine.ts` | Brief description of purpose |
|
|
29
|
-
| `hooks.ts` |
|
|
29
|
+
| `hooks.ts` | Command-hook runtime: `loadHooks` (user registry `~/.jeo/hooks.json` ⊕ `config.json`/project `.jeo/hooks.json`, with the `hooks.enabled=false` kill switch and project `enabled:false` opt-out; the registry is skipped in sandboxed sessions), `runPreToolHooks` (veto by exit code or structured `permissionDecision`, `additionalContext` passthrough), `runPostTurnHooksForBatch`, `runPostImplementationHooks`, and the per-turn `runPrePromptHooks` (context injected by `commands/launch.ts` into the prompt message) / `runTurnEndHooks` (final reply to ingesters); payloads follow the Claude hook protocol on the host (`hook_event_name`, `session_id`, `cwd`, `tool_name`/`tool_input`) and stay legacy-shaped in a sandbox |
|
|
30
30
|
| `json.ts` | Brief description of purpose |
|
|
31
31
|
| `loop.ts` | The primary execution loop orchestrating model calls and tool execution |
|
|
32
32
|
| `loop-guards.ts` | Intermediate-judgment classification (gjc ultragoal-guard parity): named `GuardState` taxonomy, `GUARD_LIMITS` thresholds, and pure classifiers (`verifierCores`/`isVerificationSignal` — recognizes wrapped verifiers such as `cd x && bun test`, env/`timeout`/`uv run`/`rtk` prefixes and `2>&1`, never pipes or `;` — `repeatHint`, `classifyDoneGate` incl. `done_stale_verification`, and `formatRejectedCompletion`, which keeps the last candidate answer under an `UNVERIFIED` marker when corrections are exhausted) consumed by `engine.ts` |
|
|
@@ -45,7 +45,7 @@ const HookConfigSchema = z.object({
|
|
|
45
45
|
hooks: z
|
|
46
46
|
.array(
|
|
47
47
|
z.object({
|
|
48
|
-
event: z.enum(["pre-tool", "post-turn", "post-implementation"]),
|
|
48
|
+
event: z.enum(["pre-tool", "post-turn", "post-implementation", "pre-prompt", "turn-end"]),
|
|
49
49
|
match: z
|
|
50
50
|
.object({
|
|
51
51
|
tool: z.string().optional(),
|
package/src/agent/engine.ts
CHANGED
|
@@ -1411,6 +1411,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1411
1411
|
// an unchanged recently-modified file is hashed on both sides or neither.
|
|
1412
1412
|
mutationMayHaveOccurred = before.ok ? workspaceSnapshotChanged(before, await takeSnapshot(before.startedAtMs)) : true;
|
|
1413
1413
|
}
|
|
1414
|
+
// A pre-tool hook's structured `additionalContext` rides along with the result
|
|
1415
|
+
// so the model sees it next to the output it concerns.
|
|
1416
|
+
if (preHookResult.context) output = `${output}\n\n[pre-tool hook context]\n${preHookResult.context}`;
|
|
1414
1417
|
}
|
|
1415
1418
|
}
|
|
1416
1419
|
}
|
package/src/agent/hooks.ts
CHANGED
|
@@ -22,8 +22,8 @@ export function withSandboxExecution<T>(scope: SandboxExecutionScope, run: () =>
|
|
|
22
22
|
import * as fs from "node:fs/promises";
|
|
23
23
|
import * as path from "node:path";
|
|
24
24
|
import { resolvePosixTool } from "../util/posix-tools";
|
|
25
|
-
import { readGlobalConfig } from "./state";
|
|
26
|
-
import type { HookConfig } from "./state";
|
|
25
|
+
import { readGlobalConfig, userHooksPath } from "./state";
|
|
26
|
+
import type { HookConfig, HookEntry, HookEvent } from "./state";
|
|
27
27
|
|
|
28
28
|
interface HookRunResult {
|
|
29
29
|
stdout: string;
|
|
@@ -57,6 +57,23 @@ type LocalHooks =
|
|
|
57
57
|
const localHooksCache = new Map<string, { mtimeMs: number; size: number; result: LocalHooks }>();
|
|
58
58
|
const LOCAL_HOOKS_CACHE_CAP = 32;
|
|
59
59
|
|
|
60
|
+
const HOOK_EVENTS: ReadonlySet<string> = new Set<HookEvent>(["pre-tool", "post-turn", "post-implementation", "pre-prompt", "turn-end"]);
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Hook files are written by installers as well as by hand: drop entries that cannot run
|
|
67
|
+
* (unknown event, missing command, bad timeout) instead of letting one poison the batch. */
|
|
68
|
+
function isHookEntry(value: unknown): value is HookEntry {
|
|
69
|
+
if (!isRecord(value)) return false;
|
|
70
|
+
if (typeof value.event !== "string" || !HOOK_EVENTS.has(value.event)) return false;
|
|
71
|
+
if (typeof value.run !== "string" || !value.run.trim()) return false;
|
|
72
|
+
if (value.timeoutMs !== undefined && !(typeof value.timeoutMs === "number" && value.timeoutMs > 0)) return false;
|
|
73
|
+
if (value.match !== undefined && !isRecord(value.match)) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
61
78
|
let st: { mtimeMs: number; size: number };
|
|
62
79
|
try {
|
|
@@ -72,7 +89,7 @@ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
|
72
89
|
const parsed = JSON.parse(await fs.readFile(localPath, "utf-8"));
|
|
73
90
|
if (parsed && typeof parsed === "object") {
|
|
74
91
|
if (parsed.enabled === false) result = { kind: "disabled" };
|
|
75
|
-
else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks };
|
|
92
|
+
else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks.filter(isHookEntry) };
|
|
76
93
|
}
|
|
77
94
|
} catch {
|
|
78
95
|
// Missing/invalid local file → fall back to the global config hooks.
|
|
@@ -85,35 +102,66 @@ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
|
85
102
|
return result;
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
/**
|
|
106
|
+
* A sandboxed session reads the project hook file through the supervisor so the host
|
|
107
|
+
* never trusts workspace content it did not mediate. Same outcome shape as
|
|
108
|
+
* `readLocalHooks`.
|
|
109
|
+
*/
|
|
110
|
+
async function readSandboxLocalHooks(sandbox: SandboxExecutionScope, cwd: string, signal?: AbortSignal): Promise<LocalHooks> {
|
|
111
|
+
const result = await sandbox.supervisor.exec(["bun", "-e", "const file=Bun.file('.jeo/hooks.json');let value={};try{if(await file.exists())value=await file.json()}catch{}process.stdout.write(JSON.stringify(value));"], { cwd, signal: signal ?? sandbox.signal });
|
|
112
|
+
if (!result.success || result.exitCode !== 0) throw new Error(`Sandbox project hook configuration read failed (exit code ${result.exitCode ?? "unavailable"}): ${result.error || result.output}`);
|
|
113
|
+
let local: unknown;
|
|
114
|
+
try { local = JSON.parse(result.stdout ?? result.output); }
|
|
115
|
+
catch { throw new Error("Malformed sandbox project hook configuration"); }
|
|
116
|
+
if (isRecord(local)) {
|
|
117
|
+
if (local.enabled === false) return { kind: "disabled" };
|
|
118
|
+
if (Array.isArray(local.hooks)) return { kind: "hooks", hooks: local.hooks.filter(isHookEntry) };
|
|
92
119
|
}
|
|
120
|
+
return { kind: "fallback" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Effective hook list, in run order: user registry (`userHooksPath()`) first, then the
|
|
125
|
+
* configured list.
|
|
126
|
+
*
|
|
127
|
+
* Precedence, strongest first:
|
|
128
|
+
* 1. `config.hooks.enabled === false` — global kill switch; nothing runs.
|
|
129
|
+
* 2. Project `.jeo/hooks.json` with `enabled: false` — project opt-out; nothing runs.
|
|
130
|
+
* 3. The user registry runs unless it opts itself out (`enabled: false`). It is
|
|
131
|
+
* user-owned and installer-managed, so it does NOT need `config.hooks.enabled: true`.
|
|
132
|
+
* 4. `config.hooks.hooks` / the project `.jeo/hooks.json` list run only when
|
|
133
|
+
* `config.hooks.enabled === true` (so an untrusted repo cannot enable its own hooks);
|
|
134
|
+
* the project list replaces the config list, as before, but never the user registry.
|
|
135
|
+
*
|
|
136
|
+
* Sandboxed sessions skip the user registry: its commands live on the host and cannot
|
|
137
|
+
* run inside the sandbox.
|
|
138
|
+
*/
|
|
139
|
+
export async function loadHooks(cwd: string, signal?: AbortSignal): Promise<HookEntry[]> {
|
|
140
|
+
const config = await readGlobalConfig();
|
|
141
|
+
if (config.hooks?.enabled === false) return [];
|
|
93
142
|
|
|
94
143
|
const sandbox = currentSandboxExecution();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const local = await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
|
|
144
|
+
const configHooksOn = config.hooks?.enabled === true;
|
|
145
|
+
// Sandboxed sessions read the project file only when hooks are explicitly enabled, as before.
|
|
146
|
+
if (sandbox && !configHooksOn) return [];
|
|
147
|
+
// The registry lives on the host; a sandboxed session never runs it.
|
|
148
|
+
const user = sandbox ? undefined : await readLocalHooks(userHooksPath());
|
|
149
|
+
const userHooks = user?.kind === "hooks" ? user.hooks : [];
|
|
150
|
+
// Common case (no registry, hooks not enabled): nothing can run, so skip the project file.
|
|
151
|
+
if (!configHooksOn && userHooks.length === 0) return [];
|
|
152
|
+
|
|
153
|
+
const local = sandbox
|
|
154
|
+
? await readSandboxLocalHooks(sandbox, cwd, signal)
|
|
155
|
+
: await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
|
|
108
156
|
if (local.kind === "disabled") return [];
|
|
109
|
-
if (local.kind === "hooks") return local.hooks;
|
|
110
157
|
|
|
111
|
-
|
|
158
|
+
const configured = !configHooksOn ? [] : local.kind === "hooks" ? local.hooks : config.hooks?.hooks ?? [];
|
|
159
|
+
return userHooks.length ? [...userHooks, ...configured] : configured;
|
|
112
160
|
}
|
|
113
161
|
|
|
114
162
|
async function runHookCommand(
|
|
115
163
|
runCmd: string,
|
|
116
|
-
payload:
|
|
164
|
+
payload: unknown,
|
|
117
165
|
cwd: string,
|
|
118
166
|
timeoutMs = 30000,
|
|
119
167
|
signal?: AbortSignal
|
|
@@ -211,13 +259,79 @@ export function hookMatchesTool(matchTool: string | undefined, tool: string): bo
|
|
|
211
259
|
return matchTool.split("|").some(t => t.trim() === tool);
|
|
212
260
|
}
|
|
213
261
|
|
|
262
|
+
let hookSessionId = "";
|
|
263
|
+
|
|
264
|
+
/** Set by the launch loop so every hook payload carries the live session id
|
|
265
|
+
* (`session_id`, Claude hook protocol). Empty string when no session is active. */
|
|
266
|
+
export function setHookSessionId(sessionId: string | undefined): void {
|
|
267
|
+
hookSessionId = sessionId ?? "";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fields every hook payload carries. `event` is jeo's own name (kept for existing hooks);
|
|
272
|
+
* `hook_event_name`/`session_id`/`cwd` follow the Claude hook protocol so scripts written
|
|
273
|
+
* for Claude Code / Codex (`UserPromptSubmit`, `Stop`, `PreToolUse`) run unmodified.
|
|
274
|
+
*
|
|
275
|
+
* A sandboxed hook runs in the guest, so it gets the legacy payload only: never the host
|
|
276
|
+
* `cwd` or session id.
|
|
277
|
+
*/
|
|
278
|
+
function basePayload(event: HookEvent, claudeEvent: string, cwd: string) {
|
|
279
|
+
if (currentSandboxExecution()) return { event };
|
|
280
|
+
return { event, hook_event_name: claudeEvent, session_id: hookSessionId, cwd };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Claude-protocol aliases (`tool_name`/`tool_input`) for a tool call; none in a sandbox. */
|
|
284
|
+
function toolAliases(tool: string, args: Record<string, unknown>) {
|
|
285
|
+
return currentSandboxExecution() ? {} : { tool_name: tool, tool_input: args };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const MAX_HOOK_CONTEXT_CHARS = 8_000;
|
|
289
|
+
|
|
290
|
+
function parseHookJson(stdout: string): Record<string, unknown> | undefined {
|
|
291
|
+
const text = stdout.trim();
|
|
292
|
+
if (!text.startsWith("{")) return undefined;
|
|
293
|
+
try {
|
|
294
|
+
const parsed: unknown = JSON.parse(text);
|
|
295
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
296
|
+
} catch {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function clampContext(text: string): string {
|
|
302
|
+
return text.length > MAX_HOOK_CONTEXT_CHARS ? `${text.slice(0, MAX_HOOK_CONTEXT_CHARS)}\n... (hook context truncated)` : text;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** `additionalContext` from a Claude-protocol hook output (`hookSpecificOutput.additionalContext`,
|
|
306
|
+
* top-level `additionalContext`, or `context`). Empty when the output carries none. */
|
|
307
|
+
function jsonHookContext(json: Record<string, unknown>): string {
|
|
308
|
+
const specific = isRecord(json.hookSpecificOutput) ? json.hookSpecificOutput : {};
|
|
309
|
+
for (const value of [specific.additionalContext, json.additionalContext, json.context]) {
|
|
310
|
+
if (typeof value === "string" && value.trim()) return clampContext(value.trim());
|
|
311
|
+
}
|
|
312
|
+
return "";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** A deny decision a hook returned with exit 0 (Claude `permissionDecision: deny|ask`,
|
|
316
|
+
* legacy `decision: block`, or `continue: false`). jeo has no approval prompt for hooks,
|
|
317
|
+
* so `ask` is treated as deny — the safe reading. */
|
|
318
|
+
function jsonHookDenial(json: Record<string, unknown>): { reason: string } | undefined {
|
|
319
|
+
const specific = isRecord(json.hookSpecificOutput) ? json.hookSpecificOutput : {};
|
|
320
|
+
const decision = specific.permissionDecision ?? json.decision;
|
|
321
|
+
const denied = decision === "deny" || decision === "ask" || decision === "block" || json.continue === false;
|
|
322
|
+
if (!denied) return undefined;
|
|
323
|
+
const reason = [specific.permissionDecisionReason, json.reason, json.stopReason].find(v => typeof v === "string" && v.trim());
|
|
324
|
+
return { reason: typeof reason === "string" ? reason.trim() : "no reason given" };
|
|
325
|
+
}
|
|
326
|
+
|
|
214
327
|
export async function runPreToolHooks(
|
|
215
328
|
cwd: string,
|
|
216
329
|
tool: string,
|
|
217
|
-
args: Record<string,
|
|
330
|
+
args: Record<string, unknown>,
|
|
218
331
|
signal?: AbortSignal,
|
|
219
332
|
onNotice?: (msg: string) => void
|
|
220
|
-
): Promise<{ vetoed: boolean; error?: string; output?: string }> {
|
|
333
|
+
): Promise<{ vetoed: boolean; error?: string; output?: string; context?: string }> {
|
|
334
|
+
const contexts: string[] = [];
|
|
221
335
|
try {
|
|
222
336
|
const hooks = await loadHooks(cwd, signal);
|
|
223
337
|
const preToolHooks = hooks.filter(
|
|
@@ -226,9 +340,10 @@ export async function runPreToolHooks(
|
|
|
226
340
|
|
|
227
341
|
for (const hook of preToolHooks) {
|
|
228
342
|
const payload = {
|
|
229
|
-
|
|
343
|
+
...basePayload("pre-tool", "PreToolUse", cwd),
|
|
230
344
|
tool,
|
|
231
345
|
args,
|
|
346
|
+
...toolAliases(tool, args),
|
|
232
347
|
};
|
|
233
348
|
|
|
234
349
|
const timeoutMs = hook.timeoutMs || 30000;
|
|
@@ -248,14 +363,27 @@ export async function runPreToolHooks(
|
|
|
248
363
|
onNotice?.(msg);
|
|
249
364
|
return { vetoed: true, error: msg, output: result.output };
|
|
250
365
|
}
|
|
366
|
+
// Exit 0 can still carry a structured decision (Claude-protocol hooks never exit 2
|
|
367
|
+
// to deny; they print `permissionDecision`). Plain stdout stays ignored so a
|
|
368
|
+
// hook's own logging never leaks into the model's tool result.
|
|
369
|
+
const json = parseHookJson(result.stdout);
|
|
370
|
+
if (!json) continue;
|
|
371
|
+
const denial = jsonHookDenial(json);
|
|
372
|
+
if (denial) {
|
|
373
|
+
const msg = `Pre-tool hook "${hook.run}" denied execution: ${denial.reason}`;
|
|
374
|
+
onNotice?.(msg);
|
|
375
|
+
return { vetoed: true, error: msg };
|
|
376
|
+
}
|
|
377
|
+
const context = jsonHookContext(json);
|
|
378
|
+
if (context) contexts.push(context);
|
|
251
379
|
}
|
|
252
|
-
} catch (err:
|
|
253
|
-
const msg = `Error executing pre-tool hooks: ${err.message}`;
|
|
380
|
+
} catch (err: unknown) {
|
|
381
|
+
const msg = `Error executing pre-tool hooks: ${err instanceof Error ? err.message : String(err)}`;
|
|
254
382
|
onNotice?.(msg);
|
|
255
383
|
return { vetoed: true, error: msg, output: "" };
|
|
256
384
|
}
|
|
257
385
|
|
|
258
|
-
return { vetoed: false };
|
|
386
|
+
return contexts.length ? { vetoed: false, context: contexts.join("\n") } : { vetoed: false };
|
|
259
387
|
}
|
|
260
388
|
|
|
261
389
|
/** One executed tool call fed to the post-turn hooks of a batch. */
|
|
@@ -301,10 +429,11 @@ export async function runPostTurnHooksForBatch(
|
|
|
301
429
|
// Distinct hooks are independent commands → run them concurrently. Each hook
|
|
302
430
|
// itself runs once for the whole batch (the redundancy this fix removes).
|
|
303
431
|
const settled = await Promise.all(jobs.map(async ({ hook, matched }) => {
|
|
432
|
+
const base = basePayload("post-turn", "PostToolUse", cwd);
|
|
304
433
|
const payload = matched.length === 1
|
|
305
|
-
? {
|
|
434
|
+
? { ...base, tool: matched[0].tool, args: matched[0].args, ...toolAliases(matched[0].tool, matched[0].args), success: matched[0].success, output: outputPreviewOf(matched[0].output) }
|
|
306
435
|
: {
|
|
307
|
-
|
|
436
|
+
...base,
|
|
308
437
|
tool: matched.map(c => c.tool).join(","),
|
|
309
438
|
calls: matched.map(c => ({ tool: c.tool, args: c.args, success: c.success })),
|
|
310
439
|
success: matched.every(c => c.success),
|
|
@@ -368,7 +497,7 @@ export async function runPostImplementationHooks(
|
|
|
368
497
|
|
|
369
498
|
for (const hook of postImplHooks) {
|
|
370
499
|
const payload = {
|
|
371
|
-
|
|
500
|
+
...basePayload("post-implementation", "post-implementation", cwd),
|
|
372
501
|
request,
|
|
373
502
|
};
|
|
374
503
|
|
|
@@ -389,3 +518,124 @@ export async function runPostImplementationHooks(
|
|
|
389
518
|
return { success: false, output: msg };
|
|
390
519
|
}
|
|
391
520
|
}
|
|
521
|
+
|
|
522
|
+
const PRE_PROMPT_TIMEOUT_MS = 10_000;
|
|
523
|
+
const TURN_END_TIMEOUT_MS = 30_000;
|
|
524
|
+
const MAX_PROMPT_PAYLOAD_CHARS = 20_000;
|
|
525
|
+
const MAX_RESPONSE_PAYLOAD_CHARS = 200_000;
|
|
526
|
+
|
|
527
|
+
export interface PrePromptHookResult {
|
|
528
|
+
/** Context the hooks asked to place in front of the model (empty when none). */
|
|
529
|
+
context: string;
|
|
530
|
+
/** Hooks that ran to completion (exit 0) — lets callers tell "nothing to add" from "no hook". */
|
|
531
|
+
ran: number;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Run `pre-prompt` hooks once for a real user prompt, before the model sees it
|
|
536
|
+
* (Claude `UserPromptSubmit`). Payload: `{event, hook_event_name:"UserPromptSubmit",
|
|
537
|
+
* session_id, cwd, prompt}`. A hook contributes context by printing either plain text or a
|
|
538
|
+
* Claude-protocol JSON object (`hookSpecificOutput.additionalContext` / `additionalContext`
|
|
539
|
+
* / `context`). This is a routing/enrichment point, never a gate: a hook that fails, times
|
|
540
|
+
* out or is aborted yields a notice and no context, so a broken hook can't block a prompt.
|
|
541
|
+
*/
|
|
542
|
+
export async function runPrePromptHooks(
|
|
543
|
+
cwd: string,
|
|
544
|
+
prompt: string,
|
|
545
|
+
signal?: AbortSignal,
|
|
546
|
+
onNotice?: (msg: string) => void,
|
|
547
|
+
): Promise<PrePromptHookResult> {
|
|
548
|
+
const contexts: string[] = [];
|
|
549
|
+
let ran = 0;
|
|
550
|
+
try {
|
|
551
|
+
const hooks = (await loadHooks(cwd, signal)).filter(h => h.event === "pre-prompt");
|
|
552
|
+
if (hooks.length === 0 || !prompt.trim()) return { context: "", ran: 0 };
|
|
553
|
+
const payload = { ...basePayload("pre-prompt", "UserPromptSubmit", cwd), prompt: prompt.slice(0, MAX_PROMPT_PAYLOAD_CHARS) };
|
|
554
|
+
const settled = await Promise.all(hooks.map(async hook => {
|
|
555
|
+
try {
|
|
556
|
+
return { hook, result: await runHookCommand(hook.run, payload, cwd, hook.timeoutMs || PRE_PROMPT_TIMEOUT_MS, signal) };
|
|
557
|
+
} catch (err: unknown) {
|
|
558
|
+
onNotice?.(`Pre-prompt hook "${hook.run}" failed to start (advisory): ${err instanceof Error ? err.message : String(err)}`);
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
}));
|
|
562
|
+
// Hook order, not completion order, decides how contexts are stacked.
|
|
563
|
+
for (const entry of settled) {
|
|
564
|
+
if (!entry) continue;
|
|
565
|
+
const { hook, result } = entry;
|
|
566
|
+
if (result.timedOut) onNotice?.(`Pre-prompt hook "${hook.run}" timed out (advisory).`);
|
|
567
|
+
else if (result.aborted) continue;
|
|
568
|
+
else if (result.exitCode !== 0) onNotice?.(`Pre-prompt hook "${hook.run}" exited with non-zero code ${result.exitCode} (advisory).`);
|
|
569
|
+
else {
|
|
570
|
+
ran++;
|
|
571
|
+
const json = parseHookJson(result.stdout);
|
|
572
|
+
const context = json ? jsonHookContext(json) : clampContext(result.stdout.trim());
|
|
573
|
+
if (context) contexts.push(context);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} catch (err: unknown) {
|
|
577
|
+
onNotice?.(`Error executing pre-prompt hooks (advisory): ${err instanceof Error ? err.message : String(err)}`);
|
|
578
|
+
}
|
|
579
|
+
return { context: contexts.join("\n\n"), ran };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export interface TurnEndInfo {
|
|
583
|
+
prompt: string;
|
|
584
|
+
/** The final reply text of the turn. */
|
|
585
|
+
response: string;
|
|
586
|
+
/** True when the model signalled completion (false for a step-limit/unverified stop). */
|
|
587
|
+
done: boolean;
|
|
588
|
+
steps: number;
|
|
589
|
+
model?: string;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Run `turn-end` hooks once per finished turn (Claude `Stop`). Payload carries the turn's
|
|
594
|
+
* final reply under `response` and `last_assistant_message` (and `assistant_output`) so
|
|
595
|
+
* scripts that ingest assistant output can read it directly, alongside `prompt`, `done`,
|
|
596
|
+
* `steps`, `model`, `session_id`, `cwd`. Advisory only: failures yield a notice and never
|
|
597
|
+
* change the turn's result. Awaited so a one-shot `jeo -p` does not exit before they finish.
|
|
598
|
+
*/
|
|
599
|
+
export async function runTurnEndHooks(
|
|
600
|
+
cwd: string,
|
|
601
|
+
info: TurnEndInfo,
|
|
602
|
+
signal?: AbortSignal,
|
|
603
|
+
onNotice?: (msg: string) => void,
|
|
604
|
+
): Promise<{ ran: number }> {
|
|
605
|
+
let ran = 0;
|
|
606
|
+
try {
|
|
607
|
+
const hooks = (await loadHooks(cwd, signal)).filter(h => h.event === "turn-end");
|
|
608
|
+
if (hooks.length === 0) return { ran: 0 };
|
|
609
|
+
const response = info.response.length > MAX_RESPONSE_PAYLOAD_CHARS ? info.response.slice(0, MAX_RESPONSE_PAYLOAD_CHARS) : info.response;
|
|
610
|
+
const payload = {
|
|
611
|
+
...basePayload("turn-end", "Stop", cwd),
|
|
612
|
+
prompt: info.prompt.slice(0, MAX_PROMPT_PAYLOAD_CHARS),
|
|
613
|
+
response,
|
|
614
|
+
last_assistant_message: response,
|
|
615
|
+
assistant_output: response,
|
|
616
|
+
done: info.done,
|
|
617
|
+
steps: info.steps,
|
|
618
|
+
model: info.model ?? "",
|
|
619
|
+
stop_hook_active: false,
|
|
620
|
+
};
|
|
621
|
+
const settled = await Promise.all(hooks.map(async hook => {
|
|
622
|
+
try {
|
|
623
|
+
return { hook, result: await runHookCommand(hook.run, payload, cwd, hook.timeoutMs || TURN_END_TIMEOUT_MS, signal) };
|
|
624
|
+
} catch (err: unknown) {
|
|
625
|
+
onNotice?.(`Turn-end hook "${hook.run}" failed to start (advisory): ${err instanceof Error ? err.message : String(err)}`);
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
}));
|
|
629
|
+
for (const entry of settled) {
|
|
630
|
+
if (!entry) continue;
|
|
631
|
+
const { hook, result } = entry;
|
|
632
|
+
if (result.timedOut) onNotice?.(`Turn-end hook "${hook.run}" timed out (advisory).`);
|
|
633
|
+
else if (result.aborted) continue;
|
|
634
|
+
else if (result.exitCode !== 0) onNotice?.(`Turn-end hook "${hook.run}" exited with non-zero code ${result.exitCode} (advisory).`);
|
|
635
|
+
else ran++;
|
|
636
|
+
}
|
|
637
|
+
} catch (err: unknown) {
|
|
638
|
+
onNotice?.(`Error executing turn-end hooks (advisory): ${err instanceof Error ? err.message : String(err)}`);
|
|
639
|
+
}
|
|
640
|
+
return { ran };
|
|
641
|
+
}
|