4 Commits
Author SHA1 Message Date
abel533 b17913e26f docs: 添加仓库分支说明,包含 main/claude/learn 三个分支的介绍和链接 2026-04-02 20:59:14 +08:00
83fb08926d feat: 补充构建配置,实现 Claude Code 源码编译
添加缺失的项目配置文件,使源码快照可以成功编译和运行:
- package.json: 85+ 依赖项 + 构建脚本
- tsconfig.json: TypeScript + JSX + src/ 路径别名
- globals.d.ts: MACRO 构建常量 + bun:bundle 类型声明
- scripts/build.ts: Bun 构建脚本(feature flag polyfill + MACRO 注入 + 内部包 stub + 缺失源文件自动 stub)
- BUILD_GUIDE.md: 中文编译文档
- 修 Commander.js v13 的 -d2e 短标志兼容问题

构建脚本通过 missingSourceStubPlugin 插件自动检测并 stub 缺失的源文件,
无需在源码树中手动创建 stub 文件。

构建结果:dist/cli.js (11.7 MB),CLI 可正常启动并显示交互式终端 UI。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 12:01:06 +08:00
instructkr 16a676ffa3 rewrite the port 2026-03-31 05:09:41 -07:00
instructkr 4b9d30f795 asdf
Squash the current repository state back into one baseline commit while
preserving the README reframing and repository contents.

Constraint: User explicitly requested a single squashed commit with subject "asdf"
Confidence: high
Scope-risk: broad
Reversibility: clean
Directive: This commit intentionally rewrites published history; coordinate before future force-pushes
Tested: git status clean; local history rewritten to one commit; force-pushed main to origin and instructkr
Not-tested: Fresh clone verification after push
2026-03-31 03:34:03 -07:00
2108 changed files with 515870 additions and 36864 deletions
-61
View File
@@ -1,61 +0,0 @@
# API Key (required)
# Get yours at: https://console.anthropic.com/
ANTHROPIC_API_KEY=sk-ant-xxx
# Model ID (required)
MODEL_ID=claude-sonnet-4-6
# Base URL (optional, for Anthropic-compatible providers)
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# =============================================================================
# Anthropic-compatible providers
#
# Provider MODEL_ID SWE-bench TB2 Base URL
# --------------- -------------------- --------- ------ -------------------
# Anthropic claude-sonnet-4-6 79.6% 59.1% (default)
# MiniMax MiniMax-M2.5 80.2% - see below
# GLM (Zhipu) glm-5 77.8% - see below
# Kimi (Moonshot) kimi-k2.5 76.8% - see below
# DeepSeek deepseek-chat 73.0% - see below
# (V3.2)
#
# SWE-bench = SWE-bench Verified (Feb 2026)
# TB2 = Terminal-Bench 2.0 (Feb 2026)
# =============================================================================
# ---- International ----
# MiniMax https://www.minimax.io
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
# MODEL_ID=MiniMax-M2.5
# GLM (Zhipu) https://z.ai
# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
# MODEL_ID=glm-5
# Kimi (Moonshot) https://platform.moonshot.ai
# ANTHROPIC_BASE_URL=https://api.moonshot.ai/anthropic
# MODEL_ID=kimi-k2.5
# DeepSeek https://platform.deepseek.com
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# MODEL_ID=deepseek-chat
# ---- China mainland ----
# MiniMax https://platform.minimax.io
# ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
# MODEL_ID=MiniMax-M2.5
# GLM (Zhipu) https://open.bigmodel.cn
# ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic
# MODEL_ID=glm-5
# Kimi (Moonshot) https://platform.moonshot.cn
# ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
# MODEL_ID=kimi-k2.5
# DeepSeek (no regional split, same endpoint globally)
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# MODEL_ID=deepseek-chat
-32
View File
@@ -1,32 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build
-68
View File
@@ -1,68 +0,0 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install dependencies
run: pip install anthropic python-dotenv
- name: Run unit tests
run: python tests/test_unit.py
session-test:
runs-on: ubuntu-latest
strategy:
matrix:
session: [v0, v1, v2, v3, v4, v5, v6, v7, v8a, v8b, v8c, v9]
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install dependencies
run: pip install anthropic python-dotenv
- name: Run ${{ matrix.session }} tests
env:
TEST_API_KEY: ${{ secrets.TEST_API_KEY }}
TEST_BASE_URL: ${{ secrets.TEST_BASE_URL }}
TEST_MODEL: ${{ secrets.TEST_MODEL }}
run: python tests/test_${{ matrix.session }}.py
web-build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
+2 -233
View File
@@ -1,233 +1,2 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.idea
*.iml
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
/lib/
/lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Transcripts (generated by compression agent)
.transcripts/
# Runtime artifacts (generated by agent tests)
.task_outputs/
.tasks/
.teams/
.team/
.worktrees/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Web app
web/node_modules/
web/.next/
web/out/
.vercel
.env*.local
test_providers.py
# Internal analysis artifacts (not learning material)
analysis/
analysis_progress.md
TODO.md
.test-workspace/
dist
node_modules
@@ -0,0 +1,88 @@
# Is legal the same as legitimate: AI reimplementation and the erosion of copyleft
- **Date:** March 9, 2026
- **Author:** Hong Minhee
- **Source context:** _Hong Minhee on Things_ (English / 日本語 / 朝鮮語 (國漢文) / 한국어 (한글))
- **Archive note:** This copy was normalized from user-provided text for this repository's research/archive context. Site navigation/footer language links were converted into metadata.
Last week, Dan Blanchard, the maintainer of chardet—a Python library for detecting text encodings used by roughly 130 million projects a month—released a new version. Version 7.0 is 48 times faster than its predecessor, supports multiple cores, and was redesigned from the ground up. Anthropic's Claude is listed as a contributor. The license changed from LGPL to MIT.
Blanchard's account is that he never looked at the existing source code directly. He fed only the API and the test suite to Claude and asked it to reimplement the library from scratch. The resulting code shares less than 1.3% similarity with any prior version, as measured by JPlag. His conclusion: this is an independent new work, and he is under no obligation to carry forward the LGPL. Mark Pilgrim, the library's original author, opened a GitHub issue to object. The LGPL requires that modifications be distributed under the same license, and a reimplementation produced with ample exposure to the original codebase cannot, in Pilgrim's view, pass as a clean-room effort.
The dispute drew responses from two prominent figures in the open source world. Armin Ronacher, the creator of Flask, welcomed the relicensing. Salvatore Sanfilippo (antirez), the creator of Redis, published a broader defense of AI reimplementation, grounding it in copyright law and the history of the GNU project. Both conclude, by different routes, that what Blanchard did is legitimate. I respect both writers, and I think both are wrong—or more precisely, both are evading the question that actually matters.
That question is this: does legal mean legitimate? Neither piece answers it. Both move from “this is legally permissible” to “this is therefore fine,” without pausing at the gap between those two claims. Law sets a floor; clearing it does not mean the conduct is right. That gap is where this essay begins.
## The analogy points the wrong way
Antirez builds his case on history. When the GNU project reimplemented the UNIX userspace, it was lawful. So was Linux. Copyright law prohibits copying “protected expressions”—the actual code, its structure, its specific mechanisms—but it does not protect ideas or behavior. AI-assisted reimplementation occupies the same legal ground. Therefore, it is lawful.
The legal analysis is largely correct, and I am not disputing it. The problem lies in what antirez does next: he presents the legal conclusion as if it were also a social one, and uses a historical analogy that, examined more carefully, argues against his own position.
When GNU reimplemented the UNIX userspace, the vector ran from proprietary to free. Stallman was using the limits of copyright law to turn proprietary software into free software. The ethical force of that project did not come from its legal permissibility—it came from the direction it was moving, from the fact that it was expanding the commons. That is why people cheered.
The vector in the chardet case runs the other way. Software protected by a copyleft license—one that guarantees users the right to study, modify, and redistribute derivative works under the same terms—has been reimplemented under a permissive license that carries no such guarantee. This is not a reimplementation that expands the commons. It is one that removes the fencing that protected the commons. Derivative works built on chardet 7.0 are under no obligation to share their source code. That obligation, which applied to a library downloaded 130 million times a month, is now gone.
Antirez does not address this directional difference. He invokes the GNU precedent, but that precedent is a counterexample to his conclusion, not a supporting one.
## Does the GPL work against sharing?
Ronacher's argument is different. He discloses upfront that he has a stake in the outcome: “I personally have a horse in the race here because I too wanted chardet to be under a non-GPL license for many years. So consider me a very biased person in that regard.” He goes on to write that he considers “the GPL to run against that spirit by restricting what can be done with it”—the spirit being that society is better off when we share.
This claim rests on a fundamental misreading of what the GPL does.
Start with what the GPL actually prohibits. It does not prohibit keeping source code private. It imposes no constraint on privately modifying GPL software and using it yourself. The GPL's conditions are triggered only by distribution. If you distribute modified code, or offer it as a networked service, you must make the source available under the same terms. This is not a restriction on sharing. It is a condition placed on sharing: if you share, you must share in kind.
The requirement that improvements be returned to the commons is not a mechanism that suppresses sharing. It is a mechanism that makes sharing recursive and self-reinforcing. The claim that imposing contribution obligations on users of a commons undermines sharing culture does not hold together logically.
The contrast with the MIT license clarifies the point. Under MIT, anyone may take code, improve it, and close it off into a proprietary product. You can receive from the commons without giving back. If Ronacher calls this structure “more share-friendly,” he is using a concept of sharing with a specific directionality built in: sharing flows toward whoever has more capital and more engineers to take advantage of it.
The historical record bears this out. In the 1990s, companies routinely absorbed GPL code into proprietary products—not because they had chosen permissive licenses, but because copyleft enforcement was slack. The strengthening of copyleft mechanisms closed that gap. For individual developers and small projects without the resources to compete on anything but reciprocity, copyleft was what made the exchange approximately fair.
The creator of Flask knows this distinction. If he elides it anyway, the argument is not naïve—it is convenient.
## A self-refuting example
The most interesting moment in Ronacher's piece is not the argument but a detail he mentions in passing: Vercel reimplemented GNU Bash using AI and published it, then got visibly upset when Cloudflare reimplemented Next.js the same way.
Ronacher notes this as an irony and moves on. But the irony cuts deeper than he lets on. Next.js is MIT licensed. Cloudflare's vinext did not violate any license—it did exactly what Ronacher calls a contribution to the culture of openness, applied to a permissively licensed codebase. Vercel's reaction had nothing to do with license infringement; it was purely competitive and territorial. The implicit position is: reimplementing GPL software as MIT is a victory for sharing, but having our own MIT software reimplemented by a competitor is cause for outrage. This is what the claim that permissive licensing is “more share-friendly” than copyleft looks like in practice. The spirit of sharing, it turns out, runs in one direction only: outward from oneself.
Ronacher registers the contradiction and does not stop. “This development plays into my worldview,” he writes. When you present evidence that cuts against your own position, acknowledge it, and then proceed to your original conclusion unchanged, that is a signal that the conclusion preceded the argument.
## Legality and social legitimacy are different registers
Back to the question posed at the start. Is legal the same as legitimate?
Antirez closes his careful legal analysis as though it settles the matter. Ronacher acknowledges that “there is an obvious moral question here, but that isn't necessarily what I'm interested in.” Both pieces treat legal permissibility as a proxy for social legitimacy. But law only says what conduct it will not prevent—it does not certify that conduct as right. Aggressive tax minimization that never crosses into illegality may still be widely regarded as antisocial. A pharmaceutical company that legally acquires a patent on a long-generic drug and raises the price a hundredfold has done something legal, but that does not make it fine. Legality is a necessary condition; it is not a sufficient one.
In the chardet case, the distinction is sharper still. What the LGPL protected was not Blanchard's labor alone. It was a social compact agreed to by everyone who contributed to the library over twelve years. The terms of that compact were: if you take this and build on it, you share back under the same terms. This compact operated as a legal instrument, yes, but it was also the foundation of trust that made contribution rational. The fact that a reimplementation may qualify legally as a new work, and the fact that it breaks faith with the original contributors, are separate questions. If a court eventually rules in Blanchard's favor, that ruling will tell us what the law permits. It will not tell us that the act was right.
Zoë Kooyman, executive director of the FSF, put it plainly: “Refusing to grant others the rights you yourself received as a user is highly antisocial, no matter what method you use.”
## Whose perspective is the default?
Reading this debate, I keep returning to a question about position. From where are these two writers looking at the situation?
Antirez created Redis. Ronacher created Flask. Both are figures at the center of the open source ecosystem, with large audiences and well-established reputations. For them, falling costs of AI reimplementation means something specific: it is easier to reimplement things they want in a different form. Ronacher says explicitly that he had begun reimplementing GNU Readline precisely because of its copyleft terms.
For the people who have spent years contributing to a library like chardet, the same shift in costs means something else entirely: the copyleft protection around their contributions can be removed. The two writers are speaking from the former position to people in the latter, telling them that this was always lawful, that historical precedent supports it, and that the appropriate response is adaptation.
When positional asymmetry of this kind is ignored, and the argument is presented as universal analysis, what you get is not analysis but rationalization. Both writers arrive at conclusions that align precisely with their own interests. Readers should hold that fact in mind.
## What this fight points toward
Bruce Perens, who wrote the original Open Source Definition, told The Register: “The entire economics of software development are dead, gone, over, kaput!” He meant it as an alarm. Antirez, from a similar assessment of the situation, draws the conclusion: adapt. Ronacher says he finds the direction exciting.
None of the three responses addresses the central question. When copyleft becomes technically easier to circumvent, does that make it less necessary, or more?
I think more. What the GPL protected was not the scarcity of code but the freedom of users. The fact that producing code has become cheaper does not make it acceptable to use that code as a vehicle for eroding freedom. If anything, as the friction of reimplementation disappears, so does the friction of stripping copyleft from anything left exposed. The erosion of enforcement capacity is a legal problem. It does not touch the underlying normative judgment.
That judgment is this: those who take from the commons owe something back to the commons. The principle does not change depending on whether a reimplementation takes five years or five days. No court ruling on AI-generated code will alter its social weight.
This is where law and community norms diverge. Law is made slowly, after the fact, reflecting existing power arrangements. The norms that open source communities built over decades did not wait for court approval. People chose the GPL when the law offered them no guarantee of its enforcement, because it expressed the values of the communities they wanted to belong to. Those values do not expire when the law changes.
In previous writing, I argued for a training copyleft (TGPL) as the next step in this line of development. The chardet situation suggests the argument has to go further: to a specification copyleft covering the layer below source code. If source code can now be generated from a specification, the specification is where the essential intellectual content of a GPL project resides. Blanchard's own claim—that he worked only from the test suite and API without reading the source—is, paradoxically, an argument for protecting that test suite and API specification under copyleft terms.
The history of the GPL is the history of licensing tools evolving in response to new forms of exploitation: GPLv2 to GPLv3, then AGPL. What drove each evolution was not a court ruling but a community reaching a value judgment first and then seeking legal instruments to express it. The same sequence is available now. Whatever courts eventually decide about AI reimplementation, the question we need to answer first is not a legal one. It is a social one. Do those who take from the commons owe something back? I think they do. That judgment does not require a verdict.
What makes the pieces by antirez and Ronacher worth reading is not that they are right. It is that they make visible, with unusual clarity, what they are choosing not to see. When legality is used as a substitute for a value judgment, the question that actually matters gets buried in the footnotes of a law it has already outgrown.
+207
View File
@@ -0,0 +1,207 @@
# Claude Code 源码编译指南
> 本文档记录了为 Claude Code 源码快照补充缺失配置文件并成功编译运行的完整过程。
## 概述
Claude Code 的源码快照仅包含 `src/` 目录和 `README.md`,缺少所有构建配置文件。本指南涵盖了从零开始恢复构建环境的全部步骤。
---
## 补充的配置文件
### 1. `package.json`
项目的核心配置文件,定义了:
- **包名**: `@anthropic-ai/claude-code`
- **入口点**: `src/entrypoints/cli.tsx`
- **构建产物**: `dist/cli.js`
- **脚本命令**:
- `bun run build` — 执行构建脚本
- `bun run dev` — 直接运行源码(开发模式)
- `bun run typecheck` — TypeScript 类型检查
**依赖项分为三类:**
| 分类 | 数量 | 示例 |
|------|------|------|
| 公开 npm 包 | ~75 个 | `react`, `chalk`, `zod`, `@anthropic-ai/sdk` |
| Anthropic 内部包(需 stub | ~10 个 | `@ant/*`, `@anthropic-ai/sandbox-runtime` |
| 开发依赖 | ~13 个 | `typescript`, `@types/react`, `@types/bun` |
### 2. `tsconfig.json`
TypeScript 编译配置,关键设置:
- **模块系统**: ESNext + bundler resolution
- **JSX**: `react-jsx`React 19 的新 JSX 转换)
- **路径别名**: `src/*``./src/*`(项目中大量使用 `import from 'src/...'` 格式的导入)
- **目标**: ESNextBun 原生支持)
- **类型**: 同时包含 `bun-types``node` 类型定义
### 3. `globals.d.ts`
全局类型声明文件,定义了:
- **`MACRO` 常量**:构建时注入的宏定义
- `MACRO.VERSION` — 版本号
- `MACRO.BUILD_TIME` — 构建时间戳
- `MACRO.PACKAGE_URL` — npm 包地址
- `MACRO.NATIVE_PACKAGE_URL` — 原生包地址
- `MACRO.FEEDBACK_CHANNEL` — 反馈渠道
- `MACRO.ISSUES_EXPLAINER` — 问题报告说明
- `MACRO.VERSION_CHANGELOG` — 版本变更日志
- **`bun:bundle` 模块**Bun 构建时特性标志(feature flags)模块的类型声明
- **内部包的类型 stub**:为不公开的 Anthropic 内部包提供类型定义
### 4. `scripts/build.ts`
核心构建脚本,使用 Bun 的 `Bun.build()` API,处理以下关键问题:
#### a) `bun:bundle` Feature Flags 处理
原始代码使用 `import { feature } from 'bun:bundle'` 进行编译时死代码消除。构建脚本通过自定义 Bun 插件为 `feature()` 函数提供 polyfill,所有特性标志在外部构建中默认返回 `false`
```
PROACTIVE, KAIROS, BRIDGE_MODE, DAEMON, VOICE_MODE,
AGENT_TRIGGERS, MONITOR_TOOL, COORDINATOR_MODE,
ABLATION_BASELINE, DUMP_SYSTEM_PROMPT, CHICAGO_MCP
```
#### b) `MACRO.*` 构建时常量注入
通过 `Bun.build()``define` 选项在编译时替换所有 `MACRO.*` 引用为实际值。
#### c) 内部包 Stub 插件
为不公开的 Anthropic 内部包(`@ant/*``@anthropic-ai/sandbox-runtime` 等)提供内联 stub,确保编译时所有命名导出都能正确解析,同时不引入运行时依赖。
#### d) 缺失源文件自动 Stub 插件
源码快照中缺少部分文件(被 `feature()` 特性标志保护或属于内部模块)。构建脚本通过 `missingSourceStubPlugin` 插件**在构建时自动检测并 stub 这些缺失文件**,无需在源码树中手动创建 stub 文件:
- 通过 `onResolve` 钩子拦截所有源文件导入,检测目标文件是否存在于磁盘
- 不存在的文件自动重定向到虚拟 `missing-source` 命名空间
- 对需要特定 named exports 的模块(如 `connectorText``protectedNamespace` 等)提供精确 stub
- 其余缺失模块返回通用 `export default {}`
- `.md``.txt` 文件返回空字符串,`.d.ts` 文件返回 `export {}`
- 构建日志中以 `⚠️ Auto-stubbing missing module:` 标记自动 stub 的模块
#### e) 外部依赖处理
所有公开 npm 包标记为 `external`,不打包进产物,运行时从 `node_modules` 解析。
### 5. 源码修复
`src/main.tsx` 中的 `-d2e` 短标志格式与 Commander.js v13 不兼容(v13 要求短标志为单个字符),已修改为仅使用 `--debug-to-stderr` 长标志。
---
## 编译步骤
### 前提条件
- [Bun](https://bun.sh) ≥ 1.1(推荐 1.3+
- Windows / macOS / Linux
### 步骤 1:安装依赖
```bash
bun install
```
这将安装约 600+ 个包(含传递依赖),耗时约 1-2 分钟。
### 步骤 2:执行构建
```bash
bun run build
```
构建脚本将:
1. 初始化 `bun:bundle` feature flag polyfill
2. 注入 `MACRO.*` 构建时常量
3. 为内部 Anthropic 包生成内联 stub
4. 自动检测并 stub 源码快照中缺失的源文件
5. 打包 `src/entrypoints/cli.tsx` 入口点
6. 输出到 `dist/cli.js`(约 11.7 MB)和 `dist/cli.js.map`
### 步骤 3:验证运行
```bash
# 检查版本
bun dist/cli.js --version
# 输出: 1.0.0-research (Claude Code)
# 查看帮助
bun dist/cli.js --help
# 启动交互式界面(需要 API Key)
bun dist/cli.js
```
### 自定义版本号
```bash
CLAUDE_CODE_VERSION=2.0.0 bun run build
```
---
## 构建产物
| 文件 | 大小 | 说明 |
|------|------|------|
| `dist/cli.js` | ~11.7 MB | 主程序包(ESM 格式) |
| `dist/cli.js.map` | ~38.6 MB | Source Map |
---
## 技术要点
### 架构概览
```
入口点: src/entrypoints/cli.tsx
↓ (动态导入)
主程序: src/main.tsx (Commander.js CLI)
终端 UI: src/ink/ (自定义 Ink 实现 + React 19)
核心系统:
├── src/tools/ (~40 个工具实现)
├── src/commands/ (~50 个斜杠命令)
├── src/services/ (API、MCP、OAuth、分析)
├── src/hooks/ (权限系统)
└── src/coordinator/ (多智能体协调)
```
### 关键技术栈
| 组件 | 技术 |
|------|------|
| 运行时 | Bun |
| 语言 | TypeScript (strict) |
| 终端 UI | React 19 + 自定义 Ink fork |
| CLI 框架 | Commander.js 13 |
| Schema 验证 | Zod v3 |
| 协议 | MCP SDK, LSP |
| API | Anthropic SDK |
| 遥测 | OpenTelemetry 2.x |
| 布局引擎 | 纯 TypeScript yoga-layout 实现 |
### 注意事项
1. **内部包不可用**`@ant/*` 系列包和部分 `@anthropic-ai/*` 包不在公共 npm 上发布,相关功能(Chrome 集成、计算机使用、沙箱运行时等)在此构建中不可用。
2. **Feature Flags 全部禁用**:所有 `bun:bundle` 特性标志默认返回 `false`,这意味着实验性功能(语音、守护进程、协调器模式等)的代码路径已被消除。
3. **需要 API Key 才能实际使用**:虽然 CLI 可以编译和启动(完整的终端 UI、主题选择等均正常),但实际使用需要 Anthropic API Key 或 OAuth 登录。
4. **React Reconciler 版本**:项目使用了 `useEffectEvent` Hook,需要 `react-reconciler@0.33.0`(而非 0.31.0),该版本才实现了 `useEffectEvent` 调度器。
5. **内部包 Stub 需完整方法签名**:某些内部包(如 `@anthropic-ai/sandbox-runtime``SandboxManager`)的 stub 需要包含完整的静态方法(如 `isSupportedPlatform``checkDependencies``wrapWithSandbox` 等),否则运行时在访问未定义属性时会报错。构建脚本已为所有已知的内部类提供了完整方法签名。
6. **非官方构建**:此构建仅用于教育和安全研究目的,不代表 Anthropic 官方发布。
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 shareAI Lab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-438
View File
@@ -1,438 +0,0 @@
# Learn Claude Code -- Harness Engineering for Real Agents
[English](./README-en.md) | [中文](./README.md) | [日本語](./README-ja.md)
## The Model IS the Agent
Before we talk about code, let's get one thing absolutely straight.
**An agent is a model. Not a framework. Not a prompt chain. Not a drag-and-drop workflow.**
### What an Agent IS
An agent is a neural network -- a Transformer, an RNN, a learned function -- that has been trained, through billions of gradient updates on action-sequence data, to perceive an environment, reason about goals, and take actions to achieve them. The word "agent" in AI has always meant this. Always.
A human is an agent. A biological neural network, shaped by millions of years of evolutionary training, perceiving the world through senses, reasoning through a brain, acting through a body. When DeepMind, OpenAI, or Anthropic say "agent," they mean the same thing the field has meant since its inception: **a model that has learned to act.**
The proof is written in history:
- **2013 -- DeepMind DQN plays Atari.** A single neural network, receiving only raw pixels and game scores, learned to play 7 Atari 2600 games -- surpassing all prior algorithms and beating human experts on 3 of them. By 2015, the same architecture scaled to [49 games and matched professional human testers](https://www.nature.com/articles/nature14236), published in *Nature*. No game-specific rules. No decision trees. One model, learning from experience. That model was the agent.
- **2019 -- OpenAI Five conquers Dota 2.** Five neural networks, having played [45,000 years of Dota 2](https://openai.com/index/openai-five-defeats-dota-2-world-champions/) against themselves in 10 months, defeated **OG** -- the reigning TI8 world champions -- 2-0 on a San Francisco livestream. In a subsequent public arena, the AI won 99.4% of 42,729 games against all comers. No scripted strategies. No meta-programmed team coordination. The models learned teamwork, tactics, and real-time adaptation entirely through self-play.
- **2019 -- DeepMind AlphaStar masters StarCraft II.** AlphaStar [beat professional players 10-1](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/) in a closed-door match, and later achieved [Grandmaster status](https://www.nature.com/articles/d41586-019-03298-6) on European servers -- top 0.15% of 90,000 players. A game with imperfect information, real-time decisions, and a combinatorial action space that dwarfs chess and Go. The agent? A model. Trained. Not scripted.
- **2019 -- Tencent Jueyu dominates Honor of Kings.** Tencent AI Lab's "Jueyu" [defeated KPL professional players](https://www.jiemian.com/article/3371171.html) in a full 5v5 match at the World Champion Cup. In 1v1 mode, pros won only [1 out of 15 games and never survived past 8 minutes](https://developer.aliyun.com/article/851058). Training intensity: one day equaled 440 human years. By 2021, Jueyu surpassed KPL pros across the full hero pool. No handcrafted matchup tables. No scripted compositions. A model that learned the entire game from scratch through self-play.
- **2024-2025 -- LLM agents reshape software engineering.** Claude, GPT, Gemini -- large language models trained on the entirety of human code and reasoning -- are deployed as coding agents. They read codebases, write implementations, debug failures, coordinate in teams. The architecture is identical to every agent before them: a trained model, placed in an environment, given tools to perceive and act. The only difference is the scale of what they've learned and the generality of the tasks they solve.
Every one of these milestones shares the same truth: **the "agent" is never the surrounding code. The agent is always the model.**
### What an Agent Is NOT
The word "agent" has been hijacked by an entire cottage industry of prompt plumbing.
Drag-and-drop workflow builders. No-code "AI agent" platforms. Prompt-chain orchestration libraries. They all share the same delusion: that wiring together LLM API calls with if-else branches, node graphs, and hardcoded routing logic constitutes "building an agent."
It doesn't. What they build is a Rube Goldberg machine -- an over-engineered, brittle pipeline of procedural rules, with an LLM wedged in as a glorified text-completion node. That is not an agent. That is a shell script with delusions of grandeur.
**Prompt plumbing "agents" are the fantasy of programmers who don't train models.** They attempt to brute-force intelligence by stacking procedural logic -- massive rule trees, node graphs, chain-of-prompt waterfalls -- and praying that enough glue code will somehow emergently produce autonomous behavior. It won't. You cannot engineer your way to agency. Agency is learned, not programmed.
Those systems are dead on arrival: fragile, unscalable, fundamentally incapable of generalization. They are the modern resurrection of GOFAI (Good Old-Fashioned AI) -- the symbolic rule systems the field abandoned decades ago, now spray-painted with an LLM veneer. Different packaging, same dead end.
### The Mind Shift: From "Developing Agents" to Developing Harness
When someone says "I'm developing an agent," they can only mean one of two things:
**1. Training the model.** Adjusting weights through reinforcement learning, fine-tuning, RLHF, or other gradient-based methods. Collecting task-process data -- the actual sequences of perception, reasoning, and action in real domains -- and using it to shape the model's behavior. This is what DeepMind, OpenAI, Tencent AI Lab, and Anthropic do. This is agent development in the truest sense.
**2. Building the harness.** Writing the code that gives the model an environment to operate in. This is what most of us do, and it is the focus of this repository.
A harness is everything the agent needs to function in a specific domain:
```
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
Tools: file I/O, shell, network, database, browser
Knowledge: product docs, domain references, API specs, style guides
Observation: git diff, error logs, browser state, sensor data
Action: CLI commands, API calls, UI interactions
Permissions: sandboxing, approval workflows, trust boundaries
```
The model decides. The harness executes. The model reasons. The harness provides context. The model is the driver. The harness is the vehicle.
**A coding agent's harness is its IDE, terminal, and filesystem access.** A farm agent's harness is its sensor array, irrigation controls, and weather data feeds. A hotel agent's harness is its booking system, guest communication channels, and facility management APIs. The agent -- the intelligence, the decision-maker -- is always the model. The harness changes per domain. The agent generalizes across them.
This repo teaches you to build vehicles. Vehicles for coding. But the design patterns generalize to any domain: farm management, hotel operations, manufacturing, logistics, healthcare, education, scientific research. Anywhere a task needs to be perceived, reasoned about, and acted upon -- an agent needs a harness.
### What Harness Engineers Actually Do
If you are reading this repository, you are likely a harness engineer -- and that is a powerful thing to be. Here is your real job:
- **Implement tools.** Give the agent hands. File read/write, shell execution, API calls, browser control, database queries. Each tool is an action the agent can take in its environment. Design them to be atomic, composable, and well-described.
- **Curate knowledge.** Give the agent domain expertise. Product documentation, architectural decision records, style guides, regulatory requirements. Load them on-demand (s05), not upfront. The agent should know what's available and pull what it needs.
- **Manage context.** Give the agent clean memory. Subagent isolation (s04) prevents noise from leaking. Context compression (s06) prevents history from overwhelming. Task systems (s07) persist goals beyond any single conversation.
- **Control permissions.** Give the agent boundaries. Sandbox file access. Require approval for destructive operations. Enforce trust boundaries between the agent and external systems. This is where safety engineering meets harness engineering.
- **Collect task-process data.** Every action sequence the agent executes in your harness is training signal. The perception-reasoning-action traces from real deployments are the raw material for fine-tuning the next generation of agent models. Your harness doesn't just serve the agent -- it can help improve the agent.
You are not writing the intelligence. You are building the world the intelligence inhabits. The quality of that world -- how clearly the agent can perceive, how precisely it can act, how rich its available knowledge is -- directly determines how effectively the intelligence can express itself.
**Build great harnesses. The agent will do the rest.**
### Why Claude Code -- A Masterclass in Harness Engineering
Why does this repository dissect Claude Code specifically?
Because Claude Code is the most elegant and fully-realized agent harness we have seen. Not because of any single clever trick, but because of what it *doesn't* do: it doesn't try to be the agent. It doesn't impose rigid workflows. It doesn't second-guess the model with elaborate decision trees. It provides the model with tools, knowledge, context management, and permission boundaries -- then gets out of the way.
Look at what Claude Code actually is, stripped to its essence:
```
Claude Code = one agent loop
+ tools (bash, read, write, edit, glob, grep, browser...)
+ on-demand skill loading
+ context compression
+ subagent spawning
+ task system with dependency graph
+ team coordination with async mailboxes
+ worktree isolation for parallel execution
+ permission governance
```
That's it. That's the entire architecture. Every component is a harness mechanism -- a piece of the world built for the agent to inhabit. The agent itself? It's Claude. A model. Trained by Anthropic on the full breadth of human reasoning and code. The harness doesn't make Claude smart. Claude is already smart. The harness gives Claude hands, eyes, and a workspace.
This is why Claude Code is the ideal teaching subject: **it demonstrates what happens when you trust the model and focus your engineering on the harness.** Every session in this repository (s01-s12) reverse-engineers one harness mechanism from Claude Code's architecture. By the end, you understand not just how Claude Code works, but the universal principles of harness engineering that apply to any agent in any domain.
The lesson is not "copy Claude Code." The lesson is: **the best agent products are built by engineers who understand that their job is harness, not intelligence.**
---
## The Vision: Fill the Universe with Real Agents
This is not just about coding agents.
Every domain where humans perform complex, multi-step, judgment-intensive work is a domain where agents can operate -- given the right harness. The patterns in this repository are universal:
```
Estate management agent = model + property sensors + maintenance tools + tenant comms
Agricultural agent = model + soil/weather data + irrigation controls + crop knowledge
Hotel operations agent = model + booking system + guest channels + facility APIs
Medical research agent = model + literature search + lab instruments + protocol docs
Manufacturing agent = model + production line sensors + quality controls + logistics
Education agent = model + curriculum knowledge + student progress + assessment tools
```
The loop is always the same. The tools change. The knowledge changes. The permissions change. The agent -- the model -- generalizes.
Every harness engineer reading this repository is learning patterns that apply far beyond software engineering. You are learning to build the infrastructure for an intelligent, automated future. Every well-designed harness deployed in a real domain is one more place where an agent can perceive, reason, and act.
First we fill the workshops. Then the farms, the hospitals, the factories. Then the cities. Then the planet.
**Bash is all you need. Real agents are all the universe needs.**
---
```
THE AGENT PATTERN
=================
User --> messages[] --> LLM --> response
|
More tool call requests?
/ \
yes no
| |
execute @Tool methods return text
return results
continue loop -----------------> messages[]
That's the minimal loop. Every AI agent needs this loop.
The model decides when to call tools and when to stop.
Spring AI's ChatClient.call() automatically drives this loop.
This repo teaches you to build everything around this loop --
the harness that makes the agent effective in a specific domain.
```
**12 progressive sessions, from a simple loop to isolated autonomous execution.**
**Each session adds one harness mechanism. Each mechanism has one motto.**
> **s01** &nbsp; *"One loop & Bash is all you need"* &mdash; one tool + one loop = an agent
>
> **s02** &nbsp; *"Adding a tool means adding one handler"* &mdash; the loop stays the same; new tools register with `@Tool` annotation + `defaultTools()`
>
> **s03** &nbsp; *"An agent without a plan drifts"* &mdash; list the steps first, then execute; completion doubles
>
> **s04** &nbsp; *"Break big tasks down; each subtask gets a clean context"* &mdash; subagents use independent messages[], keeping the main conversation clean
>
> **s05** &nbsp; *"Load knowledge when you need it, not upfront"* &mdash; inject via tool_result, not the system prompt
>
> **s06** &nbsp; *"Context will fill up; you need a way to make room"* &mdash; three-layer compression strategy for infinite sessions
>
> **s07** &nbsp; *"Break big goals into small tasks, order them, persist to disk"* &mdash; a file-based task graph with dependencies, laying the foundation for multi-agent collaboration
>
> **s08** &nbsp; *"Run slow operations in the background; the agent keeps thinking"* &mdash; daemon threads run commands, inject notifications on completion
>
> **s09** &nbsp; *"When the task is too big for one, delegate to teammates"* &mdash; persistent teammates + async mailboxes
>
> **s10** &nbsp; *"Teammates need shared communication rules"* &mdash; one request-response pattern drives all negotiation
>
> **s11** &nbsp; *"Teammates scan the board and claim tasks themselves"* &mdash; no need for the lead to assign each one
>
> **s12** &nbsp; *"Each works in its own directory, no interference"* &mdash; tasks manage goals, worktrees manage directories, bound by ID
---
## The Core Pattern
```java
// Spring AI's ChatClient + @Tool annotation implement the Agent loop
// The model automatically decides when to call tools and when to return text -- the loop is driven by the framework
@SpringBootApplication
public class S01AgentLoop implements CommandLineRunner {
@Bean
public CommandLineRunner agentLoop(ChatClient.Builder builder) {
ChatClient chatClient = builder
.defaultSystem("You are a helpful assistant with access to tools.")
.defaultTools(new BashTool()) // 注册工具
.build();
return args -> {
// 一次 call() 内部自动完成: 调用模型 → 检测工具请求 → 执行工具 → 回传结果 → 再次调用模型...
String result = chatClient.prompt()
.user(userInput)
.call()
.content();
System.out.println(result);
};
}
}
// @Tool 注解让方法自动成为模型可调用的工具
public class BashTool {
@Tool(description = "Execute a shell command and return stdout/stderr")
public String executeBash(String command) {
// 执行命令并返回结果
}
}
```
Spring AI's `ChatClient.call()` encapsulates the complete agent loop internally: call the LLM → detect tool call requests → execute `@Tool` methods → return results to the model → repeat until the model returns text. Each session layers one harness mechanism on top of this loop -- without changing the loop itself. The loop belongs to the agent. The mechanisms belong to the harness.
## Scope (Important)
This repository is a 0->1 learning project for harness engineering -- building the environment that surrounds an agent model.
It intentionally simplifies or omits several production mechanisms:
- Full event/hook buses (for example PreToolUse, SessionStart/End, ConfigChange).
s12 includes only a minimal append-only lifecycle event stream for teaching.
- Rule-based permission governance and trust workflows
- Session lifecycle controls (resume/fork) and advanced worktree lifecycle controls
- Full MCP runtime details (transport/OAuth/resource subscribe/polling)
Treat the team JSONL mailbox protocol in this repo as a teaching implementation, not a claim about any specific production internals.
## Quick Start
### Requirements
- **JDK 21+** (recommended: [Eclipse Temurin](https://adoptium.net/) or GraalVM)
- **Maven 3.9+**
- An OpenAI-compatible LLM API key (DeepSeek, GLM, Qwen, OpenAI, etc.)
### Clone & Build
```sh
git clone https://github.com/abel533/claude-code
cd learn-claude-code
mvn compile # 编译项目
```
### Set Environment Variables
```sh
# Linux / macOS
export AI_API_KEY=your-api-key
export AI_BASE_URL=https://api.deepseek.com # 替换为你的模型服务商地址
export AI_MODEL=deepseek-chat # 替换为你使用的模型名称
# Windows PowerShell
$env:AI_API_KEY="your-api-key"
$env:AI_BASE_URL="https://api.deepseek.com"
$env:AI_MODEL="deepseek-chat"
```
### Run Sessions
```sh
# 从第一课开始
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
# 完整递进终点
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
# 总纲: 全部机制合一
mvn exec:java -Dexec.mainClass=io.mybatis.learn.full.SFullAgent
```
### Web Platform
Interactive visualizations, step-through animations, source viewer, and documentation for each session.
```sh
cd web && npm install && npm run dev # http://localhost:3000
```
### Java Version Features
This project uses the **Java 21 + Spring Boot 3.5.7 + Spring AI 1.0.3** stack. Compared to the original Python version:
- **Compatible with multiple LLM providers** -- adapts to DeepSeek, GLM, Qwen, Moonshot and other models via the OpenAI protocol, no vendor lock-in
- **`@Tool` annotation handles the tool call loop automatically** -- Spring AI completes the full "model call → tool execution → result return" cycle, no hand-written while loop needed
- **Java 21 Virtual Threads** -- lightweight concurrency for background tasks and multi-agent collaboration, without thread pool management overhead
- **Each session is independently runnable** -- every session is a `@SpringBootApplication` + `CommandLineRunner`, launchable with a single `mvn exec:java` command
- **Type safety** -- Java's strong type system catches errors at compile time, with IDE-friendly auto-completion
## Learning Path
```
Phase 1: THE LOOP Phase 2: PLANNING & KNOWLEDGE
================== ==============================
s01 The Agent Loop [1] s03 TodoWrite [5]
ChatClient + @Tool TodoManager + nag reminder
| |
+-> s02 Tool Use [4] s04 Subagents [5]
@Tool registers tools independent ChatClient per child
|
s05 Skills [5]
SKILL.md via tool_result
|
s06 Context Compact [5]
3-layer compression
Phase 3: PERSISTENCE Phase 4: TEAMS
================== =====================
s07 Tasks [8] s09 Agent Teams [9]
file-based CRUD + deps graph teammates + JSONL mailboxes
| |
s08 Background Tasks [6] s10 Team Protocols [12]
virtual threads + notify queue shutdown + plan approval FSM
|
s11 Autonomous Agents [14]
idle cycle + auto-claim
|
s12 Worktree Isolation [16]
task coordination + on-demand isolated execution lanes
[N] = number of tools
```
## Project Structure
```
learn-claude-code/
|
|-- src/main/java/io/mybatis/learn/ # Java implementation (Spring AI + Spring Boot)
| |-- core/ # shared utilities (AgentRunner, BashTool, EditFileTool, etc.)
| |-- s01/ S01AgentLoop.java # session 01: Agent Loop
| |-- s02/ S02ToolUse.java # session 02: Multi-Tool Registration
| |-- s03/ S03TodoWrite.java # session 03: Plan-Driven Execution
| |-- s04/ S04Subagent.java # session 04: Subagents
| |-- s05/ S05SkillLoading.java # session 05: Skill Loading
| |-- s06/ S06ContextCompact.java # session 06: Context Compression
| |-- s07/ S07TaskSystem.java # session 07: Task System
| |-- s08/ S08BackgroundTasks.java # session 08: Background Tasks
| |-- s09/ S09AgentTeams.java # session 09: Agent Teams
| |-- s10/ S10TeamProtocols.java # session 10: Team Protocols
| |-- s11/ S11AutonomousAgents.java# session 11: Autonomous Agents
| |-- s12/ S12WorktreeIsolation.java# session 12: Worktree Isolation
| +-- full/ SFullAgent.java # capstone: all mechanisms combined
|
|-- agents/ # Python reference implementations (original version, kept for comparison)
|-- docs/{en,zh,ja}/ # Mental-model-first documentation (3 languages)
|-- web/ # Interactive learning platform (Next.js)
|-- skills/ # Skill files for s05
|-- pom.xml # Maven build config (Spring Boot 3.5.7 + Spring AI 1.0.3)
+-- .github/workflows/ci.yml # CI: typecheck + build
```
## Documentation
Mental-model-first: problem, solution, ASCII diagram, minimal code.
Available in [English](./docs/en/) | [中文](./docs/zh/) | [日本語](./docs/ja/).
| Session | Topic | Motto |
|---------|-------|-------|
| [s01](./docs/en/s01-the-agent-loop.md) | Agent Loop | *One loop & Bash is all you need* |
| [s02](./docs/en/s02-tool-use.md) | Tool Use | *Adding a tool means adding one handler* |
| [s03](./docs/en/s03-todo-write.md) | TodoWrite | *An agent without a plan drifts* |
| [s04](./docs/en/s04-subagent.md) | Subagents | *Break big tasks down; each subtask gets a clean context* |
| [s05](./docs/en/s05-skill-loading.md) | Skills | *Load knowledge when you need it, not upfront* |
| [s06](./docs/en/s06-context-compact.md) | Context Compact | *Context will fill up; you need a way to make room* |
| [s07](./docs/en/s07-task-system.md) | Task System | *Break big goals into small tasks, order them, persist to disk* |
| [s08](./docs/en/s08-background-tasks.md) | Background Tasks | *Run slow operations in the background; the agent keeps thinking* |
| [s09](./docs/en/s09-agent-teams.md) | Agent Teams | *When the task is too big for one, delegate to teammates* |
| [s10](./docs/en/s10-team-protocols.md) | Team Protocols | *Teammates need shared communication rules* |
| [s11](./docs/en/s11-autonomous-agents.md) | Autonomous Agents | *Teammates scan the board and claim tasks themselves* |
| [s12](./docs/en/s12-worktree-task-isolation.md) | Worktree + Task Isolation | *Each works in its own directory, no interference* |
## What's Next -- from understanding to shipping
After the 12 sessions you understand how harness engineering works inside out. Two ways to put that knowledge to work:
### Kode Agent CLI -- Open-Source Coding Agent CLI
> `npm i -g @shareai-lab/kode`
Skill & LSP support, Windows-ready, pluggable with GLM / MiniMax / DeepSeek and other open models. Install and go.
GitHub: **[shareAI-lab/Kode-cli](https://github.com/shareAI-lab/Kode-cli)**
### Kode Agent SDK -- Embed Agent Capabilities in Your App
The official Claude Code Agent SDK communicates with a full CLI process under the hood -- each concurrent user means a separate terminal process. Kode SDK is a standalone library with no per-user process overhead, embeddable in backends, browser extensions, embedded devices, or any runtime.
GitHub: **[shareAI-lab/Kode-agent-sdk](https://github.com/shareAI-lab/Kode-agent-sdk)**
---
## Sister Repo: from *on-demand sessions* to *always-on assistant*
The harness this repo teaches is **use-and-discard** -- open a terminal, give the agent a task, close when done, next session starts blank. That is the Claude Code model.
[OpenClaw](https://github.com/openclaw/openclaw) proved another possibility: on top of the same agent core, two harness mechanisms turn the agent from "poke it to make it move" into "it wakes up every 30 seconds to look for work":
- **Heartbeat** -- every 30s the harness sends the agent a message to check if there is anything to do. Nothing? Go back to sleep. Something? Act immediately.
- **Cron** -- the agent can schedule its own future tasks, executed automatically when the time comes.
Add multi-channel IM routing (WhatsApp / Telegram / Slack / Discord, 13+ platforms), persistent context memory, and a Soul personality system, and the agent goes from a disposable tool to an always-on personal AI assistant.
**[claw0](https://github.com/shareAI-lab/claw0)** is our companion teaching repo that deconstructs these harness mechanisms from scratch:
```
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
```
```
learn-claude-code claw0
(agent harness core: (proactive always-on harness:
loop, tools, planning, heartbeat, cron, IM channels,
teams, worktree isolation) memory, soul personality)
```
## License
MIT
---
**The model is the agent. The code is the harness. Build great harnesses. The agent will do the rest.**
**Bash is all you need. Real agents are all the universe needs.**
-438
View File
@@ -1,438 +0,0 @@
# Learn Claude Code -- 真の Agent のための Harness Engineering
[English](./README-en.md) | [中文](./README.md) | [日本語](./README-ja.md)
## モデルこそが Agent である
コードの話をする前に、一つだけ明確にしておく。
**Agent とはモデルのことだ。フレームワークではない。プロンプトチェーンではない。ドラッグ&ドロップのワークフローではない。**
### Agent とは何か
Agent とはニューラルネットワークである -- Transformer、RNN、学習された関数 -- 数十億回の勾配更新を経て、行動系列データの上で環境を知覚し、目標を推論し、行動を起こすことを学んだもの。AI における "Agent" という言葉は、始まりからずっとこの意味だった。常に。
人間も Agent だ。数百万年の進化的訓練によって形作られた生物的ニューラルネットワーク。感覚で世界を知覚し、脳で推論し、身体で行動する。DeepMind、OpenAI、Anthropic が "Agent" と言うとき、それはこの分野が誕生以来ずっと意味してきたものと同じだ:**行動することを学んだモデル。**
歴史がその証拠を刻んでいる:
- **2013 -- DeepMind DQN が Atari をプレイ。** 単一のニューラルネットワークが、生のピクセルとスコアだけを受け取り、7 つの Atari 2600 ゲームを学習 -- すべての先行アルゴリズムを超え、3 つで人間の専門家を打ち負かした。2015 年には同じアーキテクチャが [49 ゲームに拡張され、プロのテスターに匹敵](https://www.nature.com/articles/nature14236)、*Nature* に掲載。ゲーム固有のルールなし。決定木なし。一つのモデルが経験から学んだ。そのモデルが Agent だった。
- **2019 -- OpenAI Five が Dota 2 を制覇。** 5 つのニューラルネットワークが 10 ヶ月間で [45,000 年分の Dota 2](https://openai.com/index/openai-five-defeats-dota-2-world-champions/) を自己対戦し、サンフランシスコのライブストリームで **OG** -- TI8 世界王者 -- を 2-0 で撃破。その後の公開アリーナでは 42,729 試合で勝率 99.4%。スクリプト化された戦略なし。メタプログラムされたチーム連携なし。モデルが完全に自己対戦を通じてチームワーク、戦術、リアルタイム適応を学んだ。
- **2019 -- DeepMind AlphaStar が StarCraft II をマスター。** AlphaStar は非公開戦で[プロ選手を 10-1 で撃破](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/)、その後ヨーロッパサーバーで[グランドマスター到達](https://www.nature.com/articles/d41586-019-03298-6) -- 90,000 人中の上位 0.15%。不完全情報、リアルタイム判断、チェスや囲碁を遥かに凌駕する組合せ的行動空間を持つゲーム。Agent とは? モデルだ。訓練されたもの。スクリプトではない。
- **2019 -- Tencent 絶悟が王者栄耀を支配。** Tencent AI Lab の「絶悟」は 2019 年 8 月 2 日、世界チャンピオンカップで [KPL プロ選手を 5v5 で撃破](https://www.jiemian.com/article/3371171.html)。1v1 モードではプロが [15 戦中 1 勝のみ、8 分以上生存不可](https://developer.aliyun.com/article/851058)。訓練強度:1 日 = 人間の 440 年。2021 年までに全ヒーロープールで KPL プロを全面的に上回った。手書きのヒーロー相性表なし。スクリプト化されたチーム編成なし。自己対戦でゲーム全体をゼロから学んだモデル。
- **2024-2025 -- LLM Agent がソフトウェアエンジニアリングを再構築。** Claude、GPT、Gemini -- 人類のコードと推論の全幅で訓練された大規模言語モデル -- がコーディング Agent として展開される。コードベースを読み、実装を書き、障害をデバッグし、チームで協調する。アーキテクチャは先行するすべての Agent と同一:訓練されたモデルが環境に配置され、知覚と行動のツールを与えられる。唯一の違いは、学んだものの規模と解くタスクの汎用性。
すべてのマイルストーンが同じ真理を共有している:**"Agent" は決して周囲のコードではない。Agent は常にモデルそのものだ。**
### Agent ではないもの
"Agent" という言葉は、プロンプト配管工の産業全体に乗っ取られてしまった。
ドラッグ&ドロップのワークフロービルダー。ノーコード "AI Agent" プラットフォーム。プロンプトチェーン・オーケストレーションライブラリ。すべて同じ幻想を共有している:LLM API 呼び出しを if-else 分岐、ノードグラフ、ハードコードされたルーティングロジックで繋ぎ合わせることが "Agent の構築" だと。
違う。彼らが作ったものはルーブ・ゴールドバーグ・マシンだ -- 過剰に設計された脆い手続き的ルールのパイプライン。LLM は美化されたテキスト補完ノードとして押し込まれているだけ。それは Agent ではない。壮大な妄想を持つシェルスクリプトだ。
**プロンプト配管工式 "Agent" は、モデルを訓練しないプログラマーの妄想だ。** 手続き的ロジックを積み重ねて知能を力技で再現しようとする -- 巨大なルールツリー、ノードグラフ、チェーン・プロンプトの滝 -- そして十分なグルーコードがいつか自律的振る舞いを創発すると祈る。しない。工学的手段で Agency をコーディングすることはできない。Agency は学習されるものであって、プログラムされるものではない。
あのシステムたちは生まれた瞬間から死んでいる:脆弱で、スケールせず、汎化が根本的に不可能。GOFAIGood Old-Fashioned AI、古典的記号 AI)の現代版だ -- 何十年も前に学術界が放棄した記号ルールシステムが、LLM のペンキを塗り直して再登場した。パッケージが違うだけで、同じ袋小路。
### マインドシフト:「Agent を開発する」から Harness を開発する へ
「Agent を開発しています」と言うとき、意味できるのは二つだけだ:
**1. モデルを訓練する。** 強化学習、ファインチューニング、RLHF、その他の勾配ベースの手法で重みを調整する。タスクプロセスデータ -- 実ドメインにおける知覚・推論・行動の実際の系列 -- を収集し、モデルの振る舞いを形成する。DeepMind、OpenAI、Tencent AI Lab、Anthropic が行っていること。これが最も本来的な Agent 開発。
**2. Harness を構築する。** モデルに動作環境を提供するコードを書く。私たちの大半が行っていることであり、このリポジトリの核心。
Harness とは、Agent が特定のドメインで機能するために必要なすべて:
```
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
Tools: ファイル I/O、シェル、ネットワーク、データベース、ブラウザ
Knowledge: 製品ドキュメント、ドメイン資料、API 仕様、スタイルガイド
Observation: git diff、エラーログ、ブラウザ状態、センサーデータ
Action: CLI コマンド、API 呼び出し、UI インタラクション
Permissions: サンドボックス、承認ワークフロー、信頼境界
```
モデルが決断する。Harness が実行する。モデルが推論する。Harness がコンテキストを提供する。モデルはドライバー。Harness は車両。
**コーディング Agent の Harness は IDE、ターミナル、ファイルシステム。** 農業 Agent の Harness はセンサーアレイ、灌漑制御、気象データフィード。ホテル Agent の Harness は予約システム、ゲストコミュニケーションチャネル、施設管理 API。Agent -- 知性、意思決定者 -- は常にモデル。Harness はドメインごとに変わる。Agent はドメインを超えて汎化する。
このリポジトリは車両の作り方を教える。コーディング用の車両だ。だが設計パターンはあらゆるドメインに汎化する:農場管理、ホテル運営、工場製造、物流、医療、教育、科学研究。タスクが知覚され、推論され、実行される必要がある場所ならどこでも -- Agent には Harness が要る。
### Harness エンジニアの仕事
このリポジトリを読んでいるなら、あなたはおそらく Harness エンジニアだ -- それは強力なアイデンティティ。以下があなたの本当の仕事:
- **ツールの実装。** Agent に手を与える。ファイル読み書き、シェル実行、API 呼び出し、ブラウザ制御、データベースクエリ。各ツールは Agent が環境内で取れる行動。原子的で、組み合わせ可能で、記述が明確であるように設計する。
- **知識のキュレーション。** Agent にドメイン専門性を与える。製品ドキュメント、アーキテクチャ決定記録、スタイルガイド、規制要件。オンデマンドで読み込み(s05)、前もって詰め込まない。Agent は何が利用可能か知った上で、必要なものを自ら取得すべき。
- **コンテキストの管理。** Agent にクリーンな記憶を与える。サブ Agent 隔離(s04)がノイズの漏洩を防ぐ。コンテキスト圧縮(s06)が履歴の氾濫を防ぐ。タスクシステム(s07)が目標を単一の会話を超えて永続化する。
- **権限の制御。** Agent に境界を与える。ファイルアクセスのサンドボックス化。破壊的操作への承認要求。Agent と外部システム間の信頼境界の実施。安全工学と Harness 工学の交差点。
- **タスクプロセスデータの収集。** Agent があなたの Harness 内で実行するすべての行動系列は訓練シグナル。実デプロイメントの知覚-推論-行動トレースは、次世代 Agent モデルをファインチューニングする原材料。あなたの Harness は Agent に仕えるだけでなく -- Agent を進化させる助けにもなる。
あなたは知性を書いているのではない。知性が住まう世界を構築している。その世界の品質 -- Agent がどれだけ明瞭に知覚でき、どれだけ正確に行動でき、利用可能な知識がどれだけ豊かか -- が、知性がどれだけ効果的に自らを表現できるかを直接決定する。
**優れた Harness を作れ。Agent が残りをやる。**
### なぜ Claude Code か -- Harness Engineering の大師範
なぜこのリポジトリは特に Claude Code を解剖するのか?
Claude Code は私たちが見てきた中で最もエレガントで完成度の高い Agent Harness だからだ。単一の巧妙なトリックのためではなく、それが *しないこと* のために:Agent そのものになろうとしない。硬直的なワークフローを押し付けない。精緻な決定木でモデルを二度推しない。ツール、知識、コンテキスト管理、権限境界をモデルに提供し -- そして道を譲る。
Claude Code の本質を剥き出しにすると:
```
Claude Code = 一つの agent loop
+ ツール (bash, read, write, edit, glob, grep, browser...)
+ オンデマンド skill ロード
+ コンテキスト圧縮
+ サブ Agent スポーン
+ 依存グラフ付きタスクシステム
+ 非同期メールボックスによるチーム協調
+ worktree 分離による並列実行
+ 権限ガバナンス
```
これがすべてだ。これが全アーキテクチャ。すべてのコンポーネントは Harness メカニズム -- Agent が住む世界の一部。Agent そのものは? Claude だ。モデル。Anthropic が人類の推論とコードの全幅で訓練した。Harness が Claude を賢くしたのではない。Claude は元々賢い。Harness が Claude に手と目とワークスペースを与えた。
これが Claude Code が理想的な教材である理由だ:**モデルを信頼し、工学的努力を Harness に集中させるとどうなるかを示している。** このリポジトリの各セッション(s01-s12)は Claude Code アーキテクチャから一つの Harness メカニズムをリバースエンジニアリングする。終了時には、Claude Code の仕組みだけでなく、あらゆるドメインのあらゆる Agent に適用される Harness 工学の普遍的原則を理解している。
教訓は「Claude Code をコピーせよ」ではない。教訓は:**最高の Agent プロダクトは、自分の仕事が Harness であって Intelligence ではないと理解しているエンジニアが作る。**
---
## ビジョン:宇宙を本物の Agent で満たす
これはコーディング Agent だけの話ではない。
人間が複雑で多段階の判断集約的な仕事をしているすべてのドメインは、Agent が稼働できるドメインだ -- 正しい Harness さえあれば。このリポジトリのパターンは普遍的だ:
```
不動産管理 Agent = モデル + 物件センサー + メンテナンスツール + テナント通信
農業 Agent = モデル + 土壌/気象データ + 灌漑制御 + 作物知識
ホテル運営 Agent = モデル + 予約システム + ゲストチャネル + 施設 API
医学研究 Agent = モデル + 文献検索 + 実験機器 + プロトコル文書
製造 Agent = モデル + 生産ラインセンサー + 品質管理 + 物流
教育 Agent = モデル + カリキュラム知識 + 学生進捗 + 評価ツール
```
ループは常に同じ。ツールが変わる。知識が変わる。権限が変わる。Agent -- モデル -- がすべてを汎化する。
このリポジトリを読むすべての Harness エンジニアは、ソフトウェアエンジニアリングを遥かに超えたパターンを学んでいる。知的で自動化された未来のためのインフラストラクチャを構築することを学んでいる。実ドメインにデプロイされた優れた Harness の一つ一つが、Agent が知覚し、推論し、行動できる新たな拠点。
まずワークショップを満たす。次に農場、病院、工場。次に都市。次に惑星。
**Bash is all you need. Real agents are all the universe needs.**
---
```
THE AGENT PATTERN
=================
User --> messages[] --> LLM --> response
|
ツール呼び出しリクエストあり?
/ \
あり なし
| |
@Tool メソッドを実行 テキストを返す
結果を返却
ループ継続 -----------------> messages[]
最小ループ。すべての AI エージェントにこのループが必要。
モデルがツール呼び出しと停止を決める。
Spring AI の ChatClient.call() がこのループを自動駆動する。
本リポジトリはこのループを囲むすべて --
エージェントを特定ドメインで効果的にする Harness -- の作り方を教える。
```
**12 の段階的セッション、シンプルなループから分離された自律実行まで。**
**各セッションは 1 つの Harness メカニズムを追加する。各メカニズムには 1 つのモットーがある。**
> **s01** &nbsp; *"One loop & Bash is all you need"* &mdash; 1つのツール + 1つのループ = エージェント
>
> **s02** &nbsp; *"ツールを足すなら、ハンドラーを1つ足すだけ"* &mdash; ループは変わらない。新ツールは `@Tool` アノテーション + `defaultTools()` で登録するだけ
>
> **s03** &nbsp; *"計画のないエージェントは行き当たりばったり"* &mdash; まずステップを書き出し、それから実行
>
> **s04** &nbsp; *"大きなタスクを分割し、各サブタスクにクリーンなコンテキストを"* &mdash; サブエージェントは独立した messages[] を使い、メイン会話を汚さない
>
> **s05** &nbsp; *"必要な知識を、必要な時に読み込む"* &mdash; system prompt ではなく tool_result で注入
>
> **s06** &nbsp; *"コンテキストはいつか溢れる、空ける手段が要る"* &mdash; 3層圧縮で無限セッションを実現
>
> **s07** &nbsp; *"大きな目標を小タスクに分解し、順序付けし、ディスクに記録する"* &mdash; ファイルベースのタスクグラフ、マルチエージェント協調の基盤
>
> **s08** &nbsp; *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* &mdash; デーモンスレッドがコマンド実行、完了後に通知を注入
>
> **s09** &nbsp; *"一人で終わらないなら、チームメイトに任せる"* &mdash; 永続チームメイト + 非同期メールボックス
>
> **s10** &nbsp; *"チームメイト間には統一の通信ルールが必要"* &mdash; 1つの request-response パターンが全交渉を駆動
>
> **s11** &nbsp; *"チームメイトが自らボードを見て、仕事を取る"* &mdash; リーダーが逐一割り振る必要はない
>
> **s12** &nbsp; *"各自のディレクトリで作業し、互いに干渉しない"* &mdash; タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け
---
## コアパターン
```java
// Spring AI の ChatClient + @Tool 注解实现 Agent 循环
// 模型自动决定何时调用工具、何时返回文本 -- 循环由框架驱动
@SpringBootApplication
public class S01AgentLoop implements CommandLineRunner {
@Bean
public CommandLineRunner agentLoop(ChatClient.Builder builder) {
ChatClient chatClient = builder
.defaultSystem("You are a helpful assistant with access to tools.")
.defaultTools(new BashTool()) // 注册工具
.build();
return args -> {
// 一次 call() 内部自动完成: 调用模型 → 检测工具请求 → 执行工具 → 回传结果 → 再次调用模型...
String result = chatClient.prompt()
.user(userInput)
.call()
.content();
System.out.println(result);
};
}
}
// @Tool 注解让方法自动成为模型可调用的工具
public class BashTool {
@Tool(description = "Execute a shell command and return stdout/stderr")
public String executeBash(String command) {
// 执行命令并返回结果
}
}
```
Spring AI の `ChatClient.call()` は完全なエージェントループを内部にカプセル化:モデル呼び出し → ツール呼び出しリクエスト検出 → `@Tool` メソッド実行 → 結果をモデルに返却 → モデルがテキストを返すまで繰り返し。各セッションはこのループの上に 1 つの Harness メカニズムを重ねる -- ループ自体は変わらない。ループはエージェントのもの。メカニズムは Harness のもの。
## 範囲説明 (重要)
このリポジトリは Harness 工学の 0->1 学習プロジェクト -- Agent モデルを囲む環境の構築を学ぶ。
学習を優先するため、以下の本番メカニズムは意図的に簡略化または省略している:
- 完全なイベント / Hook バス (例: PreToolUse, SessionStart/End, ConfigChange)。
s12 では教材用に最小の追記型ライフサイクルイベントのみ実装。
- ルールベースの権限ガバナンスと信頼フロー
- セッションライフサイクル制御 (resume/fork) と高度な worktree ライフサイクル制御
- MCP ランタイムの詳細 (transport/OAuth/リソース購読/ポーリング)
このリポジトリの JSONL メールボックス方式は教材用の実装であり、特定の本番内部実装を主張するものではない。
## クイックスタート
### 環境要件
- **JDK 21+** (推奨 [Eclipse Temurin](https://adoptium.net/) または GraalVM)
- **Maven 3.9+**
- OpenAI プロトコル互換の LLM API Key (DeepSeek、智谱 GLM、通義千問、OpenAI 等)
### クローンとビルド
```sh
git clone https://github.com/abel533/claude-code
cd learn-claude-code
mvn compile # 编译项目
```
### 環境変数の設定
```sh
# Linux / macOS
export AI_API_KEY=your-api-key
export AI_BASE_URL=https://api.deepseek.com # 替换为你的模型服务商地址
export AI_MODEL=deepseek-chat # 替换为你使用的模型名称
# Windows PowerShell
$env:AI_API_KEY="your-api-key"
$env:AI_BASE_URL="https://api.deepseek.com"
$env:AI_MODEL="deepseek-chat"
```
### セッションの実行
```sh
# 从第一课开始
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
# 完整递进终点
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
# 总纲: 全部机制合一
mvn exec:java -Dexec.mainClass=io.mybatis.learn.full.SFullAgent
```
### Web プラットフォーム
インタラクティブな可視化、ステップスルーアニメーション、ソースビューア、各セッションのドキュメント。
```sh
cd web && npm install && npm run dev # http://localhost:3000
```
### Java 版の特色
本プロジェクトは **Java 21 + Spring Boot 3.5.7 + Spring AI 1.0.3** 技術スタックを採用しており、オリジナルの Python 版に比べ以下の特色がある:
- **複数のLLMプロバイダーに対応** -- OpenAI プロトコルを通じて DeepSeek、智谱 GLM、通義千問、Moonshot 等の国産モデルに対応、特定ベンダーに縛られない
- **`@Tool` アノテーションによるツール呼び出しループの自動処理** -- Spring AI フレームワークが「モデル呼び出し → ツール実行 → 結果返却」の完全なループを自動処理、while ループの手書き不要
- **Java 21 仮想スレッド** -- 軽量な並行処理でバックグラウンドタスクとマルチエージェント協調を実現、スレッドプール管理のオーバーヘッド不要
- **各セッション独立実行可能** -- 各セッションは `@SpringBootApplication` + `CommandLineRunner` であり、`mvn exec:java` 一行で起動可能
- **型安全** -- Java の強い型システムがコンパイル時にエラーを検出、IDE の自動補完にも優しい
## 学習パス
```
フェーズ1: ループ フェーズ2: 計画と知識
================== ==============================
s01 エージェントループ [1] s03 TodoWrite [5]
ChatClient + @Tool TodoManager + nag リマインダー
| |
+-> s02 Tool Use [4] s04 サブエージェント [5]
@Tool で複数ツール登録 各サブエージェントに独立 ChatClient
|
s05 Skills [5]
SKILL.md を tool_result で注入
|
s06 Context Compact [5]
3層コンテキスト圧縮
フェーズ3: 永続化 フェーズ4: チーム
================== =====================
s07 タスクシステム [8] s09 エージェントチーム [9]
ファイルベース CRUD + 依存グラフ チームメイト + JSONL メールボックス
| |
s08 バックグラウンドタスク [6] s10 チームプロトコル [12]
仮想スレッド + 通知キュー シャットダウン + プラン承認 FSM
|
s11 自律エージェント [14]
アイドルサイクル + 自動クレーム
|
s12 Worktree 分離 [16]
タスク調整 + 必要時の分離実行レーン
[N] = ツール数
```
## プロジェクト構成
```
learn-claude-code/
|
|-- src/main/java/io/mybatis/learn/ # Java 実装 (Spring AI + Spring Boot)
| |-- core/ # 共通ツールクラス (AgentRunner, BashTool, EditFileTool 等)
| |-- s01/ S01AgentLoop.java # セッション 01: エージェントループ
| |-- s02/ S02ToolUse.java # セッション 02: マルチツール登録
| |-- s03/ S03TodoWrite.java # セッション 03: 計画駆動
| |-- s04/ S04Subagent.java # セッション 04: サブエージェント
| |-- s05/ S05SkillLoading.java # セッション 05: Skill ロード
| |-- s06/ S06ContextCompact.java # セッション 06: コンテキスト圧縮
| |-- s07/ S07TaskSystem.java # セッション 07: タスクシステム
| |-- s08/ S08BackgroundTasks.java # セッション 08: バックグラウンドタスク
| |-- s09/ S09AgentTeams.java # セッション 09: エージェントチーム
| |-- s10/ S10TeamProtocols.java # セッション 10: チームプロトコル
| |-- s11/ S11AutonomousAgents.java# セッション 11: 自律エージェント
| |-- s12/ S12WorktreeIsolation.java# セッション 12: Worktree 分離
| +-- full/ SFullAgent.java # 総括: 全メカニズム統合
|
|-- agents/ # Python リファレンス実装 (オリジナル版、対照用に保存)
|-- docs/{en,zh,ja}/ # メンタルモデル優先のドキュメント (3言語)
|-- web/ # インタラクティブ学習プラットフォーム (Next.js)
|-- skills/ # s05 の Skill ファイル
|-- pom.xml # Maven ビルド設定 (Spring Boot 3.5.7 + Spring AI 1.0.3)
+-- .github/workflows/ci.yml # CI: 型チェック + ビルド
```
## ドキュメント
メンタルモデル優先: 問題、解決策、ASCII図、最小限のコード。
[English](./docs/en/) | [中文](./docs/zh/) | [日本語](./docs/ja/)
| セッション | トピック | モットー |
|-----------|---------|---------|
| [s01](./docs/ja/s01-the-agent-loop.md) | エージェントループ | *One loop & Bash is all you need* |
| [s02](./docs/ja/s02-tool-use.md) | Tool Use | *ツールを足すなら、ハンドラーを1つ足すだけ* |
| [s03](./docs/ja/s03-todo-write.md) | TodoWrite | *計画のないエージェントは行き当たりばったり* |
| [s04](./docs/ja/s04-subagent.md) | サブエージェント | *大きなタスクを分割し、各サブタスクにクリーンなコンテキストを* |
| [s05](./docs/ja/s05-skill-loading.md) | Skills | *必要な知識を、必要な時に読み込む* |
| [s06](./docs/ja/s06-context-compact.md) | Context Compact | *コンテキストはいつか溢れる、空ける手段が要る* |
| [s07](./docs/ja/s07-task-system.md) | タスクシステム | *大きな目標を小タスクに分解し、順序付けし、ディスクに記録する* |
| [s08](./docs/ja/s08-background-tasks.md) | バックグラウンドタスク | *遅い操作はバックグラウンドへ、エージェントは次を考え続ける* |
| [s09](./docs/ja/s09-agent-teams.md) | エージェントチーム | *一人で終わらないなら、チームメイトに任せる* |
| [s10](./docs/ja/s10-team-protocols.md) | チームプロトコル | *チームメイト間には統一の通信ルールが必要* |
| [s11](./docs/ja/s11-autonomous-agents.md) | 自律エージェント | *チームメイトが自らボードを見て、仕事を取る* |
| [s12](./docs/ja/s12-worktree-task-isolation.md) | Worktree + タスク分離 | *各自のディレクトリで作業し、互いに干渉しない* |
## 次のステップ -- 理解から出荷へ
12 セッションを終えれば、Harness 工学の内部構造を完全に理解している。その知識を活かす 2 つの方法:
### Kode Agent CLI -- オープンソース Coding Agent CLI
> `npm i -g @shareai-lab/kode`
Skill & LSP 対応、Windows 対応、GLM / MiniMax / DeepSeek 等のオープンモデルに接続可能。インストールしてすぐ使える。
GitHub: **[shareAI-lab/Kode-cli](https://github.com/shareAI-lab/Kode-cli)**
### Kode Agent SDK -- アプリにエージェント機能を埋め込む
公式 Claude Code Agent SDK は内部で完全な CLI プロセスと通信する -- 同時ユーザーごとに独立のターミナルプロセスが必要。Kode SDK は独立ライブラリでユーザーごとのプロセスオーバーヘッドがなく、バックエンド、ブラウザ拡張、組み込みデバイス等に埋め込み可能。
GitHub: **[shareAI-lab/Kode-agent-sdk](https://github.com/shareAI-lab/Kode-agent-sdk)**
---
## 姉妹教材: *オンデマンドセッション*から*常時稼働アシスタント*へ
本リポジトリが教える Harness は **使い捨て型** -- ターミナルを開き、Agent にタスクを与え、終わったら閉じる。次のセッションは白紙から始まる。Claude Code のモデル。
[OpenClaw](https://github.com/openclaw/openclaw) は別の可能性を証明した: 同じ agent core の上に 2 つの Harness メカニズムを追加するだけで、Agent は「突かないと動かない」から「30 秒ごとに自分で起きて仕事を探す」に変わる:
- **ハートビート** -- 30 秒ごとに Harness が Agent にメッセージを送り、やることがあるか確認させる。なければスリープ続行、あれば即座に行動。
- **Cron** -- Agent が自ら未来のタスクをスケジュールし、時間が来たら自動実行。
さらにマルチチャネル IM ルーティング (WhatsApp / Telegram / Slack / Discord 等 13+ プラットフォーム)、永続コンテキストメモリ、Soul パーソナリティシステムを加えると、Agent は使い捨てツールから常時稼働のパーソナル AI アシスタントへ変貌する。
**[claw0](https://github.com/shareAI-lab/claw0)** はこれらの Harness メカニズムをゼロから分解する姉妹教材リポジトリ:
```
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
```
```
learn-claude-code claw0
(agent harness コア: (能動的な常時稼働 harness:
ループ、ツール、計画、 ハートビート、cron、IM チャネル、
チーム、worktree 分離) メモリ、Soul パーソナリティ)
```
## ライセンス
MIT
---
**モデルが Agent だ。コードは Harness だ。優れた Harness を作れ。Agent が残りをやる。**
**Bash is all you need. Real agents are all the universe needs.**
+271 -408
View File
@@ -5,444 +5,307 @@
> | 分支 | 说明 |
> |------|------|
> | [`main`](../../tree/main) | **Claude Code Java** — 使用 Java + Spring AI 重写的 Claude Code CLI AI 编码助手 |
> | [`claude`](../../tree/claude) | **Claude Code 源码** — Claude Code TypeScript 源码快照,用于安全研究和架构学习 |
> | [`learn`](../../tree/learn) | **Learn Claude Code** — 拆解 Claude Code Agent Harness 架构的教学项目,含 12 节渐进式课程(当前分支) |
> | [`claude`](../../tree/claude) | **Claude Code 源码** — Claude Code TypeScript 源码快照,用于安全研究和架构学习(当前分支) |
> | [`learn`](../../tree/learn) | **Learn Claude Code** — 拆解 Claude Code Agent Harness 架构的教学项目,含 12 节渐进式课程 |
# Learn Claude Code -- 真正的 Agent Harness 工程
# Claude Code Source Snapshot for Security Research
[English](./README-en.md) | [中文](./README.md) | [日本語](./README-ja.md)
## 模型就是 Agent
在讨论代码之前,先把一件事彻底说清楚。
**Agent 是模型。不是框架。不是提示词链。不是拖拽式工作流。**
### Agent 到底是什么
Agent 是一个神经网络 -- Transformer、RNN、一个被训练出来的函数 -- 经过数十亿次梯度更新,在行动序列数据上学会了感知环境、推理目标、采取行动。"Agent" 这个词在 AI 领域从诞生之日起就是这个意思。从来都是。
人类就是 agent。一个由数百万年进化训练出来的生物神经网络,通过感官感知世界,通过大脑推理,通过身体行动。当 DeepMind、OpenAI 或 Anthropic 说 "agent" 时,他们说的和这个领域自诞生以来就一直在说的完全一样:**一个学会了行动的模型。**
历史已经写好了铁证:
- **2013 -- DeepMind DQN 玩 Atari。** 一个神经网络,只接收原始像素和游戏分数,学会了 7 款 Atari 2600 游戏 -- 超越所有先前算法,在其中 3 款上击败人类专家。到 2015 年,同一架构扩展到 [49 款游戏,达到职业人类测试员水平](https://www.nature.com/articles/nature14236),论文发表在 *Nature*。没有游戏专属规则。没有决策树。一个模型,从经验中学习。那个模型就是 agent。
- **2019 -- OpenAI Five 征服 Dota 2。** 五个神经网络,在 10 个月内与自己对战了 [45,000 年的 Dota 2](https://openai.com/index/openai-five-defeats-dota-2-world-champions/),在旧金山直播赛上 2-0 击败了 **OG** -- TI8 世界冠军。随后的公开竞技场中,AI 在 42,729 场比赛中胜率 99.4%。没有脚本化的策略。没有元编程的团队协调逻辑。模型完全通过自我对弈学会了团队协作、战术和实时适应。
- **2019 -- DeepMind AlphaStar 制霸星际争霸 II。** AlphaStar 在闭门赛中 [10-1 击败职业选手](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/),随后在欧洲服务器上达到[宗师段位](https://www.nature.com/articles/d41586-019-03298-6) -- 90,000 名玩家中的前 0.15%。一个信息不完全、实时决策、组合动作空间远超国际象棋和围棋的游戏。Agent 是什么?是模型。训练出来的。不是编出来的。
- **2019 -- 腾讯绝悟统治王者荣耀。** 腾讯 AI Lab 的 "绝悟" 于 2019 年 8 月 2 日世冠杯半决赛上[以 5v5 击败 KPL 职业选手](https://www.jiemian.com/article/3371171.html)。在 1v1 模式下,职业选手 [15 场只赢 1 场,最多坚持不到 8 分钟](https://developer.aliyun.com/article/851058)。训练强度:一天等于人类 440 年。到 2021 年,绝悟在全英雄池 BO5 上全面超越 KPL 职业选手水准。没有手工编写的英雄克制表。没有脚本化的阵容编排。一个从零开始通过自我对弈学习整个游戏的模型。
- **2024-2025 -- LLM Agent 重塑软件工程。** Claude、GPT、Gemini -- 在人类全部代码和推理上训练的大语言模型 -- 被部署为编程 agent。它们阅读代码库,编写实现,调试故障,团队协作。架构与之前每一个 agent 完全相同:一个训练好的模型,放入一个环境,给予感知和行动的工具。唯一的不同是它们学到的东西的规模和解决任务的通用性。
每一个里程碑都共享同一个真理:**"Agent" 从来都不是外面那层代码。Agent 永远是模型本身。**
### Agent 不是什么
"Agent" 这个词已经被一整个提示词水管工产业劫持了。
拖拽式工作流构建器。无代码 "AI Agent" 平台。提示词链编排库。它们共享同一个幻觉:把 LLM API 调用用 if-else 分支、节点图、硬编码路由逻辑串在一起就算是 "构建 Agent" 了。
不是的。它们做出来的东西是鲁布·戈德堡机械 -- 一个过度工程化的、脆弱的过程式规则流水线,LLM 被楔在里面当一个美化了的文本补全节点。那不是 Agent。那是一个有着宏大妄想的 shell 脚本。
**提示词水管工式 "Agent" 是不做模型的程序员的意淫。** 他们试图通过堆叠过程式逻辑来暴力模拟智能 -- 庞大的规则树、节点图、链式提示词瀑布流 -- 然后祈祷足够多的胶水代码能涌现出自主行为。不会的。你不可能通过工程手段编码出 agency。Agency 是学出来的,不是编出来的。
那些系统从诞生之日起就已经死了:脆弱、不可扩展、根本不具备泛化能力。它们是 GOFAIGood Old-Fashioned AI,经典符号 AI)的现代还魂 -- 几十年前就被学界抛弃的符号规则系统,现在喷了一层 LLM 的漆又登场了。换了个包装,同一条死路。
### 心智转换:从 "开发 Agent" 到开发 Harness
当一个人说 "我在开发 Agent" 时,他只可能是两个意思之一:
**1. 训练模型。** 通过强化学习、微调、RLHF 或其他基于梯度的方法调整权重。收集任务过程数据 -- 真实领域中感知、推理、行动的实际序列 -- 用它们来塑造模型的行为。这是 DeepMind、OpenAI、腾讯 AI Lab、Anthropic 在做的事。这是最本义的 Agent 开发。
**2. 构建 Harness。** 编写代码,为模型提供一个可操作的环境。这是我们大多数人在做的事,也是本仓库的核心。
Harness 是 agent 在特定领域工作所需要的一切:
```
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
Tools: 文件读写、Shell、网络、数据库、浏览器
Knowledge: 产品文档、领域资料、API 规范、风格指南
Observation: git diff、错误日志、浏览器状态、传感器数据
Action: CLI 命令、API 调用、UI 交互
Permissions: 沙箱隔离、审批流程、信任边界
```
模型做决策。Harness 执行。模型做推理。Harness 提供上下文。模型是驾驶者。Harness 是载具。
**编程 agent 的 harness 是它的 IDE、终端和文件系统。** 农业 agent 的 harness 是传感器阵列、灌溉控制和气象数据。酒店 agent 的 harness 是预订系统、客户沟通渠道和设施管理 API。Agent -- 那个智能、那个决策者 -- 永远是模型。Harness 因领域而变。Agent 跨领域泛化。
这个仓库教你造载具。编程用的载具。但设计模式可以泛化到任何领域:庄园管理、农田运营、酒店运作、工厂制造、物流调度、医疗保健、教育培训、科学研究。只要有一个任务需要被感知、推理和执行 -- agent 就需要一个 harness。
### Harness 工程师到底在做什么
如果你在读这个仓库,你很可能是一名 harness 工程师 -- 这是一个强大的身份。以下是你真正的工作:
- **实现工具。** 给 agent 一双手。文件读写、Shell 执行、API 调用、浏览器控制、数据库查询。每个工具都是 agent 在环境中可以采取的一个行动。设计它们时要原子化、可组合、描述清晰。
- **策划知识。** 给 agent 领域专长。产品文档、架构决策记录、风格指南、合规要求。按需加载(s05),不要前置塞入。Agent 应该知道有什么可用,然后自己拉取所需。
- **管理上下文。** 给 agent 干净的记忆。子 agent 隔离(s04)防止噪声泄露。上下文压缩(s06)防止历史淹没。任务系统(s07)让目标持久化到单次对话之外。
- **控制权限。** 给 agent 边界。沙箱化文件访问。对破坏性操作要求审批。在 agent 和外部系统之间实施信任边界。这是安全工程与 harness 工程的交汇点。
- **收集任务过程数据。** Agent 在你的 harness 中执行的每一条行动序列都是训练信号。真实部署中的感知-推理-行动轨迹是微调下一代 agent 模型的原材料。你的 harness 不仅服务于 agent -- 它还可以帮助进化 agent。
你不是在编写智能。你是在构建智能栖居的世界。这个世界的质量 -- agent 能看得多清楚、行动得多精准、可用知识有多丰富 -- 直接决定了智能能多有效地表达自己。
**造好 Harness。Agent 会完成剩下的。**
### 为什么是 Claude Code -- Harness 工程的大师课
为什么这个仓库专门拆解 Claude Code
因为 Claude Code 是我们所见过的最优雅、最完整的 agent harness 实现。不是因为某个巧妙的技巧,而是因为它 *没做* 的事:它没有试图成为 agent 本身。它没有强加僵化的工作流。它没有用精心设计的决策树去替模型做判断。它给模型提供了工具、知识、上下文管理和权限边界 -- 然后让开了。
把 Claude Code 剥到本质来看:
```
Claude Code = 一个 agent loop
+ 工具 (bash, read, write, edit, glob, grep, browser...)
+ 按需 skill 加载
+ 上下文压缩
+ 子 agent 派生
+ 带依赖图的任务系统
+ 异步邮箱的团队协调
+ worktree 隔离的并行执行
+ 权限治理
```
就这些。这就是全部架构。每一个组件都是 harness 机制 -- 为 agent 构建的栖居世界的一部分。Agent 本身呢?是 Claude。一个模型。由 Anthropic 在人类推理和代码的全部广度上训练而成。Harness 没有让 Claude 变聪明。Claude 本来就聪明。Harness 给了 Claude 双手、双眼和一个工作空间。
这就是 Claude Code 作为教学标本的意义:**它展示了当你信任模型、把工程精力集中在 harness 上时会发生什么。** 本仓库的每一个课程(s01-s12)都在逆向工程 Claude Code 架构中的一个 harness 机制。学完之后,你理解的不只是 Claude Code 怎么工作,而是适用于任何领域、任何 agent 的 harness 工程通用原则。
启示不是 "复制 Claude Code"。启示是:**最好的 agent 产品,出自那些明白自己的工作是 harness 而非 intelligence 的工程师之手。**
> This repository mirrors a **publicly exposed Claude Code source snapshot** that became accessible on **March 31, 2026** through a source map exposure in the npm distribution. It is maintained for **educational, defensive security research, and software supply-chain analysis**.
---
## 愿景:用真正的 Agent 铺满宇宙
## Research Context
这不只关乎编程 agent。
This repository is maintained by a **university student** studying:
每一个人类从事复杂、多步骤、需要判断力的工作的领域,都是 agent 可以运作的领域 -- 只要有对的 harness。本仓库中的模式是通用的:
- software supply-chain exposure and build artifact leaks
- secure software engineering practices
- agentic developer tooling architecture
- defensive analysis of real-world CLI systems
```
庄园管理 agent = 模型 + 物业传感器 + 维护工具 + 租户通信
农业 agent = 模型 + 土壤/气象数据 + 灌溉控制 + 作物知识
酒店运营 agent = 模型 + 预订系统 + 客户渠道 + 设施 API
医学研究 agent = 模型 + 文献检索 + 实验仪器 + 协议文档
制造业 agent = 模型 + 产线传感器 + 质量控制 + 物流系统
教育 agent = 模型 + 课程知识 + 学生进度 + 评估工具
```
This archive is intended to support:
循环永远不变。工具在变。知识在变。权限在变。Agent -- 那个模型 -- 泛化一切。
- educational study
- security research practice
- architecture review
- discussion of packaging and release-process failures
每一个读这个仓库的 harness 工程师都在学习远超软件工程的模式。你在学习为一个智能的、自动化的未来构建基础设施。每一个部署在真实领域的好 harness,都是 agent 能够感知、推理、行动的又一个阵地。
Related research writing:
先铺满工作室。然后是农田、医院、工厂。然后是城市。然后是星球。
- [Local archive copy — March 9, 2026: “Is legal the same as legitimate: AI reimplementation and the erosion of copyleft”](2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md)
- [Original publication — Hong Minhee, *Is legal the same as legitimate: AI reimplementation and the erosion of copyleft*](https://writings.hongminhee.org/2026/03/legal-vs-legitimate/)
**Bash is all you need. Real agents are all the universe needs.**
The essay is dated **March 9, 2026**, so it should be read as companion analysis that predates the **March 31, 2026** source exposure documented below.
## Why this archive exists (and what it is not)
I initially kept this repository as a source-exposure archive so I could study the harness, tool wiring, and agent workflow. After sitting with the legal and ethical questions more seriously—and after reading Hong Minhee's essay—I no longer wanted the README to treat raw legality as the only frame.
This branch therefore takes a smaller and more honest step: it preserves Hong Minhee's essay as companion reading and makes the archive's research-only framing more explicit. This repository is still a mirrored TypeScript source snapshot for analysis; it is **not** a clean-room or Python rewrite.
## Built with `oh-my-codex`
The README/essay archival work on this branch was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), a workflow layer built around Codex.
- **`$team` mode:** used for coordinated parallel review of repo fit, wording risk, and final architecture consistency.
- **`$ralph` mode:** used for persistent execution, verification, and final architect sign-off before claiming completion.
- **Codex-driven workflow:** this documentation/contextualization pass was completed with Codex under OmX orchestration.
It does **not** claim ownership of the original code, and it should not be interpreted as an official Anthropic repository.
---
```
THE AGENT PATTERN
=================
## How the Public Snapshot Became Accessible
User --> messages[] --> LLM --> response
|
还有工具调用请求?
/ \
有 无
| |
执行 @Tool 方法 返回文本
回传结果
继续循环 -----------------> messages[]
[Chaofan Shou (@Fried_rice)](https://x.com/Fried_rice) publicly noted that Claude Code source material was reachable through a `.map` file exposed in the npm package:
> **"Claude code source code has been leaked via a map file in their npm registry!"**
>
> — [@Fried_rice, March 31, 2026](https://x.com/Fried_rice/status/2038894956459290963)
这是最小循环。每个 AI Agent 都需要这个循环。
模型决定何时调用工具、何时停止。
Spring AI 的 ChatClient.call() 自动驱动此循环。
本仓库教你构建围绕这个循环的一切 --
让 agent 在特定领域高效工作的 harness。
```
**12 个递进式课程, 从简单循环到隔离化的自治执行。**
**每个课程添加一个 harness 机制。每个机制有一句格言。**
> **s01** &nbsp; *"One loop & Bash is all you need"* &mdash; 一个工具 + 一个循环 = 一个智能体
>
> **s02** &nbsp; *"加一个工具, 只加一个 handler"* &mdash; 循环不用动, 新工具用 `@Tool` 注解 + `defaultTools()` 注册就行
>
> **s03** &nbsp; *"没有计划的 agent 走哪算哪"* &mdash; 先列步骤再动手, 完成率翻倍
>
> **s04** &nbsp; *"大任务拆小, 每个小任务干净的上下文"* &mdash; 子智能体用独立 messages[], 不污染主对话
>
> **s05** &nbsp; *"用到什么知识, 临时加载什么知识"* &mdash; 通过 tool_result 注入, 不塞 system prompt
>
> **s06** &nbsp; *"上下文总会满, 要有办法腾地方"* &mdash; 三层压缩策略, 换来无限会话
>
> **s07** &nbsp; *"大目标要拆成小任务, 排好序, 记在磁盘上"* &mdash; 文件持久化的任务图, 为多 agent 协作打基础
>
> **s08** &nbsp; *"慢操作丢后台, agent 继续想下一步"* &mdash; 后台线程跑命令, 完成后注入通知
>
> **s09** &nbsp; *"任务太大一个人干不完, 要能分给队友"* &mdash; 持久化队友 + 异步邮箱
>
> **s10** &nbsp; *"队友之间要有统一的沟通规矩"* &mdash; 一个 request-response 模式驱动所有协商
>
> **s11** &nbsp; *"队友自己看看板, 有活就认领"* &mdash; 不需要领导逐个分配, 自组织
>
> **s12** &nbsp; *"各干各的目录, 互不干扰"* &mdash; 任务管目标, worktree 管目录, 按 ID 绑定
The published source map referenced unobfuscated TypeScript sources hosted in Anthropic's R2 storage bucket, which made the `src/` snapshot publicly downloadable.
---
## 核心模式
## Repository Scope
```java
// Spring AI 的 ChatClient + @Tool 注解实现 Agent 循环
// 模型自动决定何时调用工具、何时返回文本 -- 循环由框架驱动
Claude Code is Anthropic's CLI for interacting with Claude from the terminal to perform software engineering tasks such as editing files, running commands, searching codebases, and coordinating workflows.
@SpringBootApplication
public class S01AgentLoop implements CommandLineRunner {
This repository contains a mirrored `src/` snapshot for research and analysis.
@Bean
public CommandLineRunner agentLoop(ChatClient.Builder builder) {
ChatClient chatClient = builder
.defaultSystem("You are a helpful assistant with access to tools.")
.defaultTools(new BashTool()) // 注册工具
.build();
return args -> {
// 一次 call() 内部自动完成: 调用模型 → 检测工具请求 → 执行工具 → 回传结果 → 再次调用模型...
String result = chatClient.prompt()
.user(userInput)
.call()
.content();
System.out.println(result);
};
}
}
// @Tool 注解让方法自动成为模型可调用的工具
public class BashTool {
@Tool(description = "Execute a shell command and return stdout/stderr")
public String executeBash(String command) {
// 执行命令并返回结果
}
}
```
Spring AI 的 `ChatClient.call()` 内部封装了完整的 agent 循环:调用大模型 → 检测工具调用请求 → 执行 `@Tool` 方法 → 将结果回传模型 → 重复直到模型返回文本。每个课程在这个循环之上叠加一个 harness 机制 -- 循环本身始终不变。循环属于 agent。机制属于 harness。
## 范围说明 (重要)
本仓库是一个 0->1 的 harness 工程学习项目 -- 构建围绕 agent 模型的工作环境。
为保证学习路径清晰,仓库有意简化或省略了部分生产机制:
- 完整事件 / Hook 总线 (例如 PreToolUse、SessionStart/End、ConfigChange)。
s12 仅提供教学用途的最小 append-only 生命周期事件流。
- 基于规则的权限治理与信任流程
- 会话生命周期控制 (resume/fork) 与更完整的 worktree 生命周期控制
- 完整 MCP 运行时细节 (transport/OAuth/资源订阅/轮询)
仓库中的团队 JSONL 邮箱协议是教学实现,不是对任何特定生产内部实现的声明。
## 快速开始
### 环境要求
- **JDK 21+** (推荐 [Eclipse Temurin](https://adoptium.net/) 或 GraalVM)
- **Maven 3.9+**
- 一个兼容 OpenAI 协议的大模型 API Key (DeepSeek、智谱 GLM、通义千问、OpenAI 等)
### 克隆与构建
```sh
git clone https://github.com/abel533/claude-code
cd learn-claude-code
mvn compile # 编译项目
```
### 设置环境变量
```sh
# Linux / macOS
export AI_API_KEY=your-api-key
export AI_BASE_URL=https://api.deepseek.com # 替换为你的模型服务商地址
export AI_MODEL=deepseek-chat # 替换为你使用的模型名称
# Windows PowerShell
$env:AI_API_KEY="your-api-key"
$env:AI_BASE_URL="https://api.deepseek.com"
$env:AI_MODEL="deepseek-chat"
```
### 运行课程
```sh
# 从第一课开始
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
# 完整递进终点
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
# 总纲: 全部机制合一
mvn exec:java -Dexec.mainClass=io.mybatis.learn.full.SFullAgent
```
### Web 平台
交互式可视化、分步动画、源码查看器, 以及每个课程的文档。
```sh
cd web && npm install && npm run dev # http://localhost:3000
```
### Java 版本特色
本项目使用 **Java 21 + Spring Boot 3.5.7 + Spring AI 1.0.3** 技术栈,相比原始 Python 版本有以下特色:
- **兼容多种大模型服务商** -- 通过 OpenAI 协议适配 DeepSeek、智谱 GLM、通义千问、Moonshot 等国产模型,无需绑定特定厂商
- **`@Tool` 注解自动处理工具调用循环** -- Spring AI 框架自动完成"模型调用 → 工具执行 → 结果回传"的完整循环,无需手写 while 循环
- **Java 21 虚拟线程** -- 轻量级并发实现后台任务与多智能体协作,无需线程池管理开销
- **每节课独立可运行** -- 每个课程都是一个 `@SpringBootApplication` + `CommandLineRunner``mvn exec:java` 一行命令即可启动
- **类型安全** -- Java 强类型系统在编译期捕获错误,IDE 自动补全友好
## 学习路径
```
第一阶段: 循环 第二阶段: 规划与知识
================== ==============================
s01 Agent 循环 [1] s03 TodoWrite [5]
ChatClient + @Tool TodoManager + nag 提醒
| |
+-> s02 Tool Use [4] s04 子智能体 [5]
@Tool 注册多个工具 每个子智能体独立 ChatClient
|
s05 Skills [5]
SKILL.md 通过 tool_result 注入
|
s06 Context Compact [5]
三层上下文压缩
第三阶段: 持久化 第四阶段: 团队
================== =====================
s07 任务系统 [8] s09 智能体团队 [9]
文件持久化 CRUD + 依赖图 队友 + JSONL 邮箱
| |
s08 后台任务 [6] s10 团队协议 [12]
虚拟线程 + 通知队列 关机 + 计划审批 FSM
|
s11 自治智能体 [14]
空闲轮询 + 自动认领
|
s12 Worktree 隔离 [16]
任务协调 + 按需隔离执行通道
[N] = 工具数量
```
## 项目结构
```
learn-claude-code/
|
|-- src/main/java/io/mybatis/learn/ # Java 实现 (Spring AI + Spring Boot)
| |-- core/ # 公共工具类 (AgentRunner, BashTool, EditFileTool 等)
| |-- s01/ S01AgentLoop.java # 课程 01: Agent 循环
| |-- s02/ S02ToolUse.java # 课程 02: 多工具注册
| |-- s03/ S03TodoWrite.java # 课程 03: 计划驱动
| |-- s04/ S04Subagent.java # 课程 04: 子智能体
| |-- s05/ S05SkillLoading.java # 课程 05: Skill 加载
| |-- s06/ S06ContextCompact.java # 课程 06: 上下文压缩
| |-- s07/ S07TaskSystem.java # 课程 07: 任务系统
| |-- s08/ S08BackgroundTasks.java # 课程 08: 后台任务
| |-- s09/ S09AgentTeams.java # 课程 09: 智能体团队
| |-- s10/ S10TeamProtocols.java # 课程 10: 团队协议
| |-- s11/ S11AutonomousAgents.java# 课程 11: 自治智能体
| |-- s12/ S12WorktreeIsolation.java# 课程 12: Worktree 隔离
| +-- full/ SFullAgent.java # 总纲: 全部机制合一
|
|-- agents/ # Python 参考实现 (原始版本, 保留作为对照)
|-- docs/{en,zh,ja}/ # 心智模型优先的文档 (3 种语言)
|-- web/ # 交互式学习平台 (Next.js)
|-- skills/ # s05 的 Skill 文件
|-- pom.xml # Maven 构建配置 (Spring Boot 3.5.7 + Spring AI 1.0.3)
+-- .github/workflows/ci.yml # CI: 类型检查 + 构建
```
## 文档
心智模型优先: 问题、方案、ASCII 图、最小化代码。
[English](./docs/en/) | [中文](./docs/zh/) | [日本語](./docs/ja/)
| 课程 | 主题 | 格言 |
|------|------|------|
| [s01](./docs/zh/s01-the-agent-loop.md) | Agent 循环 | *One loop & Bash is all you need* |
| [s02](./docs/zh/s02-tool-use.md) | Tool Use | *加一个工具, 只加一个 handler* |
| [s03](./docs/zh/s03-todo-write.md) | TodoWrite | *没有计划的 agent 走哪算哪* |
| [s04](./docs/zh/s04-subagent.md) | 子智能体 | *大任务拆小, 每个小任务干净的上下文* |
| [s05](./docs/zh/s05-skill-loading.md) | Skills | *用到什么知识, 临时加载什么知识* |
| [s06](./docs/zh/s06-context-compact.md) | Context Compact | *上下文总会满, 要有办法腾地方* |
| [s07](./docs/zh/s07-task-system.md) | 任务系统 | *大目标要拆成小任务, 排好序, 记在磁盘上* |
| [s08](./docs/zh/s08-background-tasks.md) | 后台任务 | *慢操作丢后台, agent 继续想下一步* |
| [s09](./docs/zh/s09-agent-teams.md) | 智能体团队 | *任务太大一个人干不完, 要能分给队友* |
| [s10](./docs/zh/s10-team-protocols.md) | 团队协议 | *队友之间要有统一的沟通规矩* |
| [s11](./docs/zh/s11-autonomous-agents.md) | 自治智能体 | *队友自己看看板, 有活就认领* |
| [s12](./docs/zh/s12-worktree-task-isolation.md) | Worktree + 任务隔离 | *各干各的目录, 互不干扰* |
## 学完之后 -- 从理解到落地
12 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
### Kode Agent CLI -- 开源 Coding Agent CLI
> `npm i -g @shareai-lab/kode`
支持 Skill & LSP, 适配 Windows, 可接 GLM / MiniMax / DeepSeek 等开放模型。装完即用。
GitHub: **[shareAI-lab/Kode-cli](https://github.com/shareAI-lab/Kode-cli)**
### Kode Agent SDK -- 把 Agent 能力嵌入你的应用
官方 Claude Code Agent SDK 底层与完整 CLI 进程通信 -- 每个并发用户 = 一个终端进程。Kode SDK 是独立库, 无 per-user 进程开销, 可嵌入后端、浏览器插件、嵌入式设备等任意运行时。
GitHub: **[shareAI-lab/Kode-agent-sdk](https://github.com/shareAI-lab/Kode-agent-sdk)**
- **Public exposure identified on**: 2026-03-31
- **Language**: TypeScript
- **Runtime**: Bun
- **Terminal UI**: React + [Ink](https://github.com/vadimdemedes/ink)
- **Scale**: ~1,900 files, 512,000+ lines of code
---
## 姊妹教程: 从*被动临时会话*到*主动常驻助手*
本仓库教的 harness 属于 **用完即走** 型 -- 开终端、给 agent 任务、做完关掉, 下次重开是全新会话。Claude Code 就是这种模式。
但 [OpenClaw](https://github.com/openclaw/openclaw) 证明了另一种可能: 在同样的 agent core 之上, 加两个 harness 机制就能让 agent 从 "踹一下动一下" 变成 "自己隔 30 秒醒一次找活干":
- **心跳 (Heartbeat)** -- 每 30 秒 harness 给 agent 发一条消息, 让它检查有没有事可做。没事就继续睡, 有事立刻行动。
- **定时任务 (Cron)** -- agent 可以给自己安排未来要做的事, 到点自动执行。
再加上 IM 多通道路由 (WhatsApp/Telegram/Slack/Discord 等 13+ 平台)、不清空的上下文记忆、Soul 人格系统, agent 就从一个临时工具变成了始终在线的个人 AI 助手。
**[claw0](https://github.com/shareAI-lab/claw0)** 是我们的姊妹教学仓库, 从零拆解这些 harness 机制:
## Directory Structure
```text
src/
├── main.tsx # Entrypoint orchestration (Commander.js-based CLI path)
├── commands.ts # Command registry
├── tools.ts # Tool registry
├── Tool.ts # Tool type definitions
├── QueryEngine.ts # LLM query engine
├── context.ts # System/user context collection
├── cost-tracker.ts # Token cost tracking
├── commands/ # Slash command implementations (~50)
├── tools/ # Agent tool implementations (~40)
├── components/ # Ink UI components (~140)
├── hooks/ # React hooks
├── services/ # External service integrations
├── screens/ # Full-screen UIs (Doctor, REPL, Resume)
├── types/ # TypeScript type definitions
├── utils/ # Utility functions
├── bridge/ # IDE and remote-control bridge
├── coordinator/ # Multi-agent coordinator
├── plugins/ # Plugin system
├── skills/ # Skill system
├── keybindings/ # Keybinding configuration
├── vim/ # Vim mode
├── voice/ # Voice input
├── remote/ # Remote sessions
├── server/ # Server mode
├── memdir/ # Persistent memory directory
├── tasks/ # Task management
├── state/ # State management
├── migrations/ # Config migrations
├── schemas/ # Config schemas (Zod)
├── entrypoints/ # Initialization logic
├── ink/ # Ink renderer wrapper
├── buddy/ # Companion sprite
├── native-ts/ # Native TypeScript utilities
├── outputStyles/ # Output styling
├── query/ # Query pipeline
└── upstreamproxy/ # Proxy configuration
```
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
```
```
learn-claude-code claw0
(agent harness 内核: (主动式常驻 harness:
循环、工具、规划、 心跳、定时任务、IM 通道、
团队、worktree 隔离) 记忆、Soul 人格)
```
## 许可证
MIT
---
**模型就是 Agent。代码是 Harness。造好 HarnessAgent 会完成剩下的。**
## Architecture Summary
**Bash is all you need. Real agents are all the universe needs.**
### 1. Tool System (`src/tools/`)
Every tool Claude Code can invoke is implemented as a self-contained module. Each tool defines its input schema, permission model, and execution logic.
| Tool | Description |
|---|---|
| `BashTool` | Shell command execution |
| `FileReadTool` | File reading (images, PDFs, notebooks) |
| `FileWriteTool` | File creation / overwrite |
| `FileEditTool` | Partial file modification (string replacement) |
| `GlobTool` | File pattern matching search |
| `GrepTool` | ripgrep-based content search |
| `WebFetchTool` | Fetch URL content |
| `WebSearchTool` | Web search |
| `AgentTool` | Sub-agent spawning |
| `SkillTool` | Skill execution |
| `MCPTool` | MCP server tool invocation |
| `LSPTool` | Language Server Protocol integration |
| `NotebookEditTool` | Jupyter notebook editing |
| `TaskCreateTool` / `TaskUpdateTool` | Task creation and management |
| `SendMessageTool` | Inter-agent messaging |
| `TeamCreateTool` / `TeamDeleteTool` | Team agent management |
| `EnterPlanModeTool` / `ExitPlanModeTool` | Plan mode toggle |
| `EnterWorktreeTool` / `ExitWorktreeTool` | Git worktree isolation |
| `ToolSearchTool` | Deferred tool discovery |
| `CronCreateTool` | Scheduled trigger creation |
| `RemoteTriggerTool` | Remote trigger |
| `SleepTool` | Proactive mode wait |
| `SyntheticOutputTool` | Structured output generation |
### 2. Command System (`src/commands/`)
User-facing slash commands invoked with `/` prefix.
| Command | Description |
|---|---|
| `/commit` | Create a git commit |
| `/review` | Code review |
| `/compact` | Context compression |
| `/mcp` | MCP server management |
| `/config` | Settings management |
| `/doctor` | Environment diagnostics |
| `/login` / `/logout` | Authentication |
| `/memory` | Persistent memory management |
| `/skills` | Skill management |
| `/tasks` | Task management |
| `/vim` | Vim mode toggle |
| `/diff` | View changes |
| `/cost` | Check usage cost |
| `/theme` | Change theme |
| `/context` | Context visualization |
| `/pr_comments` | View PR comments |
| `/resume` | Restore previous session |
| `/share` | Share session |
| `/desktop` | Desktop app handoff |
| `/mobile` | Mobile app handoff |
### 3. Service Layer (`src/services/`)
| Service | Description |
|---|---|
| `api/` | Anthropic API client, file API, bootstrap |
| `mcp/` | Model Context Protocol server connection and management |
| `oauth/` | OAuth 2.0 authentication flow |
| `lsp/` | Language Server Protocol manager |
| `analytics/` | GrowthBook-based feature flags and analytics |
| `plugins/` | Plugin loader |
| `compact/` | Conversation context compression |
| `policyLimits/` | Organization policy limits |
| `remoteManagedSettings/` | Remote managed settings |
| `extractMemories/` | Automatic memory extraction |
| `tokenEstimation.ts` | Token count estimation |
| `teamMemorySync/` | Team memory synchronization |
### 4. Bridge System (`src/bridge/`)
A bidirectional communication layer connecting IDE extensions (VS Code, JetBrains) with the Claude Code CLI.
- `bridgeMain.ts` — Bridge main loop
- `bridgeMessaging.ts` — Message protocol
- `bridgePermissionCallbacks.ts` — Permission callbacks
- `replBridge.ts` — REPL session bridge
- `jwtUtils.ts` — JWT-based authentication
- `sessionRunner.ts` — Session execution management
### 5. Permission System (`src/hooks/toolPermission/`)
Checks permissions on every tool invocation. Either prompts the user for approval/denial or automatically resolves based on the configured permission mode (`default`, `plan`, `bypassPermissions`, `auto`, etc.).
### 6. Feature Flags
Dead code elimination via Bun's `bun:bundle` feature flags:
```typescript
import { feature } from 'bun:bundle'
// Inactive code is completely stripped at build time
const voiceCommand = feature('VOICE_MODE')
? require('./commands/voice/index.js').default
: null
```
Notable flags: `PROACTIVE`, `KAIROS`, `BRIDGE_MODE`, `DAEMON`, `VOICE_MODE`, `AGENT_TRIGGERS`, `MONITOR_TOOL`
---
## Key Files in Detail
### `QueryEngine.ts` (~46K lines)
The core engine for LLM API calls. Handles streaming responses, tool-call loops, thinking mode, retry logic, and token counting.
### `Tool.ts` (~29K lines)
Defines base types and interfaces for all tools — input schemas, permission models, and progress state types.
### `commands.ts` (~25K lines)
Manages registration and execution of all slash commands. Uses conditional imports to load different command sets per environment.
### `main.tsx`
Commander.js-based CLI parser and React/Ink renderer initialization. At startup, it overlaps MDM settings, keychain prefetch, and GrowthBook initialization for faster boot.
---
## Tech Stack
| Category | Technology |
|---|---|
| Runtime | [Bun](https://bun.sh) |
| Language | TypeScript (strict) |
| Terminal UI | [React](https://react.dev) + [Ink](https://github.com/vadimdemedes/ink) |
| CLI Parsing | [Commander.js](https://github.com/tj/commander.js) (extra-typings) |
| Schema Validation | [Zod v4](https://zod.dev) |
| Code Search | [ripgrep](https://github.com/BurntSushi/ripgrep) |
| Protocols | [MCP SDK](https://modelcontextprotocol.io), LSP |
| API | [Anthropic SDK](https://docs.anthropic.com) |
| Telemetry | OpenTelemetry + gRPC |
| Feature Flags | GrowthBook |
| Auth | OAuth 2.0, JWT, macOS Keychain |
---
## Notable Design Patterns
### Parallel Prefetch
Startup time is optimized by prefetching MDM settings, keychain reads, and API preconnect in parallel before heavy module evaluation begins.
```typescript
// main.tsx — fired as side-effects before other imports
startMdmRawRead()
startKeychainPrefetch()
```
### Lazy Loading
Heavy modules (OpenTelemetry, gRPC, analytics, and some feature-gated subsystems) are deferred via dynamic `import()` until actually needed.
### Agent Swarms
Sub-agents are spawned via `AgentTool`, with `coordinator/` handling multi-agent orchestration. `TeamCreateTool` enables team-level parallel work.
### Skill System
Reusable workflows defined in `skills/` are executed through `SkillTool`. Users can add custom skills.
### Plugin Architecture
Built-in and third-party plugins are loaded through the `plugins/` subsystem.
---
## Research / Ownership Disclaimer
- This repository is an **educational and defensive security research archive** maintained by a university student.
- It exists to study source exposure, packaging failures, and the architecture of modern agentic CLI systems.
- The original Claude Code source remains the property of **Anthropic**.
- This repository is **not affiliated with, endorsed by, or maintained by Anthropic**.
-3
View File
@@ -1,3 +0,0 @@
# agents/ - Harness implementations (s01-s12) + full reference (s_full)
# Each file is self-contained and runnable: python agents/s01_agent_loop.py
# The model is the agent. These files are the harness.
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
# Harness: the loop -- the model's first connection to the real world.
"""
s01_agent_loop.py - The Agent Loop
The entire secret of an AI coding agent in one pattern:
while stop_reason == "tool_use":
response = LLM(messages, tools)
execute tools
append results
+----------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+----------+ +---+---+ +----+----+
^ |
| tool_result |
+---------------+
(loop continues)
This is the core loop: feed tool results back to the model
until the model decides to stop. Production agents layer
policy, hooks, and lifecycle controls on top.
"""
import os
import subprocess
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain."
TOOLS = [{
"name": "bash",
"description": "Run a shell command.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
}]
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=os.getcwd(),
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
# -- The core pattern: a while loop that calls tools until the model stops --
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
# Append assistant turn
messages.append({"role": "assistant", "content": response.content})
# If the model didn't call a tool, we're done
if response.stop_reason != "tool_use":
return
# Execute each tool call, collect results
results = []
for block in response.content:
if block.type == "tool_use":
print(f"\033[33m$ {block.input['command']}\033[0m")
output = run_bash(block.input["command"])
print(output[:200])
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms01 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env python3
# Harness: tool dispatch -- expanding what the model can reach.
"""
s02_tool_use.py - Tools
The agent loop from s01 didn't change. We just added tools to the array
and a dispatch map to route calls.
+----------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+----------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+----------+ edit: run_edit |
tool_result| } |
+------------------+
Key insight: "The loop didn't change at all. I just added tools."
"""
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain."
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# -- The dispatch map: {tool_name: handler} --
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
print(f"> {block.name}: {output[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms02 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-210
View File
@@ -1,210 +0,0 @@
#!/usr/bin/env python3
# Harness: planning -- keeping the model on course without scripting the route.
"""
s03_todo_write.py - TodoWrite
The model tracks its own progress via a TodoManager. A nag reminder
forces it to keep updating when it forgets.
+----------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+----------+ +---+---+ +----+----+
^ |
| tool_result |
+---------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder>
Key insight: "The agent can track its own progress -- and I can see it."
"""
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
Prefer tools over prose."""
# -- TodoManager: structured state the LLM writes to --
class TodoManager:
def __init__(self):
self.items = []
def update(self, items: list) -> str:
if len(items) > 20:
raise ValueError("Max 20 todos allowed")
validated = []
in_progress_count = 0
for i, item in enumerate(items):
text = str(item.get("text", "")).strip()
status = str(item.get("status", "pending")).lower()
item_id = str(item.get("id", str(i + 1)))
if not text:
raise ValueError(f"Item {item_id}: text required")
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Item {item_id}: invalid status '{status}'")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item_id, "text": text, "status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress at a time")
self.items = validated
return self.render()
def render(self) -> str:
if not self.items:
return "No todos."
lines = []
for item in self.items:
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}[item["status"]]
lines.append(f"{marker} #{item['id']}: {item['text']}")
done = sum(1 for t in self.items if t["status"] == "completed")
lines.append(f"\n({done}/{len(self.items)} completed)")
return "\n".join(lines)
TODO = TodoManager()
# -- Tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"todo": lambda **kw: TODO.update(kw["items"]),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "todo", "description": "Update task list. Track progress on multi-step tasks.",
"input_schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "text": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["id", "text", "status"]}}}, "required": ["items"]}},
]
# -- Agent loop with nag reminder injection --
def agent_loop(messages: list):
rounds_since_todo = 0
while True:
# Nag reminder is injected below, alongside tool results
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
used_todo = False
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
if block.name == "todo":
used_todo = True
rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
if rounds_since_todo >= 3:
results.insert(0, {"type": "text", "text": "<reminder>Update your todos.</reminder>"})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms03 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-184
View File
@@ -1,184 +0,0 @@
#!/usr/bin/env python3
# Harness: context isolation -- protecting the model's clarity of thought.
"""
s04_subagent.py - Subagents
Spawn a child agent with fresh messages=[]. The child works in its own
context, sharing the filesystem, then returns only a summary to the parent.
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ---------->| while tool_use: |
| prompt="..." | | call tools |
| description="" | | append results |
| | summary | |
| result = "..." | <--------- | return last text |
+------------------+ +------------------+
|
Parent context stays clean.
Subagent context is discarded.
Key insight: "Process isolation gives context isolation for free."
"""
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use the task tool to delegate exploration or subtasks."
SUBAGENT_SYSTEM = f"You are a coding subagent at {WORKDIR}. Complete the given task, then summarize your findings."
# -- Tool implementations shared by parent and child --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}
# Child gets all base tools except task (no recursive spawning)
CHILD_TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
]
# -- Subagent: fresh context, filtered tools, summary-only return --
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}] # fresh context
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM, messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
)
sub_messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
# Only the final text returns to the parent -- child context is discarded
return "".join(b.text for b in response.content if hasattr(b, "text")) or "(no summary)"
# -- Parent tools: base tools + task dispatcher --
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task", "description": "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.",
"input_schema": {"type": "object", "properties": {"prompt": {"type": "string"}, "description": {"type": "string", "description": "Short description of the task"}}, "required": ["prompt"]}},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=PARENT_TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
if block.name == "task":
desc = block.input.get("description", "subtask")
print(f"> task ({desc}): {block.input['prompt'][:80]}")
output = run_subagent(block.input["prompt"])
else:
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
print(f" {str(output)[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms04 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-226
View File
@@ -1,226 +0,0 @@
#!/usr/bin/env python3
# Harness: on-demand knowledge -- domain expertise, loaded when the model asks.
"""
s05_skill_loading.py - Skills
Two-layer skill injection that avoids bloating the system prompt:
Layer 1 (cheap): skill names in system prompt (~100 tokens/skill)
Layer 2 (on demand): full skill body in tool_result
skills/
pdf/
SKILL.md <-- frontmatter (name, description) + body
code-review/
SKILL.md
System prompt:
+--------------------------------------+
| You are a coding agent. |
| Skills available: |
| - pdf: Process PDF files... | <-- Layer 1: metadata only
| - code-review: Review code... |
+--------------------------------------+
When model calls load_skill("pdf"):
+--------------------------------------+
| tool_result: |
| <skill> |
| Full PDF processing instructions | <-- Layer 2: full body
| Step 1: ... |
| Step 2: ... |
| </skill> |
+--------------------------------------+
Key insight: "Don't put everything in the system prompt. Load on demand."
"""
import os
import re
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SKILLS_DIR = WORKDIR / "skills"
# -- SkillLoader: scan skills/<name>/SKILL.md with YAML frontmatter --
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills_dir = skills_dir
self.skills = {}
self._load_all()
def _load_all(self):
if not self.skills_dir.exists():
return
for f in sorted(self.skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body, "path": str(f)}
def _parse_frontmatter(self, text: str) -> tuple:
"""Parse YAML frontmatter between --- delimiters."""
match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
if not match:
return {}, text
meta = {}
for line in match.group(1).strip().splitlines():
if ":" in line:
key, val = line.split(":", 1)
meta[key.strip()] = val.strip()
return meta, match.group(2).strip()
def get_descriptions(self) -> str:
"""Layer 1: short descriptions for the system prompt."""
if not self.skills:
return "(no skills available)"
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "No description")
tags = skill["meta"].get("tags", "")
line = f" - {name}: {desc}"
if tags:
line += f" [{tags}]"
lines.append(line)
return "\n".join(lines)
def get_content(self, name: str) -> str:
"""Layer 2: full skill body returned in tool_result."""
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
SKILL_LOADER = SkillLoader(SKILLS_DIR)
# Layer 1: skill metadata injected into system prompt
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Use load_skill to access specialized knowledge before tackling unfamiliar topics.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
# -- Tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "load_skill", "description": "Load specialized knowledge by name.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string", "description": "Skill name to load"}}, "required": ["name"]}},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms05 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-248
View File
@@ -1,248 +0,0 @@
#!/usr/bin/env python3
# Harness: compression -- clean memory for infinite sessions.
"""
s06_context_compact.py - Compact
Three-layer compression pipeline so the agent can work forever:
Every turn:
+------------------+
| Tool call result |
+------------------+
|
v
[Layer 1: micro_compact] (silent, every turn)
Replace tool_result content older than last 3
with "[Previous: used {tool_name}]"
|
v
[Check: tokens > 50000?]
| |
no yes
| |
v v
continue [Layer 2: auto_compact]
Save full transcript to .transcripts/
Ask LLM to summarize conversation.
Replace all messages with [summary].
|
v
[Layer 3: compact tool]
Model calls compact -> immediate summarization.
Same as auto, triggered manually.
Key insight: "The agent can forget strategically and keep working forever."
"""
import json
import os
import subprocess
import time
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
THRESHOLD = 50000
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
KEEP_RECENT = 3
def estimate_tokens(messages: list) -> int:
"""Rough token count: ~4 chars per token."""
return len(str(messages)) // 4
# -- Layer 1: micro_compact - replace old tool results with placeholders --
def micro_compact(messages: list) -> list:
# Collect (msg_index, part_index, tool_result_dict) for all tool_result entries
tool_results = []
for msg_idx, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for part_idx, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((msg_idx, part_idx, part))
if len(tool_results) <= KEEP_RECENT:
return messages
# Find tool_name for each result by matching tool_use_id in prior assistant messages
tool_name_map = {}
for msg in messages:
if msg["role"] == "assistant":
content = msg.get("content", [])
if isinstance(content, list):
for block in content:
if hasattr(block, "type") and block.type == "tool_use":
tool_name_map[block.id] = block.name
# Clear old results (keep last KEEP_RECENT)
to_clear = tool_results[:-KEEP_RECENT]
for _, _, result in to_clear:
if isinstance(result.get("content"), str) and len(result["content"]) > 100:
tool_id = result.get("tool_use_id", "")
tool_name = tool_name_map.get(tool_id, "unknown")
result["content"] = f"[Previous: used {tool_name}]"
return messages
# -- Layer 2: auto_compact - save transcript, summarize, replace messages --
def auto_compact(messages: list) -> list:
# Save full transcript to disk
TRANSCRIPT_DIR.mkdir(exist_ok=True)
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
print(f"[transcript saved: {transcript_path}]")
# Ask LLM to summarize
conversation_text = json.dumps(messages, default=str)[:80000]
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity. Include: "
"1) What was accomplished, 2) Current state, 3) Key decisions made. "
"Be concise but preserve critical details.\n\n" + conversation_text}],
max_tokens=2000,
)
summary = response.content[0].text
# Replace all messages with compressed summary
return [
{"role": "user", "content": f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"},
{"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."},
]
# -- Tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"compact": lambda **kw: "Manual compression requested.",
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "compact", "description": "Trigger manual conversation compression.",
"input_schema": {"type": "object", "properties": {"focus": {"type": "string", "description": "What to preserve in the summary"}}}},
]
def agent_loop(messages: list):
while True:
# Layer 1: micro_compact before each LLM call
micro_compact(messages)
# Layer 2: auto_compact if token estimate exceeds threshold
if estimate_tokens(messages) > THRESHOLD:
print("[auto_compact triggered]")
messages[:] = auto_compact(messages)
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
manual_compact = False
for block in response.content:
if block.type == "tool_use":
if block.name == "compact":
manual_compact = True
output = "Compressing..."
else:
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
# Layer 3: manual compact triggered by the compact tool
if manual_compact:
print("[manual compact]")
messages[:] = auto_compact(messages)
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms06 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-248
View File
@@ -1,248 +0,0 @@
#!/usr/bin/env python3
# Harness: persistent tasks -- goals that outlive any single conversation.
"""
s07_task_system.py - Tasks
Tasks persist as JSON files in .tasks/ so they survive context compression.
Each task has a dependency graph (blockedBy/blocks).
.tasks/
task_1.json {"id":1, "subject":"...", "status":"completed", ...}
task_2.json {"id":2, "blockedBy":[1], "status":"pending", ...}
task_3.json {"id":3, "blockedBy":[2], "blocks":[], ...}
Dependency resolution:
+----------+ +----------+ +----------+
| task 1 | --> | task 2 | --> | task 3 |
| complete | | blocked | | blocked |
+----------+ +----------+ +----------+
| ^
+--- completing task 1 removes it from task 2's blockedBy
Key insight: "State that survives compression -- because it's outside the conversation."
"""
import json
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TASKS_DIR = WORKDIR / ".tasks"
SYSTEM = f"You are a coding agent at {WORKDIR}. Use task tools to plan and track work."
# -- TaskManager: CRUD with dependency graph, persisted as JSON files --
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
def _max_id(self) -> int:
ids = [int(f.stem.split("_")[1]) for f in self.dir.glob("task_*.json")]
return max(ids) if ids else 0
def _load(self, task_id: int) -> dict:
path = self.dir / f"task_{task_id}.json"
if not path.exists():
raise ValueError(f"Task {task_id} not found")
return json.loads(path.read_text())
def _save(self, task: dict):
path = self.dir / f"task_{task['id']}.json"
path.write_text(json.dumps(task, indent=2))
def create(self, subject: str, description: str = "") -> str:
task = {
"id": self._next_id, "subject": subject, "description": description,
"status": "pending", "blockedBy": [], "blocks": [], "owner": "",
}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
def get(self, task_id: int) -> str:
return json.dumps(self._load(task_id), indent=2)
def update(self, task_id: int, status: str = None,
add_blocked_by: list = None, add_blocks: list = None) -> str:
task = self._load(task_id)
if status:
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Invalid status: {status}")
task["status"] = status
# When a task is completed, remove it from all other tasks' blockedBy
if status == "completed":
self._clear_dependency(task_id)
if add_blocked_by:
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
if add_blocks:
task["blocks"] = list(set(task["blocks"] + add_blocks))
# Bidirectional: also update the blocked tasks' blockedBy lists
for blocked_id in add_blocks:
try:
blocked = self._load(blocked_id)
if task_id not in blocked["blockedBy"]:
blocked["blockedBy"].append(task_id)
self._save(blocked)
except ValueError:
pass
self._save(task)
return json.dumps(task, indent=2)
def _clear_dependency(self, completed_id: int):
"""Remove completed_id from all other tasks' blockedBy lists."""
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
def list_all(self) -> str:
tasks = []
for f in sorted(self.dir.glob("task_*.json")):
tasks.append(json.loads(f.read_text()))
if not tasks:
return "No tasks."
lines = []
for t in tasks:
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
blocked = f" (blocked by: {t['blockedBy']})" if t.get("blockedBy") else ""
lines.append(f"{marker} #{t['id']}: {t['subject']}{blocked}")
return "\n".join(lines)
TASKS = TaskManager(TASKS_DIR)
# -- Base tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("addBlockedBy"), kw.get("addBlocks")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "task_create", "description": "Create a new task.",
"input_schema": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"]}},
{"name": "task_update", "description": "Update a task's status or dependencies.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, "addBlockedBy": {"type": "array", "items": {"type": "integer"}}, "addBlocks": {"type": "array", "items": {"type": "integer"}}}, "required": ["task_id"]}},
{"name": "task_list", "description": "List all tasks with status summary.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "task_get", "description": "Get full details of a task by ID.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms07 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-234
View File
@@ -1,234 +0,0 @@
#!/usr/bin/env python3
# Harness: background execution -- the model thinks while the harness waits.
"""
s08_background_tasks.py - Background Tasks
Run commands in background threads. A notification queue is drained
before each LLM call to deliver results.
Main thread Background thread
+-----------------+ +-----------------+
| agent loop | | task executes |
| ... | | ... |
| [LLM call] <---+------- | enqueue(result) |
| ^drain queue | +-----------------+
+-----------------+
Timeline:
Agent ----[spawn A]----[spawn B]----[other work]----
| |
v v
[A runs] [B runs] (parallel)
| |
+-- notification queue --> [results injected]
Key insight: "Fire and forget -- the agent doesn't block while the command runs."
"""
import os
import subprocess
import threading
import uuid
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use background_run for long-running commands."
# -- BackgroundManager: threaded execution + notification queue --
class BackgroundManager:
def __init__(self):
self.tasks = {} # task_id -> {status, result, command}
self._notification_queue = [] # completed task results
self._lock = threading.Lock()
def run(self, command: str) -> str:
"""Start a background thread, return task_id immediately."""
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "result": None, "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True
)
thread.start()
return f"Background task {task_id} started: {command[:80]}"
def _execute(self, task_id: str, command: str):
"""Thread target: run subprocess, capture output, push to queue."""
try:
r = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300
)
output = (r.stdout + r.stderr).strip()[:50000]
status = "completed"
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
status = "timeout"
except Exception as e:
output = f"Error: {e}"
status = "error"
self.tasks[task_id]["status"] = status
self.tasks[task_id]["result"] = output or "(no output)"
with self._lock:
self._notification_queue.append({
"task_id": task_id,
"status": status,
"command": command[:80],
"result": (output or "(no output)")[:500],
})
def check(self, task_id: str = None) -> str:
"""Check status of one task or list all."""
if task_id:
t = self.tasks.get(task_id)
if not t:
return f"Error: Unknown task {task_id}"
return f"[{t['status']}] {t['command'][:60]}\n{t.get('result') or '(running)'}"
lines = []
for tid, t in self.tasks.items():
lines.append(f"{tid}: [{t['status']}] {t['command'][:60]}")
return "\n".join(lines) if lines else "No background tasks."
def drain_notifications(self) -> list:
"""Return and clear all pending completion notifications."""
with self._lock:
notifs = list(self._notification_queue)
self._notification_queue.clear()
return notifs
BG = BackgroundManager()
# -- Tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"background_run": lambda **kw: BG.run(kw["command"]),
"check_background": lambda **kw: BG.check(kw.get("task_id")),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command (blocking).",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "background_run", "description": "Run command in background thread. Returns task_id immediately.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "check_background", "description": "Check background task status. Omit task_id to list all.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}}},
]
def agent_loop(messages: list):
while True:
# Drain background notifications and inject as system message before LLM call
notifs = BG.drain_notifications()
if notifs and messages:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs
)
messages.append({"role": "user", "content": f"<background-results>\n{notif_text}\n</background-results>"})
messages.append({"role": "assistant", "content": "Noted background results."})
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms08 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-406
View File
@@ -1,406 +0,0 @@
#!/usr/bin/env python3
# Harness: team mailboxes -- multiple models, coordinated through files.
"""
s09_agent_teams.py - Agent Teams
Persistent named agents with file-based JSONL inboxes. Each teammate runs
its own agent loop in a separate thread. Communication via append-only inboxes.
Subagent (s04): spawn -> execute -> return summary -> destroyed
Teammate (s09): spawn -> work -> idle -> work -> ... -> shutdown
.team/config.json .team/inbox/
+----------------------------+ +------------------+
| {"team_name": "default", | | alice.jsonl |
| "members": [ | | bob.jsonl |
| {"name":"alice", | | lead.jsonl |
| "role":"coder", | +------------------+
| "status":"idle"} |
| ]} | send_message("alice", "fix bug"):
+----------------------------+ open("alice.jsonl", "a").write(msg)
read_inbox("alice"):
spawn_teammate("alice","coder",...) msgs = [json.loads(l) for l in ...]
| open("alice.jsonl", "w").close()
v return msgs # drain
Thread: alice Thread: bob
+------------------+ +------------------+
| agent_loop | | agent_loop |
| status: working | | status: idle |
| ... runs tools | | ... waits ... |
| status -> idle | | |
+------------------+ +------------------+
5 message types (all declared, not all handled here):
+-------------------------+-----------------------------------+
| message | Normal text message |
| broadcast | Sent to all teammates |
| shutdown_request | Request graceful shutdown (s10) |
| shutdown_response | Approve/reject shutdown (s10) |
| plan_approval_response | Approve/reject plan (s10) |
+-------------------------+-----------------------------------+
Key insight: "Teammates that can talk to each other."
"""
import json
import os
import subprocess
import threading
import time
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TEAM_DIR = WORKDIR / ".team"
INBOX_DIR = TEAM_DIR / "inbox"
SYSTEM = f"You are a team lead at {WORKDIR}. Spawn teammates and communicate via inboxes."
VALID_MSG_TYPES = {
"message",
"broadcast",
"shutdown_request",
"shutdown_response",
"plan_approval_response",
}
# -- MessageBus: JSONL inbox per teammate --
class MessageBus:
def __init__(self, inbox_dir: Path):
self.dir = inbox_dir
self.dir.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
if msg_type not in VALID_MSG_TYPES:
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
msg = {
"type": msg_type,
"from": sender,
"content": content,
"timestamp": time.time(),
}
if extra:
msg.update(extra)
inbox_path = self.dir / f"{to}.jsonl"
with open(inbox_path, "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
inbox_path = self.dir / f"{name}.jsonl"
if not inbox_path.exists():
return []
messages = []
for line in inbox_path.read_text().strip().splitlines():
if line:
messages.append(json.loads(line))
inbox_path.write_text("")
return messages
def broadcast(self, sender: str, content: str, teammates: list) -> str:
count = 0
for name in teammates:
if name != sender:
self.send(sender, name, content, "broadcast")
count += 1
return f"Broadcast to {count} teammates"
BUS = MessageBus(INBOX_DIR)
# -- TeammateManager: persistent named agents with config.json --
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
def _load_config(self) -> dict:
if self.config_path.exists():
return json.loads(self.config_path.read_text())
return {"team_name": "default", "members": []}
def _save_config(self):
self.config_path.write_text(json.dumps(self.config, indent=2))
def _find_member(self, name: str) -> dict:
for m in self.config["members"]:
if m["name"] == name:
return m
return None
def spawn(self, name: str, role: str, prompt: str) -> str:
member = self._find_member(name)
if member:
if member["status"] not in ("idle", "shutdown"):
return f"Error: '{name}' is currently {member['status']}"
member["status"] = "working"
member["role"] = role
else:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt),
daemon=True,
)
self.threads[name] = thread
thread.start()
return f"Spawned '{name}' (role: {role})"
def _teammate_loop(self, name: str, role: str, prompt: str):
sys_prompt = (
f"You are '{name}', role: {role}, at {WORKDIR}. "
f"Use send_message to communicate. Complete your task."
)
messages = [{"role": "user", "content": prompt}]
tools = self._teammate_tools()
for _ in range(50):
inbox = BUS.read_inbox(name)
for msg in inbox:
messages.append({"role": "user", "content": json.dumps(msg)})
try:
response = client.messages.create(
model=MODEL,
system=sys_prompt,
messages=messages,
tools=tools,
max_tokens=8000,
)
except Exception:
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
output = self._exec(name, block.name, block.input)
print(f" [{name}] {block.name}: {str(output)[:120]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
member = self._find_member(name)
if member and member["status"] != "shutdown":
member["status"] = "idle"
self._save_config()
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
# these base tools are unchanged from s02
if tool_name == "bash":
return _run_bash(args["command"])
if tool_name == "read_file":
return _run_read(args["path"])
if tool_name == "write_file":
return _run_write(args["path"], args["content"])
if tool_name == "edit_file":
return _run_edit(args["path"], args["old_text"], args["new_text"])
if tool_name == "send_message":
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
if tool_name == "read_inbox":
return json.dumps(BUS.read_inbox(sender), indent=2)
return f"Unknown tool: {tool_name}"
def _teammate_tools(self) -> list:
# these base tools are unchanged from s02
return [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "send_message", "description": "Send message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain your inbox.",
"input_schema": {"type": "object", "properties": {}}},
]
def list_all(self) -> str:
if not self.config["members"]:
return "No teammates."
lines = [f"Team: {self.config['team_name']}"]
for m in self.config["members"]:
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
return "\n".join(lines)
def member_names(self) -> list:
return [m["name"] for m in self.config["members"]]
TEAM = TeammateManager(TEAM_DIR)
# -- Base tool implementations (these base tools are unchanged from s02) --
def _safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def _run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def _run_read(path: str, limit: int = None) -> str:
try:
lines = _safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def _run_write(path: str, content: str) -> str:
try:
fp = _safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def _run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = _safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# -- Lead tool dispatch (9 tools) --
TOOL_HANDLERS = {
"bash": lambda **kw: _run_bash(kw["command"]),
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
"list_teammates": lambda **kw: TEAM.list_all(),
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
}
# these base tools are unchanged from s02
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "spawn_teammate", "description": "Spawn a persistent teammate that runs in its own thread.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
{"name": "list_teammates", "description": "List all teammates with name, role, status.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "send_message", "description": "Send a message to a teammate's inbox.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "broadcast", "description": "Send a message to all teammates.",
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
]
def agent_loop(messages: list):
while True:
inbox = BUS.read_inbox("lead")
if inbox:
messages.append({
"role": "user",
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
})
messages.append({
"role": "assistant",
"content": "Noted inbox messages.",
})
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms09 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if query.strip() == "/team":
print(TEAM.list_all())
continue
if query.strip() == "/inbox":
print(json.dumps(BUS.read_inbox("lead"), indent=2))
continue
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-487
View File
@@ -1,487 +0,0 @@
#!/usr/bin/env python3
# Harness: protocols -- structured handshakes between models.
"""
s10_team_protocols.py - Team Protocols
Shutdown protocol and plan approval protocol, both using the same
request_id correlation pattern. Builds on s09's team messaging.
Shutdown FSM: pending -> approved | rejected
Lead Teammate
+---------------------+ +---------------------+
| shutdown_request | | |
| { | -------> | receives request |
| request_id: abc | | decides: approve? |
| } | | |
+---------------------+ +---------------------+
|
+---------------------+ +-------v-------------+
| shutdown_response | <------- | shutdown_response |
| { | | { |
| request_id: abc | | request_id: abc |
| approve: true | | approve: true |
| } | | } |
+---------------------+ +---------------------+
|
v
status -> "shutdown", thread stops
Plan approval FSM: pending -> approved | rejected
Teammate Lead
+---------------------+ +---------------------+
| plan_approval | | |
| submit: {plan:"..."}| -------> | reviews plan text |
+---------------------+ | approve/reject? |
+---------------------+
|
+---------------------+ +-------v-------------+
| plan_approval_resp | <------- | plan_approval |
| {approve: true} | | review: {req_id, |
+---------------------+ | approve: true} |
+---------------------+
Trackers: {request_id: {"target|from": name, "status": "pending|..."}}
Key insight: "Same request_id correlation pattern, two domains."
"""
import json
import os
import subprocess
import threading
import time
import uuid
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TEAM_DIR = WORKDIR / ".team"
INBOX_DIR = TEAM_DIR / "inbox"
SYSTEM = f"You are a team lead at {WORKDIR}. Manage teammates with shutdown and plan approval protocols."
VALID_MSG_TYPES = {
"message",
"broadcast",
"shutdown_request",
"shutdown_response",
"plan_approval_response",
}
# -- Request trackers: correlate by request_id --
shutdown_requests = {}
plan_requests = {}
_tracker_lock = threading.Lock()
# -- MessageBus: JSONL inbox per teammate --
class MessageBus:
def __init__(self, inbox_dir: Path):
self.dir = inbox_dir
self.dir.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
if msg_type not in VALID_MSG_TYPES:
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
msg = {
"type": msg_type,
"from": sender,
"content": content,
"timestamp": time.time(),
}
if extra:
msg.update(extra)
inbox_path = self.dir / f"{to}.jsonl"
with open(inbox_path, "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
inbox_path = self.dir / f"{name}.jsonl"
if not inbox_path.exists():
return []
messages = []
for line in inbox_path.read_text().strip().splitlines():
if line:
messages.append(json.loads(line))
inbox_path.write_text("")
return messages
def broadcast(self, sender: str, content: str, teammates: list) -> str:
count = 0
for name in teammates:
if name != sender:
self.send(sender, name, content, "broadcast")
count += 1
return f"Broadcast to {count} teammates"
BUS = MessageBus(INBOX_DIR)
# -- TeammateManager with shutdown + plan approval --
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
def _load_config(self) -> dict:
if self.config_path.exists():
return json.loads(self.config_path.read_text())
return {"team_name": "default", "members": []}
def _save_config(self):
self.config_path.write_text(json.dumps(self.config, indent=2))
def _find_member(self, name: str) -> dict:
for m in self.config["members"]:
if m["name"] == name:
return m
return None
def spawn(self, name: str, role: str, prompt: str) -> str:
member = self._find_member(name)
if member:
if member["status"] not in ("idle", "shutdown"):
return f"Error: '{name}' is currently {member['status']}"
member["status"] = "working"
member["role"] = role
else:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt),
daemon=True,
)
self.threads[name] = thread
thread.start()
return f"Spawned '{name}' (role: {role})"
def _teammate_loop(self, name: str, role: str, prompt: str):
sys_prompt = (
f"You are '{name}', role: {role}, at {WORKDIR}. "
f"Submit plans via plan_approval before major work. "
f"Respond to shutdown_request with shutdown_response."
)
messages = [{"role": "user", "content": prompt}]
tools = self._teammate_tools()
should_exit = False
for _ in range(50):
inbox = BUS.read_inbox(name)
for msg in inbox:
messages.append({"role": "user", "content": json.dumps(msg)})
if should_exit:
break
try:
response = client.messages.create(
model=MODEL,
system=sys_prompt,
messages=messages,
tools=tools,
max_tokens=8000,
)
except Exception:
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
output = self._exec(name, block.name, block.input)
print(f" [{name}] {block.name}: {str(output)[:120]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
if block.name == "shutdown_response" and block.input.get("approve"):
should_exit = True
messages.append({"role": "user", "content": results})
member = self._find_member(name)
if member:
member["status"] = "shutdown" if should_exit else "idle"
self._save_config()
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
# these base tools are unchanged from s02
if tool_name == "bash":
return _run_bash(args["command"])
if tool_name == "read_file":
return _run_read(args["path"])
if tool_name == "write_file":
return _run_write(args["path"], args["content"])
if tool_name == "edit_file":
return _run_edit(args["path"], args["old_text"], args["new_text"])
if tool_name == "send_message":
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
if tool_name == "read_inbox":
return json.dumps(BUS.read_inbox(sender), indent=2)
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
with _tracker_lock:
if req_id in shutdown_requests:
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(
sender, "lead", args.get("reason", ""),
"shutdown_response", {"request_id": req_id, "approve": approve},
)
return f"Shutdown {'approved' if approve else 'rejected'}"
if tool_name == "plan_approval":
plan_text = args.get("plan", "")
req_id = str(uuid.uuid4())[:8]
with _tracker_lock:
plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"}
BUS.send(
sender, "lead", plan_text, "plan_approval_response",
{"request_id": req_id, "plan": plan_text},
)
return f"Plan submitted (request_id={req_id}). Waiting for lead approval."
return f"Unknown tool: {tool_name}"
def _teammate_tools(self) -> list:
# these base tools are unchanged from s02
return [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "send_message", "description": "Send message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain your inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "shutdown_response", "description": "Respond to a shutdown request. Approve to shut down, reject to keep working.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}},
{"name": "plan_approval", "description": "Submit a plan for lead approval. Provide plan text.",
"input_schema": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}},
]
def list_all(self) -> str:
if not self.config["members"]:
return "No teammates."
lines = [f"Team: {self.config['team_name']}"]
for m in self.config["members"]:
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
return "\n".join(lines)
def member_names(self) -> list:
return [m["name"] for m in self.config["members"]]
TEAM = TeammateManager(TEAM_DIR)
# -- Base tool implementations (these base tools are unchanged from s02) --
def _safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def _run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def _run_read(path: str, limit: int = None) -> str:
try:
lines = _safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def _run_write(path: str, content: str) -> str:
try:
fp = _safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def _run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = _safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# -- Lead-specific protocol handlers --
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
with _tracker_lock:
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send(
"lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id},
)
return f"Shutdown request {req_id} sent to '{teammate}' (status: pending)"
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
with _tracker_lock:
req = plan_requests.get(request_id)
if not req:
return f"Error: Unknown plan request_id '{request_id}'"
with _tracker_lock:
req["status"] = "approved" if approve else "rejected"
BUS.send(
"lead", req["from"], feedback, "plan_approval_response",
{"request_id": request_id, "approve": approve, "feedback": feedback},
)
return f"Plan {req['status']} for '{req['from']}'"
def _check_shutdown_status(request_id: str) -> str:
with _tracker_lock:
return json.dumps(shutdown_requests.get(request_id, {"error": "not found"}))
# -- Lead tool dispatch (12 tools) --
TOOL_HANDLERS = {
"bash": lambda **kw: _run_bash(kw["command"]),
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
"list_teammates": lambda **kw: TEAM.list_all(),
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
"shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")),
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
}
# these base tools are unchanged from s02
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "spawn_teammate", "description": "Spawn a persistent teammate.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
{"name": "list_teammates", "description": "List all teammates.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "send_message", "description": "Send a message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "broadcast", "description": "Send a message to all teammates.",
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
{"name": "shutdown_request", "description": "Request a teammate to shut down gracefully. Returns a request_id for tracking.",
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
{"name": "shutdown_response", "description": "Check the status of a shutdown request by request_id.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}},
{"name": "plan_approval", "description": "Approve or reject a teammate's plan. Provide request_id + approve + optional feedback.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
]
def agent_loop(messages: list):
while True:
inbox = BUS.read_inbox("lead")
if inbox:
messages.append({
"role": "user",
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
})
messages.append({
"role": "assistant",
"content": "Noted inbox messages.",
})
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms10 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if query.strip() == "/team":
print(TEAM.list_all())
continue
if query.strip() == "/inbox":
print(json.dumps(BUS.read_inbox("lead"), indent=2))
continue
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-579
View File
@@ -1,579 +0,0 @@
#!/usr/bin/env python3
# Harness: autonomy -- models that find work without being told.
"""
s11_autonomous_agents.py - Autonomous Agents
Idle cycle with task board polling, auto-claiming unclaimed tasks, and
identity re-injection after context compression. Builds on s10's protocols.
Teammate lifecycle:
+-------+
| spawn |
+---+---+
|
v
+-------+ tool_use +-------+
| WORK | <----------- | LLM |
+---+---+ +-------+
|
| stop_reason != tool_use
v
+--------+
| IDLE | poll every 5s for up to 60s
+---+----+
|
+---> check inbox -> message? -> resume WORK
|
+---> scan .tasks/ -> unclaimed? -> claim -> resume WORK
|
+---> timeout (60s) -> shutdown
Identity re-injection after compression:
messages = [identity_block, ...remaining...]
"You are 'coder', role: backend, team: my-team"
Key insight: "The agent finds work itself."
"""
import json
import os
import subprocess
import threading
import time
import uuid
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TEAM_DIR = WORKDIR / ".team"
INBOX_DIR = TEAM_DIR / "inbox"
TASKS_DIR = WORKDIR / ".tasks"
POLL_INTERVAL = 5
IDLE_TIMEOUT = 60
SYSTEM = f"You are a team lead at {WORKDIR}. Teammates are autonomous -- they find work themselves."
VALID_MSG_TYPES = {
"message",
"broadcast",
"shutdown_request",
"shutdown_response",
"plan_approval_response",
}
# -- Request trackers --
shutdown_requests = {}
plan_requests = {}
_tracker_lock = threading.Lock()
_claim_lock = threading.Lock()
# -- MessageBus: JSONL inbox per teammate --
class MessageBus:
def __init__(self, inbox_dir: Path):
self.dir = inbox_dir
self.dir.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
if msg_type not in VALID_MSG_TYPES:
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
msg = {
"type": msg_type,
"from": sender,
"content": content,
"timestamp": time.time(),
}
if extra:
msg.update(extra)
inbox_path = self.dir / f"{to}.jsonl"
with open(inbox_path, "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
inbox_path = self.dir / f"{name}.jsonl"
if not inbox_path.exists():
return []
messages = []
for line in inbox_path.read_text().strip().splitlines():
if line:
messages.append(json.loads(line))
inbox_path.write_text("")
return messages
def broadcast(self, sender: str, content: str, teammates: list) -> str:
count = 0
for name in teammates:
if name != sender:
self.send(sender, name, content, "broadcast")
count += 1
return f"Broadcast to {count} teammates"
BUS = MessageBus(INBOX_DIR)
# -- Task board scanning --
def scan_unclaimed_tasks() -> list:
TASKS_DIR.mkdir(exist_ok=True)
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
def claim_task(task_id: int, owner: str) -> str:
with _claim_lock:
path = TASKS_DIR / f"task_{task_id}.json"
if not path.exists():
return f"Error: Task {task_id} not found"
task = json.loads(path.read_text())
task["owner"] = owner
task["status"] = "in_progress"
path.write_text(json.dumps(task, indent=2))
return f"Claimed task #{task_id} for {owner}"
# -- Identity re-injection after compression --
def make_identity_block(name: str, role: str, team_name: str) -> dict:
return {
"role": "user",
"content": f"<identity>You are '{name}', role: {role}, team: {team_name}. Continue your work.</identity>",
}
# -- Autonomous TeammateManager --
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
def _load_config(self) -> dict:
if self.config_path.exists():
return json.loads(self.config_path.read_text())
return {"team_name": "default", "members": []}
def _save_config(self):
self.config_path.write_text(json.dumps(self.config, indent=2))
def _find_member(self, name: str) -> dict:
for m in self.config["members"]:
if m["name"] == name:
return m
return None
def _set_status(self, name: str, status: str):
member = self._find_member(name)
if member:
member["status"] = status
self._save_config()
def spawn(self, name: str, role: str, prompt: str) -> str:
member = self._find_member(name)
if member:
if member["status"] not in ("idle", "shutdown"):
return f"Error: '{name}' is currently {member['status']}"
member["status"] = "working"
member["role"] = role
else:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._loop,
args=(name, role, prompt),
daemon=True,
)
self.threads[name] = thread
thread.start()
return f"Spawned '{name}' (role: {role})"
def _loop(self, name: str, role: str, prompt: str):
team_name = self.config["team_name"]
sys_prompt = (
f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. "
f"Use idle tool when you have no more work. You will auto-claim new tasks."
)
messages = [{"role": "user", "content": prompt}]
tools = self._teammate_tools()
while True:
# -- WORK PHASE: standard agent loop --
for _ in range(50):
inbox = BUS.read_inbox(name)
for msg in inbox:
if msg.get("type") == "shutdown_request":
self._set_status(name, "shutdown")
return
messages.append({"role": "user", "content": json.dumps(msg)})
try:
response = client.messages.create(
model=MODEL,
system=sys_prompt,
messages=messages,
tools=tools,
max_tokens=8000,
)
except Exception:
self._set_status(name, "idle")
return
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
idle_requested = False
for block in response.content:
if block.type == "tool_use":
if block.name == "idle":
idle_requested = True
output = "Entering idle phase. Will poll for new tasks."
else:
output = self._exec(name, block.name, block.input)
print(f" [{name}] {block.name}: {str(output)[:120]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
if idle_requested:
break
# -- IDLE PHASE: poll for inbox messages and unclaimed tasks --
self._set_status(name, "idle")
resume = False
polls = IDLE_TIMEOUT // max(POLL_INTERVAL, 1)
for _ in range(polls):
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
for msg in inbox:
if msg.get("type") == "shutdown_request":
self._set_status(name, "shutdown")
return
messages.append({"role": "user", "content": json.dumps(msg)})
resume = True
break
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
claim_task(task["id"], name)
task_prompt = (
f"<auto-claimed>Task #{task['id']}: {task['subject']}\n"
f"{task.get('description', '')}</auto-claimed>"
)
if len(messages) <= 3:
messages.insert(0, make_identity_block(name, role, team_name))
messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."})
messages.append({"role": "user", "content": task_prompt})
messages.append({"role": "assistant", "content": f"Claimed task #{task['id']}. Working on it."})
resume = True
break
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
# these base tools are unchanged from s02
if tool_name == "bash":
return _run_bash(args["command"])
if tool_name == "read_file":
return _run_read(args["path"])
if tool_name == "write_file":
return _run_write(args["path"], args["content"])
if tool_name == "edit_file":
return _run_edit(args["path"], args["old_text"], args["new_text"])
if tool_name == "send_message":
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
if tool_name == "read_inbox":
return json.dumps(BUS.read_inbox(sender), indent=2)
if tool_name == "shutdown_response":
req_id = args["request_id"]
with _tracker_lock:
if req_id in shutdown_requests:
shutdown_requests[req_id]["status"] = "approved" if args["approve"] else "rejected"
BUS.send(
sender, "lead", args.get("reason", ""),
"shutdown_response", {"request_id": req_id, "approve": args["approve"]},
)
return f"Shutdown {'approved' if args['approve'] else 'rejected'}"
if tool_name == "plan_approval":
plan_text = args.get("plan", "")
req_id = str(uuid.uuid4())[:8]
with _tracker_lock:
plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"}
BUS.send(
sender, "lead", plan_text, "plan_approval_response",
{"request_id": req_id, "plan": plan_text},
)
return f"Plan submitted (request_id={req_id}). Waiting for approval."
if tool_name == "claim_task":
return claim_task(args["task_id"], sender)
return f"Unknown tool: {tool_name}"
def _teammate_tools(self) -> list:
# these base tools are unchanged from s02
return [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "send_message", "description": "Send message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain your inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "shutdown_response", "description": "Respond to a shutdown request.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}},
{"name": "plan_approval", "description": "Submit a plan for lead approval.",
"input_schema": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}},
{"name": "idle", "description": "Signal that you have no more work. Enters idle polling phase.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "claim_task", "description": "Claim a task from the task board by ID.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
def list_all(self) -> str:
if not self.config["members"]:
return "No teammates."
lines = [f"Team: {self.config['team_name']}"]
for m in self.config["members"]:
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
return "\n".join(lines)
def member_names(self) -> list:
return [m["name"] for m in self.config["members"]]
TEAM = TeammateManager(TEAM_DIR)
# -- Base tool implementations (these base tools are unchanged from s02) --
def _safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def _run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def _run_read(path: str, limit: int = None) -> str:
try:
lines = _safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def _run_write(path: str, content: str) -> str:
try:
fp = _safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def _run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = _safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# -- Lead-specific protocol handlers --
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
with _tracker_lock:
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send(
"lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id},
)
return f"Shutdown request {req_id} sent to '{teammate}'"
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
with _tracker_lock:
req = plan_requests.get(request_id)
if not req:
return f"Error: Unknown plan request_id '{request_id}'"
with _tracker_lock:
req["status"] = "approved" if approve else "rejected"
BUS.send(
"lead", req["from"], feedback, "plan_approval_response",
{"request_id": request_id, "approve": approve, "feedback": feedback},
)
return f"Plan {req['status']} for '{req['from']}'"
def _check_shutdown_status(request_id: str) -> str:
with _tracker_lock:
return json.dumps(shutdown_requests.get(request_id, {"error": "not found"}))
# -- Lead tool dispatch (14 tools) --
TOOL_HANDLERS = {
"bash": lambda **kw: _run_bash(kw["command"]),
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
"list_teammates": lambda **kw: TEAM.list_all(),
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
"shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")),
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
"idle": lambda **kw: "Lead does not idle.",
"claim_task": lambda **kw: claim_task(kw["task_id"], "lead"),
}
# these base tools are unchanged from s02
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "spawn_teammate", "description": "Spawn an autonomous teammate.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
{"name": "list_teammates", "description": "List all teammates.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "send_message", "description": "Send a message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "broadcast", "description": "Send a message to all teammates.",
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
{"name": "shutdown_request", "description": "Request a teammate to shut down.",
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
{"name": "shutdown_response", "description": "Check shutdown request status.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}},
{"name": "plan_approval", "description": "Approve or reject a teammate's plan.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
{"name": "idle", "description": "Enter idle state (for lead -- rarely used).",
"input_schema": {"type": "object", "properties": {}}},
{"name": "claim_task", "description": "Claim a task from the board by ID.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
def agent_loop(messages: list):
while True:
inbox = BUS.read_inbox("lead")
if inbox:
messages.append({
"role": "user",
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
})
messages.append({
"role": "assistant",
"content": "Noted inbox messages.",
})
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms11 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if query.strip() == "/team":
print(TEAM.list_all())
continue
if query.strip() == "/inbox":
print(json.dumps(BUS.read_inbox("lead"), indent=2))
continue
if query.strip() == "/tasks":
TASKS_DIR.mkdir(exist_ok=True)
for f in sorted(TASKS_DIR.glob("task_*.json")):
t = json.loads(f.read_text())
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
owner = f" @{t['owner']}" if t.get("owner") else ""
print(f" {marker} #{t['id']}: {t['subject']}{owner}")
continue
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-781
View File
@@ -1,781 +0,0 @@
#!/usr/bin/env python3
# Harness: directory isolation -- parallel execution lanes that never collide.
"""
s12_worktree_task_isolation.py - Worktree + Task Isolation
Directory-level isolation for parallel task execution.
Tasks are the control plane and worktrees are the execution plane.
.tasks/task_12.json
{
"id": 12,
"subject": "Implement auth refactor",
"status": "in_progress",
"worktree": "auth-refactor"
}
.worktrees/index.json
{
"worktrees": [
{
"name": "auth-refactor",
"path": ".../.worktrees/auth-refactor",
"branch": "wt/auth-refactor",
"task_id": 12,
"status": "active"
}
]
}
Key insight: "Isolate by directory, coordinate by task ID."
"""
import json
import os
import re
import subprocess
import time
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
def detect_repo_root(cwd: Path) -> Path | None:
"""Return git repo root if cwd is inside a repo, else None."""
try:
r = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=cwd,
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return None
root = Path(r.stdout.strip())
return root if root.exists() else None
except Exception:
return None
REPO_ROOT = detect_repo_root(WORKDIR) or WORKDIR
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Use task + worktree tools for multi-task work. "
"For parallel or risky changes: create tasks, allocate worktree lanes, "
"run commands in those lanes, then choose keep/remove for closeout. "
"Use worktree_events when you need lifecycle visibility."
)
# -- EventBus: append-only lifecycle events for observability --
class EventBus:
def __init__(self, event_log_path: Path):
self.path = event_log_path
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self.path.write_text("")
def emit(
self,
event: str,
task: dict | None = None,
worktree: dict | None = None,
error: str | None = None,
):
payload = {
"event": event,
"ts": time.time(),
"task": task or {},
"worktree": worktree or {},
}
if error:
payload["error"] = error
with self.path.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload) + "\n")
def list_recent(self, limit: int = 20) -> str:
n = max(1, min(int(limit or 20), 200))
lines = self.path.read_text(encoding="utf-8").splitlines()
recent = lines[-n:]
items = []
for line in recent:
try:
items.append(json.loads(line))
except Exception:
items.append({"event": "parse_error", "raw": line})
return json.dumps(items, indent=2)
# -- TaskManager: persistent task board with optional worktree binding --
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(parents=True, exist_ok=True)
self._next_id = self._max_id() + 1
def _max_id(self) -> int:
ids = []
for f in self.dir.glob("task_*.json"):
try:
ids.append(int(f.stem.split("_")[1]))
except Exception:
pass
return max(ids) if ids else 0
def _path(self, task_id: int) -> Path:
return self.dir / f"task_{task_id}.json"
def _load(self, task_id: int) -> dict:
path = self._path(task_id)
if not path.exists():
raise ValueError(f"Task {task_id} not found")
return json.loads(path.read_text())
def _save(self, task: dict):
self._path(task["id"]).write_text(json.dumps(task, indent=2))
def create(self, subject: str, description: str = "") -> str:
task = {
"id": self._next_id,
"subject": subject,
"description": description,
"status": "pending",
"owner": "",
"worktree": "",
"blockedBy": [],
"created_at": time.time(),
"updated_at": time.time(),
}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
def get(self, task_id: int) -> str:
return json.dumps(self._load(task_id), indent=2)
def exists(self, task_id: int) -> bool:
return self._path(task_id).exists()
def update(self, task_id: int, status: str = None, owner: str = None) -> str:
task = self._load(task_id)
if status:
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Invalid status: {status}")
task["status"] = status
if owner is not None:
task["owner"] = owner
task["updated_at"] = time.time()
self._save(task)
return json.dumps(task, indent=2)
def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str:
task = self._load(task_id)
task["worktree"] = worktree
if owner:
task["owner"] = owner
if task["status"] == "pending":
task["status"] = "in_progress"
task["updated_at"] = time.time()
self._save(task)
return json.dumps(task, indent=2)
def unbind_worktree(self, task_id: int) -> str:
task = self._load(task_id)
task["worktree"] = ""
task["updated_at"] = time.time()
self._save(task)
return json.dumps(task, indent=2)
def list_all(self) -> str:
tasks = []
for f in sorted(self.dir.glob("task_*.json")):
tasks.append(json.loads(f.read_text()))
if not tasks:
return "No tasks."
lines = []
for t in tasks:
marker = {
"pending": "[ ]",
"in_progress": "[>]",
"completed": "[x]",
}.get(t["status"], "[?]")
owner = f" owner={t['owner']}" if t.get("owner") else ""
wt = f" wt={t['worktree']}" if t.get("worktree") else ""
lines.append(f"{marker} #{t['id']}: {t['subject']}{owner}{wt}")
return "\n".join(lines)
TASKS = TaskManager(REPO_ROOT / ".tasks")
EVENTS = EventBus(REPO_ROOT / ".worktrees" / "events.jsonl")
# -- WorktreeManager: create/list/run/remove git worktrees + lifecycle index --
class WorktreeManager:
def __init__(self, repo_root: Path, tasks: TaskManager, events: EventBus):
self.repo_root = repo_root
self.tasks = tasks
self.events = events
self.dir = repo_root / ".worktrees"
self.dir.mkdir(parents=True, exist_ok=True)
self.index_path = self.dir / "index.json"
if not self.index_path.exists():
self.index_path.write_text(json.dumps({"worktrees": []}, indent=2))
self.git_available = self._is_git_repo()
def _is_git_repo(self) -> bool:
try:
r = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=self.repo_root,
capture_output=True,
text=True,
timeout=10,
)
return r.returncode == 0
except Exception:
return False
def _run_git(self, args: list[str]) -> str:
if not self.git_available:
raise RuntimeError("Not in a git repository. worktree tools require git.")
r = subprocess.run(
["git", *args],
cwd=self.repo_root,
capture_output=True,
text=True,
timeout=120,
)
if r.returncode != 0:
msg = (r.stdout + r.stderr).strip()
raise RuntimeError(msg or f"git {' '.join(args)} failed")
return (r.stdout + r.stderr).strip() or "(no output)"
def _load_index(self) -> dict:
return json.loads(self.index_path.read_text())
def _save_index(self, data: dict):
self.index_path.write_text(json.dumps(data, indent=2))
def _find(self, name: str) -> dict | None:
idx = self._load_index()
for wt in idx.get("worktrees", []):
if wt.get("name") == name:
return wt
return None
def _validate_name(self, name: str):
if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", name or ""):
raise ValueError(
"Invalid worktree name. Use 1-40 chars: letters, numbers, ., _, -"
)
def create(self, name: str, task_id: int = None, base_ref: str = "HEAD") -> str:
self._validate_name(name)
if self._find(name):
raise ValueError(f"Worktree '{name}' already exists in index")
if task_id is not None and not self.tasks.exists(task_id):
raise ValueError(f"Task {task_id} not found")
path = self.dir / name
branch = f"wt/{name}"
self.events.emit(
"worktree.create.before",
task={"id": task_id} if task_id is not None else {},
worktree={"name": name, "base_ref": base_ref},
)
try:
self._run_git(["worktree", "add", "-b", branch, str(path), base_ref])
entry = {
"name": name,
"path": str(path),
"branch": branch,
"task_id": task_id,
"status": "active",
"created_at": time.time(),
}
idx = self._load_index()
idx["worktrees"].append(entry)
self._save_index(idx)
if task_id is not None:
self.tasks.bind_worktree(task_id, name)
self.events.emit(
"worktree.create.after",
task={"id": task_id} if task_id is not None else {},
worktree={
"name": name,
"path": str(path),
"branch": branch,
"status": "active",
},
)
return json.dumps(entry, indent=2)
except Exception as e:
self.events.emit(
"worktree.create.failed",
task={"id": task_id} if task_id is not None else {},
worktree={"name": name, "base_ref": base_ref},
error=str(e),
)
raise
def list_all(self) -> str:
idx = self._load_index()
wts = idx.get("worktrees", [])
if not wts:
return "No worktrees in index."
lines = []
for wt in wts:
suffix = f" task={wt['task_id']}" if wt.get("task_id") else ""
lines.append(
f"[{wt.get('status', 'unknown')}] {wt['name']} -> "
f"{wt['path']} ({wt.get('branch', '-')}){suffix}"
)
return "\n".join(lines)
def status(self, name: str) -> str:
wt = self._find(name)
if not wt:
return f"Error: Unknown worktree '{name}'"
path = Path(wt["path"])
if not path.exists():
return f"Error: Worktree path missing: {path}"
r = subprocess.run(
["git", "status", "--short", "--branch"],
cwd=path,
capture_output=True,
text=True,
timeout=60,
)
text = (r.stdout + r.stderr).strip()
return text or "Clean worktree"
def run(self, name: str, command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
wt = self._find(name)
if not wt:
return f"Error: Unknown worktree '{name}'"
path = Path(wt["path"])
if not path.exists():
return f"Error: Worktree path missing: {path}"
try:
r = subprocess.run(
command,
shell=True,
cwd=path,
capture_output=True,
text=True,
timeout=300,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (300s)"
def remove(self, name: str, force: bool = False, complete_task: bool = False) -> str:
wt = self._find(name)
if not wt:
return f"Error: Unknown worktree '{name}'"
self.events.emit(
"worktree.remove.before",
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
worktree={"name": name, "path": wt.get("path")},
)
try:
args = ["worktree", "remove"]
if force:
args.append("--force")
args.append(wt["path"])
self._run_git(args)
if complete_task and wt.get("task_id") is not None:
task_id = wt["task_id"]
before = json.loads(self.tasks.get(task_id))
self.tasks.update(task_id, status="completed")
self.tasks.unbind_worktree(task_id)
self.events.emit(
"task.completed",
task={
"id": task_id,
"subject": before.get("subject", ""),
"status": "completed",
},
worktree={"name": name},
)
idx = self._load_index()
for item in idx.get("worktrees", []):
if item.get("name") == name:
item["status"] = "removed"
item["removed_at"] = time.time()
self._save_index(idx)
self.events.emit(
"worktree.remove.after",
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
worktree={"name": name, "path": wt.get("path"), "status": "removed"},
)
return f"Removed worktree '{name}'"
except Exception as e:
self.events.emit(
"worktree.remove.failed",
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
worktree={"name": name, "path": wt.get("path")},
error=str(e),
)
raise
def keep(self, name: str) -> str:
wt = self._find(name)
if not wt:
return f"Error: Unknown worktree '{name}'"
idx = self._load_index()
kept = None
for item in idx.get("worktrees", []):
if item.get("name") == name:
item["status"] = "kept"
item["kept_at"] = time.time()
kept = item
self._save_index(idx)
self.events.emit(
"worktree.keep",
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
worktree={
"name": name,
"path": wt.get("path"),
"status": "kept",
},
)
return json.dumps(kept, indent=2) if kept else f"Error: Unknown worktree '{name}'"
WORKTREES = WorktreeManager(REPO_ROOT, TASKS, EVENTS)
# -- Base tools (kept minimal, same style as previous sessions) --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command,
shell=True,
cwd=WORKDIR,
capture_output=True,
text=True,
timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("owner")),
"task_bind_worktree": lambda **kw: TASKS.bind_worktree(kw["task_id"], kw["worktree"], kw.get("owner", "")),
"worktree_create": lambda **kw: WORKTREES.create(kw["name"], kw.get("task_id"), kw.get("base_ref", "HEAD")),
"worktree_list": lambda **kw: WORKTREES.list_all(),
"worktree_status": lambda **kw: WORKTREES.status(kw["name"]),
"worktree_run": lambda **kw: WORKTREES.run(kw["name"], kw["command"]),
"worktree_keep": lambda **kw: WORKTREES.keep(kw["name"]),
"worktree_remove": lambda **kw: WORKTREES.remove(kw["name"], kw.get("force", False), kw.get("complete_task", False)),
"worktree_events": lambda **kw: EVENTS.list_recent(kw.get("limit", 20)),
}
TOOLS = [
{
"name": "bash",
"description": "Run a shell command in the current workspace (blocking).",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
{
"name": "read_file",
"description": "Read file contents.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["path"],
},
},
{
"name": "write_file",
"description": "Write content to file.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
{
"name": "edit_file",
"description": "Replace exact text in file.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"},
},
"required": ["path", "old_text", "new_text"],
},
},
{
"name": "task_create",
"description": "Create a new task on the shared task board.",
"input_schema": {
"type": "object",
"properties": {
"subject": {"type": "string"},
"description": {"type": "string"},
},
"required": ["subject"],
},
},
{
"name": "task_list",
"description": "List all tasks with status, owner, and worktree binding.",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "task_get",
"description": "Get task details by ID.",
"input_schema": {
"type": "object",
"properties": {"task_id": {"type": "integer"}},
"required": ["task_id"],
},
},
{
"name": "task_update",
"description": "Update task status or owner.",
"input_schema": {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
},
"owner": {"type": "string"},
},
"required": ["task_id"],
},
},
{
"name": "task_bind_worktree",
"description": "Bind a task to a worktree name.",
"input_schema": {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"worktree": {"type": "string"},
"owner": {"type": "string"},
},
"required": ["task_id", "worktree"],
},
},
{
"name": "worktree_create",
"description": "Create a git worktree and optionally bind it to a task.",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"task_id": {"type": "integer"},
"base_ref": {"type": "string"},
},
"required": ["name"],
},
},
{
"name": "worktree_list",
"description": "List worktrees tracked in .worktrees/index.json.",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "worktree_status",
"description": "Show git status for one worktree.",
"input_schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
{
"name": "worktree_run",
"description": "Run a shell command in a named worktree directory.",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"command": {"type": "string"},
},
"required": ["name", "command"],
},
},
{
"name": "worktree_remove",
"description": "Remove a worktree and optionally mark its bound task completed.",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"force": {"type": "boolean"},
"complete_task": {"type": "boolean"},
},
"required": ["name"],
},
},
{
"name": "worktree_keep",
"description": "Mark a worktree as kept in lifecycle state without removing it.",
"input_schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
{
"name": "worktree_events",
"description": "List recent worktree/task lifecycle events from .worktrees/events.jsonl.",
"input_schema": {
"type": "object",
"properties": {"limit": {"type": "integer"}},
},
},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
}
)
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print(f"Repo root for s12: {REPO_ROOT}")
if not WORKTREES.git_available:
print("Note: Not in a git repo. worktree_* tools will return errors.")
history = []
while True:
try:
query = input("\033[36ms12 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
-736
View File
@@ -1,736 +0,0 @@
#!/usr/bin/env python3
# Harness: all mechanisms combined -- the complete cockpit for the model.
"""
s_full.py - Full Reference Agent
Capstone implementation combining every mechanism from s01-s11.
Session s12 (task-aware worktree isolation) is taught separately.
NOT a teaching session -- this is the "put it all together" reference.
+------------------------------------------------------------------+
| FULL AGENT |
| |
| System prompt (s05 skills, task-first + optional todo nag) |
| |
| Before each LLM call: |
| +--------------------+ +------------------+ +--------------+ |
| | Microcompact (s06) | | Drain bg (s08) | | Check inbox | |
| | Auto-compact (s06) | | notifications | | (s09) | |
| +--------------------+ +------------------+ +--------------+ |
| |
| Tool dispatch (s02 pattern): |
| +--------+----------+----------+---------+-----------+ |
| | bash | read | write | edit | TodoWrite | |
| | task | load_sk | compress | bg_run | bg_check | |
| | t_crt | t_get | t_upd | t_list | spawn_tm | |
| | list_tm| send_msg | rd_inbox | bcast | shutdown | |
| | plan | idle | claim | | | |
| +--------+----------+----------+---------+-----------+ |
| |
| Subagent (s04): spawn -> work -> return summary |
| Teammate (s09): spawn -> work -> idle -> auto-claim (s11) |
| Shutdown (s10): request_id handshake |
| Plan gate (s10): submit -> approve/reject |
+------------------------------------------------------------------+
REPL commands: /compact /tasks /team /inbox
"""
import json
import os
import re
import subprocess
import threading
import time
import uuid
from pathlib import Path
from queue import Queue
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TEAM_DIR = WORKDIR / ".team"
INBOX_DIR = TEAM_DIR / "inbox"
TASKS_DIR = WORKDIR / ".tasks"
SKILLS_DIR = WORKDIR / "skills"
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
TOKEN_THRESHOLD = 100000
POLL_INTERVAL = 5
IDLE_TIMEOUT = 60
VALID_MSG_TYPES = {"message", "broadcast", "shutdown_request",
"shutdown_response", "plan_approval_response"}
# === SECTION: base_tools ===
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# === SECTION: todos (s03) ===
class TodoManager:
def __init__(self):
self.items = []
def update(self, items: list) -> str:
validated, ip = [], 0
for i, item in enumerate(items):
content = str(item.get("content", "")).strip()
status = str(item.get("status", "pending")).lower()
af = str(item.get("activeForm", "")).strip()
if not content: raise ValueError(f"Item {i}: content required")
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Item {i}: invalid status '{status}'")
if not af: raise ValueError(f"Item {i}: activeForm required")
if status == "in_progress": ip += 1
validated.append({"content": content, "status": status, "activeForm": af})
if len(validated) > 20: raise ValueError("Max 20 todos")
if ip > 1: raise ValueError("Only one in_progress allowed")
self.items = validated
return self.render()
def render(self) -> str:
if not self.items: return "No todos."
lines = []
for item in self.items:
m = {"completed": "[x]", "in_progress": "[>]", "pending": "[ ]"}.get(item["status"], "[?]")
suffix = f" <- {item['activeForm']}" if item["status"] == "in_progress" else ""
lines.append(f"{m} {item['content']}{suffix}")
done = sum(1 for t in self.items if t["status"] == "completed")
lines.append(f"\n({done}/{len(self.items)} completed)")
return "\n".join(lines)
def has_open_items(self) -> bool:
return any(item.get("status") != "completed" for item in self.items)
# === SECTION: subagent (s04) ===
def run_subagent(prompt: str, agent_type: str = "Explore") -> str:
sub_tools = [
{"name": "bash", "description": "Run command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
]
if agent_type != "Explore":
sub_tools += [
{"name": "write_file", "description": "Write file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Edit file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
]
sub_handlers = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"]),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}
sub_msgs = [{"role": "user", "content": prompt}]
resp = None
for _ in range(30):
resp = client.messages.create(model=MODEL, messages=sub_msgs, tools=sub_tools, max_tokens=8000)
sub_msgs.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
break
results = []
for b in resp.content:
if b.type == "tool_use":
h = sub_handlers.get(b.name, lambda **kw: "Unknown tool")
results.append({"type": "tool_result", "tool_use_id": b.id, "content": str(h(**b.input))[:50000]})
sub_msgs.append({"role": "user", "content": results})
if resp:
return "".join(b.text for b in resp.content if hasattr(b, "text")) or "(no summary)"
return "(subagent failed)"
# === SECTION: skills (s05) ===
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
if skills_dir.exists():
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
meta, body = {}, text
if match:
for line in match.group(1).strip().splitlines():
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip()
body = match.group(2).strip()
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
def descriptions(self) -> str:
if not self.skills: return "(no skills)"
return "\n".join(f" - {n}: {s['meta'].get('description', '-')}" for n, s in self.skills.items())
def load(self, name: str) -> str:
s = self.skills.get(name)
if not s: return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
return f"<skill name=\"{name}\">\n{s['body']}\n</skill>"
# === SECTION: compression (s06) ===
def estimate_tokens(messages: list) -> int:
return len(json.dumps(messages, default=str)) // 4
def microcompact(messages: list):
indices = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for part in msg["content"]:
if isinstance(part, dict) and part.get("type") == "tool_result":
indices.append(part)
if len(indices) <= 3:
return
for part in indices[:-3]:
if isinstance(part.get("content"), str) and len(part["content"]) > 100:
part["content"] = "[cleared]"
def auto_compact(messages: list) -> list:
TRANSCRIPT_DIR.mkdir(exist_ok=True)
path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
conv_text = json.dumps(messages, default=str)[:80000]
resp = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content": f"Summarize for continuity:\n{conv_text}"}],
max_tokens=2000,
)
summary = resp.content[0].text
return [
{"role": "user", "content": f"[Compressed. Transcript: {path}]\n{summary}"},
{"role": "assistant", "content": "Understood. Continuing with summary context."},
]
# === SECTION: file_tasks (s07) ===
class TaskManager:
def __init__(self):
TASKS_DIR.mkdir(exist_ok=True)
def _next_id(self) -> int:
ids = [int(f.stem.split("_")[1]) for f in TASKS_DIR.glob("task_*.json")]
return max(ids, default=0) + 1
def _load(self, tid: int) -> dict:
p = TASKS_DIR / f"task_{tid}.json"
if not p.exists(): raise ValueError(f"Task {tid} not found")
return json.loads(p.read_text())
def _save(self, task: dict):
(TASKS_DIR / f"task_{task['id']}.json").write_text(json.dumps(task, indent=2))
def create(self, subject: str, description: str = "") -> str:
task = {"id": self._next_id(), "subject": subject, "description": description,
"status": "pending", "owner": None, "blockedBy": [], "blocks": []}
self._save(task)
return json.dumps(task, indent=2)
def get(self, tid: int) -> str:
return json.dumps(self._load(tid), indent=2)
def update(self, tid: int, status: str = None,
add_blocked_by: list = None, add_blocks: list = None) -> str:
task = self._load(tid)
if status:
task["status"] = status
if status == "completed":
for f in TASKS_DIR.glob("task_*.json"):
t = json.loads(f.read_text())
if tid in t.get("blockedBy", []):
t["blockedBy"].remove(tid)
self._save(t)
if status == "deleted":
(TASKS_DIR / f"task_{tid}.json").unlink(missing_ok=True)
return f"Task {tid} deleted"
if add_blocked_by:
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
if add_blocks:
task["blocks"] = list(set(task["blocks"] + add_blocks))
self._save(task)
return json.dumps(task, indent=2)
def list_all(self) -> str:
tasks = [json.loads(f.read_text()) for f in sorted(TASKS_DIR.glob("task_*.json"))]
if not tasks: return "No tasks."
lines = []
for t in tasks:
m = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
owner = f" @{t['owner']}" if t.get("owner") else ""
blocked = f" (blocked by: {t['blockedBy']})" if t.get("blockedBy") else ""
lines.append(f"{m} #{t['id']}: {t['subject']}{owner}{blocked}")
return "\n".join(lines)
def claim(self, tid: int, owner: str) -> str:
task = self._load(tid)
task["owner"] = owner
task["status"] = "in_progress"
self._save(task)
return f"Claimed task #{tid} for {owner}"
# === SECTION: background (s08) ===
class BackgroundManager:
def __init__(self):
self.tasks = {}
self.notifications = Queue()
def run(self, command: str, timeout: int = 120) -> str:
tid = str(uuid.uuid4())[:8]
self.tasks[tid] = {"status": "running", "command": command, "result": None}
threading.Thread(target=self._exec, args=(tid, command, timeout), daemon=True).start()
return f"Background task {tid} started: {command[:80]}"
def _exec(self, tid: str, command: str, timeout: int):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=timeout)
output = (r.stdout + r.stderr).strip()[:50000]
self.tasks[tid].update({"status": "completed", "result": output or "(no output)"})
except Exception as e:
self.tasks[tid].update({"status": "error", "result": str(e)})
self.notifications.put({"task_id": tid, "status": self.tasks[tid]["status"],
"result": self.tasks[tid]["result"][:500]})
def check(self, tid: str = None) -> str:
if tid:
t = self.tasks.get(tid)
return f"[{t['status']}] {t.get('result', '(running)')}" if t else f"Unknown: {tid}"
return "\n".join(f"{k}: [{v['status']}] {v['command'][:60]}" for k, v in self.tasks.items()) or "No bg tasks."
def drain(self) -> list:
notifs = []
while not self.notifications.empty():
notifs.append(self.notifications.get_nowait())
return notifs
# === SECTION: messaging (s09) ===
class MessageBus:
def __init__(self):
INBOX_DIR.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
msg = {"type": msg_type, "from": sender, "content": content,
"timestamp": time.time()}
if extra: msg.update(extra)
with open(INBOX_DIR / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
path = INBOX_DIR / f"{name}.jsonl"
if not path.exists(): return []
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("")
return msgs
def broadcast(self, sender: str, content: str, names: list) -> str:
count = 0
for n in names:
if n != sender:
self.send(sender, n, content, "broadcast")
count += 1
return f"Broadcast to {count} teammates"
# === SECTION: shutdown + plan tracking (s10) ===
shutdown_requests = {}
plan_requests = {}
# === SECTION: team (s09/s11) ===
class TeammateManager:
def __init__(self, bus: MessageBus, task_mgr: TaskManager):
TEAM_DIR.mkdir(exist_ok=True)
self.bus = bus
self.task_mgr = task_mgr
self.config_path = TEAM_DIR / "config.json"
self.config = self._load()
self.threads = {}
def _load(self) -> dict:
if self.config_path.exists():
return json.loads(self.config_path.read_text())
return {"team_name": "default", "members": []}
def _save(self):
self.config_path.write_text(json.dumps(self.config, indent=2))
def _find(self, name: str) -> dict:
for m in self.config["members"]:
if m["name"] == name: return m
return None
def spawn(self, name: str, role: str, prompt: str) -> str:
member = self._find(name)
if member:
if member["status"] not in ("idle", "shutdown"):
return f"Error: '{name}' is currently {member['status']}"
member["status"] = "working"
member["role"] = role
else:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save()
threading.Thread(target=self._loop, args=(name, role, prompt), daemon=True).start()
return f"Spawned '{name}' (role: {role})"
def _set_status(self, name: str, status: str):
member = self._find(name)
if member:
member["status"] = status
self._save()
def _loop(self, name: str, role: str, prompt: str):
team_name = self.config["team_name"]
sys_prompt = (f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. "
f"Use idle when done with current work. You may auto-claim tasks.")
messages = [{"role": "user", "content": prompt}]
tools = [
{"name": "bash", "description": "Run command.", "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Edit file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "send_message", "description": "Send message.", "input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}}, "required": ["to", "content"]}},
{"name": "idle", "description": "Signal no more work.", "input_schema": {"type": "object", "properties": {}}},
{"name": "claim_task", "description": "Claim task by ID.", "input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
while True:
# -- WORK PHASE --
for _ in range(50):
inbox = self.bus.read_inbox(name)
for msg in inbox:
if msg.get("type") == "shutdown_request":
self._set_status(name, "shutdown")
return
messages.append({"role": "user", "content": json.dumps(msg)})
try:
response = client.messages.create(
model=MODEL, system=sys_prompt, messages=messages,
tools=tools, max_tokens=8000)
except Exception:
self._set_status(name, "shutdown")
return
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
idle_requested = False
for block in response.content:
if block.type == "tool_use":
if block.name == "idle":
idle_requested = True
output = "Entering idle phase."
elif block.name == "claim_task":
output = self.task_mgr.claim(block.input["task_id"], name)
elif block.name == "send_message":
output = self.bus.send(name, block.input["to"], block.input["content"])
else:
dispatch = {"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"]),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"])}
output = dispatch.get(block.name, lambda **kw: "Unknown")(**block.input)
print(f" [{name}] {block.name}: {str(output)[:120]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if idle_requested:
break
# -- IDLE PHASE: poll for messages and unclaimed tasks --
self._set_status(name, "idle")
resume = False
for _ in range(IDLE_TIMEOUT // max(POLL_INTERVAL, 1)):
time.sleep(POLL_INTERVAL)
inbox = self.bus.read_inbox(name)
if inbox:
for msg in inbox:
if msg.get("type") == "shutdown_request":
self._set_status(name, "shutdown")
return
messages.append({"role": "user", "content": json.dumps(msg)})
resume = True
break
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
t = json.loads(f.read_text())
if t.get("status") == "pending" and not t.get("owner") and not t.get("blockedBy"):
unclaimed.append(t)
if unclaimed:
task = unclaimed[0]
self.task_mgr.claim(task["id"], name)
# Identity re-injection for compressed contexts
if len(messages) <= 3:
messages.insert(0, {"role": "user", "content":
f"<identity>You are '{name}', role: {role}, team: {team_name}.</identity>"})
messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."})
messages.append({"role": "user", "content":
f"<auto-claimed>Task #{task['id']}: {task['subject']}\n{task.get('description', '')}</auto-claimed>"})
messages.append({"role": "assistant", "content": f"Claimed task #{task['id']}. Working on it."})
resume = True
break
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
def list_all(self) -> str:
if not self.config["members"]: return "No teammates."
lines = [f"Team: {self.config['team_name']}"]
for m in self.config["members"]:
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
return "\n".join(lines)
def member_names(self) -> list:
return [m["name"] for m in self.config["members"]]
# === SECTION: global_instances ===
TODO = TodoManager()
SKILLS = SkillLoader(SKILLS_DIR)
TASK_MGR = TaskManager()
BG = BackgroundManager()
BUS = MessageBus()
TEAM = TeammateManager(BUS, TASK_MGR)
# === SECTION: system_prompt ===
SYSTEM = f"""You are a coding agent at {WORKDIR}. Use tools to solve tasks.
Prefer task_create/task_update/task_list for multi-step work. Use TodoWrite for short checklists.
Use task for subagent delegation. Use load_skill for specialized knowledge.
Skills: {SKILLS.descriptions()}"""
# === SECTION: shutdown_protocol (s10) ===
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down.", "shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent to '{teammate}'"
# === SECTION: plan_approval (s10) ===
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
req = plan_requests.get(request_id)
if not req: return f"Error: Unknown plan request_id '{request_id}'"
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback, "plan_approval_response",
{"request_id": request_id, "approve": approve, "feedback": feedback})
return f"Plan {req['status']} for '{req['from']}'"
# === SECTION: tool_dispatch (s02) ===
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"TodoWrite": lambda **kw: TODO.update(kw["items"]),
"task": lambda **kw: run_subagent(kw["prompt"], kw.get("agent_type", "Explore")),
"load_skill": lambda **kw: SKILLS.load(kw["name"]),
"compress": lambda **kw: "Compressing...",
"background_run": lambda **kw: BG.run(kw["command"], kw.get("timeout", 120)),
"check_background": lambda **kw: BG.check(kw.get("task_id")),
"task_create": lambda **kw: TASK_MGR.create(kw["subject"], kw.get("description", "")),
"task_get": lambda **kw: TASK_MGR.get(kw["task_id"]),
"task_update": lambda **kw: TASK_MGR.update(kw["task_id"], kw.get("status"), kw.get("add_blocked_by"), kw.get("add_blocks")),
"task_list": lambda **kw: TASK_MGR.list_all(),
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
"list_teammates": lambda **kw: TEAM.list_all(),
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
"idle": lambda **kw: "Lead does not idle.",
"claim_task": lambda **kw: TASK_MGR.claim(kw["task_id"], "lead"),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "TodoWrite", "description": "Update task tracking list.",
"input_schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, "activeForm": {"type": "string"}}, "required": ["content", "status", "activeForm"]}}}, "required": ["items"]}},
{"name": "task", "description": "Spawn a subagent for isolated exploration or work.",
"input_schema": {"type": "object", "properties": {"prompt": {"type": "string"}, "agent_type": {"type": "string", "enum": ["Explore", "general-purpose"]}}, "required": ["prompt"]}},
{"name": "load_skill", "description": "Load specialized knowledge by name.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}},
{"name": "compress", "description": "Manually compress conversation context.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "background_run", "description": "Run command in background thread.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}, "timeout": {"type": "integer"}}, "required": ["command"]}},
{"name": "check_background", "description": "Check background task status.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}}},
{"name": "task_create", "description": "Create a persistent file task.",
"input_schema": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"]}},
{"name": "task_get", "description": "Get task details by ID.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
{"name": "task_update", "description": "Update task status or dependencies.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "deleted"]}, "add_blocked_by": {"type": "array", "items": {"type": "integer"}}, "add_blocks": {"type": "array", "items": {"type": "integer"}}}, "required": ["task_id"]}},
{"name": "task_list", "description": "List all tasks.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "spawn_teammate", "description": "Spawn a persistent autonomous teammate.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
{"name": "list_teammates", "description": "List all teammates.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "send_message", "description": "Send a message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "broadcast", "description": "Send message to all teammates.",
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
{"name": "shutdown_request", "description": "Request a teammate to shut down.",
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
{"name": "plan_approval", "description": "Approve or reject a teammate's plan.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
{"name": "idle", "description": "Enter idle state.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "claim_task", "description": "Claim a task from the board.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
# === SECTION: agent_loop ===
def agent_loop(messages: list):
rounds_without_todo = 0
while True:
# s06: compression pipeline
microcompact(messages)
if estimate_tokens(messages) > TOKEN_THRESHOLD:
print("[auto-compact triggered]")
messages[:] = auto_compact(messages)
# s08: drain background notifications
notifs = BG.drain()
if notifs:
txt = "\n".join(f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs)
messages.append({"role": "user", "content": f"<background-results>\n{txt}\n</background-results>"})
messages.append({"role": "assistant", "content": "Noted background results."})
# s10: check lead inbox
inbox = BUS.read_inbox("lead")
if inbox:
messages.append({"role": "user", "content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>"})
messages.append({"role": "assistant", "content": "Noted inbox messages."})
# LLM call
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
# Tool execution
results = []
used_todo = False
manual_compress = False
for block in response.content:
if block.type == "tool_use":
if block.name == "compress":
manual_compress = True
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}: {str(output)[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
if block.name == "TodoWrite":
used_todo = True
# s03: nag reminder (only when todo workflow is active)
rounds_without_todo = 0 if used_todo else rounds_without_todo + 1
if TODO.has_open_items() and rounds_without_todo >= 3:
results.insert(0, {"type": "text", "text": "<reminder>Update your todos.</reminder>"})
messages.append({"role": "user", "content": results})
# s06: manual compress
if manual_compress:
print("[manual compact]")
messages[:] = auto_compact(messages)
# === SECTION: repl ===
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms_full >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if query.strip() == "/compact":
if history:
print("[manual compact via /compact]")
history[:] = auto_compact(history)
continue
if query.strip() == "/tasks":
print(TASK_MGR.list_all())
continue
if query.strip() == "/team":
print(TEAM.list_all())
continue
if query.strip() == "/inbox":
print(json.dumps(BUS.read_inbox("lead"), indent=2))
continue
history.append({"role": "user", "content": query})
agent_loop(history)
print()
+1379
View File
File diff suppressed because it is too large Load Diff
-318
View File
@@ -1,318 +0,0 @@
# s01: The Agent Loop
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"One loop & Bash is all you need"* -- one tool + one loop = an agent.
>
> **Harness layer**: The loop -- the model's first connection to the real world.
## Problem
A language model can reason about code, but it can't *touch* the real world -- can't read files, run tests, or check errors. Without a loop, every tool call requires you to manually copy-paste results back. You become the loop.
## Solution
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
(ChatClient.call() auto-loops until no tool calls)
```
A single `call()` invocation controls the entire flow. Spring AI loops automatically until the model stops calling tools.
## How It Works
### 1. Build ChatClient: Inject Model + Register Tools
Inject `ChatModel` via Spring Boot auto-configuration, build the client with `ChatClient.builder()`, set the system prompt and tools.
```java
// TIP: The Python version creates client = Anthropic() and MODEL at module level.
// Spring AI injects ChatModel via auto-configuration, then builds ChatClient with builder.
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
+ ". Use bash to solve tasks. Act, don't explain.")
.defaultTools(new BashTool()) // Tool object with @Tool annotation
.build();
}
```
### 2. `@Tool` Annotation: Declarative Tool Registration
Spring AI automatically discovers and registers tools via the `@Tool` annotation. At startup, the framework scans objects passed to `defaultTools()`, extracts all `@Tool` method signatures and descriptions, generates the tool schema the LLM needs (name, parameters, description), and automatically includes it in every `call()` request.
```java
// BashTool -- corresponds to the Python version's run_bash() function
public class BashTool {
@Tool(description = "Run a shell command and return stdout + stderr")
public String bash(@ToolParam(description = "The shell command to execute")
String command) {
// Dangerous command check + ProcessBuilder execution + timeout control + output truncation
// ...
}
}
```
> Comparison with Python's manual registration:
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
> - Java: Just `@Tool` + `@ToolParam` annotations; the framework auto-generates schemas and dispatches methods
### 3. Spring AI Internal Auto-Loop: How `call()` Works Under the Hood
**This is the most critical difference between the Java and Python versions.** The Python version requires a hand-written while loop to drive tool calls:
```python
# Python version -- manual loop
def agent_loop(messages):
while True:
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
# Collect assistant message
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return response # Model no longer calling tools, exit loop
# Execute tools and feed back results
for block in response.content:
if block.type == "tool_use":
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
Spring AI's `ChatClient.call()` **encapsulates fully equivalent logic internally**:
```
call() internal flow:
┌─────────────────────────────────────────────────────┐
│ 1. Assemble request: system prompt + user msg + tools │
│ 2. Send to LLM │
│ 3. Parse response │
│ ├── Has tool_use? ──→ Yes: │
│ │ a. Extract tool name and arguments │
│ │ b. Invoke corresponding @Tool method via reflection │
│ │ c. Append tool_result to message list │
│ │ d. Go back to step 2 (auto-loop) │
│ └── No ──→ Return final text │
└─────────────────────────────────────────────────────┘
```
Key points:
- **Tool detection**: Spring AI checks if the response contains `tool_use` content blocks (equivalent to Python's `stop_reason == "tool_use"`)
- **Reflection dispatch**: The framework uses Java reflection to find and invoke the `@Tool` method matching the tool name returned by the LLM (equivalent to Python's `TOOL_HANDLERS[block.name]`)
- **Result feedback**: Tool execution results are automatically wrapped as `tool_result` messages and appended to the conversation (equivalent to Python's manual `tool_result` content block construction)
- **Loop termination**: When the model returns pure text (no tool calls), `call()` returns the final result
Thus, Python's ~15-line while loop is condensed into a single `.call()` in Java.
### 4. `AgentRunner.interactive()`: The REPL Interaction Loop
`AgentRunner` is a shared REPL (Read-Eval-Print Loop) utility class used across all lessons, corresponding to the `input()` loop in Python's `if __name__ == "__main__"` block.
```java
public class AgentRunner {
/**
* Start an interactive REPL loop.
* @param prefix Prompt prefix (e.g., "s01")
* @param handler Function that processes user input and returns Agent response
*/
public static void interactive(String prefix, Function<String, String> handler) {
Scanner scanner = new Scanner(System.in);
System.out.println("Type 'q' or 'exit' to quit");
while (true) {
System.out.print("\033[36m" + prefix + " >> \033[0m"); // Colored prompt
String input;
try {
if (!scanner.hasNextLine()) break;
input = scanner.nextLine().trim();
} catch (Exception e) {
break;
}
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
break;
}
try {
String response = handler.apply(input); // Call Agent handler
if (response != null && !response.isBlank()) {
System.out.println(response);
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
System.out.println();
}
System.out.println("Bye!");
}
}
```
Workflow: `Scanner` reads input → `handler.apply()` sends to Agent → print response → loop. The `handler` is a functional interface; each lesson passes in its own Agent invocation logic.
### 5. Assembled into a Complete Agent Class
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S01AgentLoop implements CommandLineRunner {
private final ChatClient chatClient;
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at ...")
.defaultTools(new BashTool())
.build();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt()
.user(userMessage)
.call() // ← This single call = Python's entire while loop
.content()
);
}
}
```
> **TIPS — Key Python → Java Adaptations:**
> - Python's `while True` + `stop_reason` manual loop → Spring AI `ChatClient.call()` built-in auto-loop
> - Python's `TOOLS` array + `TOOL_HANDLERS` dict → `@Tool` annotation + `defaultTools()` auto-registration with reflection dispatch
> - Python's `client = Anthropic()` → Spring Boot auto-configured `ChatModel` injection
> - Python's `input()` interaction → `AgentRunner.interactive()` wrapping Scanner REPL + functional interface
Under 40 lines of core code, and that's the entire agent. The next 11 chapters all layer mechanisms on top of this loop -- the loop itself never changes.
## What Changed
| Component | Before | After |
|---------------|------------|-------------------------------------------------|
| Agent loop | (none) | `ChatClient.call()` built-in tool loop |
| Tools | (none) | `BashTool` (single `@Tool` tool) |
| Messages | (none) | Managed internally by Spring AI |
| Control flow | (none) | Framework auto-detects: returns final text when no tool calls |
```java
// Core code -- build + call
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(new BashTool())
.build();
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt().user(userMessage).call().content()
);
```
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
> Set environment variables before running: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
>
> **The default protocol is OpenAI** (compatible with all OpenAI API-format services, including OpenAI official, Azure OpenAI, and any third-party model services offering an OpenAI-compatible interface).
> To use the Anthropic protocol (Claude native API), expand the section below.
<details>
<summary><strong>Switching AI Protocols (OpenAI ↔ Anthropic)</strong></summary>
This project switches the underlying protocol via **Spring AI Starter dependency + configuration file**. Java business code (`ChatModel`, `ChatClient`) **requires no changes**.
#### Option 1: OpenAI Protocol (Default)
`pom.xml` dependency:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
```
`application.yml` configuration:
```yaml
spring:
ai:
openai:
api-key: ${AI_API_KEY:sk-xxx}
base-url: ${AI_BASE_URL:https://api.openai.com}
chat:
options:
model: ${AI_MODEL:gpt-4o}
```
Environment variable example:
```sh
export AI_API_KEY=sk-proj-xxxxxxxx
export AI_BASE_URL=https://api.openai.com # Replace with any OpenAI-compatible endpoint
export AI_MODEL=gpt-4o
```
> **TIP**: Many third-party model services (e.g., DeepSeek, Mistral, Qwen) provide OpenAI-compatible APIs. Simply change `AI_BASE_URL` and `AI_MODEL` to connect — no protocol switch needed.
#### Option 2: Anthropic Protocol (Claude Native API)
**Step 1**: Edit `pom.xml` — replace the OpenAI starter with the Anthropic starter:
```xml
<!-- Comment out or remove the OpenAI starter -->
<!-- <dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency> -->
<!-- Add the Anthropic starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
```
**Step 2**: Edit `application.yml` — replace `spring.ai.openai` with `spring.ai.anthropic`:
```yaml
spring:
ai:
anthropic:
api-key: ${AI_API_KEY}
base-url: ${AI_BASE_URL:https://api.anthropic.com}
chat:
options:
model: ${AI_MODEL:claude-sonnet-4-20250514}
```
**Step 3**: Set environment variables:
```sh
export AI_API_KEY=sk-ant-xxxxxxxx
export AI_BASE_URL=https://api.anthropic.com
export AI_MODEL=claude-sonnet-4-20250514
```
#### How Switching Works
Spring AI's `ChatModel` is a unified abstraction interface. Different Starters provide different implementations:
| Starter Dependency | Auto-injected ChatModel | Config Prefix |
|---|---|---|
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
Business code always programs against the `ChatModel` interface. Switching protocols only requires changing the dependency and configuration — no Java code changes needed.
</details>
Try these prompts(English prompts work better with LLMs, but Chinese also works):
1. `Create a file called Hello.java that prints "Hello, World!"`
2. `List all Java files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
-139
View File
@@ -1,139 +0,0 @@
# s02: Tool Use
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"Adding a tool means adding one @Tool method"* -- the loop stays the same; new tools are passed into `defaultTools()`.
>
> **Harness layer**: Tool dispatch -- expanding what the model can reach.
## Problem
With only `bash`, the agent shells out for everything. `cat` truncates unpredictably, `sed` fails on special characters, and every bash call is an unconstrained security surface. Dedicated tools (`read_file`, `write_file`) let you enforce path sandboxing at the tool level.
The key insight: adding tools does not require changing the loop.
## Solution
```
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
Spring AI auto-registers and dispatches via @Tool annotations.
No hand-written dispatch map needed -- the framework scans annotated methods on tool objects.
```
## How It Works
1. Each tool is a standalone class declared with `@Tool` annotation. `PathValidator` provides path sandboxing to prevent workspace escape.
```java
// PathValidator -- corresponds to the Python version's safe_path() function
public class PathValidator {
private final Path workDir;
public Path resolve(String relativePath) {
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
if (!resolved.startsWith(workDir)) {
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
}
return resolved;
}
}
// ReadFileTool -- corresponds to the Python version's run_read() function
public class ReadFileTool {
private final PathValidator pathValidator;
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
public String readFile(
@ToolParam(description = "Relative path to the file") String path,
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
Path filePath = pathValidator.resolve(path);
List<String> lines = Files.readAllLines(filePath);
if (limit != null && limit > 0 && limit < lines.size()) {
lines = lines.subList(0, limit);
}
return String.join("\n", lines);
}
}
```
2. Tool registration simply passes objects to `defaultTools()`. Spring AI scans `@Tool` annotated methods and automatically handles name mapping and parameter binding.
```java
// Corresponds to the Python version's TOOL_HANDLERS dict
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
// Java: Just pass tool objects; @Tool annotations handle auto-registration
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(
new BashTool(), // bash command execution
new ReadFileTool(), // file reading
new WriteFileTool(), // file writing
new EditFileTool() // file editing (find & replace)
)
.build();
```
3. The calling code is identical to s01. The loop is managed by the framework; developers only focus on tool implementation.
```java
// Compared to s01, the only change is that defaultTools() receives 3 more tool objects
// The loop code is exactly the same -- this is the core insight of s02
AgentRunner.interactive("s02", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
```
Add a tool = add a `@Tool` class + pass it to `defaultTools()`. The loop never changes.
> **TIPS — Key Python → Java Adaptations:**
> - Python's `TOOL_HANDLERS` dict → Spring AI `@Tool` annotation + `defaultTools()` auto-registration and dispatch
> - Python's `safe_path()` function → `PathValidator` class (same path escape check logic)
> - Python's `lambda **kw` parameter unpacking → `@ToolParam` annotation auto-binds parameters
> - Python's `block.type == "tool_use"` check → Spring AI handles detection and dispatch internally
## What Changed From s01
| Component | Before (s01) | After (s02) |
|----------------|-----------------------|------------------------------------------------|
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
| Path safety | None | `PathValidator` sandbox |
| Agent loop | Unchanged | Unchanged |
```java
// s01 → s02 only change: defaultTools() receives 3 more tool objects
.defaultTools(
new BashTool(),
new ReadFileTool(), // +new
new WriteFileTool(), // +new
new EditFileTool() // +new
)
```
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
```
> Set environment variables before running: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Read the file pom.xml`
2. `Create a file called Greet.java with a greet(name) method`
3. `Edit Greet.java to add a Javadoc comment to the method`
4. `Read Greet.java to verify the edit worked`
-120
View File
@@ -1,120 +0,0 @@
# s03: TodoWrite
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"An agent without a plan drifts"* -- list the steps first, then execute. Doubles the completion rate.
>
> **Harness layer**: Planning -- keeping the model on course without scripting the route.
## Problem
On multi-step tasks, the model loses track -- repeats work, skips steps, or wanders off. Long conversations make this worse: tool results keep filling the context, gradually diluting the system prompt's influence. A 10-step refactoring might complete steps 1-3, then the model starts improvising because steps 4-10 have been pushed out of attention.
## Solution
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+-----------------------+
|
Inject latest todo state into
system prompt via defaultSystem()
on each request
```
## How It Works
1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time.
```java
public class TodoManager {
public record TodoItem(String id, String text, String status) {}
private List<TodoItem> items = new ArrayList<>();
@Tool(description = "Update the full task list to track progress. "
+ "Each item must have id, text, status (pending/in_progress/completed). "
+ "Only one task can be in_progress at a time. Max 20 items.")
public String updateTodos(
@ToolParam(description = "The complete list of todo items")
List<TodoItem> items) {
if (items.size() > 20) return "Error: Max 20 todos allowed";
List<TodoItem> validated = new ArrayList<>();
int inProgressCount = 0;
for (TodoItem item : items) {
String status = (item.status() != null)
? item.status().toLowerCase() : "pending";
if ("in_progress".equals(status)) inProgressCount++;
validated.add(new TodoItem(item.id(), item.text().trim(), status));
}
if (inProgressCount > 1)
return "Error: Only one task can be in_progress at a time";
this.items = validated;
return render();
}
}
```
2. `TodoManager` is registered via `defaultTools()`; the `@Tool` annotated method is automatically exposed as a tool.
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool annotated method auto-registered
)
.build();
```
3. System prompt injection: on each user input, inject the latest todo state into the system prompt with emphasis on update instructions.
```java
// Dynamic system prompt: includes current todo state
String system = "You are a coding agent at " + workDir + ".\n"
+ "Use the todo tool to plan multi-step tasks. "
+ "Mark in_progress before starting, completed when done.\n"
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
```
The "only one in_progress at a time" constraint forces sequential focus. Continuously injecting todo state into the system prompt creates accountability pressure -- the model sees its own plan every turn and won't forget to update it.
> **TIP**: The Python version tracks `rounds_since_todo` inside the tool loop and injects `<reminder>` text after 3 consecutive rounds without a todo call. Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop injection, so system prompt injection is used instead to achieve the same effect.
## What Changed From s02
| Component | Before (s02) | After (s03) |
|----------------|------------------|--------------------------------------|
| Tools | 4 | 5 (+TodoManager `@Tool`) |
| Planning | None | TodoManager with statuses |
| State injection| None | System prompt injection `<current-todos>` |
| ChatClient | Fixed system prompt | Rebuilt each turn, dynamic todo state injection |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
2. `Create a Java package with utils and tests`
3. `Review all Java files and fix any style issues`
-102
View File
@@ -1,102 +0,0 @@
# s04: Subagents
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"Break big tasks down; each subtask gets a clean context"* -- subagents use independent messages[], keeping the main conversation clean.
>
> **Harness layer**: Context isolation -- protecting the model's clarity of thought.
## Problem
As the agent works, its messages array grows. Every file read, every bash output stays in context permanently. "What testing framework does this project use?" might require reading 5 files, but the parent only needs one word: "pytest."
## Solution
```
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ----------> | while tool_use: |
| prompt="..." | | call tools |
| | summary | append results |
| result = "..." | <---------- | return last text |
+------------------+ +------------------+
Parent context stays clean. Subagent context is discarded.
```
## How It Works
1. The parent agent has a `task` tool. The subagent gets all base tools except `task` (no recursive spawning).
```java
// Parent Agent: has base tools + SubagentTool
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. "
+ "Use the task tool to delegate subtasks.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
new SubagentTool(chatModel) // Parent Agent exclusive
)
.build();
```
2. The subagent starts with a brand new `ChatClient` and an independent context. Only the final text returns to the parent.
```java
@Tool(description = "Spawn a subagent with fresh context. "
+ "Use for exploration or subtasks that might pollute the main context.")
public String task(
@ToolParam(description = "The task prompt") String prompt,
@ToolParam(description = "Short description", required = false)
String description) {
// Create a brand new ChatClient -- this IS "context isolation"
ChatClient subClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding subagent. "
+ "Complete the task, then summarize findings.")
.defaultTools( // Base tools, no task (prevents recursion)
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool()
)
.build();
String result = subClient.prompt()
.user(prompt)
.call()
.content();
// Only the final text is returned; subagent context is discarded
return (result != null) ? result : "(no summary)";
}
```
The subagent may have run multiple tool calls, but its entire message history is discarded. The parent receives only a summary text, returned as a normal `tool_result`. Spring AI's `ChatClient.call()` manages the tool loop internally -- no need to manually limit iteration count.
## What Changed From s03
| Component | Before (s03) | After (s04) |
|----------------|------------------|---------------------------------------|
| Tools | 5 | 5 (base) + SubagentTool (parent only) |
| Context | Single shared | Parent + child isolation (independent ChatClient) |
| Subagent | None | `SubagentTool.task()` method |
| Return value | N/A | Summary text only |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .java files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
-155
View File
@@ -1,155 +0,0 @@
# s05: Skills
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"Load knowledge when you need it, not upfront"* -- inject via tool_result, not the system prompt.
>
> **Harness layer**: On-demand knowledge -- domain expertise, loaded when the model asks.
## Problem
You want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens -- 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.
## Solution
```
System prompt (Layer 1 -- always present):
+--------------------------------------+
| You are a coding agent. |
| Skills available: |
| - git: Git workflow helpers | ~100 tokens/skill
| - test: Testing best practices |
+--------------------------------------+
When model calls load_skill("git"):
+--------------------------------------+
| tool_result (Layer 2 -- on demand): |
| <skill name="git"> |
| Full git workflow instructions... | ~2000 tokens
| Step 1: ... |
| </skill> |
+--------------------------------------+
```
Layer 1: skill *names* in system prompt (cheap). Layer 2: full *body* via tool_result (on demand).
## How It Works
1. Each skill is a directory containing a `SKILL.md` file with YAML frontmatter.
```
skills/
pdf/
SKILL.md # ---\n name: pdf\n description: Process PDF files\n ---\n ...
code-review/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoader recursively scans for `SKILL.md` files, using the directory name as the skill identifier.
```java
public class SkillLoader {
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
record SkillInfo(Map<String, String> meta, String body, String path) {}
public SkillLoader(Path skillsDir) {
loadAll(skillsDir);
}
/** Recursively scan all SKILL.md files under the skills directory */
private void loadAll(Path skillsDir) {
if (!Files.exists(skillsDir)) return;
try (Stream<Path> paths = Files.walk(skillsDir)) {
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
.sorted()
.forEach(p -> {
String text = Files.readString(p);
var parsed = parseFrontmatter(text);
String name = parsed.meta().getOrDefault("name",
p.getParent().getFileName().toString());
skills.put(name, new SkillInfo(
parsed.meta(), parsed.body(), p.toString()));
});
}
}
/** Layer 1: Get short descriptions of all skills (for system prompt injection) */
public String getDescriptions() {
if (skills.isEmpty()) return "(no skills available)";
StringBuilder sb = new StringBuilder();
for (var entry : skills.entrySet()) {
String desc = entry.getValue().meta()
.getOrDefault("description", "No description");
sb.append(" - ").append(entry.getKey())
.append(": ").append(desc).append("\n");
}
return sb.toString().stripTrailing();
}
/** Layer 2: Load full content of a specified skill (as @Tool method) */
@Tool(description = "Load specialized knowledge by name.")
public String loadSkill(
@ToolParam(description = "Skill name to load") String name) {
SkillInfo skill = skills.get(name);
if (skill == null)
return "Error: Unknown skill '" + name + "'. Available: "
+ String.join(", ", skills.keySet());
return "<skill name=\"" + name + "\">\n"
+ skill.body() + "\n</skill>";
}
}
```
3. Layer 1 goes into the system prompt. Layer 2 is loaded on demand via the `@Tool` annotated method on SkillLoader.
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
// Layer 1: Skill metadata injected into system prompt
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
+ "Use loadSkill to access specialized knowledge.\n\n"
+ "Skills available:\n"
+ skillLoader.getDescriptions();
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
skillLoader // Layer 2: loadSkill @Tool method
)
.build();
}
```
The model learns what skills exist (cheap) and loads them when relevant (expensive).
## What Changed From s04
| Component | Before (s04) | After (s05) |
|----------------|------------------|--------------------------------|
| Tools | 5 (base + task) | 5 (base + load_skill) |
| System prompt | Static string | + skill descriptions |
| Knowledge | None | skills/\*/SKILL.md files |
| Injection | None | Two-layer (system + result) |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `What skills are available?`
2. `Load the agent-builder skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
4. `Build an MCP server using the mcp-builder skill`
-185
View File
@@ -1,185 +0,0 @@
# s06: Context Compact
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
> *"Context will fill up; you need a way to make room"* -- three-layer compression strategy for infinite sessions.
>
> **Harness layer**: Compression -- clean memory for infinite sessions.
## Problem
The context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens; after reading 30 files and running 20 commands, you easily blow past 100k tokens. Without compression, the agent simply cannot work on large codebases.
## Solution
Three layers, increasing in aggressiveness:
```
Every turn:
+------------------+
| Tool call result |
+------------------+
|
v
[Layer 1: micro_compact] (silent, every turn)
Replace tool_result > 3 turns old
with "[Previous: used {tool_name}]"
|
v
[Check: tokens > 50000?]
| |
no yes
| |
v v
continue [Layer 2: auto_compact]
Save transcript to .transcripts/
LLM summarizes conversation.
Replace all messages with [summary].
|
v
[Layer 3: compact tool]
Model calls compact explicitly.
Same summarization as auto_compact.
```
## How It Works
1. **Layer 1 -- Context window management**: Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop compression injection. The Java version achieves an equivalent effect by limiting the number of conversation turns injected into the system prompt (keeping only the most recent N turns) and truncating content.
```java
/** Estimate token count: rough estimate of 4 chars ≈ 1 token */
public int estimateTokens() {
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
return chars / 4;
}
/** Get conversation history summary (for system prompt injection, keeping only recent turns) */
public String getContextSummary() {
if (history.isEmpty()) return "";
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
for (int i = start; i < history.size(); i++) {
ConversationTurn turn = history.get(i);
sb.append("[").append(turn.role()).append("]: ")
.append(turn.content(), 0, Math.min(500, turn.content().length()))
.append("\n");
}
sb.append("</conversation-context>");
return sb.toString();
}
```
2. **Layer 2 -- auto_compact**: When tokens exceed the threshold, save the full conversation to disk and have the LLM summarize it.
```java
public String compact() {
// Save transcript to disk (full history is not lost)
Files.createDirectories(transcriptDir);
Path transcriptPath = transcriptDir.resolve(
"transcript_" + System.currentTimeMillis() + ".jsonl");
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
for (ConversationTurn turn : history) {
writer.write(objectMapper.writeValueAsString(turn));
writer.newLine();
}
}
// LLM generates summary
String conversationText = history.stream()
.map(t -> t.role() + ": " + t.content())
.reduce("", (a, b) -> a + "\n" + b);
if (conversationText.length() > 80000) {
conversationText = conversationText.substring(0, 80000);
}
ChatClient summaryClient = ChatClient.builder(chatModel).build();
String summary = summaryClient.prompt()
.user("Summarize this conversation for continuity. Include: "
+ "1) What was accomplished, 2) Current state, "
+ "3) Key decisions.\n\n" + conversationText)
.call().content();
// Replace history with summary
history.clear();
history.add(new ConversationTurn("system",
"[Conversation compressed. Transcript: " + transcriptPath
+ "]\n\n" + summary));
return summary;
}
```
3. **Layer 3 -- manual compact**: The `CompactTool` triggers the same summarization mechanism on demand.
```java
public class CompactTool {
private final ContextCompactor compactor;
public CompactTool(ContextCompactor compactor) {
this.compactor = compactor;
}
@Tool(description = "Trigger manual conversation compression to free up context space.")
public String compact(
@ToolParam(description = "What to preserve in summary",
required = false) String focus) {
compactor.requestCompact();
return "Compression triggered. Context will be summarized.";
}
}
```
4. The REPL layer integrates all three layers (Spring AI's ChatClient manages the tool loop automatically; compression is triggered at the user message level):
```java
AgentRunner.interactive("s06", userMessage -> {
// Layer 2: Auto-compact check (before each user input)
if (compactor.needsAutoCompact()) {
System.out.println("[auto_compact triggered]");
compactor.compact();
}
compactor.addTurn("user", userMessage);
// Dynamic system prompt: includes conversation context summary
String system = baseSystem + compactor.getContextSummary();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), compactTool)
.build();
String response = chatClient.prompt()
.user(userMessage).call().content();
compactor.addTurn("assistant", response != null ? response : "");
// Layer 3: Manual compact (if the agent called the compact tool)
if (compactor.isCompactRequested()) {
compactor.compact();
}
return response;
});
```
Full history is preserved on disk via transcripts. Nothing is truly lost -- just moved out of active context.
## What Changed From s05
| Component | Before (s05) | After (s06) |
|----------------|------------------|--------------------------------|
| Tools | 5 | 5 (base + compact) |
| Context mgmt | None | Three-layer compression |
| Context window mgmt | None | Limited turn injection + content truncation |
| Auto-compact | None | Token threshold trigger |
| Transcripts | None | Saved to .transcripts/ |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Read every Java file in the src/ directory one by one` (observe context window management)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
-170
View File
@@ -1,170 +0,0 @@
# s07: Task System
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`
> *"Break big goals into small tasks, order them, persist to disk"* -- a file-based task graph with dependencies, laying the foundation for multi-agent collaboration.
>
> **Harness layer**: Persistent tasks -- goals that outlive any single conversation.
## Problem
s03's TodoManager is a flat checklist in memory: no ordering, no dependencies, no status beyond done-or-not. Real goals have structure -- task B depends on task A, tasks C and D can run in parallel, task E waits for both C and D.
Without explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compaction (s06) wipes it clean.
## Solution
Promote the checklist into a **task graph** persisted to disk. Each task is a JSON file with status, dependencies (`blockedBy`), and dependents (`blocks`). The graph answers three questions at any moment:
- **What's ready?** -- tasks with `pending` status and empty `blockedBy`.
- **What's blocked?** -- tasks waiting on unfinished dependencies.
- **What's done?** -- `completed` tasks, whose completion automatically unblocks dependents.
```
.tasks/
task_1.json {"id":1, "status":"completed"}
task_2.json {"id":2, "blockedBy":[1], "status":"pending"}
task_3.json {"id":3, "blockedBy":[1], "status":"pending"}
task_4.json {"id":4, "blockedBy":[2,3], "status":"pending"}
Task graph (DAG):
+----------+
+--> | task 2 | --+
| | pending | |
+----------+ +----------+ +--> +----------+
| task 1 | | task 4 |
| completed| --> +----------+ +--> | blocked |
+----------+ | task 3 | --+ +----------+
| pending |
+----------+
Ordering: task 1 must finish before 2 and 3
Parallelism: tasks 2 and 3 can run at the same time
Dependencies: task 4 waits for both 2 and 3
Status: pending -> in_progress -> completed
```
This task graph becomes the coordination backbone for everything after s07: background execution (s08), multi-agent teams (s09+), and worktree isolation (s12) all read from and write to this same structure.
## How It Works
1. **TaskManager**: one JSON file per task, CRUD with dependency graph. Uses Jackson `ObjectMapper` for JSON serialization.
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
public TaskManager(Path tasksDir) {
this.dir = tasksDir;
Files.createDirectories(dir);
this.nextId = maxId() + 1;
}
@Tool(description = "Create a new task with subject and optional description")
public String taskCreate(
@ToolParam(description = "Short subject of the task") String subject,
@ToolParam(description = "Detailed description", required = false) String description) {
Map<String, Object> task = new LinkedHashMap<>();
task.put("id", nextId);
task.put("subject", subject);
task.put("status", "pending");
task.put("blockedBy", new ArrayList<>());
task.put("blocks", new ArrayList<>());
save(task);
nextId++;
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
}
```
2. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents.
```java
private void clearDependency(int completedId) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.forEach(f -> {
Map<String, Object> task = MAPPER.readValue(
Files.readString(f), new TypeReference<>() {});
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
save(task);
}
});
}
}
```
3. **Status transitions + dependency wiring**: `taskUpdate` handles status transitions and dependency edges. When status changes to `completed`, it automatically calls `clearDependency`; `blockedBy`/`blocks` are bidirectional relationships.
```java
@Tool(description = "Update a task's status or dependencies.")
public String taskUpdate(
@ToolParam(description = "Task ID") int taskId,
@ToolParam(description = "New status", required = false) String status,
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
Map<String, Object> task = load(taskId);
if (status != null) {
task.put("status", status);
if ("completed".equals(status)) {
clearDependency(taskId);
}
}
// Handle addBlockedBy / addBlocks bidirectional dependencies ...
save(task);
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
4. **Spring AI auto-registers tools**: Pass `TaskManager` as a `defaultTools` argument to `ChatClient`. Spring AI automatically recognizes `@Tool` annotated methods -- no manual dispatch map needed.
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S07TaskSystem implements CommandLineRunner {
private final ChatClient chatClient;
public S07TaskSystem(ChatModel chatModel) {
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
TaskManager taskManager = new TaskManager(tasksDir);
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
taskManager // @Tool methods in TaskManager are auto-registered
)
.build();
}
}
```
From s07 onward, the task graph is the default for multi-step work. s03's Todo remains for quick single-session checklists.
## What Changed From s06
| Component | Before (s06) | After (s07) |
|---|---|---|
| Tools | 5 | 8 (`task_create/update/list/get`) |
| Planning model | Flat checklist (in-memory) | Task graph with dependencies (on disk) |
| Relationships | None | `blockedBy` + `blocks` edges |
| Status tracking | Done or not | `pending` -> `in_progress` -> `completed` |
| Persistence | Lost on compression | Survives compression and restarts |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
2. `List all tasks and show the dependency graph`
3. `Complete task 1 and then list tasks to see task 2 unblocked`
4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse`
-138
View File
@@ -1,138 +0,0 @@
# s08: Background Tasks
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"Run slow operations in the background; the agent keeps thinking"* -- background threads run commands, inject notifications on completion.
>
> **Harness layer**: Background execution -- the model thinks while the harness waits.
## Problem
Some commands take minutes: `npm install`, `pytest`, `docker build`. With a blocking loop, the model sits idle waiting. If the user asks "install dependencies and while that runs, create the config file," the agent does them sequentially, not in parallel.
## Solution
```
Main thread Background thread
+-----------------+ +-----------------+
| agent loop | | subprocess runs |
| ... | | ... |
| [LLM call] <---+------- | enqueue(result) |
| ^drain queue | +-----------------+
+-----------------+
Timeline:
Agent --[spawn A]--[spawn B]--[other work]----
| |
v v
[A runs] [B runs] (parallel)
| |
+-- results injected before next LLM call --+
```
## How It Works
1. BackgroundManager tracks tasks with thread-safe concurrent containers. Java uses `ConcurrentHashMap` and `CopyOnWriteArrayList` instead of Python's manual locking.
```java
public class BackgroundManager {
private static final int TIMEOUT_SECONDS = 300;
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
record TaskInfo(String status, String result, String command) {}
public record Notification(String taskId, String status, String command, String result) {}
}
```
2. `backgroundRun()` submits a virtual thread (Java 21) and returns immediately. Compared to Python's `daemon=True` threads, virtual threads are lighter and scheduled by the JVM.
```java
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
public String backgroundRun(
@ToolParam(description = "The shell command to run in background") String command) {
String taskId = UUID.randomUUID().toString().substring(0, 8);
tasks.put(taskId, new TaskInfo("running", null, command));
executor.submit(() -> execute(taskId, command));
return "Background task " + taskId + " started: "
+ command.substring(0, Math.min(80, command.length()));
}
```
3. When the subprocess finishes, the result goes into the notification queue. Uses `ProcessBuilder` for command execution with timeout control.
```java
private void execute(String taskId, String command) {
String status, output;
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true);
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) { process.destroyForcibly(); status = "timeout"; }
else { status = "completed"; }
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
tasks.put(taskId, new TaskInfo(status, output, command));
notificationQueue.add(new Notification(taskId, status, command, output));
}
```
4. Drain the notification queue on each user input and inject into the system prompt. Spring AI's `ChatClient` manages the internal tool loop, so notifications are drained and built into the system prompt on each user input instead -- the core concept remains the same: fire and forget.
```java
AgentRunner.interactive("s08", userMessage -> {
// Drain background task notifications (corresponds to Python's pre-loop drain_notifications)
var notifs = bgManager.drainNotifications();
String bgContext = "";
if (!notifs.isEmpty()) {
String notifText = notifs.stream()
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
.collect(Collectors.joining("\n"));
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
}
String system = "You are a coding agent. Use backgroundRun for long-running commands."
+ bgContext;
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), bgManager)
.build();
return chatClient.prompt().user(userMessage).call().content();
});
```
The loop stays single-threaded. Only subprocess I/O is parallelized.
## What Changed From s07
| Component | Before (s07) | After (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (base + backgroundRun + check) |
| Execution | Blocking only | Blocking + virtual threads (Java 21)|
| Notification | None | ConcurrentLinkedQueue drained per turn |
| Concurrency | None | Virtual threads (lighter, JVM-scheduled) |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
3. `Run pytest in the background and keep working on other things`
-175
View File
@@ -1,175 +0,0 @@
# s09: Agent Teams
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"When the task is too big for one, delegate to teammates"* -- persistent teammates + JSONL mailboxes.
>
> **Harness layer**: Team mailboxes -- multiple models, coordinated through files.
## Problem
Subagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.
Real teamwork needs three things: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.
## Solution
```
Teammate lifecycle:
spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN
Communication:
.team/
config.json <- team roster + statuses
inbox/
alice.jsonl <- append-only, drain-on-read
bob.jsonl
lead.jsonl
+--------+ send("alice","bob","...") +--------+
| alice | -----------------------------> | bob |
| loop | bob.jsonl << {json_line} | loop |
+--------+ +--------+
^ |
| BUS.read_inbox("alice") |
+---- alice.jsonl -> read + drain ---------+
```
## How It Works
1. TeammateManager maintains the team roster via config.json.
```java
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
public class TeammateManager {
private final ChatModel chatModel;
private final MessageBus bus;
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
// Python uses threading.Thread + dict; Java uses ConcurrentHashMap for natural thread safety
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
this.chatModel = chatModel;
this.bus = bus;
this.configPath = teamDir.resolve("config.json");
Files.createDirectories(teamDir);
this.config = loadConfig();
}
```
2. `spawn()` creates a teammate and starts its agent loop in a thread.
```java
// Python uses threading.Thread; Java uses Thread.startVirtualThread() for virtual threads
public synchronized String spawn(String name, String role, String prompt) {
Map<String, Object> member = new LinkedHashMap<>();
member.put("name", name);
member.put("role", role);
member.put("status", "working");
((List<Map<String, Object>>) config.get("members")).add(member);
saveConfig();
// Virtual thread: lightweight, JVM-scheduled, doesn't occupy OS threads
Thread thread = Thread.startVirtualThread(
() -> teammateLoop(name, role, prompt));
threads.put(name, thread);
return "Spawned '" + name + "' (role: " + role + ")";
}
```
3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains.
```java
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
// Python relies on GIL for implicit thread safety; Java uses synchronized for explicit safety
public class MessageBus {
private final Path inboxDir;
private final ObjectMapper mapper = new ObjectMapper();
public synchronized String send(String sender, String to, String content,
String msgType, Map<String, Object> extra) {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", msgType);
msg.put("from", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
if (extra != null) msg.putAll(extra);
Path inbox = inboxDir.resolve(to + ".jsonl");
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
return "Sent " + msgType + " to " + to;
}
public synchronized List<Map<String, Object>> readInbox(String name) {
Path inbox = inboxDir.resolve(name + ".jsonl");
if (!Files.exists(inbox)) return List.of();
List<Map<String, Object>> messages = new ArrayList<>();
for (String line : Files.readAllLines(inbox)) {
if (!line.isBlank())
messages.add(mapper.readValue(line, new TypeReference<>() {}));
}
Files.writeString(inbox, ""); // drain
return messages;
}
}
```
4. Each teammate checks its inbox between `call()` invocations, injecting messages into context. ChatClient's `call()` is equivalent to Python's full tool loop (looping until `stop_reason != "tool_use"`).
```java
// Python teammates check inbox before each LLM call; Java checks between each call()
protected void teammateLoop(String name, String role, String initialPrompt) {
String sysPrompt = String.format(
"You are '%s', role: %s. Use send_message to communicate.",
name, role);
var messageTool = new TeammateMessageTool(bus, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), messageTool)
.build();
// Initial work (call() = full tool chain, equivalent to Python loop until stop_reason != "tool_use")
String response = client.prompt(initialPrompt).call().content();
// Check inbox between each call() (vs. Python's between each LLM call)
for (int round = 0; round < 50; round++) {
Thread.sleep(2000);
var inbox = bus.readInbox(name);
if (inbox.isEmpty()) break;
String inboxJson = mapper.writeValueAsString(inbox);
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
}
setStatus(name, "idle");
}
```
## What Changed From s08
| Component | Before (s08) | After (s09) |
|----------------|------------------|------------------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| Agents | Single | Lead + N teammates |
| Persistence | None | config.json + JSONL inboxes |
| Threads | Background cmds | Full agent loops per thread |
| Lifecycle | Fire-and-forget | idle -> working -> idle |
| Communication | None | message + broadcast |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
2. `Broadcast "status update: phase 1 complete" to all teammates`
3. `Check the lead inbox for any messages`
4. Type `/team` to see the team roster with statuses
5. Type `/inbox` to manually check the lead's inbox
-134
View File
@@ -1,134 +0,0 @@
# s10: Team Protocols
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`
> *"Teammates need shared communication rules"* -- one request-response pattern drives all negotiation.
>
> **Harness layer**: Protocols -- structured handshakes between models.
## Problem
In s09, teammates work and communicate but lack structured coordination:
**Shutdown**: Killing a thread leaves files half-written and config.json stale. You need a handshake -- the lead requests, the teammate approves (finish and exit) or rejects (keep working).
**Plan approval**: When the lead says "refactor the auth module," the teammate starts immediately. For high-risk changes, the lead should review the plan first.
Both share the same structure: one side sends a request with a unique ID, the other responds referencing that ID.
## Solution
```
Shutdown Protocol Plan Approval Protocol
================== ======================
Lead Teammate Teammate Lead
| | | |
|--shutdown_req-->| |--plan_req------>|
| {req_id:"abc"} | | {req_id:"xyz"} |
| | | |
|<--shutdown_resp-| |<--plan_resp-----|
| {req_id:"abc", | | {req_id:"xyz", |
| approve:true} | | approve:true} |
Shared FSM:
[pending] --approve--> [approved]
[pending] --reject---> [rejected]
Trackers:
shutdown_requests = {req_id: {target, status}}
plan_requests = {req_id: {from, plan, status}}
```
## How It Works
1. The lead initiates shutdown by generating a request_id and sending through the inbox.
```java
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
// Python uses dict + threading.Lock; Java uses ConcurrentHashMap for natural thread safety
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
= new ConcurrentHashMap<>();
public String handleShutdownRequest(String teammate) {
String reqId = UUID.randomUUID().toString().substring(0, 8);
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
"target", teammate, "status", "pending")));
bus.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", Map.of("request_id", reqId));
return "Shutdown request " + reqId + " sent to '" + teammate
+ "' (status: pending)";
}
```
2. The teammate receives the request and responds with approve/reject.
```java
// TeammateProtocolTool - teammates respond to shutdown requests via @Tool annotation
@Tool(description = "Respond to a shutdown request")
public String shutdownResponse(
@ToolParam(description = "The request_id") String requestId,
@ToolParam(description = "true to approve") boolean approve,
@ToolParam(description = "Reason for decision") String reason) {
return tracker.respondToShutdown(name, requestId, approve, reason);
}
// ProtocolTracker - updates tracker + sends response message
public String respondToShutdown(String sender, String requestId,
boolean approve, String reason) {
var req = shutdownRequests.get(requestId);
if (req != null) {
req.put("status", approve ? "approved" : "rejected");
}
bus.send(sender, "lead", reason != null ? reason : "",
"shutdown_response",
Map.of("request_id", requestId, "approve", approve));
return "Shutdown " + (approve ? "approved" : "rejected");
}
```
3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id).
```java
// ProtocolTracker - same request_id correlation pattern, two use cases
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
public String reviewPlan(String requestId, boolean approve, String feedback) {
var req = planRequests.get(requestId);
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
req.put("status", approve ? "approved" : "rejected");
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
"plan_approval_response",
Map.of("request_id", requestId, "approve", approve,
"feedback", feedback != null ? feedback : ""));
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
}
```
One FSM, two applications. The same `pending -> approved | rejected` state machine handles any request-response protocol.
## What Changed From s09
| Component | Before (s09) | After (s10) |
|----------------|------------------|--------------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
| Shutdown | Natural exit only| Request-response handshake |
| Plan gating | None | Submit/review with approval |
| Correlation | None | request_id per request |
| FSM | None | pending -> approved/rejected |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Spawn alice as a coder. Then request her shutdown.`
2. `List teammates to see alice's status after shutdown approval`
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
4. `Spawn charlie, have him submit a plan, then approve it.`
5. Type `/team` to monitor statuses
-193
View File
@@ -1,193 +0,0 @@
# s11: Autonomous Agents
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
> *"Teammates scan the board and claim tasks themselves"* -- no need for the lead to assign each one. Self-organizing.
>
> **Harness layer**: Autonomy -- models that find work without being told.
## Problem
In s09-s10, teammates only work when explicitly told to. The lead must write a prompt for each teammate. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.
True autonomy: teammates scan the task board themselves, claim unclaimed tasks, work on them, then look for more.
One subtlety: after context compaction (s06), the agent might forget who it is. Identity re-injection fixes this.
## Solution
```
Teammate lifecycle with idle cycle:
+-------+
| spawn |
+---+---+
|
v
+-------+ tool_use +-------+
| WORK | <------------- | LLM |
+---+---+ +-------+
|
| stop_reason != tool_use (or idle tool called)
v
+--------+
| IDLE | poll every 5s for up to 60s
+---+----+
|
+---> check inbox --> message? ----------> WORK
|
+---> scan .tasks/ --> unclaimed? -------> claim -> WORK
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity via system prompt (always present):
ChatClient.builder(chatModel)
.defaultSystem(identityPrompt) // automatically included in every call
```
## How It Works
1. The teammate loop has two phases: WORK and IDLE. When the LLM stops calling tools (or calls `idle`), the teammate enters IDLE.
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
private void autonomousLoop(String name, String role, String initialPrompt) {
// idle flag: set by tool call, detected by outer loop
AtomicBoolean idleRequested = new AtomicBoolean(false);
var idleTool = new IdleTool(idleRequested);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
while (true) {
// -- WORK PHASE --
String nextMsg = initialPrompt;
for (int round = 0; round < 50 && nextMsg != null; round++) {
var inbox = bus.readInbox(name);
// ... merge inbox messages into nextMsg ...
idleRequested.set(false);
String response = client.prompt(sb.toString()).call().content();
if (idleRequested.get()) break; // idle tool was called
nextMsg = null; // subsequent rounds are inbox-driven
}
// -- IDLE PHASE --
setStatus(name, "idle");
// ... poll inbox + task board (see below) ...
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
}
}
```
2. The idle phase polls inbox and task board in a loop.
```java
// IDLE PHASE: poll inbox + task board
setStatus(name, "idle");
boolean resume = false;
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
for (int p = 0; p < polls; p++) {
Thread.sleep(POLL_INTERVAL * 1000L);
// Check inbox
var inbox = bus.readInbox(name);
if (!inbox.isEmpty()) {
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
resume = true;
break;
}
// Scan task board
var unclaimed = scanUnclaimedTasks(tasksDir);
if (!unclaimed.isEmpty()) {
var task = unclaimed.get(0);
int taskId = ((Number) task.get("id")).intValue();
claimTask(tasksDir, taskId, name);
initialPrompt = String.format(
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
taskId, task.get("subject"),
task.getOrDefault("description", ""));
resume = true;
break;
}
}
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
```
3. Task board scanning: find pending, unowned, unblocked tasks.
```java
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
if (!Files.exists(tasksDir)) return List.of();
List<Map<String, Object>> unclaimed = new ArrayList<>();
ObjectMapper mapper = new ObjectMapper();
try (var files = Files.list(tasksDir)) {
files.filter(f -> f.getFileName().toString().startsWith("task_")
&& f.getFileName().toString().endsWith(".json"))
.sorted()
.forEach(f -> {
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
if ("pending".equals(task.get("status"))
&& (task.get("owner") == null || "".equals(task.get("owner")))
&& (task.get("blockedBy") == null
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
unclaimed.add(task);
}
});
}
return unclaimed;
}
```
4. Identity persistence: Java/Spring AI's `ChatClient.defaultSystem()` automatically includes the system prompt in every call, so identity is always present -- no need to manually re-inject after compaction as in the Python version.
```java
// Identity is injected via defaultSystem at build time, automatically included in every prompt
String sysPrompt = String.format(
"You are '%s', role: %s, team: %s, at %s. "
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
name, role, teamName, workDir);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt) // Identity always present in system prompt
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
```
## What Changed From s10
| Component | Before (s10) | After (s11) |
|----------------|------------------|----------------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| Autonomy | Lead-directed | Self-organizing |
| Idle phase | None | Poll inbox + task board |
| Task claiming | Manual only | Auto-claim unclaimed tasks |
| Identity | System prompt | + re-injection after compaction |
| Timeout | None | 60s idle -> auto shutdown |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
2. `Spawn a coder teammate and let it find work from the task board itself`
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
4. Type `/tasks` to see the task board with owners
5. Type `/team` to monitor who is working vs idle
-146
View File
@@ -1,146 +0,0 @@
# s12: Worktree + Task Isolation
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
> *"Each works in its own directory, no interference"* -- tasks manage goals, worktrees manage directories, bound by ID.
>
> **Harness layer**: Directory isolation -- parallel execution lanes that never collide.
## Problem
By s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Two agents refactoring different modules at the same time will collide -- agent A edits `Config.java`, agent B also edits `Config.java`, unstaged changes mix, and neither can roll back cleanly.
The task board tracks *what to do* but has no opinion about *where to do it*. The fix: give each task its own git worktree directory. Tasks manage goals, worktrees manage execution context. Bind them by task ID.
## Solution
```
Control plane (.tasks/) Execution plane (.worktrees/)
+------------------+ +------------------------+
| task_1.json | | auth-refactor/ |
| status: in_progress <------> branch: wt/auth-refactor
| worktree: "auth-refactor" | task_id: 1 |
+------------------+ +------------------------+
| task_2.json | | ui-login/ |
| status: pending <------> branch: wt/ui-login
| worktree: "ui-login" | task_id: 2 |
+------------------+ +------------------------+
|
index.json (worktree registry)
events.jsonl (lifecycle log)
State machines:
Task: pending -> in_progress -> completed
Worktree: absent -> active -> removed | kept
```
## How It Works
1. **Create a task.** Persist the goal first.
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
tasks.create("Implement auth refactor", "");
// -> .tasks/task_1.json status=pending worktree=""
```
2. **Create a worktree and bind to the task.** Passing `task_id` auto-advances the task to `in_progress`.
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
worktrees.create("auth-refactor", 1, "HEAD");
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
The binding writes state to both sides:
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
public String bindWorktree(int taskId, String worktree, String owner) {
var task = load(taskId);
task.put("worktree", worktree);
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
save(task);
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
3. **Run commands in the worktree.** `cwd` points to the isolated directory.
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
ProcessBuilder pb = isWindows
? new ProcessBuilder("cmd", "/c", command)
: new ProcessBuilder("sh", "-c", command);
pb.directory(path.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
```
4. **Close out.** Two choices:
- `worktree_keep(name)` -- preserve the directory for later.
- `worktree_remove(name, complete_task=True)` -- remove directory, complete the bound task, emit event. One call handles teardown + completion.
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
public String remove(String name, boolean force, boolean completeTask) {
var wt = findWorktree(name);
events.emit("worktree.remove.before", ...);
runGit("worktree", "remove", wt.get("path").toString());
if (completeTask && wt.get("task_id") != null) {
int taskId = ((Number) wt.get("task_id")).intValue();
tasks.update(taskId, "completed", null);
tasks.unbindWorktree(taskId);
events.emit("task.completed",
Map.of("id", taskId, "status", "completed"),
Map.of("name", name), null);
}
// Update index.json: status -> "removed"
}
```
5. **Event stream.** Every lifecycle step emits to `.worktrees/events.jsonl`:
```json
{
"event": "worktree.remove.after",
"task": {"id": 1, "status": "completed"},
"worktree": {"name": "auth-refactor", "status": "removed"},
"ts": 1730000000
}
```
Events emitted: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`.
After a crash, state reconstructs from `.tasks/` + `.worktrees/index.json` on disk. Conversation memory is volatile; file state is durable.
## What Changed From s11
| Component | Before (s11) | After (s12) |
|--------------------|----------------------------|----------------------------------------------|
| Coordination | Task board (owner/status) | Task board + explicit worktree binding |
| Execution scope | Shared directory | Task-scoped isolated directory |
| Recoverability | Task status only | Task status + worktree index |
| Teardown | Task completion | Task completion + explicit keep/remove |
| Lifecycle visibility | Implicit in logs | Explicit events in `.worktrees/events.jsonl` |
## Try It
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Create tasks for backend auth and frontend login page, then list tasks.`
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
3. `Run "git status --short" in worktree "auth-refactor".`
4. `Keep worktree "ui-login", then list worktrees and inspect events.`
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
-318
View File
@@ -1,318 +0,0 @@
# s01: The Agent Loop (エージェントループ)
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"One loop & Bash is all you need"* -- 1つのツール + 1つのループ = エージェント。
>
> **Harness 層**: ループ -- モデルと現実世界を繋ぐ最初の接点。
## 問題
言語モデルはコードについて推論できるが、現実世界に触れられない -- ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびに手動で結果を貼り戻す必要がある。あなた自身がそのループになる。
## 解決策
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
(ChatClient.call() がツール呼び出しがなくなるまで自動ループ)
```
1つの `call()` 呼び出しがフロー全体を制御する。Spring AI が自動的にループし、モデルがツール呼び出しを止めるまで続ける。
## 仕組み
### 1. ChatClient の構築:モデル注入 + ツール登録
Spring Boot の自動設定で `ChatModel` を注入し、`ChatClient.builder()` でクライアントを構築、システムプロンプトとツールを設定する。
```java
// TIP: Python 版ではモジュールレベルで client = Anthropic() と MODEL を作成。
// Spring AI は自動設定で ChatModel を注入し、builder で ChatClient を構築する。
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
+ ". Use bash to solve tasks. Act, don't explain.")
.defaultTools(new BashTool()) // @Tool アノテーション付きツールオブジェクト
.build();
}
```
### 2. `@Tool` アノテーション:宣言的ツール登録
Spring AI は `@Tool` アノテーションでツールを自動的に検出・登録する。起動時にフレームワークが `defaultTools()` に渡されたオブジェクトをスキャンし、すべての `@Tool` メソッドのシグネチャと説明を抽出し、LLM が必要とするツールスキーマ(名前、パラメータ、説明)を生成して、毎回の `call()` リクエストに自動的に含める。
```java
// BashTool -- Python 版の run_bash() 関数に相当
public class BashTool {
@Tool(description = "Run a shell command and return stdout + stderr")
public String bash(@ToolParam(description = "The shell command to execute")
String command) {
// 危険コマンドチェック + ProcessBuilder 実行 + タイムアウト制御 + 出力切り詰め
// ...
}
}
```
> Python の手動登録方式との比較:
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
> - Java: `@Tool` + `@ToolParam` アノテーションだけで、フレームワークがスキーマ生成とメソッドディスパッチを自動化
### 3. Spring AI 内部自動ループ:`call()` の内部実装
**これが Java 版と Python 版の最も重要な違いだ。** Python 版ではツール呼び出しを駆動するために手書きの while ループが必要:
```python
# Python 版 -- 手動ループ
def agent_loop(messages):
while True:
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
# assistant メッセージを収集
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return response # モデルがツールを呼ばなくなった、ループ終了
# ツールを実行して結果を返送
for block in response.content:
if block.type == "tool_use":
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
Spring AI の `ChatClient.call()` は**完全に等価なロジックを内部にカプセル化**している:
```
call() 内部フロー:
┌─────────────────────────────────────────────────────┐
│ 1. リクエスト組み立て: system prompt + user msg + tools │
│ 2. LLM に送信 │
│ 3. レスポンス解析 │
│ ├── tool_use あり? ──→ はい: │
│ │ a. ツール名と引数を抽出 │
│ │ b. リフレクションで対応する @Tool メソッドを呼出 │
│ │ c. tool_result をメッセージリストに追加 │
│ │ d. ステップ 2 に戻る(自動ループ) │
│ └── いいえ ──→ 最終テキストを返す │
└─────────────────────────────────────────────────────┘
```
キーポイント:
- **ツール検出**: Spring AI はレスポンスに `tool_use` タイプのコンテンツブロックがあるかチェック(Python の `stop_reason == "tool_use"` に相当)
- **リフレクションディスパッチ**: フレームワークが Java リフレクションで、LLM が返したツール名に対応する `@Tool` メソッドを見つけて呼び出す(Python の `TOOL_HANDLERS[block.name]` に相当)
- **結果返送**: ツール実行結果は自動的に `tool_result` メッセージとして会話に追加(Python が手動で `tool_result` コンテンツブロックを構築するのに相当)
- **ループ終了**: モデルが純粋なテキスト(ツール呼び出しなし)を返すと、`call()` が最終結果を返す
従って、Python 版の約15行の while ループは、Java 版では1行の `.call()` に凝縮される。
### 4. `AgentRunner.interactive()`REPL インタラクションループ
`AgentRunner` は全レッスン共通の REPLRead-Eval-Print Loop)ユーティリティクラスで、Python の `if __name__ == "__main__"` 内の `input()` ループに相当する。
```java
public class AgentRunner {
/**
* インタラクティブ REPL ループを開始。
* @param prefix プロンプトプレフィックス(例: "s01"
* @param handler ユーザー入力を処理し Agent レスポンスを返す関数
*/
public static void interactive(String prefix, Function<String, String> handler) {
Scanner scanner = new Scanner(System.in);
System.out.println("'q' または 'exit' で終了");
while (true) {
System.out.print("\033[36m" + prefix + " >> \033[0m"); // カラープロンプト
String input;
try {
if (!scanner.hasNextLine()) break;
input = scanner.nextLine().trim();
} catch (Exception e) {
break;
}
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
break;
}
try {
String response = handler.apply(input); // Agent ハンドラーを呼び出し
if (response != null && !response.isBlank()) {
System.out.println(response);
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
System.out.println();
}
System.out.println("Bye!");
}
}
```
ワークフロー:`Scanner` で入力読み取り → `handler.apply()` で Agent に送信 → レスポンス出力 → ループ。`handler` は関数型インターフェースで、各レッスンが自分の Agent 呼び出しロジックを渡す。
### 5. 完全な Agent クラスとして組み立て
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S01AgentLoop implements CommandLineRunner {
private final ChatClient chatClient;
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at ...")
.defaultTools(new BashTool())
.build();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt()
.user(userMessage)
.call() // ← この1つの呼び出し = Python の while ループ全体
.content()
);
}
}
```
> **TIPS — Python → Java 主要な適応ポイント:**
> - Python の `while True` + `stop_reason` 手動ループ → Spring AI `ChatClient.call()` 内蔵自動ループ
> - Python の `TOOLS` 配列 + `TOOL_HANDLERS` 辞書 → `@Tool` アノテーション + `defaultTools()` 自動登録とリフレクションディスパッチ
> - Python の `client = Anthropic()` → Spring Boot 自動設定で `ChatModel` を注入
> - Python の `input()` インタラクション → `AgentRunner.interactive()` が Scanner REPL + 関数型インターフェースをカプセル化
コアコード40行未満、これがエージェント全体だ。残り11章はすべてこのループの上にメカニズムを積み重ねる -- ループ自体は決して変わらない。
## 変更点
| コンポーネント | 変更前 | 変更後 |
|---------------|------------|--------------------------------------------------|
| Agent loop | (なし) | `ChatClient.call()` 内蔵ツールループ |
| Tools | (なし) | `BashTool` (単一の `@Tool` ツール) |
| Messages | (なし) | Spring AI が内部でメッセージリストを管理 |
| Control flow | (なし) | フレームワークが自動判定: ツール呼び出しなしで最終テキストを返す |
```java
// コアコード -- 構築 + 呼び出し
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(new BashTool())
.build();
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt().user(userMessage).call().content()
);
```
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
> 実行前に環境変数の設定が必要: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
>
> **デフォルトプロトコルは OpenAI**OpenAI 公式、Azure OpenAI、OpenAI 互換インターフェースを提供するサードパーティモデルサービスなど、すべての OpenAI API 形式のサービスに対応)。
> Anthropic プロトコル(Claude ネイティブ API)を使用する場合は、以下のセクションを展開してください。
<details>
<summary><strong>AI プロトコルの切り替え(OpenAI ↔ Anthropic</strong></summary>
このプロジェクトは **Spring AI の Starter 依存 + 設定ファイル** で基盤プロトコルを切り替える。Java ビジネスコード(`ChatModel``ChatClient`)は**変更不要**。
#### 方式 1:OpenAI プロトコル(デフォルト)
`pom.xml` の依存:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
```
`application.yml` の設定:
```yaml
spring:
ai:
openai:
api-key: ${AI_API_KEY:sk-xxx}
base-url: ${AI_BASE_URL:https://api.openai.com}
chat:
options:
model: ${AI_MODEL:gpt-4o}
```
環境変数の例:
```sh
export AI_API_KEY=sk-proj-xxxxxxxx
export AI_BASE_URL=https://api.openai.com # 任意の OpenAI 互換エンドポイントに変更可
export AI_MODEL=gpt-4o
```
> **TIP**: 多くのサードパーティモデルサービス(DeepSeek、Mistral、Qwen など)が OpenAI 互換 API を提供している。`AI_BASE_URL``AI_MODEL` を変更するだけで接続でき、プロトコル切り替えは不要。
#### 方式 2Anthropic プロトコル(Claude ネイティブ API)
**ステップ 1**`pom.xml` を編集 — OpenAI starter を Anthropic starter に置き換え:
```xml
<!-- OpenAI starter をコメントアウトまたは削除 -->
<!-- <dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency> -->
<!-- Anthropic starter を追加 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
```
**ステップ 2**`application.yml` を編集 — `spring.ai.openai``spring.ai.anthropic` に置き換え:
```yaml
spring:
ai:
anthropic:
api-key: ${AI_API_KEY}
base-url: ${AI_BASE_URL:https://api.anthropic.com}
chat:
options:
model: ${AI_MODEL:claude-sonnet-4-20250514}
```
**ステップ 3**:環境変数を設定:
```sh
export AI_API_KEY=sk-ant-xxxxxxxx
export AI_BASE_URL=https://api.anthropic.com
export AI_MODEL=claude-sonnet-4-20250514
```
#### 切り替えの仕組み
Spring AI の `ChatModel` は統一された抽象インターフェース。異なる Starter が異なる実装を提供する:
| Starter 依存 | 自動注入される ChatModel 実装 | 設定プレフィックス |
|---|---|---|
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
ビジネスコードは常に `ChatModel` インターフェースに対してプログラムする。プロトコル切り替えには依存と設定の変更だけが必要で、Java コードの変更は不要。
</details>
以下のプロンプトを試してみよう(英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Create a file called Hello.java that prints "Hello, World!"`
2. `List all Java files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
-139
View File
@@ -1,139 +0,0 @@
# s02: Tool Use (ツール使用)
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"ツールを足すなら、@Tool メソッドを1つ足すだけ"* -- ループは変わらない。新ツールは `defaultTools()` に渡すだけ。
>
> **Harness 層**: ツール分配 -- モデルが届く範囲を広げる。
## 問題
`bash` だけでは、すべての操作がシェル経由になる。`cat` は予測不能に切り詰め、`sed` は特殊文字で壊れ、すべての bash 呼び出しが制約のないセキュリティ面になる。専用ツール (`read_file`, `write_file`) ならツールレベルでパスのサンドボックス化を強制できる。
重要な洞察: ツールを追加してもループの変更は不要。
## 解決策
```
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
Spring AI が @Tool アノテーションで自動的に登録・分配する。
手書きの dispatch map は不要、フレームワークがツールオブジェクトのアノテーションメソッドをスキャンする。
```
## 仕組み
1. 各ツールは独立したクラスで、`@Tool` アノテーションで宣言する。`PathValidator` がパスサンドボックスでワークスペース外への脱出を防ぐ。
```java
// PathValidator -- Python 版の safe_path() 関数に相当
public class PathValidator {
private final Path workDir;
public Path resolve(String relativePath) {
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
if (!resolved.startsWith(workDir)) {
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
}
return resolved;
}
}
// ReadFileTool -- Python 版の run_read() 関数に相当
public class ReadFileTool {
private final PathValidator pathValidator;
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
public String readFile(
@ToolParam(description = "Relative path to the file") String path,
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
Path filePath = pathValidator.resolve(path);
List<String> lines = Files.readAllLines(filePath);
if (limit != null && limit > 0 && limit < lines.size()) {
lines = lines.subList(0, limit);
}
return String.join("\n", lines);
}
}
```
2. ツール登録は `defaultTools()` に渡すだけ。Spring AI が `@Tool` アノテーションメソッドをスキャンし、名前マッピングとパラメータバインディングを自動的に行う。
```java
// Python 版の TOOL_HANDLERS 辞書に相当
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
// Java: ツールオブジェクトを渡すだけ、@Tool アノテーションで自動登録
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(
new BashTool(), // bash コマンド実行
new ReadFileTool(), // ファイル読み取り
new WriteFileTool(), // ファイル書き込み
new EditFileTool() // ファイル編集(検索置換)
)
.build();
```
3. 呼び出しコードは s01 と完全に同一。ループはフレームワークが管理し、開発者はツール実装だけに集中する。
```java
// s01 との違いは defaultTools() に3つのツールオブジェクトが追加されたこと
// ループコードは完全に同一 -- これが s02 の核心的な洞察
AgentRunner.interactive("s02", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
```
ツール追加 = `@Tool` クラスを1つ追加 + `defaultTools()` に渡す。ループは決して変わらない。
> **TIPS — Python → Java 主要な適応ポイント:**
> - Python の `TOOL_HANDLERS` 辞書 → Spring AI `@Tool` アノテーション + `defaultTools()` 自動登録・分配
> - Python の `safe_path()` 関数 → `PathValidator` クラス(同じパス脱出チェックロジック)
> - Python の `lambda **kw` パラメータ展開 → `@ToolParam` アノテーションで自動バインディング
> - Python の `block.type == "tool_use"` 判定 → Spring AI が内部で自動検出・分配
## s01 からの変更点
| コンポーネント | 変更前 (s01) | 変更後 (s02) |
|----------------|-----------------------|----------------------------------------|
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
| パス安全性 | なし | `PathValidator` サンドボックス |
| Agent loop | 不変 | 不変 |
```java
// s01 → s02 唯一の変更: defaultTools() に3つのツールオブジェクトを追加
.defaultTools(
new BashTool(),
new ReadFileTool(), // +新規追加
new WriteFileTool(), // +新規追加
new EditFileTool() // +新規追加
)
```
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
```
> 実行前に環境変数の設定が必要: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Read the file pom.xml`
2. `Create a file called Greet.java with a greet(name) method`
3. `Edit Greet.java to add a Javadoc comment to the method`
4. `Read Greet.java to verify the edit worked`
-119
View File
@@ -1,119 +0,0 @@
# s03: TodoWrite (Todo書き込み)
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。完了率は倍増する。
>
> **Harness 層**: 計画 -- 航路を描かずにモデルを軌道に乗せる。
## 問題
マルチステップのタスクで、モデルは進捗を見失う -- 既にやったことを繰り返したり、ステップを飛ばしたり、脱線したりする。会話が長くなるほど悪化する: ツール結果がコンテキストを埋め尽くし、システムプロンプトの影響力が徐々に薄れる。10ステップのリファクタリングでステップ1-3を完了した後、即興を始めてしまう。ステップ4-10はもう注意の外だ。
## 解決策
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+-----------------------+
|
毎回のリクエスト時に defaultSystem() で
最新の todo 状態をシステムプロンプトに注入
```
## 仕組み
1. TodoManager はステータス付きアイテムを保持する。同時に `in_progress` にできるのは1つだけ。
```java
public class TodoManager {
public record TodoItem(String id, String text, String status) {}
private List<TodoItem> items = new ArrayList<>();
@Tool(description = "Update the full task list to track progress. "
+ "Each item must have id, text, status (pending/in_progress/completed). "
+ "Only one task can be in_progress at a time. Max 20 items.")
public String updateTodos(
@ToolParam(description = "The complete list of todo items")
List<TodoItem> items) {
if (items.size() > 20) return "Error: Max 20 todos allowed";
List<TodoItem> validated = new ArrayList<>();
int inProgressCount = 0;
for (TodoItem item : items) {
String status = (item.status() != null)
? item.status().toLowerCase() : "pending";
if ("in_progress".equals(status)) inProgressCount++;
validated.add(new TodoItem(item.id(), item.text().trim(), status));
}
if (inProgressCount > 1)
return "Error: Only one task can be in_progress at a time";
this.items = validated;
return render();
}
}
```
2. `TodoManager``defaultTools()` で登録し、`@Tool` アノテーションメソッドが自動的にツールとして公開される。
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool アノテーションメソッドが自動登録
)
.build();
```
3. システムプロンプト注入: ユーザー入力のたびに、最新の todo 状態をシステムプロンプトに注入し、更新指示を強調する。
```java
// 動的システムプロンプト: 現在の todo 状態を含む
String system = "You are a coding agent at " + workDir + ".\n"
+ "Use the todo tool to plan multi-step tasks. "
+ "Mark in_progress before starting, completed when done.\n"
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
```
「同時に in_progress は1つだけ」の制約が逐次的な集中を強制する。システムプロンプトへの todo 状態の継続的な注入が説明責任を生む -- モデルは毎回自分の計画を見るため、更新を忘れない。
> **TIP**: Python 版ではツールループ内で `rounds_since_todo` を追跡し、3ラウンド連続で todo を呼ばなかった場合に `<reminder>` テキストを注入する。Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内での注入はできない。そのため、システムプロンプト注入方式で同等の効果を実現している。
## s02 からの変更点
| コンポーネント | 変更前 (s02) | 変更後 (s03) |
|----------------|------------------|--------------------------------------|
| Tools | 4 | 5 (+TodoManager `@Tool`) |
| 計画 | なし | ステータス付き TodoManager |
| 状態注入 | なし | システムプロンプトに `<current-todos>` を注入 |
| ChatClient | 固定システムプロンプト | 毎ターン再構築、動的に todo 状態を注入 |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
2. `Create a Java package with utils and tests`
3. `Review all Java files and fix any style issues`
-102
View File
@@ -1,102 +0,0 @@
# s04: Subagents (サブエージェント)
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"大きなタスクを分割し、各サブタスクにクリーンなコンテキストを"* -- サブエージェントは独立した messages[] を使い、メイン会話を汚さない。
>
> **Harness 層**: コンテキスト隔離 -- モデルの思考の明晰さを守る。
## 問題
エージェントが作業するにつれ、messages 配列は膨張し続ける。すべてのファイル読み取り、すべてのコマンド出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親エージェントに必要なのは「pytest」という一言だけだ。
## 解決策
```
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ----------> | while tool_use: |
| prompt="..." | | call tools |
| | summary | append results |
| result = "..." | <---------- | return last text |
+------------------+ +------------------+
Parent context stays clean. Subagent context is discarded.
```
## 仕組み
1. 親エージェントに `task` ツールを持たせる。子は `task` を除くすべての基本ツールを持つ(再帰的な生成は不可)。
```java
// 親 Agent: 基本ツール + SubagentTool を持つ
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. "
+ "Use the task tool to delegate subtasks.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
new SubagentTool(chatModel) // 親 Agent 専用
)
.build();
```
2. サブエージェントは新しい `ChatClient` で起動し、独立したコンテキストを持つ。最終テキストだけが親に返る。
```java
@Tool(description = "Spawn a subagent with fresh context. "
+ "Use for exploration or subtasks that might pollute the main context.")
public String task(
@ToolParam(description = "The task prompt") String prompt,
@ToolParam(description = "Short description", required = false)
String description) {
// 新しい ChatClient を作成 -- これが「コンテキスト隔離」のすべて
ChatClient subClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding subagent. "
+ "Complete the task, then summarize findings.")
.defaultTools( // 基本ツール、task なし(再帰防止)
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool()
)
.build();
String result = subClient.prompt()
.user(prompt)
.call()
.content();
// 最終テキストだけを返し、子 Agent のコンテキストは破棄
return (result != null) ? result : "(no summary)";
}
```
サブエージェントは複数回のツール呼び出しを実行するかもしれないが、メッセージ履歴全体は破棄される。親が受け取るのは要約テキストだけで、通常の `tool_result` として返される。Spring AI の `ChatClient.call()` が内部でツールループを管理するため、手動でイテレーション回数を制限する必要はない。
## s03 からの変更点
| コンポーネント | 変更前 (s03) | 変更後 (s04) |
|----------------|------------------|---------------------------------------|
| Tools | 5 | 5 (基本) + SubagentTool (親側のみ) |
| コンテキスト | 単一共有 | 親 + 子隔離 (独立した ChatClient) |
| Subagent | なし | `SubagentTool.task()` メソッド |
| 戻り値 | 該当なし | 要約テキストのみ |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .java files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
-155
View File
@@ -1,155 +0,0 @@
# s05: Skills (スキルローディング)
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"必要な知識を、必要な時に読み込む"* -- system prompt ではなく tool_result で注入。
>
> **Harness 層**: オンデマンド知識 -- モデルが求めた時だけ渡すドメイン専門性。
## 問題
エージェントにドメイン固有のワークフローを遵守させたい: git の規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れるとトークンの浪費だ -- 10スキル x 2000トークン = 20,000トークン、大半が当面のタスクとは無関係。
## 解決策
```
System prompt (Layer 1 -- always present):
+--------------------------------------+
| You are a coding agent. |
| Skills available: |
| - git: Git workflow helpers | ~100 tokens/skill
| - test: Testing best practices |
+--------------------------------------+
When model calls load_skill("git"):
+--------------------------------------+
| tool_result (Layer 2 -- on demand): |
| <skill name="git"> |
| Full git workflow instructions... | ~2000 tokens
| Step 1: ... |
| </skill> |
+--------------------------------------+
```
第1層: スキル名をシステムプロンプトに(低コスト)。第2層: 完全なコンテンツを tool_result でオンデマンド配信。
## 仕組み
1. 各スキルは `SKILL.md` ファイルを含むディレクトリで、YAML frontmatter 付き。
```
skills/
pdf/
SKILL.md # ---\n name: pdf\n description: Process PDF files\n ---\n ...
code-review/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoader が `SKILL.md` を再帰的にスキャンし、ディレクトリ名をスキル識別子として使用する。
```java
public class SkillLoader {
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
record SkillInfo(Map<String, String> meta, String body, String path) {}
public SkillLoader(Path skillsDir) {
loadAll(skillsDir);
}
/** skills ディレクトリ配下のすべての SKILL.md ファイルを再帰スキャン */
private void loadAll(Path skillsDir) {
if (!Files.exists(skillsDir)) return;
try (Stream<Path> paths = Files.walk(skillsDir)) {
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
.sorted()
.forEach(p -> {
String text = Files.readString(p);
var parsed = parseFrontmatter(text);
String name = parsed.meta().getOrDefault("name",
p.getParent().getFileName().toString());
skills.put(name, new SkillInfo(
parsed.meta(), parsed.body(), p.toString()));
});
}
}
/** Layer 1: 全スキルの短い説明を取得(システムプロンプト注入用) */
public String getDescriptions() {
if (skills.isEmpty()) return "(no skills available)";
StringBuilder sb = new StringBuilder();
for (var entry : skills.entrySet()) {
String desc = entry.getValue().meta()
.getOrDefault("description", "No description");
sb.append(" - ").append(entry.getKey())
.append(": ").append(desc).append("\n");
}
return sb.toString().stripTrailing();
}
/** Layer 2: 指定スキルの完全なコンテンツを読み込む(@Tool メソッドとして) */
@Tool(description = "Load specialized knowledge by name.")
public String loadSkill(
@ToolParam(description = "Skill name to load") String name) {
SkillInfo skill = skills.get(name);
if (skill == null)
return "Error: Unknown skill '" + name + "'. Available: "
+ String.join(", ", skills.keySet());
return "<skill name=\"" + name + "\">\n"
+ skill.body() + "\n</skill>";
}
}
```
3. 第1層はシステムプロンプトに配置。第2層は SkillLoader 上の `@Tool` アノテーションメソッドでオンデマンド読み込み。
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
// Layer 1: スキルメタデータをシステムプロンプトに注入
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
+ "Use loadSkill to access specialized knowledge.\n\n"
+ "Skills available:\n"
+ skillLoader.getDescriptions();
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
skillLoader // Layer 2: loadSkill @Tool メソッド
)
.build();
}
```
モデルはどのスキルが存在するかを知り(低コスト)、必要な時にだけ完全なコンテンツを読み込む(高コスト)。
## s04 からの変更点
| コンポーネント | 変更前 (s04) | 変更後 (s05) |
|----------------|------------------|--------------------------------|
| Tools | 5 (基本 + task) | 5 (基本 + load_skill) |
| システムプロンプト | 静的文字列 | + スキル説明リスト |
| 知識ベース | なし | skills/\*/SKILL.md ファイル |
| 注入方式 | なし | 二層構造 (システムプロンプト + result) |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `What skills are available?`
2. `Load the agent-builder skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
4. `Build an MCP server using the mcp-builder skill`
-185
View File
@@ -1,185 +0,0 @@
# s06: Context Compact (コンテキスト圧縮)
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
> *"コンテキストはいつか溢れる、空ける手段が要る"* -- 3層圧縮で無限セッションを実現。
>
> **Harness 層**: 圧縮 -- クリーンな記憶、無限のセッション。
## 問題
コンテキストウィンドウは有限だ。1000行のファイルを読むだけで約4000トークンを消費する。30ファイルを読み20回のコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模プロジェクトで作業できない。
## 解決策
積極性を段階的に上げる3層構成:
```
Every turn:
+------------------+
| Tool call result |
+------------------+
|
v
[Layer 1: micro_compact] (silent, every turn)
Replace tool_result > 3 turns old
with "[Previous: used {tool_name}]"
|
v
[Check: tokens > 50000?]
| |
no yes
| |
v v
continue [Layer 2: auto_compact]
Save transcript to .transcripts/
LLM summarizes conversation.
Replace all messages with [summary].
|
v
[Layer 3: compact tool]
Model calls compact explicitly.
Same summarization as auto_compact.
```
## 仕組み
1. **第1層 -- コンテキストウィンドウ管理**: Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内に圧縮を挿入できない。Java 版では、システムプロンプトに注入する会話ターン数を制限し(最近の N ターンのみ保持)、コンテンツを切り詰めることで同等の効果を実現する。
```java
/** トークン数の推定: 粗い見積もりで 4文字 ≈ 1トークン */
public int estimateTokens() {
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
return chars / 4;
}
/** 会話履歴のサマリーを取得(システムプロンプト注入用、最近数ターンのみ保持) */
public String getContextSummary() {
if (history.isEmpty()) return "";
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
for (int i = start; i < history.size(); i++) {
ConversationTurn turn = history.get(i);
sb.append("[").append(turn.role()).append("]: ")
.append(turn.content(), 0, Math.min(500, turn.content().length()))
.append("\n");
}
sb.append("</conversation-context>");
return sb.toString();
}
```
2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全な会話をディスクに保存し、LLM に要約を依頼する。
```java
public String compact() {
// トランスクリプトをディスクに保存(完全な履歴は失われない)
Files.createDirectories(transcriptDir);
Path transcriptPath = transcriptDir.resolve(
"transcript_" + System.currentTimeMillis() + ".jsonl");
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
for (ConversationTurn turn : history) {
writer.write(objectMapper.writeValueAsString(turn));
writer.newLine();
}
}
// LLM が要約を生成
String conversationText = history.stream()
.map(t -> t.role() + ": " + t.content())
.reduce("", (a, b) -> a + "\n" + b);
if (conversationText.length() > 80000) {
conversationText = conversationText.substring(0, 80000);
}
ChatClient summaryClient = ChatClient.builder(chatModel).build();
String summary = summaryClient.prompt()
.user("Summarize this conversation for continuity. Include: "
+ "1) What was accomplished, 2) Current state, "
+ "3) Key decisions.\n\n" + conversationText)
.call().content();
// 要約で履歴を置換
history.clear();
history.add(new ConversationTurn("system",
"[Conversation compressed. Transcript: " + transcriptPath
+ "]\n\n" + summary));
return summary;
}
```
3. **第3層 -- manual compact**: `CompactTool` ツールが同じ要約メカニズムをオンデマンドでトリガーする。
```java
public class CompactTool {
private final ContextCompactor compactor;
public CompactTool(ContextCompactor compactor) {
this.compactor = compactor;
}
@Tool(description = "Trigger manual conversation compression to free up context space.")
public String compact(
@ToolParam(description = "What to preserve in summary",
required = false) String focus) {
compactor.requestCompact();
return "Compression triggered. Context will be summarized.";
}
}
```
4. REPL 層が3層すべてを統合する(Spring AI の ChatClient が内部でツールループを自動管理するため、圧縮はユーザーメッセージレベルでトリガーされる):
```java
AgentRunner.interactive("s06", userMessage -> {
// Layer 2: 自動圧縮チェック(毎回のユーザー入力前)
if (compactor.needsAutoCompact()) {
System.out.println("[auto_compact triggered]");
compactor.compact();
}
compactor.addTurn("user", userMessage);
// 動的システムプロンプト: 会話コンテキストサマリーを含む
String system = baseSystem + compactor.getContextSummary();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), compactTool)
.build();
String response = chatClient.prompt()
.user(userMessage).call().content();
compactor.addTurn("assistant", response != null ? response : "");
// Layer 3: 手動圧縮(Agent が compact ツールを呼び出した場合)
if (compactor.isCompactRequested()) {
compactor.compact();
}
return response;
});
```
完全な履歴はトランスクリプトとしてディスク上に保存される。情報は真に失われるのではなく、アクティブなコンテキストの外に移動されるだけだ。
## s05 からの変更点
| コンポーネント | 変更前 (s05) | 変更後 (s06) |
|----------------|------------------|--------------------------------|
| Tools | 5 | 5 (基本 + compact) |
| コンテキスト管理 | なし | 三層圧縮 |
| コンテキストウィンドウ管理 | なし | 注入ターン数制限 + コンテンツ切り詰め |
| Auto-compact | なし | トークン閾値トリガー |
| Transcripts | なし | .transcripts/ に保存 |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Read every Java file in the src/ directory one by one` (コンテキストウィンドウ管理の効果を観察する)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
-170
View File
@@ -1,170 +0,0 @@
# s07: Task System (タスクシステム)
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`
> *"大きな目標を小タスクに分解し、順序付けし、ディスクに記録する"* -- ファイルベースのタスクグラフ、マルチエージェント協調の基盤。
>
> **Harness 層**: 永続タスク -- どの会話よりも長く生きる目標。
## 問題
s03 の TodoManager はメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスク B はタスク A に依存し、タスク C と D は並行実行でき、タスク E は C と D の両方を待つ。
明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮 (s06) で消える。
## 解決策
フラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つの JSON ファイルで、ステータス・前方依存 (`blockedBy`)・後方依存 (`blocks`) を持つ。タスクグラフは常に3つの問いに答える:
- **何が実行可能か?** -- `pending` ステータスで `blockedBy` が空のタスク。
- **何がブロックされているか?** -- 未完了の依存を待つタスク。
- **何が完了したか?** -- `completed` のタスク。完了時に後続タスクを自動的にアンブロックする。
```
.tasks/
task_1.json {"id":1, "status":"completed"}
task_2.json {"id":2, "blockedBy":[1], "status":"pending"}
task_3.json {"id":3, "blockedBy":[1], "status":"pending"}
task_4.json {"id":4, "blockedBy":[2,3], "status":"pending"}
タスクグラフ (DAG):
+----------+
+--> | task 2 | --+
| | pending | |
+----------+ +----------+ +--> +----------+
| task 1 | | task 4 |
| completed| --> +----------+ +--> | blocked |
+----------+ | task 3 | --+ +----------+
| pending |
+----------+
順序: task 1 は 2 と 3 より先に完了する必要がある
並行: task 2 と 3 は同時に実行できる
依存: task 4 は 2 と 3 の両方を待つ
ステータス: pending -> in_progress -> completed
```
このタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行 (s08)、マルチエージェントチーム (s09+)、worktree 分離 (s12) はすべてこの同じ構造を読み書きする。
## 仕組み
1. **TaskManager**: タスクごとに1つの JSON ファイル、依存グラフ付き CRUD。Jackson `ObjectMapper` で JSON シリアライゼーションを行う。
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
public TaskManager(Path tasksDir) {
this.dir = tasksDir;
Files.createDirectories(dir);
this.nextId = maxId() + 1;
}
@Tool(description = "Create a new task with subject and optional description")
public String taskCreate(
@ToolParam(description = "Short subject of the task") String subject,
@ToolParam(description = "Detailed description", required = false) String description) {
Map<String, Object> task = new LinkedHashMap<>();
task.put("id", nextId);
task.put("subject", subject);
task.put("status", "pending");
task.put("blockedBy", new ArrayList<>());
task.put("blocks", new ArrayList<>());
save(task);
nextId++;
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
}
```
2. **依存解除**: タスク完了時に、他タスクの `blockedBy` リストから完了 ID を除去し、後続タスクをアンブロックする。
```java
private void clearDependency(int completedId) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.forEach(f -> {
Map<String, Object> task = MAPPER.readValue(
Files.readString(f), new TypeReference<>() {});
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
save(task);
}
});
}
}
```
3. **ステータス遷移 + 依存配線**: `taskUpdate` がステータス変更と依存エッジを担う。status が `completed` になると自動的に `clearDependency` を呼び出す。`blockedBy`/`blocks` は双方向の関係。
```java
@Tool(description = "Update a task's status or dependencies.")
public String taskUpdate(
@ToolParam(description = "Task ID") int taskId,
@ToolParam(description = "New status", required = false) String status,
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
Map<String, Object> task = load(taskId);
if (status != null) {
task.put("status", status);
if ("completed".equals(status)) {
clearDependency(taskId);
}
}
// addBlockedBy / addBlocks の双方向依存を処理 ...
save(task);
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
4. **Spring AI 自動ツール登録**: `TaskManager``defaultTools` として `ChatClient` に渡すと、Spring AI が `@Tool` アノテーションメソッドを自動認識する。手動 dispatch map は不要。
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S07TaskSystem implements CommandLineRunner {
private final ChatClient chatClient;
public S07TaskSystem(ChatModel chatModel) {
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
TaskManager taskManager = new TaskManager(tasksDir);
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
taskManager // TaskManager 内の @Tool メソッドが自動登録
)
.build();
}
}
```
s07 以降、タスクグラフがマルチステップ作業のデフォルト。s03 の Todo は軽量な単一セッション用チェックリストとして残る。
## s06 からの変更点
| コンポーネント | 変更前 (s06) | 変更後 (s07) |
|---|---|---|
| Tools | 5 | 8 (`task_create/update/list/get`) |
| 計画モデル | フラットチェックリスト (メモリのみ) | 依存関係付きタスクグラフ (ディスク) |
| 関係 | なし | `blockedBy` + `blocks` エッジ |
| ステータス追跡 | 完了か未完了 | `pending` -> `in_progress` -> `completed` |
| 永続性 | 圧縮で消失 | 圧縮・再起動後も存続 |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
2. `List all tasks and show the dependency graph`
3. `Complete task 1 and then list tasks to see task 2 unblocked`
4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse`
-138
View File
@@ -1,138 +0,0 @@
# s08: Background Tasks (バックグラウンドタスク)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- バックグラウンドスレッドがコマンド実行、完了後に通知を注入。
>
> **Harness 層**: バックグラウンド実行 -- モデルが考え続ける間、Harness が待つ。
## 問題
一部のコマンドは数分かかる: `npm install``pytest``docker build`。ブロッキングループでは、モデルは待つしかない。ユーザーが「依存関係をインストールして、その間に config ファイルを作って」と言っても、エージェントは1つずつしか処理できない。
## 解決策
```
Main thread Background thread
+-----------------+ +-----------------+
| agent loop | | subprocess runs |
| ... | | ... |
| [LLM call] <---+------- | enqueue(result) |
| ^drain queue | +-----------------+
+-----------------+
Timeline:
Agent --[spawn A]--[spawn B]--[other work]----
| |
v v
[A runs] [B runs] (parallel)
| |
+-- results injected before next LLM call --+
```
## 仕組み
1. BackgroundManager がスレッドセーフな並行コンテナでタスクを追跡する。Java では `ConcurrentHashMap``CopyOnWriteArrayList` を使用し、Python の手動ロックを置き換える。
```java
public class BackgroundManager {
private static final int TIMEOUT_SECONDS = 300;
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
record TaskInfo(String status, String result, String command) {}
public record Notification(String taskId, String status, String command, String result) {}
}
```
2. `backgroundRun()` が仮想スレッド (Java 21) に投入し、即座にリターンする。Python の `daemon=True` スレッドに比べ、仮想スレッドはより軽量で JVM がスケジュールする。
```java
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
public String backgroundRun(
@ToolParam(description = "The shell command to run in background") String command) {
String taskId = UUID.randomUUID().toString().substring(0, 8);
tasks.put(taskId, new TaskInfo("running", null, command));
executor.submit(() -> execute(taskId, command));
return "Background task " + taskId + " started: "
+ command.substring(0, Math.min(80, command.length()));
}
```
3. サブプロセス完了時に、結果が通知キューに入る。`ProcessBuilder` でコマンドを実行し、タイムアウト制御をサポート。
```java
private void execute(String taskId, String command) {
String status, output;
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true);
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) { process.destroyForcibly(); status = "timeout"; }
else { status = "completed"; }
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
tasks.put(taskId, new TaskInfo(status, output, command));
notificationQueue.add(new Notification(taskId, status, command, output));
}
```
4. 毎回のユーザー入力時に通知キューをドレインし、システムプロンプトに注入する。Spring AI の `ChatClient` が内部ツールループを管理するため、毎回のユーザー入力時にドレイン+システムプロンプト構築に変更。核心的なコンセプトは同じ: fire and forget。
```java
AgentRunner.interactive("s08", userMessage -> {
// バックグラウンドタスク通知をドレイン(Python のループ前 drain_notifications に相当)
var notifs = bgManager.drainNotifications();
String bgContext = "";
if (!notifs.isEmpty()) {
String notifText = notifs.stream()
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
.collect(Collectors.joining("\n"));
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
}
String system = "You are a coding agent. Use backgroundRun for long-running commands."
+ bgContext;
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), bgManager)
.build();
return chatClient.prompt().user(userMessage).call().content();
});
```
ループはシングルスレッドのまま。サブプロセス I/O だけが並列化される。
## s07 からの変更点
| コンポーネント | 変更前 (s07) | 変更後 (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (基本 + backgroundRun + check) |
| 実行方式 | ブロッキングのみ | ブロッキング + 仮想スレッド (Java 21) |
| 通知メカニズム | なし | 毎ターンドレインの ConcurrentLinkedQueue |
| 並行性 | なし | 仮想スレッド (より軽量、JVM スケジュール) |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
3. `Run pytest in the background and keep working on other things`
-175
View File
@@ -1,175 +0,0 @@
# s09: Agent Teams (エージェントチーム)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + JSONL メールボックス。
>
> **Harness 層**: チームメールボックス -- 複数モデルをファイルで協調。
## 問題
サブエージェント (s04) は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク (s08) はシェルコマンドを実行するが、LLM 誘導の意思決定はできない。
本物のチームワークには3つが必要: (1) 複数ターンの会話を超えて存続する永続エージェント、(2) アイデンティティとライフサイクル管理、(3) エージェント間の通信チャネル。
## 解決策
```
Teammate lifecycle:
spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN
Communication:
.team/
config.json <- team roster + statuses
inbox/
alice.jsonl <- append-only, drain-on-read
bob.jsonl
lead.jsonl
+--------+ send("alice","bob","...") +--------+
| alice | -----------------------------> | bob |
| loop | bob.jsonl << {json_line} | loop |
+--------+ +--------+
^ |
| BUS.read_inbox("alice") |
+---- alice.jsonl -> read + drain ---------+
```
## 仕組み
1. TeammateManager が config.json でチーム名簿を管理する。
```java
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
public class TeammateManager {
private final ChatModel chatModel;
private final MessageBus bus;
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
// Python は threading.Thread + dict を使用、Java は ConcurrentHashMap で天然スレッドセーフ
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
this.chatModel = chatModel;
this.bus = bus;
this.configPath = teamDir.resolve("config.json");
Files.createDirectories(teamDir);
this.config = loadConfig();
}
```
2. `spawn()` がチームメイトを作成し、スレッド内でエージェントループを開始する。
```java
// Python は threading.Thread を使用、Java は Thread.startVirtualThread() 仮想スレッドを使用
public synchronized String spawn(String name, String role, String prompt) {
Map<String, Object> member = new LinkedHashMap<>();
member.put("name", name);
member.put("role", role);
member.put("status", "working");
((List<Map<String, Object>>) config.get("members")).add(member);
saveConfig();
// 仮想スレッド: 軽量、JVM スケジュール、OS スレッドを占有しない
Thread thread = Thread.startVirtualThread(
() -> teammateLoop(name, role, prompt));
threads.put(name, thread);
return "Spawned '" + name + "' (role: " + role + ")";
}
```
3. MessageBus: 追記専用の JSONL インボックス。`send()` が1行を追記し、`read_inbox()` がすべて読み取ってドレインする。
```java
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
// Python は GIL で暗黙的にスレッドセーフ、Java は synchronized で明示的に保証
public class MessageBus {
private final Path inboxDir;
private final ObjectMapper mapper = new ObjectMapper();
public synchronized String send(String sender, String to, String content,
String msgType, Map<String, Object> extra) {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", msgType);
msg.put("from", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
if (extra != null) msg.putAll(extra);
Path inbox = inboxDir.resolve(to + ".jsonl");
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
return "Sent " + msgType + " to " + to;
}
public synchronized List<Map<String, Object>> readInbox(String name) {
Path inbox = inboxDir.resolve(name + ".jsonl");
if (!Files.exists(inbox)) return List.of();
List<Map<String, Object>> messages = new ArrayList<>();
for (String line : Files.readAllLines(inbox)) {
if (!line.isBlank())
messages.add(mapper.readValue(line, new TypeReference<>() {}));
}
Files.writeString(inbox, ""); // drain
return messages;
}
}
```
4. 各チームメイトは `call()` 呼び出し間でインボックスをチェックし、メッセージをコンテキストに注入する。ChatClient の `call()` は Python の完全なツールループ(`stop_reason != "tool_use"` まで繰り返す)に相当する。
```java
// Python のチームメイトは毎回の LLM 呼び出し前にインボックスをチェック、Java は毎回の call() 呼び出し間でチェック
protected void teammateLoop(String name, String role, String initialPrompt) {
String sysPrompt = String.format(
"You are '%s', role: %s. Use send_message to communicate.",
name, role);
var messageTool = new TeammateMessageTool(bus, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), messageTool)
.build();
// 初期作業(call() = 完全なツールチェーン、Python の stop_reason != "tool_use" までのループに相当)
String response = client.prompt(initialPrompt).call().content();
// 毎回の call() 間でインボックスをチェック(Python の毎回の LLM 呼び出し間ではなく)
for (int round = 0; round < 50; round++) {
Thread.sleep(2000);
var inbox = bus.readInbox(name);
if (inbox.isEmpty()) break;
String inboxJson = mapper.writeValueAsString(inbox);
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
}
setStatus(name, "idle");
}
```
## s08 からの変更点
| コンポーネント | 変更前 (s08) | 変更後 (s09) |
|----------------|------------------|------------------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| エージェント数 | 単一 | リーダー + N チームメイト |
| 永続化 | なし | config.json + JSONL インボックス |
| スレッド | バックグラウンドコマンド | 各スレッドで完全なエージェントループ |
| ライフサイクル | 使い捨て | idle -> working -> idle |
| 通信 | なし | message + broadcast |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
2. `Broadcast "status update: phase 1 complete" to all teammates`
3. `Check the lead inbox for any messages`
4. `/team` と入力してチーム名簿とステータスを確認する
5. `/inbox` と入力してリーダーのインボックスを手動確認する
-134
View File
@@ -1,134 +0,0 @@
# s10: Team Protocols (チームプロトコル)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`
> *"チームメイト間には統一の通信ルールが必要"* -- 1つの request-response パターンが全交渉を駆動。
>
> **Harness 層**: プロトコル -- モデル間の構造化されたハンドシェイク。
## 問題
s09 ではチームメイトが作業し通信するが、構造化された協調がない:
**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.json が不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。
**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にレビューすべきだ。
両方とも同じ構造: 一方がユニーク ID を持つリクエストを送り、他方がその ID で応答する。
## 解決策
```
Shutdown Protocol Plan Approval Protocol
================== ======================
Lead Teammate Teammate Lead
| | | |
|--shutdown_req-->| |--plan_req------>|
| {req_id:"abc"} | | {req_id:"xyz"} |
| | | |
|<--shutdown_resp-| |<--plan_resp-----|
| {req_id:"abc", | | {req_id:"xyz", |
| approve:true} | | approve:true} |
Shared FSM:
[pending] --approve--> [approved]
[pending] --reject---> [rejected]
Trackers:
shutdown_requests = {req_id: {target, status}}
plan_requests = {req_id: {from, plan, status}}
```
## 仕組み
1. リーダーが request_id を生成し、インボックス経由でシャットダウンを開始する。
```java
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
// Python は辞書 + threading.Lock を使用、Java は ConcurrentHashMap で天然スレッドセーフ
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
= new ConcurrentHashMap<>();
public String handleShutdownRequest(String teammate) {
String reqId = UUID.randomUUID().toString().substring(0, 8);
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
"target", teammate, "status", "pending")));
bus.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", Map.of("request_id", reqId));
return "Shutdown request " + reqId + " sent to '" + teammate
+ "' (status: pending)";
}
```
2. チームメイトがリクエストを受信し、承認または拒否で応答する。
```java
// TeammateProtocolTool - チームメイトが @Tool アノテーションでシャットダウン要求に応答
@Tool(description = "Respond to a shutdown request")
public String shutdownResponse(
@ToolParam(description = "The request_id") String requestId,
@ToolParam(description = "true to approve") boolean approve,
@ToolParam(description = "Reason for decision") String reason) {
return tracker.respondToShutdown(name, requestId, approve, reason);
}
// ProtocolTracker - トラッカー更新 + レスポンスメッセージ送信
public String respondToShutdown(String sender, String requestId,
boolean approve, String reason) {
var req = shutdownRequests.get(requestId);
if (req != null) {
req.put("status", approve ? "approved" : "rejected");
}
bus.send(sender, "lead", reason != null ? reason : "",
"shutdown_response",
Map.of("request_id", requestId, "approve", approve));
return "Shutdown " + (approve ? "approved" : "rejected");
}
```
3. プラン承認もまったく同じパターン。チームメイトがプランを提出(request_id を生成)、リーダーがレビュー(同じ request_id を参照)。
```java
// ProtocolTracker - 同じ request_id 関連パターン、2つの用途
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
public String reviewPlan(String requestId, boolean approve, String feedback) {
var req = planRequests.get(requestId);
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
req.put("status", approve ? "approved" : "rejected");
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
"plan_approval_response",
Map.of("request_id", requestId, "approve", approve,
"feedback", feedback != null ? feedback : ""));
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
}
```
1つの FSM、2つの用途。同じ `pending -> approved | rejected` 状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
## s09 からの変更点
| コンポーネント | 変更前 (s09) | 変更後 (s10) |
|----------------|------------------|--------------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
| シャットダウン | 自然終了のみ | リクエスト-レスポンスハンドシェイク |
| プランゲーティング | なし | 提出/レビューと承認 |
| 関連付け | なし | リクエストごとに request_id |
| FSM | なし | pending -> approved/rejected |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Spawn alice as a coder. Then request her shutdown.`
2. `List teammates to see alice's status after shutdown approval`
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
4. `Spawn charlie, have him submit a plan, then approve it.`
5. `/team` と入力してステータスを監視する
-193
View File
@@ -1,193 +0,0 @@
# s11: Autonomous Agents (自律エージェント)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない、自己組織化。
>
> **Harness 層**: 自律 -- 指示なしで仕事を見つけるモデル。
## 問題
s09-s10 では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトにプロンプトを書き、タスクボード上の10個の未割り当てタスクを手動で割り当てる。これはスケールしない。
真の自律性: チームメイトが自分でタスクボードをスキャンし、未確保のタスクを確保し、完了したら次を探す。
もう1つの問題: コンテキスト圧縮 (s06) 後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。
## 解決策
```
Teammate lifecycle with idle cycle:
+-------+
| spawn |
+---+---+
|
v
+-------+ tool_use +-------+
| WORK | <------------- | LLM |
+---+---+ +-------+
|
| stop_reason != tool_use (or idle tool called)
v
+--------+
| IDLE | poll every 5s for up to 60s
+---+----+
|
+---> check inbox --> message? ----------> WORK
|
+---> scan .tasks/ --> unclaimed? -------> claim -> WORK
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity via system prompt (always present):
ChatClient.builder(chatModel)
.defaultSystem(identityPrompt) // 毎回の呼び出しで自動付与
```
## 仕組み
1. チームメイトのループは WORK と IDLE の2フェーズ。LLM がツール呼び出しを止めた時(または `idle` ツールを呼んだ時)、IDLE フェーズに入る。
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
private void autonomousLoop(String name, String role, String initialPrompt) {
// idle フラグ: ツール呼び出し時に設定、外部ループが検出
AtomicBoolean idleRequested = new AtomicBoolean(false);
var idleTool = new IdleTool(idleRequested);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
while (true) {
// -- WORK PHASE --
String nextMsg = initialPrompt;
for (int round = 0; round < 50 && nextMsg != null; round++) {
var inbox = bus.readInbox(name);
// ... インボックスメッセージを nextMsg にマージ ...
idleRequested.set(false);
String response = client.prompt(sb.toString()).call().content();
if (idleRequested.get()) break; // idle ツールが呼ばれた
nextMsg = null; // 以降のラウンドは inbox 駆動
}
// -- IDLE PHASE --
setStatus(name, "idle");
// ... インボックス + タスクボードをポーリング(下記参照) ...
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
}
}
```
2. IDLE フェーズがインボックスとタスクボードをポーリングする。
```java
// IDLE PHASE: インボックス + タスクボードをポーリング
setStatus(name, "idle");
boolean resume = false;
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
for (int p = 0; p < polls; p++) {
Thread.sleep(POLL_INTERVAL * 1000L);
// インボックスをチェック
var inbox = bus.readInbox(name);
if (!inbox.isEmpty()) {
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
resume = true;
break;
}
// タスクボードをスキャン
var unclaimed = scanUnclaimedTasks(tasksDir);
if (!unclaimed.isEmpty()) {
var task = unclaimed.get(0);
int taskId = ((Number) task.get("id")).intValue();
claimTask(tasksDir, taskId, name);
initialPrompt = String.format(
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
taskId, task.get("subject"),
task.getOrDefault("description", ""));
resume = true;
break;
}
}
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
```
3. タスクボードスキャン: pending ステータスかつ owner なしかつブロックされていないタスクを探す。
```java
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
if (!Files.exists(tasksDir)) return List.of();
List<Map<String, Object>> unclaimed = new ArrayList<>();
ObjectMapper mapper = new ObjectMapper();
try (var files = Files.list(tasksDir)) {
files.filter(f -> f.getFileName().toString().startsWith("task_")
&& f.getFileName().toString().endsWith(".json"))
.sorted()
.forEach(f -> {
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
if ("pending".equals(task.get("status"))
&& (task.get("owner") == null || "".equals(task.get("owner")))
&& (task.get("blockedBy") == null
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
unclaimed.add(task);
}
});
}
return unclaimed;
}
```
4. アイデンティティ保持: Java/Spring AI の `ChatClient.defaultSystem()` は毎回の呼び出しで自動的にシステムプロンプトを付与するため、アイデンティティ情報は常に存在する。Python 版のように圧縮後に手動で再注入する必要はない。
```java
// アイデンティティ情報は defaultSystem で構築時に注入、毎回の prompt で自動付与
String sysPrompt = String.format(
"You are '%s', role: %s, team: %s, at %s. "
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
name, role, teamName, workDir);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt) // アイデンティティは常にシステムプロンプトに存在
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
```
## s10 からの変更点
| コンポーネント | 変更前 (s10) | 変更後 (s11) |
|----------------|------------------|----------------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| 自律性 | リーダー指示 | 自己組織化 |
| IDLE フェーズ | なし | インボックス + タスクボードをポーリング |
| タスク確保 | 手動のみ | 未割り当てタスクの自動確保 |
| アイデンティティ | システムプロンプト | + 圧縮後の再注入 |
| タイムアウト | なし | 60秒 IDLE → 自動シャットダウン |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
2. `Spawn a coder teammate and let it find work from the task board itself`
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
4. `/tasks` と入力して owner 付きのタスクボードを確認する
5. `/team` と入力して誰が作業中でアイドルかを監視する
-146
View File
@@ -1,146 +0,0 @@
# s12: Worktree + Task Isolation (Worktree タスク隔離)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。
>
> **Harness 層**: ディレクトリ隔離 -- 決して衝突しない並列実行レーン。
## 問題
s11 までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると -- A が `Config.java` を編集し、B も `Config.java` を編集し、未コミットの変更が互いに汚染し、どちらもクリーンにロールバックできない。
タスクボードは「何をやるか」を追跡するが「どこでやるか」には関知しない。解決策: 各タスクに独立した git worktree ディレクトリを与え、タスク ID で両者を関連付ける。
## 解決策
```
Control plane (.tasks/) Execution plane (.worktrees/)
+------------------+ +------------------------+
| task_1.json | | auth-refactor/ |
| status: in_progress <------> branch: wt/auth-refactor
| worktree: "auth-refactor" | task_id: 1 |
+------------------+ +------------------------+
| task_2.json | | ui-login/ |
| status: pending <------> branch: wt/ui-login
| worktree: "ui-login" | task_id: 2 |
+------------------+ +------------------------+
|
index.json (worktree registry)
events.jsonl (lifecycle log)
State machines:
Task: pending -> in_progress -> completed
Worktree: absent -> active -> removed | kept
```
## 仕組み
1. **タスクを作成する。** まず目標を永続化する。
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
tasks.create("Implement auth refactor", "");
// -> .tasks/task_1.json status=pending worktree=""
```
2. **worktree を作成してタスクに紐付ける。** `task_id` を渡すと、タスクが自動的に `in_progress` に遷移する。
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
worktrees.create("auth-refactor", 1, "HEAD");
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
紐付けは両側に状態を書き込む:
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
public String bindWorktree(int taskId, String worktree, String owner) {
var task = load(taskId);
task.put("worktree", worktree);
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
save(task);
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
3. **worktree 内でコマンドを実行する。** `cwd` が隔離ディレクトリを指す。
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
ProcessBuilder pb = isWindows
? new ProcessBuilder("cmd", "/c", command)
: new ProcessBuilder("sh", "-c", command);
pb.directory(path.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
```
4. **終了処理。** 2つの選択肢:
- `worktree_keep(name)` -- ディレクトリを保持する。
- `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
public String remove(String name, boolean force, boolean completeTask) {
var wt = findWorktree(name);
events.emit("worktree.remove.before", ...);
runGit("worktree", "remove", wt.get("path").toString());
if (completeTask && wt.get("task_id") != null) {
int taskId = ((Number) wt.get("task_id")).intValue();
tasks.update(taskId, "completed", null);
tasks.unbindWorktree(taskId);
events.emit("task.completed",
Map.of("id", taskId, "status", "completed"),
Map.of("name", name), null);
}
// index.json を更新: status -> "removed"
}
```
5. **イベントストリーム。** ライフサイクルの各ステップが `.worktrees/events.jsonl` に記録される:
```json
{
"event": "worktree.remove.after",
"task": {"id": 1, "status": "completed"},
"worktree": {"name": "auth-refactor", "status": "removed"},
"ts": 1730000000
}
```
イベントタイプ: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`
クラッシュ後も `.tasks/` + `.worktrees/index.json` から状態を再構築できる。会話メモリは揮発性だが、ディスク状態は永続的だ。
## s11 からの変更点
| コンポーネント | 変更前 (s11) | 変更後 (s12) |
|--------------------|----------------------------|----------------------------------------------|
| 協調 | タスクボード (owner/status) | タスクボード + worktree 明示的紐付け |
| 実行スコープ | 共有ディレクトリ | タスクごとの隔離ディレクトリ |
| 復旧可能性 | タスクステータスのみ | タスクステータス + worktree インデックス |
| 終了処理 | タスク完了 | タスク完了 + 明示的 keep/remove |
| ライフサイクル可視性 | ログ内に暗黙的 | `.worktrees/events.jsonl` で明示的イベントストリーム |
## 試してみる
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Create tasks for backend auth and frontend login page, then list tasks.`
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
3. `Run "git status --short" in worktree "auth-refactor".`
4. `Keep worktree "ui-login", then list worktrees and inspect events.`
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
-318
View File
@@ -1,318 +0,0 @@
# s01: The Agent Loop (智能体循环)
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"One loop & Bash is all you need"* -- 一个工具 + 一个循环 = 一个智能体。
>
> **Harness 层**: 循环 -- 模型与真实世界的第一道连接。
## 问题
语言模型能推理代码, 但碰不到真实世界 -- 不能读文件、跑测试、看报错。没有循环, 每次工具调用你都得手动把结果粘回去。你自己就是那个循环。
## 解决方案
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
(ChatClient.call() 自动循环直到无工具调用)
```
一个 `call()` 调用控制整个流程。Spring AI 自动循环, 直到模型不再调用工具。
## 工作原理
### 1. 构建 ChatClient:注入模型 + 注册工具
通过 Spring Boot 自动配置注入 `ChatModel`,用 `ChatClient.builder()` 构建客户端,设置系统提示和工具。
```java
// TIP: Python 版在模块级创建 client = Anthropic() 和 MODEL。
// Spring AI 通过自动配置注入 ChatModel,再用 builder 构建 ChatClient。
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
+ ". Use bash to solve tasks. Act, don't explain.")
.defaultTools(new BashTool()) // @Tool 注解的工具对象
.build();
}
```
### 2. `@Tool` 注解:声明式工具注册
Spring AI 通过 `@Tool` 注解自动发现和注册工具。框架在启动时扫描 `defaultTools()` 传入的对象,提取所有 `@Tool` 方法的签名和描述,生成 LLM 需要的 tool schema(名称、参数、描述),然后在每次 `call()` 请求中自动携带。
```java
// BashTool —— 对应 Python 版的 run_bash() 函数
public class BashTool {
@Tool(description = "Run a shell command and return stdout + stderr")
public String bash(@ToolParam(description = "The shell command to execute")
String command) {
// 危险命令检查 + ProcessBuilder 执行 + 超时控制 + 输出截断
// ...
}
}
```
> 对比 Python 版的手动注册方式:
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
> - Java: 只需 `@Tool` + `@ToolParam` 注解,框架自动完成 schema 生成和方法分派
### 3. Spring AI 内部自动循环:`call()` 的底层实现
**这是理解 Java 版与 Python 版最关键的区别。** Python 版本需要手写 while 循环来驱动工具调用:
```python
# Python 版 —— 手动循环
def agent_loop(messages):
while True:
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
# 收集 assistant 消息
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return response # 模型不再调用工具,退出循环
# 执行工具并回传结果
for block in response.content:
if block.type == "tool_use":
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
Spring AI 的 `ChatClient.call()` **内部封装了完全等价的逻辑**
```
call() 内部流程:
┌─────────────────────────────────────────────────────┐
│ 1. 组装请求: system prompt + user message + tools │
│ 2. 发送给 LLM │
│ 3. 解析响应 │
│ ├── 有 tool_use? ──→ 是: │
│ │ a. 提取工具名和参数 │
│ │ b. 通过反射调用对应的 @Tool 方法 │
│ │ c. 将 tool_result 追加到消息列表 │
│ │ d. 回到步骤 2(自动循环) │
│ └── 否 ──→ 返回最终文本 │
└─────────────────────────────────────────────────────┘
```
关键点:
- **工具检测**: Spring AI 检查响应中是否有 `tool_use` 类型的 content block(对应 Python 的 `stop_reason == "tool_use"`
- **反射分派**: 框架通过 Java 反射机制,根据 LLM 返回的工具名称找到对应的 `@Tool` 方法并调用(对应 Python 的 `TOOL_HANDLERS[block.name]`
- **结果回传**: 工具执行结果自动包装为 `tool_result` 消息追加到对话(对应 Python 手动构造 `tool_result` content block
- **循环终止**: 当模型返回纯文本(无工具调用)时,`call()` 返回最终结果
因此,Python 版约 15 行的 while 循环,在 Java 版中浓缩为一行 `.call()`
### 4. `AgentRunner.interactive()`REPL 交互循环
`AgentRunner` 是所有课程共用的交互式 REPLRead-Eval-Print Loop)工具类,对应 Python 版 `if __name__ == "__main__"` 中的 `input()` 循环。
```java
public class AgentRunner {
/**
* 启动交互式 REPL 循环。
* @param prefix 提示符前缀(如 "s01"
* @param handler 处理用户输入并返回 Agent 响应的函数
*/
public static void interactive(String prefix, Function<String, String> handler) {
Scanner scanner = new Scanner(System.in);
System.out.println("输入 'q' 或 'exit' 退出");
while (true) {
System.out.print("\033[36m" + prefix + " >> \033[0m"); // 彩色提示符
String input;
try {
if (!scanner.hasNextLine()) break;
input = scanner.nextLine().trim();
} catch (Exception e) {
break;
}
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
break;
}
try {
String response = handler.apply(input); // 调用 Agent 处理
if (response != null && !response.isBlank()) {
System.out.println(response);
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
System.out.println();
}
System.out.println("Bye!");
}
}
```
工作流程:`Scanner` 读取输入 → `handler.apply()` 发给 Agent → 打印响应 → 循环。`handler` 是一个函数式接口,每个课程传入自己的 Agent 调用逻辑。
### 5. 组装为完整的 Agent 类
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S01AgentLoop implements CommandLineRunner {
private final ChatClient chatClient;
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at ...")
.defaultTools(new BashTool())
.build();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt()
.user(userMessage)
.call() // ← 这一个调用 = Python 的整个 while 循环
.content()
);
}
}
```
> **TIPS — Python → Java 关键适配点:**
> - Python 的 `while True` + `stop_reason` 手动循环 → Spring AI `ChatClient.call()` 内置自动循环
> - Python 的 `TOOLS` 数组 + `TOOL_HANDLERS` 字典 → `@Tool` 注解 + `defaultTools()` 自动注册与反射分派
> - Python 的 `client = Anthropic()` → Spring Boot 自动配置注入 `ChatModel`
> - Python 的 `input()` 交互 → `AgentRunner.interactive()` 封装 Scanner REPL + 函数式接口
不到 40 行核心代码, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。
## 变更内容
| 组件 | 之前 | 之后 |
|---------------|------------|--------------------------------------------------|
| Agent loop | (无) | `ChatClient.call()` 内置工具循环 |
| Tools | (无) | `BashTool` (单一 `@Tool` 工具) |
| Messages | (无) | Spring AI 内部管理消息列表 |
| Control flow | (无) | 框架自动判断: 无工具调用时返回最终文本 |
```java
// 核心代码 —— 构建 + 调用
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(new BashTool())
.build();
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt().user(userMessage).call().content()
);
```
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
> 运行前需设置环境变量: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
>
> **当前默认使用 OpenAI 协议**(兼容所有 OpenAI API 格式的服务,包括 OpenAI 官方、Azure OpenAI、各类第三方大模型服务的 OpenAI 兼容接口等)。
> 如需使用 Anthropic 协议(Claude 系列模型原生接口),请展开下方「切换 AI 协议」。
<details>
<summary><strong>切换 AI 协议(OpenAI ↔ Anthropic</strong></summary>
本项目通过 Spring AI 的 **Starter 依赖 + 配置文件** 来切换底层协议,Java 业务代码(`ChatModel``ChatClient`**无需任何修改**。
#### 方式一:OpenAI 协议(默认)
`pom.xml` 依赖:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
```
`application.yml` 配置:
```yaml
spring:
ai:
openai:
api-key: ${AI_API_KEY:sk-xxx}
base-url: ${AI_BASE_URL:https://api.openai.com}
chat:
options:
model: ${AI_MODEL:gpt-4o}
```
环境变量示例(以 OpenAI 官方为例):
```sh
export AI_API_KEY=sk-proj-xxxxxxxx
export AI_BASE_URL=https://api.openai.com # 可替换为任何 OpenAI 兼容接口
export AI_MODEL=gpt-4o
```
> **TIP**: 许多第三方大模型服务(如 DeepSeek、Mistral、通义千问等)提供了 OpenAI 兼容接口,只需修改 `AI_BASE_URL``AI_MODEL` 即可接入,无需切换协议。
#### 方式二:Anthropic 协议(Claude 原生接口)
**第 1 步**:修改 `pom.xml`,将 OpenAI starter 替换为 Anthropic starter
```xml
<!-- 注释或删除 OpenAI starter -->
<!-- <dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency> -->
<!-- 添加 Anthropic starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
```
**第 2 步**:修改 `application.yml`,将 `spring.ai.openai` 替换为 `spring.ai.anthropic`
```yaml
spring:
ai:
anthropic:
api-key: ${AI_API_KEY}
base-url: ${AI_BASE_URL:https://api.anthropic.com}
chat:
options:
model: ${AI_MODEL:claude-sonnet-4-20250514}
```
**第 3 步**:设置环境变量:
```sh
export AI_API_KEY=sk-ant-xxxxxxxx
export AI_BASE_URL=https://api.anthropic.com
export AI_MODEL=claude-sonnet-4-20250514
```
#### 切换原理
Spring AI 的设计使得 `ChatModel` 是一个统一的抽象接口。不同的 Starter 提供不同的实现:
| Starter 依赖 | 自动注入的 ChatModel 实现 | 配置前缀 |
|---|---|---|
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
业务代码始终面向 `ChatModel` 接口编程,切换协议只需替换依赖和配置,无需改动任何 Java 代码。
</details>
试试这些 prompt(英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create a file called Hello.java that prints "Hello, World!"`
2. `List all Java files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
-139
View File
@@ -1,139 +0,0 @@
# s02: Tool Use (工具使用)
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"加一个工具, 只加一个 @Tool 方法"* -- 循环不用动, 新工具传入 `defaultTools()` 就行。
>
> **Harness 层**: 工具分发 -- 扩展模型能触达的边界。
## 问题
只有 `bash` 时, 所有操作都走 shell。`cat` 截断不可预测, `sed` 遇到特殊字符就崩, 每次 bash 调用都是不受约束的安全面。专用工具 (`read_file`, `write_file`) 可以在工具层面做路径沙箱。
关键洞察: 加工具不需要改循环。
## 解决方案
```
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
Spring AI 通过 @Tool 注解自动注册和分派。
无需手写 dispatch map,框架扫描工具对象的注解方法即可。
```
## 工作原理
1. 每个工具是一个独立的类,用 `@Tool` 注解声明。`PathValidator` 做路径沙箱防止逃逸工作区。
```java
// PathValidator —— 对应 Python 版的 safe_path() 函数
public class PathValidator {
private final Path workDir;
public Path resolve(String relativePath) {
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
if (!resolved.startsWith(workDir)) {
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
}
return resolved;
}
}
// ReadFileTool —— 对应 Python 版的 run_read() 函数
public class ReadFileTool {
private final PathValidator pathValidator;
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
public String readFile(
@ToolParam(description = "Relative path to the file") String path,
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
Path filePath = pathValidator.resolve(path);
List<String> lines = Files.readAllLines(filePath);
if (limit != null && limit > 0 && limit < lines.size()) {
lines = lines.subList(0, limit);
}
return String.join("\n", lines);
}
}
```
2. 工具注册只需传入 `defaultTools()`。Spring AI 扫描 `@Tool` 注解方法,自动完成名称映射和参数绑定。
```java
// 对应 Python 版的 TOOL_HANDLERS 字典
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
// Java: 只需传入工具对象,@Tool 注解自动注册
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(
new BashTool(), // bash 命令执行
new ReadFileTool(), // 文件读取
new WriteFileTool(), // 文件写入
new EditFileTool() // 文件编辑(查找替换)
)
.build();
```
3. 调用代码与 s01 完全一致。循环由框架管理,开发者只需关注工具实现。
```java
// 对比 s01,唯一变化是 defaultTools() 多传了 3 个工具对象
// 循环代码完全相同 —— 这正是 s02 的核心洞察
AgentRunner.interactive("s02", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
```
加工具 = 加一个 `@Tool` 类 + 传入 `defaultTools()`。循环永远不变。
> **TIPS — Python → Java 关键适配点:**
> - Python 的 `TOOL_HANDLERS` 字典 → Spring AI `@Tool` 注解 + `defaultTools()` 自动注册分派
> - Python 的 `safe_path()` 函数 → `PathValidator` 类(相同的路径逃逸检查逻辑)
> - Python 的 `lambda **kw` 参数解包 → `@ToolParam` 注解自动绑定参数
> - Python 的 `block.type == "tool_use"` 判断 → Spring AI 内部自动检测和分派
## 相对 s01 的变更
| 组件 | 之前 (s01) | 之后 (s02) |
|----------------|-----------------------|----------------------------------------|
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
| 路径安全 | 无 | `PathValidator` 沙箱 |
| Agent loop | 不变 | 不变 |
```java
// s01 → s02 唯一变化: defaultTools() 多传了 3 个工具对象
.defaultTools(
new BashTool(),
new ReadFileTool(), // +新增
new WriteFileTool(), // +新增
new EditFileTool() // +新增
)
```
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
```
> 运行前需设置环境变量: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Read the file pom.xml`
2. `Create a file called Greet.java with a greet(name) method`
3. `Edit Greet.java to add a Javadoc comment to the method`
4. `Read Greet.java to verify the edit worked`
-119
View File
@@ -1,119 +0,0 @@
# s03: TodoWrite (待办写入)
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"没有计划的 agent 走哪算哪"* -- 先列步骤再动手, 完成率翻倍。
>
> **Harness 层**: 规划 -- 让模型不偏航, 但不替它画航线。
## 问题
多步任务中, 模型会丢失进度 -- 重复做过的事、跳步、跑偏。对话越长越严重: 工具结果不断填满上下文, 系统提示的影响力逐渐被稀释。一个 10 步重构可能做完 1-3 步就开始即兴发挥, 因为 4-10 步已经被挤出注意力了。
## 解决方案
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+-----------------------+
|
每次请求时通过 defaultSystem()
注入最新 todo 状态到系统提示
```
## 工作原理
1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`
```java
public class TodoManager {
public record TodoItem(String id, String text, String status) {}
private List<TodoItem> items = new ArrayList<>();
@Tool(description = "Update the full task list to track progress. "
+ "Each item must have id, text, status (pending/in_progress/completed). "
+ "Only one task can be in_progress at a time. Max 20 items.")
public String updateTodos(
@ToolParam(description = "The complete list of todo items")
List<TodoItem> items) {
if (items.size() > 20) return "Error: Max 20 todos allowed";
List<TodoItem> validated = new ArrayList<>();
int inProgressCount = 0;
for (TodoItem item : items) {
String status = (item.status() != null)
? item.status().toLowerCase() : "pending";
if ("in_progress".equals(status)) inProgressCount++;
validated.add(new TodoItem(item.id(), item.text().trim(), status));
}
if (inProgressCount > 1)
return "Error: Only one task can be in_progress at a time";
this.items = validated;
return render();
}
}
```
2. `TodoManager` 通过 `defaultTools()` 注册, `@Tool` 注解方法自动暴露为工具。
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool 注解方法自动注册
)
.build();
```
3. 系统提示注入: 每次用户输入时, 将最新 todo 状态注入系统提示, 并强调更新指令。
```java
// 动态系统提示:包含当前 todo 状态
String system = "You are a coding agent at " + workDir + ".\n"
+ "Use the todo tool to plan multi-step tasks. "
+ "Mark in_progress before starting, completed when done.\n"
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
```
"同时只能有一个 in_progress" 强制顺序聚焦。系统提示中持续注入 todo 状态制造问责压力 -- 模型每次都能看到自己的计划, 不会忘记更新。
> **TIP**: Python 版在工具循环内追踪 `rounds_since_todo`, 连续 3 轮未调用 todo 时注入 `<reminder>` 文本。Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内注入, 因此改用系统提示注入的方式实现同等效果。
## 相对 s02 的变更
| 组件 | 之前 (s02) | 之后 (s03) |
|----------------|------------------|--------------------------------------|
| Tools | 4 | 5 (+TodoManager `@Tool`) |
| 规划 | 无 | 带状态的 TodoManager |
| 状态注入 | 无 | 系统提示注入 `<current-todos>` |
| ChatClient | 固定系统提示 | 每轮重建, 动态注入 todo 状态 |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
2. `Create a Java package with utils and tests`
3. `Review all Java files and fix any style issues`
-102
View File
@@ -1,102 +0,0 @@
# s04: Subagents (子智能体)
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"大任务拆小, 每个小任务干净的上下文"* -- 子智能体用独立 messages[], 不污染主对话。
>
> **Harness 层**: 上下文隔离 -- 守护模型的思维清晰度。
## 问题
智能体工作越久, messages 数组越胖。每次读文件、跑命令的输出都永久留在上下文里。"这个项目用什么测试框架?" 可能要读 5 个文件, 但父智能体只需要一个词: "pytest。"
## 解决方案
```
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ----------> | while tool_use: |
| prompt="..." | | call tools |
| | summary | append results |
| result = "..." | <---------- | return last text |
+------------------+ +------------------+
Parent context stays clean. Subagent context is discarded.
```
## 工作原理
1. 父智能体有一个 `task` 工具。子智能体拥有除 `task` 外的所有基础工具 (禁止递归生成)。
```java
// 父 Agent:拥有基础工具 + SubagentTool
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. "
+ "Use the task tool to delegate subtasks.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
new SubagentTool(chatModel) // 父 Agent 独有
)
.build();
```
2. 子智能体以全新的 `ChatClient` 启动, 拥有独立上下文。只有最终文本返回给父智能体。
```java
@Tool(description = "Spawn a subagent with fresh context. "
+ "Use for exploration or subtasks that might pollute the main context.")
public String task(
@ToolParam(description = "The task prompt") String prompt,
@ToolParam(description = "Short description", required = false)
String description) {
// 创建全新的 ChatClient —— 这就是"上下文隔离"的全部
ChatClient subClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding subagent. "
+ "Complete the task, then summarize findings.")
.defaultTools( // 基础工具, 没有 task (防止递归)
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool()
)
.build();
String result = subClient.prompt()
.user(prompt)
.call()
.content();
// 只返回最终文本, 子 Agent 上下文被丢弃
return (result != null) ? result : "(no summary)";
}
```
子智能体可能跑了多次工具调用, 但整个消息历史直接丢弃。父智能体收到的只是一段摘要文本, 作为普通 `tool_result` 返回。Spring AI 的 `ChatClient.call()` 内部管理工具循环, 无需手动限制迭代次数。
## 相对 s03 的变更
| 组件 | 之前 (s03) | 之后 (s04) |
|----------------|------------------|---------------------------------------|
| Tools | 5 | 5 (基础) + SubagentTool (仅父端) |
| 上下文 | 单一共享 | 父 + 子隔离 (独立 ChatClient) |
| Subagent | 无 | `SubagentTool.task()` 方法 |
| 返回值 | 不适用 | 仅摘要文本 |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .java files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
-155
View File
@@ -1,155 +0,0 @@
# s05: Skills (技能加载)
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"用到什么知识, 临时加载什么知识"* -- 通过 tool_result 注入, 不塞 system prompt。
>
> **Harness 层**: 按需知识 -- 模型开口要时才给的领域专长。
## 问题
你希望智能体遵循特定领域的工作流: git 约定、测试模式、代码审查清单。全塞进系统提示太浪费 -- 10 个技能, 每个 2000 token, 就是 20,000 token, 大部分跟当前任务毫无关系。
## 解决方案
```
System prompt (Layer 1 -- always present):
+--------------------------------------+
| You are a coding agent. |
| Skills available: |
| - git: Git workflow helpers | ~100 tokens/skill
| - test: Testing best practices |
+--------------------------------------+
When model calls load_skill("git"):
+--------------------------------------+
| tool_result (Layer 2 -- on demand): |
| <skill name="git"> |
| Full git workflow instructions... | ~2000 tokens
| Step 1: ... |
| </skill> |
+--------------------------------------+
```
第一层: 系统提示中放技能名称 (低成本)。第二层: tool_result 中按需放完整内容。
## 工作原理
1. 每个技能是一个目录, 包含 `SKILL.md` 文件和 YAML frontmatter。
```
skills/
pdf/
SKILL.md # ---\n name: pdf\n description: Process PDF files\n ---\n ...
code-review/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoader 递归扫描 `SKILL.md` 文件, 用目录名作为技能标识。
```java
public class SkillLoader {
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
record SkillInfo(Map<String, String> meta, String body, String path) {}
public SkillLoader(Path skillsDir) {
loadAll(skillsDir);
}
/** 递归扫描 skills 目录下所有 SKILL.md 文件 */
private void loadAll(Path skillsDir) {
if (!Files.exists(skillsDir)) return;
try (Stream<Path> paths = Files.walk(skillsDir)) {
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
.sorted()
.forEach(p -> {
String text = Files.readString(p);
var parsed = parseFrontmatter(text);
String name = parsed.meta().getOrDefault("name",
p.getParent().getFileName().toString());
skills.put(name, new SkillInfo(
parsed.meta(), parsed.body(), p.toString()));
});
}
}
/** Layer 1: 获取所有技能的简短描述(用于系统提示注入) */
public String getDescriptions() {
if (skills.isEmpty()) return "(no skills available)";
StringBuilder sb = new StringBuilder();
for (var entry : skills.entrySet()) {
String desc = entry.getValue().meta()
.getOrDefault("description", "No description");
sb.append(" - ").append(entry.getKey())
.append(": ").append(desc).append("\n");
}
return sb.toString().stripTrailing();
}
/** Layer 2: 加载指定技能的完整内容(作为 @Tool 方法) */
@Tool(description = "Load specialized knowledge by name.")
public String loadSkill(
@ToolParam(description = "Skill name to load") String name) {
SkillInfo skill = skills.get(name);
if (skill == null)
return "Error: Unknown skill '" + name + "'. Available: "
+ String.join(", ", skills.keySet());
return "<skill name=\"" + name + "\">\n"
+ skill.body() + "\n</skill>";
}
}
```
3. 第一层写入系统提示。第二层通过 SkillLoader 上的 `@Tool` 注解方法按需加载。
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
// Layer 1: 技能元数据注入系统提示
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
+ "Use loadSkill to access specialized knowledge.\n\n"
+ "Skills available:\n"
+ skillLoader.getDescriptions();
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
skillLoader // Layer 2: loadSkill @Tool 方法
)
.build();
}
```
模型知道有哪些技能 (便宜), 需要时再加载完整内容 (贵)。
## 相对 s04 的变更
| 组件 | 之前 (s04) | 之后 (s05) |
|----------------|------------------|--------------------------------|
| Tools | 5 (基础 + task) | 5 (基础 + load_skill) |
| 系统提示 | 静态字符串 | + 技能描述列表 |
| 知识库 | 无 | skills/\*/SKILL.md 文件 |
| 注入方式 | 无 | 两层 (系统提示 + result) |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `What skills are available?`
2. `Load the agent-builder skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
4. `Build an MCP server using the mcp-builder skill`
-185
View File
@@ -1,185 +0,0 @@
# s06: Context Compact (上下文压缩)
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
> *"上下文总会满, 要有办法腾地方"* -- 三层压缩策略, 换来无限会话。
>
> **Harness 层**: 压缩 -- 干净的记忆, 无限的会话。
## 问题
上下文窗口是有限的。读一个 1000 行的文件就吃掉 ~4000 token; 读 30 个文件、跑 20 条命令, 轻松突破 100k token。不压缩, 智能体根本没法在大项目里干活。
## 解决方案
三层压缩, 激进程度递增:
```
Every turn:
+------------------+
| Tool call result |
+------------------+
|
v
[Layer 1: micro_compact] (silent, every turn)
Replace tool_result > 3 turns old
with "[Previous: used {tool_name}]"
|
v
[Check: tokens > 50000?]
| |
no yes
| |
v v
continue [Layer 2: auto_compact]
Save transcript to .transcripts/
LLM summarizes conversation.
Replace all messages with [summary].
|
v
[Layer 3: compact tool]
Model calls compact explicitly.
Same summarization as auto_compact.
```
## 工作原理
1. **第一层 -- 上下文窗口管理**: Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内插入压缩。Java 版通过限制注入系统提示的对话轮数(仅保留最近 N 轮)并截断内容来实现等价效果。
```java
/** 估算 token 数量: 粗略估计 4 字符 ≈ 1 token */
public int estimateTokens() {
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
return chars / 4;
}
/** 获取对话历史的摘要(用于注入系统提示, 仅保留最近几轮) */
public String getContextSummary() {
if (history.isEmpty()) return "";
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
for (int i = start; i < history.size(); i++) {
ConversationTurn turn = history.get(i);
sb.append("[").append(turn.role()).append("]: ")
.append(turn.content(), 0, Math.min(500, turn.content().length()))
.append("\n");
}
sb.append("</conversation-context>");
return sb.toString();
}
```
2. **第二层 -- auto_compact**: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。
```java
public String compact() {
// 保存 transcript 到磁盘(完整历史不丢失)
Files.createDirectories(transcriptDir);
Path transcriptPath = transcriptDir.resolve(
"transcript_" + System.currentTimeMillis() + ".jsonl");
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
for (ConversationTurn turn : history) {
writer.write(objectMapper.writeValueAsString(turn));
writer.newLine();
}
}
// LLM 生成摘要
String conversationText = history.stream()
.map(t -> t.role() + ": " + t.content())
.reduce("", (a, b) -> a + "\n" + b);
if (conversationText.length() > 80000) {
conversationText = conversationText.substring(0, 80000);
}
ChatClient summaryClient = ChatClient.builder(chatModel).build();
String summary = summaryClient.prompt()
.user("Summarize this conversation for continuity. Include: "
+ "1) What was accomplished, 2) Current state, "
+ "3) Key decisions.\n\n" + conversationText)
.call().content();
// 用摘要替换历史
history.clear();
history.add(new ConversationTurn("system",
"[Conversation compressed. Transcript: " + transcriptPath
+ "]\n\n" + summary));
return summary;
}
```
3. **第三层 -- manual compact**: `CompactTool` 工具按需触发同样的摘要机制。
```java
public class CompactTool {
private final ContextCompactor compactor;
public CompactTool(ContextCompactor compactor) {
this.compactor = compactor;
}
@Tool(description = "Trigger manual conversation compression to free up context space.")
public String compact(
@ToolParam(description = "What to preserve in summary",
required = false) String focus) {
compactor.requestCompact();
return "Compression triggered. Context will be summarized.";
}
}
```
4. REPL 层整合三层 (Spring AI 的 ChatClient 自动管理工具循环, 压缩在用户消息级别触发):
```java
AgentRunner.interactive("s06", userMessage -> {
// Layer 2: 自动压缩检查(每次用户输入前)
if (compactor.needsAutoCompact()) {
System.out.println("[auto_compact triggered]");
compactor.compact();
}
compactor.addTurn("user", userMessage);
// 动态系统提示:包含对话上下文摘要
String system = baseSystem + compactor.getContextSummary();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), compactTool)
.build();
String response = chatClient.prompt()
.user(userMessage).call().content();
compactor.addTurn("assistant", response != null ? response : "");
// Layer 3: 手动压缩(如果 Agent 调用了 compact 工具)
if (compactor.isCompactRequested()) {
compactor.compact();
}
return response;
});
```
完整历史通过 transcript 保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。
## 相对 s05 的变更
| 组件 | 之前 (s05) | 之后 (s06) |
|----------------|------------------|--------------------------------|
| Tools | 5 | 5 (基础 + compact) |
| 上下文管理 | 无 | 三层压缩 |
| 上下文窗口管理 | 无 | 限制注入轮数 + 内容截断 |
| Auto-compact | 无 | token 阈值触发 |
| Transcripts | 无 | 保存到 .transcripts/ |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Read every Java file in the src/ directory one by one` (观察上下文窗口管理效果)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
-170
View File
@@ -1,170 +0,0 @@
# s07: Task System (任务系统)
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`
> *"大目标要拆成小任务, 排好序, 记在磁盘上"* -- 文件持久化的任务图, 为多 agent 协作打基础。
>
> **Harness 层**: 持久化任务 -- 比任何一次对话都长命的目标。
## 问题
s03 的 TodoManager 只是内存中的扁平清单: 没有顺序、没有依赖、状态只有做完没做完。真实目标是有结构的 -- 任务 B 依赖任务 A, 任务 C 和 D 可以并行, 任务 E 要等 C 和 D 都完成。
没有显式的关系, 智能体分不清什么能做、什么被卡住、什么能同时跑。而且清单只活在内存里, 上下文压缩 (s06) 一跑就没了。
## 解决方案
把扁平清单升级为持久化到磁盘的**任务图**。每个任务是一个 JSON 文件, 有状态、前置依赖 (`blockedBy`) 和后置依赖 (`blocks`)。任务图随时回答三个问题:
- **什么可以做?** -- 状态为 `pending``blockedBy` 为空的任务。
- **什么被卡住?** -- 等待前置任务完成的任务。
- **什么做完了?** -- 状态为 `completed` 的任务, 完成时自动解锁后续任务。
```
.tasks/
task_1.json {"id":1, "status":"completed"}
task_2.json {"id":2, "blockedBy":[1], "status":"pending"}
task_3.json {"id":3, "blockedBy":[1], "status":"pending"}
task_4.json {"id":4, "blockedBy":[2,3], "status":"pending"}
任务图 (DAG):
+----------+
+--> | task 2 | --+
| | pending | |
+----------+ +----------+ +--> +----------+
| task 1 | | task 4 |
| completed| --> +----------+ +--> | blocked |
+----------+ | task 3 | --+ +----------+
| pending |
+----------+
顺序: task 1 必须先完成, 才能开始 2 和 3
并行: task 2 和 3 可以同时执行
依赖: task 4 要等 2 和 3 都完成
状态: pending -> in_progress -> completed
```
这个任务图是 s07 之后所有机制的协调骨架: 后台执行 (s08)、多 agent 团队 (s09+)、worktree 隔离 (s12) 都读写这同一个结构。
## 工作原理
1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。使用 Jackson `ObjectMapper` 做 JSON 序列化。
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
public TaskManager(Path tasksDir) {
this.dir = tasksDir;
Files.createDirectories(dir);
this.nextId = maxId() + 1;
}
@Tool(description = "Create a new task with subject and optional description")
public String taskCreate(
@ToolParam(description = "Short subject of the task") String subject,
@ToolParam(description = "Detailed description", required = false) String description) {
Map<String, Object> task = new LinkedHashMap<>();
task.put("id", nextId);
task.put("subject", subject);
task.put("status", "pending");
task.put("blockedBy", new ArrayList<>());
task.put("blocks", new ArrayList<>());
save(task);
nextId++;
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
}
```
2. **依赖解除**: 完成任务时, 自动将其 ID 从其他任务的 `blockedBy` 中移除, 解锁后续任务。
```java
private void clearDependency(int completedId) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.forEach(f -> {
Map<String, Object> task = MAPPER.readValue(
Files.readString(f), new TypeReference<>() {});
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
save(task);
}
});
}
}
```
3. **状态变更 + 依赖关联**: `taskUpdate` 处理状态转换和依赖边。当 status 变为 `completed` 时自动调用 `clearDependency``blockedBy`/`blocks` 是双向关系。
```java
@Tool(description = "Update a task's status or dependencies.")
public String taskUpdate(
@ToolParam(description = "Task ID") int taskId,
@ToolParam(description = "New status", required = false) String status,
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
Map<String, Object> task = load(taskId);
if (status != null) {
task.put("status", status);
if ("completed".equals(status)) {
clearDependency(taskId);
}
}
// 处理 addBlockedBy / addBlocks 双向依赖 ...
save(task);
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
4. **Spring AI 自动注册工具**: 将 `TaskManager` 作为 `defaultTools` 传入 `ChatClient`Spring AI 自动识别 `@Tool` 注解方法,无需手动 dispatch map。
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S07TaskSystem implements CommandLineRunner {
private final ChatClient chatClient;
public S07TaskSystem(ChatModel chatModel) {
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
TaskManager taskManager = new TaskManager(tasksDir);
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
taskManager // TaskManager 中的 @Tool 方法自动注册
)
.build();
}
}
```
从 s07 起, 任务图是多步工作的默认选择。s03 的 Todo 仍可用于单次会话内的快速清单。
## 相对 s06 的变更
| 组件 | 之前 (s06) | 之后 (s07) |
|---|---|---|
| Tools | 5 | 8 (`task_create/update/list/get`) |
| 规划模型 | 扁平清单 (仅内存) | 带依赖关系的任务图 (磁盘) |
| 关系 | 无 | `blockedBy` + `blocks` 边 |
| 状态追踪 | 做完没做完 | `pending` -> `in_progress` -> `completed` |
| 持久化 | 压缩后丢失 | 压缩和重启后存活 |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
2. `List all tasks and show the dependency graph`
3. `Complete task 1 and then list tasks to see task 2 unblocked`
4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse`
-138
View File
@@ -1,138 +0,0 @@
# s08: Background Tasks (后台任务)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"慢操作丢后台, agent 继续想下一步"* -- 后台线程跑命令, 完成后注入通知。
>
> **Harness 层**: 后台执行 -- 模型继续思考, harness 负责等待。
## 问题
有些命令要跑好几分钟: `npm install``pytest``docker build`。阻塞式循环下模型只能干等。用户说 "装依赖, 顺便建个配置文件", 智能体却只能一个一个来。
## 解决方案
```
Main thread Background thread
+-----------------+ +-----------------+
| agent loop | | subprocess runs |
| ... | | ... |
| [LLM call] <---+------- | enqueue(result) |
| ^drain queue | +-----------------+
+-----------------+
Timeline:
Agent --[spawn A]--[spawn B]--[other work]----
| |
v v
[A runs] [B runs] (parallel)
| |
+-- results injected before next LLM call --+
```
## 工作原理
1. BackgroundManager 用线程安全的并发容器追踪任务。Java 使用 `ConcurrentHashMap``CopyOnWriteArrayList` 代替 Python 的手动加锁。
```java
public class BackgroundManager {
private static final int TIMEOUT_SECONDS = 300;
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
record TaskInfo(String status, String result, String command) {}
public record Notification(String taskId, String status, String command, String result) {}
}
```
2. `backgroundRun()` 提交虚拟线程 (Java 21), 立即返回。相比 Python 的 `daemon=True` 线程,虚拟线程更轻量、由 JVM 调度。
```java
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
public String backgroundRun(
@ToolParam(description = "The shell command to run in background") String command) {
String taskId = UUID.randomUUID().toString().substring(0, 8);
tasks.put(taskId, new TaskInfo("running", null, command));
executor.submit(() -> execute(taskId, command));
return "Background task " + taskId + " started: "
+ command.substring(0, Math.min(80, command.length()));
}
```
3. 子进程完成后, 结果进入通知队列。使用 `ProcessBuilder` 执行命令,支持超时控制。
```java
private void execute(String taskId, String command) {
String status, output;
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true);
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) { process.destroyForcibly(); status = "timeout"; }
else { status = "completed"; }
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
tasks.put(taskId, new TaskInfo(status, output, command));
notificationQueue.add(new Notification(taskId, status, command, output));
}
```
4. 每次用户输入时排空通知队列, 注入系统提示。Spring AI 的 `ChatClient` 管理内部工具循环, 因此改为在每次用户输入时 drain 通知并构建系统提示, 核心概念不变: fire and forget。
```java
AgentRunner.interactive("s08", userMessage -> {
// Drain 后台任务通知(对应 Python 中循环前的 drain_notifications
var notifs = bgManager.drainNotifications();
String bgContext = "";
if (!notifs.isEmpty()) {
String notifText = notifs.stream()
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
.collect(Collectors.joining("\n"));
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
}
String system = "You are a coding agent. Use backgroundRun for long-running commands."
+ bgContext;
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), bgManager)
.build();
return chatClient.prompt().user(userMessage).call().content();
});
```
循环保持单线程。只有子进程 I/O 被并行化。
## 相对 s07 的变更
| 组件 | 之前 (s07) | 之后 (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (基础 + backgroundRun + check) |
| 执行方式 | 仅阻塞 | 阻塞 + 虚拟线程 (Java 21) |
| 通知机制 | 无 | 每轮排空的 ConcurrentLinkedQueue |
| 并发 | 无 | 虚拟线程 (更轻量, JVM 调度) |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
3. `Run pytest in the background and keep working on other things`
-175
View File
@@ -1,175 +0,0 @@
# s09: Agent Teams (智能体团队)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"任务太大一个人干不完, 要能分给队友"* -- 持久化队友 + JSONL 邮箱。
>
> **Harness 层**: 团队邮箱 -- 多个模型, 通过文件协调。
## 问题
子智能体 (s04) 是一次性的: 生成、干活、返回摘要、消亡。没有身份, 没有跨调用的记忆。后台任务 (s08) 能跑 shell 命令, 但做不了 LLM 引导的决策。
真正的团队协作需要三样东西: (1) 能跨多轮对话存活的持久智能体, (2) 身份和生命周期管理, (3) 智能体之间的通信通道。
## 解决方案
```
Teammate lifecycle:
spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN
Communication:
.team/
config.json <- team roster + statuses
inbox/
alice.jsonl <- append-only, drain-on-read
bob.jsonl
lead.jsonl
+--------+ send("alice","bob","...") +--------+
| alice | -----------------------------> | bob |
| loop | bob.jsonl << {json_line} | loop |
+--------+ +--------+
^ |
| BUS.read_inbox("alice") |
+---- alice.jsonl -> read + drain ---------+
```
## 工作原理
1. TeammateManager 通过 config.json 维护团队名册。
```java
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
public class TeammateManager {
private final ChatModel chatModel;
private final MessageBus bus;
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
// Python用threading.Thread + dict; Java用ConcurrentHashMap天然线程安全
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
this.chatModel = chatModel;
this.bus = bus;
this.configPath = teamDir.resolve("config.json");
Files.createDirectories(teamDir);
this.config = loadConfig();
}
```
2. `spawn()` 创建队友并在线程中启动 agent loop。
```java
// Python用threading.Thread; Java用Thread.startVirtualThread()虚拟线程
public synchronized String spawn(String name, String role, String prompt) {
Map<String, Object> member = new LinkedHashMap<>();
member.put("name", name);
member.put("role", role);
member.put("status", "working");
((List<Map<String, Object>>) config.get("members")).add(member);
saveConfig();
// 虚拟线程:轻量级,由JVM调度,不占用OS线程
Thread thread = Thread.startVirtualThread(
() -> teammateLoop(name, role, prompt));
threads.put(name, thread);
return "Spawned '" + name + "' (role: " + role + ")";
}
```
3. MessageBus: append-only 的 JSONL 收件箱。`send()` 追加一行; `read_inbox()` 读取全部并清空。
```java
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
// Python靠GIL隐式保证线程安全; Java用synchronized显式保证
public class MessageBus {
private final Path inboxDir;
private final ObjectMapper mapper = new ObjectMapper();
public synchronized String send(String sender, String to, String content,
String msgType, Map<String, Object> extra) {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", msgType);
msg.put("from", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
if (extra != null) msg.putAll(extra);
Path inbox = inboxDir.resolve(to + ".jsonl");
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
return "Sent " + msgType + " to " + to;
}
public synchronized List<Map<String, Object>> readInbox(String name) {
Path inbox = inboxDir.resolve(name + ".jsonl");
if (!Files.exists(inbox)) return List.of();
List<Map<String, Object>> messages = new ArrayList<>();
for (String line : Files.readAllLines(inbox)) {
if (!line.isBlank())
messages.add(mapper.readValue(line, new TypeReference<>() {}));
}
Files.writeString(inbox, ""); // drain
return messages;
}
}
```
4. 每个队友在每次 `call()` 调用间检查收件箱, 将消息注入上下文。ChatClient 的 `call()` 等价于 Python 的完整工具循环(循环到 `stop_reason != "tool_use"` 为止)。
```java
// Python队友在每次LLM调用前检查收件箱; Java在每次call()调用间检查
protected void teammateLoop(String name, String role, String initialPrompt) {
String sysPrompt = String.format(
"You are '%s', role: %s. Use send_message to communicate.",
name, role);
var messageTool = new TeammateMessageTool(bus, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), messageTool)
.build();
// 初始工作(call() = 完整工具链,等价于Python循环到stop_reason != "tool_use"
String response = client.prompt(initialPrompt).call().content();
// 每次call()之间检查收件箱(而非Python的每次LLM调用之间)
for (int round = 0; round < 50; round++) {
Thread.sleep(2000);
var inbox = bus.readInbox(name);
if (inbox.isEmpty()) break;
String inboxJson = mapper.writeValueAsString(inbox);
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
}
setStatus(name, "idle");
}
```
## 相对 s08 的变更
| 组件 | 之前 (s08) | 之后 (s09) |
|----------------|------------------|------------------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| 智能体数量 | 单一 | 领导 + N 个队友 |
| 持久化 | 无 | config.json + JSONL 收件箱 |
| 线程 | 后台命令 | 每线程完整 agent loop |
| 生命周期 | 一次性 | idle -> working -> idle |
| 通信 | 无 | message + broadcast |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
2. `Broadcast "status update: phase 1 complete" to all teammates`
3. `Check the lead inbox for any messages`
4. 输入 `/team` 查看团队名册和状态
5. 输入 `/inbox` 手动检查领导的收件箱
-134
View File
@@ -1,134 +0,0 @@
# s10: Team Protocols (团队协议)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`
> *"队友之间要有统一的沟通规矩"* -- 一个 request-response 模式驱动所有协商。
>
> **Harness 层**: 协议 -- 模型之间的结构化握手。
## 问题
s09 中队友能干活能通信, 但缺少结构化协调:
**关机**: 直接杀线程会留下写了一半的文件和过期的 config.json。需要握手 -- 领导请求, 队友批准 (收尾退出) 或拒绝 (继续干)。
**计划审批**: 领导说 "重构认证模块", 队友立刻开干。高风险变更应该先过审。
两者结构一样: 一方发带唯一 ID 的请求, 另一方引用同一 ID 响应。
## 解决方案
```
Shutdown Protocol Plan Approval Protocol
================== ======================
Lead Teammate Teammate Lead
| | | |
|--shutdown_req-->| |--plan_req------>|
| {req_id:"abc"} | | {req_id:"xyz"} |
| | | |
|<--shutdown_resp-| |<--plan_resp-----|
| {req_id:"abc", | | {req_id:"xyz", |
| approve:true} | | approve:true} |
Shared FSM:
[pending] --approve--> [approved]
[pending] --reject---> [rejected]
Trackers:
shutdown_requests = {req_id: {target, status}}
plan_requests = {req_id: {from, plan, status}}
```
## 工作原理
1. 领导生成 request_id, 通过收件箱发起关机请求。
```java
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
// Python用字典 + threading.Lock; Java用ConcurrentHashMap天然线程安全
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
= new ConcurrentHashMap<>();
public String handleShutdownRequest(String teammate) {
String reqId = UUID.randomUUID().toString().substring(0, 8);
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
"target", teammate, "status", "pending")));
bus.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", Map.of("request_id", reqId));
return "Shutdown request " + reqId + " sent to '" + teammate
+ "' (status: pending)";
}
```
2. 队友收到请求后, 用 approve/reject 响应。
```java
// TeammateProtocolTool - 队友用@Tool注解响应关闭请求
@Tool(description = "Respond to a shutdown request")
public String shutdownResponse(
@ToolParam(description = "The request_id") String requestId,
@ToolParam(description = "true to approve") boolean approve,
@ToolParam(description = "Reason for decision") String reason) {
return tracker.respondToShutdown(name, requestId, approve, reason);
}
// ProtocolTracker - 更新追踪器 + 发送响应消息
public String respondToShutdown(String sender, String requestId,
boolean approve, String reason) {
var req = shutdownRequests.get(requestId);
if (req != null) {
req.put("status", approve ? "approved" : "rejected");
}
bus.send(sender, "lead", reason != null ? reason : "",
"shutdown_response",
Map.of("request_id", requestId, "approve", approve));
return "Shutdown " + (approve ? "approved" : "rejected");
}
```
3. 计划审批遵循完全相同的模式。队友提交计划 (生成 request_id), 领导审查 (引用同一个 request_id)。
```java
// ProtocolTracker - 同样的request_id关联模式,两种用途
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
public String reviewPlan(String requestId, boolean approve, String feedback) {
var req = planRequests.get(requestId);
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
req.put("status", approve ? "approved" : "rejected");
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
"plan_approval_response",
Map.of("request_id", requestId, "approve", approve,
"feedback", feedback != null ? feedback : ""));
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
}
```
一个 FSM, 两种用途。同样的 `pending -> approved | rejected` 状态机可以套用到任何请求-响应协议上。
## 相对 s09 的变更
| 组件 | 之前 (s09) | 之后 (s10) |
|----------------|------------------|--------------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
| 关机 | 仅自然退出 | 请求-响应握手 |
| 计划门控 | 无 | 提交/审查与审批 |
| 关联 | 无 | 每个请求一个 request_id |
| FSM | 无 | pending -> approved/rejected |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Spawn alice as a coder. Then request her shutdown.`
2. `List teammates to see alice's status after shutdown approval`
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
4. `Spawn charlie, have him submit a plan, then approve it.`
5. 输入 `/team` 监控状态
-193
View File
@@ -1,193 +0,0 @@
# s11: Autonomous Agents (自治智能体)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
> *"队友自己看看板, 有活就认领"* -- 不需要领导逐个分配, 自组织。
>
> **Harness 层**: 自治 -- 模型自己找活干, 无需指派。
## 问题
s09-s10 中, 队友只在被明确指派时才动。领导得给每个队友写 prompt, 任务看板上 10 个未认领的任务得手动分配。这扩展不了。
真正的自治: 队友自己扫描任务看板, 认领没人做的任务, 做完再找下一个。
一个细节: 上下文压缩 (s06) 后智能体可能忘了自己是谁。身份重注入解决这个问题。
## 解决方案
```
Teammate lifecycle with idle cycle:
+-------+
| spawn |
+---+---+
|
v
+-------+ tool_use +-------+
| WORK | <------------- | LLM |
+---+---+ +-------+
|
| stop_reason != tool_use (or idle tool called)
v
+--------+
| IDLE | poll every 5s for up to 60s
+---+----+
|
+---> check inbox --> message? ----------> WORK
|
+---> scan .tasks/ --> unclaimed? -------> claim -> WORK
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity via system prompt (always present):
ChatClient.builder(chatModel)
.defaultSystem(identityPrompt) // 每次调用自动携带
```
## 工作原理
1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
private void autonomousLoop(String name, String role, String initialPrompt) {
// idle标志:工具调用时设置,外部循环检测
AtomicBoolean idleRequested = new AtomicBoolean(false);
var idleTool = new IdleTool(idleRequested);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
while (true) {
// -- WORK PHASE --
String nextMsg = initialPrompt;
for (int round = 0; round < 50 && nextMsg != null; round++) {
var inbox = bus.readInbox(name);
// ... 合并收件箱消息到 nextMsg ...
idleRequested.set(false);
String response = client.prompt(sb.toString()).call().content();
if (idleRequested.get()) break; // idle工具被调用
nextMsg = null; // 后续轮次靠inbox驱动
}
// -- IDLE PHASE --
setStatus(name, "idle");
// ... 轮询收件箱 + 任务板(见下文) ...
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
}
}
```
2. 空闲阶段循环轮询收件箱和任务看板。
```java
// IDLE PHASE: 轮询收件箱 + 任务板
setStatus(name, "idle");
boolean resume = false;
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
for (int p = 0; p < polls; p++) {
Thread.sleep(POLL_INTERVAL * 1000L);
// 检查收件箱
var inbox = bus.readInbox(name);
if (!inbox.isEmpty()) {
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
resume = true;
break;
}
// 扫描任务板
var unclaimed = scanUnclaimedTasks(tasksDir);
if (!unclaimed.isEmpty()) {
var task = unclaimed.get(0);
int taskId = ((Number) task.get("id")).intValue();
claimTask(tasksDir, taskId, name);
initialPrompt = String.format(
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
taskId, task.get("subject"),
task.getOrDefault("description", ""));
resume = true;
break;
}
}
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
```
3. 任务看板扫描: 找 pending 状态、无 owner、未被阻塞的任务。
```java
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
if (!Files.exists(tasksDir)) return List.of();
List<Map<String, Object>> unclaimed = new ArrayList<>();
ObjectMapper mapper = new ObjectMapper();
try (var files = Files.list(tasksDir)) {
files.filter(f -> f.getFileName().toString().startsWith("task_")
&& f.getFileName().toString().endsWith(".json"))
.sorted()
.forEach(f -> {
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
if ("pending".equals(task.get("status"))
&& (task.get("owner") == null || "".equals(task.get("owner")))
&& (task.get("blockedBy") == null
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
unclaimed.add(task);
}
});
}
return unclaimed;
}
```
4. 身份保持: Java/Spring AI 的 `ChatClient.defaultSystem()` 在每次调用时自动携带系统提示, 身份信息始终存在, 无需像 Python 版本那样在压缩后手动重注入。
```java
// 身份信息通过 defaultSystem 在构建时注入, 每次 prompt 自动携带
String sysPrompt = String.format(
"You are '%s', role: %s, team: %s, at %s. "
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
name, role, teamName, workDir);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt) // 身份始终存在于系统提示中
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
```
## 相对 s10 的变更
| 组件 | 之前 (s10) | 之后 (s11) |
|----------------|------------------|----------------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| 自治性 | 领导指派 | 自组织 |
| 空闲阶段 | 无 | 轮询收件箱 + 任务看板 |
| 任务认领 | 仅手动 | 自动认领未分配任务 |
| 身份 | 系统提示 | + 压缩后重注入 |
| 超时 | 无 | 60 秒空闲 -> 自动关机 |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
2. `Spawn a coder teammate and let it find work from the task board itself`
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
4. 输入 `/tasks` 查看带 owner 的任务看板
5. 输入 `/team` 监控谁在工作、谁在空闲
-146
View File
@@ -1,146 +0,0 @@
# s12: Worktree + Task Isolation (Worktree 任务隔离)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
> *"各干各的目录, 互不干扰"* -- 任务管目标, worktree 管目录, 按 ID 绑定。
>
> **Harness 层**: 目录隔离 -- 永不碰撞的并行执行通道。
## 问题
到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个目录。两个智能体同时重构不同模块 -- A 改 `Config.java`, B 也改 `Config.java`, 未提交的改动互相污染, 谁也没法干净回滚。
任务板管 "做什么" 但不管 "在哪做"。解法: 给每个任务一个独立的 git worktree 目录, 用任务 ID 把两边关联起来。
## 解决方案
```
Control plane (.tasks/) Execution plane (.worktrees/)
+------------------+ +------------------------+
| task_1.json | | auth-refactor/ |
| status: in_progress <------> branch: wt/auth-refactor
| worktree: "auth-refactor" | task_id: 1 |
+------------------+ +------------------------+
| task_2.json | | ui-login/ |
| status: pending <------> branch: wt/ui-login
| worktree: "ui-login" | task_id: 2 |
+------------------+ +------------------------+
|
index.json (worktree registry)
events.jsonl (lifecycle log)
State machines:
Task: pending -> in_progress -> completed
Worktree: absent -> active -> removed | kept
```
## 工作原理
1. **创建任务。** 先把目标持久化。
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
tasks.create("Implement auth refactor", "");
// -> .tasks/task_1.json status=pending worktree=""
```
2. **创建 worktree 并绑定任务。** 传入 `task_id` 自动将任务推进到 `in_progress`
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
worktrees.create("auth-refactor", 1, "HEAD");
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
绑定同时写入两侧状态:
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
public String bindWorktree(int taskId, String worktree, String owner) {
var task = load(taskId);
task.put("worktree", worktree);
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
save(task);
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
3. **在 worktree 中执行命令。** `cwd` 指向隔离目录。
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
ProcessBuilder pb = isWindows
? new ProcessBuilder("cmd", "/c", command)
: new ProcessBuilder("sh", "-c", command);
pb.directory(path.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
```
4. **收尾。** 两种选择:
- `worktree_keep(name)` -- 保留目录供后续使用。
- `worktree_remove(name, complete_task=True)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
public String remove(String name, boolean force, boolean completeTask) {
var wt = findWorktree(name);
events.emit("worktree.remove.before", ...);
runGit("worktree", "remove", wt.get("path").toString());
if (completeTask && wt.get("task_id") != null) {
int taskId = ((Number) wt.get("task_id")).intValue();
tasks.update(taskId, "completed", null);
tasks.unbindWorktree(taskId);
events.emit("task.completed",
Map.of("id", taskId, "status", "completed"),
Map.of("name", name), null);
}
// 更新 index.json: status -> "removed"
}
```
5. **事件流。** 每个生命周期步骤写入 `.worktrees/events.jsonl`:
```json
{
"event": "worktree.remove.after",
"task": {"id": 1, "status": "completed"},
"worktree": {"name": "auth-refactor", "status": "removed"},
"ts": 1730000000
}
```
事件类型: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`
崩溃后从 `.tasks/` + `.worktrees/index.json` 重建现场。会话记忆是易失的; 磁盘状态是持久的。
## 相对 s11 的变更
| 组件 | 之前 (s11) | 之后 (s12) |
|--------------------|----------------------------|----------------------------------------------|
| 协调 | 任务板 (owner/status) | 任务板 + worktree 显式绑定 |
| 执行范围 | 共享目录 | 每个任务独立目录 |
| 可恢复性 | 仅任务状态 | 任务状态 + worktree 索引 |
| 收尾 | 任务完成 | 任务完成 + 显式 keep/remove |
| 生命周期可见性 | 隐式日志 | `.worktrees/events.jsonl` 显式事件流 |
## 试一试
```sh
cd learn-claude-code
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create tasks for backend auth and frontend login page, then list tasks.`
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
3. `Run "git status --short" in worktree "auth-refactor".`
4. `Keep worktree "ui-login", then list worktrees and inspect events.`
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
+77
View File
@@ -0,0 +1,77 @@
/**
* Global type declarations for Claude Code build-time constants.
*
* MACRO is defined at bundle time via Bun's --define flag.
* bun:bundle provides compile-time feature flags for dead code elimination.
*/
// Build-time macro constants injected via --define
declare const MACRO: {
/** Application version string, e.g. "1.0.0" */
VERSION: string;
/** ISO 8601 build timestamp, e.g. "2026-03-31T00:00:00Z" */
BUILD_TIME: string;
/** npm package URL, e.g. "@anthropic-ai/claude-code" */
PACKAGE_URL: string;
/** Native package URL for platform-specific binaries */
NATIVE_PACKAGE_URL: string | undefined;
/** Feedback channel URL or description */
FEEDBACK_CHANNEL: string;
/** Instructions for reporting issues */
ISSUES_EXPLAINER: string;
/** Version changelog content */
VERSION_CHANGELOG: string;
};
// Bun's bundle-time feature flag module
declare module "bun:bundle" {
/**
* Returns true if the named feature flag is enabled at bundle time.
* Used for dead code elimination disabled branches are stripped entirely.
*/
export function feature(name: string): boolean;
}
// Stub declarations for internal Anthropic packages that are not publicly available
declare module "@ant/claude-for-chrome-mcp" {
const mod: any;
export default mod;
export const runClaudeInChromeMcpServer: () => Promise<void>;
}
declare module "@ant/computer-use-input" {
const mod: any;
export default mod;
}
declare module "@ant/computer-use-mcp" {
const mod: any;
export default mod;
export const runComputerUseMcpServer: () => Promise<void>;
}
declare module "@ant/computer-use-swift" {
const mod: any;
export default mod;
}
declare module "@anthropic-ai/claude-agent-sdk" {
const mod: any;
export default mod;
}
declare module "@anthropic-ai/mcpb" {
const mod: any;
export default mod;
}
declare module "@anthropic-ai/sandbox-runtime" {
const mod: any;
export default mod;
}
declare module "color-diff-napi" {
export function diff(a: string, b: string): any;
const mod: any;
export default mod;
}
+104
View File
@@ -0,0 +1,104 @@
{
"name": "@anthropic-ai/claude-code",
"version": "1.0.0-research",
"description": "Claude Code CLI - Source snapshot for research",
"type": "module",
"main": "src/entrypoints/cli.tsx",
"bin": {
"claude": "dist/cli.js"
},
"scripts": {
"build": "bun run scripts/build.ts",
"dev": "bun run src/entrypoints/cli.tsx",
"typecheck": "bun x tsc --noEmit"
},
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.1.0",
"@anthropic-ai/bedrock-sdk": "^0.12.0",
"@anthropic-ai/sdk": "^0.39.0",
"@anthropic-ai/vertex-sdk": "^0.6.0",
"@aws-sdk/client-bedrock": "^3.750.0",
"@aws-sdk/client-bedrock-runtime": "^3.750.0",
"@aws-sdk/client-sts": "^3.750.0",
"@azure/identity": "^4.6.0",
"@commander-js/extra-typings": "^13.1.0",
"@growthbook/growthbook": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.11.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0",
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-logs": "^0.200.0",
"@opentelemetry/sdk-metrics": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"ajv": "^8.17.0",
"asciichart": "^1.5.0",
"auto-bind": "^5.0.0",
"axios": "^1.7.0",
"bidi-js": "^1.0.0",
"chalk": "^5.4.0",
"chokidar": "^4.0.0",
"cli-boxes": "^3.0.0",
"code-excerpt": "^4.0.0",
"commander": "^13.1.0",
"diff": "^7.0.0",
"emoji-regex": "^10.4.0",
"env-paths": "^3.0.0",
"execa": "^9.5.0",
"fflate": "^0.8.0",
"figures": "^6.1.0",
"fuse.js": "^7.0.0",
"get-east-asian-width": "^1.3.0",
"google-auth-library": "^9.15.0",
"highlight.js": "^11.11.0",
"https-proxy-agent": "^7.0.0",
"ignore": "^7.0.0",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.0",
"lodash-es": "^4.17.0",
"lru-cache": "^11.0.0",
"marked": "^15.0.0",
"p-map": "^7.0.0",
"picomatch": "^4.0.0",
"proper-lockfile": "^4.1.0",
"qrcode": "^1.5.0",
"react": "^19.0.0",
"react-reconciler": "0.33.0",
"semver": "^7.6.0",
"sharp": "^0.33.0",
"shell-quote": "^1.8.0",
"signal-exit": "^4.1.0",
"stack-utils": "^2.0.0",
"strip-ansi": "^7.1.0",
"supports-hyperlinks": "^3.1.0",
"tree-kill": "^1.2.0",
"turndown": "^7.2.0",
"type-fest": "^4.30.0",
"undici": "^7.3.0",
"usehooks-ts": "^3.1.0",
"vscode-jsonrpc": "^8.2.0",
"vscode-languageserver-protocol": "^3.17.0",
"vscode-languageserver-types": "^3.17.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"xss": "^1.0.0",
"yaml": "^2.7.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"@types/diff": "^7.0.0",
"@types/lodash-es": "^4.17.0",
"@types/node": "^22.0.0",
"@types/proper-lockfile": "^4.1.0",
"@types/qrcode": "^1.5.0",
"@types/react": "^19.0.0",
"@types/react-reconciler": "^0.28.0",
"@types/semver": "^7.5.0",
"@types/shell-quote": "^1.7.0",
"@types/stack-utils": "^2.0.0",
"@types/ws": "^8.5.0",
"typescript": "^5.7.0"
}
}
-70
View File
@@ -1,70 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version>
<relativePath/>
</parent>
<groupId>io.mybatis</groupId>
<artifactId>learn-claude-code</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-ai-learn-claude-code</name>
<description>Spring AI 实现的 learn-claude-code</description>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.3</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Web (Servlet 同步调用) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebFlux (流式输出 Streaming 必须) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring AI OpenAI Starter (Spring AI 1.0.0 新命名规范) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
-2
View File
@@ -1,2 +0,0 @@
anthropic>=0.25.0
python-dotenv>=1.0.0
+477
View File
@@ -0,0 +1,477 @@
/**
* Build script for Claude Code using Bun's bundler API.
*
* Handles:
* - MACRO.* build-time constant injection
* - bun:bundle feature flag polyfill (all flags default to false)
* - src/ path alias resolution
* - Internal Anthropic package stubs
*
* Usage: bun run scripts/build.ts
*/
import { type BunPlugin } from "bun";
import { existsSync, mkdirSync } from "fs";
import { dirname, join, relative } from "path";
const PROJECT_ROOT = join(import.meta.dir, "..");
const OUT_DIR = join(PROJECT_ROOT, "dist");
// Ensure output directory exists
mkdirSync(OUT_DIR, { recursive: true });
// Build-time MACRO values
const VERSION = process.env.CLAUDE_CODE_VERSION || "1.0.0-research";
const BUILD_TIME = new Date().toISOString();
const PACKAGE_URL = "@anthropic-ai/claude-code";
const FEEDBACK_CHANNEL = "https://github.com/anthropics/claude-code/issues";
const ISSUES_EXPLAINER =
"report issues at https://github.com/anthropics/claude-code/issues";
const VERSION_CHANGELOG = "";
// Feature flags — all disabled by default for external builds
const FEATURE_FLAGS: Record<string, boolean> = {
PROACTIVE: false,
KAIROS: false,
BRIDGE_MODE: false,
DAEMON: false,
VOICE_MODE: false,
AGENT_TRIGGERS: false,
MONITOR_TOOL: false,
COORDINATOR_MODE: false,
ABLATION_BASELINE: false,
DUMP_SYSTEM_PROMPT: false,
CHICAGO_MCP: false,
};
/**
* Plugin to handle `bun:bundle` imports.
* The `feature()` function returns false for all flags in external builds.
*/
const bunBundlePlugin: BunPlugin = {
name: "bun-bundle-polyfill",
setup(build) {
build.onResolve({ filter: /^bun:bundle$/ }, (args) => {
return {
path: "bun:bundle",
namespace: "bun-bundle-polyfill",
};
});
build.onLoad(
{ filter: /.*/, namespace: "bun-bundle-polyfill" },
(args) => {
const featureFnBody = Object.entries(FEATURE_FLAGS)
.map(([k, v]) => ` if (name === ${JSON.stringify(k)}) return ${v};`)
.join("\n");
return {
contents: `
export function feature(name) {
${featureFnBody}
return false;
}
`,
loader: "js",
};
}
);
},
};
/**
* Plugin to provide inline stubs for internal Anthropic packages.
* Each package gets properly named exports to satisfy static analysis.
*/
const internalPackageStubPlugin: BunPlugin = {
name: "internal-package-stubs",
setup(build) {
// Map of package names to their required value exports
const packageExports: Record<string, string[]> = {
"@ant/claude-for-chrome-mcp": [
"BROWSER_TOOLS[]",
"createClaudeForChromeMcpServer",
],
"@ant/computer-use-input": [],
"@ant/computer-use-mcp": [
"API_RESIZE_PARAMS{}",
"DEFAULT_GRANT_FLAGS{}",
"bindSessionContext",
"buildComputerUseTools",
"createComputerUseMcpServer",
"targetImageSize",
"getSentinelCategory",
],
"@ant/computer-use-mcp/sentinelApps": ["getSentinelCategory"],
"@ant/computer-use-mcp/types": [
"DEFAULT_GRANT_FLAGS{}",
"getSentinelCategory",
],
"@ant/computer-use-swift": [],
"@anthropic-ai/claude-agent-sdk": [],
"@anthropic-ai/mcpb": [],
"@anthropic-ai/sandbox-runtime": [
"SandboxManager",
"SandboxRuntimeConfigSchema",
"SandboxViolationStore",
],
"@anthropic-ai/foundry-sdk": [],
"color-diff-napi": ["ColorDiff", "ColorFile", "getSyntaxTheme"],
"modifiers-napi": ["isModifierPressed"],
};
// Match any package in our map (including subpaths like @ant/computer-use-mcp/types)
build.onResolve(
{ filter: /^(@ant\/|@anthropic-ai\/(sandbox-runtime|claude-agent-sdk|mcpb|foundry-sdk)|color-diff-napi|modifiers-napi)/ },
(args) => ({
path: args.path,
namespace: "internal-stub",
})
);
build.onLoad(
{ filter: /.*/, namespace: "internal-stub" },
(args) => {
// Find the best matching exports for this path
const exports = packageExports[args.path] || [];
const exportLines = exports
.map((name) => {
// Array exports (name[])
if (name.endsWith("[]")) {
const cleanName = name.slice(0, -2);
return `export const ${cleanName} = [];`;
}
// Object exports (name{})
if (name.endsWith("{}")) {
const cleanName = name.slice(0, -2);
return `export const ${cleanName} = {};`;
}
// ColorDiff/ColorFile need render() method
if (name === "ColorDiff" || name === "ColorFile") {
return `export class ${name} { constructor(...a){} render(...a){ return null; } static create(...a){ return new ${name}(); } }`;
}
if (
name === "SandboxManager" ||
name === "SandboxViolationStore"
) {
return `export class ${name} {
constructor(...a){}
static create(...a){ return new ${name}(); }
static isSupportedPlatform(){ return false; }
static checkDependencies(...a){ return { satisfied: false, missing: [] }; }
static wrapWithSandbox(...a){ return a[0]; }
static initialize(...a){ return Promise.resolve(); }
static updateConfig(...a){}
static reset(){ return Promise.resolve(); }
static getFsReadConfig(){ return {}; }
static getFsWriteConfig(){ return {}; }
static getNetworkRestrictionConfig(){ return {}; }
static getIgnoreViolations(){ return {}; }
static getAllowUnixSockets(){ return false; }
static getAllowLocalBinding(){ return false; }
static getEnableWeakerNestedSandbox(){ return false; }
static getProxyPort(){ return 0; }
static getSocksProxyPort(){ return 0; }
static getLinuxHttpSocketPath(){ return ''; }
static getLinuxSocksSocketPath(){ return ''; }
static waitForNetworkInitialization(){ return Promise.resolve(); }
static getSandboxViolationStore(){ return new ${name === 'SandboxManager' ? 'SandboxViolationStore' : name}(); }
static annotateStderrWithSandboxFailures(...a){ return a[0]; }
static cleanupAfterCommand(){ return Promise.resolve(); }
dispose(){}
}`;
}
if (name === "SandboxRuntimeConfigSchema") {
return `export const ${name} = { parse: (...a) => ({}), safeParse: (...a) => ({ success: true, data: {} }) };`;
}
if (
name.startsWith("create") ||
name.startsWith("build") ||
name.startsWith("bind") ||
name.startsWith("get") ||
name === "targetImageSize"
) {
return `export function ${name}(...args) { return {}; }`;
}
// Default: export as empty object/array
if (name.endsWith("[]")) {
const cleanName = name.slice(0, -2);
return `export const ${cleanName} = [];`;
}
return `export const ${name} = {};`;
})
.join("\n");
return {
contents: `// Stub: ${args.path}\nexport default {};\n${exportLines}\n`,
loader: "js",
};
}
);
},
};
/**
* Plugin to auto-stub missing source files at build time.
* When the bundler encounters an import to a file that doesn't exist on disk,
* this plugin provides a stub implementation instead of failing the build.
* This eliminates the need for physical stub files in the source tree.
*/
const missingSourceStubPlugin: BunPlugin = {
name: "missing-source-stubs",
setup(build) {
// 需要特定 named exports 的缺失模块(key: 相对路径,不含扩展名)
const specificStubs: Record<string, string> = {
"src/types/connectorText": [
"export default {};",
"export function isConnectorTextBlock(block) { return block?.type === 'connector_text'; }",
].join("\n"),
"src/utils/protectedNamespace": [
"export default {};",
"export const PROTECTED_NAMESPACE_PREFIXES = [];",
"export function isProtectedNamespace(_name) { return false; }",
].join("\n"),
"src/tools/WorkflowTool/constants": [
"export default {};",
"export const WORKFLOW_TOOL_NAME = 'workflow';",
].join("\n"),
"src/utils/filePersistence/types": [
"export default {};",
"export const DEFAULT_UPLOAD_CONCURRENCY = 5;",
"export const FILE_COUNT_LIMIT = 1000;",
"export const OUTPUTS_SUBDIR = 'outputs';",
].join("\n"),
"src/tools/REPLTool/REPLTool": [
"export default {};",
"export const REPLTool = { name: 'repl', description: 'REPL tool (stub)' };",
].join("\n"),
"src/tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool": [
"export default {};",
"export const SuggestBackgroundPRTool = { name: 'suggest_background_pr', description: 'stub' };",
].join("\n"),
"src/tools/TungstenTool/TungstenLiveMonitor": [
"export default {};",
"export const TungstenLiveMonitor = {};",
].join("\n"),
"src/tools/TungstenTool/TungstenTool": [
"export default {};",
"export const TungstenTool = { name: 'tungsten', description: 'Tungsten tool (stub)' };",
].join("\n"),
"src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool": [
"export default {};",
"export const VerifyPlanExecutionTool = { name: 'verify_plan_execution', description: 'stub' };",
].join("\n"),
"src/assistant/AssistantSessionChooser": [
"export default {};",
"export const AssistantSessionChooser = () => null;",
].join("\n"),
"src/components/agents/SnapshotUpdateDialog": [
"export default {};",
"export const SnapshotUpdateDialog = () => null;",
].join("\n"),
"src/commands/assistant/assistant": [
"export default {};",
"export const NewInstallWizard = () => null;",
"export function computeDefaultInstallDir() { return ''; }",
].join("\n"),
"src/services/remoteManagedSettings/securityCheck": [
"export default {};",
"export function checkManagedSettingsSecurity() { return { ok: true }; }",
"export function handleSecurityCheckResult() {}",
].join("\n"),
};
build.onResolve({ filter: /\.(?:ts|tsx|js|jsx|md|txt)$/ }, (args) => {
if (args.namespace !== "file") return;
let basePath: string;
if (args.path.startsWith("src/")) {
basePath = join(PROJECT_ROOT, args.path);
} else if (args.path.startsWith(".") || args.path.startsWith("/")) {
if (!args.importer) return;
basePath = join(dirname(args.importer), args.path);
} else {
return;
}
// 对 .js/.jsx 导入尝试 .ts/.tsx 变体
const candidates = [basePath];
if (basePath.endsWith(".js")) {
const base = basePath.slice(0, -3);
candidates.push(base + ".ts", base + ".tsx", base + ".d.ts");
} else if (basePath.endsWith(".jsx")) {
const base = basePath.slice(0, -4);
candidates.push(base + ".tsx", base + ".ts");
}
for (const c of candidates) {
if (existsSync(c)) return;
}
// 文件不存在 → 重定向到 stub 命名空间
let stubKey = relative(PROJECT_ROOT, basePath).replace(/\\/g, "/");
if (stubKey.endsWith(".js") || stubKey.endsWith(".jsx")) {
stubKey = stubKey.replace(/\.jsx?$/, "");
}
return { path: stubKey, namespace: "missing-source" };
});
build.onLoad({ filter: /.*/, namespace: "missing-source" }, (args) => {
const key = args.path;
if (key.endsWith(".md") || key.endsWith(".txt")) {
return { contents: `export default '';`, loader: "js" };
}
if (key.endsWith(".d.ts")) {
return { contents: `export {};`, loader: "js" };
}
const stubContent = specificStubs[key];
if (stubContent !== undefined) {
return { contents: stubContent, loader: "js" };
}
console.log(` ⚠️ Auto-stubbing missing module: ${key}`);
return { contents: `export default {};`, loader: "js" };
});
},
};
console.log("🔨 Building Claude Code...");
console.log(` Version: ${VERSION}`);
console.log(` Build time: ${BUILD_TIME}`);
console.log(` Output: ${OUT_DIR}`);
console.log(` Feature flags: all disabled (external build)`);
console.log("");
const result = await Bun.build({
entrypoints: [join(PROJECT_ROOT, "src/entrypoints/cli.tsx")],
outdir: OUT_DIR,
target: "bun",
format: "esm",
splitting: false,
sourcemap: "external",
minify: false,
plugins: [bunBundlePlugin, internalPackageStubPlugin, missingSourceStubPlugin],
define: {
"MACRO.VERSION": JSON.stringify(VERSION),
"MACRO.BUILD_TIME": JSON.stringify(BUILD_TIME),
"MACRO.PACKAGE_URL": JSON.stringify(PACKAGE_URL),
"MACRO.NATIVE_PACKAGE_URL": "undefined",
"MACRO.FEEDBACK_CHANNEL": JSON.stringify(FEEDBACK_CHANNEL),
"MACRO.ISSUES_EXPLAINER": JSON.stringify(ISSUES_EXPLAINER),
"MACRO.VERSION_CHANGELOG": JSON.stringify(VERSION_CHANGELOG),
},
external: [
// Node.js built-ins
"node:*",
"fs",
"fs/promises",
"path",
"crypto",
"os",
"util",
"url",
"http",
"https",
"net",
"tls",
"dns",
"stream",
"events",
"buffer",
"child_process",
"process",
"readline",
"tty",
"v8",
"zlib",
"async_hooks",
"perf_hooks",
// Keep all npm deps external (not bundled)
"@alcalzone/ansi-tokenize",
"@anthropic-ai/sdk",
"@aws-sdk/client-bedrock-runtime",
"@commander-js/extra-typings",
"@growthbook/growthbook",
"@modelcontextprotocol/sdk",
"@opentelemetry/*",
"ajv",
"asciichart",
"auto-bind",
"axios",
"bidi-js",
"chalk",
"chokidar",
"cli-boxes",
"code-excerpt",
"diff",
"emoji-regex",
"env-paths",
"execa",
"figures",
"fuse.js",
"get-east-asian-width",
"google-auth-library",
"highlight.js",
"https-proxy-agent",
"ignore",
"indent-string",
"jsonc-parser",
"lodash-es",
"lru-cache",
"marked",
"p-map",
"picomatch",
"proper-lockfile",
"qrcode",
"react",
"react-reconciler",
"semver",
"shell-quote",
"signal-exit",
"stack-utils",
"strip-ansi",
"supports-hyperlinks",
"tree-kill",
"type-fest",
"undici",
"usehooks-ts",
"vscode-jsonrpc",
"vscode-languageserver-protocol",
"vscode-languageserver-types",
"wrap-ansi",
"ws",
"xss",
"zod",
"@anthropic-ai/bedrock-sdk",
"@anthropic-ai/vertex-sdk",
"@anthropic-ai/foundry-sdk",
"@azure/identity",
"@aws-sdk/client-bedrock",
"@aws-sdk/client-sts",
"fflate",
"yaml",
"sharp",
"modifiers-napi",
"turndown",
],
});
if (!result.success) {
console.error("❌ Build failed!");
for (const log of result.logs) {
console.error(` ${log.message}`);
}
process.exit(1);
} else {
console.log(`✅ Build succeeded!`);
console.log(` Output files:`);
for (const output of result.outputs) {
const sizeKB = (output.size / 1024).toFixed(1);
console.log(` ${output.path} (${sizeKB} KB)`);
}
}
-129
View File
@@ -1,129 +0,0 @@
---
name: agent-builder
description: |
Design and build AI agents for any domain. Use when users:
(1) ask to "create an agent", "build an assistant", or "design an AI system"
(2) want to understand agent architecture, agentic patterns, or autonomous AI
(3) need help with capabilities, subagents, planning, or skill mechanisms
(4) ask about Claude Code, Cursor, or similar agent internals
(5) want to build agents for business, research, creative, or operational tasks
Keywords: agent, assistant, autonomous, workflow, tool use, multi-step, orchestration
---
# Agent Builder
Build AI agents for any domain - customer service, research, operations, creative work, or specialized business processes.
## The Core Philosophy
> **The model already knows how to be an agent. Your job is to get out of the way.**
An agent is not complex engineering. It's a simple loop that invites the model to act:
```
LOOP:
Model sees: context + available capabilities
Model decides: act or respond
If act: execute capability, add result, continue
If respond: return to user
```
**That's it.** The magic isn't in the code - it's in the model. Your code just provides the opportunity.
## The Three Elements
### 1. Capabilities (What can it DO?)
Atomic actions the agent can perform: search, read, create, send, query, modify.
**Design principle**: Start with 3-5 capabilities. Add more only when the agent consistently fails because a capability is missing.
### 2. Knowledge (What does it KNOW?)
Domain expertise injected on-demand: policies, workflows, best practices, schemas.
**Design principle**: Make knowledge available, not mandatory. Load it when relevant, not upfront.
### 3. Context (What has happened?)
The conversation history - the thread connecting actions into coherent behavior.
**Design principle**: Context is precious. Isolate noisy subtasks. Truncate verbose outputs. Protect clarity.
## Agent Design Thinking
Before building, understand:
- **Purpose**: What should this agent accomplish?
- **Domain**: What world does it operate in? (customer service, research, operations, creative...)
- **Capabilities**: What 3-5 actions are essential?
- **Knowledge**: What expertise does it need access to?
- **Trust**: What decisions can you delegate to the model?
**CRITICAL**: Trust the model. Don't over-engineer. Don't pre-specify workflows. Give it capabilities and let it reason.
## Progressive Complexity
Start simple. Add complexity only when real usage reveals the need:
| Level | What to add | When to add it |
|-------|-------------|----------------|
| Basic | 3-5 capabilities | Always start here |
| Planning | Progress tracking | Multi-step tasks lose coherence |
| Subagents | Isolated child agents | Exploration pollutes context |
| Skills | On-demand knowledge | Domain expertise needed |
**Most agents never need to go beyond Level 2.**
## Domain Examples
**Business**: CRM queries, email, calendar, approvals
**Research**: Database search, document analysis, citations
**Operations**: Monitoring, tickets, notifications, escalation
**Creative**: Asset generation, editing, collaboration, review
The pattern is universal. Only the capabilities change.
## Key Principles
1. **The model IS the agent** - Code just runs the loop
2. **Capabilities enable** - What it CAN do
3. **Knowledge informs** - What it KNOWS how to do
4. **Constraints focus** - Limits create clarity
5. **Trust liberates** - Let the model reason
6. **Iteration reveals** - Start minimal, evolve from usage
## Anti-Patterns
| Pattern | Problem | Solution |
|---------|---------|----------|
| Over-engineering | Complexity before need | Start simple |
| Too many capabilities | Model confusion | 3-5 to start |
| Rigid workflows | Can't adapt | Let model decide |
| Front-loaded knowledge | Context bloat | Load on-demand |
| Micromanagement | Undercuts intelligence | Trust the model |
## Resources
**Philosophy & Theory**:
- `references/agent-philosophy.md` - Deep dive into why agents work
**Implementation**:
- `references/MinimalAgent.java` - Complete working agent (基于 Spring AI ChatClient)
- `references/ToolTemplates.java` - Capability definitions (使用 `@Tool` 注解)
- `references/SubagentPattern.java` - Context isolation
**Scaffolding**:
- `scripts/init-agent.sh` - Generate new Spring Boot agent project via `mvn archetype:generate`
## The Agent Mindset
**From**: "How do I make the system do X?"
**To**: "How do I enable the model to do X?"
**From**: "What's the workflow for this task?"
**To**: "What capabilities would help accomplish this?"
The best agent code is almost boring. Simple loops. Clear capabilities. Clean context. The magic isn't in the code.
**Give the model capabilities and knowledge. Trust it to figure out the rest.**
@@ -1,154 +0,0 @@
# The Philosophy of Agent Harness Engineering
> **The model already knows how to be an agent. Your job is to build it a world worth acting in.**
## The Fundamental Truth
Strip away every framework, every library, every architectural pattern. What remains?
A loop. A model. An invitation to act.
The agent is not the code. The agent is the model itself -- a vast neural network trained on humanity's collective problem-solving, reasoning, and tool use. The code merely provides the opportunity for the model to express its agency.
The code is the harness. The model is the agent. These are not interchangeable. Confuse them, and you will build the wrong thing.
## What an Agent IS
An agent is a neural network -- a Transformer, an RNN, a learned function -- that has been trained, through billions of gradient updates on action-sequence data, to perceive an environment, reason about goals, and take actions to achieve them.
A human is an agent: a biological neural network shaped by evolution. DeepMind's DQN is an agent: a convolutional network that learned to play Atari from raw pixels. OpenAI Five is an agent: five networks that learned Dota 2 teamwork through self-play. Claude is an agent: a language model that learned to reason and act from the breadth of human knowledge.
In every case, the agent is the trained model. Not the game engine. Not the Dota 2 client. Not the terminal. The model.
## What an Agent Is NOT
Prompt plumbing is not agency. Wiring together LLM API calls with if-else branches, node graphs, and hardcoded routing logic does not produce an agent. It produces a brittle pipeline -- a Rube Goldberg machine with an LLM wedged in as a text-completion node.
You cannot engineer your way to agency. Agency is learned, not programmed. No amount of glue code will emergently produce autonomous behavior. Those systems are the modern resurrection of GOFAI -- symbolic rule systems the field abandoned decades ago, now spray-painted with an LLM veneer.
## The Harness: What We Actually Build
If the model is the agent, then what is the code? It is the **harness** -- the environment that gives the agent the ability to perceive and act in a specific domain.
```
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
```
### Tools: The Agent's Hands
Tools answer: **What can the agent DO?**
Each tool is an atomic action the agent can take in its environment. File read/write, shell execution, API calls, browser control, database queries. The model needs to understand what each tool does, but not how to sequence them -- it will figure that out.
**Design principle**: Atomic, composable, well-described. Start with 3-5. Add more only when the model consistently fails to accomplish tasks because a tool is missing.
### Knowledge: The Agent's Expertise
Knowledge answers: **What does the agent KNOW?**
Domain expertise that turns a general agent into a domain specialist. Product documentation, architectural decisions, regulatory requirements, style guides. Inject on-demand (via tool_result), not upfront (via system prompt). Progressive disclosure preserves context for what matters.
**Design principle**: Available but not mandatory. The agent should know what knowledge exists and pull what it needs.
### Context: The Agent's Memory
Context is the thread connecting individual actions into coherent behavior. What has been said, tried, learned, and decided.
**Design principle**: Context is precious. Protect it. Isolate subtasks that generate noise (s04). Compress when history grows long (s06). Persist goals beyond single conversations (s07).
### Permissions: The Agent's Boundaries
Permissions answer: **What is the agent ALLOWED to do?**
Sandbox file access. Require approval for destructive operations. Enforce trust boundaries between the agent and external systems. This is where safety engineering meets harness engineering.
**Design principle**: Constraints focus behavior, not limit it. "One task in_progress at a time" forces sequential focus. "Read-only subagent" prevents accidental modifications.
### Task-Process Data: The Agent's Training Signal
Every action sequence the agent executes in your harness is training signal. The perception-reasoning-action traces from real deployments are the raw material for fine-tuning the next generation of agent models. Your harness doesn't just serve the agent -- it can help evolve the agent.
## The Universal Loop
Every effective agent -- regardless of domain -- follows the same pattern:
```
LOOP:
Model sees: conversation history + available tools
Model decides: act or respond
If act: tool executed, result added to context, loop continues
If respond: answer returned, loop ends
```
This is not a simplification. This is the actual architecture. Everything else is harness engineering -- mechanisms layered on top of this loop to make the agent more effective. The loop belongs to the agent. The mechanisms belong to the harness.
## Principles of Harness Engineering
### Trust the Model
The most important principle: **trust the model**.
Don't anticipate every edge case. Don't build elaborate decision trees. Don't pre-specify the workflow.
The model is better at reasoning than any rule system you could write. Your conditional logic will fail on edge cases. The model will reason through them.
**Give the model tools and knowledge. Let it figure out how to use them.**
### Constraints Enable
This seems paradoxical, but constraints don't limit agents -- they focus them.
A todo list with "only one task in progress" forces sequential focus. A subagent with read-only access prevents accidental modifications. A context compression threshold keeps history from overwhelming.
The best constraints prevent the model from getting lost, not micromanage its approach.
### Progressive Complexity
Never build everything upfront.
```
Level 0: Model + one tool (bash) -- s01
Level 1: Model + tool dispatch map -- s02
Level 2: Model + planning -- s03
Level 3: Model + subagents + skills -- s04, s05
Level 4: Model + context management + persistence -- s06, s07, s08
Level 5: Model + teams + autonomy + isolation -- s09-s12
```
Start at the lowest level that might work. Move up only when real usage reveals the need.
## The Mind Shift
Building harnesses requires a fundamental shift in thinking:
**From**: "How do I make the system do X?"
**To**: "How do I enable the model to do X?"
**From**: "What should happen when the user says Y?"
**To**: "What tools would help address Y?"
**From**: "What's the workflow for this task?"
**To**: "What does the model need to figure out the workflow?"
**From**: "I'm building an agent."
**To**: "I'm building a harness for the agent."
The best harness code is almost boring. Simple loops. Clear tool definitions. Clean context management. The magic isn't in the code -- it's in the model.
## The Vehicle Metaphor
The model is the driver. The harness is the vehicle.
A coding agent's vehicle is its IDE, terminal, and filesystem. A farm agent's vehicle is its sensor array, irrigation controls, and weather data. A hotel agent's vehicle is its booking system, guest channels, and facility APIs.
The driver generalizes. The vehicle specializes. Your job as a harness engineer is to build the best vehicle for your domain -- one that gives the driver maximum visibility, precise controls, and clear boundaries.
Build the cockpit. Build the dashboard. Build the controls. The pilot is already trained.
## Conclusion
The model is the agent. The code is the harness. Know which one you're building.
You are not writing intelligence. You are building the world intelligence inhabits. The quality of that world -- how clearly the agent can perceive, how precisely it can act, how rich its knowledge -- directly determines how effectively the intelligence can express itself.
Build great harnesses. The agent will do the rest.
@@ -1,149 +0,0 @@
#!/usr/bin/env python3
"""
Minimal Agent Template - Copy and customize this.
This is the simplest possible working agent (~80 lines).
It has everything you need: 3 tools + loop.
Usage:
1. Set ANTHROPIC_API_KEY environment variable
2. python minimal-agent.py
3. Type commands, 'q' to quit
"""
from anthropic import Anthropic
from pathlib import Path
import subprocess
import os
# Configuration
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
MODEL = os.getenv("MODEL_NAME", "claude-sonnet-4-20250514")
WORKDIR = Path.cwd()
# System prompt - keep it simple
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Rules:
- Use tools to complete tasks
- Prefer action over explanation
- Summarize what you did when done"""
# Minimal tool set - add more as needed
TOOLS = [
{
"name": "bash",
"description": "Run shell command",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]
}
},
{
"name": "read_file",
"description": "Read file contents",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}
},
{
"name": "write_file",
"description": "Write content to file",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"}
},
"required": ["path", "content"]
}
},
]
def execute_tool(name: str, args: dict) -> str:
"""Execute a tool and return result."""
if name == "bash":
try:
r = subprocess.run(
args["command"], shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=60
)
return (r.stdout + r.stderr).strip() or "(empty)"
except subprocess.TimeoutExpired:
return "Error: Timeout"
if name == "read_file":
try:
return (WORKDIR / args["path"]).read_text()[:50000]
except Exception as e:
return f"Error: {e}"
if name == "write_file":
try:
p = WORKDIR / args["path"]
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(args["content"])
return f"Wrote {len(args['content'])} bytes to {args['path']}"
except Exception as e:
return f"Error: {e}"
return f"Unknown tool: {name}"
def agent(prompt: str, history: list = None) -> str:
"""Run the agent loop."""
if history is None:
history = []
history.append({"role": "user", "content": prompt})
while True:
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=history,
tools=TOOLS,
max_tokens=8000,
)
# Build assistant message
history.append({"role": "assistant", "content": response.content})
# If no tool calls, return text
if response.stop_reason != "tool_use":
return "".join(b.text for b in response.content if hasattr(b, "text"))
# Execute tools
results = []
for block in response.content:
if block.type == "tool_use":
print(f"> {block.name}: {block.input}")
output = execute_tool(block.name, block.input)
print(f" {output[:100]}...")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output
})
history.append({"role": "user", "content": results})
if __name__ == "__main__":
print(f"Minimal Agent - {WORKDIR}")
print("Type 'q' to quit.\n")
history = []
while True:
try:
query = input(">> ").strip()
except (EOFError, KeyboardInterrupt):
break
if query in ("q", "quit", "exit", ""):
break
print(agent(query, history))
print()
@@ -1,243 +0,0 @@
"""
Subagent Pattern - How to implement Task tool for context isolation.
The key insight: spawn child agents with ISOLATED context to prevent
"context pollution" where exploration details fill up the main conversation.
"""
import time
import sys
# Assuming client, MODEL, execute_tool are defined elsewhere
# =============================================================================
# AGENT TYPE REGISTRY
# =============================================================================
AGENT_TYPES = {
# Explore: Read-only, for searching and analyzing
"explore": {
"description": "Read-only agent for exploring code, finding files, searching",
"tools": ["bash", "read_file"], # No write access!
"prompt": "You are an exploration agent. Search and analyze, but NEVER modify files. Return a concise summary of what you found.",
},
# Code: Full-powered, for implementation
"code": {
"description": "Full agent for implementing features and fixing bugs",
"tools": "*", # All tools
"prompt": "You are a coding agent. Implement the requested changes efficiently. Return a summary of what you changed.",
},
# Plan: Read-only, for design work
"plan": {
"description": "Planning agent for designing implementation strategies",
"tools": ["bash", "read_file"], # Read-only
"prompt": "You are a planning agent. Analyze the codebase and output a numbered implementation plan. Do NOT make any changes.",
},
# Add your own types here...
# "test": {
# "description": "Testing agent for running and analyzing tests",
# "tools": ["bash", "read_file"],
# "prompt": "Run tests and report results. Don't modify code.",
# },
}
def get_agent_descriptions() -> str:
"""Generate descriptions for Task tool schema."""
return "\n".join(
f"- {name}: {cfg['description']}"
for name, cfg in AGENT_TYPES.items()
)
def get_tools_for_agent(agent_type: str, base_tools: list) -> list:
"""
Filter tools based on agent type.
'*' means all base tools.
Otherwise, whitelist specific tool names.
Note: Subagents don't get Task tool to prevent infinite recursion.
"""
allowed = AGENT_TYPES.get(agent_type, {}).get("tools", "*")
if allowed == "*":
return base_tools # All base tools, but NOT Task
return [t for t in base_tools if t["name"] in allowed]
# =============================================================================
# TASK TOOL DEFINITION
# =============================================================================
TASK_TOOL = {
"name": "Task",
"description": f"""Spawn a subagent for a focused subtask.
Subagents run in ISOLATED context - they don't see parent's history.
Use this to keep the main conversation clean.
Agent types:
{get_agent_descriptions()}
Example uses:
- Task(explore): "Find all files using the auth module"
- Task(plan): "Design a migration strategy for the database"
- Task(code): "Implement the user registration form"
""",
"input_schema": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "Short task name (3-5 words) for progress display"
},
"prompt": {
"type": "string",
"description": "Detailed instructions for the subagent"
},
"agent_type": {
"type": "string",
"enum": list(AGENT_TYPES.keys()),
"description": "Type of agent to spawn"
},
},
"required": ["description", "prompt", "agent_type"],
},
}
# =============================================================================
# SUBAGENT EXECUTION
# =============================================================================
def run_task(description: str, prompt: str, agent_type: str,
client, model: str, workdir, base_tools: list, execute_tool) -> str:
"""
Execute a subagent task with isolated context.
Key concepts:
1. ISOLATED HISTORY - subagent starts fresh, no parent context
2. FILTERED TOOLS - based on agent type permissions
3. AGENT-SPECIFIC PROMPT - specialized behavior
4. RETURNS SUMMARY ONLY - parent sees just the final result
Args:
description: Short name for progress display
prompt: Detailed instructions for subagent
agent_type: Key from AGENT_TYPES
client: Anthropic client
model: Model to use
workdir: Working directory
base_tools: List of tool definitions
execute_tool: Function to execute tools
Returns:
Final text output from subagent
"""
if agent_type not in AGENT_TYPES:
return f"Error: Unknown agent type '{agent_type}'"
config = AGENT_TYPES[agent_type]
# Agent-specific system prompt
sub_system = f"""You are a {agent_type} subagent at {workdir}.
{config["prompt"]}
Complete the task and return a clear, concise summary."""
# Filtered tools for this agent type
sub_tools = get_tools_for_agent(agent_type, base_tools)
# KEY: ISOLATED message history!
# The subagent starts fresh, doesn't see parent's conversation
sub_messages = [{"role": "user", "content": prompt}]
# Progress display
print(f" [{agent_type}] {description}")
start = time.time()
tool_count = 0
# Run the same agent loop (but silently)
while True:
response = client.messages.create(
model=model,
system=sub_system,
messages=sub_messages,
tools=sub_tools,
max_tokens=8000,
)
# Check if done
if response.stop_reason != "tool_use":
break
# Execute tools
tool_calls = [b for b in response.content if b.type == "tool_use"]
results = []
for tc in tool_calls:
tool_count += 1
output = execute_tool(tc.name, tc.input)
results.append({
"type": "tool_result",
"tool_use_id": tc.id,
"content": output
})
# Update progress (in-place on same line)
elapsed = time.time() - start
sys.stdout.write(
f"\r [{agent_type}] {description} ... {tool_count} tools, {elapsed:.1f}s"
)
sys.stdout.flush()
sub_messages.append({"role": "assistant", "content": response.content})
sub_messages.append({"role": "user", "content": results})
# Final progress update
elapsed = time.time() - start
sys.stdout.write(
f"\r [{agent_type}] {description} - done ({tool_count} tools, {elapsed:.1f}s)\n"
)
# Extract and return ONLY the final text
# This is what the parent agent sees - a clean summary
for block in response.content:
if hasattr(block, "text"):
return block.text
return "(subagent returned no text)"
# =============================================================================
# USAGE EXAMPLE
# =============================================================================
"""
# In your main agent's execute_tool function:
def execute_tool(name: str, args: dict) -> str:
if name == "Task":
return run_task(
description=args["description"],
prompt=args["prompt"],
agent_type=args["agent_type"],
client=client,
model=MODEL,
workdir=WORKDIR,
base_tools=BASE_TOOLS,
execute_tool=execute_tool # Pass self for recursion
)
# ... other tools ...
# In your TOOLS list:
TOOLS = BASE_TOOLS + [TASK_TOOL]
"""
@@ -1,271 +0,0 @@
"""
Tool Templates - Copy and customize these for your agent.
Each tool needs:
1. Definition (JSON schema for the model)
2. Implementation (Python function)
"""
from pathlib import Path
import subprocess
WORKDIR = Path.cwd()
# =============================================================================
# TOOL DEFINITIONS (for TOOLS list)
# =============================================================================
BASH_TOOL = {
"name": "bash",
"description": "Run a shell command. Use for: ls, find, grep, git, npm, python, etc.",
"input_schema": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
}
},
"required": ["command"],
},
}
READ_FILE_TOOL = {
"name": "read_file",
"description": "Read file contents. Returns UTF-8 text.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path to the file"
},
"limit": {
"type": "integer",
"description": "Max lines to read (default: all)"
},
},
"required": ["path"],
},
}
WRITE_FILE_TOOL = {
"name": "write_file",
"description": "Write content to a file. Creates parent directories if needed.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path for the file"
},
"content": {
"type": "string",
"description": "Content to write"
},
},
"required": ["path", "content"],
},
}
EDIT_FILE_TOOL = {
"name": "edit_file",
"description": "Replace exact text in a file. Use for surgical edits.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path to the file"
},
"old_text": {
"type": "string",
"description": "Exact text to find (must match precisely)"
},
"new_text": {
"type": "string",
"description": "Replacement text"
},
},
"required": ["path", "old_text", "new_text"],
},
}
TODO_WRITE_TOOL = {
"name": "TodoWrite",
"description": "Update the task list. Use to plan and track progress.",
"input_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "Complete list of tasks",
"items": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Task description"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
"activeForm": {"type": "string", "description": "Present tense, e.g. 'Reading files'"},
},
"required": ["content", "status", "activeForm"],
},
}
},
"required": ["items"],
},
}
TASK_TOOL_TEMPLATE = """
# Generate dynamically with agent types
TASK_TOOL = {
"name": "Task",
"description": f"Spawn a subagent for a focused subtask.\\n\\nAgent types:\\n{get_agent_descriptions()}",
"input_schema": {
"type": "object",
"properties": {
"description": {"type": "string", "description": "Short task name (3-5 words)"},
"prompt": {"type": "string", "description": "Detailed instructions"},
"agent_type": {"type": "string", "enum": list(AGENT_TYPES.keys())},
},
"required": ["description", "prompt", "agent_type"],
},
}
"""
# =============================================================================
# TOOL IMPLEMENTATIONS
# =============================================================================
def safe_path(p: str) -> Path:
"""
Security: Ensure path stays within workspace.
Prevents ../../../etc/passwd attacks.
"""
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
"""
Execute shell command with safety checks.
Safety features:
- Blocks obviously dangerous commands
- 60 second timeout
- Output truncated to 50KB
"""
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
result = subprocess.run(
command,
shell=True,
cwd=WORKDIR,
capture_output=True,
text=True,
timeout=60
)
output = (result.stdout + result.stderr).strip()
return output[:50000] if output else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Command timed out (60s)"
except Exception as e:
return f"Error: {e}"
def run_read_file(path: str, limit: int = None) -> str:
"""
Read file contents with optional line limit.
Features:
- Safe path resolution
- Optional line limit for large files
- Output truncated to 50KB
"""
try:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
lines.append(f"... ({len(text.splitlines()) - limit} more lines)")
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write_file(path: str, content: str) -> str:
"""
Write content to file, creating parent directories if needed.
Features:
- Safe path resolution
- Auto-creates parent directories
- Returns byte count for confirmation
"""
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit_file(path: str, old_text: str, new_text: str) -> str:
"""
Replace exact text in a file (surgical edit).
Features:
- Exact string matching (not regex)
- Only replaces first occurrence (safety)
- Clear error if text not found
"""
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
new_content = content.replace(old_text, new_text, 1)
fp.write_text(new_content)
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# =============================================================================
# DISPATCHER PATTERN
# =============================================================================
def execute_tool(name: str, args: dict) -> str:
"""
Dispatch tool call to implementation.
This pattern makes it easy to add new tools:
1. Add definition to TOOLS list
2. Add implementation function
3. Add case to this dispatcher
"""
if name == "bash":
return run_bash(args["command"])
if name == "read_file":
return run_read_file(args["path"], args.get("limit"))
if name == "write_file":
return run_write_file(args["path"], args["content"])
if name == "edit_file":
return run_edit_file(args["path"], args["old_text"], args["new_text"])
# Add more tools here...
return f"Unknown tool: {name}"
-279
View File
@@ -1,279 +0,0 @@
#!/usr/bin/env python3
"""
Agent Scaffold Script - Create a new agent project with best practices.
Usage:
python init_agent.py <agent-name> [--level 0-4] [--path <output-dir>]
Examples:
python init_agent.py my-agent # Level 1 (4 tools)
python init_agent.py my-agent --level 0 # Minimal (bash only)
python init_agent.py my-agent --level 2 # With TodoWrite
python init_agent.py my-agent --path ./bots # Custom output directory
"""
import argparse
import sys
from pathlib import Path
# Agent templates for each level
TEMPLATES = {
0: '''#!/usr/bin/env python3
"""
Level 0 Agent - Bash is All You Need (~50 lines)
Core insight: One tool (bash) can do everything.
Subagents via self-recursion: python {name}.py "subtask"
"""
from anthropic import Anthropic
from dotenv import load_dotenv
import subprocess
import os
load_dotenv()
client = Anthropic(
api_key=os.getenv("ANTHROPIC_API_KEY"),
base_url=os.getenv("ANTHROPIC_BASE_URL")
)
MODEL = os.getenv("MODEL_NAME", "claude-sonnet-4-20250514")
SYSTEM = """You are a coding agent. Use bash for everything:
- Read: cat, grep, find, ls
- Write: echo 'content' > file
- Subagent: python {name}.py "subtask"
"""
TOOL = [{{
"name": "bash",
"description": "Execute shell command",
"input_schema": {{"type": "object", "properties": {{"command": {{"type": "string"}}}}, "required": ["command"]}}
}}]
def run(prompt, history=[]):
history.append({{"role": "user", "content": prompt}})
while True:
r = client.messages.create(model=MODEL, system=SYSTEM, messages=history, tools=TOOL, max_tokens=8000)
history.append({{"role": "assistant", "content": r.content}})
if r.stop_reason != "tool_use":
return "".join(b.text for b in r.content if hasattr(b, "text"))
results = []
for b in r.content:
if b.type == "tool_use":
print(f"> {{b.input['command']}}")
try:
out = subprocess.run(b.input["command"], shell=True, capture_output=True, text=True, timeout=60)
output = (out.stdout + out.stderr).strip() or "(empty)"
except Exception as e:
output = f"Error: {{e}}"
results.append({{"type": "tool_result", "tool_use_id": b.id, "content": output[:50000]}})
history.append({{"role": "user", "content": results}})
if __name__ == "__main__":
h = []
print("{name} - Level 0 Agent\\nType 'q' to quit.\\n")
while (q := input(">> ").strip()) not in ("q", "quit", ""):
print(run(q, h), "\\n")
''',
1: '''#!/usr/bin/env python3
"""
Level 1 Agent - Model as Agent (~200 lines)
Core insight: 4 tools cover 90% of coding tasks.
The model IS the agent. Code just runs the loop.
"""
from anthropic import Anthropic
from dotenv import load_dotenv
from pathlib import Path
import subprocess
import os
load_dotenv()
client = Anthropic(
api_key=os.getenv("ANTHROPIC_API_KEY"),
base_url=os.getenv("ANTHROPIC_BASE_URL")
)
MODEL = os.getenv("MODEL_NAME", "claude-sonnet-4-20250514")
WORKDIR = Path.cwd()
SYSTEM = f"""You are a coding agent at {{WORKDIR}}.
Rules:
- Prefer tools over prose. Act, don't just explain.
- Never invent file paths. Use ls/find first if unsure.
- Make minimal changes. Don't over-engineer.
- After finishing, summarize what changed."""
TOOLS = [
{{"name": "bash", "description": "Run shell command",
"input_schema": {{"type": "object", "properties": {{"command": {{"type": "string"}}}}, "required": ["command"]}}}},
{{"name": "read_file", "description": "Read file contents",
"input_schema": {{"type": "object", "properties": {{"path": {{"type": "string"}}}}, "required": ["path"]}}}},
{{"name": "write_file", "description": "Write content to file",
"input_schema": {{"type": "object", "properties": {{"path": {{"type": "string"}}, "content": {{"type": "string"}}}}, "required": ["path", "content"]}}}},
{{"name": "edit_file", "description": "Replace exact text in file",
"input_schema": {{"type": "object", "properties": {{"path": {{"type": "string"}}, "old_text": {{"type": "string"}}, "new_text": {{"type": "string"}}}}, "required": ["path", "old_text", "new_text"]}}}},
]
def safe_path(p: str) -> Path:
"""Prevent path escape attacks."""
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {{p}}")
return path
def execute(name: str, args: dict) -> str:
"""Execute a tool and return result."""
if name == "bash":
dangerous = ["rm -rf /", "sudo", "shutdown", "> /dev/"]
if any(d in args["command"] for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(args["command"], shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=60)
return (r.stdout + r.stderr).strip()[:50000] or "(empty)"
except subprocess.TimeoutExpired:
return "Error: Timeout (60s)"
except Exception as e:
return f"Error: {{e}}"
if name == "read_file":
try:
return safe_path(args["path"]).read_text()[:50000]
except Exception as e:
return f"Error: {{e}}"
if name == "write_file":
try:
p = safe_path(args["path"])
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(args["content"])
return f"Wrote {{len(args['content'])}} bytes to {{args['path']}}"
except Exception as e:
return f"Error: {{e}}"
if name == "edit_file":
try:
p = safe_path(args["path"])
content = p.read_text()
if args["old_text"] not in content:
return f"Error: Text not found in {{args['path']}}"
p.write_text(content.replace(args["old_text"], args["new_text"], 1))
return f"Edited {{args['path']}}"
except Exception as e:
return f"Error: {{e}}"
return f"Unknown tool: {{name}}"
def agent(prompt: str, history: list = None) -> str:
"""Run the agent loop."""
if history is None:
history = []
history.append({{"role": "user", "content": prompt}})
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=history, tools=TOOLS, max_tokens=8000
)
history.append({{"role": "assistant", "content": response.content}})
if response.stop_reason != "tool_use":
return "".join(b.text for b in response.content if hasattr(b, "text"))
results = []
for block in response.content:
if block.type == "tool_use":
print(f"> {{block.name}}: {{str(block.input)[:100]}}")
output = execute(block.name, block.input)
print(f" {{output[:100]}}...")
results.append({{"type": "tool_result", "tool_use_id": block.id, "content": output}})
history.append({{"role": "user", "content": results}})
if __name__ == "__main__":
print(f"{name} - Level 1 Agent at {{WORKDIR}}")
print("Type 'q' to quit.\\n")
h = []
while True:
try:
query = input(">> ").strip()
except (EOFError, KeyboardInterrupt):
break
if query in ("q", "quit", "exit", ""):
break
print(agent(query, h), "\\n")
''',
}
ENV_TEMPLATE = '''# API Configuration
ANTHROPIC_API_KEY=sk-xxx
ANTHROPIC_BASE_URL=https://api.anthropic.com
MODEL_NAME=claude-sonnet-4-20250514
'''
def create_agent(name: str, level: int, output_dir: Path):
"""Create a new agent project."""
# Validate level
if level not in TEMPLATES and level not in (2, 3, 4):
print(f"Error: Level {level} not yet implemented in scaffold.")
print("Available levels: 0 (minimal), 1 (4 tools)")
print("For levels 2-4, copy from mini-claude-code repository.")
sys.exit(1)
# Create output directory
agent_dir = output_dir / name
agent_dir.mkdir(parents=True, exist_ok=True)
# Write agent file
agent_file = agent_dir / f"{name}.py"
template = TEMPLATES.get(level, TEMPLATES[1])
agent_file.write_text(template.format(name=name))
print(f"Created: {agent_file}")
# Write .env.example
env_file = agent_dir / ".env.example"
env_file.write_text(ENV_TEMPLATE)
print(f"Created: {env_file}")
# Write .gitignore
gitignore = agent_dir / ".gitignore"
gitignore.write_text(".env\n__pycache__/\n*.pyc\n")
print(f"Created: {gitignore}")
print(f"\nAgent '{name}' created at {agent_dir}")
print(f"\nNext steps:")
print(f" 1. cd {agent_dir}")
print(f" 2. cp .env.example .env")
print(f" 3. Edit .env with your API key")
print(f" 4. pip install anthropic python-dotenv")
print(f" 5. python {name}.py")
def main():
parser = argparse.ArgumentParser(
description="Scaffold a new AI coding agent project",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Levels:
0 Minimal (~50 lines) - Single bash tool, self-recursion for subagents
1 Basic (~200 lines) - 4 core tools: bash, read, write, edit
2 Todo (~300 lines) - + TodoWrite for structured planning
3 Subagent (~450) - + Task tool for context isolation
4 Skills (~550) - + Skill tool for domain expertise
"""
)
parser.add_argument("name", help="Name of the agent to create")
parser.add_argument("--level", type=int, default=1, choices=[0, 1, 2, 3, 4],
help="Complexity level (default: 1)")
parser.add_argument("--path", type=Path, default=Path.cwd(),
help="Output directory (default: current directory)")
args = parser.parse_args()
create_agent(args.name, args.level, args.path)
if __name__ == "__main__":
main()
-162
View File
@@ -1,162 +0,0 @@
---
name: code-review
description: Perform thorough code reviews with security, performance, and maintainability analysis. Use when user asks to review code, check for bugs, or audit a codebase.
---
# Code Review Skill
You now have expertise in conducting comprehensive code reviews. Follow this structured approach:
## Review Checklist
### 1. Security (Critical)
Check for:
- [ ] **Injection vulnerabilities**: SQL, command, XSS, template injection
- [ ] **Authentication issues**: Hardcoded credentials, weak auth
- [ ] **Authorization flaws**: Missing access controls, IDOR
- [ ] **Data exposure**: Sensitive data in logs, error messages
- [ ] **Cryptography**: Weak algorithms, improper key management
- [ ] **Dependencies**: Known vulnerabilities (check with `mvn dependency-check:check`, `npm audit`)
```bash
# Quick security scans
mvn dependency-check:check # Java (OWASP Dependency-Check)
mvn versions:display-dependency-updates # Check outdated deps
npm audit # Node.js
grep -r "password\|secret\|api_key" --include="*.java" --include="*.properties" --include="*.yml"
```
### 2. Correctness
Check for:
- [ ] **Logic errors**: Off-by-one, null handling, edge cases
- [ ] **Race conditions**: Concurrent access without synchronization
- [ ] **Resource leaks**: Unclosed files, connections, memory
- [ ] **Error handling**: Swallowed exceptions, missing error paths
- [ ] **Type safety**: Implicit conversions, any types
### 3. Performance
Check for:
- [ ] **N+1 queries**: Database calls in loops
- [ ] **Memory issues**: Large allocations, retained references
- [ ] **Blocking operations**: Sync I/O in async code
- [ ] **Inefficient algorithms**: O(n^2) when O(n) possible
- [ ] **Missing caching**: Repeated expensive computations
### 4. Maintainability
Check for:
- [ ] **Naming**: Clear, consistent, descriptive
- [ ] **Complexity**: Functions > 50 lines, deep nesting > 3 levels
- [ ] **Duplication**: Copy-pasted code blocks
- [ ] **Dead code**: Unused imports, unreachable branches
- [ ] **Comments**: Outdated, redundant, or missing where needed
### 5. Testing
Check for:
- [ ] **Coverage**: Critical paths tested
- [ ] **Edge cases**: Null, empty, boundary values
- [ ] **Mocking**: External dependencies isolated
- [ ] **Assertions**: Meaningful, specific checks
## Review Output Format
```markdown
## Code Review: [file/component name]
### Summary
[1-2 sentence overview]
### Critical Issues
1. **[Issue]** (line X): [Description]
- Impact: [What could go wrong]
- Fix: [Suggested solution]
### Improvements
1. **[Suggestion]** (line X): [Description]
### Positive Notes
- [What was done well]
### Verdict
[ ] Ready to merge
[ ] Needs minor changes
[ ] Needs major revision
```
## Common Patterns to Flag
### Java
```java
// Bad: SQL injection
Statement stmt = conn.createStatement();
stmt.executeQuery("SELECT * FROM users WHERE id = " + userId);
// Good: 使用 PreparedStatement 参数化查询
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setLong(1, userId);
ResultSet rs = ps.executeQuery();
// Bad: Command injection
Runtime.getRuntime().exec("ls " + userInput);
// Good: 使用 ProcessBuilder 分离命令和参数
new ProcessBuilder("ls", userInput).start();
// Bad: 未关闭资源
InputStream is = new FileInputStream("data.txt");
// Good: 使用 try-with-resources 自动关闭
try (InputStream is = new FileInputStream("data.txt")) {
// ...
}
```
### JavaScript/TypeScript
```javascript
// Bad: Prototype pollution
Object.assign(target, userInput)
// Good:
Object.assign(target, sanitize(userInput))
// Bad: eval usage
eval(userCode)
// Good: Never use eval with user input
// Bad: Callback hell
getData(x => process(x, y => save(y, z => done(z))))
// Good:
const data = await getData();
const processed = await process(data);
await save(processed);
```
## Review Commands
```bash
# Show recent changes
git diff HEAD~5 --stat
git log --oneline -10
# Find potential issues
grep -rn "TODO\|FIXME\|HACK\|XXX" .
grep -rn "password\|secret\|token" . --include="*.java" --include="*.properties"
# Check complexity (Java)
mvn pmd:pmd # 静态代码分析
mvn spotbugs:check # Bug 检测
# Check dependencies
mvn versions:display-dependency-updates # 检查过期依赖
mvn dependency-check:check # OWASP 安全扫描
```
## Review Workflow
1. **Understand context**: Read PR description, linked issues
2. **Run the code**: Build, test, run locally if possible
3. **Read top-down**: Start with main entry points
4. **Check tests**: Are changes tested? Do tests pass?
5. **Security scan**: Run automated tools
6. **Manual review**: Use checklist above
7. **Write feedback**: Be specific, suggest fixes, be kind
-257
View File
@@ -1,257 +0,0 @@
---
name: mcp-builder
description: Build MCP (Model Context Protocol) servers that give Claude new capabilities. Use when user wants to create an MCP server, add tools to Claude, or integrate external services.
---
# MCP Server Building Skill
You now have expertise in building MCP (Model Context Protocol) servers. MCP enables Claude to interact with external services through a standardized protocol.
## What is MCP?
MCP servers expose:
- **Tools**: Functions Claude can call (like API endpoints)
- **Resources**: Data Claude can read (like files or database records)
- **Prompts**: Pre-built prompt templates
## Quick Start: Java/Spring AI MCP Server
### 1. Project Setup
```bash
# 使用 Spring Initializr 创建项目
# 或通过 Maven 手动创建
mkdir my-mcp-server && cd my-mcp-server
mvn archetype:generate -DgroupId=com.example -DartifactId=my-mcp-server \
-DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
```
`pom.xml` 中添加依赖:
```xml
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
```
### 2. Basic Server Template
```java
// src/main/java/com/example/McpServerApplication.java
package com.example;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class McpServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
@Bean
public MyTools myTools() {
return new MyTools();
}
}
// 定义工具类
class MyTools {
@Tool(description = "Say hello to someone")
public String hello(@ToolParam(description = "The name to greet") String name) {
return "Hello, " + name + "!";
}
@Tool(description = "Add two numbers together")
public String addNumbers(
@ToolParam(description = "First number") int a,
@ToolParam(description = "Second number") int b) {
return String.valueOf(a + b);
}
}
```
配置 `application.properties`:
```properties
spring.ai.mcp.server.name=my-server
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.type=SYNC
spring.main.web-application-type=none
spring.main.banner-mode=off
spring.ai.mcp.server.stdio=true
```
### 3. Register with Claude
Add to `~/.claude/mcp.json`:
```json
{
"mcpServers": {
"my-server": {
"command": "java",
"args": ["-jar", "/path/to/my-mcp-server/target/my-mcp-server.jar"]
}
}
}
```
## TypeScript MCP Server
### 1. Setup
```bash
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
```
### 2. Template
```typescript
// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server({
name: "my-server",
version: "1.0.0",
});
// Define tools
server.setRequestHandler("tools/list", async () => ({
tools: [
{
name: "hello",
description: "Say hello to someone",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Name to greet" },
},
required: ["name"],
},
},
],
}));
server.setRequestHandler("tools/call", async (request) => {
if (request.params.name === "hello") {
const name = request.params.arguments.name;
return { content: [{ type: "text", text: `Hello, ${name}!` }] };
}
throw new Error("Unknown tool");
});
// Start server
const transport = new StdioServerTransport();
server.connect(transport);
```
## Advanced Patterns
### External API Integration
```java
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class WeatherTools {
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Tool(description = "Get current weather for a city")
public String getWeather(@ToolParam(description = "City name") String city) {
String url = "https://api.weatherapi.com/v1/current.json?key=YOUR_API_KEY&q=" + city;
String response = restTemplate.getForObject(url, String.class);
JsonNode data = objectMapper.readTree(response);
JsonNode current = data.get("current");
return String.format("%s: %s°C, %s",
city, current.get("temp_c"), current.get("condition").get("text").asText());
}
}
```
### Database Access
```java
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.jdbc.core.JdbcTemplate;
public class DatabaseTools {
private final JdbcTemplate jdbcTemplate;
public DatabaseTools(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Tool(description = "Execute a read-only SQL query")
public String queryDb(@ToolParam(description = "SQL query to execute") String sql) {
if (!sql.trim().toUpperCase().startsWith("SELECT")) {
return "Error: Only SELECT queries allowed";
}
var rows = jdbcTemplate.queryForList(sql);
return rows.toString();
}
}
```
### Resources (Read-only Data)
```java
import org.springframework.ai.tool.annotation.Tool;
import java.nio.file.Files;
import java.nio.file.Path;
public class ResourceTools {
@Tool(description = "Read application settings")
public String getSettings() throws Exception {
return Files.readString(Path.of("settings.json"));
}
@Tool(description = "Read a file from the workspace")
public String readFile(@ToolParam(description = "Path to the file") String path) throws Exception {
return Files.readString(Path.of(path));
}
}
```
## Testing
```bash
# Build the project
mvn clean package -DskipTests
# Test with MCP Inspector
npx @anthropics/mcp-inspector java -jar target/my-mcp-server.jar
# Or send test messages directly
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | java -jar target/my-mcp-server.jar
```
## Best Practices
1. **Clear tool descriptions**: Claude uses `@Tool(description=...)` to decide when to call tools
2. **Input validation**: Always validate and sanitize inputs in tool methods
3. **Error handling**: Return meaningful error messages, use proper exception handling
4. **Use Spring DI**: Leverage Spring's dependency injection for service wiring
5. **Security**: Never expose sensitive operations without auth
6. **Idempotency**: Tools should be safe to retry
-138
View File
@@ -1,138 +0,0 @@
---
name: pdf
description: Process PDF files - extract text, create PDFs, merge documents. Use when user asks to read PDF, create PDF, or work with PDF files.
---
# PDF Processing Skill
You now have expertise in PDF manipulation. Follow these workflows:
## Reading PDFs
**Option 1: Quick text extraction (preferred)**
```bash
# Using pdftotext (poppler-utils)
pdftotext input.pdf - # Output to stdout
pdftotext input.pdf output.txt # Output to file
```
**Option 2: Page-by-page with metadata (Apache PDFBox)**
```java
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import java.io.File;
PDDocument doc = PDDocument.load(new File("input.pdf"));
System.out.println("Pages: " + doc.getNumberOfPages());
System.out.println("Metadata: " + doc.getDocumentInformation().getTitle());
PDFTextStripper stripper = new PDFTextStripper();
for (int i = 1; i <= doc.getNumberOfPages(); i++) {
stripper.setStartPage(i);
stripper.setEndPage(i);
String text = stripper.getText(doc);
System.out.println("--- Page " + i + " ---");
System.out.println(text);
}
doc.close();
```
## Creating PDFs
**Option 1: From Markdown (recommended)**
```bash
# Using pandoc
pandoc input.md -o output.pdf
# With custom styling
pandoc input.md -o output.pdf --pdf-engine=xelatex -V geometry:margin=1in
```
**Option 2: Programmatically (Apache PDFBox)**
```java
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
PDDocument doc = new PDDocument();
PDPage page = new PDPage();
doc.addPage(page);
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
content.beginText();
content.setFont(PDType1Font.HELVETICA, 12);
content.newLineAtOffset(100, 750);
content.showText("Hello, PDF!");
content.endText();
}
doc.save("output.pdf");
doc.close();
```
**Option 3: From HTML (OpenHTMLToPDF)**
```java
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
String html = Files.readString(Path.of("input.html"));
try (OutputStream os = new FileOutputStream("output.pdf")) {
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.withHtmlContent(html, new File("input.html").toURI().toString());
builder.toStream(os);
builder.run();
}
```
## Merging PDFs
```java
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import java.io.File;
PDFMergerUtility merger = new PDFMergerUtility();
merger.addSource(new File("file1.pdf"));
merger.addSource(new File("file2.pdf"));
merger.addSource(new File("file3.pdf"));
merger.setDestinationFileName("merged.pdf");
merger.mergeDocuments(null);
```
## Splitting PDFs
```java
import org.apache.pdfbox.multipdf.Splitter;
import org.apache.pdfbox.pdmodel.PDDocument;
import java.io.File;
import java.util.List;
PDDocument doc = PDDocument.load(new File("input.pdf"));
Splitter splitter = new Splitter();
splitter.setSplitAtPage(1); // 每页拆分为一个文件
List<PDDocument> pages = splitter.split(doc);
for (int i = 0; i < pages.size(); i++) {
pages.get(i).save("page_" + (i + 1) + ".pdf");
pages.get(i).close();
}
doc.close();
```
## Key Libraries
| Task | Library | Maven Dependency |
|------|---------|-----------------|
| Read/Write/Merge/Split | Apache PDFBox | `org.apache.pdfbox:pdfbox:3.0.x` |
| Create from HTML | OpenHTMLToPDF | `com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.x` |
| Advanced layout | iText | `com.itextpdf:itext7-core:8.0.x` |
| Text extraction | pdftotext | `brew install poppler` / `apt install poppler-utils` |
## Best Practices
1. **Always check if tools are installed** before using them
2. **Handle encoding issues** - PDFs may contain various character encodings
3. **Large PDFs**: Process page by page to avoid memory issues; use try-with-resources 确保资源释放
4. **OCR for scanned PDFs**: Use Tesseract4J (`net.sourceforge.tess4j:tess4j`) if text extraction returns empty
+1295
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
import { randomBytes } from 'crypto'
import type { AppState } from './state/AppState.js'
import type { AgentId } from './types/ids.js'
import { getTaskOutputPath } from './utils/task/diskOutput.js'
export type TaskType =
| 'local_bash'
| 'local_agent'
| 'remote_agent'
| 'in_process_teammate'
| 'local_workflow'
| 'monitor_mcp'
| 'dream'
export type TaskStatus =
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'killed'
/**
* True when a task is in a terminal state and will not transition further.
* Used to guard against injecting messages into dead teammates, evicting
* finished tasks from AppState, and orphan-cleanup paths.
*/
export function isTerminalTaskStatus(status: TaskStatus): boolean {
return status === 'completed' || status === 'failed' || status === 'killed'
}
export type TaskHandle = {
taskId: string
cleanup?: () => void
}
export type SetAppState = (f: (prev: AppState) => AppState) => void
export type TaskContext = {
abortController: AbortController
getAppState: () => AppState
setAppState: SetAppState
}
// Base fields shared by all task states
export type TaskStateBase = {
id: string
type: TaskType
status: TaskStatus
description: string
toolUseId?: string
startTime: number
endTime?: number
totalPausedMs?: number
outputFile: string
outputOffset: number
notified: boolean
}
export type LocalShellSpawnInput = {
command: string
description: string
timeout?: number
toolUseId?: string
agentId?: AgentId
/** UI display variant: description-as-label, dialog title, status bar pill. */
kind?: 'bash' | 'monitor'
}
// What getTaskByType dispatches for: kill. spawn/render were never
// called polymorphically (removed in #22546). All six kill implementations
// use only setAppState — getAppState/abortController were dead weight.
export type Task = {
name: string
type: TaskType
kill(taskId: string, setAppState: SetAppState): Promise<void>
}
// Task ID prefixes
const TASK_ID_PREFIXES: Record<string, string> = {
local_bash: 'b', // Keep as 'b' for backward compatibility
local_agent: 'a',
remote_agent: 'r',
in_process_teammate: 't',
local_workflow: 'w',
monitor_mcp: 'm',
dream: 'd',
}
// Get task ID prefix
function getTaskIdPrefix(type: TaskType): string {
return TASK_ID_PREFIXES[type] ?? 'x'
}
// Case-insensitive-safe alphabet (digits + lowercase) for task IDs.
// 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
export function generateTaskId(type: TaskType): string {
const prefix = getTaskIdPrefix(type)
const bytes = randomBytes(8)
let id = prefix
for (let i = 0; i < 8; i++) {
id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
}
return id
}
export function createTaskStateBase(
id: string,
type: TaskType,
description: string,
toolUseId?: string,
): TaskStateBase {
return {
id,
type,
status: 'pending',
description,
toolUseId,
startTime: Date.now(),
outputFile: getTaskOutputPath(id),
outputOffset: 0,
notified: false,
}
}
+792
View File
@@ -0,0 +1,792 @@
import type {
ToolResultBlockParam,
ToolUseBlockParam,
} from '@anthropic-ai/sdk/resources/index.mjs'
import type {
ElicitRequestURLParams,
ElicitResult,
} from '@modelcontextprotocol/sdk/types.js'
import type { UUID } from 'crypto'
import type { z } from 'zod/v4'
import type { Command } from './commands.js'
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
import type { ThinkingConfig } from './utils/thinking.js'
export type ToolInputJSONSchema = {
[x: string]: unknown
type: 'object'
properties?: {
[x: string]: unknown
}
}
import type { Notification } from './context/notifications.js'
import type {
MCPServerConnection,
ServerResource,
} from './services/mcp/types.js'
import type {
AgentDefinition,
AgentDefinitionsResult,
} from './tools/AgentTool/loadAgentsDir.js'
import type {
AssistantMessage,
AttachmentMessage,
Message,
ProgressMessage,
SystemLocalCommandMessage,
SystemMessage,
UserMessage,
} from './types/message.js'
// Import permission types from centralized location to break import cycles
// Import PermissionResult from centralized location to break import cycles
import type {
AdditionalWorkingDirectory,
PermissionMode,
PermissionResult,
} from './types/permissions.js'
// Import tool progress types from centralized location to break import cycles
import type {
AgentToolProgress,
BashProgress,
MCPProgress,
REPLToolProgress,
SkillToolProgress,
TaskOutputProgress,
ToolProgressData,
WebSearchProgress,
} from './types/tools.js'
import type { FileStateCache } from './utils/fileStateCache.js'
import type { DenialTrackingState } from './utils/permissions/denialTracking.js'
import type { SystemPrompt } from './utils/systemPromptType.js'
import type { ContentReplacementState } from './utils/toolResultStorage.js'
// Re-export progress types for backwards compatibility
export type {
AgentToolProgress,
BashProgress,
MCPProgress,
REPLToolProgress,
SkillToolProgress,
TaskOutputProgress,
WebSearchProgress,
}
import type { SpinnerMode } from './components/Spinner.js'
import type { QuerySource } from './constants/querySource.js'
import type { SDKStatus } from './entrypoints/agentSdkTypes.js'
import type { AppState } from './state/AppState.js'
import type {
HookProgress,
PromptRequest,
PromptResponse,
} from './types/hooks.js'
import type { AgentId } from './types/ids.js'
import type { DeepImmutable } from './types/utils.js'
import type { AttributionState } from './utils/commitAttribution.js'
import type { FileHistoryState } from './utils/fileHistory.js'
import type { Theme, ThemeName } from './utils/theme.js'
export type QueryChainTracking = {
chainId: string
depth: number
}
export type ValidationResult =
| { result: true }
| {
result: false
message: string
errorCode: number
}
export type SetToolJSXFn = (
args: {
jsx: React.ReactNode | null
shouldHidePromptInput: boolean
shouldContinueAnimation?: true
showSpinner?: boolean
isLocalJSXCommand?: boolean
isImmediate?: boolean
/** Set to true to clear a local JSX command (e.g., from its onDone callback) */
clearLocalJSX?: boolean
} | null,
) => void
// Import tool permission types from centralized location to break import cycles
import type { ToolPermissionRulesBySource } from './types/permissions.js'
// Re-export for backwards compatibility
export type { ToolPermissionRulesBySource }
// Apply DeepImmutable to the imported type
export type ToolPermissionContext = DeepImmutable<{
mode: PermissionMode
additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
alwaysAllowRules: ToolPermissionRulesBySource
alwaysDenyRules: ToolPermissionRulesBySource
alwaysAskRules: ToolPermissionRulesBySource
isBypassPermissionsModeAvailable: boolean
isAutoModeAvailable?: boolean
strippedDangerousRules?: ToolPermissionRulesBySource
/** When true, permission prompts are auto-denied (e.g., background agents that can't show UI) */
shouldAvoidPermissionPrompts?: boolean
/** When true, automated checks (classifier, hooks) are awaited before showing the permission dialog (coordinator workers) */
awaitAutomatedChecksBeforeDialog?: boolean
/** Stores the permission mode before model-initiated plan mode entry, so it can be restored on exit */
prePlanMode?: PermissionMode
}>
export const getEmptyToolPermissionContext: () => ToolPermissionContext =
() => ({
mode: 'default',
additionalWorkingDirectories: new Map(),
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: false,
})
export type CompactProgressEvent =
| {
type: 'hooks_start'
hookType: 'pre_compact' | 'post_compact' | 'session_start'
}
| { type: 'compact_start' }
| { type: 'compact_end' }
export type ToolUseContext = {
options: {
commands: Command[]
debug: boolean
mainLoopModel: string
tools: Tools
verbose: boolean
thinkingConfig: ThinkingConfig
mcpClients: MCPServerConnection[]
mcpResources: Record<string, ServerResource[]>
isNonInteractiveSession: boolean
agentDefinitions: AgentDefinitionsResult
maxBudgetUsd?: number
/** Custom system prompt that replaces the default system prompt */
customSystemPrompt?: string
/** Additional system prompt appended after the main system prompt */
appendSystemPrompt?: string
/** Override querySource for analytics tracking */
querySource?: QuerySource
/** Optional callback to get the latest tools (e.g., after MCP servers connect mid-query) */
refreshTools?: () => Tools
}
abortController: AbortController
readFileState: FileStateCache
getAppState(): AppState
setAppState(f: (prev: AppState) => AppState): void
/**
* Always-shared setAppState for session-scoped infrastructure (background
* tasks, session hooks). Unlike setAppState, which is no-op for async agents
* (see createSubagentContext), this always reaches the root store so agents
* at any nesting depth can register/clean up infrastructure that outlives
* a single turn. Only set by createSubagentContext; main-thread contexts
* fall back to setAppState.
*/
setAppStateForTasks?: (f: (prev: AppState) => AppState) => void
/**
* Optional handler for URL elicitations triggered by tool call errors (-32042).
* In print/SDK mode, this delegates to structuredIO.handleElicitation.
* In REPL mode, this is undefined and the queue-based UI path is used.
*/
handleElicitation?: (
serverName: string,
params: ElicitRequestURLParams,
signal: AbortSignal,
) => Promise<ElicitResult>
setToolJSX?: SetToolJSXFn
addNotification?: (notif: Notification) => void
/** Append a UI-only system message to the REPL message list. Stripped at the
* normalizeMessagesForAPI boundary the Exclude<> makes that type-enforced. */
appendSystemMessage?: (
msg: Exclude<SystemMessage, SystemLocalCommandMessage>,
) => void
/** Send an OS-level notification (iTerm2, Kitty, Ghostty, bell, etc.) */
sendOSNotification?: (opts: {
message: string
notificationType: string
}) => void
nestedMemoryAttachmentTriggers?: Set<string>
/**
* CLAUDE.md paths already injected as nested_memory attachments this
* session. Dedup for memoryFilesToAttachments readFileState is an LRU
* that evicts entries in busy sessions, so its .has() check alone can
* re-inject the same CLAUDE.md dozens of times.
*/
loadedNestedMemoryPaths?: Set<string>
dynamicSkillDirTriggers?: Set<string>
/** Skill names surfaced via skill_discovery this session. Telemetry only (feeds was_discovered). */
discoveredSkillNames?: Set<string>
userModified?: boolean
setInProgressToolUseIDs: (f: (prev: Set<string>) => Set<string>) => void
/** Only wired in interactive (REPL) contexts; SDK/QueryEngine don't set this. */
setHasInterruptibleToolInProgress?: (v: boolean) => void
setResponseLength: (f: (prev: number) => number) => void
/** Ant-only: push a new API metrics entry for OTPS tracking.
* Called by subagent streaming when a new API request starts. */
pushApiMetricsEntry?: (ttftMs: number) => void
setStreamMode?: (mode: SpinnerMode) => void
onCompactProgress?: (event: CompactProgressEvent) => void
setSDKStatus?: (status: SDKStatus) => void
openMessageSelector?: () => void
updateFileHistoryState: (
updater: (prev: FileHistoryState) => FileHistoryState,
) => void
updateAttributionState: (
updater: (prev: AttributionState) => AttributionState,
) => void
setConversationId?: (id: UUID) => void
agentId?: AgentId // Only set for subagents; use getSessionId() for session ID. Hooks use this to distinguish subagent calls.
agentType?: string // Subagent type name. For the main thread's --agent type, hooks fall back to getMainThreadAgentType().
/** When true, canUseTool must always be called even when hooks auto-approve.
* Used by speculation for overlay file path rewriting. */
requireCanUseTool?: boolean
messages: Message[]
fileReadingLimits?: {
maxTokens?: number
maxSizeBytes?: number
}
globLimits?: {
maxResults?: number
}
toolDecisions?: Map<
string,
{
source: string
decision: 'accept' | 'reject'
timestamp: number
}
>
queryTracking?: QueryChainTracking
/** Callback factory for requesting interactive prompts from the user.
* Returns a prompt callback bound to the given source name.
* Only available in interactive (REPL) contexts. */
requestPrompt?: (
sourceName: string,
toolInputSummary?: string | null,
) => (request: PromptRequest) => Promise<PromptResponse>
toolUseId?: string
criticalSystemReminder_EXPERIMENTAL?: string
/** When true, preserve toolUseResult on messages even for subagents.
* Used by in-process teammates whose transcripts are viewable by the user. */
preserveToolUseResults?: boolean
/** Local denial tracking state for async subagents whose setAppState is a
* no-op. Without this, the denial counter never accumulates and the
* fallback-to-prompting threshold is never reached. Mutable the
* permissions code updates it in place. */
localDenialTracking?: DenialTrackingState
/**
* Per-conversation-thread content replacement state for the tool result
* budget. When present, query.ts applies the aggregate tool result budget.
* Main thread: REPL provisions once (never resets stale UUID keys
* are inert). Subagents: createSubagentContext clones the parent's state
* by default (cache-sharing forks need identical decisions), or
* resumeAgentBackground threads one reconstructed from sidechain records.
*/
contentReplacementState?: ContentReplacementState
/**
* Parent's rendered system prompt bytes, frozen at turn start.
* Used by fork subagents to share the parent's prompt cache re-calling
* getSystemPrompt() at fork-spawn time can diverge (GrowthBook coldwarm)
* and bust the cache. See forkSubagent.ts.
*/
renderedSystemPrompt?: SystemPrompt
}
// Re-export ToolProgressData from centralized location
export type { ToolProgressData }
export type Progress = ToolProgressData | HookProgress
export type ToolProgress<P extends ToolProgressData> = {
toolUseID: string
data: P
}
export function filterToolProgressMessages(
progressMessagesForMessage: ProgressMessage[],
): ProgressMessage<ToolProgressData>[] {
return progressMessagesForMessage.filter(
(msg): msg is ProgressMessage<ToolProgressData> =>
msg.data?.type !== 'hook_progress',
)
}
export type ToolResult<T> = {
data: T
newMessages?: (
| UserMessage
| AssistantMessage
| AttachmentMessage
| SystemMessage
)[]
// contextModifier is only honored for tools that aren't concurrency safe.
contextModifier?: (context: ToolUseContext) => ToolUseContext
/** MCP protocol metadata (structuredContent, _meta) to pass through to SDK consumers */
mcpMeta?: {
_meta?: Record<string, unknown>
structuredContent?: Record<string, unknown>
}
}
export type ToolCallProgress<P extends ToolProgressData = ToolProgressData> = (
progress: ToolProgress<P>,
) => void
// Type for any schema that outputs an object with string keys
export type AnyObject = z.ZodType<{ [key: string]: unknown }>
/**
* Checks if a tool matches the given name (primary name or alias).
*/
export function toolMatchesName(
tool: { name: string; aliases?: string[] },
name: string,
): boolean {
return tool.name === name || (tool.aliases?.includes(name) ?? false)
}
/**
* Finds a tool by name or alias from a list of tools.
*/
export function findToolByName(tools: Tools, name: string): Tool | undefined {
return tools.find(t => toolMatchesName(t, name))
}
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = {
/**
* Optional aliases for backwards compatibility when a tool is renamed.
* The tool can be looked up by any of these names in addition to its primary name.
*/
aliases?: string[]
/**
* One-line capability phrase used by ToolSearch for keyword matching.
* Helps the model find this tool via keyword search when it's deferred.
* 310 words, no trailing period.
* Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).
*/
searchHint?: string
call(
args: z.infer<Input>,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<P>,
): Promise<ToolResult<Output>>
description(
input: z.infer<Input>,
options: {
isNonInteractiveSession: boolean
toolPermissionContext: ToolPermissionContext
tools: Tools
},
): Promise<string>
readonly inputSchema: Input
// Type for MCP tools that can specify their input schema directly in JSON Schema format
// rather than converting from Zod schema
readonly inputJSONSchema?: ToolInputJSONSchema
// Optional because TungstenTool doesn't define this. TODO: Make it required.
// When we do that, we can also go through and make this a bit more type-safe.
outputSchema?: z.ZodType<unknown>
inputsEquivalent?(a: z.infer<Input>, b: z.infer<Input>): boolean
isConcurrencySafe(input: z.infer<Input>): boolean
isEnabled(): boolean
isReadOnly(input: z.infer<Input>): boolean
/** Defaults to false. Only set when the tool performs irreversible operations (delete, overwrite, send). */
isDestructive?(input: z.infer<Input>): boolean
/**
* What should happen when the user submits a new message while this tool
* is running.
*
* - `'cancel'` stop the tool and discard its result
* - `'block'` keep running; the new message waits
*
* Defaults to `'block'` when not implemented.
*/
interruptBehavior?(): 'cancel' | 'block'
/**
* Returns information about whether this tool use is a search or read operation
* that should be collapsed into a condensed display in the UI. Examples include
* file searching (Grep, Glob), file reading (Read), and bash commands like find,
* grep, wc, etc.
*
* Returns an object indicating whether the operation is a search or read operation:
* - `isSearch: true` for search operations (grep, find, glob patterns)
* - `isRead: true` for read operations (cat, head, tail, file read)
* - `isList: true` for directory-listing operations (ls, tree, du)
* - All can be false if the operation shouldn't be collapsed
*/
isSearchOrReadCommand?(input: z.infer<Input>): {
isSearch: boolean
isRead: boolean
isList?: boolean
}
isOpenWorld?(input: z.infer<Input>): boolean
requiresUserInteraction?(): boolean
isMcp?: boolean
isLsp?: boolean
/**
* When true, this tool is deferred (sent with defer_loading: true) and requires
* ToolSearch to be used before it can be called.
*/
readonly shouldDefer?: boolean
/**
* When true, this tool is never deferred its full schema appears in the
* initial prompt even when ToolSearch is enabled. For MCP tools, set via
* `_meta['anthropic/alwaysLoad']`. Use for tools the model must see on
* turn 1 without a ToolSearch round-trip.
*/
readonly alwaysLoad?: boolean
/**
* For MCP tools: the server and tool names as received from the MCP server (unnormalized).
* Present on all MCP tools regardless of whether `name` is prefixed (mcp__server__tool)
* or unprefixed (CLAUDE_AGENT_SDK_MCP_NO_PREFIX mode).
*/
mcpInfo?: { serverName: string; toolName: string }
readonly name: string
/**
* Maximum size in characters for tool result before it gets persisted to disk.
* When exceeded, the result is saved to a file and Claude receives a preview
* with the file path instead of the full content.
*
* Set to Infinity for tools whose output must never be persisted (e.g. Read,
* where persisting creates a circular ReadfileRead loop and the tool
* already self-bounds via its own limits).
*/
maxResultSizeChars: number
/**
* When true, enables strict mode for this tool, which causes the API to
* more strictly adhere to tool instructions and parameter schemas.
* Only applied when the tengu_tool_pear is enabled.
*/
readonly strict?: boolean
/**
* Called on copies of tool_use input before observers see it (SDK stream,
* transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place
* to add legacy/derived fields. Must be idempotent. The original API-bound
* input is never mutated (preserves prompt cache). Not re-applied when a
* hook/permission returns a fresh updatedInput those own their shape.
*/
backfillObservableInput?(input: Record<string, unknown>): void
/**
* Determines if this tool is allowed to run with this input in the current context.
* It informs the model of why the tool use failed, and does not directly display any UI.
* @param input
* @param context
*/
validateInput?(
input: z.infer<Input>,
context: ToolUseContext,
): Promise<ValidationResult>
/**
* Determines if the user is asked for permission. Only called after validateInput() passes.
* General permission logic is in permissions.ts. This method contains tool-specific logic.
* @param input
* @param context
*/
checkPermissions(
input: z.infer<Input>,
context: ToolUseContext,
): Promise<PermissionResult>
// Optional method for tools that operate on a file path
getPath?(input: z.infer<Input>): string
/**
* Prepare a matcher for hook `if` conditions (permission-rule patterns like
* "git *" from "Bash(git *)"). Called once per hook-input pair; any
* expensive parsing happens here. Returns a closure that is called per
* hook pattern. If not implemented, only tool-name-level matching works.
*/
preparePermissionMatcher?(
input: z.infer<Input>,
): Promise<(pattern: string) => boolean>
prompt(options: {
getToolPermissionContext: () => Promise<ToolPermissionContext>
tools: Tools
agents: AgentDefinition[]
allowedAgentTypes?: string[]
}): Promise<string>
userFacingName(input: Partial<z.infer<Input>> | undefined): string
userFacingNameBackgroundColor?(
input: Partial<z.infer<Input>> | undefined,
): keyof Theme | undefined
/**
* Transparent wrappers (e.g. REPL) delegate all rendering to their progress
* handler, which emits native-looking blocks for each inner tool call.
* The wrapper itself shows nothing.
*/
isTransparentWrapper?(): boolean
/**
* Returns a short string summary of this tool use for display in compact views.
* @param input The tool input
* @returns A short string summary, or null to not display
*/
getToolUseSummary?(input: Partial<z.infer<Input>> | undefined): string | null
/**
* Returns a human-readable present-tense activity description for spinner display.
* Example: "Reading src/foo.ts", "Running bun test", "Searching for pattern"
* @param input The tool input
* @returns Activity description string, or null to fall back to tool name
*/
getActivityDescription?(
input: Partial<z.infer<Input>> | undefined,
): string | null
/**
* Returns a compact representation of this tool use for the auto-mode
* security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content`
* for Edit. Return '' to skip this tool in the classifier transcript
* (e.g. tools with no security relevance). May return an object to avoid
* double-encoding when the caller JSON-wraps the value.
*/
toAutoClassifierInput(input: z.infer<Input>): unknown
mapToolResultToToolResultBlockParam(
content: Output,
toolUseID: string,
): ToolResultBlockParam
/**
* Optional. When omitted, the tool result renders nothing (same as returning
* null). Omit for tools whose results are surfaced elsewhere (e.g., TodoWrite
* updates the todo panel, not the transcript).
*/
renderToolResultMessage?(
content: Output,
progressMessagesForMessage: ProgressMessage<P>[],
options: {
style?: 'condensed'
theme: ThemeName
tools: Tools
verbose: boolean
isTranscriptMode?: boolean
isBriefOnly?: boolean
/** Original tool_use input, when available. Useful for compact result
* summaries that reference what was requested (e.g. "Sent to #foo"). */
input?: unknown
},
): React.ReactNode
/**
* Flattened text of what renderToolResultMessage shows IN TRANSCRIPT
* MODE (verbose=true, isTranscriptMode=true). For transcript search
* indexing: the index counts occurrences in this string, the highlight
* overlay scans the actual screen buffer. For count highlight, this
* must return the text that ends up visible not the model-facing
* serialization from mapToolResultToToolResultBlockParam (which adds
* system-reminders, persisted-output wrappers).
*
* Chrome can be skipped (under-count is fine). "Found 3 files in 12ms"
* isn't worth indexing. Phantoms are not fine — text that's claimed
* here but doesn't render is a counthighlight bug.
*
* Optional: omitted field-name heuristic in transcriptSearch.ts.
* Drift caught by test/utils/transcriptSearch.renderFidelity.test.tsx
* which renders sample outputs and flags text that's indexed-but-not-
* rendered (phantom) or rendered-but-not-indexed (under-count warning).
*/
extractSearchText?(out: Output): string
/**
* Render the tool use message. Note that `input` is partial because we render
* the message as soon as possible, possibly before tool parameters have fully
* streamed in.
*/
renderToolUseMessage(
input: Partial<z.infer<Input>>,
options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
): React.ReactNode
/**
* Returns true when the non-verbose rendering of this output is truncated
* (i.e., clicking to expand would reveal more content). Gates
* click-to-expand in fullscreen only messages where verbose actually
* shows more get a hover/click affordance. Unset means never truncated.
*/
isResultTruncated?(output: Output): boolean
/**
* Renders an optional tag to display after the tool use message.
* Used for additional metadata like timeout, model, resume ID, etc.
* Returns null to not display anything.
*/
renderToolUseTag?(input: Partial<z.infer<Input>>): React.ReactNode
/**
* Optional. When omitted, no progress UI is shown while the tool runs.
*/
renderToolUseProgressMessage?(
progressMessagesForMessage: ProgressMessage<P>[],
options: {
tools: Tools
verbose: boolean
terminalSize?: { columns: number; rows: number }
inProgressToolCallCount?: number
isTranscriptMode?: boolean
},
): React.ReactNode
renderToolUseQueuedMessage?(): React.ReactNode
/**
* Optional. When omitted, falls back to <FallbackToolUseRejectedMessage />.
* Only define this for tools that need custom rejection UI (e.g., file edits
* that show the rejected diff).
*/
renderToolUseRejectedMessage?(
input: z.infer<Input>,
options: {
columns: number
messages: Message[]
style?: 'condensed'
theme: ThemeName
tools: Tools
verbose: boolean
progressMessagesForMessage: ProgressMessage<P>[]
isTranscriptMode?: boolean
},
): React.ReactNode
/**
* Optional. When omitted, falls back to <FallbackToolUseErrorMessage />.
* Only define this for tools that need custom error UI (e.g., search tools
* that show "File not found" instead of the raw error).
*/
renderToolUseErrorMessage?(
result: ToolResultBlockParam['content'],
options: {
progressMessagesForMessage: ProgressMessage<P>[]
tools: Tools
verbose: boolean
isTranscriptMode?: boolean
},
): React.ReactNode
/**
* Renders multiple parallel instances of this tool as a group.
* @returns React node to render, or null to fall back to individual rendering
*/
/**
* Renders multiple tool uses as a group (non-verbose mode only).
* In verbose mode, individual tool uses render at their original positions.
* @returns React node to render, or null to fall back to individual rendering
*/
renderGroupedToolUse?(
toolUses: Array<{
param: ToolUseBlockParam
isResolved: boolean
isError: boolean
isInProgress: boolean
progressMessages: ProgressMessage<P>[]
result?: {
param: ToolResultBlockParam
output: unknown
}
}>,
options: {
shouldAnimate: boolean
tools: Tools
},
): React.ReactNode | null
}
/**
* A collection of tools. Use this type instead of `Tool[]` to make it easier
* to track where tool sets are assembled, passed, and filtered across the codebase.
*/
export type Tools = readonly Tool[]
/**
* Methods that `buildTool` supplies a default for. A `ToolDef` may omit these;
* the resulting `Tool` always has them.
*/
type DefaultableToolKeys =
| 'isEnabled'
| 'isConcurrencySafe'
| 'isReadOnly'
| 'isDestructive'
| 'checkPermissions'
| 'toAutoClassifierInput'
| 'userFacingName'
/**
* Tool definition accepted by `buildTool`. Same shape as `Tool` but with the
* defaultable methods optional `buildTool` fills them in so callers always
* see a complete `Tool`.
*/
export type ToolDef<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = Omit<Tool<Input, Output, P>, DefaultableToolKeys> &
Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>>
/**
* Type-level spread mirroring `{ ...TOOL_DEFAULTS, ...def }`. For each
* defaultable key: if D provides it (required), D's type wins; if D omits
* it or has it optional (inherited from Partial<> in the constraint), the
* default fills in. All other keys come from D verbatim preserving arity,
* optional presence, and literal types exactly as `satisfies Tool` did.
*/
type BuiltTool<D> = Omit<D, DefaultableToolKeys> & {
[K in DefaultableToolKeys]-?: K extends keyof D
? undefined extends D[K]
? ToolDefaults[K]
: D[K]
: ToolDefaults[K]
}
/**
* Build a complete `Tool` from a partial definition, filling in safe defaults
* for the commonly-stubbed methods. All tool exports should go through this so
* that defaults live in one place and callers never need `?.() ?? default`.
*
* Defaults (fail-closed where it matters):
* - `isEnabled` `true`
* - `isConcurrencySafe` `false` (assume not safe)
* - `isReadOnly` `false` (assume writes)
* - `isDestructive` `false`
* - `checkPermissions` `{ behavior: 'allow', updatedInput }` (defer to general permission system)
* - `toAutoClassifierInput` `''` (skip classifier security-relevant tools must override)
* - `userFacingName` `name`
*/
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: (_input?: unknown) => false,
isReadOnly: (_input?: unknown) => false,
isDestructive: (_input?: unknown) => false,
checkPermissions: (
input: { [key: string]: unknown },
_ctx?: ToolUseContext,
): Promise<PermissionResult> =>
Promise.resolve({ behavior: 'allow', updatedInput: input }),
toAutoClassifierInput: (_input?: unknown) => '',
userFacingName: (_input?: unknown) => '',
}
// The defaults type is the ACTUAL shape of TOOL_DEFAULTS (optional params so
// both 0-arg and full-arg call sites type-check — stubs varied in arity and
// tests relied on that), not the interface's strict signatures.
type ToolDefaults = typeof TOOL_DEFAULTS
// D infers the concrete object-literal type from the call site. The
// constraint provides contextual typing for method parameters; `any` in
// constraint position is structural and never leaks into the return type.
// BuiltTool<D> mirrors runtime `{...TOOL_DEFAULTS, ...def}` at the type level.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyToolDef = ToolDef<any, any, any>
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
// The runtime spread is straightforward; the `as` bridges the gap between
// the structural-any constraint and the precise BuiltTool<D> return. The
// type semantics are proven by the 0-error typecheck across all 60+ tools.
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name,
...def,
} as BuiltTool<D>
}
+87
View File
@@ -0,0 +1,87 @@
import axios from 'axios'
import { getOauthConfig } from '../constants/oauth.js'
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import { logForDebugging } from '../utils/debug.js'
import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.js'
export const HISTORY_PAGE_SIZE = 100
export type HistoryPage = {
/** Chronological order within the page. */
events: SDKMessage[]
/** Oldest event ID in this page → before_id cursor for next-older page. */
firstId: string | null
/** true = older events exist. */
hasMore: boolean
}
type SessionEventsResponse = {
data: SDKMessage[]
has_more: boolean
first_id: string | null
last_id: string | null
}
export type HistoryAuthCtx = {
baseUrl: string
headers: Record<string, string>
}
/** Prepare auth + headers + base URL once, reuse across pages. */
export async function createHistoryAuthCtx(
sessionId: string,
): Promise<HistoryAuthCtx> {
const { accessToken, orgUUID } = await prepareApiRequest()
return {
baseUrl: `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`,
headers: {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
},
}
}
async function fetchPage(
ctx: HistoryAuthCtx,
params: Record<string, string | number | boolean>,
label: string,
): Promise<HistoryPage | null> {
const resp = await axios
.get<SessionEventsResponse>(ctx.baseUrl, {
headers: ctx.headers,
params,
timeout: 15000,
validateStatus: () => true,
})
.catch(() => null)
if (!resp || resp.status !== 200) {
logForDebugging(`[${label}] HTTP ${resp?.status ?? 'error'}`)
return null
}
return {
events: Array.isArray(resp.data.data) ? resp.data.data : [],
firstId: resp.data.first_id,
hasMore: resp.data.has_more,
}
}
/**
* Newest page: last `limit` events, chronological, via anchor_to_latest.
* has_more=true means older events exist.
*/
export async function fetchLatestEvents(
ctx: HistoryAuthCtx,
limit = HISTORY_PAGE_SIZE,
): Promise<HistoryPage | null> {
return fetchPage(ctx, { limit, anchor_to_latest: true }, 'fetchLatestEvents')
}
/** Older page: events immediately before `beforeId` cursor. */
export async function fetchOlderEvents(
ctx: HistoryAuthCtx,
beforeId: string,
limit = HISTORY_PAGE_SIZE,
): Promise<HistoryPage | null> {
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
}
File diff suppressed because it is too large Load Diff
+539
View File
@@ -0,0 +1,539 @@
import axios from 'axios'
import { debugBody, extractErrorDetail } from './debugUtils.js'
import {
BRIDGE_LOGIN_INSTRUCTION,
type BridgeApiClient,
type BridgeConfig,
type PermissionResponseEvent,
type WorkResponse,
} from './types.js'
type BridgeApiDeps = {
baseUrl: string
getAccessToken: () => string | undefined
runnerVersion: string
onDebug?: (msg: string) => void
/**
* Called on 401 to attempt OAuth token refresh. Returns true if refreshed,
* in which case the request is retried once. Injected because
* handleOAuth401Error from utils/auth.ts transitively pulls in config.ts
* file.ts permissions/filesystem.ts sessionStorage.ts commands.ts
* (~1300 modules). Daemon callers using env-var tokens omit this their
* tokens don't refresh, so 401 goes straight to BridgeFatalError.
*/
onAuth401?: (staleAccessToken: string) => Promise<boolean>
/**
* Returns the trusted device token to send as X-Trusted-Device-Token on
* bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
* server (CCR v2); when the server's enforcement flag is on,
* ConnectBridgeWorker requires a trusted device at JWT-issuance.
* Optional when absent or returning undefined, the header is omitted
* and the server falls through to its flag-off/no-op path. The CLI-side
* gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
*/
getTrustedDeviceToken?: () => string | undefined
}
const BETA_HEADER = 'environments-2025-11-01'
/** Allowlist pattern for server-provided IDs used in URL path segments. */
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
/**
* Validate that a server-provided ID is safe to interpolate into a URL path.
* Prevents path traversal (e.g. `../../admin`) and injection via IDs that
* contain slashes, dots, or other special characters.
*/
export function validateBridgeId(id: string, label: string): string {
if (!id || !SAFE_ID_PATTERN.test(id)) {
throw new Error(`Invalid ${label}: contains unsafe characters`)
}
return id
}
/** Fatal bridge errors that should not be retried (e.g. auth failures). */
export class BridgeFatalError extends Error {
readonly status: number
/** Server-provided error type, e.g. "environment_expired". */
readonly errorType: string | undefined
constructor(message: string, status: number, errorType?: string) {
super(message)
this.name = 'BridgeFatalError'
this.status = status
this.errorType = errorType
}
}
export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
function debug(msg: string): void {
deps.onDebug?.(msg)
}
let consecutiveEmptyPolls = 0
const EMPTY_POLL_LOG_INTERVAL = 100
function getHeaders(accessToken: string): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-beta': BETA_HEADER,
'x-environment-runner-version': deps.runnerVersion,
}
const deviceToken = deps.getTrustedDeviceToken?.()
if (deviceToken) {
headers['X-Trusted-Device-Token'] = deviceToken
}
return headers
}
function resolveAuth(): string {
const accessToken = deps.getAccessToken()
if (!accessToken) {
throw new Error(BRIDGE_LOGIN_INSTRUCTION)
}
return accessToken
}
/**
* Execute an OAuth-authenticated request with a single retry on 401.
* On 401, attempts token refresh via handleOAuth401Error (same pattern as
* withRetry.ts for v1/messages). If refresh succeeds, retries the request
* once with the new token. If refresh fails or the retry also returns 401,
* the 401 response is returned for handleErrorStatus to throw BridgeFatalError.
*/
async function withOAuthRetry<T>(
fn: (accessToken: string) => Promise<{ status: number; data: T }>,
context: string,
): Promise<{ status: number; data: T }> {
const accessToken = resolveAuth()
const response = await fn(accessToken)
if (response.status !== 401) {
return response
}
if (!deps.onAuth401) {
debug(`[bridge:api] ${context}: 401 received, no refresh handler`)
return response
}
// Attempt token refresh — matches the pattern in withRetry.ts
debug(`[bridge:api] ${context}: 401 received, attempting token refresh`)
const refreshed = await deps.onAuth401(accessToken)
if (refreshed) {
debug(`[bridge:api] ${context}: Token refreshed, retrying request`)
const newToken = resolveAuth()
const retryResponse = await fn(newToken)
if (retryResponse.status !== 401) {
return retryResponse
}
debug(`[bridge:api] ${context}: Retry after refresh also got 401`)
} else {
debug(`[bridge:api] ${context}: Token refresh failed`)
}
// Refresh failed — return 401 for handleErrorStatus to throw
return response
}
return {
async registerBridgeEnvironment(
config: BridgeConfig,
): Promise<{ environment_id: string; environment_secret: string }> {
debug(
`[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`,
)
const response = await withOAuthRetry(
(token: string) =>
axios.post<{
environment_id: string
environment_secret: string
}>(
`${deps.baseUrl}/v1/environments/bridge`,
{
machine_name: config.machineName,
directory: config.dir,
branch: config.branch,
git_repo_url: config.gitRepoUrl,
// Advertise session capacity so claude.ai/code can show
// "2/4 sessions" badges and only block the picker when
// actually at capacity. Backends that don't yet accept
// this field will silently ignore it.
max_sessions: config.maxSessions,
// worker_type lets claude.ai filter environments by origin
// (e.g. assistant picker only shows assistant-mode workers).
// Desktop cowork app sends "cowork"; we send a distinct value.
metadata: { worker_type: config.workerType },
// Idempotent re-registration: if we have a backend-issued
// environment_id from a prior session (--session-id resume),
// send it back so the backend reattaches instead of creating
// a new env. The backend may still hand back a fresh ID if
// the old one expired — callers must compare the response.
...(config.reuseEnvironmentId && {
environment_id: config.reuseEnvironmentId,
}),
},
{
headers: getHeaders(token),
timeout: 15_000,
validateStatus: status => status < 500,
},
),
'Registration',
)
handleErrorStatus(response.status, response.data, 'Registration')
debug(
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
)
debug(
`[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
return response.data
},
async pollForWork(
environmentId: string,
environmentSecret: string,
signal?: AbortSignal,
reclaimOlderThanMs?: number,
): Promise<WorkResponse | null> {
validateBridgeId(environmentId, 'environmentId')
// Save and reset so errors break the "consecutive empty" streak.
// Restored below when the response is truly empty.
const prevEmptyPolls = consecutiveEmptyPolls
consecutiveEmptyPolls = 0
const response = await axios.get<WorkResponse | null>(
`${deps.baseUrl}/v1/environments/${environmentId}/work/poll`,
{
headers: getHeaders(environmentSecret),
params:
reclaimOlderThanMs !== undefined
? { reclaim_older_than_ms: reclaimOlderThanMs }
: undefined,
timeout: 10_000,
signal,
validateStatus: status => status < 500,
},
)
handleErrorStatus(response.status, response.data, 'Poll')
// Empty body or null = no work available
if (!response.data) {
consecutiveEmptyPolls = prevEmptyPolls + 1
if (
consecutiveEmptyPolls === 1 ||
consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0
) {
debug(
`[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`,
)
}
return null
}
debug(
`[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
return response.data
},
async acknowledgeWork(
environmentId: string,
workId: string,
sessionToken: string,
): Promise<void> {
validateBridgeId(environmentId, 'environmentId')
validateBridgeId(workId, 'workId')
debug(`[bridge:api] POST .../work/${workId}/ack`)
const response = await axios.post(
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`,
{},
{
headers: getHeaders(sessionToken),
timeout: 10_000,
validateStatus: s => s < 500,
},
)
handleErrorStatus(response.status, response.data, 'Acknowledge')
debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`)
},
async stopWork(
environmentId: string,
workId: string,
force: boolean,
): Promise<void> {
validateBridgeId(environmentId, 'environmentId')
validateBridgeId(workId, 'workId')
debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`)
const response = await withOAuthRetry(
(token: string) =>
axios.post(
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`,
{ force },
{
headers: getHeaders(token),
timeout: 10_000,
validateStatus: s => s < 500,
},
),
'StopWork',
)
handleErrorStatus(response.status, response.data, 'StopWork')
debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`)
},
async deregisterEnvironment(environmentId: string): Promise<void> {
validateBridgeId(environmentId, 'environmentId')
debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`)
const response = await withOAuthRetry(
(token: string) =>
axios.delete(
`${deps.baseUrl}/v1/environments/bridge/${environmentId}`,
{
headers: getHeaders(token),
timeout: 10_000,
validateStatus: s => s < 500,
},
),
'Deregister',
)
handleErrorStatus(response.status, response.data, 'Deregister')
debug(
`[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`,
)
},
async archiveSession(sessionId: string): Promise<void> {
validateBridgeId(sessionId, 'sessionId')
debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`)
const response = await withOAuthRetry(
(token: string) =>
axios.post(
`${deps.baseUrl}/v1/sessions/${sessionId}/archive`,
{},
{
headers: getHeaders(token),
timeout: 10_000,
validateStatus: s => s < 500,
},
),
'ArchiveSession',
)
// 409 = already archived (idempotent, not an error)
if (response.status === 409) {
debug(
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`,
)
return
}
handleErrorStatus(response.status, response.data, 'ArchiveSession')
debug(
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`,
)
},
async reconnectSession(
environmentId: string,
sessionId: string,
): Promise<void> {
validateBridgeId(environmentId, 'environmentId')
validateBridgeId(sessionId, 'sessionId')
debug(
`[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`,
)
const response = await withOAuthRetry(
(token: string) =>
axios.post(
`${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`,
{ session_id: sessionId },
{
headers: getHeaders(token),
timeout: 10_000,
validateStatus: s => s < 500,
},
),
'ReconnectSession',
)
handleErrorStatus(response.status, response.data, 'ReconnectSession')
debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`)
},
async heartbeatWork(
environmentId: string,
workId: string,
sessionToken: string,
): Promise<{ lease_extended: boolean; state: string }> {
validateBridgeId(environmentId, 'environmentId')
validateBridgeId(workId, 'workId')
debug(`[bridge:api] POST .../work/${workId}/heartbeat`)
const response = await axios.post<{
lease_extended: boolean
state: string
last_heartbeat: string
ttl_seconds: number
}>(
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`,
{},
{
headers: getHeaders(sessionToken),
timeout: 10_000,
validateStatus: s => s < 500,
},
)
handleErrorStatus(response.status, response.data, 'Heartbeat')
debug(
`[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`,
)
return response.data
},
async sendPermissionResponseEvent(
sessionId: string,
event: PermissionResponseEvent,
sessionToken: string,
): Promise<void> {
validateBridgeId(sessionId, 'sessionId')
debug(
`[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`,
)
const response = await axios.post(
`${deps.baseUrl}/v1/sessions/${sessionId}/events`,
{ events: [event] },
{
headers: getHeaders(sessionToken),
timeout: 10_000,
validateStatus: s => s < 500,
},
)
handleErrorStatus(
response.status,
response.data,
'SendPermissionResponseEvent',
)
debug(
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
)
debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
},
}
}
function handleErrorStatus(
status: number,
data: unknown,
context: string,
): void {
if (status === 200 || status === 204) {
return
}
const detail = extractErrorDetail(data)
const errorType = extractErrorTypeFromData(data)
switch (status) {
case 401:
throw new BridgeFatalError(
`${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`,
401,
errorType,
)
case 403:
throw new BridgeFatalError(
isExpiredErrorType(errorType)
? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.'
: `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`,
403,
errorType,
)
case 404:
throw new BridgeFatalError(
detail ??
`${context}: Not found (404). Remote Control may not be available for this organization.`,
404,
errorType,
)
case 410:
throw new BridgeFatalError(
detail ??
'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.',
410,
errorType ?? 'environment_expired',
)
case 429:
throw new Error(`${context}: Rate limited (429). Polling too frequently.`)
default:
throw new Error(
`${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`,
)
}
}
/** Check whether an error type string indicates a session/environment expiry. */
export function isExpiredErrorType(errorType: string | undefined): boolean {
if (!errorType) {
return false
}
return errorType.includes('expired') || errorType.includes('lifetime')
}
/**
* Check whether a BridgeFatalError is a suppressible 403 permission error.
* These are 403 errors for scopes like 'external_poll_sessions' or operations
* like StopWork that fail because the user's role lacks 'environments:manage'.
* They don't affect core functionality and shouldn't be shown to users.
*/
export function isSuppressible403(err: BridgeFatalError): boolean {
if (err.status !== 403) {
return false
}
return (
err.message.includes('external_poll_sessions') ||
err.message.includes('environments:manage')
)
}
function extractErrorTypeFromData(data: unknown): string | undefined {
if (data && typeof data === 'object') {
if (
'error' in data &&
data.error &&
typeof data.error === 'object' &&
'type' in data.error &&
typeof data.error.type === 'string'
) {
return data.error.type
}
}
return undefined
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Shared bridge auth/URL resolution. Consolidates the ant-only
* CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across
* a dozen files inboundAttachments, BriefTool/upload, bridgeMain,
* initReplBridge, remoteBridgeCore, daemon workers, /rename,
* /remote-control.
*
* Two layers: *Override() returns the ant-only env var (or undefined);
* the non-Override versions fall through to the real OAuth store/config.
* Callers that compose with a different auth source (e.g. daemon workers
* using IPC auth) use the Override getters directly.
*/
import { getOauthConfig } from '../constants/oauth.js'
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
export function getBridgeTokenOverride(): string | undefined {
return (
(process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
undefined
)
}
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
export function getBridgeBaseUrlOverride(): string | undefined {
return (
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
undefined
)
}
/**
* Access token for bridge API calls: dev override first, then the OAuth
* keychain. Undefined means "not logged in".
*/
export function getBridgeAccessToken(): string | undefined {
return getBridgeTokenOverride() ?? getClaudeAIOAuthTokens()?.accessToken
}
/**
* Base URL for bridge API calls: dev override first, then the production
* OAuth config. Always returns a URL.
*/
export function getBridgeBaseUrl(): string {
return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL
}
+135
View File
@@ -0,0 +1,135 @@
import { logForDebugging } from '../utils/debug.js'
import { BridgeFatalError } from './bridgeApi.js'
import type { BridgeApiClient } from './types.js'
/**
* Ant-only fault injection for manually testing bridge recovery paths.
*
* Real failure modes this targets (BQ 2026-03-12, 7-day window):
* poll 404 not_found_error 147K sessions/week, dead onEnvironmentLost gate
* ws_closed 1002/1006 22K sessions/week, zombie poll after close
* register transient failure residual: network blips during doReconnect
*
* Usage: /bridge-kick <subcommand> from the REPL while Remote Control is
* connected, then tail debug.log to watch the recovery machinery react.
*
* Module-level state is intentional here: one bridge per REPL process, the
* /bridge-kick slash command has no other way to reach into initBridgeCore's
* closures, and teardown clears the slot.
*/
/** One-shot fault to inject on the next matching api call. */
type BridgeFault = {
method:
| 'pollForWork'
| 'registerBridgeEnvironment'
| 'reconnectSession'
| 'heartbeatWork'
/** Fatal errors go through handleErrorStatus BridgeFatalError. Transient
* errors surface as plain axios rejections (5xx / network). Recovery code
* distinguishes the two: fatal teardown, transient retry/backoff. */
kind: 'fatal' | 'transient'
status: number
errorType?: string
/** Remaining injections. Decremented on consume; removed at 0. */
count: number
}
export type BridgeDebugHandle = {
/** Invoke the transport's permanent-close handler directly. Tests the
* ws_closed reconnectEnvironmentWithSession escalation (#22148). */
fireClose: (code: number) => void
/** Call reconnectEnvironmentWithSession() same as SIGUSR2 but
* reachable from the slash command. */
forceReconnect: () => void
/** Queue a fault for the next N calls to the named api method. */
injectFault: (fault: BridgeFault) => void
/** Abort the at-capacity sleep so an injected poll fault lands
* immediately instead of up to 10min later. */
wakePollLoop: () => void
/** env/session IDs for the debug.log grep. */
describe: () => string
}
let debugHandle: BridgeDebugHandle | null = null
const faultQueue: BridgeFault[] = []
export function registerBridgeDebugHandle(h: BridgeDebugHandle): void {
debugHandle = h
}
export function clearBridgeDebugHandle(): void {
debugHandle = null
faultQueue.length = 0
}
export function getBridgeDebugHandle(): BridgeDebugHandle | null {
return debugHandle
}
export function injectBridgeFault(fault: BridgeFault): void {
faultQueue.push(fault)
logForDebugging(
`[bridge:debug] Queued fault: ${fault.method} ${fault.kind}/${fault.status}${fault.errorType ? `/${fault.errorType}` : ''} ×${fault.count}`,
)
}
/**
* Wrap a BridgeApiClient so each call first checks the fault queue. If a
* matching fault is queued, throw the specified error instead of calling
* through. Delegates everything else to the real client.
*
* Only called when USER_TYPE === 'ant' zero overhead in external builds.
*/
export function wrapApiForFaultInjection(
api: BridgeApiClient,
): BridgeApiClient {
function consume(method: BridgeFault['method']): BridgeFault | null {
const idx = faultQueue.findIndex(f => f.method === method)
if (idx === -1) return null
const fault = faultQueue[idx]!
fault.count--
if (fault.count <= 0) faultQueue.splice(idx, 1)
return fault
}
function throwFault(fault: BridgeFault, context: string): never {
logForDebugging(
`[bridge:debug] Injecting ${fault.kind} fault into ${context}: status=${fault.status} errorType=${fault.errorType ?? 'none'}`,
)
if (fault.kind === 'fatal') {
throw new BridgeFatalError(
`[injected] ${context} ${fault.status}`,
fault.status,
fault.errorType,
)
}
// Transient: mimic an axios rejection (5xx / network). No .status on
// the error itself — that's how the catch blocks distinguish.
throw new Error(`[injected transient] ${context} ${fault.status}`)
}
return {
...api,
async pollForWork(envId, secret, signal, reclaimMs) {
const f = consume('pollForWork')
if (f) throwFault(f, 'Poll')
return api.pollForWork(envId, secret, signal, reclaimMs)
},
async registerBridgeEnvironment(config) {
const f = consume('registerBridgeEnvironment')
if (f) throwFault(f, 'Registration')
return api.registerBridgeEnvironment(config)
},
async reconnectSession(envId, sessionId) {
const f = consume('reconnectSession')
if (f) throwFault(f, 'ReconnectSession')
return api.reconnectSession(envId, sessionId)
},
async heartbeatWork(envId, workId, token) {
const f = consume('heartbeatWork')
if (f) throwFault(f, 'Heartbeat')
return api.heartbeatWork(envId, workId, token)
},
}
}
+202
View File
@@ -0,0 +1,202 @@
import { feature } from 'bun:bundle'
import {
checkGate_CACHED_OR_BLOCKING,
getDynamicConfig_CACHED_MAY_BE_STALE,
getFeatureValue_CACHED_MAY_BE_STALE,
} from '../services/analytics/growthbook.js'
// Namespace import breaks the bridgeEnabled → auth → config → bridgeEnabled
// cycle — authModule.foo is a live binding, so by the time the helpers below
// call it, auth.js is fully loaded. Previously used require() for the same
// deferral, but require() hits a CJS cache that diverges from the ESM
// namespace after mock.module() (daemon/auth.test.ts), breaking spyOn.
import * as authModule from '../utils/auth.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { lt } from '../utils/semver.js'
/**
* Runtime check for bridge mode entitlement.
*
* Remote Control requires a claude.ai subscription (the bridge auths to CCR
* with the claude.ai OAuth token). isClaudeAISubscriber() excludes
* Bedrock/Vertex/Foundry, apiKeyHelper/gateway deployments, env-var API keys,
* and Console API logins none of which have the OAuth token CCR needs.
* See github.com/deshaw/anthropic-issues/issues/24.
*
* The `feature('BRIDGE_MODE')` guard ensures the GrowthBook string literal
* is only referenced when bridge mode is enabled at build time.
*/
export function isBridgeEnabled(): boolean {
// Positive ternary pattern — see docs/feature-gating.md.
// Negative pattern (if (!feature(...)) return) does not eliminate
// inline string literals from external builds.
return feature('BRIDGE_MODE')
? isClaudeAISubscriber() &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_bridge', false)
: false
}
/**
* Blocking entitlement check for Remote Control.
*
* Returns cached `true` immediately (fast path). If the disk cache says
* `false` or is missing, awaits GrowthBook init and fetches the fresh
* server value (slow path, max ~5s), then writes it to disk.
*
* Use at entitlement gates where a stale `false` would unfairly block access.
* For user-facing error paths, prefer `getBridgeDisabledReason()` which gives
* a specific diagnostic. For render-body UI visibility checks, use
* `isBridgeEnabled()` instead.
*/
export async function isBridgeEnabledBlocking(): Promise<boolean> {
return feature('BRIDGE_MODE')
? isClaudeAISubscriber() &&
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))
: false
}
/**
* Diagnostic message for why Remote Control is unavailable, or null if
* it's enabled. Call this instead of a bare `isBridgeEnabledBlocking()`
* check when you need to show the user an actionable error.
*
* The GrowthBook gate targets on organizationUUID, which comes from
* config.oauthAccount populated by /api/oauth/profile during login.
* That endpoint requires the user:profile scope. Tokens without it
* (setup-token, CLAUDE_CODE_OAUTH_TOKEN env var, or pre-scope-expansion
* logins) leave oauthAccount unpopulated, so the gate falls back to
* false and users see a dead-end "not enabled" message with no hint
* that re-login would fix it. See CC-1165 / gh-33105.
*/
export async function getBridgeDisabledReason(): Promise<string | null> {
if (feature('BRIDGE_MODE')) {
if (!isClaudeAISubscriber()) {
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
}
if (!hasProfileScope()) {
return 'Remote Control requires a full-scope login token. Long-lived tokens (from `claude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `claude auth login` to use Remote Control.'
}
if (!getOauthAccountInfo()?.organizationUuid) {
return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.'
}
if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) {
return 'Remote Control is not yet enabled for your account.'
}
return null
}
return 'Remote Control is not available in this build.'
}
// try/catch: main.tsx:5698 calls isBridgeEnabled() while defining the Commander
// program, before enableConfigs() runs. isClaudeAISubscriber() → getGlobalConfig()
// throws "Config accessed before allowed" there. Pre-config, no OAuth token can
// exist anyway — false is correct. Same swallow getFeatureValue_CACHED_MAY_BE_STALE
// already does at growthbook.ts:775-780.
function isClaudeAISubscriber(): boolean {
try {
return authModule.isClaudeAISubscriber()
} catch {
return false
}
}
function hasProfileScope(): boolean {
try {
return authModule.hasProfileScope()
} catch {
return false
}
}
function getOauthAccountInfo(): ReturnType<
typeof authModule.getOauthAccountInfo
> {
try {
return authModule.getOauthAccountInfo()
} catch {
return undefined
}
}
/**
* Runtime check for the env-less (v2) REPL bridge path.
* Returns true when the GrowthBook flag `tengu_bridge_repl_v2` is enabled.
*
* This gates which implementation initReplBridge uses NOT whether bridge
* is available at all (see isBridgeEnabled above). Daemon/print paths stay
* on the env-based implementation regardless of this gate.
*/
export function isEnvLessBridgeEnabled(): boolean {
return feature('BRIDGE_MODE')
? getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_repl_v2', false)
: false
}
/**
* Kill-switch for the `cse_*` `session_*` client-side retag shim.
*
* The shim exists because compat/convert.go:27 validates TagSession and the
* claude.ai frontend routes on `session_*`, while v2 worker endpoints hand out
* `cse_*`. Once the server tags by environment_kind and the frontend accepts
* `cse_*` directly, flip this to false to make toCompatSessionId a no-op.
* Defaults to true the shim stays active until explicitly disabled.
*/
export function isCseShimEnabled(): boolean {
return feature('BRIDGE_MODE')
? getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_bridge_repl_v2_cse_shim_enabled',
true,
)
: true
}
/**
* Returns an error message if the current CLI version is below the
* minimum required for the v1 (env-based) Remote Control path, or null if the
* version is fine. The v2 (env-less) path uses checkEnvLessBridgeMinVersion()
* in envLessBridgeConfig.ts instead the two implementations have independent
* version floors.
*
* Uses cached (non-blocking) GrowthBook config. If GrowthBook hasn't
* loaded yet, the default '0.0.0' means the check passes a safe fallback.
*/
export function checkBridgeMinVersion(): string | null {
// Positive pattern — see docs/feature-gating.md.
// Negative pattern (if (!feature(...)) return) does not eliminate
// inline string literals from external builds.
if (feature('BRIDGE_MODE')) {
const config = getDynamicConfig_CACHED_MAY_BE_STALE<{
minVersion: string
}>('tengu_bridge_min_version', { minVersion: '0.0.0' })
if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) {
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`claude update\` to update.`
}
}
return null
}
/**
* Default for remoteControlAtStartup when the user hasn't explicitly set it.
* When the CCR_AUTO_CONNECT build flag is present (ant-only) and the
* tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by
* default the user can still opt out by setting remoteControlAtStartup=false
* in config (explicit settings always win over this default).
*
* Defined here rather than in config.ts to avoid a direct
* config.ts growthbook.ts import cycle (growthbook.ts user.ts config.ts).
*/
export function getCcrAutoConnectDefault(): boolean {
return feature('CCR_AUTO_CONNECT')
? getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_harbor', false)
: false
}
/**
* Opt-in CCR mirror mode every local session spawns an outbound-only
* Remote Control session that receives forwarded events. Separate from
* getCcrAutoConnectDefault (bidirectional Remote Control). Env var wins for
* local opt-in; GrowthBook controls rollout.
*/
export function isCcrMirrorEnabled(): boolean {
return feature('CCR_MIRROR')
? isEnvTruthy(process.env.CLAUDE_CODE_CCR_MIRROR) ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_mirror', false)
: false
}
File diff suppressed because it is too large Load Diff
+461
View File
@@ -0,0 +1,461 @@
/**
* Shared transport-layer helpers for bridge message handling.
*
* Extracted from replBridge.ts so both the env-based core (initBridgeCore)
* and the env-less core (initEnvLessBridgeCore) can use the same ingress
* parsing, control-request handling, and echo-dedup machinery.
*
* Everything here is pure no closure over bridge-specific state. All
* collaborators (transport, sessionId, UUID sets, callbacks) are passed
* as params.
*/
import { randomUUID } from 'crypto'
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import type {
SDKControlRequest,
SDKControlResponse,
} from '../entrypoints/sdk/controlTypes.js'
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
import { logEvent } from '../services/analytics/index.js'
import { EMPTY_USAGE } from '../services/api/emptyUsage.js'
import type { Message } from '../types/message.js'
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
import { logForDebugging } from '../utils/debug.js'
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
import { errorMessage } from '../utils/errors.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { jsonParse } from '../utils/slowOperations.js'
import type { ReplBridgeTransport } from './replBridgeTransport.js'
// ─── Type guards ─────────────────────────────────────────────────────────────
/** Type predicate for parsed WebSocket messages. SDKMessage is a
* discriminated union on `type` validating the discriminant is
* sufficient for the predicate; callers narrow further via the union. */
export function isSDKMessage(value: unknown): value is SDKMessage {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
typeof value.type === 'string'
)
}
/** Type predicate for control_response messages from the server. */
export function isSDKControlResponse(
value: unknown,
): value is SDKControlResponse {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
value.type === 'control_response' &&
'response' in value
)
}
/** Type predicate for control_request messages from the server. */
export function isSDKControlRequest(
value: unknown,
): value is SDKControlRequest {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
value.type === 'control_request' &&
'request_id' in value &&
'request' in value
)
}
/**
* True for message types that should be forwarded to the bridge transport.
* The server only wants user/assistant turns and slash-command system events;
* everything else (tool_result, progress, etc.) is internal REPL chatter.
*/
export function isEligibleBridgeMessage(m: Message): boolean {
// Virtual messages (REPL inner calls) are display-only — bridge/SDK
// consumers see the REPL tool_use/result which summarizes the work.
if ((m.type === 'user' || m.type === 'assistant') && m.isVirtual) {
return false
}
return (
m.type === 'user' ||
m.type === 'assistant' ||
(m.type === 'system' && m.subtype === 'local_command')
)
}
/**
* Extract title-worthy text from a Message for onUserMessage. Returns
* undefined for messages that shouldn't title the session: non-user, meta
* (nudges), tool results, compact summaries, non-human origins (task
* notifications, channel messages), or pure display-tag content
* (<ide_opened_file>, <session-start-hook>, etc.).
*
* Synthetic interrupts ([Request interrupted by user]) are NOT filtered here
* isSyntheticMessage lives in messages.ts (heavy import, pulls command
* registry). The initialMessages path in initReplBridge checks it; the
* writeMessages path reaching an interrupt as the *first* message is
* implausible (an interrupt implies a prior prompt already flowed through).
*/
export function extractTitleText(m: Message): string | undefined {
if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
return undefined
if (m.origin && m.origin.kind !== 'human') return undefined
const content = m.message.content
let raw: string | undefined
if (typeof content === 'string') {
raw = content
} else {
for (const block of content) {
if (block.type === 'text') {
raw = block.text
break
}
}
}
if (!raw) return undefined
const clean = stripDisplayTagsAllowEmpty(raw)
return clean || undefined
}
// ─── Ingress routing ─────────────────────────────────────────────────────────
/**
* Parse an ingress WebSocket message and route it to the appropriate handler.
* Ignores messages whose UUID is in recentPostedUUIDs (echoes of what we sent)
* or in recentInboundUUIDs (re-deliveries we've already forwarded e.g.
* server replayed history after a transport swap lost the seq-num cursor).
*/
export function handleIngressMessage(
data: string,
recentPostedUUIDs: BoundedUUIDSet,
recentInboundUUIDs: BoundedUUIDSet,
onInboundMessage: ((msg: SDKMessage) => void | Promise<void>) | undefined,
onPermissionResponse?: ((response: SDKControlResponse) => void) | undefined,
onControlRequest?: ((request: SDKControlRequest) => void) | undefined,
): void {
try {
const parsed: unknown = normalizeControlMessageKeys(jsonParse(data))
// control_response is not an SDKMessage — check before the type guard
if (isSDKControlResponse(parsed)) {
logForDebugging('[bridge:repl] Ingress message type=control_response')
onPermissionResponse?.(parsed)
return
}
// control_request from the server (initialize, set_model, can_use_tool).
// Must respond promptly or the server kills the WS (~10-14s timeout).
if (isSDKControlRequest(parsed)) {
logForDebugging(
`[bridge:repl] Inbound control_request subtype=${parsed.request.subtype}`,
)
onControlRequest?.(parsed)
return
}
if (!isSDKMessage(parsed)) return
// Check for UUID to detect echoes of our own messages
const uuid =
'uuid' in parsed && typeof parsed.uuid === 'string'
? parsed.uuid
: undefined
if (uuid && recentPostedUUIDs.has(uuid)) {
logForDebugging(
`[bridge:repl] Ignoring echo: type=${parsed.type} uuid=${uuid}`,
)
return
}
// Defensive dedup: drop inbound prompts we've already forwarded. The
// SSE seq-num carryover (lastTransportSequenceNum) is the primary fix
// for history-replay; this catches edge cases where that negotiation
// fails (server ignores from_sequence_num, transport died before
// receiving any frames, etc).
if (uuid && recentInboundUUIDs.has(uuid)) {
logForDebugging(
`[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type} uuid=${uuid}`,
)
return
}
logForDebugging(
`[bridge:repl] Ingress message type=${parsed.type}${uuid ? ` uuid=${uuid}` : ''}`,
)
if (parsed.type === 'user') {
if (uuid) recentInboundUUIDs.add(uuid)
logEvent('tengu_bridge_message_received', {
is_repl: true,
})
// Fire-and-forget — handler may be async (attachment resolution).
void onInboundMessage?.(parsed)
} else {
logForDebugging(
`[bridge:repl] Ignoring non-user inbound message: type=${parsed.type}`,
)
}
} catch (err) {
logForDebugging(
`[bridge:repl] Failed to parse ingress message: ${errorMessage(err)}`,
)
}
}
// ─── Server-initiated control requests ───────────────────────────────────────
export type ServerControlRequestHandlers = {
transport: ReplBridgeTransport | null
sessionId: string
/**
* When true, all mutable requests (interrupt, set_model, set_permission_mode,
* set_max_thinking_tokens) reply with an error instead of false-success.
* initialize still replies success the server kills the connection otherwise.
* Used by the outbound-only bridge mode and the SDK's /bridge subpath so claude.ai sees a
* proper error instead of "action succeeded but nothing happened locally".
*/
outboundOnly?: boolean
onInterrupt?: () => void
onSetModel?: (model: string | undefined) => void
onSetMaxThinkingTokens?: (maxTokens: number | null) => void
onSetPermissionMode?: (
mode: PermissionMode,
) => { ok: true } | { ok: false; error: string }
}
const OUTBOUND_ONLY_ERROR =
'This session is outbound-only. Enable Remote Control locally to allow inbound control.'
/**
* Respond to inbound control_request messages from the server. The server
* sends these for session lifecycle events (initialize, set_model) and
* for turn-level coordination (interrupt, set_max_thinking_tokens). If we
* don't respond, the server hangs and kills the WS after ~10-14s.
*
* Previously a closure inside initBridgeCore's onWorkReceived; now takes
* collaborators as params so both cores can use it.
*/
export function handleServerControlRequest(
request: SDKControlRequest,
handlers: ServerControlRequestHandlers,
): void {
const {
transport,
sessionId,
outboundOnly,
onInterrupt,
onSetModel,
onSetMaxThinkingTokens,
onSetPermissionMode,
} = handlers
if (!transport) {
logForDebugging(
'[bridge:repl] Cannot respond to control_request: transport not configured',
)
return
}
let response: SDKControlResponse
// Outbound-only: reply error for mutable requests so claude.ai doesn't show
// false success. initialize must still succeed (server kills the connection
// if it doesn't — see comment above).
if (outboundOnly && request.request.subtype !== 'initialize') {
response = {
type: 'control_response',
response: {
subtype: 'error',
request_id: request.request_id,
error: OUTBOUND_ONLY_ERROR,
},
}
const event = { ...response, session_id: sessionId }
void transport.write(event)
logForDebugging(
`[bridge:repl] Rejected ${request.request.subtype} (outbound-only) request_id=${request.request_id}`,
)
return
}
switch (request.request.subtype) {
case 'initialize':
// Respond with minimal capabilities — the REPL handles
// commands, models, and account info itself.
response = {
type: 'control_response',
response: {
subtype: 'success',
request_id: request.request_id,
response: {
commands: [],
output_style: 'normal',
available_output_styles: ['normal'],
models: [],
account: {},
pid: process.pid,
},
},
}
break
case 'set_model':
onSetModel?.(request.request.model)
response = {
type: 'control_response',
response: {
subtype: 'success',
request_id: request.request_id,
},
}
break
case 'set_max_thinking_tokens':
onSetMaxThinkingTokens?.(request.request.max_thinking_tokens)
response = {
type: 'control_response',
response: {
subtype: 'success',
request_id: request.request_id,
},
}
break
case 'set_permission_mode': {
// The callback returns a policy verdict so we can send an error
// control_response without importing isAutoModeGateEnabled /
// isBypassPermissionsModeDisabled here (bootstrap-isolation). If no
// callback is registered (daemon context, which doesn't wire this —
// see daemonBridge.ts), return an error verdict rather than a silent
// false-success: the mode is never actually applied in that context,
// so success would lie to the client.
const verdict = onSetPermissionMode?.(request.request.mode) ?? {
ok: false,
error:
'set_permission_mode is not supported in this context (onSetPermissionMode callback not registered)',
}
if (verdict.ok) {
response = {
type: 'control_response',
response: {
subtype: 'success',
request_id: request.request_id,
},
}
} else {
response = {
type: 'control_response',
response: {
subtype: 'error',
request_id: request.request_id,
error: verdict.error,
},
}
}
break
}
case 'interrupt':
onInterrupt?.()
response = {
type: 'control_response',
response: {
subtype: 'success',
request_id: request.request_id,
},
}
break
default:
// Unknown subtype — respond with error so the server doesn't
// hang waiting for a reply that never comes.
response = {
type: 'control_response',
response: {
subtype: 'error',
request_id: request.request_id,
error: `REPL bridge does not handle control_request subtype: ${request.request.subtype}`,
},
}
}
const event = { ...response, session_id: sessionId }
void transport.write(event)
logForDebugging(
`[bridge:repl] Sent control_response for ${request.request.subtype} request_id=${request.request_id} result=${response.response.subtype}`,
)
}
// ─── Result message (for session archival on teardown) ───────────────────────
/**
* Build a minimal `SDKResultSuccess` message for session archival.
* The server needs this event before a WS close to trigger archival.
*/
export function makeResultMessage(sessionId: string): SDKResultSuccess {
return {
type: 'result',
subtype: 'success',
duration_ms: 0,
duration_api_ms: 0,
is_error: false,
num_turns: 0,
result: '',
stop_reason: null,
total_cost_usd: 0,
usage: { ...EMPTY_USAGE },
modelUsage: {},
permission_denials: [],
session_id: sessionId,
uuid: randomUUID(),
}
}
// ─── BoundedUUIDSet (echo-dedup ring buffer) ─────────────────────────────────
/**
* FIFO-bounded set backed by a circular buffer. Evicts the oldest entry
* when capacity is reached, keeping memory usage constant at O(capacity).
*
* Messages are added in chronological order, so evicted entries are always
* the oldest. The caller relies on external ordering (the hook's
* lastWrittenIndexRef) as the primary dedup this set is a secondary
* safety net for echo filtering and race-condition dedup.
*/
export class BoundedUUIDSet {
private readonly capacity: number
private readonly ring: (string | undefined)[]
private readonly set = new Set<string>()
private writeIdx = 0
constructor(capacity: number) {
this.capacity = capacity
this.ring = new Array<string | undefined>(capacity)
}
add(uuid: string): void {
if (this.set.has(uuid)) return
// Evict the entry at the current write position (if occupied)
const evicted = this.ring[this.writeIdx]
if (evicted !== undefined) {
this.set.delete(evicted)
}
this.ring[this.writeIdx] = uuid
this.set.add(uuid)
this.writeIdx = (this.writeIdx + 1) % this.capacity
}
has(uuid: string): boolean {
return this.set.has(uuid)
}
clear(): void {
this.set.clear()
this.ring.fill(undefined)
this.writeIdx = 0
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js'
type BridgePermissionResponse = {
behavior: 'allow' | 'deny'
updatedInput?: Record<string, unknown>
updatedPermissions?: PermissionUpdate[]
message?: string
}
type BridgePermissionCallbacks = {
sendRequest(
requestId: string,
toolName: string,
input: Record<string, unknown>,
toolUseId: string,
description: string,
permissionSuggestions?: PermissionUpdate[],
blockedPath?: string,
): void
sendResponse(requestId: string, response: BridgePermissionResponse): void
/** Cancel a pending control_request so the web app can dismiss its prompt. */
cancelRequest(requestId: string): void
onResponse(
requestId: string,
handler: (response: BridgePermissionResponse) => void,
): () => void // returns unsubscribe
}
/** Type predicate for validating a parsed control_response payload
* as a BridgePermissionResponse. Checks the required `behavior`
* discriminant rather than using an unsafe `as` cast. */
function isBridgePermissionResponse(
value: unknown,
): value is BridgePermissionResponse {
if (!value || typeof value !== 'object') return false
return (
'behavior' in value &&
(value.behavior === 'allow' || value.behavior === 'deny')
)
}
export { isBridgePermissionResponse }
export type { BridgePermissionCallbacks, BridgePermissionResponse }
+210
View File
@@ -0,0 +1,210 @@
import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import { z } from 'zod/v4'
import { logForDebugging } from '../utils/debug.js'
import { isENOENT } from '../utils/errors.js'
import { getWorktreePathsPortable } from '../utils/getWorktreePathsPortable.js'
import { lazySchema } from '../utils/lazySchema.js'
import {
getProjectsDir,
sanitizePath,
} from '../utils/sessionStoragePortable.js'
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
/**
* Upper bound on worktree fanout. git worktree list is naturally bounded
* (50 is a LOT), but this caps the parallel stat() burst and guards against
* pathological setups. Above this, --continue falls back to current-dir-only.
*/
const MAX_WORKTREE_FANOUT = 50
/**
* Crash-recovery pointer for Remote Control sessions.
*
* Written immediately after a bridge session is created, periodically
* refreshed during the session, and cleared on clean shutdown. If the
* process dies unclean (crash, kill -9, terminal closed), the pointer
* persists. On next startup, `claude remote-control` detects it and offers
* to resume via the --session-id flow from #20460.
*
* Staleness is checked against the file's mtime (not an embedded timestamp)
* so that a periodic re-write with the same content serves as a refresh
* matches the backend's rolling BRIDGE_LAST_POLL_TTL (4h) semantics. A
* bridge that's been polling for 5+ hours and then crashes still has a
* fresh pointer as long as the refresh ran within the window.
*
* Scoped per working directory (alongside transcript JSONL files) so two
* concurrent bridges in different repos don't clobber each other.
*/
export const BRIDGE_POINTER_TTL_MS = 4 * 60 * 60 * 1000
const BridgePointerSchema = lazySchema(() =>
z.object({
sessionId: z.string(),
environmentId: z.string(),
source: z.enum(['standalone', 'repl']),
}),
)
export type BridgePointer = z.infer<ReturnType<typeof BridgePointerSchema>>
export function getBridgePointerPath(dir: string): string {
return join(getProjectsDir(), sanitizePath(dir), 'bridge-pointer.json')
}
/**
* Write the pointer. Also used to refresh mtime during long sessions
* calling with the same IDs is a cheap no-content-change write that bumps
* the staleness clock. Best-effort a crash-recovery file must never
* itself cause a crash. Logs and swallows on error.
*/
export async function writeBridgePointer(
dir: string,
pointer: BridgePointer,
): Promise<void> {
const path = getBridgePointerPath(dir)
try {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, jsonStringify(pointer), 'utf8')
logForDebugging(`[bridge:pointer] wrote ${path}`)
} catch (err: unknown) {
logForDebugging(`[bridge:pointer] write failed: ${err}`, { level: 'warn' })
}
}
/**
* Read the pointer and its age (ms since last write). Operates directly
* and handles errors no existence check (CLAUDE.md TOCTOU rule). Returns
* null on any failure: missing file, corrupted JSON, schema mismatch, or
* stale (mtime > 4h ago). Stale/invalid pointers are deleted so they don't
* keep re-prompting after the backend has already GC'd the env.
*/
export async function readBridgePointer(
dir: string,
): Promise<(BridgePointer & { ageMs: number }) | null> {
const path = getBridgePointerPath(dir)
let raw: string
let mtimeMs: number
try {
// stat for mtime (staleness anchor), then read. Two syscalls, but both
// are needed — mtime IS the data we return, not a TOCTOU guard.
mtimeMs = (await stat(path)).mtimeMs
raw = await readFile(path, 'utf8')
} catch {
return null
}
const parsed = BridgePointerSchema().safeParse(safeJsonParse(raw))
if (!parsed.success) {
logForDebugging(`[bridge:pointer] invalid schema, clearing: ${path}`)
await clearBridgePointer(dir)
return null
}
const ageMs = Math.max(0, Date.now() - mtimeMs)
if (ageMs > BRIDGE_POINTER_TTL_MS) {
logForDebugging(`[bridge:pointer] stale (>4h mtime), clearing: ${path}`)
await clearBridgePointer(dir)
return null
}
return { ...parsed.data, ageMs }
}
/**
* Worktree-aware read for `--continue`. The REPL bridge writes its pointer
* to `getOriginalCwd()` which EnterWorktreeTool/activeWorktreeSession can
* mutate to a worktree path but `claude remote-control --continue` runs
* with `resolve('.')` = shell CWD. This fans out across git worktree
* siblings to find the freshest pointer, matching /resume's semantics.
*
* Fast path: checks `dir` first. Only shells out to `git worktree list` if
* that misses the common case (pointer in launch dir) is one stat, zero
* exec. Fanout reads run in parallel; capped at MAX_WORKTREE_FANOUT.
*
* Returns the pointer AND the dir it was found in, so the caller can clear
* the right file on resume failure.
*/
export async function readBridgePointerAcrossWorktrees(
dir: string,
): Promise<{ pointer: BridgePointer & { ageMs: number }; dir: string } | null> {
// Fast path: current dir. Covers standalone bridge (always matches) and
// REPL bridge when no worktree mutation happened.
const here = await readBridgePointer(dir)
if (here) {
return { pointer: here, dir }
}
// Fanout: scan worktree siblings. getWorktreePathsPortable has a 5s
// timeout and returns [] on any error (not a git repo, git not installed).
const worktrees = await getWorktreePathsPortable(dir)
if (worktrees.length <= 1) return null
if (worktrees.length > MAX_WORKTREE_FANOUT) {
logForDebugging(
`[bridge:pointer] ${worktrees.length} worktrees exceeds fanout cap ${MAX_WORKTREE_FANOUT}, skipping`,
)
return null
}
// Dedupe against `dir` so we don't re-stat it. sanitizePath normalizes
// case/separators so worktree-list output matches our fast-path key even
// on Windows where git may emit C:/ vs stored c:/.
const dirKey = sanitizePath(dir)
const candidates = worktrees.filter(wt => sanitizePath(wt) !== dirKey)
// Parallel stat+read. Each readBridgePointer is a stat() that ENOENTs
// for worktrees with no pointer (cheap) plus a ~100-byte read for the
// rare ones that have one. Promise.all → latency ≈ slowest single stat.
const results = await Promise.all(
candidates.map(async wt => {
const p = await readBridgePointer(wt)
return p ? { pointer: p, dir: wt } : null
}),
)
// Pick freshest (lowest ageMs). The pointer stores environmentId so
// resume reconnects to the right env regardless of which worktree
// --continue was invoked from.
let freshest: {
pointer: BridgePointer & { ageMs: number }
dir: string
} | null = null
for (const r of results) {
if (r && (!freshest || r.pointer.ageMs < freshest.pointer.ageMs)) {
freshest = r
}
}
if (freshest) {
logForDebugging(
`[bridge:pointer] fanout found pointer in worktree ${freshest.dir} (ageMs=${freshest.pointer.ageMs})`,
)
}
return freshest
}
/**
* Delete the pointer. Idempotent ENOENT is expected when the process
* shut down clean previously.
*/
export async function clearBridgePointer(dir: string): Promise<void> {
const path = getBridgePointerPath(dir)
try {
await unlink(path)
logForDebugging(`[bridge:pointer] cleared ${path}`)
} catch (err: unknown) {
if (!isENOENT(err)) {
logForDebugging(`[bridge:pointer] clear failed: ${err}`, {
level: 'warn',
})
}
}
}
function safeJsonParse(raw: string): unknown {
try {
return jsonParse(raw)
} catch {
return null
}
}
+163
View File
@@ -0,0 +1,163 @@
import {
getClaudeAiBaseUrl,
getRemoteSessionUrl,
} from '../constants/product.js'
import { stringWidth } from '../ink/stringWidth.js'
import { formatDuration, truncateToWidth } from '../utils/format.js'
import { getGraphemeSegmenter } from '../utils/intl.js'
/** Bridge status state machine states. */
export type StatusState =
| 'idle'
| 'attached'
| 'titled'
| 'reconnecting'
| 'failed'
/** How long a tool activity line stays visible after last tool_start (ms). */
export const TOOL_DISPLAY_EXPIRY_MS = 30_000
/** Interval for the shimmer animation tick (ms). */
export const SHIMMER_INTERVAL_MS = 150
export function timestamp(): string {
const now = new Date()
const h = String(now.getHours()).padStart(2, '0')
const m = String(now.getMinutes()).padStart(2, '0')
const s = String(now.getSeconds()).padStart(2, '0')
return `${h}:${m}:${s}`
}
export { formatDuration, truncateToWidth as truncatePrompt }
/** Abbreviate a tool activity summary for the trail display. */
export function abbreviateActivity(summary: string): string {
return truncateToWidth(summary, 30)
}
/** Build the connect URL shown when the bridge is idle. */
export function buildBridgeConnectUrl(
environmentId: string,
ingressUrl?: string,
): string {
const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl)
return `${baseUrl}/code?bridge=${environmentId}`
}
/**
* Build the session URL shown when a session is attached. Delegates to
* getRemoteSessionUrl for the cse_session_ prefix translation, then appends
* the v1-specific ?bridge={environmentId} query.
*/
export function buildBridgeSessionUrl(
sessionId: string,
environmentId: string,
ingressUrl?: string,
): string {
return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}`
}
/** Compute the glimmer index for a reverse-sweep shimmer animation. */
export function computeGlimmerIndex(
tick: number,
messageWidth: number,
): number {
const cycleLength = messageWidth + 20
return messageWidth + 10 - (tick % cycleLength)
}
/**
* Split text into three segments by visual column position for shimmer rendering.
*
* Uses grapheme segmentation and `stringWidth` so the split is correct for
* multi-byte characters, emoji, and CJK glyphs.
*
* Returns `{ before, shimmer, after }` strings. Both renderers (chalk in
* bridgeUI.ts and React/Ink in bridge.tsx) apply their own coloring to
* these segments.
*/
export function computeShimmerSegments(
text: string,
glimmerIndex: number,
): { before: string; shimmer: string; after: string } {
const messageWidth = stringWidth(text)
const shimmerStart = glimmerIndex - 1
const shimmerEnd = glimmerIndex + 1
// When shimmer is offscreen, return all text as "before"
if (shimmerStart >= messageWidth || shimmerEnd < 0) {
return { before: text, shimmer: '', after: '' }
}
// Split into at most 3 segments by visual column position
const clampedStart = Math.max(0, shimmerStart)
let colPos = 0
let before = ''
let shimmer = ''
let after = ''
for (const { segment } of getGraphemeSegmenter().segment(text)) {
const segWidth = stringWidth(segment)
if (colPos + segWidth <= clampedStart) {
before += segment
} else if (colPos > shimmerEnd) {
after += segment
} else {
shimmer += segment
}
colPos += segWidth
}
return { before, shimmer, after }
}
/** Computed bridge status label and color from connection state. */
export type BridgeStatusInfo = {
label:
| 'Remote Control failed'
| 'Remote Control reconnecting'
| 'Remote Control active'
| 'Remote Control connecting\u2026'
color: 'error' | 'warning' | 'success'
}
/** Derive a status label and color from the bridge connection state. */
export function getBridgeStatus({
error,
connected,
sessionActive,
reconnecting,
}: {
error: string | undefined
connected: boolean
sessionActive: boolean
reconnecting: boolean
}): BridgeStatusInfo {
if (error) return { label: 'Remote Control failed', color: 'error' }
if (reconnecting)
return { label: 'Remote Control reconnecting', color: 'warning' }
if (sessionActive || connected)
return { label: 'Remote Control active', color: 'success' }
return { label: 'Remote Control connecting\u2026', color: 'warning' }
}
/** Footer text shown when bridge is idle (Ready state). */
export function buildIdleFooterText(url: string): string {
return `Code everywhere with the Claude app or ${url}`
}
/** Footer text shown when a session is active (Connected state). */
export function buildActiveFooterText(url: string): string {
return `Continue coding in the Claude app or ${url}`
}
/** Footer text shown when the bridge has failed. */
export const FAILED_FOOTER_TEXT = 'Something went wrong, please try again'
/**
* Wrap text in an OSC 8 terminal hyperlink. Zero visual width for layout purposes.
* strip-ansi (used by stringWidth) correctly strips these sequences, so
* countVisualLines in bridgeUI.ts remains accurate.
*/
export function wrapWithOsc8Link(text: string, url: string): string {
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
}
+530
View File
@@ -0,0 +1,530 @@
import chalk from 'chalk'
import { toString as qrToString } from 'qrcode'
import {
BRIDGE_FAILED_INDICATOR,
BRIDGE_READY_INDICATOR,
BRIDGE_SPINNER_FRAMES,
} from '../constants/figures.js'
import { stringWidth } from '../ink/stringWidth.js'
import { logForDebugging } from '../utils/debug.js'
import {
buildActiveFooterText,
buildBridgeConnectUrl,
buildBridgeSessionUrl,
buildIdleFooterText,
FAILED_FOOTER_TEXT,
formatDuration,
type StatusState,
TOOL_DISPLAY_EXPIRY_MS,
timestamp,
truncatePrompt,
wrapWithOsc8Link,
} from './bridgeStatusUtil.js'
import type {
BridgeConfig,
BridgeLogger,
SessionActivity,
SpawnMode,
} from './types.js'
const QR_OPTIONS = {
type: 'utf8' as const,
errorCorrectionLevel: 'L' as const,
small: true,
}
/** Generate a QR code and return its lines. */
async function generateQr(url: string): Promise<string[]> {
const qr = await qrToString(url, QR_OPTIONS)
return qr.split('\n').filter((line: string) => line.length > 0)
}
export function createBridgeLogger(options: {
verbose: boolean
write?: (s: string) => void
}): BridgeLogger {
const write = options.write ?? ((s: string) => process.stdout.write(s))
const verbose = options.verbose
// Track how many status lines are currently displayed at the bottom
let statusLineCount = 0
// Status state machine
let currentState: StatusState = 'idle'
let currentStateText = 'Ready'
let repoName = ''
let branch = ''
let debugLogPath = ''
// Connect URL (built in printBanner with correct base for staging/prod)
let connectUrl = ''
let cachedIngressUrl = ''
let cachedEnvironmentId = ''
let activeSessionUrl: string | null = null
// QR code lines for the current URL
let qrLines: string[] = []
let qrVisible = false
// Tool activity for the second status line
let lastToolSummary: string | null = null
let lastToolTime = 0
// Session count indicator (shown when multi-session mode is enabled)
let sessionActive = 0
let sessionMax = 1
// Spawn mode shown in the session-count line + gates the `w` hint
let spawnModeDisplay: 'same-dir' | 'worktree' | null = null
let spawnMode: SpawnMode = 'single-session'
// Per-session display info for the multi-session bullet list (keyed by compat sessionId)
const sessionDisplayInfo = new Map<
string,
{ title?: string; url: string; activity?: SessionActivity }
>()
// Connecting spinner state
let connectingTimer: ReturnType<typeof setInterval> | null = null
let connectingTick = 0
/**
* Count how many visual terminal rows a string occupies, accounting for
* line wrapping. Each `\n` is one row, and content wider than the terminal
* wraps to additional rows.
*/
function countVisualLines(text: string): number {
// eslint-disable-next-line custom-rules/prefer-use-terminal-size
const cols = process.stdout.columns || 80 // non-React CLI context
let count = 0
// Split on newlines to get logical lines
for (const logical of text.split('\n')) {
if (logical.length === 0) {
// Empty segment between consecutive \n — counts as 1 row
count++
continue
}
const width = stringWidth(logical)
count += Math.max(1, Math.ceil(width / cols))
}
// The trailing \n in "line\n" produces an empty last element — don't count it
// because the cursor sits at the start of the next line, not a new visual row.
if (text.endsWith('\n')) {
count--
}
return count
}
/** Write a status line and track its visual line count. */
function writeStatus(text: string): void {
write(text)
statusLineCount += countVisualLines(text)
}
/** Clear any currently displayed status lines. */
function clearStatusLines(): void {
if (statusLineCount <= 0) return
logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`)
// Move cursor up to the start of the status block, then erase everything below
write(`\x1b[${statusLineCount}A`) // cursor up N lines
write('\x1b[J') // erase from cursor to end of screen
statusLineCount = 0
}
/** Print a permanent log line, clearing status first and restoring after. */
function printLog(line: string): void {
clearStatusLines()
write(line)
}
/** Regenerate the QR code with the given URL. */
function regenerateQr(url: string): void {
generateQr(url)
.then(lines => {
qrLines = lines
renderStatusLine()
})
.catch(e => {
logForDebugging(`QR code generation failed: ${e}`, { level: 'error' })
})
}
/** Render the connecting spinner line (shown before first updateIdleStatus). */
function renderConnectingLine(): void {
clearStatusLines()
const frame =
BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
let suffix = ''
if (repoName) {
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
}
if (branch) {
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
}
writeStatus(
`${chalk.yellow(frame)} ${chalk.yellow('Connecting')}${suffix}\n`,
)
}
/** Start the connecting spinner. Stopped by first updateIdleStatus(). */
function startConnecting(): void {
stopConnecting()
renderConnectingLine()
connectingTimer = setInterval(() => {
connectingTick++
renderConnectingLine()
}, 150)
}
/** Stop the connecting spinner. */
function stopConnecting(): void {
if (connectingTimer) {
clearInterval(connectingTimer)
connectingTimer = null
}
}
/** Render and write the current status lines based on state. */
function renderStatusLine(): void {
if (currentState === 'reconnecting' || currentState === 'failed') {
// These states are handled separately (updateReconnectingStatus /
// updateFailedStatus). Return before clearing so callers like toggleQr
// and setSpawnModeDisplay don't blank the display during these states.
return
}
clearStatusLines()
const isIdle = currentState === 'idle'
// QR code above the status line
if (qrVisible) {
for (const line of qrLines) {
writeStatus(`${chalk.dim(line)}\n`)
}
}
// Determine indicator and colors based on state
const indicator = BRIDGE_READY_INDICATOR
const indicatorColor = isIdle ? chalk.green : chalk.cyan
const baseColor = isIdle ? chalk.green : chalk.cyan
const stateText = baseColor(currentStateText)
// Build the suffix with repo and branch
let suffix = ''
if (repoName) {
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
}
// In worktree mode each session gets its own branch, so showing the
// bridge's branch would be misleading.
if (branch && spawnMode !== 'worktree') {
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
}
if (process.env.USER_TYPE === 'ant' && debugLogPath) {
writeStatus(
`${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`,
)
}
writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
// Session count and per-session list (multi-session mode only)
if (sessionMax > 1) {
const modeHint =
spawnMode === 'worktree'
? 'New sessions will be created in an isolated worktree'
: 'New sessions will be created in the current directory'
writeStatus(
` ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`,
)
for (const [, info] of sessionDisplayInfo) {
const titleText = info.title
? truncatePrompt(info.title, 35)
: chalk.dim('Attached')
const titleLinked = wrapWithOsc8Link(titleText, info.url)
const act = info.activity
const showAct = act && act.type !== 'result' && act.type !== 'error'
const actText = showAct
? chalk.dim(` ${truncatePrompt(act.summary, 40)}`)
: ''
writeStatus(` ${titleLinked}${actText}
`)
}
}
// Mode line for spawn modes with a single slot (or true single-session mode)
if (sessionMax === 1) {
const modeText =
spawnMode === 'single-session'
? 'Single session \u00b7 exits when complete'
: spawnMode === 'worktree'
? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree`
: `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory`
writeStatus(` ${chalk.dim(modeText)}\n`)
}
// Tool activity line for single-session mode
if (
sessionMax === 1 &&
!isIdle &&
lastToolSummary &&
Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS
) {
writeStatus(` ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`)
}
// Blank line separator before footer
const url = activeSessionUrl ?? connectUrl
if (url) {
writeStatus('\n')
const footerText = isIdle
? buildIdleFooterText(url)
: buildActiveFooterText(url)
const qrHint = qrVisible
? chalk.dim.italic('space to hide QR code')
: chalk.dim.italic('space to show QR code')
const toggleHint = spawnModeDisplay
? chalk.dim.italic(' \u00b7 w to toggle spawn mode')
: ''
writeStatus(`${chalk.dim(footerText)}\n`)
writeStatus(`${qrHint}${toggleHint}\n`)
}
}
return {
printBanner(config: BridgeConfig, environmentId: string): void {
cachedIngressUrl = config.sessionIngressUrl
cachedEnvironmentId = environmentId
connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl)
regenerateQr(connectUrl)
if (verbose) {
write(chalk.dim(`Remote Control`) + ` v${MACRO.VERSION}\n`)
}
if (verbose) {
if (config.spawnMode !== 'single-session') {
write(chalk.dim(`Spawn mode: `) + `${config.spawnMode}\n`)
write(
chalk.dim(`Max concurrent sessions: `) + `${config.maxSessions}\n`,
)
}
write(chalk.dim(`Environment ID: `) + `${environmentId}\n`)
}
if (config.sandbox) {
write(chalk.dim(`Sandbox: `) + `${chalk.green('Enabled')}\n`)
}
write('\n')
// Start connecting spinner — first updateIdleStatus() will stop it
startConnecting()
},
logSessionStart(sessionId: string, prompt: string): void {
if (verbose) {
const short = truncatePrompt(prompt, 80)
printLog(
chalk.dim(`[${timestamp()}]`) +
` Session started: ${chalk.white(`"${short}"`)} (${chalk.dim(sessionId)})\n`,
)
}
},
logSessionComplete(sessionId: string, durationMs: number): void {
printLog(
chalk.dim(`[${timestamp()}]`) +
` Session ${chalk.green('completed')} (${formatDuration(durationMs)}) ${chalk.dim(sessionId)}\n`,
)
},
logSessionFailed(sessionId: string, error: string): void {
printLog(
chalk.dim(`[${timestamp()}]`) +
` Session ${chalk.red('failed')}: ${error} ${chalk.dim(sessionId)}\n`,
)
},
logStatus(message: string): void {
printLog(chalk.dim(`[${timestamp()}]`) + ` ${message}\n`)
},
logVerbose(message: string): void {
if (verbose) {
printLog(chalk.dim(`[${timestamp()}] ${message}`) + '\n')
}
},
logError(message: string): void {
printLog(chalk.red(`[${timestamp()}] Error: ${message}`) + '\n')
},
logReconnected(disconnectedMs: number): void {
printLog(
chalk.dim(`[${timestamp()}]`) +
` ${chalk.green('Reconnected')} after ${formatDuration(disconnectedMs)}\n`,
)
},
setRepoInfo(repo: string, branchName: string): void {
repoName = repo
branch = branchName
},
setDebugLogPath(path: string): void {
debugLogPath = path
},
updateIdleStatus(): void {
stopConnecting()
currentState = 'idle'
currentStateText = 'Ready'
lastToolSummary = null
lastToolTime = 0
activeSessionUrl = null
regenerateQr(connectUrl)
renderStatusLine()
},
setAttached(sessionId: string): void {
stopConnecting()
currentState = 'attached'
currentStateText = 'Connected'
lastToolSummary = null
lastToolTime = 0
// Multi-session: keep footer/QR on the environment connect URL so users
// can spawn more sessions. Per-session links are in the bullet list.
if (sessionMax <= 1) {
activeSessionUrl = buildBridgeSessionUrl(
sessionId,
cachedEnvironmentId,
cachedIngressUrl,
)
regenerateQr(activeSessionUrl)
}
renderStatusLine()
},
updateReconnectingStatus(delayStr: string, elapsedStr: string): void {
stopConnecting()
clearStatusLines()
currentState = 'reconnecting'
// QR code above the status line
if (qrVisible) {
for (const line of qrLines) {
writeStatus(`${chalk.dim(line)}\n`)
}
}
const frame =
BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
connectingTick++
writeStatus(
`${chalk.yellow(frame)} ${chalk.yellow('Reconnecting')} ${chalk.dim('\u00b7')} ${chalk.dim(`retrying in ${delayStr}`)} ${chalk.dim('\u00b7')} ${chalk.dim(`disconnected ${elapsedStr}`)}\n`,
)
},
updateFailedStatus(error: string): void {
stopConnecting()
clearStatusLines()
currentState = 'failed'
let suffix = ''
if (repoName) {
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
}
if (branch) {
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
}
writeStatus(
`${chalk.red(BRIDGE_FAILED_INDICATOR)} ${chalk.red('Remote Control Failed')}${suffix}\n`,
)
writeStatus(`${chalk.dim(FAILED_FOOTER_TEXT)}\n`)
if (error) {
writeStatus(`${chalk.red(error)}\n`)
}
},
updateSessionStatus(
_sessionId: string,
_elapsed: string,
activity: SessionActivity,
_trail: string[],
): void {
// Cache tool activity for the second status line
if (activity.type === 'tool_start') {
lastToolSummary = activity.summary
lastToolTime = Date.now()
}
renderStatusLine()
},
clearStatus(): void {
stopConnecting()
clearStatusLines()
},
toggleQr(): void {
qrVisible = !qrVisible
renderStatusLine()
},
updateSessionCount(active: number, max: number, mode: SpawnMode): void {
if (sessionActive === active && sessionMax === max && spawnMode === mode)
return
sessionActive = active
sessionMax = max
spawnMode = mode
// Don't re-render here — the status ticker calls renderStatusLine
// on its own cadence, and the next tick will pick up the new values.
},
setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void {
if (spawnModeDisplay === mode) return
spawnModeDisplay = mode
// Also sync the #21118-added spawnMode so the next render shows correct
// mode hint + branch visibility. Don't render here — matches
// updateSessionCount: called before printBanner (initial setup) and
// again from the `w` handler (which follows with refreshDisplay).
if (mode) spawnMode = mode
},
addSession(sessionId: string, url: string): void {
sessionDisplayInfo.set(sessionId, { url })
},
updateSessionActivity(sessionId: string, activity: SessionActivity): void {
const info = sessionDisplayInfo.get(sessionId)
if (!info) return
info.activity = activity
},
setSessionTitle(sessionId: string, title: string): void {
const info = sessionDisplayInfo.get(sessionId)
if (!info) return
info.title = title
// Guard against reconnecting/failed — renderStatusLine clears then returns
// early for those states, which would erase the spinner/error.
if (currentState === 'reconnecting' || currentState === 'failed') return
if (sessionMax === 1) {
// Single-session: show title in the main status line too.
currentState = 'titled'
currentStateText = truncatePrompt(title, 40)
}
renderStatusLine()
},
removeSession(sessionId: string): void {
sessionDisplayInfo.delete(sessionId)
},
refreshDisplay(): void {
// Skip during reconnecting/failed — renderStatusLine clears then returns
// early for those states, which would erase the spinner/error.
if (currentState === 'reconnecting' || currentState === 'failed') return
renderStatusLine()
},
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Shared capacity-wake primitive for bridge poll loops.
*
* Both replBridge.ts and bridgeMain.ts need to sleep while "at capacity"
* but wake early when either (a) the outer loop signal aborts (shutdown),
* or (b) capacity frees up (session done / transport lost). This module
* encapsulates the mutable wake-controller + two-signal merger that both
* poll loops previously duplicated byte-for-byte.
*/
export type CapacitySignal = { signal: AbortSignal; cleanup: () => void }
export type CapacityWake = {
/**
* Create a signal that aborts when either the outer loop signal or the
* capacity-wake controller fires. Returns the merged signal and a cleanup
* function that removes listeners when the sleep resolves normally
* (without abort).
*/
signal(): CapacitySignal
/**
* Abort the current at-capacity sleep and arm a fresh controller so the
* poll loop immediately re-checks for new work.
*/
wake(): void
}
export function createCapacityWake(outerSignal: AbortSignal): CapacityWake {
let wakeController = new AbortController()
function wake(): void {
wakeController.abort()
wakeController = new AbortController()
}
function signal(): CapacitySignal {
const merged = new AbortController()
const abort = (): void => merged.abort()
if (outerSignal.aborted || wakeController.signal.aborted) {
merged.abort()
return { signal: merged.signal, cleanup: () => {} }
}
outerSignal.addEventListener('abort', abort, { once: true })
const capSig = wakeController.signal
capSig.addEventListener('abort', abort, { once: true })
return {
signal: merged.signal,
cleanup: () => {
outerSignal.removeEventListener('abort', abort)
capSig.removeEventListener('abort', abort)
},
}
}
return { signal, wake }
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Thin HTTP wrappers for the CCR v2 code-session API.
*
* Separate file from remoteBridgeCore.ts so the SDK /bridge subpath can
* export createCodeSession + fetchRemoteCredentials without bundling the
* heavy CLI tree (analytics, transport, etc.). Callers supply explicit
* accessToken + baseUrl no implicit auth or config reads.
*/
import axios from 'axios'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { jsonStringify } from '../utils/slowOperations.js'
import { extractErrorDetail } from './debugUtils.js'
const ANTHROPIC_VERSION = '2023-06-01'
function oauthHeaders(accessToken: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': ANTHROPIC_VERSION,
}
}
export async function createCodeSession(
baseUrl: string,
accessToken: string,
title: string,
timeoutMs: number,
tags?: string[],
): Promise<string | null> {
const url = `${baseUrl}/v1/code/sessions`
let response
try {
response = await axios.post(
url,
// bridge: {} is the positive signal for the oneof runner — omitting it
// (or sending environment_id: "") now 400s. BridgeRunner is an empty
// message today; it's a placeholder for future bridge-specific options.
{ title, bridge: {}, ...(tags?.length ? { tags } : {}) },
{
headers: oauthHeaders(accessToken),
timeout: timeoutMs,
validateStatus: s => s < 500,
},
)
} catch (err: unknown) {
logForDebugging(
`[code-session] Session create request failed: ${errorMessage(err)}`,
)
return null
}
if (response.status !== 200 && response.status !== 201) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[code-session] Session create failed ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}
const data: unknown = response.data
if (
!data ||
typeof data !== 'object' ||
!('session' in data) ||
!data.session ||
typeof data.session !== 'object' ||
!('id' in data.session) ||
typeof data.session.id !== 'string' ||
!data.session.id.startsWith('cse_')
) {
logForDebugging(
`[code-session] No session.id (cse_*) in response: ${jsonStringify(data).slice(0, 200)}`,
)
return null
}
return data.session.id
}
/**
* Credentials from POST /bridge. JWT is opaque do not decode.
* Each /bridge call bumps worker_epoch server-side (it IS the register).
*/
export type RemoteCredentials = {
worker_jwt: string
api_base_url: string
expires_in: number
worker_epoch: number
}
export async function fetchRemoteCredentials(
sessionId: string,
baseUrl: string,
accessToken: string,
timeoutMs: number,
trustedDeviceToken?: string,
): Promise<RemoteCredentials | null> {
const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge`
const headers = oauthHeaders(accessToken)
if (trustedDeviceToken) {
headers['X-Trusted-Device-Token'] = trustedDeviceToken
}
let response
try {
response = await axios.post(
url,
{},
{
headers,
timeout: timeoutMs,
validateStatus: s => s < 500,
},
)
} catch (err: unknown) {
logForDebugging(
`[code-session] /bridge request failed: ${errorMessage(err)}`,
)
return null
}
if (response.status !== 200) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[code-session] /bridge failed ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}
const data: unknown = response.data
if (
data === null ||
typeof data !== 'object' ||
!('worker_jwt' in data) ||
typeof data.worker_jwt !== 'string' ||
!('expires_in' in data) ||
typeof data.expires_in !== 'number' ||
!('api_base_url' in data) ||
typeof data.api_base_url !== 'string' ||
!('worker_epoch' in data)
) {
logForDebugging(
`[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${jsonStringify(data).slice(0, 200)}`,
)
return null
}
// protojson serializes int64 as a string to avoid JS precision loss;
// Go may also return a number depending on encoder settings.
const rawEpoch = data.worker_epoch
const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch
if (
typeof epoch !== 'number' ||
!Number.isFinite(epoch) ||
!Number.isSafeInteger(epoch)
) {
logForDebugging(
`[code-session] /bridge worker_epoch invalid: ${jsonStringify(rawEpoch)}`,
)
return null
}
return {
worker_jwt: data.worker_jwt,
api_base_url: data.api_base_url,
expires_in: data.expires_in,
worker_epoch: epoch,
}
}
+384
View File
@@ -0,0 +1,384 @@
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { extractErrorDetail } from './debugUtils.js'
import { toCompatSessionId } from './sessionIdCompat.js'
type GitSource = {
type: 'git_repository'
url: string
revision?: string
}
type GitOutcome = {
type: 'git_repository'
git_info: { type: 'github'; repo: string; branches: string[] }
}
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
// POST /v1/sessions endpoint (discriminated union format).
type SessionEvent = {
type: 'event'
data: SDKMessage
}
/**
* Create a session on a bridge environment via POST /v1/sessions.
*
* Used by both `claude remote-control` (empty session so the user has somewhere to
* type immediately) and `/remote-control` (session pre-populated with conversation
* history).
*
* Returns the session ID on success, or null if creation fails (non-fatal).
*/
export async function createBridgeSession({
environmentId,
title,
events,
gitRepoUrl,
branch,
signal,
baseUrl: baseUrlOverride,
getAccessToken,
permissionMode,
}: {
environmentId: string
title?: string
events: SessionEvent[]
gitRepoUrl: string | null
branch: string
signal: AbortSignal
baseUrl?: string
getAccessToken?: () => string | undefined
permissionMode?: string
}): Promise<string | null> {
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
const { getOrganizationUUID } = await import('../services/oauth/client.js')
const { getOauthConfig } = await import('../constants/oauth.js')
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
const { getDefaultBranch } = await import('../utils/git.js')
const { getMainLoopModel } = await import('../utils/model/model.js')
const { default: axios } = await import('axios')
const accessToken =
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('[bridge] No access token for session creation')
return null
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('[bridge] No org UUID for session creation')
return null
}
// Build git source and outcome context
let gitSource: GitSource | null = null
let gitOutcome: GitOutcome | null = null
if (gitRepoUrl) {
const { parseGitRemote } = await import('../utils/detectRepository.js')
const parsed = parseGitRemote(gitRepoUrl)
if (parsed) {
const { host, owner, name } = parsed
const revision = branch || (await getDefaultBranch()) || undefined
gitSource = {
type: 'git_repository',
url: `https://${host}/${owner}/${name}`,
revision,
}
gitOutcome = {
type: 'git_repository',
git_info: {
type: 'github',
repo: `${owner}/${name}`,
branches: [`claude/${branch || 'task'}`],
},
}
} else {
// Fallback: try parseGitHubRepository for owner/repo format
const ownerRepo = parseGitHubRepository(gitRepoUrl)
if (ownerRepo) {
const [owner, name] = ownerRepo.split('/')
if (owner && name) {
const revision = branch || (await getDefaultBranch()) || undefined
gitSource = {
type: 'git_repository',
url: `https://github.com/${owner}/${name}`,
revision,
}
gitOutcome = {
type: 'git_repository',
git_info: {
type: 'github',
repo: `${owner}/${name}`,
branches: [`claude/${branch || 'task'}`],
},
}
}
}
}
}
const requestBody = {
...(title !== undefined && { title }),
events,
session_context: {
sources: gitSource ? [gitSource] : [],
outcomes: gitOutcome ? [gitOutcome] : [],
model: getMainLoopModel(),
},
environment_id: environmentId,
source: 'remote-control',
...(permissionMode && { permission_mode: permissionMode }),
}
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
}
const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions`
let response
try {
response = await axios.post(url, requestBody, {
headers,
signal,
validateStatus: s => s < 500,
})
} catch (err: unknown) {
logForDebugging(
`[bridge] Session creation request failed: ${errorMessage(err)}`,
)
return null
}
const isSuccess = response.status === 200 || response.status === 201
if (!isSuccess) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}
const sessionData: unknown = response.data
if (
!sessionData ||
typeof sessionData !== 'object' ||
!('id' in sessionData) ||
typeof sessionData.id !== 'string'
) {
logForDebugging('[bridge] No session ID in response')
return null
}
return sessionData.id
}
/**
* Fetch a bridge session via GET /v1/sessions/{id}.
*
* Returns the session's environment_id (for `--session-id` resume) and title.
* Uses the same org-scoped headers as create/archive the environments-level
* client in bridgeApi.ts uses a different beta header and no org UUID, which
* makes the Sessions API return 404.
*/
export async function getBridgeSession(
sessionId: string,
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
): Promise<{ environment_id?: string; title?: string } | null> {
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
const { getOrganizationUUID } = await import('../services/oauth/client.js')
const { getOauthConfig } = await import('../constants/oauth.js')
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
const { default: axios } = await import('axios')
const accessToken =
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('[bridge] No access token for session fetch')
return null
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('[bridge] No org UUID for session fetch')
return null
}
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
}
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
logForDebugging(`[bridge] Fetching session ${sessionId}`)
let response
try {
response = await axios.get<{ environment_id?: string; title?: string }>(
url,
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
)
} catch (err: unknown) {
logForDebugging(
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
)
return null
}
if (response.status !== 200) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}
return response.data
}
/**
* Archive a bridge session via POST /v1/sessions/{id}/archive.
*
* The CCR server never auto-archives sessions archival is always an
* explicit client action. Both `claude remote-control` (standalone bridge) and the
* always-on `/remote-control` REPL bridge call this during shutdown to archive any
* sessions that are still alive.
*
* The archive endpoint accepts sessions in any status (running, idle,
* requires_action, pending) and returns 409 if already archived, making
* it safe to call even if the server-side runner already archived the
* session.
*
* Callers must handle errors this function has no try/catch; 5xx,
* timeouts, and network errors throw. Archival is best-effort during
* cleanup; call sites wrap with .catch().
*/
export async function archiveBridgeSession(
sessionId: string,
opts?: {
baseUrl?: string
getAccessToken?: () => string | undefined
timeoutMs?: number
},
): Promise<void> {
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
const { getOrganizationUUID } = await import('../services/oauth/client.js')
const { getOauthConfig } = await import('../constants/oauth.js')
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
const { default: axios } = await import('axios')
const accessToken =
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('[bridge] No access token for session archive')
return
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('[bridge] No org UUID for session archive')
return
}
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
}
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`
logForDebugging(`[bridge] Archiving session ${sessionId}`)
const response = await axios.post(
url,
{},
{
headers,
timeout: opts?.timeoutMs ?? 10_000,
validateStatus: s => s < 500,
},
)
if (response.status === 200) {
logForDebugging(`[bridge] Session ${sessionId} archived successfully`)
} else {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
}
}
/**
* Update the title of a bridge session via PATCH /v1/sessions/{id}.
*
* Called when the user renames a session via /rename while a bridge
* connection is active, so the title stays in sync on claude.ai/code.
*
* Errors are swallowed title sync is best-effort.
*/
export async function updateBridgeSessionTitle(
sessionId: string,
title: string,
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
): Promise<void> {
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
const { getOrganizationUUID } = await import('../services/oauth/client.js')
const { getOauthConfig } = await import('../constants/oauth.js')
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
const { default: axios } = await import('axios')
const accessToken =
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('[bridge] No access token for session title update')
return
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('[bridge] No org UUID for session title update')
return
}
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
}
// Compat gateway only accepts session_* (compat/convert.go:27). v2 callers
// pass raw cse_*; retag here so all callers can pass whatever they hold.
// Idempotent for v1's session_* and bridgeMain's pre-converted compatSessionId.
const compatId = toCompatSessionId(sessionId)
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}`
logForDebugging(`[bridge] Updating session title: ${compatId}${title}`)
try {
const response = await axios.patch(
url,
{ title },
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
)
if (response.status === 200) {
logForDebugging(`[bridge] Session title updated successfully`)
} else {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
}
} catch (err: unknown) {
logForDebugging(
`[bridge] Session title update request failed: ${errorMessage(err)}`,
)
}
}
+141
View File
@@ -0,0 +1,141 @@
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { jsonStringify } from '../utils/slowOperations.js'
const DEBUG_MSG_LIMIT = 2000
const SECRET_FIELD_NAMES = [
'session_ingress_token',
'environment_secret',
'access_token',
'secret',
'token',
]
const SECRET_PATTERN = new RegExp(
`"(${SECRET_FIELD_NAMES.join('|')})"\\s*:\\s*"([^"]*)"`,
'g',
)
const REDACT_MIN_LENGTH = 16
export function redactSecrets(s: string): string {
return s.replace(SECRET_PATTERN, (_match, field: string, value: string) => {
if (value.length < REDACT_MIN_LENGTH) {
return `"${field}":"[REDACTED]"`
}
const redacted = `${value.slice(0, 8)}...${value.slice(-4)}`
return `"${field}":"${redacted}"`
})
}
/** Truncate a string for debug logging, collapsing newlines. */
export function debugTruncate(s: string): string {
const flat = s.replace(/\n/g, '\\n')
if (flat.length <= DEBUG_MSG_LIMIT) {
return flat
}
return flat.slice(0, DEBUG_MSG_LIMIT) + `... (${flat.length} chars)`
}
/** Truncate a JSON-serializable value for debug logging. */
export function debugBody(data: unknown): string {
const raw = typeof data === 'string' ? data : jsonStringify(data)
const s = redactSecrets(raw)
if (s.length <= DEBUG_MSG_LIMIT) {
return s
}
return s.slice(0, DEBUG_MSG_LIMIT) + `... (${s.length} chars)`
}
/**
* Extract a descriptive error message from an axios error (or any error).
* For HTTP errors, appends the server's response body message if available,
* since axios's default message only includes the status code.
*/
export function describeAxiosError(err: unknown): string {
const msg = errorMessage(err)
if (err && typeof err === 'object' && 'response' in err) {
const response = (err as { response?: { data?: unknown } }).response
if (response?.data && typeof response.data === 'object') {
const data = response.data as Record<string, unknown>
const detail =
typeof data.message === 'string'
? data.message
: typeof data.error === 'object' &&
data.error &&
'message' in data.error &&
typeof (data.error as Record<string, unknown>).message ===
'string'
? (data.error as Record<string, unknown>).message
: undefined
if (detail) {
return `${msg}: ${detail}`
}
}
}
return msg
}
/**
* Extract the HTTP status code from an axios error, if present.
* Returns undefined for non-HTTP errors (e.g. network failures).
*/
export function extractHttpStatus(err: unknown): number | undefined {
if (
err &&
typeof err === 'object' &&
'response' in err &&
(err as { response?: { status?: unknown } }).response &&
typeof (err as { response: { status?: unknown } }).response.status ===
'number'
) {
return (err as { response: { status: number } }).response.status
}
return undefined
}
/**
* Pull a human-readable message out of an API error response body.
* Checks `data.message` first, then `data.error.message`.
*/
export function extractErrorDetail(data: unknown): string | undefined {
if (!data || typeof data !== 'object') return undefined
if ('message' in data && typeof data.message === 'string') {
return data.message
}
if (
'error' in data &&
data.error !== null &&
typeof data.error === 'object' &&
'message' in data.error &&
typeof data.error.message === 'string'
) {
return data.error.message
}
return undefined
}
/**
* Log a bridge init skip debug message + `tengu_bridge_repl_skipped`
* analytics event. Centralizes the event name and the AnalyticsMetadata
* cast so call sites don't each repeat the 5-line boilerplate.
*/
export function logBridgeSkip(
reason: string,
debugMsg?: string,
v2?: boolean,
): void {
if (debugMsg) {
logForDebugging(debugMsg)
}
logEvent('tengu_bridge_repl_skipped', {
reason:
reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(v2 !== undefined && { v2 }),
})
}
+165
View File
@@ -0,0 +1,165 @@
import { z } from 'zod/v4'
import { getFeatureValue_DEPRECATED } from '../services/analytics/growthbook.js'
import { lazySchema } from '../utils/lazySchema.js'
import { lt } from '../utils/semver.js'
import { isEnvLessBridgeEnabled } from './bridgeEnabled.js'
export type EnvLessBridgeConfig = {
// withRetry — init-phase backoff (createSession, POST /bridge, recovery /bridge)
init_retry_max_attempts: number
init_retry_base_delay_ms: number
init_retry_jitter_fraction: number
init_retry_max_delay_ms: number
// axios timeout for POST /sessions, POST /bridge, POST /archive
http_timeout_ms: number
// BoundedUUIDSet ring size (echo + re-delivery dedup)
uuid_dedup_buffer_size: number
// CCRClient worker heartbeat cadence. Server TTL is 60s — 20s gives 3× margin.
heartbeat_interval_ms: number
// ±fraction of interval — per-beat jitter to spread fleet load.
heartbeat_jitter_fraction: number
// Fire proactive JWT refresh this long before expires_in. Larger buffer =
// more frequent refresh (refresh cadence ≈ expires_in - buffer).
token_refresh_buffer_ms: number
// Archive POST timeout in teardown(). Distinct from http_timeout_ms because
// gracefulShutdown races runCleanupFunctions() against a 2s cap — a 10s
// axios timeout on a slow/stalled archive burns the whole budget on a
// request that forceExit will kill anyway.
teardown_archive_timeout_ms: number
// Deadline for onConnect after transport.connect(). If neither onConnect
// nor onClose fires before this, emit tengu_bridge_repl_connect_timeout
// — the only telemetry for the ~1% of sessions that emit `started` then
// go silent (no error, no event, just nothing).
connect_timeout_ms: number
// Semver floor for the env-less bridge path. Separate from the v1
// tengu_bridge_min_version config so a v2-specific bug can force upgrades
// without blocking v1 (env-based) clients, and vice versa.
min_version: string
// When true, tell users their claude.ai app may be too old to see v2
// sessions — lets us roll the v2 bridge before the app ships the new
// session-list query.
should_show_app_upgrade_message: boolean
}
export const DEFAULT_ENV_LESS_BRIDGE_CONFIG: EnvLessBridgeConfig = {
init_retry_max_attempts: 3,
init_retry_base_delay_ms: 500,
init_retry_jitter_fraction: 0.25,
init_retry_max_delay_ms: 4000,
http_timeout_ms: 10_000,
uuid_dedup_buffer_size: 2000,
heartbeat_interval_ms: 20_000,
heartbeat_jitter_fraction: 0.1,
token_refresh_buffer_ms: 300_000,
teardown_archive_timeout_ms: 1500,
connect_timeout_ms: 15_000,
min_version: '0.0.0',
should_show_app_upgrade_message: false,
}
// Floors reject the whole object on violation (fall back to DEFAULT) rather
// than partially trusting — same defense-in-depth as pollConfig.ts.
const envLessBridgeConfigSchema = lazySchema(() =>
z.object({
init_retry_max_attempts: z.number().int().min(1).max(10).default(3),
init_retry_base_delay_ms: z.number().int().min(100).default(500),
init_retry_jitter_fraction: z.number().min(0).max(1).default(0.25),
init_retry_max_delay_ms: z.number().int().min(500).default(4000),
http_timeout_ms: z.number().int().min(2000).default(10_000),
uuid_dedup_buffer_size: z.number().int().min(100).max(50_000).default(2000),
// Server TTL is 60s. Floor 5s prevents thrash; cap 30s keeps ≥2× margin.
heartbeat_interval_ms: z
.number()
.int()
.min(5000)
.max(30_000)
.default(20_000),
// ±fraction per beat. Cap 0.5: at max interval (30s) × 1.5 = 45s worst case,
// still under the 60s TTL.
heartbeat_jitter_fraction: z.number().min(0).max(0.5).default(0.1),
// Floor 30s prevents tight-looping. Cap 30min rejects buffer-vs-delay
// semantic inversion: ops entering expires_in-5min (the *delay until
// refresh*) instead of 5min (the *buffer before expiry*) yields
// delayMs = expires_in - buffer ≈ 5min instead of ≈4h. Both are positive
// durations so .min() alone can't distinguish; .max() catches the
// inverted value since buffer ≥ 30min is nonsensical for a multi-hour JWT.
token_refresh_buffer_ms: z
.number()
.int()
.min(30_000)
.max(1_800_000)
.default(300_000),
// Cap 2000 keeps this under gracefulShutdown's 2s cleanup race — a higher
// timeout just lies to axios since forceExit kills the socket regardless.
teardown_archive_timeout_ms: z
.number()
.int()
.min(500)
.max(2000)
.default(1500),
// Observed p99 connect is ~2-3s; 15s is ~5× headroom. Floor 5s bounds
// false-positive rate under transient slowness; cap 60s bounds how long
// a truly-stalled session stays dark.
connect_timeout_ms: z.number().int().min(5_000).max(60_000).default(15_000),
min_version: z
.string()
.refine(v => {
try {
lt(v, '0.0.0')
return true
} catch {
return false
}
})
.default('0.0.0'),
should_show_app_upgrade_message: z.boolean().default(false),
}),
)
/**
* Fetch the env-less bridge timing config from GrowthBook. Read once per
* initEnvLessBridgeCore call config is fixed for the lifetime of a bridge
* session.
*
* Uses the blocking getter (not _CACHED_MAY_BE_STALE) because /remote-control
* runs well after GrowthBook init initializeGrowthBook() resolves instantly,
* so there's no startup penalty, and we get the fresh in-memory remoteEval
* value instead of the stale-on-first-read disk cache. The _DEPRECATED suffix
* warns against startup-path usage, which this isn't.
*/
export async function getEnvLessBridgeConfig(): Promise<EnvLessBridgeConfig> {
const raw = await getFeatureValue_DEPRECATED<unknown>(
'tengu_bridge_repl_v2_config',
DEFAULT_ENV_LESS_BRIDGE_CONFIG,
)
const parsed = envLessBridgeConfigSchema().safeParse(raw)
return parsed.success ? parsed.data : DEFAULT_ENV_LESS_BRIDGE_CONFIG
}
/**
* Returns an error message if the current CLI version is below the minimum
* required for the env-less (v2) bridge path, or null if the version is fine.
*
* v2 analogue of checkBridgeMinVersion() reads from tengu_bridge_repl_v2_config
* instead of tengu_bridge_min_version so the two implementations can enforce
* independent floors.
*/
export async function checkEnvLessBridgeMinVersion(): Promise<string | null> {
const cfg = await getEnvLessBridgeConfig()
if (cfg.min_version && lt(MACRO.VERSION, cfg.min_version)) {
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`claude update\` to update.`
}
return null
}
/**
* Whether to nudge users toward upgrading their claude.ai app when a
* Remote Control session starts. True only when the v2 bridge is active
* AND the should_show_app_upgrade_message config bit is set lets us
* roll the v2 bridge before the app ships the new session-list query.
*/
export async function shouldShowAppUpgradeMessage(): Promise<boolean> {
if (!isEnvLessBridgeEnabled()) return false
const cfg = await getEnvLessBridgeConfig()
return cfg.should_show_app_upgrade_message
}
+71
View File
@@ -0,0 +1,71 @@
/**
* State machine for gating message writes during an initial flush.
*
* When a bridge session starts, historical messages are flushed to the
* server via a single HTTP POST. During that flush, new messages must
* be queued to prevent them from arriving at the server interleaved
* with the historical messages.
*
* Lifecycle:
* start() enqueue() returns true, items are queued
* end() returns queued items for draining, enqueue() returns false
* drop() discards queued items (permanent transport close)
* deactivate() clears active flag without dropping items
* (transport replacement new transport will drain)
*/
export class FlushGate<T> {
private _active = false
private _pending: T[] = []
get active(): boolean {
return this._active
}
get pendingCount(): number {
return this._pending.length
}
/** Mark flush as in-progress. enqueue() will start queuing items. */
start(): void {
this._active = true
}
/**
* End the flush and return any queued items for draining.
* Caller is responsible for sending the returned items.
*/
end(): T[] {
this._active = false
return this._pending.splice(0)
}
/**
* If flush is active, queue the items and return true.
* If flush is not active, return false (caller should send directly).
*/
enqueue(...items: T[]): boolean {
if (!this._active) return false
this._pending.push(...items)
return true
}
/**
* Discard all queued items (permanent transport close).
* Returns the number of items dropped.
*/
drop(): number {
this._active = false
const count = this._pending.length
this._pending.length = 0
return count
}
/**
* Clear the active flag without dropping queued items.
* Used when the transport is replaced (onWorkReceived) the new
* transport's flush will drain the pending items.
*/
deactivate(): void {
this._active = false
}
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Resolve file_uuid attachments on inbound bridge user messages.
*
* Web composer uploads via cookie-authed /api/{org}/upload, sends file_uuid
* alongside the message. Here we fetch each via GET /api/oauth/files/{uuid}/content
* (oauth-authed, same store), write to ~/.claude/uploads/{sessionId}/, and
* return @path refs to prepend. Claude's Read tool takes it from there.
*
* Best-effort: any failure (no token, network, non-2xx, disk) logs debug and
* skips that attachment. The message still reaches Claude, just without @path.
*/
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import axios from 'axios'
import { randomUUID } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { basename, join } from 'path'
import { z } from 'zod/v4'
import { getSessionId } from '../bootstrap/state.js'
import { logForDebugging } from '../utils/debug.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { lazySchema } from '../utils/lazySchema.js'
import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js'
const DOWNLOAD_TIMEOUT_MS = 30_000
function debug(msg: string): void {
logForDebugging(`[bridge:inbound-attach] ${msg}`)
}
const attachmentSchema = lazySchema(() =>
z.object({
file_uuid: z.string(),
file_name: z.string(),
}),
)
const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema()))
export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>>
/** Pull file_attachments off a loosely-typed inbound message. */
export function extractInboundAttachments(msg: unknown): InboundAttachment[] {
if (typeof msg !== 'object' || msg === null || !('file_attachments' in msg)) {
return []
}
const parsed = attachmentsArraySchema().safeParse(msg.file_attachments)
return parsed.success ? parsed.data : []
}
/**
* Strip path components and keep only filename-safe chars. file_name comes
* from the network (web composer), so treat it as untrusted even though the
* composer controls it.
*/
function sanitizeFileName(name: string): string {
const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_')
return base || 'attachment'
}
function uploadsDir(): string {
return join(getClaudeConfigHomeDir(), 'uploads', getSessionId())
}
/**
* Fetch + write one attachment. Returns the absolute path on success,
* undefined on any failure.
*/
async function resolveOne(att: InboundAttachment): Promise<string | undefined> {
const token = getBridgeAccessToken()
if (!token) {
debug('skip: no oauth token')
return undefined
}
let data: Buffer
try {
// getOauthConfig() (via getBridgeBaseUrl) throws on a non-allowlisted
// CLAUDE_CODE_CUSTOM_OAUTH_URL — keep it inside the try so a bad
// FedStart URL degrades to "no @path" instead of crashing print.ts's
// reader loop (which has no catch around the await).
const url = `${getBridgeBaseUrl()}/api/oauth/files/${encodeURIComponent(att.file_uuid)}/content`
const response = await axios.get(url, {
headers: { Authorization: `Bearer ${token}` },
responseType: 'arraybuffer',
timeout: DOWNLOAD_TIMEOUT_MS,
validateStatus: () => true,
})
if (response.status !== 200) {
debug(`fetch ${att.file_uuid} failed: status=${response.status}`)
return undefined
}
data = Buffer.from(response.data)
} catch (e) {
debug(`fetch ${att.file_uuid} threw: ${e}`)
return undefined
}
// uuid-prefix makes collisions impossible across messages and within one
// (same filename, different files). 8 chars is enough — this isn't security.
const safeName = sanitizeFileName(att.file_name)
const prefix = (
att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8)
).replace(/[^a-zA-Z0-9_-]/g, '_')
const dir = uploadsDir()
const outPath = join(dir, `${prefix}-${safeName}`)
try {
await mkdir(dir, { recursive: true })
await writeFile(outPath, data)
} catch (e) {
debug(`write ${outPath} failed: ${e}`)
return undefined
}
debug(`resolved ${att.file_uuid}${outPath} (${data.length} bytes)`)
return outPath
}
/**
* Resolve all attachments on an inbound message to a prefix string of
* @path refs. Empty string if none resolved.
*/
export async function resolveInboundAttachments(
attachments: InboundAttachment[],
): Promise<string> {
if (attachments.length === 0) return ''
debug(`resolving ${attachments.length} attachment(s)`)
const paths = await Promise.all(attachments.map(resolveOne))
const ok = paths.filter((p): p is string => p !== undefined)
if (ok.length === 0) return ''
// Quoted form — extractAtMentionedFiles truncates unquoted @refs at the
// first space, which breaks any home dir with spaces (/Users/John Smith/).
return ok.map(p => `@"${p}"`).join(' ') + ' '
}
/**
* Prepend @path refs to content, whichever form it's in.
* Targets the LAST text block processUserInputBase reads inputString
* from processedBlocks[processedBlocks.length - 1], so putting refs in
* block[0] means they're silently ignored for [text, image] content.
*/
export function prependPathRefs(
content: string | Array<ContentBlockParam>,
prefix: string,
): string | Array<ContentBlockParam> {
if (!prefix) return content
if (typeof content === 'string') return prefix + content
const i = content.findLastIndex(b => b.type === 'text')
if (i !== -1) {
const b = content[i]!
if (b.type === 'text') {
return [
...content.slice(0, i),
{ ...b, text: prefix + b.text },
...content.slice(i + 1),
]
}
}
// No text block — append one at the end so it's last.
return [...content, { type: 'text', text: prefix.trimEnd() }]
}
/**
* Convenience: extract + resolve + prepend. No-op when the message has no
* file_attachments field (fast path no network, returns same reference).
*/
export async function resolveAndPrepend(
msg: unknown,
content: string | Array<ContentBlockParam>,
): Promise<string | Array<ContentBlockParam>> {
const attachments = extractInboundAttachments(msg)
if (attachments.length === 0) return content
const prefix = await resolveInboundAttachments(attachments)
return prependPathRefs(content, prefix)
}
+80
View File
@@ -0,0 +1,80 @@
import type {
Base64ImageSource,
ContentBlockParam,
ImageBlockParam,
} from '@anthropic-ai/sdk/resources/messages.mjs'
import type { UUID } from 'crypto'
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import { detectImageFormatFromBase64 } from '../utils/imageResizer.js'
/**
* Process an inbound user message from the bridge, extracting content
* and UUID for enqueueing. Supports both string content and
* ContentBlockParam[] (e.g. messages containing images).
*
* Normalizes image blocks from bridge clients that may use camelCase
* `mediaType` instead of snake_case `media_type` (mobile-apps#5825).
*
* Returns the extracted fields, or undefined if the message should be
* skipped (non-user type, missing/empty content).
*/
export function extractInboundMessageFields(
msg: SDKMessage,
):
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
| undefined {
if (msg.type !== 'user') return undefined
const content = msg.message?.content
if (!content) return undefined
if (Array.isArray(content) && content.length === 0) return undefined
const uuid =
'uuid' in msg && typeof msg.uuid === 'string'
? (msg.uuid as UUID)
: undefined
return {
content: Array.isArray(content) ? normalizeImageBlocks(content) : content,
uuid,
}
}
/**
* Normalize image content blocks from bridge clients. iOS/web clients may
* send `mediaType` (camelCase) instead of `media_type` (snake_case), or
* omit the field entirely. Without normalization, the bad block poisons
* the session every subsequent API call fails with
* "media_type: Field required".
*
* Fast-path scan returns the original array reference when no
* normalization is needed (zero allocation on the happy path).
*/
export function normalizeImageBlocks(
blocks: Array<ContentBlockParam>,
): Array<ContentBlockParam> {
if (!blocks.some(isMalformedBase64Image)) return blocks
return blocks.map(block => {
if (!isMalformedBase64Image(block)) return block
const src = block.source as unknown as Record<string, unknown>
const mediaType =
typeof src.mediaType === 'string' && src.mediaType
? src.mediaType
: detectImageFormatFromBase64(block.source.data)
return {
...block,
source: {
type: 'base64' as const,
media_type: mediaType as Base64ImageSource['media_type'],
data: block.source.data,
},
}
})
}
function isMalformedBase64Image(
block: ContentBlockParam,
): block is ImageBlockParam & { source: Base64ImageSource } {
if (block.type !== 'image' || block.source?.type !== 'base64') return false
return !(block.source as unknown as Record<string, unknown>).media_type
}
+569
View File
@@ -0,0 +1,569 @@
/**
* REPL-specific wrapper around initBridgeCore. Owns the parts that read
* bootstrap state gates, cwd, session ID, git context, OAuth, title
* derivation then delegates to the bootstrap-free core.
*
* Split out of replBridge.ts because the sessionStorage import
* (getCurrentSessionTitle) transitively pulls in src/commands.ts the
* entire slash command + React component tree (~1300 modules). Keeping
* initBridgeCore in a file that doesn't touch sessionStorage lets
* daemonBridge.ts import the core without bloating the Agent SDK bundle.
*
* Called via dynamic import by useReplBridge (auto-start) and print.ts
* (SDK -p mode via query.enableRemoteControl).
*/
import { feature } from 'bun:bundle'
import { hostname } from 'os'
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
import { getOrganizationUUID } from '../services/oauth/client.js'
import {
isPolicyAllowed,
waitForPolicyLimitsToLoad,
} from '../services/policyLimits/index.js'
import type { Message } from '../types/message.js'
import {
checkAndRefreshOAuthTokenIfNeeded,
getClaudeAIOAuthTokens,
handleOAuth401Error,
} from '../utils/auth.js'
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
import { logForDebugging } from '../utils/debug.js'
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
import { errorMessage } from '../utils/errors.js'
import { getBranch, getRemoteUrl } from '../utils/git.js'
import { toSDKMessages } from '../utils/messages/mappers.js'
import {
getContentText,
getMessagesAfterCompactBoundary,
isSyntheticMessage,
} from '../utils/messages.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { getCurrentSessionTitle } from '../utils/sessionStorage.js'
import {
extractConversationText,
generateSessionTitle,
} from '../utils/sessionTitle.js'
import { generateShortWordSlug } from '../utils/words.js'
import {
getBridgeAccessToken,
getBridgeBaseUrl,
getBridgeTokenOverride,
} from './bridgeConfig.js'
import {
checkBridgeMinVersion,
isBridgeEnabledBlocking,
isCseShimEnabled,
isEnvLessBridgeEnabled,
} from './bridgeEnabled.js'
import {
archiveBridgeSession,
createBridgeSession,
updateBridgeSessionTitle,
} from './createSession.js'
import { logBridgeSkip } from './debugUtils.js'
import { checkEnvLessBridgeMinVersion } from './envLessBridgeConfig.js'
import { getPollIntervalConfig } from './pollConfig.js'
import type { BridgeState, ReplBridgeHandle } from './replBridge.js'
import { initBridgeCore } from './replBridge.js'
import { setCseShimGate } from './sessionIdCompat.js'
import type { BridgeWorkerType } from './types.js'
export type InitBridgeOptions = {
onInboundMessage?: (msg: SDKMessage) => void | Promise<void>
onPermissionResponse?: (response: SDKControlResponse) => void
onInterrupt?: () => void
onSetModel?: (model: string | undefined) => void
onSetMaxThinkingTokens?: (maxTokens: number | null) => void
onSetPermissionMode?: (
mode: PermissionMode,
) => { ok: true } | { ok: false; error: string }
onStateChange?: (state: BridgeState, detail?: string) => void
initialMessages?: Message[]
// Explicit session name from `/remote-control <name>`. When set, overrides
// the title derived from the conversation or /rename.
initialName?: string
// Fresh view of the full conversation at call time. Used by onUserMessage's
// count-3 derivation to call generateSessionTitle over the full conversation.
// Optional — print.ts's SDK enableRemoteControl path has no REPL message
// array; count-3 falls back to the single message text when absent.
getMessages?: () => Message[]
// UUIDs already flushed in a prior bridge session. Messages with these
// UUIDs are excluded from the initial flush to avoid poisoning the
// server (duplicate UUIDs across sessions cause the WS to be killed).
// Mutated in place — newly flushed UUIDs are added after each flush.
previouslyFlushedUUIDs?: Set<string>
/** See BridgeCoreParams.perpetual. */
perpetual?: boolean
/**
* When true, the bridge only forwards events outbound (no SSE inbound
* stream). Used by CCR mirror mode local sessions visible on claude.ai
* without enabling inbound control.
*/
outboundOnly?: boolean
tags?: string[]
}
export async function initReplBridge(
options?: InitBridgeOptions,
): Promise<ReplBridgeHandle | null> {
const {
onInboundMessage,
onPermissionResponse,
onInterrupt,
onSetModel,
onSetMaxThinkingTokens,
onSetPermissionMode,
onStateChange,
initialMessages,
getMessages,
previouslyFlushedUUIDs,
initialName,
perpetual,
outboundOnly,
tags,
} = options ?? {}
// Wire the cse_ shim kill switch so toCompatSessionId respects the
// GrowthBook gate. Daemon/SDK paths skip this — shim defaults to active.
setCseShimGate(isCseShimEnabled)
// 1. Runtime gate
if (!(await isBridgeEnabledBlocking())) {
logBridgeSkip('not_enabled', '[bridge:repl] Skipping: bridge not enabled')
return null
}
// 1b. Minimum version check — deferred to after the v1/v2 branch below,
// since each implementation has its own floor (tengu_bridge_min_version
// for v1, tengu_bridge_repl_v2_config.min_version for v2).
// 2. Check OAuth — must be signed in with claude.ai. Runs before the
// policy check so console-auth users get the actionable "/login" hint
// instead of a misleading policy error from a stale/wrong-org cache.
if (!getBridgeAccessToken()) {
logBridgeSkip('no_oauth', '[bridge:repl] Skipping: no OAuth tokens')
onStateChange?.('failed', '/login')
return null
}
// 3. Check organization policy — remote control may be disabled
await waitForPolicyLimitsToLoad()
if (!isPolicyAllowed('allow_remote_control')) {
logBridgeSkip(
'policy_denied',
'[bridge:repl] Skipping: allow_remote_control policy not allowed',
)
onStateChange?.('failed', "disabled by your organization's policy")
return null
}
// When CLAUDE_BRIDGE_OAUTH_TOKEN is set (ant-only local dev), the bridge
// uses that token directly via getBridgeAccessToken() — keychain state is
// irrelevant. Skip 2b/2c to preserve that decoupling: an expired keychain
// token shouldn't block a bridge connection that doesn't use it.
if (!getBridgeTokenOverride()) {
// 2a. Cross-process backoff. If N prior processes already saw this exact
// dead token (matched by expiresAt), skip silently — no event, no refresh
// attempt. The count threshold tolerates transient refresh failures (auth
// server 5xx, lockfile errors per auth.ts:1437/1444/1485): each process
// independently retries until 3 consecutive failures prove the token dead.
// Mirrors useReplBridge's MAX_CONSECUTIVE_INIT_FAILURES for in-process.
// The expiresAt key is content-addressed: /login → new token → new expiresAt
// → this stops matching without any explicit clear.
const cfg = getGlobalConfig()
if (
cfg.bridgeOauthDeadExpiresAt != null &&
(cfg.bridgeOauthDeadFailCount ?? 0) >= 3 &&
getClaudeAIOAuthTokens()?.expiresAt === cfg.bridgeOauthDeadExpiresAt
) {
logForDebugging(
`[bridge:repl] Skipping: cross-process backoff (dead token seen ${cfg.bridgeOauthDeadFailCount} times)`,
)
return null
}
// 2b. Proactively refresh if expired. Mirrors bridgeMain.ts:2096 — the REPL
// bridge fires at useEffect mount BEFORE any v1/messages call, making this
// usually the first OAuth request of the session. Without this, ~9% of
// registrations hit the server with a >8h-expired token → 401 → withOAuthRetry
// recovers, but the server logs a 401 we can avoid. VPN egress IPs observed
// at 30:1 401:200 when many unrelated users cluster at the 8h TTL boundary.
//
// Fresh-token cost: one memoized read + one Date.now() comparison (~µs).
// checkAndRefreshOAuthTokenIfNeeded clears its own cache in every path that
// touches the keychain (refresh success, lockfile race, throw), so no
// explicit clearOAuthTokenCache() here — that would force a blocking
// keychain spawn on the 91%+ fresh-token path.
await checkAndRefreshOAuthTokenIfNeeded()
// 2c. Skip if token is still expired post-refresh-attempt. Env-var / FD
// tokens (auth.ts:894-917) have expiresAt=null → never trip this. But a
// keychain token whose refresh token is dead (password change, org left,
// token GC'd) has expiresAt<now AND refresh just failed — the client would
// otherwise loop 401 forever: withOAuthRetry → handleOAuth401Error →
// refresh fails again → retry with same stale token → 401 again.
// Datadog 2026-03-08: single IPs generating 2,879 such 401s/day. Skip the
// guaranteed-fail API call; useReplBridge surfaces the failure.
//
// Intentionally NOT using isOAuthTokenExpired here — that has a 5-minute
// proactive-refresh buffer, which is the right heuristic for "should
// refresh soon" but wrong for "provably unusable". A token with 3min left
// + transient refresh endpoint blip (5xx/timeout/wifi-reconnect) would
// falsely trip a buffered check; the still-valid token would connect fine.
// Check actual expiry instead: past-expiry AND refresh-failed → truly dead.
const tokens = getClaudeAIOAuthTokens()
if (tokens && tokens.expiresAt !== null && tokens.expiresAt <= Date.now()) {
logBridgeSkip(
'oauth_expired_unrefreshable',
'[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)',
)
onStateChange?.('failed', '/login')
// Persist for the next process. Increments failCount when re-discovering
// the same dead token (matched by expiresAt); resets to 1 for a different
// token. Once count reaches 3, step 2a's early-return fires and this path
// is never reached again — writes are capped at 3 per dead token.
// Local const captures the narrowed type (closure loses !==null narrowing).
const deadExpiresAt = tokens.expiresAt
saveGlobalConfig(c => ({
...c,
bridgeOauthDeadExpiresAt: deadExpiresAt,
bridgeOauthDeadFailCount:
c.bridgeOauthDeadExpiresAt === deadExpiresAt
? (c.bridgeOauthDeadFailCount ?? 0) + 1
: 1,
}))
return null
}
}
// 4. Compute baseUrl — needed by both v1 (env-based) and v2 (env-less)
// paths. Hoisted above the v2 gate so both can use it.
const baseUrl = getBridgeBaseUrl()
// 5. Derive session title. Precedence: explicit initialName → /rename
// (session storage) → last meaningful user message → generated slug.
// Cosmetic only (claude.ai session list); the model never sees it.
// Two flags: `hasExplicitTitle` (initialName or /rename — never auto-
// overwrite) vs. `hasTitle` (any title, including auto-derived — blocks
// the count-1 re-derivation but not count-3). The onUserMessage callback
// (wired to both v1 and v2 below) derives from the 1st prompt and again
// from the 3rd so mobile/web show a title that reflects more context.
// The slug fallback (e.g. "remote-control-graceful-unicorn") makes
// auto-started sessions distinguishable in the claude.ai list before the
// first prompt.
let title = `remote-control-${generateShortWordSlug()}`
let hasTitle = false
let hasExplicitTitle = false
if (initialName) {
title = initialName
hasTitle = true
hasExplicitTitle = true
} else {
const sessionId = getSessionId()
const customTitle = sessionId
? getCurrentSessionTitle(sessionId)
: undefined
if (customTitle) {
title = customTitle
hasTitle = true
hasExplicitTitle = true
} else if (initialMessages && initialMessages.length > 0) {
// Find the last user message that has meaningful content. Skip meta
// (nudges), tool results, compact summaries ("This session is being
// continued…"), non-human origins (task notifications, channel pushes),
// and synthetic interrupts ([Request interrupted by user]) — none are
// human-authored. Same filter as extractTitleText + isSyntheticMessage.
for (let i = initialMessages.length - 1; i >= 0; i--) {
const msg = initialMessages[i]!
if (
msg.type !== 'user' ||
msg.isMeta ||
msg.toolUseResult ||
msg.isCompactSummary ||
(msg.origin && msg.origin.kind !== 'human') ||
isSyntheticMessage(msg)
)
continue
const rawContent = getContentText(msg.message.content)
if (!rawContent) continue
const derived = deriveTitle(rawContent)
if (!derived) continue
title = derived
hasTitle = true
break
}
}
}
// Shared by both v1 and v2 — fires on every title-worthy user message until
// it returns true. At count 1: deriveTitle placeholder immediately, then
// generateSessionTitle (Haiku, sentence-case) fire-and-forget upgrade. At
// count 3: re-generate over the full conversation. Skips entirely if the
// title is explicit (/remote-control <name> or /rename) — re-checks
// sessionStorage at call time so /rename between messages isn't clobbered.
// Skips count 1 if initialMessages already derived (that title is fresh);
// still refreshes at count 3. v2 passes cse_*; updateBridgeSessionTitle
// retags internally.
let userMessageCount = 0
let lastBridgeSessionId: string | undefined
let genSeq = 0
const patch = (
derived: string,
bridgeSessionId: string,
atCount: number,
): void => {
hasTitle = true
title = derived
logForDebugging(
`[bridge:repl] derived title from message ${atCount}: ${derived}`,
)
void updateBridgeSessionTitle(bridgeSessionId, derived, {
baseUrl,
getAccessToken: getBridgeAccessToken,
}).catch(() => {})
}
// Fire-and-forget Haiku generation with post-await guards. Re-checks /rename
// (sessionStorage), v1 env-lost (lastBridgeSessionId), and same-session
// out-of-order resolution (genSeq — count-1's Haiku resolving after count-3
// would clobber the richer title). generateSessionTitle never rejects.
const generateAndPatch = (input: string, bridgeSessionId: string): void => {
const gen = ++genSeq
const atCount = userMessageCount
void generateSessionTitle(input, AbortSignal.timeout(15_000)).then(
generated => {
if (
generated &&
gen === genSeq &&
lastBridgeSessionId === bridgeSessionId &&
!getCurrentSessionTitle(getSessionId())
) {
patch(generated, bridgeSessionId, atCount)
}
},
)
}
const onUserMessage = (text: string, bridgeSessionId: string): boolean => {
if (hasExplicitTitle || getCurrentSessionTitle(getSessionId())) {
return true
}
// v1 env-lost re-creates the session with a new ID. Reset the count so
// the new session gets its own count-3 derivation; hasTitle stays true
// (new session was created via getCurrentTitle(), which reads the count-1
// title from this closure), so count-1 of the fresh cycle correctly skips.
if (
lastBridgeSessionId !== undefined &&
lastBridgeSessionId !== bridgeSessionId
) {
userMessageCount = 0
}
lastBridgeSessionId = bridgeSessionId
userMessageCount++
if (userMessageCount === 1 && !hasTitle) {
const placeholder = deriveTitle(text)
if (placeholder) patch(placeholder, bridgeSessionId, userMessageCount)
generateAndPatch(text, bridgeSessionId)
} else if (userMessageCount === 3) {
const msgs = getMessages?.()
const input = msgs
? extractConversationText(getMessagesAfterCompactBoundary(msgs))
: text
generateAndPatch(input, bridgeSessionId)
}
// Also re-latches if v1 env-lost resets the transport's done flag past 3.
return userMessageCount >= 3
}
const initialHistoryCap = getFeatureValue_CACHED_WITH_REFRESH(
'tengu_bridge_initial_history_cap',
200,
5 * 60 * 1000,
)
// Fetch orgUUID before the v1/v2 branch — both paths need it. v1 for
// environment registration; v2 for archive (which lives at the compat
// /v1/sessions/{id}/archive, not /v1/code/sessions). Without it, v2
// archive 404s and sessions stay alive in CCR after /exit.
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID')
onStateChange?.('failed', '/login')
return null
}
// ── GrowthBook gate: env-less bridge ──────────────────────────────────
// When enabled, skips the Environments API layer entirely (no register/
// poll/ack/heartbeat) and connects directly via POST /bridge → worker_jwt.
// See server PR #292605 (renamed in #293280). REPL-only — daemon/print stay
// on env-based.
//
// NAMING: "env-less" is distinct from "CCR v2" (the /worker/* transport).
// The env-based path below can ALSO use CCR v2 via CLAUDE_CODE_USE_CCR_V2.
// tengu_bridge_repl_v2 gates env-less (no poll loop), not transport version.
//
// perpetual (assistant-mode session continuity via bridge-pointer.json) is
// env-coupled and not yet implemented here — fall back to env-based when set
// so KAIROS users don't silently lose cross-restart continuity.
if (isEnvLessBridgeEnabled() && !perpetual) {
const versionError = await checkEnvLessBridgeMinVersion()
if (versionError) {
logBridgeSkip(
'version_too_old',
`[bridge:repl] Skipping: ${versionError}`,
true,
)
onStateChange?.('failed', 'run `claude update` to upgrade')
return null
}
logForDebugging(
'[bridge:repl] Using env-less bridge path (tengu_bridge_repl_v2)',
)
const { initEnvLessBridgeCore } = await import('./remoteBridgeCore.js')
return initEnvLessBridgeCore({
baseUrl,
orgUUID,
title,
getAccessToken: getBridgeAccessToken,
onAuth401: handleOAuth401Error,
toSDKMessages,
initialHistoryCap,
initialMessages,
// v2 always creates a fresh server session (new cse_* id), so
// previouslyFlushedUUIDs is not passed — there's no cross-session
// UUID collision risk, and the ref persists across enable→disable→
// re-enable cycles which would cause the new session to receive zero
// history (all UUIDs already in the set from the prior enable).
// v1 handles this by calling previouslyFlushedUUIDs.clear() on fresh
// session creation (replBridge.ts:768); v2 skips the param entirely.
onInboundMessage,
onUserMessage,
onPermissionResponse,
onInterrupt,
onSetModel,
onSetMaxThinkingTokens,
onSetPermissionMode,
onStateChange,
outboundOnly,
tags,
})
}
// ── v1 path: env-based (register/poll/ack/heartbeat) ──────────────────
const versionError = checkBridgeMinVersion()
if (versionError) {
logBridgeSkip('version_too_old', `[bridge:repl] Skipping: ${versionError}`)
onStateChange?.('failed', 'run `claude update` to upgrade')
return null
}
// Gather git context — this is the bootstrap-read boundary.
// Everything from here down is passed explicitly to bridgeCore.
const branch = await getBranch()
const gitRepoUrl = await getRemoteUrl()
const sessionIngressUrl =
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
// Assistant-mode sessions advertise a distinct worker_type so the web UI
// can filter them into a dedicated picker. KAIROS guard keeps the
// assistant module out of external builds entirely.
let workerType: BridgeWorkerType = 'claude_code'
if (feature('KAIROS')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { isAssistantMode } =
require('../assistant/index.js') as typeof import('../assistant/index.js')
/* eslint-enable @typescript-eslint/no-require-imports */
if (isAssistantMode()) {
workerType = 'claude_code_assistant'
}
}
// 6. Delegate. BridgeCoreHandle is a structural superset of
// ReplBridgeHandle (adds writeSdkMessages which REPL callers don't use),
// so no adapter needed — just the narrower type on the way out.
return initBridgeCore({
dir: getOriginalCwd(),
machineName: hostname(),
branch,
gitRepoUrl,
title,
baseUrl,
sessionIngressUrl,
workerType,
getAccessToken: getBridgeAccessToken,
createSession: opts =>
createBridgeSession({
...opts,
events: [],
baseUrl,
getAccessToken: getBridgeAccessToken,
}),
archiveSession: sessionId =>
archiveBridgeSession(sessionId, {
baseUrl,
getAccessToken: getBridgeAccessToken,
// gracefulShutdown.ts:407 races runCleanupFunctions against 2s.
// Teardown also does stopWork (parallel) + deregister (sequential),
// so archive can't have the full budget. 1.5s matches v2's
// teardown_archive_timeout_ms default.
timeoutMs: 1500,
}).catch((err: unknown) => {
// archiveBridgeSession has no try/catch — 5xx/timeout/network throw
// straight through. Previously swallowed silently, making archive
// failures BQ-invisible and undiagnosable from debug logs.
logForDebugging(
`[bridge:repl] archiveBridgeSession threw: ${errorMessage(err)}`,
{ level: 'error' },
)
}),
// getCurrentTitle is read on reconnect-after-env-lost to re-title the new
// session. /rename writes to session storage; onUserMessage mutates
// `title` directly — both paths are picked up here.
getCurrentTitle: () => getCurrentSessionTitle(getSessionId()) ?? title,
onUserMessage,
toSDKMessages,
onAuth401: handleOAuth401Error,
getPollIntervalConfig,
initialHistoryCap,
initialMessages,
previouslyFlushedUUIDs,
onInboundMessage,
onPermissionResponse,
onInterrupt,
onSetModel,
onSetMaxThinkingTokens,
onSetPermissionMode,
onStateChange,
perpetual,
})
}
const TITLE_MAX_LEN = 50
/**
* Quick placeholder title: strip display tags, take the first sentence,
* collapse whitespace, truncate to 50 chars. Returns undefined if the result
* is empty (e.g. message was only <local-command-stdout>). Replaced by
* generateSessionTitle once Haiku resolves (~1-15s).
*/
function deriveTitle(raw: string): string | undefined {
// Strip <ide_opened_file>, <session-start-hook>, etc. — these appear in
// user messages when IDE/hooks inject context. stripDisplayTagsAllowEmpty
// returns '' (not the original) so pure-tag messages are skipped.
const clean = stripDisplayTagsAllowEmpty(raw)
// First sentence is usually the intent; rest is often context/detail.
// Capture group instead of lookbehind — keeps YARR JIT happy.
const firstSentence = /^(.*?[.!?])\s/.exec(clean)?.[1] ?? clean
// Collapse newlines/tabs — titles are single-line in the claude.ai list.
const flat = firstSentence.replace(/\s+/g, ' ').trim()
if (!flat) return undefined
return flat.length > TITLE_MAX_LEN
? flat.slice(0, TITLE_MAX_LEN - 1) + '\u2026'
: flat
}
+256
View File
@@ -0,0 +1,256 @@
import { logEvent } from '../services/analytics/index.js'
import { logForDebugging } from '../utils/debug.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
import { errorMessage } from '../utils/errors.js'
import { jsonParse } from '../utils/slowOperations.js'
/** Format a millisecond duration as a human-readable string (e.g. "5m 30s"). */
function formatDuration(ms: number): string {
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
const m = Math.floor(ms / 60_000)
const s = Math.round((ms % 60_000) / 1000)
return s > 0 ? `${m}m ${s}s` : `${m}m`
}
/**
* Decode a JWT's payload segment without verifying the signature.
* Strips the `sk-ant-si-` session-ingress prefix if present.
* Returns the parsed JSON payload as `unknown`, or `null` if the
* token is malformed or the payload is not valid JSON.
*/
export function decodeJwtPayload(token: string): unknown | null {
const jwt = token.startsWith('sk-ant-si-')
? token.slice('sk-ant-si-'.length)
: token
const parts = jwt.split('.')
if (parts.length !== 3 || !parts[1]) return null
try {
return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8'))
} catch {
return null
}
}
/**
* Decode the `exp` (expiry) claim from a JWT without verifying the signature.
* @returns The `exp` value in Unix seconds, or `null` if unparseable
*/
export function decodeJwtExpiry(token: string): number | null {
const payload = decodeJwtPayload(token)
if (
payload !== null &&
typeof payload === 'object' &&
'exp' in payload &&
typeof payload.exp === 'number'
) {
return payload.exp
}
return null
}
/** Refresh buffer: request a new token before expiry. */
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
/** Fallback refresh interval when the new token's expiry is unknown. */
const FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
/** Max consecutive failures before giving up on the refresh chain. */
const MAX_REFRESH_FAILURES = 3
/** Retry delay when getAccessToken returns undefined. */
const REFRESH_RETRY_DELAY_MS = 60_000
/**
* Creates a token refresh scheduler that proactively refreshes session tokens
* before they expire. Used by both the standalone bridge and the REPL bridge.
*
* When a token is about to expire, the scheduler calls `onRefresh` with the
* session ID and the bridge's OAuth access token. The caller is responsible
* for delivering the token to the appropriate transport (child process stdin
* for standalone bridge, WebSocket reconnect for REPL bridge).
*/
export function createTokenRefreshScheduler({
getAccessToken,
onRefresh,
label,
refreshBufferMs = TOKEN_REFRESH_BUFFER_MS,
}: {
getAccessToken: () => string | undefined | Promise<string | undefined>
onRefresh: (sessionId: string, oauthToken: string) => void
label: string
/** How long before expiry to fire refresh. Defaults to 5 min. */
refreshBufferMs?: number
}): {
schedule: (sessionId: string, token: string) => void
scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void
cancel: (sessionId: string) => void
cancelAll: () => void
} {
const timers = new Map<string, ReturnType<typeof setTimeout>>()
const failureCounts = new Map<string, number>()
// Generation counter per session — incremented by schedule() and cancel()
// so that in-flight async doRefresh() calls can detect when they've been
// superseded and should skip setting follow-up timers.
const generations = new Map<string, number>()
function nextGeneration(sessionId: string): number {
const gen = (generations.get(sessionId) ?? 0) + 1
generations.set(sessionId, gen)
return gen
}
function schedule(sessionId: string, token: string): void {
const expiry = decodeJwtExpiry(token)
if (!expiry) {
// Token is not a decodable JWT (e.g. an OAuth token passed from the
// REPL bridge WebSocket open handler). Preserve any existing timer
// (such as the follow-up refresh set by doRefresh) so the refresh
// chain is not broken.
logForDebugging(
`[${label}:token] Could not decode JWT expiry for sessionId=${sessionId}, token prefix=${token.slice(0, 15)}…, keeping existing timer`,
)
return
}
// Clear any existing refresh timer — we have a concrete expiry to replace it.
const existing = timers.get(sessionId)
if (existing) {
clearTimeout(existing)
}
// Bump generation to invalidate any in-flight async doRefresh.
const gen = nextGeneration(sessionId)
const expiryDate = new Date(expiry * 1000).toISOString()
const delayMs = expiry * 1000 - Date.now() - refreshBufferMs
if (delayMs <= 0) {
logForDebugging(
`[${label}:token] Token for sessionId=${sessionId} expires=${expiryDate} (past or within buffer), refreshing immediately`,
)
void doRefresh(sessionId, gen)
return
}
logForDebugging(
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires=${expiryDate}, buffer=${refreshBufferMs / 1000}s)`,
)
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
timers.set(sessionId, timer)
}
/**
* Schedule refresh using an explicit TTL (seconds until expiry) rather
* than decoding a JWT's exp claim. Used by callers whose JWT is opaque
* (e.g. POST /v1/code/sessions/{id}/bridge returns expires_in directly).
*/
function scheduleFromExpiresIn(
sessionId: string,
expiresInSeconds: number,
): void {
const existing = timers.get(sessionId)
if (existing) clearTimeout(existing)
const gen = nextGeneration(sessionId)
// Clamp to 30s floor — if refreshBufferMs exceeds the server's expires_in
// (e.g. very large buffer for frequent-refresh testing, or server shortens
// expires_in unexpectedly), unclamped delayMs ≤ 0 would tight-loop.
const delayMs = Math.max(expiresInSeconds * 1000 - refreshBufferMs, 30_000)
logForDebugging(
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires_in=${expiresInSeconds}s, buffer=${refreshBufferMs / 1000}s)`,
)
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
timers.set(sessionId, timer)
}
async function doRefresh(sessionId: string, gen: number): Promise<void> {
let oauthToken: string | undefined
try {
oauthToken = await getAccessToken()
} catch (err) {
logForDebugging(
`[${label}:token] getAccessToken threw for sessionId=${sessionId}: ${errorMessage(err)}`,
{ level: 'error' },
)
}
// If the session was cancelled or rescheduled while we were awaiting,
// the generation will have changed — bail out to avoid orphaned timers.
if (generations.get(sessionId) !== gen) {
logForDebugging(
`[${label}:token] doRefresh for sessionId=${sessionId} stale (gen ${gen} vs ${generations.get(sessionId)}), skipping`,
)
return
}
if (!oauthToken) {
const failures = (failureCounts.get(sessionId) ?? 0) + 1
failureCounts.set(sessionId, failures)
logForDebugging(
`[${label}:token] No OAuth token available for refresh, sessionId=${sessionId} (failure ${failures}/${MAX_REFRESH_FAILURES})`,
{ level: 'error' },
)
logForDiagnosticsNoPII('error', 'bridge_token_refresh_no_oauth')
// Schedule a retry so the refresh chain can recover if the token
// becomes available again (e.g. transient cache clear during refresh).
// Cap retries to avoid spamming on genuine failures.
if (failures < MAX_REFRESH_FAILURES) {
const retryTimer = setTimeout(
doRefresh,
REFRESH_RETRY_DELAY_MS,
sessionId,
gen,
)
timers.set(sessionId, retryTimer)
}
return
}
// Reset failure counter on successful token retrieval
failureCounts.delete(sessionId)
logForDebugging(
`[${label}:token] Refreshing token for sessionId=${sessionId}: new token prefix=${oauthToken.slice(0, 15)}`,
)
logEvent('tengu_bridge_token_refreshed', {})
onRefresh(sessionId, oauthToken)
// Schedule a follow-up refresh so long-running sessions stay authenticated.
// Without this, the initial one-shot timer leaves the session vulnerable
// to token expiry if it runs past the first refresh window.
const timer = setTimeout(
doRefresh,
FALLBACK_REFRESH_INTERVAL_MS,
sessionId,
gen,
)
timers.set(sessionId, timer)
logForDebugging(
`[${label}:token] Scheduled follow-up refresh for sessionId=${sessionId} in ${formatDuration(FALLBACK_REFRESH_INTERVAL_MS)}`,
)
}
function cancel(sessionId: string): void {
// Bump generation to invalidate any in-flight async doRefresh.
nextGeneration(sessionId)
const timer = timers.get(sessionId)
if (timer) {
clearTimeout(timer)
timers.delete(sessionId)
}
failureCounts.delete(sessionId)
}
function cancelAll(): void {
// Bump all generations so in-flight doRefresh calls are invalidated.
for (const sessionId of generations.keys()) {
nextGeneration(sessionId)
}
for (const timer of timers.values()) {
clearTimeout(timer)
}
timers.clear()
failureCounts.clear()
}
return { schedule, scheduleFromExpiresIn, cancel, cancelAll }
}

Some files were not shown because too many files have changed in this diff Show More