Past a context threshold, records a hand-off, resets the context (a compaction in place, through /compact where the session has no compaction call, or the…

Small mods for Claude Code.
A mod is a plugin that changes how Claude Code looks and behaves. This repository holds three, and works as a plugin marketplace, so you can install any of them by name.
| Mod | What it does | Where it works | | :- | :- | :- | | context-meter | Shows the git branch, how full the context window is, how much the last turn added, and how much of your plan limits you have used, in a band above the prompt | Terminal and the Desktop app's Code tab | | auto-pin | Pins a new session in the sidebar as soon as it starts | The Desktop app's Code tab | | context-handoff | Once the context window is 40% full, has Claude write hand-off notes, resets the context, and continues from the notes | Terminal and the Desktop app's Code tab |
claude --version./status and read the Claude Code row.A mod is code that runs with your permissions: it can read and write your files, start processes, and make network requests. Read the source before you install, or list what each mod does.
Add this repository as a marketplace, then install the mods you want:
claude plugin marketplace add ulttla/claude-mod-repo
claude plugin install context-meter@claude-mod-repo
claude plugin install auto-pin@claude-mod-repo
claude plugin install context-handoff@claude-mod-repo
In a Claude Code session the same commands are /plugin marketplace add ulttla/claude-mod-repo and /plugin install context-meter@claude-mod-repo.
A mod loads the next time you start a session. In a session that is already open, run /reload-plugins.
Clone the repository and load a mod's folder for one terminal session:
git clone https://github.com/ulttla/claude-mod-repo.git
claude --plugin-dir ./claude-mod-repo/context-meter
With the repository cloned, this lists the events a mod handles and what it asks Claude Code to do, without running it:
claude plugin validate ./claude-mod-repo/context-meter
Draws one line above the prompt:
main Ctx 12% (119k/1M) Last +119k 5H 1% ↻17:30 1W 25% ↻Wed 11:00
| Segment | Meaning | | :- | :- | | main | The current git branch. Left out when the session's folder is not in a git repository. On a detached HEAD, the short commit hash | | Ctx 12% (119k/1M) | Context window: percent full, tokens used, window size | | Last +119k | Tokens the last finished turn added. A negative number means the turn compacted the conversation | | 5H 1% ↻17:30 | Five-hour plan limit: percent used, and when it resets | | 1W 25% ↻Wed 11:00 | Weekly plan limit: percent used, and when it resets |
/context-meter prints the same line as text, for places that don't draw the band, such as the VS Code extension.Each segment can be turned on or off.
| Option | Default | What it shows | | :- | :- | :- | | showBranch | on | The current git branch | | showDirty | off | A * after the branch name when the working tree has uncommitted changes, as in main*. Runs git status on each turn | | showModel | off | The session's model | | showContext | on | Ctx, the context window | | showLastTurn | on | Last, what the last turn added | | showFiveHour | on | 5H, the five-hour plan limit | | showWeekly | on | 1W, the weekly plan limit | | showOtherLimits | on | Any other limit your account reports, such as a gateway's spend limit (Spend) | | showResetTimes | on | When each limit resets | | showCost | off | What the session has cost in US dollars, as in $1.24. On a subscription plan this is an estimate at API prices, not a charge |
To change them in a terminal session, run /plugin configure context-meter@claude-mod-repo. From your shell, pass each one when you install:
claude plugin install context-meter@claude-mod-repo --config showModel=true --config showCost=true
A change takes effect in the next session, or after /reload-plugins.
When you start a new session in the Desktop app's Code tab, the mod pins it in the sidebar. You unpin it yourself when the work is done.
/auto-pin pins the current session on request, and says why when it can't.A long session gets worse as its context fills up. This mod hands the work over to a fresh context before that happens, without you opening a new session.
After each turn it reads how full the context window is. Past the threshold (40% by default), it:
session-close skill, Claude is told to run it; otherwise Claude follows the session-close procedure in the project's CLAUDE.md, or updates PROGRESS.md, or writes HANDOFF.md at the project root./compact with instructions), so the process, model and settings stay as they were. In the Desktop app the session has no compaction call of its own, so the mod runs /compact with those instructions instead, as if you had typed it. With reset set to clear, it instead asks the Desktop app to clear the conversation (the same as /clear): the session keeps its row in the sidebar, the old conversation stays under Resume previous session, but the app starts a new Claude Code process only at your next message.Each step shows a toast. A message you had already queued runs first. If you interrupt the hand-off turn, or the reset fails, the mod gives up, says why, and tries again once the context has grown by another 5 points.
/handoff-now hands off right away, at any context size. /handoff-status shows where the hand-off stands and what the mod did last.[context-handoff], and each step leaves a dim context-handoff: line there./compact (the Desktop app, or compaction set to command): the /compact line shows in the transcript, the resume follows once its compaction has run, and if nothing has been compacted within five minutes the mod gives up.pauseRemoteControl). Outside the Desktop app there is no app to clear, so the mod gives up after recording the notes.| Option | Default | What it does | | :- | :- | :- | | enabled | on | Hand off on its own past the threshold. Off, only /handoff-now hands off | | threshold | 40 | How full the context window is, in percent, before the hand-off starts | | retriggerStep | 5 | After an interrupted or failed hand-off, try again once the context has grown this many points | | reset | compact | How the context is reset: compact (in place, process and settings kept, no input needed) or clear (the Desktop app's clear: a fresh conversation, continued only after your next message) | | compaction | auto | How the compaction is made: auto (the engine's compaction call, or /compact where the session has none, as in the Desktop app), call (the call alone) or command (/compact alone, queued as if typed) | | closeCommand | session-close | The project skill Claude is told to run to record the hand-off, when the project has it | | closePrompt | empty | Replaces the built-in hand-off prompt and the close skill. {percent} and {threshold} are filled in | | resumePrompt | empty | Replaces the built-in prompt submitted after the reset | | pauseRemoteControl | on | With the clear: turn Remote Control off for it, and on again after the resume. Off, such a session is not cleared |
To change them in a terminal session, run /plugin configure context-handoff@claude-mod-repo, or pass them when you install:
claude plugin install context-handoff@claude-mod-repo --config threshold=50
claude plugin uninstall context-meter@claude-mod-repo
claude plugin uninstall auto-pin@claude-mod-repo
claude plugin uninstall context-handoff@claude-mod-repo
MIT. Shared as is, without support.
mod는 Claude Code의 모양과 동작을 바꾸는 플러그인입니다. 이 리포에는 mod 세 개가 들어 있고, 리포 자체가 플러그인 마켓플레이스 역할을 하므로 이름으로 골라 설치할 수 있습니다.
| Mod | 하는 일 | 동작하는 곳 | | :- | :- | :- | | context-meter | git 브랜치, 컨텍스트 창이 얼마나 찼는지, 직전 턴이 얼마나 늘렸는지, 플랜 한도를 얼마나 썼는지를 입력창 위 한 줄로 표시 | 터미널, 데스크톱 앱 Code 탭 | | auto-pin | 새 세션이 시작되면 사이드바에 바로 고정 | 데스크톱 앱 Code 탭 | | context-handoff | 컨텍스트 창이 40% 차면 Claude가 인계 기록을 쓰게 하고, 컨텍스트를 초기화한 뒤, 기록을 읽어 이어감 | 터미널, 데스크톱 앱 Code 탭 |
claude --version으로 확인합니다./status를 입력하고 Claude Code 행을 봅니다.mod는 사용자 권한으로 실행되는 코드입니다. 파일을 읽고 쓰고, 프로세스를 실행하고, 네트워크 요청을 보낼 수 있습니다. 설치하기 전에 소스를 읽거나 mod가 하는 일을 먼저 확인하세요.
이 리포를 마켓플레이스로 추가한 뒤 원하는 mod를 설치합니다.
claude plugin marketplace add ulttla/claude-mod-repo
claude plugin install context-meter@claude-mod-repo
claude plugin install auto-pin@claude-mod-repo
claude plugin install context-handoff@claude-mod-repo
Claude Code 세션 안에서는 같은 명령을 /plugin marketplace add ulttla/claude-mod-repo, /plugin install context-meter@claude-mod-repo로 입력합니다.
mod는 다음에 세션을 시작할 때 로드됩니다. 이미 열려 있는 세션에서는 /reload-plugins를 실행합니다.
리포를 클론한 뒤 터미널 세션 하나에만 mod 폴더를 로드합니다.
git clone https://github.com/ulttla/claude-mod-repo.git
claude --plugin-dir ./claude-mod-repo/context-meter
리포를 클론한 상태에서 아래 명령을 실행하면, mod를 실행하지 않고 어떤 이벤트를 처리하고 Claude Code에 무엇을 요청하는지 나열합니다.
claude plugin validate ./claude-mod-repo/context-meter
입력창 위에 한 줄을 그립니다.
main Ctx 12% (119k/1M) Last +119k 5H 1% ↻17:30 1W 25% ↻Wed 11:00
| 항목 | 의미 | | :- | :- | | main | 현재 git 브랜치. 세션 폴더가 git 리포가 아니면 생략됩니다. detached HEAD에서는 짧은 커밋 해시 | | Ctx 12% (119k/1M) | 컨텍스트 창: 찬 비율, 사용한 토큰, 창 크기 | | Last +119k | 직전에 끝난 턴이 늘린 토큰. 음수면 그 턴에서 대화가 압축된 것 | | 5H 1% ↻17:30 | 5시간 플랜 한도: 사용률과 초기화 시각 | | 1W 25% ↻Wed 11:00 | 주간 플랜 한도: 사용률과 초기화 시각 |
/context-meter는 같은 내용을 텍스트로 출력합니다. VS Code 확장처럼 밴드를 그리지 않는 곳에서 씁니다.항목마다 켜고 끌 수 있습니다.
| 옵션 | 기본값 | 표시 내용 | | :- | :- | :- | | showBranch | 켜짐 | 현재 git 브랜치 | | showDirty | 꺼짐 | 커밋하지 않은 변경이 있으면 브랜치 이름 뒤에 * 표시 (예: main*). 턴마다 git status를 실행합니다 | | showModel | 꺼짐 | 세션의 모델 | | showContext | 켜짐 | Ctx, 컨텍스트 창 | | showLastTurn | 켜짐 | Last, 직전 턴이 늘린 양 | | showFiveHour | 켜짐 | 5H, 5시간 플랜 한도 | | showWeekly | 켜짐 | 1W, 주간 플랜 한도 | | showOtherLimits | 켜짐 | 계정이 보고하는 그 밖의 한도. 예: 게이트웨이의 지출 한도(Spend) | | showResetTimes | 켜짐 | 각 한도의 초기화 시각 | | showCost | 꺼짐 | 세션 비용(미국 달러, 예: $1.24). 구독 플랜에서는 실제 청구액이 아니라 API 가격 기준 추정치입니다 |
터미널 세션에서는 /plugin configure context-meter@claude-mod-repo로 바꿉니다. 셸에서는 설치할 때 하나씩 넘깁니다.
claude plugin install context-meter@claude-mod-repo --config showModel=true --config showCost=true
변경은 다음 세션부터, 또는 /reload-plugins 후에 적용됩니다.
데스크톱 앱 Code 탭에서 새 세션을 시작하면 사이드바에 고정합니다. 작업이 끝나면 직접 고정을 해제하면 됩니다.
/auto-pin은 현재 세션을 직접 고정하고, 고정하지 못하면 이유를 알려 줍니다.긴 세션은 컨텍스트가 찰수록 품질이 떨어집니다. 이 mod는 그 전에 작업을 새 컨텍스트로 넘깁니다. 새 세션을 직접 열 필요가 없습니다.
턴이 끝날 때마다 컨텍스트 창이 얼마나 찼는지 읽고, 임계값(기본 40%)을 넘으면 다음을 차례로 합니다.
session-close 스킬이 있으면 그 스킬을 실행하라고 지시하고, 없으면 프로젝트 CLAUDE.md의 세션 종료 절차를 따르거나, PROGRESS.md를 갱신하거나, 프로젝트 루트에 HANDOFF.md를 씁니다./compact에 지시문을 붙인 것과 같음)이라 프로세스·모델·설정이 그대로 유지됩니다. 데스크톱 앱의 세션에는 압축 호출이 없으므로, 거기서는 직접 입력한 것처럼 같은 지시문으로 /compact를 실행합니다. reset을 clear로 두면 대신 데스크톱 앱에 대화를 비워 달라고 요청합니다(/clear와 같음). 세션은 사이드바의 같은 행에 남고 이전 대화는 Resume previous session으로 되돌릴 수 있지만, 앱은 다음 메시지를 보낼 때에야 새 Claude Code 프로세스를 띄웁니다.단계마다 토스트로 알립니다. 이미 대기 중이던 메시지가 있으면 그것이 먼저 실행됩니다. 인계 턴을 중단하거나 초기화에 실패하면 포기하고 이유를 알린 뒤, 컨텍스트가 5포인트 더 차면 다시 시도합니다.
/handoff-now는 컨텍스트 크기와 상관없이 바로 인계합니다. /handoff-status는 인계가 어느 단계인지와 마지막으로 한 일을 보여 줍니다.[context-handoff] 표시와 함께 대화에 보이고, 단계마다 흐릿한 context-handoff: 줄이 대화에 남습니다./compact로 압축할 때(데스크톱 앱, 또는 compaction을 command로 둔 경우): /compact 줄이 대화에 보이고, 그 압축이 끝나면 재개합니다. 5분 안에 압축되지 않으면 포기합니다.clear일 때: 인계 턴이 시작될 때 앱에 요청하고 턴이 끝날 때 비워집니다. 8초 안에 비워지지 않으면 포기합니다. 대기 중이던 메시지가 인계 턴 뒤에 실행되면 기록을 다시 씁니다. 재개 지시는 mod 저장소에 적어 두므로 앱이 다음에 띄우는 프로세스가 기록을 읽고 이어갑니다. 앱은 비운 뒤 아무리 시간이 지나도 다음 메시지를 보낼 때에야 그 프로세스를 띄우며, 그 메시지가 먼저 실행된 뒤 재개합니다. 그때까지 대화는 비어 있습니다. 다른 기기에서 시작한 세션은 비울 수 없으므로 비우는 동안 Remote Control을 끄고 재개 후 다시 켭니다(pauseRemoteControl). 데스크톱 앱 밖에는 비워 줄 앱이 없으므로 기록만 쓴 뒤 포기합니다.| 옵션 | 기본값 | 하는 일 | | :- | :- | :- | | enabled | 켜짐 | 임계값을 넘으면 자동으로 인계. 끄면 /handoff-now로만 인계 | | threshold | 40 | 인계를 시작하는 컨텍스트 창 사용률(%) | | retriggerStep | 5 | 중단되거나 실패한 인계를 컨텍스트가 몇 포인트 더 찼을 때 다시 시도할지 | | reset | compact | 컨텍스트 초기화 방식: compact(제자리 압축, 프로세스·설정 유지, 입력 불필요) 또는 clear(데스크톱 앱의 비우기: 새 대화, 다음 메시지를 보내야 이어감) | | compaction | auto | 압축 방법: auto(엔진의 압축 호출, 데스크톱 앱처럼 호출이 없는 세션에서는 /compact), call(호출만), command(/compact만, 입력한 것처럼 큐에 넣음) | | closeCommand | session-close | 프로젝트에 있을 때 인계 기록용으로 실행하라고 지시할 스킬 | | closePrompt | 비어 있음 | 내장 인계 프롬프트와 스킬 지시를 대체. {percent}, {threshold}가 채워짐 | | resumePrompt | 비어 있음 | 초기화 뒤 제출하는 내장 프롬프트를 대체 | | pauseRemoteControl | 켜짐 | clear일 때 비우는 동안 Remote Control을 끄고 재개 후 다시 켬. 끄면 그런 세션은 비우지 않음 |
터미널 세션에서는 /plugin configure context-handoff@claude-mod-repo로 바꾸거나, 설치할 때 넘깁니다.
claude plugin install context-handoff@claude-mod-repo --config threshold=50
claude plugin uninstall context-meter@claude-mod-repo
claude plugin uninstall auto-pin@claude-mod-repo
claude plugin uninstall context-handoff@claude-mod-repo
MIT. 지원 없이 있는 그대로 공유합니다.
hooks/register.js 536 lines1// The mod's name, as toasts, log lines and the hand-off prompts carry it
2const NAME = 'context-handoff'
3// A tag every prompt this mod submits carries, so its own turn can be told apart
4const MARK = '[context-handoff]'
5// The store key that survives the desktop app's clear, which ends the process
6const RESUME_KEY = 'pendingResume'
7// How long the app gets to clear the conversation once the hand-off turn has ended
8const CLEAR_WAIT_MS = 8000
9// When the compaction is tried after the hand-off turn: it is refused while a turn still runs
10const COMPACT_TRIES_MS = [300, 1000, 2000, 4000, 8000]
11// How long the /compact command gets to compact the conversation where it stands in for the call
12const COMPACT_COMMAND_WAIT_MS = 5 * 60 * 1000
13// How long after the compaction the resume prompt is submitted
14const RESUME_AFTER_COMPACT_MS = 1000
15// What the summarizer is told when the conversation is compacted after a hand-off
16const COMPACT_INSTRUCTIONS =
17 'A hand-off for a fresh start was just recorded in the project notes. Keep only: where the notes are (file and section), ' +
18 'the exact next step, and any question still waiting for the user. Leave out everything else; it is in the notes.'
19
20// Where the hand-off stands:
21// idle nothing in progress
22// closing the hand-off prompt is submitted; waiting for its turn to run and finish
23// clearing (clear) the app was asked to clear; waiting for the conversation to end with it
24// compacting (compact) the hand-off turn has ended; compacting the conversation
25// resuming the conversation was reset; waiting to submit the resume prompt
26let phase = 'idle'
27// The hand-off prompt as submitted, and the id of the turn running it
28let closeText = null
29let closeTurnId = null
30// The context percent at which the last hand-off began; the next waits for `step` more
31let triggeredAt = null
32// The fallback that submits the resume prompt when no SessionStart follows the clear
33let resumeTimer = null
34// Gives up when the app has not cleared the conversation soon after the hand-off turn ended
35let clearWatchdog = null
36// The compaction attempt waiting to run
37let compactTimer = null
38// How the conversation is being compacted: the engine's call, or the /compact command where
39// the session has no such call (a headless one, as the desktop app runs)
40let compactVia = null
41// Gives up when the /compact command has not compacted the conversation in time
42let compactWatchdog = null
43// True while Remote Control is turned off for the clear, to be turned on again after the resume
44let remoteControlPaused = false
45// The last thing that happened, for /handoff-status
46let lastOutcome = 'nothing yet'
47
48// A number option, or its default when unset or out of range
49function numberOf(value, fallback, min, max) {
50 const n = typeof value === 'string' ? Number(value) : value
51 return typeof n === 'number' && Number.isFinite(n) && n >= min && n <= max ? n : fallback
52}
53
54// The settings, from the options plugin.json declares
55function settingsOf(options) {
56 return {
57 enabled: options.enabled !== false,
58 threshold: numberOf(options.threshold, 40, 1, 100),
59 step: numberOf(options.retriggerStep, 5, 1, 100),
60 reset: options.reset === 'clear' ? 'clear' : 'compact',
61 compaction: options.compaction === 'call' || options.compaction === 'command' ? options.compaction : 'auto',
62 closeCommand: String(options.closeCommand ?? 'session-close').trim().replace(/^\//, ''),
63 closePrompt: String(options.closePrompt ?? '').trim(),
64 resumePrompt: String(options.resumePrompt ?? '').trim(),
65 pauseRemoteControl: options.pauseRemoteControl !== false,
66 }
67}
68
69function fillIn(text, percent, cfg) {
70 return text
71 .replaceAll('{percent}', String(Math.round(percent)))
72 .replaceAll('{threshold}', String(cfg.threshold))
73}
74
75// The prompt that records the hand-off: the project's own close skill when it has one,
76// a custom prompt when configured, or the built-in instructions. It opens with why the
77// hand-off is made: the threshold passed, or /handoff-now asked for it, whatever the context holds
78function closePromptFor(cfg, percent, hasClose, asked) {
79 const fill = `The context window is ${Math.round(percent)}% full`
80 const level = asked
81 ? `${fill}; a hand-off was asked for with /handoff-now.`
82 : `${fill}, past the ${cfg.threshold}% hand-off threshold.`
83 const pending =
84 'If the previous turn stopped to ask the user something, put that question in the notes instead of answering it.'
85 const then = 'the context is then reset automatically and the work continues from the notes.'
86 if (cfg.closePrompt) return `${MARK} ${fillIn(cfg.closePrompt, percent, cfg)}`
87 // A prompt may not begin with a slash, so the skill is named for the model to invoke
88 if (hasClose) {
89 return (
90 `${MARK} ${level} Run this project's /${cfg.closeCommand} skill now (invoke it with the Skill tool) to record the hand-off, ` +
91 `and make the next starting point exact. ${pending} When the notes are written, stop: ${then}`
92 )
93 }
94 return (
95 `${MARK} ${level} Do not start new work. Record a hand-off for a fresh start: ` +
96 "if this project's CLAUDE.md defines a session-close or progress-logging procedure, follow it; " +
97 'otherwise update PROGRESS.md if the project has one, or else write HANDOFF.md at the project root. ' +
98 'Cover what was done this session, the current state, measured facts worth not measuring again, open questions, ' +
99 `and the exact next step. ${pending} When the notes are written, stop: ${then}`
100 )
101}
102
103// The prompt that continues the work after the reset
104function resumePromptFor(cfg) {
105 if (cfg.resumePrompt) return `${MARK} ${cfg.resumePrompt}`
106 return (
107 `${MARK} The context was reset after a hand-off. Read this project's hand-off notes: ` +
108 'the progress file CLAUDE.md names, else PROGRESS.md, else HANDOFF.md at the project root. ' +
109 'Say in one line what you are picking up, then continue from the recorded next step without redoing work the notes mark as done. ' +
110 'If the notes hold a question for the user, ask it and wait.'
111 )
112}
113
114function messageOf(error) {
115 return error instanceof Error ? error.message : String(error)
116}
117
118// The text blocks of an MCP result, joined
119function textOf(result) {
120 return (result.content ?? [])
121 .map((block) => (block.type === 'text' ? block.text : ''))
122 .join(' ')
123 .trim()
124}
125
126// Whether the engine answered that this session has no compaction call: a headless (SDK)
127// session, as the desktop app runs, where a compaction runs as a /compact prompt
128function hasNoCompactCall(message) {
129 return /headless|SDK|\/compact prompt/i.test(message)
130}
131
132// The sizes a compaction reports, for the transcript line
133function sizesOf(result) {
134 return typeof result?.tokensBefore === 'number' && typeof result?.tokensAfter === 'number'
135 ? ` (${result.tokensBefore} to ${result.tokensAfter} tokens)`
136 : ''
137}
138
139// Keep what happened: a dim line in the transcript, and the answer of /handoff-status
140function note($, text) {
141 lastOutcome = text
142 $.ui.log(`${NAME}: ${text}`)
143}
144
145// How full the context window is, in percent; null before the first reading
146async function readPercent($) {
147 try {
148 const { context } = await $.session.usage()
149 if (typeof context.percent === 'number') return context.percent
150 if (typeof context.tokens === 'number' && context.window > 0) return (context.tokens / context.window) * 100
151 } catch {
152 // Keep going without a reading
153 }
154 return null
155}
156
157// Whether the project offers the close command: listed as a command, or a skill folder of the project
158async function hasCommand($, name) {
159 if (!name) return false
160 try {
161 if ((await $.command.list()).some((command) => command.name === name)) return true
162 } catch {
163 // Not listable here; look at the folder
164 }
165 try {
166 const root = await $.session.root()
167 return await $.fs.exists(`${root}/.claude/skills/${name}/SKILL.md`)
168 } catch {
169 return false
170 }
171}
172
173// One call on the desktop app's session server. Resolves to null when it succeeded, else to why not.
174async function callApp($, tool, args) {
175 try {
176 const result = await $.mcp.call('ccd_session_mgmt', tool, args)
177 return result.isError ? textOf(result) || 'no reason given' : null
178 } catch (error) {
179 return messageOf(error)
180 }
181}
182
183// The desktop app's id of this session, which outlives the process; null outside the app
184async function appSessionId($) {
185 try {
186 const result = await $.mcp.call('ccd_session_mgmt', 'get_session', { session_id: 'self' })
187 if (result.isError) return null
188 const id = JSON.parse(textOf(result)).sessionId
189 return typeof id === 'string' ? id : null
190 } catch {
191 return null
192 }
193}
194
195// Turn Remote Control on again after it was turned off for the clear
196async function restoreRemoteControl($) {
197 if (!remoteControlPaused) return
198 remoteControlPaused = false
199 const failed = await callApp($, 'set_remote_control', { session_id: 'self', enabled: true })
200 note($, failed ? `Remote Control could not be turned on again: ${failed}` : 'Remote Control turned on again')
201}
202
203function cancelTimers() {
204 if (resumeTimer) resumeTimer.cancel()
205 if (clearWatchdog) clearWatchdog.cancel()
206 if (compactTimer) compactTimer.cancel()
207 if (compactWatchdog) compactWatchdog.cancel()
208 resumeTimer = clearWatchdog = compactTimer = compactWatchdog = null
209}
210
211function forgetPendingResume($) {
212 $.store.delete(RESUME_KEY).catch(() => {})
213}
214
215// Give up the hand-off in progress and say why; the next tries once the context grew by the step
216function abandon($, reason) {
217 phase = 'idle'
218 closeText = null
219 closeTurnId = null
220 compactVia = null
221 cancelTimers()
222 forgetPendingResume($)
223 note($, `gave up: ${reason}`)
224 $.ui.toast(`${NAME}: ${reason}. /handoff-now retries.`, { timeoutMs: 8000 })
225 void restoreRemoteControl($)
226}
227
228// Submit the hand-off prompt; its turn is found by the tag at turn.start
229async function begin($, cfg, percent, why, asked = false) {
230 phase = 'closing'
231 closeTurnId = null
232 triggeredAt = percent
233 cancelTimers()
234 const text = closePromptFor(cfg, percent, await hasCommand($, cfg.closeCommand), asked)
235 closeText = text
236 note($, `hand-off started: ${why}`)
237 $.ui.toast(`${NAME}: ${why}, recording the hand-off`)
238 $.prompt
239 .submit({ text, asUser: true })
240 .then((result) => {
241 if (result && result.drop && phase === 'closing' && closeText === text) {
242 abandon($, `the hand-off prompt was refused: ${result.drop}`)
243 }
244 })
245 .catch((error) => {
246 if (phase === 'closing' && closeText === text) abandon($, `the hand-off prompt failed: ${messageOf(error)}`)
247 })
248}
249
250// Ask the desktop app to clear this conversation once the running turn ends. The app keeps
251// the request only while the turn runs and acts on it as the turn's result arrives, so it is
252// made as the hand-off turn starts. A session started from another device (Remote Control)
253// cannot be cleared, so Remote Control is turned off for it when allowed.
254async function requestClear($, cfg) {
255 phase = 'clearing'
256 let refused = await callApp($, 'clear_session', { session_id: 'self' })
257 if (refused && cfg.pauseRemoteControl && /remote control/i.test(refused)) {
258 const failed = await callApp($, 'set_remote_control', { session_id: 'self', enabled: false })
259 if (failed) {
260 return abandon($, `the app refused to clear the context: ${refused}; Remote Control could not be turned off: ${failed}`)
261 }
262 remoteControlPaused = true
263 note($, 'Remote Control turned off for the clear; it is turned on again after the resume')
264 refused = await callApp($, 'clear_session', { session_id: 'self' })
265 }
266 if (refused) return abandon($, `the app refused to clear the context: ${refused}`)
267 // The clear ends this process; the next one finds the resume to make in the store
268 try {
269 await $.store.set(RESUME_KEY, {
270 appSessionId: await appSessionId($),
271 cwd: await $.session.cwd(),
272 askedAt: await $.clock.now(),
273 })
274 } catch (error) {
275 note($, `the resume could not be stored for the next process: ${messageOf(error)}`)
276 }
277 note($, 'the app will clear the conversation when the hand-off turn ends')
278}
279
280// The hand-off turn has ended: the app clears the conversation now, or the hand-off is given up
281function awaitClear($) {
282 note($, 'hand-off recorded, waiting for the app to clear the conversation')
283 $.ui.toast(`${NAME}: hand-off recorded, clearing the context`)
284 if (clearWatchdog) clearWatchdog.cancel()
285 clearWatchdog = $.clock.after(CLEAR_WAIT_MS, () => {
286 clearWatchdog = null
287 if (phase === 'clearing') {
288 abandon($, `the app did not clear the conversation within ${CLEAR_WAIT_MS / 1000} s of the hand-off turn ending`)
289 }
290 })
291}
292
293// The hand-off turn has ended: compact the conversation down to the notes, then resume.
294// A compaction is refused while a turn still runs, so it is tried a few times. A session
295// with no compaction call of its own (the desktop app's) compacts through /compact instead,
296// as does one configured to.
297function startCompaction($, cfg) {
298 phase = 'compacting'
299 compactVia = 'call'
300 note($, 'hand-off recorded, compacting the conversation')
301 $.ui.toast(`${NAME}: hand-off recorded, compacting the conversation`)
302 const attempt = (i) => {
303 compactTimer = $.clock.after(COMPACT_TRIES_MS[i], async () => {
304 compactTimer = null
305 if (phase !== 'compacting') return
306 if (cfg.compaction === 'command') return compactByCommand($, cfg, 'compacting through /compact, as configured')
307 let result
308 try {
309 result = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
310 } catch (error) {
311 const message = messageOf(error)
312 if (cfg.compaction === 'auto' && hasNoCompactCall(message)) {
313 return compactByCommand($, cfg, 'this session has no compaction call; compacting through /compact')
314 }
315 if (i + 1 < COMPACT_TRIES_MS.length) return attempt(i + 1)
316 return abandon($, `the conversation could not be compacted: ${message}`)
317 }
318 if (result && result.skip) return abandon($, `the compaction was skipped: ${result.skip}`)
319 compacted($, cfg, `compacted the conversation${sizesOf(result)}`, 0)
320 })
321 }
322 attempt(0)
323}
324
325// Compact through the /compact command, queued as if typed, with the same instructions.
326// The compaction is seen as it runs, by the session.compact hook; the command's own answer,
327// which may come later or never, stands in when the hook saw nothing.
328function compactByCommand($, cfg, why) {
329 compactVia = 'command'
330 note($, why)
331 if (compactWatchdog) compactWatchdog.cancel()
332 compactWatchdog = $.clock.after(COMPACT_COMMAND_WAIT_MS, () => {
333 compactWatchdog = null
334 if (phase === 'compacting') {
335 abandon($, `the /compact command did not compact the conversation within ${COMPACT_COMMAND_WAIT_MS / 60000} min`)
336 }
337 })
338 $.command
339 .run({ command: 'compact', args: COMPACT_INSTRUCTIONS })
340 .then(async (result) => {
341 if (phase !== 'compacting') return
342 // The hook saw no compaction: the context says whether the command made one
343 const percent = await readPercent($)
344 const answer = typeof result?.text === 'string' && result.text.trim() ? `: ${result.text.trim().slice(0, 160)}` : ''
345 if (percent !== null && triggeredAt && percent >= triggeredAt) {
346 return abandon($, `the /compact command ran, but the context is still at ${Math.round(percent)}%${answer}`)
347 }
348 compacted($, cfg, `the /compact command finished${percent === null ? '' : `, the context at ${Math.round(percent)}%`}`)
349 })
350 .catch((error) => {
351 if (phase === 'compacting') abandon($, `the /compact command failed: ${messageOf(error)}`)
352 })
353}
354
355// The conversation is compacted: the resume prompt follows, once. From the engine's call it
356// follows at once (the call resolves between turns, the compaction installed); from /compact
357// a moment later, as the hook sees the compaction while the command's turn still runs.
358function compacted($, cfg, what, afterMs = RESUME_AFTER_COMPACT_MS) {
359 if (phase !== 'compacting') return
360 phase = 'resuming'
361 cancelTimers()
362 note($, what)
363 if (afterMs === 0) submitResume($, cfg)
364 else resumeTimer = $.clock.after(afterMs, () => submitResume($, cfg))
365}
366
367// Submit the resume prompt into the fresh conversation, once
368function submitResume($, cfg) {
369 if (phase !== 'resuming') return
370 phase = 'idle'
371 closeText = null
372 closeTurnId = null
373 triggeredAt = null
374 compactVia = null
375 cancelTimers()
376 forgetPendingResume($)
377 note($, 'continuing in a fresh context')
378 $.ui.toast(`${NAME}: continuing in a fresh context`)
379 $.prompt.submit({ text: resumePromptFor(cfg), asUser: true }).catch(() => {})
380}
381
382// A fresh process after the app's clear: when the store says this session's hand-off asked
383// for a resume, and no prompt has run yet, continue from the notes. The app starts that process
384// at the user's next message, however long after the clear, so the resume keeps until then
385async function resumeIfPending($, cfg) {
386 let pending
387 try {
388 pending = await $.store.get(RESUME_KEY)
389 } catch {
390 return
391 }
392 if (!pending || typeof pending !== 'object') return
393 const forget = () => forgetPendingResume($)
394 if ((await $.session.turns()) !== 0) return forget()
395 const id = await appSessionId($)
396 const isThisSession =
397 id && pending.appSessionId ? id === pending.appSessionId : (await $.session.cwd()) === pending.cwd
398 // Another session's hand-off: leave its resume to it
399 if (!isThisSession) return
400 forget()
401 phase = 'resuming'
402 note($, 'a fresh process after the clear; continuing from the notes')
403 resumeTimer = $.clock.after(1000, () => submitResume($, cfg))
404}
405
406export function register(on, options) {
407 const cfg = settingsOf(options)
408
409 // Runs before your first prompt, and again after a reload
410 on('session.start', async ($, e, next) => {
411 await $.command.register({
412 name: 'handoff-now',
413 description: 'Record a hand-off now, reset the context, and continue from the notes',
414 })
415 await $.command.register({
416 name: 'handoff-status',
417 description: 'Show where the automatic hand-off stands and what it did last',
418 })
419 await resumeIfPending($, cfg)
420 return next(e)
421 })
422
423 // The turn that runs the hand-off prompt is the one whose text carries the tag. With the
424 // clear, the app is asked as it starts, so the conversation is cleared as this turn ends.
425 on('turn.start', async ($, e, next) => {
426 if (phase === 'closing' && closeTurnId === null && typeof e.text === 'string' && e.text.includes(MARK)) {
427 closeTurnId = e.turnId
428 note($, 'the hand-off turn is running')
429 if (cfg.reset === 'clear') await requestClear($, cfg)
430 }
431 return next(e)
432 })
433
434 on('turn.complete', async ($, e, next) => {
435 const out = await next(e)
436 // A subagent's turn is not the conversation's
437 if (e.agentId !== undefined) return out
438 const ended = e.reason ?? (e.isAborted ? 'aborted' : 'answer')
439
440 if (phase === 'closing') {
441 // The hand-off prompt is still queued, behind the turn that just ran
442 if (e.turnId !== closeTurnId) return out
443 if (ended === 'aborted') abandon($, 'the hand-off turn was interrupted')
444 else startCompaction($, cfg)
445 return out
446 }
447 if (phase === 'clearing') {
448 if (e.turnId === closeTurnId) {
449 // An interrupted hand-off turn takes the app's queued clear with it
450 if (ended === 'aborted') abandon($, 'the hand-off turn was interrupted')
451 else awaitClear($)
452 } else if (ended === 'answer') {
453 // A turn ran after the hand-off, so the app dropped the clear: record again
454 const percent = (await readPercent($)) ?? triggeredAt ?? cfg.threshold
455 await begin($, cfg, percent, 'a turn ran before the clear')
456 }
457 return out
458 }
459 if (phase !== 'idle') return out
460 // The resume turn has run: the session may serve Remote Control again
461 await restoreRemoteControl($)
462 if (!cfg.enabled || ended !== 'answer') return out
463
464 const percent = await readPercent($)
465 if (percent === null || percent < cfg.threshold) return out
466 if (triggeredAt !== null && percent < triggeredAt + cfg.step) return out
467 await begin($, cfg, percent, `context at ${Math.round(percent)}%, past ${cfg.threshold}%`)
468 return out
469 })
470
471 // A compaction run through /compact passes here as it runs: once it stands, the resume
472 // follows; a compaction the engine makes on its own meanwhile serves as well
473 on('session.compact', async ($, e, next) => {
474 const watching = phase === 'compacting' && compactVia === 'command' && e.agentId === undefined
475 let out
476 try {
477 out = await next(e)
478 } catch (error) {
479 if (watching && phase === 'compacting') abandon($, `the compaction failed: ${messageOf(error)}`)
480 throw error
481 }
482 if (watching && phase === 'compacting') {
483 if (out && out.skip !== undefined) abandon($, `the compaction was skipped: ${out.skip}`)
484 else compacted($, cfg, `compacted the conversation${sizesOf(out)}`)
485 }
486 return out
487 })
488
489 // A /clear ends the conversation; where the process goes on, it continues with a fresh one
490 on('session.end', async ($, e, next) => {
491 if (e.reason === 'clear' && phase === 'clearing') {
492 phase = 'resuming'
493 cancelTimers()
494 resumeTimer = $.clock.after(3000, () => submitResume($, cfg))
495 } else if (e.reason !== 'clear') {
496 phase = 'idle'
497 cancelTimers()
498 }
499 return next(e)
500 })
501
502 // The fresh conversation's SessionStart is the moment to continue
503 on('classic.SessionStart', async ($, e, next) => {
504 const out = await next(e)
505 if (e.source === 'clear' && phase === 'resuming') submitResume($, cfg)
506 return out
507 }).catch(($, e, next) => next(e)) // A failure here never holds up the session
508
509 // Hand off on request, whatever the context holds. A prompt cannot be submitted from
510 // inside a command's own run, so the hand-off begins a moment later, from a timer.
511 on('command.run', { command: 'handoff-now' }, async ($) => {
512 if (phase !== 'idle') return { text: `A hand-off is already in progress (${phase})` }
513 const percent = (await readPercent($)) ?? 0
514 phase = 'closing'
515 $.clock.after(50, () => {
516 phase = 'idle'
517 void begin($, cfg, percent, 'asked by /handoff-now', true)
518 })
519 return {
520 text:
521 `Recording the hand-off at ${Math.round(percent)}% context. ` +
522 `Once it is written the context is reset (${cfg.reset}) and the work continues from the notes.`,
523 }
524 })
525
526 on('command.run', { command: 'handoff-status' }, async ($) => {
527 const percent = await readPercent($)
528 const fill = percent === null ? 'no reading yet' : `${Math.round(percent)}%`
529 return {
530 text:
531 `Phase: ${phase}. Context: ${fill} (threshold ${cfg.threshold}%). ` +
532 `Automatic hand-off: ${cfg.enabled ? 'on' : 'off'}. Reset: ${cfg.reset}. Last: ${lastOutcome}.`,
533 }
534 })
535}
536