@itookit/dsht 0.1.0 → 0.2.1

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 CHANGED
@@ -1,3 +1,3 @@
1
1
  # Git blob hashes of the reviewed bilingual pair.
2
- README.md: 488c226ae1a27be9130f546072de861dc93edaa0
3
- README.zh.md: b892b5901b523231282c5a8b1b57154533d0771b
2
+ README.md: 2ee216f6c15212f4025b5a6f893b721f57b0a89f
3
+ README.zh.md: 7d2db96e581c08759e446a18966ead68458980f2
package/README.md CHANGED
@@ -4,22 +4,132 @@ English | [中文](README.zh.md)
4
4
 
5
5
  ![DeepSeek Harness Terminal (dsht)](dsht.png)
6
6
 
7
+ > **dsht — Control DeepSeek Harness from any terminal, anywhere.**
8
+
7
9
  ## Summary
8
10
 
9
- Choose a workspace and session, chat with a running DeepSeek Harness host, and inspect session history from your terminal. This is an independent Node.js repository: it has its own Git history, dependencies, and tests, and imports no Harness packages.
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 and success/failure status, and paged conversation history.
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
- - Session and daily CNY cost estimates, versioned peak/off-peak prices, and `/cost` summaries.
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’s inherited access controls. Launch tokens are never saved.
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` exits directly without cancelling remote work. Cancellation leaves pending queue items intact.
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’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.
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 whose 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.
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’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.
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
- `/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, and `*` marks unpriced requests or incomplete coverage. Each host origin has a separate ledger. Totals cover HTTP-visible sessions and previously cached sessions; they are not account-wide provider bills.
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. Missing timestamps, inconsistent usage and unknown prices remain unpriced. Failed scans retain labelled partial cached totals.
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 ¥3/¥0.10/¥9 per million tokens; Pro rates are ¥9/¥0.30/¥27. 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.
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
- 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.
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. Every scan reprices stored requests from the current configuration, so correcting a price version also corrects earlier totals; a request that no entry covered is priced once an entry covers its settlement date.
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’s starting sequence for pagination.
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.1.0` |
380
+ | Name and version | `@itookit/dsht` `0.2.1` |
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 `0.1.0` is published by hand; after that, `npm version 0.1.1 && git push --follow-tags` releases.
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 earliest versions were published by hand; a later release pushes the matching tag, for example `npm version 0.2.2 && git push --follow-tags`.
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
  ![DeepSeek Harness Terminal(dsht)](dsht.png)
6
6
 
7
+ > **dsht — 只要有终端,就能随时控制 DeepSeek Harness。**
8
+
7
9
  ## 摘要
8
10
 
9
- 在终端中选择工作区和会话,与运行中的 DeepSeek Harness 服务对话,并查看会话历史。这是独立的 Node.js 仓库,拥有自己的 Git 历史、依赖和测试,不导入 Harness 内部包。
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
- - 会话与今日人民币费用估算、按版本保存的高峰/空闲价格,以及 `/cost` 汇总。
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` 加载更早记录。`/quit` 直接退出,不取消远程任务。取消当前任务会保留排队消息。
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 命令在选择器和对话输入框中均可使用。输入 `/` 会显示匹配命令。长命令 `/workspace`、`/workspaces`、`/session`、`/sessions` 保留为别名。名称可以包含空格,完整目标两侧的引号可选。不带引号的目标 `all` 保留给 `/s all`;打开标题为 `all` 的会话时,使用 `/s "all"` 或其 ID。目标有歧义时必须提供完整 ID。切换工作区会打开其会话列表并解除旧对话订阅;切换会话会同步工作区标签。两种操作均不会取消远程代理。
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
- `/cost` 显示当前会话、今日及今日加前两个自然日的费用。日期使用 Asia/Shanghai,三日统计不是滚动 72 小时。状态栏中 `S:` 表示会话费用,`D:` 表示今日费用。`~` 表示估算,`*` 表示存在未计价请求或统计尚不完整。每个服务端 origin 使用独立账本;总额覆盖 HTTP 可见会话及之前缓存的会话,不是供应商账户级账单。
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 高峰未命中输入/缓存命中/输出为每百万 token ¥3/¥0.10/¥9,Pro 为 ¥9/¥0.30/¥27。单列的缓存写入按未命中输入价计算。配置中的精确模型价格优先;否则 `deepseek-official` 模型名包含 `pro`(不区分大小写)时按 Pro 计价,其余名称包括临时别名均按 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
- 默认价格有效期从核对日期的北京时间零点开始,这是本地估算规则,不代表官方价格生效日期。更早用量需要补充历史价格版本。程序按助手请求结算记录的时间选择单价;官方未说明跨时段请求的归属,因此边界附近的估算可能与账单不同。图片使用供应商报告的 token 数。已计价请求保留原价格版本,不随配置修改重新套价;未计价请求可以在后续扫描时补算。
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.1.0` |
380
+ | 名称与版本 | `@itookit/dsht` `0.2.1` |
173
381
  | 可执行命令 | `dsht`,不安装时用 `npx @itookit/dsht` |
174
382
  | 库入口 | `@itookit/dsht` 和 `@itookit/dsht/auth` |
175
- | 作者 | lizlok@gmail.com |
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 无法创建包,因此 `0.1.0` 需手工发布;之后执行 `npm version 0.1.1 && git push --follow-tags` 即可发布。
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 无法创建包,因此最早的版本需手工发布;之后的版本推送对应 tag 即可发布,例如 `npm version 0.2.2 && 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.stop();
104
+ await controller.shutdown();
105
105
  }
