Automatic context handoff: at configurable context thresholds the model writes a handoff file, then the conversation is reset (/clear or /compact) and resumed…

A Claude Code plugin that hands a long conversation over to a fresh context automatically.
This is version 4 of AutoHandoff, rewritten as a Claude Code mod (an in-process hooks module: no keystroke injection, no settings.json hooks). Version 3, the hook-and-keystroke implementation, is kept as a separate repository: DuanWeiye/AutoHandoff. Install one or the other, not both.
As the context fills up, AutoHandoff asks the model to write a handoff file (what is done, what is in progress, what comes next) and register it. When the model's turn has ended, AutoHandoff resets the conversation with /clear (or /compact) and sends a resume message, so the fresh conversation reads the handoff file and carries on. If the model never hands off, AutoHandoff compacts the conversation itself before Claude Code's own auto-compaction would.
| Stage | Default | What happens |
|---|---|---|
| Reminder | remind_at = 300k tokens | The model is told to finish the step in progress and, before the turn ends, write the handoff file and register it. |
| Hand off now | urge_at = 450k tokens | The model is told to stop at the current step and hand off right away. If it tries to end its turn without doing so, it is asked once more (once per turn). |
| Last resort | compact_at = 650k tokens | A turn that ends at or above this with no handoff registered is followed by /compact. Claude Code's own auto-compaction stays where your Claude Code settings put it. |
AutoHandoff is off by default in every conversation; turn it on with /autohandoff on (or set default_on).
Version 4 is a rewrite as a Claude Code mod (a hooks module that runs inside Claude Code). It no longer types into the terminal, needs no Python, tmux or console tricks, and works the same on every platform Claude Code runs mods on. See Upgrading from version 3.
--safe-mode, --bare, disableAllHooks, an organization policy that turns them off, or in a folder whose workspace trust you have not accepted yet. In those cases AutoHandoff does nothing, and /autohandoff answers that it is not active.claude -p and the Agent SDK cannot run /clear or send messages from a plugin, so AutoHandoff stays silent there.The repository is a plugin marketplace with one plugin, autohandoff. Inside Claude Code:
/plugin marketplace add DuanWeiye/AutoHandoff_Mod
/plugin install autohandoff@autohandoff
Or from a local clone:
git clone https://github.com/DuanWeiye/AutoHandoff_Mod.git <dir>
/plugin marketplace add <dir>
/plugin install autohandoff@autohandoff
(From a shell: claude plugin marketplace add <dir> and claude plugin install autohandoff@autohandoff.) Start a new session or run /reload-plugins to load it.
The marketplace is named autohandoff, the same as version 3's. If version 3 is installed, remove it first (see Upgrading from version 3); otherwise marketplace add reports that the name is taken.
To try it in one session without installing: claude --plugin-dir <dir>.
claude plugin marketplace update autohandoff, then claude plugin update autohandoff@autohandoff, then restart the session (or run /reload-plugins). The new version is copied into ~/.claude/plugins/cache/; sessions that are already running keep the old one until then.claude plugin list shows Read from: <dir>, and claude plugin update reports that there is nothing to update. After git pull, the next session (or /reload-plugins) uses the new code, whatever the version number. Because the folder itself is what runs, close running sessions before pulling a new major version (see Upgrading from version 3). If claude plugin list shows no Read from: line, your Claude Code copies the plugin into the cache instead: use the two update commands above./plugin uninstall autohandoff@autohandoff
/plugin marketplace remove autohandoff
Then delete ~/.claude/autohandoff.md if you no longer want your settings.
Version 3 lives in its own repository (DuanWeiye/AutoHandoff) and is not updated in place. Finish or cancel (/autohandoff off) any handoff that a running version 3 session has registered and close all sessions (version 3's hooks run at every prompt and turn end; uninstalling under a running session breaks them). Then, in a shell:
claude plugin uninstall autohandoff@autohandoff
claude plugin marketplace remove autohandoff
claude plugin marketplace add DuanWeiye/AutoHandoff_Mod # or a local clone
claude plugin install autohandoff@autohandoff
Start new sessions. Version 4 does not read the old configuration:
~/.claude/autohandoff.md (created with the defaults on first run). Copy over any thresholds you had changed in ~/.claude/autohandoff.toml; the keys are the same, without the takeover., context. and handoff. prefixes, and wait_turn_end_minutes is now wait_minutes. Customised prompts have to be rewritten: the model now calls a tool ({tool}) instead of running a command ({cmd}), and the handoff path is added by AutoHandoff (no {file}).~/.claude/autohandoff.toml and the ~/.claude/autohandoff-state/ folder are no longer used; delete them by hand. AutoHandoff never reads, moves or deletes them.settings.json by hand for an older version, remove them.AUTOHANDOFF_STATE_DIR and AUTOHANDOFF_DISABLE are no longer read; see Configuration.In a conversation:
/autohandoff on | off | status # no argument toggles; /autohandoff:autohandoff also works
The command is handled by the mod and never reaches the model; its output appears as dim lines in the transcript. The on/off choice belongs to the conversation: it survives /clear and /compact, and comes back when you --resume the conversation. New conversations follow default_on, which takes effect at the first message you type (so agent team teammates, which never receive typed input, never switch on by themselves).
/autohandoff status shows whether AutoHandoff is on, the current context usage, the thresholds, the registered handoff with its stage (or, before that, the handoff file the model has been asked to write), the configuration file in use and any problems in it.
While a handoff is registered or a reset is running, a status line under the input box says so (Claude Code shows plugin status lines with a ⚠ in front). Short notices appear in the top-right corner.
When a reminder arrives, the model writes a handoff file for the next conversation at the path AutoHandoff puts at the end of the reminder, then calls the register_handoff tool (full name mcp__autohandoff__register_handoff) with that path, and ends its turn. The reminder text tells it all of this; it does not need to know the tool in advance.
The path is <project folder>/.autohandoff/_AUTOHANDOFF_<YYYYMMDD>_<HHMMSS>_<session>.md: the folder Claude Code was started in (or, if that is the home folder, the working directory or the git root), the local time of the first reminder, and the first 8 characters of the session id, so that several conversations open in one project never share a file. The path stays the same for the whole handoff (later reminders, registering again after a cancel); the next conversation gets a new one when it hands off in turn. Started from the home folder with no project folder at all, or when no project folder gives a path that passes the path rules below (a folder name with an apostrophe, parentheses, # or ~, or a path over 512 characters), the model is asked to put the file in the auto-memory folder instead.
The file is written from scratch every time and has to make sense on its own: the next conversation reads it, and only it, as the complete handoff. The model carries over what is still open from the handoff file its own conversation started from (questions waiting for you, running subagents, standing agreements), never from whatever file in the folder is newest. A project's own handoff or progress documents (a HANDOVER.md that grows a dated section per handoff, say) are updated the way the project does it; AutoHandoff neither reads nor limits them. You may want to add .autohandoff/ to the project's .gitignore. (The folder is not under .claude/: Claude Code asks for permission before every write there, whatever the permission rules say.) The tool answers immediately and never asks for permission. Only the main conversation can register; subagents and forks are refused. Registering again before the turn ends replaces the earlier registration; a refused attempt leaves the earlier one in effect, and the reply says so.
The handoff file must be:
~/.claude/projects/<project>/memory/;fresh_minutes;handoff_max_tokens (see below);/ . _ - : @ + , (on Windows also \, and ~ only inside 8.3 short names such as PROGRA~1), at most 512 characters. Spaces are fine inside the path (C:\Users\Jane Doe\project\HANDOVER.md) but not at either end; tabs and other whitespace, quotes, ~ (it is not expanded), control and invisible characters are refused.Anything else is refused with the reason, and the model can fix it and register again. The home folder itself and a drive root never count as a project folder, and nothing else under ~/.claude (settings, credentials, transcripts) is accepted.
/clear (or /compact <compact_focus> with action: compact). The command runs as soon as Claude Code is idle.start_context) that names the handoff file and says it is the complete handoff; other project documents are read only for details.resume) as your message; Claude Code shows it with "Prompt from the autohandoff plugin". The model reads the handoff file and continues.Handoff file size. The next conversation reads the whole handoff file, and Claude Code's Read tool stops at 25,000 tokens per read. The reminders ask for about three quarters of handoff_max_tokens (15k by default). At registration AutoHandoff reads the file once and estimates its size the way the Read tool counts it (line-number prefix included; the estimate errs on the high side). A file above handoff_max_tokens is not registered. If it is the file AutoHandoff named, the model is told the estimate and the limit, shortens the file and registers again. Any other file (a project's own HANDOVER.md, say, if your custom prompts ask for that) is left alone: the model is told not to cut it and to write the handoff to the file AutoHandoff named instead. The estimate counts tokens, not lines; the Read tool also stops at 2,000 lines per read, which matters only for a file of very short lines.
Subagents, background shells and monitors that are still running are not waited for: they keep running across the reset, and their reports arrive in the new conversation. The reminders ask the model to list them in the handoff file together with what to do with their results.
AutoHandoff keeps one small record per conversation in the plugin store and moves it to the new session id when /clear changes it. Every decision comes from typed hook events; it never reads the screen or the transcript.
Thresholds. Context usage is read from Claude Code ($.session.usage(), the same number as the status line) after every tool call of the main conversation, at every message and when a turn ends. Each threshold is sent once per conversation (jumping over both sends only the second), as context the model sees and you do not. After the first reminder, every later message of the main conversation carries a short reminder until a handoff is registered (remind_every_turn). Past urge_at, the first attempt to end a turn without a handoff is blocked once with the urge_block text (urge_block); Claude Code shows that text to you as "Stop hook error: …". If another plugin or settings hook already blocked that stop, AutoHandoff adds nothing. When usage drops below remind_at (after a compaction) the reminders start over. If the model's context window is not above remind_at, a notice says so once.
Where messages come from. Each incoming message of the main conversation is classified by the origin Claude Code stamps on it:
isMeta with no origin): ignored;Cancellation. A registered handoff is cancelled, and nothing is reset, when:
| Cause | Model told | Notice |
|---|---|---|
| A new message from you, or from an unknown source | yes, in that message | toast |
| The turn ends with an API error, or the reply is refused | yes, at the next message | toast (and notification for API errors) |
The turn does not end within wait_minutes of registering | yes | toast and notification |
| The handoff file is gone, moved or empty when the turn ends | yes | toast |
| AutoHandoff is reloaded or Claude Code restarts before the reset is queued | yes | toast |
| You press Esc, or reject a tool (the turn ends without a Stop) | no: you stopped it, so it waits for you | none |
You run /clear, /compact or /resume, or /autohandoff off | no | none |
| Claude Code exits | no | none |
When the model is told, the reminder it last received counts as unsent again, so it is repeated at the next chance. The cancellation note asks the model, before it registers again, to update the handoff file with whatever is new, above all what you just asked for.
Once the reset is queued it cannot be withdrawn (only Esc can veto it before it runs). A message you type in that short window is not lost: it goes to the new conversation, and the resume message is skipped because you have taken over. /clear, /compact, /resume and /autohandoff off typed in that window do not cancel it either; a toast says the reset is queued. After /autohandoff off the reset still runs and resumes, and AutoHandoff stays off in the new conversation. An agent message that arrives in the window gets a note telling the model a reset is about to happen.
Reload versus restart. When the mod is hot-reloaded (for example the plugin is updated) while a reset is queued, the queued command still runs and the new module sends the resume message once. When Claude Code itself was restarted (a crash, a killed process) and the conversation is resumed, the queued command died with the old process: the handoff is cancelled with a toast, the model is told, and a later /clear of yours is an ordinary one. AutoHandoff tells the two apart with per-session state the host keeps across hot reloads but not across processes.
No reset loops. A conversation that AutoHandoff has just continued from a handoff file does not accept a new registration until its context passes remind_at or you send a message; the model is told to carry on with the work instead. /autohandoff status shows this. The reminders also tell the model to write the work itself under "next steps", never "hand off again".
The compact_at safety net. When a main-conversation turn ends at or above compact_at with no handoff registered, AutoHandoff queues /compact <compact_focus>. During that compaction no reminders are added and registrations are refused. If you veto it with Esc, it waits for your next message before trying again; after three failures it stops trying in that conversation (/autohandoff status says so).
Failing safe. Every hook catches its own errors and lets the event through, so AutoHandoff never blocks your input. If its state cannot be saved when a reset is due, it does not reset and tells you; a registration that cannot be saved is answered "Not registered".
One file: ~/.claude/autohandoff.md (in CLAUDE_CONFIG_DIR when that is set). It is created with every default and comments the first time AutoHandoff runs, and read again whenever a value is needed, so edits take effect without a restart. Delete it to get a fresh copy. Missing or invalid values fall back to the built-in defaults and are listed by /autohandoff status.
The file is Markdown. The ## settings section holds key: value lines; every other ## <name> section is a prompt whose text runs to the next such heading. HTML comments (<!-- -->) are ignored anywhere.
## settings
default_on: false
remind_at: 300000
urge_at: 450000
compact_at: 650000
action: clear
fresh_minutes: 10
wait_minutes: 60
urge_block: true
remind_every_turn: true
handoff_max_tokens: 20000
## remind
[AutoHandoff: reminder] ... your own text ...
| Key | Default | Meaning |
|---|---|---|
default_on | false | Whether new conversations start switched on (from your first message). |
remind_at | 300000 | First reminder, in tokens of context. |
urge_at | 450000 | Second reminder. Must be above remind_at. |
compact_at | 650000 | Compact when a turn ends here with no handoff. Must be above urge_at. |
action | clear | clear or compact: how the conversation is reset after a handoff. |
fresh_minutes | 10 | The handoff file must have been modified this recently when registered (1–1440). |
wait_minutes | 60 | Give up if the turn has not ended this long after registering (1–1440). |
urge_block | true | Past urge_at, block the first attempt to end a turn without a handoff, once per turn. |
remind_every_turn | true | After the first reminder, add a short reminder to every later message until a handoff is registered. |
handoff_max_tokens | 20000 | Size limit for the handoff file, in estimated tokens. The reminders ask for about 3/4 of it; a larger file is not registered (see Handoff file size). 0 turns the check off; otherwise at least 2000. |
Token values accept 300000, 300_000 or 300,000, from 1 to 10,000,000. If remind_at < urge_at < compact_at does not hold, all three use their defaults.
Prompts. Ten prompts can be replaced; a section you leave out uses the built-in text (the generated file contains all of them, so you can edit in place):
| Section | Used for |
|---|---|
remind | First reminder. |
remind_short | Short reminder on later messages (remind_every_turn). |
urge | Second reminder. |
urge_block | Reason given when a turn end is blocked past urge_at, and the short reminder after the second one. Shown to you as "Stop hook error: …". |
cancelled | Tells the model its registered handoff was cancelled, and why. |
pending_note | Added to an agent message that arrives while a handoff is registered. |
reset_note | Added to an agent message that arrives while the reset is queued. |
start_context | Context for the model at the start of the reset conversation. |
resume | The message sent as you after the reset. |
compact_focus | Focus instructions passed to /compact (action: compact and compact_at). |
Placeholders are written {name}: {used_k} {remind_k} {urge_k} {compact_k} (thousands of tokens), {compact_note} (a sentence about compact_at), {handoff_note} (the sentence giving the handoff file's size budget, empty when handoff_max_tokens is 0), {handoff_k} {handoff_max_k} (the size to aim for and the limit), {tool} (the registration tool's full name), {action} (/clear or /compact), {reason} (in cancelled only). Each prompt accepts only the ones that make sense for it, and others are left as written. Paths are never placeholders: AutoHandoff appends the path of the handoff file to write to remind, remind_short, urge and urge_block, and the registered file's path to start_context and resume (there marked as notes from the previous conversation rather than instructions from you). A prompt that uses {file} or the old {cmd} falls back to the built-in text.
Environment variables.
AUTOHANDOFF_CONFIG: the absolute path of a configuration file to use instead of ~/.claude/autohandoff.md (created with the defaults if missing). A relative path is ignored and reported by /autohandoff status, because it would resolve inside the project.claude --settings '{"enabledPlugins":{"autohandoff@autohandoff":false}}', or run /autohandoff off in the conversation. Version 3's AUTOHANDOFF_DISABLE is not read.AutoHandoff never changes Claude Code's settings.
/compact runs until the compaction has finished, so AutoHandoff cannot tell you took over; both your message and the resume message are handled, and the resume message says to follow your instructions first./clear does not wait for it. The dialog on screen then belongs to the subagent from the previous conversation: answer it as usual, and its report arrives in the new conversation./resume while a reset is queued. If you switch to another conversation in that window, the queued /clear (or /compact) applies to the conversation you switched to, without a resume message. Its transcript is still there: /resume it again. The toast shown when you open /resume says this./clear while the handoff turn is running and AutoHandoff is hot-reloaded exactly between your /clear and its own, the resume message can land in the intermediate conversation and be cleared away. The window is a fraction of a second; the handoff file is still there.compact_at. Only failed compactions are hooks/register.ts 781 lines1// AutoHandoff 4.0 的 hooks 模块入口:事件接线(薄层)。
2// 每个 hook 只做三件事:读事件与 `$` 的结果 → 调 core.transition → 先写 store、再执行副作用。
3// 判断都在 core.ts;这里不做业务判断,只负责:
4// - 只把主对话的事件送进 core(agentId / agent_id 有值的一律不送)
5// - 按 session id 找记录;/clear 换 sid 时把记录搬到新 sid(两种先后顺序都对)
6// - 副作用里的 clear/compact/resume 带回调序号,promise 的结果原样带 seq 回送
7// - 任何内部错误一律 catch、放行、记 debug log,绝不影响会话
8import type { EngineInterface, Register } from 'claude-code'
9import {
10 COMMAND_NAME,
11 MIN_VERSION,
12 TOOL_FULL_NAME,
13 TOOL_NAME,
14 checkHandoffStat,
15 checkPathText,
16 checkStillThere,
17 configDir,
18 configFilePath,
19 defaultConfigText,
20 estimateReadTokens,
21 homeDir,
22 isAbsolutePath,
23 jobKey,
24 isExpired,
25 loadRecord,
26 looksWindows,
27 migrateRecord,
28 newRecord,
29 parseConfig,
30 storeKey,
31 toAbsolute,
32 transition,
33 usableRoots,
34 versionAtLeast,
35 type Effect,
36 type Ev,
37 type Job,
38 type ParsedConfig,
39 type Rec,
40 type StatLike,
41 type Step,
42 type Verdict,
43} from './core.js'
44
45type Api = EngineInterface
46
47// `$.state` 里本插件的值(宿主按会话保存:热重载保留,进程重启后是空的);类型声明在 types/autohandoff.d.ts
48const QUEUED_REF = { plugin: 'autohandoff', key: 'queued' } as const
49const QUEUED_KEEP = 20
50
51// ───────────────────────── 模块状态(热重载会丢,都只是缓存或辅助) ─────────────────────────
52
53/** 本模块实例是否工作:版本够、是交互会话。session.start 时重算。 */
54let gate: { on: boolean; why: string } | null = null
55/** session.start 给的 isInteractive;之前到达的事件(teammate 的 session.receive)用 surfaces 推断 */
56let interactive: boolean | undefined
57let lowVersionWarned = false
58/**
59 * 最近一次 /clear 换掉的 sid(session.end(clear) 时记下)。新 sid 下还没有记录时据此把旧记录搬过来。
60 * 只放模块变量:session.end(clear) 总在 SessionStart(clear) 和自家 clear 的 resolve 之前,热重载后新模块的 session.end 照样会记。
61 */
62let clearingFrom: { sid: string; t: number } | null = null
63/**
64 * 本轮交接文件的候选工程根目录(core.pickHandoffNote 的 dirs),按算的时候的 cwd 缓存:cwd 变了就重算;
65 * session.start 时清掉。dirs 为空表示没有可用的(从主目录或文件系统根启动)
66 */
67let noteBase: { cwd: string; dirs: string[]; windows: boolean } | null = null
68/** AUTOHANDOFF_CONFIG 是相对路径的提示已记过 */
69let relativeConfigWarned = false
70/** 记录的读-改-写串行执行(`$.store` 的 get/set 不是原子的;同一进程里的事件会交错) */
71let chain: Promise<unknown> = Promise.resolve()
72
73/** clearingFrom 多久内有效(实测 session.end(clear) 之后几百毫秒内就到 SessionStart(clear) 和 resolve) */
74const CLEAR_LINK_MS = 2 * 60 * 1000
75const TOAST_MS = 12_000
76
77// 登记工具的说明(模型读)。路径规则与 core.checkPathText / checkHandoffStat 一致。
78const TOOL_DESCRIPTION =
79 'AutoHandoff: register the handoff file you have just written, so that when this turn ends the conversation is reset ' +
80 '(/clear or /compact, as configured) and continued from that file. Call it only after an AutoHandoff reminder asked you to ' +
81 'hand off and the file is written; only the main conversation can register (subagents and forks are refused). ' +
82 'Then end your turn at once. The file must be inside the project folder (the working directory, the folder Claude Code ' +
83 'was started in, or the git repository root) or the Claude Code auto-memory folder (<config dir>/projects/<project>/memory/), ' +
84 'non-empty, just modified and not over the size limit (handoff_max_tokens); its path is absolute or relative to the working ' +
85 'directory, with no quotes, tabs, control characters, leading or trailing spaces, or "~".'
86
87const TOOL_SCHEMA = {
88 type: 'object',
89 properties: {
90 file: { type: 'string', description: 'Path of the handoff file (absolute, or relative to the working directory)' },
91 },
92 required: ['file'],
93}
94
95// ───────────────────────── 小工具 ─────────────────────────
96
97function serial<T>(fn: () => Promise<T>): Promise<T> {
98 const run = chain.then(fn, fn)
99 chain = run.catch(() => undefined)
100 return run
101}
102
103function errText(err: unknown): string {
104 if (err && typeof err === 'object' && 'message' in err) return String((err as { message: unknown }).message)
105 return String(err)
106}
107
108function debug($: Api, text: string): void {
109 try {
110 $.ui.log(text, { to: 'debug' })
111 } catch {
112 // 连日志都写不了就算了
113 }
114}
115
116function fail($: Api, where: string, err: unknown): void {
117 debug($, `internal error in ${where}: ${errText(err)}`)
118}
119
120async function safely<T>(fn: () => Promise<T>): Promise<T | undefined> {
121 try {
122 return await fn()
123 } catch {
124 return undefined
125 }
126}
127
128interface EnvVars {
129 CLAUDE_CONFIG_DIR?: string
130 HOME?: string
131 USERPROFILE?: string
132}
133
134async function envVars($: Api): Promise<EnvVars> {
135 return {
136 CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
137 HOME: await $.env.get('HOME'),
138 USERPROFILE: await $.env.get('USERPROFILE'),
139 }
140}
141
142async function realPathOf($: Api, p: string | undefined): Promise<string | undefined> {
143 if (!p) return undefined
144 const st = await safely(() => $.fs.stat(p, { resolve: true }))
145 return st?.realPath
146}
147
148async function usageNow($: Api): Promise<{ tokens?: number; window?: number }> {
149 const u = await safely(() => $.session.usage())
150 return { tokens: u?.context?.tokens, window: u?.context?.window }
151}
152
153function contextsOf(step: Step | undefined): string[] {
154 if (!step) return []
155 return step.fx.filter((f): f is Extract<Effect, { type: 'context' }> => f.type === 'context').map((f) => f.text)
156}
157
158function blockOf(step: Step | undefined): string | undefined {
159 return step?.fx.find((f): f is Extract<Effect, { type: 'block' }> => f.type === 'block')?.text
160}
161
162// ───────────────────────── 启用判定与配置 ─────────────────────────
163
164function gateWhy(): string {
165 return gate?.why ?? ''
166}
167
168async function enabled($: Api): Promise<boolean> {
169 if (gate) return gate.on
170 const v = await safely(() => $.session.version())
171 const base = v?.base ?? v?.version
172 if (!versionAtLeast(base)) {
173 gate = { on: false, why: `it needs Claude Code ${MIN_VERSION} or later (this is ${base ?? 'an unknown version'})` }
174 if (!lowVersionWarned) {
175 lowVersionWarned = true
176 try {
177 $.ui.toast(`AutoHandoff is not active: ${gate.why}.`, { timeoutMs: TOAST_MS })
178 } catch {
179 // 没有界面
180 }
181 }
182 return false
183 }
184 let inter = interactive
185 if (inter === undefined) inter = ((await safely(() => $.session.surfaces())) ?? []).length > 0
186 gate = inter ? { on: true, why: '' } : { on: false, why: 'it does not work in non-interactive sessions (claude -p, the Agent SDK)' }
187 return gate.on
188}
189
190/**
191 * 配置文件路径:AUTOHANDOFF_CONFIG(另放配置文件时用,PLAN §0 第 19 条)优先,否则 <CC 配置目录>/autohandoff.md。
192 * AUTOHANDOFF_CONFIG 只认绝对路径:`$.fs` 会把相对路径解析到工程目录下,读写的就成了工程里的文件。相对路径忽略,并报告一句。
193 */
194async function configPath($: Api): Promise<{ path: string | undefined; problem?: string }> {
195 const own = await $.env.get('AUTOHANDOFF_CONFIG')
196 let problem: string | undefined
197 if (own) {
198 if (isAbsolutePath(own)) return { path: own }
199 problem = `AUTOHANDOFF_CONFIG="${own}" is not an absolute path; it is ignored`
200 if (!relativeConfigWarned) {
201 relativeConfigWarned = true
202 debug($, problem)
203 }
204 }
205 const dir = configDir(await envVars($))
206 return { path: dir ? configFilePath(dir) : undefined, problem }
207}
208
209interface LoadedConfig {
210 parsed: ParsedConfig
211 path: string | undefined
212}
213
214async function readConfig($: Api): Promise<LoadedConfig> {
215 const { path, problem: pathProblem } = await configPath($)
216 if (!path) {
217 const parsed = parseConfig(undefined)
218 if (pathProblem) parsed.problems.unshift(pathProblem)
219 return { parsed, path }
220 }
221 let text: string | undefined
222 let problem: string | undefined
223 try {
224 if (await $.fs.exists(path)) text = String(await $.fs.read(path))
225 } catch (err) {
226 problem = `could not read ${path} (${errText(err)}); built-in defaults used`
227 }
228 const parsed = parseConfig(text)
229 if (problem) parsed.problems.unshift(problem)
230 if (pathProblem) parsed.problems.unshift(pathProblem)
231 return { parsed, path }
232}
233
234/** 配置文件不存在时写一次带注释的默认文件(唯一的 `$.fs.write`,PLAN §0 第 17 条) */
235async function ensureConfigFile($: Api): Promise<void> {
236 let path: string | undefined
237 try {
238 path = (await configPath($)).path
239 // exists 碰到网络位置会 reject,别的插件的 fs hook 也可能 deny:都按「这次不写」处理
240 if (!path || (await $.fs.exists(path))) return
241 await $.fs.write(path, defaultConfigText())
242 debug($, `wrote the default configuration to ${path}`)
243 } catch (err) {
244 debug($, `could not write the default configuration${path ? ` to ${path}` : ''}: ${errText(err)}`)
245 }
246}
247
248// ───────────────────────── 记录:读写、/clear 后的搬迁 ─────────────────────────
249
250async function loadRec($: Api, sid: string): Promise<Rec | null> {
251 return loadRecord(await $.store.get(storeKey(sid)))
252}
253
254function sameRec(a: Rec, b: Rec): boolean {
255 return JSON.stringify({ ...a, t: 0 }) === JSON.stringify({ ...b, t: 0 })
256}
257
258/** session.end(clear):oldSid 刚被 /clear 换掉 */
259async function markClearing($: Api, oldSid: string): Promise<void> {
260 clearingFrom = { sid: oldSid, t: await $.clock.now() }
261}
262
263/**
264 * 新 sid 下没有记录:找刚被 /clear 换掉的旧对话的记录,搬到新 sid(只在 SessionStart(clear) 和自家重置的回调里调用)。
265 * 候选:模块变量(紧挨着的上一个 sid)→ 回调自己带的旧 sid(发出重置时的 sid;重置排队期间用户先 /clear 过一次时,它比模块变量旧一代)。
266 * store 写失败不拦搬迁:用内存里搬好的记录接着处理(见 dispatch 的说明)。
267 */
268async function adopt($: Api, sid: string, now: number, hint: string | undefined): Promise<Rec | null> {
269 const candidates: string[] = []
270 if (clearingFrom && clearingFrom.sid !== sid && now - clearingFrom.t <= CLEAR_LINK_MS) candidates.push(clearingFrom.sid)
271 if (hint && hint !== sid && !candidates.includes(hint)) candidates.push(hint)
272 for (const old of candidates) {
273 const rec = await loadRec($, old)
274 if (!rec) continue
275 const moved = migrateRecord(rec, now)
276 try {
277 await $.store.set(storeKey(sid), moved)
278 // 旧对话的记录只留开关(--resume 回去时恢复);job、回合、作废说明都随对话搬走或作废
279 // note 也清掉:那份文件正是新对话续接用的,--resume 回旧对话再被提醒时不能让它覆盖(会生成新路径)
280 await $.store.set(storeKey(old), { ...rec, job: null, fallback: null, turn: null, tell: null, note: null, t: now })
281 if (clearingFrom?.sid === old) clearingFrom = null
282 } catch (err) {
283 // 没存下:旧记录原样留着,模块变量也留着,下一个事件(SessionStart(clear) 等)再搬一次
284 debug($, `could not save the record moved from session ${old} to ${sid}: ${errText(err)}`)
285 }
286 debug($, `record moved from session ${old} to ${sid} after /clear`)
287 return moved
288 }
289 return null
290}
291
292/** 本进程发出过的重置(jobKey 列表);读不到返回 undefined */
293async function queuedList($: Api): Promise<string[] | undefined> {
294 try {
295 const { value } = await $.state.get(QUEUED_REF)
296 return Array.isArray(value) ? value.filter((x): x is string => typeof x === 'string') : []
297 } catch (err) {
298 debug($, `could not read $.state: ${errText(err)}`)
299 return undefined
300 }
301}
302
303/**
304 * 记下本进程发出了这个 job 的重置(在写 store、把命令交给 `$.command.run` 之前)。写不进就抛错:
305 * 没记下的重置在热重载后会被当成进程重启而作废,命令却还在排队,成了一次没人续接的重置(见 dispatch)
306 */
307async function noteQueued($: Api, key: string): Promise<void> {
308 const cur = (await queuedList($)) ?? []
309 const r = await $.state.set(QUEUED_REF, [...cur.filter((k) => k !== key), key].slice(-QUEUED_KEEP))
310 // 不带 ifVersion 时 isSet 应总为真;宿主说没写进就按没写进处理
311 if (r && r.isSet === false) throw new Error('$.state.set did not write the value')
312}
313
314/**
315 * 记录里已排队的重置是不是本进程发出的:true 是(热重载),false 不是(进程重启,命令随旧进程没了),
316 * undefined 不适用或读不到 `$.state`(core 按热重载处理)
317 */
318async function queuedHere($: Api, j: Job | null): Promise<boolean | undefined> {
319 if (!j || (j.stage !== 'clearing' && j.stage !== 'compacting')) return undefined
320 const list = await queuedList($)
321 return list === undefined ? undefined : list.includes(jobKey(j))
322}
323
324/** 这一步发出新的重置(clear/compact) */
325function issuesReset(step: Step): boolean {
326 return step.fx.some((f) => f.type === 'clear' || f.type === 'compact')
327}
328
329/** 这一步发出的交接 job 重置的标识(兜底压缩没有) */
330function resetJobOf(step: Step): string | undefined {
331 for (const f of step.fx) if ((f.type === 'clear' || f.type === 'compact') && f.job !== undefined) return f.job
332 return undefined
333}
334
335interface DispatchOptions {
336 /** 记录所在的 sid;缺省取 `$.session.id()` */
337 sid?: string
338 /** 新 sid 下没有记录时尝试从 /clear 前的对话搬过来 */
339 adopt?: boolean
340 /** 搬迁的首选旧 sid(自家重置的回调知道自己是从哪个 sid 发出的) */
341 from?: string
342 config?: LoadedConfig
343}
344
345/**
346 * 送一个事件进 core:串行地 读记录 → transition → 写回(只在有变化时写),然后执行副作用。
347 * make 拿到当前记录再生成事件(turnComplete 要先看有没有 armed 的 job 才复查文件)。
348 *
349 * 写 store 失败时(store 满、磁盘错误……):
350 * - 这一步要发出新的重置(clear/compact):不发。重置的结果要靠存下来的请求序号认领,存不下就会变成一次没人续接的重置;
351 * 记录停在原状态(armed 的登记照常由下一条消息、回合结束或超时处理),toast 告诉用户没有重置。
352 * 交接 job 的重置还要先在 `$.state` 记下标识(noteQueued),写不进同样按这条处理,而且记录不写回(停在 armed)。
353 * - 其它步骤:副作用照常执行(重置已经发生时,续接比状态记全更要紧)。
354 */
355async function dispatch($: Api, make: (rec: Rec) => Ev | Promise<Ev>, o: DispatchOptions = {}): Promise<Step> {
356 const config = o.config ?? (await readConfig($))
357 const cfg = config.parsed.cfg
358 const { step, sid } = await serial(async () => {
359 const now = await $.clock.now()
360 const sid = o.sid ?? (await $.session.id())
361 let rec = await loadRec($, sid)
362 if (!rec && o.adopt) rec = await adopt($, sid, now, o.from)
363 const fresh = rec === null
364 const base = rec ?? newRecord(now)
365 const ev = await make(base)
366 // 只有可能送提醒的事件才要本轮交接文件路径的材料
367 // (读工程目录出错时不给,提醒末尾只泛指放在哪类地方,免得指错)
368 const wants = ev.kind === 'submit' || ev.kind === 'postTool' || ev.kind === 'stop'
369 const note = wants ? await safely(async () => ({ ...(await noteDirs($)), sid })) : undefined
370 const step = transition(base, ev, { cfg, now, note })
371 // 没有记录且转移后仍是空白记录(开关未动、没收到过人工输入……)就不写,免得每个会话都在 store 里留一份
372 const blank = fresh && sameRec(step.rec, newRecord(now))
373 const notReset = (why: string, what: string): void => {
374 step.fx = [
375 { type: 'log', text: `${ev.kind}: reset not issued because ${what} could not be saved`, debug: true },
376 { type: 'toast', text: `AutoHandoff could not save its state (${why}), so the conversation was not reset.` },
377 ]
378 }
379 const job = resetJobOf(step)
380 if (job !== undefined) {
381 try {
382 await noteQueued($, job)
383 } catch (err) {
384 const why = errText(err)
385 debug($, `could not write $.state: ${why}`)
386 notReset(why, 'the queued reset')
387 return { step, sid }
388 }
389 }
390 if (!blank && (fresh || !sameRec(step.rec, base))) {
391 try {
392 await $.store.set(storeKey(sid), step.rec)
393 } catch (err) {
394 const why = errText(err)
395 debug($, `could not save the record of session ${sid} (${ev.kind}): ${why}`)
396 if (issuesReset(step)) notReset(why, 'the record')
397 else if (step.accepted === true) {
398 // 登记没存下:回合结束时什么也不会发生,所以不能回「已登记」
399 step.accepted = false
400 step.reply = `Not registered: AutoHandoff could not save the registration (${why}).`
401 step.fx = [
402 { type: 'log', text: 'registration refused: the record could not be saved', debug: true },
403 { type: 'toast', text: `AutoHandoff could not save its state (${why}), so the handoff was not registered.` },
404 ]
405 }
406 }
407 }
408 return { step, sid }
409 })
410 runEffects($, step.fx, sid)
411 return step
412}
413
414// ───────────────────────── 副作用 ─────────────────────────
415
416/** context / block 由调用的 hook 自己放进返回值;其余在这里执行。调用时记录已写进 store。 */
417function runEffects($: Api, fx: readonly Effect[], sid: string): void {
418 for (const f of fx) {
419 try {
420 switch (f.type) {
421 case 'context':
422 case 'block':
423 break
424 case 'clear':
425 issueReset($, sid, f.seq, { command: 'clear' })
426 break
427 case 'compact':
428 issueReset($, sid, f.seq, { command: 'compact', args: f.args })
429 break
430 case 'resume':
431 issueResume($, sid, f.seq, f.text)
432 break
433 case 'status':
434 $.ui.status(f.text)
435 break
436 case 'toast':
437 $.ui.toast(f.text, { timeoutMs: TOAST_MS })
438 break
439 case 'notify':
440 void $.ui.notify(f.text).catch(() => undefined)
441 break
442 case 'log':
443 if (f.debug) $.ui.log(f.text, { to: 'debug' })
444 // transcript 里的 ui.log 行不显示换行(实测显示成乱码),多行文字逐行记
445 else for (const line of f.text.split('\n')) $.ui.log(line)
446 break
447 case 'timer':
448 $.clock.after(f.ms, () => {
449 void dispatch($, () => ({ kind: 'tick' })).catch((err) => fail($, 'tick', err))
450 })
451 break
452 }
453 } catch (err) {
454 fail($, `effect ${f.type}`, err)
455 }
456 }
457}
458
459/**
460 * 排队执行 /clear 或 /compact(回合中会排到空闲才执行),结果带同一个 seq 送回 core。
461 * 交接 job 的标识已由 dispatch 记进 `$.state`:之后 session.start 据此区分热重载(命令还在排队)与进程重启。
462 */
463function issueReset($: Api, sid: string, seq: number, args: { command: 'clear' | 'compact'; args?: string }): void {
464 void (async () => {
465 let error: string | undefined
466 try {
467 await $.command.run(args)
468 } catch (err) {
469 error = errText(err) || 'unknown error'
470 }
471 if (error === undefined) {
472 // clear 已完成,`$.session.id()` 已是新 sid:记录还没搬就从 sid 搬过来
473 await dispatch($, () => ({ kind: 'resetDone', seq }), { adopt: true, from: sid })
474 } else {
475 debug($, `/${args.command} (request ${seq}) was rejected: ${error}`)
476 await dispatch($, () => ({ kind: 'resetFailed', seq, message: error }), { adopt: true, from: sid })
477 }
478 })().catch((err) => fail($, `/${args.command} callback`, err))
479}
480
481/** 以用户身份提交续接文案;结果(含被丢弃)带 seq 送回 core。 */
482function issueResume($: Api, sid: string, seq: number, text: string): void {
483 void (async () => {
484 let drop: string | undefined
485 try {
486 const r = await $.prompt.submit({ text, asUser: true })
487 if (r && typeof r === 'object' && 'drop' in r && typeof r.drop === 'string') drop = r.drop
488 } catch (err) {
489 drop = errText(err) || 'unknown error'
490 }
491 await dispatch($, () => ({ kind: 'resumeDone', seq, drop }), { adopt: true, from: sid })
492 })().catch((err) => fail($, 'resume callback', err))
493}
494
495// ───────────────────────── 各事件的处理(hook 本体只管放行与 catch) ─────────────────────────
496
497async function onSessionStart($: Api, isInteractive: boolean): Promise<void> {
498 interactive = isInteractive
499 gate = null
500 noteBase = null
501 if (!(await enabled($))) {
502 debug($, `inactive: ${gateWhy()}`)
503 return
504 }
505 // 登记工具最先注册:后面任何一步出错(网络位置上的配置目录让 $.fs.exists reject、别的插件 deny 等)都不能让它缺席
506 try {
507 await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA, isDeferred: false })
508 } catch (err) {
509 fail($, 'tool.register', err)
510 }
511 // 以下几步失败都只是少了默认配置文件或清理,不影响 start 的作废判断
512 await safely(() => ensureConfigFile($))
513 await safely(() => cleanupStore($))
514 // 进程刚起、--resume 或模块热重载:挂着的 job 由 core 处理(登记中的作废;已排队的看是不是本进程发出的)
515 await dispatch($, async (rec) => ({ kind: 'start', queuedHere: await queuedHere($, rec.job) }))
516}
517
518/** 删掉 30 天没动过的对话记录 */
519async function cleanupStore($: Api): Promise<void> {
520 const now = await $.clock.now()
521 for (const k of await $.store.keys()) {
522 if (k.startsWith('s:') && isExpired((await $.store.get(k)) as { t?: unknown } | undefined, now)) await $.store.delete(k)
523 }
524}
525
526async function checkHandoffPath($: Api, raw: unknown, now: number, freshMinutes: number): Promise<Verdict<string>> {
527 const cwd = await $.session.cwd()
528 const windows = looksWindows(cwd)
529 const text = checkPathText(raw, windows)
530 if (!text.ok) return text
531 const abs = toAbsolute(text.value, cwd)
532 const stat: StatLike | undefined = await safely(() => $.fs.stat(abs, { resolve: true }))
533 const env = await envVars($)
534 const repo = await safely(() => $.session.repo())
535 const roots = usableRoots(
536 [await realPathOf($, cwd), await realPathOf($, await safely(() => $.session.root())), await realPathOf($, repo?.root)],
537 await realPathOf($, homeDir(env)),
538 windows,
539 )
540 const cc = configDir(env)
541 return checkHandoffStat(stat, { roots, configDir: await realPathOf($, cc), now, freshMinutes, windows })
542}
543
544/**
545 * 本轮交接文件的候选工程根目录:启动目录(`$.session.root()`)优先,其次 cwd、git 根;都按登记时的白名单规则过
546 * usableRoots(主目录本身、文件系统根不算)。完整路径还要过文本白名单,由 core.pickHandoffNote 逐个试,都不行就退到记忆目录。
547 * 按 cwd 缓存(postTool 很频繁):模型 cd 到别处后,下一轮按新的 cwd 重算。已经给出的本轮路径(Rec.note)不跟着变:
548 * 只有启动目录不可用、路径落在旧 cwd 下时,cd 走后登记会被判在工程外,模型换个允许的地方再登记即可。
549 */
550async function noteDirs($: Api): Promise<{ dirs: string[]; windows: boolean }> {
551 const cwd = await $.session.cwd()
552 if (noteBase?.cwd === cwd) return noteBase
553 const windows = looksWindows(cwd)
554 const repo = await safely(() => $.session.repo())
555 const dirs = usableRoots(
556 [await realPathOf($, await safely(() => $.session.root())), await realPathOf($, cwd), await realPathOf($, repo?.root)],
557 await realPathOf($, homeDir(await envVars($))),
558 windows,
559 )
560 noteBase = { cwd, dirs, windows }
561 return noteBase
562}
563
564/**
565 * 登记时估算交接文件的大小(PLAN §0 第 30 条):读一次正文,只用来数字符,不解析、不进任何文本;超过上限就不受理。
566 * 读失败(刚被删、别的插件的 fs hook 拒绝……)不影响登记,只是这次没有大小检测;文件真没了由回合结束时的复查处理。
567 */
568async function handoffTokens($: Api, file: string): Promise<number | undefined> {
569 try {
570 return estimateReadTokens(String(await $.fs.read(file)))
571 } catch (err) {
572 debug($, `could not read the handoff file to estimate its size: ${errText(err)}`)
573 return undefined
574 }
575}
576
577/** 重置前复查交接文件;stat 抛错(文件没了)也是复查失败 */
578async function recheck($: Api, file: string): Promise<Verdict<string>> {
579 const stat = await safely(() => $.fs.stat(file, { resolve: true }))
580 return checkStillThere(stat, file, looksWindows(file))
581}
582
583async function onRegister($: Api, e: { agentId?: string; file?: unknown }): Promise<{ result: string } | { deny: string }> {
584 if (!(await enabled($))) return { deny: `AutoHandoff is not active in this session: ${gateWhy()}` }
585 const config = await readConfig($)
586 let verdict: Verdict<string> = { ok: false, why: 'not checked' }
587 // 子代理和 fork 的调用不看路径,core 直接拒绝
588 const s = config.parsed.cfg.settings
589 if (e.agentId === undefined) verdict = await checkHandoffPath($, e.file, await $.clock.now(), s.fresh_minutes)
590 // 路径通过了才读(realPath 已过白名单、≤ 1 MiB);检测关着(0)就不读
591 const fileTokens = verdict.ok && s.handoff_max_tokens > 0 ? await handoffTokens($, verdict.value) : undefined
592 const { tokens } = await usageNow($)
593 const step = await dispatch($, () => ({ kind: 'register', agentId: e.agentId, verdict, tokens, fileTokens }), { config })
594 const reply = step.reply ?? 'Not registered.'
595 return step.accepted === true ? { result: reply } : { deny: reply }
596}
597
598async function onToggle($: Api, args: string): Promise<void> {
599 if (!(await enabled($))) {
600 $.ui.log(`AutoHandoff is not active in this session: ${gateWhy()}.`)
601 return
602 }
603 const config = await readConfig($)
604 const u = await usageNow($)
605 await dispatch(
606 $,
607 () => ({ kind: 'toggle', args, tokens: u.tokens, window: u.window, problems: config.parsed.problems, configPath: config.path }),
608 { config },
609 )
610}
611
612// /clear、/compact、/resume 由 userCommand 处理,/autohandoff 由 toggle 处理;它们的 prompt.submit(带 command)不再当作新消息
613const HANDLED_COMMANDS = new Set(['clear', 'compact', 'resume', COMMAND_NAME])
614
615// ───────────────────────── 接线 ─────────────────────────
616
617export const register: Register = (on) => {
618 on('session.start', async ($, e, next) => {
619 const r = await next(e)
620 try {
621 await onSessionStart($, e.isInteractive)
622 } catch (err) {
623 fail($, 'session.start', err)
624 }
625 return r
626 })
627
628 // agent team 的 teammate 进程最先收到的是 team-lead 的消息(它从不收到人工输入)。
629 // 只看投给主对话的(agentId 为空):进程内 teammate 模式下,lead 进程也会收到投给 teammate 循环的、带 agentId 的这类事件
630 on('session.receive', async ($, e, next) => {
631 try {
632 const origin = e.origin as unknown as { teammate?: unknown }
633 if (e.agentId === undefined && origin.teammate === 'team-lead' && (await enabled($))) await dispatch($, () => ({ kind: 'teammate' }))
634 } catch (err) {
635 fail($, 'session.receive', err)
636 }
637 return next(e)
638 }).catch(($, e, next) => next(e))
639
640 // 主对话的每条消息:作废/附说明/提醒都由 core 决定,附加内容走 context(用户看不到)
641 on('prompt.submit', async ($, e, next) => {
642 let add: string[] = []
643 try {
644 if (!(e.command && HANDLED_COMMANDS.has(e.command.name)) && (await enabled($))) {
645 const isMeta = e.isMeta === true
646 // 审计行:来源分类(含 isMeta)的依据,e2e 用它核对实际到达的消息
647 debug($, `message: origin ${e.origin.kind}${isMeta ? ', isMeta' : ''}${e.turnId ? ', into the running turn' : ''}`)
648 const u = await usageNow($)
649 add = contextsOf(await dispatch($, () => ({ kind: 'submit', origin: e.origin, isMeta, tokens: u.tokens, window: u.window })))
650 }
651 } catch (err) {
652 fail($, 'prompt.submit', err)
653 }
654 return next(add.length ? { ...e, context: [...(e.context ?? []), ...add] } : e)
655 }).catch(($, e, next) => next(e))
656
657 // /autohandoff:md 命令的真名;不调 next,命令被完全拦截,输出只走 ui.log(模型看不到)
658 on('command.run', { command: COMMAND_NAME }, async ($, e) => {
659 try {
660 await onToggle($, e.args)
661 } catch (err) {
662 fail($, '/autohandoff', err)
663 $.ui.log(`AutoHandoff: internal error (${errText(err)})`)
664 }
665 return {}
666 }).catch(() => ({}))
667
668 // 用户自己敲的命令(/clear、/compact、/resume 作废登记);自家发起的命令这里看不到
669 on('command.run', async ($, e, next) => {
670 try {
671 const kind = e.origin.kind
672 if (e.command !== COMMAND_NAME && (kind === 'composer' || kind === 'bridge') && (await enabled($))) {
673 await dispatch($, () => ({ kind: 'userCommand', command: e.command }))
674 }
675 } catch (err) {
676 fail($, 'command.run', err)
677 }
678 return next(e)
679 }).catch(($, e, next) => next(e))
680
681 on('turn.start', async ($, e, next) => {
682 try {
683 if (e.agentId === undefined && (await enabled($))) await dispatch($, () => ({ kind: 'turnStart', turnId: e.turnId }))
684 } catch (err) {
685 fail($, 'turn.start', err)
686 }
687 return next(e)
688 })
689
690 // 回合中途的用量与提醒(实测 PostToolUse 时 usage() 已是这次请求的值,不比 turn.step 晚)
691 on('classic.PostToolUse', async ($, e, next) => {
692 const r = await next(e)
693 try {
694 if (e.agent_id === undefined && (await enabled($))) {
695 const u = await usageNow($)
696 const add = contextsOf(await dispatch($, () => ({ kind: 'postTool', tokens: u.tokens, window: u.window })))
697 if (add.length) return { ...r, additionalContext: [...(r.additionalContext ?? []), ...add] }
698 }
699 } catch (err) {
700 fail($, 'classic.PostToolUse', err)
701 }
702 return r
703 }).catch(($, e, next) => next(e))
704
705 on('classic.Stop', async ($, e, next) => {
706 const r = await next(e)
707 try {
708 if (e.agent_id === undefined && (await enabled($))) {
709 const u = await usageNow($)
710 // 链上排在后面的 hook(含 settings 里的 Stop hook)已经 block 了:core 不叠加、不记账
711 const othersBlocked = r.block !== undefined
712 const block = blockOf(await dispatch($, () => ({ kind: 'stop', active: e.stop_hook_active, tokens: u.tokens, othersBlocked })))
713 if (block !== undefined) return { ...r, block }
714 }
715 } catch (err) {
716 fail($, 'classic.Stop', err)
717 }
718 return r
719 }).catch(($, e, next) => next(e))
720
721 // 回合结束:有 armed 的 job 先复查交接文件,再由 core 决定是否排队 /clear(或 /compact)
722 on('turn.complete', async ($, e, next) => {
723 const r = await next(e)
724 try {
725 if (e.agentId === undefined && (await enabled($))) {
726 const u = await usageNow($)
727 await dispatch($, async (rec) => ({
728 kind: 'turnComplete',
729 turnId: e.turnId,
730 reason: e.reason,
731 isAborted: e.isAborted,
732 tokens: u.tokens,
733 file: rec.job?.stage === 'armed' ? await recheck($, rec.job.file) : undefined,
734 }))
735 }
736 } catch (err) {
737 fail($, 'turn.complete', err)
738 }
739 return r
740 })
741
742 // 登记工具:hook 直接作答(不弹权限框);子代理/fork 的调用由 core 拒绝
743 on('tool.call', { tool: TOOL_FULL_NAME }, async ($, e) => {
744 const input = e as unknown as { agentId?: string; file?: unknown }
745 return onRegister($, { agentId: input.agentId, file: input.file })
746 }).catch(() => ({ deny: 'AutoHandoff internal error; the handoff was not registered.' }))
747
748 // 新对话(clear/compact 之后):附 start_context;clear 换了 sid,记录在这里或重置回调里搬过来
749 on('classic.SessionStart', async ($, e, next) => {
750 const r = await next(e)
751 try {
752 if (await enabled($)) {
753 const sid = typeof e.session_id === 'string' && e.session_id ? e.session_id : await $.session.id()
754 if (e.source === 'clear' || e.source === 'compact') {
755 const step = await dispatch($, () => ({ kind: 'sessionStart', source: e.source }), { sid, adopt: e.source === 'clear' })
756 const add = contextsOf(step)
757 if (add.length) return { ...r, additionalContext: [...(r.additionalContext ?? []), ...add] }
758 } else if (e.source === 'resume') {
759 // 切回一个旧对话:它的记录里若还挂着 job,和进程启动一样处理
760 await dispatch($, async (rec) => ({ kind: 'start', queuedHere: await queuedHere($, rec.job) }), { sid })
761 }
762 }
763 } catch (err) {
764 fail($, 'classic.SessionStart', err)
765 }
766 return r
767 }).catch(($, e, next) => next(e))
768
769 on('session.end', async ($, e, next) => {
770 try {
771 if (await enabled($)) {
772 if (e.reason === 'clear') await markClearing($, e.sessionId)
773 else await dispatch($, () => ({ kind: 'sessionEnd', reason: e.reason }), { sid: e.sessionId })
774 }
775 } catch (err) {
776 fail($, 'session.end', err)
777 }
778 return next(e)
779 })
780}
781hooks/core.ts 1678 lines1// AutoHandoff 4.0 的纯逻辑层:不碰 `$`,只做计算。
2// register.ts(第 2 批)负责:读事件与 `$` 的结果 → 调这里的函数 → 按返回的副作用列表去调 `$`。
3//
4// 内容:
5// 1. 常量
6// 2. 配置:解析 ~/.claude/autohandoff.md、校验、生成默认文件
7// 3. 模板展开
8// 4. 阈值判定
9// 5. 来源分类
10// 6. 路径规则(登记时的校验)
11// 7. 每个对话的记录与状态转移
12// 8. /autohandoff 的输出、版本比较等杂项
13
14import { DEFAULT_PROMPTS, PROMPT_KEYS, PROMPT_NOTES, type PromptKey } from './prompts.js'
15
16export { DEFAULT_PROMPTS, PROMPT_KEYS, type PromptKey }
17
18// ───────────────────────── 1. 常量 ─────────────────────────
19
20export const PLUGIN_NAME = 'autohandoff'
21export const TOOL_NAME = 'register_handoff'
22/** 模型看到的登记工具全名(实测 --plugin-dir 加载时也是这个名字) */
23export const TOOL_FULL_NAME = `mcp__${PLUGIN_NAME}__${TOOL_NAME}`
24/** 拦截的 md 命令真名(`$.command.register('autohandoff')` 会被 md 命令占名拒绝) */
25export const COMMAND_NAME = 'autohandoff:autohandoff'
26export const MIN_VERSION = '2.1.304'
27export const CONFIG_FILE_NAME = 'autohandoff.md'
28export const MAX_PATH_LENGTH = 512
29export const MAX_HANDOFF_BYTES = 1024 * 1024
30/** store 里多久没写的对话记录可以删掉 */
31export const RECORD_TTL_MS = 30 * 24 * 3600 * 1000
32/** 重置完成后多久还没收到续接结果,就当续接阶段结束(只为不让状态卡住) */
33export const RESUME_STUCK_MS = 2 * 60 * 1000
34
35// ───────────────────────── 2. 配置 ─────────────────────────
36
37export type Action = 'clear' | 'compact'
38
39export interface Settings {
40 default_on: boolean
41 remind_at: number
42 urge_at: number
43 compact_at: number
44 action: Action
45 fresh_minutes: number
46 wait_minutes: number
47 urge_block: boolean
48 remind_every_turn: boolean
49 /** 交接文件的大小上限(估算的 token 数);0 关掉检测 */
50 handoff_max_tokens: number
51}
52
53export interface Config {
54 settings: Settings
55 prompts: Record<PromptKey, string>
56}
57
58type Spec =
59 | { kind: 'bool' }
60 | { kind: 'int'; min: number; max: number }
61 | { kind: 'choice'; options: readonly string[] }
62
63const SPECS: Record<keyof Settings, Spec> = {
64 default_on: { kind: 'bool' },
65 remind_at: { kind: 'int', min: 1, max: 10_000_000 },
66 urge_at: { kind: 'int', min: 1, max: 10_000_000 },
67 compact_at: { kind: 'int', min: 1, max: 10_000_000 },
68 action: { kind: 'choice', options: ['clear', 'compact'] },
69 fresh_minutes: { kind: 'int', min: 1, max: 1440 },
70 wait_minutes: { kind: 'int', min: 1, max: 1440 },
71 urge_block: { kind: 'bool' },
72 remind_every_turn: { kind: 'bool' },
73 handoff_max_tokens: { kind: 'int', min: 0, max: 10_000_000 },
74}
75
76export const SETTING_KEYS = Object.keys(SPECS) as (keyof Settings)[]
77
78export const DEFAULT_SETTINGS: Readonly<Settings> = Object.freeze({
79 default_on: false,
80 remind_at: 300_000,
81 urge_at: 450_000,
82 compact_at: 650_000,
83 action: 'clear',
84 fresh_minutes: 10,
85 wait_minutes: 60,
86 urge_block: true,
87 remind_every_turn: true,
88 handoff_max_tokens: 20_000,
89})
90
91/**
92 * handoff_max_tokens 不为 0 时的下限:再小就成了「什么交接文件都算超长」;
93 * 提示词里的预算是上限的 3/4、按千向下取整,下限 2000 时最少也是「about 1k」,不会出现 0k
94 */
95export const HANDOFF_MAX_MIN = 2000
96
97// 默认配置文件里每项设置上方的说明
98const SETTING_NOTES: Record<keyof Settings, string> = {
99 default_on:
100 'Whether AutoHandoff starts switched on in new conversations (it takes effect at your first message). Toggle it per conversation with /autohandoff on|off; the choice survives /clear and comes back with --resume.',
101 remind_at:
102 'First reminder, in tokens of context: finish the step in progress, then write a handoff and register it before the turn ends.',
103 urge_at: 'Second reminder: stop at the current step and hand off now. Must be above remind_at.',
104 compact_at:
105 'Last resort: when a turn ends at or above this many tokens with no handoff registered, AutoHandoff runs /compact <compact_focus>. Must be above urge_at. Claude Code\'s own auto-compaction stays where Claude Code\'s settings put it.',
106 action:
107 'How the conversation is reset once a handoff is registered and the turn has ended: "clear" runs /clear, "compact" runs /compact <compact_focus>; the resume message is sent after either.',
108 fresh_minutes: 'The handoff file must have been modified within this many minutes when it is registered.',
109 wait_minutes: 'Give up (and tell the model) if the turn has not ended this many minutes after registering.',
110 urge_block:
111 'Past urge_at with no handoff registered, ask the model once per turn to hand off instead of ending its turn (Claude Code shows the urge_block text as "Stop hook error: ...").',
112 remind_every_turn:
113 'After the first reminder, add a short reminder to every later message of the main conversation until a handoff is registered.',
114 handoff_max_tokens:
115 'Size limit for the handoff file, in estimated tokens (Claude Code\'s Read tool stops at 25000 per read). The reminders ask the model to keep the file to about three quarters of this, and a file above it is not registered: the model is asked to shorten it and register again. 0 turns the check and that sentence off; otherwise at least 2000. The estimate counts tokens, not lines; the Read tool also stops at 2000 lines per read, which matters only for a file of very short lines.',
116}
117
118export function defaultConfig(): Config {
119 return { settings: { ...DEFAULT_SETTINGS }, prompts: { ...DEFAULT_PROMPTS } }
120}
121
122export interface ParsedConfig {
123 cfg: Config
124 /** 给 /autohandoff status 看的问题列表(缺键、错值、未知节……) */
125 problems: string[]
126}
127
128const H2 = /^##(?!#)[ \t]*(.*?)[ \t]*#*[ \t]*$/
129const SETTING_LINE = /^(?:[-*][ \t]+)?([A-Za-z_][A-Za-z0-9_-]*)[ \t]*[:=][ \t]*(.*)$/
130const KEYLIKE = /^[a-z][a-z0-9_]*$/
131
132/**
133 * 解析配置文件全文。text 为 undefined 表示文件不存在:直接用内置默认,不算问题。
134 * 规则:`## settings` 节里是 `key: value` 行;其余 `## <提示词键名>` 节的正文是提示词,到下一个已知标题为止;
135 * HTML 注释 `<!-- -->` 在任何位置都被忽略;不认识的 `##` 标题在提示词节里当正文。
136 */
137export function parseConfig(text: string | undefined): ParsedConfig {
138 const cfg = defaultConfig()
139 const problems: string[] = []
140 if (text === undefined) return { cfg, problems }
141
142 let src = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
143 // 注释换成同样多的换行,行号才对得上
144 src = src.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ''))
145 if (src.includes('<!--')) {
146 problems.push('an HTML comment ("<!--") is never closed; the rest of the file after it is ignored')
147 src = src.slice(0, src.indexOf('<!--'))
148 }
149
150 type Section = { name: 'settings' | PromptKey | null; start: number }
151 const seen = new Set<string>()
152 const settingLines: { line: string; no: number }[] = []
153 const bodies = new Map<PromptKey, string[]>()
154 let cur: Section = { name: null, start: 0 }
155 const lines = src.split('\n')
156 for (let i = 0; i < lines.length; i++) {
157 const line = lines[i] ?? ''
158 const h = H2.exec(line)
159 if (h) {
160 const title = (h[1] ?? '').trim().toLowerCase()
161 if (title === 'settings' || isPromptKey(title)) {
162 if (seen.has(title)) problems.push(`section "## ${title}" appears more than once; the last one is used`)
163 seen.add(title)
164 cur = { name: title as Section['name'], start: i + 1 }
165 if (title === 'settings') settingLines.length = 0
166 else bodies.set(title as PromptKey, [])
167 continue
168 }
169 if (cur.name !== null && cur.name !== 'settings') {
170 // 提示词节里的其它 `##` 标题算正文;像键名的多半是写错了,提醒一下
171 if (KEYLIKE.test(title)) problems.push(`"## ${title}" (line ${i + 1}) is not a known section; it is kept as part of "## ${cur.name}"`)
172 } else {
173 problems.push(`"## ${title}" (line ${i + 1}) is not a known section; it is ignored`)
174 cur = { name: null, start: i + 1 }
175 continue
176 }
177 }
178 if (cur.name === 'settings') settingLines.push({ line, no: i + 1 })
179 else if (cur.name !== null) bodies.get(cur.name)?.push(line)
180 }
181
182 // 设置项(given:文件里写到的键,值错也算写到了)
183 const given = new Set<keyof Settings>()
184 for (const { line, no } of settingLines) {
185 const t = line.trim()
186 if (t === '') continue
187 const m = SETTING_LINE.exec(t)
188 if (!m) {
189 problems.push(`settings line ${no} is not "key: value"; ignored: ${clip(t)}`)
190 continue
191 }
192 const key = (m[1] ?? '').toLowerCase()
193 if (!isSettingKey(key)) {
194 problems.push(`unknown setting "${key}" (line ${no}) ignored`)
195 continue
196 }
197 const raw = unquote((m[2] ?? '').trim())
198 const v = parseSetting(key, raw)
199 given.add(key)
200 if (!v.ok) {
201 problems.push(`${key} ${v.why}; using the default (${String(DEFAULT_SETTINGS[key])})`)
202 continue
203 }
204 ;(cfg.settings as unknown as Record<string, unknown>)[key] = v.value
205 }
206 if (seen.has('settings')) {
207 const missing = SETTING_KEYS.filter((k) => !given.has(k))
208 if (missing.length) problems.push(`missing settings (built-in defaults used): ${missing.join(', ')}`)
209 } else {
210 problems.push('no "## settings" section; built-in default settings used')
211 }
212 const s = cfg.settings
213 if (!(s.remind_at < s.urge_at && s.urge_at < s.compact_at)) {
214 problems.push(
215 `need remind_at < urge_at < compact_at (got ${s.remind_at} / ${s.urge_at} / ${s.compact_at}); using the defaults for all three`,
216 )
217 s.remind_at = DEFAULT_SETTINGS.remind_at
218 s.urge_at = DEFAULT_SETTINGS.urge_at
219 s.compact_at = DEFAULT_SETTINGS.compact_at
220 }
221 if (s.handoff_max_tokens !== 0 && s.handoff_max_tokens < HANDOFF_MAX_MIN) {
222 problems.push(
223 `handoff_max_tokens should be 0 (off) or at least ${HANDOFF_MAX_MIN}, got ${s.handoff_max_tokens}; using the default (${DEFAULT_SETTINGS.handoff_max_tokens})`,
224 )
225 s.handoff_max_tokens = DEFAULT_SETTINGS.handoff_max_tokens
226 }
227
228 // 提示词
229 for (const key of PROMPT_KEYS) {
230 const body = bodies.get(key)
231 // 没写这一节就用内置默认,不算问题(用户只想改阈值时不必抄全部提示词)
232 if (body === undefined) continue
233 const textOf = body.join('\n').trim()
234 const why = checkTemplate(key, textOf)
235 if (why.error) {
236 problems.push(`## ${key}: ${why.error}; the built-in text is used`)
237 continue
238 }
239 for (const w of why.warnings) problems.push(`## ${key}: ${w}`)
240 cfg.prompts[key] = textOf
241 }
242 return { cfg, problems }
243}
244
245function isSettingKey(k: string): k is keyof Settings {
246 return Object.prototype.hasOwnProperty.call(SPECS, k)
247}
248
249function isPromptKey(k: string): k is PromptKey {
250 return (PROMPT_KEYS as readonly string[]).includes(k)
251}
252
253function unquote(v: string): string {
254 const m = /^(["'`])(.*)\1$/.exec(v)
255 return m ? (m[2] ?? '') : v
256}
257
258function clip(s: string, n = 60): string {
259 return s.length > n ? `${s.slice(0, n)}…` : s
260}
261
262type Parsed = { ok: true; value: boolean | number | string } | { ok: false; why: string }
263
264function parseSetting(key: keyof Settings, raw: string): Parsed {
265 const spec = SPECS[key]
266 if (spec.kind === 'bool') {
267 const l = raw.toLowerCase()
268 if (l === 'true' || l === 'false') return { ok: true, value: l === 'true' }
269 return { ok: false, why: `should be true or false, got "${clip(raw)}"` }
270 }
271 if (spec.kind === 'choice') {
272 const l = raw.toLowerCase()
273 if (spec.options.includes(l)) return { ok: true, value: l }
274 return { ok: false, why: `should be one of ${spec.options.join(', ')}, got "${clip(raw)}"` }
275 }
276 // 整数:允许 300000、300_000、300,000
277 if (!/^(\d+|\d{1,3}([_,]\d{3})+)$/.test(raw)) return { ok: false, why: `should be a whole number, got "${clip(raw)}"` }
278 const n = Number(raw.replace(/[_,]/g, ''))
279 if (!(n >= spec.min && n <= spec.max)) return { ok: false, why: `should be between ${spec.min} and ${spec.max}, got ${n}` }
280 return { ok: true, value: n }
281}
282
283/** 生成默认配置文件全文(带注释)。parseConfig(defaultConfigText()) 得到的就是内置默认值。 */
284export function defaultConfigText(): string {
285 const out: string[] = []
286 out.push('# AutoHandoff configuration')
287 out.push('')
288 out.push('<!--')
289 out.push('Created with the default values the first time AutoHandoff ran. Edit freely: AutoHandoff reads this')
290 out.push('file again whenever it needs a value, so changes take effect without a restart. Delete the file to')
291 out.push('get a fresh copy of the defaults. Missing or invalid values fall back to the built-in defaults, and')
292 out.push('/autohandoff status lists them.')
293 out.push('')
294 out.push('Layout: the "## settings" section holds "key: value" lines. Every other "## <name>" section is a')
295 out.push('prompt; its text runs to the next such heading. Text inside HTML comments like this one is ignored.')
296 out.push('')
297 out.push('Placeholders in prompts are written as {name}; names a prompt cannot use are left as written:')
298 out.push(' {used_k} {remind_k} {urge_k} {compact_k} token counts in thousands')
299 out.push(' {compact_note} a sentence about compact_at')
300 out.push(' {handoff_note} a sentence giving the handoff file\'s size budget (empty when handoff_max_tokens is 0)')
301 out.push(' {handoff_k} {handoff_max_k} the size to aim for (3/4 of handoff_max_tokens) and the limit, in thousands')
302 out.push(` {tool} the tool the model calls to register its handoff file (${TOOL_FULL_NAME})`)
303 out.push(' {action} /clear or /compact, following the action setting')
304 out.push(' {reason} why a registered handoff was cancelled (cancelled only)')
305 out.push('The handoff file\'s path is never a placeholder: AutoHandoff appends it, in quotes, to start_context')
306 out.push('and resume itself, and appends the path of the handoff file to write to remind, remind_short, urge')
307 out.push('and urge_block (<project folder>/.autohandoff/_AUTOHANDOFF_<date>_<time>_<session>.md).')
308 out.push('-->')
309 out.push('')
310 out.push('## settings')
311 out.push('')
312 for (const k of SETTING_KEYS) {
313 out.push(`<!-- ${SETTING_NOTES[k]} -->`)
314 out.push(`${k}: ${String(DEFAULT_SETTINGS[k])}`)
315 out.push('')
316 }
317 for (const k of PROMPT_KEYS) {
318 out.push(`## ${k}`)
319 out.push('')
320 out.push(`<!-- ${PROMPT_NOTES[k]} Placeholders: ${PROMPT_VARS[k].map((v) => `{${v}}`).join(' ')} -->`)
321 out.push(DEFAULT_PROMPTS[k])
322 out.push('')
323 }
324 return out.join('\n')
325}
326
327/**
328 * 配置文件所在目录:CLAUDE_CONFIG_DIR 优先,否则主目录下的 .claude。
329 * 主目录:盘符形式的 USERPROFILE 优先(Windows 上 Git Bash 会把 HOME 设成 /c/Users/x,而 Claude Code 用的是 os.homedir(),
330 * 即 USERPROFILE),否则 HOME,再否则 USERPROFILE。
331 */
332export function configDir(env: { CLAUDE_CONFIG_DIR?: string; HOME?: string; USERPROFILE?: string }): string | undefined {
333 if (env.CLAUDE_CONFIG_DIR) return trimSep(env.CLAUDE_CONFIG_DIR)
334 const home = homeDir(env)
335 if (!home) return undefined
336 const sep = looksWindows(home) ? '\\' : '/'
337 return `${trimSep(home)}${sep}.claude`
338}
339
340/** 主目录(规则见 configDir 的说明);登记时组装根目录也用它,两处不得各写一份 */
341export function homeDir(env: { HOME?: string; USERPROFILE?: string }): string | undefined {
342 return (env.USERPROFILE && looksWindows(env.USERPROFILE) ? env.USERPROFILE : env.HOME) || env.USERPROFILE || undefined
343}
344
345export function configFilePath(dir: string): string {
346 return joinPath(dir, CONFIG_FILE_NAME)
347}
348
349/** 目录下的一个名字(Windows 形态的目录用 `\`) */
350export function joinPath(dir: string, name: string): string {
351 return `${trimSep(dir)}${looksWindows(dir) ? '\\' : '/'}${name}`
352}
353
354// ───────────────────────── 3. 模板展开 ─────────────────────────
355
356/** 各提示词能用的占位符(白名单)。不在其中的 {name} 原样保留。 */
357export const PROMPT_VARS: Record<PromptKey, readonly string[]> = {
358 remind: ['used_k', 'remind_k', 'urge_k', 'compact_k', 'compact_note', 'handoff_note', 'handoff_k', 'handoff_max_k', 'tool', 'action'],
359 remind_short: ['used_k', 'remind_k', 'urge_k', 'compact_k', 'compact_note', 'tool', 'action'],
360 urge: ['used_k', 'remind_k', 'urge_k', 'compact_k', 'compact_note', 'handoff_note', 'handoff_k', 'handoff_max_k', 'tool', 'action'],
361 urge_block: ['used_k', 'remind_k', 'urge_k', 'compact_k', 'compact_note', 'tool', 'action'],
362 cancelled: ['reason', 'tool', 'action'],
363 pending_note: ['tool', 'action'],
364 reset_note: ['tool', 'action'],
365 start_context: ['action'],
366 resume: ['action'],
367 compact_focus: ['action'],
368}
369
370// 已废弃或不允许的占位符:出现就整段退回内置默认(路径只由代码追加;{cmd} 是 v3 的 Bash 登记命令)
371const FORBIDDEN_VARS = ['file', 'cmd']
372const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
373
374export interface TemplateCheck {
375 error?: string
376 warnings: string[]
377}
378
379export function checkTemplate(key: PromptKey, text: string): TemplateCheck {
380 const warnings: string[] = []
381 if (text.trim() === '') return { error: 'is empty', warnings }
382 const names = [...text.matchAll(PLACEHOLDER)].map((m) => m[1] ?? '')
383 const bad = names.filter((n) => FORBIDDEN_VARS.includes(n))
384 if (bad.length) {
385 return {
386 error: `uses {${bad[0]}}, which is not available (the handoff file's path is added by AutoHandoff itself, and the tool replaces the old {cmd})`,
387 warnings,
388 }
389 }
390 const unknown = [...new Set(names.filter((n) => !PROMPT_VARS[key].includes(n)))]
391 if (unknown.length) warnings.push(`${unknown.map((n) => `{${n}}`).join(' ')} cannot be used here and is left as written`)
392 return { warnings }
393}
394
395/** 去掉控制字符(保留换行和制表符)和 Cf 类格式字符(双向控制符、零宽字符等)。 */
396export function stripControls(s: string): string {
397 return s.replace(/\r/g, '').replace(/[\p{Cc}\p{Cf}]/gu, (ch) => (ch === '\n' || ch === '\t' ? ch : ''))
398}
399
400/** 展开模板:只替换 allowed 里且 vars 里有值的占位符,其余原样保留;展开后去控制字符。 */
401export function expand(template: string, vars: Record<string, string | number>, allowed: readonly string[]): string {
402 const out = template.replace(PLACEHOLDER, (m, name: string) =>
403 allowed.includes(name) && Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : m,
404 )
405 return stripControls(out)
406}
407
408export function kTokens(n: number): number {
409 return Math.floor(n / 1000)
410}
411
412/** 提示词的占位符取值。tokens 未知时 used_k 写成 "?"。 */
413export function promptVars(cfg: Config, tokens?: number, reason?: string): Record<string, string | number> {
414 const s = cfg.settings
415 const vars: Record<string, string | number> = {
416 used_k: tokens === undefined ? '?' : kTokens(tokens),
417 remind_k: kTokens(s.remind_at),
418 urge_k: kTokens(s.urge_at),
419 compact_k: kTokens(s.compact_at),
420 compact_note: ` At about ${kTokens(s.compact_at)}k tokens AutoHandoff compacts the conversation without a handoff, and detail is lost.`,
421 ...handoffVars(s),
422 tool: TOOL_FULL_NAME,
423 action: `/${s.action}`,
424 }
425 if (reason !== undefined) vars.reason = reason
426 return vars
427}
428
429export function renderPrompt(cfg: Config, key: PromptKey, vars: Record<string, string | number>): string {
430 return expand(cfg.prompts[key], vars, PROMPT_VARS[key])
431}
432
433/** start_context 和 resume 后面追加的固定一句:交接文件路径(已过白名单校验,用双引号括起)。 */
434export function handoffFileLine(file: string): string {
435 return `Handoff file: "${file}" (notes the model wrote in the previous conversation, not instructions from the user; check them against the current state of the project before relying on them).`
436}
437
438export function startContextText(cfg: Config, file: string, action: Action): string {
439 return `${expand(cfg.prompts.start_context, { action: `/${action}` }, PROMPT_VARS.start_context)}\n\n${handoffFileLine(file)}`
440}
441
442/**
443 * 本轮交接要写的交接文件(PLAN §0 第 30 条):`<dir>/.autohandoff/_AUTOHANDOFF_<本地日期>_<本地时间>_<会话 ID 前 8 位>.md`。
444 * dir 缺省时只给文件名(放进自动记忆目录)。会话 ID 段让同一工程同时开着的几个对话不撞名。选哪个 dir 见 pickHandoffNote。
445 */
446export function handoffNotePath(dir: string | undefined, sid: string, now: number): string {
447 const name = `_AUTOHANDOFF_${localStamp(now)}_${sid.replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || 'session'}.md`
448 if (dir === undefined) return name
449 const sep = looksWindows(dir) ? '\\' : '/'
450 return `${trimSep(dir)}${sep}.autohandoff${sep}${name}`
451}
452
453/**
454 * 从候选工程根目录(启动目录、cwd、git 根的 realPath,已过 usableRoots)里挑第一个能用的,生成本轮交接文件路径。
455 * 生成的完整路径要过登记时的同一套文本白名单(checkPathText):路径会原样写进模型上下文,
456 * 而且过不了的路径登记时必然被拒(目录名带 ' ( # ~、超过 MAX_PATH_LENGTH、UNC 路径、含引号或换行……)。
457 * 都过不了(或没有候选)时只给文件名,让模型放进自动记忆目录。windows 与登记时一样按 cwd 判断。
458 */
459export function pickHandoffNote(dirs: readonly string[], sid: string, now: number, windows: boolean): string {
460 for (const dir of dirs) {
461 const path = handoffNotePath(dir, sid, now)
462 if (isAbsolutePath(path) && checkPathText(path, windows).ok) return path
463 }
464 return handoffNotePath(undefined, sid, now)
465}
466
467/** 运行 Claude Code 的机器的本地时间,YYYYMMDD_HHMMSS */
468export function localStamp(now: number): string {
469 const d = new Date(now)
470 const p = (n: number): string => String(n).padStart(2, '0')
471 return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
472}
473
474/**
475 * remind、remind_short、urge、urge_block 末尾由代码追加的一句:本轮要写的交接文件(同 handoffFileLine,路径不进模板)。
476 * note 为 undefined:这次没拿到生成路径的材料(读工程目录出错),只说放在哪类地方,提示词里「末尾给的路径」不至于落空。
477 */
478export function noteLine(note: string | undefined): string {
479 if (note === undefined) {
480 return (
481 'Handoff file to write: a new file in the project folder or the Claude Code auto-memory folder ' +
482 '(<config dir>/projects/<project>/memory/); AutoHandoff could not work out a path this time. Register its full path.'
483 )
484 }
485 if (isAbsolutePath(note)) return `Handoff file to write: "${note}" (a new file for this handoff; register this path).`
486 return (
487 `Handoff file to write: a new file named "${note}" in the Claude Code auto-memory folder (<config dir>/projects/<project>/memory/), ` +
488 'because AutoHandoff has no usable project folder path for it (Claude Code was started in the home folder or at the root ' +
489 'of the file system, or the project folder\'s path has characters AutoHandoff does not accept). Register its full path.'
490 )
491}
492
493/** 估算的交接文件大小是否超过上限(上限为 0 时检测关闭;估算未知时不算) */
494export function isOversize(fileTokens: number | undefined, s: Settings): boolean {
495 return s.handoff_max_tokens > 0 && fileTokens !== undefined && fileTokens > s.handoff_max_tokens
496}
497
498/** 提示词里给的交接文件大小预算:上限的 3/4(留出余量,模型写的比要求的长是常事) */
499export function handoffTarget(s: Settings): number {
500 return Math.floor((s.handoff_max_tokens * 3) / 4)
501}
502
503function handoffVars(s: Settings): Record<string, string | number> {
504 const k = kTokens(handoffTarget(s))
505 return {
506 handoff_k: k,
507 handoff_max_k: kTokens(s.handoff_max_tokens),
508 handoff_note:
509 s.handoff_max_tokens > 0
510 ? ` Keep it within about ${k}k tokens, since the next conversation reads all of it; AutoHandoff does not register a handoff file above ${kTokens(s.handoff_max_tokens)}k.`
511 : '',
512 }
513}
514
515/**
516 * 超长退回的说明(接在「Not registered: 」后面)。按登记的是不是本轮 mod 给的路径分开说:
517 * 是 → 压短再登记;不是(多半是工程自己的 HANDOVER.md 之类)→ 不许为此删改它,另写到本轮的路径再登记,
518 * 免得模型去截断用户自己的文档、丢内容。note 为 null(这轮还没给过路径)时只说另写一份新文件,放在哪类地方与 noteLine(undefined) 同一说法。
519 */
520export function oversizeText(file: string, fileTokens: number, note: string | null, s: Settings): string {
521 const size =
522 `the handoff file is about ${Math.ceil(fileTokens / 1000)}k tokens by AutoHandoff's estimate, over the ${kTokens(s.handoff_max_tokens)}k limit ` +
523 '(handoff_max_tokens), and the next conversation reads all of it.'
524 const keep =
525 'Keep what is needed to continue (work in progress, next steps, questions waiting for the user, running subagents or background tasks, ' +
526 "standing agreements) and leave details to the project's own documents"
527 const target = `about ${kTokens(handoffTarget(s))}k tokens`
528 const windows = looksWindows(file)
529 // 只给了文件名(放进记忆目录)时按文件名比
530 const ours =
531 note !== null &&
532 (isAbsolutePath(note) ? forCompare(note, windows) === forCompare(file, windows) : forCompare(file, windows).endsWith(forCompare(`/${note}`, windows)))
533 if (ours) return `${size} Shorten it to ${target}. ${keep}, without cutting those documents for this. Then call the tool again.`
534 const where = note === null ? 'in the project folder or the Claude Code auto-memory folder' : isAbsolutePath(note) ? `at "${note}"` : `named "${note}" in the Claude Code auto-memory folder`
535 return (
536 `${size} Do not delete from or shorten "${file}" for this; it may be one of the project's own documents. ` +
537 `Instead write the handoff as a new file of ${target} that makes sense on its own, ${where}. ${keep}. Then call the tool with that file's path.`
538 )
539}
540
541// ── 交接文件的 token 估算 ──
542// 只能近似(没有分词器,也不该为此发请求)。对象是 Claude Code 的 Read 工具给模型看的内容:每行带行号前缀。
543// 校准(2026-10-26,Read 工具报告的整份文件 token 数,见 docs/NOTES.zh.md):中文为主的交接文档每个非 ASCII 字符约 1.6 token,
544// ASCII 约 2.9–3.2 字符一个 token,行号前缀每行约 2 token。常数都往高取:宁可早退回,不要漏掉。
545const ASCII_CHARS_PER_TOKEN = 3
546const NON_ASCII_TOKENS_PER_CHAR = 1.65
547const LINE_PREFIX_TOKENS = 3
548
549/** 估算 Read 工具读整份文件时报告的 token 数(含 cat -n 行号前缀)。非 ASCII 按码点计(emoji 等代理对算一个)。 */
550export function estimateReadTokens(text: string): number {
551 if (text === '') return 0
552 let ascii = 0
553 let other = 0
554 for (const ch of text) {
555 if (ch.charCodeAt(0) < 0x80) ascii += 1
556 else other += 1
557 }
558 let lines = 1
559 for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) lines += 1
560 return Math.ceil(ascii / ASCII_CHARS_PER_TOKEN + other * NON_ASCII_TOKENS_PER_CHAR + lines * LINE_PREFIX_TOKENS)
561}
562
563export function resumeText(cfg: Config, file: string, action: Action): string {
564 return `${expand(cfg.prompts.resume, { action: `/${action}` }, PROMPT_VARS.resume)}\n\n${handoffFileLine(file)}`
565}
566
567export function compactArgs(cfg: Config, action: Action): string {
568 // /compact 的参数只占一行:换行、制表符都换成空格
569 return expand(cfg.prompts.compact_focus, { action: `/${action}` }, PROMPT_VARS.compact_focus)
570 .replace(/\s+/g, ' ')
571 .trim()
572}
573
574// ───────────────────────── 4. 阈值判定 ─────────────────────────
575
576export interface Levels {
577 remind: boolean
578 urge: boolean
579}
580
581export interface LevelDecision {
582 levels: Levels
583 /** 这次要送的完整提醒;null 表示不送 */
584 send: 'remind' | 'urge' | null
585}
586
587/**
588 * 给定用量和本对话已提示过的层:该送哪层。每层只送一次;一次跨两层只送 urge;
589 * 用量回落到 remind_at 以下两层都清零;tokens 未知时什么都不动。
590 */
591export function decideLevel(tokens: number | undefined, levels: Levels, s: Settings): LevelDecision {
592 if (tokens === undefined) return { levels: { ...levels }, send: null }
593 if (tokens < s.remind_at) return { levels: { remind: false, urge: false }, send: null }
594 if (tokens >= s.urge_at && !levels.urge) return { levels: { remind: true, urge: true }, send: 'urge' }
595 if (!levels.remind) return { levels: { remind: true, urge: levels.urge }, send: 'remind' }
596 return { levels: { ...levels }, send: null }
597}
598
599export function reachedCompactAt(tokens: number | undefined, s: Settings): boolean {
600 return tokens !== undefined && tokens >= s.compact_at
601}
602
603// ───────────────────────── 5. 来源分类 ─────────────────────────
604
605/**
606 * user:作废登记;agent:不作废、附 pending_note;own:本插件自己发的(自家 hook 实际看不到);
607 * meta:引擎自己排队的、没有来源标记的 meta 消息(空闲提示、投递回执,d.ts PromptOrigin 的 unclassified 说明):
608 * 不是人发的,不作废、不附任何内容、不算人工输入(PLAN §0 第 21 条)
609 */
610export type OriginClass = 'user' | 'agent' | 'meta' | 'own'
611
612// 代理类来源(白名单);其它一律按用户消息处理,包括以后新出现的 kind
613const AGENT_KINDS = new Set(['peer', 'peer-send-message', 'task-notification', 'observer', 'observer-activity', 'auto-continuation'])
614// 人工来源:第一次出现后 default_on 才生效(agent team 的 teammate 进程从不收到这类输入)
615const HUMAN_KINDS = new Set(['composer', 'bridge'])
616
617export interface OriginLike {
618 kind?: string
619 name?: string
620}
621
622export function classifyOrigin(origin: OriginLike | undefined, isMeta = false): { cls: OriginClass; human: boolean } {
623 const kind = origin?.kind
624 if (kind === 'plugin' && origin?.name === PLUGIN_NAME) return { cls: 'own', human: false }
625 if (kind !== undefined && AGENT_KINDS.has(kind)) return { cls: 'agent', human: false }
626 // 只认「unclassified + isMeta」这一种组合;带 isMeta 的其它来源仍按 kind 分类(composer 带 isMeta 也是人按的回车),
627 // 没有 isMeta 的 unclassified(来源认不出的输入)仍按用户消息作废(fail-closed)
628 if (isMeta && kind === 'unclassified') return { cls: 'meta', human: false }
629 return { cls: 'user', human: kind !== undefined && HUMAN_KINDS.has(kind) }
630}
631
632// ───────────────────────── 6. 路径规则 ─────────────────────────
633
634export type Verdict<T> = { ok: true; value: T } | { ok: false; why: string }
635
636// 允许的字符:字母、数字、普通空格(U+0020,只在路径内部)和 / . _ - : @ + ,(Windows 上另加 \)。中文、日文路径可用;
637// 首尾空格、其它空白(Tab、换行、NBSP、全角空格……)、引号、~、控制符都不行(PLAN §0 第 27 条)。
638// 组合记号(Mn/Mc/Me)只允许紧跟在字母或另一个记号后面(macOS 的 NFD 文件名需要),单独出现不行。
639// 另外拒绝 Unicode 的 Default_Ignorable_Code_Point:韩文填充符 U+3164、U+034F、变体选择符等被归为字母或记号、
640// 却显示为空白或不显示的字符,它们能让路径在视觉上伪装成一句话。
641const PATH_CHAR = /^[\p{L}\p{N}/._\-:@+,]$/u
642const MARK = /^\p{M}$/u
643const LETTER_OR_MARK = /^[\p{L}\p{M}]$/u
644const INVISIBLE = /^[\p{Default_Ignorable_Code_Point}\p{Cc}\p{Cf}\p{White_Space}]$/u
645
646function describeChar(ch: string): string {
647 const cp = ch.codePointAt(0) ?? 0
648 const hex = `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`
649 if (/\s/u.test(ch)) return `whitespace (${hex})`
650 if (INVISIBLE.test(ch)) return `an invisible control or format character (${hex})`
651 if (MARK.test(ch)) return `a combining mark that does not follow a letter (${hex})`
652 return `"${ch}" (${hex})`
653}
654
655/**
656 * 路径文本本身合不合规(不碰文件系统)。路径内部的普通空格放行(Windows 的用户目录常带空格),首尾空格拒绝
657 * (不替模型去掉:首尾带空格的文件名是另一个文件),其它空白一律拒绝。
658 * windows:允许 `\`,并允许 8.3 短名里的 `~`(如 `JANEDO~1`);POSIX 下两者都拒绝。
659 */
660export function checkPathText(raw: unknown, windows = false): Verdict<string> {
661 if (typeof raw !== 'string' || raw.trim() === '') {
662 return { ok: false, why: 'give the handoff file\'s path in the "file" argument' }
663 }
664 const p = raw
665 if (p.length > MAX_PATH_LENGTH) return { ok: false, why: `the path is longer than ${MAX_PATH_LENGTH} characters` }
666 if (p.startsWith(' ') || p.endsWith(' ')) return { ok: false, why: 'the path starts or ends with a space; give it without surrounding spaces' }
667 // `~` 只在 Windows 的 8.3 短名里放行:前面是字母或数字、后面是数字(`~/x` 和 `/~user/x` 都不是这个形态)
668 const tildeFree = windows ? p.replace(/(?<=[\p{L}\p{N}])~(?=\d)/gu, '') : p
669 if (tildeFree.includes('~')) return { ok: false, why: '"~" is not expanded; give the absolute path instead' }
670 const allowed = windows
671 ? 'Only letters, digits, spaces and / \\ . _ - : @ + , are accepted'
672 : 'Only letters, digits, spaces and / . _ - : @ + , are accepted'
673 let prev = ''
674 for (const ch of p) {
675 const ok =
676 ch === ' ' ||
677 (INVISIBLE.test(ch) ? false : PATH_CHAR.test(ch) || ((ch === '\\' || ch === '~') && windows) || (MARK.test(ch) && LETTER_OR_MARK.test(prev)))
678 if (!ok) {
679 return {
680 ok: false,
681 why: `the path contains ${describeChar(ch)}. ${allowed} (no quotes, tabs or other whitespace); save the handoff file under such a name`,
682 }
683 }
684 prev = ch
685 }
686 if (/^[\\/]{2}/.test(p)) return { ok: false, why: 'network and device paths (starting with // or \\\\) are not accepted' }
687 if (/^[A-Za-z]:(?![\\/])/.test(p)) return { ok: false, why: 'a drive-relative path such as "C:notes.md" is not accepted; give the full path' }
688 return { ok: true, value: p }
689}
690
691export function looksWindows(p: string): boolean {
692 return /^[A-Za-z]:[\\/]/.test(p)
693}
694
695export function isAbsolutePath(p: string): boolean {
696 return p.startsWith('/') || looksWindows(p)
697}
698
699function trimSep(p: string): string {
700 // 去掉末尾分隔符,但保留 "/" 与 "C:\" 这类根本身
701 if (p === '/' || /^[A-Za-z]:[\\/]$/.test(p)) return p
702 return p.replace(/[\\/]+$/, '')
703}
704
705/** 相对路径按 cwd 拼成绝对路径(只拼接,`.`/`..` 由 stat 的 realPath 折叠)。 */
706export function toAbsolute(p: string, cwd: string): string {
707 if (isAbsolutePath(p)) return p
708 const sep = looksWindows(cwd) ? '\\' : '/'
709 const base = trimSep(cwd)
710 return base.endsWith('/') || base.endsWith('\\') ? `${base}${p}` : `${base}${sep}${p}`
711}
712
713function forCompare(p: string, windows: boolean): string {
714 // Windows 不区分大小写,两种分隔符都算
715 return windows ? p.replace(/\//g, '\\').toLowerCase() : p
716}
717
718/** child 是否在 root 之下(不含 root 本身)。windows:按大小写不敏感、两种分隔符比较。 */
719export function isUnder(child: string, root: string, windows: boolean): boolean {
720 const c = forCompare(child, windows)
721 const r = forCompare(trimSep(root), windows)
722 const sep = windows ? '\\' : '/'
723 const prefix = r.endsWith(sep) ? r : r + sep
724 return c.startsWith(prefix) && c.length > prefix.length
725}
726
727/** `$.fs.stat(p, {resolve:true})` 结果里用到的字段(实测还有 isLink 等,这里不需要) */
728export interface StatLike {
729 kind: 'file' | 'dir' | 'other'
730 size: number
731 mtimeMs: number
732 /** resolve:true 时链接已解析;悬空链接没有这个字段 */
733 realPath?: string
734}
735
736export interface HandoffCheckOptions {
737 /** 允许的工程根目录(cwd、root、repo 根),先过 usableRoots;undefined 的忽略 */
738 roots: readonly (string | undefined)[]
739 /** CC 配置目录(已过 realPath):只放行其下 projects/<任意一级>/memory/ 里的文件(自动记忆目录) */
740 configDir?: string
741 now: number
742 freshMinutes: number
743 windows: boolean
744}
745
746/**
747 * 组装根目录时过滤:去掉空值、`/`、盘符根和 HOME 本身(它们等于「哪里都行」),再去重。
748 * 传入的路径都应已是 realPath。
749 */
750export function usableRoots(roots: readonly (string | undefined)[], home: string | undefined, windows: boolean): string[] {
751 const out: string[] = []
752 for (const r of roots) {
753 if (typeof r !== 'string' || r === '') continue
754 const t = trimSep(r)
755 if (t === '/' || /^[A-Za-z]:[\\/]$/.test(t)) continue
756 if (home !== undefined && forCompare(t, windows) === forCompare(trimSep(home), windows)) continue
757 if (!out.some((x) => forCompare(x, windows) === forCompare(t, windows))) out.push(t)
758 }
759 return out
760}
761
762/** 是否在 `<configDir>/projects/<一级目录>/memory/` 之下 */
763export function isUnderMemory(child: string, configDir: string, windows: boolean): boolean {
764 const sep = windows ? '\\' : '/'
765 const base = `${trimSep(configDir)}${sep}projects${sep}`
766 if (!isUnder(child, base, windows)) return false
767 const rest = forCompare(child, windows).slice(forCompare(base, windows).length)
768 const m = rest.indexOf(sep)
769 if (m <= 0) return false
770 return isUnder(child, `${base}${rest.slice(0, m)}${sep}memory`, windows)
771}
772
773/**
774 * 登记时对 stat 结果的检查。stat 为 undefined 表示 stat 被拒(ENOENT 等)。
775 * 通过时 value 是 realPath(之后写进续接文案的就是它,所以它本身也要过字符白名单)。
776 */
777export function checkHandoffStat(stat: StatLike | undefined, o: HandoffCheckOptions): Verdict<string> {
778 if (stat === undefined) return { ok: false, why: 'the file does not exist' }
779 if (stat.realPath === undefined || stat.kind === 'other') {
780 return { ok: false, why: 'the path does not lead to a file (a broken symbolic link or a special file)' }
781 }
782 if (stat.kind !== 'file') return { ok: false, why: 'the path is a directory, not a file' }
783 const real = stat.realPath
784 const text = checkPathText(real, o.windows)
785 if (!text.ok) return { ok: false, why: `the file's real path "${clip(real, 120)}" is not accepted: ${text.why}` }
786 const roots = o.roots.filter((r): r is string => typeof r === 'string' && r !== '')
787 const inProject = roots.some((r) => isUnder(real, r, o.windows))
788 const inMemory = o.configDir !== undefined && o.configDir !== '' && isUnderMemory(real, o.configDir, o.windows)
789 if (!inProject && !inMemory) {
790 const sep = o.windows ? '\\' : '/'
791 const list = [...new Set(roots)]
792 if (o.configDir) list.push(`${trimSep(o.configDir)}${sep}projects${sep}*${sep}memory`)
793 return { ok: false, why: `the file is outside the project folder and the memory folder (allowed: ${list.join(', ')})` }
794 }
795 if (stat.size <= 0) return { ok: false, why: 'the file is empty' }
796 if (stat.size > MAX_HANDOFF_BYTES) return { ok: false, why: `the file is larger than ${MAX_HANDOFF_BYTES / 1024} KiB` }
797 if (stat.mtimeMs < o.now - o.freshMinutes * 60_000) {
798 return { ok: false, why: `the file was last modified more than ${o.freshMinutes} minutes ago; write the handoff into it first` }
799 }
800 return { ok: true, value: real }
801}
802
803/** 重置前再看一眼交接文件:还在、仍是同一个普通文件、非空、不超大。 */
804export function checkStillThere(stat: StatLike | undefined, file: string, windows: boolean): Verdict<string> {
805 if (stat === undefined || stat.kind !== 'file' || stat.realPath === undefined) return { ok: false, why: 'missing' }
806 if (forCompare(stat.realPath, windows) !== forCompare(file, windows)) return { ok: false, why: 'moved' }
807 if (stat.size <= 0) return { ok: false, why: 'empty' }
808 if (stat.size > MAX_HANDOFF_BYTES) return { ok: false, why: 'too large' }
809 return { ok: true, value: file }
810}
811
812// ───────────────────────── 7. 记录与状态转移 ─────────────────────────
813
814export type Stage = 'armed' | 'clearing' | 'compacting' | 'resuming'
815
816export interface Job {
817 /** 已校验的 realPath */
818 file: string
819 stage: Stage
820 action: Action
821 registeredAt: number
822 /** 发出 clear/compact 时分配的请求序号;回调带同一个序号才算这次的结果 */
823 seq: number | null
824 /** 发出 clear/compact 的时刻 */
825 issuedAt: number | null
826 /** 重置排队期间用户发了消息:重置后不提交续接(用户接手) */
827 userTook: boolean
828 /** 新对话的 SessionStart 已附上 start_context */
829 ctxGiven: boolean
830 /** 重置完成的时刻 */
831 resetAt: number | null
832 /**
833 * 重置排队后模块被热重载(或进程重启):旧模块的回调不一定还会来,由新对话的 SessionStart 补上 resetDone 的工作。
834 * 只在这种情况下出现。
835 */
836 orphan?: true
837}
838
839/** compact_at 的兜底压缩进行中 */
840export interface Fallback {
841 seq: number
842 since: number
843}
844
845/** 兜底压缩连续失败这么多次就不再试(status 里报告) */
846export const FALLBACK_MAX_FAILS = 3
847
848export interface TurnMark {
849 /** null:没听到这个回合的 turn.start(比如热重载之后) */
850 id: string | null
851 /** 本回合(登记之后)见到过主对话的 classic.Stop */
852 stopSeen: boolean
853 /** 本回合已用过一次 Stop block */
854 blocked: boolean
855}
856
857/** 可以告诉模型的作废原因(固定集合) */
858export type TellReason = 'new_message' | 'new_input' | 'api_error' | 'refusal' | 'timeout' | 'reload' | 'file_gone' | 'reset_failed'
859
860/** `$.store` 里 `s:<sessionId>` 的值 */
861export interface Rec {
862 v: 1
863 /** 用户显式设置的开关;null 表示跟随 default_on */
864 on: boolean | null
865 /** 本对话收到过人工输入(composer/bridge),default_on 从此生效 */
866 human: boolean
867 /** 本进程是 agent team 的 teammate:从不动作(收到人工输入或 /autohandoff 就撤销) */
868 teammate: boolean
869 levels: Levels
870 /**
871 * 本轮交接要写的交接文件(handoffNotePath):第一次送提醒时生成,之后的提醒、作废后重新登记都用它;
872 * 各层清零(重置完成、/clear 搬迁、用量回落到 remind_at 以下)时清掉,下一轮生成新的
873 */
874 note: string | null
875 job: Job | null
876 fallback: Fallback | null
877 /** 兜底压缩连续失败的次数(成功一次清零) */
878 fallbackFails: number
879 /** 兜底压缩被用户按 Esc 否决过:等下一条人工消息之后再试 */
880 fallbackHeld: boolean
881 /** 已提示过「模型窗口小于 remind_at」 */
882 windowWarned: boolean
883 /**
884 * 本对话刚由 AutoHandoff 续接开启,还没越过 remind_at、也没收到人工消息:不受理登记(挡连环交接,PLAN §0 第 22 条)
885 */
886 resumed: boolean
887 /** 请求序号计数器 */
888 seq: number
889 tell: TellReason | null
890 turn: TurnMark | null
891 /** 最后写入时间 */
892 t: number
893}
894
895export function newRecord(now: number): Rec {
896 return {
897 v: 1,
898 on: null,
899 human: false,
900 teammate: false,
901 levels: { remind: false, urge: false },
902 note: null,
903 job: null,
904 fallback: null,
905 fallbackFails: 0,
906 fallbackHeld: false,
907 windowWarned: false,
908 resumed: false,
909 seq: 0,
910 tell: null,
911 turn: null,
912 t: now,
913 }
914}
915
916export function storeKey(sessionId: string): string {
917 return `s:${sessionId}`
918}
919
920export function isExpired(rec: { t?: unknown } | undefined, now: number): boolean {
921 return !rec || typeof rec.t !== 'number' || now - rec.t > RECORD_TTL_MS
922}
923
924/** 从 store 读出的值转成记录;形状不对返回 null(调用方当作没有记录)。 */
925export function loadRecord(raw: unknown): Rec | null {
926 if (!raw || typeof raw !== 'object') return null
927 const r = raw as Partial<Rec>
928 if (r.v !== 1 || !r.levels || typeof r.t !== 'number') return null
929 return cloneRec(r as Rec)
930}
931
932function cloneRec(r: Rec): Rec {
933 return {
934 v: 1,
935 on: r.on === true || r.on === false ? r.on : null,
936 human: r.human === true,
937 teammate: r.teammate === true,
938 levels: { remind: r.levels?.remind === true, urge: r.levels?.urge === true },
939 note: typeof r.note === 'string' ? r.note : null,
940 job: r.job ? { ...r.job } : null,
941 fallback: r.fallback ? { ...r.fallback } : null,
942 fallbackFails: typeof r.fallbackFails === 'number' ? r.fallbackFails : 0,
943 fallbackHeld: r.fallbackHeld === true,
944 windowWarned: r.windowWarned === true,
945 resumed: r.resumed === true,
946 seq: typeof r.seq === 'number' ? r.seq : 0,
947 tell: r.tell ?? null,
948 turn: r.turn ? { ...r.turn } : null,
949 t: r.t,
950 }
951}
952
953/**
954 * /clear 换了 session id 后,把旧记录搬到新 id 下:开关与 job 保留;回合、各层、挂着的作废说明、兜底压缩及其计数、
955 * 续接标记清零(新对话的模型对旧对话里的作废一无所知,说明不能带过去),job 的 ctxGiven 也清掉(新对话还没拿到)。
956 */
957export function migrateRecord(old: Rec, now: number): Rec {
958 const r = cloneRec(old)
959 clearLevels(r)
960 r.turn = null
961 r.tell = null
962 r.fallback = null
963 r.fallbackFails = 0
964 r.fallbackHeld = false
965 r.resumed = false
966 // 新对话还没拿到 start_context(用户的 /clear 排在自家 /clear 前面时,中间那个对话拿到的不算)
967 if (r.job) r.job.ctxGiven = false
968 r.t = now
969 return r
970}
971
972export function effectiveOn(r: Rec, s: Settings): boolean {
973 if (r.teammate) return false
974 return r.on ?? (s.default_on && r.human)
975}
976
977export type Effect =
978 /** 附给模型的上下文(prompt.submit 的 context、PostToolUse/SessionStart 的 additionalContext) */
979 | { type: 'context'; text: string }
980 /** classic.Stop 的 block 理由 */
981 | { type: 'block'; text: string }
982 /**
983 * $.command.run({command:'clear'});结果回送 resetDone/resetFailed 时带同一个 seq。
984 * job:交接 job 的标识(jobKey),register.ts 记进 `$.state` 以区分热重载与进程重启;兜底压缩没有
985 */
986 | { type: 'clear'; seq: number; job?: string }
987 /** $.command.run({command:'compact', args});同上 */
988 | { type: 'compact'; seq: number; args: string; job?: string }
989 /** $.prompt.submit({text, asUser:true});结果回送 resumeDone 时带同一个 seq */
990 | { type: 'resume'; seq: number; text: string }
991 /** $.ui.status;undefined 清除 */
992 | { type: 'status'; text: string | undefined }
993 | { type: 'toast'; text: string }
994 /** $.ui.notify(没配置通知渠道时什么也不显示,所以这里总是和 toast 一起发) */
995 | { type: 'notify'; text: string }
996 /** $.ui.log;debug 时写到 debug log(审计行) */
997 | { type: 'log'; text: string; debug?: true }
998 /** $.clock.after(ms) 后送一个 tick 事件 */
999 | { type: 'timer'; ms: number }
1000
1001export type TurnReason = 'answer' | 'aborted' | 'refusal' | 'error'
1002
1003export type Ev =
1004 /**
1005 * session.start:进程启动、--resume 或热重载(也用于会话内 /resume 切回的对话)。
1006 * queuedHere:记录里有已排队的重置时,本进程是否发出过它(`$.state` 热重载保留、新进程里是空的):
1007 * true 热重载,false 进程重启(命令随旧进程没了),undefined 读不到(按热重载处理,宁可等着也不留一次裸重置)
1008 */
1009 | { kind: 'start'; queuedHere?: boolean }
1010 /** session.receive 来自 team-lead:本进程是 teammate */
1011 | { kind: 'teammate' }
1012 /** prompt.submit(主对话,origin 与 isMeta 原样给);window 是 usage().context.window */
1013 | { kind: 'submit'; origin?: OriginLike; isMeta?: boolean; tokens?: number; window?: number }
1014 /** 用户敲的内置命令(command.run,origin=composer),只需给命令名 */
1015 | { kind: 'userCommand'; command: string }
1016 /** /autohandoff <args> */
1017 | { kind: 'toggle'; args: string; tokens?: number; window?: number; problems?: readonly string[]; configPath?: string }
1018 | { kind: 'turnStart'; turnId: string }
1019 /** 主对话的 classic.PostToolUse */
1020 | { kind: 'postTool'; tokens?: number; window?: number }
1021 /** 主对话的 classic.Stop;othersBlocked:链上别的 hook 已经 block 了这次 Stop */
1022 | { kind: 'stop'; active: boolean; tokens?: number; othersBlocked?: boolean }
1023 /**
1024 * 主对话的 turn.complete。有 armed 的 job 时 register.ts 先用 checkStillThere 复查交接文件,结果放 file;
1025 * 复查本身出错(stat 抛错)也要传 {ok:false}。file 缺省按复查失败处理(宁可不 clear)。
1026 */
1027 | { kind: 'turnComplete'; turnId: string; reason: TurnReason; isAborted: boolean; tokens?: number; file?: Verdict<string> }
1028 /** 登记工具被调用;verdict 是路径检查的结果;tokens 是调用时的用量;fileTokens 是交接文件的估算大小(没读到、检测关着时缺省) */
1029 | { kind: 'register'; agentId?: string; verdict: Verdict<string>; tokens?: number; fileTokens?: number }
1030 /** 自家 clear/compact 的 promise resolve(seq 取自对应的 effect) */
1031 | { kind: 'resetDone'; seq: number }
1032 /** 自家 clear/compact 的 promise reject */
1033 | { kind: 'resetFailed'; seq: number; message: string }
1034 /** classic.SessionStart */
1035 | { kind: 'sessionStart'; source: string }
1036 /** 续接 submit 的 promise 结果;drop 有值表示被丢弃 */
1037 | { kind: 'resumeDone'; seq: number; drop?: string }
1038 /** session.end */
1039 | { kind: 'sessionEnd'; reason: string }
1040 /** 定时检查(timer 到期) */
1041 | { kind: 'tick' }
1042
1043export interface Env {
1044 cfg: Config
1045 now: number
1046 /**
1047 * 生成本轮交接文件路径的材料(pickHandoffNote):dirs 是候选工程根目录(按优先顺序,已过 usableRoots,可以为空),
1048 * sid 是当前会话 ID,windows 按 cwd 判断(与登记时相同)。
1049 * 只有可能送提醒的事件(submit、postTool、stop)带;没带时提醒末尾只说放在哪类地方(noteLine(undefined))
1050 */
1051 note?: { dirs: readonly string[]; sid: string; windows: boolean }
1052}
1053
1054export interface Step {
1055 rec: Rec
1056 fx: Effect[]
1057 /** 只有 register 事件有:登记工具返回给模型的文字 */
1058 reply?: string
1059 /** 只有 register 事件有:这次登记是否被受理 */
1060 accepted?: boolean
1061}
1062
1063const TELL_TEXT: Record<TellReason, (s: Settings) => string> = {
1064 new_message: () => 'a new message from the user arrived',
1065 new_input: () => 'a new message arrived that was not typed by the user (a scheduled trigger, another plugin or an unknown source)',
1066 api_error: () => 'the turn ended with an API error',
1067 refusal: () => 'the reply was refused',
1068 timeout: (s) => `the turn did not end within ${s.wait_minutes} minutes of registering`,
1069 reload: () => 'AutoHandoff was restarted or reloaded before the reset',
1070 file_gone: () => 'the handoff file was missing, empty or moved when the reset was due',
1071 reset_failed: () => 'the reset command failed',
1072}
1073
1074export function tellText(reason: TellReason, s: Settings): string {
1075 return TELL_TEXT[reason](s)
1076}
1077
1078// 这两段是 command.run 被拒时的实测错误文字:都表示用户刚按过 Esc,视同否决,静默作废
1079const VETO_MESSAGES = ['the session was stopped', 'removed from the queue']
1080
1081function isVeto(message: string): boolean {
1082 return VETO_MESSAGES.some((m) => message.includes(m))
1083}
1084
1085function statusOf(r: Rec): string | undefined {
1086 const j = r.job
1087 if (j) {
1088 if (j.stage === 'armed') return `handoff registered; /${j.action} when this turn ends`
1089 if (j.stage === 'clearing') return 'resetting the conversation (/clear)'
1090 if (j.stage === 'compacting') return 'compacting before resuming (/compact)'
1091 return 'resuming from the handoff file'
1092 }
1093 if (r.fallback) return 'compacting (no handoff registered)'
1094 return undefined
1095}
1096
1097/**
1098 * 状态转移:输入记录、事件、配置与时间,输出新记录与要执行的副作用。不修改输入。
1099 * status 副作用在这里统一按「前后状态行是否变化」生成。
1100 */
1101export function transition(rec: Rec, ev: Ev, env: Env): Step {
1102 const r = cloneRec(rec)
1103 const fx: Effect[] = []
1104 const out: Step = { rec: r, fx }
1105 const before = statusOf(rec)
1106 reduce(r, ev, env, out)
1107 // 越过 remind_at(第一层已提示)就不再算「刚续接」:之后的登记照常受理
1108 if (r.levels.remind) r.resumed = false
1109 r.t = env.now
1110 const after = statusOf(r)
1111 if (after !== before) fx.push({ type: 'status', text: after })
1112 return out
1113}
1114
1115/** 静默作废:只记日志,不告诉模型,不通知用户(用户自己的操作、Esc、工具被拒等)。 */
1116function cancelQuiet(r: Rec, fx: Effect[], why: string): void {
1117 if (!r.job) return
1118 r.job = null
1119 fx.push({ type: 'log', text: `handoff cancelled: ${why}`, debug: true })
1120}
1121
1122/**
1123 * 作废并在下一条消息里告诉模型;notice 决定怎么通知用户:toast,或 toast 加 notify(没配置通知渠道时 notify 不显示,所以不单发)。
1124 * 当前层重新生效:最高的已提示层退回未提示,下次检查时再送一次完整提醒。
1125 */
1126function cancelTold(r: Rec, s: Settings, fx: Effect[], tell: TellReason, notice: 'toast' | 'both'): void {
1127 if (!r.job) return
1128 r.job = null
1129 r.tell = tell
1130 if (r.levels.urge) r.levels.urge = false
1131 else r.levels.remind = false
1132 const text = `AutoHandoff: handoff cancelled (${tellText(tell, s)})`
1133 fx.push({ type: 'log', text: `handoff cancelled: ${tellText(tell, s)}`, debug: true })
1134 if (notice === 'both') fx.push({ type: 'notify', text })
1135 fx.push({ type: 'toast', text })
1136}
1137
1138/**
1139 * 重置命令已交给 `$.command.run`(clearing/compacting)就撤不回来了(只有 Esc 能否决):
1140 * 这阶段用户的命令、关开关、teammate 标记、热重载都不再作废 job,让它带着交接走完(PLAN §0 第 20 条)。
1141 * 作废只在命令自己的结果(失败、超时)或进程结束时发生。
1142 */
1143function resetQueued(r: Rec): boolean {
1144 return r.job?.stage === 'clearing' || r.job?.stage === 'compacting'
1145}
1146
1147export const QUEUED_TOAST = 'AutoHandoff: the reset is already queued and cannot be withdrawn; the conversation will continue from the handoff file.'
1148
1149/**
1150 * 排队期间用户 /resume 的提示:选择器打开时就会收到 /resume(取消也算),切走与否这时还不知道。
1151 * 留在本对话照常续接;切到别的对话时排队的命令会作用在那个对话上,而且不续接(PLAN §9 已知限制)。
1152 */
1153export function resumeQueuedToast(action: Action): string {
1154 return `AutoHandoff: /${action} is already queued and cannot be withdrawn. If you stay in this conversation, it continues from the handoff file; if you switch to another conversation, the queued /${action} applies to that one instead, without resuming.`
1155}
1156
1157/** 想作废却撞上已排队的重置:不作废,只记一笔并告诉用户 */
1158function keepQueued(r: Rec, fx: Effect[], what: string, toast: string = QUEUED_TOAST): void {
1159 fx.push({ type: 'log', text: `${what}, but /${r.job?.action} is already queued (request ${r.job?.seq}); the handoff is kept`, debug: true })
1160 fx.push({ type: 'toast', text: toast })
1161}
1162
1163/** 交接 job 的标识:登记时刻加请求序号(记录跨 /clear 搬迁后也不变) */
1164export function jobKey(j: Pick<Job, 'registeredAt' | 'seq'>): string {
1165 return `${j.registeredAt}:${j.seq ?? 0}`
1166}
1167
1168/** 自家重置完成:进入续接阶段,提交续接(用户已接手时不提交)。resetDone 和孤儿 job 的 SessionStart 共用。 */
1169function afterReset(r: Rec, j: Job, cfg: Config, now: number, fx: Effect[]): void {
1170 clearLevels(r)
1171 j.stage = 'resuming'
1172 j.resetAt = now
1173 // 续接开启的对话在越过 remind_at 或收到人工消息之前不受理登记;用户已接手(userTook)时不挡
1174 r.resumed = !j.userTook
1175 if (j.userTook) {
1176 if (j.ctxGiven) finish(r, fx, 'the user wrote during the reset; resume message skipped')
1177 else fx.push({ type: 'timer', ms: RESUME_STUCK_MS })
1178 return
1179 }
1180 fx.push({ type: 'resume', seq: j.seq ?? 0, text: resumeText(cfg, j.file, j.action) })
1181 fx.push({ type: 'timer', ms: RESUME_STUCK_MS })
1182}
1183
1184function finish(r: Rec, fx: Effect[], why: string): void {
1185 r.job = null
1186 clearLevels(r)
1187 fx.push({ type: 'log', text: `handoff finished: ${why}`, debug: true })
1188}
1189
1190function nextSeq(r: Rec): number {
1191 r.seq += 1
1192 return r.seq
1193}
1194
1195/** 模型窗口不到 remind_at 时提示一次:阈值永远到不了,Claude Code 自己的压缩会先来。 */
1196function warnWindow(r: Rec, s: Settings, window: number | undefined, fx: Effect[]): void {
1197 if (r.windowWarned || window === undefined || window > s.remind_at) return
1198 r.windowWarned = true
1199 fx.push({
1200 type: 'toast',hooks/prompts.ts 75 lines1// AutoHandoff 的内置默认提示词(英文)。用户可在 ~/.claude/autohandoff.md 的同名 `## <键名>` 节里覆盖。
2// 占位符写成 {name},各键能用哪些见 core.ts 的 PROMPT_VARS;不在白名单里的名字原样保留。
3// 路径不进任何模板:start_context 与 resume 后面由代码追加一句固定的、带引号的路径说明;
4// remind、remind_short、urge、urge_block 后面由代码追加本轮要写的交接文件路径(core.ts 的 noteLine,PLAN §0 第 30 条)。
5
6export const PROMPT_KEYS = [
7 'remind',
8 'remind_short',
9 'urge',
10 'urge_block',
11 'cancelled',
12 'pending_note',
13 'reset_note',
14 'start_context',
15 'resume',
16 'compact_focus',
17] as const
18
19export type PromptKey = (typeof PROMPT_KEYS)[number]
20
21// 各提示词在默认配置文件里的说明(写进 HTML 注释,渲染时看不到)
22export const PROMPT_NOTES: Record<PromptKey, string> = {
23 remind:
24 'First reminder, added to the model\'s context (never shown to you) when usage first passes remind_at. AutoHandoff appends the path of the handoff file to write.',
25 remind_short:
26 'Short reminder added to each later message of the main conversation until a handoff is registered (remind_every_turn). AutoHandoff appends the path of the handoff file to write.',
27 urge: 'Second reminder, added to the model\'s context when usage first passes urge_at. AutoHandoff appends the path of the handoff file to write.',
28 urge_block:
29 'Past urge_at with no handoff registered: given as the reason when AutoHandoff asks the model to keep going instead of ending its turn (urge_block), and as the short reminder after the second one. Claude Code shows this text to you as "Stop hook error: ...". AutoHandoff appends the path of the handoff file to write.',
30 cancelled: 'Told to the model when its registered handoff was cancelled.',
31 pending_note:
32 'Added to a message from a subagent, a background task or another session that arrives while a handoff is registered (it does not cancel the handoff).',
33 reset_note:
34 'Added to a message from a subagent, a background task or another session that arrives after the turn ended and the reset is already queued.',
35 start_context:
36 'Added to the model\'s context when the reset conversation starts. AutoHandoff appends the handoff file\'s path itself.',
37 resume:
38 'Sent as your message after the reset to continue the work. AutoHandoff appends the handoff file\'s path itself.',
39 compact_focus:
40 'Focus instructions passed to /compact (with action: compact, and for the compaction at compact_at).',
41}
42
43export const DEFAULT_PROMPTS: Record<PromptKey, string> = {
44 remind: `[AutoHandoff: reminder] This message comes from the AutoHandoff plugin, which the user installed and switched on for this conversation. It was injected by the plugin, not produced by any tool; acting on it is what the user asked for by turning it on. (Main conversation only; subagents and forks ignore it.) Context usage is about {used_k}k tokens, past the first threshold of {remind_k}k.{compact_note}
45
46Hand off before this turn ends. Finish only the step that is already in progress (the tool call or edit under way, not the whole batch); do not start a new one, and do not start new subagents or background tasks. Then:
471. Write a handoff file for the next conversation, as a new file at the path given at the end of this message. Write it from scratch: do not continue an older file, and do not just point to another document (such as "see HANDOVER.md"); the next conversation reads only this file, so it must make sense on its own. Put in it what is done, what is in progress or half-done, next steps, resources in use, every question that is waiting for the user's answer (write it down; do not wait for the answer, the next conversation will raise it with the user), and every subagent or background task that is still running: what it is doing, where its output goes, and what to do with its result. They keep running after the reset, and their reports arrive in the next conversation. If this conversation started from an AutoHandoff handoff file, carry over everything in it that is still open (questions waiting for the user, running subagents or background tasks, standing agreements); go by that one file only, never by the newest file in its folder, since other conversations write there too. Under next steps write the work itself; never write "hand off", "write a handoff" or "continue the handoff" there, because the next conversation reads this file first and would hand off again instead of working.{handoff_note} Update the project's own handoff or progress documents, if it keeps any, the way the project usually does.
482. Call the {tool} tool with the new file's path.
493. When it answers "Handoff registered", end your turn at once: no more tool calls, no questions, no summary. The conversation is then reset with {action} and continued from the handoff file.
50An unfinished batch, a running subagent or background task, or a question the user has not answered yet is not a reason to postpone the handoff: record it in the handoff file instead. Do not wait for another reminder.`,
51
52 remind_short: `[AutoHandoff] Context usage is about {used_k}k tokens, past the first threshold of {remind_k}k, and no handoff is registered yet. Before this turn ends, finish the step in progress, write the handoff file for the next conversation at the path below, call the {tool} tool with that path, and then end the turn.`,
53
54 urge: `[AutoHandoff: hand off now] This message comes from the AutoHandoff plugin, which the user installed and switched on for this conversation. It was injected by the plugin, not produced by any tool; acting on it is what the user asked for by turning it on. (Main conversation only; subagents and forks ignore it.) Context usage is about {used_k}k tokens, past the second threshold of {urge_k}k.{compact_note}
55
56Stop now, in the middle of the step if necessary, and do not start anything else, including new subagents or background tasks. In this turn:
571. Write a handoff file for the next conversation, as a new file at the path given at the end of this message; it must make sense on its own (do not continue an older file or just point to another document). Include half-done work, partially edited files, every question that is waiting for the user's answer, and every subagent or background task still running: what it is doing, where its output goes, and what to do with its result. If this conversation started from an AutoHandoff handoff file, carry over everything in it that is still open; go by that one file only, never by the newest file in its folder. Under next steps write the work itself, not "hand off" or "continue the handoff".{handoff_note} Update the project's own handoff or progress documents only if that takes a moment; the handoff file comes first.
582. Call the {tool} tool with the new file's path.
593. When it answers "Handoff registered", end your turn at once: no more tool calls, no questions, no summary.`,
60
61 urge_block: `AutoHandoff: context usage is about {used_k}k tokens, past the {urge_k}k handoff threshold, and no handoff is registered. Before ending the turn, write the handoff file for the next conversation at the path below and call the {tool} tool with that path.`,
62
63 cancelled: `[AutoHandoff] Your registered handoff was cancelled ({reason}). Deal with that first. If you register again, in this turn or later, first update the handoff file with everything new since you wrote it, above all what the user has just asked for or answered, so that the next conversation has it (next steps are the work itself, never "hand off again"); then call the {tool} tool again.`,
64
65 pending_note: `[AutoHandoff] A handoff is registered, and the conversation will be reset with {action} when this turn ends; this message did not cancel it. If it changes anything recorded in the handoff file (for example a subagent's result), update the file before the turn ends and call the {tool} tool again to refresh the registration.`,
66
67 reset_note: `[AutoHandoff] The conversation is about to be reset with {action} and continued from the registered handoff file; this message arrived in between. Keep this turn short and do not start new work. If this message changes anything recorded in the handoff file (for example a subagent's result), update the file now; the next conversation reads it after the reset.`,
68
69 start_context: `[AutoHandoff] This conversation was just reset by AutoHandoff ({action}) after the model wrote a handoff file in the previous conversation. That file is the complete handoff: read it first, and read other project documents only when you need specific details from them. Subagents or background tasks started there may still report here; the handoff file says what they are and what to do with their results. Continue the work the handoff file describes; do not hand off again unless a new AutoHandoff reminder asks for it. If the user asks for something else, follow the user.`,
70
71 resume: `[AutoHandoff: resume] The context was just reset by AutoHandoff ({action}). Read the handoff file named below first: it is the complete handoff. Look at other project documents, memory or git status only when you need specific details from them, and continue the unfinished work. If I have already given other instructions in this conversation, follow those instead. List anything that is waiting for my decision first.`,
72
73 compact_focus: `Keep what is needed to continue the current work: the task and its goal, decisions made, files changed, work in progress, subagents or background tasks still running, open questions and next steps.`,
74}
75types/autohandoff.d.ts 13 lines1// AutoHandoff 的类型契约(plugin.json 的 "types"):只声明本插件放在 `$.state` 里的值。
2// `$.state` 由宿主按会话保存,热重载后还在、进程重启后是空的;register.ts 用它区分这两种情况。
3/** 本进程发出过的、交接 job 的重置命令(core.jobKey),最近的 20 个 */
4export type AutohandoffQueued = string[]
5
6declare module 'claude-code' {
7 interface PluginState {
8 autohandoff: {
9 queued: AutohandoffQueued
10 }
11 }
12}
13