jeo-code 0.11.2 → 0.11.3

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/README.zh.md CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  ## 亮点
40
40
 
41
- - **多提供商、单一循环** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio,以及 20+ 个 OpenAI、Anthropic 兼容云服务(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai 等),统一在一个 JSON 工具循环中 — **还包括你自己注册的端点**: `jeo provider add --id my-proxy --base-url https://…`(或 `--preset litellm|vllm|sglang|azure-openai|…`)让 LiteLLM 代理、自托管 vLLM、企业内部 Anthropic 网关拥有各自的路由前缀、模型列表和凭据,而不是劫持内置的 `openai` 提供商。输入框内直接 OAuth 登录(`/provider login`),模型选择即刻持久化为默认值。Prompt routing 只会自动选择真实可用的凭据路径: Gemini OAuth 会走 provider-qualified 的 `antigravity/*` 代理模型集(Gemini 3.5 Flash 各档、Gemini 3.1 Pro、Claude Sonnet/Opus 4.6),绝不选择需要 `GEMINI_API_KEY` 的 public `google/gemini-*` 行。
41
+ - **多提供商、单一循环** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio,以及 20+ 个 OpenAI、Anthropic 兼容云服务(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai 等),统一在一个 JSON 工具循环中 — **还包括你自己注册的端点**: `jeo provider add --id my-proxy --base-url https://…`(或 `--preset litellm|vllm|sglang|azure-openai|…`)让 LiteLLM 代理、自托管 vLLM、企业内部 Anthropic 网关拥有各自的路由前缀、模型列表和凭据,而不是劫持内置的 `openai` 提供商。输入框内直接 OAuth 登录(`/provider login`),每次模型选择都会持久化为新的默认值,而 prompt routing 只会自动选择真实可用的凭据路径: Gemini OAuth 会走 provider-qualified 的 `antigravity/*` 代理模型集,**以账号当前实际提供的为准**(实时发现 — 例如 Gemini 3.6 Flash 各档、Gemini 3.1 Pro、Claude Sonnet/Opus 4.6;非对话模型与已弃用的 id 会被过滤掉,列表为空时也不会用静态目录顶替),绝不选择需要 `GEMINI_API_KEY` 的 public `google/gemini-*` 行;如果已配置的路由指向尚未就绪的提供商,jeo 会先切换到同层级的等价凭据模型,再退回默认模型。
42
42
  - **编辑完整性** — read 输出携带内容锚点(`42ab|`);带锚点的编辑会与当前文件校验、行移动时自动重映射、不匹配时连同最新内容一起拒绝 — 绝不污染文件。
43
43
  - **自我修正的验证循环** — 配置 post-edit 钩子(tsc / eslint / 测试),代理会*亲自读取*诊断并在循环内修复;钩子未通过时 `done` 会被阻断。
44
44
  - **没有表演的真实门禁** — `ralplan` 共识由真正读取仓库的 critic 子代理执行,`[OKAY]` 裁决被持久化且 `jeo approve` *强制要求*它;`ultragoal` 诚实报告(套件运行只是全局信号,绝不伪造逐条通过)。
@@ -49,7 +49,8 @@
49
49
  - **持续积累的技能** — 卡壳的一轮现在会把死胡同写入同一个技能的项目级文件(`.jeo/skills/<name>.md`,首次写入时从内置技能播种,采用确定性关键词匹配,不使用 LLM),因此下一个会话的 `$<skill>` 调用会带上累积的"Known Failure Modes"/"Anti-Patterns"知识,而不是让内置文档永远保持静态。手动记录用 `jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"`;`jeo skills eval <skill>` 会运行一次真正的 LLM 判断,检查每条已记录的经验是否仍被技能当前的指引覆盖,还是已经过时。
50
50
  - **低价位评分模型路由** — `/goal` 验证器、`critic` 子代理角色,以及未锁定的 `task` 扇出批次,现在默认使用低价位、已配置凭证的模型,而不是悄悄搭乘与它们所评分/执行的工作相同的全价模型(`resolveVerifierModel`,针对浏览器 `verify` 动作按视觉能力过滤,确保纯文本的低价模型不会悄悄丢弃附带的截图)。
51
51
  - **`jeo routine init`** — 生成一个 GitHub Actions 工作流,在 schedule/issue/PR 触发器上以无头模式运行 jeo(`jeo "<prompt>" -p`),运行在 GitHub 自己的 runner 上 — 不需要笔记本电脑,并且不会给 jeo 自身增加任何新的攻击面(没有进程内调度器或 webhook 监听器)。`--dry-run` 可预览,`--no-pr` 则改为直接提交而非默认的每次运行一个 PR。
