@toddzheng024/dscode-bundle 0.7.0 → 0.7.2

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/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.0",
2
+ "version": "0.7.2",
3
3
  "type": "module",
4
4
  "license": "MIT",
5
5
  "author": "Todd Zheng",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "https://github.com/qiz029/dscode.git"
14
+ "url": "https://github.com/qiz029/dscode"
15
15
  },
16
16
  "name": "@toddzheng024/dscode-bundle",
17
17
  "description": "DSCODE coding harness: minimal persistent shell, Ultra subagents, auto review, Chrome, computer use and session telemetry.",
@@ -1,6 +1,6 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { BlockAssembler, createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
3
- import { REVIEW_POLICY, needsMcpApproval, redact, fingerprint, contextFor, parseDecision } from './policy.mjs';
3
+ import { REVIEW_POLICY, escalationDiagnosticGrant, needsMcpApproval, redact, fingerprint, contextFor, parseDecision } from './policy.mjs';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { auditStore } from './audit.mjs';
6
6
 
@@ -11,6 +11,7 @@ export const Config = z.object({
11
11
  timeoutMs: z.number().min(100).max(120000).default(30000),
12
12
  maxOutputTokens: z.number().step(1).min(128).max(2048).default(768),
13
13
  maxReviewsPerTurn: z.number().step(1).min(1).max(100).default(20),
14
+ maxEscalationGrantsPerTurn: z.number().step(1).min(0).max(10).default(2),
14
15
  auditDirectory: z.string(),
15
16
  });
16
17
 
@@ -26,7 +27,7 @@ export function apply(ctx, config) {
26
27
  const turn = events.findLast(e => e.type === 'turn/start')?.seq;
27
28
  let state = budgets.get(agent);
28
29
  if (!state || state.turn !== turn) {
29
- state = { turn, reviews: 0, denials: 0, blocked: false, denied: new Map(), tail: Promise.resolve() };
30
+ state = { turn, reviews: 0, grants: 0, denials: 0, blocked: false, denied: new Map(), tail: Promise.resolve() };
30
31
  budgets.set(agent, state);
31
32
  }
32
33
  return state;
@@ -164,7 +165,24 @@ export function apply(ctx, config) {
164
165
 
165
166
  ctx.on('approval/request', (req, next) => {
166
167
  // OS/application access and sensitive Computer Use confirmations remain human.
167
- if (mode(req.agent) !== 'auto' || req.toolName.startsWith('computer_')) return next();
168
+ if (req.toolName.startsWith('computer_')) return next();
169
+ // Sandbox escalation: only a single, unquoted read-only diagnostic may leave the
170
+ // sandbox without a human. The grant binds to the exact pending arguments, is
171
+ // budgeted per turn, and is audited; anything else keeps asking the user.
172
+ const escalating = calls.get(req.agent)?.get(req.callId);
173
+ const grant = escalating === undefined ? undefined : escalationDiagnosticGrant(escalating.name, escalating.arguments);
174
+ if (grant !== undefined && ctx.approval?.effectivePolicy?.(req.agent.session) !== 'never') {
175
+ const state = stateFor(req.agent);
176
+ const grantBudget = config.maxEscalationGrantsPerTurn ?? 2;
177
+ if (state.grants < grantBudget) {
178
+ state.grants++;
179
+ record(req, { decision: 'allowed-once', policy: 'escalation-allowlist', reason: `Read-only diagnostic escalation: ${grant.argv[0]}`, actionHash: fingerprint({ tool: escalating.name, arguments: escalating.arguments }) });
180
+ announce(req.agent, `Escalation granted once for the read-only diagnostic \`${grant.command}\`.`);
181
+ return 'allowed-once';
182
+ }
183
+ announce(req.agent, `Escalation budget reached (${grantBudget} per turn); asking the user.`);
184
+ }
185
+ if (mode(req.agent) !== 'auto') return next();
168
186
  const state = stateFor(req.agent);
169
187
  // Serialize per agent so simultaneous calls cannot race the denial budget.
170
188
  const pending = state.tail.then(() => review(req, next, state));
@@ -57,3 +57,28 @@ export function parseDecision(text) {
57
57
  Object.keys(value).some(key => !['decision', 'reason'].includes(key))) throw new Error('Invalid reviewer response');
58
58
  return { decision: value.decision, reason: redact(value.reason) };
59
59
  }
60
+
61
+ /**
62
+ * Read-only diagnostics that cannot change state: the only escalation class the
63
+ * agent may grant itself, one exact argv at a time. Everything else stays human.
64
+ */
65
+ const ESCALATION_DIAGNOSTICS = new Set(['ps', 'lsof', 'pgrep', 'sw_vers', 'uname', 'id', 'date', 'hostname', 'pwd', 'sysctl']);
66
+ // Quotes, substitution, redirects and chaining all mean the command can do more
67
+ // than the diagnostic whose name it starts with.
68
+ const SHELL_META = /[;&|<>`$(){}\[\]\n\\'"]/;
69
+
70
+ /**
71
+ * Match one pending escalation against the read-only diagnostic allowlist.
72
+ * @param toolName - Pending tool name.
73
+ * @param args - Its exact arguments (the caller binds the grant to these).
74
+ * @returns The matched command, or undefined when a human must decide.
75
+ */
76
+ export function escalationDiagnosticGrant(toolName, args) {
77
+ if (toolName !== 'shell_retry') return undefined;
78
+ if (typeof args?.sandbox_permissions !== 'string' || args.sandbox_permissions.length === 0) return undefined;
79
+ const command = typeof args?.command === 'string' ? args.command.trim() : '';
80
+ if (command.length === 0 || command.length > 200 || SHELL_META.test(command)) return undefined;
81
+ const argv = command.split(/\s+/);
82
+ if (!ESCALATION_DIAGNOSTICS.has(argv[0])) return undefined;
83
+ return { command, argv };
84
+ }
@@ -30,13 +30,13 @@ export const MESSAGES = {
30
30
  'agents.running': 'running', 'agents.idle': 'idle', 'agents.done': 'done', 'agents.total': 'total',
31
31
  'welcome.model': 'model', 'welcome.effort': 'effort', 'welcome.project': 'project',
32
32
  'verbose.on': 'verbose on: thinking and tool calls are shown in the chat', 'verbose.off': 'verbose off',
33
- 'mouse.on': 'mouse on: the wheel scrolls the chat · hold Option (iTerm2) or Fn (Terminal) while dragging to select text',
33
+ 'mouse.on': 'mouse on: the wheel scrolls the chat · hold Shift while dragging to select text (Option in iTerm2, Fn in Terminal)',
34
34
  'mouse.off': 'mouse off: select and copy freely · PageUp/PageDown scroll the chat · /mouse turns wheel scrolling on',
35
35
  'language.current': 'Language: {name} · /language en | zh-CN | zh-TW | ja | ko | es',
36
36
  'language.set': 'language → {name}', 'language.title': '/language — interface language', 'language.currentMark': 'current', 'language.unknown': 'Unknown language "{value}". Choose en, zh-CN, zh-TW, ja, ko or es.',
37
37
  'language.saveFailed': 'language save failed: {error}',
38
38
  'doctor.logs.new': 'Only warnings/errors since this TUI version started are recorded.',
39
- 'footer.current': 'current', 'footer.average': 'average', 'footer.context': 'context', 'footer.cache': 'cache', 'composer.placeholder': 'type a message',
39
+ 'footer.current': 'current', 'footer.average': 'average', 'footer.context': 'context', 'footer.cache': 'cache hit', 'composer.placeholder': 'type a message',
40
40
  'doctor.nearTimeout': 'Assessment: {count} Bash calls ended at about 300 seconds, which matches the tool timeout; traces alone cannot prove the root cause. Next, check those calls\' shell completion markers and terminal errors.',
41
41
  'doctor.logs.none': 'No log file yet; earlier console logs were not persisted and cannot be recovered.',
42
42
  'doctor.evidence': 'Diagnostic evidence: {traces} recent sessions, {logs} warnings/errors.',
@@ -51,13 +51,13 @@ export const MESSAGES = {
51
51
  'agents.running': '运行中', 'agents.idle': '空闲', 'agents.done': '已完成', 'agents.total': '总计',
52
52
  'welcome.model': '模型', 'welcome.effort': '推理', 'welcome.project': '项目',
53
53
  'verbose.on': '详细模式已开启:对话中显示思考与工具调用', 'verbose.off': '详细模式已关闭',
54
- 'mouse.on': '鼠标捕获已开启:滚轮滚动对话 · 按住 Option(iTerm2)或 Fn(Terminal)拖选文本',
54
+ 'mouse.on': '鼠标捕获已开启:滚轮滚动对话 · 拖选文本时按住 Shift(iTerm2 按住 Option、Terminal 按住 Fn',
55
55
  'mouse.off': '鼠标捕获已关闭:可自由选择复制 · PageUp/PageDown 滚动对话 · /mouse 重新开启',
56
56
  'language.current': '语言:{name} · /language en | zh-CN | zh-TW | ja | ko | es',
57
57
  'language.set': '语言 → {name}', 'language.title': '/language — 界面语言', 'language.currentMark': '当前', 'language.unknown': '未知语言 "{value}"。可选 en、zh-CN、zh-TW、ja、ko、es。',
58
58
  'language.saveFailed': '语言设置保存失败:{error}',
59
59
  'doctor.logs.new': '仅记录新版 TUI 启动后的 warning/error。',
60
- 'footer.current': '当前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '缓存', 'composer.placeholder': '输入消息',
60
+ 'footer.current': '当前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '缓存命中', 'composer.placeholder': '输入消息',
61
61
  'doctor.nearTimeout': '判断:{count} 次 Bash 调用在约 300 秒结束,符合工具超时特征;trace 不能单独证明触发超时的根因。下一步检查这些调用的 shell 完成标记和终端错误。',
62
62
  'doctor.logs.none': '日志文件尚未建立;旧版控制台日志未持久化,无法回溯。',
63
63
  'doctor.evidence': '诊断证据:{traces} 个近期会话、{logs} 条 warning/error。',
@@ -72,13 +72,13 @@ export const MESSAGES = {
72
72
  'agents.running': '執行中', 'agents.idle': '閒置', 'agents.done': '已完成', 'agents.total': '總計',
73
73
  'welcome.model': '模型', 'welcome.effort': '推理', 'welcome.project': '專案',
74
74
  'verbose.on': '詳細模式已開啟:對話中顯示思考與工具呼叫', 'verbose.off': '詳細模式已關閉',
75
- 'mouse.on': '滑鼠擷取已開啟:滾輪捲動對話 · 按住 Option(iTerm2)或 Fn(Terminal)拖選文字',
75
+ 'mouse.on': '滑鼠擷取已開啟:滾輪捲動對話 · 拖選文字時按住 Shift(iTerm2 按住 Option、Terminal 按住 Fn',
76
76
  'mouse.off': '滑鼠擷取已關閉:可自由選取複製 · PageUp/PageDown 捲動對話 · /mouse 重新開啟',
77
77
  'language.current': '語言:{name} · /language en | zh-CN | zh-TW | ja | ko | es',
78
78
  'language.set': '語言 → {name}', 'language.title': '/language — 介面語言', 'language.currentMark': '目前', 'language.unknown': '未知語言 "{value}"。可選 en、zh-CN、zh-TW、ja、ko、es。',
79
79
  'language.saveFailed': '語言設定儲存失敗:{error}',
80
80
  'doctor.logs.new': '僅記錄新版 TUI 啟動後的 warning/error。',
81
- 'footer.current': '目前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '快取', 'composer.placeholder': '輸入訊息',
81
+ 'footer.current': '目前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '快取命中', 'composer.placeholder': '輸入訊息',
82
82
  'doctor.nearTimeout': '判斷:{count} 次 Bash 呼叫在約 300 秒結束,符合工具逾時特徵;trace 無法單獨證明觸發逾時的根因。下一步檢查這些呼叫的 shell 完成標記和終端錯誤。',
83
83
  'doctor.logs.none': '日誌檔尚未建立;舊版主控台日誌未持久化,無法回溯。',
84
84
  'doctor.evidence': '診斷證據:{traces} 個近期工作階段、{logs} 筆 warning/error。',
@@ -93,13 +93,13 @@ export const MESSAGES = {
93
93
  'agents.running': '実行中', 'agents.idle': '待機中', 'agents.done': '完了', 'agents.total': '合計',
94
94
  'welcome.model': 'モデル', 'welcome.effort': '推論', 'welcome.project': 'プロジェクト',
95
95
  'verbose.on': '詳細モード オン:思考とツール呼び出しをチャットに表示します', 'verbose.off': '詳細モード オフ',
96
- 'mouse.on': 'マウス キャプチャ オン:ホイールでチャットをスクロール · Option(iTerm2)または Fn(Terminal)を押しながらドラッグでテキストを選択',
96
+ 'mouse.on': 'マウス キャプチャ オン:ホイールでチャットをスクロール · Shift(iTerm2 は Option、Terminal は Fn)を押しながらドラッグでテキスト選択',
97
97
  'mouse.off': 'マウス キャプチャ オフ:自由に選択・コピーできます · PageUp/PageDown でスクロール · /mouse で再びオン',
98
98
  'language.current': '言語:{name} · /language en | zh-CN | zh-TW | ja | ko | es',
99
99
  'language.set': '言語 → {name}', 'language.title': '/language — 表示言語', 'language.currentMark': '現在', 'language.unknown': '不明な言語 "{value}"。en、zh-CN、zh-TW、ja、ko、es から選んでください。',
100
100
  'language.saveFailed': '言語設定の保存に失敗しました:{error}',
101
101
  'doctor.logs.new': 'この TUI バージョンの起動以降の warning/error のみ記録されています。',
102
- 'footer.current': '現在', 'footer.average': '平均', 'footer.context': 'コンテキスト', 'footer.cache': 'キャッシュ', 'composer.placeholder': 'メッセージを入力',
102
+ 'footer.current': '現在', 'footer.average': '平均', 'footer.context': 'コンテキスト', 'footer.cache': 'キャッシュヒット', 'composer.placeholder': 'メッセージを入力',
103
103
  'doctor.nearTimeout': '判断:Bash 呼び出し {count} 件が約 300 秒で終了しており、ツールのタイムアウトの特徴に一致します。トレースだけでは根本原因を証明できません。次はこれらの呼び出しのシェル完了マーカーと端末エラーを確認してください。',
104
104
  'doctor.logs.none': 'ログファイルはまだありません。以前のコンソールログは保存されておらず、遡れません。',
105
105
  'doctor.evidence': '診断の根拠:直近のセッション {traces} 件、warning/error {logs} 件。',
@@ -114,13 +114,13 @@ export const MESSAGES = {
114
114
  'agents.running': '실행 중', 'agents.idle': '대기', 'agents.done': '완료', 'agents.total': '전체',
115
115
  'welcome.model': '모델', 'welcome.effort': '추론', 'welcome.project': '프로젝트',
116
116
  'verbose.on': '상세 모드 켜짐: 생각과 도구 호출을 채팅에 표시합니다', 'verbose.off': '상세 모드 꺼짐',
117
- 'mouse.on': '마우스 캡처 켜짐: 휠로 채팅 스크롤 · Option(iTerm2) 또는 Fn(Terminal) 누른 채 드래그하여 텍스트 선택',
117
+ 'mouse.on': '마우스 캡처 켜짐: 휠로 채팅 스크롤 · Shift(iTerm2 Option, Terminal은 Fn) 누른 채 드래그하여 텍스트 선택',
118
118
  'mouse.off': '마우스 캡처 꺼짐: 자유롭게 선택·복사 · PageUp/PageDown으로 채팅 스크롤 · /mouse로 다시 켜기',
119
119
  'language.current': '언어: {name} · /language en | zh-CN | zh-TW | ja | ko | es',
120
120
  'language.set': '언어 → {name}', 'language.title': '/language — 인터페이스 언어', 'language.currentMark': '현재', 'language.unknown': '알 수 없는 언어 "{value}". en, zh-CN, zh-TW, ja, ko, es 중에서 선택하세요.',
121
121
  'language.saveFailed': '언어 설정 저장 실패: {error}',
122
122
  'doctor.logs.new': '이 TUI 버전 시작 이후의 warning/error만 기록됩니다.',
123
- 'footer.current': '현재', 'footer.average': '평균', 'footer.context': '컨텍스트', 'footer.cache': '캐시', 'composer.placeholder': '메시지를 입력하세요',
123
+ 'footer.current': '현재', 'footer.average': '평균', 'footer.context': '컨텍스트', 'footer.cache': '캐시 적중', 'composer.placeholder': '메시지를 입력하세요',
124
124
  'doctor.nearTimeout': '판단: Bash 호출 {count}건이 약 300초에 종료되어 도구 시간 초과 특징과 일치합니다. 트레이스만으로는 근본 원인을 증명할 수 없습니다. 다음으로 해당 호출의 셸 완료 표시와 터미널 오류를 확인하세요.',
125
125
  'doctor.logs.none': '아직 로그 파일이 없습니다. 이전 콘솔 로그는 저장되지 않아 복구할 수 없습니다.',
126
126
  'doctor.evidence': '진단 근거: 최근 세션 {traces}개, warning/error {logs}건.',
@@ -135,13 +135,13 @@ export const MESSAGES = {
135
135
  'agents.running': 'en ejecución', 'agents.idle': 'inactivo', 'agents.done': 'terminado', 'agents.total': 'en total',
136
136
  'welcome.model': 'modelo', 'welcome.effort': 'esfuerzo', 'welcome.project': 'proyecto',
137
137
  'verbose.on': 'modo detallado activado: el razonamiento y las llamadas a herramientas se muestran en el chat', 'verbose.off': 'modo detallado desactivado',
138
- 'mouse.on': 'ratón activado: la rueda desplaza el chat · mantén Option (iTerm2) o Fn (Terminal) al arrastrar para seleccionar texto',
138
+ 'mouse.on': 'ratón activado: la rueda desplaza el chat · mantén Shift al arrastrar para seleccionar texto (Option en iTerm2, Fn en Terminal)',
139
139
  'mouse.off': 'ratón desactivado: selecciona y copia libremente · PageUp/PageDown desplazan el chat · /mouse vuelve a activar la captura',
140
140
  'language.current': 'Idioma: {name} · /language en | zh-CN | zh-TW | ja | ko | es',
141
141
  'language.set': 'idioma → {name}', 'language.title': '/language — idioma de la interfaz', 'language.currentMark': 'actual', 'language.unknown': 'Idioma desconocido "{value}". Elige en, zh-CN, zh-TW, ja, ko o es.',
142
142
  'language.saveFailed': 'no se pudo guardar el idioma: {error}',
143
143
  'doctor.logs.new': 'Solo se registran los warnings/errores desde que arrancó esta versión del TUI.',
144
- 'footer.current': 'actual', 'footer.average': 'promedio', 'footer.context': 'contexto', 'footer.cache': 'caché', 'composer.placeholder': 'escribe un mensaje',
144
+ 'footer.current': 'actual', 'footer.average': 'promedio', 'footer.context': 'contexto', 'footer.cache': 'éxitos de caché', 'composer.placeholder': 'escribe un mensaje',
145
145
  'doctor.nearTimeout': 'Valoración: {count} llamadas a Bash terminaron a unos 300 segundos, lo que coincide con el tiempo de espera de la herramienta; las trazas por sí solas no prueban la causa raíz. A continuación, revisa los marcadores de finalización del shell y los errores de terminal de esas llamadas.',
146
146
  'doctor.logs.none': 'Aún no hay archivo de registro; los registros de consola anteriores no se conservaron y no se pueden recuperar.',
147
147
  'doctor.evidence': 'Evidencia del diagnóstico: {traces} sesiones recientes, {logs} warnings/errores.',
@@ -0,0 +1,52 @@
1
+ // Remaining DeepSeek balance, refreshed on a long cache, plus the trusted clock
2
+ // that comes with it: the same request's `Date` header anchors peak/off-peak
3
+ // pricing without a second network call.
4
+ const BALANCE_URL = 'https://api.deepseek.com/user/balance';
5
+ const CACHE_MS = 5 * 60 * 1000;
6
+ const RETRY_MS = 60 * 1000;
7
+ let snapshot = { balance: null, fetchedAt: 0, clock: null, skewMs: 0, pending: false };
8
+
9
+ /** Best-effort positive USD balance from a `/user/balance` body. */
10
+ export function parseBalance(body) {
11
+ const infos = Array.isArray(body?.balance_infos) ? body.balance_infos : [];
12
+ const usd = infos.find(info => String(info?.currency ?? '').toUpperCase() === 'USD') ?? infos[0];
13
+ const total = Number(usd?.total_balance);
14
+ if (!Number.isFinite(total) || total < 0) return null;
15
+ return body?.is_available === false ? null : total;
16
+ }
17
+
18
+ export function balanceNow() { return snapshot.balance; }
19
+ /** Clock anchored to the last balance response's `Date` header, else the local one. */
20
+ export function trustedNow() {
21
+ const local = Date.now();
22
+ return snapshot.clock === null ? local : local + snapshot.skewMs;
23
+ }
24
+
25
+ /** Refresh at most once per cache window; never throws into the render path. */
26
+ export async function refreshBalance(options = {}) {
27
+ const now = Date.now();
28
+ if (snapshot.pending || now - snapshot.fetchedAt < CACHE_MS) return snapshot.balance;
29
+ const key = options.key ?? process.env.DEEPSEEK_API_KEY;
30
+ if (!key) { snapshot = { ...snapshot, fetchedAt: now - (CACHE_MS - RETRY_MS), pending: false }; return snapshot.balance; }
31
+ const fetchImpl = options.fetch ?? globalThis.fetch;
32
+ if (typeof fetchImpl !== 'function') return snapshot.balance;
33
+ snapshot.pending = true;
34
+ try {
35
+ const response = await fetchImpl(BALANCE_URL, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' } });
36
+ const header = response.headers?.get?.('date');
37
+ const anchor = header ? Date.parse(header) : NaN;
38
+ const body = await response.json();
39
+ const parsed = response.ok ? parseBalance(body) : null;
40
+ snapshot = {
41
+ balance: parsed === null && response.ok ? null : parsed ?? snapshot.balance,
42
+ fetchedAt: parsed === null ? Date.now() - (CACHE_MS - RETRY_MS) : Date.now(),
43
+ clock: Number.isFinite(anchor) ? anchor : snapshot.clock,
44
+ skewMs: Number.isFinite(anchor) ? anchor - Date.now() : snapshot.skewMs,
45
+ pending: false,
46
+ };
47
+ } catch {
48
+ // A transient failure keeps the last known balance and retries sooner.
49
+ snapshot = { ...snapshot, fetchedAt: Date.now() - (CACHE_MS - RETRY_MS), pending: false };
50
+ }
51
+ return snapshot.balance;
52
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { appendMetric } from './store.mjs';
3
3
  import { estimateCost, PRICE_VERSION } from './pricing.mjs';
4
4
  import { setMetricSource } from './view.mjs';
5
+ import { refreshBalance } from './balance.mjs';
5
6
  import { createWindowRate } from './rate.mjs';
6
7
  export const name = 'dscode-session-metrics';
7
8
  export const inject = ['llm', 'agents', 'tokenMeter', 'sessionProjections'];
@@ -21,6 +22,23 @@ export function apply(ctx) {
21
22
  return { ...value, currentTps: liveRate.get(session) };
22
23
  }));
23
24
  const home = process.env.DSH_HOME;
25
+ // Remaining balance is best-effort decoration: resolve the key lazily (never
26
+ // inject the credentials service, so a missing one cannot fail startup), keep
27
+ // the request on a five-minute cache, and never let it reach the render path.
28
+ const credentials = ctx.get?.('credentials');
29
+ const refresh = async () => {
30
+ try {
31
+ const resolved = await credentials?.resolve?.('DEEPSEEK_API_KEY');
32
+ const key = typeof resolved === 'string' ? resolved : resolved?.value;
33
+ await refreshBalance({ key: key ?? process.env.DEEPSEEK_API_KEY });
34
+ } catch {
35
+ /* balance stays unknown */
36
+ }
37
+ };
38
+ refresh();
39
+ const balanceTimer = setInterval(refresh, 5 * 60 * 1000);
40
+ if (typeof balanceTimer.unref === 'function') balanceTimer.unref();
41
+ ctx.effect(() => () => clearInterval(balanceTimer));
24
42
  const record = (id, entry) => { try { appendMetric(home, id, entry); } catch { ctx.logger.warn('Session cost telemetry could not be saved.'); } };
25
43
  ctx.on('llm/stream', async function* (options, next) {
26
44
  if (!options.sessionId || !home) { yield* next(); return; }
@@ -9,10 +9,24 @@ export function estimateCost(provider, model, usage, time) {
9
9
  const flash = ['deepseek-flash', 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp'].includes(model)
10
10
  || model === 'deepseek-v4-pro' && time >= Date.UTC(2026, 8, 14, 4);
11
11
  if (!flash && model !== 'deepseek-v4-pro') return null;
12
- const date = new Date(time), day = date.getUTCDay(), hour = date.getUTCHours();
13
- const peak = day >= 1 && day <= 5 && (hour >= 1 && hour < 4 || hour >= 6 && hour < 10);
12
+ const peak = isPeak(time);
14
13
  const [read, input, output] = flash ? [0.003, 0.15, 0.6] : [0.022, 0.66, 1.98];
15
14
  const values = [usage.inputTokens, usage.outputTokens, usage.cacheReadTokens ?? 0, usage.cacheWriteTokens ?? 0];
16
15
  if (!values.every(n => Number.isFinite(n) && n >= 0) || values[3] !== 0) return null;
17
16
  return (values[0] * input + values[1] * output + values[2] * read) * (peak ? 2 : 1) / 1e6;
18
17
  }
18
+
19
+ /**
20
+ * Peak/off-peak window in UTC: weekdays 01:00-04:00 and 06:00-10:00.
21
+ * @param time - Unix ms timestamp.
22
+ * @returns Whether that instant bills at the peak rate.
23
+ */
24
+ export function isPeak(time) {
25
+ const date = new Date(time), day = date.getUTCDay(), hour = date.getUTCHours();
26
+ return day >= 1 && day <= 5 && (hour >= 1 && hour < 4 || hour >= 6 && hour < 10);
27
+ }
28
+
29
+ /** Footer marker for the billing window: fire for peak, snowflake for off-peak. */
30
+ export function peakEmoji(time) {
31
+ return isPeak(time) ? '🔥' : '❄️';
32
+ }
@@ -1,6 +1,7 @@
1
1
  import { readMetrics } from './store.mjs';
2
2
  import { t } from '../i18n/messages.mjs';
3
- import { estimateCost } from './pricing.mjs';
3
+ import { estimateCost, peakEmoji } from './pricing.mjs';
4
+ import { balanceNow, trustedNow } from './balance.mjs';
4
5
  import { sessionAverageTps } from './rate.mjs';
5
6
  let source;
6
7
  export function setMetricSource(next) { source = next; return () => { if (source === next) source = undefined; }; }
@@ -37,31 +38,49 @@ export function summarize(rows, events = [], corrupt = false) {
37
38
  }
38
39
  return { cost, unknown, calls, pending, cache: input > 0 && !cacheUnknown ? Math.min(100, hit / input * 100) : null };
39
40
  }
40
- /** Terminal columns of a string: East Asian wide characters (including the separator) take two. */
41
+ /** Terminal columns of a string: East Asian wide characters (such as the cache label's CJK glyphs) take two. */
41
42
  export function displayWidth(text) {
42
43
  let width = 0;
43
- for (const char of text) width += /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(char) ? 2 : 1;
44
+ for (const char of text) width += /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u2600-\u27bf\u{1f300}-\u{1faff}]/u.test(char) ? 2 : 1;
44
45
  return width;
45
46
  }
46
- export function formatFooter(metrics, context, columns = 80, rates, locale = 'en') {
47
+ export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', header = '') {
47
48
  const label = key => t(locale, key);
48
49
  const ctx = Number.isFinite(context) ? `${Math.round(context)}%` : '--';
49
50
  const cache = metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}%`;
50
- const dollars = metrics.unknown && metrics.cost === 0 ? '--' : `~$${metrics.cost.toFixed(metrics.cost < 1 ? 4 : 2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
51
- const parts = rates ? [
51
+ const balance = balanceNow();
52
+ const spend = metrics.unknown && metrics.cost === 0 ? '--' : `$${metrics.cost.toFixed(2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
53
+ const dollars = `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)} ${peakEmoji(trustedNow())}`;
54
+ const base = rates ? [
52
55
  `${label('footer.current')}: ${Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--'} tps`,
53
56
  `${label('footer.average')}: ${Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--'} tps`,
54
- `${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')} ${cache}`,
55
- ] : [`${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')} ${cache}`];
56
- for (let count = parts.length; count > 0; count--) {
57
- const value = parts.slice(0, count).join(' ');
58
- if (displayWidth(value) <= columns) return value;
57
+ `${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')}: ${cache}`,
58
+ ] : [`${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')}: ${cache}`];
59
+ // Narrow terminals shed the quietest figures first: average, cache hit, current. The
60
+ // model header then falls back to its bare `model @ effort` form, then context goes,
61
+ // and only then the header itself — the running cost is the last thing standing.
62
+ const drops = rates ? [1, 4, 0, 2] : [2, 0];
63
+ const offset = header === '' ? 0 : 1;
64
+ const parts = header === '' ? base : [header, ...base];
65
+ const short = header.replace(/^[^:]+: /, '');
66
+ const heads = header === '' ? [''] : short === header ? [header] : [header, short];
67
+ const render = ({ omit, head }) => parts
68
+ .map((part, index) => (index === 0 && header !== '' ? head : part))
69
+ .filter((part, index) => part !== '' && !omit.has(index))
70
+ .join(' | ');
71
+ for (let dropped = 0; dropped <= drops.length; dropped++) {
72
+ const omit = new Set(drops.slice(0, dropped).map(index => index + offset));
73
+ for (const head of heads) {
74
+ const value = render({ omit, head });
75
+ if (displayWidth(value) <= columns) return value;
76
+ }
59
77
  }
78
+ const floor = render({ omit: new Set(drops.map(index => index + offset)), head: '' });
60
79
  let clipped = '';
61
- for (const char of parts[0]) { if (displayWidth(clipped + char) > columns) break; clipped += char; }
80
+ for (const char of floor) { if (displayWidth(clipped + char) > columns) break; clipped += char; }
62
81
  return clipped;
63
82
  }
64
- export function footerFor(id, stats, columns, locale = 'en') {
83
+ export function footerFor(id, stats, columns, header = '', locale = 'en') {
65
84
  try {
66
85
  const data = id ? source?.(id) : undefined;
67
86
  const ledger = id && process.env.DSH_HOME ? readMetrics(process.env.DSH_HOME, id) : { rows: [], corrupt: false };
@@ -69,6 +88,6 @@ export function footerFor(id, stats, columns, locale = 'en') {
69
88
  const used = data?.used;
70
89
  const capacity = data?.capacity ?? stats.contextWindow;
71
90
  const average = sessionAverageTps(data?.events ?? []);
72
- return formatFooter(summary, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average }, locale);
73
- } catch { return formatFooter({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale); }
91
+ return formatFooter(summary, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average }, locale, header);
92
+ } catch { return formatFooter({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale, header); }
74
93
  }
@@ -1,3 +1,6 @@
1
+ // dscode-stdin-stall-v2
2
+ const STDIN_STALL_MS = 5000;
3
+ const STDIN_STALL_NOTE = "\n[Interrupted after 5s with no output: the command held the terminal without producing anything, and this tool cannot supply terminal input. Give it stdin (a heredoc, `< file` or `< /dev/null`) or use a non-interactive form.]";
1
4
  // dscode-shell-reset-v1
2
5
  import { randomUUID } from "node:crypto";
3
6
  import z from "@deepseek-ai/schemastery";
@@ -255,6 +258,9 @@ async function executeCommand(ctx, shells, owner, command, config, upstream) {
255
258
  const id = await shells.get(owner, commandDeadline.signal);
256
259
  const marker = markers();
257
260
  const wrapped = wrapCommand(command, marker);
261
+ const startedAt = Date.now();
262
+ let stdinWaited = false;
263
+ let stdinStalled = false;
258
264
  let first = true;
259
265
  let fallback = "";
260
266
  let fallbackTruncated = false;
@@ -298,10 +304,21 @@ async function executeCommand(ctx, shells, owner, command, config, upstream) {
298
304
  }
299
305
  if (latest.text.includes(marker.end)) {
300
306
  const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker);
301
- if (complete !== void 0) return renderCaptured(complete, config.maxOutputChars);
307
+ if (complete !== void 0) return renderCaptured(complete, config.maxOutputChars) + (stdinStalled ? STDIN_STALL_NOTE : "");
302
308
  }
303
309
  if (result.sessionStatus.kind === "exited") return await respondToSessionExit(ctx, shells, owner, id, result.sessionStatus, marker, fallback, fallbackTruncated, config);
304
- if (result.waitReason === "stdin_read") return renderCaptured(partialOutput(retainedScrollback(ctx, owner, id, latest), marker, fallback, fallbackTruncated), config.maxOutputChars);
310
+ const partial = renderCaptured(partialOutput(retainedScrollback(ctx, owner, id, latest), marker, fallback, fallbackTruncated), config.maxOutputChars);
311
+ if (result.waitReason === "stdin_read") stdinWaited = true;
312
+ if (result.waitReason === "stdin_read" && partial.length > 0) return partial;
313
+ if (stdinWaited && partial.length === 0 && !stdinStalled && Date.now() - startedAt >= STDIN_STALL_MS) {
314
+ stdinStalled = true;
315
+ try {
316
+ await ctx.terminals.signal(owner, id, "SIGINT");
317
+ } catch (_unsignallableForeground) {
318
+ stdinStalled = false;
319
+ return partial;
320
+ }
321
+ }
305
322
  await pause();
306
323
  }
307
324
  } catch (e_1) {