SLOPSHOPPER

ctx-relay

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

newpanebandcommandtoastprompt
★ 1v0.9.1MITupdated 2026-10-08mangow314/mango-mods/ctx-relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx-relay
│ ┃ ctx-relay notes ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Cannot locate the harness root. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ q: close ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ctx-relay-status │ ⎿ ctx-relay: [ctx-relay] context 97400; handoff line 170000 (85% o │ ⎿ ctx-relay: auto handoff: idle │ ⎿ ctx-relay: background work: none │ ⎿ ctx-relay: cache: ttl 1h (subscription) · last request 0s ago · │ ⎿ ctx-relay: last handoff file: none │ │ ⟨Claude Code's own drawing⟩ 󰯉  97K/170K 57% · +0K $0.00 42s · cache 59m · Writing handoff file… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 󰯉  97K/170K 57% · +0K $0.00 42s · cache 59m · Writing handoff file…
Pane · ctx-relay notes
Cannot locate the harness root. q: close
README

ctx-relay

繁體中文版: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.

What it does

WhenWhat happens
The main conversation stops (Stop) with context ≥ the handoff lineIf 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 countdownPress 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 endsThe 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 /clearStays 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.

Thresholds

  • Compaction point: the auto-compact trigger Claude Code reports itself (autoCompactThreshold from $.session.usage({ breakdown })). With auto-compact off, the model's context window is used instead.
  • Handoff line: the 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.
  • Orange warning line: 88% of the handoff line.
  • Deferral cap: with background work, the handoff can be deferred at most to halfway between the handoff line and the compaction point.

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.

The band

ctx-relay band: icon, capsule progress bar, tokens, cost, cache countdown

One line, left to right:

  1. An icon and a 10-cell capsule progress bar, both colored by how close context is to the handoff line (tokens ÷ handoff line):
Tokens ÷ handoff lineIconColor
below 70%space invadergreen
70% up to the warning lineghostyellow
warning line up to the handoff lineskullorange
at or past the handoff lineskullvermilion

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.

  1. 220K/434K 51%: current context / handoff line, and the share.
  2. · +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.
  3. · cache 42m: the prompt cache countdown; see "Cache countdown". Hidden while a turn runs.
  4. Bars: context at the end of each recent turn, a full bar = at the handoff line. Only when the terminal is at least 110 columns wide.
  5. · <status>: handoff deferred (with the reason), writing the handoff file, failed, cancelled, or which file this conversation resumed from.
  • Foreground colors only; no backgrounds.
  • Below 80 columns the progress bar is dropped (the icon and numbers stay); whatever still does not fit is cut at the end.
  • During the countdown the whole line becomes 󰚌 Handoff in 42s · at 400K · send a message to postpone, with a Cancel [1] button next to it.
  • After a handoff (automatic, or 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.
  • If /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.
  • When another mod also draws a band (for example blast-radius, which draws Proceed / Cancel here in a narrow terminal), its content goes on top and the ctx-relay line below. When there is not enough height, or the other mod does not give way, the ctx-relay line can be hidden; see "Known limits".
  • The icons, the snowflake, and the capsule (U+EE00–EE05, in Nerd Fonts 3.0 and later) need a Nerd Font, for example Symbols Nerd Font as a fallback; without one they render as boxes.
  • Inside tmux, Claude Code uses only 256 colors by default. If tmux has RGB enabled, set CLAUDE_CODE_TMUX_TRUECOLOR=1 to get the exact colors.

Cache countdown

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.

  • Time left = TTL − (now − when the last main-conversation turn ended). Shown in minutes, in seconds under one minute, orange in the last 5 minutes, and 󰜗 cold once expired (the last turn's hit rate is hidden then).
  • Mods do not receive Claude Code's own cache state (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.
  • TTL, first match wins, in the order Claude Code uses (prompt caching):
  • FORCE_PROMPT_CACHING_5M set → 5 minutes
  • CLAUDE_CODE_PROMPT_CACHE_TTL (5m or 1h)
  • the promptCacheTtl setting (5m or 1h)
  • ENABLE_PROMPT_CACHING_1H set → 1 hour
  • a Claude subscription (the session reports rate limits) → 1 hour; otherwise (API key, cloud provider) → 5 minutes
  • Correction from what it sees: if you come back after more than 5 minutes but before the assumed TTL, and that turn's hit rate is below 40%, ctx-relay switches this session to 5 minutes (for example, a subscription that went over its included usage drops to 5 minutes). /ctx-relay-status shows why.
  • After the main conversation is compacted (/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.
  • Idle compaction: Claude Code can compact a long conversation by itself while you are idle, before the cache expires, and then shows "Compacted while idle, before the prompt cache expired". This is not in the docs; read from the 2.1.294 executable: it runs only when the cache is a one-hour one and still warm, context is at least 200K (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.
  • ctx-relay does not keep the cache warm. A request sent with $.model.fork does not read the main conversation's cache (see "Known limits"), so it cannot extend it.

Where handoff files go

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.

Waiting to be picked up (handoff-pickup)

ctx-relay pickup line: an unclaimed handoff file, press 1 to resume

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.

WhenWhat happens
A new conversation starts (no messages yet at startup; an old conversation reopened with --resume does not count), or after you run /clear yourselfLook 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
  • "Picked up" is recorded as <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.
  • A file is marked as picked up in three cases: you press resume; a message you send contains the full path of a handoff file (you pasted a resume prompt by hand); ctx-relay hands off automatically (marked before /clear, so the new conversation does not list it).
  • On first enable, ctx-relay stores the current time in its own store (pickupSince): handoff files modified before that count as picked up, so a fresh install does not list a pile of old files.
  • "How long ago" uses the file's modification time; "from" is the first 8 characters of the first session \<id>\`` in the handoff file, and is omitted if not found.
  • The message sent by pressing 1 is labeled as coming from ctx-relay (the mod sends it for you; the model sees the original text).
  • While the pickup line is shown, typing "1" into an empty prompt presses resume instead of typing. The countdown's cancel button is also 1, but the countdown only appears after a turn ends, when the pickup line is already gone, so the two never appear together.
  • If sending the resume prompt is blocked (for example by a hook in settings), the line stays and the file is not marked as picked up.

Commands

  • /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".
  • You can append your latest instruction, for example /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.
  • The instruction is passed verbatim to the fork for writing the "goal + latest instruction" and "next step" fields, and written verbatim into the handoff file header and the resume message (each line prefixed with > , so a ## in the instruction never becomes a handoff file heading).
  • With an instruction attached, the resume message tells the new conversation to check git state and then follow the instruction, without waiting for you to repeat it. Without one, as before: report the current state and wait for you.
  • Exception: when the handoff file is missing 硬約束 ("hard constraints"; the header's 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.

Known limits

  • Background work never times out: a long-running server defers the handoff until the cap forces a countdown; to hand off earlier, use /ctx-relay-now yes.
  • The background work the commands see is the list from the last time the main conversation stopped, plus the subagents running right now.
  • A subagent may appear in both Claude Code's background-work list and its subagent list, so the count in the deferral reason can be too high.
  • Tasks sent through agent-bridge and not yet answered are not detected: check them yourself before /clear.
  • With blast-radius, when the terminal is narrow enough that it draws its interception box where the band goes (it does at 80 columns; at 200 columns it uses a side pane and is unaffected), the ctx-relay line may be hidden during the interception. The buttons still work, and the band comes back when the interception ends.
  • The terminal needs at least 42 rows to fit both (measured on 2.1.289, with 2 files listed in the box, 11 rows in total: at 41 rows or fewer it shows "↓ 2 more"). The more files the box lists, the more rows it needs.
  • When blast-radius draws first, it does not pass the slot on to the next mod, so no height is enough. Which mod draws first depends on load order, which the official docs do not specify.
  • Writing the handoff file costs about as much as one turn on a cold cache. The fork does not read the main conversation's cache: measured on 2.1.294, a fork sent 30 seconds after a turn hit 43% (only the shared system prompt and tools), while the main conversation's next turn hit 100%.
  • After a compaction, the band's token count is still the reading from before it until the next turn ends.
  • The handoff content comes from the fork; the machine check only looks for empty fields and does not guarantee the content is correct. The new conversation is asked to check against git first.
  • After /clear, $.state is reset; module variables and timers survive (measured), so the mod cancels its timers before clearing.
  • Needs bash and git on PATH; outside git, also realpath (GNU, with -m) and sha256sum or shasum.
  • The handoff path algorithm is copied from mango's dotfiles hooks/_lib/harness-paths.sh, so both put handoff files in the same place; change one, change the other.
Source 2 files
hooks/register.tsx 1411 lines
1import { 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 驗證缺口」,不降級就會記成假 thin
types/index.d.ts 34 lines
1// 一筆主線回合結束時的讀數
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