52
- - **远程子代理可见性(Telegram)** — 配对一次机器人(`jeo notify setup`)后,`jeo daemon start` 会在子代理每次状态变化(启动 → 完成/失败/取消)时推送消息,并接受 `/subagents`、`/steer <id> <subagentId> <msg>`、`/cancel <id> <subagentId>` 回传。现在提供 Telegram 论坛主题、内联键盘、图片附件等完整 `gjc` parity;命令仅对配对的聊天授权。
52
+ - **远程监控与控制(Telegram、Discord、Slack)** — 配对一次机器人(`jeo notify setup --provider telegram|discord|slack`)后,一个共享的 `jeo daemon start` 会在所有活跃会话中、子代理每次状态变化(启动 → 完成/失败/取消)时推送消息,并接受来自人类 allowlist 的 `/subagents`、`/steer`、`/cancel`(Discord/Slack 上还有 `/sessions` 和 `/send`)回传。Telegram 额外提供论坛主题、内联键盘和图片附件;Discord 使用 REST + Gateway WebSocket;Slack 使用 Web API + Socket Mode。每一条远程控制都由本地会话确认收到,绝不假定已执行。
53
+ - **无法绕过的门禁** — `approve` 会重新校验它正在批准的那份计划的精确摘要,`team` 会拒绝已审阅摘要发生变化的计划;`done` 调用会对照最新的验证证据重新检查(后来的失败检查会使先前的通过失效,三次纠正往返后硬性拒绝);`autopilot` ratchet 会把失败的回滚记录为 `rollback_failed` 并以非零状态停止,而不是声称该步骤已回退。
53
54
  - **不阻塞回合的会话级异步执行** — 通过 `task` 工具真实的 `tasks` 数组扇出独立工作,不阻塞父回合。detached 子代理、后台 job 和逐行 monitor 可在后续回合中继续使用 `subagent`/`job`/`monitor` 的 `list`、`inspect`、`await`、`cancel`、`tail` 控制。内联 TUI 为每个 worker 保留独立的实时 activity 槽位,并在会话退出或 Ctrl-C 时清理全部 registry。
54
55
  - **独立验证器,真正强制执行** — 计划再也无法跳过 architect/critic 步骤: `PlanSchema` 会拒绝任何以未验证变更结尾的计划(把验证器放在它该检查的变更之前也不算数),在 `ralplan` 起草阶段和 `team`/`approve` 执行阶段都强制生效。每一次 architect/critic 裁决也都必须展示真实证据 — 观测到的 `read`/`search`/`find`/`ast_grep`/`lsp` 调用为零,无论文本怎么声称,裁决都会被拦下。
55
56
  - **安全边界自动模型回退** — 未分类的安全拒绝(可能是分类器误报,而非真实的内容策略命中)现在会切换到真正不同提供商的模型,而不是在同一个模型上无限退让 — 与现有的 rate-limit 快速回退是同一套模式。`Refusal (<category>)` 这种形态的确定性拒绝不受影响,仍然零回退硬失败。
@@ -85,27 +86,45 @@ jeo --tmux # 在独立 tmux 会话中运行
85
86
 
86
87
  | 命令 | 说明 |
87
88
  | --- | --- |
88
- | `/model` · `/provider` | 选择模型/提供商;`/model` 在一个流程内显示默认/角色徽章、Ralph 风格嵌套角色·thinking 选择与 OpenAI Codex 角色预设 |
89
+ | `/model` · `/provider` | 选择模型/提供商;`/model` 在一个流程内显示默认/角色徽章、Ralph 风格嵌套的"设为角色"thinking 选择与 OpenAI Codex 角色预设 |
89
90
  | `/provider login <name>` · `/logout` | 在输入框内 OAuth 登录/登出 |
90
91
  | `/provider add` · `list` · `remove` · `presets` | 将 OpenAI/Anthropic 兼容端点注册为一等提供商(15 个网关预设) |
91
92
  | `/agents [role]` · `/subagent` | 按角色(executor/planner/architect/critic)配置模型·thinking·步数 |
92
93
  | `/thinking [level]` | 查看/设置默认推理预算(low…xhigh) |
93
- | `/route [status\|on\|off\|why\|history [n]]` | 切换本会话的基于提示词的模型路由 · 解释最近一次路由决策 · `history [n]` 列出本会话最近 n 条(默认 10 条)路由决策(仅在已配置凭证 — OAuth 或 API 密钥 — 实际可用的模型内自动路由) |
94
+ | `/route [status\|on\|off\|why\|history [n]]` | 切换本会话基于提示词的模型路由 · 解释最近一次路由决策 · `history [n]` 列出本会话最近 n 条(默认 10 条)路由决策(每轮自动路由到与层级匹配的模型,且只在你已配置的凭证 — OAuth 或 API 密钥 — 实际可用的模型中选择;已配置的路由未就绪时会切换到同层级的等价模型) |
94
95
  | `/fast [on\|off\|status]` | 当前模型支持 low 推理时切换 fast thinking 模式 |
95
96
  | `/skill` · `$<skill> [intent]` | 列出/运行工作流技能(`$team "任务"` 风格) |
96
97
  | `/view` · `/diff` · `/find` · `/search` | 代码查看、git diff、文件/模式搜索 |
97
- || `/new` · `/sessions` | 开始新会话或列出已保存会话 |
98
- || `/resume [id|gajae:<session-id>[#<leaf>]] [--any-cwd]` | 恢复 Jeo 会话,或将只读的精确版本 GJC v5 分支导入新的 Jeo 会话 |
99
- || `/changelog [--full]` · `/jobs [list|tail|await|cancel]` | 显示发布说明 · 查看、等待或取消当前会话的后台任务 |
98
+ | `/computer [status\|on\|off]` | 切换本会话的 fail-closed 桌面自动化工具 |
99
+ | `/new` · `/sessions` | 开始新会话或列出已保存会话 |
100
+ | `/resume [id\|gajae:<session-id>[#<leaf>]] [--any-cwd]` | 恢复 Jeo 会话,或将只读的精确版本 GJC v5 分支导入新的 Jeo 会话 |
101
+ | `/changelog [--full]` · `/jobs [list\|tail\|await\|cancel]` | 显示发布说明 · 查看、等待或取消当前会话的后台任务 |
100
102
  | `/history [n\|all]` · `/export` | 将可读的工作活动历史重新输出到滚动区 · 导出记录 |
101
- | `/retry` · `/btw <问题>` | 重试上次请求 · 不写入历史的旁路提问 |
103
+ | `/retry` · `/btw <q>` | 重试上次请求 · 不写入历史的旁路提问 |
102
104
  | `/usage` · `/context` · `/compact` | Token 用量、上下文明细、手动压缩 |
