dowafu 0.5.1 → 0.7.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/CHANGELOG.md +92 -0
- package/README.md +57 -13
- package/README_zh-tw.md +45 -12
- package/dist/cli-args.js +29 -7
- package/dist/cli-commands.js +12 -0
- package/dist/cli.js +44 -18
- package/dist/daemon.js +35 -13
- package/dist/doctor.js +8 -3
- package/dist/job.js +29 -0
- package/dist/messages.js +32 -10
- package/dist/prompt.js +6 -0
- package/dist/providers-store.js +4 -1
- package/dist/runner.js +10 -1
- package/dist/ticket-store.js +3 -0
- package/package.json +4 -3
- package/providers.json +12 -9
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes per released version. Entries for 0.5.0–0.6.0 were **reconstructed** from
|
|
4
|
+
release commits and internal records after the fact; from 0.7.0 on they are written at
|
|
5
|
+
release time. Versions before 0.5.0 are not covered here — see the git tags (`v0.3.0`
|
|
6
|
+
onwards).
|
|
7
|
+
|
|
8
|
+
## 0.7.0 — 2026-09-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`--ticket <name>`** names the ticket explicitly. The positional form still works, and
|
|
13
|
+
giving both is rejected rather than silently resolved. The queue worker now uses the
|
|
14
|
+
flag, which is what makes the fix below reachable for tickets that already exist.
|
|
15
|
+
- **Three models**, each verified against the live API before shipping — tool calling,
|
|
16
|
+
usage on every round, and the model name echoed back unchanged:
|
|
17
|
+
`gpt-6-astra`, `deepseek-flash`, `gemini-3.8-flash`.
|
|
18
|
+
- `dowafu result --help` and `dowafu approve --help` now print their usage. Previously
|
|
19
|
+
they treated `--help` as an id to look up, failed with "no results found", and opened
|
|
20
|
+
the database on the way.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **`ticket create` and `ticket import` reject names the CLI reserves**: the eight
|
|
25
|
+
subcommands (`ticket`, `providers`, `token`, `key`, `result`, `approve`, `serve`,
|
|
26
|
+
`mcp`), `-h`, `-V`, and any name beginning with `--`. Such a ticket could be created
|
|
27
|
+
before but could never run, because the CLI parsed the name as a command — one named
|
|
28
|
+
`serve` started a daemon instead. Tickets already stored under a reserved name still
|
|
29
|
+
run via `--ticket <name>`.
|
|
30
|
+
- **The deepseek model enabled on a fresh install is now `deepseek-flash`.** DeepSeek
|
|
31
|
+
retired `deepseek-v4-flash` on 2026-09-10 and currently routes it to V4.1 Flash with no
|
|
32
|
+
stated end date; shipping it as the default meant new installs depended on that routing.
|
|
33
|
+
`deepseek-v4-flash` stays on the whitelist, disabled by default. **Existing databases
|
|
34
|
+
are not touched** — this only affects a database seeded after upgrading.
|
|
35
|
+
- Prices for every provider re-checked against the vendor pricing pages on 2026-09-12.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **The install command printed inside each language pack's own README pointed at the
|
|
40
|
+
`v0.4.0` tag** — unchanged through four releases, so anyone following it fetched
|
|
41
|
+
reviewer definitions three versions old. All four places that carry the tag are now on
|
|
42
|
+
the released version.
|
|
43
|
+
- **A reviewer could not read its own ticket when the CLI ran with `--repo-root` from a
|
|
44
|
+
different working directory.** The ticket paths in its first message came out absolute
|
|
45
|
+
while the snapshot is keyed by a relative path, so every read of the ticket was refused
|
|
46
|
+
— and the audit still reported success. The result was a paid review written without
|
|
47
|
+
ever seeing the plan. Present since 0.5.0.
|
|
48
|
+
|
|
49
|
+
### Upgrading
|
|
50
|
+
|
|
51
|
+
Upgrading the package does not refresh the model list: seeding happens only when the model
|
|
52
|
+
table is empty, so your existing prices and enabled flags are never silently rewritten. To
|
|
53
|
+
pick up the three new models:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
dowafu providers import "$(npm root -g)/dowafu/providers.json"
|
|
57
|
+
dowafu providers list
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 0.6.0 — 2026-09-10
|
|
61
|
+
|
|
62
|
+
*Reconstructed.*
|
|
63
|
+
|
|
64
|
+
- A daemon no longer runs jobs that were approved before it started; those are returned to
|
|
65
|
+
pending approval at startup, so a restart cannot silently execute an old approval.
|
|
66
|
+
- `--doctor` reports the job queue.
|
|
67
|
+
- `engines` corrected to `>=22.13.0`.
|
|
68
|
+
|
|
69
|
+
## 0.5.1 — 2026-09-09
|
|
70
|
+
|
|
71
|
+
*Reconstructed.*
|
|
72
|
+
|
|
73
|
+
- Reviewer output no longer carries template placeholders through to the saved report.
|
|
74
|
+
- Queued jobs choose their own language: `dispatch_submit` accepts `lang`.
|
|
75
|
+
|
|
76
|
+
## 0.5.0 — 2026-09-06
|
|
77
|
+
|
|
78
|
+
*Reconstructed. The largest release so far; this is a summary, not a full list.*
|
|
79
|
+
|
|
80
|
+
- **Tickets and reviewer output moved into SQLite.** No ticket directories or output files
|
|
81
|
+
on disk. What a reviewer reads is the snapshot taken when the ticket was written, not
|
|
82
|
+
the file as it stands at run time.
|
|
83
|
+
- **Job queue and a long-running daemon** (`dowafu serve`), with MCP over stdio
|
|
84
|
+
(`dowafu mcp`) and over HTTP bound to `127.0.0.1` only. Inbound tokens are stored
|
|
85
|
+
hashed, compared in constant time, and rate limited per token.
|
|
86
|
+
- **The model whitelist, per-model enabled flags and prices moved into the database**;
|
|
87
|
+
the `--providers` flag was removed. An empty database is seeded from the file shipped
|
|
88
|
+
with the package.
|
|
89
|
+
- **API keys come from the database only** (`dowafu key`); environment variables and
|
|
90
|
+
`.env` support were removed, along with the `dotenv` dependency.
|
|
91
|
+
- **Costs are stated in dollars** before you approve: a hard upper bound, plus the typical
|
|
92
|
+
spend of past runs when there are any.
|
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ dowafu ticket create auth-review - <<'EOF'
|
|
|
117
117
|
...
|
|
118
118
|
EOF
|
|
119
119
|
|
|
120
|
-
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-
|
|
120
|
+
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-flash - <<'EOF'
|
|
121
121
|
# Questions
|
|
122
122
|
1. Does the permission check described here hold under concurrent requests?
|
|
123
123
|
|
|
@@ -177,12 +177,24 @@ response for every call. Read a run back with `dowafu result <id>`.
|
|
|
177
177
|
|
|
178
178
|
## Models
|
|
179
179
|
|
|
180
|
-
| provider | model |
|
|
181
|
-
| --- | --- |
|
|
182
|
-
| `openai` | `gpt-5.6-luna
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
|
|
|
180
|
+
| provider | model | on a fresh install |
|
|
181
|
+
| --- | --- | :-: |
|
|
182
|
+
| `openai` | `gpt-5.6-luna` | **enabled** |
|
|
183
|
+
| | `gpt-5.6-terra`, `gpt-5.6-sol`, `gpt-6-astra` | disabled |
|
|
184
|
+
| `deepseek` | `deepseek-flash` | **enabled** |
|
|
185
|
+
| | `deepseek-v4-flash`, `deepseek-v4-pro` | disabled |
|
|
186
|
+
| `gemini` | `gemini-3.1-flash-lite`, `gemini-3.5-flash-lite` | **enabled** |
|
|
187
|
+
| | `gemini-3.6-flash`, `gemini-3.7-flash`, `gemini-3.8-flash` | disabled |
|
|
188
|
+
| `anthropic` | `claude-haiku-4-5-20251001` | **enabled** |
|
|
189
|
+
| | `claude-opus-5`, `claude-sonnet-5` | disabled |
|
|
190
|
+
|
|
191
|
+
Fifteen models, five of them enabled out of the box — the pricier ones start disabled so
|
|
192
|
+
that a typo cannot quietly dispatch to an expensive model. Dispatching to a disabled model
|
|
193
|
+
is rejected before any spend, and tells you the `providers enable` command to run.
|
|
194
|
+
|
|
195
|
+
`deepseek-v4-flash` was retired upstream on 2026-09-10 and is currently routed to V4.1
|
|
196
|
+
Flash with no stated end date. It stays on the list so existing tickets keep working;
|
|
197
|
+
**new tickets should use `deepseek-flash`**.
|
|
186
198
|
|
|
187
199
|
The list ships with the package as `providers.json` and is seeded into the database the
|
|
188
200
|
first time one is opened. From then on **the database is the source of truth**:
|
|
@@ -193,6 +205,16 @@ dowafu providers enable|disable <model> # flip one on or off
|
|
|
193
205
|
dowafu providers import <path> # load your own file
|
|
194
206
|
```
|
|
195
207
|
|
|
208
|
+
**Upgrading the package does not refresh the list.** Seeding happens only when the model
|
|
209
|
+
table is completely empty, so an upgrade never silently changes a price or an enabled flag
|
|
210
|
+
you already have. To pick up models added in a later release, import the shipped file
|
|
211
|
+
yourself:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
dowafu providers import "$(npm root -g)/dowafu/providers.json"
|
|
215
|
+
dowafu providers list # confirm the new models are there
|
|
216
|
+
```
|
|
217
|
+
|
|
196
218
|
There is no `--providers` flag — it was removed in 0.5.0, when the whitelist moved into
|
|
197
219
|
the database. Passing it is rejected as an unknown option.
|
|
198
220
|
|
|
@@ -208,7 +230,7 @@ not work: a reviewer's fixed closing line has to match the template the audit ch
|
|
|
208
230
|
against.
|
|
209
231
|
|
|
210
232
|
```bash
|
|
211
|
-
npx degit eyesofkids/dowafu/publish/en#v0.
|
|
233
|
+
npx degit eyesofkids/dowafu/publish/en#v0.7.0 .claude-pack # or publish/zh-tw
|
|
212
234
|
|
|
213
235
|
cd .claude-pack
|
|
214
236
|
TARGET=<your project>
|
|
@@ -244,8 +266,9 @@ There are two transports, and the difference that matters is who starts what.
|
|
|
244
266
|
### Both transports need `dowafu serve`
|
|
245
267
|
|
|
246
268
|
`dowafu mcp` only answers tool calls — **it never runs a reviewer**. The worker that
|
|
247
|
-
actually spends money lives in `dowafu serve`. Without one running, an approved job
|
|
248
|
-
|
|
269
|
+
actually spends money lives in `dowafu serve`. Without one running, an approved job never
|
|
270
|
+
runs — `dispatch_submit`, `dispatch_approve` and `dispatch_status` each print a `⚠` line
|
|
271
|
+
saying so.
|
|
249
272
|
|
|
250
273
|
```
|
|
251
274
|
stdio: your client ──spawn──→ dowafu mcp ──┐
|
|
@@ -255,9 +278,11 @@ stdio: your client ──spawn──→ dowafu mcp ──┐
|
|
|
255
278
|
HTTP: your client ──x-api-key──→ dowafu serve (endpoint and worker, one process)
|
|
256
279
|
```
|
|
257
280
|
|
|
258
|
-
So on stdio you keep `dowafu serve` running in a terminal next to the client.
|
|
259
|
-
|
|
260
|
-
|
|
281
|
+
So on stdio you keep `dowafu serve` running in a terminal next to the client. **Start it
|
|
282
|
+
before you approve.** A daemon only runs approvals made while it is up: when it starts,
|
|
283
|
+
and again when it stops, any job still waiting in `approved` goes back to pending approval,
|
|
284
|
+
so an approval given before the daemon was running has to be given again. `dowafu serve`
|
|
285
|
+
prints how many it sent back.
|
|
261
286
|
|
|
262
287
|
### stdio
|
|
263
288
|
|
|
@@ -353,6 +378,25 @@ With the daemon always up the stdio path needs nothing extra — `dowafu mcp` fi
|
|
|
353
378
|
worker already running. Typing `dowafu serve` yourself will then refuse with
|
|
354
379
|
`daemon already running (pid N)`: that is the guard working, not a fault.
|
|
355
380
|
|
|
381
|
+
### Stopping and restarting
|
|
382
|
+
|
|
383
|
+
`dowafu serve --stop` stops the daemon; `dowafu serve --restart` stops it and starts a new
|
|
384
|
+
one (or just starts one if none is running). When something would be affected, both ask
|
|
385
|
+
first — separately — about:
|
|
386
|
+
|
|
387
|
+
- **jobs still running** — stopping the daemon does not stop a reviewer already in flight.
|
|
388
|
+
It runs to completion, and its results and API cost are still recorded.
|
|
389
|
+
- **jobs approved but not yet started** — they go back to pending approval, so the next
|
|
390
|
+
daemon cannot run them without a fresh approval.
|
|
391
|
+
|
|
392
|
+
With no terminal to answer (an agent calling it, a script), a question counts as "no" and
|
|
393
|
+
nothing is stopped; pass `--yes` to go ahead.
|
|
394
|
+
|
|
395
|
+
If the daemon ignores SIGTERM, `--stop` says so and prints the pid to inspect. Do not reach
|
|
396
|
+
for SIGKILL: a reviewer's worker process can outlive the daemon and still write its results.
|
|
397
|
+
And if the daemon comes straight back after stopping, a service manager (the `KeepAlive` /
|
|
398
|
+
`Restart=always` above) is restarting it — stop it with `launchctl` or `systemctl` instead.
|
|
399
|
+
|
|
356
400
|
### Approval is still a separate step
|
|
357
401
|
|
|
358
402
|
`dispatch_submit` queues a job and stops; nothing is billed until it is approved. Two
|
package/README_zh-tw.md
CHANGED
|
@@ -98,7 +98,7 @@ dowafu ticket create auth-review - <<'EOF'
|
|
|
98
98
|
...
|
|
99
99
|
EOF
|
|
100
100
|
|
|
101
|
-
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-
|
|
101
|
+
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-flash - <<'EOF'
|
|
102
102
|
# 具體問題
|
|
103
103
|
1. 這裡描述的權限檢查在併發請求下還成立嗎?
|
|
104
104
|
|
|
@@ -166,12 +166,22 @@ dowafu result <id>
|
|
|
166
166
|
|
|
167
167
|
## Models
|
|
168
168
|
|
|
169
|
-
| Provider | Model |
|
|
170
|
-
| --- | --- |
|
|
171
|
-
| `openai` | `gpt-5.6-luna
|
|
172
|
-
|
|
|
173
|
-
| `
|
|
174
|
-
|
|
|
169
|
+
| Provider | Model | 全新安裝時 |
|
|
170
|
+
| --- | --- | :-: |
|
|
171
|
+
| `openai` | `gpt-5.6-luna` | **啟用** |
|
|
172
|
+
| | `gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-6-astra` | 停用 |
|
|
173
|
+
| `deepseek` | `deepseek-flash` | **啟用** |
|
|
174
|
+
| | `deepseek-v4-flash`、`deepseek-v4-pro` | 停用 |
|
|
175
|
+
| `gemini` | `gemini-3.1-flash-lite`、`gemini-3.5-flash-lite` | **啟用** |
|
|
176
|
+
| | `gemini-3.6-flash`、`gemini-3.7-flash`、`gemini-3.8-flash` | 停用 |
|
|
177
|
+
| `anthropic` | `claude-haiku-4-5-20251001` | **啟用** |
|
|
178
|
+
| | `claude-opus-5`、`claude-sonnet-5` | 停用 |
|
|
179
|
+
|
|
180
|
+
共 15 個 Model,開箱只啟用五個——較貴的預設停用,讓打錯字不會安靜地派到昂貴的 Model 上。
|
|
181
|
+
派到停用的 Model 會在產生任何費用之前被拒絕,並告訴你該跑哪一條 `providers enable`。
|
|
182
|
+
|
|
183
|
+
`deepseek-v4-flash` 已於 2026-09-10 在上游退役,目前被轉址到 V4.1 Flash,官方未說明何時停止。
|
|
184
|
+
它留在清單上是為了讓既有工單還能運作,**新工單請改用 `deepseek-flash`**。
|
|
175
185
|
|
|
176
186
|
這份清單會隨套件以 `providers.json` 一起提供,並在第一次開啟資料庫時播種進去。
|
|
177
187
|
之後**資料庫就是唯一來源**:
|
|
@@ -182,6 +192,14 @@ dowafu providers enable|disable <model> # 開關其中一個
|
|
|
182
192
|
dowafu providers import <path> # 匯入自己的設定檔
|
|
183
193
|
```
|
|
184
194
|
|
|
195
|
+
**升級套件不會刷新這份清單。** 播種只在 Model 表完全為空時發生,所以升級永遠不會安靜地改掉
|
|
196
|
+
你既有的價格或啟用狀態。要取得後續版本新增的 Model,請自己匯入隨套件出貨的那一份:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
dowafu providers import "$(npm root -g)/dowafu/providers.json"
|
|
200
|
+
dowafu providers list # 確認新的 Model 已經進來
|
|
201
|
+
```
|
|
202
|
+
|
|
185
203
|
**沒有 `--providers` 旗標**——0.5.0 把白名單搬進資料庫時已移除,帶了會被當成未知選項拒絕。
|
|
186
204
|
|
|
187
205
|
## 從 Agent 驅動
|
|
@@ -199,7 +217,7 @@ dowafu providers import <path> # 匯入自己的設定檔
|
|
|
199
217
|
`publish/` 提供兩種語言:`publish/en/` 與 `publish/zh-tw/`。**擇一複製,不可混裝**——審查者的固定收尾句必須與稽核檢查用的範本是同一種語言。
|
|
200
218
|
|
|
201
219
|
```bash
|
|
202
|
-
npx degit eyesofkids/dowafu/publish/zh-tw#v0.
|
|
220
|
+
npx degit eyesofkids/dowafu/publish/zh-tw#v0.7.0 .claude-pack # 或 publish/en
|
|
203
221
|
|
|
204
222
|
cd .claude-pack
|
|
205
223
|
TARGET=<your project>
|
|
@@ -238,8 +256,8 @@ Agent 可以不走 shell,改用 MCP 驅動 `dowafu`。露出五個 tool:`dis
|
|
|
238
256
|
### 兩條路都需要 `dowafu serve`
|
|
239
257
|
|
|
240
258
|
`dowafu mcp` 只回應 tool 呼叫,**它不執行任何 reviewer**。真正花錢的 worker 在
|
|
241
|
-
`dowafu serve` 裡。沒有它,核准過的 job
|
|
242
|
-
|
|
259
|
+
`dowafu serve` 裡。沒有它,核准過的 job 永遠不會執行——`dispatch_submit`、`dispatch_approve`、
|
|
260
|
+
`dispatch_status` 都會印一行 `⚠` 說明這件事。
|
|
243
261
|
|
|
244
262
|
```
|
|
245
263
|
stdio: 你的 client ──spawn──→ dowafu mcp ──┐
|
|
@@ -249,8 +267,9 @@ stdio: 你的 client ──spawn──→ dowafu mcp ──┐
|
|
|
249
267
|
HTTP: 你的 client ──x-api-key──→ dowafu serve (端點與 worker 同一個行程)
|
|
250
268
|
```
|
|
251
269
|
|
|
252
|
-
所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve`
|
|
253
|
-
|
|
270
|
+
所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve` 跑著。**要先起 daemon,再核准。**
|
|
271
|
+
daemon 只執行它在線期間核准的 job:它啟動時、以及停止時,還停在「已核准」的 job 會被退回待核准,
|
|
272
|
+
所以在 daemon 起來之前給的核准要再給一次。`dowafu serve` 會印出退回了幾張。
|
|
254
273
|
|
|
255
274
|
### stdio
|
|
256
275
|
|
|
@@ -339,6 +358,20 @@ daemon 常駐之後,stdio 那條路不需要任何額外動作——`dowafu mc
|
|
|
339
358
|
這時自己再打 `dowafu serve` 會被擋下並回報 `daemon already running (pid N)`:
|
|
340
359
|
**那是守衛在運作,不是故障。**
|
|
341
360
|
|
|
361
|
+
### 停止與重啟
|
|
362
|
+
|
|
363
|
+
`dowafu serve --stop` 停掉 daemon;`dowafu serve --restart` 停掉之後再起一支新的
|
|
364
|
+
(沒在跑的話就直接起)。有東西會受影響時,兩者都會先分開問兩件事:
|
|
365
|
+
|
|
366
|
+
- **還在執行的 job**——停掉 daemon 不會停掉已經在跑的審查者,它會跑完,結果與 API 成本照樣記帳。
|
|
367
|
+
- **已核准但還沒開始的 job**——會退回待核准,下一支 daemon 沒有新的核准就不會跑它們。
|
|
368
|
+
|
|
369
|
+
沒有終端機可以回答時(agent 代為呼叫、腳本),問題一律視為「不要」,什麼都不會停;要繼續就帶 `--yes`。
|
|
370
|
+
|
|
371
|
+
daemon 收到 SIGTERM 卻沒停下來時,`--stop` 會明說並印出要檢查的 pid。**不要改用 SIGKILL**:
|
|
372
|
+
審查者的 worker 行程可能比 daemon 活得久,照樣會寫入結果。停掉之後 daemon 馬上又回來,
|
|
373
|
+
代表有服務管理器(上面的 `KeepAlive`/`Restart=always`)在重拉它——那要用 `launchctl` 或 `systemctl` 停。
|
|
374
|
+
|
|
342
375
|
### 核准仍然是獨立的一步
|
|
343
376
|
|
|
344
377
|
`dispatch_submit` 只把 job 排進佇列就停下,核准之前不會計費。兩種核准方式:在自己的終端機下
|
package/dist/cli-args.js
CHANGED
|
@@ -10,6 +10,7 @@ import { DEFAULT_HTTP_PORT } from "./mcp/http.js";
|
|
|
10
10
|
import { getCommandName } from "./pkg-info.js";
|
|
11
11
|
import { DEFAULT_MAX_TOOL_CALLS } from "./cost.js";
|
|
12
12
|
import { m } from "./messages.js";
|
|
13
|
+
import { CLI_COMMANDS, CLI_SHORTCUTS } from "./cli-commands.js";
|
|
13
14
|
const DEFAULTS = {
|
|
14
15
|
out: "tmp/spoke",
|
|
15
16
|
concurrency: 2,
|
|
@@ -86,9 +87,19 @@ export function resolveLang(cliLangRaw, envLangRaw) {
|
|
|
86
87
|
export function parseArgs(argv, lang) {
|
|
87
88
|
const helpText = m(lang, "helpText", getCommandName());
|
|
88
89
|
// --help/--version 可出現在任何位置,且優先於其他一切解析——不要求先有 ticketDir。
|
|
89
|
-
|
|
90
|
+
// A named ticket is opaque, including legacy names such as --help or -V.
|
|
91
|
+
let ticketValue = false;
|
|
92
|
+
const controlArgs = argv.filter((arg) => {
|
|
93
|
+
if (ticketValue) {
|
|
94
|
+
ticketValue = false;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
ticketValue = arg === "--ticket";
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
if (controlArgs.includes("--help") || controlArgs.includes(CLI_SHORTCUTS.help))
|
|
90
101
|
return { mode: "help" };
|
|
91
|
-
if (
|
|
102
|
+
if (controlArgs.includes("--version") || controlArgs.includes(CLI_SHORTCUTS.version))
|
|
92
103
|
return { mode: "version" };
|
|
93
104
|
const options = { ...DEFAULTS };
|
|
94
105
|
// 熱修補(票 C 驗收):`DOWAFU_HTTP_PORT` 原本只寫在 `daemon.ts` 的 `serve()` 參數預設值裡,
|
|
@@ -104,6 +115,7 @@ export function parseArgs(argv, lang) {
|
|
|
104
115
|
let doctorMode = false;
|
|
105
116
|
let serveMode = false;
|
|
106
117
|
let serveStop = false;
|
|
118
|
+
let serveRestart = false;
|
|
107
119
|
let mcpMode = false;
|
|
108
120
|
const numFlag = (name, apply) => {
|
|
109
121
|
flagHandlers[name] = (v) => {
|
|
@@ -114,6 +126,11 @@ export function parseArgs(argv, lang) {
|
|
|
114
126
|
};
|
|
115
127
|
};
|
|
116
128
|
const flagHandlers = {};
|
|
129
|
+
flagHandlers["ticket"] = (v) => {
|
|
130
|
+
if (ticketDir !== undefined)
|
|
131
|
+
throw new DispatchError(m(lang, "tooManyArgs", v, ticketDir, helpText), 2);
|
|
132
|
+
ticketDir = v;
|
|
133
|
+
};
|
|
117
134
|
flagHandlers["out"] = (v) => (options.out = v);
|
|
118
135
|
flagHandlers["repo-root"] = (v) => (options.repoRoot = v);
|
|
119
136
|
flagHandlers["db"] = (v) => (options.dbPath = v);
|
|
@@ -153,13 +170,16 @@ export function parseArgs(argv, lang) {
|
|
|
153
170
|
// (見迴圈後方)——這樣 --doctor 與 --lang 之類的已知旗標仍可同時給、彼此不衝突。
|
|
154
171
|
doctorMode = true;
|
|
155
172
|
}
|
|
156
|
-
else if (arg ===
|
|
173
|
+
else if (arg === CLI_COMMANDS.serve) {
|
|
157
174
|
serveMode = true;
|
|
158
175
|
}
|
|
159
176
|
else if (arg === "--stop") {
|
|
160
177
|
serveStop = true;
|
|
161
178
|
}
|
|
162
|
-
else if (arg === "
|
|
179
|
+
else if (arg === "--restart") {
|
|
180
|
+
serveRestart = true;
|
|
181
|
+
}
|
|
182
|
+
else if (arg === CLI_COMMANDS.mcp) {
|
|
163
183
|
mcpMode = true;
|
|
164
184
|
}
|
|
165
185
|
else if (arg.startsWith("--")) {
|
|
@@ -190,16 +210,18 @@ export function parseArgs(argv, lang) {
|
|
|
190
210
|
if (ticketDir !== undefined) {
|
|
191
211
|
throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "--doctor", helpText), 2);
|
|
192
212
|
}
|
|
193
|
-
if (serveStop)
|
|
213
|
+
if (serveStop || serveRestart)
|
|
194
214
|
throw new DispatchError(m(lang, "stopRequiresServe"), 2);
|
|
195
215
|
return { mode: "doctor", options };
|
|
196
216
|
}
|
|
197
217
|
if (serveMode) {
|
|
198
218
|
if (ticketDir !== undefined)
|
|
199
219
|
throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "serve", helpText), 2);
|
|
200
|
-
|
|
220
|
+
if (serveStop && serveRestart)
|
|
221
|
+
throw new DispatchError(m(lang, "stopRestartConflict"), 2);
|
|
222
|
+
return { mode: "serve", options, stop: serveStop, restart: serveRestart };
|
|
201
223
|
}
|
|
202
|
-
if (serveStop)
|
|
224
|
+
if (serveStop || serveRestart)
|
|
203
225
|
throw new DispatchError(m(lang, "stopRequiresServe"), 2);
|
|
204
226
|
if (mcpMode) {
|
|
205
227
|
if (ticketDir !== undefined)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// These spellings drive both dispatch and ticket-name validation.
|
|
2
|
+
export const CLI_COMMANDS = {
|
|
3
|
+
ticket: "ticket", providers: "providers", token: "token", key: "key",
|
|
4
|
+
result: "result", approve: "approve", serve: "serve", mcp: "mcp",
|
|
5
|
+
};
|
|
6
|
+
export const CLI_SHORTCUTS = { help: "-h", version: "-V" };
|
|
7
|
+
export function reservedTicketNames() {
|
|
8
|
+
return [...Object.values(CLI_COMMANDS), ...Object.values(CLI_SHORTCUTS), "--*"];
|
|
9
|
+
}
|
|
10
|
+
export function isReservedTicketName(name) {
|
|
11
|
+
return name.startsWith("--") || reservedTicketNames().includes(name);
|
|
12
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -13,10 +13,10 @@ import { loadProviders } from "./providers.js";
|
|
|
13
13
|
import { loadTicket } from "./ticket.js";
|
|
14
14
|
import { resolveSpokes } from "./validate.js";
|
|
15
15
|
import { estimateTokens, estimateAllowlistTokens, estimateSequentialRead, checkGateOne } from "./gate.js";
|
|
16
|
-
import {
|
|
16
|
+
import { buildSystemPrompt } from "./prompt.js";
|
|
17
17
|
import { buildReport, effectiveCap, effectiveCharsPerToken } from "./report.js";
|
|
18
18
|
import { checkGitignore } from "./gitignore-check.js";
|
|
19
|
-
import { runSpoke } from "./runner.js";
|
|
19
|
+
import { buildSpokeFirstUserText, runSpoke } from "./runner.js";
|
|
20
20
|
import { Semaphore } from "./semaphore.js";
|
|
21
21
|
import { createResponsesAdapter } from "./adapters/responses.js";
|
|
22
22
|
import { createGeminiAdapter } from "./adapters/gemini-native.js";
|
|
@@ -29,12 +29,13 @@ import { registerSecrets, maskString, maskDeep } from "./mask.js";
|
|
|
29
29
|
import { buildDoctorReportData, renderDoctorReport } from "./doctor.js";
|
|
30
30
|
import { bundledProvidersPath, getPackageVersion, getCommandName } from "./pkg-info.js";
|
|
31
31
|
import { buildJsonPayload, buildJsonPlan } from "./json-output.js";
|
|
32
|
+
import { CLI_COMMANDS } from "./cli-commands.js";
|
|
32
33
|
import { parseArgs, formatEvent, resolveLang, resolveFallbackLang, buildStdoutSummary } from "./cli-args.js";
|
|
33
34
|
import { m } from "./messages.js";
|
|
34
35
|
import { defaultDbPath, openDb } from "./db.js";
|
|
35
36
|
import { issueToken, listTokens, revokeToken } from "./api-token.js";
|
|
36
37
|
import { addAllow, addSpoke, createTicket, formatTicket, hasResults, importTicketDirectory } from "./ticket-store.js";
|
|
37
|
-
import { approveJob, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
|
|
38
|
+
import { approveJob, countQueueJobs, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
|
|
38
39
|
import { serve, stopDaemon } from "./daemon.js";
|
|
39
40
|
import { daemonAlive, daemonStatePath } from "./liveness.js";
|
|
40
41
|
import { startStdioServer } from "./mcp/stdio.js";
|
|
@@ -489,24 +490,28 @@ async function main() {
|
|
|
489
490
|
// 這個不會拋例外的簡化判定(resolveFallbackLang,見 cli-args.ts)。--lang 旗標的值在這個
|
|
490
491
|
// 時間點還沒被驗證,不查它(v1.3 §二之2 #5/#6:help/version 的語言不受 --lang 影響)。
|
|
491
492
|
const messageLang = resolveFallbackLang(process.env.DISPATCH_LANG);
|
|
492
|
-
if (process.argv[2] ===
|
|
493
|
+
if (process.argv[2] === CLI_COMMANDS.ticket) {
|
|
493
494
|
await ticketCommand(process.argv.slice(3), messageLang);
|
|
494
495
|
return;
|
|
495
496
|
}
|
|
496
|
-
if (process.argv[2] ===
|
|
497
|
+
if (process.argv[2] === CLI_COMMANDS.providers) {
|
|
497
498
|
await providersCommand(process.argv.slice(3), messageLang);
|
|
498
499
|
return;
|
|
499
500
|
}
|
|
500
|
-
if (process.argv[2] ===
|
|
501
|
+
if (process.argv[2] === CLI_COMMANDS.token) {
|
|
501
502
|
await tokenCommand(process.argv.slice(3), messageLang);
|
|
502
503
|
return;
|
|
503
504
|
}
|
|
504
|
-
if (process.argv[2] ===
|
|
505
|
+
if (process.argv[2] === CLI_COMMANDS.key) {
|
|
505
506
|
await keyCommand(process.argv.slice(3), messageLang);
|
|
506
507
|
return;
|
|
507
508
|
}
|
|
508
|
-
if (process.argv[2] ===
|
|
509
|
+
if (process.argv[2] === CLI_COMMANDS.result) {
|
|
509
510
|
const args = process.argv.slice(3);
|
|
511
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
512
|
+
console.log(m(messageLang, "resultHelp", getCommandName()));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
510
515
|
const positional = [];
|
|
511
516
|
let dbPath;
|
|
512
517
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -547,8 +552,12 @@ async function main() {
|
|
|
547
552
|
console.log(lines.join("\n\n"));
|
|
548
553
|
return;
|
|
549
554
|
}
|
|
550
|
-
if (process.argv[2] ===
|
|
555
|
+
if (process.argv[2] === CLI_COMMANDS.approve) {
|
|
551
556
|
const args = process.argv.slice(3);
|
|
557
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
558
|
+
console.log(m(messageLang, "approveHelp", getCommandName()));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
552
561
|
const positional = [];
|
|
553
562
|
let dbPath;
|
|
554
563
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -611,6 +620,9 @@ async function main() {
|
|
|
611
620
|
// 工單 key-db §B-3 #6:逐 provider 印出實際生效的是哪一層——doctorDb 開不起來時就沒有
|
|
612
621
|
// DB 可查,keyStatusRows 留空陣列(buildApiKeyValue 對空陣列的降級輸出)。
|
|
613
622
|
let keyStatusRows = [];
|
|
623
|
+
// A database failure must leave --doctor usable. Its JSON contract still has
|
|
624
|
+
// numeric queue fields, so the unavailable DB degrades to zero counts.
|
|
625
|
+
let queue = { pendingApproval: 0, approved: 0, running: 0 };
|
|
614
626
|
// A(gaps #1):整份報表——DB、金鑰、型號白名單、daemon——都要來自**這次真的會用到的**
|
|
615
627
|
// 那個 DB。先前這裡寫死 undefined/defaultDbPath(),於是 `--doctor --db <別的>` 印的
|
|
616
628
|
// 全是預設 DB 的狀況,而使用者指定的那個檔連建都不會建。
|
|
@@ -620,49 +632,60 @@ async function main() {
|
|
|
620
632
|
const providers = loadProviders(doctorDb, messageLang);
|
|
621
633
|
providersResult = { ok: true, providers, models: listModels(doctorDb) };
|
|
622
634
|
keyStatusRows = buildKeyStatusRows(doctorDb);
|
|
635
|
+
queue = countQueueJobs(doctorDb);
|
|
623
636
|
}
|
|
624
637
|
catch (err) {
|
|
625
638
|
const reason = err instanceof DispatchError ? err.message : maskString(String(err));
|
|
626
639
|
providersResult = { ok: false, reason };
|
|
627
640
|
}
|
|
628
|
-
const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath);
|
|
641
|
+
const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath, queue);
|
|
629
642
|
console.log(parsed.options.json ? JSON.stringify(doctorData) : renderDoctorReport(messageLang, getCommandName(), doctorData));
|
|
630
643
|
return;
|
|
631
644
|
}
|
|
632
645
|
if (parsed.mode === "serve") {
|
|
633
|
-
if (parsed.stop) {
|
|
646
|
+
if (parsed.stop || parsed.restart) {
|
|
634
647
|
const outcome = await stopDaemon(parsed.options.dbPath, {
|
|
635
648
|
force: parsed.options.yes,
|
|
636
649
|
confirmRunningJobs: async (count) => {
|
|
637
650
|
console.log(m(messageLang, "daemonStopRunningWarning", count));
|
|
638
651
|
return confirm(m(messageLang, "daemonStopConfirmPrompt"), process.stdout);
|
|
639
652
|
},
|
|
653
|
+
confirmApprovedJobs: async (count) => {
|
|
654
|
+
console.log(m(messageLang, "daemonStopApprovedWarning", count));
|
|
655
|
+
return confirm(m(messageLang, "daemonStopApprovedConfirmPrompt"), process.stdout);
|
|
656
|
+
},
|
|
640
657
|
});
|
|
641
658
|
switch (outcome.status) {
|
|
642
659
|
case "not_running":
|
|
643
660
|
console.log(m(messageLang, "daemonStopNotRunning", outcome.reason));
|
|
661
|
+
if (parsed.restart)
|
|
662
|
+
break;
|
|
644
663
|
break;
|
|
645
664
|
case "cancelled":
|
|
646
665
|
console.log(m(messageLang, "daemonStopCancelled"));
|
|
647
|
-
|
|
666
|
+
return;
|
|
648
667
|
case "pid_not_dowafu":
|
|
649
668
|
console.log(m(messageLang, "daemonStopPidNotDowafu", outcome.pid));
|
|
650
|
-
|
|
669
|
+
return;
|
|
651
670
|
case "signal_failed":
|
|
652
671
|
console.log(m(messageLang, "daemonStopSignalFailed", outcome.pid));
|
|
653
|
-
|
|
672
|
+
console.log(m(messageLang, "daemonStopApprovedCleaned", outcome.requeuedApprovedJobs));
|
|
673
|
+
return;
|
|
654
674
|
case "stopped":
|
|
655
675
|
console.log(m(messageLang, "daemonStopSucceeded", outcome.pid));
|
|
676
|
+
console.log(m(messageLang, "daemonStopApprovedCleaned", outcome.requeuedApprovedJobs));
|
|
656
677
|
break;
|
|
657
678
|
case "restarted":
|
|
658
679
|
console.log(m(messageLang, "daemonStopRestarted", outcome.pid));
|
|
659
|
-
|
|
680
|
+
return;
|
|
660
681
|
}
|
|
661
|
-
|
|
682
|
+
if (parsed.stop)
|
|
683
|
+
return;
|
|
662
684
|
}
|
|
663
685
|
try {
|
|
664
686
|
const daemon = await serve(parsed.options.dbPath, undefined, parsed.options.httpPort, messageLang);
|
|
665
|
-
console.log(m(messageLang, "daemonStarted"));
|
|
687
|
+
console.log(m(messageLang, parsed.restart ? "daemonRestartStarted" : "daemonStarted"));
|
|
688
|
+
console.log(m(messageLang, "daemonStartApprovedCleaned", daemon.requeuedApprovedJobs));
|
|
666
689
|
console.log(m(messageLang, "httpServerStarted", daemon.httpPort));
|
|
667
690
|
// B-1 #6:端點活著但沒發過 token,任何請求都進不來——失敗長得像成功,必須警告。
|
|
668
691
|
if (!daemon.hasActiveToken)
|
|
@@ -689,6 +712,8 @@ async function main() {
|
|
|
689
712
|
const lang = resolveLang(options.langRaw, process.env.DISPATCH_LANG);
|
|
690
713
|
// v1.10 §9:--repo-root 只影響白名單邊界、.claude/agents 位置、_docs/ 拒絕判定;
|
|
691
714
|
// 工單目錄仍相對 cwd 解析,不要求位於 repoRoot 內(見 resolveSpokes 呼叫處)。
|
|
715
|
+
// (ticket-dir-cwd 補註:0.5.0 起 CLI 只走 DB 工單,ticket.ticketDir 是虛擬的 `ticket/<name>`,
|
|
716
|
+
// 不再相對 cwd 解析——見 runner.ts 的 buildSpokeFirstUserText。上一行是目錄工單時代的描述。)
|
|
692
717
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
693
718
|
const ticketId = ticketName;
|
|
694
719
|
const db = openDbSeeded(options.dbPath, lang);
|
|
@@ -703,7 +728,8 @@ async function main() {
|
|
|
703
728
|
// §10 步驟 6/§14 閘門一:呼叫前估算
|
|
704
729
|
const estimates = spokes.map((spoke) => {
|
|
705
730
|
const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
|
|
706
|
-
|
|
731
|
+
// ticket-dir-cwd 不變量 3:與 runSpoke 同一個組裝點——估算的就是實跑會送出的那則訊息。
|
|
732
|
+
const firstUserText = buildSpokeFirstUserText(spoke, ticket.ticketDir, repoRoot);
|
|
707
733
|
const charsPerToken = effectiveCharsPerToken(spoke, options);
|
|
708
734
|
return {
|
|
709
735
|
agent: spoke.agent,
|
package/dist/daemon.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { hasAnyActiveToken } from "./api-token.js";
|
|
4
4
|
import { defaultDbPath, openDb } from "./db.js";
|
|
5
|
-
import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs } from "./job.js";
|
|
5
|
+
import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs, requeueApprovedJobs } from "./job.js";
|
|
6
6
|
import { DAEMON_STALE_MS, daemonAlive, daemonStatePath, writeHeartbeat } from "./liveness.js";
|
|
7
7
|
import { createHttpServer, DEFAULT_HTTP_PORT } from "./mcp/http.js";
|
|
8
8
|
import { maskString } from "./mask.js";
|
|
@@ -84,8 +84,8 @@ export function runCliJob(job, dbPath, lang = "en", deps = DEFAULT_RUN_CLI_JOB_D
|
|
|
84
84
|
// stdout use the same language as the daemon's own failure message.
|
|
85
85
|
const jobLang = job.lang ?? lang;
|
|
86
86
|
const args = sourceMode
|
|
87
|
-
? ["--import", "tsx", cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang]
|
|
88
|
-
: [cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang];
|
|
87
|
+
? ["--import", "tsx", cliPath, "--ticket", job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang]
|
|
88
|
+
: [cliPath, "--ticket", job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang];
|
|
89
89
|
return new Promise((resolve) => {
|
|
90
90
|
const child = deps.spawn(process.execPath, args, { stdio: ["inherit", "inherit", "pipe"], env: process.env });
|
|
91
91
|
let stderrTail = Buffer.alloc(0);
|
|
@@ -112,6 +112,11 @@ const DEFAULT_STOP_DAEMON_DEPS = {
|
|
|
112
112
|
const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'running'").get();
|
|
113
113
|
return row.count;
|
|
114
114
|
},
|
|
115
|
+
countApprovedJobs: (dbPath) => {
|
|
116
|
+
const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'approved'").get();
|
|
117
|
+
return row.count;
|
|
118
|
+
},
|
|
119
|
+
requeueApprovedJobs: (dbPath) => requeueApprovedJobs(openDb(dbPath)),
|
|
115
120
|
processCommand: (pid) => {
|
|
116
121
|
try {
|
|
117
122
|
return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
@@ -120,6 +125,15 @@ const DEFAULT_STOP_DAEMON_DEPS = {
|
|
|
120
125
|
return null;
|
|
121
126
|
}
|
|
122
127
|
},
|
|
128
|
+
processExists: (pid) => {
|
|
129
|
+
try {
|
|
130
|
+
process.kill(pid, 0);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
123
137
|
kill: (pid, signal) => process.kill(pid, signal),
|
|
124
138
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
125
139
|
};
|
|
@@ -132,28 +146,33 @@ export async function stopDaemon(dbPath = defaultDbPath(), options = {}) {
|
|
|
132
146
|
const initial = deps.liveness(statePath);
|
|
133
147
|
if (!initial.alive)
|
|
134
148
|
return { status: "not_running", reason: initial.reason };
|
|
135
|
-
const runningJobs = deps.countRunningJobs(dbPath);
|
|
136
|
-
if (runningJobs > 0 && !options.force) {
|
|
137
|
-
const confirmed = await (options.confirmRunningJobs?.(runningJobs) ?? Promise.resolve(false));
|
|
138
|
-
if (!confirmed)
|
|
139
|
-
return { status: "cancelled", runningJobs };
|
|
140
|
-
}
|
|
141
149
|
if (!isDowafuProcess(deps.processCommand(initial.pid)))
|
|
142
150
|
return { status: "pid_not_dowafu", pid: initial.pid };
|
|
151
|
+
const runningJobs = deps.countRunningJobs(dbPath);
|
|
152
|
+
const approvedJobs = deps.countApprovedJobs(dbPath);
|
|
153
|
+
const confirmedRunning = runningJobs === 0 || options.force || await (options.confirmRunningJobs?.(runningJobs) ?? Promise.resolve(false));
|
|
154
|
+
// Ask independently even when the stop confirmation was declined. A running
|
|
155
|
+
// worker warning and an old-approval cleanup warning describe different risks.
|
|
156
|
+
const confirmedApproved = approvedJobs === 0 || options.force || await (options.confirmApprovedJobs?.(approvedJobs) ?? Promise.resolve(false));
|
|
157
|
+
if (!confirmedRunning || !confirmedApproved)
|
|
158
|
+
return { status: "cancelled", runningJobs, approvedJobs };
|
|
159
|
+
const requeuedApprovedJobs = deps.requeueApprovedJobs(dbPath);
|
|
143
160
|
try {
|
|
144
161
|
deps.kill(initial.pid, "SIGTERM");
|
|
145
162
|
}
|
|
146
163
|
catch {
|
|
147
|
-
return { status: "signal_failed", pid: initial.pid };
|
|
164
|
+
return { status: "signal_failed", pid: initial.pid, requeuedApprovedJobs };
|
|
148
165
|
}
|
|
149
166
|
for (let attempt = 0; attempt < STOP_CHECK_ATTEMPTS; attempt++) {
|
|
150
167
|
await deps.wait(STOP_CHECK_INTERVAL_MS);
|
|
151
|
-
if (!deps.liveness(statePath).alive) {
|
|
168
|
+
if (!deps.liveness(statePath).alive && !deps.processExists(initial.pid)) {
|
|
152
169
|
await deps.wait(STOP_RESTART_WAIT_MS);
|
|
153
|
-
return deps.liveness(statePath).alive
|
|
170
|
+
return deps.liveness(statePath).alive
|
|
171
|
+
? { status: "restarted", pid: initial.pid }
|
|
172
|
+
: { status: "stopped", pid: initial.pid, requeuedApprovedJobs };
|
|
154
173
|
}
|
|
155
174
|
}
|
|
156
|
-
return { status: "signal_failed", pid: initial.pid };
|
|
175
|
+
return { status: "signal_failed", pid: initial.pid, requeuedApprovedJobs };
|
|
157
176
|
}
|
|
158
177
|
function bindHttp(db, dbPath, httpPort) {
|
|
159
178
|
return new Promise((resolve, reject) => {
|
|
@@ -186,7 +205,9 @@ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.e
|
|
|
186
205
|
const db = openDb(dbPath);
|
|
187
206
|
return bindHttp(db, dbPath, httpPort).then((httpServer) => {
|
|
188
207
|
let daemon;
|
|
208
|
+
let requeuedApprovedJobs;
|
|
189
209
|
try {
|
|
210
|
+
requeuedApprovedJobs = requeueApprovedJobs(db);
|
|
190
211
|
daemon = createDaemon(db, { dbPath, maxConcurrent, execute: (job) => runCliJob(job, dbPath, lang) });
|
|
191
212
|
}
|
|
192
213
|
catch (err) {
|
|
@@ -208,6 +229,7 @@ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.e
|
|
|
208
229
|
},
|
|
209
230
|
httpPort: boundPort,
|
|
210
231
|
hasActiveToken: hasAnyActiveToken(db),
|
|
232
|
+
requeuedApprovedJobs,
|
|
211
233
|
};
|
|
212
234
|
});
|
|
213
235
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -64,6 +64,9 @@ function buildDaemonValue(lang, daemon) {
|
|
|
64
64
|
return m(lang, "doctorDaemonAlive", daemon.pid);
|
|
65
65
|
return m(lang, "doctorDaemonMissing", daemon?.reason ?? "heartbeat_missing");
|
|
66
66
|
}
|
|
67
|
+
function buildQueueValue(lang, queue) {
|
|
68
|
+
return m(lang, "doctorQueueValue", queue.pendingApproval, queue.approved, queue.running);
|
|
69
|
+
}
|
|
67
70
|
function buildConfigDirValue(lang, configDir) {
|
|
68
71
|
if (configDir.source === "unresolved")
|
|
69
72
|
return m(lang, "doctorConfigDirUnresolved");
|
|
@@ -117,7 +120,7 @@ function buildLensValue(lang, lenses) {
|
|
|
117
120
|
const closingNames = lenses.withClosing.length > 0 ? lenses.withClosing.join(joiner) : m(lang, "noneLabel");
|
|
118
121
|
return m(lang, "doctorLensFoundValue", lenses.dirPath, lenses.withClosing.length + lenses.withoutClosing.length, lenses.withClosing.length, closingNames, noClosingSuffix);
|
|
119
122
|
}
|
|
120
|
-
export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
|
|
123
|
+
export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }) {
|
|
121
124
|
const dispatchHome = resolveDispatchHome(env, homedir);
|
|
122
125
|
const configDir = dispatchHome === null
|
|
123
126
|
? { source: "unresolved" }
|
|
@@ -149,6 +152,7 @@ export function buildDoctorReportData(cmd, providers, env = process.env, homedir
|
|
|
149
152
|
apiKeys: keyStatusRows.map((row) => ({ ...row })),
|
|
150
153
|
models,
|
|
151
154
|
daemon: daemon ?? { alive: false, reason: "heartbeat_missing" },
|
|
155
|
+
queue: { ...queue },
|
|
152
156
|
lenses: buildLensData(probe, cwd),
|
|
153
157
|
};
|
|
154
158
|
}
|
|
@@ -163,12 +167,13 @@ export function renderDoctorReport(lang, cmd, data) {
|
|
|
163
167
|
m(lang, "doctorApiKeyLine", buildApiKeyValue(lang, data.apiKeys)),
|
|
164
168
|
m(lang, "doctorModelListLine", buildModelListValue(lang, data.models)),
|
|
165
169
|
m(lang, "doctorDaemonLine", buildDaemonValue(lang, data.daemon)),
|
|
170
|
+
m(lang, "doctorQueueLine", buildQueueValue(lang, data.queue)),
|
|
166
171
|
m(lang, "doctorLensLine", buildLensValue(lang, data.lenses)),
|
|
167
172
|
"",
|
|
168
173
|
m(lang, "doctorFooter"),
|
|
169
174
|
];
|
|
170
175
|
return lines.join("\n");
|
|
171
176
|
}
|
|
172
|
-
export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
|
|
173
|
-
return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath));
|
|
177
|
+
export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }) {
|
|
178
|
+
return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath, queue));
|
|
174
179
|
}
|
package/dist/job.js
CHANGED
|
@@ -21,6 +21,26 @@ export function findJobsByIdPrefix(db, prefix) {
|
|
|
21
21
|
export function listPendingApprovalJobs(db) {
|
|
22
22
|
return db.prepare("SELECT * FROM jobs WHERE status = 'pending_approval' ORDER BY created_at, id").all();
|
|
23
23
|
}
|
|
24
|
+
// Keep this to one indexed aggregate query rather than loading jobs and counting
|
|
25
|
+
// in JavaScript. `idx_jobs_status` covers both the WHERE predicate and status.
|
|
26
|
+
export function countQueueJobs(db) {
|
|
27
|
+
const rows = db
|
|
28
|
+
.prepare(`SELECT status, COUNT(*) AS count
|
|
29
|
+
FROM jobs
|
|
30
|
+
WHERE status IN ('pending_approval', 'approved', 'running')
|
|
31
|
+
GROUP BY status`)
|
|
32
|
+
.all();
|
|
33
|
+
const counts = { pendingApproval: 0, approved: 0, running: 0 };
|
|
34
|
+
for (const row of rows) {
|
|
35
|
+
if (row.status === "pending_approval")
|
|
36
|
+
counts.pendingApproval = Number(row.count);
|
|
37
|
+
else if (row.status === "approved")
|
|
38
|
+
counts.approved = Number(row.count);
|
|
39
|
+
else
|
|
40
|
+
counts.running = Number(row.count);
|
|
41
|
+
}
|
|
42
|
+
return counts;
|
|
43
|
+
}
|
|
24
44
|
export function submitJob(db, ticket, id = randomUUID(), lang = null) {
|
|
25
45
|
if (!db.prepare("SELECT 1 FROM tickets WHERE name = ?").get(ticket))
|
|
26
46
|
throw new Error(`ticket does not exist: ${ticket}`);
|
|
@@ -51,6 +71,15 @@ export function approveJob(db, id) {
|
|
|
51
71
|
WHERE id = ? AND status = 'pending_approval'
|
|
52
72
|
RETURNING *`, now(), id);
|
|
53
73
|
}
|
|
74
|
+
// A daemon owns only approvals made after it starts. Before a new daemon begins
|
|
75
|
+
// claiming work, return any older approvals to the explicit human-approval gate.
|
|
76
|
+
// Keep this as a single guarded UPDATE: concurrent approval is allowed to remain
|
|
77
|
+
// approved for the new daemon, and no other lifecycle state is touched.
|
|
78
|
+
export function requeueApprovedJobs(db) {
|
|
79
|
+
return Number(db.prepare(`UPDATE jobs
|
|
80
|
+
SET status = 'pending_approval', approved_at = NULL
|
|
81
|
+
WHERE status = 'approved'`).run().changes);
|
|
82
|
+
}
|
|
54
83
|
export function rejectJob(db, id) {
|
|
55
84
|
return one(db, `UPDATE jobs
|
|
56
85
|
SET status = 'rejected', finished_at = ?
|
package/dist/messages.js
CHANGED
|
@@ -25,7 +25,7 @@ const MESSAGES = {
|
|
|
25
25
|
unknownOption: (arg, helpText) => `未知選項:${arg}\n\n${helpText}`,
|
|
26
26
|
missingProviders: (path) => `找不到 providers.json:${path}`,
|
|
27
27
|
dryRunNotice: () => "--dry-run:僅解析/驗證/估算/印報表,未呼叫任何 API。",
|
|
28
|
-
helpText: (cmd) => `用法:${cmd} <
|
|
28
|
+
helpText: (cmd) => `用法:${cmd} --ticket <name> [options](亦接受位置參數 <ticket-id>)
|
|
29
29
|
|
|
30
30
|
子指令(各自有自己的 --help/用法訊息):
|
|
31
31
|
${cmd} ticket ... 建立、匯入、檢視工單
|
|
@@ -35,9 +35,11 @@ const MESSAGES = {
|
|
|
35
35
|
${cmd} token ... 入站 HTTP 憑證的發/列/撤銷
|
|
36
36
|
${cmd} approve [jobId] 核准佇列中等待的派工
|
|
37
37
|
${cmd} serve 啟動常駐 daemon(含 MCP 的 HTTP binding)
|
|
38
|
-
${cmd} serve --stop [--yes] 停止常駐 daemon;有執行中的 job 時會先確認
|
|
38
|
+
${cmd} serve --stop [--yes] 停止常駐 daemon;有執行中的 job 或舊核准 job 時會先確認
|
|
39
|
+
${cmd} serve --restart [--yes] 停止後重新啟動 daemon,會走兩端清理
|
|
39
40
|
${cmd} mcp 以 stdio 提供 MCP server
|
|
40
41
|
|
|
42
|
+
--ticket <name> 指定工單名稱,不可與位置參數同時給
|
|
41
43
|
--lang <en|zh-tw> CLI 輸出與 spoke prompt 的語言,預設 en
|
|
42
44
|
--repo-root <dir> 白名單邊界與 .claude/agents 的根,預設 cwd
|
|
43
45
|
--db <path> SQLite 工單與產物資料庫,預設 DISPATCH_HOME/dowafu.db
|
|
@@ -107,7 +109,8 @@ const MESSAGES = {
|
|
|
107
109
|
mcpJobPrefixAmbiguous: (prefix, count) => `前綴 ${prefix} 命中 ${count} 個 job,請改用完整 id:`,
|
|
108
110
|
jobNotApprovable: (jobId, status) => `job ${jobId} 目前是 ${status},不能核准`,
|
|
109
111
|
jobApproved: (jobId) => `已核准 job:${jobId}`,
|
|
110
|
-
daemonStarted: () => "daemon 已啟動。收到 SIGINT
|
|
112
|
+
daemonStarted: () => "daemon 已啟動。收到 SIGINT 時會乾淨退出。",
|
|
113
|
+
daemonStartApprovedCleaned: (count) => `啟動前已將 ${count} 張舊的已核准 job 退回待核准。`,
|
|
111
114
|
daemonStartFailed: (detail) => `daemon 無法啟動:${detail}`,
|
|
112
115
|
daemonWorkerSpawnFailed: (detail) => `worker 無法啟動:${detail}`,
|
|
113
116
|
daemonWorkerExited: (exitCode) => `worker 以 exit code ${exitCode} 結束`,
|
|
@@ -115,18 +118,23 @@ const MESSAGES = {
|
|
|
115
118
|
daemonWorkerTerminated: (signal) => `worker 被訊號 ${signal} 終止`,
|
|
116
119
|
daemonWorkerTerminatedDetail: (signal, detail) => `worker 被訊號 ${signal} 終止:${detail}`,
|
|
117
120
|
stopRequiresServe: () => "--stop 只能與 serve 一起使用。",
|
|
121
|
+
stopRestartConflict: () => "--stop 與 --restart 不能同時使用。",
|
|
118
122
|
daemonStopRunningWarning: (count) => `目前有 ${count} 張 job 正在執行;停止 daemon 不會停止正在跑的 spoke,它們會繼續跑完,之後結果與 API 成本仍會被認列。`,
|
|
119
123
|
daemonStopConfirmPrompt: () => "確定要停止 daemon 嗎?[y/N] ",
|
|
124
|
+
daemonStopApprovedWarning: (count) => `目前有 ${count} 張已核准 job 尚未執行;停止前會退回待核准,避免下一支 daemon 未經本輪核准就執行。`,
|
|
125
|
+
daemonStopApprovedConfirmPrompt: () => "確定要清理這些已核准 job 嗎?[y/N] ",
|
|
120
126
|
daemonStopCancelled: () => "已取消,daemon 仍在執行。",
|
|
121
127
|
daemonStopNotRunning: (reason) => `daemon 未在執行(${reason})。`,
|
|
122
128
|
daemonStopPidNotDowafu: (pid) => `pid ${pid} 的行程不是 dowafu,為避免誤殺未送出訊號。`,
|
|
123
|
-
daemonStopSignalFailed: (pid) => `已向 pid ${pid}
|
|
129
|
+
daemonStopSignalFailed: (pid) => `已向 pid ${pid} 送出 SIGTERM,但它沒有停止。請手動檢查:ps -p ${pid} -o pid,ppid,command=;確認後可再送 SIGTERM。不要用 SIGKILL,worker 子行程可能仍會繼續寫入。`,
|
|
124
130
|
daemonStopSucceeded: (pid) => `daemon(pid ${pid})已停止。`,
|
|
125
131
|
daemonStopRestarted: (pid) => `daemon(原 pid ${pid})停止後又恢復運行;看起來有 OS 層服務管理在重拉它。要真的停下來請用 launchctl 或 systemctl。`,
|
|
132
|
+
daemonStopApprovedCleaned: (count) => `已將 ${count} 張已核准 job 退回待核准。`,
|
|
133
|
+
daemonRestartStarted: () => "daemon 已重新啟動。",
|
|
126
134
|
doctorDaemonLine: (value) => ` daemon ${value}`,
|
|
127
135
|
doctorDaemonAlive: (pid) => `運行中(pid ${pid})`,
|
|
128
136
|
doctorDaemonMissing: (reason) => `未運行(${reason})`,
|
|
129
|
-
daemonOfflineWarning: (reason) => `⚠ worker daemon 未運行(${reason});job
|
|
137
|
+
daemonOfflineWarning: (reason) => `⚠ worker daemon 未運行(${reason});job 已排入但不會執行。請先起 dowafu serve 再核准——daemon 沒在跑時給的核准,會在它啟動時退回待核准。`,
|
|
130
138
|
mcpTicketsEmpty: () => "沒有可派的 DB 工單。",
|
|
131
139
|
mcpTicketsTitle: () => "可派工單:",
|
|
132
140
|
mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}:${spokeCount} 個 spoke`,
|
|
@@ -209,6 +217,7 @@ const MESSAGES = {
|
|
|
209
217
|
agentFileNotFound: (agentPath, agent) => `找不到 ${agentPath}(_dispatch.md 列了 agent "${agent}")`,
|
|
210
218
|
ticketNotFound: (name) => `找不到 DB 工單:${name}`,
|
|
211
219
|
ticketCorrupt: (name) => `DB 工單內容損毀:${name}`,
|
|
220
|
+
ticketNameReserved: (name, reserved) => `工單名稱「${name}」是 CLI 保留字。保留名稱:${reserved}(--* 表示所有 -- 開頭的名稱)`,
|
|
212
221
|
ticketNameInvalid: (name) => `工單名稱無效:${name}`,
|
|
213
222
|
ticketAlreadyExists: (name) => `DB 工單已存在:${name}`,
|
|
214
223
|
ticketSpokeNotFound: (name, agent) => `DB 工單 "${name}" 沒有 agent "${agent}"`,
|
|
@@ -346,6 +355,8 @@ const MESSAGES = {
|
|
|
346
355
|
doctorModelListValue: (enabled, total, mostExpensive) => `啟用 ${enabled}/${total};最貴啟用型號 ${mostExpensive};來源 SQLite DB`,
|
|
347
356
|
doctorModelListLoadFailedValue: (reason) => `無法載入:${reason}`,
|
|
348
357
|
doctorLensLine: (value) => ` lens 定義 ${value}`,
|
|
358
|
+
doctorQueueLine: (value) => ` 佇列 ${value}`,
|
|
359
|
+
doctorQueueValue: (pendingApproval, approved, running) => `待核准 ${pendingApproval};已核准 ${approved};執行中 ${running}`,
|
|
349
360
|
doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} 找到 ${total} 個定義檔,其中 ${closingCount} 個具備固定收尾句:\n${" ".repeat(14)}${closingNames}${noClosingSuffix}`,
|
|
350
361
|
doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(14)}(另有 ${count} 個不是 lens:${names})`,
|
|
351
362
|
doctorLensDirMissingValue: (dirPath) => `目錄不存在:${dirPath}`,
|
|
@@ -355,7 +366,7 @@ const MESSAGES = {
|
|
|
355
366
|
unknownOption: (arg, helpText) => `Unknown option: ${arg}\n\n${helpText}`,
|
|
356
367
|
missingProviders: (path) => `providers.json not found: ${path}`,
|
|
357
368
|
dryRunNotice: () => "--dry-run: parsed, validated, estimated, and reported only; no API calls were made.",
|
|
358
|
-
helpText: (cmd) => `Usage: ${cmd} <ticket-id>
|
|
369
|
+
helpText: (cmd) => `Usage: ${cmd} --ticket <name> [options] (positional <ticket-id> also accepted)
|
|
359
370
|
|
|
360
371
|
Subcommands (each has its own usage message):
|
|
361
372
|
${cmd} ticket ... Create, import and inspect tickets
|
|
@@ -365,9 +376,11 @@ Subcommands (each has its own usage message):
|
|
|
365
376
|
${cmd} token ... Issue, list and revoke inbound HTTP tokens
|
|
366
377
|
${cmd} approve [jobId] Approve a dispatch waiting in the queue
|
|
367
378
|
${cmd} serve Run the daemon (includes the MCP HTTP binding)
|
|
368
|
-
${cmd} serve --stop [--yes] Stop the daemon; asks first
|
|
379
|
+
${cmd} serve --stop [--yes] Stop the daemon; asks first about running or old approved jobs
|
|
380
|
+
${cmd} serve --restart [--yes] Stop then start the daemon, running both cleanup phases
|
|
369
381
|
${cmd} mcp Serve MCP over stdio
|
|
370
382
|
|
|
383
|
+
--ticket <name> Select a ticket; cannot be combined with a positional ticket
|
|
371
384
|
--lang <en|zh-tw> Language for CLI output and spoke prompts, default en
|
|
372
385
|
--repo-root <dir> Root for the allowlist boundary and .claude/agents, default cwd
|
|
373
386
|
--db <path> SQLite ticket/result database, default DISPATCH_HOME/dowafu.db
|
|
@@ -435,7 +448,8 @@ Subcommands (each has its own usage message):
|
|
|
435
448
|
mcpJobPrefixAmbiguous: (prefix, count) => `Prefix ${prefix} matches ${count} jobs; use the full id:`,
|
|
436
449
|
jobNotApprovable: (jobId, status) => `Job ${jobId} is ${status} and cannot be approved`,
|
|
437
450
|
jobApproved: (jobId) => `Approved job: ${jobId}`,
|
|
438
|
-
daemonStarted: () => "Daemon started. SIGINT exits cleanly
|
|
451
|
+
daemonStarted: () => "Daemon started. SIGINT exits cleanly.",
|
|
452
|
+
daemonStartApprovedCleaned: (count) => `${count} older approved job(s) returned to pending approval before startup.`,
|
|
439
453
|
daemonStartFailed: (detail) => `Could not start daemon: ${detail}`,
|
|
440
454
|
daemonWorkerSpawnFailed: (detail) => `Could not start worker: ${detail}`,
|
|
441
455
|
daemonWorkerExited: (exitCode) => `worker exited ${exitCode}`,
|
|
@@ -443,18 +457,23 @@ Subcommands (each has its own usage message):
|
|
|
443
457
|
daemonWorkerTerminated: (signal) => `worker terminated by ${signal}`,
|
|
444
458
|
daemonWorkerTerminatedDetail: (signal, detail) => `worker terminated by ${signal}: ${detail}`,
|
|
445
459
|
stopRequiresServe: () => "--stop can only be used with serve.",
|
|
460
|
+
stopRestartConflict: () => "--stop and --restart cannot be used together.",
|
|
446
461
|
daemonStopRunningWarning: (count) => `${count} job(s) are running; stopping the daemon does not stop their active spokes, which continue to completion and will still have their results and API costs recorded.`,
|
|
447
462
|
daemonStopConfirmPrompt: () => "Stop the daemon? [y/N] ",
|
|
463
|
+
daemonStopApprovedWarning: (count) => `${count} approved job(s) have not started; stopping returns them to pending approval so the next daemon cannot run them without a new approval.`,
|
|
464
|
+
daemonStopApprovedConfirmPrompt: () => "Clear these approved jobs? [y/N] ",
|
|
448
465
|
daemonStopCancelled: () => "Cancelled; the daemon is still running.",
|
|
449
466
|
daemonStopNotRunning: (reason) => `Daemon is not running (${reason}).`,
|
|
450
467
|
daemonStopPidNotDowafu: (pid) => `pid ${pid} is not a dowafu process; no signal was sent to avoid killing the wrong process.`,
|
|
451
|
-
daemonStopSignalFailed: (pid) => `
|
|
468
|
+
daemonStopSignalFailed: (pid) => `SIGTERM was sent to pid ${pid}, but it did not stop. Inspect it manually: ps -p ${pid} -o pid,ppid,command=; after confirming, you can send SIGTERM again. Do not use SIGKILL: worker children may still write results.`,
|
|
452
469
|
daemonStopSucceeded: (pid) => `Daemon (pid ${pid}) stopped.`,
|
|
453
470
|
daemonStopRestarted: (pid) => `Daemon (previous pid ${pid}) came back after stopping; an OS-level service manager appears to be restarting it. Use launchctl or systemctl to stop it permanently.`,
|
|
471
|
+
daemonStopApprovedCleaned: (count) => `${count} approved job(s) returned to pending approval.`,
|
|
472
|
+
daemonRestartStarted: () => "Daemon restarted.",
|
|
454
473
|
doctorDaemonLine: (value) => ` Daemon ${value}`,
|
|
455
474
|
doctorDaemonAlive: (pid) => `running (pid ${pid})`,
|
|
456
475
|
doctorDaemonMissing: (reason) => `not running (${reason})`,
|
|
457
|
-
daemonOfflineWarning: (reason) => `⚠ The worker daemon is not running (${reason}); the job is queued but will not execute. Start
|
|
476
|
+
daemonOfflineWarning: (reason) => `⚠ The worker daemon is not running (${reason}); the job is queued but will not execute. Start dowafu serve before approving — approvals given while no daemon is running are returned to pending approval when it starts.`,
|
|
458
477
|
mcpTicketsEmpty: () => "No dispatchable DB tickets.",
|
|
459
478
|
mcpTicketsTitle: () => "Dispatchable tickets:",
|
|
460
479
|
mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}: ${spokeCount} spoke(s)`,
|
|
@@ -540,6 +559,7 @@ Subcommands (each has its own usage message):
|
|
|
540
559
|
agentFileNotFound: (agentPath, agent) => `Not found: ${agentPath} (_dispatch.md lists agent "${agent}")`,
|
|
541
560
|
ticketNotFound: (name) => `DB ticket not found: ${name}`,
|
|
542
561
|
ticketCorrupt: (name) => `DB ticket is corrupt: ${name}`,
|
|
562
|
+
ticketNameReserved: (name, reserved) => `Ticket name "${name}" is reserved by the CLI. Reserved names: ${reserved} (--* means every name starting with --)`,
|
|
543
563
|
ticketNameInvalid: (name) => `Invalid ticket name: ${name}`,
|
|
544
564
|
ticketAlreadyExists: (name) => `DB ticket already exists: ${name}`,
|
|
545
565
|
ticketSpokeNotFound: (name, agent) => `DB ticket "${name}" has no agent "${agent}"`,
|
|
@@ -677,6 +697,8 @@ Subcommands (each has its own usage message):
|
|
|
677
697
|
doctorModelListValue: (enabled, total, mostExpensive) => `enabled ${enabled}/${total}; most expensive enabled ${mostExpensive}; source SQLite DB`,
|
|
678
698
|
doctorModelListLoadFailedValue: (reason) => `failed to load: ${reason}`,
|
|
679
699
|
doctorLensLine: (value) => ` Lens defs ${value}`,
|
|
700
|
+
doctorQueueLine: (value) => ` Queue ${value}`,
|
|
701
|
+
doctorQueueValue: (pendingApproval, approved, running) => `pending approval ${pendingApproval}; approved ${approved}; running ${running}`,
|
|
680
702
|
doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} holds ${total} definition file(s), ${closingCount} with a fixed closing line:\n${" ".repeat(15)}${closingNames}${noClosingSuffix}`,
|
|
681
703
|
doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(15)}(${count} other agent file(s), not lenses: ${names})`,
|
|
682
704
|
doctorLensDirMissingValue: (dirPath) => `directory not found: ${dirPath}`,
|
package/dist/prompt.js
CHANGED
|
@@ -90,6 +90,12 @@ function buildStep3(allowedReadsRelative, lang) {
|
|
|
90
90
|
function displayTicketDir(ticketDir, repoRoot) {
|
|
91
91
|
if (!repoRoot)
|
|
92
92
|
return ticketDir;
|
|
93
|
+
// ticket-dir-cwd:相對路徑一律原樣輸出,不交給 path.relative——後者會先以 cwd 解析它,
|
|
94
|
+
// cwd 不是 repoRoot 時(含 repoRoot 的子目錄)就變成另一個字串。DB 工單的 ticketDir 是虛擬的
|
|
95
|
+
// `ticket/<name>`,它就是快照查表鍵的前綴,spoke 必須逐字看到它。目錄工單由呼叫端
|
|
96
|
+
// (runner.ts 的 buildSpokeFirstUserText)先 resolve 成絕對路徑,不走這條。
|
|
97
|
+
if (!path.isAbsolute(ticketDir))
|
|
98
|
+
return ticketDir;
|
|
93
99
|
const rel = path.relative(repoRoot, ticketDir);
|
|
94
100
|
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
|
95
101
|
return ticketDir;
|
package/dist/providers-store.js
CHANGED
|
@@ -7,7 +7,10 @@ import { parseProvidersFile } from "./providers.js";
|
|
|
7
7
|
import { bundledProvidersPath } from "./pkg-info.js";
|
|
8
8
|
const DEFAULT_ENABLED = new Set([
|
|
9
9
|
"gpt-5.6-luna",
|
|
10
|
-
|
|
10
|
+
// 2026-09-12:預設改為 deepseek-flash。deepseek-v4-flash 已於 2026-09-10 退役,
|
|
11
|
+
// 目前只是暫時轉址到 V4.1 Flash 且官方未給截止日——預設開一個隨時會消失的型號,
|
|
12
|
+
// 壞法會是「新裝的人某天突然跑不動」。deepseek-flash 已 smoke 實跑通過。
|
|
13
|
+
"deepseek-flash",
|
|
11
14
|
"gemini-3.1-flash-lite",
|
|
12
15
|
"gemini-3.5-flash-lite",
|
|
13
16
|
"claude-haiku-4-5-20251001",
|
package/dist/runner.js
CHANGED
|
@@ -134,10 +134,19 @@ async function sendWithResilience(adapter, conv, sendOpts, cfg, ctx) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
// ticket-dir-cwd(log_v0_6_0_open.md #31):spoke 第一則訊息的唯一組裝點——實跑(runSpoke)與
|
|
138
|
+
// 乾跑估算(cli.ts)都走這裡,不得各自組裝。原本 runSpoke 先 path.resolve、乾跑不 resolve,
|
|
139
|
+
// cwd ≠ --repo-root 時實跑給出查表查不到的 `<cwd>/ticket/<name>`,乾跑卻一切正常,所以一路看不見。
|
|
140
|
+
// DB 工單(readableContent 存在)的 ticketDir 是快照查表鍵的前綴、不是檔案系統路徑,原樣傳遞——
|
|
141
|
+
// 判別條件與 executeToolCall 相同,「prompt 給的路徑」因此就是「查表用的鍵」。
|
|
142
|
+
// 目錄工單(僅解析器測試)維持原本「相對 cwd 解析」的語意。
|
|
143
|
+
export function buildSpokeFirstUserText(spoke, ticketDir, repoRoot) {
|
|
144
|
+
return buildFirstUserText(spoke.readableContent ? ticketDir : path.resolve(ticketDir), spoke.agent, spoke.allowedReadsRelative, repoRoot, spoke.lang);
|
|
145
|
+
}
|
|
137
146
|
export async function runSpoke(spoke, adapter, ticketDir, options) {
|
|
138
147
|
const startedAt = Date.now();
|
|
139
148
|
const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
|
|
140
|
-
const firstUserText =
|
|
149
|
+
const firstUserText = buildSpokeFirstUserText(spoke, ticketDir, options.repoRoot);
|
|
141
150
|
const conv = { systemPrompt, turns: [{ role: "user", text: firstUserText }] };
|
|
142
151
|
let toolCallCount = 0;
|
|
143
152
|
let cumulativeUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, available: true };
|
package/dist/ticket-store.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DispatchError } from "./types.js";
|
|
5
5
|
import { m } from "./messages.js";
|
|
6
|
+
import { isReservedTicketName, reservedTicketNames } from "./cli-commands.js";
|
|
6
7
|
import { now } from "./db.js";
|
|
7
8
|
import { parseAgentTicket, parseDispatchTable } from "./ticket.js";
|
|
8
9
|
import { isModelEnabled, loadProviders } from "./providers-store.js";
|
|
@@ -32,6 +33,8 @@ function put(db, name, doc) {
|
|
|
32
33
|
export function createTicket(db, name, shared, lang) {
|
|
33
34
|
if (!name || name.includes(path.sep) || name === "." || name === "..")
|
|
34
35
|
throw new DispatchError(m(lang, "ticketNameInvalid", name), 2);
|
|
36
|
+
if (isReservedTicketName(name))
|
|
37
|
+
throw new DispatchError(m(lang, "ticketNameReserved", name, reservedTicketNames().join(", ")), 2);
|
|
35
38
|
try {
|
|
36
39
|
db.prepare("INSERT INTO tickets (name, doc, created_at) VALUES (?, ?, ?)").run(name, JSON.stringify({ shared, spokes: [] }), now());
|
|
37
40
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dowafu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Send a section of your design doc to external LLMs for review. They read only the files you whitelist, and nothing is billed until you confirm.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"type": "module",
|
|
27
27
|
"engines": {
|
|
28
|
-
"node": ">=
|
|
28
|
+
"node": ">=22.13.0"
|
|
29
29
|
},
|
|
30
30
|
"bin": {
|
|
31
31
|
"dowafu": "dist/cli.js"
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"files": [
|
|
34
34
|
"dist",
|
|
35
35
|
"providers.json",
|
|
36
|
-
"README_zh-tw.md"
|
|
36
|
+
"README_zh-tw.md",
|
|
37
|
+
"CHANGELOG.md"
|
|
37
38
|
],
|
|
38
39
|
"scripts": {
|
|
39
40
|
"verify:providers": "tsx scripts/verify-providers.ts",
|
package/providers.json
CHANGED
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
"allowed": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
11
11
|
"default": "high"
|
|
12
12
|
},
|
|
13
|
-
"models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"],
|
|
13
|
+
"models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-6-astra"],
|
|
14
14
|
"charsPerToken": 1.0,
|
|
15
15
|
"tpmLimit": 200000,
|
|
16
16
|
"maxSpokeTokens": null,
|
|
17
|
-
"pricingSource": { "url": "https://developers.openai.com/api/docs/pricing", "asOf": "2026-09-
|
|
17
|
+
"pricingSource": { "url": "https://developers.openai.com/api/docs/pricing", "asOf": "2026-09-12", "note": "gpt-5.6-sol 為優惠價,官方頁措辭為「at least through 2026-11-21」,到期日可能再延。" },
|
|
18
18
|
"pricing": {
|
|
19
19
|
"gpt-5.6-luna": { "inputPerM": 0.20, "cachedInputPerM": 0.02, "outputPerM": 1.20 },
|
|
20
20
|
"gpt-5.6-terra": { "inputPerM": 2.00, "cachedInputPerM": 0.20, "outputPerM": 12.00 },
|
|
21
|
-
"gpt-5.6-sol": { "inputPerM": 4.00, "cachedInputPerM": 0.40, "outputPerM": 20.00 }
|
|
21
|
+
"gpt-5.6-sol": { "inputPerM": 4.00, "cachedInputPerM": 0.40, "outputPerM": 20.00 },
|
|
22
|
+
"gpt-6-astra": { "inputPerM": 10.00, "cachedInputPerM": 1.00, "outputPerM": 50.00 }
|
|
22
23
|
}
|
|
23
24
|
},
|
|
24
25
|
"deepseek": {
|
|
@@ -31,17 +32,18 @@
|
|
|
31
32
|
"allowed": ["low", "high", "max"],
|
|
32
33
|
"default": "high"
|
|
33
34
|
},
|
|
34
|
-
"models": ["deepseek-v4-flash", "deepseek-v4-pro"],
|
|
35
|
+
"models": ["deepseek-v4-flash", "deepseek-flash", "deepseek-v4-pro"],
|
|
35
36
|
"charsPerToken": 1.0,
|
|
36
37
|
"tpmLimit": null,
|
|
37
38
|
"maxSpokeTokens": null,
|
|
38
39
|
"pricingSource": {
|
|
39
40
|
"url": "https://api-docs.deepseek.com/quick_start/pricing/",
|
|
40
|
-
"asOf": "2026-09-
|
|
41
|
-
"note": "
|
|
41
|
+
"asOf": "2026-09-12",
|
|
42
|
+
"note": "離峰(尖峰時段為週一至週五 01:00-04:00 與 06:00-10:00 UTC,其餘時段皆為離峰)為尖峰的一半;本檔一個型號只放得下一組價,取尖峰=取上限。deepseek-v4-flash 已於 2026-09-10 退役、目前暫時轉址到 V4.1 Flash(deepseek-flash),官方未給截止日;其價格仍為退役前的舊值,故估算偏高。"
|
|
42
43
|
},
|
|
43
44
|
"pricing": {
|
|
44
45
|
"deepseek-v4-flash": { "inputPerM": 0.44, "cachedInputPerM": 0.014, "outputPerM": 1.32 },
|
|
46
|
+
"deepseek-flash": { "inputPerM": 0.30, "cachedInputPerM": 0.006, "outputPerM": 1.20 },
|
|
45
47
|
"deepseek-v4-pro": { "inputPerM": 1.32, "cachedInputPerM": 0.044, "outputPerM": 3.96 }
|
|
46
48
|
}
|
|
47
49
|
},
|
|
@@ -55,16 +57,17 @@
|
|
|
55
57
|
"allowed": ["minimal", "low", "medium", "high"],
|
|
56
58
|
"default": "high"
|
|
57
59
|
},
|
|
58
|
-
"models": ["gemini-3.1-flash-lite", "gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-3.7-flash"],
|
|
60
|
+
"models": ["gemini-3.1-flash-lite", "gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"],
|
|
59
61
|
"charsPerToken": 1.0,
|
|
60
62
|
"tpmLimit": null,
|
|
61
63
|
"maxSpokeTokens": null,
|
|
62
|
-
"pricingSource": { "url": "https://ai.google.dev/gemini-api/docs/pricing", "asOf": "2026-09-
|
|
64
|
+
"pricingSource": { "url": "https://ai.google.dev/gemini-api/docs/pricing", "asOf": "2026-09-12", "note": "文字/圖片/影片價;音訊另計。output 價含 thinking tokens。gemini-3.6-flash/gemini-3.7-flash/gemini-3.8-flash 為優惠價,官方頁載明至 2026-12-31;2027-01-01 起漲為 $1.50 in/$7.50 out(三者皆是)" },
|
|
63
65
|
"pricing": {
|
|
64
66
|
"gemini-3.1-flash-lite": { "inputPerM": 0.25, "cachedInputPerM": 0.025, "outputPerM": 1.50 },
|
|
65
67
|
"gemini-3.5-flash-lite": { "inputPerM": 0.30, "cachedInputPerM": 0.03, "outputPerM": 2.50 },
|
|
66
68
|
"gemini-3.6-flash": { "inputPerM": 0.75, "cachedInputPerM": 0.075, "outputPerM": 3.75 },
|
|
67
|
-
"gemini-3.7-flash": { "inputPerM": 0.75, "cachedInputPerM": 0.075, "outputPerM": 3.75 }
|
|
69
|
+
"gemini-3.7-flash": { "inputPerM": 0.75, "cachedInputPerM": 0.075, "outputPerM": 3.75 },
|
|
70
|
+
"gemini-3.8-flash": { "inputPerM": 0.75, "cachedInputPerM": 0.075, "outputPerM": 3.75 }
|
|
68
71
|
}
|
|
69
72
|
},
|
|
70
73
|
"anthropic": {
|