Context handover as a Claude Code mod: nudge, auto handover, last light, limits — no tmux, no status line.

Context handover as a Claude Code mod: a pure-TypeScript set of function hooks. No tmux, no status line, no Python CLI. It watches how full the context window is, nudges you, and (when you are away) hands over, /clears in-process and resumes from the handover.
It is a second implementation of context-vigil (the "classic" tmux + status-line plugin). Classic is untouched. Distinct names everywhere (/vho not /ho, vigil_handover, files under context-vigil-mod/), and a temporary interlock (below) makes the mod stand down wherever classic is installed.
Load it one way only, never both:
# from the marketplace, in a Claude Code session
/plugin marketplace add ppryde/pip-skills
/plugin install context-vigil-mod@pip-skills
# or straight from a checkout
bash plugins/context-vigil-mod/plugin/scripts/install.sh install # also: status | uninstall
Uninstall classic's hooks first (classic's own uninstall) -- the script refuses otherwise. It lists this folder in env.CLAUDE_CODE_PLUGIN_DIRS in your user settings.json ($CLAUDE_CONFIG_DIR, else ~/.claude), never a repo's. Then start a new session and run /vigil-setup.
/vigil-handover, or /vho for short -- hand over now.📜 Handing over — as /vho and runs exactly what /vho runs (same standdown, latch and in-flight notices). This applies only while the mod is active: when classic is installed and the mod stands down, the words go to the model as usual. Questions and talk about handovers ("how does the handover work", "fix the handover bug", "don't hand over yet") go through untouched, as does anything from a plugin; a last-light hold on your return is checked first.handover, hand over, handoff, ...) and the model then calls vigil_handover without the mod having asked, the call counts as asked for: it clears and resumes as /vho does. The mention is session state, wiped by a /clear, and spent by the call. The mention does not count while the mod stands down. Without a mention, an unrequested call is still the model volunteering: saved and offered, never cleared. The tool's description tells the model to write a handover only through the tool, never as an ad-hoc file or summary./vigil-overrides [add|rm|check] -- thresholds per model, window size or both. Bare (or check) lists the overrides in the order they are tried, what this session gets and from which override, and any faults or ambiguity; add asks for an override for the session you are in; rm model=… window=… (either or both) takes one out. See Overrides below./vigil-setup [nudge|bar|auto|last-light|limits|rc] -- the whole setup flow, or one step. limits covers on/off, trigger % and windows together. The mod asks each step itself in a dialog ($.ui.ask), one at a time: nothing is sent to the model and no prompt appears in the conversation. Options are labels only, the recommended one marked (Recommended); Tell me more re-asks the step with its explanation. Dismissing a dialog stops setup and keeps what was already answered.Per-model and per-window thresholds live in <config dir>/context-vigil-mod/overrides.json, yours to edit by hand or through /vigil-overrides add. When the file is missing it is written with the default override, so it is there to see:
{
"overrides": [
{ "window": 200000, "nudgeAt": 70 },
{ "model": "opus", "window": 1000000, "nudgeAt": 25, "step": 10 },
{ "model": "haiku", "nudgeAt": 80, "lastLightAt": 50 }
]
}
An override has a model, a window or both, and sets any of nudgeAt, step and lastLightAt. Each value comes from the most specific matching override that sets it, field by field; /vigil-setup values are the fallback beneath them all. Most specific first:
model and window -- the longer model pattern first (opus5.5 before opus)window only -- any model on that windowmodel only -- that model on any window/vigil-setup valuesA model pattern is a family and at most a version: opus takes every Opus, opus5 every Opus 5, opus5.5 (or opus-5-5) only Opus 5.5. A window is a whole number of tokens, matched exactly. When a window override and a model override both match a session and both set the same field (whatever their values), the window wins and a notice says so once per session, naming the model+window override that would settle it. The file is checked whenever a threshold is decided: a fault in an override (an unknown key, a bad pattern, a duplicate key) is told once, naming the override, and only that override is ignored. A file that is not valid JSON, or not { "overrides": [ … ] }, names no override: it is told once and the last good read stays in force (the default override if there was none).
From 0.1.3: overrides set with the old /vsetup models (kept in the settings store) move into overrides.json at the next session start, once: [1m] becomes window=1M, a family such as opus a model pattern, opus-5-5[1m] both. One already in the file for the same key stands; a pattern that has no equivalent here is named in the notice and not carried over.
The threshold reads the main session's own model and context window from the engine's measure, which fires after main-thread turns only: a subagent's tokens and its smaller window never move it, so a subagent cannot trip a handover.
Settings are per account ($.store) and re-read before every decision that matters, so a change made in one session reaches the others. Defaults: nudge 35% (+5% steps), with one default override window=200k → 70%, bar On, auto Off, idle window 30 min (15, 30 or 60), last light Off (writes its handover only at 25%+ context), limits On (95%, seven_day + spend_limit), RC auto-clear unanswered (follows auto mode, with the phone safeguards: a 30 s countdown first, nothing within 2 min of your last phone message).
Every moment is in exactly one:
| You | The agent | State | At the context threshold |
|---|---|---|---|
| engaged | anything | attended | nudge only (bar and notice in the terminal); never clears |
| idle >= idle window | working | auto armed | hands over, clears, resumes by itself |
| idle | idle | last-light territory | no clear; before the cache goes cold, write the handover only |
Engaged = a composer, bridge (Remote Control) or slack-ping prompt, a slash command you ran, an answer to one of the mod's questions, a bar button press, or a prompt-box edit/draft, within the idle window. Working = agent activity within the last 2 min. Auto mode is off until enabled in /vigil-setup auto. sdk sessions (claude -p) are unattended from the first turn. An unclassified, channel or auto-continuation prompt disarms auto mode but counts for nothing else.
The mod registers a vigil_handover tool and asks the model to call it. Required fields: goal, state, next_step and session_name; optional: decisions, open_questions, failed_attempts. The mod adds a snapshot (cwd, branch, dirty files, files edited, context %) and saves handovers/<session>-<n>.md. It then clears (only when the prompt box is empty, the RC rules hold and no limit latch is set; every wait shows a notice), injects the handover into the fresh session and submits the resume prompt. A /clear you run yourself picks up a saved handover the same way; the automatic resume is sent only when no turn has run since the handover was written and no usage limit is in force — otherwise the handover is injected and a notice asks you where to pick up.
Session naming. After the clear, an unnamed session is renamed to the handover's session_name (a mod-run /rename: prompt border, /resume, Remote Control). A session that already has a name (a /rename of yours, or an earlier handover) keeps its name -- /clear carries it. Only a transcript custom-title counts as named; Claude Code's own auto title does not. If the check cannot run, no rename is attempted.
State that must cross a clear (pending handover, limit latch) lives in $.store, because $.state is wiped by every /clear (PROBES section 9).
Terminal only. Shown from the threshold crossing until you choose or a handover happens: 🕯️ context 41% · threshold 35% 📜 Hand over now · ⏰ Remind me at 45% · ✖ Dismiss, with hotkeys 1, 2, 0 on the three buttons (the label names the absolute next step).
1 starts a handover; 2 hides it until the next step; 0 hides it silently until a /clear.hasSurvey) and returns after it./vigil-setup bar: a toast and log line (fired from the context measure) still mark the threshold and every step after. Notices (ui.toast/ui.log) show in the terminal only: they do not reach Remote Control yet (PROBES.md §1).vigil_handover, it is asked once more; then a "couldn't write a handover" notice appears and nothing clears. A request that is lost altogether expires after 10 idle minutes.guard.wait resume-retry with its attempt, and the send logs resume.sent. It is never sent twice, anything you type meanwhile cancels the retry, and so does a new /clear or /resume. The engine offers no readiness signal, so this is a backoff, not a wait.$.ui.log line says so per file. If the engine still answers the old session id right after a clear, a line says that too (the events of that clear then land in the old session's file)./vho while one is in flight is refused with a notice, and a clear is never queued twice. /vho says plainly when a handover is already running, the mod is standing down or a limit is latched.clear.skipped, and a notice says to /vho or /clear when ready. /vho and the bar's 1 are attended and unaffected.step (default 5) above the session's baseline, its first context reading (a /clear starts a new baseline). This stops a session that resumes just under the threshold from handing over again within a turn; the log says guard.baseline when it holds one back. Attended nudges are unaffected./clear notice. Parked handovers older than 14 days are pruned.CLAUDE_CONFIG_DIR, HOME, USERPROFILE and HOMEDRIVE+HOMEPATH all unset), nothing is written anywhere and a handover is refused with a notice naming those variables./vigil-setup last-light. When nothing has come from you since the agent's last turn, the agent is idle, the 1 h prompt cache is about to lapse (5 min lead) and context is at or past the last-light threshold, the mod writes the handover only; it never clears.
It only works with a 1-hour cache: just before it would fire, the mod reads the latest cache write from the transcript's last 64 KB and does not fire for a 5-minute cache, or when it finds nothing, so it never warms a cold one. Separately, for information only: after the session's first cache-writing response the mod reads the cache type once; for a 5-minute cache it shows a "Last light is off for this session" line above the prompt until your next message (the threshold bar takes precedence). A model switch's cache_ttl updates it, announcing when last light is back on. It fires at most once until a human prompt re-arms it. When you return after the cache has expired, your next prompt is held and the mod asks: resume from the handover (cheap) or carry on (pays the cold cache). Your message is re-sent either way.
A rate-limit failure or a window at its limit sets an account-wide latch with the reset time: no clearing and no prompting until it lifts (notices show ⏳ resumes HH:MM). A full window that reports no reset time latches for an hour at a time. Early stop (configurable via /vigil-setup limits): when a watched window (seven_day, spend_limit) reaches the trigger % (default 95), once per window, the mod writes a handover and arranges a resume 5 min after the reset if the session is still open and you have not come back in the meantime (then a notice names the handover instead). The latch is account-wide ($.store); early-stop marks are per running session. The 5-hour window is left to Claude Code's own wrap-up and auto-continue.
"On the phone" = the last human prompt came from bridge. Whether auto-clear may run there is asked in setup (/vigil-setup, right after the auto-mode question when auto is on; /vigil-setup rc asks it alone) and never during a run. Until it is answered it follows auto mode: on means it clears on the phone too, with the safeguards below, and a single hint per session says so (/vigil-setup rc to change). If allowed: a 30 s countdown with Cancel in the terminal bar (any message cancels; the countdown notice does not reach the phone yet, PROBES.md §1), and no clear within 2 min of the last phone prompt. If you answered No, the handover is saved and a notice says why nothing cleared (/vigil-setup rc to enable). The countdown and holdback apply only to unattended clears: /vho or the bar's 1 from the phone bypass them. The countdown bar draws even with the bar setting Off.
Under $CLAUDE_CONFIG_DIR/context-vigil-mod/, one account only:
handovers/<session>-<n>.mdevents/<day>/<session>.jsonl -- one JSON line per decision (arm, threshold, handover.written, clear, resume, ...) with its reason.The mod stands down (no arming, clearing or bar; one notice) when classic context-vigil is active for the session: classic hooks in the account's settings.json, or a classic session record. Temporary: removed, with all classic-detection code, when classic retires.
Works on Windows with Claude Code's own shell tools. The config dir is CLAUDE_CONFIG_DIR, else <home>\.claude, with the home taken from HOME, else USERPROFILE, else HOMEDRIVE+HOMEPATH; paths keep the separator of the base (C:\Users\you\.claude\context-vigil-mod\...). Last light and the "is this session named" check read the transcript with tail and grep; where sh does not run (no Git Bash) they read the transcript file directly instead, and a transcript over the engine's 4 MiB read cap reads as "cannot tell": last light then stays off (unknown never fires) and the session is left unrenamed. Everything else spawns only git.
SMOKES.md is the owner-run live checklist; PROBES.md records the engine behaviours this relies on. Gates: claude plugin validate plugins/context-vigil-mod/plugin, bash tests/run-mods.sh context-vigil-mod (the tests live in plugins/context-vigil-mod/tests/, beside plugin/, so they do not ship), bash plugins/context-vigil-mod/typecheck.sh (also typechecks those tests), pytest plugins/context-vigil-mod/tests.
hooks/register.tsx 1446 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Activity, Awaiting, EventKind, EventRecord, Git, Latch, Mode, Pending, PhoneFacts, PendingReason, RateLimit, Override, Settings, StepId } from '../types'
4import { ASK, DEFAULT_OVERRIDES, checkOverrides, fromModelThresholds, formatKey, formatOverride, formatWindow, overridesJson, ordered, parseKey, parseOverridesArgs, patternFor, removeOverride, resolve, sameKey, setOverride } from '../core/overrides'
5import type { Resolved } from '../core/overrides'
6import { COMMANDS, TOOL, TOOL_FULL, classicSessionPath, configRoot, eventsPath, handoverPath, overridesPath } from '../core/name'
7import { DEFAULTS, PENDING_KEEP_MS, PENDING_PREFIX, STORE_KEY, loadSettings, pendingKey } from '../core/settings'
8import { EMPTY_ACTIVITY, WORKING_MS, armed, classifyOrigin, mode, onPhone, record, transition } from '../core/arming'
9import type { Signal } from '../core/arming'
10import { appendLine, dayKey, makeRecord } from '../core/eventlog'
11import { COALESCE_MS, GIT_ARGV, GIT_DIR_ARGV, parseGit, touchesGit, watchPaths } from '../core/git'
12import { INPUT_SCHEMA, TOOL_DESCRIPTION, fresh, grownEnough, injectText, instructionText, isHandoverRequest, limitResumeText, mentionsHandover, nextThreshold, parseFields, renderHandover, resumeText, reusable } from '../core/handover'
13import { HOME_VARS_CHECKED } from '../core/home'
14import { TAIL_CMD, type CacheTtl, cacheLinesFromText, parseWrites, transcriptPathFor, ttlFromWrites } from '../core/cache-ttl'
15import { TTL_1H, fireAt, holdOnReturn, rearm, shouldFire } from '../core/last-light'
16import { clearGate, needsRcHint } from '../core/surfaces'
17import { applyAnswers, extractAnswers, isStep, nextCard, questionFor } from '../core/setup'
18import { RESUME_DELAY_MS, earlyStopDue, formatHHMM, latchCleared, latchFromMeasure, latchFromStopFailure, nextHop } from '../core/limits'
19import { classicHooksInstalled } from '../core/interlock'
20import { V } from '../core/voice'
21import type { WaitReason } from '../core/voice'
22
23// The effectful shell: the ONLY file that touches `$`. Decisions live in ../core.
24
25// $.state is per session and every /clear wipes it (PROBES §9): these atoms hold only what a
26// fresh session may forget.
27const modeA = atom({ plugin: 'context-vigil-mod', key: 'mode' } as const, 'idle')
28const contextA = atom({ plugin: 'context-vigil-mod', key: 'contextPct' } as const, null)
29const windowA = atom({ plugin: 'context-vigil-mod', key: 'contextWindow' } as const, null as number | null)
30const modelA = atom({ plugin: 'context-vigil-mod', key: 'contextModel' } as const, null as string | null)
31const baselineA = atom({ plugin: 'context-vigil-mod', key: 'baselinePct' } as const, null as number | null)
32const lastNudgedA = atom({ plugin: 'context-vigil-mod', key: 'lastNudged' } as const, null)
33const barShownA = atom({ plugin: 'context-vigil-mod', key: 'barShown' } as const, false)
34const barDismissedA = atom({ plugin: 'context-vigil-mod', key: 'barDismissed' } as const, false)
35const pendingA = atom({ plugin: 'context-vigil-mod', key: 'pending' } as const, null)
36const countdownA = atom({ plugin: 'context-vigil-mod', key: 'countdownEndsAt' } as const, null)
37const transcriptA = atom({ plugin: 'context-vigil-mod', key: 'transcriptPath' } as const, null as string | null)
38// Information only (PROBES §11): what the session's cache is. The fire-time check stays the gate.
39const cacheTtlA = atom({ plugin: 'context-vigil-mod', key: 'cacheTtl' } as const, 'unknown' as CacheTtl)
40const ttlReadA = atom({ plugin: 'context-vigil-mod', key: 'ttlRead' } as const, false)
41const ttlInfoDismissedA = atom({ plugin: 'context-vigil-mod', key: 'ttlInfoDismissed' } as const, false)
42// Did the person's latest message mention a handover at all? Lets the model's tool call count as asked for.
43const handoverMentionedA = atom({ plugin: 'context-vigil-mod', key: 'handoverMentioned' } as const, false)
44const lastApiA = atom({ plugin: 'context-vigil-mod', key: 'lastApiAt' } as const, null)
45const awaitingA = atom({ plugin: 'context-vigil-mod', key: 'awaiting' } as const, null as Awaiting | null)
46const deferredA = atom({ plugin: 'context-vigil-mod', key: 'deferred' } as const, null as Awaiting | null)
47const handoverCountA = atom({ plugin: 'context-vigil-mod', key: 'handoverCount' } as const, 0)
48// The phone facts of `activity`, written through so a hot reload (which keeps $.state) restores
49// them; a clear wipes this, so the clear branch writes it again from the module copy.
50// The prompts held while the return question is open (R1-19, R2-11). In $.state so a hot reload
51// that kills the module (and its ask's closure) still knows what is owed; a clear wipes it.
52// R3-02: the held text also lives here, because a /clear or /resume wipes $.state while the ask stays open.
53let returnHeldMirror: string[] | null = null
54const returnHeldA = atom({ plugin: 'context-vigil-mod', key: 'returnHeld' } as const, null as string[] | null)
55const phoneA = atom({ plugin: 'context-vigil-mod', key: 'phoneFacts' } as const, null as PhoneFacts | null)
56
57// An account fact in $.store: the limit latch, shared by every session of the account.
58const LATCH_KEY = 'latch'
59
60// Facts about the person and the install that a /clear must not forget: module variables
61// survive it (same process). A new session or a hot reload (bindSession) starts them fresh,
62// counting that moment as the person being here for one idle window.
63let activity: Activity = EMPTY_ACTIVITY
64let lastLightArmed = false
65let standDown = false
66// Per process, never reset: each running session owes its own early stop before a hard limit,
67// and a clear (new session id) must not re-fire one for the same window. A reload may re-fire once.
68let firedEarlyStops: string[] = []
69// The limit resume waiting on its handover file; the tool call fills `path` when it is written.
70let limitResume: { path: string | null; at: number; stoppedAt: number; gen: number } | null = null
71let resumeChain: { cancel: () => void } | null = null
72let resumeGen = 0
73// The timer that lifts the account latch for this process: one, replaced never stacked (R2-04).
74let latchTimer: { cancel: () => void } | null = null
75// The RC hint is shown once per session: a clear wipes $.state, so it cannot live there.
76// A new session (bindSession) resets it; resetCaches leaves it alone.
77let rcHinted = false
78
79// Module caches: rebuilt at session.start / after a hot reload.
80let root: string | null = null // null: no config dir known, so nothing is written (configRoot)
81let session = 'unknown'
82let cwd = ''
83let settings: Settings = DEFAULTS
84let git: Git = { branch: null, dirty: [] }
85let gitTimer: { cancel: () => void } | null = null
86const edited = new Set<string>()
87let dayText: Record<string, string> = {}
88// lastApiAt, mirrored out of $.state: a /clear wipes the atom, and the clear branch must still
89// know whether turns have run since a parked handover was written (R1-10).
90let lastApiMirror: number | null = null
91let clearParked = false
92let unattendedClear = false
93let lastWait: WaitReason | null = null
94let retryTimer: { cancel: () => void } | null = null
95// A /clear handed to the engine and not yet run: it is queued until the session is idle (R2-10),
96// so a second one must not follow it. Cleared when the command settles.
97let clearInFlight = false
98let setupRun: { only: string | undefined; asked: StepId[] } | null = null
99let lastLightTimer: { cancel: () => void } | null = null
100let countdownTick: { cancel: () => void } | null = null
101
102// A timer-driven job that threw: logged, never an unhandled rejection (R2-15).
103async function timerFailed($: EngineInterface, job: string, err: unknown) {
104 await log($, 'guard.wait', { reason: 'timer-error', job, error: String(err) }).catch(() => {})
105}
106
107async function nowMs($: EngineInterface): Promise<number> {
108 return $.clock.now()
109}
110
111// Appends are serialised through one chain, so a day file's first read-then-append cannot be raced
112// by a second hook (R1-15). A day file that exists but cannot be read is never overwritten.
113let logChain: Promise<void> = Promise.resolve()
114const logWriteFailed = new Set<string>()
115
116async function appendToDayFile($: EngineInterface, path: string, rec: EventRecord) {
117 if (dayText[path] === undefined) {
118 try {
119 dayText[path] = String(await $.fs.read(path))
120 } catch (err) {
121 if (!/ENOENT|no such file|not found/i.test(String((err as { code?: string; message?: string })?.code ?? '') + String((err as Error)?.message ?? err))) {
122 try { await $.ui.log(`context-vigil-mod: event log skipped, ${path} is unreadable: ${String(err)}`) } catch { /* nowhere left to say it */ }
123 return
124 }
125 dayText[path] = ''
126 }
127 }
128 dayText[path] = appendLine(dayText[path] ?? '', rec)
129 await $.fs.write(path, dayText[path] ?? '').catch(async (err: unknown) => {
130 // A log that cannot be written must not be silent, and must not become a loop: say it once per path.
131 if (logWriteFailed.has(path)) return
132 logWriteFailed.add(path)
133 try { await $.ui.log(`context-vigil-mod: event log not written, ${path}: ${String(err)}`) } catch { /* nowhere left to say it */ }
134 })
135}
136
137async function log($: EngineInterface, kind: EventKind, fields: Record<string, unknown> = {}) {
138 if (!root) return
139 const now = await nowMs($)
140 const path = eventsPath(root, dayKey(now), session)
141 const rec = makeRecord(now, session, kind, fields)
142 const turn = logChain.then(() => appendToDayFile($, path, rec))
143 logChain = turn.catch(() => {})
144 await turn
145}
146
147// PROBES.md §1 decides the channel; both are sent until it says otherwise.
148async function notify($: EngineInterface, text: string) {
149 await Promise.all([$.ui.toast(text), $.ui.log(text)]).catch(() => {})
150}
151
152// Every plugin prompt goes through here: from a timer, never awaited by the hook the turn waits on.
153function submitSoon($: EngineInterface, prompt: { text: string; asUser?: true }, delayMs = 0, onSent?: () => void, onFailed?: () => void) {
154 $.clock.after(delayMs, () => { void $.prompt.submit(prompt).then(() => onSent?.(), () => onFailed?.()) })
155}
156
157// A prompt sent right after a clear can be refused while the session is still starting (slow SessionStart
158// hooks). Retry with backoff -- the first attempt after `firstDelayMs`, then 1, 2, 4 and 8 s later -- and
159// give up only after the last. A success ends the chain (never two submits); a human prompt or a newer
160// chain cancels it: the person's own words win. The engine offers no readiness signal, hence the backoff.
161const RETRY_AFTER_MS = [1000, 2000, 4000, 8000]
162let submitGen = 0 // bumped by a human prompt and by every new chain
163
164function sleepMs($: EngineInterface, ms: number): Promise<void> {
165 return new Promise(resolve => { $.clock.after(ms, () => resolve()) })
166}
167
168async function submitWithRetry($: EngineInterface, prompt: { text: string; asUser?: true }, firstDelayMs: number): Promise<'sent' | 'cancelled' | 'failed'> {
169 const gen = ++submitGen
170 const delays = [firstDelayMs, ...RETRY_AFTER_MS]
171 for (let attempt = 1; attempt <= delays.length; attempt++) {
172 await sleepMs($, delays[attempt - 1]!)
173 if (gen !== submitGen) {
174 await log($, 'guard.wait', { reason: 'resume-cancelled', attempt })
175 return 'cancelled'
176 }
177 try {
178 await $.prompt.submit(prompt)
179 } catch (err) {
180 await log($, 'guard.wait', { reason: 'resume-retry', attempt, error: String(err) })
181 continue
182 }
183 await log($, 'resume.sent', { attempt })
184 return 'sent'
185 }
186 return 'failed'
187}
188
189function resumeSoon($: EngineInterface, prompt: { text: string; asUser?: true }, path: string | null, held: string | null) {
190 void submitWithRetry($, prompt, 500)
191 .then(outcome => (outcome === 'failed' ? resumeFailed($, path, held) : undefined))
192 .catch(err => timerFailed($, 'resume-submit', err))
193}
194
195// A resume or held-prompt submit that is refused: say so, with the handover and the held text.
196async function resumeFailed($: EngineInterface, path: string | null, held: string | null) {
197 await notify($, V.resumeFailed(path, held))
198 await log($, 'guard.wait', { reason: 'resume-rejected', path, held: held !== null })
199}
200
201// PROBES.md §7: a mod never sees its own submit in prompt.submit, so `started` is marked here.
202function submitInstruction($: EngineInterface, reason: PendingReason) {
203 submitSoon($, { text: instructionText(reason) }, 0, () => {
204 void update($, awaitingA, a => (a ? { ...a, started: true } : a))
205 }, () => {
206 // A rejected submit must not leave the handover waiting for a turn that never comes.
207 void (async () => {
208 await update($, awaitingA, () => null)
209 await notify($, V.handoverFailed)
210 await log($, 'guard.wait', { reason: 'submit-rejected' })
211 })()
212 })
213}
214
215async function observe($: EngineInterface, signal: Signal) {
216 const now = await nowMs($)
217 // Every change is computed from the value it replaces, so overlapping observers cannot erase each other.
218 activity = record(activity, signal)
219 if (signal.kind === 'prompt' && classifyOrigin(signal.origin) === 'human') await savePhoneFacts($)
220 // R1-09: an explicit act from the person cancels a handover that waited on the latch.
221 if ((signal.kind === 'human-command' || (signal.kind === 'prompt' && classifyOrigin(signal.origin) === 'human')) && (await read($, deferredA))) {
222 const dropped = await read($, deferredA)
223 await update($, deferredA, () => null)
224 await notify($, V.deferredDropped)
225 await log($, 'guard.wait', { reason: 'deferred-dropped', deferred: dropped?.reason })
226 }
227 // R2-01: the same act turns a clear the latch parked into an offer; the handover stays pending.
228 if (clearParked && lastWait === 'latched' && (signal.kind === 'human-command' || (signal.kind === 'prompt' && classifyOrigin(signal.origin) === 'human'))) {
229 const parked = await read($, pendingA)
230 if (parked) await dropParkedClear($, parked, 'human')
231 }
232 // Spec §2: a non-empty draft in the terminal box is you being here.
233 if (signal.kind === 'agent-step' && (await $.prompt.read()).text.trim()) activity = record(activity, { kind: 'edit', at: now })
234 const act = activity // this observation's view: later awaits may move `activity` on
235 const next = mode(act, now, settings)
236 let prev: string | undefined
237 await update($, modeA, p => { prev = p; return next })
238 if (prev === undefined || prev === next) return
239 const t = transition(prev as Mode, next)
240 if (t && settings.auto) {
241 await log($, t, { from: prev, to: next, idleMs: act.lastHumanAt === null ? null : now - act.lastHumanAt, origin: act.lastHumanOrigin })
242 }
243 if (t === 'arm') await maybeHintRc($)
244}
245
246// A clear the latch parked is no longer going to run: it is offered instead (spec §5). Pending and
247// file stay; `clearParked` stays so the latch's lift leaves it alone. Never silent.
248async function dropParkedClear($: EngineInterface, pending: Pending, cause: 'human' | 'auto-off' | 'attended' | 'stale') {
249 lastWait = null
250 await notify($, V.pendingOffer(pending.path))
251 await log($, 'guard.wait', { reason: 'parked-dropped', cause })
252}
253
254// Setup asks through $.ui.ask, one step at a time: no prompt is submitted and nothing
255// reaches the model. The dialog does not pass through this mod's own tool.call hook
256// (PROBES.md §6), so answers are applied here, not there.
257async function startSetup($: EngineInterface, only?: string) {
258 if (only !== undefined && !isStep(only)) { await notify($, V.setupUsage); return }
259 const run = { only, asked: [] as StepId[] }
260 setupRun = run
261 if (!nextCard(settings, [], only).length) { setupRun = null; await notify($, V.setupSaved); return }
262 // After the command has replied: the dialogs follow it rather than holding it open.
263 $.clock.after(0, () => { void askSteps($, run).catch(err => timerFailed($, 'setup', err)) })
264}
265
266async function askSteps($: EngineInterface, run: NonNullable<typeof setupRun>) {
267 for (;;) {
268 // A clear can land inside any await (a store write included): never ask into the new session.
269 if (setupRun !== run) return
270 const id = nextCard(settings, run.asked, run.only)[0]
271 if (id === undefined) break
272 let explain = false
273 for (;;) {
274 const q = questionFor(id, explain)
275 const answer = await $.ui
276 .ask(q.question, { header: q.header, options: q.options, ...(q.multiSelect ? { multiSelect: true as const } : {}) })
277 .then(a => ({ a }), (err: unknown) => ({ err }))
278 // A clear or another /vigil-setup took over while the dialog was open: its answer is not ours.
279 if (setupRun !== run) return
280 // A dismissal and a failed dialog reject alike (no reason is typed): either way stop, log
281 // why, and say so, so a failure is never silent. What was answered is already saved.
282 if ('err' in answer) {
283 setupRun = null
284 await log($, 'setup', { steps: [id], stopped: String(answer.err).slice(0, 200) })
285 await notify($, V.setupStopped)
286 return
287 }
288 await observe($, { kind: 'human-command', at: await nowMs($) })
289 await reloadSettings($)
290 const applied = applyAnswers(settings, [{ step: id, answer: answer.a }])
291 if (setupRun !== run) return
292 await log($, 'setup', { steps: [id], retell: applied.retell })
293 // The log write is an await too: a newer /vigil-setup or a clear may have taken over during it.
294 if (setupRun !== run) return
295 if (applied.retell.length) { explain = true; continue }
296 settings = applied.settings
297 await $.store.set(STORE_KEY, settings)
298 if (id === 'rc') await log($, 'rc.answer', { answer: settings.rcAutoClear })
299 break
300 }
301 run.asked.push(id)
302 }
303 if (setupRun !== run) return
304 setupRun = null
305 await notify($, V.setupSaved)
306 // The person who just answered is here: a clear parked while they were away is offered, never run.
307 const parked = clearParked ? await read($, pendingA) : null
308 if (parked) await notify($, V.pendingOffer(parked.path))
309}
310
311// Never a dialog mid-run (WF-262): the question lives in /vigil-setup. Unanswered follows auto mode
312// (clears on the phone, safeguards kept); this says so once per session, and only when the stand-down and the latch leave the mod free to speak.
313async function maybeHintRc($: EngineInterface) {
314 if (!needsRcHint(onPhone(activity), settings.rcAutoClear, settings.auto)) return
315 if (rcHinted || standDown || (await readLatch($))) return
316 rcHinted = true
317 await notify($, V.rcHint)
318 await log($, 'rc.answer', { hinted: true })
319}
320
321async function refreshGit($: EngineInterface) {
322 gitTimer = null
323 const run = (argv: readonly string[]) =>
324 $.process.run(argv, { cwd }).then(r => ({ exitCode: r.exitCode, stdout: r.stdout })).catch(() => ({ exitCode: 1, stdout: '' }))
325 const [b, s] = await Promise.all([run(GIT_ARGV.branch), run(GIT_ARGV.status)])
326 git = parseGit(b, s)
327}
328
329function scheduleGit($: EngineInterface) {
330 if (gitTimer) return
331 gitTimer = $.clock.after(COALESCE_MS, () => { void refreshGit($) })
332}
333
334// RESET: every module cache and timer (pre-flight F3), in one place — a hot reload, a reused
335// module or a /clear must start clean. Tasks 13–15 add their own module variables here.
336function resetCaches() {
337 gitTimer?.cancel()
338 gitTimer = null
339 git = { branch: null, dirty: [] }
340 edited.clear()
341 dayText = {}
342 retryTimer?.cancel()
343 retryTimer = null
344 clearParked = false
345 unattendedClear = false
346 lastWait = null
347 lastLightTimer?.cancel()
348 lastLightTimer = null
349 setupRun = null
350 overridesRun = null
351 ambiguityTold.clear()
352 countdownTick?.cancel()
353 countdownTick = null
354}
355
356async function savePhoneFacts($: EngineInterface) {
357 const { lastHumanOrigin, lastBridgeAt } = activity
358 await update($, phoneA, () => ({ lastHumanOrigin, lastBridgeAt }))
359}
360
361// overrides.json is the person's to edit: read whenever a threshold is decided, written only when
362// missing (the default override, so it is visible) or by /vigil-overrides. A fault drops only its override,
363// and is told once per distinct file text.
364let overrides: Override[] = DEFAULT_OVERRIDES
365let faultsToldFor: string | null = null
366const ambiguityTold = new Set<string>()
367
368async function loadOverrides($: EngineInterface): Promise<{ faults: string[] }> {
369 if (!root) { overrides = DEFAULT_OVERRIDES; return { faults: [] } }
370 const path = overridesPath(root)
371 const text = await $.fs.read(path).then(t => String(t), () => null)
372 if (text === null) {
373 overrides = DEFAULT_OVERRIDES
374 await $.fs.write(path, overridesJson(DEFAULT_OVERRIDES)).catch(() => {})
375 return { faults: [] }
376 }
377 const checked = checkOverrides(text)
378 // A file that does not parse names no override to drop: keep the last good read (the default
379 // override before any) rather than lose every override to one stray comma.
380 if (!checked.fileFault) overrides = checked.overrides
381 if (checked.faults.length && text !== faultsToldFor) {
382 faultsToldFor = text
383 await notify($, checked.fileFault
384 ? V.overridesFileFault(path, checked.faults[0] ?? '', ordered(overrides).map(o => ` ${formatOverride(o)}`).join('\n') || ' (no overrides)')
385 : V.overridesFaults(path, checked.faults.length, checked.faults.map(f => ` ${f}`).join('\n')))
386 }
387 return { faults: checked.faults }
388}
389
390async function saveOverrides($: EngineInterface, next: Override[]): Promise<boolean> {
391 if (!root) return false
392 const ok = await $.fs.write(overridesPath(root), overridesJson(next)).then(() => true, () => false)
393 if (ok) { overrides = next; await log($, 'setup', { overrides: next }) }
394 return ok
395}
396
397// Overrides 0.1.3 saved in the settings store move to overrides.json once; one already in the file
398// for the same key stands. The store field is dropped only after the file is written.
399async function migrateModelThresholds($: EngineInterface) {
400 const raw = await $.store.get(STORE_KEY)
401 if (!root || !raw || typeof raw !== 'object' || !('modelThresholds' in raw)) return
402 const { overrides: moved, dropped } = fromModelThresholds(raw)
403 const fresh = moved.filter(m => !overrides.some(o => sameKey(o, m)))
404 const kept = moved.filter(m => !fresh.includes(m))
405 if (fresh.length && !(await saveOverrides($, fresh.reduce(setOverride, overrides)))) return
406 const { modelThresholds: _gone, ...rest } = raw as Record<string, unknown>
407 await $.store.set(STORE_KEY, rest)
408 settings = loadSettings(rest)
409 if (moved.length || dropped.length) {
410 await notify($, V.overridesMigrated(overridesPath(root), fresh.map(o => ` ${formatOverride(o)}`).join('\n'), kept.map(o => formatKey(o)).join(', '), dropped.join(', ')))
411 }
412}
413
414async function thresholds($: EngineInterface): Promise<Resolved> {
415 return resolve({ nudgeAt: settings.nudgeAt, step: settings.step, lastLightAt: settings.lastLightAt }, overrides, await read($, modelA), await read($, windowA))
416}
417
418async function tellAmbiguity($: EngineInterface, r: Resolved) {
419 if (!r.ambiguous) return
420 const { model, window, fields } = r.ambiguous
421 const key = `${formatKey(model)}|${formatKey(window)}`
422 if (ambiguityTold.has(key)) return
423 ambiguityTold.add(key)
424 await notify($, V.overridesAmbiguous(formatKey(model), formatKey(window), fields.map(f => `${f} ${r.values[f]}%`).join(', '), formatKey({ ...model, ...window })))
425}
426
427async function overridesReport($: EngineInterface, faults: string[]): Promise<string> {
428 const here = await thresholds($)
429 const model = await read($, modelA)
430 const window = await read($, windowA)
431 const from = (k: 'nudgeAt' | 'step' | 'lastLightAt') => here.from[k] ? formatKey(here.from[k]!) : 'settings'
432 const ambiguous = here.ambiguous ? [`⚠️ ${formatKey(here.ambiguous.model)} and ${formatKey(here.ambiguous.window)} both set ${here.ambiguous.fields.join(', ')}; the window wins`] : []
433 return V.overridesList(
434 root ? overridesPath(root) : '',
435 ordered(overrides).map(o => ` ${formatOverride(o)}`).join('\n'),
436 `nudge ${settings.nudgeAt}%, step ${settings.step}%, last light ${settings.lastLightAt}%`,
437 `${model ?? 'model unknown'} · ${window === null ? 'window not measured yet' : formatWindow(window)}`,
438 `nudge ${here.values.nudgeAt}% (${from('nudgeAt')}), step ${here.values.step}% (${from('step')}), last light ${here.values.lastLightAt}% (${from('lastLightAt')})`,
439 [...ambiguous, ...faults.map(f => `⚠️ ${f} — ignored`)].join('\n'),
440 )
441}
442
443// /vigil-overrides add: an override for the session you are in, asked through $.ui.ask like setup.
444let overridesRun: object | null = null
445
446async function askOverrides($: EngineInterface, run: object) {
447 const ask = async (question: string, header: string, options: string[]) => {
448 const a = await $.ui.ask(question, { header, options }).then(x => String(x), () => null)
449 if (overridesRun !== run) return null
450 // An answer is the person here, as in setup: the idle window starts again.
451 if (a !== null) {
452 await observe($, { kind: 'human-command', at: await nowMs($) })
453 if (overridesRun !== run) return null
454 }
455 return a
456 }
457 const model = await read($, modelA)
458 const window = await read($, windowA)
459 const pattern = model ? patternFor(model) : null
460 const keys: { label: string; key: Pick<Override, 'model' | 'window'> }[] = []
461 if (pattern && window !== null) keys.push({ label: `${pattern} on ${formatWindow(window)}`, key: { model: pattern, window } })
462 if (window !== null) keys.push({ label: `Any model on ${formatWindow(window)}`, key: { window } })
463 if (pattern) keys.push({ label: `${pattern} on any window`, key: { model: pattern } })
464 for (const w of [1_000_000, 200_000]) if (keys.length < 2 && w !== window) keys.push({ label: `Any model on ${formatWindow(w)}`, key: { window: w } })
465 const which = await ask(ASK.key, '🔧 Covers', keys.map(k => k.label))
466 if (which === null) { await notify($, V.overridesStopped); return }
467 const key = keys.find(k => k.label === which)?.key ?? parseKey(which)
468 if (!key) { await notify($, V.overridesBadKey(which)); return }
469 const base = (await thresholds($)).values
470 const pct = (label: string | null, lo: number, hi: number): number | null | undefined => {
471 if (label === null) return undefined
472 if (label.startsWith('Inherit')) return null
473 const n = Number(label.replace(/%.*$/, '').trim())
474 return Number.isInteger(n) && n >= lo && n <= hi ? n : undefined
475 }
476 const nudgeAt = pct(await ask(ASK.nudge(formatKey(key)), '🎚️ Nudge at', [`Inherit (${base.nudgeAt}%)`, '25%', '35%', '50%']), 1, 100)
477 if (nudgeAt === undefined) { await notify($, V.overridesStopped); return }
478 const step = pct(await ask(ASK.step, '📏 Step', [`Inherit (${base.step}%)`, '5%', '10%']), 1, 50)
479 if (step === undefined) { await notify($, V.overridesStopped); return }
480 const lastLightAt = pct(await ask(ASK.lastLight, 'Last light', [`Inherit (${base.lastLightAt}%)`, '25%', '50%']), 1, 100)
481 if (lastLightAt === undefined) { await notify($, V.overridesStopped); return }
482 const override: Override = { ...key, ...(nudgeAt !== null ? { nudgeAt } : {}), ...(step !== null ? { step } : {}), ...(lastLightAt !== null ? { lastLightAt } : {}) }
483 if (nudgeAt === null && step === null && lastLightAt === null) { await notify($, V.overridesNothingSet); return }
484 const { faults } = await loadOverrides($)
485 if (overridesRun !== run) return
486 if (!(await saveOverrides($, setOverride(overrides, override)))) { await notify($, V.overridesUnwritable); return }
487 overridesRun = null
488 await notify($, await overridesReport($, faults))
489}
490
491async function bindSession($: EngineInterface) {
492 resetCaches()
493 // A reload keeps $.state: the phone facts come back so the RC gate still sees the phone.
494 activity = { ...EMPTY_ACTIVITY, ...(await read($, phoneA)), lastHumanAt: await nowMs($) }
495 lastLightArmed = false
496 standDown = false
497 rcHinted = false
498 root = configRoot({
499 CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
500 HOME: await $.env.get('HOME'),
501 USERPROFILE: await $.env.get('USERPROFILE'),
502 HOMEDRIVE: await $.env.get('HOMEDRIVE'),
503 HOMEPATH: await $.env.get('HOMEPATH'),
504 })
505 session = await $.session.id()
506 cwd = await $.session.cwd()
507 settings = loadSettings(await $.store.get(STORE_KEY))
508 await loadOverrides($)
509 await migrateModelThresholds($)
510 lastApiMirror = await read($, lastApiA)
511}
512
513// Settings are an account fact other sessions write too: re-read before a decision that matters
514// (R1-06). One cheap store read; loadSettings fills what is missing.
515async function reloadSettings($: EngineInterface) {
516 settings = loadSettings(await $.store.get(STORE_KEY))
517}
518
519async function checkInterlock($: EngineInterface) {
520 const at = root
521 const text = at ? await $.fs.read(`${at}/settings.json`).then(t => String(t)).catch(() => null) : null
522 const record = at ? await $.fs.exists(classicSessionPath(at, session)).catch(() => false) : false
523 const classic = classicHooksInstalled(text) || record
524 const was = standDown
525 standDown = classic
526 if (classic && !was) {
527 await notify($, V.classicActive)
528 await log($, 'standdown', { settingsHooks: classicHooksInstalled(text), sessionRecord: record })
529 }
530}
531
532async function readLatch($: EngineInterface): Promise<Latch> {
533 return ((await $.store.get(LATCH_KEY)) as Latch | undefined) ?? null
534}
535
536// Per-session state the new session must not inherit. After a clear the wipe already empties
537// these; reset anyway so nothing leans on it.
538async function resetSessionState($: EngineInterface) {
539 await update($, awaitingA, () => null)
540 await update($, deferredA, () => null)
541 await update($, lastNudgedA, () => null)
542 await update($, baselineA, () => null)
543 await update($, barShownA, () => false)
544 await update($, barDismissedA, () => false)
545 await update($, countdownA, () => null)
546 await update($, handoverCountA, () => 0)
547 await update($, cacheTtlA, () => 'unknown')
548 await update($, ttlReadA, () => false)
549 await update($, ttlInfoDismissedA, () => false)
550 await update($, returnHeldA, () => null)
551 // A resume or fork may be a different model and window: never keep the last session's until the
552 // next measure, or /vigil-overrides would list or add for the wrong session.
553 const model = await $.session.model().catch(() => null)
554 const window = (await $.session.usage().catch(() => null))?.context?.window ?? null
555 await update($, modelA, () => model)
556 await update($, windowA, () => window)
557}
558
559async function prunePending($: EngineInterface) {
560 const now = await nowMs($)
561 for (const key of await $.store.keys().catch(() => [] as string[])) {
562 if (!key.startsWith(PENDING_PREFIX)) continue
563 const p = (await $.store.get(key)) as Pending | null | undefined
564 if (!p || now - p.createdAt > PENDING_KEEP_MS) await $.store.delete(key)
565 }
566}
567
568async function savePending($: EngineInterface, p: Pending | null) {
569 const prev = await read($, pendingA)
570 await update($, pendingA, () => p)
571 if (p) await $.store.set(pendingKey(p.session), p)
572 else await $.store.delete(pendingKey(prev?.session ?? session))
573}
574
575// An instruction nobody answered for this long, with the agent idle, is lost (R2-03).
576const AWAITING_LOST_MS = 10 * 60_000
577
578// What startHandover did, so a caller can say it truthfully (R2-14).
579type Started = 'started' | 'reused' | 'covered' | 'standdown' | 'latched' | 'in-flight'
580
581async function startHandover($: EngineInterface, reason: PendingReason, resume: boolean, unattended: boolean = reason === 'threshold'): Promise<Started> {
582 if (standDown) return 'standdown'
583 const now = await nowMs($)
584 // R2-12: a fresh handover is already on disk (one asked for, waiting to clear): an early stop needs
585 // no second one — the limit resume names this file and the waiting clear is left alone.
586 if (reason === 'limit' && reusable(await read($, pendingA), await read($, lastApiA), now)) {
587 await log($, 'guard.wait', { reason: 'limit-covered' })
588 return 'covered'
589 }
590 if (await readLatch($)) {
591 // Spec §5: while latched the mod never submits; Task 14's checkLatch starts it later.
592 await update($, deferredA, () => ({ reason, resume, attempts: 1, started: false, unattended, since: now }))
593 await notify($, V.waiting('latched'))
594 await log($, 'guard.wait', { reason: 'latched', deferred: reason })
595 return 'latched'
596 }
597 // R1-11: one handover in flight at a time; a second instruction would produce a second tool call.
598 // Lost = old AND nothing is running (a queued instruction would have started), so one waiting
599 // behind a long turn is never re-sent; a deleted or altered instruction no longer blocks forever.
600 const inFlight = await read($, awaitingA)
601 if (inFlight) {
602 const idle = activity.lastAgentAt === null || now - activity.lastAgentAt >= WORKING_MS
603 if (now - inFlight.since < AWAITING_LOST_MS || !idle) {
604 await notify($, V.handoverInProgress)
605 await log($, 'guard.wait', { reason: 'handover-in-flight', asked: reason })
606 return 'in-flight'
607 }
608 await update($, awaitingA, () => null)
609 await notify($, V.handoverLost)
610 await log($, 'guard.wait', { reason: 'awaiting-expired', asked: reason })
611 }
612 const pending = await read($, pendingA)
613 if ((reason === 'threshold' || reason === 'request') && reusable(pending, await read($, lastApiA), now)) {
614 // R3-01: the person's /vho wants the resume; a volunteered pending saved resume:false.
615 if (pending && (pending.resume !== resume || (unattended && !pending.unattended))) await savePending($, { ...pending, resume, unattended: pending.unattended || unattended })
616 if (!clearInFlight) scheduleClear($, unattended)
617 return 'reused'
618 }
619 if (pending) await supersedePending($)
620 await update($, awaitingA, () => ({ reason, resume, attempts: 1, started: false, unattended, since: now }))
621 await log($, 'handover.requested', { reason, resume })
622 submitInstruction($, reason)
623 return 'started'
624}
625
626// A stale pending handover gives way to the fresh one: its clear stops waiting and a /clear
627// in between injects nothing old. The file stays on disk.
628async function supersedePending($: EngineInterface) {
629 retryTimer?.cancel()
630 retryTimer = null
631 clearParked = false
632 lastWait = null
633 await setCountdown($, null)
634 await savePending($, null)
635}
636
637// The band draws the seconds left from the clock, which never redraws it: tick while it runs.
638async function setCountdown($: EngineInterface, endsAt: number | null) {
639 await update($, countdownA, () => endsAt)
640 countdownTick?.cancel()
641 countdownTick = endsAt === null ? null : $.clock.every(1000, () => { $.ui.invalidate('ui.render') })
642}
643
644function scheduleClear($: EngineInterface, unattended: boolean) {
645 unattendedClear = unattended
646 clearParked = false
647 retryTimer?.cancel()
648 retryTimer = $.clock.after(0, () => { void tryClear($) })
649}
650
651// R2-15: a timer-driven attempt has nobody to throw to. A failure parks the handover as an offer,
652// with a notice, instead of leaving it pending with no retry.
653async function tryClear($: EngineInterface) {
654 try {
655 await tryClearInner($)
656 } catch (err) {
657 clearParked = true
658 lastWait = null
659 await notify($, V.clearRejected)
660 await log($, 'guard.wait', { reason: 'clear-error', error: String(err) }).catch(() => {})
661 }
662}
663
664async function tryClearInner($: EngineInterface) {
665 retryTimer = null
666 if (clearInFlight || !(await read($, pendingA))) return
667 await reloadSettings($)
668 await checkInterlock($) // spec §7: at session start AND before every clear (TEMPORARY)
669 const now = await nowMs($)
670 // An unattended clear is only for an unattended session with auto mode on: re-checked here, not
671 // just when it began (R1-06, R2-08). Every exit leaves the handover offered, with a notice.
672 const autoOff = unattendedClear && !settings.auto
673 if (autoOff || (unattendedClear && mode(activity, now, settings) === 'attended')) {
674 await setCountdown($, null)
675 lastWait = null
676 clearParked = true
677 await notify($, autoOff ? V.clearSkippedAutoOff : V.clearSkippedAttended)
678 await log($, 'clear.skipped', { reason: autoOff ? 'auto-off' : 'attended' })
679 return
680 }
681 const gate = clearGate({
682 now, draft: (await $.prompt.read()).text, onPhone: onPhone(activity), lastBridgeAt: activity.lastBridgeAt,
683 rcAutoClear: settings.rcAutoClear, latched: (await readLatch($)) !== null,
684 countdownEndsAt: await read($, countdownA), classicActive: standDown, unattended: unattendedClear,
685 })
686 if (gate.go) {
687 lastWait = null
688 await setCountdown($, null)
689 await log($, 'clear', { unattended: unattendedClear })
690 clearInFlight = true
691 try {
692 await $.command.run({ command: 'clear' })
693 } catch {
694 clearParked = true
695 await notify($, V.clearRejected)
696 await log($, 'guard.wait', { reason: 'clear-rejected' })
697 } finally {
698 clearInFlight = false
699 }
700 return
701 }
702 if (gate.reason !== lastWait) {
703 lastWait = gate.reason
704 await notify($, V.waiting(gate.reason))
705 await log($, 'guard.wait', { reason: gate.reason, recheckMs: gate.recheckMs })
706 }
707 if (gate.reason === 'countdown-start') await setCountdown($, now + (gate.recheckMs ?? 0))
708 if (gate.recheckMs === null) { clearParked = true; return }
709 retryTimer = $.clock.after(gate.recheckMs, () => { void tryClear($) })
710}
711
712async function setLatch($: EngineInterface, l: Latch) {
713 if (!l) return
714 const existing = await readLatch($)
715 if (!existing) {
716 await $.store.set(LATCH_KEY, l)
717 await log($, 'limit.latched', { window: l.kind, resetsAtMs: l.resetsAtMs })
718 await notify($, V.limitLatched(formatHHMM(l.resetsAtMs)))
719 } else if (latchTimer) return
720 // R2-04: a latch another session set is lifted by this process too, or an idle one waits forever.
721 const target = existing ?? l
722 latchTimer?.cancel()
723 latchTimer = $.clock.after(Math.max(0, target.resetsAtMs - (await nowMs($))) + 1000, () => { latchTimer = null; void checkLatch($, []).catch(err => timerFailed($, 'latch-lift', err)) })
724}
725
726// The latch is account-wide: another session may lift it (delete the key) and drain only its
727// own work, so an absent latch drains this session's deferred handover too.
728async function checkLatch($: EngineInterface, limits: RateLimit[]) {
729 const l = await readLatch($)
730 if (l) {
731 if (!latchCleared(l, await nowMs($), limits)) return
732 await $.store.delete(LATCH_KEY)
733 await log($, 'limit.cleared', { window: l.kind })
734 await notify($, V.limitCleared)
735 }
736 const deferred = await read($, deferredA)
737 if (deferred) {
738 await reloadSettings($)
739 await update($, deferredA, () => null)
740 // R1-09: hours later the person may be anywhere. A threshold or request runs only if auto mode
741 // is on and they are not attended, and then as an unattended handover (attended re-check, RC
742 // gate). A last light should not get here (shouldFire refuses under a latch); drop it quietly.
743 if (deferred.reason === 'limit') await startHandover($, deferred.reason, deferred.resume, false)
744 else if (deferred.reason !== 'last_light' && settings.auto && mode(activity, await nowMs($), settings) !== 'attended') {
745 await startHandover($, deferred.reason, deferred.resume, true)
746 } else {
747 if (deferred.reason !== 'last_light') await notify($, V.deferredDropped)
748 await log($, 'guard.wait', { reason: 'deferred-dropped', deferred: deferred.reason })
749 }
750 return
751 }
752 // Only a clear the latch parked: one parked for a cancelled countdown or a refusal stays put.
753 // R2-01: hours later it follows the deferred handover's rule — only if auto mode is on, the person
754 // is still away and no turn has run since the write, and then as an unattended clear. Else offered.
755 if (!clearParked || lastWait !== 'latched') return
756 const parked = await read($, pendingA)
757 if (!parked) return
758 await reloadSettings($)
759 const now = await nowMs($)
760 const away = mode(activity, now, settings) !== 'attended'
761 if (settings.auto && away && fresh(parked, await read($, lastApiA), now)) scheduleClear($, true)
762 else await dropParkedClear($, parked, !settings.auto ? 'auto-off' : !away ? 'attended' : 'stale')
763}
764
765// Waits in hops of at most an hour; never submits while latched (spec §5); drops the limit
766// handover once the resume is sent so a later /clear does not re-inject it. One chain per process
767// (R1-12): a second early stop moves the existing job's time out, it never starts a second chain.
768// Nothing is sent over a person who has come back since the stop, or over a draft: a notice names
769// the handover instead. The path lives on the job, so a clear that consumed the pending handover
770// in between still names the file.
771async function scheduleResume($: EngineInterface, at: number) {
772 const stoppedAt = await nowMs($)
773 resumeChain?.cancel()
774 limitResume = { path: limitResume?.path ?? null, at: Math.max(limitResume?.at ?? 0, at), stoppedAt: limitResume?.stoppedAt ?? stoppedAt, gen: ++resumeGen }
775 hopResume($, 0, limitResume)
776}
777
778function hopResume($: EngineInterface, delayMs: number, job: NonNullable<typeof limitResume>) {
779 resumeChain = $.clock.after(delayMs, async () => {
780 if (job.gen !== resumeGen) return
781 const wait = nextHop(await nowMs($), job.at)
782 if (wait > 0) { hopResume($, wait, job); return }
783 await checkLatch($, []) // R2-04: an expired latch nobody lifted is lifted here
784 if (await readLatch($)) { hopResume($, 60_000, job); return }
785 const pending = await read($, pendingA)
786 const path = job.path ?? pending?.path ?? null
787 const back = activity.lastHumanAt !== null && activity.lastHumanAt > job.stoppedAt
788 if (back || (await $.prompt.read()).text.trim()) {
789 await notify($, V.resumeSkipped(path))
790 await log($, 'guard.wait', { reason: 'resume-skipped', human: true })
791 limitResume = null
792 return
793 }
794 const outcome = await submitWithRetry($, { text: limitResumeText(path) }, 0)
795 if (outcome === 'cancelled') {
796 await notify($, V.resumeSkipped(path))
797 limitResume = null
798 return
799 }
800 if (outcome === 'failed') {
801 await notify($, V.resumeFailed(path, null))
802 await log($, 'guard.wait', { reason: 'submit-rejected' })
803 limitResume = null // R3-05: the chain has ended; the next early stop starts its own job
804 resumeChain = null
805 return
806 }
807 limitResume = null
808 if (pending?.reason === 'limit') await savePending($, null)
809 })
810}
811
812async function cancelCountdown($: EngineInterface) {
813 if ((await read($, countdownA)) === null) return
814 await setCountdown($, null)
815 retryTimer?.cancel()
816 retryTimer = null
817 clearParked = true
818 lastWait = null
819 await notify($, V.countdownCancelled)
820 await log($, 'guard.wait', { reason: 'countdown-cancelled' })
821}
822
823async function showNudge($: EngineInterface, pct: number) {
824 if (settings.bar && !onPhone(activity)) {
825 if (await read($, barDismissedA)) return // 0 hid it for this cycle: silent
826 await update($, barShownA, () => true)
827 await log($, 'bar', { action: 'shown', pct })
828 return
829 }
830 await notify($, V.nudge(pct))
831}
832
833async function barChoice($: EngineInterface, action: 'handover' | 'later' | 'dismiss') {
834 // R2-07: a pressed button is the person being here, same as the countdown's Cancel (R1-22).
835 await observe($, { kind: 'human-command', at: await nowMs($) })
836 await update($, barShownA, () => false)
837 if (action === 'dismiss') await update($, barDismissedA, () => true)
838 await log($, 'bar', { action })
839 if (action === 'handover') await startHandover($, 'request', true)
840}
841
842// Whether `sh` runs here (it does not on Windows); asked once per process.
843let shellRuns: boolean | undefined
844async function hasShell($: EngineInterface): Promise<boolean> {
845 if (shellRuns === undefined) shellRuns = await $.process.run(['sh', '-c', 'exit 0'], { timeoutMs: 5000 }).then(r => r.exitCode === 0, () => false)
846 return shellRuns
847}
848
849// Does the transcript hold a custom-title line? null = could not tell.
850async function sessionNamed($: EngineInterface, transcriptPath: string): Promise<boolean | null> {
851 try {
852 if (!(await hasShell($))) {
853 const text = await $.fs.read(transcriptPath).catch(() => undefined) // over the read cap or missing: cannot tell
854 return typeof text === 'string' ? text.includes('"type":"custom-title"') : null
855 }
856 const r = await $.process.run(['grep', '-c', '-F', '"type":"custom-title"', transcriptPath])
857 if (r.exitCode === 0) return Number.parseInt(r.stdout, 10) > 0
858 return r.exitCode === 1 ? false : null
859 } catch {
860 return null
861 }
862}
863
864// /clear carries an existing name into the new session, so only an unnamed one is renamed.
865// Never blocks the resume: a failed rename is a notice, not an error.
866async function renameSession($: EngineInterface, name: string | undefined, oldTranscript: string | undefined) {
867 if (!name?.trim()) return // an older stored handover may carry no name
868 const named = oldTranscript === undefined ? null : await sessionNamed($, oldTranscript)
869 if (named === true) return void (await log($, 'rename', { kept: true }))
870 if (named === null) return void (await log($, 'guard.wait', { reason: 'rename-unknown' }))
871 await log($, 'rename', { name })
872 try {
873 await $.command.run({ command: 'rename', args: name })
874 } catch {
875 await notify($, V.renameFailed)
876 await log($, 'guard.wait', { reason: 'rename-rejected' })
877 }
878}
879
880function scheduleLastLight($: EngineInterface, lastApiAt: number, now: number) {
881 lastLightTimer?.cancel()
882 lastLightTimer = null
883 if (!settings.lastLight) return
884 if (now >= lastApiAt + TTL_1H) return // no fire for a cache that is already cold
885 lastLightTimer = $.clock.after(Math.max(0, fireAt(lastApiAt) - now), () => { void maybeFireLastLight($).catch(err => timerFailed($, 'last-light', err)) })
886}
887
888// The one place the cache lifetime is asked (PROBES §11): scheduling assumed 1 hour, the latest
889// write in the transcript's tail says whether that held. Nothing found is no fire; no retry.
890async function readTtl($: EngineInterface): Promise<CacheTtl> {
891 let writes = null
892 try {
893 const path = (await read($, transcriptA)) ?? (root ? transcriptPathFor(root, await $.session.cwd(), await $.session.id()) : null)
894 if (!path) return ttlFromWrites(null)
895 if (await hasShell($)) {
896 const r = await $.process.run(['sh', '-c', TAIL_CMD, 'sh', path])
897 if (r.exitCode === 0) writes = parseWrites(r.stdout)
898 } else {
899 const text = await $.fs.read(path).catch(() => undefined)
900 if (typeof text === 'string') writes = parseWrites(cacheLinesFromText(text))
901 }
902 } catch { /* unknown */ }
903 return ttlFromWrites(writes)
904}
905
906async function cacheIsOneHour($: EngineInterface): Promise<boolean> {
907 const ttl = await readTtl($)
908 if (ttl !== '1h') await log($, 'last_light.skip', { reason: `ttl-${ttl}` })
909 return ttl === '1h'
910}
911
912async function setCacheTtl($: EngineInterface, to: CacheTtl, source: 'response' | 'switch') {
913 const from = await read($, cacheTtlA)
914 if (to === from) return
915 await update($, cacheTtlA, () => to)
916 if (to === '5m') await update($, ttlInfoDismissedA, () => false) // shown again until the next message
917 if (!settings.lastLight) return
918 if (to === '5m') await log($, 'last_light.off', { ttl: to, source })
919 else if (from === '5m' && to === '1h') {
920 await log($, 'last_light.on', { ttl: to, source })
921 await notify($, V.lastLightBackOn)
922 }
923}
924
925// The session's first response that wrote to the cache: one detached tail read, never again.
926async function learnSessionTtl($: EngineInterface) {
927 await setCacheTtl($, await readTtl($), 'response')
928}
929
930async function maybeFireLastLight($: EngineInterface) {
931 lastLightTimer = null
932 await reloadSettings($)
933 const now = await nowMs($)
934 await observe($, { kind: 'agent-step', at: activity.lastAgentAt ?? 0 }) // picks up a draft (spec §2)
935 // Last light runs on the cache's clock, not the idle window (§2 vs §4): you are idle when nothing
936 // has come from you since the agent's last API activity (R1-07). `lastHumanAt` moves on a prompt,
937 // a command, an edit and the draft pickup above.
938 const lastApiAt = await read($, lastApiA)
939 const youIdle = lastApiAt !== null && (activity.lastHumanAt === null || activity.lastHumanAt <= lastApiAt)
940 const agentIdle = activity.lastAgentAt === null || now - activity.lastAgentAt >= WORKING_MS
941 // The file may have been edited while everyone was idle: decide on what it says now.
942 await loadOverrides($)
943 const verdict = shouldFire({
944 enabled: settings.lastLight, youIdle, agentIdle,
945 contextPct: await read($, contextA), threshold: (await thresholds($)).values.lastLightAt,
946 pending: (await read($, pendingA)) !== null, latched: (await readLatch($)) !== null,
947 armed: lastLightArmed,
948 })
949 if (!verdict.fire) return
950 // R2-14: under stand-down nothing is read, fired or spent (spec §7, SMOKES #7).
951 if (standDown) { await log($, 'last_light.skip', { reason: 'standdown' }); return }
952 if (!(await cacheIsOneHour($))) return
953 const contextPct = await read($, contextA)
954 const outcome = await startHandover($, 'last_light', false)
955 if (outcome !== 'started') { await log($, 'last_light.skip', { reason: outcome }); return }
956 lastLightArmed = false
957 await log($, 'last_light.fired', { contextPct })
958}
959
960// One ask at a time: a message typed meanwhile joins the first instead of opening a second ask whose
961// answer would undo the first. Whichever answer acts first consumes the held texts, atomically, so a
962// dialog that outlives a reload can never send them twice (R2-11).
963async function askReturn($: EngineInterface) {
964 const choice = String(await $.ui.ask(V.lastLightAsk, [V.lastLightResume, V.lastLightCarryOn]).catch(() => V.lastLightCarryOn))
965 const taken: { v: string[] | null } = { v: null }
966 await update($, returnHeldA, h => { taken.v = h; returnHeldMirror = null; return null })
967 if (!taken.v?.length) { await log($, 'last_light.choice', { stale: true }); return } // already answered, or a clear took it
968 // Free text under "Other" is never a clear: carry on, with what was typed kept (intent: when in doubt, don't).
969 const typed = choice !== V.lastLightResume && choice !== V.lastLightCarryOn && choice.trim() ? [choice] : []
970 const held = [...taken.v, ...typed].join('\n\n')
971 const resume = choice === V.lastLightResume
972 await log($, 'last_light.choice', { choice: resume ? 'resume' : 'carry_on', ...(typed.length ? { typed: true } : {}) })
973 const pending = await read($, pendingA)
974 if (resume && pending) {
975 await savePending($, { ...pending, resume: false, followUp: held })
976 scheduleClear($, false)
977 return
978 }
979 await savePending($, null)
980 submitSoon($, { text: held, asUser: true }, 0, undefined, () => { void resumeFailed($, pending?.path ?? null, held) })
981}
982
983export const register: Register = on => {
984 on('session.start', async ($, e, next) => {
985 const r = await next(e)
986 await bindSession($)
987 await $.command.register({ name: COMMANDS.handover, description: V.cmdHandover })
988 await $.command.register({ name: COMMANDS.handoverShort, description: V.cmdHandoverShort })
989 await $.command.register({ name: COMMANDS.setup, description: V.cmdSetup })
990 await $.command.register({ name: COMMANDS.overrides, description: V.cmdOverrides })
991 await $.tool.register({ name: TOOL, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA as unknown as Record<string, unknown> })
992 await checkInterlock($)
993 await checkLatch($, []) // a latch another process left behind and never lifted
994 // One still live gets this process's lift timer: its setter may have exited (setLatch arms it for an existing latch).
995 await setLatch($, await readLatch($))
996 await prunePending($)
997 const stored = (await $.store.get(pendingKey(session))) as Pending | null | undefined
998 const live = await read($, pendingA)
999 if (stored && !live) {
1000 await update($, pendingA, () => stored)
1001 await notify($, V.pendingOffer(stored.path))
1002 } else if (live) {
1003 // A hot reload: $.state kept the pending handover but its clear's timers are gone. The
1004 // reload also forgot the phone, so only a clear the person asked for is picked back up;
1005 // an unattended one is offered, never run past an RC answer or a countdown it can't see.
1006 await setCountdown($, null)
1007 if (live.reason === 'request' && !live.unattended && reusable(live, await read($, lastApiA), await nowMs($))) scheduleClear($, false)
1008 else await notify($, V.pendingOffer(live.path))
1009 }
1010 // A reload counts as the person being here (bindSession), so this period's last light is skipped
1011 // when the timer fires (not-idle, R1-07 deferred half). The timer is still armed here so that
1012 // fix can make it live.
1013 const lastApi = await read($, lastApiA)
1014 if (lastApi !== null) {
1015 lastLightArmed = true
1016 scheduleLastLight($, lastApi, await nowMs($))
1017 }
1018 scheduleGit($)
1019 // R2-11: a reload while the return question was open: its dialog's closure is gone, ask again.
1020 returnHeldMirror = await read($, returnHeldA) // a real reload reset the module copy; $.state kept the text
1021 if ((await read($, returnHeldA))?.length && (await read($, pendingA))?.reason === 'last_light') $.clock.after(0, () => { void askReturn($) })
1022 return r
1023 })
1024
1025 on('classic.SessionStart', async ($, e, next) => {
1026 // A different conversation begins (a /clear, a /resume, a start): a resume still retrying belongs to the old one.
1027 if (e.source !== 'compact') submitGen++
1028 const r = await next(e)
1029 const gitDir = await $.process
1030 .run(GIT_DIR_ARGV, { cwd: await $.session.cwd() })
1031 .then(x => ({ exitCode: x.exitCode, stdout: x.stdout }))
1032 .catch(() => ({ exitCode: 1, stdout: '' }))
1033 const watch = watchPaths(gitDir)
1034 const out = watch.length ? { ...r, watchPaths: [...(r.watchPaths ?? []), ...watch] } : r
1035 if (e.transcript_path) await update($, transcriptA, () => e.transcript_path ?? null)
1036 if (e.source === 'resume' || e.source === 'fork') {
1037 // R1-08: session.start fires once per process, so an in-process /resume or fork is a new
1038 // session that only this event announces. Rebind it; whatever it parked is offered, never
1039 // the old session's.
1040 session = await $.session.id()
1041 // A resume is the person acting now: the old conversation's idle time must not read as away.
1042 activity = { ...EMPTY_ACTIVITY, ...(await read($, phoneA)), lastHumanAt: await nowMs($) }
1043 resetCaches()
1044 // R2-16: process-wide jobs belong to the conversation that is gone.
1045 if (resumeChain || limitResume) await log($, 'guard.wait', { reason: 'resume-dropped', cause: 'session-changed' })
1046 resumeChain?.cancel()
1047 resumeChain = null
1048 limitResume = null
1049 // R3-02: never submit into a different conversation; say what was not sent.
1050 const heldAway = returnHeldMirror
1051 returnHeldMirror = null
1052 if (heldAway?.length) {
1053 await notify($, V.heldNotSent(heldAway.join('\n\n')))
1054 await log($, 'last_light.choice', { choice: 'dropped', viaResume: true })
1055 }
1056 await checkInterlock($)
1057 scheduleGit($)
1058 await resetSessionState($)
1059 await update($, lastApiA, () => null)
1060 lastApiMirror = null
1061 const stored = ((await $.store.get(pendingKey(session))) as Pending | null | undefined) ?? null
1062 await update($, pendingA, () => stored)
1063 if (stored) await notify($, V.pendingOffer(stored.path))
1064 return out
1065 }
1066 if (e.source === 'compact') {
1067 // R1-13: the context just shrank; the nudge ladder and its bar start over, nothing else does.
1068 await update($, lastNudgedA, () => null)
1069 await update($, baselineA, () => null)
1070 await update($, barShownA, () => false)
1071 await update($, barDismissedA, () => false)
1072 return out
1073 }
1074 if (e.source !== 'clear') return out
1075 // PROBES §9: $.state is already wiped here, so the handover comes from $.store, keyed by
1076 // `session` — still the pre-clear id until it is rebound below.
1077 const pending = ((await $.store.get(pendingKey(session))) as Pending | null | undefined) ?? null
1078 if (pending) await $.store.delete(pendingKey(session))
1079 const heldOnClear = returnHeldMirror?.length ? returnHeldMirror.join('\n\n') : null // R3-02
1080 returnHeldMirror = null
1081 const apiBefore = lastApiMirror
1082 lastApiMirror = null // the new session has run no turn
1083 const before = session
1084 session = await $.session.id()
1085 // A slow start can leave the engine still answering the old id: this clear's events then land in the
1086 // old session's file. Say so rather than look like a log that went missing.
1087 if (session === before) {
1088 try { await $.ui.log(`context-vigil-mod: session id not yet rebound after the clear (still ${session}); its events go to that session's log`) } catch { /* nowhere left to say it */ }
1089 }
1090 resetCaches()
1091 scheduleGit($)
1092 await resetSessionState($)
1093 if (activity.lastHumanOrigin !== null) await savePhoneFacts($) // the wipe took them; a later reload needs them
1094 if (heldOnClear) await log($, 'last_light.choice', { choice: 'resume', viaClear: true })
1095 if (!pending) {
1096 if (heldOnClear) resumeSoon($, { text: heldOnClear, asUser: true }, null, heldOnClear)
1097 return out
1098 }
1099 const follow = [pending.followUp, heldOnClear].filter(Boolean).join('\n\n') || null
1100 // /rename starts from its own timer, before the resume submit, and never blocks it.
1101 const tp = e.transcript_path
1102 const oldTranscript = tp === undefined ? undefined : `${tp.slice(0, tp.lastIndexOf('/') + 1)}${pending.session}.jsonl`
1103 $.clock.after(0, () => { void renameSession($, pending.name, oldTranscript) })
1104 // The person's own held text is always sent. The mod's resume prompt needs a fresh handover
1105 // (no turn since it was written) and no latch (R1-10); otherwise a notice says why not.
1106 let stale = false
1107 let noResume = false
1108 if (follow) resumeSoon($, { text: follow, asUser: true }, pending.path, follow)
1109 else if (pending.resume) {
1110 if (!fresh(pending, apiBefore, await nowMs($))) {
1111 stale = true
1112 await notify($, V.resumeStale(pending.path))
1113 } else if (await readLatch($)) {
1114 await notify($, V.resumeLatched(pending.path))
1115 await log($, 'guard.wait', { reason: 'latched', deferred: 'resume' })
1116 } else resumeSoon($, { text: resumeText(pending.path) }, pending.path, null)
1117 } else {
1118 // R2-17: a handover that carries no automatic resume (a limit or last-light one) is still
1119 // injected on a manual /clear; never silently.
1120 await notify($, V.injectedNoResume(pending.path))
1121 noResume = true
1122 }
1123 await log($, 'resume', { path: pending.path, reason: pending.reason, followUp: follow !== null, ...(stale ? { stale: true } : {}), ...(noResume ? { noResume: true } : {}) })
1124 return { ...out, additionalContext: [...(out.additionalContext ?? []), injectText(pending.markdown)] }
1125 })
1126
1127 on('classic.StopFailure', async ($, e, next) => {
1128 const error = String((e as unknown as { error?: unknown }).error ?? '')
1129 const usage = await $.session.usage().catch(() => null)
1130 await setLatch($, latchFromStopFailure(error, usage?.rateLimits ?? [], await nowMs($)))
1131 return next(e)
1132 })
1133
1134 on('classic.FileChanged', async ($, e, next) => {
1135 scheduleGit($)
1136 return next(e)
1137 })
1138
1139 on('prompt.submit', async ($, e, next) => {
1140 if (classifyOrigin(e.origin.kind) === 'human') submitGen++ // the person's own prompt wins over a resume still retrying
1141 const now = await nowMs($)
1142 if (rearm(lastLightArmed, e.origin.kind)) lastLightArmed = true
1143 const pending = await read($, pendingA)
1144 const lastApi = await read($, lastApiA)
1145 // R2-05: no turn seen in this process (a restart, a /resume) = the cache clock is the last-light
1146 // turn's own, which ran at about the handover's creation; never 'unknown, so drop it'.
1147 if (holdOnReturn({ pendingIsLastLight: pending?.reason === 'last_light', origin: e.origin.kind, now, cacheExpiresAt: lastApi !== null ? lastApi + TTL_1H : pending?.reason === 'last_light' ? pending.createdAt + TTL_1H : null })) {
1148 await observe($, { kind: 'prompt', origin: e.origin.kind, at: now })
1149 let open = false
1150 let after: string[] = []
1151 await update($, returnHeldA, h => { open = h !== null; after = [...(h ?? []), e.text]; return after })
1152 returnHeldMirror = after
1153 if (!open) $.clock.after(0, () => { void askReturn($) })
1154 return { drop: V.heldForLastLight }
1155 }
1156 await observe($, { kind: 'prompt', origin: e.origin.kind, at: await nowMs($) })
1157 // R1-05: back while the cache is still warm, the last-light handover has no job left: drop it
1158 // (the file stays), or it blocks every later last light and is offered stale as "cheap".
1159 if (pending?.reason === 'last_light' && classifyOrigin(e.origin.kind) === 'human') {
1160 await savePending($, null)
1161 await log($, 'last_light.dropped', { path: pending.path })
1162 }
1163 // R1-20: the "last light is off" line goes away with the person's next message (no hotkey: spec §3).
1164 if (classifyOrigin(e.origin.kind) === 'human' && (await read($, cacheTtlA)) === '5m') await update($, ttlInfoDismissedA, () => true)
1165 if (e.origin.kind !== 'plugin') await cancelCountdown($)
1166 if (classifyOrigin(e.origin.kind) === 'human') {
1167 // A short plain request to hand over runs exactly what /vho runs, not whatever the model improvises.
1168 const request = !standDown && isHandoverRequest(e.text)
1169 await update($, handoverMentionedA, () => !request && mentionsHandover(e.text))
1170 if (request) {
1171 $.clock.after(0, () => { void startHandover($, 'request', true).catch(err => timerFailed($, 'handover-request', err)) })
1172 return { drop: V.handoverRequested }
1173 }
1174 }
1175 return next(e)
1176 })
1177
1178 on('command.run', async ($, e, next) => {
1179 if (classifyOrigin(e.origin.kind) === 'human') await observe($, { kind: 'human-command', at: await nowMs($) })
1180 return next(e)
1181 })
1182
1183 on('prompt.edit', async ($, e, next) => {
1184 await observe($, { kind: 'edit', at: await nowMs($) })
1185 return next(e)
1186 })
1187
1188 on('turn.start', async ($, e, next) => {
1189 scheduleGit($)
1190 // The instruction's own turn is the only one whose end counts as an attempt (R1-02).
1191 await update($, awaitingA, a => (a && a.turnId === undefined && e.text === instructionText(a.reason) ? { ...a, turnId: e.turnId } : a))
1192 return next(e)
1193 })
1194
1195 on('tool.call', async ($, e, next) => {
1196 const r = await next(e)
1197 const input = e as unknown as { tool: string; file_path?: unknown }
1198 await observe($, { kind: 'agent-step', at: await nowMs($) })
1199 if ((input.tool === 'Edit' || input.tool === 'Write') && typeof input.file_path === 'string') edited.add(input.file_path)
1200 if (touchesGit(input.tool)) scheduleGit($)core/overrides.ts 229 lines1import type { Override, OverrideValues } from '../types'
2
3// Threshold overrides keyed by model, window or both, in a hand-editable overrides.json.
4// Each value comes from the most specific matching override that sets it, field by field; the
5// account settings sit beneath them all.
6//
7// { model: 'opus5.5', window: 1M } > { model: 'opus', window: 1M } > { window: 1M }
8// > { model: 'opus5.5' } > { model: 'opus' } > settings
9
10export const VALUE_KEYS = ['nudgeAt', 'step', 'lastLightAt'] as const
11type ValueKey = typeof VALUE_KEYS[number]
12
13export const DEFAULT_OVERRIDES: Override[] = [{ window: 200_000, nudgeAt: 70 }]
14
15// 'claude-opus-5-5[1m]', 'Opus 5.5 (1M context)' and 'opus5.5' all lead with opus·5·5.
16export function modelTokens(model: string): string[] {
17 const t = model.toLowerCase().match(/[a-z]+|\d+/g) ?? []
18 return t[0] === 'claude' ? t.slice(1) : t
19}
20
21// A pattern is a family and at most a major.minor version: 'opus', 'opus5', 'opus5.5'.
22export function validPattern(pattern: string): boolean {
23 const t = modelTokens(pattern)
24 return t.length >= 1 && t.length <= 3 && /^[a-z]+$/.test(t[0]!) && t.slice(1).every(x => /^\d+$/.test(x))
25}
26
27// The pattern naming a model's version: 'claude-haiku-4-5-20251001' → 'haiku4.5'.
28export function patternFor(model: string): string | null {
29 const t = modelTokens(model)
30 if (!t[0] || !/^[a-z]+$/.test(t[0])) return null
31 const nums: string[] = []
32 for (const x of t.slice(1)) { if (!/^\d+$/.test(x) || x.length > 2 || nums.length === 2) break; nums.push(x) }
33 return t[0] + nums.join('.')
34}
35
36export function modelMatches(pattern: string, model: string): boolean {
37 const p = modelTokens(pattern)
38 const m = modelTokens(model)
39 return p.length > 0 && p.length <= m.length && p.every((x, i) => x === m[i])
40}
41
42// Both keys beat one; of one, window beats model; between models, the longer pattern wins.
43function specificity(r: Override): number {
44 const tier = r.model !== undefined && r.window !== undefined ? 3 : r.window !== undefined ? 2 : 1
45 return tier * 1_000 + (r.model === undefined ? 0 : modelTokens(r.model).length)
46}
47
48const matches = (r: Override, model: string | null, window: number | null): boolean =>
49 (r.model === undefined || (model !== null && modelMatches(r.model, model)))
50 && (r.window === undefined || r.window === window)
51
52export type Ambiguity = { model: Override; window: Override; fields: ValueKey[] }
53
54export type Resolved = {
55 values: OverrideValues
56 from: Partial<Record<ValueKey, Override>>
57 // A window-only and a model-only override both set a field and nothing with both keys settles it.
58 ambiguous: Ambiguity | null
59}
60
61export function resolve(base: OverrideValues, overrides: Override[], model: string | null, window: number | null): Resolved {
62 const hits = ordered(overrides.filter(r => matches(r, model, window)))
63 const values: OverrideValues = { ...base }
64 const from: Partial<Record<ValueKey, Override>> = {}
65 for (const k of VALUE_KEYS) {
66 const r = hits.find(h => h[k] !== undefined)
67 if (r) { values[k] = r[k]!; from[k] = r }
68 }
69 const byWindow = hits.find(h => h.model === undefined)
70 const modelOnly = hits.filter(h => h.window === undefined)
71 const fields = byWindow ? VALUE_KEYS.filter(k => from[k] === byWindow && modelOnly.some(h => h[k] !== undefined)) : []
72 const byModel = modelOnly.find(h => fields.some(k => h[k] !== undefined))
73 return { values, from, ambiguous: byWindow && byModel ? { model: byModel, window: byWindow, fields } : null }
74}
75
76export function ordered(overrides: Override[]): Override[] {
77 return [...overrides].sort((a, b) => specificity(b) - specificity(a))
78}
79
80const keyOf = (r: Pick<Override, 'model' | 'window'>): string =>
81 `${r.model === undefined ? '*' : modelTokens(r.model).join('.')}@${r.window ?? '*'}`
82
83export const sameKey = (a: Pick<Override, 'model' | 'window'>, b: Pick<Override, 'model' | 'window'>): boolean => keyOf(a) === keyOf(b)
84
85// Adding an override replaces the one with the same key.
86export function setOverride(overrides: Override[], override: Override): Override[] {
87 return [...overrides.filter(r => !sameKey(r, override)), override]
88}
89
90export function removeOverride(overrides: Override[], key: Pick<Override, 'model' | 'window'>): { overrides: Override[]; removed: boolean } {
91 const next = overrides.filter(r => !sameKey(r, key))
92 return { overrides: next, removed: next.length !== overrides.length }
93}
94
95// '1m', '1M', '200k', '1000000' → tokens.
96export function parseWindow(s: string): number | null {
97 const m = /^(\d+(?:\.\d+)?)([km]?)$/i.exec(s.trim())
98 if (!m) return null
99 const unit = m[2]?.toLowerCase()
100 const scaled = Number(m[1]) * (unit === 'k' ? 1_000 : unit === 'm' ? 1_000_000 : 1)
101 // Only float noise is rounded (1.1m); a count that is not whole (1.4, 1.0005k) is refused, so
102 // `rm window=1.4` can never take out window=1.
103 const n = Math.round(scaled)
104 return Math.abs(scaled - n) < 1e-6 && n > 0 ? n : null
105}
106
107export function formatWindow(n: number): string {
108 return n % 1_000_000 === 0 ? `${n / 1_000_000}M` : n % 1_000 === 0 ? `${n / 1_000}k` : `${n}`
109}
110
111export function formatKey(r: Pick<Override, 'model' | 'window'>): string {
112 return [r.model !== undefined ? `model=${r.model}` : '', r.window !== undefined ? `window=${formatWindow(r.window)}` : ''].filter(Boolean).join(' ')
113}
114
115const LABEL: Record<ValueKey, string> = { nudgeAt: 'nudge', step: 'step', lastLightAt: 'last light' }
116
117export function formatOverride(r: Override): string {
118 return `${formatKey(r)} → ${VALUE_KEYS.filter(k => r[k] !== undefined).map(k => `${LABEL[k]} ${r[k]}%`).join(', ')}`
119}
120
121// overrides.json: { "overrides": [ ... ] }. A fault drops only the override it is in; each is named for the person.
122// A file that cannot be read as a list at all (`fileFault`) names no override: the caller keeps what it had.
123export type Checked = { overrides: Override[]; faults: string[]; fileFault?: true }
124
125const KEYS = new Set<string>(['model', 'window', ...VALUE_KEYS])
126const isPct = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 100
127
128export function checkOverrides(text: string): Checked {
129 let raw: unknown
130 try { raw = JSON.parse(text) } catch (err) { return { overrides: [], faults: [`not valid JSON (${String((err as Error).message).slice(0, 80)})`], fileFault: true } }
131 const list = (raw as { overrides?: unknown } | null)?.overrides
132 if (!raw || typeof raw !== 'object' || !Array.isArray(list)) return { overrides: [], faults: ['expected { "overrides": [ … ] }'], fileFault: true }
133 const overrides: Override[] = []
134 const faults: string[] = []
135 list.forEach((v, i) => {
136 const at = `override ${i + 1}`
137 if (!v || typeof v !== 'object' || Array.isArray(v)) { faults.push(`${at}: not an object`); return }
138 const o = v as Record<string, unknown>
139 const unknown = Object.keys(o).filter(k => !KEYS.has(k))
140 if (unknown.length) { faults.push(`${at}: unknown key ${unknown.map(k => `"${k}"`).join(', ')} (allowed: ${[...KEYS].join(', ')})`); return }
141 if (o.model !== undefined && (typeof o.model !== 'string' || !validPattern(o.model))) { faults.push(`${at}: model must be a family and optional version, like "opus" or "opus5.5"`); return }
142 if (o.window !== undefined && !(typeof o.window === 'number' && Number.isInteger(o.window) && o.window > 0)) { faults.push(`${at}: window must be a whole number of tokens, like 1000000`); return }
143 if (o.model === undefined && o.window === undefined) { faults.push(`${at}: needs a model, a window or both`); return }
144 for (const k of ['nudgeAt', 'lastLightAt'] as const) if (o[k] !== undefined && !isPct(o[k])) { faults.push(`${at}: ${k} must be a whole % from 1 to 100`); return }
145 if (o.step !== undefined && !(typeof o.step === 'number' && Number.isInteger(o.step) && o.step >= 1 && o.step <= 50)) { faults.push(`${at}: step must be a whole % from 1 to 50`); return }
146 if (!VALUE_KEYS.some(k => o[k] !== undefined)) { faults.push(`${at}: sets nothing (give it ${VALUE_KEYS.join(', ')} or any of them)`); return }
147 const r = o as Override
148 const dup = overrides.findIndex(x => sameKey(x, r))
149 if (dup !== -1) { faults.push(`${at}: same key as an earlier override (${formatKey(r)}); the earlier one stands`); return }
150 overrides.push(r)
151 })
152 return { overrides, faults }
153}
154
155export function overridesJson(overrides: Override[]): string {
156 return `${JSON.stringify({ overrides }, null, 2)}\n`
157}
158
159// 0.1.3 kept per-model overrides in the settings store (`modelThresholds`, substring patterns such
160// as `opus` or `[1m]`). They move into overrides.json once: `[1m]` becomes window=1M, the rest a
161// model pattern. One that does not fit this format is named in `dropped`, with why. Entries go in
162// 0.1.3's own order (longest pattern first, then by name), so when two collapse to one key
163// (`claude-opus`, `opus`) the one that used to win is the one kept.
164export function fromModelThresholds(raw: unknown): { overrides: Override[]; dropped: string[] } {
165 const table = (raw as { modelThresholds?: unknown } | null)?.modelThresholds
166 const out: Override[] = []
167 const dropped: string[] = []
168 if (!table || typeof table !== 'object' || Array.isArray(table)) return { overrides: out, dropped }
169 const entries = Object.entries(table as Record<string, unknown>)
170 .sort(([a], [b]) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0))
171 for (const [key, v] of entries) {
172 const o = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>
173 const lower = key.trim().toLowerCase()
174 const window = lower.includes('[1m]') ? 1_000_000 : undefined
175 const rest = lower.replace('[1m]', '').replace(/^[-\s]+|[-\s]+$/g, '')
176 const tokens = modelTokens(rest)
177 const model = rest ? (validPattern(rest) ? tokens[0]! + tokens.slice(1).join('.') : null) : undefined
178 const values = { ...(isPct(o.nudgeAt) ? { nudgeAt: o.nudgeAt } : {}), ...(isPct(o.lastLightAt) ? { lastLightAt: o.lastLightAt } : {}) }
179 if (model === null || (model === undefined && window === undefined)) { dropped.push(`${key} (no equivalent)`); continue }
180 if (!Object.keys(values).length) { dropped.push(`${key} (sets nothing)`); continue }
181 const r: Override = { ...(model !== undefined ? { model } : {}), ...(window !== undefined ? { window } : {}), ...values }
182 const winner = out.find(x => sameKey(x, r))
183 if (winner) { dropped.push(`${key} (${formatKey(winner)} from a longer pattern won)`); continue }
184 out.push(r)
185 }
186 return { overrides: out, dropped }
187}
188
189// /vigil-overrides · /vigil-overrides add · /vigil-overrides rm [model=<m>] [window=<w>]
190export type OverridesCommand =
191 | { op: 'list' }
192 | { op: 'add' }
193 | { op: 'rm'; key: Pick<Override, 'model' | 'window'> }
194 | { op: 'error' }
195
196export function parseOverridesArgs(args: string): OverridesCommand {
197 const words = args.trim().split(/\s+/).filter(Boolean)
198 if (words.length === 0 || (words.length === 1 && (words[0] === 'list' || words[0] === 'check'))) return { op: 'list' }
199 if (words.length === 1 && words[0] === 'add') return { op: 'add' }
200 if (words[0] !== 'rm') return { op: 'error' }
201 const key = parseKey(words.slice(1).join(' '))
202 return key ? { op: 'rm', key } : { op: 'error' }
203}
204
205// 'model=opus5.5 window=1m' (either or both) → an override key; null when it names neither or is malformed.
206export function parseKey(text: string): Pick<Override, 'model' | 'window'> | null {
207 let model: string | undefined
208 let window: number | undefined
209 for (const w of text.trim().split(/\s+/).filter(Boolean)) {
210 const eq = w.indexOf('=')
211 const k = eq > 0 ? w.slice(0, eq).toLowerCase() : ''
212 const v = w.slice(eq + 1)
213 if (k === 'model' && model === undefined && validPattern(v)) { model = v; continue }
214 const n = k === 'window' && window === undefined ? parseWindow(v) : null
215 if (n !== null) { window = n; continue }
216 return null
217 }
218 if (model === undefined && window === undefined) return null
219 return { ...(model !== undefined ? { model } : {}), ...(window !== undefined ? { window } : {}) }
220}
221
222// The /vigil-overrides add dialog: questions, like setup's, are not notices and carry no emoji.
223export const ASK = {
224 key: 'Which sessions should this override cover? (Other: model=… window=…, either or both)',
225 nudge: (key: string) => `For ${key}: at what % of context should I suggest a handover?`,
226 step: 'Then nudge again every how many %?',
227 lastLight: 'Last light: only write its handover from what % of context?',
228}
229core/name.ts 31 lines1import { configRootOf, joinPath } from './home'
2import type { HomeEnv } from './home'
3
4export const NAME = 'context-vigil-mod'
5export const TOOL = 'vigil_handover'
6export const TOOL_FULL = `mcp__${NAME}__${TOOL}`
7export const COMMANDS = { handover: 'vigil-handover', handoverShort: 'vho', setup: 'vigil-setup', overrides: 'vigil-overrides' } as const
8
9// null when no home can be told (CLAUDE_CONFIG_DIR, HOME, USERPROFILE, HOMEDRIVE+HOMEPATH): a relative `.claude`
10// would land under the session's cwd, so callers write nothing.
11export function configRoot(env: HomeEnv): string | null {
12 return configRootOf(env)
13}
14
15export function handoverPath(root: string, session: string, n: number): string {
16 return joinPath(root, NAME, 'handovers', `${session}-${n}.md`)
17}
18
19export function overridesPath(root: string): string {
20 return joinPath(root, NAME, 'overrides.json')
21}
22
23export function eventsPath(root: string, day: string, session: string): string {
24 return joinPath(root, NAME, 'events', day, `${session}.jsonl`)
25}
26
27// TEMPORARY (interlock): where classic context-vigil keeps a session record.
28export function classicSessionPath(root: string, session: string): string {
29 return joinPath(root, 'context-vigil', 'sessions', `${session}.json`)
30}
31core/settings.ts 46 lines1import type { Settings, Window } from '../types'
2
3export const STORE_KEY = 'settings'
4
5// Per session: a second session on the account must never see or wipe this one's handover.
6export function pendingKey(session: string): string {
7 return `pending:${session}`
8}
9
10// A parked handover nobody came back for is not kept forever: $.store refuses writes past 4 MiB (R1-17).
11export const PENDING_KEEP_MS = 14 * 86_400_000
12export const PENDING_PREFIX = 'pending:'
13
14export const DEFAULTS: Settings = {
15 nudgeAt: 35, step: 5, bar: true, auto: false, idleMin: 30,
16 lastLight: false, lastLightAt: 25, limits: true, limitPct: 95,
17 limitWindows: ['seven_day', 'spend_limit'], rcAutoClear: 'unanswered',
18}
19
20const isPct = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 1 && v <= 100
21const inRange = (v: unknown, lo: number, hi: number): v is number =>
22 typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi
23
24export function loadSettings(raw: unknown): Settings {
25 const s: Settings = { ...DEFAULTS, limitWindows: [...DEFAULTS.limitWindows] }
26 if (!raw || typeof raw !== 'object') return s
27 const r = raw as Record<string, unknown>
28 if (r.modelThresholds !== undefined) s.modelThresholds = r.modelThresholds
29 if (isPct(r.nudgeAt)) s.nudgeAt = r.nudgeAt
30 if (inRange(r.step, 1, 50)) s.step = r.step
31 if (typeof r.bar === 'boolean') s.bar = r.bar
32 if (typeof r.auto === 'boolean') s.auto = r.auto
33 if (inRange(r.idleMin, 1, 1440)) s.idleMin = r.idleMin
34 if (typeof r.lastLight === 'boolean') s.lastLight = r.lastLight
35 if (isPct(r.lastLightAt)) s.lastLightAt = r.lastLightAt
36 if (typeof r.limits === 'boolean') s.limits = r.limits
37 if (isPct(r.limitPct)) s.limitPct = r.limitPct
38 if (Array.isArray(r.limitWindows)) {
39 const ok = r.limitWindows.filter((w): w is Window => w === 'seven_day' || w === 'spend_limit')
40 // An empty list would watch nothing while limits reads On: keep the defaults instead.
41 if (ok.length) s.limitWindows = [...new Set(ok)]
42 }
43 if (r.rcAutoClear === 'yes' || r.rcAutoClear === 'no' || r.rcAutoClear === 'unanswered') s.rcAutoClear = r.rcAutoClear
44 return s
45}
46core/arming.ts 67 lines1import type { Activity, Mode, Settings, Who } from '../types'
2
3const HUMAN = new Set(['composer', 'bridge', 'slack-ping'])
4// Presence evidence that is not attestably the person's own message: a same-user channel the engine
5// cannot attest (R1-24), a message relayed by an MCP channel server, the engine's follow-up to a UI
6// action (R2-06). They disarm auto mode and nothing else.
7const DISARM_ONLY = new Set(['unclassified', 'channel', 'auto-continuation'])
8
9export function classifyOrigin(kind: string): Who {
10 if (HUMAN.has(kind)) return 'human'
11 if (kind === 'sdk') return 'headless'
12 return 'agent'
13}
14
15export const EMPTY_ACTIVITY: Activity = {
16 lastHumanAt: null, lastHumanOrigin: null, lastBridgeAt: null, lastAgentAt: null, headless: false,
17}
18
19export type Signal =
20 | { kind: 'prompt'; origin: string; at: number }
21 | { kind: 'human-command'; at: number }
22 | { kind: 'edit'; at: number }
23 | { kind: 'agent-step'; at: number }
24
25export function record(a: Activity, s: Signal): Activity {
26 switch (s.kind) {
27 case 'prompt': {
28 // An origin the engine cannot attest might be a person: it disarms auto mode and restarts the
29 // idle clock, and counts for nothing else (not a phone fact, not a return, not agent work). R1-24.
30 if (DISARM_ONLY.has(s.origin)) return { ...a, lastHumanAt: s.at }
31 const who = classifyOrigin(s.origin)
32 if (who === 'human') {
33 return { ...a, lastHumanAt: s.at, lastHumanOrigin: s.origin, lastBridgeAt: s.origin === 'bridge' ? s.at : a.lastBridgeAt }
34 }
35 return { ...a, lastAgentAt: s.at, headless: a.headless || who === 'headless' }
36 }
37 case 'human-command':
38 case 'edit':
39 return { ...a, lastHumanAt: s.at }
40 case 'agent-step':
41 return { ...a, lastAgentAt: s.at }
42 }
43}
44
45export const WORKING_MS = 120_000
46
47export function mode(a: Activity, now: number, s: Pick<Settings, 'idleMin'>): Mode {
48 const engaged = !a.headless && a.lastHumanAt !== null && now - a.lastHumanAt < s.idleMin * 60_000
49 if (engaged) return 'attended'
50 const working = a.lastAgentAt !== null && now - a.lastAgentAt < WORKING_MS
51 return working ? 'auto' : 'idle'
52}
53
54export function armed(a: Activity, now: number, s: Pick<Settings, 'idleMin' | 'auto'>): boolean {
55 return s.auto && mode(a, now, s) === 'auto'
56}
57
58export function transition(prev: Mode, next: Mode): 'arm' | 'disarm' | null {
59 if (next === 'auto' && prev !== 'auto') return 'arm'
60 if (prev === 'auto' && next !== 'auto') return 'disarm'
61 return null
62}
63
64export function onPhone(a: Activity): boolean {
65 return a.lastHumanOrigin === 'bridge'
66}
67core/eventlog.ts 15 lines1import type { EventKind, EventRecord } from '../types'
2
3export function dayKey(ms: number): string {
4 return new Date(ms).toISOString().slice(0, 10)
5}
6
7export function makeRecord(ms: number, session: string, kind: EventKind, fields: Record<string, unknown>): EventRecord {
8 return { ...fields, ts: new Date(ms).toISOString(), session, kind }
9}
10
11export function appendLine(existing: string, rec: EventRecord): string {
12 const base = existing === '' || existing.endsWith('\n') ? existing : `${existing}\n`
13 return `${base}${JSON.stringify(rec)}\n`
14}
15core/git.ts 36 lines1import type { Git } from '../types'
2import { isAbsolute } from './home'
3
4// Two questions only: `ahead` was dropped while the status-line band is parked (pre-flight F30).
5export const GIT_ARGV = {
6 branch: ['git', 'symbolic-ref', '--short', 'HEAD'],
7 status: ['git', 'status', '--porcelain'],
8} as const
9
10export const COALESCE_MS = 1500
11
12export type RunOut = { exitCode: number; stdout: string }
13
14export function parseGit(branch: RunOut, status: RunOut): Git {
15 const b = branch.exitCode === 0 ? branch.stdout.trim() || null : null
16 const dirty = status.exitCode === 0
17 ? status.stdout.split('\n').filter(l => l.length > 3 && !l.startsWith('??')).map(l => l.slice(3))
18 : []
19 return { branch: b, dirty }
20}
21
22const TOUCH = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
23export function touchesGit(tool: string): boolean {
24 return TOUCH.has(tool)
25}
26
27// The session's own git dir: in a worktree `<root>/.git` is a file, so HEAD and index live elsewhere.
28export const GIT_DIR_ARGV = ['git', 'rev-parse', '--absolute-git-dir'] as const
29
30/** What to watch for a `git rev-parse --absolute-git-dir` answer; nothing when it failed. */
31export function watchPaths(gitDir: RunOut): string[] {
32 const dir = gitDir.exitCode === 0 ? gitDir.stdout.trim() : ''
33 // git prints forward slashes even on Windows (`C:/repo/.git`)
34 return isAbsolute(dir) ? [`${dir}/HEAD`, `${dir}/index`] : []
35}
36core/handover.ts 154 lines1import type { Fields, Pending, PendingReason, Settings, Snapshot } from '../types'
2import { TOOL_FULL } from './name'
3
4export function nextThreshold(pct: number, s: Pick<Settings, 'nudgeAt' | 'step'>, lastNudged: number | null): number | null {
5 if (pct < s.nudgeAt) return null
6 const crossed = s.nudgeAt + Math.floor((pct - s.nudgeAt) / s.step) * s.step
7 if (lastNudged !== null && crossed <= lastNudged) return null
8 return crossed
9}
10
11// An unattended (auto-mode) handover waits until context has grown at least one step above the
12// session's baseline (its first reading), so a session that starts just under the threshold
13// cannot hand over, clear and hand over again within a turn. Attended nudges ignore this.
14export function grownEnough(pct: number, baseline: number | null, step: number): boolean {
15 return baseline !== null && pct - baseline >= step
16}
17
18// The turn that wrote a handover ends just after the tool call; a turn ending later than this
19// means the conversation moved on and the handover is stale.
20export const REUSE_SLACK_MS = 60_000
21
22// Is a handover still the latest word on the session? No turn has completed since it was written
23// (give or take the slack). A process that has seen no turn yet (a restart, a --resume) knows only
24// the clock: the handover must be young. One rule for reuse (R1-04) and for a manual /clear's
25// automatic resume (R1-10).
26export function fresh(p: Pick<Pending, 'createdAt'>, lastApiAt: number | null, now: number): boolean {
27 return (lastApiAt ?? now) <= p.createdAt + REUSE_SLACK_MS
28}
29
30// A pending handover is reused (cleared into) only when it was written for a clear and is fresh;
31// anything else gets a fresh handover.
32export function reusable(p: Pick<Pending, 'reason' | 'createdAt'> | null, lastApiAt: number | null, now: number): boolean {
33 if (!p || (p.reason !== 'threshold' && p.reason !== 'request')) return false
34 return fresh(p, lastApiAt, now)
35}
36
37export const FIELD_NAMES = ['goal', 'state', 'decisions', 'next_step', 'open_questions', 'failed_attempts', 'session_name'] as const
38const REQUIRED = ['goal', 'state', 'next_step', 'session_name'] as const
39
40const DESCRIBE: Record<(typeof FIELD_NAMES)[number], string> = {
41 goal: 'What this session is trying to achieve, in a sentence or two.',
42 state: 'Where the work stands right now: done, in flight, blocked.',
43 decisions: 'Decisions and rulings made, each with its reason.',
44 next_step: 'The very next concrete action on resume.',
45 open_questions: 'Questions still waiting on the person.',
46 failed_attempts: 'Approaches tried that did not work, and why.',
47 session_name: 'A short name for the session that resumes this work: 2–6 words saying what it will do next (e.g. "vigil-mod: shell handover flow"). It becomes the new session\'s name after the clear.',
48}
49
50export const INPUT_SCHEMA = {
51 type: 'object',
52 properties: Object.fromEntries(FIELD_NAMES.map(n => [n, { type: 'string', description: DESCRIBE[n] }])),
53 required: [...REQUIRED],
54} as const
55
56export const TOOL_DESCRIPTION =
57 'Save a handover for this session so work can resume after the context is cleared. ' +
58 'Call it when context-vigil-mod asks you to, or when the person asks you for a handover. Never write a handover any other way: no ad-hoc files or summaries. ' +
59 'Write for a fresh reader who knows nothing of this conversation.'
60
61export function cleanName(raw: string): string {
62 return raw.replace(/\s+/g, ' ').trim().replace(/^\/+/, '').slice(0, 60).trim()
63}
64
65export function parseFields(input: Record<string, unknown>): { ok: true; fields: Fields } | { ok: false; error: string } {
66 const out: Record<string, string> = {}
67 for (const name of FIELD_NAMES) {
68 const v = input[name]
69 if (v === undefined || v === null) { out[name] = ''; continue }
70 if (typeof v !== 'string') return { ok: false, error: `${name} must be a string` }
71 out[name] = name === 'session_name' ? cleanName(v) : v.trim()
72 }
73 for (const name of REQUIRED) if (!out[name]) return { ok: false, error: `${name} is required and must not be blank` }
74 return { ok: true, fields: out as Fields }
75}
76
77const TITLES: Record<(typeof FIELD_NAMES)[number], string> = {
78 goal: 'Goal', state: 'State', decisions: 'Decisions', next_step: 'Next step',
79 open_questions: 'Open questions', failed_attempts: 'Failed attempts', session_name: 'Session name',
80}
81
82export function renderHandover(f: Fields, s: Snapshot): string {
83 const parts = [`# 📜 Handover — ${s.session} (${s.at})`, `**Next session:** ${f.session_name}`, '']
84 for (const name of FIELD_NAMES) {
85 if (name === 'session_name' || !f[name]) continue
86 parts.push(`## ${TITLES[name]}`, '', f[name], '')
87 }
88 parts.push('## Snapshot', '',
89 `- cwd: ${s.cwd}`,
90 `- branch: ${s.branch ?? '(not a git repo)'}`,
91 `- context: ${s.contextPct === null ? 'unknown' : `${s.contextPct}%`}`,
92 `- dirty: ${s.dirty.length ? s.dirty.join(', ') : 'none'}`,
93 `- edited this session: ${s.edited.length ? s.edited.join(', ') : 'none'}`,
94 '')
95 return parts.join('\n')
96}
97
98const WHY: Record<PendingReason, string> = {
99 threshold: 'Context is past the handover threshold.',
100 request: 'The person asked for a handover.',
101 last_light: 'The session has gone idle and the prompt cache is about to go cold. Do not clear; just save the handover.',
102 limit: 'A usage limit is close. Save the handover so work can resume after the reset.',
103}
104
105export function instructionText(reason: PendingReason): string {
106 return `[context-vigil-mod] ${WHY[reason]} Call the ${TOOL_FULL} tool now with a complete handover ` +
107 '(goal, state, decisions, next step, open questions, failed attempts, session name). Do nothing else this turn.'
108}
109
110export function resumeText(path: string): string {
111 return `[context-vigil-mod] Resume from the handover injected above (saved at ${path}). Start with its next step.`
112}
113
114export function injectText(markdown: string): string {
115 return `[context-vigil-mod] Handover from before the clear:\n\n${markdown}`
116}
117
118// After a limit early stop there was no clear: the conversation is still here.
119export function limitResumeText(path: string | null): string {
120 if (path === null) {
121 return '[context-vigil-mod] The usage limit has reset. No handover was written before the stop (it was deferred or classic was active); pick up from the conversation as it stands.'
122 }
123 return `[context-vigil-mod] The usage limit has reset. Continue the work; the handover you wrote is saved at ${path} if you need it.`
124}
125
126// Any mention of a handover at all: the looser net behind the tool (a person who said "handover" and
127// then had the model call the tool meant it).
128export function mentionsHandover(text: string): boolean {
129 return /hand[ -]?over|hand(?:ing)?[ -]?off/i.test(text)
130}
131
132const REQUEST_MAX_WORDS = 12
133const REQUEST_CORE = /\bhand[ -]?over\b|\bhand(?:ing)?[ -]?off\b|\bhand\s+(?:this|it|that|things|everything|us|work|session|this session)\s+(?:over|off)\b|\bhand\s+this\s+session\s+(?:over|off)\b/
134const REQUEST_NEGATION = /\b(?:don'?t|do not|dont|never|no|not|stop|cancel|without|skip|instead|isn'?t|won'?t|can'?t)\b/
135// Questions about handovers, and talk about the mod's code, files and behaviour.
136const REQUEST_ABOUT = /\b(?:how|what|what'?s|whats|why|when|where|which|who|does|did|is|are|was|were|has|have|bug|bugs|fix|fixing|fixed|broken|broke|fail|fails|failed|failing|wrong|work|works|working|implement|code|file|files|test|tests|spec|docs?|document|explain|show|read|review|check|debug|issue|error|last|previous|latest|update|improve|refactor|think|tool|mod|hook|slow|empty|status|summary|details?|info|information|list|history|log|logs|count|size|path|location|contents?|diff|name)\b/
137const REQUEST_CONDITION = /\b(?:if|unless|after|before|once|whenever|until)\b/
138const REQUEST_LEAD = new Set([
139 'handover', 'handoff', 'hand', 'do', 'run', 'start', 'begin', 'make', 'write', 'create', 'give', 'trigger', 'initiate', 'time', "let's", 'lets', 'let',
140 'go', 'ok', 'okay', 'alright', 'right', 'so', 'now', 'please', 'pls', 'can', 'could', 'would', 'will', 'shall', 'i', 'we', "it's", 'its', 'ready',
141 'need', 'needs', 'want', 'yes', 'yep', 'yeah', 'sure', 'just', 'then', 'kindly', 'hey', 'hi',
142])
143
144/** Is this short human message a request to hand over now (not a question or talk about handovers)? */
145export function isHandoverRequest(text: string): boolean {
146 const clean = text.toLowerCase().replace(/[.!?,;:-]+/g, ' ').replace(/\s+/g, ' ').trim()
147 if (!clean || clean.startsWith('/')) return false
148 const words = clean.split(' ')
149 if (words.length > REQUEST_MAX_WORDS) return false
150 if (!REQUEST_CORE.test(clean)) return false
151 if (REQUEST_NEGATION.test(clean) || REQUEST_ABOUT.test(clean) || REQUEST_CONDITION.test(clean)) return false
152 return REQUEST_LEAD.has(words[0]!)
153}
154core/home.ts 76 lines1// Where "home" and the config dir are, on macOS, Linux and Windows. THE SAME FILE lives in census-mod, context-vigil-mod
2// and agent-roster (plugins share no code); tests/census/test_home_copies.py fails if the copies differ.
3//
4// Rule: the config dir is CLAUDE_CONFIG_DIR, else <home>/.claude; <home> is HOME, else USERPROFILE, else
5// HOMEDRIVE+HOMEPATH. `~`, `~/x` and `~\x` expand with the same home.
6
7export type HomeEnv = {
8 CLAUDE_CONFIG_DIR?: string
9 HOME?: string
10 USERPROFILE?: string
11 HOMEDRIVE?: string
12 HOMEPATH?: string
13}
14
15/** What was looked at, for messages: name the variables actually checked. */
16export const HOME_VARS_CHECKED = 'CLAUDE_CONFIG_DIR, HOME, USERPROFILE and HOMEDRIVE+HOMEPATH'
17
18const nonEmpty = (v: string | undefined): string | undefined => (v && v.trim() ? v : undefined)
19
20/** The home dir: HOME, else USERPROFILE, else HOMEDRIVE+HOMEPATH; null when none is set. */
21export function homeOf(env: HomeEnv): string | null {
22 const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE)
23 if (home) return home
24 const drive = nonEmpty(env.HOMEDRIVE)
25 const path = nonEmpty(env.HOMEPATH)
26 return drive && path ? `${drive}${path}` : null
27}
28
29/** The separator a base path uses: a backslash when it has one and no slash (`C:\Users\x`), else a slash. */
30export const sepOf = (p: string): '/' | '\\' => (p.includes('\\') && !p.includes('/') ? '\\' : '/')
31
32/** Whether a path is absolute on either platform: `/x`, `C:\x`, `C:/x`, `\\server\share`. */
33export const isAbsolute = (p: string): boolean => p.startsWith('/') || p.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(p)
34
35/**
36 * Trailing separators dropped (slash or backslash), the root kept: `/a/b//` -> `/a/b`, `/` -> `/`,
37 * `C:\Users\x\` -> `C:\Users\x`, `C:\` -> `C:\`.
38 */
39export function trimSeps(p: string): string {
40 if (/^[A-Za-z]:$/.test(p)) return p // `C:` is the drive-relative cwd of C:, not the root `C:\`
41 if (/^[A-Za-z]:[\\/]+$/.test(p)) return `${p.slice(0, 2)}${p.includes('/') ? '/' : '\\'}`
42 const t = p.replace(/[\\/]+$/, '')
43 return t || (/^[\\/]/.test(p) ? p[0] ?? '/' : p)
44}
45
46/** `base` + parts, joined with the separator the base uses, so `C:\Users\x` + `.claude` stays all backslashes. */
47export function joinPath(base: string, ...parts: string[]): string {
48 const sep = sepOf(base)
49 const root = trimSeps(base)
50 const tail = parts.map(p => p.replace(/^[\\/]+|[\\/]+$/g, '')).filter(Boolean).join(sep)
51 const joined = root.endsWith('/') || root.endsWith('\\') || /^[A-Za-z]:$/.test(root) ? `${root}${tail}` : `${root}${sep}${tail}`
52 return tail ? joined.replace(sep === '\\' ? /\//g : /\\/g, sep) : root
53}
54
55/** `~`, `~/x` or `~\x` with the home dir (in the home's own separator); anything else unchanged. */
56export function expandHome(p: string, env: HomeEnv): string {
57 const home = homeOf(env)
58 if (!home || !(p === '~' || p.startsWith('~/') || p.startsWith('~\\'))) return p
59 return p === '~' ? trimSeps(home) : joinPath(home, p.slice(2))
60}
61
62/** The config dir: CLAUDE_CONFIG_DIR, else <home>/.claude; null when neither can be told. */
63export function configRootOf(env: HomeEnv): string | null {
64 const set = nonEmpty(env.CLAUDE_CONFIG_DIR)
65 if (set) return trimSeps(set)
66 const home = homeOf(env)
67 return home ? joinPath(home, '.claude') : null
68}
69
70/** A path as a comparison key: one separator, no trailing one, lower-cased when it is a Windows (drive or UNC) path. */
71export function pathKey(p: string): string {
72 const flat = p.replace(/\\/g, '/')
73 const trimmed = flat.length > 1 ? flat.replace(/\/+$/, '') || '/' : flat
74 return /^[A-Za-z]:/.test(p) || p.startsWith('\\\\') ? trimmed.toLowerCase() : trimmed
75}
76core/cache-ttl.ts 63 lines1// Which prompt-cache lifetime the session's main conversation is writing — PROBES §11.
2// Asked once, when last light is about to act. 'unknown' (nothing found) never fires: warming
3// a cold 5-minute cache is the opposite of what last light is for.
4import { joinPath } from './home'
5
6export type CacheTtl = '1h' | '5m' | 'unknown'
7export type Writes = { h1: number; m5: number }
8
9// Reads the usage split off the end of the transcript without ever loading a whole row: grep -o
10// prints only each row's `cache_creation` object. Positional args: $1 = transcript path.
11export const TAIL_CMD = 'tail -c 65536 "$1" | grep -o \'"cache_creation":{[^}]*}\''
12
13/** The last `bytes` UTF-8 bytes of the text (as `tail -c` takes them), not the last UTF-16 units: a cut mid-character is dropped. */
14export function tailBytes(text: string, bytes: number): string {
15 let used = 0
16 let i = text.length
17 while (i > 0) {
18 const code = text.charCodeAt(i - 1)
19 const isLow = code >= 0xdc00 && code <= 0xdfff && i > 1
20 const size = isLow ? 4 : code < 0x80 ? 1 : code < 0x800 ? 2 : 3
21 if (used + size > bytes) break
22 used += size
23 i -= isLow ? 2 : 1
24 }
25
26 return text.slice(i)
27}
28
29/** What TAIL_CMD prints, from the file's text: for where there is no sh/tail/grep (Windows). */
30export function cacheLinesFromText(text: string, bytes = 65536): string {
31 return (tailBytes(text, bytes).match(/"cache_creation":\{[^}]*\}/g) ?? []).join('\n')
32}
33
34const field = (line: string, key: string): number => {
35 const m = new RegExp(`"ephemeral_${key}_input_tokens":(\\d+)`).exec(line)
36 return m ? Number.parseInt(m[1] ?? '0', 10) : 0
37}
38
39// The split of the LAST response that wrote to the cache. A pure cache read writes nothing and
40// says nothing about the lifetime, so it is skipped. null = no write in the tail.
41export function parseWrites(stdout: string): Writes | null {
42 const lines = stdout.split('\n')
43 for (let i = lines.length - 1; i >= 0; i--) {
44 const line = lines[i] ?? ''
45 if (!line.includes('"cache_creation"')) continue
46 const w = { h1: field(line, '1h'), m5: field(line, '5m') }
47 if (w.h1 > 0 || w.m5 > 0) return w
48 }
49 return null
50}
51
52// Any 5m write makes it 5m: content written for five minutes goes cold first, and the cost of
53// warming a cold cache is worse than the cost of skipping a warm one. No write found: unknown.
54export function ttlFromWrites(w: Writes | null): CacheTtl {
55 if (w === null) return 'unknown'
56 return w.m5 > 0 ? '5m' : '1h'
57}
58
59// Where Claude Code keeps a session's transcript, for when no event has carried the path yet.
60export function transcriptPathFor(configRoot: string, cwd: string, sessionId: string): string {
61 return joinPath(configRoot, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'), `${sessionId}.jsonl`)
62}
63core/last-light.ts 40 lines1import { classifyOrigin } from './arming'
2
3export const LEAD_MS = 300_000
4export const TTL_1H = 3_600_000
5
6// Scheduled on the assumption of a 1-hour cache; the fire checks the assumption (PROBES §11).
7export function fireAt(lastApiAt: number): number {
8 return lastApiAt + TTL_1H - LEAD_MS
9}
10
11export type FireFacts = {
12 enabled: boolean
13 youIdle: boolean // nothing from the person since the agent's last API activity
14 agentIdle: boolean // no step in the last WORKING_MS
15 contextPct: number | null
16 threshold: number
17 pending: boolean
18 latched: boolean
19 armed: boolean
20}
21
22export function shouldFire(f: FireFacts):
23 { fire: true } | { fire: false; reason: 'off' | 'not-idle' | 'small' | 'pending' | 'latched' | 'disarmed' } {
24 if (!f.enabled) return { fire: false, reason: 'off' }
25 if (!f.youIdle || !f.agentIdle) return { fire: false, reason: 'not-idle' }
26 if (f.contextPct === null || f.contextPct < f.threshold) return { fire: false, reason: 'small' }
27 if (f.pending) return { fire: false, reason: 'pending' }
28 if (f.latched) return { fire: false, reason: 'latched' }
29 if (!f.armed) return { fire: false, reason: 'disarmed' }
30 return { fire: true }
31}
32
33export function rearm(prev: boolean, origin: string): boolean {
34 return classifyOrigin(origin) === 'human' ? true : prev
35}
36
37export function holdOnReturn(f: { pendingIsLastLight: boolean; origin: string; now: number; cacheExpiresAt: number | null }): boolean {
38 return f.pendingIsLastLight && classifyOrigin(f.origin) === 'human' && f.cacheExpiresAt !== null && f.now >= f.cacheExpiresAt
39}
40core/surfaces.ts 42 lines1import type { RcAnswer } from '../types'
2import type { WaitReason } from './voice'
3
4export const COUNTDOWN_MS = 30_000
5export const HOLDBACK_MS = 120_000
6export const RECHECK_MS = 2_000
7
8export type GateFacts = {
9 now: number
10 draft: string
11 onPhone: boolean
12 lastBridgeAt: number | null
13 rcAutoClear: RcAnswer
14 latched: boolean
15 countdownEndsAt: number | null
16 classicActive: boolean
17 unattended: boolean
18}
19
20export type Gate = { go: true } | { go: false; reason: WaitReason; recheckMs: number | null }
21
22export function clearGate(f: GateFacts): Gate {
23 if (f.classicActive) return { go: false, reason: 'classic', recheckMs: null }
24 if (f.latched) return { go: false, reason: 'latched', recheckMs: null }
25 if (f.draft.trim()) return { go: false, reason: 'draft', recheckMs: RECHECK_MS }
26 if (f.onPhone && f.unattended) {
27 // Unanswered follows the terminal (auto is on, so allowed), safeguards included: the question is asked in setup, never mid-run.
28 if (f.rcAutoClear === 'no') return { go: false, reason: 'rc-declined', recheckMs: null }
29 if (f.lastBridgeAt !== null && f.now - f.lastBridgeAt < HOLDBACK_MS) {
30 return { go: false, reason: 'rc-holdback', recheckMs: HOLDBACK_MS - (f.now - f.lastBridgeAt) }
31 }
32 if (f.countdownEndsAt === null) return { go: false, reason: 'countdown-start', recheckMs: COUNTDOWN_MS }
33 if (f.now < f.countdownEndsAt) return { go: false, reason: 'countdown', recheckMs: f.countdownEndsAt - f.now }
34 }
35 return { go: true }
36}
37
38// The one-per-session hint (never a question) when auto mode arms on the phone with no answer yet.
39export function needsRcHint(onPhone: boolean, rc: RcAnswer, wouldArm: boolean): boolean {
40 return onPhone && wouldArm && rc === 'unanswered'
41}
42