103
105
  | `/theme` · `/config` · `/help` | 主题、运行时配置、帮助 |
104
- | `jeo autopilot status` | 显示分数方向、keep/revert 次数和下一步动作的 ratchet 状态字段 |
105
106
 
106
107
  > [!CAUTION]
107
108
  > **通过 `/model <name>` 手动指定模型后,路由会在本会话内保持锁定。** Prompt routing(`/route`)只在没有手动锁定模型时才会逐轮重新评估。用 `/model <name>` 选定具体模型后,该选择会被锁定 — 直到你运行 `/model auto`(彻底解除锁定),或运行 `/route on`(不会清除锁定,只是优先级更高 — 一旦运行 `/route off`,锁定会立刻恢复)之前,路由都不会再切换。未配置 `roles.*` 条目时,只有 `standard` 层级会确定性地退回到 `defaultModel`;`high`/`complex` 层级通常会先实时扫描已配置凭证中最强的可用模型再决定是否回退,所以即使未配置,也可能每轮落到不同的模型上。**例外:** 通过 Antigravity 或 Gemini OAuth 授权的会话会用同一个凭证重新导出 Anthropic/Google/OpenAI 的模型 —此时 `high`/`complex` 会按公司各选一个模型、以会话为单位稳定分布(不一定是最强的那个),因此在同一会话内保持固定,而不是逐轮变化。
108
109
 
110
+ ## CLI 命令
111
+
112
+ `jeo --help` 是权威列表;下面是你最常用到的:
113
+
114
+ | 命令 | 用途 |
115
+ | --- | --- |
116
+ | `jeo [prompt] [--resume [id]] [--tmux] [--worktree <path>] [-p] [-q]` | 交互式代理(默认);`-p`/`--print` 用于无头单次输出,`--worktree` 用于隔离的同级检出 |
117
+ | `jeo setup` · `jeo auth login\|logout\|refresh\|status [provider]` · `jeo doctor` | 提供商、自动刷新的 OAuth(PKCE)令牌、实时连通性 + 过期模型锁定检查 |
118
+ | `jeo provider list\|add\|remove\|presets\|test` | 具名的自定义 OpenAI/Anthropic 兼容提供商(与 `/provider` 共用同一套 planner) |
119
+ | `jeo deep-interview` → `ralplan` → `approve` → `team` → `ultragoal` | Spec-first 工作流(见下文);`jeo state <skill> read\|write\|clear\|handoff` 用于检查回执 |
120
+ | `jeo notify setup\|status\|health\|test` · `jeo daemon start\|stop\|status\|reload` | Telegram / Discord / Slack 通知与共享控制守护进程 |
121
+ | `jeo autopilot <subcommand>` · `jeo ledger <subcommand>` | 带分数 ratchet 的自主构建循环(`status` 显示方向、keep/revert 次数和下一步动作) · 跨计划的只追加账本 |
122
+ | `jeo routine init …` | 生成一个 GitHub Actions 工作流,按计划或仓库事件以无头模式运行 jeo |
123
+ | `jeo skills list\|read\|sync\|lesson\|eval` | 内置、用户和项目技能;漂移检查;已记录的经验 |
124
+ | `jeo mcp serve\|tools` · `jeo computer <action>` | 供外部控制器使用的 MCP stdio 服务器 · 桌面自动化动作 |
125
+ | `jeo session list\|attach\|rm` · `jeo export [id]` · `jeo chat "<msg>"` | tmux 会话管理 · 导出记录 · 无工具的流式聊天 |
126
+ | `jeo update [--check]` · `jeo whats-new` · `jeo memory-migrate` | 从 npm 自更新 · 内置发布说明 · 旧版 `MEMORY.md` → OKF 概念包 |
127
+
109
128
  ## Spec-first 工作流
110
129
 
111
130
  需求 → 计划 → 批准 → 执行 → 验证,经由 `.jeo/state/` 串联,每次交接都有**可阻断的真实门禁**:
@@ -182,32 +201,45 @@ jeo ultragoal
182
201
 
183
202
  `--worktree <name>` 在隔离的同级 git worktree 中运行 jeo(路径存在则复用,否则以 basename 分支创建),因此有风险或需审查的工作绝不会触及您的主检出。`jeo mcp serve` 通过 stdio 向任何支持 MCP 的控制器公开 jeo 的工具(用 `jeo mcp tools` 列出)。添加 `-q`/`--quiet`(或 `JEO_QUIET=1`)可抑制启动横幅、欢迎动画、发布说明和恢复提示,使 jeo 能够与其他代理并行运行或由机器人驱动。`-p`/`--print` 隐含 quiet。
184
203
 
185
- ## 远程监控与控制 (Telegram)
204
+ ## 远程监控与控制 (Telegram, Discord & Slack)
205
+
206
+ 可选功能: 一个共享守护进程监视本机上所有活跃的 `jeo` 会话,并在每次**子代理状态变化**(启动 → 完成/失败/取消)时推送消息,另附会话身份头、回合开始/结束摘要以及最终的回合文本 — 绝不会重复"仍在运行"之类的通知。allowlist 中的人类可以在聊天里列出、发送、引导和取消;每一条控制都会转发给本地会话,并报告为**已确认(已接受,未必已完成)**或**未确认** — 守护进程绝不声称某条命令已执行。
186
207
 
