提示框上方顯示每輪花費與距交接線的剩餘空間;越過自動交接線時倒數 60 秒,用 fork 產生交接檔後 /clear 並在新對話接續;新對話開場列出還沒人接手的交接檔,按 1 接續

繁體中文版:README.zh-TW.md. The band and commands are in English; the handoff files it writes and the resume prompts it sends are in Traditional Chinese.
A Claude Code mod that draws a band above the prompt: current context / the handoff line, this turn's cost, and how long the prompt cache stays warm. When the main conversation crosses the automatic handoff line, it hands off on its own: it writes a handoff file, runs /clear, and resumes in the new conversation. When a new conversation starts and there is a handoff file nobody has picked up, the band gets a second line, Pending handoff; press 1 to send the resume prompt.
Tested on: Claude Code 2.1.289 (0.5.0, loaded with --plugin-dir; tested the countdown, cancelling by button, cancelling by typing, a full automatic handoff, and running alongside blast-radius), and 2.1.294 (the current band and the cache countdown). The mods API is still in early access, so a Claude Code release may require changes.
| When | What happens |
|---|---|
| The main conversation stops (Stop) with context ≥ the handoff line | If Claude Code reports background work (shells, subagents, monitors, workflows, …) or a one-shot scheduled task, the handoff is deferred and the band shows why; recurring schedules do not block it. Otherwise a 60-second countdown starts |
| While deferred, context reaches the cap (halfway between the handoff line and the compaction point) | The countdown runs anyway; the handoff file header lists the work still running (its completion notice may never arrive) |
| Another Stop hook blocks the stop (the turn has not really ended) | No decision; wait for the stop that really ends the turn |
| During the countdown | Press Cancel (hotkey 1) to cancel; for the rest of this conversation it only reminds you. Sending any message only postpones it: if you are still past the line when that turn ends, the countdown starts again |
| A new turn starts during the countdown or preparation (a schedule, a background notification, a message from another session) | This switch is dropped; it decides again when the turn ends |
| The countdown ends | The mod collects git state and the progress INDEX → $.model.fork fills in only the body of the handoff skill's 8 fields, one === KEY === line per field (GOAL / FILES / VERIFIED / DIRTY / NEXT / NOTES / CONSTRAINTS / POINTERS) (gives up after 3 minutes without an answer) → the mod assembles the handoff file under 8 hard-coded Chinese ## headings, so the model has no chance to mistype a heading → machine check (a missing field is marked thin:; no section marker at all counts as a failure) → the source handoff file's coordination contract is appended verbatim → write the file and read it back → confirm the conversation has not changed → /clear → send the handoff file path and the pickup rules in the new conversation |
Any step fails before /clear | Stays in the original conversation, the band shows why, no retry |
The mod appends the coordination contract verbatim, without the model: a === CONTRACT === section written by the fork, and any ## 協調契約 ("coordination contract") section in its body, are always dropped. Any other ## line in the fork's body is demoted to ### , so the only second-level headings in a handoff file are the mod's own. A section whose key is mistyped (for example === VERIFED ===): that field is marked thin:, and its body is kept, appended at the end of 關鍵細節備忘 ("key details") with a note. A `ui-summary block in the fork's output (the summary the sitrep mod asks the model to append to each reply) is removed.
autoCompactThreshold from $.session.usage({ breakdown })). With auto-compact off, the model's context window is used instead.handoffTokens setting if set; if not (0), 85% of the compaction point. A setting above 85% of the compaction point (for example after switching to a 200K model) falls back to 85%, and /ctx-relay-status says so.Set the handoff line from the ctx-relay row in /config, or from a shell:
echo '{"handoffTokens": "400000"}' | claude plugin configure ctx-relay@mango-mods --values-stdin
A setting changed from the shell takes effect after restarting Claude Code or running /reload-plugins in the session. For end-to-end tests you can set it very low (for example 1).
Note: with CLAUDE_AUTOCOMPACT_PCT_OVERRIDE set, the compaction point Claude Code reports may not apply that percentage. Measured on 2.1.288: with CLAUDE_CODE_AUTO_COMPACT_WINDOW=600000 and CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=88, it reported 567,000 (=600,000−33,000). Measured on 2.1.289 with a 120,000 window: it reported 87,000, and compaction happened on the turn where context grew from 86,412 to 88,335; both the reported value and (120,000−20,000)×88% = 88,000 fall in that range, so it is impossible to tell which is right. With a 600K window the real compaction point may still be 510K, 528K, or 567K. If you use this environment variable, set handoffTokens.
When loaded with claude --plugin-dir, the installed copy's handoffTokens is not read, so the handoff line is the automatic value.

One line, left to right:
| Tokens ÷ handoff line | Icon | Color |
|---|---|---|
| below 70% | space invader | green |
| 70% up to the warning line | ghost | yellow |
| warning line up to the handoff line | skull | orange |
| at or past the handoff line | skull | vermilion |
The bar moves: every 250 ms a highlight steps right across the filled part, and the icon pulses. Once the prompt cache has expired (you are probably away), the animation stops; it starts again when the next turn ends.
220K/434K 51%: current context / handoff line, and the share.· +20K $0.50 12s: how much context this turn added, what it cost, and how long it took. When this turn's cache hit rate (summed over every request in the turn) is below 40%, 12% follows: the turn ran on a cold cache and cost more, usually because the cache expired while you were idle.· cache 42m: the prompt cache countdown; see "Cache countdown". Hidden while a turn runs.· <status>: handoff deferred (with the reason), writing the handoff file, failed, cancelled, or which file this conversation resumed from. Handoff in 42s · at 400K · send a message to postpone, with a Cancel [1] button next to it.Resume [1] on a pending one) the status reads Resumed from <file> · next: <first line of the next-step field> · /ctx-relay-notes, and an automatic handoff also shows a toast. The "next" part goes away as soon as you type./clear succeeded but the resume message could not be sent, the line reads Cleared, but <reason>. Type: 讀 <path> 並依其接續. The part after "Type:" stays in Chinese because it is what you paste into the conversation.CLAUDE_CODE_TMUX_TRUECOLOR=1 to get the exact colors.The prompt cache lets the next request re-read the conversation cheaply. It expires after a stretch with no requests (its time to live, TTL), and every request restarts the clock. The countdown tells you how long it has left, so you can decide whether to keep going, compact first, or start a new session.
cold once expired (the last turn's hit rate is hidden then).prompt_cache is only in the status line's input), so ctx-relay works it out itself. It measures from the end of the turn, which is later than the start of the turn's last request, so it can run long by about the length of the last reply. Treat it as a guide.FORCE_PROMPT_CACHING_5M set → 5 minutesCLAUDE_CODE_PROMPT_CACHE_TTL (5m or 1h)promptCacheTtl setting (5m or 1h)ENABLE_PROMPT_CACHING_1H set → 1 hour/ctx-relay-status shows why./compact, auto-compact, or idle compaction), the old cache no longer matches the start of the conversation, so the countdown is dropped until the next turn ends.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS, no lower than 100K), you have been idle for about 90% of the TTL (about 54 minutes), and Anthropic has turned the feature on for your account. Turn it off with "idleCompaction": false in settings. It shrinks the context, so the handoff line is not reached.$.model.fork does not read the main conversation's cache (see "Known limits"), so it cannot extend it.In a git repo: <git-common-dir>/harness/handoff/; outside git: ~/.claude/harness/<directory name>-<first 8 chars of sha256>/handoff/. File name: <timestamp>-<slug>-<first 8 chars of the source session id>-<batch number>.md, so two batches in the same second, or two sessions sharing one git-common-dir, never overwrite each other. If <same root>/progress/<session id>/INDEX.md exists, it is passed to the fork as well.

