SLOPSHOPPER

flightwake-mod

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…

newpanebandguardcommandtoast
★ 10v0.3.0MITupdated 2026-10-11kaiwutech-TW/flightwake/mods/flightwake
A shopper browsing a rack in a slop shop
README

<!-- Primary edition (English). Translations: README.zh-TW.md / README.zh-CN.md / README.ja.md — editing any edition obligates syncing the others. -->

flightwake ✈️

Records are the contrail your work naturally leaves behind — not a flight plan you must file before takeoff.

npm OpenSSF Scorecard

🌐 English · 繁體中文 · 简体中文 · 日本語

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 repo: /fw-coldstart reads STATE + the latest record and reports a safe takeover in ~30 seconds

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.

Install

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:

LanguageFresh installAlready installed in another language
Englishnpx flightwake init --statuslinenpx flightwake update --lang=en
繁體中文npx flightwake init --lang=zh-TW --statuslinenpx flightwake update --lang=zh-TW
简体中文npx flightwake init --lang=zh-CN --statuslinenpx flightwake update --lang=zh-CN
日本語npx flightwake init --lang=ja --statuslinenpx 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.

How to use

Right after the first install

  1. Open an agent session in the repo and run /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)
  2. git add .flightwake .claude CLAUDE.md && git commit
  3. Every session after that follows the daily loop below

The daily loop

You (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.

The only thing you need to watch

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.

See what it actually looks like

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.

Stage-by-stage playbook

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)

Why this project exists

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:

  1. Sessions die; context is finite. When work spans sessions, memory resets to zero; without records, every takeover is a git archaeology dig — a strong model just digs faster, it doesn't get to skip the dig.
  2. Git records the what, not the why. Commits tell you what changed, never "why the other path wasn't taken" or "the root cause of that trap" — which happen to be the two most expensive pieces of information for the next session (or the next agent).
  3. Discipline drifts in long sessions. "Reported done before the tests ran", "touched prod without leaving verification evidence" — these slides have nothing to do with model intelligence and need guards outside the model.
  4. Agents don't share state. Claude, Codex, Gemini, and human teammates each see their own world; state only becomes everyone's once it's in git.

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:

  1. Dashcam: decisions, discoveries, verification evidence, recorded after the fact (records/, DECISIONS.md, TRAPS.md)
  2. Warning lights: hard guards independent of model strength (tests green before "done", prod changes must leave verification evidence, destructive operations need confirmation first)
  3. Road signs: any session can die; the next session reads STATE.md and takes over safely within 2 minutes

The 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.

Core principle: records follow work — they don't lead it

GSD is stage-driven (research→plan→execute→verify gates); flightwake is trigger-driven (events create obligations):

Trigger eventObligationTool
Starting to touch a repoRead STATE + the latest record first/fw-coldstart
Making a decision that closes off other optionsOne line into DECISIONS (append-only, with the why)write directly
Hitting a non-obvious trapOne entry into TRAPS/fw-trap
Touching schema / touching prod / ~3+ commitsWrap up with a record/fw-record
Work will span sessionsWrite handoff/CONTEXT before stopping (not before starting)/fw-handoff
Session about to closeUpdate STATE's position and next-step entry pointpart 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).

File layout (after installing into a target repo)

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).

Advanced install

--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.

Migrating from GSD

Wrap up your current milestone first, then:

  1. npx flightwake init — coexists with .planning/; nothing is deleted
  2. Tell your agent: "This repo is switching from GSD to flightwake. Read .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."
  3. Remove (or comment out) GSD's own instruction block from CLAUDE.md, so the two rulebooks don't compete for the model's obedience

Status line (optional)

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

Source 21 files
hooks/register.ts 51 lines
1/**
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}
51
hooks/features/state-inject.ts 162 lines
1/**
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}
162
hooks/features/band.ts 225 lines
1/**
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}
225
hooks/features/recorder.ts 521 lines
1/**
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}
521
hooks/features/tripwire.ts 239 lines
1/**
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}
239
hooks/features/role-guard.ts 289 lines
1/**
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}
289
hooks/features/status.ts 239 lines
1/**
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}
239
hooks/features/spend.ts 174 lines
1/**
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}
174
hooks/features/fleet.ts 940 lines
1/**
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}
940
hooks/features/panel.ts 514 lines
1/**
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}
514
hooks/lib/core.ts 321 lines
1/**
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}
321
hooks/lib/shell.ts 242 lines
1/**
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