187
208
  ```bash
188
- jeo notify setup # 配对一次 BotFather 机器人(getMe 校验 + chat-id 配对)
189
- jeo notify status # 已遮蔽的令牌、已配对 chat id、守护进程状态
190
- jeo daemon start # 启动单例后台守护进程
191
- jeo daemon status # 检查是否正在运行
192
- jeo daemon stop # 发送 SIGTERM 停止
209
+ jeo notify setup [--provider telegram|discord|slack] [--token-env NAME] [--app-token-env NAME] [--chat-id ID | --channel-id ID] [--allowed-user-ids ID,ID,...]
210
+ jeo notify status [--provider …] # masked token, destination, allowlist, daemon state (stopped / stale / initializing / initialized / pairing)
211
+ jeo notify health [--provider …] # read-only: bot identity + destination access, nothing sent
212
+ jeo notify test [--provider …] # sends one real test message
213
+ jeo daemon start|stop|status|reload # the shared daemon has no provider selector
193
214
  ```
194
215
 
216
+ | | Telegram(默认) | Discord | Slack |
217
+ | --- | --- | --- | --- |
218
+ | 凭证 | 机器人 token(`JEO_TELEGRAM_BOT_TOKEN` 或 `--token-env`) | 机器人 token(`JEO_DISCORD_BOT_TOKEN` 或 `--token-env`)+ **Message Content** intent | 机器人 `xoxb` token(`JEO_SLACK_BOT_TOKEN` 或 `--token-env`)+ 应用 `xapp` token(`SLACK_APP_TOKEN` 或 `--app-token-env`),并启用 Socket Mode |
219
+ | 目标 | 通过挑战配对的私聊(在 120 秒内把显示的 `/start jeo_<code>` 发给机器人)或显式 `--chat-id`;群组需要 `--allowed-user-ids` | 显式 `--channel-id`(文字频道、DM、公告频道或已有线程) | 机器人已加入的显式 `--channel-id`(`C…`);工作区固定为 token 所属的团队 |
220
+ | Allowlist | 私聊可选,群组必需 | 必需(人类用户 ID;机器人会被忽略) | 必需(人类用户 ID) |
221
+ | 传输 | 直接调用 Bot API `getUpdates` 长轮询,每个机器人 token 只有一个轮询所有者 | 发送走 REST,入站走 Gateway WebSocket | 发送走 Web API,入站走 Socket Mode WebSocket |
222
+ | 附加能力 | 论坛主题、内联取消按钮、图片附件、可选的每会话主题(`notifications.telegram.perSessionTopics`,仅限开启 Threaded Mode 的私聊) | 回复某条会话通知即可向该会话发送文本(24 小时内) | 在会话通知的线程中回复即可向该会话发送文本(24 小时内);已注册的 Slack 斜杠命令或 `@bot /command` 均可用 |
223
+ | 命令 | `/subagents` `/steer <session> <subagent> <msg>` `/cancel <session> <subagent>` `/help` | `/sessions` `/subagents` `/send <session> <text>` `/steer …` `/cancel …` `/help` | 与 Discord 相同 |
224
+
225
+ 这些词不可互换,所以说明一下它们各自的含义:
226
+
227
+ - **Setup** 会通过官方 API 验证机器人身份和目标访问权限(Telegram `getMe`,Discord 机器人 + 频道查询,Slack `auth.test`/`bots.info`/`conversations.info` 以及一个 Socket Mode URL),并把结果存入 `~/.jeo/config.json` 的 `notifications.<provider>` 下 — **明文**;请保持该文件私有。它不会发送消息,也不会打开 socket。
228
+ - **`initialized`**(`jeo daemon status`)表示守护进程已持有自己的锁并启动了各传输层;它并不证明 Telegram 轮询、Discord Gateway 握手或 Slack Socket Mode 连接已经成功。要验证这些请使用 `notify health` / `notify test`。
229
+ - **更改 token 或目标**会重置 Discord 和 Slack 的 allowlist(你必须再次传入 `--allowed-user-ids`)以及 Telegram 的每会话主题映射;用完全相同的凭证和目标重新运行 setup 则会保留它们。
230
+ - **远程命令是字面文本**(`/subagents` 等),仅对已配置的聊天/频道和 allowlist 授权;Slack 还额外接受同名的已注册斜杠命令。allowlist 中的操作者实际上拥有本地代理的全部能力 — 请把这份名单当作信任边界对待。
231
+ - **重试按提供商有界**: Telegram 遵守 429 的 `retry_after` 冷却(限制在 1 秒–1 小时之间)并使用独立的轮询退避;Discord 和 Slack 在 429 时按服务器的 `Retry-After` 最多重试 3 次,且绝不重发结果未知的 POST(平台可能已经收到了它)。
232
+ - **孤儿恢复**: 守护进程崩溃后,其锁会变为 `stale`;下一次 `jeo daemon start` 会回收它。当宿主机能够报告进程启动时间时,`jeo daemon stop` 会拒绝向已被复用的 PID 发送信号;无法报告时也会明确说明。
233
+ - **目前已验证**: Telegram 端到端(真实发送、轮询、干净关闭)。Discord 和 Slack 基于同一个守护进程实现,配有离线的传输/接线测试以及代码、安全和类型审查;尚未用真实的机器人/应用凭证进行实际连通性验证。
234
+
195
235
  ```
196
- ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
197
- │ interactive turn │◄──ws──►│ notify daemon │◄─poll──►│ Telegram bot │
198
- │ SubagentRegistry │ │ (singleton) │ │ (paired chat) │
199
- └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
236
+ ┌─────────────────────┐ ┌──────────────────┐ ┌───────────────────────────┐
237
+ │ interactive turn │◄──ws──►│ notify daemon │◄──────►│ Telegram Bot API (poll) │
238
+ │ SubagentRegistry │ (one │ (singleton) │ │ Discord REST + Gateway WS │
239
+ │ session endpoint │ per │ │ │ Slack Web API + Socket WS │
240
+ └─────────────────────┘ session└──────────────────┘ └───────────────────────────┘
200
241
  ```