The line reads Pending handoff: <file name> (2h ago, from 1f3a9c2e), with a Resume [1] button. When more handoff files are waiting, a +N follows.
| When | What happens |
|---|---|
A new conversation starts (no messages yet at startup; an old conversation reopened with --resume does not count), or after you run /clear yourself | Look for handoff files in the handoff folder that nobody has picked up, and show the newest; the count of the others goes in +N |
| Press 1 while the prompt is empty (or click the button) | Sends "讀 <full path> 並依其接續執行;先確認 git 狀態與下一步再動手。" ("Read <full path> and continue from it; check git state and the next step before acting."), and marks this file as picked up |
| The main conversation starts a new turn (you send a message, press resume, a schedule fires) | The line goes away |
<handoff folder>/.picked/<handoff file name> (an empty file); the handoff file itself is not touched. Because the folder name starts with a dot, the handoff skill's ls -t for the newest handoff file does not see it./clear, so the new conversation does not list it).pickupSince): handoff files modified before that count as picked up, so a fresh install does not list a pile of old files.session \<id>\`` in the handoff file, and is omitted if not found./ctx-relay-status: shows thresholds, readings, handoff state, background work, the cache TTL (with where it came from) and time left, and the last three handoff failures. Every failed handoff (before /clear) is appended to <harness root>/ctx-relay/failures.jsonl (time, auto or manual, tokens, minutes since you last typed, reason; last 100 kept)./ctx-relay-notes: opens (or, typed again, closes) a notes pane for checking where things stood: the handoff file this conversation resumed from (or the newest one in handoff/), plus the phase from the INDEX.md of the session named in its header. After an automatic handoff it opens by itself once, without taking the prompt's focus, when the terminal is wide enough; otherwise the toast points to the command. Two pages: s status (next steps, don'ts, gaps, then goal, phase and files) and v evidence (the verified notes as written, and where the handoff came from). One accent color and gray text; red, yellow and green mark only the ✓ ▲ ✗ symbols. Long lines wrap. The pane paints its own dark background, so it reads the same over a transparent terminal. q closes it./ctx-relay-now: hand off right away; with background work running, type /ctx-relay-now yes. The fork instructions, the handoff file header, and the resume message in the new conversation all say this was a manual /ctx-relay-now handoff, not "crossed the automatic handoff line"./ctx-relay-now yes, a newline, then "do B and install the new mod". Only a first word of yes counts as confirmation; the rest is the instruction. Without background work, yes is not needed and all the arguments are the instruction.> , so a ## in the instruction never becomes a handoff file heading).thin lists 硬約束), the task's constraints may not have been written down, so the resume message instead asks the new conversation to first report how it plans to follow the instruction and wait for your confirmation./ctx-relay-now yes./clear./clear, $.state is reset; module variables and timers survive (measured), so the mod cancels its timers before clearing.bash and git on PATH; outside git, also realpath (GNU, with -m) and sha256sum or shasum.hooks/_lib/harness-paths.sh, so both put handoff files in the same place; change one, change the other.hooks/register.tsx 1411 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput, SessionContextUsage, Timer, TurnCompleteInput } from 'claude-code'
3
4import type { Auto, Cache, Limits, Pickup, Reading, Receipt } from '../types'
5
6// ctx-relay:提示框上方一行,顯示每輪花費與距交接線的剩餘空間;
7// 主對話停下(classic.Stop)時 context 越過自動交接線 → 引擎回報沒有背景工作就倒數 60 秒 →
8// mod 自己收集 git/INDEX、用 $.model.fork 產生交接檔、機器檢查後寫檔 → /clear → 在新對話送出交接檔路徑。
9// 倒數或準備中有新回合開始(排程、通知、你送訊息)就作廢這次切換。
10// 壓縮點取自 Claude Code 自己回報的 autoCompactThreshold,本檔不寫窗口數字;
11// 交接線=userConfig handoffTokens,沒設或超過壓縮點的 HANDOFF_PCT 時用後者。
12//
13// /clear 之後(P1 probe 實測):$.state 歸零、模組變數與 $.clock 計時器保留、session.start 不重跑。
14// 所以要撐過 clear 的東西放模組變數,clear 前先取消計時器。
15//
16// handoff-pickup:新對話開場(session.start 且還沒有訊息,或 /clear 之後)找還沒人接手的交接檔,
17// band 多一行「待接手」+接續按鈕(hotkey 1);新回合開始就收掉。
18// 已接手=<root>/handoff/.picked/<檔名> 空檔:按接續、你送出的訊息含交接檔完整路徑、mod 自己自動交接時寫。
19// 第一次啟用前就有的交接檔(mtime 早於 $.store 的 pickupSince)一律算已接手。
20
21const HISTORY = 12
22const COUNTDOWN_MS = 60_000
23// fork 沒有取消參數:超過這個時間就放棄等待、記失敗,不 clear(回來的結果不再使用)
24const FORK_TIMEOUT_MS = 180_000
25const BARS = '▁▂▃▄▅▆▇█'
26const TAG = '[ctx-relay]'
27
28// handoff skill(~/.claude/skills/handoff/SKILL.md)的 8 欄位。中文標題由 mod 寫死,fork 只用左欄的 ASCII 鍵名分段填內文:
29// 模型沒有機會把標題打錯(2026-10-05 實例:標題一個形近字就過不了讀回檢查,整次交接中止)
30const SLOTS = [
31 ['GOAL', '目標 + 最新指令'],
32 ['FILES', '已改/將改檔'],
33 ['VERIFIED', '已驗證 vs 驗證缺口'],
34 ['DIRTY', 'dirty 無關項'],
35 ['NEXT', '下一步具體動作'],
36 ['NOTES', '關鍵細節備忘'],
37 ['CONSTRAINTS', '硬約束(結構化)'],
38 ['POINTERS', '指標'],
39] as const
40const FIELDS = SLOTS.map(([, title]) => title)
41// 已接手標記的資料夾:點開頭,handoff skill 用 `ls -t handoff/ | head -1` 找最新交接檔時看不到它
42const PICKED = '.picked'
43const SINCE_KEY = 'pickupSince'
44const CONTRACT = '協調契約'
45const CONSTRAINT_KEYS = ['stop_status', 'unresolved_prerequisite', 'responsible_authority', 'admissible_fallback'] as const
46
47// dark-daltonized 主題下可分辨的三態:天藍=正常、橘=過出場提醒線、朱紅=交接線/倒數/失敗
48const SKY = '#56B4E9'
49const ORANGE = '#E69F00'
50const VERMILION = '#D55E00'
51
52// band 樣式:只用前景色(深色底在 tmux 256 色下會變刺眼的 #00005f)。開頭一個圖示+進度條,跟著離交接線的比例變色
53const GREEN = '#009E73'
54const YELLOW = '#F0E442'
55const BAR_CELLS = 10
56// Nerd Font 的 Fira Code 進度字形:U+EE00/EE01/EE02=空心左/中/右,+3=實心
57const FIRA = 0xee00
58const FIRA_FILLED = 3
59// Raster 的顏色是 0xRRGGBB 整數;0x01000000=終端預設色
60const TRACK = 0x464e5a
61const TERMINAL_DEFAULT = 0x01000000
62// 動畫一幀(掃光往右一格、圖示呼吸)
63const FRAME_MS = 250
64// 比例=token ÷ 交接線:<70% 小怪獸(綠)、到提醒線前幽靈(黃)、過提醒線骷髏(橘)、過交接線骷髏(朱紅)
65const MOOD_GHOST = 0.7
66// 窄終端:<110 欄拿掉長條圖、<80 欄再拿掉進度條
67const WIDE_COLUMNS = 110
68const BAR_COLUMNS = 80
69const LABEL = '#7d8794'
70const VALUE = '#f5f7fa'
71const DOT = '#464e5a'
72// notes pane 自帶實心深底,不靠終端機透明背景;色值與對比(APCA Lc 為自算估值)見 scratchpad tui-ux/r3-color-ux.md。
73// 深底上 Okabe-Ito 原色偏暗(Lc 33–56),pane 內改用自訂深底語意色;色盲靠符號(✓ ▲ ✗)+文字區分。band 不動
74const N = {
75 PANE: '#181818',
76 CARD: '#262626',
77 RULE: '#5a616b',
78 TITLE: '#f5f7fa',
79 TEXT: '#e6e9ee',
80 MUTED: '#b1b8bf',
81 ACCENT: '#6CC0F0',
82 SUCCESS: '#3DD68C',
83 WARNING: '#F0C040',
84 DANGER: '#FF8F66',
85}
86const INVADER = '' // Nerd Font md-space_invaders U+F0BC9
87const GHOST = '' // md-ghost U+F02A0
88const SKULL = '' // md-skull U+F068C
89// cache 冷暖:熱=大多命中(便宜),冷=大多重算(貴,例如閒置超過 cache 存活時間)
90const SNOW = '' // md-snowflake U+F0717
91// 命中率低於這個才顯示(偏冷=這輪比較貴,多半是閒置超過快取存活時間)
92const CACHE_COLD = 40
93// 快取存活時間:Claude Code 訂閱額度內主對話 1h,API key/超額 5m(code.claude.com/docs/en/prompt-caching)
94const TTL_5M = 5 * 60_000
95const TTL_1H = 60 * 60_000
96// 剩這麼多以內倒數改橘色
97const CACHE_SOON_MS = 5 * 60_000
98
99// 自動交接線=壓縮點的 85%(大輪 +45K+交接輪 +19K 仍在壓縮點前);橘色提醒線=交接線的 88%
100const HANDOFF_PCT = 85
101const NUDGE_PCT = 88
102
103// harness 狀態根目錄:git repo → <git-common-dir>/harness;非 git → ~/.claude/harness/<目錄名>-<sha256 前 8 碼>;
104// 任何一步失敗印空字串。照抄 mango 的 dotfiles hooks/_lib/harness-paths.sh(契約:vault Harness-State-Layer-Contract.md),
105// 刻意偏離該檔「hash 演算法只存一份」:plugin 要能在沒有那份 dotfiles 的環境獨立運作。改演算法時兩邊一起改。
106const ROOT_SH = [
107 'real=$(realpath -m "$1" 2>/dev/null) || true',
108 '[ -n "$real" ] || exit 0',
109 'common=$(git -C "$real" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || true',
110 'if [ -n "$common" ]; then',
111 ' common=$(realpath -m "$common" 2>/dev/null) || true',
112 ' [ -n "$common" ] && printf "%s/harness\\n" "$common"',
113 ' exit 0',
114 'fi',
115 '[ -n "${HOME:-}" ] || exit 0',
116 'hash=""',
117 'if command -v sha256sum >/dev/null 2>&1; then hash=$(printf "%s" "$real" | sha256sum 2>/dev/null) || true',
118 'elif command -v shasum >/dev/null 2>&1; then hash=$(printf "%s" "$real" | shasum -a 256 2>/dev/null) || true; fi',
119 'hash=${hash%% *}; hash=${hash:0:8}',
120 '[ -n "$hash" ] || exit 0',
121 'printf "%s/.claude/harness/%s-%s\\n" "$HOME" "${real##*/}" "$hash"',
122].join('\n')
123
124const readingsAtom = atom({ plugin: 'ctx-relay', key: 'readings' } as const, [] as Reading[])
125const receiptAtom = atom({ plugin: 'ctx-relay', key: 'receipt' } as const, null as Receipt | null)
126const limitsAtom = atom({ plugin: 'ctx-relay', key: 'limits' } as const, null as Limits | null)
127const autoAtom = atom({ plugin: 'ctx-relay', key: 'auto' } as const, { phase: 'idle' } as Auto)
128const cacheAtom = atom({ plugin: 'ctx-relay', key: 'cache' } as const, { lastRequestAt: null, turnStartedAt: null, ttlMs: TTL_1H, ttlSource: 'default', observed: '', keepalives: 0 } as Cache)
129
130// 模組變數:hot reload 會清掉;/clear 不會
131let tick: Timer | null = null
132// band 動畫計時器(startFrames);跟交接倒數的 tick 分開,disarm() 不會停到它
133let frameTimer: Timer | null = null
134let frame = 0
135let fireTimer: Timer | null = null
136let mainTurns = 0
137// 上一次主對話停下(classic.Stop)時引擎回報、會再叫醒這個 session 的工作;給指令用,換 session 就清掉
138let stopWork: string[] = []
139// 上一次切換的結果,撐過 /clear 顯示在新對話的 band;之後你自己 clear 就清掉
140let lastHandoff: { path: string; error?: string } | null = null
141// mod 自己正在執行 /clear(這時的 session.end 不清 lastHandoff)
142let ownClear = false
143// 準備批次編號:每次開始準備或取消都 +1;準備流程每個檢查點都要求編號沒變,
144// 避免「取消 A → /ctx-relay-now 開 B → A 的 fork 回來」時 A 誤用 B 的 preparing 狀態
145let prepGen = 0
146// userConfig handoffTokens(0=自動);改設定會重新載入模組,所以每次 register 讀一次就好
147let handoffTokens = 0
148// 待接手的交接檔(撐過 /clear)與 session.start 時算好的 harness root(prompt.submit 比對路徑用,免得每則訊息都跑 bash)
149let pickup: Pickup | null = null
150let pickupRoot = ''
151// 接手後 band 上的「下一步」(取自交接檔的「下一步具體動作」第一行);你一打字就收掉
152let resumeNext = ''
153// /ctx-relay-notes pane 的內容:打開時讀檔整理一次存在這裡,render 只畫(band 每 250ms 重畫,不能每幀讀檔)
154let notes: Notes | null = null
155type NotesView = 'resume' | 'evidence'
156let notesView: NotesView = 'resume'
157const NOTES = 'ctx-relay-notes'
158// 你上一次親手送出訊息的時間(交接失敗紀錄用:失敗時你離開多久)
159let lastTypedAt: number | null = null
160
161export const register: Register = (on, options) => {
162 handoffTokens = typeof options.handoffTokens === 'number' ? options.handoffTokens : 0
163
164 // 你手動 /clear 或 session 結束:停掉倒數與進行中的準備(計時器會撐過 clear)
165 on('session.end', async ($, e, next) => {
166 disarm()
167 prepGen += 1
168 stopWork = []
169 if (!ownClear) lastHandoff = null
170 pickup = null
171 // 你手動 /clear:接下來是新對話(session.start 不會再跑),在這裡找待接手的交接檔。
172 // mod 自己的 clear 不找:它接著就送出交接檔路徑
173 if (e.reason === 'clear' && !ownClear) {
174 await scanPickup($)
175 $.ui.invalidate('ui.render')
176 }
177 return next(e)
178 })
179
180 on('session.start', async ($, e, next) => {
181 const result = await next(e)
182 const usage = await $.session.usage({ breakdown: 'summary' })
183 // 第一輪也要有收據:沒有歷史時記一筆起點基準(reload 時歷史還在,不覆蓋)
184 const tokens = usage.context.tokens ?? 0
185 await update($, readingsAtom, list => (list.length > 0 ? list : [{ tokens, costUsd: usage.cost?.usd ?? 0 }]))
186 await update($, limitsAtom, () => deriveLimits(usage.context))
187 await rearm($)
188 pickupRoot = await harnessRoot($)
189 startFrames($)
190 // 還沒有任何訊息=新對話;--resume 接回的舊對話與 hot reload 不找
191 if (await $.session.messages().then(m => m.length === 0, () => false)) {
192 await scanPickup($)
193 }
194 $.ui.invalidate('ui.render')
195 // 名稱衝突會丟例外並中斷本 hook,所以放最後並各自包住
196 for (const [name, description] of [
197 ['ctx-relay-status', 'ctx-relay: thresholds, readings, auto handoff state and background work'],
198 ['ctx-relay-now', 'ctx-relay: write a handoff file now and /clear to resume (add yes when background work runs; text after it is passed on)'],
199 [NOTES, 'ctx-relay: open or close a pane with the latest handoff file and its session progress (INDEX.md)'],
200 ] as const) {
201 try {
202 await $.command.register({ name, description })
203 } catch (err) {
204 $.ui.log(`${TAG} failed to register /${name}: ${String(err)}`)
205 }
206 }
207 return result
208 })
209
210 on('turn.complete', async ($, e, next) => {
211 const result = await next(e)
212 if (e.agentId) {
213 return result
214 }
215 mainTurns += 1
216 await takeReading($, e)
217 $.ui.invalidate('ui.render')
218 return result
219 })
220
221 // 主對話停下:要不要交接在這裡判斷,因為引擎在這裡回報背景工作與排程(shell、子代理、monitor、workflow…)
222 on('classic.Stop', async ($, e, next) => {
223 const result = await next(e)
224 // 別的 Stop hook 擋下=回合其實沒結束,等真正停下的那次再判斷
225 if (result.block !== undefined) {
226 return result
227 }
228 stopWork = [
229 ...(e.background_tasks ?? []).map(t => `${t.type} ${t.description}`.slice(0, 80)),
230 // 一次性排程會再叫醒這個 session;循環排程每次都會醒,不擋交接
231 ...(e.session_crons ?? []).filter(c => !c.recurring).map(c => `one-shot schedule ${c.prompt}`.slice(0, 80)),
232 ]
233 await afterStop($)
234 $.ui.invalidate('ui.render')
235 return result
236 })
237
238 on('prompt.submit', async ($, e, next) => {
239 // 你親手送出(或遠端轉來你的訊息)=人在場 → 這次倒數或準備作廢、回到 idle,
240 // 這輪結束時還在線上就重新倒數(只延後不停用,使用者 2026-10-08 選 A;要停用按取消鈕)
241 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
242 lastTypedAt = await $.clock.now()
243 if (resumeNext !== '') {
244 resumeNext = ''
245 $.ui.invalidate('ui.render')
246 }
247 const auto = await read($, autoAtom)
248 if (auto.phase === 'countdown' || auto.phase === 'preparing') {
249 disarm()
250 prepGen += 1
251 await setAuto($, { phase: 'idle' })
252 $.ui.invalidate('ui.render')
253 }
254 // 訊息裡帶交接檔完整路徑=你接手了那份(手動貼上的接續指令)
255 if (pickupRoot !== '' && e.text.includes('/handoff/')) {
256 for (const m of e.text.matchAll(handoffPattern(pickupRoot, 'g'))) await markPicked($, m[0])
257 }
258 }
259 return next(e)
260 })
261
262 // 主對話開了新回合(排程、背景通知、別的 session 傳訊;子代理不發 turn.start):
263 // 這次倒數或準備作廢、回到 idle,回合結束時再重新判斷(你親手送出的訊息在上面已先處理)
264 on('turn.start', async ($, e, next) => {
265 const startedAt = await $.clock.now()
266 await update($, cacheAtom, c => ({ ...c, turnStartedAt: startedAt }))
267 // 主對話開始跑(你送出、按接續、排程)=不再是新對話開場,待接手那行收掉
268 if (pickup !== null) {
269 pickup = null
270 $.ui.invalidate('ui.render')
271 }
272 const auto = await read($, autoAtom)
273 if (auto.phase === 'countdown' || auto.phase === 'preparing') {
274 disarm()
275 prepGen += 1
276 await setAuto($, { phase: 'idle' })
277 $.ui.invalidate('ui.render')
278 }
279 return next(e)
280 })
281
282 // 主對話真的壓縮了(/compact、自動、閒置壓縮):舊快取對不上新的對話開頭,倒數作廢,等下一輪結束再算
283 on('session.compact', async ($, e, next) => {
284 const result = await next(e)
285 if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result)) {
286 await update($, cacheAtom, c => ({ ...c, lastRequestAt: null, turnStartedAt: null }))
287 $.ui.invalidate('ui.render')
288 }
289 return result
290 })
291
292 // 只觀察:本 session 已載入 handoff skill(你手動交接)→ 之後不再自動交接
293 on('skill.prompt', { skill: 'handoff' }, async ($, e, next) => {
294 const auto = await read($, autoAtom)
295 if (auto.phase === 'idle' || auto.phase === 'deferred') {
296 await setAuto($, { phase: 'done', detail: 'Handed off manually; auto handoff off for this session' })
297 $.ui.invalidate('ui.render')
298 }
299 return next(e)
300 })
301
302 on('command.run', { command: 'ctx-relay-status' }, async $ => {
303 const limits = await read($, limitsAtom)
304 const auto = await read($, autoAtom)
305 const readings = await read($, readingsAtom)
306 const work = await runningWork($)
307 return {
308 text: [
309 `${TAG} context ${readings.at(-1)?.tokens ?? '?'}; handoff line ${limits?.handoff ?? '?'}${limits ? ` (${sourceLabel(limits.source)})` : ''}; compaction point ${limits?.fuse ?? '?'}; warning line ${limits?.nudge ?? '?'}; deferral cap ${limits?.cap ?? '?'}`,
310 `auto handoff: ${auto.phase}${auto.detail ? ` (${auto.detail})` : ''}`,
311 `background work: ${work.length === 0 ? 'none' : work.join(', ')}`,
312 ...(await cacheStatus($)),
313 `last handoff file: ${lastHandoff ? lastHandoff.path + (lastHandoff.error ? ` (${lastHandoff.error})` : '') : 'none'}`,
314 ...(await failureStatus($)),
315 ].join('\n'),
316 }
317 })
318
319 // 你打的指令=asked:任何寬度都畫;再打一次關掉
320 on('command.run', { command: NOTES }, async $ => {
321 if ((await $.ui.panes()).some(p => p.id === NOTES)) {
322 await $.ui.close({ id: NOTES })
323 return { text: `${TAG} closed the notes pane` }
324 }
325 notes = await loadNotes($)
326 notesView = 'resume'
327 await $.ui.open({ id: NOTES, title: 'ctx-relay notes', focus: true })
328 return { text: `${TAG} opened the notes pane` }
329 })
330
331 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
332 if (e.requestId !== NOTES) return next(e)
333 return drawNotes($, e)
334 })
335
336 on('command.run', { command: 'ctx-relay-now' }, async ($, e) => {
337 const auto = await read($, autoAtom)
338 if (auto.phase === 'preparing') {
339 return { text: `${TAG} already writing a handoff file` }
340 }
341 const work = await runningWork($)
342 const { isYes, note } = parseNowArgs(e.args)
343 if (work.length > 0 && !isYes) {
344 return { text: `${TAG} background work still running: ${work.join(', ')}. The handoff runs /clear, so their completion notices may never arrive; to go ahead type /ctx-relay-now yes (text after it is passed on)` }
345 }
346 disarm()
347 await setAuto($, { phase: 'preparing' })
348 $.ui.invalidate('ui.render')
349 const gen = ++prepGen
350 $.clock.after(0, () => void prepare($, gen, true, note))
351 return { text: `${TAG} writing a handoff file; then /clear and resume in the new conversation` }
352 })
353
354 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
355 if (e.props.hasSurvey) {
356 return next(e)
357 }
358 // band 只有一個:先讓排在下面的 mod 畫(例如 blast-radius 在窄終端機把 Proceed/Cancel 畫在這裡),
359 // 自己這行疊在它下面;只回自己的 tree 會把它整個蓋掉
360 const below = await next(e)
361 // 待接手那行獨立畫:/clear 後還沒有讀數時 drawBand 回 null,這行仍要出現
362 const mine = [await drawBand($, e), await drawPickup($, e)].filter((x): x is RenderElement => x !== null)
363 const [only, ...rest] = mine
364 if (only === undefined) return below
365 if (!below && rest.length === 0) return only
366 const { Box } = $.ui.resolve(e)
367 return (
368 <Box flexDirection="column">
369 {below}
370 {mine}
371 </Box>
372 )
373 })
374}
375
376// 沒有東西要畫時回 null
377async function drawBand($: EngineInterface, e: RenderInput<'AbovePrompt'>): Promise<RenderElement | null> {
378 const readings = await read($, readingsAtom)
379 const limits = await read($, limitsAtom)
380 const { Box, Button, Text } = $.ui.resolve(e)
381 if (readings.length === 0 || limits === null) {
382 // /clear 後還沒有讀數:送出失敗時新對話不會自己跑回合,這裡仍要畫出手動接續的指示
383 if (lastHandoff?.error) {
384 return (
385 <Text color={VERMILION} bold wrap="truncate-end">
386 {` ${SKULL} ${TAG} ${clearedButFailed(lastHandoff)} `}
387 </Text>
388 )
389 }
390 return null
391 }
392 const receipt = await read($, receiptAtom)
393 const auto = await read($, autoAtom)
394 const now = await $.clock.now()
395 const tokens = readings.at(-1)?.tokens ?? 0
396 const columns = e.props.bodyColumns
397
398 if (auto.phase === 'countdown') {
399 const left = Math.max(0, Math.ceil(((auto.deadline ?? now) - now) / 1000))
400 return (
401 <Box flexDirection="row">
402 <Text color={VERMILION} bold wrap="truncate-end">
403 {` ${SKULL} Handoff in `}
404 <Text color={VALUE}>{`${left}s`}</Text>
405 <Text color={LABEL}>{` · at ${k(limits.handoff)} · send a message to postpone `}</Text>
406 </Text>
407 <Button key="cancel" label="Cancel [1]" hotkey="1" onPress={() => cancel($, 'you pressed Cancel')} />
408 </Box>
409 )
410 }
411
412 let status = ''
413 let statusColor = LABEL
414 if (auto.phase === 'deferred') [status, statusColor] = [`Handoff deferred: ${auto.detail ?? ''}`, ORANGE]
415 if (auto.phase === 'preparing') [status, statusColor] = ['Writing handoff file…', VERMILION]
416 if (auto.phase === 'done') status = auto.detail ?? ''
417 if (auto.phase === 'failed') [status, statusColor] = [`Auto handoff failed: ${auto.detail ?? ''}. Hand off manually`, VERMILION]
418 if (auto.phase === 'cancelled') [status, statusColor] = [`Auto handoff cancelled (${auto.detail ?? ''}; reminders only)`, ORANGE]
419 if (auto.phase === 'idle' && lastHandoff) {
420 ;[status, statusColor] = lastHandoff.error
421 ? [clearedButFailed(lastHandoff), VERMILION]
422 : [`Resumed from ${basename(lastHandoff.path)}${resumeNext === '' ? '' : ` · next: ${resumeNext}`} · /${NOTES}`, LABEL]
423 }
424
425 // 圖示與進度條用比例色、標籤灰、數值白
426 const ratio = tokens / Math.max(limits.handoff, 1)
427 const [glyph, color] = mood(tokens, limits)
428 const sep = () => <Text color={DOT}>{' · '}</Text>
429 const icon = <Text color={breathe(color)} bold>{` ${glyph} `}</Text>
430 const cache = await read($, cacheAtom)
431 const isCold = cache.lastRequestAt !== null && cacheLeft(cache, now) <= 0
432 const segments = [
433 <Text color={VALUE} bold>{k(tokens)}</Text>,
434 <Text color={LABEL}>{`/${k(limits.handoff)} `}</Text>,
435 <Text color={color}>{`${Math.round(ratio * 100)}%`}</Text>,
436 ]
437 if (receipt) {
438 segments.push(sep(), <Text color={VALUE} bold>{`${signed(receipt.deltaTokens)} $${receipt.deltaCost.toFixed(2)}`}</Text>)
439 segments.push(<Text color={LABEL}>{` ${duration(receipt.durationMs)}`}</Text>)
440 // 快取已過期就不再顯示上一輪的命中率,免得一列兩個雪花
441 if (!isCold && receipt.cachePct !== null && receipt.cachePct < CACHE_COLD) {
442 segments.push(<Text color={SKY}>{` ${SNOW} ${receipt.cachePct}%`}</Text>)
443 }
444 }
445 if (!e.props.isWorking && cache.lastRequestAt !== null) {
446 const [text, c] = cacheLabel(cache, now)
447 segments.push(sep(), <Text color={c}>{text}</Text>)
448 }
449 if (columns >= WIDE_COLUMNS && readings.length >= 2) segments.push(<Text color={LABEL}>{` ${spark(readings, limits.handoff)}`}</Text>)
450 if (status !== '') segments.push(sep(), <Text color={statusColor}>{status}</Text>)
451 segments.push(<Text> </Text>)
452
453 const rest = <Text wrap="truncate-end">{segments}</Text>
454 // Raster 只有終端機有(桌面版沒有)
455 const Raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : null
456 if (Raster === null || columns < BAR_COLUMNS) return <Box flexDirection="row">{icon}{rest}</Box>
457 return (
458 <Box flexDirection="row">
459 {icon}
460 <Raster key="bar" columns={BAR_CELLS} rows={1} cells={bar(ratio, color)} />
461 <Text> </Text>
462 {rest}
463 </Box>
464 )
465}
466
467// 待接手那行;沒有待接手的交接檔時回 null
468async function drawPickup($: EngineInterface, e: RenderInput<'AbovePrompt'>): Promise<RenderElement | null> {
469 const p = pickup
470 if (p === null) return null
471 const { Box, Button, Text } = $.ui.resolve(e)
472 const now = await $.clock.now()
473 const segments = [
474 <Text color={GREEN} bold>{` ${INVADER} `}</Text>,
475 <Text color={LABEL}>{'Pending handoff: '}</Text>,
476 <Text color={VALUE} bold>{p.name}</Text>,
477 <Text color={LABEL}>{` (${ago(now - p.mtimeMs)}${p.from === '' ? '' : `, from ${p.from}`})`}</Text>,
478 ]
479 if (p.more > 0) segments.push(<Text color={ORANGE} bold>{` +${p.more}`}</Text>)
480 segments.push(<Text> </Text>)
481 return (
482 <Box flexDirection="row">
483 <Text wrap="truncate-end">{segments}</Text>
484 <Button key="pickup" label="Resume [1]" hotkey="1" onPress={() => pickUp($, p)} />
485 </Box>
486 )
487}
488
489// 已 /clear 但接續訊息沒送出去:引號裡是要你貼進對話的接續指令,維持中文
490function clearedButFailed(h: { path: string; error?: string }): string {
491 return `Cleared, but ${h.error ?? ''}. Type: 讀 ${h.path} 並依其接續`
492}
493
494// 壓縮點取引擎回報值;auto-compact 關掉時沒有壓縮點,改以模型窗為基準
495function deriveLimits(context: SessionContextUsage): Limits {
496 const fuse = context.breakdown?.autoCompactThreshold ?? context.window
497 const auto = Math.floor((fuse * HANDOFF_PCT) / 100)
498 // 設定值超過自動上限(例:400K 設定換到 200K 模型)會在交接前先被壓縮,改用自動值
499 const handoff = handoffTokens > 0 ? Math.min(handoffTokens, auto) : auto
500 const source = handoffTokens <= 0 ? 'auto' : handoffTokens <= auto ? 'config' : 'capped'
501 // 背景工作一直不結束時,延後到交接線與壓縮點的中點就強制倒數(使用者 2026-10-04 選 B)
502 const cap = handoff + Math.floor((fuse - handoff) / 2)
503 return { window: context.window, fuse, nudge: Math.floor((handoff * NUDGE_PCT) / 100), handoff, cap, source }
504}
505
506// /ctx-relay-status 的快取行
507async function cacheStatus($: EngineInterface): Promise<string[]> {
508 const cache = await read($, cacheAtom)
509 if (cache.lastRequestAt === null) return ['cache: no request yet']
510 const now = await $.clock.now()
511 const ttl = cache.ttlMs === TTL_1H ? '1h' : '5m'
512 const left = cacheLeft(cache, now)
513 const lines = [`cache: ttl ${ttl} (${cache.ttlSource}) · last request ${duration(now - cache.lastRequestAt)} ago · ${left <= 0 ? 'cold' : `${cacheLabel(cache, now)[0].replace(/^cache /, '')} left`}`]
514 if (cache.observed !== '') lines.push(`cache note: switched to 5m, ${cache.observed}`)
515 return lines
516}
517
518function sourceLabel(source: Limits['source']): string {
519 if (source === 'config') return 'setting handoffTokens'
520 if (source === 'capped') return `setting ${handoffTokens} is above ${HANDOFF_PCT}% of the compaction point; using that instead`
521 return `${HANDOFF_PCT}% of the compaction point`
522}
523
524async function takeReading($: EngineInterface, e: TurnCompleteInput) {
525 const usage = await $.session.usage({ breakdown: 'summary' })
526 const tokens = usage.context.tokens ?? 0
527 const costUsd = usage.cost?.usd ?? 0
528 const readings = await read($, readingsAtom)
529 const prev = readings.at(-1) ?? null
530 if (prev) {
531 const u = e.usage
532 const total = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
533 await update($, receiptAtom, () => ({
534 deltaTokens: tokens - prev.tokens,
535 deltaCost: costUsd - prev.costUsd,
536 durationMs: e.durationMs,
537 cachePct: u && total > 0 ? Math.round((u.cache_read_input_tokens * 100) / total) : null,
538 }))
539 }
540 await update($, readingsAtom, list => [...list, { tokens, costUsd }].slice(-HISTORY))
541 // 快取倒數只是附加資訊:讀不到 env/settings 也不能拖垮讀數與交接判斷
542 await noteCache($, e).catch(err => $.ui.log(`${TAG} cache countdown skipped: ${String(err)}`))
543 await update($, limitsAtom, () => deriveLimits(usage.context))
544}
545
546// 主線回合結束:記下時間、這段閒置的保溫次數歸零、推定 TTL;
547// 觀測修正:閒置超過 5 分鐘但還沒到推定 TTL,這輪卻偏冷 → 本 session 改判 5m(例如訂閱超額改扣用量)
548async function noteCache($: EngineInterface, e: TurnCompleteInput) {
549 const now = await $.clock.now()
550 const cache = await read($, cacheAtom)
551 const u = e.usage
552 const total = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
553 const pct = u && total > 0 ? Math.round((u.cache_read_input_tokens * 100) / total) : null
554 const idle = cache.lastRequestAt !== null && cache.turnStartedAt !== null ? cache.turnStartedAt - cache.lastRequestAt : null
555 let observed = cache.observed
556 if (observed === '' && idle !== null && pct !== null && pct < CACHE_COLD && idle > TTL_5M && idle < cache.ttlMs) {
557 observed = `came back cold (${pct}%) after ${duration(idle)} idle`
558 }
559 const ttl = observed !== '' ? { ttlMs: TTL_5M, ttlSource: 'observed' } : await resolveTtl($)
560 await update($, cacheAtom, () => ({ lastRequestAt: now, turnStartedAt: null, ...ttl, observed, keepalives: 0 }))
561 startFrames($)
562}
563
564// band 動畫,快取倒數也靠它每幀更新。快取冷了=人多半不在(閒置超過 TTL),停下省得整晚每秒重畫 4 次;
565// 下一輪結束再開
566function startFrames($: EngineInterface) {
567 frameTimer?.cancel()
568 frameTimer = $.clock.every(FRAME_MS, () => {
569 void (async () => {
570 const cache = await read($, cacheAtom)
571 if (cache.lastRequestAt !== null && cacheLeft(cache, await $.clock.now()) <= 0) {
572 frameTimer?.cancel()
573 frameTimer = null
574 } else frame += 1
575 $.ui.invalidate('ui.render')
576 })()
577 })
578}
579
580// TTL 依序:FORCE_PROMPT_CACHING_5M → CLAUDE_CODE_PROMPT_CACHE_TTL → 設定 promptCacheTtl → ENABLE_PROMPT_CACHING_1H →
581// 有 rateLimits(訂閱)1h、沒有 5m。rateLimits 取自上一次 API 回應,所以只在回合結束後判斷
582async function resolveTtl($: EngineInterface): Promise<{ ttlMs: number; ttlSource: string }> {
583 const ttlOf = (v: unknown) => (v === '5m' ? TTL_5M : v === '1h' ? TTL_1H : null)
584 const isOn = (v: string | undefined) => v !== undefined && v !== '' && v !== '0'
585 if (isOn(await $.env.get('FORCE_PROMPT_CACHING_5M'))) return { ttlMs: TTL_5M, ttlSource: 'FORCE_PROMPT_CACHING_5M' }
586 const fromEnv = ttlOf(await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'))
587 if (fromEnv !== null) return { ttlMs: fromEnv, ttlSource: 'CLAUDE_CODE_PROMPT_CACHE_TTL' }
588 const fromSetting = ttlOf((await $.settings.read().catch(() => ({} as Record<string, unknown>))).promptCacheTtl)
589 if (fromSetting !== null) return { ttlMs: fromSetting, ttlSource: 'promptCacheTtl setting' }
590 if (isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H'))) return { ttlMs: TTL_1H, ttlSource: 'ENABLE_PROMPT_CACHING_1H' }
591 const usage = await $.session.usage()
592 return usage.rateLimits.length > 0 ? { ttlMs: TTL_1H, ttlSource: 'subscription' } : { ttlMs: TTL_5M, ttlSource: 'no subscription' }
593}
594
595// 剩餘時間=TTL −(現在 − 上一次請求)
596function cacheLeft(cache: Cache, now: number): number {
597 return cache.lastRequestAt === null ? 0 : cache.ttlMs - (now - cache.lastRequestAt)
598}
599
600// ≥1 分鐘以分計、<1 分鐘以秒計、到了就是雪花 cold
601function cacheLabel(cache: Cache, now: number): [string, string] {
602 const left = cacheLeft(cache, now)
603 if (left <= 0) return [`${SNOW} cold`, SKY]
604 const text = left < 60_000 ? `cache ${Math.ceil(left / 1000)}s` : `cache ${Math.floor(left / 60_000)}m`
605 return [text, left <= CACHE_SOON_MS ? ORANGE : LABEL]
606}
607
608async function afterStop($: EngineInterface) {
609 const auto = await read($, autoAtom)
610 const limits = await read($, limitsAtom)
611 if ((auto.phase !== 'idle' && auto.phase !== 'deferred') || limits === null) {
612 return
613 }
614 // 讀當下的 token 數:Stop 和 turn.complete 誰先發沒有文件保證(不要 breakdown,免費)
615 const tokens = (await $.session.usage()).context.tokens ?? 0
616 if (tokens < limits.handoff) {
617 return
618 }
619 // 還有背景工作就先不交接:它結束時的通知會再跑一個主線回合,到時再判斷;
620 // 到上限還沒結束就照樣倒數(交接檔會寫明還在跑的工作),免得滑到壓縮點被原生摘要取代
621 const work = await runningWork($)
622 if (work.length > 0 && tokens < limits.cap) {
623 await setAuto($, { phase: 'deferred', detail: `${work.length} background task${work.length === 1 ? '' : 's'} still running (forced at ${k(limits.cap)}; /ctx-relay-now yes hands off now)` })
624 return
625 }
626 const now = await $.clock.now()
627 await setAuto($, { phase: 'countdown', deadline: now + COUNTDOWN_MS })
628 arm($, COUNTDOWN_MS)
629}
630
631// 在跑的子代理+上一次 Stop 時引擎回報的背景工作與一次性排程。不設逾時作廢(使用者 2026-10-03 選 B):
632// 常駐 server 會一直延後,band 顯示原因,要交接就用 /ctx-relay-now yes
633async function runningWork($: EngineInterface): Promise<string[]> {
634 let agents: string[] = []
635 try {
636 agents = (await $.agent.list()).filter(a => a.status === 'running').map(a => `subagent ${a.description || a.id}`)
637 } catch (err) {
638 // 查不到就當作可能有工作在跑(寧可延後,/ctx-relay-now yes 可強制)
639 agents = [`cannot list subagents (${String(err).slice(0, 80)})`]
640 }
641 return [...agents, ...stopWork]
642}
643
644function arm($: EngineInterface, ms: number) {
645 disarm()
646 tick = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
647 fireTimer = $.clock.after(Math.max(0, ms), () => {
648 void fire($)
649 })
650}
651
652function disarm() {
653 tick?.cancel()
654 fireTimer?.cancel()
655 tick = null
656 fireTimer = null
657}
658
659// hot reload 後:倒數中就依剩餘時間重新掛計時器;準備到一半被 reload 中斷 → 記失敗,不自動重來
660async function rearm($: EngineInterface) {
661 const auto = await read($, autoAtom)
662 if (auto.phase === 'preparing') {
663 await setAuto($, { phase: 'failed', detail: 'a reload interrupted the handoff' })
664 return
665 }
666 if (auto.phase !== 'countdown') {
667 return
668 }
669 const now = await $.clock.now()
670 arm($, (auto.deadline ?? now) - now)
671}
672
673async function cancel($: EngineInterface, why: string) {
674 disarm()
675 prepGen += 1
676 await setAuto($, { phase: 'cancelled', detail: why })
677 $.ui.invalidate('ui.render')
678}
679
680async function fire($: EngineInterface) {
681 disarm()
682 // 同一次 update 裡確認仍在倒數才取得準備權(期間你可能已取消)
683 const after = await update($, autoAtom, (a): Auto => (a.phase === 'countdown' ? { phase: 'preparing' } : a))
684 $.ui.invalidate('ui.render')
685 if (after.phase === 'preparing') {
686 await prepare($, ++prepGen, false, '')
687 }
688}
689
690// 收集 → fork → 檢查 → 寫檔讀回 → 確認來源沒變 → /clear → 送出。
691// /clear 之前任何一步失敗都留在原對話;/clear 之後失敗只能顯示交接檔路徑讓你手動接續。
692// isManual=你打 /ctx-relay-now 觸發:fork 指示、檔頭、接續訊息都寫「手動交接」,不寫「越過自動交接線」
693// note=你打 /ctx-relay-now 時附的最新指令(原話):交給 fork 寫 GOAL/NEXT,並原樣寫進檔頭與接續訊息
694async function prepare($: EngineInterface, gen: number, isManual: boolean, note: string) {
695 // 仍是本批次、而且仍在準備中(期間被取消、手動 clear 或另開一批都算不是)
696 // 先等狀態讀回來再比批次編號:比完才 await 的話,等待期間被取消又另開一批就會漏判
697 const isMine = async () => {
698 const phase = (await read($, autoAtom)).phase
699 return gen === prepGen && phase === 'preparing'
700 }
701 const fail = async (detail: string) => {
702 if (await isMine()) {
703 await setAuto($, { phase: 'failed', detail })
704 await recordFailure($, detail, isManual)
705 }
706 $.ui.invalidate('ui.render')
707 }
708 try {
709 if (!(await isMine())) return
710 const source = { id: await $.session.id(), turns: mainTurns }
711 const cwd = await $.session.cwd()
712 const root = await harnessRoot($)
713 if (root === '') return await fail('cannot locate the harness root')
714
715 const git = await gitTruth($, cwd)
716 if (git === null) return await fail('cannot read git state (git failed, and this is not a non-git directory)')
717 const index = await readOr($, `${root}/progress/${source.id}/INDEX.md`, '')
718 const sourceHandoff = await findSourceHandoff($, root)
719 const contract = sourceHandoff ? section(await readOr($, sourceHandoff, ''), CONTRACT) : ''
720 const readings = await read($, readingsAtom)
721 const limits = await read($, limitsAtom)
722 const tokens = readings.at(-1)?.tokens ?? 0
723 // 強制交接(延後到上限、/ctx-relay-now yes)時還在跑的工作:寫進檔頭,新對話才知道有通知會收不到
724 const work = await runningWork($)
725
726 const r = await Promise.race([
727 $.model.fork({ prompt: forkPrompt({ tokens, limits, git, index, contract, isManual, note }) }),
728 $.clock.sleep(FORK_TIMEOUT_MS).then(() => null),
729 ])
730 if (r === null) return await fail(`handoff fork timed out (${FORK_TIMEOUT_MS / 60_000} min)`)
731 if (!r.isAnswered) return await fail(`handoff fork failed: ${r.reason}`)
732 if (!(await isMine())) return
733
734 const split = splitSlug(r.text)
735 const slug = split.slug
736 const slots = parseSlots(split.body)
737 if (!SLOTS.some(([key]) => slots.has(key))) return await fail('the fork output has no recognised "=== KEY ===" section markers')
738 // 協調契約由 mod 原樣附上,不經模型:fork 寫的 CONTRACT 分段與內文裡的「## 協調契約」段都丟掉
739 const body = assemble(slots, contract)
740 const thin = checkThin(body)
741 const stamp = formatStamp(await $.clock.now())
742 const dir = `${root}/handoff`
743 // 檔名帶來源 session 與批次編號:同一秒、同 slug 的兩批(或共用 git-common-dir 的兩個 session)不會互相覆寫
744 const path = `${dir}/${stamp}-${slug}-${source.id.slice(0, 8)}-${gen}.md`
745 const header = [
746 `讀 ${path} 並依其接續執行;先確認 git 狀態與下一步再動手。`,
747 `- 時間戳:${stamp}`,
748 `- task slug:${slug}`,
749 `- 來源:branch \`${git.branch || '(非 git)'}\` · cwd \`${cwd}\` · 前一個 session \`${source.id}\`(ctx ≈${k(tokens)},由 ctx-relay mod ${isManual ? '依 /ctx-relay-now 手動交接' : '自動交接'})`,
750 '- unattended: true',
751 '- producer: ctx-relay-mod',
752 ...(work.length > 0 ? [`- 交接時仍在跑(完成通知可能收不到):${work.join('、')}`] : []),
753 ...(thin.length > 0 ? [`- thin: ${thin.join('、')}`] : []),
754 ...(note !== '' ? ['- 使用者交接時附的最新指令(原話):', quote(note)] : []),
755 ].join('\n')
756 const content = `${header}\n\n${body.trim()}\n`
757
758 const mk = await $.process.run(['mkdir', '-p', dir])
759 if (mk.exitCode !== 0) return await fail(`failed to create ${dir}`)
760 await $.fs.write(path, content)
761 const back = await readOr($, path, '')
762 if (!back.startsWith(`讀 ${path}`) || FIELDS.some(f => !hasHeading(back, f))) return await fail(`handoff file read-back incomplete: ${path}`)
763
764 // 準備期間你送了訊息、手動 clear 或又跑了一輪 → 交接檔已過時,作廢切換(檔案留著)
765 const currentId = await $.session.id()
766 if (!(await isMine())) return
767 if (currentId !== source.id || mainTurns !== source.turns) {
768 return await fail(`the conversation changed while preparing; switch dropped (handoff file kept at ${path})`)
769 }
770
771 disarm()
772 lastHandoff = { path }
773 // clear 前就記已接手:新對話開場不會先閃一行「待接手」;送出失敗時 band 另有手動接續的提示
774 await markPicked($, path)
775 ownClear = true
776 try {
777 await $.command.run({ command: 'clear' })
778 } catch (err) {
779 // clear 途中 session.end 可能已改了批次編號,這裡不經 isMine,直接記失敗
780 lastHandoff = null
781 await setAuto($, { phase: 'failed', detail: `/clear failed: ${String(err)} (handoff file at ${path})` })
782 $.ui.invalidate('ui.render')
783 return
784 } finally {
785 ownClear = false
786 }
787 // 實際引擎在 /clear 後會把 $.state 歸零;這裡再明確設回 idle,新對話才能再次自動交接
788 await setAuto($, { phase: 'idle' })
789 try {
790 const sent = await $.prompt.submit({ text: resumeText(path, slug, isManual, note, thin.includes('硬約束')) })
791 if (sent.drop !== undefined) lastHandoff = { path, error: `the resume message was blocked: ${sent.drop}` }
792 else {
793 resumeNext = firstLine(section(content, '下一步具體動作'))
794 // 交接後是 notes 唯一獨有價值的時刻(新對話沒有歷史,官方 recap 沒東西可總結):自動開一次,不搶輸入框焦點。
795 // 太窄放不下或開不了就退回 toast 提示;開 pane 只是顯示,失敗不影響交接
796 notes = await loadNotes($)
797 notesView = 'resume'
798 const shown = await $.ui.open({ id: NOTES, title: 'ctx-relay notes' }).then(o => o.isPlaced, () => false)
799 $.ui.toast(shown ? `${TAG} handed off to a new conversation; the notes pane shows the handoff (q closes)` : `${TAG} handed off to a new conversation; /${NOTES} shows the handoff file and progress`, { timeoutMs: 8000 })
800 }
801 } catch (err) {
802 lastHandoff = { path, error: `the resume message failed: ${String(err)}` }
803 }
804 $.ui.invalidate('ui.render')
805 } catch (err) {
806 await fail(`unexpected error: ${String(err)}`)
807 }
808}
809
810type Git = { branch: string; status: string; stat: string; log: string }
811
812// 非 git 目錄回傳空欄位(handoff skill 支援非 git);其他任何 git 失敗回 null,交接要停下
813async function gitTruth($: EngineInterface, cwd: string): Promise<Git | null> {
814 const run = async (args: string[]) => {
815 try {
816 const r = await $.process.run(['git', '-C', cwd, ...args])
817 // 只去尾端:git status --short 開頭的空白有意義(" M" 與 "M " 不同)
818 return { ok: r.exitCode === 0, out: r.stdout.trimEnd(), err: r.stderr }
819 } catch (err) {
820 return { ok: false, out: '', err: String(err) }
821 }
822 }
823 const branch = await run(['rev-parse', '--abbrev-ref', 'HEAD'])
824 if (!branch.ok) {
825 return /not a git repository/i.test(branch.err) ? { branch: '', status: '', stat: '', log: '' } : null
826 }
827 const status = await run(['status', '--short'])
828 const stat = await run(['diff', '--stat'])
829 const log = await run(['log', '--oneline', '-6'])
830 if (!status.ok || !stat.ok || !log.ok) return null
831 return { branch: branch.out, status: status.out, stat: stat.out, log: log.out }
832}
833
834// 交接失敗紀錄:<harness root>/ctx-relay/failures.jsonl,一次一行,只留最近 FAILURES_KEPT 筆。
835// 目前沒有失敗的持久紀錄,不知道多久失敗一次;先記下來,再決定要不要做 fork 失敗時的降級交接(使用者 2026-10-08 選 B)
836const FAILURES_KEPT = 100
837type Failure = { at: string; session: string; manual: boolean; tokens: number; idleMin: number | null; detail: string }
838
839async function recordFailure($: EngineInterface, detail: string, isManual: boolean) {
840 try {
841 const root = await harnessRoot($)
842 if (root === '') return $.ui.log(`${TAG} handoff failed but the harness root is unknown; failure not recorded: ${detail}`)
843 const now = await $.clock.now()
844 const entry: Failure = {
845 at: new Date(now).toISOString(),
846 session: await $.session.id(),
847 manual: isManual,
848 tokens: (await read($, readingsAtom)).at(-1)?.tokens ?? 0,
849 idleMin: lastTypedAt === null ? null : Math.round((now - lastTypedAt) / 60_000),
850 detail,
851 }
852 const dir = `${root}/ctx-relay`
853 const path = `${dir}/failures.jsonl`
854 const mk = await $.process.run(['mkdir', '-p', dir])
855 if (mk.exitCode !== 0) return $.ui.log(`${TAG} failed to create ${dir}; failure not recorded: ${detail}`)
856 const lines = (await readOr($, path, '')).split('\n').filter(l => l.trim() !== '')
857 await $.fs.write(path, [...lines, JSON.stringify(entry)].slice(-FAILURES_KEPT).join('\n') + '\n')
858 } catch (err) {
859 $.ui.log(`${TAG} failed to record a handoff failure: ${String(err)}`)
860 }
861}
862
863// /ctx-relay-status 的失敗段:筆數+最近 3 筆(新的在前)
864async function failureStatus($: EngineInterface): Promise<string[]> {
865 const root = await harnessRoot($)
866 if (root === '') return []
867 const lines = (await readOr($, `${root}/ctx-relay/failures.jsonl`, '')).split('\n').filter(l => l.trim() !== '')
868 if (lines.length === 0) return ['handoff failures: none recorded']
869 const recent = lines.slice(-3).reverse().map(l => {
870 try {
871 const f = JSON.parse(l) as Failure
872 return ` ${f.at} ${f.manual ? 'manual' : 'auto'} ctx ${k(f.tokens)}${f.idleMin === null ? '' : `, idle ${f.idleMin}m`}: ${f.detail}`
873 } catch {
874 return ` (unreadable line) ${l.slice(0, 80)}`
875 }
876 })
877 return [`handoff failures: ${lines.length} recorded (${root}/ctx-relay/failures.jsonl)`, ...recent]
878}
879
880// notes pane:這次接手的交接檔(沒有就拿 handoff/ 裡最新一份)整理成總覽卡,加上檔頭來源 session 的 INDEX.md 進度。
881// 先給結論、原文不進這頁(使用者 2026-10-08:原文直接塞進 pane 沒人想看,選 B 先做總覽頁+r/q)
882type Notes =
883 | { kind: 'none'; reason: string }
884 | {
885 kind: 'file'
886 path: string
887 name: string
888 mtimeMs: number
889 from: string
890 branch: string
891 how: string
892 thin: string
893 next: string[]
894 moreNext: number
895 goal: string
896 verified: string
897 gaps: string
898 raw: string[]
899 dont: string[]
900 task: string
901 phase: string
902 files: number
903 dirty: string
904 }
905
906async function loadNotes($: EngineInterface): Promise<Notes> {
907 const root = await harnessRoot($)
908 if (root === '') return { kind: 'none', reason: 'Cannot locate the harness root.' }
909 const dir = `${root}/handoff`
910 const list = (await $.fs.exists(dir)) ? (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.md')) : []
911 const want = lastHandoff ? basename(lastHandoff.path) : ''
912 const file = list.find(f => f.name === want) ?? list.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
913 if (file === undefined) return { kind: 'none', reason: 'No handoff file yet.' }
914 const path = `${dir}/${file.name}`
915 const text = await readOr($, path, '')
916 const session = /session `([^`\s]+)`/.exec(text)?.[1] ?? ''
917 const index = session === '' ? '' : await readOr($, `${root}/progress/${session}/INDEX.md`, '')
918 const items = (title: string) => section(text, title).split('\n').map(l => l.trim()).filter(l => l !== '' && !l.startsWith('```'))
919 const bullet = (l: string) => l.replace(/^(?:[-*]|\d+[.)])\s+/, '')
920 // 下一步:有清單就取最外層清單項(略過說明行),沒有清單就整段每行一項;子項併進上一項
921 const nextTop = outline(section(text, SLOTS[4][1]))
922 const listed = nextTop.filter(t => t.isList)
923 const next = (listed.length > 0 ? listed : nextTop).map(t => t.text)
924 const check = outline(section(text, SLOTS[2][1])).map(t => t.text)
925 const after = (re: RegExp) => check.filter(l => re.test(l)).map(l => l.replace(/^[^::]*[::]\s*/, '')).join(' · ')
926 const stop = /stop_status:\s*(.+)/.exec(section(text, SLOTS[6][1]))?.[1] ?? ''
927 return {
928 kind: 'file',
929 path,
930 name: file.name.replace(/\.md$/, ''),
931 mtimeMs: file.mtimeMs,
932 from: session.slice(0, 8),
933 branch: /branch[::]?\s*`?([^`((\s·]+)/.exec(text)?.[1] ?? '',
934 how: /手動交接/.test(text) ? 'manual handoff' : /自動交接/.test(text) ? 'auto handoff' : 'handoff skill',
935 thin: /^- thin: (.+)$/m.exec(text)?.[1] ?? '',
936 next: next.slice(0, 3),
937 moreNext: Math.max(0, next.length - 3),
938 goal: bullet(items(SLOTS[0][1])[0] ?? ''),
939 verified: after(/^已驗證/),
940 gaps: after(/^(?:缺口|驗證缺口|未驗證)/),
941 raw: check.slice(0, 2),
942 dont: stop.split(/[;;]/).map(x => x.trim()).filter(x => x !== ''),
943 task: /^#\s+(.+)$/m.exec(index)?.[1] ?? '',
944 phase: /^phase:\s*(.+)$/m.exec(index)?.[1] ?? '',
945 files: (l => l.filter(t => t.isList).length || l.length)(outline(section(text, SLOTS[1][1]))),
946 dirty: bullet(items(SLOTS[3][1])[0] ?? ''),
947 }
948}
949
950// 總覽卡(2026-10-09 TUI 競品研究+codex/agy 互評定稿,scratchpad tui-ux/design.md):
951// 一個強調色+灰階,語意色只染符號;順序 NEXT → DON'T → GAPS → 背景;DONE 原文與來源放 evidence 頁
952async function drawNotes($: EngineInterface, e: RenderInput<'Pane'>): Promise<RenderElement> {
953 const { Box, Button, Text } = $.ui.resolve(e)
954 const n = notes
955 const close = <Button key="close" plain hotkey="q" label="close" onPress={() => $.ui.close({ id: NOTES })} />
956 if (n === null || n.kind === 'none') {
957 return (
958 <Box flexDirection="column" backgroundColor={N.PANE} paddingX={1} paddingY={1}>
959 <Text color={N.TEXT}>{n?.reason ?? 'Not loaded.'}</Text>
960 <Box marginTop={1}>{close}</Box>
961 </Box>
962 )
963 }
964 const now = await $.clock.now()
965 // 區塊標題:符號染語意色、標籤亮白、數字前置,細線吃剩下的寬(扣 pane 左右 padding;不夠 3 格就不畫)
966 const head = (key: string, sym: string, symColor: string, label: string, count?: number) => {
967 const title = `${sym} ${count === undefined ? '' : `${count} `}${label} `
968 const fill = e.props.bodyColumns - 2 - title.length
969 return (
970 <Box key={key} flexDirection="row" marginTop={1}>
971 <Text color={symColor} bold>{`${sym} `}</Text>
972 <Text color={N.TITLE} bold>{`${count === undefined ? '' : `${count} `}${label}`}</Text>
973 {fill >= 3 ? <Text color={N.RULE}>{` ${'─'.repeat(fill)}`}</Text> : <Text>{''}</Text>}
974 </Box>
975 )
976 }
977 const para = (key: string, text: string, color = N.TEXT) => (
978 <Box key={key} paddingLeft={2}><Text color={color} wrap="wrap">{text}</Text></Box>
979 )
980 const field = (key: string, label: string, text: string) => (
981 <Box key={key} flexDirection="row">
982 <Box width={9} flexShrink={0}><Text color={N.MUTED}>{label}</Text></Box>
983 <Box flexGrow={1} flexShrink={1}><Text color={N.TEXT} wrap="wrap">{text}</Text></Box>
984 </Box>
985 )
986 const tab = (view: NotesView, hotkey: string, label: string) => (
987 <Box key={`tab-${view}`} backgroundColor={notesView === view ? N.CARD : undefined} paddingX={1}>
988 <Button key={`view-${view}`} plain hotkey={hotkey} label={label} dimColor={notesView !== view} onPress={() => showNotes($, view)} />
989 </Box>
990 )
991 const meta = [n.from === '' ? '' : `from ${n.from}`, n.how, n.thin === '' ? '' : `thin: ${n.thin}`].filter(x => x !== '').join(' · ')
992 const body: RenderElement[] = [
993 <Box key="title" flexDirection="row" backgroundColor={N.CARD} paddingX={1}>
994 <Text color={N.TITLE} bold wrap="truncate-end">{n.branch === '' ? n.name : n.branch}</Text>
995 <Box flexGrow={1} />
996 <Text color={N.MUTED}>{` ${ago(now - n.mtimeMs)}`}</Text>
997 </Box>,
998 <Box key="meta" paddingX={1}><Text color={N.MUTED} wrap="truncate-end">{meta}</Text></Box>,
999 <Box key="tabs" flexDirection="row" marginTop={1}>
1000 {tab('resume', 's', 'status')}
1001 {tab('evidence', 'v', 'evidence')}
1002 <Box flexGrow={1} />
1003 {close}
1004 </Box>,
1005 ]
1006 if (notesView === 'resume') {
1007 body.push(head('next-h', '▶', N.ACCENT, 'NEXT', n.next.length + n.moreNext))
1008 if (n.next.length === 0) body.push(para('next-none', 'No next step written; read the gaps and goal first.', N.MUTED))
1009 n.next.forEach((t, i) => body.push(
1010 <Box key={`next-${i}`} flexDirection="row">
1011 <Box width={2} flexShrink={0}><Text color={N.ACCENT}>{'▌'}</Text></Box>
1012 <Box width={3} flexShrink={0}><Text color={N.ACCENT} bold>{`${i + 1}.`}</Text></Box>
1013 <Box flexGrow={1} flexShrink={1}><Text color={i === 0 ? N.TITLE : N.TEXT} bold={i === 0} wrap="wrap">{t}</Text></Box>
1014 </Box>,
1015 ))
1016 if (n.moreNext > 0) body.push(<Box key="next-more" paddingLeft={5}><Text color={N.MUTED} wrap="wrap">{`+${n.moreNext} more in the handoff file (path on evidence)`}</Text></Box>)
1017 if (n.dont.length > 0) {
1018 body.push(head('dont-h', '✗', N.DANGER, "DON'T", n.dont.length))
1019 body.push(
1020 <Box key="dont" paddingLeft={2}>
1021 <Text wrap="wrap">{n.dont.flatMap((d, i) => [
1022 ...(i > 0 ? [<Text key={`ds-${i}`} color={N.MUTED}>{' · '}</Text>] : []),
1023 <Text key={`d-${i}`} color={N.TEXT}>{d}</Text>,
1024 ])}</Text>
1025 </Box>,
1026 )
1027 }
1028 body.push(head('gap-h', '▲', N.WARNING, 'GAPS'))
1029 body.push(n.gaps === '' ? para('gap', 'None recorded.', N.MUTED) : para('gap', n.gaps))
1030 body.push(<Box key="ctx-gap" marginTop={1} />)
1031 if (n.goal !== '') body.push(field('goal', 'goal', n.goal))
1032 if (n.task !== '' || n.phase !== '') body.push(field('phase', 'phase', [n.phase, n.task === '' ? '' : `(${n.task})`].filter(x => x !== '').join(' ')))
1033 body.push(field('files', 'files', `${n.files} listed in the handoff${n.dirty === '' ? '' : ` · dirty: ${n.dirty}`}`))
1034 } else {
1035 body.push(head('ok-h', '✓', N.SUCCESS, 'VERIFIED'))
1036 if (n.verified !== '') body.push(para('ok', n.verified))
1037 else if (n.raw.length > 0) {
1038 body.push(para('raw-h', 'Check notes (not classified):', N.MUTED))
1039 n.raw.forEach((t, i) => body.push(para(`raw-${i}`, t)))
1040 } else body.push(para('ok', 'No verified items recorded.', N.MUTED))
1041 body.push(head('src-h', '·', N.MUTED, 'SOURCE'))
1042 body.push(field('src-file', 'file', `${n.name}.md`))
1043 body.push(field('src-path', 'path', n.path))
1044 if (n.from !== '') body.push(field('src-from', 'session', n.from))
1045 body.push(field('src-how', 'how', n.how))
1046 if (n.dirty !== '') body.push(field('src-dirty', 'dirty', n.dirty))
1047 }
1048 return <Box flexDirection="column" backgroundColor={N.PANE} paddingX={1} paddingY={1}>{body}</Box>
1049}
1050
1051// 切分頁:換頁、重畫、捲回頂端(同一個 pane 共用捲動位置,不回頂會停在上一頁的位置)。
1052// 捲不動不影響換頁;測試環境沒有 ui.scroll 的實作會丟錯,所以吞掉
1053async function showNotes($: EngineInterface, view: NotesView) {
1054 notesView = view
1055 $.ui.invalidate('ui.render')
1056 await $.ui.scroll({ to: 'start', in: NOTES }).catch(() => undefined)
1057}
1058
1059// 段落第一個非空行,去掉清單符號與編號
1060function firstLine(text: string): string {
1061 const line = text.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
1062 return line.replace(/^(?:[-*]|\d+[.)])\s+/, '')
1063}
1064
1065async function readOr($: EngineInterface, path: string, fallback: string): Promise<string> {
1066 try {
1067 if (!(await $.fs.exists(path))) return fallback
1068 return await $.fs.read(path)
1069 } catch {
1070 return fallback
1071 }
1072}
1073
1074// 交接檔的完整路徑 <root>/handoff/<檔名>.md;檔名不含 /,.picked/ 底下的標記檔不算
1075function handoffPattern(root: string, flags = ''): RegExp {
1076 return new RegExp(`${escapeRegExp(root)}/handoff/[^\\s\`'"))/]+\\.md`, flags)
1077}
1078
1079// 本對話的來源交接檔:前幾則使用者訊息裡第一個指向 <root>/handoff/*.md 的路徑
1080async function findSourceHandoff($: EngineInterface, root: string): Promise<string | null> {
1081 try {
1082 const messages = await $.session.messages()
1083 const pattern = handoffPattern(root)
1084 for (const m of messages.filter(x => x.role === 'user').slice(0, 5)) {
1085 const hit = pattern.exec(m.text)
1086 if (hit) return hit[0]
1087 }
1088 } catch {
1089 return null
1090 }
1091 return null
1092}
1093
1094function forkPrompt(x: { tokens: number; limits: Limits | null; git: Git; index: string; contract: string; isManual: boolean; note: string }): string {
1095 const line = x.isManual
1096 ? `使用者打了 /ctx-relay-now 要求立刻交接(context ${k(x.tokens)})。交接檔寫完會直接 /clear,不會先給使用者確認`
1097 : `${x.limits ? `context 已達 ${k(x.tokens)},越過自動交接線 ${k(x.limits.handoff)}(壓縮點 ${k(x.limits.fuse)})` : 'context 已越過自動交接線'}。使用者不在場,這是無人值守交接`
1098 return [
1099 `${TAG} ${line}:請為接手這段工作的新對話寫交接檔內容。`,
1100 '只輸出交接檔內容:不要呼叫工具、不要寒暄、不要用 code fence 包住整份。',
1101 '第一行寫 `SLUG: <任務的 kebab-case 英文 slug>`,接著依序寫八欄:每欄以獨立一行 `=== 鍵名 ===` 開頭(鍵名照抄、該行不寫別的字),下一行起寫內文,每欄都要有內容。中文標題由 mod 補上,你不要自己寫 `## ` 標題:',
1102 '- `=== GOAL ===`:目標 + 最新指令 —— 當前任務一句話+使用者最新意圖(盡量用使用者原話)',
1103 '- `=== FILES ===`:已改/將改檔 —— 以下方 git 真相為準,列路徑與改了什麼',
1104 '- `=== VERIFIED ===`:已驗證 vs 驗證缺口 —— 跑過什麼(精確指令與結果)、還缺什麼;缺口不得寫成已完成',
1105 '- `=== DIRTY ===`:dirty 無關項 —— 與本任務無關的 worktree 變更,提醒勿誤 add;沒有寫「無」',
1106 '- `=== NEXT ===`:下一步具體動作 —— 新對話第一步做什麼',
1107 '- `=== NOTES ===`:關鍵細節備忘 —— 精確數字、完整錯誤訊息、絕對路徑、決策理由',
1108 '- `=== CONSTRAINTS ===`:硬約束(結構化) —— 一個 ```yaml 區塊,固定四鍵 stop_status / unresolved_prerequisite / responsible_authority / admissible_fallback,沒有值寫 none,不得省略鍵',
1109 '- `=== POINTERS ===`:指標 —— plan、spec、decisions 等更深檔案的路徑',
1110 '規則:「已改/將改檔」與你的對話記憶矛盾時以 git 真相為準,並在「關鍵細節備忘」註明修正。git 只證明檔案與 commit 狀態,證明不了測試或檢查跑過:「已驗證」只寫你在對話裡看過結果的項目,其餘列為缺口。',
1111 // 指令只在 /ctx-relay-now 的參數裡,fork 從對話記錄看不到
1112 ...(x.note !== ''
1113 ? ['', '### 使用者打 /ctx-relay-now 時附的最新指令(原話;mod 會原樣寫進檔頭與接續訊息)', 'GOAL 的最新指令與 NEXT 以這段為準:', x.note]
1114 : []),
1115 ...(x.contract !== ''
1116 ? ['', `### 來源交接檔的「${CONTRACT}」(mod 會把原文附在交接檔末尾;你不要寫這一欄,其他欄位要遵守它)`, x.contract]
1117 : []),
1118 '',
1119 '### git 真相(mod 剛剛收集)',
1120 `branch: ${x.git.branch || '(非 git repo)'}`,
1121 'git status --short:',
1122 x.git.status || '(乾淨)',
1123 'git diff --stat:',
1124 x.git.stat || '(無)',
1125 'git log --oneline -6:',
1126 x.git.log || '(無)',
1127 '',
1128 '### progress INDEX(參考來源之一,可能過時)',
1129 x.index.trim() || '(無)',
1130 ].join('\n')
1131}
1132
1133function splitSlug(text: string): { slug: string; body: string } {
1134 const m = /^\s*SLUG:\s*([a-z0-9][a-z0-9-]{0,60})\s*$/im.exec(text)
1135 const slug = m?.[1] ?? 'ctx-relay-auto'
1136 const body = m ? text.replace(m[0], '').trim() : text.trim()
1137 return { slug, body }
1138}
1139
1140// 照 handoff skill 無人值守分支的機器 gate:缺欄記 thin,不阻擋寫檔
1141function checkThin(body: string): string[] {
1142 const thin: string[] = FIELDS.filter(f => f !== '硬約束(結構化)' && section(body, f) === '')
1143 const constraints = section(body, '硬約束(結構化)')
1144 // 只准行內空白:用 \s 會跨行,把下一行的鍵名當成本鍵的值
1145 if (constraints === '' || CONSTRAINT_KEYS.some(key => !new RegExp(`^[ \\t]*${key}:[ \\t]*\\S`, 'm').test(constraints))) {
1146 thin.push('硬約束')
1147 }
1148 return thin
1149}
1150
1151function hasHeading(text: string, title: string): boolean {
1152 return new RegExp(`^##\\s+${escapeRegExp(title)}\\s*$`, 'm').test(text)
1153}
1154
1155// 「## 標題」到下一個「## 」之間的內容(去掉頭尾空白;只剩 code fence 標記也算空)
1156function section(text: string, title: string): string {
1157 const m = new RegExp(`^##\\s+${escapeRegExp(title)}\\s*$`, 'm').exec(text)
1158 if (!m) return ''
1159 const rest = text.slice(m.index + m[0].length)
1160 const end = rest.search(/^##\s/m)
1161 const body = (end === -1 ? rest : rest.slice(0, end)).trim()
1162 return body.replace(/```\w*/g, '').trim() === '' ? '' : body
1163}
1164
1165// 段落內文 → 最外層的項目;縮排更深的行(子清單、續行)併進上一項,以「 · 」串接。
1166// fork 會寫「- 缺口:」再把內容放在子項(2026-10-09 實例:notes pane 只讀冒號後面,缺口顯示成沒有)
1167function outline(body: string): { text: string; isList: boolean }[] {
1168 const LIST = /^(?:[-*]|\d+[.)])\s+/
1169 const lines = body.split('\n').filter(l => l.trim() !== '' && !l.trim().startsWith('```'))
1170 const indent = (l: string) => l.length - l.trimStart().length
1171 const top = Math.min(...lines.map(indent))
1172 const out: { text: string; isList: boolean; kids: string[] }[] = []
1173 for (const l of lines) {
1174 const t = l.trim()
1175 const last = out.at(-1)
1176 if (indent(l) > top && last) last.kids.push(t.replace(LIST, ''))
1177 else out.push({ text: t.replace(LIST, ''), isList: LIST.test(t), kids: [] })
1178 }
1179 return out.map(({ text, isList, kids }) => ({
1180 text: kids.length === 0 ? text : `${text}${/[::]$/.test(text) ? '' : ' '}${kids.join(' · ')}`,
1181 isList,
1182 }))
1183}
1184
1185// 與 sitrep 的 FENCE_RE 同形
1186const UI_SUMMARY_RE = /```ui-summary[^\n]*\n([\s\S]*?)\n?```[^\n]*\n?/g
1187
1188// fork 輸出的「=== 鍵名 ===」分段 → 鍵名→內文(含不認得的鍵,由 assemble 處置);同一鍵出現兩次就接起來,不丟內容
1189function parseSlots(raw: string): Map<string, string> {
1190 // sitrep 叫模型每輪回覆末尾附 ```ui-summary 區塊(給介面讀的一行 JSON);fork 照習慣也會附,混進交接檔只是雜訊
1191 const text = raw.replace(UI_SUMMARY_RE, '')
1192 const hits = [...text.matchAll(/^===[ \t]*([A-Z_]+)[ \t]*===[ \t]*$/gm)]
1193 const slots = new Map<string, string>()
1194 hits.forEach((m, i) => {
1195 const key = m[1] ?? ''
1196 const start = (m.index ?? 0) + m[0].length
1197 const end = hits[i + 1]?.index ?? text.length
1198 // 內文裡 fork 自己寫的「## 協調契約」段整段丟掉(契約只能來自 mod 附的原文,連降級留著都會誤導讀的人);
1199 // 其餘「## 」降成「### 」:交接檔的二級標題只能是 mod 寫的,否則 section() 會在那裡截斷、把該欄誤判為空。
1200 // 具體情境:剛換格式時模型照舊習慣在 === VERIFIED === 下再寫一行「## 已驗證 vs 驗證缺口」,不降級就會記成假 thintypes/index.d.ts 34 lines1// 一筆主線回合結束時的讀數
2export type Reading = { tokens: number; costUsd: number }
3
4// 上一個主線回合的收據
5export type Receipt = { deltaTokens: number; deltaCost: number; durationMs: number; cachePct: number | null }
6
7// 門檻(token 數):fuse=引擎回報的壓縮點;handoff 來源 auto=壓縮點 85%、config=handoffTokens、capped=設定值超過 auto 改用 auto;
8// cap=有背景工作時延後的上限(交接線與壓縮點的中點)
9export type Limits = { window: number; fuse: number; nudge: number; handoff: number; cap: number; source: 'auto' | 'config' | 'capped' }
10
11// 快取倒數:lastRequestAt=上一次主線回合(或保溫 fork)結束的時間,turnStartedAt=這一輪開始的時間(算閒置多久);
12// ttlMs/ttlSource=推定的快取存活時間與依據;observed=觀測修正的原因(有值=本 session 改判 5m);keepalives=這段閒置已保溫幾次
13export type Cache = { lastRequestAt: number | null; turnStartedAt: number | null; ttlMs: number; ttlSource: string; observed: string; keepalives: number }
14
15// 自動交接狀態機:idle →(deferred ⇄)countdown → preparing → 切換;failed/cancelled/done 為本對話終態。
16// $.state 在 /clear 後歸零,所以新對話一律從 idle 開始。
17export type AutoPhase = 'idle' | 'deferred' | 'countdown' | 'preparing' | 'done' | 'failed' | 'cancelled'
18export type Auto = { phase: AutoPhase; deadline?: number; detail?: string }
19
20// handoff-pickup:新對話開場時待接手的交接檔(最新一份);from=來源 session 前 8 碼(讀不到為空),more=其他待接手份數
21export type Pickup = { path: string; name: string; mtimeMs: number; from: string; more: number }
22
23declare module 'claude-code' {
24 interface PluginState {
25 'ctx-relay': {
26 readings: Reading[]
27 receipt: Receipt | null
28 limits: Limits | null
29 auto: Auto
30 cache: Cache
31 }
32 }
33}
34