@itookit/dsht 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +225 -17
- package/README.zh.md +223 -15
- package/dist/app.d.ts +2 -1
- package/dist/app.js +17 -1
- package/dist/cli.js +1 -1
- package/dist/controller.d.ts +4 -0
- package/dist/controller.js +8 -0
- package/dist/cost-view.js +4 -1
- package/dist/cost.d.ts +26 -4
- package/dist/cost.js +74 -21
- package/dist/status.js +5 -4
- package/package.json +14 -7
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: 8f9296caa5de076cfc7541ff372a5ce0712e9181
|
|
3
|
+
README.zh.md: c78f46e20d2cca7fbda700e810234872016e69a7
|
package/README.md
CHANGED
|
@@ -4,22 +4,132 @@ English | [中文](README.zh.md)
|
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
+
> **dsht — Control DeepSeek Harness from any terminal, anywhere.**
|
|
8
|
+
|
|
7
9
|
## Summary
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
`dsht` is a lightweight DeepSeek Harness TUI client designed for remote use.
|
|
12
|
+
|
|
13
|
+
Its goal is to fit DeepSeek Harness naturally into the terminal and SSH workflows a developer already has: Harness keeps running on a remote workstation or server while you reconnect from a laptop, tablet, or phone to check status, send a message, steer a task, answer an approval, answer a question, cancel a turn, or switch sessions.
|
|
14
|
+
|
|
15
|
+
A typical setup:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
phone / tablet / laptop
|
|
19
|
+
│
|
|
20
|
+
│ SSH
|
|
21
|
+
▼
|
|
22
|
+
jump host / bastion
|
|
23
|
+
│
|
|
24
|
+
│ SSH
|
|
25
|
+
▼
|
|
26
|
+
development host
|
|
27
|
+
│
|
|
28
|
+
├── dsht
|
|
29
|
+
│ │
|
|
30
|
+
│ ▼
|
|
31
|
+
│ dsh web
|
|
32
|
+
│ │
|
|
33
|
+
│ ▼
|
|
34
|
+
└── DeepSeek Harness
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`dsht` **is not an SSH client**. It runs in an ordinary terminal, so it works directly inside SSH, nested SSH, ProxyJump/bastion, tmux, and similar remote terminal environments. Whenever your terminal can reach the host running `dsht`, you keep controlling the same DeepSeek Harness sessions.
|
|
38
|
+
|
|
39
|
+
Besides remote control, `dsht` tracks usage for cost control: it records token usage per request, separates uncached input, cache read, cache write, and output, and combines the model, the settlement time, peak/off-peak rates, and versioned price tables into a CNY estimate for the current session, today, and the last three calendar days.
|
|
10
40
|
|
|
11
41
|
Main features:
|
|
12
42
|
|
|
43
|
+
- **Remote-first**: built for SSH, nested SSH, bastion hosts, ProxyJump, and tmux.
|
|
44
|
+
- **Phone-friendly**: one working mobile SSH client is enough to keep controlling a remote Harness away from your desk.
|
|
13
45
|
- Workspace and session pickers, direct switching with `/ws` and `/s`, and explicit session creation.
|
|
14
|
-
- Streaming replies, reasoning, compact tool names
|
|
46
|
+
- Streaming replies, reasoning, compact tool names, success/failure status, and paged conversation history.
|
|
15
47
|
- Queued prompts, steering, turn cancellation, approvals, and free-text question answers.
|
|
16
|
-
- Cookie persistence per host, automatic reconnect, and snapshot replacement.
|
|
48
|
+
- Cookie persistence per host, automatic reconnect, and snapshot replacement, so control survives a dropped connection.
|
|
17
49
|
- JSON or tab-separated workspace/session lists for scripts, plus a reusable HTTP client.
|
|
18
|
-
-
|
|
50
|
+
- **Cost-aware**: session, today, and three-day CNY estimates with versioned peak/off-peak prices and `/cost` summaries.
|
|
51
|
+
|
|
52
|
+
## Why use dsht?
|
|
53
|
+
|
|
54
|
+
### Built for remote control
|
|
55
|
+
|
|
56
|
+
DeepSeek Harness usually runs on a development workstation or server with more performance and a more complete environment, while the person is not always sitting at that machine.
|
|
57
|
+
|
|
58
|
+
`dsht` keeps the control interface in a plain terminal, so the remote server needs no desktop environment and a phone needs no full development setup. Leave Harness working on the development host and, when you need to look or intervene, enter that host over the SSH path you already have and run `dsht`.
|
|
59
|
+
|
|
60
|
+
The simplest form:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
laptop ───────── SSH ────────> development host ──> dsht
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Through a jump host:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
phone ── SSH ──> jump host ── SSH ──> development host ──> dsht
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This makes "control Harness from the phone in your hand" a practical workflow: no remote desktop, and no need to expose the Harness web service on the public internet.
|
|
73
|
+
|
|
74
|
+
> SSH tunnels, ProxyJump, bastion hosts, and access control stay the responsibility of your existing SSH environment; `dsht` focuses on terminal interaction with DeepSeek Harness.
|
|
75
|
+
|
|
76
|
+
### Keep controlling tasks from a phone
|
|
77
|
+
|
|
78
|
+
Mobile sessions are poor for long editing work but well suited to control and decisions.
|
|
79
|
+
|
|
80
|
+
After SSHing from a phone into the remote terminal running `dsht`, you can:
|
|
81
|
+
|
|
82
|
+
- watch running tasks and live output;
|
|
83
|
+
- read assistant replies, reasoning, and tool status;
|
|
84
|
+
- send a new prompt or a `/steer` instruction;
|
|
85
|
+
- approve with `/allow` or reject with `/deny`;
|
|
86
|
+
- answer questions Harness asks;
|
|
87
|
+
- stop the current turn with `/cancel`;
|
|
88
|
+
- switch workspaces and sessions;
|
|
89
|
+
- search history;
|
|
90
|
+
- check the current task and recent cost with `/cost`.
|
|
91
|
+
|
|
92
|
+
Leaving your desk therefore does not mean losing control of a long-running Harness task.
|
|
93
|
+
|
|
94
|
+
### More than a log viewer
|
|
95
|
+
|
|
96
|
+
`dsht` is an interactive control surface for a running DeepSeek Harness, not a read-only log tool.
|
|
97
|
+
|
|
98
|
+
It can send input, handle approvals and questions, steer a running task, cancel a turn, switch sessions, and reconnect after the network returns. Execution state stays on the host; the client only presents and controls it through the terminal.
|
|
99
|
+
|
|
100
|
+
### Cost awareness
|
|
101
|
+
|
|
102
|
+
Long AI coding tasks keep consuming tokens, and a token total alone says little about what they cost.
|
|
103
|
+
|
|
104
|
+
`dsht` stores usage per request and combines it with the request settlement time, the model identity, and the matching price version. Its totals separate:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
uncached input
|
|
108
|
+
cache read
|
|
109
|
+
cache write
|
|
110
|
+
output
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`/cost` shows:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
current session
|
|
117
|
+
today
|
|
118
|
+
today + the previous two calendar days
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The status bar also keeps showing session cost `S:` and today's cost `D:`, so cost changes surface while a task runs instead of only after the invoice arrives.
|
|
122
|
+
|
|
123
|
+
That gives `dsht` two roles at once:
|
|
124
|
+
|
|
125
|
+
1. **a remote terminal control surface for DeepSeek Harness**
|
|
126
|
+
2. **a cost monitor for the work as it happens**
|
|
19
127
|
|
|
20
128
|
## Contents
|
|
21
129
|
|
|
130
|
+
- [Why use dsht?](#why-use-dsht)
|
|
22
131
|
- [Start](#start)
|
|
132
|
+
- [Remote SSH workflows](#remote-ssh-workflows)
|
|
23
133
|
- [List workspaces and sessions](#list-workspaces-and-sessions)
|
|
24
134
|
- [Conversation controls](#conversation-controls)
|
|
25
135
|
- [Live status](#live-status)
|
|
@@ -58,10 +168,104 @@ Both paths read the same `DSH_URL` and `DSH_TOKEN` variables.
|
|
|
58
168
|
|
|
59
169
|
Select a workspace with ↑/↓ and Enter, then select a session or **New session**. **All sessions** also exposes sessions outside registered workspaces. **Add workspace** accepts an existing absolute directory on the host, which may differ from your local filesystem. Creating a session requires a selected workspace.
|
|
60
170
|
|
|
61
|
-
On first login, authentication exchanges the token at `GET /` and saves the cookie per HTTP origin. Later starts, including list commands, reuse that cookie without a token. The store uses `$XDG_STATE_HOME/dsht/auth`, or `~/.local/state/dsht/auth` when unset; `--auth-dir` or `DSHT_AUTH_DIR` overrides it. POSIX directories use 0700 and cookie files use 0600; Windows uses the account directory
|
|
171
|
+
On first login, authentication exchanges the token at `GET /` and saves the cookie per HTTP origin. Later starts, including list commands, reuse that cookie without a token. The store uses `$XDG_STATE_HOME/dsht/auth`, or `~/.local/state/dsht/auth` when unset; `--auth-dir` or `DSHT_AUTH_DIR` overrides it. POSIX directories use 0700 and cookie files use 0600; Windows uses the account directory's inherited access controls. Launch tokens are never saved.
|
|
62
172
|
|
|
63
173
|
The host determines cookie expiration. An expired or rejected cookie requires a token again; a supplied token refreshes authentication automatically after HTTP 401. Network failures and HTTP 403 do not trigger token exchange. Corrupt or insecure cookie files fail explicitly. The base URL must be an origin without a path or extra query parameters, and the host must allow its hostname.
|
|
64
174
|
|
|
175
|
+
## Remote SSH workflows
|
|
176
|
+
|
|
177
|
+
`dsht` is at its best combined with existing SSH infrastructure. It requires neither a DeepSeek Harness exposed to the public internet nor a client device that can reach `dsh web` directly.
|
|
178
|
+
|
|
179
|
+
### SSH straight to the development host
|
|
180
|
+
|
|
181
|
+
When the development host accepts SSH directly:
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
Laptop / Phone
|
|
185
|
+
│
|
|
186
|
+
│ SSH
|
|
187
|
+
▼
|
|
188
|
+
Development Host
|
|
189
|
+
│
|
|
190
|
+
├── dsht
|
|
191
|
+
└── dsh web
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Log in to the remote host and run:
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
dsht
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Or without a global install:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
npx @itookit/dsht
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Through a jump host
|
|
207
|
+
|
|
208
|
+
When the development host is reachable only through a jump host:
|
|
209
|
+
|
|
210
|
+
```text
|
|
211
|
+
Phone
|
|
212
|
+
│
|
|
213
|
+
│ SSH
|
|
214
|
+
▼
|
|
215
|
+
Jump Host
|
|
216
|
+
│
|
|
217
|
+
│ SSH / ProxyJump
|
|
218
|
+
▼
|
|
219
|
+
Development Host
|
|
220
|
+
│
|
|
221
|
+
├── dsht
|
|
222
|
+
└── dsh web
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
With an existing OpenSSH `ProxyJump` configuration, SSH to the target development host as usual and run:
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
dsht
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`dsht` does not need to understand that SSH path; from its point of view it simply runs in a terminal that can reach `dsh web`.
|
|
232
|
+
|
|
233
|
+
### With tmux
|
|
234
|
+
|
|
235
|
+
On a remote host, `dsht` can live in a tmux session so that a dropped network still leaves the same terminal environment behind:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
tmux new -s dsht
|
|
239
|
+
dsht
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Then, after logging in again:
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
tmux attach -t dsht
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Even without tmux the Harness session state stays on the server, and a restarted `dsht` can select the same workspace and session again. The value of tmux is keeping the local terminal layout and the running TUI process.
|
|
249
|
+
|
|
250
|
+
### Phone access
|
|
251
|
+
|
|
252
|
+
Any mobile terminal that can use SSH is a usable entry point:
|
|
253
|
+
|
|
254
|
+
```text
|
|
255
|
+
Mobile SSH Client
|
|
256
|
+
│
|
|
257
|
+
▼
|
|
258
|
+
Jump Host
|
|
259
|
+
│
|
|
260
|
+
▼
|
|
261
|
+
Development Host
|
|
262
|
+
│
|
|
263
|
+
▼
|
|
264
|
+
dsht
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The experience depends on how well the mobile terminal supports ANSI, Unicode, arrow keys, and SGR mouse reports. Even with limited touch mouse support, the core operations remain available through the keyboard and slash commands.
|
|
268
|
+
|
|
65
269
|
## List workspaces and sessions
|
|
66
270
|
|
|
67
271
|
```sh
|
|
@@ -83,7 +287,7 @@ JSON output is `{ "items": [...] }`; omit `--json` for tab-separated output. Wor
|
|
|
83
287
|
|
|
84
288
|
## Conversation controls
|
|
85
289
|
|
|
86
|
-
Enter submits a prompt. Ctrl+C requests cancellation while the selected session is running and exits only when it is idle; repeated keys share an in-flight cancellation. Cancellation waits for a pending prompt admission, and failures keep the client open. Esc sends an explicit cancellation from the conversation even when the cached running flag is idle; open menus also cancel a known running agent while closing. Active local history/search/cost loads are cancelled first. Page Up/Down scroll the retained transcript; `/older` loads an earlier page. `/quit`
|
|
290
|
+
Enter submits a prompt. Ctrl+C requests cancellation while the selected session is running and exits only when it is idle; repeated keys share an in-flight cancellation. Cancellation waits for a pending prompt admission, and failures keep the client open. Esc sends an explicit cancellation from the conversation even when the cached running flag is idle; open menus also cancel a known running agent while closing. Active local history/search/cost loads are cancelled first. Page Up/Down scroll the retained transcript; `/older` loads an earlier page. Every exit path, including `/quit` and SIGTERM, stops the selected turn before the connection closes, so quitting does not leave the agent running; an idle session is left untouched. Cancellation leaves pending queue items intact.
|
|
87
291
|
|
|
88
292
|
The mouse wheel and Page Up/Down scroll conversation history; scrolling to the top automatically requests an older page. New output preserves a scrolled reading position. `/jump last` resumes following the newest output. Mouse reporting is enabled while the TUI is mounted and disabled on exit; the terminal must support SGR mouse reports. Esc or Ctrl+C cancels a history load or search before interrupting the remote agent.
|
|
89
293
|
|
|
@@ -123,9 +327,9 @@ The single-line composer supports Readline-style editing. Words are whitespace-d
|
|
|
123
327
|
| `/cost` | Toggle session/today/three-day estimates and refresh usage |
|
|
124
328
|
| `/help`, `/quit` | Show command hints or exit |
|
|
125
329
|
|
|
126
|
-
Slash commands work in both pickers and the conversation composer. Typing `/` displays matching commands. The long forms `/workspace`, `/workspaces`, `/session`, and `/sessions` remain aliases. Names may contain spaces; quotes around the complete target are optional. The unquoted target `all` is reserved for `/s all`; use `/s "all"` or an ID to open a session titled `all`. Ambiguous targets require a full ID. Switching a workspace opens its sessions and detaches the old transcript; switching sessions updates the workspace label. Neither operation cancels a remote agent.
|
|
330
|
+
Slash commands work in both pickers and the conversation composer. Typing `/` displays matching commands. The `/help`, `/cost`, and `/status` panels are temporary: the next command, or ten seconds, closes whichever one is open. The long forms `/workspace`, `/workspaces`, `/session`, and `/sessions` remain aliases. Names may contain spaces; quotes around the complete target are optional. The unquoted target `all` is reserved for `/s all`; use `/s "all"` or an ID to open a session titled `all`. Ambiguous targets require a full ID. Switching a workspace opens its sessions and detaches the old transcript; switching sessions updates the workspace label. Neither operation cancels a remote agent.
|
|
127
331
|
|
|
128
|
-
Type `@` at the end of the draft to search files and directories in the selected session
|
|
332
|
+
Type `@` at the end of the draft to search files and directories in the selected session's working directory **on the host**. Use ↑/↓ to select and Tab or Enter to insert; selecting a directory continues completion inside it. Paths with spaces use `@"path with spaces"`. Escape closes the menu and requests cancellation when the agent is running; after closing it, Enter sends the literal draft, including an unmatched path. Lookup failures remain visible and do not submit the draft. Completion operates on the trailing reference, not the cursor position inside existing text.
|
|
129
333
|
|
|
130
334
|
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.
|
|
131
335
|
|
|
@@ -135,33 +339,37 @@ The conversation header shows the latest session title, falling back to the ID;
|
|
|
135
339
|
|
|
136
340
|
## Live status
|
|
137
341
|
|
|
138
|
-
The footer defaults to one borderless line showing activity, model, workspace, context occupancy, and total tokens; wider terminals also show input/output, cache, queue, and job counts. Long names shorten by terminal display width, and narrow terminals omit lower-priority fields first. `/status` toggles full multiline details with the complete path, provider/model, reasoning effort, and usage buckets. `!` flags a metrics or model catalog error
|
|
342
|
+
The footer defaults to one borderless line showing activity, model, workspace, context occupancy, and total tokens; wider terminals also show input/output, cache, queue, and job counts. Long names shorten by terminal display width, and narrow terminals omit lower-priority fields first. `/status` toggles full multiline details with the complete path, provider/model, reasoning effort, and usage buckets. `!` flags a metrics or model catalog error, or incomplete cost coverage; the reason appears in the details. During a run it distinguishes the last-used model from a different next-request selection; a fresh session uses the host catalog default. Model catalog changes refresh on host settings, credential, and adapter notifications.
|
|
139
343
|
|
|
140
344
|
Working time uses the retained `turn/start` timestamp. If that timestamp is unavailable, `(observed)` means time since this client observed the run; reconnecting can reset this fallback. The clock stops when the host reports idle. The status includes model generation, tool execution, and approval waits, not just streamed text. Offline status is explicitly marked as last known.
|
|
141
345
|
|
|
142
|
-
Context occupancy is marked `~`: Harness combines provider usage with estimated surface changes and the latest route capacity. Token totals come from the complete session
|
|
346
|
+
Context occupancy is marked `~`: Harness combines provider usage with estimated surface changes and the latest route capacity. Token totals come from the complete session's `tokenUsage` projection, with separate uncached input, output, cache-read, and cache-write buckets; reasoning is already included in output. Totals update when the host publishes usage, not on every streamed character. Missing measurements display `unknown` or `?`. Control-stream baselines replace state on reconnect, and per-key watermarks prevent an older follow snapshot from overwriting newer metrics.
|
|
143
347
|
|
|
144
348
|
Tool-only rows omit the separate role heading: `⚙` identifies a call, `✓` a successful result, and `✗` a failed result. Once complete arguments are available, each row shows the tool name and operation description, falling back to its command, path, or query. Results reuse the matching call summary when retained history contains it. Each operation occupies at most one terminal row, with whitespace flattened and long text ellipsized by display width. Other arguments, nested results, and tool output remain hidden. Assistant prose and explicit approval requests remain visible so the user can understand the response and decide whether to approve an action.
|
|
145
349
|
|
|
146
350
|
## Cost estimates
|
|
147
351
|
|
|
148
|
-
|
|
352
|
+
`dsht` does not just show token counts: it turns Harness-visible per-request usage into a traceable CNY estimate. It separates uncached input, cache read, cache write, and output, and combines the model, the request settlement time, the price version, and the peak/off-peak window, so the cost of the current session and of the recent past stays visible while work is running.
|
|
353
|
+
|
|
354
|
+
> These figures are a high-precision estimate from Harness-visible usage and local price configuration, for cost monitoring and control. They are not a provider account bill, and the provider invoice remains authoritative.
|
|
355
|
+
|
|
356
|
+
`/cost` shows the selected session, today, and today plus the preceding two calendar days. Dates use Asia/Shanghai; the three-day view is not a rolling 72-hour window. The status bar reserves `S:` for session cost and `D:` for today. `~` marks an estimate; `*` marks a subtotal that is not exact, because a request carries no timestamp, no price covers it, or a calendar range cannot place it. Incomplete coverage is reported separately: charges cached by an earlier run count as complete, while an empty or failed scan raises the bar's `!` prefix and a reason in `/status`. Each host origin has a separate ledger. Totals cover HTTP-visible sessions and previously cached sessions; they are not account-wide provider bills.
|
|
149
357
|
|
|
150
|
-
The client reads complete histories in the background on connection, every 60 seconds, at turn completion, and when opening `/cost`. Idle sessions with unchanged host update timestamps are skipped. No model requests are made by billing. Esc or Ctrl+C cancels an explicit refresh. The ledger counts disjoint uncached input, cache read/write and output buckets; reasoning is already part of output. Retries count separately, replacement samples update their attempt, and fork-inherited history is excluded.
|
|
358
|
+
The client reads complete histories in the background on connection, every 60 seconds, at turn completion, and when opening `/cost`. Idle sessions with unchanged host update timestamps are skipped. No model requests are made by billing. Esc or Ctrl+C cancels an explicit refresh. The ledger counts disjoint uncached input, cache read/write and output buckets; reasoning is already part of output. Retries count separately, replacement samples update their attempt, and fork-inherited history is excluded. A request without a settlement timestamp still contributes a floor amount, priced at the cheapest rate of its model family and reported as estimated. Inconsistent usage, and prices that no model or provider entry covers, remain unpriced; the model-name family decides Pro against Flash, while an unlisted provider is never billed from the official table. Failed scans retain labelled partial cached totals.
|
|
151
359
|
|
|
152
|
-
The bundled CNY rates were checked against the [official pricing page](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/) on 2026-09-10. Beijing weekday peak windows are 09:00–12:00 and 14:00–18:00; other times use half-price rates. Flash peak input/cache-hit/output rates are ¥
|
|
360
|
+
The bundled CNY rates were checked against the [official pricing page](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/) on 2026-09-10. Beijing weekday peak windows are 09:00–12:00 and 14:00–18:00; other times use half-price rates. Flash peak cache-miss-input/cache-hit-input/output rates are ¥2/¥0.04/¥8 per million tokens and Pro rates are ¥9/¥0.30/¥27; the current model name is `deepseek-flash`, and older Flash names keep those same rates. The provider has announced that from 2026-09-14T12:00+08:00 it serves `deepseek-v4-pro` from Flash and bills it at Flash rates, which the bundled entry records so that date does not overstate Pro usage. Separate cache writes use the uncached-input rate. An exact configured model price takes priority; otherwise `deepseek-official` names containing `pro` (case-insensitive) use Pro and all other names use Flash, including temporary model aliases. Other providers require explicit entries.
|
|
153
361
|
|
|
154
362
|
The default price validity starts at Beijing midnight on the verification date; this is a local estimate policy, not a claim about the official effective date. Earlier usage needs historical price entries. The recorded assistant settlement timestamp selects the rate; requests spanning a tariff boundary may differ from the invoice because the official page does not specify their attribution. Images use provider-reported tokens. Cached priced requests retain their price version when configuration changes; previously unpriced requests can be priced on a later scan.
|
|
155
363
|
|
|
156
364
|
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.
|
|
157
365
|
|
|
158
|
-
Usage files live under `~/.local/state/dsht/cost/<origin-hash>/` (respecting `XDG_STATE_HOME`, or `DSHT_STATE_DIR` for the application state root). They contain only session IDs, timestamps, model identities, token counts, selected price versions and estimates. They exclude prompts, tool bodies, credentials and cookies. Writes use private temporary files and atomic replacement; opening-cut filenames prevent older concurrent scans from displacing a newer cached cut. The cache survives restart and does not need access to the host configuration directory.
|
|
366
|
+
Usage files live under `~/.local/state/dsht/cost/<origin-hash>/` (respecting `XDG_STATE_HOME`, or `DSHT_STATE_DIR` for the application state root). They contain only session IDs, timestamps, model identities, token counts, selected price versions and estimates. They exclude prompts, tool bodies, credentials and cookies. 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; opening-cut filenames prevent older concurrent scans from displacing a newer cached cut. The cache survives restart and does not need access to the host configuration directory. It stores each request rather than a running total, and the skip bookkeeping lives in memory only, so the first scan after a restart re-reads every session and reprices whatever accrued while the client was closed using each request's own settlement time.
|
|
159
367
|
|
|
160
368
|
## Client API
|
|
161
369
|
|
|
162
370
|
Installed packages export `Client` from `@itookit/dsht` and `login`/`CookieStore` from `@itookit/dsht/auth`, with TypeScript declarations. Source consumers can import from `src/client.ts` with a TypeScript loader, or from `dist/client.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`. Library consumers opt into persistence with `login(client, token, new CookieStore())` from `src/auth.ts`; `Client.authenticate()` itself only retains credentials in memory.
|
|
163
371
|
|
|
164
|
-
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
|
|
372
|
+
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.
|
|
165
373
|
|
|
166
374
|
## Publishing to npm
|
|
167
375
|
|
|
@@ -169,7 +377,7 @@ This repository publishes one public package, `@itookit/dsht`, from the `mushuan
|
|
|
169
377
|
|
|
170
378
|
| Field | Value |
|
|
171
379
|
| --- | --- |
|
|
172
|
-
| Name and version | `@itookit/dsht` `0.
|
|
380
|
+
| Name and version | `@itookit/dsht` `0.2.0` |
|
|
173
381
|
| Executable | `dsht`, or `npx @itookit/dsht` without installing |
|
|
174
382
|
| Library entries | `@itookit/dsht` and `@itookit/dsht/auth` |
|
|
175
383
|
| Author | lizlok@gmail.com |
|
|
@@ -191,7 +399,7 @@ npm publish --access public
|
|
|
191
399
|
|
|
192
400
|
`publishConfig.access` is `public`, which a scoped package needs to be installable without a paid plan; the flag is therefore part of the package rather than of the publish command. An account with two-factor authentication publishes with a live code, `npm publish --otp=<code>`; the code is checked at the final request, after the typecheck, suite, and build have already run.
|
|
193
401
|
|
|
194
|
-
Later releases run in `.github/workflows/publish.yml`, which publishes from a version tag with [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) and provenance, so no publish token is stored. Configure it once at `npmjs.com` → `@itookit/dsht` → Settings → Trusted Publisher → GitHub Actions with organization or user `mushuanli`, repository `dsht`, workflow filename `publish.yml`, and allowed action `npm publish`. Trusted publishing cannot create a package, so version
|
|
402
|
+
Later releases run in `.github/workflows/publish.yml`, which publishes from a version tag with [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) and provenance, so no publish token is stored. Configure it once at `npmjs.com` → `@itookit/dsht` → Settings → Trusted Publisher → GitHub Actions with organization or user `mushuanli`, repository `dsht`, workflow filename `publish.yml`, and allowed action `npm publish`. Trusted publishing cannot create a package, so the first version is published by hand; after that, `npm version 0.2.1 && git push --follow-tags` releases.
|
|
195
403
|
|
|
196
404
|
The workflow packs without publishing when started manually, and refuses a tag that disagrees with `package.json`. See the official [scoped publishing guide](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/) and [npx documentation](https://docs.npmjs.com/cli/npm-exec/). Registry publication is not part of the local validation performed for this repository.
|
|
197
405
|
|
package/README.zh.md
CHANGED
|
@@ -4,22 +4,132 @@
|
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
+
> **dsht — 只要有终端,就能随时控制 DeepSeek Harness。**
|
|
8
|
+
|
|
7
9
|
## 摘要
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
`dsht` 是一个面向远程使用场景设计的轻量级 DeepSeek Harness TUI 客户端。
|
|
12
|
+
|
|
13
|
+
它的核心目标是让 DeepSeek Harness 自然融入开发者已有的终端和 SSH 工作流:Harness 可以持续运行在远程工作站或服务器上,而你可以从笔记本、平板,甚至手机重新连入终端,继续查看状态、发送消息、转向任务、审批操作、回答问题、取消轮次或切换会话。
|
|
14
|
+
|
|
15
|
+
典型场景:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
手机 / 平板 / 笔记本
|
|
19
|
+
│
|
|
20
|
+
│ SSH
|
|
21
|
+
▼
|
|
22
|
+
跳板机 / Bastion
|
|
23
|
+
│
|
|
24
|
+
│ SSH
|
|
25
|
+
▼
|
|
26
|
+
开发主机
|
|
27
|
+
│
|
|
28
|
+
├── dsht
|
|
29
|
+
│ │
|
|
30
|
+
│ ▼
|
|
31
|
+
│ dsh web
|
|
32
|
+
│ │
|
|
33
|
+
│ ▼
|
|
34
|
+
└── DeepSeek Harness
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`dsht` **本身不是 SSH 客户端**。它运行在普通终端中,因此可以直接工作在 SSH、嵌套 SSH、ProxyJump/跳板机、tmux 等远程终端环境里。只要你的终端能够到达运行 `dsht` 的主机,就可以继续控制同一套 DeepSeek Harness 会话。
|
|
10
38
|
|
|
11
|
-
|
|
39
|
+
除了远程控制,`dsht` 还内置了面向成本控制的用量统计:它按请求记录 token 用量,区分未缓存输入、缓存读取、缓存写入和输出,并结合模型、时间、高峰/空闲价格及版本化价格表,计算当前会话、今日和最近三日的人民币费用估算。
|
|
12
40
|
|
|
41
|
+
主要特点:
|
|
42
|
+
|
|
43
|
+
- **远程优先**:适合 SSH、嵌套 SSH、跳板机、ProxyJump、tmux 等远程开发环境。
|
|
44
|
+
- **手机友好**:只需要一个可用的移动端 SSH 客户端,就能在离开电脑后继续控制远程 Harness。
|
|
13
45
|
- 工作区和会话选择器,通过 `/ws`、`/s` 直接切换,并显式创建会话。
|
|
14
|
-
-
|
|
46
|
+
- 流式回复、思考内容、精简工具名称、成功/失败状态以及分页对话历史。
|
|
15
47
|
- 排队消息、转向输入、轮次取消、审批和自由文本问题回答。
|
|
16
|
-
- 按服务端保存 cookie
|
|
48
|
+
- 按服务端保存 cookie、自动重连和快照替换,方便断线后恢复控制。
|
|
17
49
|
- 面向脚本的 JSON/制表符工作区与会话列表,以及可复用的 HTTP 客户端。
|
|
18
|
-
-
|
|
50
|
+
- **成本感知**:会话、今日和三日人民币费用估算,支持版本化高峰/空闲价格和 `/cost` 汇总。
|
|
51
|
+
|
|
52
|
+
## 为什么使用 dsht?
|
|
53
|
+
|
|
54
|
+
### 为远程控制而设计
|
|
55
|
+
|
|
56
|
+
DeepSeek Harness 往往运行在性能更强、环境更完整的开发工作站或服务器上,而人并不总是在那台机器前。
|
|
57
|
+
|
|
58
|
+
`dsht` 将控制界面保持在纯终端中,因此无需给远程服务器安装桌面环境,也不要求手机运行完整的开发环境。你可以让 Harness 留在开发主机持续工作,需要查看或干预时,再通过已有的 SSH 链路进入主机运行 `dsht`。
|
|
59
|
+
|
|
60
|
+
最简单的方式:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
笔记本 ───────── SSH ────────> 开发主机 ──> dsht
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
经过跳板机时:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
手机 ── SSH ──> 跳板机 ── SSH ──> 开发主机 ──> dsht
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
这使得“手机在手,随时控制 Harness”成为实际可用的工作流:无需远程桌面,也无需在公网直接暴露 Harness 的 Web 服务。
|
|
73
|
+
|
|
74
|
+
> SSH 隧道、ProxyJump、跳板机和访问控制由现有 SSH 环境负责;`dsht` 专注于 DeepSeek Harness 的终端交互与控制。
|
|
75
|
+
|
|
76
|
+
### 手机也能继续控制任务
|
|
77
|
+
|
|
78
|
+
在移动场景中,通常不适合长时间编辑代码,但非常适合进行控制和决策。
|
|
79
|
+
|
|
80
|
+
通过手机 SSH 进入运行 `dsht` 的远程终端后,可以:
|
|
81
|
+
|
|
82
|
+
- 查看正在运行的任务和实时输出;
|
|
83
|
+
- 阅读助手回复、思考内容和工具执行状态;
|
|
84
|
+
- 发送新的 prompt 或 `/steer` 转向指令;
|
|
85
|
+
- `/allow` 或 `/deny` 审批操作;
|
|
86
|
+
- 回答 Harness 提出的问题;
|
|
87
|
+
- `/cancel` 停止当前轮次;
|
|
88
|
+
- 切换工作区和会话;
|
|
89
|
+
- 搜索历史记录;
|
|
90
|
+
- 使用 `/cost` 查看当前任务和近期费用。
|
|
91
|
+
|
|
92
|
+
因此,即使离开电脑,也不必失去对长时间 Harness 任务的控制。
|
|
93
|
+
|
|
94
|
+
### 不只是日志查看器
|
|
95
|
+
|
|
96
|
+
`dsht` 是运行中 DeepSeek Harness 的交互式控制界面,而不是只读日志工具。
|
|
97
|
+
|
|
98
|
+
它能够发送输入、处理审批和问题、转向正在执行的任务、取消轮次、切换会话并在网络恢复后重新连接。Harness 的实际执行状态仍保留在服务端,客户端只负责通过终端呈现和控制。
|
|
99
|
+
|
|
100
|
+
### 成本感知
|
|
101
|
+
|
|
102
|
+
长时间的 AI 编码任务可能持续消耗大量 token,而仅看 token 总数很难直观判断实际费用。
|
|
103
|
+
|
|
104
|
+
`dsht` 按请求保存用量信息,并结合请求结算时间、模型身份和对应价格版本进行计算。费用统计区分:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
未缓存输入
|
|
108
|
+
缓存读取
|
|
109
|
+
缓存写入
|
|
110
|
+
输出
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`/cost` 可以查看:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
当前会话
|
|
117
|
+
今日
|
|
118
|
+
今日 + 前两个自然日
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
状态栏还可以持续显示会话费用 `S:` 和今日费用 `D:`,便于在任务执行过程中及时发现成本变化,而不是等到账单出现后才知道消耗了多少。
|
|
122
|
+
|
|
123
|
+
这让 `dsht` 同时承担两个角色:
|
|
124
|
+
|
|
125
|
+
1. **DeepSeek Harness 的远程终端控制界面**
|
|
126
|
+
2. **面向实际使用过程的成本监控工具**
|
|
19
127
|
|
|
20
128
|
## 目录
|
|
21
129
|
|
|
130
|
+
- [为什么使用 dsht?](#为什么使用-dsht)
|
|
22
131
|
- [启动](#启动)
|
|
132
|
+
- [远程 SSH 工作流](#远程-ssh-工作流)
|
|
23
133
|
- [列出工作区和会话](#列出工作区和会话)
|
|
24
134
|
- [对话操作](#对话操作)
|
|
25
135
|
- [实时状态](#实时状态)
|
|
@@ -62,6 +172,100 @@ npm start
|
|
|
62
172
|
|
|
63
173
|
Cookie 有效期由服务端决定。过期或被拒绝后,需要再次提供 token;已提供 token 时,HTTP 401 会自动触发重新认证。网络故障和 HTTP 403 不触发 token 兑换。损坏或权限不安全的 cookie 文件会明确报错。服务地址必须是不带路径、且除 `token` 外无其他查询参数的 origin,主机名须受服务端信任。
|
|
64
174
|
|
|
175
|
+
## 远程 SSH 工作流
|
|
176
|
+
|
|
177
|
+
`dsht` 最适合与现有 SSH 基础设施组合使用。它不要求 DeepSeek Harness 暴露到公网,也不要求客户端设备能够直接访问 `dsh web`。
|
|
178
|
+
|
|
179
|
+
### 直接 SSH 到开发主机
|
|
180
|
+
|
|
181
|
+
如果开发主机可以直接 SSH:
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
Laptop / Phone
|
|
185
|
+
│
|
|
186
|
+
│ SSH
|
|
187
|
+
▼
|
|
188
|
+
Development Host
|
|
189
|
+
│
|
|
190
|
+
├── dsht
|
|
191
|
+
└── dsh web
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
登录远程主机后直接运行:
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
dsht
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
或者无需全局安装:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
npx @itookit/dsht
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### 通过跳板机访问
|
|
207
|
+
|
|
208
|
+
如果开发主机只能通过跳板机访问:
|
|
209
|
+
|
|
210
|
+
```text
|
|
211
|
+
Phone
|
|
212
|
+
│
|
|
213
|
+
│ SSH
|
|
214
|
+
▼
|
|
215
|
+
Jump Host
|
|
216
|
+
│
|
|
217
|
+
│ SSH / ProxyJump
|
|
218
|
+
▼
|
|
219
|
+
Development Host
|
|
220
|
+
│
|
|
221
|
+
├── dsht
|
|
222
|
+
└── dsh web
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
例如已有 OpenSSH `ProxyJump` 配置时,可以先正常 SSH 到目标开发主机,然后运行:
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
dsht
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`dsht` 不需要理解这条 SSH 链路;从它的角度看,它只是运行在能够访问 `dsh web` 的终端环境中。
|
|
232
|
+
|
|
233
|
+
### 与 tmux 配合
|
|
234
|
+
|
|
235
|
+
远程环境中可以把 `dsht` 放在 tmux 会话中,以便网络中断后重新进入同一个终端环境:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
tmux new -s dsht
|
|
239
|
+
dsht
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
之后重新 SSH 登录:
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
tmux attach -t dsht
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
即使不使用 tmux,Harness 会话状态仍然保留在服务端;重新启动 `dsht` 后可以重新选择原工作区和会话。tmux 的价值主要在于保留本地终端布局和当前 TUI 进程。
|
|
249
|
+
|
|
250
|
+
### 手机访问
|
|
251
|
+
|
|
252
|
+
任何能够正常使用 SSH 的手机终端都可以作为入口:
|
|
253
|
+
|
|
254
|
+
```text
|
|
255
|
+
Mobile SSH Client
|
|
256
|
+
│
|
|
257
|
+
▼
|
|
258
|
+
Jump Host
|
|
259
|
+
│
|
|
260
|
+
▼
|
|
261
|
+
Development Host
|
|
262
|
+
│
|
|
263
|
+
▼
|
|
264
|
+
dsht
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
实际体验取决于移动终端对 ANSI、Unicode、方向键、SGR mouse reports 等终端能力的支持。即使触摸鼠标能力有限,核心操作仍可以通过键盘和 slash 命令完成。
|
|
268
|
+
|
|
65
269
|
## 列出工作区和会话
|
|
66
270
|
|
|
67
271
|
```sh
|
|
@@ -83,7 +287,7 @@ JSON 输出格式为 `{ "items": [...] }`;省略 `--json` 则输出制表符
|
|
|
83
287
|
|
|
84
288
|
## 对话操作
|
|
85
289
|
|
|
86
|
-
Enter 提交消息。所选会话运行中时,Ctrl+C 请求取消;只有空闲时才退出,连续按键会复用尚未完成的取消请求。取消会等待正在提交的消息完成接收,失败时保留客户端。聊天界面中 Esc 会发送取消请求,不受本地空闲状态判断限制。任务运行中时,Esc 关闭文件或历史/搜索菜单的同时请求取消;空闲菜单仅关闭。正在执行的本地历史/搜索/费用加载优先被取消。Page Up/Down 滚动当前对话;`/older`
|
|
290
|
+
Enter 提交消息。所选会话运行中时,Ctrl+C 请求取消;只有空闲时才退出,连续按键会复用尚未完成的取消请求。取消会等待正在提交的消息完成接收,失败时保留客户端。聊天界面中 Esc 会发送取消请求,不受本地空闲状态判断限制。任务运行中时,Esc 关闭文件或历史/搜索菜单的同时请求取消;空闲菜单仅关闭。正在执行的本地历史/搜索/费用加载优先被取消。Page Up/Down 滚动当前对话;`/older` 加载更早记录。所有退出路径(包括 `/quit` 和 SIGTERM)都会在关闭连接前停止所选任务,因此退出不会留下仍在运行的代理;会话空闲时不发送取消。取消当前任务会保留排队消息。
|
|
87
291
|
|
|
88
292
|
鼠标滚轮和 Page Up/Down 滚动对话;滚到顶部自动加载更早的一页。查看旧记录时,新输出保留阅读位置;`/jump last` 恢复跟随最新输出。TUI 挂载时启用鼠标报告,退出时关闭,需要终端支持 SGR 鼠标报告。加载历史或搜索期间,Esc 或 Ctrl+C 优先取消本地操作,不中断远程任务。
|
|
89
293
|
|
|
@@ -123,7 +327,7 @@ Enter 提交消息。所选会话运行中时,Ctrl+C 请求取消;只有空
|
|
|
123
327
|
| `/cost` | 展开/收起会话、今日、三日费用,并刷新用量 |
|
|
124
328
|
| `/help`, `/quit` | 显示命令提示或退出 |
|
|
125
329
|
|
|
126
|
-
Slash 命令在选择器和对话输入框中均可使用。输入 `/`
|
|
330
|
+
Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示匹配命令。`/help`、`/cost`、`/status` 三个面板是临时的:执行下一条命令、或十秒后,当前打开的面板会自动关闭。长命令 `/workspace`、`/workspaces`、`/session`、`/sessions` 保留为别名。名称可以包含空格,完整目标两侧的引号可选。不带引号的目标 `all` 保留给 `/s all`;打开标题为 `all` 的会话时,使用 `/s "all"` 或其 ID。目标有歧义时必须提供完整 ID。切换工作区会打开其会话列表并解除旧对话订阅;切换会话会同步工作区标签。两种操作均不会取消远程代理。
|
|
127
331
|
|
|
128
332
|
在输入末尾键入 `@`,可搜索所选会话**在服务端**工作目录中的文件和目录。使用 ↑/↓ 选择,Tab 或 Enter 插入;选择目录后继续补全其内部路径。带空格的路径使用 `@"path with spaces"`。Esc 关闭菜单,任务运行中时同时请求取消;关闭后 Enter 发送原样输入,包括未匹配到的路径。搜索失败时显示错误,不提交输入。补全针对输入末尾的引用,不跟踪已有文本内部的光标位置。
|
|
129
333
|
|
|
@@ -135,7 +339,7 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
135
339
|
|
|
136
340
|
## 实时状态
|
|
137
341
|
|
|
138
|
-
底栏默认无边框单行显示运行状态、模型、工作区、上下文占用和 token 总量;宽度足够时补充输入/输出、缓存、队列和后台任务数。长名称按终端显示宽度缩短,窄终端优先省略次要信息。`/status` 切换完整多行详情,显示完整路径、供应商/模型、思考强度及各项用量。`!`
|
|
342
|
+
底栏默认无边框单行显示运行状态、模型、工作区、上下文占用和 token 总量;宽度足够时补充输入/输出、缓存、队列和后台任务数。长名称按终端显示宽度缩短,窄终端优先省略次要信息。`/status` 切换完整多行详情,显示完整路径、供应商/模型、思考强度及各项用量。`!` 表示有指标或模型目录错误,或计费覆盖不完整;详情中显示原因。运行中时区分最近实际使用的模型和不同的下次请求模型;新会话使用服务端模型目录的默认值。服务端设置、凭据和适配器变更通知会刷新模型目录。
|
|
139
343
|
|
|
140
344
|
工作计时使用已加载日志的 `turn/start` 时间戳。缺少该时间戳时,`(observed)` 表示从客户端观察到运行开始计时;重连可能重置此备用计时。服务端报告空闲后停止计时。运行状态涵盖模型生成、工具执行及审批等待,不仅是文本输出。断线时明确标注为最后已知状态。
|
|
141
345
|
|
|
@@ -145,17 +349,21 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
145
349
|
|
|
146
350
|
## 费用估算
|
|
147
351
|
|
|
148
|
-
|
|
352
|
+
`dsht` 不只是显示 token 数,而是把 Harness 可见的逐请求用量转换为可追踪的人民币费用估算。它区分未缓存输入、缓存读取、缓存写入和输出,并结合模型、请求结算时间、价格版本以及高峰/空闲时段进行计算,便于在任务运行过程中及时了解当前会话和近期总成本。
|
|
353
|
+
|
|
354
|
+
> 这里的费用是基于 Harness 可见用量和本地价格配置得到的高精度估算,用于成本监控和控制;它不是供应商账户级账单,最终费用仍以供应商账单为准。
|
|
355
|
+
|
|
356
|
+
`/cost` 显示当前会话、今日及今日加前两个自然日的费用。日期使用 Asia/Shanghai,三日统计不是滚动 72 小时。状态栏中 `S:` 表示会话费用,`D:` 表示今日费用。`~` 表示估算;`*` 表示该小计并不精确:请求缺少时间戳、没有价格覆盖,或无法归入所选自然日区间。覆盖不完整另行通报:之前运行缓存的费用视为完整,而账本为空或扫描失败时状态栏出现 `!` 前缀,并在 `/status` 中说明原因。每个服务端 origin 使用独立账本;总额覆盖 HTTP 可见会话及之前缓存的会话,不是供应商账户级账单。
|
|
149
357
|
|
|
150
|
-
客户端连接后、每 60 秒、任务结束及打开 `/cost` 时在后台通过 HTTP 读取完整历史;服务端更新时间未变的空闲会话跳过扫描。计费不会发起模型请求。显式刷新时可按 Esc 或 Ctrl+C 取消。账本分别统计未缓存输入、缓存读/写和输出,思考 token 已包含在输出中。重试单独计费,同一次尝试的替换用量更新原记录,fork
|
|
358
|
+
客户端连接后、每 60 秒、任务结束及打开 `/cost` 时在后台通过 HTTP 读取完整历史;服务端更新时间未变的空闲会话跳过扫描。计费不会发起模型请求。显式刷新时可按 Esc 或 Ctrl+C 取消。账本分别统计未缓存输入、缓存读/写和输出,思考 token 已包含在输出中。重试单独计费,同一次尝试的替换用量更新原记录,fork 继承历史不重复计费。缺少结算时间戳的请求仍按该模型族的最低费率给出下限金额,并标记为估算。用量矛盾,以及没有任何模型或供应商条目覆盖的价格,仍标为未计价;模型名是否包含 `pro` 决定按 Pro 还是 Flash 计价,而未列出的供应商不会套用官方价目。扫描失败保留并标明部分缓存结果。
|
|
151
359
|
|
|
152
|
-
内置人民币价格于 2026-09-10 根据[官方价格页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)核对。北京时间工作日 09:00–12:00、14:00–18:00 为高峰,其余时段半价。Flash
|
|
360
|
+
内置人民币价格于 2026-09-10 根据[官方价格页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)核对。北京时间工作日 09:00–12:00、14:00–18:00 为高峰,其余时段半价。Flash 高峰未命中输入/缓存命中输入/输出为每百万 token ¥2/¥0.04/¥8,Pro 为 ¥9/¥0.30/¥27;当前模型名为 `deepseek-flash`,旧 Flash 名称沿用同一费率。供应方已公告自北京时间 2026-09-14 12:00 起将 `deepseek-v4-pro` 交由 Flash 服务并按 Flash 价格计费,内置条目已记录该变更,避免此后高估 Pro 用量。单列的缓存写入按未命中输入价计算。配置中的精确模型价格优先;否则 `deepseek-official` 模型名包含 `pro`(不区分大小写)时按 Pro 计价,其余名称包括临时别名均按 Flash 计价。其他供应商需要显式配置。
|
|
153
361
|
|
|
154
362
|
默认价格有效期从核对日期的北京时间零点开始,这是本地估算规则,不代表官方价格生效日期。更早用量需要补充历史价格版本。程序按助手请求结算记录的时间选择单价;官方未说明跨时段请求的归属,因此边界附近的估算可能与账单不同。图片使用供应商报告的 token 数。已计价请求保留原价格版本,不随配置修改重新套价;未计价请求可以在后续扫描时补算。
|
|
155
363
|
|
|
156
364
|
首次交互启动会创建 `~/.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` 衔接的新版本;程序拒绝重叠区间。重启后读取配置修改;价格由用户维护,启动时不抓取网页价格。
|
|
157
365
|
|
|
158
|
-
用量文件位于 `~/.local/state/dsht/cost/<origin-hash>/`,遵循 `XDG_STATE_HOME`,也可通过 `DSHT_STATE_DIR` 指定应用状态根目录。文件只含会话 ID、时间戳、模型身份、token 数、所选价格版本和估算值,不包含提示词、工具正文、凭据或 cookie
|
|
366
|
+
用量文件位于 `~/.local/state/dsht/cost/<origin-hash>/`,遵循 `XDG_STATE_HOME`,也可通过 `DSHT_STATE_DIR` 指定应用状态根目录。文件只含会话 ID、时间戳、模型身份、token 数、所选价格版本和估算值,不包含提示词、工具正文、凭据或 cookie。价格文件属于配置,这些用量文件属于状态,因此只有前者需要纳入设置备份。写入使用私有临时文件及原子替换,按历史截点命名的文件避免旧扫描覆盖更新的缓存截点。缓存跨重启保留,不需要访问服务端配置目录。账本保存的是逐条请求而非累计总额,且跳过已扫描会话的记录只存在内存中,因此重启后的首次扫描会重新读取每个会话,并按各请求自身的结算时间重新计算停机期间新增的用量。
|
|
159
367
|
|
|
160
368
|
## 客户端接口
|
|
161
369
|
|
|
@@ -169,10 +377,10 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
169
377
|
|
|
170
378
|
| 字段 | 值 |
|
|
171
379
|
| --- | --- |
|
|
172
|
-
| 名称与版本 | `@itookit/dsht` `0.
|
|
380
|
+
| 名称与版本 | `@itookit/dsht` `0.2.0` |
|
|
173
381
|
| 可执行命令 | `dsht`,不安装时用 `npx @itookit/dsht` |
|
|
174
382
|
| 库入口 | `@itookit/dsht` 和 `@itookit/dsht/auth` |
|
|
175
|
-
| 作者 | lizlok
|
|
383
|
+
| 作者 | lizlok\@gmail.com |
|
|
176
384
|
| 许可证 | MIT,许可证正文位于 `LICENSE` |
|
|
177
385
|
| 仓库与问题反馈 | [mushuanli/dsht](https://github.com/mushuanli/dsht) |
|
|
178
386
|
| Node.js | 22.19 或更新版本 |
|
|
@@ -191,7 +399,7 @@ npm publish --access public
|
|
|
191
399
|
|
|
192
400
|
`publishConfig.access` 为 `public`;scoped 包需要它才能被公开安装,因此该设置放在包里而不是每次发布命令上。启用两步验证的账号需用即时验证码发布:`npm publish --otp=<验证码>`;验证码在最后一次请求时校验,此时类型检查、测试和构建均已执行完毕。
|
|
193
401
|
|
|
194
|
-
后续版本由 `.github/workflows/publish.yml` 发布:它以版本 tag 触发,使用 [trusted publishing](https://docs.npmjs.com/trusted-publishers)(OIDC)并生成 provenance,不保存任何发布 token。需在 `npmjs.com` → `@itookit/dsht` → Settings → Trusted Publisher → GitHub Actions 一次性配置:组织或用户 `mushuanli`、仓库 `dsht`、工作流文件名 `publish.yml`、允许动作 `npm publish`。Trusted publishing
|
|
402
|
+
后续版本由 `.github/workflows/publish.yml` 发布:它以版本 tag 触发,使用 [trusted publishing](https://docs.npmjs.com/trusted-publishers)(OIDC)并生成 provenance,不保存任何发布 token。需在 `npmjs.com` → `@itookit/dsht` → Settings → Trusted Publisher → GitHub Actions 一次性配置:组织或用户 `mushuanli`、仓库 `dsht`、工作流文件名 `publish.yml`、允许动作 `npm publish`。Trusted publishing 无法创建包,因此首个版本需手工发布;之后执行 `npm version 0.2.1 && git push --follow-tags` 即可发布。
|
|
195
403
|
|
|
196
404
|
手动触发时该工作流只打包不发布,并拒绝与 `package.json` 不一致的 tag。参见官方 [scoped 发布指南](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/)和 [npx 文档](https://docs.npmjs.com/cli/npm-exec/)。Registry 发布不属于本仓库已执行的本地验证。
|
|
197
405
|
|
package/dist/app.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from './controller.ts';
|
|
2
2
|
/** The caller owns starting and stopping the controller around the Ink render lifetime. */
|
|
3
|
-
export declare function App({ controller }: {
|
|
3
|
+
export declare function App({ controller, panelLifetimeMs }: {
|
|
4
4
|
controller: Controller;
|
|
5
|
+
panelLifetimeMs?: number;
|
|
5
6
|
}): import("react").JSX.Element;
|
package/dist/app.js
CHANGED
|
@@ -13,6 +13,8 @@ import { navigationCommand, sessionLabel } from "./navigation.js";
|
|
|
13
13
|
import { array, errorText, object, safeText, string } from "./wire.js";
|
|
14
14
|
const COMMANDS = ['/ws', '/s', '/new', '/older', '/history', '/jump', '/search', '/ssearch', '/wsearch', '/cancel', '/steer', '/allow', '/deny', '/status', '/cost', '/help', '/quit'];
|
|
15
15
|
const HELP = '/ws [name or ID] · /s [title or ID] · /s all · /new · /older · /history [text] · /jump <seq|first|last> · /search text · /ssearch text · /wsearch text · /cancel · /steer text · /allow · /deny · /status · /cost · /quit';
|
|
16
|
+
/** A slash-command panel closes on the next command or after this long, whichever comes first. */
|
|
17
|
+
const PANEL_LIFETIME_MS = 10_000;
|
|
16
18
|
function Picker({ choices, enabled, canSelect }) {
|
|
17
19
|
const [selected, setSelected] = useState(0);
|
|
18
20
|
const current = Math.min(selected, choices.length - 1);
|
|
@@ -30,7 +32,7 @@ function Picker({ choices, enabled, canSelect }) {
|
|
|
30
32
|
return _jsxs(Box, { flexDirection: "column", children: [choices.slice(start, start + 12).map((choice, index) => _jsxs(Text, { color: start + index === current ? 'cyan' : undefined, children: [start + index === current ? '❯ ' : ' ', safeText(choice.label)] }, choice.key)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 select \u00B7 Enter open \u00B7 Ctrl+C stop / exit" })] });
|
|
31
33
|
}
|
|
32
34
|
/** The caller owns starting and stopping the controller around the Ink render lifetime. */
|
|
33
|
-
export function App({ controller }) {
|
|
35
|
+
export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
|
|
34
36
|
const state = useSyncExternalStore(controller.subscribe, controller.snapshot);
|
|
35
37
|
const { exit } = useApp();
|
|
36
38
|
const { stdout } = useStdout();
|
|
@@ -52,6 +54,13 @@ export function App({ controller }) {
|
|
|
52
54
|
const [referenceIndex, setReferenceIndex] = useState(0);
|
|
53
55
|
const [dismissedReference, dismissReference] = useState();
|
|
54
56
|
const [lookup, setLookup] = useState();
|
|
57
|
+
// A slash-command panel is temporary: the next command or its lifetime closes it.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!help && !costExpanded && !statusExpanded)
|
|
60
|
+
return;
|
|
61
|
+
const timer = setTimeout(() => { setHelp(false); setCostExpanded(false); setStatusExpanded(false); }, panelLifetimeMs);
|
|
62
|
+
return () => clearTimeout(timer);
|
|
63
|
+
}, [help, costExpanded, statusExpanded, panelLifetimeMs]);
|
|
55
64
|
const pending = state.pending[0];
|
|
56
65
|
const token = state.screen === 'chat' && state.online && !state.busy && !pending
|
|
57
66
|
&& (!input.startsWith('/') || input.startsWith('/steer ')) && dismissedReference !== input && cursor === input.length
|
|
@@ -137,6 +146,13 @@ export function App({ controller }) {
|
|
|
137
146
|
const value = raw.trim();
|
|
138
147
|
if (!value)
|
|
139
148
|
return;
|
|
149
|
+
// Each panel belongs to the command that opened it, so any other command closes it.
|
|
150
|
+
if (value !== '/help')
|
|
151
|
+
setHelp(false);
|
|
152
|
+
if (value !== '/cost')
|
|
153
|
+
setCostExpanded(false);
|
|
154
|
+
if (value !== '/status')
|
|
155
|
+
setStatusExpanded(false);
|
|
140
156
|
if (value === '/quit') {
|
|
141
157
|
exit();
|
|
142
158
|
return;
|
package/dist/cli.js
CHANGED
|
@@ -101,7 +101,7 @@ async function main() {
|
|
|
101
101
|
}
|
|
102
102
|
finally {
|
|
103
103
|
process.off('SIGTERM', terminate);
|
|
104
|
-
await controller.
|
|
104
|
+
await controller.shutdown();
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
main().catch(error => { process.stderr.write(`${errorText(error)}\n`); process.exitCode = 1; });
|
package/dist/controller.d.ts
CHANGED
|
@@ -71,6 +71,10 @@ export declare class Controller {
|
|
|
71
71
|
start(): void;
|
|
72
72
|
/** Cancel retries and HTTP, close the socket, and wait for the loop to settle. */
|
|
73
73
|
stop(): Promise<void>;
|
|
74
|
+
/** Stop the selected turn and then close, so quitting does not leave host work running.
|
|
75
|
+
* An idle session stays untouched, and an in-flight cancellation is awaited rather than repeated.
|
|
76
|
+
*/
|
|
77
|
+
shutdown(): Promise<void>;
|
|
74
78
|
/** Run a UI operation and expose errors without destroying the current input. */
|
|
75
79
|
perform(operation: () => Promise<void>): Promise<boolean>;
|
|
76
80
|
/** Refresh all HTTP-visible sessions without changing the selected conversation.
|
package/dist/controller.js
CHANGED
|
@@ -119,6 +119,14 @@ export class Controller {
|
|
|
119
119
|
await Promise.all(this.catalogTasks);
|
|
120
120
|
await this.costTask;
|
|
121
121
|
}
|
|
122
|
+
/** Stop the selected turn and then close, so quitting does not leave host work running.
|
|
123
|
+
* An idle session stays untouched, and an in-flight cancellation is awaited rather than repeated.
|
|
124
|
+
*/
|
|
125
|
+
async shutdown() {
|
|
126
|
+
if (this.interruptTask || this.running || this.admission)
|
|
127
|
+
await this.interrupt(true);
|
|
128
|
+
await this.stop();
|
|
129
|
+
}
|
|
122
130
|
/** Run a UI operation and expose errors without destroying the current input. */
|
|
123
131
|
async perform(operation) {
|
|
124
132
|
if (this.state.busy || !this.state.online)
|
package/dist/cost-view.js
CHANGED
|
@@ -16,5 +16,8 @@ export function CostPanel({ controller }) {
|
|
|
16
16
|
['Session', id && costs.hasSession(id) ? costs.total(id) : undefined],
|
|
17
17
|
['Today', costs.total(undefined, 1)], ['3 days (today + previous 2)', costs.total(undefined, 3)],
|
|
18
18
|
];
|
|
19
|
-
return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
|
|
19
|
+
return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced${total.estimated ? ` · ${total.estimated} estimated` : ''} / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
|
|
20
|
+
: costs.coverage === 'partial' ? 'Partial totals · awaiting a complete scan'
|
|
21
|
+
: costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}`
|
|
22
|
+
: 'Cached totals from the previous run' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: "yellow", children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: "yellow", children: safeText(reason) }, reason))] });
|
|
20
23
|
}
|
package/dist/cost.d.ts
CHANGED
|
@@ -20,7 +20,6 @@ export interface PriceVersion {
|
|
|
20
20
|
weekdays: number[];
|
|
21
21
|
windows: [number, number][];
|
|
22
22
|
}
|
|
23
|
-
/** Published rates verified on 2026-09-10; preceding dates require historical configuration. */
|
|
24
23
|
export declare const DEFAULT_PRICES: PriceVersion[];
|
|
25
24
|
/** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
|
|
26
25
|
* @param value - Parsed prices.json array.
|
|
@@ -32,12 +31,18 @@ export declare function pricesFrom(value: unknown): PriceVersion[];
|
|
|
32
31
|
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
33
32
|
*/
|
|
34
33
|
export declare function costDay(time: number): string;
|
|
35
|
-
/** Summary
|
|
34
|
+
/** Summary retains the known subtotal, the records it could not price, and the coarse estimates.
|
|
35
|
+
* `unknown` counts records with no amount at all; `estimated` counts records that only have a
|
|
36
|
+
* floor amount, including dated requests whose timestamp cannot place them inside the range.
|
|
37
|
+
*/
|
|
36
38
|
export interface CostTotal {
|
|
37
39
|
amount: number;
|
|
38
40
|
unknown: number;
|
|
41
|
+
estimated: number;
|
|
39
42
|
records: number;
|
|
40
43
|
}
|
|
44
|
+
/** How much of the visible history the cached ledger currently covers. */
|
|
45
|
+
export type Coverage = 'complete' | 'scanning' | 'partial';
|
|
41
46
|
/** Select a price by event time, applying half-open local peak windows.
|
|
42
47
|
* @param prices - Validated versions.
|
|
43
48
|
* @param provider - Provider identity from the recorded request.
|
|
@@ -49,6 +54,18 @@ export declare function priceAt(prices: PriceVersion[], provider: string, model:
|
|
|
49
54
|
price: PriceVersion;
|
|
50
55
|
rates: Rates;
|
|
51
56
|
} | undefined;
|
|
57
|
+
/** Select a rate without a settlement time, so an unattributable request still enters the total.
|
|
58
|
+
* The cheapest candidate off-peak rate is a floor: it never overstates, and the charge stays
|
|
59
|
+
* marked as estimated.
|
|
60
|
+
* @param prices - Validated versions.
|
|
61
|
+
* @param provider - Provider identity from the recorded request.
|
|
62
|
+
* @param model - Recorded model name.
|
|
63
|
+
* @returns The candidate version with the lowest off-peak input rate and its rates, if any.
|
|
64
|
+
*/
|
|
65
|
+
export declare function lowestPrice(prices: PriceVersion[], provider: string, model: string): {
|
|
66
|
+
price: PriceVersion;
|
|
67
|
+
rates: Rates;
|
|
68
|
+
} | undefined;
|
|
52
69
|
/** Keep only billing-relevant fields; prompts, tool bodies, cookies and keys never enter the ledger.
|
|
53
70
|
* @param records - One HTTP history page's records.
|
|
54
71
|
* @returns Minimal durable events for a deterministic usage fold.
|
|
@@ -64,6 +81,10 @@ export declare class CostLedger {
|
|
|
64
81
|
scanning: boolean;
|
|
65
82
|
error: string;
|
|
66
83
|
constructor(prices?: PriceVersion[], directory?: string | undefined);
|
|
84
|
+
/** Cached charges count as complete; only a failed scan or an empty ledger is partial.
|
|
85
|
+
* @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
|
|
86
|
+
*/
|
|
87
|
+
get coverage(): Coverage;
|
|
67
88
|
/** Load immutable cut files, keeping the newest complete scan for each session. */
|
|
68
89
|
load(): Promise<void>;
|
|
69
90
|
/** Replace one session using all billing events through the opening snapshot cut.
|
|
@@ -85,11 +106,12 @@ export declare class CostLedger {
|
|
|
85
106
|
* @param sessionId - Optional session restriction.
|
|
86
107
|
* @param days - Today or today plus the preceding two calendar days.
|
|
87
108
|
* @param now - Clock used for date attribution.
|
|
88
|
-
* @returns Known subtotal and
|
|
109
|
+
* @returns Known subtotal, unpriceable count, and estimated count; an estimated record always
|
|
110
|
+
* names an amount, but a dated range only adds the records it can place inside that range.
|
|
89
111
|
*/
|
|
90
112
|
total(sessionId?: string, days?: 1 | 3, now?: number): CostTotal;
|
|
91
113
|
}
|
|
92
|
-
/** Compact estimates retain an asterisk whenever a subtotal
|
|
114
|
+
/** Compact estimates retain an asterisk whenever a subtotal is not exact.
|
|
93
115
|
* @param total - Summary from the ledger.
|
|
94
116
|
* @returns Yuan amount and incompleteness marker.
|
|
95
117
|
*/
|
package/dist/cost.js
CHANGED
|
@@ -4,15 +4,27 @@ import { mkdir, readFile, readdir, rename, writeFile, unlink } from 'node:fs/pro
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { object, array } from "./wire.js";
|
|
6
6
|
const clocks = new Map();
|
|
7
|
-
/** Published rates verified on 2026-09-10; preceding dates require historical configuration.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
7
|
+
/** Published rates verified on 2026-09-10; preceding dates require historical configuration.
|
|
8
|
+
* Flash and Pro are priced independently, and a separate cache write uses the cache-miss input rate.
|
|
9
|
+
*/
|
|
10
|
+
const OFFICIAL_PRICING = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/';
|
|
11
|
+
const PEAK_SCHEDULE = { weekdays: [1, 2, 3, 4, 5], windows: [[540, 720], [840, 1080]] };
|
|
12
|
+
const FLASH_RATES = { peak: { input: 2, cacheRead: 0.04, cacheWrite: 2, output: 8 },
|
|
13
|
+
offPeak: { input: 1, cacheRead: 0.02, cacheWrite: 1, output: 4 } };
|
|
14
|
+
const PRO_RATES = { peak: { input: 9, cacheRead: 0.3, cacheWrite: 9, output: 27 },
|
|
15
|
+
offPeak: { input: 4.5, cacheRead: 0.15, cacheWrite: 4.5, output: 13.5 } };
|
|
16
|
+
export const DEFAULT_PRICES = [
|
|
17
|
+
{ id: 'deepseek-2026-09-10-flash', provider: 'deepseek-official', model: 'deepseek-flash',
|
|
18
|
+
from: '2026-09-10T00:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
|
|
19
|
+
...PEAK_SCHEDULE, ...FLASH_RATES },
|
|
20
|
+
{ id: 'deepseek-2026-09-10-pro', provider: 'deepseek-official', model: 'deepseek-v4-pro',
|
|
21
|
+
from: '2026-09-10T00:00:00+08:00', until: '2026-09-14T12:00:00+08:00', currency: 'CNY',
|
|
22
|
+
source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai', ...PEAK_SCHEDULE, ...PRO_RATES },
|
|
23
|
+
// The provider bills `deepseek-v4-pro` requests at Flash rates once V4 Pro is retired.
|
|
24
|
+
{ id: 'deepseek-2026-09-14-pro-served-by-flash', provider: 'deepseek-official', model: 'deepseek-v4-pro',
|
|
25
|
+
from: '2026-09-14T12:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
|
|
26
|
+
...PEAK_SCHEDULE, ...FLASH_RATES },
|
|
27
|
+
];
|
|
16
28
|
/** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
|
|
17
29
|
* @param value - Parsed prices.json array.
|
|
18
30
|
* @returns Price versions with validated rates and schedules.
|
|
@@ -58,6 +70,15 @@ export function pricesFrom(value) {
|
|
|
58
70
|
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
59
71
|
*/
|
|
60
72
|
export function costDay(time) { return new Date(time + 8 * 3600_000).toISOString().slice(0, 10); }
|
|
73
|
+
/** Price family used when a recorded model name has no exact entry. */
|
|
74
|
+
function priceFamily(model) { return model.toLowerCase().includes('pro') ? 'deepseek-v4-pro' : 'deepseek-flash'; }
|
|
75
|
+
/** Candidate versions for one request: its exact model first, then the official model family. */
|
|
76
|
+
function candidates(prices, provider, model) {
|
|
77
|
+
const exact = prices.filter(p => p.provider === provider && p.model === model);
|
|
78
|
+
if (exact.length)
|
|
79
|
+
return exact;
|
|
80
|
+
return provider === 'deepseek-official' ? prices.filter(p => p.model === priceFamily(model)) : [];
|
|
81
|
+
}
|
|
61
82
|
/** Select a price by event time, applying half-open local peak windows.
|
|
62
83
|
* @param prices - Validated versions.
|
|
63
84
|
* @param provider - Provider identity from the recorded request.
|
|
@@ -66,10 +87,7 @@ export function costDay(time) { return new Date(time + 8 * 3600_000).toISOString
|
|
|
66
87
|
* @returns Matching price version and per-million-token rates, if known.
|
|
67
88
|
*/
|
|
68
89
|
export function priceAt(prices, provider, model, time) {
|
|
69
|
-
const
|
|
70
|
-
const family = model.toLowerCase().includes('pro') ? 'deepseek-v4-pro' : 'deepseek-v4-flash';
|
|
71
|
-
const price = prices.find(p => active(p) && p.model === model)
|
|
72
|
-
?? (provider === 'deepseek-official' ? prices.find(p => active(p) && p.model === family) : undefined);
|
|
90
|
+
const price = candidates(prices, provider, model).find(p => Date.parse(p.from) <= time && (p.until === undefined || time < Date.parse(p.until)));
|
|
73
91
|
if (!price)
|
|
74
92
|
return;
|
|
75
93
|
let clock = clocks.get(price.timezone);
|
|
@@ -83,6 +101,22 @@ export function priceAt(prices, provider, model, time) {
|
|
|
83
101
|
const minute = Number(part('hour')) * 60 + Number(part('minute'));
|
|
84
102
|
return { price, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
|
|
85
103
|
}
|
|
104
|
+
/** Select a rate without a settlement time, so an unattributable request still enters the total.
|
|
105
|
+
* The cheapest candidate off-peak rate is a floor: it never overstates, and the charge stays
|
|
106
|
+
* marked as estimated.
|
|
107
|
+
* @param prices - Validated versions.
|
|
108
|
+
* @param provider - Provider identity from the recorded request.
|
|
109
|
+
* @param model - Recorded model name.
|
|
110
|
+
* @returns The candidate version with the lowest off-peak input rate and its rates, if any.
|
|
111
|
+
*/
|
|
112
|
+
export function lowestPrice(prices, provider, model) {
|
|
113
|
+
let best;
|
|
114
|
+
for (const price of candidates(prices, provider, model)) {
|
|
115
|
+
if (best === undefined || price.offPeak.input < best.rates.input)
|
|
116
|
+
best = { price, rates: price.offPeak };
|
|
117
|
+
}
|
|
118
|
+
return best;
|
|
119
|
+
}
|
|
86
120
|
/** Keep only billing-relevant fields; prompts, tool bodies, cookies and keys never enter the ledger.
|
|
87
121
|
* @param records - One HTTP history page's records.
|
|
88
122
|
* @returns Minimal durable events for a deterministic usage fold.
|
|
@@ -111,6 +145,16 @@ export class CostLedger {
|
|
|
111
145
|
this.prices = prices;
|
|
112
146
|
this.directory = directory;
|
|
113
147
|
}
|
|
148
|
+
/** Cached charges count as complete; only a failed scan or an empty ledger is partial.
|
|
149
|
+
* @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
|
|
150
|
+
*/
|
|
151
|
+
get coverage() {
|
|
152
|
+
if (this.scanning)
|
|
153
|
+
return 'scanning';
|
|
154
|
+
if (this.error)
|
|
155
|
+
return 'partial';
|
|
156
|
+
return this.scannedAt !== undefined || this.sessions.size > 0 ? 'complete' : 'partial';
|
|
157
|
+
}
|
|
114
158
|
/** Load immutable cut files, keeping the newest complete scan for each session. */
|
|
115
159
|
async load() {
|
|
116
160
|
if (!this.directory)
|
|
@@ -133,6 +177,7 @@ export class CostLedger {
|
|
|
133
177
|
if (v.version !== 1 || typeof v.sessionId !== 'string' || !Number.isSafeInteger(v.cut) || !Array.isArray(v.charges)
|
|
134
178
|
|| v.charges.some(c => !c || typeof c.key !== 'string' || typeof c.provider !== 'string' || typeof c.model !== 'string'
|
|
135
179
|
|| (c.amount !== undefined && (typeof c.amount !== 'number' || !Number.isFinite(c.amount) || c.amount < 0))
|
|
180
|
+
|| (c.estimated !== undefined && c.estimated !== true)
|
|
136
181
|
|| (c.time !== undefined && (typeof c.time !== 'number' || !Number.isFinite(c.time) || c.time < 0 || c.time > 8.64e15))
|
|
137
182
|
|| (c.usage !== undefined && Object.values(c.usage).some(n => typeof n !== 'number' || !Number.isSafeInteger(n) || n < 0))))
|
|
138
183
|
throw new Error('Invalid cost ledger');
|
|
@@ -189,11 +234,13 @@ export class CostLedger {
|
|
|
189
234
|
last = { turn: d.turn, step: d.step, index };
|
|
190
235
|
continue;
|
|
191
236
|
}
|
|
192
|
-
const
|
|
237
|
+
const reusable = previous?.price && previous.provider === provider && previous.model === model ? [previous.price] : this.prices;
|
|
238
|
+
const selected = time === undefined ? lowestPrice(reusable, provider, model) : priceAt(reusable, provider, model, time);
|
|
193
239
|
const estimate = usage && selected ? (usage.input * selected.rates.input + usage.output * selected.rates.output + usage.cacheRead * selected.rates.cacheRead + usage.cacheWrite * selected.rates.cacheWrite) / 1e6 : undefined;
|
|
194
240
|
const amount = estimate !== undefined && Number.isFinite(estimate) ? estimate : undefined;
|
|
195
241
|
charges[index] = { key, time, provider, model, usage, price: selected?.price, amount,
|
|
196
|
-
|
|
242
|
+
...(time === undefined && amount !== undefined ? { estimated: true } : {}),
|
|
243
|
+
reason: !usage ? 'missing usage' : time === undefined ? 'missing timestamp, floor rate' : !selected ? 'no price version' : amount === undefined ? 'invalid estimate' : undefined };
|
|
197
244
|
last = { turn: d.turn, step: d.step, index };
|
|
198
245
|
}
|
|
199
246
|
const saved = { version: 1, sessionId, cut, charges };
|
|
@@ -233,14 +280,15 @@ export class CostLedger {
|
|
|
233
280
|
* @param sessionId - Optional session restriction.
|
|
234
281
|
* @param days - Today or today plus the preceding two calendar days.
|
|
235
282
|
* @param now - Clock used for date attribution.
|
|
236
|
-
* @returns Known subtotal and
|
|
283
|
+
* @returns Known subtotal, unpriceable count, and estimated count; an estimated record always
|
|
284
|
+
* names an amount, but a dated range only adds the records it can place inside that range.
|
|
237
285
|
*/
|
|
238
286
|
total(sessionId, days, now = Date.now()) {
|
|
239
287
|
const cacheKey = JSON.stringify([sessionId, days, days ? costDay(now) : '']);
|
|
240
288
|
const cached = this.totals.get(cacheKey);
|
|
241
289
|
if (cached)
|
|
242
290
|
return cached;
|
|
243
|
-
const result = { amount: 0, unknown: 0, records: 0 };
|
|
291
|
+
const result = { amount: 0, unknown: 0, estimated: 0, records: 0 };
|
|
244
292
|
const end = costDay(now);
|
|
245
293
|
const start = costDay(now - ((days ?? 1) - 1) * 86400_000);
|
|
246
294
|
for (const session of this.sessions.values()) {
|
|
@@ -250,9 +298,14 @@ export class CostLedger {
|
|
|
250
298
|
if (days && charge.time !== undefined && (costDay(charge.time) < start || costDay(charge.time) > end))
|
|
251
299
|
continue;
|
|
252
300
|
result.records++;
|
|
253
|
-
if (charge.amount === undefined
|
|
301
|
+
if (charge.amount === undefined) {
|
|
254
302
|
result.unknown++;
|
|
255
|
-
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const dated = days === undefined || charge.time !== undefined;
|
|
306
|
+
if (charge.estimated === true || !dated)
|
|
307
|
+
result.estimated++;
|
|
308
|
+
if (dated)
|
|
256
309
|
result.amount += charge.amount;
|
|
257
310
|
}
|
|
258
311
|
}
|
|
@@ -260,8 +313,8 @@ export class CostLedger {
|
|
|
260
313
|
return result;
|
|
261
314
|
}
|
|
262
315
|
}
|
|
263
|
-
/** Compact estimates retain an asterisk whenever a subtotal
|
|
316
|
+
/** Compact estimates retain an asterisk whenever a subtotal is not exact.
|
|
264
317
|
* @param total - Summary from the ledger.
|
|
265
318
|
* @returns Yuan amount and incompleteness marker.
|
|
266
319
|
*/
|
|
267
|
-
export function costText(total) { return `~¥${total.amount.toFixed(4)}${total.unknown ? '*' : ''}`; }
|
|
320
|
+
export function costText(total) { return `~¥${total.amount.toFixed(4)}${total.unknown || total.estimated ? '*' : ''}`; }
|
package/dist/status.js
CHANGED
|
@@ -87,8 +87,9 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false
|
|
|
87
87
|
const view = controller.telemetry.view(state.sessionId);
|
|
88
88
|
const costs = controller.costs;
|
|
89
89
|
const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
|
|
90
|
-
const
|
|
91
|
-
|
|
90
|
+
const todayCost = costs ? costText(costs.total(undefined, 1, Date.now())) : '?';
|
|
91
|
+
// `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
|
|
92
|
+
const coverage = costs?.coverage ?? 'complete';
|
|
92
93
|
const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
|
|
93
94
|
if (!expanded) {
|
|
94
95
|
const selection = record(view.values.modelSelection);
|
|
@@ -103,7 +104,7 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false
|
|
|
103
104
|
const compactCount = (value) => value === undefined ? '?' : new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value);
|
|
104
105
|
const activity = running ? `Working ${since === undefined ? '?' : elapsedTime(now - since)}${state.transcript.activeTurnStartedAt === undefined ? '~' : ''}` : 'Idle';
|
|
105
106
|
const fields = [
|
|
106
|
-
`${!state.online ? 'Offline · ' : ''}${state.controlError || state.modelError ? '! ' : ''}${activity}`,
|
|
107
|
+
`${!state.online ? 'Offline · ' : ''}${state.controlError || state.modelError || coverage === 'partial' ? '! ' : ''}${activity}`,
|
|
107
108
|
model, `ws: ${workspace ? workspace.title : '—'}`,
|
|
108
109
|
`ctx: ${used !== undefined && capacity !== undefined && capacity > 0 ? `~${Math.min(100, Math.round(used / capacity * 100))}%` : '?'}`,
|
|
109
110
|
`tok: ${compactCount(total)}`,
|
|
@@ -118,7 +119,7 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false
|
|
|
118
119
|
}
|
|
119
120
|
return _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { color: running ? 'yellow' : 'gray', children: [running
|
|
120
121
|
? `Working ${since === undefined ? 'unknown duration' : elapsedTime(now - since)}${state.transcript.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Esc / Ctrl+C stop`
|
|
121
|
-
: 'Idle · Ctrl+C exit', !state.online ? ' · disconnected, last known status' : ''] }), state.sessionId && _jsxs(Text, { children: ["Session ID: ", safeText(state.sessionId)] }), _jsxs(Text, { wrap: "truncate-end", children: ["Workspace: ", safeText(label)] }), metricLines(view.values, state.defaultModel, running).map((line, index) => _jsx(Text, { dimColor: true, children: safeText(line) }, index)), costs && _jsxs(Text, { dimColor: true, children: ["Cost (CNY estimate): Session ", sessionCost, " \u00B7 Today ", todayCost] }), _jsxs(Text, { dimColor: true, children: ["Queued: ", count(view.queued), " \u00B7 Active jobs: ", count(view.jobs)] }), state.controlError && _jsx(Text, { color: "yellow", children: safeText(state.controlError) }), state.modelError && _jsxs(Text, { color: "yellow", children: ["Model catalog unavailable: ", safeText(state.modelError)] })] });
|
|
122
|
+
: 'Idle · Ctrl+C exit', !state.online ? ' · disconnected, last known status' : ''] }), state.sessionId && _jsxs(Text, { children: ["Session ID: ", safeText(state.sessionId)] }), _jsxs(Text, { wrap: "truncate-end", children: ["Workspace: ", safeText(label)] }), metricLines(view.values, state.defaultModel, running).map((line, index) => _jsx(Text, { dimColor: true, children: safeText(line) }, index)), costs && _jsxs(Text, { dimColor: true, children: ["Cost (CNY estimate): Session ", sessionCost, " \u00B7 Today ", todayCost] }), costs && coverage === 'partial' && _jsxs(Text, { color: "yellow", children: ["Cost coverage incomplete: ", costs.error ? safeText(costs.error) : 'no complete scan yet'] }), _jsxs(Text, { dimColor: true, children: ["Queued: ", count(view.queued), " \u00B7 Active jobs: ", count(view.jobs)] }), state.controlError && _jsx(Text, { color: "yellow", children: safeText(state.controlError) }), state.modelError && _jsxs(Text, { color: "yellow", children: ["Model catalog unavailable: ", safeText(state.modelError)] })] });
|
|
122
123
|
});
|
|
123
124
|
function record(value) {
|
|
124
125
|
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itookit/dsht",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Remote-first TUI for DeepSeek Harness with SSH-friendly mobile access and request-level cost tracking",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22.19"
|
|
8
8
|
},
|
|
@@ -40,12 +40,19 @@
|
|
|
40
40
|
"deepseek-harness",
|
|
41
41
|
"tui",
|
|
42
42
|
"terminal",
|
|
43
|
-
"
|
|
43
|
+
"terminal-ui",
|
|
44
|
+
"remote",
|
|
45
|
+
"ssh",
|
|
46
|
+
"mobile",
|
|
47
|
+
"jump-host",
|
|
48
|
+
"bastion",
|
|
49
|
+
"ai-agent",
|
|
50
|
+
"coding-agent",
|
|
51
|
+
"cost-tracking",
|
|
52
|
+
"cost-control",
|
|
53
|
+
"token-usage",
|
|
44
54
|
"cli",
|
|
45
|
-
"
|
|
46
|
-
"http",
|
|
47
|
-
"websocket",
|
|
48
|
-
"cost"
|
|
55
|
+
"websocket"
|
|
49
56
|
],
|
|
50
57
|
"author": "lizlok@gmail.com",
|
|
51
58
|
"license": "MIT",
|