flightwake's Claude Code layer: STATE in the system prompt, a quiet status band, a session flight log, trap tripwires, tokens and API-equivalent cost, a…

<!-- Primary edition (English). Translations: README.zh-TW.md / README.zh-CN.md / README.ja.md — editing any edition obligates syncing the others. -->
Records are the contrail your work naturally leaves behind — not a flight plan you must file before takeoff.
Explore the illustrated website for the four-language overview and a real Orca multi-agent case study. Website source.
An ultra-lightweight work-recording framework for strong AI coding agents (Claude Fable 5 generation and beyond). Zero runtime dependencies, pure Markdown, everything lives in git.

A real cold start on this very repo (recorded live, zh-TW install): one command, two file reads, and a fresh session reports exactly where the last one left off.
cd your-repo
npx flightwake setup # guided install: a few questions, shows every path it will write, installs after you confirm
setup needs a terminal. It checks git first (offering git init if the directory isn't a repo — default No, run only after the final confirmation), then asks language (preselected from your terminal locale — LC_ALL, then LC_MESSAGES, then LANG, the OS locale only when none of them is set; C / POSIX mean English — and shown with where it came from), agents (the detected ones if the folder already has CLAUDE.md / AGENTS.md / GEMINI.md; otherwise it asks which tools you use — pick one or more, nothing preselected), optional add-ons as one list (below) and repo type (code / notes), prints one summary line (lang=… · agents=… · add-ons=… · profile=…) so a wrong guess is easy to spot, lists every path it will write, and asks Proceed? [Y/n] — Enter installs; n, EOF or Ctrl-C writes nothing. It installs through the same path as init, then runs doctor and prints next steps. Flags you pass on the command line answer their question; --private is flag-only and is never asked.
The add-on list has fixed numbers — 1 bottom gauge, 2 Claude Code mod, 3 team roles, 4 Orca collaboration (rows that don't apply here are left out) — and your answer is the exact set you want: Enter keeps the ones marked *, typing numbers or names (1 2, mod roles) picks exactly those and nothing else, 0 picks none. With Claude Code among the agents, the mod is preselected unless claude --version reports a version older than 2.1.287 (the mod would never load). The bottom gauge is preselected only when neither you (~/.claude/settings.json) nor the repo already has a status line: it is written to the committed .claude/settings.json, so in this repo it replaces every teammate's own status line, and the row says so. Orca collaboration is preselected only when setup runs inside Orca (an ORCA_* environment variable is set); an orca program found only on PATH shows the row unmarked, because GNOME's screen reader is also called orca. Team roles are never preselected. Rows already installed are shown as ✓ and kept as they are.
On a repo that already has flightwake, setup changes neither language nor repo type (that is update --lang=… / --profile=…). If an add-on that applies here is not installed, it offers three choices: upgrade in place (the same as npx flightwake update), upgrade and add add-ons (only adds — your other options and your records are untouched; the same path as update --statusline / --mod / --orca), or leave it as it is (writes nothing, exit 0). When everything that applies is installed, it goes straight to the in-place upgrade.
Non-interactive form — npx flightwake init [flags] (a bare npx flightwake does the same) never asks anything: use it for automation, agents, CI, and when you already know what you want:
npx flightwake init --statusline # English (default) + the bottom gauge
npx flightwake init --statusline --agents=claude,codex,gemini # all three agents at once (Claude Code + Codex + Gemini CLI)
npx flightwake update # upgrade an existing install in place (keeps your options: lang/statusline/private); ends with one line naming add-ons you don't have
npx flightwake update --mod # upgrade and add the Claude Code mod (also --statusline, --orca)
update and init never ask. When an add-on that applies here is not installed, they end with one line naming it, plus npx flightwake setup (asks first, lists every path) or the direct command (npx flightwake update --statusline / --mod / --orca, npx flightwake roles install). Nothing is added automatically, and the line tells agents to ask the person before adding any; it is gone once everything that applies is installed. On a --private install, follow these update commands rather than init flags: init without --private would write the shared .claude/settings.json.
Pick your language (non-interactive form; setup asks it for you) — installed templates, skills, and all CLI/gauge output follow it. setup starts the language question on your terminal locale and shows what it read — Enter keeps it, a number changes it. init never guesses: the installed language is shared by everyone who clones the repo, so one person's terminal must not pick it. It installs English unless you pass --lang, and if your terminal locale says another language it prints the switch command (npx flightwake update --lang=<x>). Copy the line you want:
| Language | Fresh install | Already installed in another language |
|---|---|---|
| English | npx flightwake init --statusline | npx flightwake update --lang=en |
| 繁體中文 | npx flightwake init --lang=zh-TW --statusline | npx flightwake update --lang=zh-TW |
| 简体中文 | npx flightwake init --lang=zh-CN --statusline | npx flightwake update --lang=zh-CN |
| 日本語 | npx flightwake init --lang=ja --statusline | npx flightwake update --lang=ja |
Switching languages is safe: update --lang=<x> replaces only framework-owned files (templates, skills, hooks, the marker block) and keeps your other options (private, gauge, add-ons, profile). Your STATE / DECISIONS / TRAPS / records are never touched — they stay in whatever language you wrote them. Drop --statusline from a fresh-install line if you don't want the gauge. Add --agents=claude,codex,gemini (any subset) to install for those agents explicitly — without it, init installs for whichever instruction files already exist (CLAUDE.md / AGENTS.md / GEMINI.md).
Don't hand-translate the installed files. The marker records which language you installed, so the next update refreshes them from that language's source and your edits disappear. Switch with update --lang=<x> instead; if you already did hand-edit, init/update will now name each file it overwrote.
init creates .flightwake/ (templates + Stop hook), copies 4 skills into .claude/skills/, merges the Stop hook into .claude/settings.json, and appends the trigger-obligation table (wrapped in <!-- flightwake:begin/end --> markers) to detected agent instruction files (CLAUDE.md / AGENTS.md / GEMINI.md — whichever exist; if none, it creates AGENTS.md; --agents=claude,codex,gemini selects explicitly). Each detected platform gets the same skills and hook in its own dialect: Codex and Gemini CLI read the skills from .agents/skills/fw-*, the STATE check goes into .codex/hooks.json (Stop) or .gemini/settings.json (AfterAgent), and the table says $fw-coldstart to Codex, /fw-coldstart to Claude Code, and the bare skill name to Gemini. Codex asks you to trust the repo hook once on first run. Pure file copying, zero runtime dependencies (Node ≥18 used only at install time and by the hooks). User data (STATE/DECISIONS/TRAPS) is never overwritten; --force only updates framework-owned files. update re-detects what you installed and refreshes it from the latest version.
/fw-coldstart — it notices STATE is still the unfilled template and writes the first STATE from the repo itself (health is never guessed green: it is yellow until something was actually verified)git add .flightwake .claude CLAUDE.md && git commitYou (and the model) only need to remember one thing: start work with /fw-coldstart; the model triggers every other obligation itself — the obligation table is already in the instruction file, and strong models both read it and honor it. A typical session:
You: /fw-coldstart
Model: (reads STATE + the latest record, ~1 minute)
"Last session got to X, health green, next entry point is Y.
Unverified changes: none. Pick up from Y?"
You: Yes, go
Model: (starts working directly. Makes a decision that closes off options →
one line appended to DECISIONS; hits a non-obvious trap → /fw-trap)
You: Wrap up
Model: (/fw-record: writes the flight record, updates STATE, runs the
sensitive-info self-check)
Forgot to wrap up? When STATE lags ≥3 commits, the Stop hook blocks once before the session ends to remind you (it also nags when STATE claims health=green but the latest record carries no test evidence); --ci brings the same gate to other agents and human collaborators. Honest edges of the net: the lag counts human commits only — bot commits (dependabot[bot], renovate[bot], …) are excluded, because a dependency bump never makes STATE wrong and the bot's own PR could never satisfy the gate. A session that commits nothing (research, ops work) or a squash/rebase flow slips under it — the net catches forgetting; it doesn't replace the session-end obligation. For multi-session construction, say "handoff" before stopping so the model runs /fw-handoff.
Whether STATE's health is honest (green/yellow/red). The framework has a single quality metric: how long it takes a fresh session to reach a safe takeover after /fw-coldstart — if that takes more than 5 minutes, your records are degrading. Everything else — record count, format compliance — doesn't matter.
When that light comes on, you don't do the maintenance yourself. Say: "this cold start took X minutes — diagnose what's slow and compact." The model comes back with a diagnosis (STATE too long? last session never wrapped up? stale TRAPS/DECISIONS entries?) and an item-by-item plan — which entries to mark superseded and why, which to merge — and you approve with one word. Facts work better than pressure: "over 5 minutes means the next session will fumble the takeover" is a prompt the model can reason about; "this is serious!" is not.
This repo dogfoods its own framework: .flightwake/ contains the real STATE, DECISIONS, and records — every step from the gap list to the open-source launch is recorded there. That's exactly what will grow in your repo after installing.
New to working with a strong model? docs/workflow.md is a stage map of what you do and what to say to the model at each point — beginner main line, advanced folds for Claude Code veterans. (繁體中文版:workflow.zh-TW.md)
Using more than one model on the same repo? docs/multi-agent.md shows how Claude Code, Codex, and Gemini CLI share one .flightwake/ — what init installs for each, how to invoke the skills in each tool, and the wrap-up → commit → cold-start loop that makes the handover identical whichever model wrote last. (繁體中文版:multi-agent.zh-TW.md)
Running a team of agents (a project manager, a tech lead, a coder, a reviewer)? docs/roles.md covers flightwake roles (opt-in, v0.14.0+): the agent recommends a set of roles for your project, you preview and customize them, and each role is written into the instruction file its agent re-reads at every session start — so nobody forgets their job after /clear, across repos if your team spans several. (繁體中文:roles.zh-TW.md · 简体中文:roles.zh-CN.md · 日本語:roles.ja.md)
A Fable 5-class model doesn't need to be taught how to do the work — but there are four things no model can do however strong it gets, because they are structural and don't disappear as models improve:
So flightwake supplements persistence and discipline, not intelligence. Its ancestor in spirit is GSD: GSD is navigation (turn-by-turn guidance for every step); flightwake is a dashcam + warning lights + road signs — strong models drive themselves, so the framework only does three things:
records/, DECISIONS.md, TRAPS.md)STATE.md and takes over safely within 2 minutesThe origin was a real three-day session (2026-07-15~17: two repos, 19 commits, 4 cron jobs, 2 deep bug fixes — no upfront planning, zero derailment). It proved a strong model needs no navigation — but everything it left behind to make the next session possible (SUMMARY/CONTEXT/memory files) was improvised on the spot. flightwake turns that improvisation into an installable convention.
GSD is stage-driven (research→plan→execute→verify gates); flightwake is trigger-driven (events create obligations):
| Trigger event | Obligation | Tool |
|---|---|---|
| Starting to touch a repo | Read STATE + the latest record first | /fw-coldstart |
| Making a decision that closes off other options | One line into DECISIONS (append-only, with the why) | write directly |
| Hitting a non-obvious trap | One entry into TRAPS | /fw-trap |
| Touching schema / touching prod / ~3+ commits | Wrap up with a record | /fw-record |
| Work will span sessions | Write handoff/CONTEXT before stopping (not before starting) | /fw-handoff |
| Session about to close | Update STATE's position and next-step entry point | part of /fw-record |
Escalation rule (the opposite of GSD): by default everything is quick — just start working; only "construction spanning multiple sessions" escalates to a phase (one CONTEXT file; plan decomposition is left to the model's in-the-moment judgment).
your-repo/
├── .flightwake/
│ ├── STATE.md # where we are now, next-step entry (always short, always current)
│ ├── DECISIONS.md # append-only decision log (one line per decision, with the why)
│ ├── TRAPS.md # trap registry (OKF-style frontmatter entries)
│ ├── TEMPLATE-record.md # flight-record template
│ ├── hooks/state-check.mjs # Stop hook: reminds you to wrap up when STATE lags ≥3 commits
│ └── records/ # flight records (one per meaningful wrap-up)
├── .claude/skills/fw-*/ # the four skills (Claude Code)
├── .claude/settings.json # init merges the Stop hook config here
├── .agents/skills/fw-*/ # the same four skills for Codex / Gemini CLI (only when AGENTS.md / GEMINI.md is detected)
├── .codex/hooks.json # Codex Stop hook (only when AGENTS.md is detected)
└── .gemini/settings.json # Gemini CLI AfterAgent hook (only when GEMINI.md is detected)
The skills and hooks are convenience sugar per platform — the same four skills and the same check script, installed where Claude Code, Codex, and Gemini CLI each look for them; .flightwake/ itself is plain Markdown in git, so every agent (and every human) reads and writes the same state. Any other agent that reads the instruction file can follow the same trigger obligations by hand. Coexists with an existing GSD .planning/ (old records become historical archives).
--private keeps records local-only, out of git: every write is registered in .git/info/exclude (purely local — no trace left in the repo), the hook goes into .claude/settings.local.json, and the obligation table goes into CLAUDE.local.md (git-tracked instruction files are never touched). The cost: records aren't shared with the repo, and a fresh clone needs init --private again — "in git, shared with the repo" is flightwake's default and reason to exist; --private is the escape hatch for personal use inside someone else's repo.
doctor (npx flightwake doctor) is a read-only, no-network check of the install structure: git and git root, Node ≥18, .flightwake/, STATE (unfilled template fields are a warning), latest_record, marker blocks and their version/lang/profile consistency, skills, hook registration (valid JSON, exact command, correct event — Stop for Claude Code/Codex, AfterAgent for Gemini CLI — no duplicates, script exists), that --private excludes are actually in effect, and the status of optional add-ons. Each line is ok / warning / fail; exit code 1 on any failure. It verifies structure only, not that hooks fire at runtime (whether Codex trusts the hook path can't be checked — doctor prints a hint instead). Writes nothing.
--profile=code|notes (default code) picks the obligation table. notes is for repos that aren't code (writing, research, notes): it drops "tests green + typecheck clean", "prod verification evidence", and the schema/prod wrap-up trigger, and keeps cold start, decisions, traps, handoff, the ≥3-commit wrap-up, confirming destructive operations, and an honest STATE at session end. The same files are installed. The profile is stored in the marker (profile=notes); update keeps it, and update --profile=code switches back.
--orca (opt-in; also a setup list item, listed only when Orca is detected and preselected only when setup runs inside Orca) adds a marked block to each active platform's instruction file: use visible Orca tabs — not hidden background runs — for cross-agent discussion and review, plus a one-writer review protocol (the agent that was asked to review writes no record and doesn't touch STATE; the asker records the conclusions it adopts). uninstall removes it; update refreshes it where installed; update --orca adds it.
--mod (optional; offered by setup when Claude Code is selected and preselected if Claude Code is at least 2.1.287) installs flightwake-mod in .claude/skills/flightwake-mod/. Mod 0.3.0 has eight switches: STATE injection, a prompt band, a session flight log, TRAPS tripwire, role guard (off by default), API-equivalent cost, session Fleet, and the side panel. /fw-mod shows their current state; /fw-panel opens the side panel with this repo's health, context and attention items. Inside Orca, the panel also groups Claude and Codex terminals by repo, with switch controls and a confirmed wrap-up action for idle agents. The panel opens only when requested and remembers that choice until closed. It requires Claude Code 2.1.287+, folder trust and a session started at the repo root. After a new mod install, restart Claude Code, then run /fw-mod to confirm it loaded. update refreshes an installed mod; update --mod adds it; uninstall removes shipped files while keeping user-added files. Details: docs/mod.md.
--git-init makes init create the git repo when the directory isn't one — explicit flag only; without it, init stops and tells you. Both init and setup check that git is installed first and print per-platform install hints if not.
uninstall reverses init's fixed write scope: removes the skills' and the framework's own files (only what it shipped — anything you added inside a skill folder, or a directory where a shipped file was, is kept and listed), extracts flightwake's Stop hook from settings (your other hooks stay untouched), and strips the marker blocks from instruction files and .git/info/exclude (files created by flightwake are deleted once emptied). .flightwake/ is user data and is kept by default; only uninstall --purge deletes it too.
Monorepo policy: one install per repo, at the git root. Work is session-shaped — a session routinely spans multiple packages, and records follow the session; per-subdirectory installs would shred one stretch of work into fragmented records and turn "which STATE do I read?" into a new cold-start ambiguity. Running init in a subdirectory stops and points you to the root. Submodules have their own .git and count as independent repos. If a high-traffic multi-team monorepo sees false positives from the CI staleness check, tune --threshold first.
Wrap up your current milestone first, then:
npx flightwake init — coexists with .planning/; nothing is deleted.planning/ for the current state and initialize .flightwake/STATE.md with /fw-record — unfinished items go into the next-step entries. From now on .planning/ is a historical archive; don't update it."npx flightwake init --statusline puts a persistent gauge at the bottom of Claude Code:
✈️ flightwake │ ●green · STATE 2c behind │ ▓▓░░░░░░░░ 23%
Health color (the one thing you watch), STATE staleness (same rev-list logic as the Stop hook — but as a live gauge instead of an exit-time reminder), and context usage. The gauge also tells you the next command for the current state — session just started → → 開工先 /fw-coldstart; STATE ≥3 commits behind → → /fw-record; context running hot → → /fw-record → /clear → /fw-coldstart; all healthy → silence. It never overwrites an existing statusline (a single-value setting), and repo-level config takes precedence over user-level, so it coexists with tools that set a global one.
Note: a plain npx flightwake init does not install the gauge — it's opt-in. Already ran init without it? npx flightwake update --statusline adds the gauge and keeps your other options (including --private); the bar appears in the next Claude Code session.
The gauge also tells you when a newer flightwake exists (`→ v0.9.1 av
hooks/register.ts 51 lines1/**
2 * flightwake-mod — Claude Code's add-on layer over flightwake's Markdown records (docs/plans/mods.md).
3 * register only assembles: each feature lives in its own module under features/ and is switched by its
4 * userConfig field. A feature that throws while registering is dropped alone; at run time the engine skips a
5 * failing hook and the chain goes on, so one feature's failure never reaches the others or the person's work.
6 */
7import type { PluginOptions, Register } from 'claude-code'
8
9import { registerStateInject } from './features/state-inject'
10import { registerBand } from './features/band'
11import { registerRecorder } from './features/recorder'
12import { registerTripwire } from './features/tripwire'
13import { registerRoleGuard } from './features/role-guard'
14import { registerStatus } from './features/status'
15import { registerSpend } from './features/spend'
16import { registerFleet } from './features/fleet'
17import { registerPanel } from './features/panel'
18
19/** userConfig field → default, mirrored from .claude-plugin/plugin.json (F5 is opt-in). */
20export const DEFAULTS = { stateInject: true, band: true, recorder: true, tripwire: true, roleGuard: false, spend: true, fleet: true, panel: true } as const
21
22export const isEnabled = (options: PluginOptions, key: keyof typeof DEFAULTS): boolean =>
23 typeof options[key] === 'boolean' ? options[key] === true : DEFAULTS[key]
24
25// Each call is spelled out (not looped over a table): the engine's loader only accepts `on` passed to a function
26// imported by name. Each is wrapped so a feature that throws while registering stays off alone.
27export const register: Register = (on, options) => {
28 if (isEnabled(options, 'stateInject')) try { registerStateInject(on) } catch {} // F1
29 if (isEnabled(options, 'band')) try { registerBand(on) } catch {} // F2
30 if (isEnabled(options, 'recorder')) try { registerRecorder(on) } catch {} // F3
31 if (isEnabled(options, 'tripwire')) try { registerTripwire(on) } catch {} // F4
32 if (isEnabled(options, 'roleGuard')) try { registerRoleGuard(on) } catch {} // F5
33 if (isEnabled(options, 'spend')) try { registerSpend(on) } catch {} // F6
34 if (isEnabled(options, 'fleet')) try { registerFleet(on) } catch {} // F7
35 if (isEnabled(options, 'panel')) {
36 try {
37 registerPanel(on, {
38 band: isEnabled(options, 'band'), recorder: isEnabled(options, 'recorder'), spend: isEnabled(options, 'spend'), fleet: isEnabled(options, 'fleet'),
39 })
40 } catch {}
41 } // F8
42 // /fw-mod: not a switch — it is how the switches are seen
43 try {
44 registerStatus(on, {
45 stateInject: isEnabled(options, 'stateInject'), band: isEnabled(options, 'band'), recorder: isEnabled(options, 'recorder'),
46 tripwire: isEnabled(options, 'tripwire'), roleGuard: isEnabled(options, 'roleGuard'), spend: isEnabled(options, 'spend'),
47 fleet: isEnabled(options, 'fleet'), panel: isEnabled(options, 'panel'),
48 })
49 } catch {}
50}
51hooks/features/state-inject.ts 162 lines1/**
2 * F1 STATE snapshot into the system prompt (docs/plans/mods.md, revised; DECISIONS 2026-10-05).
3 * One snapshot per session id, taken on session.start (pre-warm) or lazily on the first prompt.compose, then
4 * reused: the section never changes mid-session, so the prompt cache behind it stays valid. /clear (session.end
5 * reason 'clear') drops it; the next compose retakes it under the new id. Read-only; a failure injects nothing.
6 */
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, On } from 'claude-code'
9
10import { M, STATE_REL, fwContext, isUninitializedState, readRel } from '../lib/core'
11import type { Io, Lang } from '../lib/core'
12
13const SECTION_ID = 'flightwake-mod:state'
14/** DECISIONS 2026-10-05: STATE text over this many characters is injected condensed. */
15const LIMIT = 6000
16
17const snapshotRef = atom({ plugin: 'flightwake-mod', key: 'stateSnapshot' } as const, null)
18
19type Snap = { sessionId: string; text: string | null }
20
21// Local copy of IO_OF_TEMPLATE (hooks/lib/core.ts): `$` must not cross an import.
22function ioOf($: EngineInterface): Io {
23 return {
24 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
25 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
26 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
27 read: async (p) => {
28 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
29 },
30 git: async (args, cwd) => {
31 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
32 },
33 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
34 }
35}
36
37/** STATE split into its frontmatter block and its top-level "# " sections (comments and fenced code ignored). */
38function splitState(text: string): { front: string; sections: string[] } {
39 const lines = text.split(/\r?\n/)
40 let i = 0
41 let front: string[] = []
42 if (lines[0]?.trim() === '---') {
43 const end = lines.findIndex((l, k) => k > 0 && l.trim() === '---')
44 if (end > 0) { front = lines.slice(0, end + 1); i = end + 1 }
45 }
46 const sections: string[][] = []
47 let inComment = false
48 let inFence = false
49 for (; i < lines.length; i++) {
50 const line = lines[i] ?? ''
51 let isHeading = false
52 if (inComment) {
53 if (line.includes('-->')) inComment = false
54 } else if (inFence) {
55 if (/^\s*(```|~~~)/.test(line)) inFence = false
56 } else if (/^\s*(```|~~~)/.test(line)) {
57 inFence = true
58 } else if (line.trimStart().startsWith('<!--')) {
59 if (!line.includes('-->')) inComment = true
60 } else if (/^# /.test(line)) {
61 isHeading = true
62 }
63 if (isHeading) sections.push([line])
64 else sections[sections.length - 1]?.push(line)
65 }
66 return { front: front.join('\n'), sections: sections.map((s) => s.join('\n').replace(/\s+$/, '')) }
67}
68
69/** `text` cut at a line boundary to at most `max` characters (the "…" marker included); unchanged when it fits. */
70function trimLines(text: string, max: number): string {
71 if (text.length <= max) return text
72 const cut = text.slice(0, Math.max(0, max - 2))
73 const nl = cut.lastIndexOf('\n')
74 return `${nl > 0 ? cut.slice(0, nl) : cut}\n…`
75}
76
77/** The condensed STATE: frontmatter (health), the 2nd and 3rd top-level sections, each trimmed if still over LIMIT. */
78function condense(text: string): string {
79 const { front, sections } = splitState(text)
80 const kept = [sections[1], sections[2]].filter((s): s is string => typeof s === 'string')
81 const frontKept = front ? trimLines(front, 1500) : ''
82 const join = (b: string[]) => [frontKept, ...b].filter(Boolean).join('\n\n')
83 if (join(kept).length <= LIMIT) return join(kept)
84 const budget = LIMIT - frontKept.length - 2 * (kept.length + 1)
85 if (kept.length === 0) return join([trimLines(sections.join('\n\n') || text, Math.max(200, budget))])
86 const each = Math.max(200, Math.floor(budget / kept.length))
87 return join(kept.map((s) => trimLines(s, each)))
88}
89
90function snapshotText(lang: Lang, state: string): string {
91 if (isUninitializedState(state)) {
92 return M(lang, {
93 en: 'flightwake: .flightwake/STATE.md is not initialized yet. Run /fw-coldstart first.',
94 'zh-TW': 'flightwake:.flightwake/STATE.md 尚未初始化,請先執行 /fw-coldstart。',
95 'zh-CN': 'flightwake:.flightwake/STATE.md 尚未初始化,请先执行 /fw-coldstart。',
96 ja: 'flightwake: .flightwake/STATE.md はまだ初期化されていません。先に /fw-coldstart を実行してください。',
97 })
98 }
99 const header = M(lang, {
100 en: 'flightwake: this is .flightwake/STATE.md as of the last wrap-up, a snapshot taken at session start. Check the git state before acting on it. /fw-coldstart still checks how far STATE lags and reads the latest record.',
101 'zh-TW': 'flightwake:以下是 .flightwake/STATE.md 在上次收尾時的內容,於 session 開始時取的快照。動手前請先核對 git 狀態。落後量檢查與最新 record 的閱讀仍由 /fw-coldstart 負責。',
102 'zh-CN': 'flightwake:以下是 .flightwake/STATE.md 在上次收尾时的内容,于 session 开始时取的快照。动手前请先核对 git 状态。落后量检查与最新 record 的阅读仍由 /fw-coldstart 负责。',
103 ja: 'flightwake: 以下は前回の締め時点の .flightwake/STATE.md で、セッション開始時に取ったスナップショットです。作業の前に git の状態を確認してください。遅れの確認と最新 record の読み込みは引き続き /fw-coldstart が行います。',
104 })
105 if (state.length <= LIMIT) return `${header}\n\n${state.replace(/\s+$/, '')}`
106 const n = state.length
107 const note = M(lang, {
108 en: `(condensed; full file at ${STATE_REL} (${n} characters, over ${LIMIT}); read it when needed. Consider compacting STATE.)`,
109 'zh-TW': `(已精簡;完整內容見 ${STATE_REL}(${n} 字元,超過 ${LIMIT});需要時請讀取原檔。建議壓實 STATE。)`,
110 'zh-CN': `(已精简;完整内容见 ${STATE_REL}(${n} 字符,超过 ${LIMIT});需要时请读取原文件。建议压实 STATE。)`,
111 ja: `(要約済み。全文は ${STATE_REL}(${n} 文字、上限 ${LIMIT} 超)にあります。必要なときに読んでください。STATE の圧縮を検討してください。)`,
112 })
113 return `${header}\n\n${condense(state)}\n\n${note}`
114}
115
116async function takeText(io: Io): Promise<string | null> {
117 const ctx = await fwContext(io)
118 if (!ctx) return null
119 const state = await readRel(io, ctx.root, STATE_REL)
120 return state === null ? null : snapshotText(ctx.lang, state)
121}
122
123/** The snapshot for the current session id: kept when it already exists, otherwise taken now and stored. */
124async function ensureSnapshot($: EngineInterface): Promise<Snap | null> {
125 const io = ioOf($)
126 const id = await io.sessionId()
127 if (!id) return null
128 const cur = (await read($, snapshotRef)) as Snap | null
129 if (cur && cur.sessionId === id) return cur
130 const fresh: Snap = { sessionId: id, text: await takeText(io) }
131 // A concurrent taker (session.start vs. the first compose) may have stored one meanwhile: that one wins.
132 await update($, snapshotRef, (c) => ((c as Snap | null)?.sessionId === id ? c : fresh))
133 return ((await read($, snapshotRef)) as Snap | null) ?? fresh
134}
135
136export function registerStateInject(on: On): void {
137 on('session.start', {}, async ($, e, next) => {
138 const r = await next(e)
139 try { await ensureSnapshot($) } catch {}
140 return r
141 })
142
143 on('session.end', {}, async ($, e, next) => {
144 if (e.reason === 'clear') {
145 try { await update($, snapshotRef, () => null) } catch {}
146 }
147 return next(e)
148 })
149
150 on('prompt.compose', {}, async ($, e, next) => {
151 const r = await next(e)
152 if (e.traits.includes('bare')) return r
153 try {
154 const snap = await ensureSnapshot($)
155 if (!snap || !snap.text) return r
156 return { sections: [...r.sections.filter((s) => s.id !== SECTION_ID), { id: SECTION_ID, text: snap.text, scope: 'session' as const }] }
157 } catch {
158 return r
159 }
160 })
161}
162hooks/features/band.ts 225 lines1/**
2 * F2 status band above the prompt + one context toast. Contract: docs/plans/mods.md (revised) and hooks/lib/core.ts.
3 *
4 * The FwBandView ($.state bandView) is computed OUTSIDE rendering (session.start, turn.complete, and after a Bash
5 * tool call that reports a commit or branch operation); the ui.render hook only reads it and draws, never runs git.
6 * Hints point only at flightwake commands (same wording family as hooks/statusline.mjs); no update check, no network.
7 * When the legacy statusline.mjs is the effective statusLine every field would duplicate it, so the band stays
8 * quiet and only the one-time context toast remains. Without it the band IS the gauge: always drawn, always with the
9 * context percent when Claude Code reports one (2026-10-05, replacing "silent while all is well" — someone with the
10 * mod and no gauge otherwise never saw their context use). The mod never edits settings.
11 */
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, On, RenderElement } from 'claude-code'
14
15import { fwContext, healthOf, isUninitializedState, joinPath, legacyStatuslineActive, M, STATE_REL, stateLag } from '../lib/core'
16import type { Io, Lang } from '../lib/core'
17import type { FwBandView, FwSpend } from '../../types/index'
18import { spendLine } from './spend'
19
20const bandView = atom({ plugin: 'flightwake-mod', key: 'bandView' } as const, null)
21const bandToastSession = { plugin: 'flightwake-mod', key: 'bandToastSession' } as const
22const spendRef = atom({ plugin: 'flightwake-mod', key: 'spend' } as const, null)
23
24function ioOf($: EngineInterface): Io {
25 return {
26 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
27 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
28 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
29 read: async (p) => {
30 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
31 },
32 git: async (args, cwd) => {
33 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
34 },
35 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
36 }
37}
38
39const HOT = 80
40const WARM = 60
41const LAG_HINT = 3
42
43function hintFor(lang: Lang, v: { health: string; contextPercent: number | null; behind: number | null; justOpened: boolean; isUninitialized?: boolean }): string | null {
44 if (v.isUninitialized) return M(lang, {
45 en: 'STATE not initialized yet — run /fw-coldstart',
46 'zh-TW': 'STATE 尚未初始化——先跑 /fw-coldstart',
47 'zh-CN': 'STATE 尚未初始化——先跑 /fw-coldstart',
48 ja: 'STATE が未初期化——まず /fw-coldstart',
49 })
50 if (v.health === 'yellow' || v.health === 'red') return M(lang, {
51 en: 'handle unverified items before stacking new work (read STATE)',
52 'zh-TW': '先處理未驗證項再疊新工作(讀 STATE)',
53 'zh-CN': '先处理未验证项再叠新工作(读 STATE)',
54 ja: '未検証の項目を片付けてから新しい作業を積む(STATE を読む)',
55 })
56 if (v.contextPercent !== null && v.contextPercent >= HOT) return M(lang, {
57 en: '/fw-record → /clear → /fw-coldstart',
58 'zh-TW': '/fw-record 收尾 → /clear → /fw-coldstart 接手',
59 'zh-CN': '/fw-record 收尾 → /clear → /fw-coldstart 接手',
60 ja: '/fw-record で締め → /clear → /fw-coldstart で引き継ぎ',
61 })
62 if (v.behind !== null && v.behind >= LAG_HINT) return M(lang, {
63 en: '/fw-record to wrap up',
64 'zh-TW': '/fw-record 收尾',
65 'zh-CN': '/fw-record 收尾',
66 ja: '/fw-record で締める',
67 })
68 if (v.justOpened) return M(lang, {
69 en: 'start with /fw-coldstart',
70 'zh-TW': '先跑 /fw-coldstart 接手',
71 'zh-CN': '先跑 /fw-coldstart 接手',
72 ja: 'まず /fw-coldstart で引き継ぐ',
73 })
74 return null
75}
76
77/** Pure: the view for a set of facts. Language only shapes the hint. */
78function viewOf(lang: Lang, f: {
79 health: FwBandView['health']
80 lag: FwBandView['lagKind']
81 behind: number | null
82 contextPercent: number | null
83 isLegacyGaugeActive: boolean
84 justOpened: boolean
85 isUninitialized?: boolean
86}): FwBandView {
87 const hint = hintFor(lang, { health: f.health, contextPercent: f.contextPercent, behind: f.behind, justOpened: f.justOpened, isUninitialized: f.isUninitialized })
88 return {
89 isLegacyGaugeActive: f.isLegacyGaugeActive,
90 health: f.health,
91 lagKind: f.lag,
92 behind: f.behind,
93 contextPercent: f.contextPercent,
94 hint,
95 // Quiet only when the bottom gauge already shows all of this; otherwise the band stands in for the gauge
96 isQuiet: f.isLegacyGaugeActive,
97 lang,
98 }
99}
100
101type Computed = { view: FwBandView; lang: Lang; sessionId: string }
102
103/** Reads the world and builds the view; null when flightwake is not set up here. A template STATE → health unknown + coldstart hint. */
104async function compute($: EngineInterface): Promise<Computed | null> {
105 const io = ioOf($)
106 const ctx = await fwContext(io)
107 if (ctx === null) return null
108 const text = await io.read(joinPath(ctx.root, STATE_REL))
109 if (text === null) return null
110 const isUninitialized = isUninitializedState(text)
111 const lag = isUninitialized ? null : await stateLag(io, ctx.root)
112 let contextPercent: number | null = null
113 try {
114 const p = (await $.session.usage()).context.percent
115 contextPercent = typeof p === 'number' && Number.isFinite(p) ? p : null
116 } catch { contextPercent = null }
117 let turns = 1
118 try { turns = await $.session.turns() } catch { turns = 1 }
119 const view = viewOf(ctx.lang, {
120 health: isUninitialized ? 'unknown' : healthOf(text),
121 lag: lag === null ? 'none' : lag.kind,
122 behind: lag !== null && lag.kind === 'behind' ? lag.behind : null,
123 contextPercent,
124 isLegacyGaugeActive: await legacyStatuslineActive(io),
125 justOpened: turns === 0,
126 isUninitialized,
127 })
128 return { view, lang: ctx.lang, sessionId: await io.sessionId() }
129}
130
131/** Recompute the view, store it, and toast once per session when context runs hot. Never throws. */
132async function refresh($: EngineInterface): Promise<void> {
133 try {
134 const c = await compute($)
135 await update($, bandView, () => c === null ? null : c.view)
136 if (c === null || c.sessionId === '' || c.view.contextPercent === null || c.view.contextPercent < HOT) return
137 const held = await $.state.get(bandToastSession)
138 if (held.value === c.sessionId) return
139 // ifVersion: of two refreshes racing in one session only the winner of the write toasts.
140 const w = await $.state.set(bandToastSession, c.sessionId, { ifVersion: held.version })
141 if (!w.isSet) return
142 $.ui.toast(M(c.lang, {
143 en: 'flightwake: context is running hot — wrap up with /fw-record, then /clear and /fw-coldstart',
144 'zh-TW': 'flightwake:context 快滿了——先 /fw-record 收尾,再 /clear 與 /fw-coldstart 接手',
145 'zh-CN': 'flightwake:context 快满了——先 /fw-record 收尾,再 /clear 与 /fw-coldstart 接手',
146 ja: 'flightwake:コンテキストが逼迫しています。/fw-record で締めてから /clear と /fw-coldstart へ',
147 }), { timeoutMs: 8000 })
148 } catch {
149 try { await update($, bandView, () => null) } catch {}
150 }
151}
152
153/** The lag field's words; shared with the side panel (F8) so both say it the same way. */
154export function lagText(lang: Lang, v: Pick<FwBandView, 'lagKind' | 'behind'>): string {
155 switch (v.lagKind) {
156 case 'dirty':
157 return M(lang, { en: 'STATE updating', 'zh-TW': 'STATE 更新中', 'zh-CN': 'STATE 更新中', ja: 'STATE 更新中' })
158 case 'error':
159 return M(lang, { en: 'STATE lag ?', 'zh-TW': 'STATE 落後量未知 ?', 'zh-CN': 'STATE 落后量未知 ?', ja: 'STATE の遅れ不明 ?' })
160 case 'behind': {
161 const n = v.behind ?? 0
162 return n > 0
163 ? M(lang, { en: `STATE ${n}c behind`, 'zh-TW': `STATE 落後 ${n}c`, 'zh-CN': `STATE 落后 ${n}c`, ja: `STATE ${n}c 遅れ` })
164 : M(lang, { en: 'STATE in sync', 'zh-TW': 'STATE 同步', 'zh-CN': 'STATE 同步', ja: 'STATE 同期済' })
165 }
166 default:
167 return '' // no-baseline / none: not measured, say nothing
168 }
169}
170
171export function registerBand(on: On): void {
172 on('session.start', {}, async ($, e, next) => {
173 const r = await next(e)
174 await refresh($)
175 return r
176 })
177
178 on('turn.complete', {}, async ($, e, next) => {
179 const r = await next(e)
180 await refresh($)
181 return r
182 })
183
184 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
185 const ran = await next(e)
186 try {
187 if (ran.deny === undefined && ran.isError === undefined) {
188 const op = (ran.result as { gitOperation?: { commit?: unknown; branch?: unknown } } | undefined)?.gitOperation
189 if (op && (op.commit || op.branch)) await refresh($)
190 }
191 } catch {}
192 return ran
193 })
194
195 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
196 try {
197 if (e.props.hasSurvey) return next(e)
198 const v = await read($, bandView)
199 if (!v || v.isQuiet) return next(e)
200 const lang = v.lang
201 const { Box, Text } = $.ui.resolve(e)
202 const color = v.health === 'green' ? 'green' : v.health === 'yellow' ? 'yellow' : v.health === 'red' ? 'red' : undefined
203 const lag = lagText(lang, v)
204 const pct = v.contextPercent
205 const row: unknown[] = [
206 h(Text, { bold: true, wrap: 'truncate' }, '✈ flightwake'),
207 h(Text, { color, wrap: 'truncate' }, ` · ●${v.health === 'unknown' ? '?' : v.health}`),
208 ]
209 if (lag) row.push(h(Text, { wrap: 'truncate' }, ` · ${lag}`))
210 // Always the percent when known (the band stands in for the gauge); the colour thresholds are unchanged
211 if (pct !== null) row.push(h(Text, { color: pct >= HOT ? 'red' : pct >= WARM ? 'yellow' : undefined, wrap: 'truncate' }, ` · ${Math.round(pct)}%`))
212 // F6: tokens so far and the API-equivalent cost (its own feature computes them; the band only reads)
213 let spend: FwSpend | null = null
214 try { spend = (await read($, spendRef)) as FwSpend | null } catch { spend = null }
215 const sl = spendLine(spend)
216 if (sl) row.push(h(Text, { dimColor: true, wrap: 'truncate' }, ` · ${sl}`))
217 if (v.hint) row.push(h(Text, { dimColor: true, wrap: 'truncate-end' }, ` → ${v.hint}`))
218 const tree = h(Box, { width: e.props.bodyColumns, flexDirection: 'row', overflow: 'hidden' }, ...row)
219 return tree && typeof tree !== 'string' ? (tree as RenderElement) : next(e)
220 } catch {
221 return next(e)
222 }
223 })
224}
225hooks/features/recorder.ts 521 lines1/**
2 * F3 session flight log + /fw-log (docs/plans/mods.md, F3 as revised 2026-10-05).
3 *
4 * Observation only: the log holds what this session's tools did (files the agent edited, test/typecheck commands
5 * whose completion was observed, commits git reported) and nothing else. It never writes a record, never reads
6 * git-visible changes made by other programs, and is kept in $.state per session id (a /clear is a new id → fresh
7 * log; a module reload keeps it). No cross-session history.
8 *
9 * Conservative by design: a command is recorded only when it is recognised (known runner, a package script whose
10 * body was read, a command STATE.md declares as verification); the result is pass/fail only for a single plain
11 * command with a reliable completion and an exit code. Everything else that is recorded is 'unknown' with a reason.
12 */
13import { atom, update } from 'claude-code'
14import type { EngineInterface, On } from 'claude-code'
15
16import type { FwFlightLog, FwShellWrite, FwTestRun } from '../../types'
17import { M, STATE_REL, fwContext, packageScripts, readRel, relToRoot, tableCell as cell } from '../lib/core'
18import type { Io, Lang } from '../lib/core'
19import { parseCommand, shellWriteTargets } from '../lib/shell'
20import type { Segment } from '../lib/shell'
21import { declaredCommands, judge, type Judgment } from '../lib/testcmd'
22
23const flightLog = atom({ plugin: 'flightwake-mod', key: 'flightLog' } as const, null)
24
25const CAP_FILES = 500
26const CAP_TESTS = 200
27const CAP_COMMITS = 200
28const CAP_COMMAND = 300
29const CAP_SCRIPT = 200
30const LIST_LIMIT = 100
31
32// The Io closure over this file's `$` (IO_OF_TEMPLATE in hooks/lib/core.ts; `$` may not cross an import).
33function ioOf($: EngineInterface): Io {
34 return {
35 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
36 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
37 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
38 read: async (p) => {
39 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
40 },
41 git: async (args, cwd) => {
42 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
43 },
44 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
45 }
46}
47
48// ---------------------------------------------------------------------------------------------------------------
49// Redaction: applied to every command and script text before it is stored (never to what is matched against).
50// ---------------------------------------------------------------------------------------------------------------
51
52const SECRET_WORDS = 'token|secret|password|passwd|pwd|apikey|api_key|auth|credential|key'
53const VALUE = `"[^"]*"|'[^']*'|\\S+`
54const RE_URL_CREDS = /(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]*@/gi
55const RE_BEARER = /\bBearer\s+[^\s"']+/gi
56const RE_KEY_VALUE = new RegExp(`([A-Za-z0-9_.-]*(?:${SECRET_WORDS})[A-Za-z0-9_.-]*=)(?:${VALUE})`, 'gi')
57const RE_FLAG_VALUE = new RegExp(`(--?[A-Za-z0-9_.-]*(?:${SECRET_WORDS})[A-Za-z0-9_.-]*\\s+)(?!-)(?:${VALUE})`, 'gi')
58const RE_LONG_RUN = /[A-Za-z0-9+/=_-]{32,}/g
59
60export function redact(text: string, cap = CAP_COMMAND): string {
61 const out = text
62 .replace(RE_URL_CREDS, '$1***@')
63 .replace(RE_BEARER, 'Bearer ***')
64 .replace(RE_KEY_VALUE, '$1***')
65 .replace(RE_FLAG_VALUE, '$1***')
66 // A long opaque run with a digit is a likely token; paths (leading / . ~) and plain words are kept, or the
67 // evidence itself (e.g. an absolute test path) would be erased.
68 .replace(RE_LONG_RUN, (m) => (/^[./~]/.test(m) || !/\d/.test(m) || !/[A-Za-z]/.test(m) ? m : '***'))
69 return out.length > cap ? `${out.slice(0, cap - 1)}…` : out
70}
71
72// ---------------------------------------------------------------------------------------------------------------
73// Judging a Bash call before it runs: hooks/lib/testcmd.ts (positive proof; pure). Here: the cd prefix and the cwd.
74// ---------------------------------------------------------------------------------------------------------------
75
76type Plan = {
77 cwd: string
78 /** Absolute cwd, for git. */
79 cwdAbs: string
80 /** Leading `cd <dir> &&` segments were stripped: exit 0 still proves the run, a non-zero exit may be the cd's. */
81 hasCdPrefix: boolean
82 judgment: Judgment
83}
84
85const isCdPrefix = (s: Segment): boolean =>
86 s.tokens[0] === 'cd' && s.op === '&&' && s.env.length === 0 && (s.tokens.length === 2 || (s.tokens.length === 3 && s.tokens[1] === '--'))
87
88async function planBash(io: Io, root: string, cwd0: string, command: string): Promise<Plan | null> {
89 const parsed = parseCommand(command.trim())
90 let segs = parsed.segments
91 let cwdAbs = cwd0 || root
92 let hasCdPrefix = false
93 // Leading `cd <dir> &&` segments only move the cwd.
94 while (segs.length > 1 && isCdPrefix(segs[0] as Segment)) {
95 const dir = (segs[0] as Segment).tokens.at(-1) as string // `cd dir` or `cd -- dir`
96 if (dir === '-' || dir.startsWith('~') || dir.includes('$')) return null // not resolvable without guessing
97 cwdAbs = dir.startsWith('/') ? dir : `${cwdAbs}/${dir}`
98 segs = segs.slice(1)
99 hasCdPrefix = true
100 }
101 if (segs.length === 0) return null
102 const rel = relToRoot(root, cwdAbs)
103 const cwdRel = rel === null ? cwdAbs : rel
104 const stateText = await readRel(io, root, STATE_REL)
105 const scripts = rel === null ? null : await packageScripts(io, root, cwdRel)
106 const judgment = judge(segs, parsed.isComplex, { scripts, declared: stateText === null ? [] : declaredCommands(stateText) })
107 if (judgment === null) return null
108 return { cwd: cwdRel === '' ? '.' : cwdRel, cwdAbs, hasCdPrefix, judgment }
109}
110
111// ---------------------------------------------------------------------------------------------------------------
112// Reading the outcome
113// ---------------------------------------------------------------------------------------------------------------
114
115type Outcome = { result: FwTestRun['result']; exitCode: number | null; reason?: string }
116
117type BashResultFields = { interrupted?: boolean; backgroundTaskId?: string; timedOutAfterMs?: number; gitOperation?: { commit?: { sha: string; kind: string } } }
118
119/** What the tool observably did: its exit code when it completed in the foreground, else why there is none. */
120export function observe(res: { isError?: boolean; result?: unknown; text?: string }, isBackground: boolean): { exitCode: number | null; reason?: string } {
121 if (res.isError === true) {
122 const text = typeof res.text === 'string' ? res.text : typeof res.result === 'string' ? res.result : ''
123 const m = /^Exit code (\d+)/.exec(text)
124 return m ? { exitCode: Number(m[1]) } : { exitCode: null, reason: 'no-exit-code' }
125 }
126 const r = res.result
127 if (r === undefined || r === null || typeof r !== 'object') return { exitCode: null, reason: 'no-result' }
128 const b = r as BashResultFields
129 if (isBackground || b.backgroundTaskId) return { exitCode: null, reason: 'background' }
130 if (b.interrupted === true) return { exitCode: null, reason: 'interrupted' }
131 if (b.timedOutAfterMs !== undefined) return { exitCode: null, reason: 'timeout' }
132 return { exitCode: 0 }
133}
134
135/**
136 * pass/fail only for a proven judgment with an observed exit code; everything else is unknown with its reason, and
137 * keeps whatever exit code was observed so the reader can weigh it.
138 */
139export function outcomeOf(j: Judgment, seen: { exitCode: number | null; reason?: string }, hasCdPrefix: boolean): Outcome {
140 if (seen.exitCode === null) return { result: 'unknown', exitCode: null, reason: seen.reason ?? 'no-exit-code' }
141 if (!j.isProven) return { result: 'unknown', exitCode: seen.exitCode, reason: j.reason ?? 'unproven' }
142 if (seen.exitCode === 0) return { result: 'pass', exitCode: 0 }
143 if (hasCdPrefix) return { result: 'unknown', exitCode: seen.exitCode, reason: 'cd-prefix' }
144 return { result: 'fail', exitCode: seen.exitCode }
145}
146
147// ---------------------------------------------------------------------------------------------------------------
148// The log
149// ---------------------------------------------------------------------------------------------------------------
150
151const freshLog = (sessionId: string, now: number): FwFlightLog => ({ sessionId, startedAt: now, files: [], shellFiles: [], tests: [], commits: [], dropped: 0 })
152
153/** Applies `change` to this session's log (a log of another session id is replaced: that is the /clear behaviour). */
154async function record($: EngineInterface, io: Io, change: (log: FwFlightLog) => void): Promise<void> {
155 // Same install test as every feature: no .flightwake/STATE.md under the root → this folder is not ours, keep nothing.
156 if ((await fwContext(io)) === null) return
157 const sid = await io.sessionId()
158 const now = await $.clock.now()
159 await update($, flightLog, (cur) => {
160 const base = cur !== null && cur.sessionId === sid ? cur : freshLog(sid, now)
161 const log: FwFlightLog = { ...base, files: [...base.files], shellFiles: [...(base.shellFiles ?? [])], tests: [...base.tests], commits: [...base.commits] }
162 change(log)
163 return log
164 })
165}
166
167function touchFile(log: FwFlightLog, path: string, tool: string, at: number, agentId: string | undefined): void {
168 const i = log.files.findIndex((f) => f.path === path)
169 if (i >= 0) log.files.splice(i, 1)
170 else if (log.files.length >= CAP_FILES) {
171 log.dropped += 1
172 return
173 }
174 log.files.push(agentId === undefined ? { path, tool, at } : { path, tool, at, agentId })
175}
176
177function touchShellFile(log: FwFlightLog, w: FwShellWrite): void {
178 const list = log.shellFiles ?? (log.shellFiles = [])
179 const i = list.findIndex((f) => f.path === w.path)
180 if (i >= 0) list.splice(i, 1)
181 else if (list.length >= CAP_FILES) {
182 log.dropped += 1
183 return
184 }
185 list.push(w)
186}
187
188/**
189 * Candidate files a Bash command wrote, from its words (lib/shell shellWriteTargets), as repo-relative paths. A record
190 * would rather miss than misrecord (unlike F4's hints, which would rather over-warn): a relative word is resolved only
191 * while the cwd is certain (start, or a leading `cd X &&` chain), and paths outside the repo are dropped. The caller
192 * then keeps only what git confirms as changed.
193 */
194function shellCandidates(root: string, cwd0: string, command: string): Array<{ path: string; via: string }> {
195 const out: Array<{ path: string; via: string }> = []
196 for (const w of shellWriteTargets(command)) {
197 let abs: string
198 if (w.path.startsWith('/')) abs = w.path
199 else if (w.dir === null) continue // the shell may be anywhere by now: don't guess
200 else abs = w.dir.startsWith('/') ? `${w.dir}/${w.path}` : `${cwd0 || root}/${w.dir ? `${w.dir}/` : ''}${w.path}`
201 const rel = relToRoot(root, abs)
202 if (rel !== null && rel !== '' && !rel.startsWith('.git/') && !out.some((o) => o.path === rel)) out.push({ path: rel, via: w.via })
203 }
204 return out
205}
206
207/**
208 * The candidates git reports as changed after the command — modified, added, untracked or deleted (a removal only
209 * shows for a tracked file, which is the point). Read-only (--no-optional-locks via io.git; --literal-pathspecs so a
210 * path is never a pattern). Not a repo, git failing, or a candidate git does not list → left out.
211 */
212async function confirmedByGit(io: Io, root: string, candidates: Array<{ path: string; via: string }>): Promise<Array<{ path: string; via: string }>> {
213 if (candidates.length === 0) return []
214 const out = await io.git(['--literal-pathspecs', 'status', '--porcelain', '-z', '--untracked-files=all', '--', ...candidates.map((c) => c.path)], root)
215 if (out === null) return []
216 const changed = new Set<string>()
217 const parts = out.split('\0')
218 for (let i = 0; i < parts.length; i++) {
219 // `XY path`; io.git trims the output, so the first entry may have lost the leading space of its status
220 const m = /^([ MADRCUT?!]{1,2}) (.+)$/.exec(parts[i] as string)
221 if (!m) continue
222 changed.add(m[2] as string)
223 if (/[RC]/.test((m[1] as string)[0] as string)) { const from = parts[++i]; if (from) changed.add(from) } // rename: the source follows
224 }
225 return candidates.filter((c) => changed.has(c.path))
226}
227
228/** The one-per-session note for a test run that was chained with other commands (its own exit code is not visible). */
229const chainHint = (lang: Lang): string => M(lang, {
230 en: 'flightwake: this test command ran chained with other commands, so only the whole chain\'s exit code was visible and the run cannot count as passing evidence. When you need evidence (for fw-record\'s tests:), run the test command on its own once.',
231 'zh-TW': 'flightwake:這次的測試是和其他指令串在一起跑的,只看得到整串的退出碼,無法當成通過的證據。需要留證據(fw-record 的 tests:)時,請把測試指令單獨執行一次。',
232 'zh-CN': 'flightwake:这次的测试是和其他命令串在一起跑的,只看得到整串的退出码,无法当成通过的证据。需要留证据(fw-record 的 tests:)时,请把测试命令单独执行一次。',
233 ja: 'flightwake:このテストは他のコマンドとつなげて実行されたため、見えるのはつなげた全体の終了コードだけで、成功の証拠になりません。証拠が必要なとき(fw-record の tests:)は、テストコマンドを単独で一度実行してください。',
234})
235
236// ---------------------------------------------------------------------------------------------------------------
237// /fw-log
238// ---------------------------------------------------------------------------------------------------------------
239
240/** Local UTC offset in minutes (east positive), or null when unknown. */
241export type TzOffset = number | null
242
243/** `+0800` → 480; anything else → null. */
244export function parseOffset(s: string): TzOffset {
245 const m = /^([+-])(\d{2})(\d{2})$/.exec(s.trim())
246 return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : null
247}
248
249const stamp = (ms: number): string => new Date(ms).toISOString().replace('T', ' ').slice(0, 19)
250/** Local time with its offset, then UTC (`2026-10-05 03:32:10 +0800 (19:32:10 UTC)`); UTC only when the offset is unknown. */
251export function when(ms: number, tz: TzOffset = null): string {
252 if (tz === null) return `${stamp(ms)} UTC`
253 const sign = tz < 0 ? '-' : '+'
254 const a = Math.abs(tz)
255 const off = `${sign}${String(Math.floor(a / 60)).padStart(2, '0')}${String(a % 60).padStart(2, '0')}`
256 return `${stamp(ms + tz * 60_000)} ${off} (${stamp(ms).slice(11)} UTC)`
257}
258const code = (s: string): string => (s.includes('`') ? cell(s) : `\`${cell(s)}\``)
259
260export function renderLog(lang: Lang, log: FwFlightLog | null, tz: TzOffset = null): string {
261 const lines: string[] = []
262 lines.push(`## ${M(lang, { en: 'flightwake session log', 'zh-TW': 'flightwake 本 session 記錄', 'zh-CN': 'flightwake 本 session 记录', ja: 'flightwake セッション記録' })}`, '')
263 const isEmpty = log === null || (log.files.length === 0 && (log.shellFiles ?? []).length === 0 && log.tests.length === 0 && log.commits.length === 0)
264 if (isEmpty || log === null) {
265 lines.push(M(lang, {
266 en: 'Nothing observed yet in this session.',
267 'zh-TW': '本 session 尚未觀測到任何檔案變更、測試或 commit。',
268 'zh-CN': '本 session 尚未观测到任何文件变更、测试或 commit。',
269 ja: 'このセッションではまだ何も観測されていません。',
270 }), '')
271 } else {
272 lines.push(`### ${M(lang, { en: 'Files changed by the agent', 'zh-TW': 'agent 改過的檔案', 'zh-CN': 'agent 改过的文件', ja: 'エージェントが変更したファイル' })} (${log.files.length})`, '')
273 if (log.files.length === 0) lines.push(M(lang, { en: '_none_', 'zh-TW': '_無_', 'zh-CN': '_无_', ja: '_なし_' }))
274 for (const f of log.files.slice(0, LIST_LIMIT)) lines.push(`- ${code(f.path)} (${f.tool}${f.agentId ? `, subagent ${f.agentId}` : ''})`)
275 if (log.files.length > LIST_LIMIT) lines.push(M(lang, { en: `- … and ${log.files.length - LIST_LIMIT} more`, 'zh-TW': `- …另有 ${log.files.length - LIST_LIMIT} 個`, 'zh-CN': `- …另有 ${log.files.length - LIST_LIMIT} 个`, ja: `- …ほか ${log.files.length - LIST_LIMIT} 件` }))
276 lines.push('')
277
278 const shell = log.shellFiles ?? []
279 if (shell.length > 0) {
280 lines.push(`### ${M(lang, {
281 en: 'Files possibly changed through shell commands — inferred from the commands, may be incomplete',
282 'zh-TW': '可能經由 shell 指令改動的檔案——由指令推斷,可能不完整',
283 'zh-CN': '可能经由 shell 命令改动的文件——由命令推断,可能不完整',
284 ja: 'シェルコマンドで変更された可能性のあるファイル——コマンドから推定、不完全な場合あり',
285 })} (${shell.length})`, '')
286 for (const f of shell.slice(0, LIST_LIMIT)) lines.push(`- ${code(f.path)} (${f.via}${f.agentId ? `, subagent ${f.agentId}` : ''})`)
287 if (shell.length > LIST_LIMIT) lines.push(M(lang, { en: `- … and ${shell.length - LIST_LIMIT} more`, 'zh-TW': `- …另有 ${shell.length - LIST_LIMIT} 個`, 'zh-CN': `- …另有 ${shell.length - LIST_LIMIT} 个`, ja: `- …ほか ${shell.length - LIST_LIMIT} 件` }))
288 lines.push('', M(lang, {
289 en: 'Read from redirections and cp / mv / rm / tee / sed -i in the commands the agent ran, and listed only where the path could be confirmed (inside the repo, and reported as changed by git afterwards) — so some changes may be missing. Writes made any other way (scripts, other programs, git) are not listed.',
290 'zh-TW': '依 agent 執行的指令中的重導向與 cp / mv / rm / tee / sed -i 推斷,且只列出能確認的路徑(在 repo 內、事後 git 也顯示有變更)——所以可能漏記。以其他方式寫入的(腳本、其他程式、git)不會列出。',
291 'zh-CN': '依 agent 执行的命令中的重定向与 cp / mv / rm / tee / sed -i 推断,且只列出能确认的路径(在 repo 内、事后 git 也显示有变更)——所以可能漏记。以其他方式写入的(脚本、其他程序、git)不会列出。',
292 ja: 'エージェントが実行したコマンドのリダイレクトと cp / mv / rm / tee / sed -i から推定し、確認できたパスだけを載せる(repo 内で、実行後に git が変更ありと示すもの)——そのため漏れがありうる。それ以外の方法(スクリプト、他のプログラム、git)による書き込みは載りません。',
293 }), '')
294 }
295
296 lines.push(`### ${M(lang, { en: 'Test / typecheck runs', 'zh-TW': '測試 / typecheck 執行', 'zh-CN': '测试 / typecheck 执行', ja: 'テスト / typecheck 実行' })} (${log.tests.length})`, '')
297 if (log.tests.length === 0) lines.push(M(lang, { en: '_none observed_', 'zh-TW': '_未觀測到_', 'zh-CN': '_未观测到_', ja: '_観測なし_' }))
298 else {
299 lines.push(`| ${M(lang, { en: 'command', 'zh-TW': '指令', 'zh-CN': '指令', ja: 'コマンド' })} | ${M(lang, { en: 'kind', 'zh-TW': '類型', 'zh-CN': '类型', ja: '種別' })} | ${M(lang, { en: 'result', 'zh-TW': '結果', 'zh-CN': '结果', ja: '結果' })} | ${M(lang, { en: 'exit code', 'zh-TW': '退出碼', 'zh-CN': '退出码', ja: '終了コード' })} | ${M(lang, { en: 'finished', 'zh-TW': '完成時間', 'zh-CN': '完成时间', ja: '終了時刻' })} | ${M(lang, { en: 'revision', 'zh-TW': '版本', 'zh-CN': '版本', ja: 'リビジョン' })} | cwd | ${M(lang, { en: 'reason', 'zh-TW': '原因', 'zh-CN': '原因', ja: '理由' })} |`)
300 lines.push('|---|---|---|---|---|---|---|---|')
301 for (const r of log.tests) {
302 const result = r.result === 'pass'
303 ? M(lang, { en: 'pass', 'zh-TW': '通過', 'zh-CN': '通过', ja: '成功' })
304 : r.result === 'fail'
305 ? M(lang, { en: 'fail', 'zh-TW': '失敗', 'zh-CN': '失败', ja: '失敗' })
306 : M(lang, { en: 'unknown', 'zh-TW': '未知', 'zh-CN': '未知', ja: '不明' })
307 const rev = r.revision === null ? '?' : `${r.revision.slice(0, 7)}${r.isDirty === true ? '*' : r.isDirty === null ? '?' : ''}`
308 const kind = r.kind === 'package-script' && r.script ? `package-script (${cell(r.script)})` : r.kind
309 lines.push(`| ${code(r.command)} | ${kind} | ${result} | ${r.exitCode === null ? '-' : r.exitCode} | ${when(r.finishedAt, tz)} | ${rev} | ${code(r.cwd)} | ${r.reason ?? ''}${r.agentId ? `${r.reason ? ' ' : ''}subagent ${r.agentId}` : ''} |`)
310 }
311 lines.push('', M(lang, {
312 en: '`*` = the working tree had uncommitted changes when the command started; `?` = could not be read.',
313 'zh-TW': '`*` = 指令開始時工作區有未 commit 的變更;`?` = 讀不到。',
314 'zh-CN': '`*` = 指令开始时工作区有未 commit 的变更;`?` = 读不到。',
315 ja: '`*` = コマンド開始時に未コミットの変更あり、`?` = 取得不可。',
316 }))
317 }
318 lines.push('')
319
320 lines.push(`### Commits (${log.commits.length})`, '')
321 if (log.commits.length === 0) lines.push(M(lang, { en: '_none_', 'zh-TW': '_無_', 'zh-CN': '_无_', ja: '_なし_' }))
322 for (const c of log.commits) lines.push(`- ${code(c.sha.slice(0, 7))} ${c.kind} ${when(c.at, tz)}${c.agentId ? ` (subagent ${c.agentId})` : ''}`)
323 lines.push('')
324
325 if (log.dropped > 0) {
326 lines.push(M(lang, {
327 en: `Note: ${log.dropped} older or overflow entries were dropped by the size caps.`,
328 'zh-TW': `注意:${log.dropped} 筆因容量上限未收錄。`,
329 'zh-CN': `注意:${log.dropped} 条因容量上限未收录。`,
330 ja: `注意: 上限を超えた ${log.dropped} 件は記録されていません。`,
331 }), '')
332 }
333 }
334 lines.push('---', M(lang, {
335 en: 'Observed by flightwake-mod in this session only: changes made by other programs and runs outside this session are not included. Use the rows verbatim as fw-record\'s `tests:` evidence and change list; `unknown` rows are not evidence of passing.',
336 'zh-TW': '由 flightwake-mod 只在本 session 內觀測:其他程式造成的變更、本 session 以外的執行都不在內。可把這些列原樣作為 fw-record 的 `tests:` 證據與變更清單;`unknown`(未知)的列不是通過的證據。',
337 'zh-CN': '由 flightwake-mod 只在本 session 内观测:其他程序造成的变更、本 session 以外的执行都不在内。可把这些行原样作为 fw-record 的 `tests:` 证据与变更清单;`unknown`(未知)的行不是通过的证据。',
338 ja: 'flightwake-mod がこのセッション内でのみ観測したものです。他のプログラムによる変更やセッション外の実行は含まれません。各行は fw-record の `tests:` 証拠と変更一覧にそのまま使えます。`unknown` の行は成功の証拠ではありません。',
339 }), '', M(lang, {
340 en: '`pass` means: a directly called runner ran recognisably and returned 0. It cannot see config files or the outside environment that may keep tests from running (e.g. addopts in pytest.ini, a skip in a build profile), and it is not a guarantee that the tests themselves are meaningful.',
341 'zh-TW': '`pass`(通過)的意思是:直接呼叫的 runner 以可辨識的方式執行並回傳 0。它看不到設定檔或外部環境裡會讓測試不執行的設定(例如 pytest.ini 的 addopts、建置 profile 裡的略過設定),也不保證測試內容本身有效。',
342 'zh-CN': '`pass`(通过)的意思是:直接调用的 runner 以可辨识的方式执行并返回 0。它看不到配置文件或外部环境里会让测试不执行的设置(例如 pytest.ini 的 addopts、构建 profile 里的跳过设置),也不保证测试内容本身有效。',
343 ja: '`pass` の意味:直接呼び出した runner が判別できる形で実行され 0 を返したこと。設定ファイルや外部環境にあるテストを実行させない設定(pytest.ini の addopts、ビルド profile のスキップなど)は見えず、テスト内容そのものが有効である保証でもありません。',
344 }))
345 return lines.join('\n')
346}
347
348// ---------------------------------------------------------------------------------------------------------------
349// Registration
350// ---------------------------------------------------------------------------------------------------------------
351
352// Declares /fw-log: at session.start, and once more lazily on first use (a hot reload may not re-fire session.start).
353// Top-level so that `$` is passed only to a function declared at the top of this file.
354let isCommandRegistered = false
355async function ensureCommand($: EngineInterface): Promise<void> {
356 if (isCommandRegistered) return
357 isCommandRegistered = true
358 try {
359 const io = ioOf($)
360 const ctx = await fwContext(io)
361 if (ctx === null) {
362 isCommandRegistered = false // not installed here: no command (a later record in an installed root registers it)
363 return
364 }
365 const lang = ctx.lang
366 await $.command.register({
367 name: 'fw-log',
368 description: M(lang, {
369 en: 'Print this session\'s observed files, test runs and commits (flightwake-mod)',
370 'zh-TW': '印出本 session 觀測到的檔案變更、測試結果與 commit(flightwake-mod)',
371 'zh-CN': '打印本 session 观测到的文件变更、测试结果与 commit(flightwake-mod)',
372 ja: 'このセッションで観測したファイル変更・テスト結果・commit を表示(flightwake-mod)',
373 }),
374 })
375 } catch {
376 isCommandRegistered = false
377 }
378}
379
380
381export function registerRecorder(on: On): void {
382 on('session.start', {}, async ($, e, next) => {
383 await ensureCommand($)
384 return next(e)
385 })
386
387 on('command.run', { command: 'fw-log' }, async ($, e, next) => {
388 try {
389 const io = ioOf($)
390 const ctx = await fwContext(io)
391 if (ctx === null) return next(e) // not installed here: as if the command did not exist
392 const sid = await io.sessionId()
393 const cur = (await $.state.get({ plugin: 'flightwake-mod', key: 'flightLog' } as const)).value ?? null
394 const log = cur !== null && cur.sessionId === sid ? cur : null
395 // Local offset from the OS (read-only, the person's own clock settings); unknown → UTC only
396 let tz: TzOffset = null
397 try {
398 const r = await $.process.run(['date', '+%z'], { timeoutMs: 2000 })
399 if (r.exitCode === 0) tz = parseOffset(r.stdout)
400 } catch {}
401 return { text: renderLog(ctx.lang, log, tz) }
402 } catch {
403 return next(e)
404 }
405 })
406
407 on('tool.call', {}, async ($, e, next) => {
408 const tool = e.tool
409 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
410 const res = await next(e)
411 try {
412 if (res.deny === undefined && res.isError !== true) {
413 const io = ioOf($)
414 const root = await io.root()
415 const abs = tool === 'NotebookEdit' ? (e as { notebook_path?: string }).notebook_path : (e as { file_path?: string }).file_path
416 if (typeof abs === 'string' && abs) {
417 const path = root ? (relToRoot(root, abs) ?? abs) : abs
418 const at = await $.clock.now()
419 const agentId = e.agentId
420 await ensureCommand($)
421 await record($, io, (log) => touchFile(log, path, tool, at, agentId))
422 }
423 }
424 } catch {}
425 return res
426 }
427 if (tool !== 'Bash') return next(e)
428
429 // Bash: plan before it runs (revision / dirtiness must be those of the start), observe after.
430 const command = (e as { command?: string }).command
431 const isBackground = (e as { run_in_background?: boolean }).run_in_background === true
432 let plan: Plan | null = null
433 let revision: string | null = null
434 let isDirty: boolean | null = null
435 let startedAt = 0
436 try {
437 if (typeof command === 'string' && command.trim() && (await fwContext(ioOf($))) !== null) {
438 const io = ioOf($)
439 const root = await io.root()
440 let cwd0 = root
441 try { cwd0 = (await $.session.cwd()).replace(/\/+$/, '') || root } catch {}
442 plan = await planBash(io, root, cwd0, command)
443 if (plan !== null) {
444 revision = await io.git(['rev-parse', 'HEAD'], plan.cwdAbs)
445 const st = await io.git(['status', '--porcelain'], plan.cwdAbs)
446 isDirty = st === null ? null : st !== ''
447 startedAt = await $.clock.now()
448 }
449 }
450 } catch {
451 plan = null
452 }
453
454 const res = await next(e)
455 let hint: string | null = null
456
457 try {
458 if (res.deny === undefined) {
459 const io = ioOf($)
460 const finishedAt = await $.clock.now()
461 // Files the command wrote, as far as its words say (listed apart from tool edits; F4 reads the same words)
462 let shellWrites: Array<{ path: string; via: string }> = []
463 let lang: Lang = 'en'
464 if (typeof command === 'string' && command.trim()) {
465 const ctx = await fwContext(io)
466 if (ctx !== null) {
467 lang = ctx.lang
468 let cwd0 = ctx.root
469 try { cwd0 = (await $.session.cwd()).replace(/\/+$/, '') || ctx.root } catch {}
470 shellWrites = await confirmedByGit(io, ctx.root, shellCandidates(ctx.root, cwd0, command))
471 }
472 }
473 const agentId = e.agentId
474 const commit = res.isError === true ? undefined : (res.result as BashResultFields | undefined)?.gitOperation?.commit
475 const runs: FwTestRun[] = []
476 if (plan !== null && typeof command === 'string') {
477 const j = plan.judgment
478 const o = outcomeOf(j, observe(res, isBackground), plan.hasCdPrefix)
479 const run: FwTestRun = {
480 command: redact(command.trim()),
481 kind: j.kind,
482 cwd: plan.cwd,
483 startedAt,
484 finishedAt,
485 revision,
486 isDirty,
487 result: o.result,
488 exitCode: o.exitCode,
489 }
490 if (j.script !== undefined) run.script = redact(j.script, CAP_SCRIPT)
491 if (o.reason !== undefined) run.reason = o.reason
492 if (agentId !== undefined) run.agentId = agentId
493 runs.push(run)
494 }
495 const isChained = runs.some((r) => r.reason === 'compound')
496 if (runs.length > 0 || shellWrites.length > 0 || (commit !== undefined && commit.sha)) {
497 await ensureCommand($)
498 await record($, io, (log) => {
499 for (const w of shellWrites) touchShellFile(log, agentId === undefined ? { ...w, at: finishedAt } : { ...w, at: finishedAt, agentId })
500 if (isChained && log.isChainHintShown !== true) {
501 log.isChainHintShown = true
502 hint = chainHint(lang)
503 }
504 for (const r of runs) {
505 if (log.tests.length >= CAP_TESTS) log.dropped += 1
506 else log.tests.push(r)
507 }
508 if (commit !== undefined && commit.sha && !log.commits.some((c) => c.sha === commit.sha && c.kind === commit.kind)) {
509 if (log.commits.length >= CAP_COMMITS) log.dropped += 1
510 else log.commits.push(agentId === undefined ? { sha: commit.sha, kind: commit.kind, at: finishedAt } : { sha: commit.sha, kind: commit.kind, at: finishedAt, agentId })
511 }
512 })
513 }
514 }
515 } catch {}
516 // The chained-run note rides on this tool result (as F4's hints do): it states a fact and what to do, blocks nothing
517 if (hint === null || res.deny !== undefined) return res
518 return { ...res, context: [...(res.context ?? []), hint] }
519 })
520}
521hooks/features/tripwire.ts 239 lines1/**
2 * F4 TRAPS tripwires: when an Edit/Write/NotebookEdit or Bash call matches an active TRAPS entry's `paths` or
3 * `commands`, a compact hint is appended to the tool result's `context` (what the model reads after the result).
4 * It NEVER blocks: a denied call passes through untouched, and every failure falls through to the plain result.
5 * Contract: docs/plans/mods.md (revised) and the header of hooks/lib/core.ts. Read scope: .flightwake/TRAPS.md only.
6 */
7import { atom, update } from 'claude-code'
8import type { EngineInterface, On } from 'claude-code'
9
10import { M, fwContext, readRel, relToRoot, TRAPS_REL } from '../lib/core'
11import type { Io, Lang } from '../lib/core'
12import { matchAny } from '../lib/glob'
13import { parseCommand, startsWithTokens } from '../lib/shell'
14import { hasMatchers, isActive, parseTraps } from '../lib/traps'
15import type { TrapEntry } from '../lib/traps'
16
17const hinted = atom({ plugin: 'flightwake-mod', key: 'trapsHinted' } as const, null)
18
19const MAX_ENTRIES = 5
20const MAX_LINE = 300
21
22function ioOf($: EngineInterface): Io {
23 return {
24 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
25 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
26 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
27 read: async (p) => {
28 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
29 },
30 git: async (args, cwd) => {
31 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
32 },
33 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
34 }
35}
36
37type Hit = { entry: TrapEntry; via: string }
38
39const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null)
40
41/** A bash word that plausibly names a repo file: has `/` or `.`, isn't an option, a URL or a home shorthand. */
42function looksLikePath(t: string): boolean {
43 if (t.startsWith('-') || t.startsWith('~') || t.includes('://') || t.includes('=')) return false
44 return t.includes('/') || t.includes('.')
45}
46
47function matchFile(entries: TrapEntry[], root: string, filePath: string): Hit[] {
48 const rel = relToRoot(root, filePath)
49 if (rel === null || rel === '') return []
50 const out: Hit[] = []
51 for (const entry of entries) if (entry.paths.length && matchAny(entry.paths, rel) !== null) out.push({ entry, via: rel })
52 return out
53}
54
55/** At most this many possible cwds are tracked; past it, matching degrades to glob tails (no cwd at all). */
56const MAX_CWDS = 16
57
58/**
59 * Coarse match used once the cwd is no longer tracked: does `glob` match the path word itself or any tail of the
60 * glob match it (`pkg/src/**` → `src/**` → `**`)? Over-hints by design — F4 only hints, once per entry per session.
61 */
62function tailMatch(globs: readonly string[], word: string): boolean {
63 const w = word.replace(/^\.\//, '')
64 for (const g of globs) {
65 const segs = g.replace(/^\.?\//, '').split('/')
66 for (let k = 0; k < segs.length; k++) {
67 const tail = segs.slice(k).join('/')
68 if (tail && tail !== '**' && matchAny([tail], w) !== null) return true
69 }
70 }
71 return false
72}
73
74/** The file a redirection names (`>out`, `2>>log`, `&>f`, `<in`), or the word itself. '' for a bare operator. */
75const redirectTarget = (tok: string): string => tok.replace(/^(?:\d*|&)?[<>]>?/, '')
76
77/**
78 * Bash: command prefixes per segment, and path-like words resolved against every directory the shell could be in.
79 * F4 only hints and hints once per entry per session, so when the cwd is uncertain it over-hints rather than miss:
80 * - only a plain chain `cd X && …` is a certain move (the set of cwds is replaced);
81 * - a `cd` followed by `||`, `&`, `|`, `;` or a newline, or after any such operator, may or may not have moved this
82 * shell: the moved directories are ADDED to the set;
83 * - `( … )` subshells are walked: inside, cds apply as above; after the `)`, the set from before the `(` is back;
84 * - a `cd` that can't be resolved without guessing (`cd -`, `~`, `$VAR`, bare `cd`) leaves the set as it is;
85 * - the set is bounded (MAX_CWDS): past it, matching degrades to glob tails (tailMatch) for the rest, so
86 * the hint never costs more than one pass over the words — a long chain of uncertain cds can't blow it up.
87 */
88function matchBash(entries: TrapEntry[], root: string, cwd0: string, command: string): Hit[] {
89 const parsed = parseCommand(command)
90 const out = new Map<TrapEntry, string>()
91 let cwds: Set<string> | null = new Set<string>([cwd0]) // null = degraded: cwd no longer tracked
92 let isCertain = true
93 const stack: Array<{ cwds: Set<string> | null; isCertain: boolean }> = []
94 for (const seg of parsed.segments) {
95 if (seg.group === 'open') {
96 stack.push({ cwds: cwds === null ? null : new Set(cwds), isCertain })
97 continue
98 }
99 if (seg.group === 'close') {
100 const saved = stack.pop()
101 if (saved) ({ cwds, isCertain } = saved)
102 if (seg.op && seg.op !== '&&') isCertain = false
103 continue
104 }
105 for (const entry of entries) {
106 if (out.has(entry)) continue
107 const prefix = entry.commands.find((p) => startsWithTokens(seg.tokens, p))
108 if (prefix !== undefined) {
109 out.set(entry, prefix.trim().split(/\s+/).join(' '))
110 continue
111 }
112 if (!entry.paths.length) continue
113 search: for (const tok of seg.tokens) {
114 const word = redirectTarget(tok)
115 if (!word || !looksLikePath(word)) continue
116 if (word.startsWith('/')) {
117 const rel = relToRoot(root, word)
118 if (rel !== null && rel !== '' && matchAny(entry.paths, rel) !== null) { out.set(entry, rel); break search }
119 continue
120 }
121 if (cwds === null) {
122 if (tailMatch(entry.paths, word)) { out.set(entry, word); break search }
123 continue
124 }
125 for (const cwd of cwds) {
126 const rel = relToRoot(root, `${cwd}/${word}`)
127 if (rel !== null && rel !== '' && matchAny(entry.paths, rel) !== null) { out.set(entry, rel); break search }
128 }
129 }
130 }
131 if (seg.tokens[0] === 'cd' && cwds !== null) {
132 const args = seg.tokens[1] === '--' ? seg.tokens.slice(2) : seg.tokens.slice(1)
133 const dir = args[0]
134 const isResolvable = args.length === 1 && dir !== undefined && dir !== '-' && !dir.startsWith('~') && !dir.includes('$')
135 if (isResolvable) {
136 const moved = [...cwds].map((c) => (dir.startsWith('/') ? dir : `${c}/${dir}`))
137 const next: Set<string> = isCertain && seg.op === '&&' ? new Set(moved) : new Set([...cwds, ...moved])
138 cwds = next.size > MAX_CWDS ? null : next
139 }
140 }
141 if (seg.op && seg.op !== '&&') isCertain = false
142 }
143 return [...out].map(([entry, via]) => ({ entry, via }))
144}
145
146const clip = (s: string): string => (s.length > MAX_LINE ? `${s.slice(0, MAX_LINE)}…` : s)
147
148function render(lang: Lang, shown: Hit[], more: number): string {
149 const lines: string[] = []
150 let isLead = false
151 for (const { entry, via } of shown) {
152 if (entry.confidence !== 'confirmed') isLead = true
153 lines.push(
154 M(lang, {
155 en: `flightwake TRAPS: \`${entry.name}\` [${entry.confidence}] matches ${via}`,
156 'zh-TW': `flightwake TRAPS:\`${entry.name}\` [${entry.confidence}] 命中 ${via}`,
157 'zh-CN': `flightwake TRAPS:\`${entry.name}\` [${entry.confidence}] 命中 ${via}`,
158 ja: `flightwake TRAPS: \`${entry.name}\` [${entry.confidence}] が ${via} に該当`,
159 }),
160 )
161 const detail = [entry.labelled[1], entry.labelled[2]].filter((l): l is string => !!l)
162 if (detail.length) for (const l of detail) lines.push(` ${clip(l)}`)
163 else if (entry.body) lines.push(` ${clip(entry.body.replace(/\s+/g, ' '))}`)
164 }
165 if (more > 0) {
166 lines.push(M(lang, { en: `+${more} more`, 'zh-TW': `另有 ${more} 條`, 'zh-CN': `另有 ${more} 条`, ja: `ほか ${more} 件` }))
167 }
168 lines.push(M(lang, {
169 en: 'This note arrives after this call ran: it cannot stop this one; it is for the next time you touch this.',
170 'zh-TW': '這則提示在本次呼叫執行之後才出現:它擋不了這一次,是提醒你下一次碰到這裡時注意。',
171 'zh-CN': '这则提示在本次调用执行之后才出现:它拦不住这一次,是提醒你下一次碰到这里时注意。',
172 ja: 'この注意は今回の呼び出しが実行された後に届きます。今回は防げません。次にここに触れるときのためのものです。',
173 }))
174 lines.push(M(lang, { en: `Full entry: ${TRAPS_REL}`, 'zh-TW': `完整條目:${TRAPS_REL}`, 'zh-CN': `完整条目:${TRAPS_REL}`, ja: `全文: ${TRAPS_REL}` }))
175 if (isLead) {
176 lines.push(
177 M(lang, {
178 en: 'Note: probable/suspected/unknown entries are leads, not settled facts. Verify before relying on them, and never use one to argue that something is safe.',
179 'zh-TW': '注意:probable/suspected/unknown 的條目是線索,不是定論。採信前先驗證,也不可拿它來論證某件事是安全的。',
180 'zh-CN': '注意:probable/suspected/unknown 的条目是线索,不是定论。采信前先验证,也不可拿它来论证某件事是安全的。',
181 ja: '注意: probable/suspected/unknown のエントリは手がかりであり確定事項ではありません。依拠する前に検証し、安全性の根拠には使わないでください。',
182 }),
183 )
184 }
185 return lines.join('\n')
186}
187
188/** The hint to append for this call, or null. May take `$` because it is declared in this file. */
189async function hintFor($: EngineInterface, e: Record<string, unknown>): Promise<string | null> {
190 const tool = e.tool
191 const isFile = tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit'
192 if (!isFile && tool !== 'Bash') return null
193 const target = isFile ? (str(e.file_path) ?? str(e.notebook_path)) : str(e.command)
194 if (target === null) return null
195
196 const io = ioOf($)
197 const ctx = await fwContext(io)
198 if (ctx === null) return null
199 const text = await readRel(io, ctx.root, TRAPS_REL)
200 if (text === null) return null
201 const entries = parseTraps(text).filter((t) => isActive(t) && hasMatchers(t))
202 if (!entries.length) return null
203
204 let cwd0 = ctx.root
205 if (!isFile) {
206 try { cwd0 = (await $.session.cwd()).replace(/\/+$/, '') || ctx.root } catch {}
207 }
208 const hits = isFile ? matchFile(entries, ctx.root, target) : matchBash(entries, ctx.root, cwd0, target)
209 if (!hits.length) return null
210
211 const sessionId = await io.sessionId()
212 const key = (h: Hit) => `${h.entry.name}@${h.entry.version}`
213 let shown: Hit[] = []
214 let more = 0
215 await update($, hinted, (cur) => {
216 const seen = cur && cur.sessionId === sessionId ? cur.keys : []
217 const fresh = hits.filter((h) => !seen.includes(key(h)))
218 shown = fresh.slice(0, MAX_ENTRIES)
219 more = fresh.length - shown.length
220 return { sessionId, keys: [...seen, ...shown.map(key)] }
221 })
222 if (!shown.length) return null
223 return render(ctx.lang, shown, more)
224}
225
226export function registerTripwire(on: On): void {
227 on('tool.call', {}, async ($, e, next) => {
228 const r = await next(e)
229 if (r.deny !== undefined) return r
230 try {
231 const hint = await hintFor($, e as Record<string, unknown>)
232 if (hint !== null) return { ...r, context: [...(r.context ?? []), hint] }
233 } catch {
234 // a failing tripwire never touches the person's work
235 }
236 return r
237 })
238}
239hooks/features/role-guard.ts 289 lines1/**
2 * F5 — opt-in role guard (userConfig `roleGuard`, off by default).
3 *
4 * One machine-readable rule only: the role body's own `deny-write: [glob, …]` line (hooks/lib/roles.ts). It blocks
5 * the MAIN session's Edit/Write/NotebookEdit into those paths. Natural-language "Never" bullets are never compiled
6 * into rules, no shell blacklist, Bash/MCP/Read are never touched. Subagents (an explicit assignment) are not
7 * checked; a dispatch card at the start of a prompt replaces the seat role for this session. The role is a
8 * snapshot per session id (a /clear is a new id: re-read lazily on the next tool call); edits to the role files
9 * take effect in a new session. Release: `/fw-role-release`, accepted only from the person's own composer input.
10 * This is a convenience, not a security boundary — the deny message and docs/roles.md say so.
11 */
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, On } from 'claude-code'
14
15import type { FwRoleGuard } from '../../types'
16import { fwContext, M, readRel, relToRoot, type Io, type Lang } from '../lib/core'
17import { matchGlob } from '../lib/glob'
18import { parseCard, parseSeatBlock } from '../lib/roles'
19
20// Pasted from IO_OF_TEMPLATE (hooks/lib/core.ts): `$` cannot cross an import, so each feature file builds its own.
21function ioOf($: EngineInterface): Io {
22 return {
23 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
24 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
25 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
26 read: async (p) => {
27 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
28 },
29 git: async (args, cwd) => {
30 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
31 },
32 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
33 }
34}
35
36const guardAtom = atom({ plugin: 'flightwake-mod', key: 'roleGuard' } as const, null)
37
38/** Seat files in roles.mjs order (.claude/CLAUDE.md first); the first one holding a roles block wins. */
39const SEAT_FILES = ['.claude/CLAUDE.md', 'CLAUDE.md'] as const
40
41async function readSeat(io: Io, root: string, sid: string): Promise<FwRoleGuard> {
42 for (const rel of SEAT_FILES) {
43 const t = await readRel(io, root, rel)
44 if (t === null) continue
45 const seat = parseSeatBlock(t)
46 if (seat) return { sessionId: sid, root, role: seat.id, source: 'seat', denyWrite: seat.denyWrite, released: [], isAllReleased: false }
47 }
48 return { sessionId: sid, root, role: null, source: 'none', denyWrite: [], released: [], isAllReleased: false }
49}
50
51/**
52 * This session's snapshot for this root: the stored one when it carries the current (session id, root), else read
53 * the seat of this root and store it. Another root in the same session (a /cd, a worktree move) never inherits the
54 * old seat — nor its releases.
55 */
56async function ensureSnapshot($: EngineInterface, io: Io, root: string, sid: string): Promise<FwRoleGuard> {
57 const stored = await read($, guardAtom)
58 if (stored && stored.sessionId === sid && stored.root === root) return stored
59 const fresh = await readSeat(io, root, sid)
60 await update($, guardAtom, () => fresh)
61 return fresh
62}
63
64const isReleased = (g: FwRoleGuard, glob: string): boolean => g.isAllReleased === true || g.released.includes(glob)
65const hasReleases = (g: FwRoleGuard): boolean => g.isAllReleased === true || g.released.length > 0
66
67const sourceLabel = (lang: Lang, s: FwRoleGuard['source']): string =>
68 s === 'card'
69 ? M(lang, { en: 'dispatch card', 'zh-TW': '派工卡', 'zh-CN': '派活卡', ja: '割り振りカード' })
70 : M(lang, { en: 'seat', 'zh-TW': '座位', 'zh-CN': '座位', ja: '席' })
71
72function releasedText(lang: Lang, g: FwRoleGuard): string {
73 const what = g.isAllReleased === true
74 ? M(lang, { en: 'all rules', 'zh-TW': '全部規則', 'zh-CN': '全部规则', ja: 'すべてのルール' })
75 : g.released.join(', ')
76 return M(lang, {
77 en: `⚠ role guard released: ${what} (this session)`,
78 'zh-TW': `⚠ 角色守門已放行:${what}(本 session)`,
79 'zh-CN': `⚠ 角色守门已放行:${what}(本 session)`,
80 ja: `⚠ ロールガード解除中:${what}(この session)`,
81 })
82}
83
84/** Persistent status line while releases exist; cleared otherwise. */
85function showStatus($: EngineInterface, lang: Lang, g: FwRoleGuard): void {
86 try {
87 $.ui.status(hasReleases(g) ? releasedText(lang, g) : undefined)
88 } catch {}
89}
90
91/** A visible trace in the transcript (a notice the model never reads). Silent when the engine refuses it. */
92async function trace($: EngineInterface, text: string): Promise<void> {
93 try {
94 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
95 } catch {}
96}
97
98function listText(lang: Lang, g: FwRoleGuard): string {
99 const head = M(lang, {
100 en: `role guard — role: ${g.role}, from the ${sourceLabel(lang, g.source)}`,
101 'zh-TW': `角色守門——角色:${g.role},來源:${sourceLabel(lang, g.source)}`,
102 'zh-CN': `角色守门——角色:${g.role},来源:${sourceLabel(lang, g.source)}`,
103 ja: `ロールガード — ロール:${g.role}(${sourceLabel(lang, g.source)}由来)`,
104 })
105 const none = M(lang, { en: '(none)', 'zh-TW': '(無)', 'zh-CN': '(无)', ja: '(なし)' })
106 const rules = g.denyWrite.map((r, i) => {
107 const mark = isReleased(g, r) ? M(lang, { en: ' [released]', 'zh-TW': ' [已放行]', 'zh-CN': ' [已放行]', ja: ' [解除中]' }) : ''
108 return ` ${i + 1}. ${r}${mark}`
109 })
110 const usage = M(lang, {
111 en: 'Usage: /fw-role-release <glob|number> · all · revoke',
112 'zh-TW': '用法:/fw-role-release <glob|編號> · all · revoke',
113 'zh-CN': '用法:/fw-role-release <glob|编号> · all · revoke',
114 ja: '使い方:/fw-role-release <glob|番号> · all · revoke',
115 })
116 return [head, ...(rules.length ? rules : [` ${none}`]), usage].join('\n')
117}
118
119async function runRelease($: EngineInterface, io: Io, root: string, lang: Lang, args: string): Promise<string> {
120 const sid = await io.sessionId()
121 const g = await ensureSnapshot($, io, root, sid)
122 if (!g || g.role === null || g.denyWrite.length === 0) {
123 return M(lang, {
124 en: 'role guard: nothing to release (no role in force, or its role has no deny-write rules).',
125 'zh-TW': '角色守門:沒有可放行的項目(目前沒有角色,或該角色沒有 deny-write 規則)。',
126 'zh-CN': '角色守门:没有可放行的项目(当前没有角色,或该角色没有 deny-write 规则)。',
127 ja: 'ロールガード:解除するものはありません(ロールがないか、そのロールに deny-write ルールがありません)。',
128 })
129 }
130 const arg = args.trim()
131 if (arg === '') return listText(lang, g)
132
133 let next: string[]
134 let nextAll: boolean
135 let done: string
136 if (arg === 'revoke') {
137 next = []
138 nextAll = false
139 done = M(lang, {
140 en: 'role guard: releases revoked, rules apply again.',
141 'zh-TW': '角色守門:已收回放行,規則恢復生效。',
142 'zh-CN': '角色守门:已收回放行,规则恢复生效。',
143 ja: 'ロールガード:解除を取り消しました。ルールが再び有効です。',
144 })
145 } else {
146 const isAll = arg === 'all'
147 let glob: string | null = null
148 if (isAll) glob = null
149 else if (/^\d+$/.test(arg)) glob = g.denyWrite[Number(arg) - 1] ?? null
150 else glob = g.denyWrite.includes(arg) ? arg : null
151 if (!isAll && glob === null) {
152 return (
153 M(lang, {
154 en: `role guard: "${arg}" is not one of this role's rules.`,
155 'zh-TW': `角色守門:「${arg}」不是此角色的規則之一。`,
156 'zh-CN': `角色守门:「${arg}」不是此角色的规则之一。`,
157 ja: `ロールガード:「${arg}」はこのロールのルールではありません。`,
158 }) +
159 '\n' +
160 listText(lang, g)
161 )
162 }
163 nextAll = isAll || g.isAllReleased === true
164 next = isAll || glob === null ? [...g.released] : [...new Set([...g.released, glob])]
165 const label = isAll ? M(lang, { en: 'all rules', 'zh-TW': '全部規則', 'zh-CN': '全部规则', ja: 'すべてのルール' }) : (glob as string)
166 done = M(lang, {
167 en: `role guard: released ${label} for this session.`,
168 'zh-TW': `角色守門:本 session 已放行 ${label}。`,
169 'zh-CN': `角色守门:本 session 已放行 ${label}。`,
170 ja: `ロールガード:この session では ${label} を解除しました。`,
171 })
172 }
173 const after: FwRoleGuard = { ...g, released: next, isAllReleased: nextAll }
174 await update($, guardAtom, (cur) => (cur && cur.sessionId === sid && cur.root === root ? { ...cur, released: next, isAllReleased: nextAll } : after))
175 showStatus($, lang, after)
176 await trace($, done)
177 return done
178}
179
180export function registerRoleGuard(on: On): void {
181 on('session.start', {}, async ($, e, next) => {
182 try {
183 const io = ioOf($)
184 const ctx = await fwContext(io) // same install test as every feature: no .flightwake/STATE.md → no guard
185 if (ctx !== null) {
186 const { root, lang } = ctx
187 try {
188 await $.command.register({
189 name: 'fw-role-release',
190 description: M(lang, {
191 en: 'Release the role guard (deny-write rules) for this session',
192 'zh-TW': '放行本 session 的角色守門(deny-write 規則)',
193 'zh-CN': '放行本 session 的角色守门(deny-write 规则)',
194 ja: 'この session のロールガード(deny-write ルール)を解除する',
195 }),
196 argumentHint: '[glob|number|all|revoke]',
197 })
198 } catch {}
199 const sid = await io.sessionId()
200 // Reload keeps the stored snapshot (role and releases); a new session id re-reads the seat.
201 const g = await ensureSnapshot($, io, root, sid)
202 if (hasReleases(g)) showStatus($, lang, g)
203 }
204 } catch {}
205 return next(e)
206 })
207
208 on('prompt.submit', {}, async ($, e, next) => {
209 try {
210 if (e.origin?.kind !== 'plugin') {
211 const card = parseCard(e.text)
212 if (card) {
213 const io = ioOf($)
214 const sid = await io.sessionId()
215 const ctx = await fwContext(io)
216 if (ctx !== null) {
217 const { root, lang } = ctx
218 const g: FwRoleGuard = { sessionId: sid, root, role: card.id, source: 'card', denyWrite: card.denyWrite, released: [], isAllReleased: false }
219 await update($, guardAtom, () => g)
220 showStatus($, lang, g)
221 }
222 }
223 }
224 } catch {}
225 return next(e)
226 })
227
228 on('tool.call', {}, async ($, e, next) => {
229 try {
230 // Main loop only: a subagent (incl. a spawned on-call role) is an explicit assignment that overrides the seat.
231 if (e.agentId !== undefined) return next(e)
232 let target: string | undefined
233 if (e.tool === 'Edit' || e.tool === 'Write') target = e.file_path
234 else if (e.tool === 'NotebookEdit') target = e.notebook_path
235 if (typeof target !== 'string' || target === '') return next(e)
236
237 const io = ioOf($)
238 const ctx = await fwContext(io)
239 if (ctx === null) return next(e)
240 const { root, lang } = ctx
241 const rel = relToRoot(root, target)
242 if (rel === null || rel === '') return next(e)
243 const g = await ensureSnapshot($, io, root, await io.sessionId())
244 if (g.role === null || g.denyWrite.length === 0) return next(e)
245 // Every rule the path falls under must be released: releasing src/** never releases src/private/**.
246 const blocking = g.denyWrite.filter((r) => matchGlob(r, rel) && !isReleased(g, r))
247 if (blocking.length === 0) return next(e)
248 const glob = blocking.join(', ')
249 const how = blocking.map((r) => `/fw-role-release ${r}`).join(', ')
250
251 const src = sourceLabel(lang, g.source)
252 return {
253 deny: M(lang, {
254 en: `Role guard: this session's role "${g.role}" (from the ${src}) may not write ${rel} (rule deny-write: ${glob}). Dispatch the work to the role that owns it, or call an on-call role or a worker (\`npx flightwake roles card <id>\`); or ask the user to run ${how} for this session. This guard is a convenience, not a security boundary: Bash and other tools are not checked.`,
255 'zh-TW': `角色守門:本 session 的角色「${g.role}」(來自${src})不可寫入 ${rel}(規則 deny-write: ${glob})。請把這件事派給負責的角色,或召喚待命角色/用 \`npx flightwake roles card <id>\` 派給 worker;也可以請使用者對本 session 執行 ${how}。這道守門只是個方便,不是安全邊界:Bash 與其他工具不受檢查。`,
256 'zh-CN': `角色守门:本 session 的角色「${g.role}」(来自${src})不可写入 ${rel}(规则 deny-write: ${glob})。请把这件事派给负责的角色,或召唤待命角色/用 \`npx flightwake roles card <id>\` 派给 worker;也可以请用户对本 session 执行 ${how}。这道守门只是个方便,不是安全边界:Bash 与其他工具不受检查。`,
257 ja: `ロールガード:この session のロール「${g.role}」(${src}由来)は ${rel} を書き込めません(ルール deny-write: ${glob})。担当のロールに割り振るか、オンコールロールを呼ぶか、\`npx flightwake roles card <id>\` で worker に渡してください。または本人にこの session で ${how} を実行してもらってください。このガードは便宜であってセキュリティ境界ではありません:Bash などほかのツールは検査されません。`,
258 }),
259 }
260 } catch {
261 return next(e)
262 }
263 })
264
265 on('command.run', { command: 'fw-role-release' }, async ($, e, next) => {
266 try {
267 const io = ioOf($)
268 const ctx = await fwContext(io)
269 if (ctx === null) return next(e) // not installed here: as if the command did not exist
270 const lang = ctx.lang
271 // Only the person's own Enter at the prompt (origin composer). Everything else — a plugin, the bridge, the SDK,
272 // a peer, a schedule, and an unstamped origin — is refused: nothing the model can cause releases the guard.
273 if (e.origin?.kind !== 'composer') {
274 return {
275 text: M(lang, {
276 en: 'role guard: /fw-role-release only works when you type it yourself in the prompt. Nothing changed.',
277 'zh-TW': '角色守門:/fw-role-release 只接受你自己在輸入框打出的指令。未做任何變更。',
278 'zh-CN': '角色守门:/fw-role-release 只接受你自己在输入框里敲出的命令。未做任何更改。',
279 ja: 'ロールガード:/fw-role-release は、あなた自身がプロンプトに入力したときだけ有効です。何も変更していません。',
280 }),
281 }
282 }
283 return { text: await runRelease($, io, ctx.root, lang, e.args) }
284 } catch {
285 return next(e)
286 }
287 })
288}
289hooks/features/status.ts 239 lines1/**
2 * /fw-mod — what each of the eight features is doing right now and why (2026-10-05 field feedback: the mod is
3 * designed not to interrupt, so after installing nobody could tell whether it had loaded or which features were
4 * acting). Read-only: it reads what the features themselves read (STATE, TRAPS, the instruction-file markers and the
5 * seat block, settings) and the features' own $.state values; it changes nothing. Registered regardless of the eight
6 * switches — it is how you see them — but only in a folder where flightwake is installed.
7 */
8import type { EngineInterface, On } from 'claude-code'
9
10import type { FwFleet, FwFlightLog, FwRoleGuard, FwSpend, FwStateSnapshot } from '../../types'
11import { detectProfile, fwContext, isUninitializedState, legacyStatuslineActive, M, MOD_VERSION, readRel, STATE_REL, TRAPS_REL } from '../lib/core'
12import type { Io, Lang } from '../lib/core'
13import { parseSeatBlock } from '../lib/roles'
14import { hasMatchers, isActive, parseTraps } from '../lib/traps'
15import { fmtTokens, fmtUsd, totalTokens } from '../lib/spend'
16import { fleetModLine, T as FLEET_T } from '../lib/fleet'
17import { PANE_ID, STORE_KEY } from '../lib/panel'
18
19export type Switches = { stateInject: boolean; band: boolean; recorder: boolean; tripwire: boolean; roleGuard: boolean; spend: boolean; fleet: boolean; panel: boolean }
20
21// The Io closure over this file's `$` (IO_OF_TEMPLATE in hooks/lib/core.ts; `$` may not cross an import).
22function ioOf($: EngineInterface): Io {
23 return {
24 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
25 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
26 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
27 read: async (p) => {
28 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
29 },
30 git: async (args, cwd) => {
31 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
32 },
33 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
34 }
35}
36
37const SEAT_FILES = ['.claude/CLAUDE.md', 'CLAUDE.md'] as const
38// The loader holds every $.state reference to literal plugin/key values written in this file
39const SNAPSHOT_REF = { plugin: 'flightwake-mod', key: 'stateSnapshot' } as const
40const LOG_REF = { plugin: 'flightwake-mod', key: 'flightLog' } as const
41const GUARD_REF = { plugin: 'flightwake-mod', key: 'roleGuard' } as const
42const SPEND_REF = { plugin: 'flightwake-mod', key: 'spend' } as const
43const FLEET_REF = { plugin: 'flightwake-mod', key: 'fleet' } as const
44
45/** How to switch a feature on or off yourself (plugin options are not read from project settings). */
46const howTo = (lang: Lang, key: keyof Switches, value: boolean): string => {
47 const json = `"pluginConfigs": { "flightwake-mod@skills-dir": { "options": { "${key}": ${value} } } }`
48 return M(lang, {
49 en: `turn it ${value ? 'on' : 'off'} in /config, or in your user settings (~/.claude/settings.json): ${json}`,
50 'zh-TW': `在 /config ${value ? '開啟' : '關閉'},或寫進你的使用者設定(~/.claude/settings.json):${json}`,
51 'zh-CN': `在 /config ${value ? '开启' : '关闭'},或写进你的用户设置(~/.claude/settings.json):${json}`,
52 ja: `/config で${value ? 'オン' : 'オフ'}にするか、ユーザー設定(~/.claude/settings.json)に:${json}`,
53 })
54}
55const ON = (lang: Lang) => M(lang, { en: 'on', 'zh-TW': '開啟', 'zh-CN': '开启', ja: 'オン' })
56const OFF = (lang: Lang) => M(lang, { en: 'off', 'zh-TW': '關閉', 'zh-CN': '关闭', ja: 'オフ' })
57const IDLE = (lang: Lang) => M(lang, { en: 'idle', 'zh-TW': '閒置', 'zh-CN': '闲置', ja: '待機' })
58
59const LABEL: Record<keyof Switches, Record<Lang, string>> = {
60 stateInject: { en: 'STATE at session start', 'zh-TW': 'STATE 自動載入', 'zh-CN': 'STATE 自动载入', ja: 'STATE の自動読み込み' },
61 band: { en: 'band above the prompt', 'zh-TW': '輸入框上方橫條', 'zh-CN': '输入框上方横条', ja: '入力欄上の帯' },
62 recorder: { en: 'session flight log', 'zh-TW': 'session 行車記錄', 'zh-CN': 'session 行车记录', ja: 'セッション記録' },
63 tripwire: { en: 'trap tripwire', 'zh-TW': '踩坑絆線', 'zh-CN': '踩坑绊线', ja: '落とし穴の検知線' },
64 roleGuard: { en: 'role guard', 'zh-TW': '角色守門', 'zh-CN': '角色守门', ja: 'ロールガード' },
65 spend: { en: 'tokens and cost', 'zh-TW': 'token 與費用', 'zh-CN': 'token 与费用', ja: 'トークンとコスト' },
66 fleet: FLEET_T.label,
67 panel: { en: 'side panel', 'zh-TW': '側邊面板', 'zh-CN': '侧边面板', ja: 'サイドパネル' },
68}
69const line = (lang: Lang, key: keyof Switches, state: string, why: string): string => `- **${LABEL[key][lang]}** (\`${key}\`): ${state} — ${why}`
70
71async function statusText($: EngineInterface, sw: Switches): Promise<string | null> {
72 const io = ioOf($)
73 const ctx = await fwContext(io)
74 if (ctx === null) return null
75 const { root, lang } = ctx
76 const sid = await io.sessionId()
77 const state = await readRel(io, root, STATE_REL)
78 const out: string[] = []
79 out.push(`## flightwake-mod v${MOD_VERSION}`, '')
80 out.push(M(lang, {
81 en: `language: ${lang} · profile: ${await detectProfile(io, root)} · root: ${root}`,
82 'zh-TW': `語言(language): ${lang} · 類型(profile): ${await detectProfile(io, root)} · 根目錄: ${root}`,
83 'zh-CN': `语言(language): ${lang} · 类型(profile): ${await detectProfile(io, root)} · 根目录: ${root}`,
84 ja: `言語(language): ${lang} · 種別(profile): ${await detectProfile(io, root)} · ルート: ${root}`,
85 }), '')
86
87 // F1
88 if (!sw.stateInject) out.push(line(lang, 'stateInject', OFF(lang), howTo(lang, 'stateInject', true)))
89 else {
90 let snap: FwStateSnapshot | null = null
91 try { snap = (await $.state.get(SNAPSHOT_REF)).value ?? null } catch {}
92 const isThisSession = snap !== null && snap.sessionId === sid && snap.text !== null
93 out.push(line(lang, 'stateInject', ON(lang), state !== null && isUninitializedState(state)
94 ? M(lang, { en: 'STATE is not initialized yet, so only a "run /fw-coldstart" note is injected', 'zh-TW': 'STATE 尚未初始化,所以只注入「請先跑 /fw-coldstart」的提示', 'zh-CN': 'STATE 尚未初始化,所以只注入「请先跑 /fw-coldstart」的提示', ja: 'STATE が未初期化のため「まず /fw-coldstart」という注記だけを注入' })
95 : isThisSession
96 ? M(lang, { en: 'STATE was injected into this session (a snapshot taken at session start)', 'zh-TW': '已在本 session 注入 STATE(session 開始時取的快照)', 'zh-CN': '已在本 session 注入 STATE(session 开始时取的快照)', ja: 'このセッションに STATE を注入済み(セッション開始時のスナップショット)' })
97 : M(lang, { en: 'nothing injected yet in this session (it is taken when a session starts)', 'zh-TW': '本 session 尚未注入(在 session 開始時取得)', 'zh-CN': '本 session 尚未注入(在 session 开始时取得)', ja: 'このセッションではまだ注入していない(セッション開始時に取得)' })))
98 }
99
100 // F2
101 if (!sw.band) out.push(line(lang, 'band', OFF(lang), howTo(lang, 'band', true)))
102 else out.push(line(lang, 'band', ON(lang), (await legacyStatuslineActive(io))
103 ? M(lang, { en: 'hiding the fields the bottom gauge already shows (health / STATE lag / context); only the one-time 80% context toast remains', 'zh-TW': '偵測到底部儀表,隱藏儀表已顯示的欄位(health/STATE 落後/context),只保留 context 80% 時的一次提示', 'zh-CN': '检测到底部仪表,隐藏仪表已显示的栏位(health/STATE 落后/context),只保留 context 80% 时的一次提示', ja: '下部ゲージを検出したため、ゲージが表示する欄(health / STATE の遅れ / context)を隠し、context 80% の一度きりのトーストだけ残す' })
104 : M(lang, { en: 'shown above the prompt with health, STATE lag and context use (it stands in for the bottom gauge)', 'zh-TW': '顯示在輸入框上方:health、STATE 落後、context 用量(代替底部儀表)', 'zh-CN': '显示在输入框上方:health、STATE 落后、context 用量(代替底部仪表)', ja: '入力欄の上に health・STATE の遅れ・context 使用量を表示(下部ゲージの代わり)' })))
105
106 // F3
107 if (!sw.recorder) out.push(line(lang, 'recorder', OFF(lang), howTo(lang, 'recorder', true)))
108 else {
109 let raw: FwFlightLog | null = null
110 try { raw = (await $.state.get(LOG_REF)).value ?? null } catch {}
111 const log = raw !== null && raw.sessionId === sid ? raw : null
112 const n = { files: log?.files.length ?? 0, shell: log?.shellFiles?.length ?? 0, tests: log?.tests.length ?? 0, commits: log?.commits.length ?? 0 }
113 out.push(line(lang, 'recorder', ON(lang), M(lang, {
114 en: `this session: ${n.files} file(s) by tools, ${n.shell} via shell (inferred), ${n.tests} test run(s), ${n.commits} commit(s) — /fw-log prints them`,
115 'zh-TW': `本 session:工具改了 ${n.files} 個檔、shell 推斷 ${n.shell} 個、測試 ${n.tests} 次、commit ${n.commits} 個 — /fw-log 印出明細`,
116 'zh-CN': `本 session:工具改了 ${n.files} 个文件、shell 推断 ${n.shell} 个、测试 ${n.tests} 次、commit ${n.commits} 个 — /fw-log 打印明细`,
117 ja: `このセッション:ツールで ${n.files} 件、シェル推定 ${n.shell} 件、テスト ${n.tests} 回、commit ${n.commits} 件 — /fw-log で詳細`,
118 })))
119 }
120
121 // F4
122 if (!sw.tripwire) out.push(line(lang, 'tripwire', OFF(lang), howTo(lang, 'tripwire', true)))
123 else {
124 const traps = await readRel(io, root, TRAPS_REL)
125 const watched = traps === null ? 0 : parseTraps(traps).filter((t) => isActive(t) && hasMatchers(t)).length
126 out.push(watched === 0
127 ? line(lang, 'tripwire', IDLE(lang), M(lang, { en: 'no active TRAPS entry has paths or commands — add them to an entry to be warned (fw-trap explains the two fields)', 'zh-TW': 'TRAPS 裡沒有任何帶 paths 或 commands 的 active 條目 — 在條目加上這兩個欄位才會提示(fw-trap 有說明)', 'zh-CN': 'TRAPS 里没有任何带 paths 或 commands 的 active 条目 — 在条目加上这两个栏位才会提示(fw-trap 有说明)', ja: 'TRAPS に paths か commands を持つ active な項目がない — 項目にこの 2 欄を足すとヒントが出る(fw-trap に説明あり)' }))
128 : line(lang, 'tripwire', ON(lang), M(lang, { en: `watching ${watched} TRAPS entr${watched === 1 ? 'y' : 'ies'} with paths / commands`, 'zh-TW': `監看 ${watched} 條帶 paths / commands 的 TRAPS 條目`, 'zh-CN': `监看 ${watched} 条带 paths / commands 的 TRAPS 条目`, ja: `paths / commands を持つ TRAPS 項目 ${watched} 件を監視中` })))
129 }
130
131 // F5
132 const notBoundary = M(lang, { en: 'not a security boundary', 'zh-TW': '不是安全邊界', 'zh-CN': '不是安全边界', ja: 'セキュリティ境界ではない' })
133 if (!sw.roleGuard) out.push(line(lang, 'roleGuard', OFF(lang), `${M(lang, { en: 'opt-in', 'zh-TW': '選配', 'zh-CN': '选配', ja: 'オプトイン' })}; ${howTo(lang, 'roleGuard', true)} (${notBoundary})`))
134 else {
135 let snap: FwRoleGuard | null = null
136 try { snap = (await $.state.get(GUARD_REF)).value ?? null } catch {}
137 let role: string | null = null
138 let deny: string[] = []
139 let source = 'seat'
140 let released: string[] = []
141 if (snap !== null && snap.sessionId === sid && snap.root === root) {
142 role = snap.role; deny = snap.denyWrite; source = snap.source; released = snap.isAllReleased ? ['*all*'] : snap.released
143 } else {
144 for (const rel of SEAT_FILES) {
145 const t = await readRel(io, root, rel)
146 const seat = t === null ? null : parseSeatBlock(t)
147 if (seat) { role = seat.id; deny = seat.denyWrite; break }
148 }
149 }
150 if (role === null) out.push(line(lang, 'roleGuard', IDLE(lang), M(lang, { en: "this folder's Claude seat has no role (roles apply writes one into CLAUDE.md)", 'zh-TW': '這個資料夾的 Claude 座位沒有角色(roles apply 會寫進 CLAUDE.md)', 'zh-CN': '这个文件夹的 Claude 座位没有角色(roles apply 会写进 CLAUDE.md)', ja: 'このフォルダーの Claude の席にロールがない(roles apply が CLAUDE.md に書く)' })))
151 else if (deny.length === 0) out.push(line(lang, 'roleGuard', IDLE(lang), M(lang, { en: `role ${role} has no deny-write line, so there is nothing to guard`, 'zh-TW': `角色 ${role} 沒有 deny-write,沒有要守的路徑`, 'zh-CN': `角色 ${role} 没有 deny-write,没有要守的路径`, ja: `ロール ${role} に deny-write がなく、守る対象がない` })))
152 else out.push(line(lang, 'roleGuard', ON(lang), M(lang, {
153 en: `role ${role} (${source}) blocks Edit/Write into ${deny.join(', ')}${released.length ? `; released this session: ${released.join(', ')}` : ''} (${notBoundary})`,
154 'zh-TW': `角色 ${role}(${source})擋下 Edit/Write 寫入 ${deny.join(', ')}${released.length ? `;本 session 已放行:${released.join(', ')}` : ''}(${notBoundary})`,
155 'zh-CN': `角色 ${role}(${source})挡下 Edit/Write 写入 ${deny.join(', ')}${released.length ? `;本 session 已放行:${released.join(', ')}` : ''}(${notBoundary})`,
156 ja: `ロール ${role}(${source})が ${deny.join(', ')} への Edit/Write を止める${released.length ? `。このセッションで解除:${released.join(', ')}` : ''}(${notBoundary})`,
157 })))
158 }
159 // F6
160 if (!sw.spend) out.push(line(lang, 'spend', OFF(lang), howTo(lang, 'spend', true)))
161 else {
162 let s: FwSpend | null = null
163 try { s = (await $.state.get(SPEND_REF)).value ?? null } catch {}
164 const cur = s !== null && s.sessionId === sid ? s : null
165 const tok = cur?.tokens ? fmtTokens(totalTokens(cur.tokens)) : null
166 const usd = cur !== null && cur.usd !== null ? fmtUsd(cur.usd) : null
167 out.push(line(lang, 'spend', ON(lang), cur === null
168 ? M(lang, { en: 'no figure yet this session (taken after each turn) — /fw-cost prints the breakdown', 'zh-TW': '本 session 尚無數字(每回合結束後更新)— /fw-cost 印出明細', 'zh-CN': '本 session 尚无数字(每回合结束后更新)— /fw-cost 打印明细', ja: 'このセッションの数値はまだない(各ターン後に更新)— /fw-cost で詳細' })
169 : M(lang, {
170 en: `this session: ${tok === null ? 'tokens unknown' : `${tok} tokens`} · API-equivalent ${usd === null ? 'cost unknown' : `≈ ${usd}`} — /fw-cost prints the breakdown`,
171 'zh-TW': `本 session:${tok === null ? 'token 不明' : `${tok} tokens`} · API 等值 ${usd === null ? '費用不明' : `≈ ${usd}`} — /fw-cost 印出明細`,
172 'zh-CN': `本 session:${tok === null ? 'token 不明' : `${tok} tokens`} · API 等值 ${usd === null ? '费用不明' : `≈ ${usd}`} — /fw-cost 打印明细`,
173 ja: `このセッション:${tok === null ? 'トークン不明' : `${tok} tokens`} · API 換算 ${usd === null ? 'コスト不明' : `≈ ${usd}`} — /fw-cost で詳細`,
174 })))
175 }
176 // F7
177 if (!sw.fleet) out.push(line(lang, 'fleet', OFF(lang), howTo(lang, 'fleet', true)))
178 else {
179 let f: FwFleet | null = null
180 try { f = (await $.state.get(FLEET_REF)).value ?? null } catch {}
181 let home: string | null = null
182 try { home = (await $.env.get('HOME')) || null } catch { home = null }
183 if (home === null) { try { home = (await $.env.get('USERPROFILE')) || null } catch { home = null } }
184 const fl = fleetModLine(lang, f, home)
185 out.push(line(lang, 'fleet', fl.isOn ? ON(lang) : IDLE(lang), fl.text))
186 }
187 // F8
188 if (!sw.panel) out.push(line(lang, 'panel', OFF(lang), howTo(lang, 'panel', true)))
189 else {
190 let isOpen = false
191 let isRemembered = false
192 try { isOpen = (await $.ui.panes()).some((p) => p.id === PANE_ID) } catch {}
193 try { isRemembered = (await $.store.get(STORE_KEY)) === true } catch {}
194 out.push(line(lang, 'panel', isOpen ? ON(lang) : IDLE(lang), isOpen
195 ? M(lang, { en: 'open in this session; it reopens in later sessions until you close it — /fw-panel closes it', 'zh-TW': '本 session 已開啟;之後的 session 會自動再開,直到你關掉 — /fw-panel 關閉', 'zh-CN': '本 session 已打开;之后的 session 会自动再开,直到你关掉 — /fw-panel 关闭', ja: 'このセッションで表示中。閉じるまで以後のセッションでも自動で開く — /fw-panel で閉じる' })
196 : isRemembered
197 ? M(lang, { en: 'remembered as open, but not open now (it opens at session start) — /fw-panel opens it', 'zh-TW': '記得是開著的,但目前沒開(session 開始時才自動開)— /fw-panel 開啟', 'zh-CN': '记得是开着的,但目前没开(session 开始时才自动开)— /fw-panel 打开', ja: '開いた状態を記憶しているが今は閉じている(セッション開始時に開く)— /fw-panel で開く' })
198 : M(lang, { en: 'closed; it opens only when you ask — /fw-panel opens it beside the transcript (from 110 columns)', 'zh-TW': '未開啟;只有你要求才開 — /fw-panel 在對話旁開啟(終端機 110 欄以上)', 'zh-CN': '未打开;只有你要求才开 — /fw-panel 在对话旁打开(终端 110 栏以上)', ja: '閉じている。頼まれたときだけ開く — /fw-panel で会話の横に開く(110 列以上)' })))
199 }
200 out.push('', M(lang, { en: 'Read-only: /fw-mod changes nothing.', 'zh-TW': '唯讀:/fw-mod 不改任何東西。', 'zh-CN': '只读:/fw-mod 不改任何东西。', ja: '読み取り専用:/fw-mod は何も変更しない。' }))
201 return out.join('\n')
202}
203
204let isCommandRegistered = false
205async function ensureCommand($: EngineInterface): Promise<void> {
206 if (isCommandRegistered) return
207 isCommandRegistered = true
208 try {
209 const ctx = await fwContext(ioOf($))
210 if (ctx === null) { isCommandRegistered = false; return }
211 await $.command.register({
212 name: 'fw-mod',
213 description: M(ctx.lang, {
214 en: 'Show what each flightwake-mod feature is doing and why (read-only)',
215 'zh-TW': '列出 flightwake-mod 各功能的狀態與原因(唯讀)',
216 'zh-CN': '列出 flightwake-mod 各功能的状态与原因(只读)',
217 ja: 'flightwake-mod の各機能の状態と理由を表示(読み取り専用)',
218 }),
219 })
220 } catch {
221 isCommandRegistered = false
222 }
223}
224
225export function registerStatus(on: On, switches: Switches): void {
226 on('session.start', {}, async ($, e, next) => {
227 await ensureCommand($)
228 return next(e)
229 })
230 on('command.run', { command: 'fw-mod' }, async ($, e, next) => {
231 try {
232 const text = await statusText($, switches)
233 return text === null ? next(e) : { text }
234 } catch {
235 return next(e)
236 }
237 })
238}
239hooks/features/spend.ts 174 lines1/**
2 * F6 spend — what this session has used: tokens (summed from the session's transcript, one count per API response)
3 * and the API-equivalent cost in US dollars (the engine's own ledger, the figure /cost and the status line total;
4 * on a subscription it is what the same traffic would have cost on the API, not a bill).
5 * Refreshed on session.start and on every session.measure (the engine pushes it after each main-thread turn);
6 * the band and /fw-mod only read the stored FwSpend, and `/fw-cost` prints the breakdown. Reads only; never writes.
7 */
8import { atom, update } from 'claude-code'
9import type { EngineInterface, On } from 'claude-code'
10
11import { fwContext, M } from '../lib/core'
12import type { Io, Lang } from '../lib/core'
13import { emptyTotals, fmtTokens, fmtUsd, sumTranscriptUsage, totalTokens, transcriptPath } from '../lib/spend'
14import type { TokenTotals } from '../lib/spend'
15import type { FwSpend } from '../../types/index'
16
17const spendRef = atom({ plugin: 'flightwake-mod', key: 'spend' } as const, null)
18
19// The Io closure over this file's `$` (IO_OF_TEMPLATE in hooks/lib/core.ts; `$` may not cross an import).
20function ioOf($: EngineInterface): Io {
21 return {
22 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
23 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
24 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
25 read: async (p) => {
26 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
27 },
28 git: async (args, cwd) => {
29 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
30 },
31 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
32 }
33}
34
35/** The launch directory, which keys the transcript folder (session.start's `cwd`; the op as a fallback). */
36let launchCwd: string | null = null
37
38async function readTotals($: EngineInterface, io: Io): Promise<TokenTotals | null> {
39 try {
40 let home: string | undefined
41 try { home = await $.env.get('HOME') } catch { home = undefined }
42 if (!home) { try { home = await $.env.get('USERPROFILE') } catch { home = undefined } }
43 const cwd = launchCwd ?? (await (async () => { try { return await $.session.cwd() } catch { return '' } })())
44 const p = transcriptPath(home ?? '', cwd, await io.sessionId())
45 if (p === null) return null
46 const text = await io.read(p)
47 return text === null ? null : sumTranscriptUsage(text)
48 } catch {
49 return null
50 }
51}
52
53/** Recompute and store; usd from the figures the engine handed us (a measure event) or from the op. Never throws. */
54async function refresh($: EngineInterface, usdHint?: number | null): Promise<void> {
55 try {
56 const io = ioOf($)
57 if ((await fwContext(io)) === null) return
58 let usd: number | null = typeof usdHint === 'number' && Number.isFinite(usdHint) ? usdHint : null
59 let model: string | null = null
60 if (usd === null) {
61 try {
62 const u = await $.session.usage()
63 usd = typeof u.cost?.usd === 'number' && Number.isFinite(u.cost.usd) ? u.cost.usd : null
64 } catch { usd = null }
65 }
66 try { model = await $.session.model() } catch { model = null }
67 const totals = await readTotals($, io)
68 const next: FwSpend = { sessionId: await io.sessionId(), usd, tokens: totals, model, at: Date.now() }
69 await update($, spendRef, () => next)
70 } catch {}
71}
72
73function costText(lang: Lang, s: FwSpend): string {
74 const t = s.tokens ?? emptyTotals()
75 const out: string[] = []
76 out.push(`## /fw-cost`, '')
77 out.push(M(lang, {
78 en: `**API-equivalent cost this session: ${s.usd === null ? 'unknown (the host keeps no cost ledger)' : `≈ ${fmtUsd(s.usd)}`}**${s.model ? ` · model ${s.model}` : ''}`,
79 'zh-TW': `**本 session 的 API 等值費用:${s.usd === null ? '不明(這個環境沒有費用帳)' : `≈ ${fmtUsd(s.usd)}`}**${s.model ? ` · 模型 ${s.model}` : ''}`,
80 'zh-CN': `**本 session 的 API 等值费用:${s.usd === null ? '不明(这个环境没有费用账)' : `≈ ${fmtUsd(s.usd)}`}**${s.model ? ` · 模型 ${s.model}` : ''}`,
81 ja: `**このセッションの API 換算コスト:${s.usd === null ? '不明(この環境にはコスト台帳がない)' : `≈ ${fmtUsd(s.usd)}`}**${s.model ? ` · モデル ${s.model}` : ''}`,
82 }), '')
83 if (s.tokens === null) {
84 out.push(M(lang, {
85 en: 'Tokens: unknown — the session transcript could not be read (expected under ~/.claude/projects/).',
86 'zh-TW': 'Token:不明——讀不到 session transcript(預期在 ~/.claude/projects/ 下)。',
87 'zh-CN': 'Token:不明——读不到 session transcript(预期在 ~/.claude/projects/ 下)。',
88 ja: 'トークン:不明——セッションの transcript が読めない(~/.claude/projects/ 配下のはず)。',
89 }))
90 } else {
91 const head = M(lang, { en: '| tokens | count |', 'zh-TW': '| token | 數量 |', 'zh-CN': '| token | 数量 |', ja: '| トークン | 数 |' })
92 out.push(head, '|---|---:|')
93 const row = (label: Record<Lang, string>, n: number) => out.push(`| ${M(lang, label)} | ${n.toLocaleString('en-US')} |`)
94 row({ en: 'input (uncached)', 'zh-TW': '輸入(未快取)', 'zh-CN': '输入(未缓存)', ja: '入力(キャッシュ外)' }, t.input)
95 row({ en: 'cache write', 'zh-TW': '寫入快取', 'zh-CN': '写入缓存', ja: 'キャッシュ書き込み' }, t.cacheWrite)
96 row({ en: 'cache read', 'zh-TW': '讀取快取', 'zh-CN': '读取缓存', ja: 'キャッシュ読み取り' }, t.cacheRead)
97 row({ en: 'output', 'zh-TW': '輸出', 'zh-CN': '输出', ja: '出力' }, t.output)
98 row({ en: '**total**', 'zh-TW': '**合計**', 'zh-CN': '**合计**', ja: '**合計**' }, totalTokens(t))
99 out.push('', M(lang, {
100 en: `${t.responses} API response(s) counted from the main transcript; subagents run in their own transcripts and are not included here (their cost is in the dollar figure).`,
101 'zh-TW': `從主 transcript 數到 ${t.responses} 次 API 回應;子 agent 有自己的 transcript,token 不計在這裡(費用已含在金額裡)。`,
102 'zh-CN': `从主 transcript 数到 ${t.responses} 次 API 响应;子 agent 有自己的 transcript,token 不计在这里(费用已含在金额里)。`,
103 ja: `メイン transcript から ${t.responses} 件の API 応答を集計。サブエージェントは別 transcript のためここには含まない(金額には含まれる)。`,
104 }))
105 }
106 out.push('', M(lang, {
107 en: 'The dollar figure is Claude Code\'s own ledger (what /cost shows) at API list prices; on a subscription it is not a bill. Read-only: /fw-cost changes nothing.',
108 'zh-TW': '金額是 Claude Code 自己的帳(與 /cost 相同),按 API 牌價計;訂閱用戶不會另外被收這筆。唯讀:/fw-cost 不改任何東西。',
109 'zh-CN': '金额是 Claude Code 自己的账(与 /cost 相同),按 API 牌价计;订阅用户不会另外被收这笔。只读:/fw-cost 不改任何东西。',
110 ja: '金額は Claude Code 自身の台帳(/cost と同じ)で API 定価換算。サブスクリプションでは請求ではない。読み取り専用:/fw-cost は何も変更しない。',
111 }))
112 return out.join('\n')
113}
114
115let isCommandRegistered = false
116async function ensureCommand($: EngineInterface): Promise<void> {
117 if (isCommandRegistered) return
118 isCommandRegistered = true
119 try {
120 const ctx = await fwContext(ioOf($))
121 if (ctx === null) { isCommandRegistered = false; return }
122 await $.command.register({
123 name: 'fw-cost',
124 description: M(ctx.lang, {
125 en: 'Tokens used this session and the API-equivalent cost (read-only)',
126 'zh-TW': '本 session 用了多少 token、API 等值多少錢(唯讀)',
127 'zh-CN': '本 session 用了多少 token、API 等值多少钱(只读)',
128 ja: 'このセッションのトークン使用量と API 換算コスト(読み取り専用)',
129 }),
130 })
131 } catch {
132 isCommandRegistered = false
133 }
134}
135
136/** One line for the band: "1.2M tok · ≈$4.99"; '' when nothing is known. */
137export function spendLine(s: FwSpend | null): string {
138 if (!s) return ''
139 const parts: string[] = []
140 if (s.tokens !== null) parts.push(`${fmtTokens(totalTokens(s.tokens))} tok`)
141 if (s.usd !== null) parts.push(`≈${fmtUsd(s.usd)}`)
142 return parts.join(' · ')
143}
144
145export function registerSpend(on: On): void {
146 on('session.start', {}, async ($, e, next) => {
147 launchCwd = e.cwd
148 const r = await next(e)
149 await ensureCommand($)
150 await refresh($)
151 return r
152 })
153
154 on('session.measure', {}, async ($, e, next) => {
155 const r = await next(e)
156 await refresh($, typeof e.cost?.usd === 'number' ? e.cost.usd : null)
157 return r
158 })
159
160 on('command.run', { command: 'fw-cost' }, async ($, e, next) => {
161 try {
162 const io = ioOf($)
163 const ctx = await fwContext(io)
164 if (ctx === null) return next(e)
165 await refresh($)
166 const cur = (await $.state.get({ plugin: 'flightwake-mod', key: 'spend' })).value ?? null
167 const s = cur !== null && cur.sessionId === (await io.sessionId()) ? cur : null
168 return { text: costText(ctx.lang, s ?? { sessionId: '', usd: null, tokens: null, model: null, at: 0 }) }
169 } catch {
170 return next(e)
171 }
172 })
173}
174hooks/features/fleet.ts 940 lines1/**
2 * F7 fleet — this session reports its state (working / blocked / idle / ended) to `<base>/sessions/<id>.json`,
3 * outside the repo (base = an absolute FLIGHTWAKE_HOME, else ~/.flightwake), for /fw-fleet, `npx flightwake fleet`,
4 * the bottom gauge (hooks/statusline.mjs) and other tools (Orca). The question it answers is "who needs me".
5 *
6 * The one write the mod makes, and the only file in hooks/ that may call `$.fs.write` (a smoke grep enforces it):
7 * refused when the base lies inside the session root, by spelling or by real path (re-checked at every write: the
8 * root follows /cd), or is a flightwake records folder or lies in any git repo or flightwake root; never through a
9 * symbolic link, never over a file that is not ours (both re-checked at every heartbeat), never for a -p / SDK run;
10 * a guard that cannot get an answer refuses. Never written: prompt, command, question, option or plan text, URLs,
11 * tool_input, paths outside the root, git remote or branch, model, cost. No git at all.
12 *
13 * blocked means a person is needed now: an unanswered classic.PermissionRequest (no settings hook beneath answered,
14 * so the dialog is up) or a main-loop AskUserQuestion / ExitPlanMode in flight. A tool.check `ask` alone is not proof
15 * (it may go to the auto-mode classifier or a headless decider); it is kept only to pair the dialog with its call.
16 * The evidence lives in module memory on purpose: a reload drops it, since a dialog cannot be proven after one.
17 * State machine, writer policy and file schema: the F7 spec (docs/mod.md "Fleet file contract").
18 */
19import type { EngineInterface, On, Timer } from 'claude-code'
20
21import { detectVersion, fwContext, MOD_VERSION, readRel, relToRoot } from '../lib/core'
22import { parseSeatBlock, SEAT_FILES } from '../lib/roles'
23import type { Io, Lang } from '../lib/core'
24import { transcriptPath } from '../lib/spend'
25import {
26 CLOCK_SKEW_MS, ENDED_CAP, ENDED_CAP_ALL, fleetBase, fleetFilePath, fleetReport, fmtAge, HEARTBEAT_MS, HINT_DELAY_MS,
27 HINT_TOAST_MS, hintCandidates, inputPrint, isOursText, isSafeId, LIST_WINDOW_ALL_MS, LIST_WINDOW_MS, lastSeen,
28 notReportingReason, parseRec, pickHint, recordText, repoMarkersOf, resumableOf, resumeCmd, rowOf, schemaMajor, selectFiles, selfAndAncestors,
29 FLEET_SINCE, sessionsDirOf, staleAfter, STALE_FLOOR_MS, T, targetOf, tildePath, transcriptOf, turnReason, countOthers, otherRows, deriveState, SCHEMA,
30} from '../lib/fleet'
31import type { Activity, BlockedKind, FleetEnv, Host, ListEntry, SessionRec } from '../lib/fleet'
32import type { FwFleet, FwFleetStatus } from '../../types/index'
33
34// The loader holds every $.state reference to literal plugin/key values written in this file
35const FLEET_REF = { plugin: 'flightwake-mod', key: 'fleet' } as const
36
37/** A guard's answer: 'unknown' (the call was refused or failed other than ENOENT) refuses, like 'yes'. */
38type Tri = 'yes' | 'no' | 'unknown'
39
40type FleetIo = Io & {
41 /** $.fs.write; null on success, else the error's code (or name). The mod's only write. */
42 write: (abs: string, text: string) => Promise<string | null>
43 /** $.fs.list; null when the folder is missing or unreadable. */
44 list: (abs: string) => Promise<ListEntry[] | null>
45 /** Whether the path itself is a symbolic link: 'no' only when it is not one or is missing (ENOENT). */
46 isLink: (abs: string) => Promise<Tri>
47 /** Whether the path exists: 'unknown' when $.fs.exists was refused. */
48 probe: (abs: string) => Promise<Tri>
49 /**
50 * Where the path really lands: the realPath of its deepest existing folder (`{ resolve: true }`), the rest of
51 * the path appended; null when it cannot be placed (no realPath, or a stat refused other than ENOENT).
52 */
53 place: (abs: string) => Promise<string | null>
54}
55
56/** A rejection that says the path is missing (the engine's ENOENT), as opposed to a refusal. */
57function isEnoent(err: unknown): boolean {
58 const x = err as { code?: unknown; message?: unknown } | null
59 if (x !== null && typeof x === 'object' && x.code === 'ENOENT') return true
60 const text = x !== null && typeof x === 'object' && typeof x.message === 'string' ? x.message : typeof err === 'string' ? err : ''
61 return /\bENOENT\b/.test(text)
62}
63
64// The Io closure over this file's `$` (IO_OF_TEMPLATE in hooks/lib/core.ts; `$` may not cross an import), plus the
65// fleet's own write / list / link check. F7 runs no git and reads no settings.
66function fleetIoOf($: EngineInterface): FleetIo {
67 return {
68 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
69 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
70 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
71 read: async (p) => {
72 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
73 },
74 git: async () => null,
75 settings: async () => ({}),
76 write: async (p, text) => {
77 try { await $.fs.write(p, text); return null } catch (err) {
78 const x = err as { code?: unknown; name?: unknown } | null
79 return typeof x?.code === 'string' && x.code ? x.code : typeof x?.name === 'string' && x.name ? x.name : 'error'
80 }
81 },
82 list: async (p) => {
83 try {
84 const es = await $.fs.list(p)
85 return Array.isArray(es) ? es.map((e) => ({ name: e.name, kind: e.kind, isLink: e.isLink === true, mtimeMs: e.mtimeMs })) : null
86 } catch { return null }
87 },
88 isLink: async (p) => {
89 try { return (await $.fs.stat(p)).isLink === true ? 'yes' : 'no' } catch (err) { return isEnoent(err) ? 'no' : 'unknown' }
90 },
91 probe: async (p) => { try { return (await $.fs.exists(p)) ? 'yes' : 'no' } catch { return 'unknown' } },
92 place: async (p) => {
93 const full = p.length > 1 ? p.replace(/[\\/]+$/, '') : p
94 for (const a of selfAndAncestors(full)) {
95 let real: string | undefined
96 try {
97 real = (await $.fs.stat(a, { resolve: true })).realPath
98 } catch (err) {
99 if (isEnoent(err)) continue
100 return null
101 }
102 if (typeof real !== 'string' || real === '') return null
103 const rest = a === full ? '' : a === '/' ? full.slice(1) : full.slice(a.length + 1)
104 return rest === '' ? real : `${real.replace(/[\\/]+$/, '')}/${rest}`
105 }
106 return null
107 },
108 }
109}
110
111/** The env F7 reads, each by its literal name (an unanswered read counts as unset). */
112async function readEnv($: EngineInterface): Promise<FleetEnv & { host: Host }> {
113 let FLIGHTWAKE_HOME: string | undefined
114 let HOME: string | undefined
115 let USERPROFILE: string | undefined
116 try { FLIGHTWAKE_HOME = await $.env.get('FLIGHTWAKE_HOME') } catch { FLIGHTWAKE_HOME = undefined }
117 try { HOME = await $.env.get('HOME') } catch { HOME = undefined }
118 try { USERPROFILE = await $.env.get('USERPROFILE') } catch { USERPROFILE = undefined }
119 const host: Host = {}
120 try { const v = await $.env.get('ORCA_TERMINAL_HANDLE'); if (typeof v === 'string' && v !== '') host.orcaTerminal = v } catch {}
121 try { const v = await $.env.get('ORCA_WORKSPACE_ID'); if (typeof v === 'string' && v !== '') host.orcaWorkspace = v } catch {}
122 try { const v = await $.env.get('TMUX_PANE'); if (typeof v === 'string' && v !== '') host.tmuxPane = v } catch {}
123 return { FLIGHTWAKE_HOME, HOME, USERPROFILE, host }
124}
125
126async function clockNow($: EngineInterface): Promise<number> {
127 try { const t = await $.clock.now(); return typeof t === 'number' && Number.isFinite(t) ? t : Date.now() } catch { return Date.now() }
128}
129
130// ── module memory (a reload drops it on purpose) ───────────────────────────────────────────────────────────────
131type Evidence = { kind: BlockedKind; tool: string; target: string | null; agent: boolean; since: number }
132
133/** This process's FwFleet; mirrored into $.state after every change (the copy /fw-mod reads and a reload restores). */
134let F: FwFleet | null = null
135/**
136 * Real-call `ask` verdicts by tool_use_id, kept only to pair a PermissionRequest (which has no id) with its call:
137 * by tool name and a fingerprint of the input (module memory only, never written).
138 */
139const asks = new Map<string, { tool: string; at: number; paired: boolean; fp: string | null }>()
140/** Open proof that a person is needed: tool_use_id (or `perm:n`) → what. */
141const evidence = new Map<string, Evidence>()
142/** Tool calls inside next(e), main loop and subagents together. */
143const inFlight = new Map<string, { tool: string; target: string | null; agent: boolean; at: number }>()
144let activity: Activity | null = null
145let chain: Promise<void> = Promise.resolve()
146let dirty = false
147let timer: Timer | null = null
148let anon = 0
149let lastWriteAt = 0
150let lastBlockedKey = 'null'
151let pendingFlush = false
152let isTicking = false
153/** What a write carries besides FwFleet, refreshed at start, adoption and heartbeat (session.end reads only this). */
154const W: {
155 root: string; repoRoot: string | null; lang: Lang; home: string | null; host: Host; realRoot: string | null; realSessions: string | null
156 /** For other sessions' readers (the side panel): context use, the seat role, the flightwake install version. */
157 context: number | null; role: string | null; fwVersion: string | null
158} = { root: '', repoRoot: null, lang: 'en', home: null, host: {}, realRoot: null, realSessions: null, context: null, role: null, fwVersion: null }
159
160const noop = (): void => {}
161const canWrite = (f: FwFleet | null): f is FwFleet => f !== null && (f.status === 'active' || f.status === 'write-failed') && f.path !== null
162/** Events are tracked only while this session writes and has not ended. */
163const isTracking = (): boolean => canWrite(F) && F !== null && F.ended === null
164
165function clearEvidence(): void {
166 evidence.clear()
167 asks.clear()
168}
169
170/**
171 * At a main-loop turn boundary: the main loop's evidence and asks go. A subagent's or teammate's open dialog may
172 * outlive the main turn; it stays until its own call settles, is denied, draws its pill, or the session ends.
173 */
174function clearMainEvidence(): void {
175 for (const [k, ev] of evidence) if (!ev.agent) evidence.delete(k)
176 for (const [k] of asks) if (inFlight.get(k)?.agent !== true) asks.delete(k)
177}
178
179/** The oldest open evidence, as the record's `blocked`. */
180function oldestEvidence(): Evidence | null {
181 let best: Evidence | null = null
182 for (const ev of evidence.values()) if (best === null || ev.since < best.since) best = ev
183 return best
184}
185
186function recordOf(f: FwFleet, now: number): SessionRec {
187 const blocked = f.state === 'blocked' ? oldestEvidence() : null
188 return {
189 schema: SCHEMA,
190 mod: MOD_VERSION,
191 sessionId: f.sessionId,
192 instance: f.instance,
193 state: f.state,
194 why: f.why,
195 stateSince: f.stateSince,
196 seq: f.seq,
197 heartbeatAt: f.ended !== null ? f.ended.at : now,
198 heartbeatMs: HEARTBEAT_MS,
199 startedAt: f.startedAt,
200 root: W.root,
201 repoRoot: W.repoRoot,
202 launchCwd: f.launchCwd,
203 lang: W.lang,
204 surface: f.surface,
205 blocked: blocked === null ? null : { kind: blocked.kind, tool: blocked.tool, target: blocked.target, agent: blocked.agent, since: blocked.since },
206 activity: activity === null ? null : { ...activity },
207 inFlight: inFlight.size,
208 turns: f.turns,
209 lastTurn: f.lastTurn,
210 ended: f.ended,
211 resume: { id: f.resumeId, resumable: f.turns > 0 },
212 transcript: W.home ? transcriptPath(W.home, f.launchCwd, f.sessionId) : null,
213 parent: f.parent,
214 contested: f.contested,
215 host: { ...W.host },
216 context: W.context === null ? null : { percent: W.context },
217 role: W.role,
218 fwVersion: W.fwVersion,
219 }
220}
221
222async function persist($: EngineInterface): Promise<void> {
223 if (F === null) return
224 try { await $.state.set(FLEET_REF, { ...F }) } catch {}
225}
226
227/**
228 * The write guards that need no IO, re-checked before every write: the root follows /cd and worktree moves, so a
229 * base that was outside the root at start may be inside it now. On a hit the status says so and nothing is written.
230 */
231function isStillOutside(f: FwFleet): boolean {
232 if (f.base === null || f.path === null) return false
233 const real = W.realSessions
234 // by spelling against the root as it is now; by real path against the root as last resolved (start, heartbeat)
235 const isInside = real === null || W.realRoot === null || relToRoot(W.realRoot, real) !== null ||
236 (W.root !== '' && (relToRoot(W.root, f.base) !== null || relToRoot(W.root, f.path) !== null || relToRoot(W.root, real) !== null))
237 if (isInside) {
238 f.status = 'inside-repo'
239 stopTimer()
240 return false
241 }
242 return true
243}
244
245/**
246 * `blocked` with no evidence left can only follow a ToolProgress clear (U1) whose after(0) has not run (or never
247 * will: a refused clock.after drops its callback). Re-derive before writing, so a record never pairs blocked with null.
248 */
249function reconcile(f: FwFleet, now: number): void {
250 if (f.state !== 'blocked' || evidence.size > 0) return
251 f.state = deriveState({ isEnded: f.ended !== null, evidence: 0, turnOpen: f.turnOpen, compacting: f.compacting })
252 f.why = 'unblocked:running'
253 f.stateSince = now
254}
255
256/** The single-flight writer: always writes the newest memory; a burst of changes coalesces into one write. */
257async function doWrite($: EngineInterface): Promise<void> {
258 if (!dirty) return
259 dirty = false
260 const f = F
261 if (!canWrite(f) || f.path === null) return
262 if (!isStillOutside(f)) { await persist($); return }
263 const now = await clockNow($)
264 reconcile(f, now)
265 f.seq += 1
266 const err = await fleetIoOf($).write(f.path, recordText(recordOf(f, now)))
267 lastWriteAt = now
268 if (err === null) { f.status = 'active'; f.lastError = null } else { f.status = 'write-failed'; f.lastError = err }
269 await persist($)
270}
271
272function schedule($: EngineInterface): void {
273 dirty = true
274 lastBlockedKey = JSON.stringify(F !== null && F.state === 'blocked' ? oldestEvidence() : null)
275 chain = chain.then(() => doWrite($)).catch(noop)
276}
277
278/** Write `f` as it stands now, queued in order (for a record that is about to be replaced in memory). */
279async function writeSnapshot($: EngineInterface, f: FwFleet): Promise<void> {
280 if (!canWrite(f) || f.path === null || !isStillOutside(f)) return
281 const now = await clockNow($)
282 f.seq += 1
283 const path = f.path
284 const text = recordText(recordOf(f, now))
285 chain = chain.then(async () => { await fleetIoOf($).write(path, text); lastWriteAt = now }).catch(noop)
286}
287
288/** Derive the state; on a change set stateSince / why, and write when the state or the blocked view changed. */
289async function apply($: EngineInterface, why: string): Promise<void> {
290 const f = F
291 if (f === null) return
292 pendingFlush = false // this derives from the evidence as it stands now: any ToolProgress clear is taken in
293 const next = deriveState({ isEnded: f.ended !== null, evidence: evidence.size, turnOpen: f.turnOpen, compacting: f.compacting })
294 let isChanged = false
295 if (next !== f.state) {
296 f.state = next
297 f.why = why
298 f.stateSince = await clockNow($)
299 isChanged = true
300 }
301 if (JSON.stringify(next === 'blocked' ? oldestEvidence() : null) !== lastBlockedKey) isChanged = true
302 await persist($)
303 if (isChanged && canWrite(f)) {
304 // the root as it is now (a /cd since the last heartbeat), so the write guard sees it; no fs read
305 try { const r = (await $.session.root()).replace(/\/+$/, ''); if (r !== '') W.root = r } catch {}
306 schedule($)
307 }
308}
309
310// ── guards (spec §2): run at session.start and at adoption, never at session.end ──────────────────────────────
311type Guard = { status: FwFleetStatus; base: string | null; path: string | null; existing: SessionRec | null }
312
313/**
314 * Where the sessions folder really lands, against the root resolved the same way (a spelling check misses a link
315 * above the base): refreshes W.realRoot / W.realSessions; false when it lands inside the root or cannot be placed.
316 */
317async function placeOutside(io: FleetIo, root: string, base: string): Promise<boolean> {
318 W.realRoot = await io.place(root)
319 W.realSessions = await io.place(sessionsDirOf(base))
320 return W.realRoot !== null && W.realSessions !== null && relToRoot(W.realRoot, W.realSessions) === null && relToRoot(root, W.realSessions) === null
321}
322
323async function guard($: EngineInterface, io: FleetIo, id: string, isInteractive: boolean): Promise<Guard> {
324 if (!isInteractive) return { status: 'not-interactive', base: null, path: null, existing: null }
325 const env = await readEnv($)
326 W.home = env.HOME || env.USERPROFILE || null
327 W.host = env.host
328 const base = fleetBase(env)
329 if (base === null) return { status: 'no-home', base: null, path: null, existing: null }
330 const path = fleetFilePath(base, id)
331 if (path === null) return { status: 'bad-id', base, path: null, existing: null }
332 const root = await io.root()
333 // the file must lie outside the repo: the base, and the file itself (a root inside the base is fine)
334 if (!root || relToRoot(root, base) !== null || relToRoot(root, path) !== null) return { status: 'inside-repo', base, path, existing: null }
335 // ...and by real path (a link or a relative spelling above the base); denied when it cannot be placed
336 if (!(await placeOutside(io, root, base))) return { status: 'inside-repo', base, path, existing: null }
337 // ...and outside every other repo and records folder: a repo at $HOME, or FLIGHTWAKE_HOME=<some repo>/.flightwake;
338 // probed under the base's spelling and its real path; a probe that cannot be answered refuses
339 const realBase = await io.place(base)
340 if (realBase === null) return { status: 'inside-repo', base, path, existing: null }
341 for (const p of new Set([...repoMarkersOf(base), ...repoMarkersOf(realBase)])) {
342 if ((await io.probe(p)) !== 'no') return { status: 'other-repo', base, path, existing: null }
343 }
344 for (const p of [base, sessionsDirOf(base), path]) {
345 if ((await io.isLink(p)) !== 'no') return { status: 'symlink', base, path: p, existing: null }
346 }
347 let existing: SessionRec | null = null
348 const isThere = await io.probe(path)
349 if (isThere === 'unknown') return { status: 'foreign-file', base, path, existing: null }
350 if (isThere === 'yes') {
351 const text = await io.read(path)
352 if (text === null || !isOursText(text)) return { status: 'foreign-file', base, path, existing: null }
353 existing = parseRec(text)
354 }
355 return { status: 'active', base, path, existing }
356}
357
358/** Another live process holds this id: its own fresh, not-ended record. */
359const isLiveSibling = (rec: SessionRec | null, instance: string, now: number): boolean =>
360 rec !== null && schemaMajor(rec) === 1 && rec.instance !== instance && (rec.ended === null || rec.ended === undefined) &&
361 typeof rec.heartbeatAt === 'number' && now - rec.heartbeatAt <= staleAfter(rec) && rec.heartbeatAt <= now + CLOCK_SKEW_MS
362
363async function refreshWorld($: EngineInterface, io: FleetIo, withRepo: boolean): Promise<void> {
364 const root = await io.root()
365 const isMoved = root !== '' && root !== W.root
366 if (root !== '') W.root = root
367 if (withRepo || isMoved) {
368 try { const r = await $.session.repo(); W.repoRoot = r && typeof r.root === 'string' ? r.root.replace(/\/+$/, '') : null } catch { W.repoRoot = null }
369 }
370}
371
372/** The seat role and install version of the current root (re-read on each heartbeat: `roles apply` may change it). */
373async function readSeat(io: FleetIo): Promise<void> {
374 if (W.root === '') return
375 let role: string | null = null
376 for (const rel of SEAT_FILES) {
377 const t = await readRel(io, W.root, rel)
378 const seat = t === null ? null : parseSeatBlock(t)
379 if (seat) { role = seat.id; break }
380 }
381 W.role = role
382 W.fwVersion = await detectVersion(io, W.root)
383}
384
385async function readContext($: EngineInterface): Promise<void> {
386 try {
387 const p = (await $.session.usage()).context.percent
388 W.context = typeof p === 'number' && Number.isFinite(p) ? Math.round(p) : null
389 } catch { W.context = null }
390}
391
392async function sessionTurns($: EngineInterface): Promise<number> {
393 try { const n = await $.session.turns(); return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : 0 } catch { return 0 }
394}
395
396function startTimer($: EngineInterface): void {
397 try { timer?.cancel() } catch {}
398 timer = null
399 try { timer = $.clock.every(HEARTBEAT_MS, () => { void tick($) }) } catch { timer = null }
400}
401
402function stopTimer(): void {
403 try { timer?.cancel() } catch {}
404 timer = null
405}
406
407// ── adoption: the process goes on under another session id (/clear, /resume, a fork) ─────────────────────────
408async function adopt($: EngineInterface, newId: string, via: 'clear' | 'resume' | 'fork' | 'id-changed'): Promise<void> {
409 const old = F
410 if (old === null || old.status === 'not-interactive') return
411 const io = fleetIoOf($)
412 const now = await clockNow($)
413 if (old.ended === null && canWrite(old)) {
414 const stateAtEnd = old.state === 'ended' ? 'idle' : old.state
415 old.ended = { reason: 'id-changed', at: now, stateAtEnd }
416 old.state = 'ended'
417 old.why = 'session.end:id-changed'
418 old.stateSince = now
419 await writeSnapshot($, old)
420 }
421 clearEvidence()
422 inFlight.clear()
423 activity = null
424 const g = await guard($, io, newId, true)
425 await refreshWorld($, io, true)
426 const turns = g.status === 'active' ? await sessionTurns($) : 0
427 F = {
428 sessionId: newId,
429 instance: old.instance,
430 startedAt: now,
431 launchCwd: old.launchCwd,
432 surface: old.surface,
433 status: g.status,
434 lastError: null,
435 path: g.path,
436 base: g.base,
437 state: 'idle',
438 why: `adopt:${via}`,
439 stateSince: now,
440 seq: typeof g.existing?.seq === 'number' && Number.isFinite(g.existing.seq) ? g.existing.seq : 0,
441 turnOpen: false,
442 compacting: false,
443 turns,
444 lastTurn: null,
445 ended: null,
446 resumeId: newId,
447 parent: { sessionId: old.sessionId, via },
448 contested: isLiveSibling(g.existing, old.instance, now),
449 hinted: old.hinted,
450 others: old.others,
451 othersList: old.othersList ?? null,
452 }
453 await persist($)
454 if (canWrite(F)) {
455 if (timer === null) startTimer($)
456 schedule($)
457 }
458}
459
460/** Row A3: the engine moved to another id without telling us (no classic.SessionStart): end the old, adopt the new. */
461async function ensureCurrent($: EngineInterface): Promise<void> {
462 if (F === null || F.status === 'not-interactive') return
463 let id = ''
464 try { id = await $.session.id() } catch { return }
465 if (id && id !== F.sessionId) await adopt($, id, 'id-changed')
466}
467
468// ── readers over the sessions folder ──────────────────────────────────────────────────────────────────────────
469async function scan(io: FleetIo, dir: string, now: number, windowMs: number): Promise<{ recs: SessionRec[]; unreadable: number; skipped: number; isMissing: boolean }> {
470 const entries = await io.list(dir)
471 if (entries === null) return { recs: [], unreadable: 0, skipped: 0, isMissing: true }
472 const { names, skipped } = selectFiles(entries, now, windowMs)
473 const recs: SessionRec[] = []
474 let unreadable = 0
475 for (const name of names) {
476 const text = await io.read(`${dir}/${name}`)
477 const rec = text === null ? null : parseRec(text)
478 if (rec === null) unreadable += 1
479 else recs.push(rec)
480 }
481 return { recs, unreadable, skipped, isMissing: false }
482}
483
484
485// ── heartbeat ─────────────────────────────────────────────────────────────────────────────────────────────────
486async function tick($: EngineInterface): Promise<void> {
487 if (isTicking) return
488 isTicking = true
489 try {
490 if (!canWrite(F)) return
491 await ensureCurrent($)
492 const f = F
493 if (!canWrite(f) || f.ended !== null || f.path === null || f.base === null) return
494 const io = fleetIoOf($)
495 await refreshWorld($, io, false)
496 await readSeat(io)
497 await readContext($)
498 // the guards again: a /cd may have moved the root over the base, or a link may have replaced a folder since start
499 if (W.root !== '') await placeOutside(io, W.root, f.base)
500 if (!isStillOutside(f)) { await persist($); return }
501 for (const p of [f.base, sessionsDirOf(f.base), f.path]) {
502 if ((await io.isLink(p)) !== 'no') { f.status = 'symlink'; f.path = p; stopTimer(); await persist($); return }
503 }
504 const now = await clockNow($)
505 // contested: another live process wrote this id since our last write
506 const mine = await io.read(f.path)
507 // something else put a file of its own at our path since start: never overwrite it
508 if (mine !== null && !isOursText(mine)) { f.status = 'foreign-file'; stopTimer(); await persist($); return }
509 const onDisk = mine === null ? null : parseRec(mine)
510 const isContested = onDisk !== null && onDisk.instance !== f.instance && (onDisk.ended === null || onDisk.ended === undefined) &&
511 typeof onDisk.heartbeatAt === 'number' && onDisk.heartbeatAt > lastWriteAt && onDisk.heartbeatAt <= now + CLOCK_SKEW_MS
512 f.contested = isContested
513 // the other sessions, with the gauge's filter (files touched within the stale floor's 4/3), for /fw-mod
514 const s = await scan(io, sessionsDirOf(f.base), now, (STALE_FLOOR_MS * 4) / 3)
515 f.others = countOthers(s.recs, f.sessionId, now)
516 // the side panel's console joins these to Orca's terminals: keep every live one, not a screenful
517 f.othersList = otherRows(s.recs, f.sessionId, now, 60)
518 await persist($)
519 schedule($)
520 } catch {
521 } finally {
522 isTicking = false
523 }
524}
525
526// ── resume hint (once per process, fresh start only) ──────────────────────────────────────────────────────────
527async function hintScan($: EngineInterface): Promise<void> {
528 try {
529 const f = F
530 if (!canWrite(f) || f.hinted || f.base === null) return
531 const io = fleetIoOf($)
532 const now = await clockNow($)
533 const s = await scan(io, sessionsDirOf(f.base), now, LIST_WINDOW_MS)
534 const me = { id: f.sessionId, root: W.root }
535 const existing = new Set<string>()
536 for (const c of hintCandidates(s.recs, me, now)) {
537 const t = transcriptOf(c, W.home)
538 if (t !== null && (await io.exists(t))) { existing.add(t); break }
539 }
540 const c = pickHint(s.recs, me, now, existing)
541 if (c === null) return
542 const cmd = resumeCmd(c, f.launchCwd)
543 if (cmd === null) return
544 const word = T.word[W.lang] as Record<string, string>
545 const was = c.ended?.stateAtEnd ?? c.state
546 const text = T.hint[W.lang]({ age: fmtAge(now - lastSeen(c)), s: (typeof was === 'string' && word[was]) || word.unknown || '?', cmd })
547 $.ui.toast(text, { timeoutMs: HINT_TOAST_MS })
548 if (F !== null) { F.hinted = true; await persist($) }
549 } catch {}
550}
551
552// ── /fw-fleet ─────────────────────────────────────────────────────────────────────────────────────────────────
553let isCommandRegistered = false
554async function ensureCommand($: EngineInterface, lang: Lang): Promise<void> {
555 if (isCommandRegistered) return
556 isCommandRegistered = true
557 try {
558 await $.command.register({ name: 'fw-fleet', description: T.description[lang] })
559 } catch {
560 isCommandRegistered = false
561 }
562}
563
564async function fleetCommandText($: EngineInterface, io: FleetIo, lang: Lang, args: string): Promise<string> {
565 const isAll = args.trim() === 'all'
566 const env = await readEnv($)
567 const base = fleetBase(env)
568 if (base === null) return `## /fw-fleet\n\n${T.nodir[lang]}`
569 const dir = sessionsDirOf(base)
570 const now = await clockNow($)
571 const s = await scan(io, dir, now, isAll ? LIST_WINDOW_ALL_MS : LIST_WINDOW_MS)
572 const me = await io.sessionId()
573 const myLaunch = F?.launchCwd ?? null
574 const rows = []
575 for (const rec of s.recs) {
576 let cmd: string | null = null
577 if (resumableOf(rec, now)) {
578 const t = transcriptOf(rec, env.HOME || env.USERPROFILE || null)
579 if (t !== null && (await io.exists(t))) cmd = resumeCmd(rec, myLaunch)
580 }
581 rows.push(rowOf(rec, now, cmd))
582 }
583 return fleetReport(lang, {
584 rows,
585 me,
586 dir: tildePath(dir, env.HOME || env.USERPROFILE || null),
587 days: isAll ? 30 : 7,
588 endedCap: isAll ? ENDED_CAP_ALL : ENDED_CAP,
589 skipped: s.skipped,
590 unreadable: s.unreadable,
591 ver: FLEET_SINCE,
592 notMe: notReportingReason(lang, F),
593 })
594}
595
596// ── session start (rows S1–S3) ────────────────────────────────────────────────────────────────────────────────
597async function onStart($: EngineInterface, e: { cwd: string; surface: string | null; isInteractive: boolean }): Promise<void> {
598 const io = fleetIoOf($)
599 const ctx = await fwContext(io)
600 if (ctx === null) return // S1: flightwake is not installed here
601 W.lang = ctx.lang
602 W.root = ctx.root
603 await readSeat(io)
604 await ensureCommand($, ctx.lang)
605 const sid = await io.sessionId()
606 if (!sid) return
607 let prev = F
608 if (prev === null) { try { prev = (await $.state.get(FLEET_REF)).value ?? null } catch { prev = null } }
609
610 // S3: a reload of this module in the same session — keep the instance and counters; the evidence is gone
611 if (prev !== null && prev.sessionId === sid && prev.ended === null) {
612 F = { ...prev }
613 const wasBlocked = prev.state === 'blocked'
614 clearEvidence()
615 inFlight.clear()
616 // a compaction started under the old module memory cannot be followed: its finally checks `F === f`
617 F.compacting = false
618 lastBlockedKey = JSON.stringify(null)
619 if (canWrite(F)) {
620 await guardHome($)
621 await refreshWorld($, io, true)
622 // the new module memory has no real path yet: place it (a write refused until then)
623 if (F.base !== null) await placeOutside(io, W.root, F.base)
624 startTimer($)
625 }
626 await apply($, wasBlocked ? 'reload:evidence-lost' : 'session.start:reload')
627 return
628 }
629
630 // S2: a fresh process (or a new id) — take the file over as idle; a live sibling on the same id is contested
631 const now = await clockNow($)
632 if (prev !== null && prev.sessionId !== sid && prev.ended === null && canWrite(prev)) {
633 F = prev
634 await adopt($, sid, 'id-changed')
635 return
636 }
637 clearEvidence()
638 inFlight.clear()
639 activity = null
640 const g = await guard($, io, sid, e.isInteractive === true)
641 const instance = `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
642 const isWriting = g.status === 'active'
643 if (isWriting) await refreshWorld($, io, true)
644 F = {
645 sessionId: sid,
646 instance,
647 startedAt: now,
648 launchCwd: e.cwd,
649 surface: e.surface ?? null,
650 status: g.status,
651 lastError: null,
652 path: g.path,
653 base: g.base,
654 state: 'idle',
655 why: 'session.start',
656 stateSince: now,
657 seq: typeof g.existing?.seq === 'number' && Number.isFinite(g.existing.seq) ? g.existing.seq : 0,
658 turnOpen: false,
659 compacting: false,
660 turns: isWriting ? await sessionTurns($) : 0,
661 lastTurn: null,
662 ended: null,
663 resumeId: sid,
664 parent: null,
665 contested: isLiveSibling(g.existing, instance, now),
666 hinted: false,
667 others: null,
668 othersList: null,
669 }
670 await persist($)
671 if (!isWriting) return
672 startTimer($)
673 schedule($)
674 try { $.clock.after(HINT_DELAY_MS, () => { void hintScan($) }) } catch {}
675}
676
677/** On a reload the module memory is new: read home and host again (the guards already ran for this id). */
678async function guardHome($: EngineInterface): Promise<void> {
679 const env = await readEnv($)
680 W.home = env.HOME || env.USERPROFILE || null
681 W.host = env.host
682}
683
684/** Settle a call (row C3): it is no longer in flight, and its evidence (if any) is cleared. */
685async function settle($: EngineInterface, key: string, tool: string): Promise<void> {
686 const call = inFlight.get(key)
687 inFlight.delete(key)
688 asks.delete(key)
689 let isRemoved = evidence.delete(key)
690 const perms = [...evidence.entries()].filter(([k, ev]) => k.startsWith('perm:') && ev.tool === tool)
691 if (isRemoved) {
692 for (const [k] of perms) evidence.delete(k)
693 } else if (call !== undefined) {
694 // an unpaired dialog (no tool_use_id to match) raised while this call was in flight: this call has settled
695 const own = perms.filter(([, ev]) => ev.since >= call.at).sort((a, b) => a[1].since - b[1].since)[0]
696 if (own !== undefined) isRemoved = evidence.delete(own[0])
697 }
698 // also when a ToolProgress clear took this call's evidence and its after(0) has not re-derived the state yet
699 if ((isRemoved || (F !== null && F.state === 'blocked' && evidence.size === 0)) && isTracking()) await apply($, 'unblocked:tool-done')
700}
701
702export function registerFleet(on: On): void {
703 on('session.start', {}, async ($, e, next) => {
704 const r = await next(e)
705 try { await onStart($, e) } catch {}
706 return r
707 })
708
709 // A1: /clear, an in-process /resume or a fork hands us the new id at once
710 on('classic.SessionStart', {}, async ($, e, next) => {
711 const r = await next(e)
712 try {
713 const src = e.source
714 if (F !== null && (src === 'clear' || src === 'resume' || src === 'fork') && typeof e.session_id === 'string' && e.session_id !== '' && e.session_id !== F.sessionId) {
715 await adopt($, e.session_id, src)
716 }
717 } catch {}
718 return r
719 })
720
721 // T1 (main loop only: a subagent raises no turn.start)
722 on('turn.start', {}, async ($, e, next) => {
723 try {
724 await ensureCurrent($)
725 if (isTracking() && F !== null) {
726 F.turnOpen = true
727 F.turns += 1
728 clearMainEvidence()
729 await apply($, 'turn.start')
730 }
731 } catch {}
732 return next(e)
733 })
734
735 // P1: a prompt submitted while idle with a turn still open means a turn.complete was missed. Never reads e.text.
736 on('prompt.submit', {}, async ($, e, next) => {
737 try {
738 await ensureCurrent($)
739 if (isTracking() && F !== null && e.turnId === undefined && F.turnOpen) {
740 F.turnOpen = false
741 clearMainEvidence()
742 await apply($, 'resync:prompt-while-idle')
743 }
744 } catch {}
745 return next(e)
746 })
747
748 // T2: the main loop's turn ended (a subagent's turn changes nothing)
749 on('turn.complete', {}, async ($, e, next) => {
750 const r = await next(e)
751 try {
752 if (e.agentId === undefined) {
753 await ensureCurrent($)
754 if (isTracking() && F !== null) {
755 const reason = turnReason(e.reason)
756 F.turnOpen = false
757 // no compaction of the main conversation outlives the turn boundary
758 F.compacting = false
759 clearMainEvidence()
760 for (const [k, c] of inFlight) if (!c.agent) inFlight.delete(k)
761 F.lastTurn = { reason, at: await clockNow($), durationMs: typeof e.durationMs === 'number' && Number.isFinite(e.durationMs) ? e.durationMs : 0 }
762 F.turns = Math.max(F.turns, await sessionTurns($))
763 await readContext($)
764 await apply($, `turn.complete:${reason}`)
765 }
766 }
767 } catch {}
768 return r
769 })
770
771 // C1–C3: in flight, activity, question / plan evidence; settled in `finally` however the call ends
772 on('tool.call', {}, async ($, e, next) => {
773 let key: string | null = null
774 const tool = e.tool
775 try {
776 if (F !== null) {
777 await ensureCurrent($)
778 if (isTracking()) {
779 key = e.tool_use_id || `anon:${++anon}`
780 const agent = e.agentId !== undefined
781 const target = targetOf(tool, e, W.root)
782 const at = await clockNow($)
783 inFlight.set(key, { tool, target, agent, at })
784 activity = { tool, target, agent, at }
785 if (!agent && (tool === 'AskUserQuestion' || tool === 'ExitPlanMode')) {
786 evidence.set(key, { kind: tool === 'AskUserQuestion' ? 'question' : 'plan', tool, target: null, agent: false, since: at })
787 await apply($, tool === 'AskUserQuestion' ? 'question' : 'plan-approval')
788 }
789 }
790 }
791 } catch {}
792 try {
793 return await next(e)
794 } finally {
795 if (key !== null) { try { await settle($, key, tool) } catch {} }
796 }
797 })
798
799 // K1: a real call put to the mode's decider — recorded for pairing only, never blocked by itself
800 on('tool.check', {}, async ($, e, next) => {
801 const r = await next(e)
802 try {
803 if (isTracking() && typeof e.tool_use_id === 'string' && e.tool_use_id !== '' && r?.decision === 'ask') {
804 asks.set(e.tool_use_id, { tool: e.tool, at: await clockNow($), paired: false, fp: inputPrint(e.input) })
805 }
806 } catch {}
807 return r
808 })
809
810 // R1: the permission dialog is up — nothing beneath answered it, in an interactive session. tool_input never kept.
811 on('classic.PermissionRequest', {}, async ($, e, next) => {
812 const r = await next(e)
813 try {
814 const tool = typeof e.tool_name === 'string' ? e.tool_name : '?'
815 // this dialog's ask, by identity: same tool, same input. Consumed whatever the outcome (a settings hook
816 // beneath answering it too), so a later dialog of the same tool never pairs with an ask that had none.
817 const fp = inputPrint(e.tool_input)
818 const matches = fp === null ? [] : [...asks.entries()].filter(([, a]) => !a.paired && a.tool === tool && a.fp === fp)
819 const newest = matches[matches.length - 1]
820 if (newest !== undefined) newest[1].paired = true
821 if (r?.decision === undefined && r?.block === undefined) {
822 await ensureCurrent($)
823 if (isTracking()) {
824 // two identical calls waiting cannot be told apart: unpaired (`perm:n`), cleared when a call of the tool settles
825 let key: string | null = matches.length === 1 && newest !== undefined ? newest[0] : null
826 // only for a call still running: next(e) waited on the settings hooks beneath, and the call may have
827 // settled meanwhile (then nothing would ever clear the evidence)
828 const isLive = key !== null ? inFlight.has(key) : [...inFlight.values()].some((c) => c.tool === tool)
829 // a question / plan already blocks on this call (AskUserQuestion and ExitPlanMode reach the person
830 // through this dialog too): keep its kind and its since, which is a reader's dedupe key
831 const prior = key !== null ? evidence.get(key) : undefined
832 if (isLive && prior === undefined) {
833 if (key === null) key = `perm:${++anon}`
834 evidence.set(key, { kind: 'permission', tool, target: inFlight.get(key)?.target ?? null, agent: e.agent_id !== undefined, since: await clockNow($) })
835 await apply($, 'permission-dialog')
836 }
837 }
838 }
839 } catch {}
840 return r
841 })
842
843 // R2: the call was denied — its permission evidence goes
844 on('classic.PermissionDenied', {}, async ($, e, next) => {
845 const r = await next(e)
846 try {
847 // Only the denied call's own evidence: the auto-mode classifier denies calls that never had a dialog, so a
848 // match by tool name would clear another call's open dialog. An unpaired dialog clears when its call settles.
849 if (isTracking() && typeof e.tool_use_id === 'string' && evidence.get(e.tool_use_id)?.kind === 'permission') {
850 if (evidence.delete(e.tool_use_id)) await apply($, 'unblocked:denied')
851 }
852 } catch {}
853 return r
854 })
855
856 // U1: the engine drew the run pill for a paired call — it is running, so its dialog is answered (clear only).
857 // Module memory alone here: $.state.set is refused while a render hook draws, so the write waits for after(0).
858 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
859 try {
860 const id = e.props.tool_use_id
861 const ev = typeof id === 'string' ? evidence.get(id) : undefined
862 if (ev !== undefined && ev.kind === 'permission') {
863 evidence.delete(id)
864 if (!pendingFlush) {
865 // apply() resets the flag, so a callback that never runs (a refused clock.after) cannot hold later ones
866 // back; the next write, settle or tick re-derives the state in any case (reconcile, settle)
867 pendingFlush = true
868 try {
869 $.clock.after(0, () => {
870 pendingFlush = false
871 if (isTracking()) void apply($, 'unblocked:running').catch(noop)
872 })
873 } catch { pendingFlush = false }
874 }
875 }
876 } catch {}
877 return next(e)
878 })
879
880 // M1: a manual (or plugin) compaction of the main conversation shows as working while it runs
881 on('session.compact', {}, async ($, e, next) => {
882 let f: FwFleet | null = null
883 try {
884 if (e.agentId === undefined && e.trigger !== 'precompute') {
885 await ensureCurrent($)
886 if (isTracking() && F !== null) {
887 f = F
888 f.compacting = true
889 await apply($, 'compact')
890 }
891 }
892 } catch {}
893 try {
894 return await next(e)
895 } finally {
896 if (f !== null && F === f) {
897 try { f.compacting = false; if (isTracking()) await apply($, 'compact-done') } catch {}
898 }
899 }
900 })
901
902 // E1: built from memory only (no stat / read / exists inside the 1.5 s bound); the write is awaited
903 on('session.end', {}, async ($, e, next) => {
904 try {
905 const f = F
906 if (f !== null && f.sessionId === e.sessionId && f.ended === null) {
907 const now = await clockNow($)
908 const stateAtEnd = f.state === 'ended' ? 'idle' : f.state
909 clearEvidence()
910 f.turnOpen = false
911 f.compacting = false
912 f.ended = { reason: e.reason, at: now, stateAtEnd }
913 if (isSafeId(e.resume?.id)) f.resumeId = e.resume.id
914 f.state = 'ended'
915 f.why = `session.end:${e.reason}`
916 f.stateSince = now
917 if (e.reason === 'prompt_input_exit' || e.reason === 'logout' || e.reason === 'other') stopTimer()
918 if (canWrite(f)) {
919 schedule($)
920 await chain
921 } else {
922 await persist($)
923 }
924 }
925 } catch {}
926 return next(e)
927 })
928
929 on('command.run', { command: 'fw-fleet' }, async ($, e, next) => {
930 try {
931 const io = fleetIoOf($)
932 const ctx = await fwContext(io)
933 if (ctx === null) return next(e)
934 return { text: await fleetCommandText($, io, ctx.lang, e.args ?? '') }
935 } catch {
936 return next(e)
937 }
938 })
939}
940hooks/features/panel.ts 514 lines1/**
2 * F8 side panel — one pane beside the transcript gathering what the other features already know: STATE (health,
3 * lag, context, the "In progress" and "Next entry points" items), this session's flight log and spend, and the
4 * other live sessions from the fleet heartbeat. 2026-10-11 field feedback: the band hides itself under the bottom
5 * gauge, so with both installed nothing of the mod was on screen.
6 *
7 * Opened only by the person: `/fw-panel` toggles it and its answer is kept in $.store (`panelOpen`), so a later
8 * session reopens it unasked — which the engine places beside the transcript from 110 columns and otherwise keeps
9 * waiting undrawn. Closing it by hand (its close mark, ctrl+x x) or `/fw-panel off` turns that off again.
10 *
11 * The digest is computed outside rendering (session.start, turn.complete, a commit); the render hook only reads
12 * $.state values, so a feature's write redraws the panel. It never runs git while drawing and writes nothing but
13 * the one $.store key.
14 */
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, On, RenderElement, Timer } from 'claude-code'
17
18import { detectVersion, FW_DIR, fwContext, git, healthOf, isUninitializedState, joinPath, MOD_VERSION, parseFrontmatter, readRel, STATE_REL, stateLag, TRAPS_REL } from '../lib/core'
19import type { Io, Lang } from '../lib/core'
20import { fmtAge, T as FLEET_T } from '../lib/fleet'
21import { CONTEXT_HOT, healthWhy, PANE_ID, panelVerb, recordUnfinished, stateDigest, STORE_KEY, T, TRAPS_COMPACT_AT } from '../lib/panel'
22import { isActive, parseTraps } from '../lib/traps'
23import { agentName, baseName, cleanTitle, contextBar, groupByRepo, parseTerminalList, POLL_MS, REPO_CAP, stateWord, T as OT, termState, wrapUpCommand } from '../lib/orca'
24import type { TermState } from '../lib/orca'
25import type { FwBandView, FwConsole, FwConsoleRepo, FwFleet, FwFleetOther, FwFlightLog, FwPanelDigest, FwPanelUi, FwSpend } from '../../types/index'
26import { lagText } from './band'
27import { spendLine } from './spend'
28
29const panelDigest = atom({ plugin: 'flightwake-mod', key: 'panelDigest' } as const, null)
30const bandRef = atom({ plugin: 'flightwake-mod', key: 'bandView' } as const, null)
31const spendRef = atom({ plugin: 'flightwake-mod', key: 'spend' } as const, null)
32const logRef = atom({ plugin: 'flightwake-mod', key: 'flightLog' } as const, null)
33const fleetRef = atom({ plugin: 'flightwake-mod', key: 'fleet' } as const, null)
34const consoleRef = atom({ plugin: 'flightwake-mod', key: 'console' } as const, null)
35const uiRef = atom({ plugin: 'flightwake-mod', key: 'panelUi' } as const, null)
36
37export type PanelSwitches = { band: boolean; recorder: boolean; spend: boolean; fleet: boolean }
38
39// The Io closure over this file's `$` (IO_OF_TEMPLATE in hooks/lib/core.ts; `$` may not cross an import).
40function ioOf($: EngineInterface): Io {
41 return {
42 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
43 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
44 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
45 read: async (p) => {
46 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
47 },
48 git: async (args, cwd) => {
49 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
50 },
51 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
52 }
53}
54
55/** Reads STATE (and, with the band off, the lag) into the digest; null when flightwake is not set up here. */
56async function compute($: EngineInterface, sw: PanelSwitches): Promise<FwPanelDigest | null> {
57 const io = ioOf($)
58 const ctx = await fwContext(io)
59 if (ctx === null) return null
60 const text = await io.read(joinPath(ctx.root, STATE_REL))
61 if (text === null) return null
62 const isUninitialized = isUninitializedState(text)
63 const d = stateDigest(text)
64 // With the band on, its view already holds the lag (same git calls); measuring again would double them per turn
65 let lag: FwPanelDigest['lag'] = null
66 if (!sw.band && !isUninitialized) {
67 const l = await stateLag(io, ctx.root)
68 lag = l === null ? { kind: 'none', behind: null } : { kind: l.kind, behind: l.kind === 'behind' ? l.behind : null }
69 }
70 let contextPercent: number | null = null
71 try {
72 const p = (await $.session.usage()).context.percent
73 contextPercent = typeof p === 'number' && Number.isFinite(p) ? p : null
74 } catch { contextPercent = null }
75 return {
76 sessionId: await io.sessionId(),
77 lang: ctx.lang,
78 health: isUninitialized ? 'unknown' : healthOf(text),
79 updated: isUninitialized ? null : d.updated,
80 isUninitialized,
81 inProgress: isUninitialized ? [] : d.inProgress,
82 next: isUninitialized ? [] : d.next,
83 moreInProgress: isUninitialized ? 0 : d.moreInProgress,
84 moreNext: isUninitialized ? 0 : d.moreNext,
85 lag,
86 contextPercent,
87 fwVersion: await detectVersion(io, ctx.root),
88 selfHandle: await selfHandleOf($),
89 attention: await attentionOf(io, ctx.root, text, isUninitialized),
90 }
91}
92
93/** This session's Orca terminal handle, or null outside Orca. */
94async function selfHandleOf($: EngineInterface): Promise<string | null> {
95 try { const v = await $.env.get('ORCA_TERMINAL_HANDLE'); return typeof v === 'string' && /^term_[A-Za-z0-9-]{1,80}$/.test(v) ? v : null } catch { return null }
96}
97
98/** What needs attention beyond STATE's items: uncommitted files, why health is not green, the latest record's open ends, TRAPS to compact. */
99async function attentionOf(io: Io, root: string, stateText: string, isUninitialized: boolean): Promise<FwPanelDigest['attention']> {
100 const status = await git(io, root, ['status', '--porcelain'])
101 const dirty = status === null ? null : status === '' ? 0 : status.split('\n').length
102 const health = healthOf(stateText)
103 let record: string | null = null
104 let unfinished: string[] = []
105 let moreUnfinished = 0
106 const latest = isUninitialized ? undefined : parseFrontmatter(stateText)?.latest_record
107 // `records/<name>.md` under .flightwake/ only: never follow a path out of it
108 if (latest && /^records\/[^/\\]+\.md$/.test(latest)) {
109 const t = await readRel(io, root, `${FW_DIR}/${latest}`)
110 if (t !== null) {
111 const u = recordUnfinished(t)
112 record = latest.slice('records/'.length, -'.md'.length)
113 unfinished = u.items
114 moreUnfinished = u.more
115 }
116 }
117 const traps = await readRel(io, root, TRAPS_REL)
118 const active = traps === null ? 0 : parseTraps(traps).filter(isActive).length
119 return {
120 dirty,
121 healthWhy: health === 'yellow' || health === 'red' ? healthWhy(stateText) : null,
122 record,
123 unfinished,
124 moreUnfinished,
125 trapsActive: active > TRAPS_COMPACT_AT ? active : null,
126 }
127}
128
129async function refresh($: EngineInterface, sw: PanelSwitches): Promise<FwPanelDigest | null> {
130 try {
131 const d = await compute($, sw)
132 await update($, panelDigest, () => d)
133 return d
134 } catch {
135 return null
136 }
137}
138
139async function isPaneOpen($: EngineInterface): Promise<boolean> {
140 try { return (await $.ui.panes()).some((p) => p.id === PANE_ID) } catch { return false }
141}
142
143let isCommandRegistered = false
144async function ensureCommand($: EngineInterface, lang: Lang): Promise<void> {
145 if (isCommandRegistered) return
146 isCommandRegistered = true
147 try {
148 await $.command.register({ name: 'fw-panel', description: T.description[lang], argumentHint: '[on|off]' })
149 } catch {
150 isCommandRegistered = false
151 }
152}
153
154const HOT = 80
155const WARM = 60
156const RECENT_FILES = 4
157const RECENT_TESTS = 3
158const REPO_INFO_MS = 60000
159
160// ── the fleet console: Orca's terminals, joined to the fleet reports ──────────────────────────────────────────────
161// Module memory on purpose: a reload starts the poll again from session.start, and nothing here outlives the pane.
162let pollTimer: Timer | null = null
163let isPolling = false
164const repoInfo = new Map<string, { at: number; info: FwConsoleRepo }>()
165
166/** Where Orca put its CLI beside this terminal (an absolute path), or null. */
167async function orcaBinDir($: EngineInterface): Promise<string | null> {
168 try { const v = await $.env.get('ORCA_CLI_BIN_DIR'); return typeof v === 'string' && v.startsWith('/') ? v : null } catch { return null }
169}
170
171/** `orca <args>`: the CLI Orca put beside the terminal (ORCA_CLI_BIN_DIR), else `orca` on PATH. Null on any failure. */
172async function runOrca($: EngineInterface, args: readonly string[]): Promise<string | null> {
173 try {
174 const dir = await orcaBinDir($)
175 const bin = dir !== null ? `${dir.replace(/\/+$/, '')}/orca` : 'orca'
176 const r = await $.process.run([bin, ...args], { timeoutMs: 5000 })
177 return r.exitCode === 0 ? r.stdout : null
178 } catch {
179 return null
180 }
181}
182
183/** A listed worktree's own flightwake files, read-only: is it installed, its health, its marker version. */
184async function readRepo(io: Io, path: string, now: number): Promise<FwConsoleRepo> {
185 const held = repoInfo.get(path)
186 if (held && now - held.at < REPO_INFO_MS) return held.info
187 const state = await io.read(joinPath(path, STATE_REL))
188 const info: FwConsoleRepo = state === null
189 ? { isFlightwake: false, health: 'unknown', fwVersion: null }
190 : { isFlightwake: true, health: isUninitializedState(state) ? 'unknown' : healthOf(state), fwVersion: await detectVersion(io, path) }
191 repoInfo.set(path, { at: now, info })
192 return info
193}
194
195async function poll($: EngineInterface): Promise<void> {
196 if (isPolling) return
197 isPolling = true
198 try {
199 if (!(await isPaneOpen($))) { stopPolling(); return }
200 let now = Date.now()
201 try { now = await $.clock.now() } catch {}
202 const out = await runOrca($, ['terminal', 'list', '--json'])
203 const terms = out === null ? null : parseTerminalList(out)
204 if (terms === null) {
205 await update($, consoleRef, (cur): FwConsole => ({ at: now, status: 'no-orca', terms: cur?.terms ?? [], repos: cur?.repos ?? {} }))
206 return
207 }
208 const io = ioOf($)
209 const repos: Record<string, FwConsoleRepo> = {}
210 for (const p of [...new Set(terms.map((t) => t.worktreePath).filter((p) => p.startsWith('/')))].slice(0, REPO_CAP)) {
211 repos[p] = await readRepo(io, p, now)
212 }
213 await update($, consoleRef, (): FwConsole => ({ at: now, status: 'ok', terms, repos }))
214 } catch {
215 } finally {
216 isPolling = false
217 }
218}
219
220/** Starts the 10 s poll (once) and resolves after its first answer. */
221async function startPolling($: EngineInterface): Promise<void> {
222 if (pollTimer !== null) return
223 try {
224 pollTimer = $.clock.every(POLL_MS, () => { void poll($) })
225 } catch { pollTimer = null; return }
226 await poll($)
227}
228
229function stopPolling(): void {
230 if (pollTimer === null) return
231 try { pollTimer.cancel() } catch {}
232 pollTimer = null
233}
234
235/** The console runs only inside Orca: this session's own terminal handle says so. */
236async function startIfOrca($: EngineInterface): Promise<void> {
237 if ((await selfHandleOf($)) !== null) await startPolling($)
238}
239
240async function notify($: EngineInterface, text: string, isError: boolean): Promise<void> {
241 let at = Date.now()
242 try { at = await $.clock.now() } catch {}
243 await update($, uiRef, () => ({ confirm: null, notice: { text, isError, at } }))
244}
245
246/** "Wrap up" for one agent: only while it is still idle, judged from a fresh list (never from what was drawn). */
247async function sendWrapUp($: EngineInterface, lang: Lang, handle: string): Promise<void> {
248 const out = await runOrca($, ['terminal', 'list', '--json'])
249 const term = (out === null ? null : parseTerminalList(out))?.find((t) => t.handle === handle) ?? null
250 if (term === null) { await notify($, OT.failed[lang]('terminal list'), true); return }
251 const who = `${agentName(term.agent)} · ${cleanTitle(term)}`
252 let now = Date.now()
253 try { now = await $.clock.now() } catch {}
254 const f = (await read($, fleetRef)) as FwFleet | null
255 const reported = f?.othersList?.find((o) => o.orcaTerminal === handle) ?? null
256 const state = reported !== null ? reported.display : termState(term, now)
257 const cmd = wrapUpCommand(term.agent)
258 if (state !== 'idle' || cmd === null) { await notify($, OT.notIdle[lang](who), true); return }
259 const sent = await runOrca($, ['terminal', 'send', '--terminal', handle, '--text', cmd, '--enter', '--json'])
260 await notify($, sent === null ? OT.failed[lang]('terminal send') : OT.sent[lang](cmd, who), sent === null)
261}
262
263export function registerPanel(on: On, sw: PanelSwitches): void {
264 on('session.start', {}, async ($, e, next) => {
265 const r = await next(e)
266 const d = await refresh($, sw)
267 if (d === null) return r
268 await ensureCommand($, d.lang)
269 try {
270 // Unasked: the engine seats it from 110 columns for a pane the person opened before, else it waits undrawn
271 // (an unasked open resolves at once, placed or waiting, so the poll below finds the pane registered)
272 if (e.isInteractive && (await $.store.get(STORE_KEY)) === true && !(await isPaneOpen($))) {
273 try { await $.ui.open({ id: PANE_ID, title: T.title[d.lang] }) } catch {}
274 }
275 // a reload of this module finds the pane still open: the poll starts again
276 // in the background: a session start never waits on Orca
277 if (e.isInteractive && ((await $.store.get(STORE_KEY)) === true || (await isPaneOpen($)))) void startIfOrca($).catch(() => {})
278 } catch {}
279 return r
280 })
281
282 on('turn.complete', {}, async ($, e, next) => {
283 const r = await next(e)
284 await refresh($, sw)
285 return r
286 })
287
288 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
289 const ran = await next(e)
290 try {
291 if (ran.deny === undefined && ran.isError === undefined) {
292 const op = (ran.result as { gitOperation?: { commit?: unknown; branch?: unknown } } | undefined)?.gitOperation
293 if (op && (op.commit || op.branch)) await refresh($, sw)
294 }
295 } catch {}
296 return ran
297 })
298
299 on('command.run', { command: 'fw-panel' }, async ($, e) => {
300 const d = await refresh($, sw)
301 const lang: Lang = d?.lang ?? 'en'
302 const verb = panelVerb(e.args)
303 const isOpen = await isPaneOpen($)
304 if (verb === 'off' || (verb === 'toggle' && isOpen)) {
305 try { await $.store.set(STORE_KEY, false) } catch {}
306 if (isOpen) { try { await $.ui.close({ id: PANE_ID }) } catch {} }
307 stopPolling()
308 return { text: T.closed[lang] }
309 }
310 try { await $.store.set(STORE_KEY, true) } catch {}
311 try {
312 const opened = await $.ui.open({ id: PANE_ID, title: T.title[lang] })
313 await startIfOrca($)
314 return { text: opened.isPlaced ? T.opened[lang] : T.waiting[lang](opened.reason) }
315 } catch {
316 return { text: T.failed[lang] }
317 }
318 })
319
320 // The person's own close is their answer for later sessions too (the engine stops seating it unasked as well)
321 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
322 if (e.origin.kind === 'person') { try { await $.store.set(STORE_KEY, false) } catch {} }
323 stopPolling()
324 return next(e)
325 })
326
327 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
328 try {
329 const d = await read($, panelDigest)
330 if (!d) return next(e)
331 const lang = d.lang
332 const { Box, Text, Button } = $.ui.resolve(e)
333 // Prose wraps to the pane's width (2026-10-11 field feedback: cut lines hid what they were about); a marked line
334 // keeps its mark in a column of its own so the wrapped rows hang under the text. Commands and paths stay one row.
335 const line = (props: Record<string, unknown>, text: string) => h(Text, { wrap: 'wrap', ...props }, text)
336 const cut = (props: Record<string, unknown>, text: string) => h(Text, { wrap: 'truncate-end', ...props }, text)
337 const para = (mark: string, props: Record<string, unknown>, text: string) => h(Box, { flexDirection: 'row' },
338 h(Box, { flexShrink: 0 }, h(Text, props, mark)),
339 h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { wrap: 'wrap', ...props }, text)))
340 const card = (color: string | undefined, ...children: unknown[]) =>
341 h(Box, { flexDirection: 'column', borderStyle: 'round', borderColor: color, borderDimColor: color === undefined, paddingX: 1, marginTop: 1 }, ...children)
342 const bar = (pct: number) => {
343 const b = contextBar(pct)
344 return h(Text, { wrap: 'truncate' }, h(Text, { color: b.color }, b.filled), h(Text, { dimColor: true }, b.empty), ` ${Math.round(pct)}%`)
345 }
346 const healthColor = (hl: string) => (hl === 'green' ? 'green' : hl === 'yellow' ? 'yellow' : hl === 'red' ? 'red' : undefined)
347 const rows: unknown[] = []
348
349 // ── this repo: health · lag · context bar · cost · versions · watch ──
350 const band = (await read($, bandRef)) as FwBandView | null
351 const lagSrc = d.lag !== null ? { lagKind: d.lag.kind, behind: d.lag.behind } : band ? { lagKind: band.lagKind, behind: band.behind } : null
352 const lag = lagSrc === null ? '' : lagText(lang, lagSrc)
353 const pct = d.contextPercent
354 const spend = sw.spend ? ((await read($, spendRef)) as FwSpend | null) : null
355 const sl = spend !== null && spend.sessionId === d.sessionId ? spendLine(spend) : ''
356 const head: unknown[] = [
357 h(Text, { wrap: 'wrap' },
358 h(Text, { bold: true }, '✈ flightwake'),
359 h(Text, { color: healthColor(d.health) }, ` ●${d.health === 'unknown' ? '?' : d.health}`),
360 ...(lag ? [` · ${lag}`] : [])),
361 ]
362 if (pct !== null) head.push(h(Box, { flexDirection: 'row', gap: 1 }, h(Text, { dimColor: true }, 'context'), bar(pct)))
363 const sub = [sl || (sw.spend ? T.spendNone[lang] : ''), T.versions[lang](d.fwVersion, MOD_VERSION)].filter(Boolean).join(' · ')
364 head.push(line({ dimColor: true }, sub))
365 const a = d.attention
366 if (pct !== null && pct >= CONTEXT_HOT) head.push(para('! ', { color: 'red' }, T.wrapUp[lang]))
367 if (a.healthWhy) head.push(para('! ', { color: d.health === 'red' ? 'red' : 'yellow' }, T.healthWhy[lang](d.health, a.healthWhy)))
368 if (a.dirty !== null && a.dirty > 0) head.push(para('! ', { color: 'yellow' }, T.dirty[lang](a.dirty)))
369 if (a.record !== null && a.unfinished.length > 0) {
370 head.push(para('! ', {}, T.unfinished[lang](a.record)))
371 for (const it of a.unfinished) head.push(para(' – ', {}, it))
372 if (a.moreUnfinished > 0) head.push(line({ dimColor: true }, ` ${T.more[lang](a.moreUnfinished)}`))
373 }
374 if (a.trapsActive !== null) head.push(para('! ', { color: 'yellow' }, T.traps[lang](a.trapsActive)))
375 rows.push(card(healthColor(d.health), ...head))
376
377 // ── fleet: every agent Orca has, by repo; Claude Code sessions with the mod add their exact state ──
378 const ui = ((await read($, uiRef)) as FwPanelUi | null) ?? { confirm: null, notice: null }
379 const f = sw.fleet ? ((await read($, fleetRef)) as FwFleet | null) : null
380 const reported = new Map<string, FwFleetOther>()
381 for (const o of f?.othersList ?? []) if (o.orcaTerminal !== null) reported.set(o.orcaTerminal, o)
382 const con = d.selfHandle !== null ? ((await read($, consoleRef)) as FwConsole | null) : null
383 rows.push(h(Box, { marginTop: 1 }, line({ bold: true }, OT.fleet[lang])))
384 if (d.selfHandle === null || con === null || (con.status === 'no-orca' && con.terms.length === 0)) {
385 // outside Orca, before the first answer, or Orca not answering: the fleet reports alone
386 rows.push(line({ dimColor: true }, d.selfHandle === null ? OT.outsideOrca[lang] : con === null ? OT.waitingFirst[lang] : OT.noOrca[lang]))
387 if (!sw.fleet) rows.push(line({ dimColor: true }, ` ${T.fleetOff[lang]}`))
388 else if (f === null || f.status !== 'active') rows.push(line({ dimColor: true }, ` ${T.fleetNotReporting[lang]}`))
389 else if (f.othersList === undefined || f.othersList === null) rows.push(line({ dimColor: true }, ` ${T.fleetWaiting[lang]}`))
390 else if (f.othersList.length === 0) rows.push(line({ dimColor: true }, ` ${T.none[lang]}`))
391 else {
392 const word = FLEET_T.word[lang]
393 for (const o of f.othersList) {
394 const mark = o.display === 'blocked' ? '⛔' : o.display === 'working' ? '●' : '○'
395 const kind = o.blockedKind ? `(${FLEET_T.kind[lang][o.blockedKind]})` : ''
396 const role = o.role ? ` · ${o.role}` : ''
397 rows.push(para(`${mark} `, { color: o.display === 'blocked' ? 'red' : undefined, dimColor: o.display === 'idle' },
398 `${o.label}${role} · ${word[o.display]}${kind} ${fmtAge(o.ageMs)}`))
399 if (o.contextPercent !== null && o.contextPercent >= CONTEXT_HOT) rows.push(para(' ', { color: 'red' }, `${T.context[lang](o.contextPercent)} · ${T.wrapUp[lang]}`))
400 const vers: string[] = []
401 if (o.fwVersion !== null && d.fwVersion !== null && o.fwVersion !== d.fwVersion) vers.push(`flightwake ${o.fwVersion}`)
402 if (o.mod !== null && o.mod !== MOD_VERSION) vers.push(`mod ${o.mod}`)
403 if (vers.length > 0) rows.push(para(' ', { color: 'yellow' }, `${vers.join(' · ')} — ${T.differs[lang]}`))
404 }
405 }
406 } else {
407 if (con.status === 'no-orca') rows.push(line({ color: 'yellow' }, OT.noOrca[lang]))
408 const listed = con.terms.map((term) => {
409 const rep = reported.get(term.handle) ?? null
410 const isSelf = term.handle === d.selfHandle
411 const state: TermState = isSelf ? 'working' : rep !== null ? rep.display : termState(term, con.at)
412 return { term, state, rep, isSelf }
413 })
414 if (listed.length === 0) rows.push(line({ dimColor: true }, ` ${OT.noAgents[lang]}`))
415 for (const g of groupByRepo(listed)) {
416 const repo = con.repos[g.path] ?? null
417 const items: unknown[] = []
418 const vDiffers = repo?.fwVersion != null && d.fwVersion !== null && repo.fwVersion !== d.fwVersion
419 items.push(h(Text, { wrap: 'wrap' },
420 h(Text, { bold: true }, baseName(g.path) || '?'),
421 ...(repo?.isFlightwake ? [h(Text, { color: healthColor(repo.health) }, ` ●${repo.health === 'unknown' ? '?' : repo.health}`)] : []),
422 ...(repo?.fwVersion ? [h(Text, { color: vDiffers ? 'yellow' : undefined, dimColor: !vDiffers }, ` v${repo.fwVersion}${vDiffers ? ` ≠ ${d.fwVersion}` : ''}`)] : []),
423 ))
424 for (const r of g.rows) {
425 const mark = r.state === 'blocked' ? '⛔' : r.state === 'working' ? '●' : r.state === 'idle' ? '○' : '?'
426 const color = r.state === 'blocked' ? 'red' : r.state === 'working' ? 'green' : undefined
427 const role = r.rep?.role ? ` · ${r.rep.role}` : ''
428 const age = r.rep !== null ? ` ${fmtAge(r.rep.ageMs)}` : r.term.lastOutputAt !== null ? ` ${fmtAge(Math.max(0, con.at - r.term.lastOutputAt))}` : ''
429 const who = r.isSelf ? `${agentName(r.term.agent)} · ${OT.you[lang]}` : `${agentName(r.term.agent)} · ${cleanTitle(r.term)}`
430 items.push(para(`${mark} `, { color, dimColor: r.state === 'idle' && !r.isSelf }, `${who}${role} · ${stateWord(lang, r.state)}${age}`))
431 const ctx = r.isSelf ? pct : r.rep?.contextPercent ?? null
432 const meta: unknown[] = []
433 if (ctx !== null) meta.push(bar(ctx))
434 if (r.rep !== null && r.rep.mod !== null && r.rep.mod !== MOD_VERSION) meta.push(h(Text, { color: 'yellow' }, `mod ${r.rep.mod}`))
435 if (!r.isSelf) {
436 const h0 = r.term.handle
437 if (ui.confirm === h0) {
438 const cmd = wrapUpCommand(r.term.agent) ?? ''
439 meta.push(h(Button, { key: `ok-${h0}`, label: OT.confirm[lang](cmd), variant: 'primary', onPress: () => sendWrapUp($, lang, h0) }))
440 meta.push(h(Button, { key: `no-${h0}`, label: OT.cancel[lang], onPress: () => update($, uiRef, (u) => ({ confirm: null, notice: u?.notice ?? null })) }))
441 } else {
442 meta.push(h(Button, {
443 key: `go-${h0}`, label: OT.jump[lang],
444 onPress: async () => { if ((await runOrca($, ['terminal', 'switch', '--terminal', h0, '--json'])) === null) await notify($, OT.failed[lang]('terminal switch'), true) },
445 }))
446 // wrap-up is offered only to an idle agent flightwake has a command for; it asks again before sending
447 if (r.state === 'idle' && wrapUpCommand(r.term.agent) !== null) {
448 meta.push(h(Button, { key: `wrap-${h0}`, label: OT.wrap[lang], onPress: () => update($, uiRef, () => ({ confirm: h0, notice: null })) }))
449 }
450 }
451 }
452 if (ctx !== null && ctx >= CONTEXT_HOT && !r.isSelf) items.push(para(' ', { color: 'red' }, T.wrapUp[lang]))
453 if (meta.length > 0) items.push(h(Box, { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, paddingLeft: 2 }, ...meta))
454 }
455 const worst = g.rows.some((r) => r.state === 'blocked') ? 'red' : undefined
456 rows.push(card(worst, ...items))
457 }
458 rows.push(line({ dimColor: true }, OT.stateHeuristic[lang]))
459 }
460 if (ui.notice !== null) rows.push(line({ color: ui.notice.isError ? 'red' : 'green' }, ui.notice.text))
461
462 // ── this repo's STATE items ──
463 if (d.isUninitialized) {
464 rows.push(card('yellow', line({ color: 'yellow' }, T.uninitialized[lang])))
465 } else {
466 const st: unknown[] = []
467 const section = (title: string, items: string[], more: number, mark: string) => {
468 st.push(line({ bold: true }, title))
469 if (items.length === 0) st.push(line({ dimColor: true }, ` ${T.none[lang]}`))
470 for (const it of items) st.push(para(`${mark} `, {}, it))
471 if (more > 0) st.push(line({ dimColor: true }, ` ${T.more[lang](more)}`))
472 }
473 section(T.inProgress[lang], d.inProgress, d.moreInProgress, '•')
474 section(T.next[lang], d.next, d.moreNext, '›')
475 if (d.updated) st.push(line({ dimColor: true }, T.updated[lang](d.updated)))
476 rows.push(card(undefined, ...st))
477 }
478
479 // ── this session ──
480 const ses: unknown[] = [line({ bold: true }, T.session[lang])]
481 if (!sw.recorder) ses.push(line({ dimColor: true }, ` ${T.recorderOff[lang]}`))
482 else {
483 const raw = (await read($, logRef)) as FwFlightLog | null
484 const log = raw !== null && raw.sessionId === d.sessionId ? raw : null
485 const tests = log?.tests ?? []
486 ses.push(line({}, T.counts[lang]({
487 files: log?.files.length ?? 0, shell: log?.shellFiles?.length ?? 0, tests: tests.length,
488 pass: tests.filter((t) => t.result === 'pass').length, fail: tests.filter((t) => t.result === 'fail').length,
489 commits: log?.commits.length ?? 0,
490 })))
491 for (const t of tests.slice(-RECENT_TESTS).reverse()) {
492 const ok = t.result === 'pass'
493 ses.push(cut({ color: ok ? 'green' : t.result === 'fail' ? 'red' : undefined, dimColor: t.result === 'unknown' }, ` ${ok ? '✓' : t.result === 'fail' ? '✗' : '?'} ${t.command}`))
494 }
495 const seen = new Set<string>()
496 const recent: string[] = []
497 for (const fl of [...(log?.files ?? [])].reverse()) {
498 if (seen.has(fl.path)) continue
499 seen.add(fl.path)
500 recent.push(fl.path)
501 if (recent.length >= RECENT_FILES) break
502 }
503 for (const p of recent) ses.push(cut({ dimColor: true }, ` ✎ ${p}`))
504 }
505 rows.push(card(undefined, ...ses))
506
507 const tree = h(Box, { width: e.props.bodyColumns, flexDirection: 'column' }, ...rows)
508 return tree && typeof tree !== 'string' ? (tree as RenderElement) : next(e)
509 } catch {
510 return next(e)
511 }
512 })
513}
514hooks/lib/core.ts 321 lines1/**
2 * Shared world access for every feature.
3 *
4 * READ SCOPE (docs/plans/mods.md, revised 2026-10-05; F7 additions) — the mod reads only:
5 * .flightwake/ · git state · the flightwake / roles markers in CLAUDE.md, .claude/CLAUDE.md, CLAUDE.local.md ·
6 * the flightwake marker in AGENTS.md / GEMINI.md (language only) · package.json `scripts` · the effective
7 * `statusLine` setting · the session transcript (F6) · F7's `<base>/sessions/*.json`, whether a listed session's
8 * transcript exists (existence only), existence probes for `<base>/STATE.md` and for `.git` and
9 * `.flightwake/STATE.md` on `<base>` and each folder above it, and where the root and `<base>/sessions` really
10 * land (stat with resolve). F8's console, only inside Orca (ORCA_TERMINAL_HANDLE set) and only while the side
11 * panel is open: `orca terminal list --json` every 10 s, and for each worktree it lists, that worktree's
12 * `.flightwake/STATE.md` and instruction-file markers (read-only, no git there); on a press, `orca terminal switch`
13 * and — after a second, confirming press, and only to an idle agent — `orca terminal send` of `/fw-record` /
14 * `$fw-record`. Nothing else, and no network. Everything outside the module goes through `$`; every
15 * helper here swallows its own errors and answers null/false, so a feature built on them degrades silently (no
16 * .flightwake/, not a git repo, git missing, unreadable file) instead of surfacing an error to the person.
17 * Never writes inside the repo or the user's records: the mod's only write is F7's own session file,
18 * `<base>/sessions/<id>.json` outside the repo, made in hooks/features/fleet.ts alone — refused when `<base>` lies
19 * inside the repo (by spelling or by real path), is a records folder, or lies in any git repo or flightwake root;
20 * never through a symlink; never over a file that is not ours. The shared Io here has no write; everything else
21 * stays in $.state / $.store.
22 */
23
24export const PLUGIN = 'flightwake-mod' as const
25/** Mirrors .claude-plugin/plugin.json `version` (the mod cannot read its own manifest); test/smoke.sh checks they match. */
26export const MOD_VERSION = '0.3.0'
27export const FW_DIR = '.flightwake'
28export const STATE_REL = '.flightwake/STATE.md'
29export const TRAPS_REL = '.flightwake/TRAPS.md'
30
31export type Lang = 'en' | 'zh-TW' | 'zh-CN' | 'ja'
32export const LANGS: readonly Lang[] = ['en', 'zh-TW', 'zh-CN', 'ja']
33/** A message in the four install languages; a missing key falls back to English (same rule as hooks/*.mjs). */
34export type Msg = { en: string } & Partial<Record<Exclude<Lang, 'en'>, string>>
35export const M = (lang: Lang, m: Msg): string => m[lang] ?? m.en
36
37/** One markdown table cell: newlines become spaces, then `\` before `|` is escaped so a `\|` in the text stays unambiguous. */
38export const tableCell = (s: string): string => s.replace(/\r?\n/g, ' ').replace(/\\/g, '\\\\').replace(/\|/g, '\\|')
39
40/**
41 * The world as the shared helpers see it. `$` can't be passed across an import — the engine's loader refuses a
42 * module that does (`$ is followed only into a function declared in this same file, never across an import`) —
43 * so each feature file builds this from its own `$` with a local `ioOf($)` (copy IO_OF_TEMPLATE below) and passes
44 * the closures in. Every member must swallow its own errors (null / false / {}), never throw.
45 */
46export type Io = {
47 /** $.session.root(), absolute, no trailing slash; '' when unreadable. */
48 root: () => Promise<string>
49 /** $.session.id(); '' when unreadable. */
50 sessionId: () => Promise<string>
51 /** $.fs.exists(abs); false on error. */
52 exists: (abs: string) => Promise<boolean>
53 /** $.fs.read(abs) as text; null when absent/unreadable. */
54 read: (abs: string) => Promise<string | null>
55 /**
56 * `git --no-optional-locks <args>` in cwd: trimmed stdout on exit 0, null otherwise (not a repo, git missing,
57 * timeout). The flag is part of the zero-write promise: a plain `git status` may refresh and rewrite .git/index.
58 */
59 git: (args: readonly string[], cwd: string) => Promise<string | null>
60 /** $.settings.read({}) — the merged, effective settings; {} on error. */
61 settings: () => Promise<Record<string, unknown>>
62}
63
64/*
65 * IO_OF_TEMPLATE — paste into a feature file (it must live in the same file as the hooks that call it):
66 *
67 * import type { EngineInterface } from 'claude-code'
68 * import type { Io } from '../lib/core'
69 *
70 * function ioOf($: EngineInterface): Io {
71 * return {
72 * root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
73 * sessionId: async () => { try { return await $.session.id() } catch { return '' } },
74 * exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
75 * read: async (p) => {
76 * try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
77 * },
78 * git: async (args, cwd) => {
79 * try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
80 * },
81 * settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
82 * }
83 * }
84 */
85
86
87export const joinPath = (root: string, rel: string): string => `${root}/${rel.replace(/^\/+/, '')}`
88
89/**
90 * `abs` relative to `root` with forward slashes, or null when it lies outside root.
91 * Purely lexical: collapses `.`/`..` segments; does not resolve links.
92 */
93export function relToRoot(root: string, abs: string): string | null {
94 const norm = (p: string) => {
95 const out: string[] = []
96 for (const seg of p.split('/')) {
97 if (seg === '' || seg === '.') continue
98 if (seg === '..') out.pop()
99 else out.push(seg)
100 }
101 return out
102 }
103 const r = norm(root)
104 const a = norm(abs.startsWith('/') ? abs : `${root}/${abs}`)
105 if (a.length < r.length || r.some((s, i) => a[i] !== s)) return null
106 return a.slice(r.length).join('/')
107}
108
109/** A text file under the root, or null when absent/unreadable. */
110export const readRel = (io: Io, root: string, rel: string): Promise<string | null> => io.read(joinPath(root, rel))
111
112/** `git <args>` in root (see Io.git). */
113export const git = (io: Io, root: string, args: readonly string[]): Promise<string | null> => io.git(args, root)
114
115/** The YAML-ish frontmatter block of a Markdown file as flat key → raw string (inline comments stripped). */
116export function parseFrontmatter(text: string): Record<string, string> | null {
117 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
118 if (!m || m[1] === undefined) return null
119 return parseFlatYaml(m[1])
120}
121
122/** `key: value` lines → record; `# comment` tails and surrounding quotes removed; nested/continuation lines ignored. */
123export function parseFlatYaml(block: string): Record<string, string> {
124 const out: Record<string, string> = {}
125 for (const line of block.split(/\r?\n/)) {
126 const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
127 if (!m || m[1] === undefined) continue
128 let v = (m[2] ?? '').replace(/\s+#.*$/, '').trim()
129 if (/^(['"]).*\1$/.test(v)) v = v.slice(1, -1)
130 out[m[1]] = v
131 }
132 return out
133}
134
135/** An inline YAML list (`[a, "b c", 'd']`) or a single scalar → items; empty input → []. */
136export function parseInlineList(v: string | undefined): string[] {
137 if (!v) return []
138 const s = v.trim()
139 const body = s.startsWith('[') && s.endsWith(']') ? s.slice(1, -1) : s
140 const items: string[] = []
141 const re = /\s*("([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|([^,]+))\s*(?:,|$)/g
142 let m: RegExpExecArray | null
143 while ((m = re.exec(body)) !== null && m[0] !== '') {
144 const item = (m[2] ?? m[3] ?? m[4] ?? '').trim()
145 if (item) items.push(item)
146 }
147 return items
148}
149
150/**
151 * Files whose flightwake marker carries the install language, in bin/cli.mjs detectMarker order: Claude's first,
152 * then AGENTS.md / GEMINI.md (read for the marker only — a repo installed for Codex/Gemini alone has just those).
153 */
154export const MARKER_FILES = ['.claude/CLAUDE.md', 'CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', 'GEMINI.md'] as const
155/** `<!-- flightwake:begin v<version> [attr=value …] -->`; attributes in any order (e.g. `lang=zh-TW profile=notes`). */
156const MARKER_RE = /<!-- flightwake:begin v(\d+\.\d+\.\d+\S*)((?:\s+[\w-]+=[^\s>]+)*)\s*-->/
157
158/** The language attribute of a marker line, or what its absence means; null when the text holds no marker. */
159export function markerLang(text: string): Lang | null {
160 const m = MARKER_RE.exec(text)
161 if (!m) return null
162 const attrs = m[2] ?? ''
163 const lang = /(?:^|\s)lang=([\w-]+)/.exec(attrs)?.[1]
164 // A pre-0.9 marker carried no attributes at all, and every install back then was zh-TW. A marker that has other
165 // attributes but no lang is a newer writer that left the language out: fall back to the default, English.
166 if (lang === undefined) return attrs.trim() === '' ? 'zh-TW' : 'en'
167 return (LANGS as readonly string[]).includes(lang) ? (lang as Lang) : 'en'
168}
169
170/** The flightwake version a marker was written by (`0.16.0`), or null when the text holds no marker. */
171export function markerVersion(text: string): string | null {
172 return MARKER_RE.exec(text)?.[1] ?? null
173}
174
175/** The version recorded at install time, from the same first marker detectLang reads; null when none. */
176export async function detectVersion(io: Io, root: string): Promise<string | null> {
177 for (const rel of MARKER_FILES) {
178 const t = await readRel(io, root, rel)
179 if (t === null) continue
180 const v = markerVersion(t)
181 if (v !== null) return v
182 }
183 return null
184}
185
186/** The profile attribute of a marker (`profile=notes`); absent = code; null when the text holds no marker. */
187export function markerProfile(text: string): 'code' | 'notes' | null {
188 const m = MARKER_RE.exec(text)
189 if (!m) return null
190 return /(?:^|\s)profile=notes(?:\s|$)/.test(m[2] ?? '') ? 'notes' : 'code'
191}
192
193/** The profile recorded at install time, from the same first marker detectLang reads; none → code. */
194export async function detectProfile(io: Io, root: string): Promise<'code' | 'notes'> {
195 for (const rel of MARKER_FILES) {
196 const t = await readRel(io, root, rel)
197 if (t === null) continue
198 const p = markerProfile(t)
199 if (p !== null) return p
200 }
201 return 'code'
202}
203
204/** The language recorded at install time: the first marker found in MARKER_FILES; none → en. */
205export async function detectLang(io: Io, root: string): Promise<Lang> {
206 for (const rel of MARKER_FILES) {
207 const t = await readRel(io, root, rel)
208 if (t === null) continue
209 const l = markerLang(t)
210 if (l !== null) return l
211 }
212 return 'en'
213}
214
215export type Health = 'green' | 'yellow' | 'red' | 'unknown'
216
217/** health from STATE frontmatter (`health: green # …`); anything unrecognised is 'unknown'. */
218export function healthOf(stateText: string): Health {
219 const h = /^health:\s*(\S+)/m.exec(stateText)?.[1]
220 return h === 'green' || h === 'yellow' || h === 'red' ? h : 'unknown'
221}
222
223/** The shipped STATE template's own frontmatter placeholders (identical in all four languages). */
224const TEMPLATE_FIELDS = ['{{DATE}}', '{{SESSION_OR_PERSON}}', '{{YYMMDD}}', '{{slug}}'] as const
225
226/**
227 * STATE is still the unfilled template: its frontmatter still holds one of the template's own placeholders.
228 * Only those count — a filled STATE may legitimately document `{{customer}}`-style placeholders in its body.
229 */
230export function isUninitializedState(stateText: string): boolean {
231 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(stateText)
232 if (!m || m[1] === undefined) return false
233 const front = m[1]
234 return TEMPLATE_FIELDS.some((f) => front.includes(f))
235}
236
237/**
238 * STATE lag with state-check.mjs's full semantics:
239 * - `dirty`: STATE.md has uncommitted changes → an update is in progress, counts as fresh
240 * - `no-baseline`: STATE.md was never committed → not measured (state-check stays quiet)
241 * - `behind`: human commits since STATE's last commit (`--author=\[bot\]` excluded); 0 = in sync
242 * - `error`: a git call failed midway → unknown; callers must NEVER render this as "in sync"
243 * null: not a git repo / git unavailable (nothing to say).
244 */
245export type StateLag =
246 | { kind: 'dirty' }
247 | { kind: 'no-baseline' }
248 | { kind: 'behind'; behind: number }
249 | { kind: 'error' }
250
251export async function stateLag(io: Io, root: string): Promise<StateLag | null> {
252 const status = await git(io, root, ['status', '--porcelain', '--', STATE_REL])
253 if (status === null) return null
254 if (status !== '') return { kind: 'dirty' }
255 const last = await git(io, root, ['log', '-1', '--format=%H', '--', STATE_REL])
256 if (last === null) return { kind: 'error' }
257 if (last === '') return { kind: 'no-baseline' }
258 const range = `${last}..HEAD`
259 const all = await git(io, root, ['rev-list', '--count', range])
260 const bots = await git(io, root, ['rev-list', '--count', '--author=\\[bot\\]', range])
261 const a = Number(all)
262 const b = Number(bots)
263 if (all === null || bots === null || all === '' || bots === '' || !Number.isFinite(a) || !Number.isFinite(b)) {
264 return { kind: 'error' }
265 }
266 return { kind: 'behind', behind: Math.max(0, a - b) }
267}
268
269/**
270 * Whether the legacy node gauge (hooks/statusline.mjs) is the *effective* statusLine: the settings as the engine
271 * runs under them (every source merged), not whether a settings file mentions it. The mod only reads this to
272 * hide its own duplicate fields; it never edits settings.
273 */
274export async function legacyStatuslineActive(io: Io): Promise<boolean> {
275 try {
276 return JSON.stringify((await io.settings()).statusLine ?? null).includes('statusline.mjs')
277 } catch {
278 return false
279 }
280}
281
282/**
283 * `scripts` of the package.json in `dirRel` (repo-relative, '' = root), or null when there is none / it doesn't
284 * parse. The only reason the mod reads package.json: confirming what a `npm test`-style script actually runs.
285 */
286export async function packageScripts(io: Io, root: string, dirRel = ''): Promise<Record<string, string> | null> {
287 const t = await readRel(io, root, dirRel ? `${dirRel.replace(/\/+$/, '')}/package.json` : 'package.json')
288 if (t === null) return null
289 try {
290 const s = JSON.parse(t).scripts
291 if (!s || typeof s !== 'object') return null
292 const out: Record<string, string> = {}
293 for (const [k, v] of Object.entries(s)) if (typeof v === 'string') out[k] = v
294 return out
295 } catch {
296 return null
297 }
298}
299
300/** FNV-1a 32-bit, hex — a cheap content version for dedup keys (not a security hash). */
301export function contentHash(text: string): string {
302 let h = 0x811c9dc5
303 for (let i = 0; i < text.length; i++) {
304 h ^= text.charCodeAt(i)
305 h = Math.imul(h, 0x01000193) >>> 0
306 }
307 return h.toString(16).padStart(8, '0')
308}
309
310/**
311 * The context every feature starts from: the root and the install language, or null when this folder has no
312 * .flightwake/STATE.md (flightwake isn't installed here → every feature stays silent).
313 */
314export type FwContext = { root: string; lang: Lang }
315export async function fwContext(io: Io): Promise<FwContext | null> {
316 const root = (await io.root()).replace(/\/+$/, '')
317 if (!root) return null
318 if (!(await io.exists(joinPath(root, STATE_REL)))) return null
319 return { root, lang: await detectLang(io, root) }
320}
321hooks/lib/shell.ts 242 lines1/**
2 * A deliberately small shell-command reader for F3 (test recognition) and F4 (command tripwires).
3 * It is NOT a shell parser: it splits on top-level control operators outside quotes and tokenizes each simple
4 * command. Anything it can't read plainly is flagged (`isComplex`) so callers can stay conservative.
5 */
6
7export type Segment = {
8 /** Words of one simple command, quotes removed; leading `VAR=value` assignments moved to `env`. */
9 tokens: string[]
10 env: string[]
11 /** The operator that ended this segment: '&&', '||', ';', '|', '&', '\n', or '' for the last one. */
12 op: string
13 /** A subshell boundary marker (`(` / `)`), carrying no words; F4 walks into subshells, F3 counts it as compound. */
14 group?: 'open' | 'close'
15}
16
17export type ParsedCommand = {
18 segments: Segment[]
19 /** Command substitution, backticks, subshell parens, heredocs or an unterminated quote — not plainly readable. */
20 isComplex: boolean
21}
22
23const OPS = ['&&', '||', ';;', '|&', ';', '|', '&', '\n'] as const
24
25export function parseCommand(command: string): ParsedCommand {
26 const segments: Segment[] = []
27 let isComplex = false
28 let tokens: string[] = []
29 let cur = ''
30 let hasCur = false
31 let quote: '' | "'" | '"' = ''
32 const pushWord = () => {
33 if (hasCur) tokens.push(cur)
34 cur = ''
35 hasCur = false
36 }
37 const endSegment = (op: string) => {
38 pushWord()
39 const env: string[] = []
40 while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] as string)) env.push(tokens.shift() as string)
41 if (tokens.length || env.length) segments.push({ tokens, env, op })
42 else if (segments.length && op) (segments[segments.length - 1] as Segment).op = op
43 tokens = []
44 }
45 for (let i = 0; i < command.length; i++) {
46 const c = command[i] as string
47 if (quote) {
48 if (c === quote) quote = ''
49 else if (c === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i]
50 else {
51 if (quote === '"' && (c === '`' || (c === '$' && command[i + 1] === '('))) isComplex = true
52 cur += c
53 }
54 continue
55 }
56 if (c === "'" || c === '"') {
57 quote = c
58 hasCur = true
59 continue
60 }
61 if (c === '\\' && i + 1 < command.length) {
62 if (command[i + 1] === '\n') { i++; continue } // line continuation
63 cur += command[++i]
64 hasCur = true
65 continue
66 }
67 // `$( … )` and backticks: command substitution stays inside the word (nested parens counted)
68 if (c === '$' && command[i + 1] === '(') {
69 isComplex = true
70 let depth = 0
71 for (; i < command.length; i++) {
72 const d = command[i] as string
73 cur += d
74 if (d === '(') depth++
75 else if (d === ')' && --depth === 0) break
76 }
77 hasCur = true
78 continue
79 }
80 if (c === '`') {
81 isComplex = true
82 const close = command.indexOf('`', i + 1)
83 const end = close < 0 ? command.length - 1 : close
84 cur += command.slice(i, end + 1)
85 i = end
86 hasCur = true
87 continue
88 }
89 // A bare `(` / `)` opens / closes a subshell: an explicit marker segment, so the commands inside stay visible
90 if (c === '(' || c === ')') {
91 isComplex = true
92 endSegment('')
93 segments.push({ tokens: [], env: [], op: '', group: c === '(' ? 'open' : 'close' })
94 continue
95 }
96 if (c === '<' && command[i + 1] === '<') isComplex = true
97 // `&` inside a redirection (`2>&1`, `>&2`, `<&3`, `&>file`, `&>>file`) is part of the word, not an operator
98 const isRedirAmp = c === '&' && (command[i - 1] === '>' || command[i - 1] === '<' || command[i + 1] === '>')
99 const op = isRedirAmp ? undefined : OPS.find((o) => command.startsWith(o, i))
100 if (op) {
101 endSegment(op === '|&' ? '|' : op === ';;' ? ';' : op)
102 i += op.length - 1
103 continue
104 }
105 if (c === ' ' || c === '\t') {
106 pushWord()
107 continue
108 }
109 if (c === '#' && !hasCur) {
110 // a comment runs to the end of its line only; the newline itself still ends the segment
111 while (i + 1 < command.length && command[i + 1] !== '\n') i++
112 continue
113 }
114 cur += c
115 hasCur = true
116 }
117 if (quote) isComplex = true
118 endSegment('')
119 return { segments, isComplex }
120}
121
122/** True when the tokens start with every token of `prefix` (whitespace-separated), compared exactly. */
123export function startsWithTokens(tokens: readonly string[], prefix: string): boolean {
124 const p = prefix.trim().split(/\s+/).filter(Boolean)
125 return p.length > 0 && p.length <= tokens.length && p.every((t, i) => tokens[i] === t)
126}
127
128/**
129 * A file a command writes or removes, as far as its words say. `dir` is where a relative `path` resolves: '' = the
130 * command's starting directory, 'pkg' or '/abs' after a leading `cd … &&` chain, null = no longer certain (any other cd,
131 * or a cd inside a subshell, happened earlier) — the caller must not resolve a relative path then.
132 */
133export type ShellWrite = {
134 path: string
135 via: '>' | '>>' | 'cp' | 'mv' | 'rm' | 'tee' | 'sed -i'
136 /** remove = rm targets and mv sources (gone afterwards); write = everything else. */
137 effect: 'write' | 'remove'
138 dir: string | null
139}
140
141/** A word that can only be a path as written: no expansions, no globs, not a device, not a descriptor. */
142const isPathWord = (w: string | undefined): w is string =>
143 typeof w === 'string' && w !== '' && !/[$`*?[\]{}~]/.test(w) && !w.startsWith('/dev/') && !w.startsWith('&') && !w.startsWith('-')
144
145/**
146 * Splits a command's arguments by its own option grammar: flags that take a value consume it (attached or as the next
147 * word), `--` ends options, everything else is an operand. `withValue` lists the short and long options that take a value.
148 */
149function operands(args: readonly string[], withValue: { short: string; long: readonly string[] }): { operands: string[]; values: Record<string, string[]> } {
150 const out: string[] = []
151 const values: Record<string, string[]> = {}
152 const keep = (k: string, v: string) => { (values[k] ??= []).push(v) }
153 for (let i = 0; i < args.length; i++) {
154 const a = args[i] as string
155 if (a === '--') { out.push(...args.slice(i + 1)); break }
156 if (a.startsWith('--')) {
157 const [name, eq] = a.includes('=') ? [a.slice(2, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a.slice(2), undefined]
158 if (withValue.long.includes(name)) keep(name, eq ?? (args[++i] ?? ''))
159 continue
160 }
161 if (a.startsWith('-') && a.length > 1) {
162 // a cluster of short flags; the first one that takes a value takes the rest of the word or the next word
163 for (let j = 1; j < a.length; j++) {
164 const f = a[j] as string
165 if (withValue.short.includes(f)) { keep(f, j + 1 < a.length ? a.slice(j + 1) : (args[++i] ?? '')); break }
166 }
167 continue
168 }
169 out.push(a)
170 }
171 return { operands: out, values }
172}
173
174const joinDir = (dir: string, name: string): string => `${dir.replace(/\/+$/, '')}/${name.replace(/^.*\//, '')}`
175
176/**
177 * The write targets plainly readable from a command (F3's "changed through shell commands" list): output
178 * redirections (`>f`, `>> f`, `2>f`, `&>f`; not fd duplications like `2>&1`, not /dev/*), and the operands of
179 * cp/mv (destination, or each source joined to `-t DIR`; mv also its sources), rm, tee and `sed -i`, each read with that
180 * command's option grammar so option values (sed -e/-f scripts, -S suffixes, …) never pass for paths. Words with
181 * expansions or globs are skipped. Only a candidate list — the recorder lists what git confirms as changed.
182 */
183export function shellWriteTargets(command: string): ShellWrite[] {
184 const out: ShellWrite[] = []
185 let dir: string | null = ''
186 let isChainCertain = true // every segment so far ended with && (a cd here is a certain move)
187 let depth = 0
188 for (const seg of parseCommand(command).segments) {
189 if (seg.group === 'open') { depth++; continue }
190 if (seg.group === 'close') { depth = Math.max(0, depth - 1); if (seg.op && seg.op !== '&&') isChainCertain = false; continue }
191 const add = (path: string, via: ShellWrite['via'], effect: ShellWrite['effect'] = 'write') => { if (isPathWord(path)) out.push({ path, via, effect, dir }) }
192 const words: string[] = []
193 const t = seg.tokens
194 for (let i = 0; i < t.length; i++) {
195 const tok = t[i] as string
196 const m = /^(?:\d*|&)(>>?)(.*)$/.exec(tok)
197 if (m) {
198 const via = m[1] as '>' | '>>'
199 const rest = m[2] as string
200 if (rest === '') add(t[++i] as string, via) // `> file`
201 else if (!rest.startsWith('&')) add(rest, via) // `>file`; `>&2` duplicates a descriptor
202 continue
203 }
204 if (/^\d*<.*/.test(tok)) { if (tok.replace(/^\d*</, '') === '') i++; continue } // input redirection: skip its word
205 words.push(tok)
206 }
207 const [cmd, ...args] = words
208 if (cmd === 'cd') {
209 const target = args[0] === '--' ? args[1] : args[0]
210 const isResolvable = target !== undefined && args.length <= (args[0] === '--' ? 2 : 1) && target !== '-' && isPathWord(target)
211 dir = depth === 0 && isChainCertain && seg.op === '&&' && isResolvable && dir !== null
212 ? (target.startsWith('/') ? target : dir ? `${dir}/${target}` : target)
213 : null
214 } else if (cmd === 'cp' || cmd === 'mv') {
215 const { operands: o, values } = operands(args, { short: 'tS', long: ['target-directory', 'suffix', 'backup'] })
216 const into = values.t?.[0] ?? values['target-directory']?.[0]
217 if (into !== undefined) {
218 if (isPathWord(into)) for (const src of o) { if (cmd === 'mv') add(src, 'mv', 'remove'); if (isPathWord(src)) add(joinDir(into, src), cmd) }
219 } else if (o.length >= 2) {
220 if (cmd === 'mv') for (const src of o.slice(0, -1)) add(src, 'mv', 'remove')
221 add(o[o.length - 1] as string, cmd)
222 }
223 } else if (cmd === 'rm') {
224 for (const a of operands(args, { short: '', long: [] }).operands) add(a, 'rm', 'remove')
225 } else if (cmd === 'tee') {
226 for (const a of operands(args, { short: '', long: [] }).operands) add(a, 'tee')
227 } else if (cmd === 'sed') {
228 // -i takes its suffix only when attached (GNU) — except BSD's separate empty word (`-i ''`), which is dropped here
229 const hasInPlace = args.some((a) => a === '-i' || /^-i./.test(a) || a === '--in-place' || a.startsWith('--in-place='))
230 if (hasInPlace) {
231 const cleaned = args.filter((a, i) => !(a === '' && args[i - 1] === '-i'))
232 const { operands: o, values } = operands(cleaned.map((a) => (/^-i./.test(a) ? '-i' : a)), { short: 'efl', long: ['expression', 'file', 'line-length'] })
233 // with -e/-f the script is an option value, so every operand is a file; otherwise the first operand is the script
234 const files = values.e || values.f || values.expression || values.file ? o : o.slice(1)
235 for (const f of files) add(f, 'sed -i')
236 }
237 }
238 if (seg.op !== '&&') isChainCertain = false
239 }
240 return out
241}
242