agentus 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/README.zh-CN.md +396 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/package.json +5 -4
- package/packages/server/mock/agent.mjs +35 -0
- package/packages/server/src/fs.ts +216 -2
- package/packages/server/src/index.ts +87 -5
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/term.ts +69 -28
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-B52l55_4.css +0 -1
- package/packages/web/dist/assets/index-BRgC7oYt.js +0 -246
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# 安装 Agentus
|
|
2
|
+
|
|
3
|
+
[English](./installation.md) · [README](../README.md)
|
|
4
|
+
|
|
5
|
+
Agentus 是面向「说 ACP 协议的编码 agent」的多会话 Web 驾驶舱。一个 npm 包(`agentus`)跑起一个
|
|
6
|
+
Node 服务:它提供构建好的 React 单页应用,并按需把 agent CLI(`hermes acp` / `qodercli --acp`)
|
|
7
|
+
作为子进程拉起。浏览器负责驾驶会话,推理始终留在 CLI 子进程里。
|
|
8
|
+
|
|
9
|
+
## 前置条件
|
|
10
|
+
|
|
11
|
+
必须满足两条:
|
|
12
|
+
|
|
13
|
+
- **Node >= 22.5。** 存储层用的是 `node:sqlite`,它在 22.5 才落地。版本不够时 Agentus 会直接拒绝
|
|
14
|
+
启动,并给出明确提示。
|
|
15
|
+
- **PATH 上有一个 ACP agent CLI** —— `hermes acp` 或 `qodercli --acp`。这是 npm 唯一无法替你装的
|
|
16
|
+
前置项。没有它,驾驶舱照样能跑、也能登录,只是没有后端可以开会话。
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 检查 agent 侧(Hermes 会自检;qoder 需要先登录)
|
|
20
|
+
hermes acp --check
|
|
21
|
+
qodercli login # 否则 newSession 会以 -32000 失败
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
装 Node 本身很简单:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
nvm install 22 && nvm use 22 # 或者
|
|
28
|
+
brew install node # 或者 https://nodejs.org/en/download
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
### 一条命令 —— 用 npm 上发布的包(对外)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx agentus # 跑一次,不往全局装
|
|
37
|
+
npm i -g agentus # 或者装上,然后直接跑 `agentus`
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`npx agentus` 在前台运行驾驶舱 —— Ctrl-C 即停,并且会把它拉起的 agent 子进程一并带走。
|
|
41
|
+
|
|
42
|
+
### 一条命令 —— 仓里的安装脚本
|
|
43
|
+
|
|
44
|
+
和上面同一件事,把检查与回退写在脚本里(**绝不**用 `sudo`):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
curl -fsSL https://raw.githubusercontent.com/agentus-org/agentus/main/scripts/install.sh | bash
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
安装脚本(`scripts/install.sh`)会检查 Node、用 npm 全局装上已发布的 `agentus` 包;如果全局目录
|
|
51
|
+
不可写,它会退回到用户级前缀(`~/.local`),**绝不**动 `sudo`。装完会打印启动命令和要打开的网址。
|
|
52
|
+
除非你显式设置 `AGENTUS_ALLOW_ROOT=1`,它拒绝以 root 运行。
|
|
53
|
+
|
|
54
|
+
可用环境变量覆盖默认行为:
|
|
55
|
+
|
|
56
|
+
| 变量 | 默认值 | 作用 |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `AGENTUS_VERSION` | `latest` | 固定安装的版本,例如 `0.1.0` |
|
|
59
|
+
| `AGENTUS_INSTALL_DIR` | 未设置 | 安装到该 `npm --prefix`,而不是全局目录 |
|
|
60
|
+
| `AGENTUS_REGISTRY` | npm 官方源 | 拉取用的源 |
|
|
61
|
+
| `AGENTUS_ALLOW_ROOT` | `0` | `1` 允许以 root 运行 |
|
|
62
|
+
| `AGENTUS_NODE_MIN` | `22.5` | 覆盖 Node 版本下限检查 |
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# 固定版本 + 指定前缀
|
|
66
|
+
AGENTUS_VERSION=0.1.0 AGENTUS_INSTALL_DIR="$HOME/.local" \
|
|
67
|
+
bash scripts/install.sh
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`npx agentus` 在前台运行驾驶舱 —— Ctrl-C 即停,并且会把它拉起的 agent 子进程一并带走。
|
|
71
|
+
|
|
72
|
+
### 从源码(一份 checkout)
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone https://github.com/agentus-org/agentus.git
|
|
76
|
+
cd agentus
|
|
77
|
+
npm start # 需要时自动装依赖、构建、起服务(scripts/start.sh)
|
|
78
|
+
npm run dev -w @agentus/web # 前端热更新(Vite :5173,局域网可达)
|
|
79
|
+
npm run agentus # 与 `npx agentus` 同一个 CLI,直接从 checkout 里跑
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`npm start`(→ `scripts/start.sh`)会检查 Node 版本,`node_modules` 缺失时以
|
|
83
|
+
`NODE_ENV=development` 装依赖,前端产物过期时重新构建,然后只起一次服务(无 watcher)。
|
|
84
|
+
用 `AGENTUS_PORT=9000 npm start` 换端口。
|
|
85
|
+
|
|
86
|
+
## 命令行
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
agentus [options]
|
|
90
|
+
|
|
91
|
+
-p, --port <n> 监听端口 (AGENTUS_PORT,默认 8787)
|
|
92
|
+
-d, --data <dir> 状态目录(sqlite、登录、TLS 材料、设置)
|
|
93
|
+
(AGENTUS_DATA,默认 ~/.agentus)
|
|
94
|
+
-o, --open 就绪后自动在浏览器打开驾驶舱
|
|
95
|
+
--where 打印解析出的路径后退出(不启动任何东西)
|
|
96
|
+
-h, --help 帮助文本
|
|
97
|
+
-v, --version 打印版本
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`--where` 值得记住:它会打印服务端**实际**解析出的路径 —— 数据目录、前端产物、随附 APK、TLS
|
|
101
|
+
监听、Node 版本 —— 然后退出,什么都不启动。它是「我的数据去哪了」的唯一权威答案(见下文)。
|
|
102
|
+
|
|
103
|
+
## 首次运行
|
|
104
|
+
|
|
105
|
+
1. 启动:`agentus`(或 `npx agentus`,或 checkout 里的 `npm start`)。
|
|
106
|
+
2. 打开 **http://localhost:8787** 登录。默认凭据:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
username: admin
|
|
110
|
+
password: 123456 ← 请改掉
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
在应用内改:**设置 → 账号**(第一张卡片)。改了立即生效,并写入
|
|
114
|
+
`<DATA_DIR>/credentials.json`(0600,scrypt 哈希)。
|
|
115
|
+
3. 添加后端。后端注册表在首次启动时用内置行播种(见[后端](#后端));确认 `hermes` 或 `qoder`
|
|
116
|
+
能解析到,或把某一行指向你自己的命令。
|
|
117
|
+
4. **+ new session** → 选一个后端 + 一个工作目录 → 开会话。
|
|
118
|
+
|
|
119
|
+
你启动的第一个会话会使用你自己 agent 已经在用的 Hermes home(`~/.hermes`),除非后端行里指定了
|
|
120
|
+
另一个 —— 见 [HERMES_HOME](#hermes_home)。
|
|
121
|
+
|
|
122
|
+
## 配置参考
|
|
123
|
+
|
|
124
|
+
### 环境变量
|
|
125
|
+
|
|
126
|
+
| 变量 | 默认值 | 含义 |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `AGENTUS_PORT` | `8787` | 服务端口(从不读裸 `PORT`) |
|
|
129
|
+
| `AGENTUS_DATA` | `~/.agentus` | 状态目录;见[数据目录](#数据目录) |
|
|
130
|
+
| `AGENTUS_HERMES_CMD` | `hermes` | hermes 后端要拉起的可执行文件 |
|
|
131
|
+
| `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | qoder 后端同理 |
|
|
132
|
+
| `AGENTUS_HERMES_HOME` | `~/.hermes` | 未在行里指定 home 时的默认 `HERMES_HOME` |
|
|
133
|
+
| `AGENTUS_PERM_TIMEOUT_MS` | `300000`(5 分钟) | 权限提示自动取消前的等待时间 |
|
|
134
|
+
| `AGENTUS_HISTORY_PAGE` | `500` | 对话记录分页大小 |
|
|
135
|
+
| `AGENTUS_TLS_PORT` | 有证书时 `8443`,否则关闭 | TLS 监听端口(`0` = 关闭) |
|
|
136
|
+
| `AGENTUS_TLS_CERT` / `AGENTUS_TLS_KEY` | `<DATA_DIR>/tls/{cert,key}.pem` | 该监听用的证书材料 |
|
|
137
|
+
| `AGENTUS_TLS_CA` | `<DATA_DIR>/tls/ca.pem` | 设备只需装一次的根证书 |
|
|
138
|
+
| `AGENTUS_TTS_BASE_URL` | 未设置 | 服务端语音合成的 OpenAI 兼容 base(`…/v1`);不设 = 只用浏览器语音 |
|
|
139
|
+
| `AGENTUS_TTS_API_KEY` / `AGENTUS_TTS_MODEL` / `AGENTUS_TTS_VOICE` | – / `tts-1` / `alloy` | 同上 |
|
|
140
|
+
| `AGENTUS_STT_BASE_URL` | 未设置 | 服务端语音识别的 OpenAI 兼容 base;不设 = 只用浏览器识别 |
|
|
141
|
+
| `AGENTUS_STT_API_KEY` / `AGENTUS_STT_MODEL` / `AGENTUS_STT_LANGUAGE` | – / `whisper-1` / – | 同上 |
|
|
142
|
+
| `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` | – | 百炼(DashScope)凭据;从进程环境**和** `~/.hermes/.env` 读取 |
|
|
143
|
+
|
|
144
|
+
认证相关:
|
|
145
|
+
|
|
146
|
+
| 变量 | 默认值 | 含义 |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `AGENTUS_AUTH` | `on` | `off` = 完全不要登录(仅供本机折腾) |
|
|
149
|
+
| `AGENTUS_USERNAME` | `admin` | 操作员用户名 |
|
|
150
|
+
| `AGENTUS_PASSWORD` | `123456` | 操作员密码(环境里是明文) |
|
|
151
|
+
| `AGENTUS_PASSWORD_HASH` | — | `scrypt:<salt>:<hex>`;设了就优先于 `AGENTUS_PASSWORD` |
|
|
152
|
+
| `AGENTUS_SESSION_TTL_MS` | 604800000(7 天) | 会话有效期 |
|
|
153
|
+
| `AGENTUS_AUTH_SECRET` | `<DATA_DIR>/auth.secret` | cookie 签名密钥(0600,自动生成) |
|
|
154
|
+
| `AGENTUS_AUTH_TOKEN` | `<DATA_DIR>/auth.token` | 供脚本用的机器令牌(0600,自动生成) |
|
|
155
|
+
| `AGENTUS_LOGIN_MAX_FAILS` / `AGENTUS_LOGIN_LOCK_MS` | `5` / `30000` | 按 IP 的暴力破解锁定 |
|
|
156
|
+
| `AGENTUS_BASIC_AUTH` | — | 挡在**所有**请求前的 HTTP Basic(`:pass` 只校验密码) |
|
|
157
|
+
|
|
158
|
+
语音/主题类的环境变量只是**引导值**:之后由设置页(侧栏 ⚙)接管,存到 `<DATA_DIR>/settings.json`
|
|
159
|
+
(0600)。页面里的设置优先;环境变量的作用是让一份全新安装在没人打开设置页之前也能正常工作。
|
|
160
|
+
|
|
161
|
+
### 数据目录
|
|
162
|
+
|
|
163
|
+
默认 `~/.agentus`。里面有 `agentus.sqlite`(会话 + 记录)、`credentials.json`、`auth.token`、
|
|
164
|
+
`auth.secret`、`tls/`、`settings.json`。首次启动时以 `0700` 创建,启动日志会打印它**以及为什么是
|
|
165
|
+
这个目录胜出**:
|
|
166
|
+
|
|
167
|
+
| 顺序 | 规则 |
|
|
168
|
+
|---|---|
|
|
169
|
+
| 1 | 设置了 `AGENTUS_DATA` 时用它 —— 每个启动器和 dev/QA 实例都钉住这个 |
|
|
170
|
+
| 2 | `~/.agentus`,当它已有内容,或仓内还没有数据时 |
|
|
171
|
+
| 3 | `<repo>/packages/server/.data` —— 早于这套布局的既有 checkout |
|
|
172
|
+
|
|
173
|
+
规则 3 的存在是为了让「悄悄换了目录」的安装不会以空的会话列表启动(那读起来就像是「我的会话
|
|
174
|
+
都不见了」)。要迁移这样的 checkout,用 `AGENTUS_DATA=~/.agentus` 启动一次即可。`agentus --where`
|
|
175
|
+
会打印服务端解析的结果,不用靠猜。
|
|
176
|
+
|
|
177
|
+
任何东西都不会写进 `node_modules`:对已安装的包来说,`<pkg>/packages/server/.data` 会被下一次
|
|
178
|
+
`npm i -g agentus` 删掉,连带记录、登录和 TLS 密钥一起没了。
|
|
179
|
+
|
|
180
|
+
### 端口
|
|
181
|
+
|
|
182
|
+
| 端口 | 协议 | 用途 |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `AGENTUS_PORT`(8787) | 纯 HTTP | 局域网、本机回环、`curl`、脚本 —— 无证书警告,CI 不变 |
|
|
185
|
+
| `AGENTUS_TLS_PORT`(8443) | HTTPS(自签) | **隧道** —— 加密公网那一跳 |
|
|
186
|
+
| 5173 | Vite 开发服务器 | 前端热更新,仅在 `npm run dev` 期间 |
|
|
187
|
+
|
|
188
|
+
两个监听的 handler、路由、鉴权完全相同,只有 socket 不同。
|
|
189
|
+
|
|
190
|
+
### 登录
|
|
191
|
+
|
|
192
|
+
驾驶舱默认在登录后面。存在两种凭据:
|
|
193
|
+
|
|
194
|
+
| 凭据 | 怎么传递 | 谁在用 |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| 会话 cookie | `HttpOnly` + `SameSite=Lax`,HMAC-SHA256 签名,7 天 | 浏览器(`POST /api/auth/login` 签发) |
|
|
197
|
+
| 机器令牌 | `Authorization: Bearer <t>` 或 `?token=<t>` | 脚本、CI、启动器 —— 从 `<DATA_DIR>/auth.token` 读(0600) |
|
|
198
|
+
|
|
199
|
+
会话是无状态 cookie,服务端索引在 `<DATA_DIR>/sessions.json`(0600),所以吊销和「登出所有设备」
|
|
200
|
+
是真实生效的,且能跨重启存活。`AGENTUS_USERNAME` / `AGENTUS_PASSWORD` / `AGENTUS_PASSWORD_HASH`
|
|
201
|
+
是引导值;一旦你在「账号」卡片里保存过,就以文件为准。删掉 `credentials.json` 即可回退到环境变量。
|
|
202
|
+
|
|
203
|
+
### TLS
|
|
204
|
+
|
|
205
|
+
纯 TCP 隧道(SakuraFrp、`ssh -L`、多数 frp 配置)只是转发字节 —— 它**不会**替你终结 TLS。所以
|
|
206
|
+
服务端自己会讲 TLS,落在**第二个**监听上:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
scripts/make-cert.sh # 自签根证书 + 短期叶证书,SAN = 你实际输入的名字
|
|
210
|
+
# -> $AGENTUS_DATA/tls/{ca.pem,cert.pem,key.pem} (key 0600,已被 git 忽略)
|
|
211
|
+
# 启动日志会写:https://0.0.0.0:8443 (self-signed …) ; 把隧道指向这个端口
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
该监听还会在 `/cert.crt` 上提供自己的签发证书,设备只需装一次根证书:
|
|
215
|
+
`https://<你的名字>:<tls-port>/cert.crt`。iOS 会直接打开描述文件安装器;Android 会下载它
|
|
216
|
+
(设置 → 安全 → CA 证书);iOS 还需要再到 *关于本机 → 证书信任设置 → 启用*。这是公开材料 ——
|
|
217
|
+
私钥永远不会离开 `<DATA_DIR>/tls/`。
|
|
218
|
+
|
|
219
|
+
`make-cert.sh` 有意签**两**张证书:一张 10 年的根(`ca.pem`,设备装它)和一张 390 天的叶
|
|
220
|
+
(`cert.pem`,监听提供它)。Apple 把服务器证书有效期卡在 398 天,所以一张 10 年的叶证书正是
|
|
221
|
+
iOS 在用户走完安装流程**之后**才拒绝的东西。叶证书可以随意轮换(`--leaf-only`),已装根证书的
|
|
222
|
+
设备不受影响。
|
|
223
|
+
|
|
224
|
+
### 后端
|
|
225
|
+
|
|
226
|
+
| id | 命令 | 说明 |
|
|
227
|
+
|---|---|---|
|
|
228
|
+
| `hermes` | `hermes acp` | 需要 PATH 上有 Hermes 安装(`hermes acp --check`) |
|
|
229
|
+
| `qoder` | `qodercli --acp` | 需要先 `qodercli login`,否则 `newSession` 以 `-32000` 失败 |
|
|
230
|
+
| `mock` | `node packages/server/mock/agent.mjs` | 离线开发/QA:假流式、权限提示、崩溃/规划/工具触发 |
|
|
231
|
+
|
|
232
|
+
新增后端就是 `packages/server/src/acp/backends.ts` 里的一行;协议层永远不变。
|
|
233
|
+
|
|
234
|
+
## HERMES_HOME
|
|
235
|
+
|
|
236
|
+
被拉起的 agent CLI 会继承服务端的环境,所以服务端**总是显式设置 `HERMES_HOME`**:行里指定了就用
|
|
237
|
+
行里的,否则用默认 —— **你真实的 `~/.hermes`**,也就是你的 gateway 和 Studio 正打开的那个
|
|
238
|
+
`state.db`。这就是预期的生产设置:一个会话驱动的是你真正的 agent,带着你的配置、凭据、记忆和会话
|
|
239
|
+
列表。
|
|
240
|
+
|
|
241
|
+
**继承来的** `HERMES_HOME` 会被忽略,所以会话往哪写永远是你选定的东西 —— 在后端行上,或经由
|
|
242
|
+
`AGENTUS_HERMES_HOME`。要让某个会话的数据独立,就在那行的 HERMES_HOME 里写一个目录,不需要任何
|
|
243
|
+
许可。
|
|
244
|
+
|
|
245
|
+
## 作为服务常驻
|
|
246
|
+
|
|
247
|
+
Agentus 默认在前台跑;一个服务单元能让它跨登录、跨重启常驻。把单元指向你平时用的同一个
|
|
248
|
+
`AGENTUS_DATA`,否则驾驶舱会带着另一份会话列表起来。
|
|
249
|
+
|
|
250
|
+
**macOS(launchd)** —— `~/Library/LaunchAgents/org.agentus.cockpit.plist`:
|
|
251
|
+
|
|
252
|
+
```xml
|
|
253
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
254
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
255
|
+
<plist version="1.0">
|
|
256
|
+
<dict>
|
|
257
|
+
<key>Label</key> <string>org.agentus.cockpit</string>
|
|
258
|
+
<key>ProgramArguments</key>
|
|
259
|
+
<array>
|
|
260
|
+
<string>/usr/bin/env</string><string>agentus</string><string>--port</string><string>8787</string>
|
|
261
|
+
</array>
|
|
262
|
+
<key>EnvironmentVariables</key>
|
|
263
|
+
<dict><key>AGENTUS_DATA</key><string>/Users/YOU/.agentus</string></dict>
|
|
264
|
+
<key>RunAtLoad</key> <true/>
|
|
265
|
+
<key>KeepAlive</key> <true/>
|
|
266
|
+
<key>StandardOutPath</key> <string>/tmp/agentus.out.log</string>
|
|
267
|
+
<key>StandardErrorPath</key><string>/tmp/agentus.err.log</string>
|
|
268
|
+
</dict>
|
|
269
|
+
</plist>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
launchctl load ~/Library/LaunchAgents/org.agentus.cockpit.plist
|
|
274
|
+
launchctl kickstart -k gui/$(id -u)/org.agentus.cockpit # 重启
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Linux(systemd,用户单元)** —— `~/.config/systemd/user/agentus.service`:
|
|
278
|
+
|
|
279
|
+
```ini
|
|
280
|
+
[Unit]
|
|
281
|
+
Description=Agentus cockpit
|
|
282
|
+
After=network.target
|
|
283
|
+
|
|
284
|
+
[Service]
|
|
285
|
+
ExecStart=/usr/bin/env agentus --port 8787
|
|
286
|
+
Environment=AGENTUS_DATA=%h/.agentus
|
|
287
|
+
Restart=on-failure
|
|
288
|
+
|
|
289
|
+
[Install]
|
|
290
|
+
WantedBy=default.target
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
systemctl --user daemon-reload
|
|
295
|
+
systemctl --user enable --now agentus
|
|
296
|
+
journalctl --user -u agentus -f
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
TODO(verify):launchd/systemd 单元里 `agentus` 能否被 `env` 解析,取决于 npm 把 bin 装到了哪
|
|
300
|
+
(用户级前缀默认不在服务进程的 PATH 上)—— 如果单元起不来,把 `ExecStart` / `ProgramArguments`
|
|
301
|
+
改成 `agentus` bin 的绝对路径。
|
|
302
|
+
|
|
303
|
+
## 升级
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
npm i -g agentus@latest # 或者:npx agentus@latest … 跑一次
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
状态不受影响 —— `~/.agentus`(或 `AGENTUS_DATA` 指向的地方)不在包内,升级不会删掉它。如果你
|
|
310
|
+
用 `AGENTUS_INSTALL_DIR=~/.local` 装到了某个前缀,就用同一个前缀升级。启动时服务端会一次性接管
|
|
311
|
+
既有会话 cookie,而不是把所有设备登出。
|
|
312
|
+
|
|
313
|
+
## 卸载
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
npm rm -g agentus # 删程序(状态保留)
|
|
317
|
+
rm -rf ~/.agentus # 删状态 —— SQLite、登录、TLS 密钥、设置
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
装到用户级前缀的话用 `npm rm -g --prefix ~/.local agentus`。卸载**不会**碰 `~/.hermes`(你 agent
|
|
321
|
+
自己的数据),也不会碰任何工作目录。
|
|
322
|
+
|
|
323
|
+
## 排错
|
|
324
|
+
|
|
325
|
+
**端口被占用。** 用 `agentus --port 9000`,或找出并停掉占用者:
|
|
326
|
+
`lsof -nP -iTCP:8787 -sTCP:LISTEN`。Agentus 从不读裸 `PORT`,原因正在于此。
|
|
327
|
+
|
|
328
|
+
**「没有后端」/ 会话起不来。** 缺少 agent CLI,或它没登录。跑 `hermes acp --check`,或
|
|
329
|
+
`qodercli login`(`newSession` 报 `-32000` 就是 qoder 没登录)。在设置里检查后端行的命令;启动
|
|
330
|
+
日志和「探测」都会说明命令能否解析。
|
|
331
|
+
|
|
332
|
+
**登录问题。**
|
|
333
|
+
|
|
334
|
+
- 忘了密码:删掉 `<DATA_DIR>/credentials.json` 并重启 —— `AGENTUS_USERNAME` / `AGENTUS_PASSWORD`
|
|
335
|
+
的引导值(或默认值)重新生效。
|
|
336
|
+
- 被暴力破解限制器锁了:**设置 → 登录失败锁定** 会列出被锁的 IP,可 unlock / unlock all。
|
|
337
|
+
- 彻底进不去(登录不了就够不到设置页):删掉 `<DATA_DIR>/sessions.json` 丢掉会话索引,或重启
|
|
338
|
+
服务 —— 内存里的锁定会在启动时清除。
|
|
339
|
+
|
|
340
|
+
**「我的会话不见了。」** 服务端读的是另一个数据目录。跑 `agentus --where` —— 它会打印数据目录
|
|
341
|
+
**以及是哪条规则选中的它**(见本页开头)。常见原因是:某个既有 checkout 的会话在
|
|
342
|
+
`<repo>/packages/server/.data`,而现在的安装默认到了 `~/.agentus`。用 `AGENTUS_DATA` 指向真正有
|
|
343
|
+
数据的那份启动一次,例如 `AGENTUS_DATA=~/.agentus agentus` 或
|
|
344
|
+
`AGENTUS_DATA=<repo>/packages/server/.data agentus`。
|
|
345
|
+
|
|
346
|
+
**页面能打开但一直显示「reconnecting」。** 某条隧道丢掉了 WebSocket 的 `Upgrade`。实时流走
|
|
347
|
+
`/ws`;请换成能透传 WebSocket 的隧道(SakuraFrp 的 TCP+auto-HTTPS 隧道已验证可用)。
|
|
348
|
+
|
|
349
|
+
**局域网里出现证书警告。** 那是 TLS 监听的自签证书(裸局域网 IP 无法承载可信证书)。要么在
|
|
350
|
+
局域网里用纯 HTTP 的 `:8787` 端口,要么从 `/cert.crt` 装一次签发证书(见 [TLS](#tls))。
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/
|
|
8
|
+
"url": "git+https://github.com/agentus-org/agentus.git"
|
|
9
9
|
},
|
|
10
|
-
"homepage": "https://github.com/
|
|
10
|
+
"homepage": "https://github.com/agentus-org/agentus#readme",
|
|
11
11
|
"bugs": {
|
|
12
|
-
"url": "https://github.com/
|
|
12
|
+
"url": "https://github.com/agentus-org/agentus/issues"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
15
|
"agentus": "bin/agentus.mjs"
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"live-stream": "tsx scripts/qa/live-stream.mts",
|
|
66
66
|
"open-target": "tsx scripts/qa/open-target.mts",
|
|
67
67
|
"rail-recent": "tsx scripts/qa/rail-recent.mts",
|
|
68
|
+
"turn-pulse": "tsx scripts/qa/turn-pulse.mts",
|
|
68
69
|
"resume-tail": "tsx scripts/qa/resume-tail.mts",
|
|
69
70
|
"render-lag-sweep": "node scripts/qa/render-lag-sweep.mjs",
|
|
70
71
|
"replay-window": "node scripts/qa/replay-window.mjs",
|
|
@@ -401,6 +401,41 @@ const agent = () => ({
|
|
|
401
401
|
// per-turn slow mode: long stream so QA can drop the socket mid-turn (AC6)
|
|
402
402
|
const slow = /\[slow\]/.test(text) ? Math.max(SLOW, 900) : SLOW;
|
|
403
403
|
|
|
404
|
+
// `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
|
|
405
|
+
// Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
|
|
406
|
+
// relative image, so "clicking a header reorders the rows" and "a relative path resolves" are
|
|
407
|
+
// measurable claims instead of a screenshot someone has to squint at. The directory is
|
|
408
|
+
// overridable because the sweep creates its own fixtures.
|
|
409
|
+
if (/\[table\]/.test(text)) {
|
|
410
|
+
const dir = process.env.MOCK_TABLE_DIR || "/tmp/agentus-qa-files";
|
|
411
|
+
const rows = [
|
|
412
|
+
["zeta", "10", "1.2 MB"],
|
|
413
|
+
["alpha", "9", "900 kB"],
|
|
414
|
+
["mu", "100", "12 MB"],
|
|
415
|
+
["beta", "2", "3 kB"],
|
|
416
|
+
["kappa", "33", "44 kB"],
|
|
417
|
+
["gamma", "7", "8 kB"],
|
|
418
|
+
];
|
|
419
|
+
const reply = [
|
|
420
|
+
"### table fixture",
|
|
421
|
+
"",
|
|
422
|
+
"| name | count | size |",
|
|
423
|
+
"| --- | --- | --- |",
|
|
424
|
+
...rows.map((r) => `| ${r.join(" | ")} |`),
|
|
425
|
+
"",
|
|
426
|
+
`a local file: [note.md](file://${dir}/note.md#L2) and a bare ${dir}/app.py:2`,
|
|
427
|
+
"",
|
|
428
|
+
"",
|
|
429
|
+
"",
|
|
430
|
+
].join("\n");
|
|
431
|
+
await send(agent._conn, sessionId, {
|
|
432
|
+
sessionUpdate: "agent_message_chunk",
|
|
433
|
+
messageId: randomUUID(),
|
|
434
|
+
content: { type: "text", text: reply },
|
|
435
|
+
});
|
|
436
|
+
return { stopReason: "end_turn" };
|
|
437
|
+
}
|
|
438
|
+
|
|
404
439
|
if (wantThink) {
|
|
405
440
|
// ONE `messageId` per burst, like the real adapter sends: ACP gives every chunk of one
|
|
406
441
|
// thought block the same id, and the cockpit/server coalesce on it (`block_key`). A mock
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// product), so this adds no new power — it just makes the choice discoverable instead
|
|
5
5
|
// of demanding a path typed from memory. Listing is deliberately narrow: directories
|
|
6
6
|
// only, one level at a time, capped, and never followed through symlinks we cannot stat.
|
|
7
|
+
import crypto from "node:crypto";
|
|
7
8
|
import fs from "node:fs";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import path from "node:path";
|
|
@@ -125,12 +126,219 @@ export interface FilePreview {
|
|
|
125
126
|
truncated: boolean;
|
|
126
127
|
/** looks like binary: the panel shows a "cannot preview" note instead of mojibake */
|
|
127
128
|
binary: boolean;
|
|
129
|
+
/** how the panel should draw it (see FileKind) */
|
|
130
|
+
kind: FileKind;
|
|
131
|
+
contentType: string;
|
|
132
|
+
mtimeMs: number;
|
|
133
|
+
/** sha1 of the bytes on disk: the editor hands this back on save so a rewrite the agent made
|
|
134
|
+
* while the operator was typing is refused instead of silently overwritten */
|
|
135
|
+
hash: string;
|
|
136
|
+
editable: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What the panel should DRAW for a file. Decided here, once, so the browser does not carry a
|
|
140
|
+
* second extension table that can drift from this one. */
|
|
141
|
+
export type FileKind =
|
|
142
|
+
| "markdown" | "image" | "pdf" | "html" | "csv" | "code" | "text"
|
|
143
|
+
| "audio" | "video" | "office" | "archive" | "binary";
|
|
144
|
+
|
|
145
|
+
const KIND_BY_EXT: Record<string, FileKind> = {
|
|
146
|
+
md: "markdown", markdown: "markdown", mdx: "markdown",
|
|
147
|
+
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", avif: "image",
|
|
148
|
+
bmp: "image", ico: "image", svg: "image", tiff: "image", heic: "image",
|
|
149
|
+
pdf: "pdf",
|
|
150
|
+
html: "html", htm: "html",
|
|
151
|
+
csv: "csv", tsv: "csv",
|
|
152
|
+
// languages the panel can syntax-highlight (the web side has the hljs set; anything not
|
|
153
|
+
// listed still renders as text, which is the honest outcome)
|
|
154
|
+
js: "code", mjs: "code", cjs: "code", jsx: "code", ts: "code", tsx: "code", json: "code",
|
|
155
|
+
py: "code", rb: "code", go: "code", rs: "code", java: "code", c: "code", h: "code",
|
|
156
|
+
cpp: "code", cc: "code", hpp: "code", cs: "code", php: "code", swift: "code", kt: "code",
|
|
157
|
+
sh: "code", bash: "code", zsh: "code", fish: "code", ps1: "code",
|
|
158
|
+
css: "code", scss: "code", less: "code", sql: "code", yml: "code", yaml: "code",
|
|
159
|
+
toml: "code", ini: "code", conf: "code", env: "code", xml: "code", plist: "code",
|
|
160
|
+
dockerfile: "code", gitignore: "code", patch: "code", diff: "code",
|
|
161
|
+
txt: "text", log: "text", text: "text", lock: "text",
|
|
162
|
+
mp3: "audio", wav: "audio", ogg: "audio", m4a: "audio", flac: "audio", aac: "audio", opus: "audio",
|
|
163
|
+
mp4: "video", webm: "video", mov: "video", mkv: "video", m4v: "video",
|
|
164
|
+
docx: "office", doc: "office", xlsx: "office", xls: "office", pptx: "office", ppt: "office",
|
|
165
|
+
odt: "office", ods: "office", odp: "office", rtf: "office",
|
|
166
|
+
zip: "archive", tar: "archive", gz: "archive", bz2: "archive", xz: "archive", "7z": "archive",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
170
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
171
|
+
avif: "image/avif", bmp: "image/bmp", ico: "image/x-icon", svg: "image/svg+xml",
|
|
172
|
+
tiff: "image/tiff", heic: "image/heic",
|
|
173
|
+
pdf: "application/pdf",
|
|
174
|
+
html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8",
|
|
175
|
+
css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
|
|
176
|
+
mjs: "text/javascript; charset=utf-8", json: "application/json; charset=utf-8",
|
|
177
|
+
xml: "application/xml; charset=utf-8", svgz: "image/svg+xml",
|
|
178
|
+
mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", flac: "audio/flac",
|
|
179
|
+
aac: "audio/aac", opus: "audio/opus",
|
|
180
|
+
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", mkv: "video/x-matroska", m4v: "video/mp4",
|
|
181
|
+
txt: "text/plain; charset=utf-8", md: "text/markdown; charset=utf-8",
|
|
182
|
+
csv: "text/csv; charset=utf-8", tsv: "text/tab-separated-values; charset=utf-8",
|
|
183
|
+
zip: "application/zip", gz: "application/gzip", tar: "application/x-tar",
|
|
184
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
185
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
186
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Extension of a path, lowercased, without the dot. Dotfiles like `.env` report `env`. */
|
|
190
|
+
function extOf(target: string): string {
|
|
191
|
+
return path.extname(target).replace(/^\./, "").toLowerCase() || path.basename(target).replace(/^\./, "").toLowerCase();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function fileKind(target: string): FileKind {
|
|
195
|
+
return KIND_BY_EXT[extOf(target)] ?? "text"; // unknown extensions are shown as text, never refused
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Only ever used for `inline` preview; unknown types fall back to a neutral binary type
|
|
199
|
+
* rather than a wrong one, so the browser does not try to parse e.g. a .dmg as HTML. */
|
|
200
|
+
export function contentTypeFor(target: string): string {
|
|
201
|
+
return MIME_BY_EXT[extOf(target)] ?? "application/octet-stream";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface FileStatInfo {
|
|
205
|
+
path: string;
|
|
206
|
+
name: string;
|
|
207
|
+
/** what the panel should draw for this file (one table, see KIND_BY_EXT) */
|
|
208
|
+
kind: FileKind;
|
|
209
|
+
contentType: string;
|
|
210
|
+
size: number;
|
|
211
|
+
mtimeMs: number;
|
|
212
|
+
hash: string;
|
|
213
|
+
/** the file kind is editable in the panel (text-ish only; binaries are not) */
|
|
214
|
+
editable: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const EDITABLE: ReadonlySet<FileKind> = new Set<FileKind>(["markdown", "text", "code", "csv", "html"]);
|
|
218
|
+
|
|
219
|
+
function statOrThrow(target: string): fs.Stats {
|
|
220
|
+
try {
|
|
221
|
+
return fs.statSync(target);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
224
|
+
if (code === "ENOENT") throw new FsError(`no such file: ${target}`, "not_found");
|
|
225
|
+
if (code === "EACCES") throw new FsError(`permission denied: ${target}`, "permission_denied");
|
|
226
|
+
throw new FsError(`cannot read ${target}: ${code ?? String(e)}`, "bad_path");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** sha1 of the file's bytes. Doubles as the "did the file change under the editor?" check, so
|
|
231
|
+
* a save can refuse to clobber a rewrite the agent made while the operator was typing. */
|
|
232
|
+
export function fileHash(target: string): string {
|
|
233
|
+
const h = crypto.createHash("sha1");
|
|
234
|
+
const fd = fs.openSync(target, "r");
|
|
235
|
+
try {
|
|
236
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
237
|
+
for (;;) {
|
|
238
|
+
const n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
239
|
+
if (!n) break;
|
|
240
|
+
h.update(buf.subarray(0, n));
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
fs.closeSync(fd);
|
|
244
|
+
}
|
|
245
|
+
return h.digest("hex");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Everything the panel needs to decide HOW to draw a file, without reading its bytes. */
|
|
249
|
+
export function statFile(input: string | null | undefined): FileStatInfo {
|
|
250
|
+
const target = expandPath(input);
|
|
251
|
+
const st = statOrThrow(target);
|
|
252
|
+
if (st.isDirectory()) throw new FsError(`not a file: ${target}`, "not_a_file");
|
|
253
|
+
const kind = fileKind(target);
|
|
254
|
+
return {
|
|
255
|
+
path: target,
|
|
256
|
+
name: path.basename(target),
|
|
257
|
+
kind,
|
|
258
|
+
contentType: contentTypeFor(target),
|
|
259
|
+
size: st.size,
|
|
260
|
+
mtimeMs: st.mtimeMs,
|
|
261
|
+
hash: fileHash(target),
|
|
262
|
+
editable: EDITABLE.has(kind),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface WriteResult {
|
|
267
|
+
path: string;
|
|
268
|
+
size: number;
|
|
269
|
+
mtimeMs: number;
|
|
270
|
+
hash: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const MAX_WRITE_BYTES = 8 * 1024 * 1024;
|
|
274
|
+
|
|
275
|
+
/** Save a text file the operator edited in the panel.
|
|
276
|
+
*
|
|
277
|
+
* Deliberate limits: the file must ALREADY exist (this is an editor for files the agent and
|
|
278
|
+
* the operator are looking at, not a way to create them — a chat window should not be able to
|
|
279
|
+
* drop new files anywhere the process can write), it must be a text-ish kind, and it must fit
|
|
280
|
+
* in memory. The write goes through a temp file + rename so a crash mid-save cannot leave a
|
|
281
|
+
* half-written file where an agent might read it; mode is carried over from the original.
|
|
282
|
+
*
|
|
283
|
+
* `ifHash` is the hash the editor loaded. When it no longer matches, the agent changed the
|
|
284
|
+
* file while the operator was typing: a 409 with the current bytes beats silently losing
|
|
285
|
+
* either side's work. */
|
|
286
|
+
export function writeTextFile(
|
|
287
|
+
input: string | null | undefined,
|
|
288
|
+
content: string,
|
|
289
|
+
opts: { ifHash?: string } = {},
|
|
290
|
+
): WriteResult {
|
|
291
|
+
const target = expandPath(input);
|
|
292
|
+
const st = statOrThrow(target);
|
|
293
|
+
if (st.isDirectory()) throw new FsError(`not a file: ${target}`, "not_a_file");
|
|
294
|
+
const kind = fileKind(target);
|
|
295
|
+
if (!EDITABLE.has(kind)) {
|
|
296
|
+
throw new FsError(`not an editable file: ${path.basename(target)} (${kind})`, "not_a_file");
|
|
297
|
+
}
|
|
298
|
+
const bytes = Buffer.from(content, "utf8");
|
|
299
|
+
if (bytes.length > MAX_WRITE_BYTES) {
|
|
300
|
+
throw new FsError(`content too large (${bytes.length} > ${MAX_WRITE_BYTES})`, "bad_path");
|
|
301
|
+
}
|
|
302
|
+
const before = opts.ifHash ? fileHash(target) : "";
|
|
303
|
+
if (opts.ifHash && opts.ifHash !== before) {
|
|
304
|
+
const cur = readTextFile(target);
|
|
305
|
+
throw new ChangedOnDiskError(target, before, cur.content, cur.truncated);
|
|
306
|
+
}
|
|
307
|
+
const tmp = `${target}.agentus-${process.pid}.tmp`;
|
|
308
|
+
try {
|
|
309
|
+
fs.writeFileSync(tmp, bytes, { mode: st.mode & 0o7777 });
|
|
310
|
+
fs.renameSync(tmp, target);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* the temp file may never have existed */ }
|
|
313
|
+
throw new FsError(`cannot write ${target}: ${String((e as NodeJS.ErrnoException).code ?? e)}`, "bad_path");
|
|
314
|
+
}
|
|
315
|
+
const after = fs.statSync(target);
|
|
316
|
+
return { path: target, size: after.size, mtimeMs: after.mtimeMs, hash: fileHash(target) };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The operator's buffer is based on bytes that no longer exist on disk. Carries the CURRENT
|
|
320
|
+
* content so the panel can offer "reload theirs" without a second round trip. */
|
|
321
|
+
export class ChangedOnDiskError extends FsError {
|
|
322
|
+
constructor(
|
|
323
|
+
readonly target: string,
|
|
324
|
+
readonly currentHash: string,
|
|
325
|
+
readonly currentContent: string,
|
|
326
|
+
readonly truncated: boolean,
|
|
327
|
+
) {
|
|
328
|
+
super("changed_on_disk", "bad_path");
|
|
329
|
+
}
|
|
128
330
|
}
|
|
129
331
|
|
|
130
332
|
const DEFAULT_PREVIEW_BYTES = 256 * 1024;
|
|
131
333
|
|
|
132
|
-
/** Read a file for the workspace panel.
|
|
133
|
-
*
|
|
334
|
+
/** Read a file for the workspace panel.
|
|
335
|
+
*
|
|
336
|
+
* This used to be read-only on the reasoning that "writing files is the agent's job, not the
|
|
337
|
+
* cockpit's". The operator overruled that (2026-10-09: 「可编辑一起搞吧」) and it is his call — a
|
|
338
|
+
* cockpit where he can fix a line of markdown without leaving for an editor is the point. So
|
|
339
|
+
* the read now also reports what the panel needs to EDIT safely (`hash`, `editable`), and
|
|
340
|
+
* `writeTextFile` below does the saving. Reading is still capped and still refuses to guess at
|
|
341
|
+
* binaries. */
|
|
134
342
|
export function readTextFile(input: string, opts: { maxBytes?: number } = {}): FilePreview {
|
|
135
343
|
const target = expandPath(input);
|
|
136
344
|
const maxBytes = Math.max(1024, opts.maxBytes ?? DEFAULT_PREVIEW_BYTES);
|
|
@@ -151,6 +359,7 @@ export function readTextFile(input: string, opts: { maxBytes?: number } = {}): F
|
|
|
151
359
|
fs.readSync(fd, buf, 0, len, 0);
|
|
152
360
|
// A NUL byte in the first 8KB is the classic binary tell.
|
|
153
361
|
const binary = buf.subarray(0, Math.min(buf.length, 8192)).includes(0);
|
|
362
|
+
const kind = fileKind(target);
|
|
154
363
|
return {
|
|
155
364
|
path: target,
|
|
156
365
|
name: path.basename(target),
|
|
@@ -158,6 +367,11 @@ export function readTextFile(input: string, opts: { maxBytes?: number } = {}): F
|
|
|
158
367
|
content: binary ? "" : buf.toString("utf8"),
|
|
159
368
|
truncated: st.size > len,
|
|
160
369
|
binary,
|
|
370
|
+
kind,
|
|
371
|
+
contentType: contentTypeFor(target),
|
|
372
|
+
mtimeMs: st.mtimeMs,
|
|
373
|
+
hash: fileHash(target),
|
|
374
|
+
editable: EDITABLE.has(kind),
|
|
161
375
|
};
|
|
162
376
|
} finally {
|
|
163
377
|
fs.closeSync(fd);
|