201
242
 
202
- 默认关闭、延迟绑定:只有设置了 `notifications.enabled` 且真正运行了一个 detached 子代理(`task {detached:true}`)才会绑定。守护进程扫描存活的会话发现文件,为每个会话建立一条回环 WebSocket,并且只在子代理状态发生*变化*时(启动 → 完成/失败/取消)推送消息 — 绝不会重复推送"仍在运行"这类状态。收到的 Telegram 命令仅对已配对的聊天授权,其余一律静默丢弃。
203
-
204
- | 命令 | 效果 |
205
- | --- | --- |
206
- | `/subagents` | 列出所有已连接会话中正在运行/最近的子代理 |
207
- | `/steer <sessionId> <subagentId> <message>` | 向正在运行的子代理发送实时消息 |
208
- | `/cancel <sessionId> <subagentId>` | 取消正在运行的子代理 |
209
- | `/help` | 显示命令参考 |
210
-
211
243
  ## 例行任务 (GitHub Actions)
212
244
 
213
245
  ```bash
@@ -317,10 +349,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
317
349
 
318
350
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
319
351
  - **[Unreleased]**
352
+ - **[0.11.3]** (2026-10-01) — Remote control grew from one Telegram bot into one shared daemon serving Telegram, Discord, and Slack — and the workflow gates that were meant to block unverified work (`approve`/`team` plan identity, `done` re-checks, the autopilot ratchet) now actually block it.
320
353
  - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
321
354
  - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
322
355
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
323
- - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
324
356
 
325
357
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
326
358
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -104,11 +104,8 @@ export const ConfigSchema = z
104
104
  onAsk: z.boolean().optional(),
105
105
  })
106
106
  .optional(),
107
- /** Remote subagent visibility/control over Telegram (gjc Telegram-daemon
108
- * parity, scoped to subagents — see `src/agent/notify/`). `enabled` is the
109
- * master toggle; a session only starts publishing its loopback endpoint
110
- * when this is true, and `jeo notify-daemon-run` refuses to poll Telegram
111
- * without a stored botToken + chatId. */
107
+ /** Remote session visibility/control. `enabled` is the master toggle;
108
+ * each configured transport is hosted by the single notification daemon. */
112
109
  notifications: z
113
110
  .object({
114
111
  enabled: z.boolean().optional(),
@@ -123,18 +120,34 @@ export const ConfigSchema = z
123
120
  .object({
124
121
  botToken: z.string().optional(),
125
122
  chatId: z.string().optional(),
123
+ allowedUserIds: z.array(z.string().regex(/^[1-9]\d*$/)).optional(),
126
124
  /** Forum-topic thread id (message_thread_id) for supergroups with
127
125
  * topics enabled — daemon pushes go into this topic. Ignored for a
128
126
  * session-owned topic when `perSessionTopics` is true. */
129
127
  topicId: z.number().optional(),
130
128
  /** Auto-create and manage ONE forum topic per interactive session
131
129
  * (gjc per-session-thread parity), instead of the single flat/global
132
- * `topicId` above. Requires the paired chat to be a supergroup with
133
- * Topics enabled AND private (fail-closed — see `TopicRegistry`).
130
+ * `topicId` above. Requires a private chat with Threaded Mode
131
+ * enabled (fail-closed — see `TopicRegistry`).
134
132
  * Off by default; existing flat-topic/no-topic setups are unaffected. */
135
133
  perSessionTopics: z.boolean().optional(),
136
134
  })
137
135
  .optional(),
136
+ discord: z
137
+ .object({
138
+ botToken: z.string().optional(),
139
+ channelId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
140
+ allowedUserIds: z.array(z.string().regex(/^[1-9]\d{0,19}$/)).optional(),
141
+ })
142
+ .optional(),
143
+ slack: z
144
+ .object({
145
+ botToken: z.string().min(1),
146
+ appToken: z.string().min(1),
147
+ channelId: z.string().regex(/^[CGD][A-Z0-9]+$/),
148
+ allowedUserIds: z.array(z.string().regex(/^[UW][A-Z0-9]+$/)).min(1),
149
+ })
150
+ .optional(),
138
151
  })
139
152
  .optional(),
140
153
 
@@ -236,12 +236,16 @@ export class DebugSession {
236
236
  const decoder = new TextDecoder();
237
237
  let buf = "";
238
238
  const deadline = Date.now() + CONNECT_TIMEOUT_MS;
239
+ let pendingRead: ReturnType<typeof reader.read> | undefined;
239
240
  try {
240
241
  while (Date.now() < deadline) {
241
- const { value, done } = await Promise.race([
242
- reader.read(),
243
- new Promise<{ value: undefined; done: false }>((r) => setTimeout(() => r({ value: undefined, done: false }), 200)),
242
+ const result = await Promise.race([
243
+ pendingRead ??= reader.read(),
244
+ new Promise<undefined>((r) => setTimeout(() => r(undefined), 200)),
244
245
  ]);
246
+ if (!result) continue;
247
+ pendingRead = undefined;
248
+ const { value, done } = result;
245
249
  if (done) break;
246
250
  if (value) {
247
251
  const text = decoder.decode(value, { stream: true });
@@ -276,15 +276,12 @@ export interface AgentLoopEvents {
276
276
  /** Step-budget change (gjc-style retry flow): the limit was extended because the
277
277
  * turn is making progress. `limit` is the new max; `reason` is display-ready. */
278
278
  onBudget?(limit: number, reason: string): void;
279
- /** Consulted when a lone `done` arrives. Return a corrective message to bounce
280
- * the done ONCE (e.g. "todo list still shows unfinished items — update it
281
- * first"); return null to let the turn finish. The engine guarantees at most
282
- * one bounce per turn, so a stubborn model can never loop here. `evidence` is
283
- * the SAME mutation/verification signal the engine's own done-gate already
284
- * computed (see `classifyDoneGate` above) — a caller-owned gate (e.g. the goal
285
- * verifier) can use it to deterministically downgrade an LLM-judged MET verdict
286
- * when the turn mutated files without a fresh passing verification, instead of
287
- * trusting the transcript self-report alone. */
279
+ /** Consulted on every completion attempt, including salvaged prose. Return a
280
+ * corrective message to defer completion; null approves the caller-owned gate.
281
+ * Corrections are bounded by GUARD_LIMITS.MAX_DONE_CORRECTIONS, after which a
282
+ * rejected attempt stops incomplete. `evidence` is the SAME mutation/verification
283
+ * signal the engine's own done-gate uses, so callers can reject unsupported
284
+ * transcript-only success claims. */
288
285
  onBeforeDone?(
289
286
  reason: string,
290
287
  evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean }
@@ -583,10 +580,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
583
580
  // calls (bad edits, failing commands) would otherwise burn the whole step budget.
584
581
  const MAX_FAILURES = GUARD_LIMITS.MAX_FAILURES;
585
582
  let consecutiveFailures = 0;
586
- // done-verification guard (plan/gjc-inheritance.md B4, gjc ultragoal-guard 경량 계승):
587
- // a turn that MUTATED files but shows no verification signal gets ONE pushback on
588
- // `done` — run the relevant test/build, or call done again (the escape hatch for
589
- // doc/config changes where verification is genuinely not applicable).
583
+ // Completion requires fresh passing verification after a successful mutation.
590
584
  let sawMutation = false;
591
585
  let sawVerification = false;
592
586
  // Monotonic ordering of mutation vs. verification events so the done gate can
@@ -595,16 +589,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
595
589
  let mutVerSeq = 0;
596
590
  let lastMutationSeq = 0;
597
591
  let lastVerificationSeq = 0;
598
- let donePushbackUsed = false;
599
- // Abuse guard for the done-pushback escape hatch: the "second done always
600
- // passes" latch below is meant for turns where verification is genuinely
601
- // not applicable (docs/config-only changes) — not for a model that just
602
- // resends `done` immediately with zero intervening action. Reset to false
603
- // when the first pushback fires; any executed tool step (even read-only)
604
- // sets it back to true, so the escape hatch requires at least one attempt.
605
- let sawActionSincePushback = true;
606
- // Caller-owned done gate (onBeforeDone) — also strictly once per turn.
607
- let beforeDoneNudgeUsed = false;
592
+ let doneCorrections = 0;
608
593
  // F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
609
594
  // diagnostics the model saw but has not yet resolved (a later clean hook run
610
595
  // clears it). The done guard treats this as "verification missing" — the hook
@@ -981,6 +966,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
981
966
  if (sawUsage) ev.onUsage?.({ ...acc }, lastCallUsage ? { ...lastCallUsage } : undefined);
982
967
 
983
968
  let invocation: any;
969
+ let salvagedAnswer = false;
984
970
  try {
985
971
  const extracted = extractJsonObjectWithSpan<any>(responseText, { preferKeys: ["tool", "tools"] });
986
972
  invocation = extracted.value;
@@ -991,30 +977,27 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
991
977
  // would reinforce the corruption instead of self-correcting.
992
978
  responseText = extracted.matched;
993
979
  } catch (err) {
994
- ev.onAssistant?.(responseText, null);
995
- // Prose salvage: a reply with no JSON object at all is a chat-style final
996
- // answer, not a malformed tool call. Bouncing it back only made the model
997
- // apologize for the format — and that apology surfaced as the visible reply.
998
- // Same salvage after repeated bounces: the text we have IS the best answer.
980
+ // Preserve chat-style final answers, but subject them to the same completion
981
+ // gates as explicit done calls rather than treating prose as proof of success.
999
982
  const trimmed = responseText.trim();
1000
983
  parseFailures++;
1001
984
  if (trimmed && (!trimmed.includes("{") || parseFailures > MAX_PARSE_BOUNCES)) {
985
+ salvagedAnswer = true;
986
+ invocation = { tool: "done", arguments: { reason: stripLeakedReasoningTags(trimmed) || trimmed } };
987
+ } else {
988
+ ev.onAssistant?.(responseText, null);
1002
989
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1003
- // Strip leaked <think>/<parameter>/Harmony scaffolding some API-entered
1004
- // models emit inline so the salvaged answer doesn't surface raw tags.
1005
- return finish({ done: true, steps: step, doneReason: stripLeakedReasoningTags(trimmed) || trimmed });
990
+ history.push({
991
+ role: "user",
992
+ content:
993
+ `Your last reply was not a valid tool call (${(err as Error).message}). ` +
994
+ `Do NOT apologize or explain the formatting mistake. If that reply was your final answer, ` +
995
+ `resend it as {"tool":"done","arguments":{"reason":"<that answer, verbatim>"}}; ` +
996
+ `otherwise reply with exactly one JSON tool call: {"tool":"<name>","arguments":{...}}.`,
997
+ });
998
+ step++;
999
+ continue;
1006
1000
  }
1007
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1008
- history.push({
1009
- role: "user",
1010
- content:
1011
- `Your last reply was not a valid tool call (${(err as Error).message}). ` +
1012
- `Do NOT apologize or explain the formatting mistake. If that reply was your final answer, ` +
1013
- `resend it as {"tool":"done","arguments":{"reason":"<that answer, verbatim>"}}; ` +
1014
- `otherwise reply with exactly one JSON tool call: {"tool":"<name>","arguments":{...}}.`,
1015
- });
1016
- step++;
1017
- continue;
1018
1001
  }
1019
1002
  // A successfully parsed reply ends any bounce streak: MAX_PARSE_BOUNCES is a
1020
1003
  // CONSECUTIVE-failure salvage, not a cumulative one — without this reset a long
@@ -1066,64 +1049,27 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1066
1049
  toolCalls = toolCalls.slice(0, 6);
1067
1050
  }
1068
1051
 
1069
- ev.onAssistant?.(responseText, toolCalls[0]);
1052
+ ev.onAssistant?.(responseText, salvagedAnswer ? null : toolCalls[0]);
1070
1053
 
1071
1054
  if (toolCalls.length === 1 && toolCalls[0].tool === "done") {
1072
- // done-verification gate — jeo's descendant of gjc's ultragoal-guard completion
1073
- // state machine (plan/gjc-inheritance.md B4). The classifier owns the JUDGMENT
1074
- // (which named state, which message); the loop owns the once-pushback latch.
1075
- const doneGate = classifyDoneGate({
1055
+ const evidence = {
1076
1056
  sawMutation,
1077
1057
  sawVerification,
1078
1058
  verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
1079
- pendingHookFailure,
1080
- });
1081
- if (doneGate.block) {
1082
- if (!donePushbackUsed) {
1083
- donePushbackUsed = true;
1084
- sawActionSincePushback = false; // require a real attempt before the 2nd done is honored
1085
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1086
- history.push({ role: "user", content: doneGate.message });
1087
- step++;
1088
- continue;
1089
- }
1090
- if (!sawActionSincePushback) {
1091
- // Abuse guard: the model re-sent `done` immediately with zero intervening
1092
- // tool calls — not the intended "verification genuinely not applicable"
1093
- // escape hatch. Bounce once more instead of silently accepting it.
1094
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1095
- history.push({
1096
- role: "user",
1097
- content:
1098
- `${doneGate.message}\n\nYou called 'done' again without taking any further action. ` +
1099
- `Either run the relevant verification (test/build/lint) or take another concrete step, ` +
1100
- `then call done — do not resend 'done' unchanged.`,
1101
- });
1102
- step++;
1103
- continue;
1104
- }
1105
- // donePushbackUsed && sawActionSincePushback: the model made at least one
1106
- // real attempt since the pushback — honor the escape hatch.
1107
- }
1108
- // Caller-owned done gate (e.g. stale-todo reconciliation): ONE bounded
1109
- // bounce, then any later done passes — field case: a 28-step turn ended
1110
- // [DONE] with the Todos checklist still showing 1 in-progress + 4 pending
1111
- // because nothing ever forced a status update.
1112
- if (!beforeDoneNudgeUsed && ev.onBeforeDone) {
1113
- const nudge = await ev.onBeforeDone((toolCalls[0].arguments?.reason as string) ?? "", {
1114
- sawMutation,
1115
- sawVerification,
1116
- verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
1117
- });
1118
-
1119
- if (nudge) {
1120
- beforeDoneNudgeUsed = true;
1121
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1122
- history.push({ role: "user", content: nudge });
1123
- ev.onNotice?.("done deferred once — final plan reconciliation requested");
1124
- step++;
1125
- continue;
1059
+ };
1060
+ const doneGate = classifyDoneGate({ ...evidence, pendingHookFailure });
1061
+ const nudge = await ev.onBeforeDone?.((toolCalls[0].arguments?.reason as string) ?? "", evidence);
1062
+ const rejection = [doneGate.block ? doneGate.message : "", nudge].filter(Boolean).join("\n\n");
1063
+ if (rejection) {
1064
+ if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
1065
+ return finish({ done: false, steps: step, doneReason: `Stopped: completion rejected after ${doneCorrections} corrections. ${rejection}` });
1126
1066
  }
1067
+ doneCorrections++;
1068
+ pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1069
+ history.push({ role: "user", content: rejection });
1070
+ ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${GUARD_LIMITS.MAX_DONE_CORRECTIONS}`);
1071
+ step++;
1072
+ continue;
1127
1073
  }
