@itookit/dsht 0.5.1 → 0.6.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.i18n.yaml +2 -2
- package/README.md +10 -4
- package/README.zh.md +12 -6
- package/dist/catalog/controller.d.ts +26 -6
- package/dist/catalog/controller.js +73 -45
- package/dist/catalog/index.d.ts +1 -0
- package/dist/cli/dsht.js +22 -2
- package/dist/cli/startup.js +30 -11
- package/dist/cli/verifier.d.ts +4 -0
- package/dist/cli/verifier.js +28 -5
- package/dist/contracts.d.ts +42 -5
- package/dist/controller/connection-streams.d.ts +22 -0
- package/dist/controller/connection-streams.js +105 -0
- package/dist/controller/connection.d.ts +14 -3
- package/dist/controller/connection.js +40 -69
- package/dist/controller/controller.d.ts +20 -234
- package/dist/controller/controller.js +113 -811
- package/dist/controller/foreground.d.ts +44 -0
- package/dist/controller/foreground.js +79 -0
- package/dist/controller/loop-coordinator.d.ts +48 -0
- package/dist/controller/loop-coordinator.js +647 -0
- package/dist/controller/loop-prompts-schema.d.ts +16 -2
- package/dist/controller/loop-prompts-schema.js +106 -27
- package/dist/controller/loop-prompts.d.ts +17 -2
- package/dist/controller/loop-prompts.generated.js +2 -1
- package/dist/controller/loop-prompts.js +35 -9
- package/dist/controller/loop-protocols.d.ts +3 -1
- package/dist/controller/loop-protocols.js +8 -3
- package/dist/controller/loop-source.d.ts +74 -0
- package/dist/controller/loop-source.js +224 -0
- package/dist/controller/verifier.d.ts +4 -0
- package/dist/cost/controller.d.ts +3 -1
- package/dist/cost/controller.js +26 -7
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger-files.d.ts +20 -0
- package/dist/cost/ledger-files.js +115 -15
- package/dist/cost/ledger.d.ts +31 -6
- package/dist/cost/ledger.js +74 -22
- package/dist/cost/pricing.d.ts +39 -0
- package/dist/cost/pricing.js +46 -0
- package/dist/cost/scanner.js +1 -0
- package/dist/cost/types.d.ts +9 -3
- package/dist/session/controller.d.ts +23 -35
- package/dist/session/controller.js +113 -363
- package/dist/session/history-reader.d.ts +32 -0
- package/dist/session/history-reader.js +170 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/info.d.ts +3 -38
- package/dist/session/info.js +14 -1
- package/dist/session/interactions.d.ts +26 -0
- package/dist/session/interactions.js +75 -0
- package/dist/session/navigator.d.ts +47 -0
- package/dist/session/navigator.js +158 -0
- package/dist/session/prompt-backfill.d.ts +23 -0
- package/dist/session/prompt-backfill.js +88 -0
- package/dist/session/state.d.ts +20 -0
- package/dist/session/state.js +1 -0
- package/dist/session/telemetry.d.ts +15 -6
- package/dist/session/telemetry.js +44 -7
- package/dist/session/transcript.d.ts +5 -1
- package/dist/slash/index.d.ts +1 -1
- package/dist/slash/parse.d.ts +2 -126
- package/dist/slash/registry.d.ts +1 -1
- package/dist/slash/types.d.ts +126 -0
- package/dist/slash/types.js +1 -0
- package/dist/state.d.ts +5 -17
- package/dist/state.js +1 -1
- package/dist/storage/files.d.ts +8 -0
- package/dist/storage/files.js +18 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/transport/client.d.ts +4 -3
- package/dist/transport/client.js +71 -25
- package/dist/ui/app.js +88 -301
- package/dist/ui/chat/shell-view.d.ts +2 -0
- package/dist/ui/chat/shell-view.js +8 -0
- package/dist/ui/chat/use-history-view.d.ts +69 -0
- package/dist/ui/chat/use-history-view.js +123 -0
- package/dist/ui/dialogs/cost.d.ts +6 -0
- package/dist/ui/dialogs/cost.js +5 -1
- package/dist/ui/dialogs/loop.d.ts +5 -4
- package/dist/ui/dialogs/loop.js +14 -6
- package/dist/ui/dialogs/use-panels.d.ts +53 -0
- package/dist/ui/dialogs/use-panels.js +51 -0
- package/dist/ui/input/use-composer.d.ts +35 -0
- package/dist/ui/input/use-composer.js +109 -0
- package/dist/ui/input/use-deferred-lines.d.ts +16 -0
- package/dist/ui/input/use-deferred-lines.js +54 -0
- package/dist/ui/input/use-history-recall.d.ts +20 -0
- package/dist/ui/input/use-history-recall.js +47 -0
- package/loop.yaml +230 -0
- package/package.json +5 -4
package/README.i18n.yaml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# Git blob hashes of the reviewed bilingual pair.
|
|
2
|
-
README.md:
|
|
3
|
-
README.zh.md:
|
|
2
|
+
README.md: 02d559e19c88336d6eb1f1e1ff80999bbfaa7274
|
|
3
|
+
README.zh.md: b962a44f8a85716f4bd35f8afd0af7d140ffe0ca
|
package/README.md
CHANGED
|
@@ -345,7 +345,7 @@ Tab completes the leading slash command, extending an ambiguous draft to the sha
|
|
|
345
345
|
| `/permission [preset]` | View or switch the host sandbox/approval preset |
|
|
346
346
|
| `/feedback TEXT` | Record feedback about the current session |
|
|
347
347
|
| `/handoff` | Delete the local HANDOFF.md, then have the agent write a fresh session handoff |
|
|
348
|
-
| `/loop [name\|stop\|answer] [score] [tries]` | Pick a
|
|
348
|
+
| `/loop [name\|stop\|answer] [score] [tries]` | Pick a loop record from a list, edit the inputs and limits it starts from, then run the scored loop; records are the shipped `loop.yaml` with any `loop.yaml` from the config directory merged over it; `stop`/`abort` ends the run and `answer TEXT` supplies what a paused verifier asked for |
|
|
349
349
|
| `/export [local.zip]` | Download the session log ZIP to a new local file |
|
|
350
350
|
| `/export-html [local.html]` | Save the loaded conversation as offline HTML with diagrams and math |
|
|
351
351
|
| `/coredump [tag]` | Write a V8 heap snapshot to the working directory for memory diagnosis |
|
|
@@ -374,6 +374,8 @@ A command that would write to the conversation while the agent is working — `/
|
|
|
374
374
|
|
|
375
375
|
`/loop [name|stop|answer] [score] [tries]` runs one record from `loop.yaml` as a **client-driven** loop, and it is meant to be chosen rather than typed. Typing `/loop` lists every record under the composer with its rounds, artifact, declared defaults and any input it is pointed at; ↑/↓ selects, Enter confirms the highlighted record, and Tab inserts its name for anyone who wants to add flags. Confirming opens a parameter list pre-filled with that record's own inputs first — for `designdoc-review`, the `path` of the document under review — followed by the shared limits `From`, `To`, `Pass`, `Tries`; a value is replaced by typing over it, the inputs as free text and the limits as numbers checked exactly as the flags are, leaving a row with the arrows or Enter commits what was typed so no box needs its own confirmation, `Start run` launches, and `← Choose another record` goes back; nothing runs until Start is pressed, so a default is always visible before it is spent. Editing a record's input retargets that one run and leaves `loop.yaml` alone, so `designdoc-review` reviews any document and `design-review` is unaffected. Passing any flag on the line (`/loop design-review 9 3`, `--from`, `--to`, `--score`, `--tries`) skips the form and runs exactly what was typed, which keeps scripted and headless use unchanged, and a name that does not exist lists the records that do. The record itself owns the rounds, their checklists, any extra standard, its fixed inputs and the defaults: this client sends the round's brief, an independent `dsht` process verifies the work in a session of its own and writes a verdict file back, and the client decides what comes next — a score at or above the passing mark advances to the next round, a lower score costs one attempt, and a round that exhausts its budget stops the run. The shipped records are `design-review` (ten rounds over the module design) and `designdoc-review` (the same loop over the document named by the record's `vars.path`, keeping one review file per document next to it — `tui-design.md.review.md`, `loop.md.review.md` — so retargeting a run never reads or overwrites another document's rounds; that file is a template in the record, `{{path}}.review.md`, and it is gitignored). `blocked` stops the run at once instead of spending the attempt budget, a verifier that cannot judge asks for a person instead and stops it the same way (headless runs exit 3), a round is only accepted once the client itself has checked that the round's own section reached the artifact file, and `--deadline <minutes>` bounds the whole run. A `passed` run only ever claims the rounds it covered and says which ones (`rounds 1–3/10 · selected range`); a run over the whole record ends on the consolidation round, whose verifier is handed every earlier round's checklist to re-check, so a later round that broke an earlier requirement cannot pass unnoticed. A round that starts by verifying runs inside the forked verifier's own session, so the selected session's conversation stays empty until a round fails and asks the agent to work; the progress line says `verifying step N · attempt M` while that check runs, the status bar reports the same work (`◐` with the loop's step) instead of claiming Ready, and a verifier that cannot produce a verdict reports a structured reason — its exit code plus what the child itself reported (no JSON verdict in the reply, or no reply committed after the turn) rather than the class of its error output alone — instead of waiting in silence, and the verifier is handed the run's own inputs, so it cannot judge a document the artifact only mentions from an earlier run, and `--trace-verbose` adds the sanitized last line. A progress line above the composer shows the round, attempt and best score; sending an ordinary message, `/loop stop`, `/cancel`, Esc, Ctrl+C, switching sessions or losing the connection all stop the loop, which is never persisted. `/loop stop` is a control command, so it is admitted while the run is in flight; with nothing running it says so instead of failing, and the finished run's progress line stays readable — the line that ended it still shows the result, and the next line you run clears it. A verifier that cannot judge *pauses* the run instead of ending it: the progress line and the status bar say `needs you`, `/loop answer TEXT` adds the missing condition and re-judges the current artifact under a new verification identity without spending an attempt or sending the agent anything, and `/loop abort` (the paused spelling of `/loop stop`) ends the run.
|
|
376
376
|
|
|
377
|
+
The shipped `loop.yaml` is read at startup rather than compiled in, so the records are configuration: the file travels in the package, and a `loop.yaml` in the configuration directory (`$DSHT_CONFIG_DIR`, default `~/.config/dsht`) layers over it. A record with the same name **replaces** the shipped one whole, a new name adds a record, and every record your file does not name keeps following the package — which is why an upgrade still fixes shipped records you did not override. `DSHT_LOOP_FILE` points at another file instead. The record list says so: rows from your file are marked `· yours` and the file is named above them. An invalid file stops the client at startup with the path and the field at fault; a shipped file that cannot be read falls back to the records compiled into the build. Because a record you override can never be updated for you, the shipped definition's digest is stamped under the state directory and the first start after it changes reports that the shipped update is not reaching your copy — your file still wins, and nothing ever rewrites it.
|
|
378
|
+
|
|
377
379
|
A file reference sends only `@path` in a text block. Harness instructs the model to read the referenced file or list the directory when needed; the TUI does not read local files, upload bytes, or expand contents into the prompt. Referencing an image path does not attach image data. Local attachments, image uploads/previews, and `@` session references are not implemented.
|
|
378
380
|
|
|
379
381
|
Pending ordinary messages appear inside the composer, with up to two previews. `/queue` opens the full pending-input picker; ↑/↓ selects and Enter, `d`, or the dedicated Delete key removes an item through the host. Esc closes this picker without cancelling the task. A claimed item is no longer removable; the host reports that race instead of resubmitting it. The control stream owns the list, including reconnect replacement and removal when input is claimed; the client does not keep a second submission queue. Questions and approvals take precedence over queue navigation, and their answers never become steering. Slash commands keep their own execution semantics. Queue previews and deletion require the host `session/control` and `session/updateQueue` capabilities.
|
|
@@ -398,6 +400,8 @@ Closed `mermaid` fences render as Unicode diagrams for supported flowcharts, sta
|
|
|
398
400
|
|
|
399
401
|
`/export-html [local.html]` saves the currently loaded conversation and live tail with Mermaid SVG images and MathJax-generated MathML. Open the file in a browser for full mathematical layout; no network or scripts are required. Older or evicted messages are excluded, and tool rows remain summaries. The default filename is timestamped in the working directory; quoted paths are accepted, existing files are never overwritten, and cancellation removes incomplete output. `/export` remains the complete host-log ZIP download.
|
|
400
402
|
|
|
403
|
+
While disconnected, you can keep editing your draft and save, edit, or delete local shortcut prompts. Host operations report that the connection is unavailable and retain the draft. Reconnecting refreshes data and restores subscriptions while preserving the selected conversation, picker filter, or host-path screen; it never automatically submits an unsent draft.
|
|
404
|
+
|
|
401
405
|
## Live status
|
|
402
406
|
|
|
403
407
|
The footer groups `◐ Working · 8s · Ctrl+C Stop`, `● Ready`, or `? Needs you` while this client still owes an approval or an answer, model and reasoning effort, the session cost beside today's spend with the all-time total, a ten-cell context bar and percentage, and session turns / total tokens / cache-hit share. Wide terminals reserve the activity column so model and metrics stay aligned when a run completes. Narrow terminals reclaim padding, remove the bar, shorten the model, and then omit lower-priority metrics while retaining the stop hint. `/status` shows the host URL, operation status, workspace path, full provider/model, pending model, usage buckets, turns, queues, jobs, and four-decimal costs. `!` flags a metrics or model catalog error, or incomplete cost coverage; details explain the cause. Running sessions use the last-used model; ready sessions use the next selection, with the host catalog default for new sessions. Model catalog changes refresh on host settings, credential, and adapter notifications.
|
|
@@ -432,11 +436,13 @@ The default price validity starts at Beijing midnight on the verification date;
|
|
|
432
436
|
|
|
433
437
|
On first interactive launch, the client creates `~/.config/dsht/prices.json` (or `$XDG_CONFIG_HOME/dsht/prices.json`). `DSHT_CONFIG_DIR` overrides that directory. The JSON array contains price versions with `id`, `provider`, `model`, `currency: "CNY"`, `source`, inclusive `from`, optional exclusive `until`, `timezone`, weekday numbers (`0` Sunday), minute-of-day `windows`, and `peak`/`offPeak` rates named `input`, `cacheRead`, `cacheWrite`, `output`, per million tokens. To update prices, close the old interval with `until` and append a new version with a unique ID and matching `from`; overlapping intervals are rejected. Restart to load configuration changes. Price discovery is manual; the TUI does not scrape prices during startup.
|
|
434
438
|
|
|
435
|
-
Usage files live under `~/.local/state/dsht/cost/<origin-hash>/` (respecting `XDG_STATE_HOME`, or `DSHT_STATE_DIR` for the application state root). Each holds one session's folded totals: the session amount with its request and unpriced counts,
|
|
439
|
+
Usage files live under `~/.local/state/dsht/cost/<origin-hash>/` (respecting `XDG_STATE_HOME`, or `DSHT_STATE_DIR` for the application state root). Each holds one session's folded totals: the session amount with its request and unpriced counts, one bucket per Beijing calendar day the session spent inside the retained window, the decision-rules revision and a digest of the price table that produced them, and the reasons a total is inexact. They exclude prompts, tool bodies, credentials, cookies, and every per-request fact. The price file is configuration and these usage files are state, so only the former belongs in a settings backup. Writes use private temporary files and atomic replacement; each session keeps one fixed file whose recorded cut and rules revision are compared before writing, so an older scan cannot displace a newer one. The cache survives restart and does not need access to the host configuration directory. A file of another generation is ignored and rebuilt by the next scan; so is an unreadable one, because a slice is a projection of the host log rather than a system of record.
|
|
440
|
+
|
|
441
|
+
The window is 60 Beijing days. Days older than that are dropped as a slice is written, and a slice whose newest day has left the window is deleted at startup, as is any ledger file — readable or not — whose file time is older than the window, so the directory cannot grow with every session this client has ever seen; letting one go costs a rescan of that session, never data. `/cost` reports the session, today, this week (from Monday) and this month (from the 1st), each counted through the current Beijing day, and the week and month rows reach back only as far as the retained window. A scan pages a session's history only when it could have spent inside that window — a session that is running, or whose host update time is inside it — plus the session on screen, whose own total the panel always reports. Billable activity moves that update time, so usage recorded while this client was away is still read on the next connection.
|
|
436
442
|
|
|
437
443
|
## Client API
|
|
438
444
|
|
|
439
|
-
Installed packages export `Client` from `@itookit/dsht` and `login`/`CookieStore` from `@itookit/dsht/auth`, with TypeScript declarations. Source consumers can import from `src/transport/client.ts` with a TypeScript loader, or from `dist/index.js` after building. `authenticate(token)` exchanges credentials; `connect()` opens one multiplexed socket; `listWorkspaces()` and `listSessions(workspaceId?)` return promises of server rows. `call(endpoint, args, signal?)` preserves host errors as `RemoteError` with `code` and `details`. Always await `close()` in `finally
|
|
445
|
+
Installed packages export `Client` from `@itookit/dsht` and `login`/`CookieStore` from `@itookit/dsht/auth`, with TypeScript declarations. Source consumers can import from `src/transport/client.ts` with a TypeScript loader, or from `dist/index.js` after building. `authenticate(token)` exchanges credentials; `connect()` opens one multiplexed socket; `listWorkspaces(signal?)` and `listSessions(workspaceId?, signal?)` return promises of server rows. `call(endpoint, args, signal?)` preserves host errors as `RemoteError` with `code` and `details`. Always await `close()` in `finally`; concurrent calls share completion. Closing permanently ends that client, so create a new `Client` for a new lifetime. A peer disconnect without `close()` allows `connect()` again with the retained cookie. Library consumers opt into persistence with `login(client, token, new CookieStore())` from `src/transport/auth.ts`; `Client.authenticate()` itself only retains credentials in memory.
|
|
440
446
|
|
|
441
447
|
Session and workspace command methods use `{ request: { ... } }` inside `args`; session listing uses `{ _request: {} }`. `$events/result` uses its named arguments directly. Follow snapshots replace retained state after reconnect; durable messages and transient assistant text remain separate. The reader accepts both `event` records and older `chunks` wrappers containing `chunkrow/text-chunks`, `chunkrow/reasoning-chunks`, or `chunkrow/tool-call-chunks`. Hosts without `assistantStream` expose live text through logged chunks; the TUI reconstructs only the unfinished attempt and preserves each packed record's starting sequence for pagination.
|
|
442
448
|
|
|
@@ -484,7 +490,7 @@ node dist/cli/index.js --help
|
|
|
484
490
|
|
|
485
491
|
Tests use isolated HTTP/WebSocket hosts, drive the real Ink picker and composer, run the CLI in subprocesses, and project copied Harness v2 workspace-edit and v0 packed-chunk recordings. The repository needs no model credentials for these checks. The recording and expected transcript live under `tests/`; they do not depend on a parent checkout. Live model-provider behavior is not covered by these tests.
|
|
486
492
|
|
|
487
|
-
Source is organised by business domain under `src/`: `transport/` owns the host wire protocol and normalizes host frames into semantic events, `session/` the transcript, history, interactions and the session runtime, `cost/` the billing ledger, `catalog/` models and presets, `shell/` local `!` commands, `controller/` the application facade and event routing, `slash/` the command syntax, `ui/` everything React and Ink, `storage/` every filesystem operation, `cli/` the composition root, and the root files (`state.ts`, `json.ts`, `text.ts`, `contracts.ts`, `session-title.ts`, `references.ts`) the shared contracts.
|
|
493
|
+
Source is organised by business domain under `src/`: `transport/` owns the host wire protocol and normalizes host frames into semantic events, `session/` the transcript, history, interactions and the session runtime, `cost/` the billing ledger, `catalog/` models and presets, `shell/` local `!` commands, `controller/` the application facade and event routing, `slash/` the command syntax, `ui/` everything React and Ink, `storage/` every filesystem operation, `cli/` the composition root, and the root files (`state.ts`, `json.ts`, `text.ts`, `contracts.ts`, `session-title.ts`, `references.ts`) the shared contracts. Internal cross-domain imports follow the allowed dependency directions, using a domain entry point or a specific module. UI leaves read domain types through `contracts.ts`. The TypeScript syntax check in `tests/architecture/dependencies.test.ts` enforces nine forbidden directions, type-only contracts, resolvable literal module targets, and the absence of dependency cycles, including type edges.
|
|
488
494
|
|
|
489
495
|
`npm test` renders frames without styling, because the assertions and the recorded expectations in `tests/expected/` describe text. A test runner started from a terminal exports `FORCE_COLOR=1` to each test file, which makes Ink interleave SGR escapes between a prompt and its text; `npm run test:terminal` reproduces that environment on any host, and `prepublishOnly` runs it so a publish from a terminal validates what a terminal actually renders. Theme tests render separate truecolor and plain subprocesses with terminal and CI color detection isolated from the parent environment.
|
|
490
496
|
|
package/README.zh.md
CHANGED
|
@@ -279,8 +279,8 @@ npx @itookit/dsht list sessions --workspace WORKSPACE_ID --json
|
|
|
279
279
|
```sh
|
|
280
280
|
npm start -- list workspaces --json
|
|
281
281
|
npm start -- list sessions --json
|
|
282
|
-
node --import tsx src/cli/index.
|
|
283
|
-
node --import tsx src/cli/index.
|
|
282
|
+
node --import tsx src/cli/index.ts list workspaces --json
|
|
283
|
+
node --import tsx src/cli/index.ts list sessions --json
|
|
284
284
|
```
|
|
285
285
|
|
|
286
286
|
JSON 输出格式为 `{ "items": [...] }`;省略 `--json` 则输出制表符分隔的列表。工作区筛选使用服务端 `sessionIds` 成员关系。工作区列表读取 `workspace/follow` 的首个 baseline 后取消订阅,不会调用不存在的 `workspace/list` 端点。
|
|
@@ -345,7 +345,7 @@ Tab 补全开头的 slash 命令,多个候选时补到公共前缀;回车也
|
|
|
345
345
|
| `/permission [preset]` | 查看或切换服务端沙箱与审批预设 |
|
|
346
346
|
| `/feedback TEXT` | 记录当前会话反馈 |
|
|
347
347
|
| `/handoff` | 先删除本地 HANDOFF.md,再让 agent 写出新的会话交接文档 |
|
|
348
|
-
| `/loop [name\|stop\|answer] [score] [tries]` |
|
|
348
|
+
| `/loop [name\|stop\|answer] [score] [tries]` | 用列表选择循环记录(随包 `loop.yaml` 与配置目录中的 `loop.yaml` 合并),修改它启动时用的输入与限制后再运行评分循环;`stop`/`abort` 结束当前运行,`answer TEXT` 补上验证者要的判断条件 |
|
|
349
349
|
| `/export [local.zip]` | 下载会话日志 ZIP 到新的本地文件 |
|
|
350
350
|
| `/export-html [local.html]` | 将已加载会话保存为包含图表和公式的离线 HTML |
|
|
351
351
|
| `/coredump [tag]` | 在当前工作目录写出 V8 堆快照,用于内存诊断 |
|
|
@@ -374,6 +374,8 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
374
374
|
|
|
375
375
|
`/loop [name|stop|answer] [score] [tries]` 运行 `loop.yaml` 里的一条记录,是一条**客户端驱动**的循环,并且设计成"选"而不是"敲"。输入 `/loop` 会在输入框下方列出所有记录及其轮数、产出物、自带默认值和它指向的输入;↑/↓ 选择,Enter 确认高亮记录,Tab 则把记录名补进草稿以便继续加旗标。确认后会打开参数列表:**先是该记录自己的输入**(如 `designdoc-review` 的 `path`,即被审查的文档),**再是共享的四个值**(`From`、`To`、`Pass`、`Tries`);选中某行直接输入即可覆盖——输入按自由文本,四个值按数字、与旗标同一套校验;用方向键或 Enter 离开某行即提交该行的内容,因此不需要在每个框里各按一次回车;`Start run` 开始运行,`← Choose another record` 返回记录列表;只有按下 Start 才会真正启动,因此每个默认值都在被花掉之前可见。改一条记录的输入只作用于本次运行、不改 `loop.yaml`,所以 `designdoc-review` 可以审查任意文档,而 `design-review` 不受影响。若命令行里写了任一旗标(`/loop design-review 9 3`、`--from`、`--to`、`--score`、`--tries`),则跳过表单、按所写的值直接运行,脚本与 headless 用法因此保持不变;名字不存在时会列出可用记录。轮次、每轮检查要点、附加标准、固定输入与默认值都由记录自带:本客户端发出本轮 Brief,由 dsht fork 出的独立进程在自己的 session 里验证并把 verdict 文件写回,客户端据此决定下一步——达到及格线进入下一轮,低于及格线消耗一次尝试,某轮用尽预算则停止。内置记录有 `design-review`(对模块设计做十轮审查)与 `designdoc-review`(同一循环,审查记录 `vars.path` 指定的文档,并**一份文档一个审查文件、就放在它旁边**(`tui-design.md.review.md`、`loop.md.review.md`),所以换文档重跑既不会读到也不会覆盖另一份文档的轮次;这个文件名在记录里是模板 `{{path}}.review.md`,且已被 `.gitignore` 覆盖)。`status` 为 `blocked` 时立即停止、不再消耗尝试预算;验证者无法判断时会要求人工介入并以同样方式停止(headless 退出码 3);只有客户端自己核对过「本轮小节确实写进了产出物文件」的轮次才算通过;`--deadline <minutes>` 约束整个 run。`passed` 只声称本次 run 覆盖的轮次并写明是哪些(`rounds 1–3/10 · selected range`);跑完整份记录时最后一轮是收束轮,它的验证者会拿到前面每一轮的检查要点逐轮复核,因此后来某一轮改坏了前序要求不会被放过。以"先验证"开始的一轮(`starts: verify` 且有 forked verifier)跑在独立验证进程自己的 session 里,因此当前会话的 history 不会出现内容,直到某一轮未通过、才要求 agent 去工作;验证进行期间进度行显示 `verifying step N · attempt M`,状态栏也如实显示同一件事(`◐` 加本轮 `review N/M`)而不是 Ready;验证者拿不出 verdict 时会给出结构化原因——退出码 + 子进程自己报告的原因(回复里没有 JSON 判定、或回复在 turn 结束后仍未提交),而不是只给错误输出类别,更不是无声等待;同时验证者会拿到本次 run 自己的输入,因此不会去评审产出物里只属于更早那次 run 的文档,`--trace-verbose` 才会附上脱敏后的最后一行。输入框上方的进度行显示轮次、尝试与最高分;发送普通消息、`/loop stop`、`/cancel`、Esc、Ctrl+C、切换会话或断线都会停止循环,该状态不持久化:`/loop stop` 属于控制泳道,运行期间也允许提交;没有运行时它会直接说明而不是报错,且已结束运行的那行进度会保留可读——结束它的那一行仍然显示结果,而你运行下一行时它就被清掉。验证者无法判决时 run 会**暂停**而不是结束:进度行与状态栏显示 `needs you`,`/loop answer TEXT` 补上缺的判断条件、以新的 verification identity 重新判断当前产出物——不消耗尝试次数,也不给 agent 发任何东西;`/loop abort`(`/loop stop` 在暂停期的拼写)结束它。
|
|
376
376
|
|
|
377
|
+
随包发布的 `loop.yaml` 在启动时读取,而不是编译期写死,因此记录是配置:文件随包分发,配置目录(`$DSHT_CONFIG_DIR`,默认 `~/.config/dsht`)下的 `loop.yaml` 叠加其上。同名记录**整条替换**内置记录,新名字新增一条记录,你没有点名的记录继续跟随包内版本——这正是升级仍能修好你没覆盖的内置记录的原因。`DSHT_LOOP_FILE` 可改指另一个文件。记录列表会把这件事说出来:来自你的文件的行标 `· yours`,并在上方写明文件名。文件非法时启动即失败,并给出行路径与出错字段;内置文件读不到时回退到编译进包的记录。被你覆盖的那条记录无法再为你自动更新,因此其内置定义的摘要会记在 state 目录下,内置定义变化后的第一次启动会提示"内置更新没有到达你的副本"——你的文件仍然生效,且任何情况下都不会被改写。
|
|
378
|
+
|
|
377
379
|
文件引用仅在文本块中发送 `@path`。Harness 提示模型按需读取文件或列出目录;TUI 不读取本地文件、不上传字节,也不将文件内容展开进提示词。引用图片路径不会附带图片数据。尚未实现本地附件、图片上传/预览及 `@` 会话引用。
|
|
378
380
|
|
|
379
381
|
待处理的普通消息显示在输入框内,最多预览两条。`/queue` 打开完整的待处理输入列表,↑/↓ 选择,Enter、`d` 或独立 Delete 键通过服务端删除。Esc 仅关闭此列表,不取消任务。消息已被领取后无法删除,服务端会提示该竞争情况,客户端不会重新发送。控制流负责列表、重连替换及消息领取后的移除,客户端不维护第二份发送队列。问题和审批优先于队列导航,其回答不会成为转向输入;slash 命令仍按各自语义执行。队列预览和删除需要服务端提供 `session/control` 与 `session/updateQueue`。
|
|
@@ -398,6 +400,8 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
398
400
|
|
|
399
401
|
`/export-html [local.html]` 将当前已加载会话和实时尾部保存为包含 Mermaid SVG 图片及 MathJax 生成的 MathML 的页面。用浏览器打开文件可查看完整数学排版,不需要网络或脚本。尚未加载或已回收的消息不包含在内,工具行仍为摘要。默认文件名带时间戳并保存在当前工作目录,支持带引号的路径,不覆盖已有文件,取消时删除未完成输出。`/export` 仍下载服务端完整日志 ZIP。
|
|
400
402
|
|
|
403
|
+
断线期间仍可编辑草稿,保存、编辑或删除本地快捷提示词。需要服务端的操作会报告未连接并保留草稿。重连只刷新数据和恢复订阅,保留当前会话、选择器筛选或路径输入页面,不会自动发送未提交的草稿。
|
|
404
|
+
|
|
401
405
|
## 实时状态
|
|
402
406
|
|
|
403
407
|
底栏分组显示 `◐ Working · 8s · Ctrl+C Stop`、`● Ready`,或本客户端还欠一个审批/回答时的 `? Needs you`、模型与思考强度、本会话费用与「今天花费(历史总计)」、十格上下文进度条及百分比、会话轮次与累计 token 及缓存命中率。宽终端为运行状态预留固定宽度,完成后模型和指标保持对齐。窄终端依次回收留白、隐藏进度条、缩短模型名、省略次要指标,优先保留停止提示。`/status` 显示主机 URL、操作状态、工作区完整路径、完整供应商/模型、下次模型、各项用量、轮次、队列、后台任务和四位小数费用。`!` 表示有指标或模型目录错误,或计费覆盖不完整;详情中显示原因。运行中使用最近实际使用的模型,空闲时使用下次模型,新会话使用服务端模型目录默认值。服务端设置、凭据和适配器变更通知会刷新模型目录。
|
|
@@ -432,11 +436,13 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
432
436
|
|
|
433
437
|
首次交互启动会创建 `~/.config/dsht/prices.json`(或 `$XDG_CONFIG_HOME/dsht/prices.json`),可用 `DSHT_CONFIG_DIR` 覆盖目录。JSON 数组中的价格版本包含 `id`、`provider`、`model`、`currency: "CNY"`、`source`、包含起点的 `from`、可选且不含终点的 `until`、`timezone`、星期数字 `weekdays`(`0` 为周日)、日内分钟区间 `windows`,以及 `peak`/`offPeak` 下每百万 token 的 `input`、`cacheRead`、`cacheWrite`、`output` 单价。调价时用 `until` 结束旧区间,再添加唯一 ID 且 `from` 衔接的新版本;程序拒绝重叠区间。重启后读取配置修改;价格由用户维护,启动时不抓取网页价格。
|
|
434
438
|
|
|
435
|
-
用量文件位于 `~/.local/state/dsht/cost/<origin-hash>/`,遵循 `XDG_STATE_HOME`,也可通过 `DSHT_STATE_DIR`
|
|
439
|
+
用量文件位于 `~/.local/state/dsht/cost/<origin-hash>/`,遵循 `XDG_STATE_HOME`,也可通过 `DSHT_STATE_DIR` 指定应用状态根目录。每个文件保存一个会话折叠后的总额:会话金额及其请求数与未计价数、该会话在保留窗口内每个自然日一个分桶、决策规则版本与该次折叠所用价格表的摘要,以及总额不精确的原因。文件不含提示词、工具正文、凭据、cookie,也不含任何逐请求信息。价格文件属于配置,这些用量文件属于状态,因此只有前者需要纳入设置备份。写入使用私有临时文件及原子替换;每个会话一个固定文件,写入前比较文件中记录的 cut 与规则版本,因此旧扫描无法覆盖较新的一次。缓存跨重启保留,不需要访问服务端配置目录。其他代数的文件会被忽略并由下一次扫描重建;无法解析的文件同样如此,因为切片是服务端日志的投影,而不是账本本身。
|
|
440
|
+
|
|
441
|
+
保留窗口是 60 个北京自然日。更早的日子在写入切片时被丢弃,最新一天已滑出窗口的切片会在启动时删除;任何账本文件——无论能否解析——只要文件时间早于窗口起点也会一并清掉,因此该目录不会随客户端见过的会话数无限增长;放掉一个切片只会让该会话被重扫一次,不会丢数据。`/cost` 显示会话、今天、本周(周一起算)与本自然月(1 日起算)四项,都统计到当前北京自然日;周与月两行只能回溯到保留窗口能覆盖的范围。扫描只在会话「可能在本窗口内产生过花费」时才翻它的历史——会话正在运行,或其服务端更新时间落在窗口内——外加当前打开的会话(面板总要报它自己的总额)。任何计费活动都会推进该更新时间,因此客户端离线期间产生的用量在下次连接时仍会被读到。
|
|
436
442
|
|
|
437
443
|
## 客户端接口
|
|
438
444
|
|
|
439
|
-
安装后的包通过 `@itookit/dsht` 导出 `Client`,通过 `@itookit/dsht/auth` 导出 `login`/`CookieStore`,并提供 TypeScript 声明。源码调用方可通过 TypeScript loader 从 `src/transport/client.ts` 导入,或构建后从 `dist/index.js` 导入。`authenticate(token)` 兑换凭据;`connect()` 打开一条多路复用连接;`listWorkspaces()` 和 `listSessions(workspaceId?)` 返回服务端列表的 Promise。`call(endpoint, args, signal?)` 将服务端错误保留为带有 `code` 和 `details` 的 `RemoteError`。务必在 `finally` 中等待 `close()`。库调用方可使用 `src/transport/auth.ts` 的 `login(client, token, new CookieStore())` 启用持久化;`Client.authenticate()` 本身仅在内存中保留凭据。
|
|
445
|
+
安装后的包通过 `@itookit/dsht` 导出 `Client`,通过 `@itookit/dsht/auth` 导出 `login`/`CookieStore`,并提供 TypeScript 声明。源码调用方可通过 TypeScript loader 从 `src/transport/client.ts` 导入,或构建后从 `dist/index.js` 导入。`authenticate(token)` 兑换凭据;`connect()` 打开一条多路复用连接;`listWorkspaces(signal?)` 和 `listSessions(workspaceId?, signal?)` 返回服务端列表的 Promise。`call(endpoint, args, signal?)` 将服务端错误保留为带有 `code` 和 `details` 的 `RemoteError`。务必在 `finally` 中等待 `close()`;并发调用等待同一次关闭完成。关闭会永久结束该客户端,下一次使用需新建 `Client`。若只是对端断线而未调用 `close()`,可保留 Cookie 再次 `connect()`。库调用方可使用 `src/transport/auth.ts` 的 `login(client, token, new CookieStore())` 启用持久化;`Client.authenticate()` 本身仅在内存中保留凭据。
|
|
440
446
|
|
|
441
447
|
会话和工作区命令在 `args` 内使用 `{ request: { ... } }`;会话列表使用 `{ _request: {} }`。`$events/result` 直接使用具名参数。重连后的 follow 快照整体替换保留状态;持久消息与临时助手文本分别保存。读取器同时支持 `event` 记录和旧版 `chunks` 包装;后者包含 `chunkrow/text-chunks`、`chunkrow/reasoning-chunks` 或 `chunkrow/tool-call-chunks`。不提供 `assistantStream` 的服务端通过日志 chunk 传递实时文本;TUI 只重建尚未完成的尝试,并保留每条压缩记录的起始序号用于翻页。
|
|
442
448
|
|
|
@@ -484,7 +490,7 @@ node dist/cli/index.js --help
|
|
|
484
490
|
|
|
485
491
|
测试使用隔离的 HTTP/WebSocket 服务,驱动实际 Ink 选择器和输入框,在子进程中运行 CLI,并投影复制的 Harness v2 工作区编辑记录和 v0 压缩 chunk 记录。这些检查不需要模型凭据。记录和预期对话输出位于 `tests/`,不依赖父仓库。测试不覆盖真实模型供应商行为。
|
|
486
492
|
|
|
487
|
-
源码在 `src/` 下按业务域组织:`transport/` 负责服务端 wire 协议并把宿主帧归一化为语义事件,`session/` 负责对话、历史、交互与会话运行态,`cost/` 负责计费账本,`catalog/` 负责模型与 preset,`shell/` 负责本地 `!` 命令,`controller/` 是应用门面与事件路由,`slash/` 负责命令语法,`ui/` 承载全部 React 与 Ink,`storage/` 负责全部文件系统操作,`cli/` 是组装入口,根文件(`state.ts`、`json.ts`、`text.ts`、`contracts.ts`、`session-title.ts`、`references.ts
|
|
493
|
+
源码在 `src/` 下按业务域组织:`transport/` 负责服务端 wire 协议并把宿主帧归一化为语义事件,`session/` 负责对话、历史、交互与会话运行态,`cost/` 负责计费账本,`catalog/` 负责模型与 preset,`shell/` 负责本地 `!` 命令,`controller/` 是应用门面与事件路由,`slash/` 负责命令语法,`ui/` 承载全部 React 与 Ink,`storage/` 负责全部文件系统操作,`cli/` 是组装入口,根文件(`state.ts`、`json.ts`、`text.ts`、`contracts.ts`、`session-title.ts`、`references.ts`)是共享契约。内部跨域导入沿允许的依赖方向,可使用域入口或具体模块;UI 叶子组件通过 `contracts.ts` 读取领域类型。`tests/architecture/dependencies.test.ts` 使用 TypeScript 语法解析,检查九条禁止方向、纯类型契约、可解析的字面量模块路径,以及包含类型边在内的无环依赖。
|
|
488
494
|
|
|
489
495
|
`npm test` 渲染不带样式的帧,因为断言和 `tests/expected/` 中的预期输出描述的是文本。从终端启动的测试运行器会向每个测试文件导出 `FORCE_COLOR=1`,使 Ink 在提示符与文本之间插入 SGR 转义序列;`npm run test:terminal` 在任何主机上复现该环境,`prepublishOnly` 也会运行它,因此从终端发布时验证的就是终端实际渲染的结果。 主题测试在独立子进程中分别渲染真彩色和纯文本,并隔离父进程中影响终端和 CI 颜色检测的环境设置。
|
|
490
496
|
|
|
@@ -1,32 +1,52 @@
|
|
|
1
1
|
import type { HostAccess } from '../transport/host.ts';
|
|
2
2
|
import { type ObjectValue } from '../transport/wire.ts';
|
|
3
|
-
|
|
3
|
+
/** Catalog can publish its metadata and selection result, but cannot navigate or mutate sessions. */
|
|
4
|
+
export interface CatalogUpdate {
|
|
5
|
+
defaultModel?: ObjectValue;
|
|
6
|
+
modelError?: string;
|
|
7
|
+
presets?: ObjectValue[];
|
|
8
|
+
presetError?: string;
|
|
9
|
+
status?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CatalogHost extends HostAccess {
|
|
12
|
+
selection(): {
|
|
13
|
+
revision: number;
|
|
14
|
+
sessionId: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
publish(patch: CatalogUpdate): void;
|
|
17
|
+
}
|
|
4
18
|
/** Owns model-catalog and preset loads for the selected session. */
|
|
5
19
|
export declare class CatalogController {
|
|
6
|
-
private readonly store;
|
|
7
20
|
private readonly host;
|
|
8
21
|
private presetClient?;
|
|
9
22
|
private revision;
|
|
23
|
+
private generation;
|
|
24
|
+
private refreshAbort?;
|
|
10
25
|
private tasks;
|
|
11
|
-
constructor(
|
|
26
|
+
constructor(host: CatalogHost);
|
|
12
27
|
/** Drop generation-scoped catalog state at the start of a connection generation. */
|
|
13
28
|
reset(): void;
|
|
29
|
+
/** Stop catalog requests before waiting for transport shutdown. Reset opens the next generation. */
|
|
30
|
+
close(): void;
|
|
14
31
|
/** Wait for every in-flight catalog task, so shutdown leaves no pending request. */
|
|
15
32
|
settle(): Promise<void>;
|
|
33
|
+
private track;
|
|
34
|
+
private signal;
|
|
16
35
|
/** Load the optional preset roster once per connection, only when a session names a preset. */
|
|
17
36
|
loadPresetNames(): void;
|
|
18
37
|
/** Fetch current model routes and adapter-owned reasoning choices for the selected session.
|
|
19
38
|
* @returns Host catalog; provider failures remain available to the selector.
|
|
20
39
|
*/
|
|
21
|
-
modelCatalog(): Promise<ObjectValue>;
|
|
40
|
+
modelCatalog(caller?: AbortSignal): Promise<ObjectValue>;
|
|
22
41
|
/** Select the next request's model; the host also attempts to save its deployment default.
|
|
23
42
|
* @param provider - Host provider route ID.
|
|
24
43
|
* @param model - Exact model ID.
|
|
25
44
|
* @param reasoningEffort - Optional adapter-owned effort ID; omission uses its default.
|
|
26
45
|
*/
|
|
27
|
-
selectModel(provider: string, model: string, reasoningEffort?: string): Promise<void>;
|
|
46
|
+
selectModel(provider: string, model: string, reasoningEffort?: string, caller?: AbortSignal): Promise<void>;
|
|
28
47
|
/** Reload the default route and provider failures without touching session state. */
|
|
29
48
|
refresh(): void;
|
|
30
49
|
/** @returns The selected session identity, or a `Select a session first` failure. */
|
|
31
|
-
private
|
|
50
|
+
private selected;
|
|
51
|
+
private isSelected;
|
|
32
52
|
}
|
|
@@ -1,88 +1,116 @@
|
|
|
1
1
|
import { array, errorText, object, string } from "../transport/wire.js";
|
|
2
2
|
/** Owns model-catalog and preset loads for the selected session. */
|
|
3
3
|
export class CatalogController {
|
|
4
|
-
store;
|
|
5
4
|
host;
|
|
6
5
|
presetClient;
|
|
7
6
|
revision = 0;
|
|
7
|
+
generation = new AbortController();
|
|
8
|
+
refreshAbort;
|
|
8
9
|
tasks = new Set();
|
|
9
|
-
constructor(
|
|
10
|
-
this.store = store;
|
|
10
|
+
constructor(host) {
|
|
11
11
|
this.host = host;
|
|
12
12
|
}
|
|
13
13
|
/** Drop generation-scoped catalog state at the start of a connection generation. */
|
|
14
14
|
reset() {
|
|
15
|
-
this.
|
|
15
|
+
this.close();
|
|
16
|
+
this.generation = new AbortController();
|
|
17
|
+
this.presetClient = undefined;
|
|
18
|
+
this.host.publish({ modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
|
|
16
19
|
}
|
|
20
|
+
/** Stop catalog requests before waiting for transport shutdown. Reset opens the next generation. */
|
|
21
|
+
close() { this.revision++; this.generation.abort(); }
|
|
17
22
|
/** Wait for every in-flight catalog task, so shutdown leaves no pending request. */
|
|
18
|
-
async settle() { await Promise.
|
|
23
|
+
async settle() { await Promise.allSettled(this.tasks); }
|
|
24
|
+
track(task) {
|
|
25
|
+
this.tasks.add(task);
|
|
26
|
+
void task.then(() => this.tasks.delete(task), () => this.tasks.delete(task));
|
|
27
|
+
return task;
|
|
28
|
+
}
|
|
29
|
+
signal(caller = this.host.signal()) {
|
|
30
|
+
return AbortSignal.any([caller, this.host.signal(), this.generation.signal]);
|
|
31
|
+
}
|
|
19
32
|
/** Load the optional preset roster once per connection, only when a session names a preset. */
|
|
20
33
|
loadPresetNames() {
|
|
21
34
|
const client = this.host.client();
|
|
22
|
-
|
|
35
|
+
const signal = this.signal();
|
|
36
|
+
if (!client || !this.host.online() || this.presetClient === client || signal.aborted)
|
|
23
37
|
return;
|
|
24
38
|
this.presetClient = client;
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
const current = () => !signal.aborted && client === this.host.client();
|
|
40
|
+
const task = client.call('agentPresets/list', {}, signal).then(value => {
|
|
41
|
+
if (current())
|
|
42
|
+
this.host.publish({ presets: array(object(value).presets).map(object), presetError: undefined });
|
|
28
43
|
}).catch(error => {
|
|
29
|
-
if (
|
|
30
|
-
this.
|
|
44
|
+
if (current())
|
|
45
|
+
this.host.publish({ presets: [], presetError: errorText(error) });
|
|
31
46
|
});
|
|
32
|
-
this.
|
|
33
|
-
void task.finally(() => this.tasks.delete(task));
|
|
47
|
+
this.track(task);
|
|
34
48
|
}
|
|
35
49
|
/** Fetch current model routes and adapter-owned reasoning choices for the selected session.
|
|
36
50
|
* @returns Host catalog; provider failures remain available to the selector.
|
|
37
51
|
*/
|
|
38
|
-
async modelCatalog() {
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
async modelCatalog(caller) {
|
|
53
|
+
const selection = this.selected();
|
|
54
|
+
const signal = this.signal(caller);
|
|
55
|
+
signal.throwIfAborted();
|
|
56
|
+
return this.track((async () => {
|
|
57
|
+
const value = object(await this.host.require().call('session/modelCatalog', {}, signal));
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
if (!this.isSelected(selection))
|
|
60
|
+
throw new Error('Session changed while loading models');
|
|
61
|
+
return value;
|
|
62
|
+
})());
|
|
45
63
|
}
|
|
46
64
|
/** Select the next request's model; the host also attempts to save its deployment default.
|
|
47
65
|
* @param provider - Host provider route ID.
|
|
48
66
|
* @param model - Exact model ID.
|
|
49
67
|
* @param reasoningEffort - Optional adapter-owned effort ID; omission uses its default.
|
|
50
68
|
*/
|
|
51
|
-
async selectModel(provider, model, reasoningEffort) {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
async selectModel(provider, model, reasoningEffort, caller) {
|
|
70
|
+
const selection = this.selected();
|
|
71
|
+
const signal = this.signal(caller);
|
|
72
|
+
signal.throwIfAborted();
|
|
73
|
+
await this.track((async () => {
|
|
74
|
+
const selected = object(object(await this.host.require().call('session/selectModel', { request: {
|
|
75
|
+
sessionId: selection.sessionId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
|
|
76
|
+
} }, signal)).selected);
|
|
77
|
+
signal.throwIfAborted();
|
|
78
|
+
if (!this.isSelected(selection))
|
|
79
|
+
return;
|
|
80
|
+
this.host.publish({ status: `Next request: ${string(selected.provider)} / ${string(selected.model)}${selected.reasoningEffort ? ` · ${string(selected.reasoningEffort)}` : ''}` });
|
|
81
|
+
this.refresh();
|
|
82
|
+
})());
|
|
61
83
|
}
|
|
62
84
|
/** Reload the default route and provider failures without touching session state. */
|
|
63
85
|
refresh() {
|
|
64
86
|
const client = this.host.client();
|
|
65
|
-
if (!client)
|
|
87
|
+
if (!client || !this.host.online())
|
|
88
|
+
return;
|
|
89
|
+
this.refreshAbort?.abort();
|
|
90
|
+
this.refreshAbort = new AbortController();
|
|
91
|
+
const signal = this.signal(this.refreshAbort.signal);
|
|
92
|
+
if (signal.aborted)
|
|
66
93
|
return;
|
|
67
94
|
const revision = ++this.revision;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (client === this.host.client() && revision === this.revision)
|
|
73
|
-
this.store.update({ defaultModel: undefined, modelError: errorText(error) });
|
|
95
|
+
const current = () => !signal.aborted && client === this.host.client() && revision === this.revision;
|
|
96
|
+
const task = client.call('session/modelCatalog', {}, signal).then(value => {
|
|
97
|
+
if (current())
|
|
98
|
+
this.host.publish({ defaultModel: object(object(value).default), modelError: undefined });
|
|
74
99
|
}).catch(error => {
|
|
75
|
-
if (
|
|
76
|
-
this.
|
|
100
|
+
if (current())
|
|
101
|
+
this.host.publish({ defaultModel: undefined, modelError: errorText(error) });
|
|
77
102
|
});
|
|
78
|
-
this.
|
|
79
|
-
void task.finally(() => this.tasks.delete(task));
|
|
103
|
+
this.track(task);
|
|
80
104
|
}
|
|
81
105
|
/** @returns The selected session identity, or a `Select a session first` failure. */
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
106
|
+
selected() {
|
|
107
|
+
const selected = this.host.selection();
|
|
108
|
+
if (!selected.sessionId)
|
|
85
109
|
throw new Error('Select a session first');
|
|
86
|
-
return
|
|
110
|
+
return { ...selected, sessionId: selected.sessionId };
|
|
111
|
+
}
|
|
112
|
+
isSelected(selection) {
|
|
113
|
+
const current = this.host.selection();
|
|
114
|
+
return current.revision === selection.revision && current.sessionId === selection.sessionId;
|
|
87
115
|
}
|
|
88
116
|
}
|
package/dist/catalog/index.d.ts
CHANGED
package/dist/cli/dsht.js
CHANGED
|
@@ -15,6 +15,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import { historyLimits } from "../session/memory.js";
|
|
16
16
|
import { ProcessVerifier } from "./verifier.js";
|
|
17
17
|
import { Controller } from "../controller/controller.js";
|
|
18
|
+
import { loadLoopSource } from "../controller/loop-source.js";
|
|
19
|
+
import { installLoopSource } from "../controller/loop-prompts.js";
|
|
18
20
|
import { endpoint } from "../transport/endpoint.js";
|
|
19
21
|
import { errorText, object, string } from "../transport/wire.js";
|
|
20
22
|
import { formatTraceSummary, summarizeTrace } from "./trace-summary.js";
|
|
@@ -51,10 +53,12 @@ With no command, choose a workspace and session interactively.
|
|
|
51
53
|
The default host is http://127.0.0.1:3080.
|
|
52
54
|
First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
|
|
53
55
|
Cookies are saved per server origin and reused on later starts. Tokens are never saved.
|
|
54
|
-
/cost shows the session and
|
|
56
|
+
/cost shows the session, day, week and month CNY estimates.
|
|
55
57
|
/prompt lists saved shortcut prompts; /prompt TEXT saves one in <state>/prompts.json.
|
|
56
58
|
!command runs on this machine, not on the host, and prints its output in the transcript.
|
|
57
59
|
DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
|
|
60
|
+
The shipped loop.yaml is read at startup; a loop.yaml in the config directory adds to it, and a
|
|
61
|
+
record with the same name replaces the shipped one. DSHT_LOOP_FILE names another file instead.
|
|
58
62
|
The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
|
|
59
63
|
The transition trace defaults to <state>/trace.log; DSHT_TRACE sets another path or 'off'.
|
|
60
64
|
prices.json overrides the shipped rates and is seeded on first use; every scan re-decides the
|
|
@@ -151,6 +155,15 @@ async function main() {
|
|
|
151
155
|
await ensureDirectory(config);
|
|
152
156
|
const { prices, custom } = await loadPrices(config);
|
|
153
157
|
const stateRoot = process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht');
|
|
158
|
+
// The loop records are configuration: the shipped file is read now, a user file layered over it and
|
|
159
|
+
// the result installed before anything can list or run a record. An invalid user file stops the
|
|
160
|
+
// client here rather than running shipped records while the operator believes their own are in
|
|
161
|
+
// force; a shipped file that cannot be read falls back to the compiled-in records with a warning.
|
|
162
|
+
const loopSource = await loadLoopSource({
|
|
163
|
+
...(process.env.DSHT_LOOP_FILE === undefined ? {} : { overlayFile: process.env.DSHT_LOOP_FILE }),
|
|
164
|
+
configDirectory: config, stateDirectory: stateRoot,
|
|
165
|
+
});
|
|
166
|
+
installLoopSource(loopSource.source, loopSource.info);
|
|
154
167
|
const costDirectory = join(stateRoot, 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
|
|
155
168
|
const costs = new CostLedger(prices, costDirectory, custom);
|
|
156
169
|
await costs.load();
|
|
@@ -168,7 +181,10 @@ async function main() {
|
|
|
168
181
|
cwd: localDirectory, env: process.env,
|
|
169
182
|
timeoutMs: verifyTimeoutMs(process.env.DSHT_VERIFY_TIMEOUT_MS),
|
|
170
183
|
createSession: (title) => controller.actions.createVerifierSession(title),
|
|
171
|
-
cancelSession: async (sessionId) => {
|
|
184
|
+
cancelSession: async (sessionId) => {
|
|
185
|
+
if (!await controller.actions.cancelVerifierSession(sessionId))
|
|
186
|
+
throw new Error('Verifier cancellation was not accepted');
|
|
187
|
+
},
|
|
172
188
|
onLine: line => { if (values.headless)
|
|
173
189
|
log(line); },
|
|
174
190
|
// Off by default: a verifier reason reaches the progress line and the trace, and that log may be
|
|
@@ -199,6 +215,10 @@ async function main() {
|
|
|
199
215
|
timeoutSeconds: 3600,
|
|
200
216
|
};
|
|
201
217
|
const log = (line) => process.stderr.write(`${line}\n`);
|
|
218
|
+
// Headless runs have no record list, so the loop source's notes would otherwise never be read.
|
|
219
|
+
if (values.headless)
|
|
220
|
+
for (const warning of loopSource.info.warnings)
|
|
221
|
+
log(warning);
|
|
202
222
|
controller.start();
|
|
203
223
|
if (values.headless) {
|
|
204
224
|
// No renderer: run the plan, follow a started loop to its verdict, and report it as the exit code.
|
package/dist/cli/startup.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { runCommand } from "../controller/index.js";
|
|
8
8
|
import { errorText } from "../transport/wire.js";
|
|
9
9
|
import { dirname } from 'node:path';
|
|
10
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
11
|
import { ensureDirectory, renameFile, writePrivateFile } from "../storage/index.js";
|
|
11
12
|
import { latestAssistantText } from "../controller/loop.js";
|
|
12
13
|
import { parseVerdict } from "../controller/loop-contract.js";
|
|
@@ -26,12 +27,15 @@ const VERDICT_POLL_MS = 200;
|
|
|
26
27
|
* @param what - Human-readable subject for the timeout message.
|
|
27
28
|
* @param timeoutMs - Longest wait.
|
|
28
29
|
*/
|
|
29
|
-
async function until(condition, what, timeoutMs = STEP_TIMEOUT_MS) {
|
|
30
|
+
async function until(signal, condition, what, timeoutMs = STEP_TIMEOUT_MS) {
|
|
30
31
|
const deadline = Date.now() + timeoutMs;
|
|
31
|
-
|
|
32
|
+
for (;;) {
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
if (condition())
|
|
35
|
+
return;
|
|
32
36
|
if (Date.now() >= deadline)
|
|
33
37
|
throw new Error(`Timed out waiting for ${what}`);
|
|
34
|
-
await
|
|
38
|
+
await delay(POLL_MS, undefined, { signal });
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
41
|
/** Run the plan against a started controller.
|
|
@@ -41,9 +45,10 @@ async function until(condition, what, timeoutMs = STEP_TIMEOUT_MS) {
|
|
|
41
45
|
* @returns Whether a started loop passed, nothing ran, or the run failed.
|
|
42
46
|
*/
|
|
43
47
|
export async function runStartup(controller, plan, log) {
|
|
48
|
+
const signal = controller.connection.signal();
|
|
44
49
|
// `online` alone is too early: the startup picker runs after it and would overwrite a selection
|
|
45
50
|
// made here, which is exactly how a forked verifier used to lose its session.
|
|
46
|
-
await until(() => controller.state.online && controller.queries.connectionSettled, 'the host connection');
|
|
51
|
+
await until(signal, () => controller.state.online && controller.queries.connectionSettled, 'the host connection');
|
|
47
52
|
if (plan.workspace !== undefined && !await controller.actions.switchWorkspace(plan.workspace)) {
|
|
48
53
|
throw new Error(`Workspace not found: ${plan.workspace}`);
|
|
49
54
|
}
|
|
@@ -60,12 +65,13 @@ export async function runStartup(controller, plan, log) {
|
|
|
60
65
|
}
|
|
61
66
|
// A selected session is not sendable until its follow snapshot lands.
|
|
62
67
|
if (plan.commands.length) {
|
|
63
|
-
await until(() => controller.state.screen === 'chat' && controller.state.sessionId !== undefined && controller.queries.record.ready, 'the session snapshot');
|
|
68
|
+
await until(signal, () => controller.state.screen === 'chat' && controller.state.sessionId !== undefined && controller.queries.record.ready, 'the session snapshot');
|
|
64
69
|
}
|
|
65
70
|
// Start-up lines are one-shot action commands; the cancellable port is only used by searches
|
|
66
71
|
// and exports, which have no meaning before the operator is present.
|
|
67
72
|
const port = { run: (_label, operation) => operation(controller.connection.signal()) };
|
|
68
73
|
for (const line of plan.commands) {
|
|
74
|
+
signal.throwIfAborted();
|
|
69
75
|
// The scripted half runs the same two stages the composer does after `interpret`: a headless
|
|
70
76
|
// caller has no menus or screens, but it has every application fact `normalize`/`authorize` read.
|
|
71
77
|
const command = normalize({ kind: 'line', line }, {
|
|
@@ -100,14 +106,19 @@ export async function runStartup(controller, plan, log) {
|
|
|
100
106
|
// Sending a prompt needs the follow snapshot, and a verifier starts the instant a busy turn ends,
|
|
101
107
|
// so this wait is longer than a normal startup step: the host can be slow to open the new stream.
|
|
102
108
|
try {
|
|
103
|
-
await until(() => controller.state.sessionId !== undefined && controller.queries.record.ready, 'the verifier session snapshot', PROMPT_SNAPSHOT_TIMEOUT_MS);
|
|
109
|
+
await until(signal, () => controller.state.sessionId !== undefined && controller.queries.record.ready, 'the verifier session snapshot', PROMPT_SNAPSHOT_TIMEOUT_MS);
|
|
104
110
|
}
|
|
105
111
|
catch (error) {
|
|
112
|
+
signal.throwIfAborted();
|
|
106
113
|
// Which of the three conditions failed is the whole diagnosis, so it travels with the error.
|
|
107
114
|
throw new Error(`${errorText(error)} (screen=${controller.state.screen}, session=${controller.state.sessionId ?? 'none'},`
|
|
108
115
|
+ ` snapshot=${String(controller.queries.record.ready)}, online=${String(controller.state.online)})`);
|
|
109
116
|
}
|
|
110
|
-
await controller.actions.prompt(plan.prompt)
|
|
117
|
+
if (!await controller.actions.prompt(plan.prompt)) {
|
|
118
|
+
signal.throwIfAborted();
|
|
119
|
+
const reason = controller.state.lastFailure;
|
|
120
|
+
throw new Error(`Prompt was not accepted${reason ? ` (${reason})` : ''}`);
|
|
121
|
+
}
|
|
111
122
|
}
|
|
112
123
|
if (controller.queries.loop !== undefined)
|
|
113
124
|
return await waitForLoop(controller, plan.timeoutSeconds * 1000, log);
|
|
@@ -129,8 +140,10 @@ export async function runStartup(controller, plan, log) {
|
|
|
129
140
|
* @returns The verdict once the line may run, or the refusal it will never outlive.
|
|
130
141
|
*/
|
|
131
142
|
async function authorizeWhenReady(controller, command) {
|
|
143
|
+
const signal = controller.connection.signal();
|
|
132
144
|
const deadline = Date.now() + STEP_TIMEOUT_MS;
|
|
133
145
|
for (;;) {
|
|
146
|
+
signal.throwIfAborted();
|
|
134
147
|
const verdict = authorize(command, {
|
|
135
148
|
sessionSelected: controller.state.sessionId !== undefined,
|
|
136
149
|
pending: controller.state.pending.length > 0,
|
|
@@ -141,7 +154,7 @@ async function authorizeWhenReady(controller, command) {
|
|
|
141
154
|
return verdict;
|
|
142
155
|
if (Date.now() >= deadline)
|
|
143
156
|
throw new Error(`Timed out waiting for the client to be free: ${command.kind}`);
|
|
144
|
-
await
|
|
157
|
+
await delay(POLL_MS, undefined, { signal });
|
|
145
158
|
}
|
|
146
159
|
}
|
|
147
160
|
/** Persist the verdict this session's reply just produced.
|
|
@@ -157,6 +170,8 @@ async function authorizeWhenReady(controller, command) {
|
|
|
157
170
|
* @param log - Progress sink.
|
|
158
171
|
*/
|
|
159
172
|
async function writeVerdict(controller, target, repliesBefore, log) {
|
|
173
|
+
const signal = controller.connection.signal();
|
|
174
|
+
signal.throwIfAborted();
|
|
160
175
|
const [, kind = '', step = '', attempt = ''] = target.identity.split('/');
|
|
161
176
|
const expect = { verificationId: target.identity, kind, step: Number(step), attempt: Number(attempt) };
|
|
162
177
|
// The host reports the turn idle just before that reply is committed, so an immediate parse can
|
|
@@ -169,7 +184,7 @@ async function writeVerdict(controller, target, repliesBefore, log) {
|
|
|
169
184
|
let parsed = replyArrived() ? parseVerdict(latestAssistantText(controller.queries.record.messages), expect) : undefined;
|
|
170
185
|
const deadline = Date.now() + VERDICT_GRACE_MS;
|
|
171
186
|
while (parsed === undefined && Date.now() < deadline) {
|
|
172
|
-
await
|
|
187
|
+
await delay(VERDICT_POLL_MS, undefined, { signal });
|
|
173
188
|
if (replyArrived())
|
|
174
189
|
parsed = parseVerdict(latestAssistantText(controller.queries.record.messages), expect);
|
|
175
190
|
}
|
|
@@ -209,8 +224,10 @@ async function writeVerdict(controller, target, repliesBefore, log) {
|
|
|
209
224
|
* @returns `idle` when the turn ended, `failed` on timeout.
|
|
210
225
|
*/
|
|
211
226
|
async function waitForTurn(controller, finishedBefore, timeoutMs, log) {
|
|
227
|
+
const signal = controller.connection.signal();
|
|
212
228
|
const deadline = Date.now() + timeoutMs;
|
|
213
229
|
for (;;) {
|
|
230
|
+
signal.throwIfAborted();
|
|
214
231
|
if (controller.queries.turnsCompleted > finishedBefore) {
|
|
215
232
|
log('Turn finished');
|
|
216
233
|
return 'idle';
|
|
@@ -226,7 +243,7 @@ async function waitForTurn(controller, finishedBefore, timeoutMs, log) {
|
|
|
226
243
|
log('Turn timed out');
|
|
227
244
|
return 'failed';
|
|
228
245
|
}
|
|
229
|
-
await
|
|
246
|
+
await delay(POLL_MS, undefined, { signal });
|
|
230
247
|
}
|
|
231
248
|
}
|
|
232
249
|
/** The host interaction this client cannot answer itself, when one is pending.
|
|
@@ -257,9 +274,11 @@ async function ensureWorkspace(controller, log) {
|
|
|
257
274
|
* @returns `passed` when the loop met its threshold, `failed` otherwise.
|
|
258
275
|
*/
|
|
259
276
|
async function waitForLoop(controller, timeoutMs, log) {
|
|
277
|
+
const signal = controller.connection.signal();
|
|
260
278
|
const deadline = Date.now() + timeoutMs;
|
|
261
279
|
let previous = '';
|
|
262
280
|
for (;;) {
|
|
281
|
+
signal.throwIfAborted();
|
|
263
282
|
const progress = controller.queries.loop;
|
|
264
283
|
if (progress === undefined || progress.phase !== 'running') {
|
|
265
284
|
// A cancelled loop usually means the connection ended; say so, or the exit code is a mystery.
|
|
@@ -290,6 +309,6 @@ async function waitForLoop(controller, timeoutMs, log) {
|
|
|
290
309
|
log('Loop timed out and was stopped');
|
|
291
310
|
return 'failed';
|
|
292
311
|
}
|
|
293
|
-
await
|
|
312
|
+
await delay(POLL_MS, undefined, { signal });
|
|
294
313
|
}
|
|
295
314
|
}
|