SLOPSHOPPER

progress-bar

背景跑的指令(pytest、安裝、下載)在提示列上方顯示進度條:讀輸出裡的百分比,兩次讀數之間依估計的速率往前走、顯示還要多久;沒有百分比時依同一個指令過去的長度估計,都沒有就轉圈

newbandguardtoastprocesstimer
v0.3.0MITupdated 2026-10-25PsychQuant/cc-mods/plugins/progress-bar
A shopper browsing a rack in a slop shop
README

progress-bar

Claude Code 的 mod:Claude 用 Bash 在背景跑指令(run_in_background)時,在提示列上方畫一條進度條。 兩次讀數之間長條照樣往前走,並顯示還要多久。

▕██████▍░░░░░░░░░░░░░▏  32.1% · 還要約 25m  Run full pytest suite · 12m
⠹ Install package dependencies · 45s · Downloading numpy-2.3.1.tar.gz
✓ Build documentation site 100% · completed · 3m

它怎麼知道跑到幾趴

mod 看不到程式內部,只讀程式寫出來的輸出:

  1. 背景 Bash 指令開始時,記下它的任務 id 與要讀的檔 —— 任務的輸出檔,加上指令裡 >、>>、tee 寫到的檔。
  2. 每 2 秒讀一次那些檔的尾端(檔沒變就不讀),取最後一個 0–100 的百分比(42%、pytest 的 [ 29%]); 讀不到百分比就顯示最後一行與經過時間。
  3. 收到背景任務結束的通知時打勾(或打叉),15 秒後收起。
  4. 每一輪結束時,對照引擎列出的背景工作(shell、subagent、workflow、monitor):還在跑、但還沒被記下的接上(例如 mod 載入之前就開始跑的),shell 讀它重導向的檔或這個 session 輸出資料夾裡的 <id>.output(資料夾從第一個背景 Bash 的結果學到),其他種類只顯示名字與經過時間;記著、但已經不在清單裡的當作結束,沒有結束通知的種類也收得起來。接上的任務沒有開始時間,經過時間從接上的那一刻算。
  5. 跟同一條 band 上的其他 mod 疊著畫:先拿下面的 plugin 畫的東西,自己的列放在上面,不取代它。