1128
1074
  // Steering that arrived DURING this final step (after the top-of-loop drain,
1129
1075
  // while the model was generating its `done`): reopen the turn and handle it now
@@ -1148,6 +1094,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1148
1094
  continue;
1149
1095
  }
1150
1096
  }
1097
+ if (salvagedAnswer) pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1151
1098
  return finish({ done: true, steps: step, doneReason: stripLeakedReasoningTags((toolCalls[0].arguments?.reason as string) ?? "") });
1152
1099
  }
1153
1100
 
@@ -1445,20 +1392,18 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1445
1392
  if (results[i].executed) budget.record(callSigs[i], results[i].success, callTargets[i]);
1446
1393
 
1447
1394
  }
1448
- // done-verification guard bookkeeping: write/edit successes mark the turn as
1449
- // mutating; a successful bash whose command/output looks like a test/build run
1450
- // counts as verification. A monotonic seq records ORDER so the done gate can
1451
- // reject a verification that predates the last mutation (stale evidence).
1395
+ // Only the latest recognized verification result is evidence; a later failure
1396
+ // invalidates earlier success. Sequence numbers also reject tests before edits.
1452
1397
  for (let i = 0; i < toolCalls.length; i++) {
1453
- if (!results[i].executed || !results[i].success) continue;
1398
+ if (!results[i].executed) continue;
1454
1399
  const t = toolCalls[i].tool;
1455
- if (t === "write" || t === "edit") {
1400
+ if ((t === "write" || t === "edit") && results[i].success) {
1456
1401
  sawMutation = true;
1457
1402
  lastMutationSeq = ++mutVerSeq;
1458
1403
  } else if (t === "bash") {
1459
1404
  const cmd = String(toolCalls[i].arguments?.command ?? "");
1460
1405
  if (isVerificationSignal(cmd, results[i].output)) {
1461
- sawVerification = true;
1406
+ sawVerification = results[i].success;
1462
1407
  lastVerificationSeq = ++mutVerSeq;
1463
1408
  }
1464
1409
  }
@@ -1494,7 +1439,6 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1494
1439
  // Executed tool step = progress: the stall budget clocks time WITHOUT this.
1495
1440
  if (results.some(r => r.executed)) {
1496
1441
  lastProgressAt = Date.now();
1497
- sawActionSincePushback = true; // any real step after a pushback re-enables the done escape hatch
1498
1442
  }
1499
1443
  step++;
1500
1444
  }
@@ -41,6 +41,8 @@ export const GUARD_LIMITS = Object.freeze({
41
41
  MAX_REPEAT: 4,
42
42
  /** Consecutive different-but-failing steps before the turn stops. */
43
43
  MAX_FAILURES: 5,
44
+ /** Rejected completion attempts that may request a correction before stopping incomplete. */
45
+ MAX_DONE_CORRECTIONS: 3,
44
46
  /** Context-mutating refusal-ladder rungs (resend → reset → guidance strip) before
45
47
  * recovery switches to unbounded backoff resends (gjc parity: never terminal). */
46
48
  MAX_REFUSAL_RETRIES: 3,
@@ -114,7 +116,7 @@ export function repeatHint(tool: string, prev?: { success: boolean; output: stri
114
116
  export interface DoneGateInput {
115
117
  /** A write/edit succeeded this turn. */
116
118
  sawMutation: boolean;
117
- /** A test/build/typecheck/lint command succeeded this turn. */
119
+ /** The most recent recognized test/build/typecheck/lint command succeeded. */
118
120
  sawVerification: boolean;
119
121
  /**
120
122
  * A mutation landed AFTER the most recent successful verification — so the
@@ -129,7 +131,7 @@ export interface DoneGateInput {
129
131
  /** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
130
132
  export interface DoneGateVerdict {
131
133
  state: Extract<GuardState, "done_ok" | "done_unverified" | "done_stale_verification" | "done_hook_failing">;
132
- /** When true, `done` should be bounced ONCE with `message` (the caller owns the once-gate). */
134
+ /** When true, completion must remain blocked until the condition is resolved. */
133
135
  block: boolean;
134
136
  /** Corrective message to push back on `done`; empty when `state === "done_ok"`. */
135
137
  message: string;
@@ -139,10 +141,9 @@ export interface DoneGateVerdict {
139
141
  * Classify whether a `done` should be accepted or bounced — the direct descendant of
140
142
  * gjc's `ultragoal-guard` completion gate (plan/gjc-inheritance.md B4).
141
143
  *
142
- * A turn that MUTATED files is blocked ONCE when its verification is missing
143
- * (no test/build signal), STALE (a passing run that predates the last edit), or
144
- * a post-turn hook is still failing. The caller owns the single-pushback latch; a
145
- * second `done` always passes (the escape hatch for unverifiable docs/config changes).
144
+ * A turn that MUTATED files remains blocked while verification is missing/failed,
145
+ * STALE (a passing run predates the last edit), or a post-turn hook is still failing.
146
+ * The caller bounds corrective retries without converting rejection into success.
146
147
  */
147
148
  export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
148
149
  const hookFailing = input.pendingHookFailure !== null;
@@ -155,8 +156,7 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
155
156
  block: true,
156
157
  message:
157
158
  `Your latest mutation left the post-turn hook "${input.pendingHookFailure}" FAILING (non-zero exit) — its diagnostics were shown in the tool result above. ` +
158
- "Fix the reported problems (the hook re-runs on your next mutation), then call done. " +
159
- "If the hook failure is a false positive, call done again and say why in the reason.",
159
+ "Fix the reported problems (the hook re-runs on your next mutation), then call done.",
160
160
  };
161
161
  }
162
162
  if (stale) {
@@ -165,16 +165,14 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
165
165
  block: true,
166
166
  message:
167
167
  "You verified earlier, but then modified files again — your last passing test/build no longer reflects the current tree. " +
168
- "Re-run the narrowest verification command against the latest changes, then call done. " +
169
- "If the later edits are verification-irrelevant (docs/config-only), call done again and say why in the reason.",
168
+ "Re-run the narrowest verification command against the latest changes, then call done.",
170
169
  };
171
170
  }
172
171
  return {
173
172
  state: "done_unverified",
174
173
  block: true,
175
174
  message:
176
- "You modified files this turn but ran NO verification (no test/build/typecheck command succeeded). " +
177
- "Run the narrowest command that proves your change works, then call done. " +
178
- "If verification is genuinely not applicable (docs/config-only change), call done again and say why in the reason.",
175
+ "You modified files this turn but have no passing verification (no test/build/typecheck command succeeded, or the latest recognized verification failed). " +
176
+ "Run the narrowest command that proves your change works, then call done.",
179
177
  };
180
178
  }