106
106
  }
107
107
  main().catch(error => { process.stderr.write(`${errorText(error)}\n`); process.exitCode = 1; });
@@ -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.
@@ -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…' : costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}` : 'Partial cached totals · awaiting complete scan' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means incomplete. 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))] });
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 always retains the number of unpriced records alongside the known subtotal. */
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 unpriced count; undated records are unpriced in every date range.
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 contains unpriced records.
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
- export const DEFAULT_PRICES = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp'].map(model => {
9
- const scale = model === 'deepseek-v4-pro' ? 3 : 1;
10
- return { id: `deepseek-2026-09-10-${model}`, provider: 'deepseek-official', model,
11
- from: '2026-09-10T00:00:00+08:00', currency: 'CNY', source: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/', timezone: 'Asia/Shanghai',
12
- peak: { input: 3 * scale, cacheRead: 0.1 * scale, cacheWrite: 3 * scale, output: 9 * scale },
13
- offPeak: { input: 1.5 * scale, cacheRead: 0.05 * scale, cacheWrite: 1.5 * scale, output: 4.5 * scale },
14
- weekdays: [1, 2, 3, 4, 5], windows: [[540, 720], [840, 1080]] };
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 active = (p) => p.provider === provider && Date.parse(p.from) <= time && (p.until === undefined || time < Date.parse(p.until));
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');
@@ -151,7 +196,6 @@ export class CostLedger {
151
196
  async replace(sessionId, cut, events) {
152
197
  if ((this.sessions.get(sessionId)?.cut ?? -2) > cut)
153
198
  return;
154
- const old = new Map(this.sessions.get(sessionId)?.charges.map(c => [c.key, c]));
155
199
  const charges = [];
156
200
  const inheritedCut = Math.max(-1, ...events.filter(e => e.type === 'session/end-seed' && object(e.data).inherited === true).map(e => Number(e.seq)));
157
201
  let route = {};
@@ -182,18 +226,14 @@ export class CostLedger {
182
226
  const key = charges[index]?.key ?? String(e.seq);
183
227
  if (!usage && charges[index]?.usage)
184
228
  continue;
185
- const previous = old.get(key);
186
- if (previous?.amount !== undefined && previous.time === time && previous.provider === provider && previous.model === model
187
- && usage && previous.usage && Object.keys(usage).every(k => usage[k] === previous.usage[k])) {
188
- charges[index] = previous;
189
- last = { turn: d.turn, step: d.step, index };
190
- continue;
191
- }
192
- const selected = time === undefined ? undefined : priceAt(previous?.price && previous.provider === provider && previous.model === model ? [previous.price] : this.prices, provider, model, time);
229
+ // Every scan reprices from the current configuration, so correcting prices.json updates
230
+ // stored totals instead of leaving the version an earlier scan happened to apply.
231
+ const selected = time === undefined ? lowestPrice(this.prices, provider, model) : priceAt(this.prices, provider, model, time);
193
232
  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
233
  const amount = estimate !== undefined && Number.isFinite(estimate) ? estimate : undefined;
195
234
  charges[index] = { key, time, provider, model, usage, price: selected?.price, amount,
196
- reason: !usage ? 'missing usage' : time === undefined ? 'missing timestamp' : !selected ? 'no price version' : amount === undefined ? 'invalid estimate' : undefined };
235
+ ...(time === undefined && amount !== undefined ? { estimated: true } : {}),
236
+ reason: !usage ? 'missing usage' : time === undefined ? 'missing timestamp, floor rate' : !selected ? 'no price version' : amount === undefined ? 'invalid estimate' : undefined };
197
237
  last = { turn: d.turn, step: d.step, index };
198
238
  }
199
239
  const saved = { version: 1, sessionId, cut, charges };
@@ -233,14 +273,15 @@ export class CostLedger {
233
273
  * @param sessionId - Optional session restriction.
234
274
  * @param days - Today or today plus the preceding two calendar days.
235
275
  * @param now - Clock used for date attribution.
236
- * @returns Known subtotal and unpriced count; undated records are unpriced in every date range.
276
+ * @returns Known subtotal, unpriceable count, and estimated count; an estimated record always
277
+ * names an amount, but a dated range only adds the records it can place inside that range.
237
278
  */
238
279
  total(sessionId, days, now = Date.now()) {
239
280
  const cacheKey = JSON.stringify([sessionId, days, days ? costDay(now) : '']);
240
281
  const cached = this.totals.get(cacheKey);
241
282
  if (cached)
242
283
  return cached;
243
- const result = { amount: 0, unknown: 0, records: 0 };
284
+ const result = { amount: 0, unknown: 0, estimated: 0, records: 0 };
244
285
  const end = costDay(now);
245
286
  const start = costDay(now - ((days ?? 1) - 1) * 86400_000);
246
287
  for (const session of this.sessions.values()) {
@@ -250,9 +291,14 @@ export class CostLedger {
250
291
  if (days && charge.time !== undefined && (costDay(charge.time) < start || costDay(charge.time) > end))
251
292
  continue;
252
293
  result.records++;
253
- if (charge.amount === undefined || days && charge.time === undefined)
294
+ if (charge.amount === undefined) {
254
295
  result.unknown++;
255
- else
296
+ continue;
297
+ }
298
+ const dated = days === undefined || charge.time !== undefined;
299
+ if (charge.estimated === true || !dated)
300
+ result.estimated++;
301
+ if (dated)
256
302
  result.amount += charge.amount;
257
303
  }
258
304
  }
@@ -260,8 +306,8 @@ export class CostLedger {
260
306
  return result;
261
307
  }
262
308
  }
263
- /** Compact estimates retain an asterisk whenever a subtotal contains unpriced records.
309
+ /** Compact estimates retain an asterisk whenever a subtotal is not exact.
264
310
  * @param total - Summary from the ledger.
265
311
  * @returns Yuan amount and incompleteness marker.
266
312
  */
267
- export function costText(total) { return `~¥${total.amount.toFixed(4)}${total.unknown ? '*' : ''}`; }
313
+ 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 todayEstimate = costs ? costText(costs.total(undefined, 1, Date.now())) : '?';
91
- const todayCost = costs && (!costs.scannedAt || costs.error) && !todayEstimate.endsWith('*') ? todayEstimate + '*' : todayEstimate;
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.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
- "description": "Standalone terminal client for the DeepSeek Harness HTTP gateway",
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
- "ink",
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
- "client",
46
- "http",
47
- "websocket",
48
- "cost"
55
+ "websocket"
49
56
  ],
50
57
  "author": "lizlok@gmail.com",
51
58
  "license": "MIT",