讀數之間怎麼往前走(cc-mods#1)

程式不會每秒都印百分比(pytest 一整行才印一次),所以畫面上的百分比是估計值,每 250 ms 算一次:

  1. 速率:讀數進指數權重的 local linear regression(只用過去的讀數,越舊權重越小),得到現在的速率。 讀數少時往同一個指令過去的速率收縮。開始時間已知時,(開始, 0%) 算第一個點。
  2. 目標:以最後一個真實讀數為錨,依速率往前外插;外插會飽和、最多領先一個典型步幅 —— 讀數該來沒來,本身就是「變慢了」的訊號。
  3. 顯示值:落後時追這一拍的目標(一階延遲,時間常數 0.75 秒)、超前時照目標的速度變慢。只增不減; 到 99.5% 之前不會停(最低速度是剩下距離的 1/3600 每秒),到了 99.5% 就停在那裡,等任務結束。 顯示值不能往回,所以寧可落後(追得回來)也不要超前(收不回來)。剛讀到的那一刻畫讀數本身。
  4. 歷史:跑完(completed)的背景指令,長度記在這個 mod 的 store(本機,跨 session)。以完全相同的指令字串比對, 但 store 裡只存字串的 64 位元雜湊,不存指令本身(指令裡可能有密碼或 token)。每跑完一次寫一個新鍵、不覆寫, 同一個指令同時跑完兩次也不會互蓋;每個指令留最近 5 次,最多 200 個指令,90 天前寫的在 session 開始時刪掉。 寫不進 store 時先清掉超額與過期的歷史再試;還是不行,之後每 2 秒再試,試 3 次都失敗就放棄並跳一次提示。 還沒寫進去的最多留 20 筆,超過時先丟還沒等到結束通知的、再丟最舊的。 同一個字串再跑時,沒有讀數也依經過時間 ÷ 過去長度的中位數畫長條;跑得比平常久寫「比平常久」。 長度是「開始」到「最早得知結束」(每一輪結束時發現它不在背景清單裡,或結束通知到的那一刻)—— 引擎沒有給完成時間,所以是估計,通常略長。0.3.0 開發版存過的舊鍵(明文或讀改寫),session 開始時刪掉。
  5. 多階段(git clone、pip 下載好幾個套件、連跑兩次 pytest:讀數到頂之後又從頭開始):讀數往回掉超過 5 個百分點 視為新的一段。長條不退,百分比後面寫這一段的真實讀數(「第 2 段 37%」,放在標籤前,截斷先截標籤), 沒有歷史時不顯示還要多久。跑完時歷史記下 「這個指令會分段」,之後同一個字串一開始就依經過時間畫,不會被第一段推到頂。
  6. 讀數只取第一個讀到百分比的檔(stdout 與 stderr 各有一種進度時,不會被當成分段)。
  7. 沒有讀數也沒有歷史:轉圈(每秒一格)。手感的參數都在 hooks/est.ts 頂端。

限制

  • 只看得到程式自己印出的百分比。沒有印進度、也沒有跑過的程式只會轉圈,顯示最後一行與經過時間。
  • 指令字串裡有時間戳或暫存路徑的($(date …)、mktemp),每次字串都不同,查不到歷史。
  • 歷史只存指令字串的雜湊,只在本機這個 mod 的 store,不送到任何地方。
  • 跑得比歷史久時,長條幾乎不動(最低速度是剩下距離的 1/3600 每秒),到上限 99.5% 就完全停住; 只剩「比平常久」與經過時間在變。
  • 依讀數畫的長條:讀數停住時,還要多久停在最後一次算出的值,不倒數。依經過時間畫的(有歷史)照樣每秒倒數。
  • 每一輪結束時接上的 shell 沒有真的開始時間,所以就算這個指令有歷史,也不依經過時間畫(只在有讀數時畫長條, 沒有讀數就轉圈),也不寫歷史。指令超過 1000 字時引擎會截斷,截斷的字串也對不上歷史。
  • 兩個時鐘(2 秒讀檔、250 ms 算顯示值)互相看對方有沒有在跑;背景指令開始、每一輪結束、結束通知到的時候 也看一次讀檔的那一個。兩個同時停掉、之後又沒有這三種事件時,不會自己重開。
  • 第一次跑的多階段指令,第一段到頂之後長條只能停著(不能往回),後面幾段看列尾的「第 k 段」。
  • 指令寫到的檔要是絕對路徑,或只用到 HOME、TMPDIR、CLAUDE_JOB_DIR、PWD 這四個變數(與開頭的 ~)。 相對路徑要看指令當時的工作目錄,cd 之後猜不準,所以不讀。
  • 進度列用 \r 原地重畫的程式(pip、curl)也讀得到,因為 \r 算換行;顏色碼會先拿掉。
  • 同時最多顯示 6 個任務;沒收到結束通知的任務 6 小時後收起。
  • 同一條 band 上排在它上面、又不呼叫 next(e) 的 mod,會把它蓋掉(例如 token-weather 0.1.0);排在它下面的會一起畫出來。

開發

claude plugin test plugins/progress-bar 跑測試。型別檢查用 API 與內建工具的型別(.claude-plugin/types/claude-code、claude-code-tools);接了很多 MCP 工具的機器上,引擎產生的 tsconfig 把 MCP 工具的型別也算進去,tsc 可能報 TS2589(型別展開太深),那不是程式的錯。

安裝

在 Claude Code 的提示列輸入:

/plugin marketplace add PsychQuant/cc-mods
/plugin install progress-bar@cc-mods

檔案

檔內容
hooks/register.tsxhooks module:tool.call(記下背景任務)、session.append(結束通知、寫歷史)、session.start(每 2 秒讀檔、每 250 ms 算顯示值)、ui.render(畫在提示列上方)
hooks/est.ts純函式:速率估計、目標、顯示值的控制器、歷史
hooks/parse.ts純函式:讀百分比與最後一行、找重導向的目標、展開路徑、畫長條(八分之一格)、還要多久
types/index.d.ts$.state 的型別合約

這是 function hooks 的 plugin(mod):hooks/hooks.json 寫的是 { "modules": [...] },不是命令 hook 的 { "hooks": ... }。

Source 4 files
hooks/register.tsx 589 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Pending, Shown, Tracked } from '../types'
5import {
6  asRun, CAP, capPending, etaFromReading, evictRunKeys, expiredKeys, fitAdd, fitEval, frameDt, hashCommand, HISTORY_PREFIX,
7  parseRunKey, rate, runKey, seedDisplay, step, summarize, target, targetByTime, targetRate,
8} from './est'
9import { bar, elapsed, etaText, expandPath, pct, readProgress, redirectTargets } from './parse'
10
11// 欄位改了就換 shape:舊版存的值會被拒收,不會被讀成錯的形狀(重載之後 $.state 還在)
12const tasks = atom({ plugin: 'progress-bar', key: 'tasks' } as const, [] as Tracked[], { shape: 'tasks-4' })
13// 每一拍寫一次現在的時間,讓經過時間跟著重畫
14const now = atom({ plugin: 'progress-bar', key: 'now' } as const, 0)
15// 這個 session 背景任務輸出檔所在的資料夾:從第一個背景 Bash 的結果學到,接上已經在跑的任務時用
16const outputDir = atom({ plugin: 'progress-bar', key: 'outputDir' } as const, null as string | null)
17// 畫面上的顯示值:顯示時鐘算出來,畫出來的字串變了才寫
18const shown = atom({ plugin: 'progress-bar', key: 'shown' } as const, {} as Record<string, Shown>, { shape: 'shown-2' })
19// 跑完、還沒寫進歷史的長度:與顯示的任務清單分開,任務收起之後晚到的通知仍找得到(verify R2 #3)
20const pending = atom({ plugin: 'progress-bar', key: 'pending' } as const, [] as Pending[], { shape: 'pending-2' })
21
22const POLL_MS = 2000
23const POLL_STALE_MS = 3 * POLL_MS      // 輪詢時鐘這麼久沒跑,顯示時鐘或下一個事件就重開它(verify R2 #5)
24const DISPLAY_MS = 250                 // 顯示時鐘:只算、不讀檔
25const STALE_FRAME_MS = 1000            // 顯示時鐘這麼久沒跑,2 秒的 tick 就重開它(verify R1 #2、#6)
26const SPIN_MS = 1000                   // 轉圈每秒一格:轉圈列 shown 每秒最多寫一次(verify R1 #13)
27const KEEP_DONE_MS = 15_000            // 結束後再顯示多久
28const STALE_MS = 6 * 60 * 60 * 1000    // 沒收到結束通知的任務(與待寫的歷史),最多留多久
29const MAX_TASKS = 6
30const MAX_TRIES = 3                    // 一次跑完的長度寫不進 store,最多試幾次
31const MAX_READ = 4 * 1024 * 1024       // $.fs.read 的上限;更大的檔改用 tail
32const DROP = 5                         // 讀數下降超過幾個百分點算新階段
33const MIN_RECORD_MS = 2000             // 跑不到這麼久的不寫歷史
34const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
35const OUTPUT = /Output is being written to: (\S+?)\.?(?:\s|$)/
36const NOTICE = /<task-id>([^<]+)<\/task-id>[\s\S]*?<status>([^<]+)<\/status>/g
37
38/** 0.3.0 開發版的歷史鍵(`history-1` 存明文指令字串、`h2:` 讀改寫):session 開始時刪掉(verify R1 #11)。 */
39const isLegacyKey = (k: string): boolean => k === 'history-1' || k.startsWith('h2:')
40
41/**
42 * 路徑裡能展開的變數:只有這四個(封閉列舉)。`$.env.get` 只收字面的變數名,引擎才列得出 mod 讀了哪些變數;
43 * 用到其他變數的路徑展不開,就不讀。
44 */
45async function knownEnv($: EngineInterface): Promise<Record<string, string | undefined>> {
46  return {
47    HOME: await $.env.get('HOME'),
48    TMPDIR: await $.env.get('TMPDIR'),
49    CLAUDE_JOB_DIR: await $.env.get('CLAUDE_JOB_DIR'),
50    PWD: await $.env.get('PWD'),
51  }
52}
53
54/** 這個背景指令要讀哪些檔:指令自己寫到的絕對路徑優先,任務的輸出檔放最後。 */
55async function filesOf($: EngineInterface, command: string, output: string | undefined): Promise<string[]> {
56  const raws = redirectTargets(command)
57  const env = raws.length > 0 ? await knownEnv($) : {}
58  const paths = raws.map(r => expandPath(r, env)).filter((p): p is string => p !== undefined)
59  return [...new Set([...paths, ...(output === undefined ? [] : [output])])]
60}
61
62async function readTail($: EngineInterface, path: string, size: number): Promise<string | undefined> {
63  if (size <= MAX_READ) return $.fs.read(path).catch(() => undefined)
64  const r = await $.process.run(['tail', '-c', '8192', path]).catch(() => undefined)
65  return r?.exitCode === 0 ? r.stdout : undefined
66}
67
68// ── 歷史:每次跑完一個 store 鍵 `h3:<指令字串的雜湊>:<時間>:<隨機>`(不存原字串、不讀改寫) ──
69
70/** 這個指令過去幾次跑完的長度中位數,以及會不會分段;沒有歷史是 `{ prior: null }`。 */
71async function loadPrior($: EngineInterface, key: string, t: number): Promise<{ prior: number | null; phased: boolean }> {
72  if (key === '') return { prior: null, phased: false }
73  const mine = (await $.store.keys().catch(() => [] as string[])).filter(k => k.startsWith(`${HISTORY_PREFIX}${key}:`))
74  const stale = new Set(expiredKeys(mine, t))
75  const runs: { ms: number; phased: boolean; at: number }[] = []
76  for (const k of mine) {
77    const at = parseRunKey(k)?.at
78    if (at === undefined || stale.has(k)) continue
79    const r = asRun(await $.store.get(k).catch(() => undefined))
80    if (r !== null) runs.push({ ...r, at })
81  }
82  return summarize(runs)
83}
84
85const nonce = (): string => Math.floor(Math.random() * 36 ** 6).toString(36)
86
87/**
88 * 刪掉不該留的歷史鍵:0.3.0 開發版的鍵、90 天前寫的(verify R2 #10)、超過每個指令 5 次 / 200 個指令的。
89 * session 開始時、每次寫入之後、寫入失敗時都跑一次 —— store 滿了寫不進去時,要先騰出空間(verify R3 #4)。
90 */
91async function pruneHistory($: EngineInterface, t: number): Promise<void> {
92  const keys = await $.store.keys()
93  const doomed = new Set([...keys.filter(isLegacyKey), ...expiredKeys(keys, t), ...evictRunKeys(keys)])
94  for (const k of doomed) await $.store.delete(k).catch(() => undefined)
95}
96
97/**
98 * 寫一次跑完的長度到這一筆自己的鍵(`p.store`,建這一筆時就決定):重試與熱重載都寫同一個鍵,不會記成兩次(verify R3 #6)。
99 * 寫不進去時先清一次再試;還是不行就往外丟,讓呼叫端下一次 tick 再試。寫入之後的淘汰失敗不影響這一次。
100 */
101async function saveRun($: EngineInterface, p: Pending, t: number): Promise<void> {
102  const value = { ms: p.ms, phased: p.phased }
103  try {
104    await $.store.set(p.store, value)
105  } catch {
106    await pruneHistory($, t).catch(() => undefined)
107    await $.store.set(p.store, value)
108  }
109  await pruneHistory($, t).catch(() => undefined)
110}
111
112/** 跑完的長度能不能記:開始時間是真的、是 shell(有雜湊)、跑了至少 2 秒。 */
113const recordable = (x: Tracked, end: number): boolean => x.startKnown && x.key !== '' && end - x.startedAt >= MIN_RECORD_MS
114
115const pendingOf = (x: Tracked, end: number, status: string | null): Pending =>
116  ({ id: x.id, key: x.key, ms: end - x.startedAt, phased: x.phased, status, at: end, tries: 0, store: runKey(x.key, end, nonce()) })
117
118const saved = new Set<string>()        // 這個模組寫過的任務 id:重複的通知、交錯的兩次寫入都不再寫一次
119const recording = new Set<string>()    // 正在寫的
120
121/**
122 * 把收到 completed 通知的那幾筆寫進歷史。寫入成功才從待寫的清單拿掉;失敗的下一次 tick 再試,
123 * 試了 MAX_TRIES 次都失敗就放棄,並提示一次(不含指令內容,verify R2 #11)。failed、killed 不記;
124 * 推定結束之後 6 小時都沒等到通知的丟掉。
125 */
126async function flushHistory($: EngineInterface, t: number): Promise<void> {
127  const list = await read($, pending)
128  if (list.length === 0) return
129  const result = new Map<string, boolean>()
130  for (const p of list) {
131    if (p.status !== 'completed' || saved.has(p.id) || recording.has(p.id)) continue
132    recording.add(p.id)
133    try {
134      await saveRun($, p, t)
135      saved.add(p.id)
136      result.set(p.id, true)
137    } catch {
138      result.set(p.id, false)
139    }
140  }
141  const gaveUp = list.filter(p => result.get(p.id) === false && p.tries + 1 >= MAX_TRIES).length
142  try {
143    await update($, pending, latest => latest.flatMap(p => {
144      if (saved.has(p.id)) return []
145      if (p.status !== null && p.status !== 'completed') return []
146      if (p.status === null && t - p.at > STALE_MS) return []
147      if (result.get(p.id) === false) return p.tries + 1 >= MAX_TRIES ? [] : [{ ...p, tries: p.tries + 1 }]
148      return [p]
149    }))
150  } finally {
151    // 待寫清單寫不回去也要放掉:不然這幾筆以後永遠被當成「正在寫」,再也不重試(verify R3 #5)
152    for (const id of result.keys()) recording.delete(id)
153  }
154  if (saved.size > 200) for (const id of [...saved].slice(0, saved.size - 200)) saved.delete(id)
155  if (gaveUp > 0) {
156    try {
157      $.ui.toast(`progress-bar:${gaveUp} 次跑完的長度寫不進歷史(試了 ${MAX_TRIES} 次)`)
158    } catch {
159      // 提示不了就算了
160    }
161  }
162}
163
164/** 收到新讀數:更新速率估計。下降超過 DROP 是新階段:統計量重來,速率帶過去。 */
165function addReading(x: Tracked, t: number, p: number): Pick<Tracked, 'fit' | 'phased' | 'phase'> {
166  const ts = Math.max(0, (t - x.startedAt) / 1000)
167  if (x.fit === null) {
168    // 開始時間是真的:(開始, 0%) 當第一個點,第一個讀數一到就有斜率
169    const seed = x.startKnown && p > 0 && ts > 0 ? fitAdd(null, 0, 0) : null
170    return { fit: fitAdd(seed, ts, p), phased: x.phased, phase: x.phase }
171  }
172  if (p < x.fit.pLast - DROP) {
173    const slope = fitEval(x.fit).slope
174    const carry = slope !== null && slope > 0 ? slope : x.fit.carry
175    return { fit: { ...fitAdd(null, ts, p), carry }, phased: true, phase: x.phase + 1 }
176  }
177  return { fit: fitAdd(x.fit, ts, p), phased: x.phased, phase: x.phase }
178}
179
180/** 依經過時間畫:有歷史、開始時間已知,而且沒有讀數、或這一次分段了、或歷史記著這個指令會分段(verify R1 #5、#8)。 */
181function byTime(x: Tracked): boolean {
182  return x.prior !== null && x.prior > 0 && x.startKnown && (x.fit === null || x.phased || x.priorPhased)
183}
184
185type Estimate = { target: number; speed: number; eta: number | null; note: string | null }
186
187/**
188 * 這一刻的目標值、目標的速度、還要幾秒與這一段的說明;null = 沒有估計(畫轉圈)。
189 *  - 依經過時間(`byTime`):目標 = 經過時間 ÷ 歷史長度。
190 *  - 分段了而沒有歷史:長條不退(只剩最低速度),說明寫這一段的真實讀數,不顯示還要多久。
191 *  - 其餘:依讀數的速率,讀數少時往歷史收縮。
192 */
193function estimateNow(x: Tracked, t: number): Estimate | null {
194  const elapsedS = Math.max(0, (t - x.startedAt) / 1000)
195  const priorS = x.prior !== null && x.prior > 0 ? x.prior / 1000 : null
196  const note = x.phased && x.fit !== null ? `第 ${x.phase} 段 ${Math.round(x.fit.pLast)}%` : null
197  if (priorS !== null && byTime(x)) {
198    const tgt = targetByTime(elapsedS, priorS)
199    return { target: tgt, speed: tgt < CAP ? 100 / priorS : 0, eta: priorS - elapsedS, note }
200  }
201  if (x.fit === null) return null
202  if (x.phased) return { target: 0, speed: 0, eta: null, note }
203  const ev = fitEval(x.fit)
204  const avg = x.startKnown && x.fit.tLast > 0 ? x.fit.pLast / x.fit.tLast : null
205  const b = rate(ev, priorS === null ? null : 100 / priorS, avg, x.fit.carry)
206  const showEta = b !== null && (ev.nEff >= 2 || priorS !== null)
207  return {
208    target: target(x.fit, elapsedS, b),
209    speed: targetRate(x.fit, elapsedS, b),
210    eta: showEta && b !== null ? etaFromReading(x.fit.pLast, b) : null,
211    note: null,
212  }
213}
214
215/** 字串層級的比較鍵:畫出來會一樣就不寫(顯示值到一位小數、還要多久的文字、轉圈的格、這一段的說明)。 */
216function keyOf(m: Readonly<Record<string, Shown>>): string {
217  return Object.entries(m)
218    .map(([id, s]) => `${id}:${s.spin ? `s${s.frame}` : s.d.toFixed(1)}:${s.eta === null ? '' : etaText(s.eta * 1000)}:${s.note ?? ''}`)
219    .sort()
220    .join('|')
221}
222
223// ── 兩個時鐘:2 秒的輪詢(讀檔)與 250 ms 的顯示(只算)。一期被拒會結束整個 interval,所以互相守門,
224//    每個事件也看一次;換掉的計時器已經在跑的那一拍,不再改共享的狀態(generation,verify R2 #5、#6) ──
225
226/** 顯示的那一份:每個執行中任務的顯示值(精確值;`spin` = 上一拍畫的是轉圈),畫面上的 `shown` 只在字串變了時才寫。 */
227const live = new Map<string, { d: number; at: number; spin: boolean }>()
228const mtimes = new Map<string, number>()   // 檔 → 上次讀時的 mtime;重載就從頭讀一次,無妨
229let display: Timer | null = null
230let displayGen = 0
231let lastFrame = 0
232let startRequests = 0
233let poll: Timer | null = null
234let pollGen = 0
235let lastTick = 0
236
237function startDisplay($: EngineInterface, t: number): void {
238  startRequests += 1
239  if (display !== null) return
240  const gen = ++displayGen
241  lastFrame = t
242  display = $.clock.every(DISPLAY_MS, () => {
243    void frame($, gen).catch(() => undefined)
244  })
245}
246
247function stopDisplay(): void {
248  display?.cancel()
249  display = null
250  displayGen += 1
251}
252
253function startPoll($: EngineInterface, t: number): void {
254  poll?.cancel()
255  const gen = ++pollGen
256  lastTick = t
257  poll = $.clock.every(POLL_MS, () => {
258    void tick($, gen).catch(() => undefined)
259  })
260}
261
262/** 輪詢時鐘不在或太久沒跑:重開。 */
263function ensurePoll($: EngineInterface, t: number): void {
264  if (poll === null || t - lastTick > POLL_STALE_MS) startPoll($, t)
265}
266
267/** 結束那一刻畫面上的顯示值;畫的是轉圈或還沒畫過是 null。 */
268function shownD(id: string): number | null {
269  const w = live.get(id)
270  return w === undefined || w.spin ? null : w.d
271}
272
273/** 讀一次每個還在跑(或剛結束)的任務的檔,有變才寫回;重試寫不進的歷史;看顯示時鐘還在不在跑。 */
274async function tick($: EngineInterface, gen: number): Promise<void> {
275  const t = await $.clock.now()
276  if (gen !== pollGen) return
277  lastTick = t
278  const list = await read($, tasks)
279  if (list.length === 0) {
280    await flushHistory($, t)
281    return
282  }
283  const patch = new Map<string, Pick<Tracked, 'percent' | 'line' | 'fit' | 'phased' | 'phase' | 'src'>>()
284  const drop = new Set<string>()
285  // 這一拍讀到的 mtime 先記在這裡,讀數寫回去了才併進 mtimes:讀檔失敗、或這一拍已經被換掉時,下一拍還會再讀(verify R3 #2)
286  const seen = new Map<string, number>()
287  for (const task of list) {
288    const isOver = task.endedAt !== null && t - task.endedAt > KEEP_DONE_MS
289    if (isOver || t - task.startedAt > STALE_MS) {
290      drop.add(task.id)
291      continue
292    }
293    // 結束之後再讀一兩拍,最後的百分比(常常是 100%)才會畫上去
294    if (task.endedAt !== null && t - task.endedAt > 2 * POLL_MS) continue
295    let cur: Tracked = task
296    for (const f of task.files) {
297      const st = await $.fs.stat(f).catch(() => undefined)
298      if (st === undefined || st.kind !== 'file' || st.size === 0 || mtimes.get(f) === st.mtimeMs) continue
299      const text = await readTail($, f, st.size)
300      if (text === undefined) continue
301      seen.set(f, st.mtimeMs)
302      const r = readProgress(text)
303      // 讀數只取一個檔(第一個讀到百分比的):兩個檔交替時不會被當成分段(verify R1 #37)。
304      // 同一個百分比再讀到一次不算新讀數(pytest 一整行才印一次百分比,其間檔案照樣在變)
305      if (r.percent !== null && (cur.src === null || cur.src === f)) {
306        if (r.percent !== cur.percent) cur = { ...cur, ...addReading(cur, t, r.percent), percent: r.percent }
307        if (cur.src === null) cur = { ...cur, src: f }
308      }
309      if (r.line) cur = { ...cur, line: r.line }
310    }
311    // 比欄位,不比物件身分:沒變就不寫(verify R1 #25)
312    if (cur.percent !== task.percent || cur.line !== task.line || cur.fit !== task.fit || cur.phased !== task.phased
313      || cur.phase !== task.phase || cur.src !== task.src) {
314      patch.set(task.id, { percent: cur.percent, line: cur.line, fit: cur.fit, phased: cur.phased, phase: cur.phase, src: cur.src })
315    }
316  }
317  if (gen !== pollGen) return
318  let committed = true
319  if (patch.size > 0 || drop.size > 0) {
320    committed = false
321    // 用最新的清單套改動:這段時間 tool.call 可能剛加進一個任務。套的那一刻再看一次 generation(verify R3 #3)
322    await update($, tasks, latest => {
323      if (gen !== pollGen) return latest
324      committed = true
325      return latest.filter(x => !drop.has(x.id)).map(x => (patch.has(x.id) ? { ...x, ...patch.get(x.id) } : x))
326    })
327  }
328  if (!committed || gen !== pollGen) return
329  for (const [f, m] of seen) mtimes.set(f, m)
330  await update($, now, cur => (gen === pollGen ? Math.max(cur, t) : cur))
331  await flushHistory($, t)
332  if (gen !== pollGen) return
333  // 有執行中的任務,顯示時鐘卻不在或太久沒跑(被取消的競態、某一期被拒):重開(verify R1 #2、#6)
334  if (list.some(x => x.endedAt === null && !drop.has(x.id)) && (display === null || t - lastFrame > STALE_FRAME_MS)) {
335    stopDisplay()
336    startDisplay($, t)
337  }
338}
339
340/** 顯示時鐘的一拍:只算、不讀檔;順便看輪詢時鐘還在不在跑。沒有執行中的任務就停掉自己。 */
341async function frame($: EngineInterface, gen: number): Promise<void> {
342  const req0 = startRequests
343  const t = await $.clock.now()
344  const list = await read($, tasks)
345  const prev = await read($, shown)
346  if (gen !== displayGen) return
347  lastFrame = t
348  ensurePoll($, t)
349  const running = list.filter(x => x.endedAt === null)
350  if (running.length === 0) {
351    live.clear()
352    if (Object.keys(prev).length > 0) await update($, shown, cur => (gen === displayGen ? {} : cur))
353    if (gen !== displayGen) return
354    stopDisplay()
355    // 這一拍讀到的清單是同一刻的:停之前有人要了時鐘(剛加進任務),就重開(verify R1 #2)
356    if (startRequests !== req0) startDisplay($, t)
357    return
358  }
359  const next: Record<string, Shown> = {}
360  for (const x of running) {
361    const est = estimateNow(x, t)
362    const w = live.get(x.id)
363    let d: number
364    if (est === null) d = w?.d ?? 0
365    else if (w !== undefined && !w.spin) d = step(w.d, est.target, est.speed, frameDt(w.at, t))
366    else {
367      // 這一列第一次畫長條(剛開始、剛從轉圈換成長條、重載之後):從初值起畫 —— 依讀數的畫讀數,
368      // 依經過時間的延續畫面上原本的值(verify R1 #12、R2 #4)
369      const p = prev[x.id]
370      d = seedDisplay(p !== undefined && !p.spin ? p.d : undefined, x.percent, byTime(x))
371    }
372    live.set(x.id, { d, at: t, spin: est === null })
373    const fr = Math.floor(Math.max(0, t - x.startedAt) / SPIN_MS) % SPIN.length
374    next[x.id] = { d, frame: est === null ? fr : 0, eta: est?.eta ?? null, spin: est === null, note: est?.note ?? null }
375  }
376  for (const id of live.keys()) if (!(id in next)) live.delete(id)
377  if (keyOf(prev) !== keyOf(next)) await update($, shown, cur => (gen === displayGen ? next : cur))
378}
379
380/** 執行中的那一列畫轉圈還是長條;顯示時鐘還沒算過的任務,照 estimateNow 會走的那一支(verify R1 #28)。 */
381function isSpin(x: Tracked, s: Shown | undefined): boolean {
382  return s === undefined ? x.percent === null && !byTime(x) : s.spin
383}
384
385function row(x: Tracked, s: Shown | undefined, t: number, width: number): string {
386  const took = elapsed((x.endedAt ?? t) - x.startedAt)
387  if (x.endedAt !== null) {
388    const mark = x.status === 'completed' ? '✓' : x.status === 'failed' || x.status === 'killed' ? '✗' : '·'
389    // 結束那一刻不往回掉:completed 寫 100%,其餘寫最後的顯示值(verify R1 #16)
390    const last = x.status === 'completed' && (x.percent !== null || x.final !== null) ? 100 : x.final ?? x.percent
391    const p = last === null ? '' : ` ${Math.round(last * 10) / 10}%`
392    return `${mark} ${x.label}${p} · ${x.status ?? 'ended'} · ${took}`
393  }
394  if (isSpin(x, s)) return `${SPIN[(s?.frame ?? 0) % SPIN.length]} ${x.label} · ${took}${x.line ? ` · ${x.line}` : ''}`
395  const d = Math.min(s?.d ?? seedDisplay(undefined, x.percent, byTime(x)), CAP)
396  const left = s?.eta === null || s?.eta === undefined ? '' : ` · ${etaText(s.eta * 1000)}`
397  // 這一段的讀數也放在標籤前:第一次跑的多階段指令,長條停住時它是唯一在動的進度,截斷先截標籤(verify R2 #9)
398  const note = s?.note ? ` · ${s.note}` : ''
399  // 依經過時間畫的長條(沒有讀數)保留最後一行輸出(verify R1 #14)
400  const line = x.percent === null && x.line ? ` · ${x.line}` : ''
401  return `▕${bar(d, width)}▏ ${pct(d)}${left}${note}  ${x.label} · ${took}${line}`
402}
403
404export const register: Register = on => {
405  // 重載時兩個時鐘都在這裡重開;同一個模組再收到一次也先停掉舊的,不會有兩個
406  on('session.start', async ($, e, next) => {
407    const t = await $.clock.now()
408    stopDisplay()
409    startPoll($, t)
410    startDisplay($, t)                          // 沒有執行中的任務,第一拍就自己停
411    void pruneHistory($, t).catch(() => undefined)
412    return next(e)
413  })
414
415  // 背景跑的 Bash:記下它的 id、指令字串的雜湊與要讀的檔。追蹤失敗不影響指令本身
416  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
417    const ran = await next(e)
418    if (ran.deny !== undefined || ran.isError === true) return ran
419    const id = ran.result.backgroundTaskId
420    if (id === undefined) return ran
421    try {
422      const output = OUTPUT.exec(ran.text ?? '')?.[1]
423      const own = `/${id}.output`
424      if (output !== undefined && output.endsWith(own)) {
425        const dir = output.slice(0, -own.length)
426        await update($, outputDir, () => dir)
427      }
428      const t0 = await $.clock.now()
429      const key = hashCommand(e.command)
430      const hist = await loadPrior($, key, t0)
431      // 讀不到檔也照樣記下:沒有百分比的任務顯示轉圈(有歷史就依經過時間畫長條)
432      const tracked: Tracked = {
433        id,
434        label: (e.description ?? e.command).replace(/\s+/g, ' ').slice(0, 48),
435        key,
436        files: await filesOf($, e.command, output),
437        src: null,
438        startedAt: t0,
439        startKnown: true,
440        prior: hist.prior,
441        priorPhased: hist.phased,
442        percent: null,
443        fit: null,
444        phased: false,
445        phase: 1,
446        line: '',
447        endedAt: null,
448        status: null,
449        presumed: false,
450        final: null,
451      }
452      await update($, tasks, list => [...list.filter(x => x.id !== id), tracked].slice(-MAX_TASKS))
453      ensurePoll($, t0)
454      startDisplay($, t0)
455    } catch {
456      // 只是顯示,記不起來就不畫
457    }
458    return ran
459  }).catch(($, e, next) => next(e))   // 這個 hook 壞了也不擋指令:照原樣跑
460
461  // 每一輪結束:對照引擎列出的背景工作(shell、subagent、workflow、monitor)。
462  // (1) 還在跑、但沒被記下的接上(例如 mod 載入之前就開始跑的);只有 shell 讀檔、查歷史,其他種類只轉圈。
463  //     接上的這一列沒有開始時間,經過時間從接上的那一刻算,也不寫歷史。
464  // (2) 記著、但已經不在清單裡的,推定結束;能記歷史的先放進待寫的清單,等結束通知說它是不是 completed
465  on('classic.Stop', async ($, e, next) => {
466    const r = await next(e)
467    // next(e) 已經跑過了:之後的錯誤在這裡接住(丟出去的話引擎把這個 hook 記成被跳過;結果照樣是 next(e) 的那一個)
468    try {
469      ensurePoll($, await $.clock.now())
470      if (e.background_tasks === undefined) return r       // 引擎沒給清單:不猜
471      const running = e.background_tasks.filter(b => b.status === 'running' || b.status === 'pending')
472      const alive = new Set(running.map(b => b.id))
473      const list = await read($, tasks)
474      const known = new Set(list.map(x => x.id))
475      const dir = await read($, outputDir)
476      const t = await $.clock.now()
477      const picked: Tracked[] = []
478      for (const b of running) {
479        if (known.has(b.id)) continue
480        const isShell = b.type === 'shell'
481        const command = isShell ? (b.command ?? '') : ''
482        const key = command === '' ? '' : hashCommand(command)
483        const hist = await loadPrior($, key, t)
484        picked.push({
485          id: b.id,
486          label: (b.description || b.name || b.agent_type || b.command || b.id).replace(/\s+/g, ' ').slice(0, 48),
487          key,
488          files: isShell ? await filesOf($, command, dir === null ? undefined : `${dir}/${b.id}.output`) : [],
489          src: null,
490          startedAt: t,
491          startKnown: false,
492          prior: hist.prior,
493          priorPhased: hist.phased,
494          percent: null,
495          fit: null,
496          phased: false,
497          phase: 1,
498          line: '',
499          endedAt: null,
500          status: null,
501          presumed: false,
502          final: null,
503        })
504      }
505      const gone = list.filter(x => x.endedAt === null && !alive.has(x.id))
506      const goneIds = new Set(gone.map(x => x.id))
507      if (picked.length > 0 || gone.length > 0) {
508        await update($, tasks, latest => [
509          ...latest.map(x => (goneIds.has(x.id) && x.endedAt === null
510            ? { ...x, endedAt: t, status: x.status ?? 'ended', presumed: true, final: shownD(x.id) }
511            : x)),
512          ...picked.filter(p => !latest.some(x => x.id === p.id)),
513        ].slice(-MAX_TASKS))
514      }
515      const add = gone.filter(x => recordable(x, t)).map(x => pendingOf(x, t, null))
516      if (add.length > 0) {
517        await update($, pending, latest => capPending([...latest, ...add.filter(a => !latest.some(p => p.id === a.id))]))
518      }
519      if (picked.length > 0) startDisplay($, t)
520    } catch {
521      // 只是顯示,記不起來就算了
522    }
523    return r
524  }).catch(($, e, next) => next(e))   // 壞了也不影響這一輪的結束
525
526  // 背景任務結束的通知:`<task-id>` 與 `<status>`。推定結束的也更正;跑完(completed)、開始時間已知的寫進歷史。
527  // 長度取「開始」到「最早得知結束」(推定結束或這個通知):沒有引擎的完成時間,是估計
528  on('session.append', async ($, e, next) => {
529    const r = await next(e)
530    // next(e) 已經跑過了:之後的錯誤在這裡接住(丟出去的話引擎把這個 hook 記成被跳過;這一列照樣只存一次)
531    try {
532      if (e.message.type !== 'user') return r
533      const text = JSON.stringify(e.message)
534      if (!text.includes('<task-id>')) return r
535      const ends = new Map<string, string>()
536      for (const m of text.matchAll(NOTICE)) {
537        if (m[1] !== undefined && m[2] !== undefined) ends.set(m[1], m[2])
538      }
539      if (ends.size === 0) return r
540      const t = await $.clock.now()
541      const list = await read($, tasks)
542      const open = (x: Tracked) => ends.has(x.id) && (x.endedAt === null || x.presumed)
543      // 推定結束了的也建:每一輪結束時先寫任務、再寫待寫清單,通知可能剛好插在中間(verify R3 #1)。長度用推定結束的那一刻
544      const fresh = list.filter(x => open(x) && recordable(x, x.endedAt ?? t))
545        .map(x => pendingOf(x, x.endedAt ?? t, ends.get(x.id) ?? null))
546      await update($, tasks, l => l.map(x => (open(x)
547        ? { ...x, endedAt: x.endedAt ?? t, status: ends.get(x.id) ?? null, presumed: false, final: x.final ?? shownD(x.id) }
548        : x)))
549      await update($, pending, latest => capPending([
550        ...latest.map(p => (ends.has(p.id) && p.status === null ? { ...p, status: ends.get(p.id) ?? null } : p)),
551        ...fresh.filter(f => !latest.some(p => p.id === f.id)),
552      ]))
553      ensurePoll($, t)
554      await flushHistory($, t)
555    } catch {
556      // 只是顯示,記不起來就算了
557    }
558    return r
559  }).catch(($, e, next) => next(e))   // 壞了也照原樣存這一列
560
561  // 疊在下面的 plugin(或引擎)畫的東西上方,不取代它:同一條 band 上的其他 mod(例如 token-weather)照樣畫得出來
562  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
563    const list = await read($, tasks)
564    if (e.props.hasSurvey || list.length === 0) return next(e)
565    const below = await next(e)
566    const view = await read($, shown)
567    const t = Math.max(await read($, now), ...list.map(x => x.startedAt))
568    const width = Math.max(10, Math.min(30, e.props.bodyColumns - 60))
569    const { Box, Text } = $.ui.resolve(e)
570    return (
571      <Box flexDirection="column">
572        {below}
573        {list.map(x => (
574          <Box key={x.id}>
575            <Text
576              wrap="truncate-end"
577              color={x.endedAt === null ? undefined
578                : x.status === 'completed' ? 'green' : x.status === 'failed' || x.status === 'killed' ? 'red' : undefined}
579              dimColor={(x.endedAt === null && isSpin(x, view[x.id])) || (x.endedAt !== null && x.status === 'ended')}
580            >
581              {row(x, view[x.id], t, width)}
582            </Text>
583          </Box>
584        ))}
585      </Box>
586    )
587  })
588}
589
hooks/est.ts 254 lines
1/**
2 * 進度的估計與顯示控制:全部是純函式,不碰 `$`(cc-mods#1)。
3 *
4 * 時間一律是「秒,從任務開始(或接上)算起」,速率是「百分點 / 秒」;只有歷史的長度用 ms。
5 * 三層:
6 *  1. 速率:指數權重的 local linear regression(單邊核,只用過去的讀數),遞迴更新六個統計量。
7 *  2. 目標:以最後一個真實讀數為錨往前外插,外插會飽和、最多領先一個典型步幅 —— 顯示值不能往回,
8 *     寧可落後(追得回來)也不要超前(收不回來)。
9 *  3. 控制器:落後時以一階延遲追這一拍結束時的目標,超前時跟著目標的速度、超前越多越慢;
10 *     最低速度是剩下距離的 1/3600 每秒 —— 只增不減、不停,但讀數停住時不會一路衝上去。
11 */
12
13import type { Fit, Pending } from '../types'
14
15// ── 參數:手感要調只改這裡 ──
16export const CAP = 99.5            // 任務結束前的上限(%)
17const H_MIN = 15                   // 帶寬下限(秒)
18const H_MAX = 300                  // 帶寬上限(秒)
19const H_FIRST = 30                 // 讀數間隔還不知道時的帶寬(秒)
20const H_GAPS = 3                   // 帶寬 = 3 × 讀數間隔
21const PRIOR_WEIGHT = 2             // 歷史速率的先驗強度(相當於幾個讀數)
22const FLOOR_FRAC = 0.2             // 速率下限 = 0.2 × 平均速率
23const GAIN = 3                     // 增益尺度(百分點):超前 3 點,速度 ×1/e
24const CREEP_S = 3600               // 最低速度 = (CAP − d) / 3600 每秒
25const CATCH_S = 0.75               // 落後時追趕的時間常數(秒)
26const SNAP = 0.05                  // 差不到這麼多就直接貼上目標(百分點)
27const TAU_MIN = 5                  // 外插飽和的時間常數下限(秒)
28export const MAX_LEAD = 10         // 領先最後讀數最多幾個百分點
29const EW = 0.3                     // 讀數間隔、步幅的 EW 平均係數
30const MAX_RUNS = 5                 // 每個指令留幾次的長度
31const MAX_COMMANDS = 200           // 最多記幾個指令
32const MAX_PENDING = 20             // 跑完、還沒寫進歷史的最多留幾筆
33const MAX_AGE_MS = 90 * 86_400_000 // 90 天前的歷史刪掉
34const MAX_DT_S = 0.5               // 每拍的 dt 上限(秒):兩拍隔很久時只會落後,不會超前(verify R1 #7)
35
36// 指數權重 local linear 的狀態放在 state 裡,型別由 contract 定義;原點放在最後一個讀數(`tLast`),所以 `s1` ≤ 0。
37export type { Fit }
38
39export type Eval = { level: number; slope: number | null; nEff: number }
40
41const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x))
42const ewAvg = (prev: number | null, x: number): number => (prev === null ? x : prev + EW * (x - prev))
43
44function bandwidth(gap: number | null): number {
45  return gap === null ? H_FIRST : clamp(H_GAPS * gap, H_MIN, H_MAX)
46}
47
48/** 加入讀數 `(t, p)`:舊的權重乘上 `e^{−Δt/h}`、原點移到新的點,再加入它(權重 1)。不改動 `fit`。 */
49export function fitAdd(fit: Fit | null, t: number, p: number): Fit {
50  if (fit === null) {
51    return { s0: 1, s1: 0, s2: 0, sy: p, sty: 0, sww: 1, tLast: t, pLast: p, gap: null, step: null, carry: null }
52  }
53  const tt = Math.max(t, fit.tLast)       // 時間倒退:原點不動(verify R1 #20)
54  const dt = tt - fit.tLast
55  const lam = Math.exp(-dt / bandwidth(fit.gap))
56  // 衰減,再把原點往後移 dt:t' = t − dt
57  const s0 = lam * fit.s0
58  const s1 = lam * fit.s1
59  const s2 = lam * fit.s2
60  const sy = lam * fit.sy
61  const sty = lam * fit.sty
62  const dp = p - fit.pLast
63  return {
64    s0: s0 + 1,
65    s1: s1 - dt * s0,
66    s2: s2 - 2 * dt * s1 + dt * dt * s0,
67    sy: sy + p,
68    sty: sty - dt * sy,
69    sww: lam * lam * fit.sww + 1,
70    tLast: tt,
71    pLast: p,
72    gap: dt > 0 ? ewAvg(fit.gap, dt) : fit.gap,
73    step: dp > 0 ? ewAvg(fit.step, dp) : fit.step,
74    carry: fit.carry,
75  }
76}
77
78/** 最後一點的水準、斜率(只有一個點、或時間都一樣時是 null)、有效樣本數。 */
79export function fitEval(fit: Fit): Eval {
80  const denom = fit.s0 * fit.s2 - fit.s1 * fit.s1
81  const nEff = (fit.s0 * fit.s0) / fit.sww
82  if (!(denom > 1e-12 * fit.s0 * fit.s0)) return { level: fit.sy / fit.s0, slope: null, nEff }
83  const slope = (fit.s0 * fit.sty - fit.s1 * fit.sy) / denom
84  return { level: (fit.sy - slope * fit.s1) / fit.s0, slope, nEff }
85}
86
87/**
88 * 現在用的速率(百分點 / 秒):局部斜率往歷史速率收縮;沒有斜率時用歷史、再來用上一段帶過來的;
89 * 不低於 `0.2 ×` 平均速率(平均速率、歷史、上一段依序取第一個有的)。都沒有 → null。
90 */
91export function rate(ev: Eval | null, prior: number | null, avg: number | null, carry: number | null): number | null {
92  let b = ev?.slope ?? null
93  if (b !== null && prior !== null && ev !== null) b = (ev.nEff * b + PRIOR_WEIGHT * prior) / (ev.nEff + PRIOR_WEIGHT)
94  if (b === null) b = prior ?? carry
95  if (b === null) return null
96  const ref = avg ?? prior ?? carry
97  if (ref !== null && ref > 0) b = Math.max(b, FLOOR_FRAC * ref)
98  return b > 0 ? b : null
99}
100
101/**
102 * 時間 `t` 的目標值:最後讀數 + 會飽和的外插 `b·τ·(1 − e^{−Δ/τ})`,`τ = 2 ×` 讀數間隔;
103 * 最多領先一個典型步幅(且不超過 `MAX_LEAD`),不超過 `CAP`。讀數該來沒來,外插就平了。
104 */
105export function target(fit: Fit, t: number, b: number | null): number {
106  const { ext, lead } = extrapolation(fit, t, b)
107  return Math.min(fit.pLast + Math.min(ext, lead), CAP)
108}
109
110/** 目標值每秒走幾個百分點:外插還沒到領先上限(與 `CAP`)時是 `b·e^{−Δ/τ}`,到了就是 0。 */
111export function targetRate(fit: Fit, t: number, b: number | null): number {
112  const { ext, lead, decay } = extrapolation(fit, t, b)
113  if (b === null || ext >= lead || fit.pLast + ext >= CAP) return 0
114  return b * decay
115}
116
117function extrapolation(fit: Fit, t: number, b: number | null): { ext: number; lead: number; decay: number } {
118  const dt = Math.max(0, t - fit.tLast)
119  const tau = Math.max(TAU_MIN, 2 * (fit.gap ?? H_FIRST / 2))
120  const decay = Math.exp(-dt / tau)
121  return { ext: b === null ? 0 : b * tau * (1 - decay), lead: Math.min(fit.step ?? MAX_LEAD, MAX_LEAD), decay }
122}
123
124/** 只有歷史、沒有讀數時的目標:經過時間 ÷ 歷史長度。 */
125export function targetByTime(elapsed: number, total: number): number {
126  return Math.min(CAP, (100 * elapsed) / total)
127}
128
129/**
130 * 控制器的一步:顯示值 `d` 經過 `dt` 秒之後;`tgt` 是這一拍結束時的目標,`vt` 是目標的速度(百分點 / 秒)。
131 * 只增不減、不超過 `CAP`(輸入先夾進 [0, CAP])。
132 *  - 落後:補上差距的 `1 − e^{−dt/CATCH_S}`(一階延遲,差一點點就直接貼上)。`tgt` 已經含這一拍目標的移動,
133 *    不另外加 `vt·dt` —— 否則高速率、小差距時一拍就越過目標(verify R2 #3:原本 step(0, 1, 10, 0.5) ≈ 5.5)。
134 *    目標以等速前進時,顯示值落後約 `vt · CATCH_S`。
135 *  - 剛好在目標上:不動(只剩最低速度)—— 下一拍目標往前了,就回到落後那一支。
136 *  - 超前:目標的速度乘上 `e^{g/GAIN}`,超前越多越慢(目標往回跳到新讀數之後,顯示值不凍住)。
137 *  - 最低速度 `(CAP − d) / CREEP_S`:不停,但讀數停住十分鐘,50% 附近只會多走約 8 個百分點;只有這一項會越過目標,
138 *    每拍最多 `(CAP − d) · dt / CREEP_S`。
139 */
140export function step(d: number, tgt: number, vt: number, dt: number): number {
141  const cur = clamp(d, 0, CAP)
142  const g = tgt - cur
143  let next = g > 0 ? cur + g * (1 - Math.exp(-dt / CATCH_S))
144    : g < 0 ? cur + vt * Math.exp(g / GAIN) * dt
145    : cur
146  if (g > 0 && tgt - next < SNAP) next = tgt
147  next = Math.max(next, cur + ((CAP - cur) / CREEP_S) * dt)
148  return Math.max(cur, Math.min(CAP, next))
149}
150
151/**
152 * 顯示值的初值(這個任務的列還沒有長條顯示值時,或剛從轉圈變成長條時):
153 *  - 依讀數:上次顯示值與讀數的較大者 —— 剛讀到時畫讀數(verify R1 #12)。
154 *  - 依經過時間:只延續上次的顯示值,沒有就從 0 —— 分段指令的讀數是「這一段」的,不能拿來當整體進度(verify R2 #4)。
155 * 一律夾進 [0, CAP]。
156 */
157export function seedDisplay(prev: number | undefined, reading: number | null, timeMode: boolean): number {
158  return clamp(timeMode ? (prev ?? 0) : Math.max(prev ?? 0, reading ?? 0), 0, CAP)
159}
160
161/** 這一拍的 dt(秒):`fromMs` 到 `toMs`,上限 `MAX_DT_S`、下限 0。 */
162export function frameDt(fromMs: number, toMs: number): number {
163  return Math.min(MAX_DT_S, Math.max(0, (toMs - fromMs) / 1000))
164}
165
166/** 讀數那一支的還要幾秒:從最後讀數到 100%;讀數已到 100% 或速率不是正的就不顯示(verify R1 #9)。 */
167export function etaFromReading(pLast: number, b: number): number | null {
168  const v = (100 - pLast) / b
169  return b > 0 && v > 0 ? v : null
170}
171
172// ── 歷史:完全相同的指令字串 → 最近幾次跑完的長度(ms)。store 裡只存字串的雜湊,不存原字串(verify R1 #11)。
173//    每次跑完寫一個新鍵 `h3:<雜湊>:<時間>:<隨機>`,值是 `{ms, phased}`:只新增、不讀改寫,同一個指令同時跑完兩次
174//    也不會互蓋;過期與淘汰只看鍵名,淘汰只刪「比留下的都舊」的鍵,跟並發的寫入不衝突(verify R2 #1、#2) ──
175
176export const HISTORY_PREFIX = 'h3:'
177export type Run = { ms: number; phased: boolean }
178
179const FNV_OFFSET = 0xcbf29ce484222325n
180const FNV_PRIME = 0x100000001b3n
181const MASK64 = (1n << 64n) - 1n
182const RUN_KEY = /^h3:([0-9a-f]{16}):(\d+):[0-9a-z]+$/
183
184/** 64 位元 FNV-1a(逐個 UTF-16 code unit):同一字串同一雜湊;16 位十六進位。 */
185export function hashCommand(command: string): string {
186  let h = FNV_OFFSET
187  for (let i = 0; i < command.length; i++) {
188    h = ((h ^ BigInt(command.charCodeAt(i))) * FNV_PRIME) & MASK64
189  }
190  return h.toString(16).padStart(16, '0')
191}
192
193export function median(xs: readonly number[]): number | null {
194  if (xs.length === 0) return null
195  const s = [...xs].sort((a, b) => a - b)
196  const m = Math.floor(s.length / 2)
197  return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2
198}
199
200/** 一次跑完的鍵。`nonce` 是 [0-9a-z]+,讓同一毫秒跑完的兩次不撞鍵。 */
201export function runKey(hash: string, at: number, nonce: string): string {
202  return `${HISTORY_PREFIX}${hash}:${at}:${nonce}`
203}
204
205/** 歷史的鍵 → 雜湊與時間;不是歷史的鍵(舊版的 `history-1`、`h2:` …)是 null。 */
206export function parseRunKey(k: string): { hash: string; at: number } | null {
207  const m = RUN_KEY.exec(k)
208  return m === null ? null : { hash: m[1]!, at: Number(m[2]) }
209}
210
211/** 讀回 store 的值:形狀不對是 null。 */
212export function asRun(v: unknown): Run | null {
213  if (typeof v !== 'object' || v === null || Array.isArray(v)) return null
214  const { ms, phased } = v as { ms?: unknown; phased?: unknown }
215  return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? { ms, phased: phased === true } : null
216}
217
218/** 一個指令的幾次:最近 5 次長度的中位數;分段只要有一次就記著。 */
219export function summarize(runs: readonly (Run & { at: number })[]): { prior: number | null; phased: boolean } {
220  const recent = [...runs].sort((a, b) => b.at - a.at).slice(0, MAX_RUNS)
221  return { prior: median(recent.map(r => r.ms)), phased: runs.some(r => r.phased) }
222}
223
224/** 90 天前寫的歷史鍵:session 開始時刪掉(不只是查詢時忽略,verify R2 #10)。 */
225export function expiredKeys(keys: readonly string[], now: number): string[] {
226  return keys.filter(k => {
227    const r = parseRunKey(k)
228    return r !== null && now - r.at > MAX_AGE_MS
229  })
230}
231
232/** 要淘汰的鍵:每個指令只留最近 5 次;最近一次跑完最晚的 200 個指令以外,整個指令刪掉。 */
233export function evictRunKeys(keys: readonly string[], maxRuns = MAX_RUNS, maxCommands = MAX_COMMANDS): string[] {
234  const byHash = new Map<string, { k: string; at: number }[]>()
235  for (const k of keys) {
236    const r = parseRunKey(k)
237    if (r === null) continue
238    byHash.set(r.hash, [...(byHash.get(r.hash) ?? []), { k, at: r.at }])
239  }
240  const newestFirst = (a: { k: string; at: number }, b: { k: string; at: number }) => b.at - a.at || (a.k < b.k ? 1 : -1)
241  const groups = [...byHash.values()].map(g => [...g].sort(newestFirst))
242  const out = groups.flatMap(g => g.slice(maxRuns).map(x => x.k))
243  const ranked = [...groups].sort((a, b) => newestFirst(a[0]!, b[0]!))
244  return [...out, ...ranked.slice(maxCommands).flatMap(g => g.slice(0, maxRuns).map(x => x.k))]
245}
246
247/** 待寫的最多 `max` 筆;超過時先丟沒等到通知的、再丟最舊的 —— 已確認跑完、還沒寫進去的盡量留著(verify R3 #7)。不改動 `list`。 */
248export function capPending(list: readonly Pending[], max = MAX_PENDING): Pending[] {
249  if (list.length <= max) return [...list]
250  const order = [...list].sort((a, b) => Number(a.status !== null) - Number(b.status !== null) || a.at - b.at)
251  const out = new Set(order.slice(0, list.length - max).map(p => p.id))
252  return list.filter(p => !out.has(p.id))
253}
254
hooks/parse.ts 95 lines
1/** 從一段輸出讀出的進度。 */
2export type Reading = { percent: number | null; line: string }
3
4const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
5// 百分比:`42%`、`42.5 %`、pytest 的 `[ 29%]`;大於 100 的數不算(像 `150%` 的縮放倍率)
6const PERCENT = /(\d{1,3}(?:\.\d+)?)\s?%/g
7const TAIL = 8192
8const LINE_MAX = 120
9
10/**
11 * 讀輸出的尾端:最後一個 0–100 的百分比,與最後一個非空行。
12 * 進度列常用 `\r` 原地重畫,所以 `\r` 也算換行;顏色碼先拿掉。
13 */
14export function readProgress(text: string): Reading {
15  const tail = text.slice(-TAIL).replace(ANSI, '')
16  let percent: number | null = null
17  for (const m of tail.matchAll(PERCENT)) {
18    const v = Number(m[1])
19    if (v <= 100) percent = v
20  }
21  const lines = tail.split(/\r\n|\r|\n/).map(l => l.trim()).filter(l => l !== '')
22  return { percent, line: (lines.at(-1) ?? '').slice(0, LINE_MAX) }
23}
24
25// `>` `>>` `1>` `&>` 後面的目標(`2>&1` 這種接到另一個 fd 的不算),以及 `tee [-a]` 的目標
26const REDIRECT = /(?:^|[\s;&|(])(?:\d?>>?|&>>?)\s*(?!&)("[^"]+"|'[^']+'|[^\s;&|()<>]+)/g
27const TEE = /\btee\s+(?:(?:-a|--append)\s+)?("[^"]+"|'[^']+'|[^\s;&|()<>]+)/g
28const VAR = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g
29
30/** 指令裡寫到檔的目標,原樣(變數還沒展開、引號已拿掉)。`/dev/null` 不算。 */
31export function redirectTargets(command: string): string[] {
32  const out: string[] = []
33  for (const rx of [REDIRECT, TEE]) {
34    for (const m of command.matchAll(rx)) {
35      const target = (m[1] ?? '').replace(/^["']|["']$/g, '')
36      if (target !== '' && target !== '/dev/null' && !out.includes(target)) out.push(target)
37    }
38  }
39  return out
40}
41
42/**
43 * 展開 `$VAR`、`${VAR}` 與開頭的 `~`;展開後是絕對路徑才回傳,否則 undefined
44 * (相對路徑要看指令當時的 cwd,`cd` 之後就猜不準,寧可不讀)。
45 */
46export function expandPath(raw: string, env: Readonly<Record<string, string | undefined>>): string | undefined {
47  let missing = false
48  let path = raw.replace(VAR, (_m, a: string | undefined, b: string | undefined) => {
49    const v = env[a ?? b ?? '']
50    if (v === undefined) missing = true
51    return v ?? ''
52  })
53  if (missing) return undefined
54  if (path === '~' || path.startsWith('~/')) {
55    const home = env.HOME
56    if (home === undefined) return undefined
57    path = home + path.slice(1)
58  }
59  return path.startsWith('/') ? path : undefined
60}
61
62const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
63
64/**
65 * `█` 與 `░` 畫的長條,寬 `width` 格;最後一格用八分之一格(`▏`–`▉`),解析度是 `width × 8`。
66 * 無條件捨去:只有 100% 才是滿格(執行中的上限 99.5% 不會畫成已經跑完)。
67 */
68export function bar(percent: number, width = 20): string {
69  const units = Math.max(0, Math.min(width * 8, Math.floor((percent / 100) * width * 8 + 1e-9)))
70  const full = Math.floor(units / 8)
71  const part = EIGHTHS[units % 8] ?? ''
72  return '█'.repeat(full) + part + '░'.repeat(width - full - (part === '' ? 0 : 1))
73}
74
75/** 百分比,一位小數,寬 6(`  0.0%`–`100.0%`)。 */
76export function pct(percent: number): string {
77  return `${percent.toFixed(1)}%`.padStart(6)
78}
79
80/** 還要多久:一分鐘以內以秒計,以上取到分;0 或負的(跑得比歷史久)寫「比平常久」。 */
81export function etaText(ms: number): string {
82  if (!(ms > 0)) return '比平常久'
83  if (ms < 60_000) return `還要 ${Math.ceil(ms / 1000)}s`
84  return `還要約 ${elapsed(Math.round(ms / 60_000) * 60_000)}`
85}
86
87/** 經過時間:`45s`、`12m`、`1h05m`。 */
88export function elapsed(ms: number): string {
89  const s = Math.max(0, Math.floor(ms / 1000))
90  if (s < 60) return `${s}s`
91  const m = Math.floor(s / 60)
92  if (m < 60) return `${m}m`
93  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
94}
95
types/index.d.ts 115 lines
1/** 指數權重 local linear 的狀態(hooks/est.ts)。時間是秒,從任務開始(或接上)算起;原點放在最後一個讀數。 */
2export type Fit = {
3  /** Σw */
4  s0: number
5  /** Σw·t */
6  s1: number
7  /** Σw·t² */
8  s2: number
9  /** Σw·p */
10  sy: number
11  /** Σw·t·p */
12  sty: number
13  /** Σw²(算有效樣本數) */
14  sww: number
15  /** 最後一個讀數的時間(秒) */
16  tLast: number
17  /** 最後一個讀數(%) */
18  pLast: number
19  /** 讀數間隔的 EW 平均(秒) */
20  gap: number | null
21  /** 每次讀數增加量的 EW 平均(百分點) */
22  step: number | null
23  /** 換階段(讀數往回掉)時從上一段帶過來的速率 */
24  carry: number | null
25}
26
27/** 一個在背景跑的指令:它的輸出檔與讀到的進度。 */
28export type Tracked = {
29  /** 背景任務的 id(Bash 結果的 backgroundTaskId) */
30  id: string
31  /** 顯示的名字:Bash 的 description,沒有就用指令開頭 */
32  label: string
33  /** 指令字串的雜湊(歷史的鍵,store 裡不存原字串);不是 shell 的背景工作是空字串 */
34  key: string
35  /** 要讀的檔:任務的輸出檔,加上指令裡 `>`、`>>`、`tee` 寫到的絕對路徑 */
36  files: string[]
37  /** 讀數取自哪一個檔(第一個讀到百分比的檔);兩個檔各有百分比時只看這一個 */
38  src: string | null
39  /** 開始的時間(epoch ms);接上的任務是接上的那一刻 */
40  startedAt: number
41  /** 開始時間是真的(Bash 開跑時記下的);接上的任務是 false,不寫歷史 */
42  startKnown: boolean
43  /** 同一個指令字串過去跑完的長度的中位數(ms);沒有歷史是 null */
44  prior: number | null
45  /** 歷史記著這個指令會分段(讀數到頂後又從頭開始) */
46  priorPhased: boolean
47  /** 讀到的最後一個百分比(0–100);讀不到是 null */
48  percent: number | null
49  /** 速率估計的狀態;還沒有讀數是 null */
50  fit: Fit | null
51  /** 這一次讀數往回掉過(多階段的指令) */
52  phased: boolean
53  /** 第幾段(從 1 起) */
54  phase: number
55  /** 輸出的最後一行(截短) */
56  line: string
57  /** 任務結束的時間;還在跑是 null */
58  endedAt: number | null
59  /** 結束時的狀態(completed、failed、killed;每一輪結束時清單裡沒有了是 ended);還在跑是 null */
60  status: string | null
61  /** 只是從背景清單推定結束,還沒收到結束通知 */
62  presumed: boolean
63  /** 結束那一刻畫面上的顯示值;沒有是 null */
64  final: number | null
65}
66
67/**
68 * 一次跑完、還沒寫進歷史的長度。與顯示的任務清單分開放:任務結束 15 秒後就從畫面收起,
69 * 晚到的結束通知仍找得到這一筆(verify R2 #3)。
70 */
71export type Pending = {
72  /** 背景任務的 id */
73  id: string
74  /** 指令字串的雜湊 */
75  key: string
76  /** 長度(ms):開始到最早得知結束的那一刻(推定結束或結束通知);沒有引擎的完成時間,是估計 */
77  ms: number
78  /** 這一次讀數往回掉過 */
79  phased: boolean
80  /** 結束通知的狀態;推定結束、還沒等到通知是 null */
81  status: string | null
82  /** 得知結束的時間(epoch ms):沒等到通知的,過一段時間丟掉 */
83  at: number
84  /** 寫入 store 失敗過幾次 */
85  tries: number
86  /** 這一次要寫的 store 鍵,建這一筆時就決定:重試與熱重載都寫同一個鍵,不會記成兩次(verify R3 #6) */
87  store: string
88}
89
90/** 畫面上的顯示值(每 250 ms 由顯示時鐘算出;畫出來的字串變了才寫)。 */
91export type Shown = {
92  /** 顯示的百分比 */
93  d: number
94  /** 轉圈的第幾格(每秒一格) */
95  frame: number
96  /** 還要幾秒;不顯示是 null */
97  eta: number | null
98  /** 沒有估計:畫轉圈 */
99  spin: boolean
100  /** 列尾的說明(分段時是這一段的真實讀數);沒有是 null */
101  note: string | null
102}
103
104declare module 'claude-code' {
105  interface PluginState {
106    'progress-bar': {
107      tasks: Shaped<Tracked[]>
108      now: number
109      outputDir: string | null
110      shown: Shaped<Record<string, Shown>>
111      pending: Shaped<Pending[]>
112    }
113  }
114}
115