@sayansr26/agent-os 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -1
- package/CHANGELOG.md +44 -0
- package/README.md +36 -2
- package/cursor/hooks/git-guard.mjs +40 -0
- package/cursor/skills/agent-os-init/SKILL.md +116 -0
- package/package.json +4 -4
- package/plugins/agent-os/.claude-plugin/plugin.json +3 -3
- package/plugins/agent-os/hooks/pre-edit.mjs +36 -7
- package/plugins/agent-os/hooks/session-resume.mjs +29 -15
- package/plugins/auto-handoff/.claude-plugin/plugin.json +49 -0
- package/plugins/auto-handoff/LICENSE +21 -0
- package/plugins/auto-handoff/README.md +131 -0
- package/plugins/auto-handoff/UPSTREAM.md +22 -0
- package/plugins/auto-handoff/hooks/brief.ts +190 -0
- package/plugins/auto-handoff/hooks/config.ts +64 -0
- package/plugins/auto-handoff/hooks/hooks.json +1 -0
- package/plugins/auto-handoff/hooks/panel.tsx +45 -0
- package/plugins/auto-handoff/hooks/register.tsx +615 -0
- package/plugins/auto-handoff/hooks/server.ts +32 -0
- package/plugins/auto-handoff/hooks/templates.ts +17 -0
- package/plugins/auto-handoff/hooks/viewer.ts +173 -0
- package/plugins/auto-handoff/statusline/handoff-link.sh +15 -0
- package/plugins/auto-handoff/templates/brief.md +34 -0
- package/plugins/auto-handoff/templates/instructions.md +10 -0
- package/plugins/auto-handoff/tests/auto-handoff.test.tsx +931 -0
- package/plugins/auto-handoff/tests/seed-row.test.tsx +58 -0
- package/plugins/auto-handoff/tests/viewer.test.ts +111 -0
- package/plugins/auto-handoff/tsconfig.json +4 -0
- package/src/cli.mjs +57 -12
- package/src/cursor.mjs +167 -0
- package/src/plugin.mjs +5 -4
- package/src/selftest.mjs +65 -1
- package/src/targets.mjs +18 -11
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# claude-auto-handoff
|
|
2
|
+
|
|
3
|
+
> Vendored into the `sayan-plugins` marketplace from [alexknowshtml/claude-auto-handoff](https://github.com/alexknowshtml/claude-auto-handoff) (MIT, © 2026 Alex Hillman). See [UPSTREAM.md](UPSTREAM.md) for the commit and what differs here.
|
|
4
|
+
|
|
5
|
+
A Claude Code mod that hands a long session off to a fresh one before the context fills up. It replaces auto-compact.
|
|
6
|
+
|
|
7
|
+
At the threshold, Haiku writes a structured handoff brief to disk. Then the mod runs `/clear` and seeds the new session with one line that points at the brief. The fresh session reads the brief and keeps working.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
A live run on Haiku with the threshold at 80k. The mod refuses a read at the threshold, writes the brief, clears, and the fresh session is back at work about 7 seconds later at 31k. ([video](https://github.com/alexknowshtml/claude-auto-handoff/blob/8d762ce726c962268e2dbe24c1c60f0889a2fcde/docs/demo.mp4))
|
|
12
|
+
|
|
13
|
+
## Why not auto-compact?
|
|
14
|
+
|
|
15
|
+
Auto-compact summarizes in place, and you can't control what it keeps. A handoff brief has a fixed structure that you can edit. It covers work in progress, decisions, assumptions to verify, dead ends, your last request and whether it was answered, and the next step. The files, commits and issues sections come from the transcript in code, so they don't depend on the model's memory.
|
|
16
|
+
|
|
17
|
+
## What happens
|
|
18
|
+
|
|
19
|
+
1. **Threshold.** The mod checks the context size after each turn and before each model request, including tool output that hasn't been measured yet. Once it's past the threshold, the mod refuses new tool calls, so one burst of reads can't overflow the window. Unmeasured tool output is an estimate that can run high, so the real size sometimes comes in under the threshold. A refused call still ends in a handoff, at the next request or the end of the turn.
|
|
20
|
+
2. **Brief.** Haiku writes the brief from the transcript. If Haiku fails, a facts-only brief stands in. Briefs go to `~/.claude/state/auto-handoff/<session-id>.md`.
|
|
21
|
+
3. **Clear and seed.** The mod runs `/clear` and sends the fresh session one line: read the brief and follow its Instructions section. In the transcript, that line's brief path and viewer URL are drawn as links. Claude Code makes them clickable only when it detects a terminal that supports links. Over plain SSH it usually doesn't, so set `FORCE_HYPERLINK=1` if your terminal handles links, or use the status line link below.
|
|
22
|
+
4. **A panel above the prompt.** It shows each step with a braille spinner on the one still running: writing the brief, clearing, starting the fresh session. Once the new session is measured it reads `✓ handed off · 162k → 45k` with an `open brief` link, then collapses after 10 seconds. Failures, the loop-guard pause, a facts-only brief and a too-tight threshold stay up until you press Dismiss. Typing `/clear` yourself closes the panel, including one waiting for Dismiss, unless a handoff is running. The panel steps aside while a survey holds that band. The band is drawn on the terminal and desktop only, so on the mobile app or in VS Code the threshold, the result, and anything that stays up also arrive as a toast.
|
|
23
|
+
5. **Viewer.** Each brief also gets a readable page in `~/.claude/state/auto-handoff/pages/`. The page shows the brief and every handoff in the same run, linked in order. The served link is short, like `http://100.x.y.z:3846/1a2b3c4d`, so it fits on one line on a phone. In this copy the server is off by default and the link is the local file; set `viewer` in `/config` (for example `tailscale:3846`) to serve the pages. With `tailscale:3846` the mod serves them on your Tailscale IP at port 3846, so you can open them from any device on your tailnet. Devices off your tailnet can't reach them. The server starts with the first session that loads the mod and runs while that session is open; if it stops, including when the mod reloads, the next session to finish a turn starts it again. A session that finds the port already taken logs one line and leaves the running server alone, since it serves the same pages. Without Tailscale, the mod serves on `127.0.0.1` instead, so the link opens only on this machine. If Tailscale comes up later, a session already serving on localhost keeps using it; the next new session can serve on the Tailscale IP.
|
|
24
|
+
6. **Status line link (optional).** `statusline/handoff-link.sh` wraps your status line command and adds a `↪ <link>` line when the session came from a handoff. Set it as the `statusLine` command in `~/.claude/settings.json`, with your existing command after it:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
"statusLine": { "type": "command", "command": "~/claude-auto-handoff/statusline/handoff-link.sh ~/.claude/my-statusline.sh" }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It needs `jq`. It finds the link in the previous brief's header, which names this session in `to:` and the page in `viewer:`.
|
|
31
|
+
|
|
32
|
+
Loop guards stop a fresh session that starts large from handing off again right away. They also cap how many handoffs run in a row before you type something.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Requires a Claude Code build with mods (function-hook plugins). From the `sayan-plugins` marketplace (`agent-os init` installs it too; `--no-auto-handoff` skips it):
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/plugin marketplace add sayansr26/agent-os
|
|
40
|
+
/plugin install auto-handoff@sayan-plugins
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or from upstream, as a plugin folder:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
git clone https://github.com/alexknowshtml/claude-auto-handoff.git ~/claude-auto-handoff
|
|
47
|
+
claude --plugin-dir ~/claude-auto-handoff
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
To load it in every session, set `CLAUDE_CODE_PLUGIN_DIRS` to the folder in your shell environment, or in the `env` block of `~/.claude/settings.json`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-auto-handoff" } }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Configure
|
|
57
|
+
|
|
58
|
+
Every setting is a row in `/config` under auto-handoff. They're stored in `~/.claude/settings.json` under `pluginConfigs`.
|
|
59
|
+
|
|
60
|
+
| Setting | Default | What it does |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `threshold` | `160000` | Context tokens that trigger a handoff. Sized for a 200k window: it leaves room for the brief and the turn in flight. A seeded session hands off no sooner than 40k past its own starting size, whatever this says; set it lower than that and the panel tells you where the line actually is |
|
|
63
|
+
| `maxConsecutiveHandoffs` | `2` | Handoffs allowed before you type a prompt; past this, the mod pauses until you do |
|
|
64
|
+
| `briefTemplate` | `~/.claude/auto-handoff/brief.md` | Your copy of the sections Haiku writes |
|
|
65
|
+
| `instructionsTemplate` | `~/.claude/auto-handoff/instructions.md` | Your copy of what the fresh session is told to do |
|
|
66
|
+
| `ignoreFiles` | blank | Regex for edited files to leave out of the brief, such as caches or synced state |
|
|
67
|
+
| `viewer` | blank | Where to serve the brief pages, as `host:port`. `tailscale` as the host means this machine's Tailscale IP, or `127.0.0.1` when Tailscale isn't set up. Leave blank for no server; the link is then the local file |
|
|
68
|
+
|
|
69
|
+
Environment variables:
|
|
70
|
+
|
|
71
|
+
- `AUTO_HANDOFF_TOKENS=60000` overrides the threshold for one run, so you can watch a handoff without filling 160k first. It stays set in that shell after the test. Seeded sessions start near 45k, so a value under about 85k leaves them less than 40k of room: the mod then hands off at start + 40k instead and the panel shows `threshold 60k (AUTO_HANDOFF_TOKENS) leaves 15k ...` so you know the override is still live.
|
|
72
|
+
- `AUTO_HANDOFF_DISABLE=1` turns the mod off for one session, viewer server included.
|
|
73
|
+
- `DISABLE_AUTO_COMPACT` also turns it off. When something else manages the context limit, such as a wrapper that pipes the session, `/clear` would break that pipe. The viewer server still runs there.
|
|
74
|
+
|
|
75
|
+
## Change the brief's structure and rules
|
|
76
|
+
|
|
77
|
+
The brief is shaped by two markdown files. The defaults live in this repo's [`templates/`](templates/) folder:
|
|
78
|
+
|
|
79
|
+
- **[`templates/brief.md`](templates/brief.md)** is the prompt Haiku gets after the transcript. Each `## ` heading is a section of the brief.
|
|
80
|
+
- **[`templates/instructions.md`](templates/instructions.md)** goes at the top of the brief and tells the fresh session what to do with it.
|
|
81
|
+
|
|
82
|
+
On a session's first start, the mod copies both files to `~/.claude/auto-handoff/` if they aren't there yet. Edit those copies, not the ones in the repo, so a `git pull` never overwrites your changes. The next handoff uses your version.
|
|
83
|
+
|
|
84
|
+
To get the current default back, delete your copy. The next start copies it fresh. To keep your files somewhere else, point `briefTemplate` or `instructionsTemplate` in `/config` at them.
|
|
85
|
+
|
|
86
|
+
### Editing `brief.md`
|
|
87
|
+
|
|
88
|
+
Add, remove, rename or reorder `## ` sections. The text under each heading tells Haiku what to put there. A Haiku reply counts as valid if it contains at least one of your headings. Otherwise the mod falls back to a facts-only brief.
|
|
89
|
+
|
|
90
|
+
Leave out files and commits sections. The mod adds them from the transcript in code.
|
|
91
|
+
|
|
92
|
+
### Editing `instructions.md`
|
|
93
|
+
|
|
94
|
+
It has one switch:
|
|
95
|
+
|
|
96
|
+
```md
|
|
97
|
+
{{#priority}}Shown when the last request is not fully answered.{{/priority}}
|
|
98
|
+
{{^priority}}Shown when it is.{{/priority}}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The switch reads the brief's `## Last Request from the User` section and its `Status:` line. Keep both in `brief.md` if you want it to work.
|
|
102
|
+
|
|
103
|
+
## Logs
|
|
104
|
+
|
|
105
|
+
Everything the mod does is logged to `~/.claude/state/auto-handoff/auto-handoff.log`.
|
|
106
|
+
|
|
107
|
+
## Develop
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
claude plugin validate .
|
|
111
|
+
claude plugin test .
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The mod hot-reloads when you save while it's loaded with `--plugin-dir`.
|
|
115
|
+
|
|
116
|
+
## Changelog
|
|
117
|
+
|
|
118
|
+
- **0.8.6** On a machine without `sh` (Windows), the brief page is still written, and the link opens it as a local file instead of a server that never started. The viewer no longer shells out to `mkdir`.
|
|
119
|
+
- **0.8.5** Windows support, from [@davidboomcycle](https://github.com/davidboomcycle) (#3). The mod falls back to `USERPROFILE` when `HOME` is unset, so briefs no longer land in `<project>/undefined/`. Where there is no `sh`, the log is written through `$.fs`. The tests pass on Windows. The viewer server still needs a POSIX shell.
|
|
120
|
+
- **0.8.4** Any token figure in Haiku's brief that isn't in Handoff Numbers is marked `[unverified: not in Handoff Numbers]` and logged. The figure is marked, not removed.
|
|
121
|
+
- **0.8.3** The brief gets the real numbers: tokens at handoff, the threshold and where it came from, the session's starting size, and how many handoffs ran with no message from you. Haiku must copy them or write "unknown", so a brief can no longer invent a figure like "burned its 200k budget".
|
|
122
|
+
- **0.8.2** A refused tool call always ends in a handoff, even when the real size measures under the threshold. Your own `/clear` closes the panel. A second session that finds the viewer port taken exits quietly instead of logging a stack trace.
|
|
123
|
+
- **0.8.1** On the mobile app and in VS Code, which don't draw the panel, the threshold, the result and anything that stays up also arrive as toasts.
|
|
124
|
+
- **0.8.0** A panel above the prompt replaces the toasts, with a spinner on each step and an `open brief` link.
|
|
125
|
+
- **0.7.0** A seeded session hands off no sooner than 40k past its starting size. When the threshold is set tighter than that, the panel says so and names the setting. The viewer serves on `127.0.0.1` when Tailscale isn't available.
|
|
126
|
+
- **0.6.0** A viewer page for each brief, served on your Tailscale IP with short links. The pages in a chain link to each other. The seed row's links are clickable, and the status line script adds a handoff link.
|
|
127
|
+
- **0.5.0** First release: a Haiku brief, `/clear` and a seed prompt. It includes the tool gate, the check before each request, auto-compact replaced by a handoff, and the loop guards.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Upstream
|
|
2
|
+
|
|
3
|
+
This plugin is a vendored copy of [alexknowshtml/claude-auto-handoff](https://github.com/alexknowshtml/claude-auto-handoff)
|
|
4
|
+
by Alex Hillman, MIT licensed (see [LICENSE](LICENSE)); the copyright and author stay his.
|
|
5
|
+
|
|
6
|
+
- Source commit: `8d762ce726c962268e2dbe24c1c60f0889a2fcde` (upstream version 0.8.6), taken 2026-10-22.
|
|
7
|
+
- Left out: `docs/demo.gif` and `docs/demo.mp4` (1.5 MB); the README links to them upstream.
|
|
8
|
+
|
|
9
|
+
## Local changes
|
|
10
|
+
|
|
11
|
+
- `viewer` defaults to blank (no server) in `.claude-plugin/plugin.json` and `hooks/config.ts`.
|
|
12
|
+
Upstream defaults to `tailscale:3846`, which starts a detached HTTP server that outlives the
|
|
13
|
+
session. `agent-os init` installs this plugin into projects by default, so it must not start a
|
|
14
|
+
network listener nobody asked for. Set `viewer` in `/config` to turn it on.
|
|
15
|
+
- Tests that exercise the server name `viewer: 'tailscale:3846'` explicitly.
|
|
16
|
+
- README: provenance note, install from `sayan-plugins`, viewer default.
|
|
17
|
+
|
|
18
|
+
## Syncing from upstream
|
|
19
|
+
|
|
20
|
+
Fetch the new commit into a scratch folder, diff it against this folder, re-apply the local
|
|
21
|
+
changes above, update the commit here, and bump `version` in `.claude-plugin/plugin.json` so
|
|
22
|
+
installs update. Run `claude plugin validate` and `claude plugin test` on this folder after.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { SessionMessage } from 'claude-code'
|
|
2
|
+
import { renderTemplate, sectionHeadings } from './templates.ts'
|
|
3
|
+
|
|
4
|
+
// Brief building.
|
|
5
|
+
// Files, commits, issues and the last real request come from the transcript in code;
|
|
6
|
+
// Haiku only writes the judgment sections. Its reply is checked, and the facts alone
|
|
7
|
+
// stand in when it fails.
|
|
8
|
+
|
|
9
|
+
const MAX_MESSAGES = 120
|
|
10
|
+
const MAX_MSG_CHARS = 2_000
|
|
11
|
+
const MAX_TRANSCRIPT_CHARS = 150_000
|
|
12
|
+
|
|
13
|
+
// Harness signals that arrive as user messages but are never the user's words.
|
|
14
|
+
const META_PREFIXES = ['Stop hook feedback', '[Automatic handoff]', '[auto-handoff]', '[Image', '<system-reminder>', '<command-name>', '<local-command']
|
|
15
|
+
|
|
16
|
+
const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
|
|
17
|
+
const GIT_COMMIT_OUTPUT = /^\[[\w./-]+(?: \(root-commit\))? ([0-9a-f]{7,})\] (.+)$/m
|
|
18
|
+
|
|
19
|
+
export type Facts = {
|
|
20
|
+
filesModified: string[]
|
|
21
|
+
commits: string[]
|
|
22
|
+
issues: string[]
|
|
23
|
+
lastUserMessage?: string
|
|
24
|
+
handoffTokens?: number
|
|
25
|
+
threshold?: number
|
|
26
|
+
/** Where the base threshold came from: the env override or the /config setting. */
|
|
27
|
+
thresholdSource?: string
|
|
28
|
+
seededSessionStartSize?: number
|
|
29
|
+
/** Handoffs since the user last typed, this one included. */
|
|
30
|
+
unattendedCount?: number
|
|
31
|
+
/** Which handoff this is in its chain (1 for the first, 2 for the second, etc.). */
|
|
32
|
+
depth?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The user's own words, or undefined for a harness signal or a tool-result-only message. */
|
|
36
|
+
export function userText(m: SessionMessage): string | undefined {
|
|
37
|
+
if (m.role !== 'user') return undefined
|
|
38
|
+
const raw = m.text.trim()
|
|
39
|
+
if (!raw) return undefined
|
|
40
|
+
return isMeta(raw) ? undefined : raw
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isMeta(text: string): boolean {
|
|
44
|
+
return META_PREFIXES.some(p => text.startsWith(p))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function commitSubject(command: string): string | undefined {
|
|
48
|
+
if (!/\bgit\b[^\n|;&]*\bcommit\b/.test(command)) return undefined
|
|
49
|
+
const heredoc = command.match(/<<\s*'?(\w+)'?\n([\s\S]*?)\n\1/)
|
|
50
|
+
if (heredoc) return heredoc[2]?.trim().split('\n')[0]
|
|
51
|
+
return command.match(/-m\s+(["'])(.+?)\1/)?.[2]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** ignoreFiles: paths that never count as edits (auto-synced state, caches); the ignoreFiles setting. */
|
|
55
|
+
export function extractFacts(messages: readonly SessionMessage[], ignoreFiles?: RegExp): Facts {
|
|
56
|
+
const files = new Set<string>()
|
|
57
|
+
const commits: string[] = []
|
|
58
|
+
const issues = new Set<string>()
|
|
59
|
+
let lastUserMessage: string | undefined
|
|
60
|
+
for (const m of messages) {
|
|
61
|
+
const said = userText(m)
|
|
62
|
+
if (said) lastUserMessage = said
|
|
63
|
+
for (const n of `${said ?? (m.role === 'assistant' ? m.text : '')}`.matchAll(/(?<![\w&/])#(\d{2,5})\b/g)) issues.add(`#${n[1]}`)
|
|
64
|
+
for (const t of m.toolUses) {
|
|
65
|
+
const path = t.input.file_path ?? t.input.notebook_path
|
|
66
|
+
if (EDIT_TOOLS.has(t.tool) && typeof path === 'string' && !t.isError && !ignoreFiles?.test(path)) files.add(path)
|
|
67
|
+
if (t.tool !== 'Bash' || typeof t.input.command !== 'string' || t.isError) continue
|
|
68
|
+
// git's own "[main abc1234] subject" line is the proof a commit landed; the command is the fallback
|
|
69
|
+
const out = t.text?.match(GIT_COMMIT_OUTPUT)
|
|
70
|
+
const subject = out ? `${out[2]} (${out[1]})` : t.text === undefined ? commitSubject(t.input.command) : undefined
|
|
71
|
+
if (subject && !commits.includes(subject)) commits.push(subject)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { filesModified: [...files].slice(-20), commits: commits.slice(-10), issues: [...issues].slice(-15), lastUserMessage }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The transcript as Haiku reads it. Harness signals are labelled so they are not taken for the user. */
|
|
78
|
+
export function renderTranscript(messages: readonly SessionMessage[]): string {
|
|
79
|
+
const lines = messages.slice(-MAX_MESSAGES).map(m => {
|
|
80
|
+
const role = m.role === 'user' && m.text.trim() && !userText(m) ? 'system signal (not the user)' : m.role
|
|
81
|
+
const text = m.text.length > MAX_MSG_CHARS ? m.text.slice(0, MAX_MSG_CHARS) + ' [truncated]' : m.text
|
|
82
|
+
const tools = m.toolUses.map(t => ` [tool ${t.tool}${t.isError ? ' ERROR' : ''}] ${JSON.stringify(t.input).slice(0, 300)}`)
|
|
83
|
+
return [`### ${role}`, text, ...tools].filter(Boolean).join('\n')
|
|
84
|
+
})
|
|
85
|
+
const joined = lines.join('\n\n')
|
|
86
|
+
return joined.length > MAX_TRANSCRIPT_CHARS ? joined.slice(-MAX_TRANSCRIPT_CHARS) : joined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function list(items: string[], empty: string): string {
|
|
90
|
+
return items.length ? items.map(i => `- ${i}`).join('\n') : empty
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The only token figures the brief may use. Haiku once wrote "burned its 200k budget" for a
|
|
94
|
+
// session at 93k because the prompt held no numbers at all.
|
|
95
|
+
function handoffNumbersBlock(f: Facts): string {
|
|
96
|
+
const lines = []
|
|
97
|
+
if (f.depth !== undefined) lines.push(`- **Handoff depth:** ${f.depth}`)
|
|
98
|
+
if (f.handoffTokens !== undefined) lines.push(`- **Tokens at handoff:** ${f.handoffTokens} (${k(f.handoffTokens)})`)
|
|
99
|
+
if (f.threshold !== undefined) lines.push(`- **Threshold:** ${f.threshold} (${k(f.threshold)})${f.thresholdSource ? `, from ${f.thresholdSource}` : ''}`)
|
|
100
|
+
if (f.seededSessionStartSize !== undefined) lines.push(`- **This session's starting size (seeded from a handoff):** ${f.seededSessionStartSize} (${k(f.seededSessionStartSize)})`)
|
|
101
|
+
if (f.unattendedCount !== undefined) lines.push(`- **Handoffs in a row with no user message:** ${f.unattendedCount}`)
|
|
102
|
+
return lines.length ? `## Handoff Numbers\n${lines.join('\n')}\n` : ''
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const k = (n: number) => `${Math.round(n / 1000)}k`
|
|
106
|
+
|
|
107
|
+
export function factsBlock(f: Facts, withLastMessage = true): string {
|
|
108
|
+
const numbers = handoffNumbersBlock(f)
|
|
109
|
+
const files = `## Files Modified (from Edit/Write calls)
|
|
110
|
+
${list(f.filesModified, 'None.')}
|
|
111
|
+
|
|
112
|
+
## Commits This Session
|
|
113
|
+
${list(f.commits, 'None.')}
|
|
114
|
+
|
|
115
|
+
## GitHub Issues Mentioned
|
|
116
|
+
${f.issues.length ? f.issues.join(', ') : 'None.'}`
|
|
117
|
+
const all = [numbers, files].filter(Boolean).join('\n')
|
|
118
|
+
return withLastMessage ? `${all}\n\n## Last Real User Message (verbatim)\n${f.lastUserMessage ?? 'None found.'}` : all
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function briefPrompt(messages: readonly SessionMessage[], facts: Facts, template: string): string {
|
|
122
|
+
// Data first, instructions last.
|
|
123
|
+
return `## Extracted Facts\n${factsBlock(facts)}\n\n## Conversation\n${renderTranscript(messages)}\n\n---\n\n${template.trim()}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A reply that holds none of the template's sections is a dialogue fragment, not a brief. */
|
|
127
|
+
export function isValidBrief(text: string, template: string): boolean {
|
|
128
|
+
return sectionHeadings(template).some(h => text.includes(h))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Whether the brief's last-request section
|
|
132
|
+
// says the request is not (fully) answered. A chain with no user message is not a real request.
|
|
133
|
+
export function hasUnansweredLastRequest(text: string): boolean {
|
|
134
|
+
const heading = /##\s*Last Request from the User/i.exec(text)
|
|
135
|
+
if (!heading) return false
|
|
136
|
+
const rest = text.slice(heading.index + heading[0].length)
|
|
137
|
+
const next = /^#{1,6}\s/m.exec(rest)
|
|
138
|
+
const section = next ? rest.slice(0, next.index) : rest
|
|
139
|
+
if (/No user message found|^\s*\[auto-handoff\]/i.test(section)) return false
|
|
140
|
+
return /\bStatus[\s*_`]*:[\s*_`"]*(Not (?:yet |fully )?answered|Partially answered|Unanswered)/i.test(section)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type BriefContext = {
|
|
144
|
+
sessionId: string
|
|
145
|
+
/** The session's transcript file. */
|
|
146
|
+
transcript: string
|
|
147
|
+
/** The instructions template, rendered at the top of the brief. */
|
|
148
|
+
instructions: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function assembleBrief(ctx: BriefContext, facts: Facts, haiku: string | undefined): string {
|
|
152
|
+
const header = `${renderTemplate(ctx.instructions, { priority: haiku ? hasUnansweredLastRequest(haiku) : false })}
|
|
153
|
+
|
|
154
|
+
## Session Handoff Brief
|
|
155
|
+
|
|
156
|
+
- **Previous Session:** ${ctx.sessionId}
|
|
157
|
+
- **Transcript:** \`${ctx.transcript}\`
|
|
158
|
+
|
|
159
|
+
## How to Use This Brief
|
|
160
|
+
${haiku ? 'Haiku wrote the judgment sections from conversation text with tool output abbreviated. The facts sections came from tool calls in code.' : 'Haiku did not return a usable brief, so this holds only facts extracted in code. Read the transcript for the rest.'} Treat every line as a starting point, not a fact. Before acting on anything here, spawn a subagent to verify: run git status, gh pr view, or Read the file directly. If a fact is missing, grep the transcript before asking the user.`
|
|
161
|
+
// A valid Haiku brief already quotes the last request in its own section.
|
|
162
|
+
return [header, haiku?.trim(), factsBlock(facts, !haiku)].filter(Boolean).join('\n\n')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A token figure: "93k", "93.1k", "93,105 tokens", "93105 tokens".
|
|
166
|
+
const TOKEN_FIGURE = /\b(\d{1,4}(?:\.\d+)?)k\b|\b(\d{1,3}(?:,\d{3})+|\d{4,7})(?= tokens\b)/gi
|
|
167
|
+
|
|
168
|
+
const figureValue = (k?: string, whole?: string) => k !== undefined ? Number(k) * 1000 : Number(whole!.replace(/,/g, ''))
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Marks every token figure in Haiku's text that the Handoff Numbers block does not hold, within
|
|
172
|
+
* rounding to the nearest thousand. The template asks Haiku to copy those numbers; this makes it a
|
|
173
|
+
* rule. Figures are marked, not removed, so the next session sees what was claimed and that it is
|
|
174
|
+
* unchecked. With no numbers block, every figure is marked.
|
|
175
|
+
*/
|
|
176
|
+
export function markUnverifiedFigures(text: string, f: Facts): { text: string; flagged: string[] } {
|
|
177
|
+
const block = handoffNumbersBlock(f)
|
|
178
|
+
const allowed = [
|
|
179
|
+
...[...block.matchAll(/\b(\d{1,4}(?:\.\d+)?)k\b/g)].map(m => figureValue(m[1])),
|
|
180
|
+
...[...block.matchAll(/\b\d{4,7}\b/g)].map(m => Number(m[0])),
|
|
181
|
+
]
|
|
182
|
+
const flagged: string[] = []
|
|
183
|
+
const marked = text.replace(TOKEN_FIGURE, (match: string, k?: string, whole?: string) => {
|
|
184
|
+
const value = figureValue(k, whole)
|
|
185
|
+
if (allowed.some(a => Math.abs(a - value) < 1000)) return match
|
|
186
|
+
flagged.push(match)
|
|
187
|
+
return `${match} [unverified: not in Handoff Numbers]`
|
|
188
|
+
})
|
|
189
|
+
return { text: marked, flagged }
|
|
190
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Settings from /config and the constants built on them. Engine-free: the host follows $ only
|
|
2
|
+
// into functions declared in register.tsx, so anything taking $ stays there.
|
|
3
|
+
|
|
4
|
+
export const BRIEF_DIR = '.claude/state/auto-handoff'
|
|
5
|
+
// The two numbers are userConfig fields (plugin.json), set in /config. Defaults match the manifest.
|
|
6
|
+
// threshold: matches HANDOFF_ARM_TOKENS in context-warning.ts.
|
|
7
|
+
// MIN_HEADROOM and maxUnattended are loop guards. A seeded session must grow MIN_HEADROOM past its
|
|
8
|
+
// first-turn size (its floor) before it can hand off again, and at most maxUnattended handoffs may
|
|
9
|
+
// run before the user types a prompt. MIN_HEADROOM is fixed: seeded sessions start near 45k, so at
|
|
10
|
+
// the default threshold it never moves the line; it only matters when the threshold is set low.
|
|
11
|
+
// 40k is the empirical line where a seeded session can read its brief and still do real work. The
|
|
12
|
+
// 2026-10-03 live run (threshold 20k, seeded sessions start at ~31k) chained six times with no
|
|
13
|
+
// guard; the 2026-10-04 run (AUTO_HANDOFF_TOKENS=80000 left in a shell, floor ~45k) chained eight
|
|
14
|
+
// times with a guard of a quarter of the threshold, because max(80k, 45k + 20k) is still 80k, and
|
|
15
|
+
// 35k of room goes in reading the brief. Progress, not time: a 15-minute chain cap could block a
|
|
16
|
+
// real session that fills fast.
|
|
17
|
+
// The rest shape the brief: two template files and a pattern for files
|
|
18
|
+
// that never count as edits.
|
|
19
|
+
// viewer: where the mod serves the brief pages, "host:port"; "tailscale" as the host means this
|
|
20
|
+
// machine's Tailscale IP, or 127.0.0.1 without Tailscale. Blank: no server, and the link is the local file.
|
|
21
|
+
// sayan-plugins ships it blank (upstream: 'tailscale:3846'), so installing the mod starts no server.
|
|
22
|
+
export type Config = { threshold: number; maxUnattended: number; briefTemplate: string; instructionsTemplate: string; ignoreFiles?: RegExp; viewer: string }
|
|
23
|
+
export const DEFAULTS: Config = { threshold: 160_000, maxUnattended: 2, briefTemplate: '~/.claude/auto-handoff/brief.md', instructionsTemplate: '~/.claude/auto-handoff/instructions.md', viewer: '' }
|
|
24
|
+
export const MIN_HEADROOM = 40_000
|
|
25
|
+
// Each template's default, a file in the mod's templates/ folder.
|
|
26
|
+
export const TEMPLATES = [['briefTemplate', 'brief.md'], ['instructionsTemplate', 'instructions.md']] as const
|
|
27
|
+
export type TemplateKey = typeof TEMPLATES[number][0]
|
|
28
|
+
// Used only when the shipped default is unreadable too, so the fresh session still knows what to do.
|
|
29
|
+
export const LAST_RESORT_INSTRUCTIONS = '## Instructions\n\nThis turn was triggered by the system, not by a user. Read this brief and continue the work it describes.'
|
|
30
|
+
|
|
31
|
+
export const k = (n: number) => `${Math.round(n / 1000)}k`
|
|
32
|
+
export const short = (sessionId: string) => sessionId.slice(0, 8)
|
|
33
|
+
export const expand = (path: string, home: string) => path.replace(/^~(?=\/|$)/, home)
|
|
34
|
+
|
|
35
|
+
// A non-positive or non-numeric value falls back to the default rather than handing off at 0.
|
|
36
|
+
const num = (v: unknown, fallback: number) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback
|
|
37
|
+
const str = (v: unknown, fallback: string) => typeof v === 'string' && v.trim() ? v.trim() : fallback
|
|
38
|
+
// A bad pattern is dropped, not fatal: a typo in /config should not stop handoffs.
|
|
39
|
+
function pattern(v: unknown): RegExp | undefined {
|
|
40
|
+
if (typeof v !== 'string' || !v.trim()) return undefined
|
|
41
|
+
try { return new RegExp(v) } catch { return undefined }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The /config values, each checked, with the manifest's defaults for anything missing or bad. */
|
|
45
|
+
export function parseConfig(options: Record<string, unknown>): Config {
|
|
46
|
+
return {
|
|
47
|
+
threshold: num(options.threshold, DEFAULTS.threshold),
|
|
48
|
+
maxUnattended: num(options.maxConsecutiveHandoffs, DEFAULTS.maxUnattended),
|
|
49
|
+
briefTemplate: str(options.briefTemplate, DEFAULTS.briefTemplate),
|
|
50
|
+
instructionsTemplate: str(options.instructionsTemplate, DEFAULTS.instructionsTemplate),
|
|
51
|
+
ignoreFiles: pattern(options.ignoreFiles),
|
|
52
|
+
viewer: typeof options.viewer === 'string' ? options.viewer.trim() : DEFAULTS.viewer,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The seed prompt's first characters; the render hook knows the seed row by them.
|
|
57
|
+
export const SEED_PREFIX = '[auto-handoff] ↪ Handoff from session'
|
|
58
|
+
// The seed text with its brief path and viewer URL as markdown links. The path becomes a
|
|
59
|
+
// file: link labelled by its file name; the URL links to itself. Exported for the test.
|
|
60
|
+
export function linkify(text: string): string {
|
|
61
|
+
return text
|
|
62
|
+
.replace(/(?<=\bat )(\/\S+\.md)(?=[\s)]|$)/, (p) => `[${p.slice(p.lastIndexOf('/') + 1)}](file://${p})`)
|
|
63
|
+
.replace(/(https?:\/\/[^\s)]+)/, (u) => `[${u}](${u})`)
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The band above the prompt is the mod's whole UI: a header line and the steps under it, with
|
|
2
|
+
// a braille spinner on whatever is still running. A toast cannot animate or carry a link, so the
|
|
3
|
+
// panel replaced them. This file draws; register.tsx holds the panel and its timers, since the
|
|
4
|
+
// host never follows $ across an import.
|
|
5
|
+
|
|
6
|
+
export type Mark = 'spin' | 'done' | 'warn' | 'fail'
|
|
7
|
+
export type Line = { mark: Mark; text: string }
|
|
8
|
+
// sticky: stays until dismissed (a failure, the pause, a warning); otherwise a panel with nothing
|
|
9
|
+
// spinning collapses on its own.
|
|
10
|
+
export type Panel = { header: Line; steps: Line[]; link?: string; sticky?: boolean }
|
|
11
|
+
|
|
12
|
+
export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
13
|
+
const GLYPH: Record<Exclude<Mark, 'spin'>, string> = { done: '✓', warn: '⚠', fail: '✗' }
|
|
14
|
+
const COLOR: Record<Mark, string> = { spin: 'yellow', done: 'green', warn: 'yellow', fail: 'red' }
|
|
15
|
+
|
|
16
|
+
export const isSpinning = (p: Panel) => p.header.mark === 'spin' || p.steps.some((l) => l.mark === 'spin')
|
|
17
|
+
|
|
18
|
+
// The plain text of the panel, one line per row, for logs and tests.
|
|
19
|
+
export const panelText = (p: Panel, frame = 0) =>
|
|
20
|
+
[p.header, ...p.steps].map((l, i) => `${i ? ' ' : ''}${glyph(l.mark, frame)} ${l.text}`).join('\n')
|
|
21
|
+
|
|
22
|
+
const glyph = (m: Mark, frame: number) => m === 'spin' ? SPINNER[frame % SPINNER.length] : GLYPH[m]
|
|
23
|
+
|
|
24
|
+
// The elements come from $.ui.resolve in the render hook.
|
|
25
|
+
type Elements = { Box: any; Text: any; Markdown: any; Button: any }
|
|
26
|
+
|
|
27
|
+
export function panelTree({ Box, Text, Markdown, Button }: Elements, p: Panel, frame: number, onDismiss: () => void) {
|
|
28
|
+
return (
|
|
29
|
+
<Box flexDirection="column">
|
|
30
|
+
<Box>
|
|
31
|
+
<Text color={COLOR[p.header.mark]}>{glyph(p.header.mark, frame)} </Text>
|
|
32
|
+
<Text bold>{p.header.text}</Text>
|
|
33
|
+
</Box>
|
|
34
|
+
{p.steps.map((l, i) => (
|
|
35
|
+
<Box key={`step-${i}`}>
|
|
36
|
+
<Text> </Text>
|
|
37
|
+
<Text color={COLOR[l.mark]}>{glyph(l.mark, frame)} </Text>
|
|
38
|
+
<Text dimColor={l.mark === 'done'}>{l.text}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
))}
|
|
41
|
+
{p.link ? <Markdown text={` [open brief](${p.link})`} /> : null}
|
|
42
|
+
{p.sticky ? <Button key="dismiss" label="Dismiss" onPress={onDismiss} /> : null}
|
|
43
|
+
</Box>
|
|
44
|
+
)
|
|
45
|
+
}
|