Skip to main content
Glama
README.md
# πŸ”­ nina_planner β€” An Observatory Operator Agent

![Banner for repository](banner.png)

`nina_planner` is an MCP tool server for N.I.N.A. (Nighttime Imaging 'N' Astronomy).  The separate events `nina-notify` plugin is OpenCode-specific.  The focus is on building, executing, and monitoring multi-target sequence runs from observation plans.

## πŸš€ Quick Start

1. **πŸ”­ Prepare N.I.N.A.** β€” install the **Advanced API**, **Sequencer+**, and **Session Metadata** plugins, and put `$$TARGETNAME$$` in the profile's `FilePattern` (see [Notes](#-notes)) β€” without it, light frames can't be attributed to a plan.
2. **πŸ“¦ Install requirements** β€” [opencode](https://opencode.ai) v2 (the `nina-notify` plugin is not compatible with Claude Code or other coding agents), Python 3.11+, and [uv](https://docs.astral.sh/uv/). Dependencies are installed by `uv` on first run.
3. **🧩 Pick a config** β€” replace `.opencode/opencode.json` with the opencode.json variant that matches where opencode runs: `win_opencode.json` (Windows), `wsl_opencode.json` (WSL), or `smb_opencode.json` (e.g. a Mac reaching a N.I.N.A. VM).
4. **πŸ”Œ Point at N.I.N.A.** β€” set `NINA_ENDPOINT` (default `127.0.0.1:1888`) in the config's `environment`. If the server isn't on the N.I.N.A. machine, also set `NINA_DRIVE_MOUNT` so image metadata can be read, as `smb_opencode.json` does (see [Environment Variables](#-environment-variables)).
5. **▢️ Start opencode** in the project root; it launches the MCP server for you.
6. **🌟 Plan a night** β€” list targets in `PLAN.md` and let the agent write plans, load a sequence, and start it.

## 🧰 Available Tools

Each table gives a one-line summary; details for the larger tools are in [Tool Details](#-tool-details).

### πŸ“‘ Equipment & Telemetry

| Tool | Purpose |
|---|---|
| `get_site_equipment_status()` | Live connection, state, measurements, and capabilities of all equipment, plus weather and safety-monitor status. ⚠️ **Not read-only** β€” it auto-connects devices (see [details](#get_site_equipment_status)). |
| `get_site_profile()` | Active NINA profile and static configuration: site, optics, camera geometry, filters, plate solvers, image-save path. Read-only. |
| `list_site_profiles()` | Summaries (id, name, description, last-used time) of every NINA profile, with the active one marked `active: true`. Read-only. |
| `switch_site_profile(profile_id)` | Switch the active NINA profile by GUID from `list_site_profiles`. Reconnects equipment β€” 🚫 never call during a running sequence. |
| `get_events(since=300)` | Timestamped NINA events from the last `since` seconds, to reconstruct sequence, equipment, safety, imaging, and error activity. Read-only. |
| `get_logs(since=300)` | NINA application log entries from the last `since` seconds, for diagnosing failures. Read-only. |
| `screenshot_dashboard()` | Capture the N.I.N.A. dashboard as a PNG; returns the file path and the image. Only side effect: switches NINA to the Imaging tab. |

### πŸ—ΊοΈ Planning & Progress

| Tool | Purpose |
|---|---|
| `list_plans(project_dir?)` | List every observation plan file in the project directory with its `file`, absolute `path`, `plan_id`, `target`, `intent`, `pointing_count`, and mtime β€” enumerate plans without globbing. Read-only. |
| `write_plan_file(plan)` | Validate and write an observation plan JSON file for later use by `load_sequence`. Creates the file only; loads and starts nothing. |
| `write_mosaic_plan(plan_path, mosaic_csv, output_path?)` | Expand a base plan into a multi-pointing mosaic plan from a Telescopius CSV. Creates the file only. |
| `get_plan_progress(file_path, max_hfr?, min_detected_stars?, max_guiding_rms_arcsec?)` | Per-frame-type acquired/remaining counts for every pointing of a plan. Use before `load_sequence` to see what still needs acquiring. Read-only. |
| `audit_plans(plans?, project_dir?, types?, include_orphans=true)` | **Whole-project capture audit in one call**: reconcile plan expectations with CSV attribution (plus the one filesystem fact the CSV cannot hold β€” `bad` rejects) for every plan into an `all_saved` verdict plus `left_to_do` (including never-shot plans) and `blocked` work lists, and a `never_shot` list of plans with no frames at all. Read-only. |
| `get_imaging_metadata(file_path, pointing_index=1, image_type="light")` | Per-exposure quality, guiding, pointing, and weather metrics for one pointing of a plan. Read-only. |
| `get_frame_stats(image_type?)` | Live per-frame statistics for the current (most recent) frame, straight from the `image-history` API β€” the watchdog's own data source (HFR, HFR Οƒ, stars, ADU, guiding RMS, temp, filter, filename). Single-frame; for a trend use `get_imaging_metadata`. Read-only. |

### 🌌 Sky Calculations

Powered by [astropy](https://www.astropy.org); no NINA connection is needed for `resolve_target`.

| Tool | Purpose |
|---|---|
| `resolve_target(name)` | Resolve a deep-sky object name (e.g. `M31`, `NGC 6960`) to J2000 `ra_hours` / `dec_deg` through a SIMBAD lookup, so plans use verified coordinates. 🌐 Requires internet access. Read-only. |
| `target_altitudes(targets, hours_ahead=0.0)` | Altitude/azimuth of each target at the active site, sorted highest first, for ranking candidates. Each target is `{"name": ...}` plus optional `ra_hours` / `dec_deg` (otherwise resolved via SIMBAD). Read-only. |
| `sun_times()` | Next sunrise/sunset plus all four twilight boundaries (civil βˆ’6Β°, nautical βˆ’12Β°, astronomical βˆ’18Β°) and the astronomical-darkness window in effect or next to open, with `night_phase` (`evening`/`dark`/`morning`) and `is_dark_now` for end-of-night decisions. Read-only; reads only the site profile. |
| `best_targets(min_altitude=30.0, top=10, level, kinds)` | Ranks Messier and bright NGC objects to observe from the active site right now, returning `visible_now` (dark and above the floor now) plus `later_tonight` (peak altitude within astronomical darkness), with `is_dark_now` and surface-brightness-based difficulty filtering. Catalog coordinates are for ranking only β€” use `resolve_target` for pointing. Read-only. |

### 🎬 Sequence Management

| Tool | Purpose |
|---|---|
| `load_sequence(request=[...])` | Load a sequence for the whole night with safety guardrails. πŸ’€ **Never starts it.** An empty list is the stow. See [details](#load_sequence). |
| `start_sequence()` | Start the currently loaded sequence immediately. Use after `load_sequence()`. ▢️ |
| `stop_sequence()` | Stop the running sequence and wait until NINA reports it stopped. ⚠️ Does **not** stow the scope. |
| `get_sequence_state(detail)` | Per-step view of the loaded sequence: plan attribution, status, exposure progress, horizon altitude, errors. `detail`: `summary` (default) or `raw` (`/sequence/state`). Read-only. |

### βš™οΈ NINA Lifecycle

| Tool | Purpose |
|---|---|
| `get_nina_status()` | Lightweight liveness probe (no device probes); never raises on a down NINA. |
| `start_nina()` | πŸš€ Launch NINA into the interactive desktop session. Raises if NINA is already running or the host has no Windows interop. |
| `stop_nina()` | πŸ›‘ Terminate NINA.exe immediately via `taskkill`. A no-op when NINA is already down. |

## πŸ“– Tool Details

### `get_site_equipment_status`

Returns the current connection, operating state, measurements, and capabilities of active observatory equipment (mount, camera, focuser, guider, filter wheel, rotator, dome, switches), including weather and safety-monitor status (whether the enclosure is open and it is safe to unpark) and mount capabilities (`can_park`, `can_find_home`) that determine how the scope can be stowed. Use for live operational and safety checks.

> ⚠️ **Side effect:** unlike the other telemetry tools, this call is **not read-only**. It connects any device that is present but disconnected. If any device is mid-connect the call errors; retry after a few seconds.

### `get_site_profile`

`image_save_path` is the raw Windows path from the profile; `local_image_save_path` is the derived path usable on this host (the drive letter is mapped through `NINA_DRIVE_MOUNT`, default `/mnt/<drive>`, on Linux/WSL/macOS; unchanged on Windows) and is the one to use for local file access. Use `get_site_equipment_status` for live equipment state.

### `list_site_profiles` / `switch_site_profile`

`list_site_profiles` only exposes summaries; only the active profile exposes full site/optics/filter detail, so use `get_site_profile` to inspect it. `switch_site_profile` passes the id straight through to NINA; an unknown id or refusal surfaces NINA's own error. Switching disconnects and reconnects equipment and changes site, filters, image-save path, and file pattern. Confirm with `get_site_profile` afterward.

### `get_events` / `get_logs`

Use `get_events` to reconstruct activity; use `get_site_equipment_status` and `get_sequence_state` for current status. Use `get_logs` to diagnose sequence failures, equipment communication problems, and unexpected behavior.

### `screenshot_dashboard`

Returns **two content blocks: the absolute file path as text, then the image itself** (e.g. `/var/folders/.../T/nina_screenshots/dashboard_20261005T120000123456.png`). The image is written to the system temp dir under `nina_screenshots/`, one uniquely named file per call, nothing overwritten. Clients that render MCP image blocks show the capture inline; otherwise open the returned path with the read tool. The tool is unstructured (no `outputSchema`) because it carries mixed text/image content. Switches NINA to the Imaging tab first so the capture shows the main imaging workspace; that tab switch is the only side effect.

### `get_imaging_metadata`

Returns per-exposure imaging metadata for one pointing of an observation plan, parsed from `ImageMetaData.csv` and `WeatherData.csv` in the N.I.N.A. imaging directory. `image_type` is `light` (default), `dark`, `bias`, or `flat` (case-insensitive).

- **Attribution:** lights are attributed via the underscore-delimited plan-id token in the recorded file path (`_{base_plan_id}_` for a single-pointing plan, `_{base_plan_id}-{pointing_index}_` per pane for a mosaic; no target-name fallback). Dark/bias frames are matched plan-wide by image type and exposure; flat frames by image type and filter (exposure is not checked for flats). All calibration frames count only when shot within `calibration_window_days` of the session start (the earliest attributed light), on either side of it.
- **`pointing_index`:** range-checked (1..pointing count) for every image type and labels the response. For lights it selects the pointing; for calibration types it only selects which pointing's light session anchors the window.
- **Scope:** reports the frames that are attributable, not what the plan still needs β€” use `get_plan_progress` for that.
- **Output:** a wide table with one row per exposure: identity fields (`file_path`, `exposure_number`, `exposure_start`, `duration`, `filter_name`) plus metrics as columns namespaced by group (`quality.hfr`, `guiding.rms`, `background.adu_mean`, `pointing.airmass`, `weather.temperature`, ...). Weather samples are joined by exposure number and UTC exposure start. The result also carries `plan_id`, `pointing_index`, and `target`.
- **Timestamps:** come from the CSV's `ExposureStartUTC` column and are **displayed in the host's local time with its UTC offset** (e.g. `2026-09-21T21:16:25.123-05:00`); calibration-window arithmetic runs on UTC instants.
- **Summary:** columns constant across the returned frames are listed once under `summary.constants`; columns with no real values (NaN/-1, empty, `n/a`, or the `0` sentinel N.I.N.A. writes for unmeasured metrics) are listed under `summary.unpopulated`.

### `get_frame_stats`

Returns N.I.N.A.'s live per-frame statistics for the **current (most recently captured) frame**, read from the `GET /v2/api/image-history` API β€” the same source the `nina_notify` quality watchdog polls, exposed on demand so a `QUALITY-WATCHDOG` wake can be verified against the exact numbers that fired it.

- **Full record:** `hfr`, `hfr_st_dev`, `stars`, background ADU (`mean`, `median`, `st_dev`, `min`, `max`), guiding RMS text (`rms_text`), `temperature`, `gain`, `offset`, `filter`, `exposure_time`, `target_name`, `filename`, and `date` (the capture timestamp). A genuine `0` (e.g. `stars: 0`, a star-count failure) is preserved as `0` β€” unlike the CSV path, `0` here is a real reading, not a "not measured" sentinel.
- **Single frame only:** despite its name, `image-history` serves only the current frame β€” one record, no paging, no count parameter. For a multi-frame trend use `get_imaging_metadata`, which reads the on-disk `ImageMetaData.csv` for the whole session. This tool is the live view; that one is the historical view.
- **`image_type`:** optionally passed through as the API's `imageType` filter to assert the current frame's type (e.g. `"light"`, `"dark"`). When the current frame isn't of that type the tool reports `count: 0` with a note rather than erroring.
- **Output:** `{count, image_type, frame, note}` β€” `count` is `1` when a frame is present (else `0`, with `frame: null` and `note` explaining why). This tool is read-only.

### `write_plan_file`

Validates and writes an observation plan to a JSON file. The plan defines one or more target pointings (RA/Dec/PA, optional label), acquisition intent, light and calibration frames, batching, cooling, autofocus, guiding, and observing constraints. See [The Observation Plan](#-the-observation-plan).

### `write_mosaic_plan`

Expands a base observation plan into a multi-pointing mosaic plan from a Telescopius-formatted mosaic CSV (see [Mosaic CSV format](#-mosaic-csv-format)). The base plan supplies every setting except the pointings, which are replaced wholesale by one pointing per CSV row. Writes `<target>_<intent>_mosaic_<timestamp>.json` in the project directory unless `output_path` is given.

### `get_plan_progress`

Returns per-frame-type acquisition progress for every pointing of an observation-plan JSON file: for each exposure group, `total_count` from the plan, `acquired_count` attributed to that plan/pointing from the imaging metadata (`ImageMetaData.csv`), `remaining_count`, and `deficit_count` (the raw `total βˆ’ acquired`, floored at zero, **never** adjusted for the window).

- **Attribution:** lights are attributed via `_{base_plan_id}_` (single-pointing) or `_{base_plan_id}-{pointing_index}_` (mosaic pane). Flats are matched by image type and filter, darks/bias by image type and exposure, and all count only when shot within `calibration_window_days` of the earliest attributed light, on either side of it (inclusive; a 1-day window spans `anchor βˆ’ 1d … anchor + 1d`).
- **Calibration window:** calibration groups also carry `window_open` (bool) and `window_end` (`earliest light + calibration_window_days`, **displayed in the host's local time with its UTC offset**). While the current time is inside the window `remaining_count` is `total βˆ’ acquired`; once outside, no frame shot now could be credited, so `remaining_count` is `0` (with `window_open: false`). `acquired_count` stays truthful, and lights carry neither field. Raise `calibration_window_days` or write a new plan to reopen the window.
- **`remaining_count == 0` is not "complete":** the window rule above zeroes a calibration group's remaining even when frames are missing, so completion is decided from `acquired_count` vs `total_count` and window state instead β€” a closed window under a deficit is reported as **`expired_with_deficit`**, not complete. Per pane, `status` is `complete` / `incomplete` / `expired_with_deficit`; the top level carries `expired_with_deficit` (bool), and `complete` is true only when every pane is genuinely done. The expired case is separated from `incomplete` because its fix differs: no sequence run can close an expired calibration deficit β€” raise `calibration_window_days` or write a new plan β€” and `warning` says so.
- **Quality thresholds:** pass `max_hfr`, `min_detected_stars`, and/or `max_guiding_rms_arcsec` to exclude light frames that fail them (frames missing the relevant field are excluded when a threshold is set).
- **Scope:** one plan per call β€” for a whole-project sweep (every plan, disk files included, in one call), use [`audit_plans`](#audit_plans).
- **Output:** `complete` (all panes genuinely done β€” decided from acquired counts and window state, never from `remaining_count == 0` alone) plus a `pointings` list with per-pane progress, per-pane `complete`, and per-pane `status`, so panes can be acquired sequentially.
- **Overshoot β€” read this before trusting `complete`:** light items also carry `surplus_count` (frames attributed beyond `total_count`), and the top level carries `overshot` (bool) and `warning` (string or null). `acquired_count` is **cumulative across every run** of the plan while `total_count` is a per-plan constant, so a `mode="full"` re-shoot asks N.I.N.A. for `total_count` *more* frames and the older ones keep the plan complete no matter what that run delivered β€” a re-shoot that lost 103 of its 120 frames still reported `acquired_count: 137, remaining_count: 0, complete: true`. When `overshot` is true, treat `complete` as "these frames exist" and **not** as "the last run succeeded": confirm what the latest run actually landed with [`audit_plans`](#audit_plans) plus the per-date frame folders on the imaging drive. `surplus_count` cannot tell a re-shoot apart from the same target shot on separate nights under one plan id, so it is a prompt to check, not a verdict. Lights only β€” calibration is pool-credited across plans and routinely overshoots.

### `list_plans`

`list_plans(project_dir?)`

Returns `{"plans": [...], "errors": [...]}` β€” one entry per plan JSON in the project directory (default: the server's working directory) with `file`, absolute `path`, `plan_id`, `target`, `intent`, `pointing_count`, and `written` (mtime, ISO-8601 local with offset). Unparseable JSON files are reported per file under `errors` instead of failing the call, and a relative `project_dir` resolves against the project directory. Use it to enumerate plans without globbing; `audit_plans` shares the same discovery.

### `audit_plans`

`audit_plans(plans?, project_dir?, types?, include_orphans=true)`

Answers **"are all my targets' captures saved to disk, and what is left to do?"** for every plan in one call, replacing per-plan `get_plan_progress` sweeps (15 plans = 15 calls otherwise). `plans` selects a subset (paths or names, resolved against `project_dir`, default = every `*.json` in the project directory); `types` subsets the output rows to any of `light` / `flat` / `dark` / `bias` (case-insensitive).

- **Two truth sources, reconciled per plan Γ— frame type:** `expected` (the plan's `total_count`, summed over its exposure groups β€” per pointing for lights, plan-level for calibration) and `attributed` / `remaining` (exactly what `get_plan_progress` reports, quirks included). The filesystem is consulted for exactly one fact `ImageMetaData.csv` cannot carry: frames N.I.N.A. rejected via its `BAD_` filename prefix β€” `2026-10-22_04-46-37_L_...fits.fz` β†’ `BAD_2026-10-22_04-46-37_L_...fits.fz` β€” whose CSV row still names the pre-rename file, so the reject never reaches `attributed`; the light row reports them as `bad`. Calibration rows carry `own_rows`: how many of that type's recorded frames sit under *this plan's own* token (`_{base_plan_id}_`, or `_{base_plan_id}-{n}_` per mosaic pane) rather than the shared pool.
- **Top-level output:** `all_saved` (true only when no row needs work, no plan file failed to load, and no plan is unshot), `totals` (expected / attributed across everything, plus a `never_shot` count), `left_to_do`, `blocked`, `never_shot`, `rows`, `orphans`, `errors`, and `root` (the imaging directory actually scanned).
- **`left_to_do`** holds only what *running a sequence* can fix β€” `MISSING_FILES` and row-level `NEVER_SHOT` rows, with `missing` = `remaining` and an `action` hint per type, plus one `NEVER_SHOT` entry per unshot pointing of a wholly unshot plan with `missing` = that pointing's whole light ask. **`blocked`** is `EXPIRED`: the calibration window closed under a deficit, so only raising `calibration_window_days` or writing a new plan helps.
- **`never_shot` β€” the left join's blind spot, closed:** discovery is driven by `ImageMetaData.csv`, so a plan that was written but never imaged has no row to join on and used to vanish from the audit entirely. The cost was real: an operator reading `left_to_do` alone saw two 1-frame deficits while **six whole unshot plans (240 frames)** also waited β€” found only by diffing `list_plans` by hand. Only **light** attributions count as a reference: calibration is pool-credited and lands under the shared base token, so a plan whose id appears solely on flat/dark/bias rows has never had a light acquired and is reported here, not joined into the audit by its own darks. Every loadable plan whose id no recorded light frame references is now reported here (`plan_id`, `file`, `target`, `intent`, `pointing_count`, `light_expected`, `written`) **and** in `left_to_do` with `reason: "NEVER_SHOT"`. Matched on **plan id**, not file: a rewritten/archived twin of an already-shot plan is never flagged (its frames exist under the shared id), and two unshot copies of one plan report once, from the shallowest file. Unshot plans still get no `rows` β€” expected vs attributed means nothing for frames never attempted β€” and they make `all_saved` false, so **delete or move out any plan file you never intend to shoot** (unrelated JSON is already ignored: it never loads as a plan). Explicit `plans=` never populates it: naming a file *is* the request to audit it, so an unshot plan named there audits as an ordinary row with status `NEVER_SHOT`.
- **`status` per row** (first match wins):
  - `EXPIRED` β€” calibration window closed with an uncredited deficit; surfaces it despite `remaining` being forced to `0` by the window rule ⏳
  - `NEVER_SHOT` β€” a light row with **zero** attributed frames: no run ever delivered a frame for this pointing, so the whole ask is untried work. Identified from the attribution count itself, never from `remaining` (which is just `expected βˆ’ attributed` and cannot tell untried work from a partial deficit) πŸŒ‘
  - `MISSING_FILES` β€” `remaining > 0` and the frames genuinely aren't there: shoot more πŸ”«
  - `SUSPECT_JUNK` β€” degenerate light/dark rows (duration under half the plan's exposure or `0.01s`, e.g. `0.00003s` Smart-Exposure junk) riding alongside an otherwise-complete set. Bias and flats are exempt (0s is correct for bias; SkyFlat adapts). When junk instead *leaves a deficit*, the status is `MISSING_FILES` with the junk count as the cause πŸ—‘οΈ
  - `POOL_CREDITED` β€” calibration satisfied plan-wide from another target's directory while this plan's own `own_rows` fall short β€” informational, not a gap 🏷️
  - `OVERSHOT` β€” lights attributed above expected: the target has been acquired more than once, either by a `mode="full"` re-run or on separate nights under one plan id. Harmless in itself ℹ️, but it is the case where `complete` cannot describe the *latest* run β€” a re-shoot that lost frames still reads complete because the older ones satisfy the plan β€” so confirm what that run landed via the per-date folder counts 🧐
  - `OK` βœ…
- ⚠️ **Why the columns can disagree:** calibration is matched **plan-wide** (flats by type+filter, darks by type+exposure, bias by type, all within the window around the earliest attributed light), so one bias set credits every plan and `attributed` can legitimately exceed that plan's own `own_rows` β€” which is precisely what `POOL_CREDITED` reports. Lights also require the exposure duration to match (Β±0.05s), so degenerate frames never count. ℹ️ A `bad`-rejected frame never reaches `attributed`, so it shows up as a `remaining` deficit explained by the light row's `bad` count. There is no disk-vs-CSV cross-check any more: a frame the Session Metadata plugin never recorded appears in *neither* source, so it surfaces as an ordinary `MISSING_FILES` deficit. That is rare and safe to re-shoot, which is all this audit ever advised.
- 🚨 **Failure semantics:** a missing or unmounted imaging root **raises** (naming the site profile path and `NINA_DRIVE_MOUNT`) rather than reporting empty counts β€” "no frames" and "no mount" must never look alike. A broken plan file lands in `errors` and the rest of the sweep still runs; a project directory with no plans returns a `warning` with `root: null`. `orphans` lists landing token directories whose plan file no longer exists (from deleted plans) β€” the dual of `never_shot`, which is plan files whose frames do not exist. Every `.fits`/`.fits.fz` under an orphan is counted regardless of what its parent folder is called, so an unexpected folder name can never make real frames read as absent.

### `load_sequence`

`load_sequence(request=[{action, plan, variant?, pointing_index?, mode="remaining", max_hfr?, min_detected_stars?, max_guiding_rms_arcsec?}, ...])`

Loads a sequence with safety guardrails β€” πŸ’€ it **never starts it**; call `start_sequence()` to begin. ⚠️ Fails if a sequence is already running β€” call `stop_sequence()` first.

`request` is a **list** of request objects composing one N.I.N.A. sequence for the whole night: a single Start area, one Target area holding each step's containers in list order, and one End area that always stows β€” one bounded 3-minute "On Safe" wait, then park (or home) the mount and warm the camera. πŸ…ΏοΈ **An empty list (the default) is therefore the stow**; there is no `stow`/`park`/`warm`/`unpark` action, and `load_sequence()` with no arguments is the teardown.

| Field | Description |
|---|---|
| `action` | `light`, `dark`, `bias`, or `flat`; decides what is built from `plan`. |
| `plan` | The observation-plan JSON path. Required by every action; omitting it raises an error naming the entry. |
| `pointing_index` | 1-based, default 1. Selects the pointing; applies to lights only. Calibration is plan-level, so a calibration request must omit it (passing one is rejected). |
| `variant` | Scoped to the action; a bad pairing fails naming the entry. `flat` requires `dawn` (waits for morning twilight) or `dusk` (waits for sunset). `dark`/`bias` take `wheel` (default β€” park the wheel on the profile's `Dark` slot), `cover` (close the motorized cover, e.g. a flip-flat, around the exposures, then reopen it), or `shutter` (expose without moving the filter wheel, relying on the camera's shutter) β€” each capability-checked at load: `wheel` needs a filter wheel with a `Dark` slot, `cover` a flat device reporting open/close support, `shutter` a camera reporting HasShutter, and a variant the rig cannot perform fails naming the reason plus the variants it supports. `light` takes none. |
| `mode` | `remaining` (default) acquires only frames still needed (total minus frames already attributed); if nothing remains the plan is reported complete and nothing is loaded. `full` acquires the whole plan again β€” and warns when that light group is already complete (see Behavior). |
| `max_hfr`, `min_detected_stars`, `max_guiding_rms_arcsec` | Exclude failing light frames from the acquired count. |

Behavior:

- Lights embed `_{base_plan_id}_` (single-pointing plan, shared with its calibration) or `_{base_plan_id}-{pointing_index}_` (per mosaic pane) in the target name for attribution, which requires `$$TARGETNAME$$` in the profile's `FilePattern`. The token is underscore-delimited on purpose: N.I.N.A. rewrites `[` and `]` to `_` when it substitutes `$$TARGETNAME$$` into the path, so the id is written exactly as it will be recorded (the older bracketed and parenthesised spellings still attribute as legacy).
- Steps run in order as the sequence reaches them; a step with its own waits (lights, flats) waits, or is skipped once its window has passed, without blocking the steps behind it.
- Entries already complete are skipped and reported; if every entry is complete nothing is sent to N.I.N.A. A calibration group whose window expired under a deficit reports the expired shortfall rather than "complete" (and still loads nothing β€” no frame shot now could be credited).
- A `full`-mode load of a **light** group that is already complete appends a ⚠️ warning naming the frame count, because nothing downstream can verify the outcome afterwards β€” [`get_plan_progress`](#get_plan_progress) reports `complete: true` whether the re-shoot delivered every frame or a handful, since its acquired counts are cumulative across runs. Calibration is deliberately exempt: it is pool-credited across plans, so a `dark`/`bias`/`flat` group reads "already complete" on nearly every plan and warning there would fire on every routine calibration load. A metadata read failure degrades to *no warning* rather than a failed load β€” `full` is the mode chosen precisely when remaining counts are untrusted.
- Profile, equipment, and metadata are fetched once, one POST carries the whole sequence, and a validation error names the entry by 0-based position (`request[1]: ...`).

### `stop_sequence`

For an urgent halt or a stuck sequence. ⚠️ The scope is left where it is. To stow afterward, `load_sequence()` (empty list) and start it. Avoid interrupting an in-progress stow unless safety requires it, since stopping mid-slew can leave the scope in an unsafe position.

### `get_nina_status`

Cheaper than `get_site_equipment_status`. Returns `running` (true iff `GET /time` answered β€” a missing local `NINA.exe` does not mean NINA is down when it runs remotely), `lifecycle_control_available` (this host has Windows interop, so `start_nina`/`stop_nina` can work), `probe_error` (N.I.N.A.'s failure text, or `None` when running), and a one-line `summary`.

### `start_nina` / `stop_nina`

`stop_nina` determines liveness from the REST API: it is a no-op when NINA is already down and raises when NINA is up but remote (no local Windows interop), since it cannot be terminated from here. `start_nina` raises if NINA is already running (call `stop_nina()` first) or if this host has no Windows interop to launch a remote NINA.

## πŸ“ The Observation Plan

A plan is a JSON document that describes one complete imaging session. It encodes the **target**, **exposure settings** for three of the four frame types (light, dark, bias β€” flats only list a filter and a count, since N.I.N.A. picks their exposure automatically), and **equipment configuration** (cooler, autofocus, guiding, constraints).

### 🧱 Plan structure

An example json plan:

[plans/m15_globular-cluster_20261003T112008.json](./plans/m15_globular-cluster_20261003T112008.json)
```json plans/m15_globular-cluster_20261003T112008.json
{
  "target": "M15",
  "intent": "Globular cluster",
  "plan_id": "plan-a745ffa2357a",
  "description": "Seestar S30 starter plan for M15. Use the available broadband L filter and short exposures to help preserve the bright cluster core; 90 ten-second light frames for 15 minutes total.",
  "pointings": [
    {
      "label": "M15 center",
      "ra_hours": 21.49954,
      "dec_deg": 12.167,
      "position_angle_deg": null,
      "row": null,
      "column": null
    }
  ],
  "batch_size": 10,
  "calibration_window_days": 7,
  "cooler": {
    "on": false,
    "setpoint_celsius": -10.0
  },
  "constraints": {
    "min_altitude": 30.0,
    "horizon_offset_degrees": 2.0
  },
  "autofocus": {
    "reference_filter_name": "L",
    "hfr_increase_sample_size": 3,
    "hfr_increase_threshold_percent": 15.0,
    "every_n_exposures": 10,
    "threshold_celsius": 1.0
  },
  "guiding": {
    "dither_every_n_exposures": 2,
    "check_drift_every_n_exposures": 6,
    "max_drift_arcmin": 1.5
  },
  "light_frames": [
    {
      "filter_name": "L",
      "exposure_time_seconds": 10.0,
      "total_count": 90
    }
  ],
  "flat_frames": [
    {
      "filter_name": "L",
      "total_count": 20
    }
  ],
  "dark_frames": [
    {
      "exposure_time_seconds": 10.0,
      "total_count": 20
    }
  ],
  "bias_frames": [
    {
      "total_count": 20
    }
  ]
}
``` 

### 🧾 Fields

| Field | Required | Description |
|---|---|---|
| `target` | no | Catalog designation, e.g. "M31", "NGC 7000" |
| `intent` | no | 2-3 words describing the goal |
| `plan_id` | no | Stable identifier stamped on write. Used for frame attribution; when absent, a deterministic hash of plan content is used. Must be empty or ASCII letters, digits, `.` and `-` only: `_` is the attribution-token delimiter, and N.I.N.A. rewrites other characters when it puts `$$TARGETNAME$$` into the file path, so an id outside that charset is refused at write and at load rather than silently never matching its frames. |
| `description` | no | Explanation of the observation plan, including rationale, exposure goals, equipment, or sky constraints. |
| `pointings` | **yes** | One or more pointing objects. Each requires `ra_hours` (J2000 RA in hours, `[0, 24)`) and `dec_deg` (J2000 declination in degrees, `[-90, +90]`). Optional `label` is shown in the N.I.N.A. target name; `position_angle_deg` is rotator PA in degrees east of north, `[0, 360)`. If omitted, `0` is sent to N.I.N.A., leaving the rotator at its current/synced position. Optional `row` and `column` are nonnegative mosaic metadata not sent to N.I.N.A. |
| `batch_size` | no | Exposures per batch; defaults to `5`. Set to `0` to disable batching. |
| `calibration_window_days` | no | Defaults to `7` (minimum `1`). Calibration frames count only when taken within this many days before or after the earliest attributed light. After the window closes, remaining calibration is reported as `0`. |
| `cooler` | no | Cooler settings: `on` defaults to `true`; `setpoint_celsius` defaults to `-10` and must be in `[-40, 30]`. |
| `constraints` | no | `min_altitude` defaults to `30` degrees and must be in `[0, 90]`; `horizon_offset_degrees` defaults to `2` and must be in `[0, 10]`. |
| `autofocus` | no | `reference_filter_name` defaults to `"L"`; `hfr_increase_sample_size` to `3`; `hfr_increase_threshold_percent` to `15`; `every_n_exposures` to `10`; and `threshold_celsius` to `1`. Numeric values must be greater than zero. |
| `guiding` | no | `dither_every_n_exposures` defaults to `2`; `check_drift_every_n_exposures` to `6`; and `max_drift_arcmin` to `1.5`. Numeric values must be greater than zero. |
| `light_frames` | **yes** | Nonempty list of light exposure specs, each with `filter_name`, `exposure_time_seconds` (greater than zero), and `total_count` (at least `1`). |
| `flat_frames` | **yes** | Nonempty list of flat exposure specs, each with `filter_name` and `total_count` (at least `1`). No exposure time: N.I.N.A.'s SkyFlat item chooses it automatically to hit the right signal level. |
| `dark_frames` | **yes** | Nonempty list of dark exposure specs, each with `exposure_time_seconds` (greater than zero) and `total_count` (at least `1`). |
| `bias_frames` | **yes** | Nonempty list of bias exposure specs, each with `total_count` (at least `1`). |

### πŸ“Š Mosaic CSV format

For `write_mosaic_plan`, use a Telescopius-formatted CSV with these nine columns in this order:

[plans/mosaic.csv](./plans/mosaic.csv)
```csv plans/mosaic.csv
Pane, RA, DEC, Position Angle (East), Pane width (arcmins), Pane height (arcmins), Overlap, Row, Column
Pane 1, 0hr 56' 01", 45ΒΊ 51' 18", 0.00, 309.00, 205.80, 10%, 1, 1
Pane 2, 0hr 29' 28", 45ΒΊ 51' 18", 0.00, 309.00, 205.80, 10%, 1, 2
Pane 3, 0hr 55' 22", 42ΒΊ 46' 13", 0.00, 309.00, 205.80, 10%, 2, 1
Pane 4, 0hr 30' 07", 42ΒΊ 46' 13", 0.00, 309.00, 205.80, 10%, 2, 2
Pane 5, 0hr 54' 47", 39ΒΊ 41' 07", 0.00, 309.00, 205.80, 10%, 3, 1
Pane 6, 0hr 30' 42", 39ΒΊ 41' 07", 0.00, 309.00, 205.80, 10%, 3, 2
Pane 7, 0hr 54' 15", 36ΒΊ 36' 00", 0.00, 309.00, 205.80, 10%, 4, 1
Pane 8, 0hr 31' 14", 36ΒΊ 36' 00", 0.00, 309.00, 205.80, 10%, 4, 2
```

Each row becomes a `Pointing`: RA/DEC are parsed server-side (sexagesimal like `0hr 56' 01"` / `45ΒΊ 51' 18"` or decimal), `Pane` becomes the `label`, and `Row`/`Column` are stored as `row`/`column` metadata (N.I.N.A. does not use them β€” they are kept for ordering, labeling, and later mosaic assembly). The base plan's `plan_id` is cleared so the mosaic gets its own content-derived id; each pane is attributed independently via `_{plan_id}-{pointing_index}_`.

- 🚫 **No rotator:** every pane's `position_angle_deg` must be `0`. Any nonzero PA fails loudly, since PA can't be enforced without a rotator.
- **Acquiring panes:** query `get_plan_progress(file_path)` to see which panes still need frames, then load the first incomplete pane with `load_sequence(request=[{"plan": file_path, "action": "light", "pointing_index": N, "mode": "remaining"}])` and `start_sequence()`. Repeat pane by pane across the night; when the top-level `complete` is true, the mosaic is done β€” and if `expired_with_deficit` is true instead, the lights may well be fine and only the calibration window needs raising or a new plan (see [`get_plan_progress`](#get_plan_progress)).

## πŸ“Œ Notes

- πŸ”Œ **Required N.I.N.A. plugins:** the MCP server requires the following three plugins to be installed in N.I.N.A.: **Advanced API**, **Sequencer+**, and **Session Metadata**.
- πŸ“ **Image file pattern:** for **light frames only**, the N.I.N.A. profile's `ImageFileSettings.FilePattern` must put `$$TARGETNAME$$` in a path segment so frames can be attributed to a plan (e.g. `$$TARGETNAME$$\$$IMAGETYPE$$\...`). Attribution accepts the token in **any** segment β€” directory or filename β€” but put it in a **directory**, and let that directory be a direct child of the image-save path (the usual `<target> _<plan-id>_` landing folder): `audit_plans` claims landing directories by the names of the top-level directories under the image-save path, so a filename-only token (or a token nested one level deeper) attributes fine on the CSV side yet still lands the folder in `orphans`, flagged as belonging to no plan. Its frames are counted correctly under that entry β€” the disk and CSV views no longer disagree about how many there are β€” but the folder will keep reading as unclaimed until the pattern puts the token in that directory. Calibration frames (dark, bias, and flat) do not require this token; they are matched by frame type and exposure settings within the calibration window (darks by exposure time, flats by filter alone).
- βœ… **Quality thresholds:** `max_hfr`, `min_detected_stars`, and `max_guiding_rms_arcsec` (all optional) exclude light frames that fail the thresholds from the acquired count. Frames missing the quality fields are excluded whenever a threshold is set (fail-closed). Thresholds are applied only to light frames β€” calibration frames have no star quality.
- πŸ‘Ž **Manually failing a frame:** delete (or move using thumbs down icon) the image file on disk β€” e.g. a bad `.fits`/`.tif`. The metadata row stays in the CSV (the plugin only appends), but it is omitted when the metadata is read, so progress no longer counts that frame as acquired and the next `mode="remaining"` load re-acquires it.
- πŸ’Ύ **The plan file is persistent.** You can inspect, edit, and reuse it across sessions. Changing the file will, however, alter its derived `plan_id`, which is calculated when `plan_id` is missing. A hand-written `plan_id` must satisfy the same charset rule (empty, or ASCII letters, digits, `.` and `-`) β€” anything else, `_` included, is refused when the plan is loaded.
- 🧩 **opencode.json variants:** the repo contains example opencode.json variants: `win_opencode.json` for Windows, `wsl_opencode.json` for WSL, and `smb_opencode.json` for, say, a Mac accessing a N.I.N.A. VM with its image file directory mapped back over SMB.

## πŸ”” `nina-notify` β€” OpenCode v2 Autonomous Plugin

`nina-notify` is an [opencode](https://opencode.ai) v2 plugin (not compatible with Claude Code or other coding agents).  Registered in `.opencode/opencode.json`, it runs as a background server inside opencode and does two things:

1. **Websocket event monitoring** β€” Connects to the N.I.N.A. event socket (`ws://<host:port>/v2/socket`), subscribes to all events, and wakes the active agent only when a decision is actually required.

   **Only three classes of event ever wake the agent**, because N.I.N.A. already runs routine work itself β€” autofocus, dithering and drift correction are its own internal triggers, so reporting them to the agent buys nothing:

   - **Milestones** β€” sequence lifecycle (`RUN-STARTING`/`RUN-FINISHED`/`SEQUENCE-STARTING`/`SEQUENCE-FINISHED`) and per-target transitions (`TS-NEWTARGETSTART`): the observation queue needs evaluating β€” transit checks, altitude limits, next target dispatch.
   - **Faults** β€” failures (`ERROR`/`FAIL`/`EXCEPTION`/`ABORT`/`INTERRUPT`/`CRITICAL`/`TIMEOUT`, which catches `CAMERA-DOWNLOAD-TIMEOUT`) and link loss (`OFFLINE`/`DISCONNECT`/`LOST`/`EMERGENCY`): the agent diagnoses the root cause and selects a fallback. The tokens are matched as substrings, so `PRESTOW-DELAY` is caught by `STOW`.
   - **Safety** β€” safety state (`SAFE`/`UNSAFE`/`SAFETY`/`WEATHER`/`DOME`/`ROOF`) and mount/stow (`MOUNT*`/`STOW*`/`PRESTOW-*`): stow or stand down.

   Two demotions came out of that same 16h sample of real events (476 events β†’ 45 matched the regex β†’ but only 11 of the 24 coalesced prompts actually carried a milestone, fault or safety change): **`MOUNT-CENTER` never wakes** β€” it is the slew/settle centering the mount performs itself on every acquisition, and it alone produced 8 wake events β€” and **device link events wake only while a sequence is running**. N.I.N.A. cycles every device at session start and teardown, which produced the other 29 of 45 wake events, and a link change is only operationally interesting while something is imaging. That gate is seeded from `GET /v2/api/sequence/json` at startup (using the same any-node-`RUNNING` rule as the MCP server, so the two never disagree) and maintained from `RUN-*`/`SEQUENCE-*` events; a seed failure leaves it wake-worthy, the safe default. Safety *state* changes (`SAFETY-CHANGED`) match neither rule and always wake. Replaying the same 476 events through the new rules gives **7 prompts instead of 24 β€” every one a genuine milestone, fault or safety change.**

   Wakes are coalesced over a 250ms window, so a burst of *distinct* fault events costs the agent **one** prompt instead of one per event; safety still lands essentially instantly, since an agent wake already takes seconds.

   **Routine telemetry is never pushed.** Everything else β€” per-frame `IMAGE-PREPARED`/`IMAGE-SAVE`, `GUIDER-*`, `ACQUISITION-*`, `EXPOSING-FINISHED`, and any unrecognized event β€” is counted and dropped. Nothing is buffered to disk either: **N.I.N.A. already keeps its own event history server-side** (`GET /v2/api/event-history`), which the MCP server exposes as [`get_events(since=…)`](#get_events--get_logs). So every wake prompt carries the instruction `get_events(since=<seconds since your last wake>)` plus how many routine events were waiting, and the agent reads exactly the window it needs, on demand. Per-frame quality detail (HFR, star counts, ADU) is likewise already on disk in `ImageMetaData.csv` / `get_imaging_metadata`.

   **Quality watchdog.** Because routine telemetry no longer wakes anyone, a slowly degrading night (climbing HFR, collapsing star count) would otherwise stay invisible until the next milestone. While `light` frames are being acquired the plugin polls `GET /v2/api/image-history` **on each `IMAGE-SAVE`** and wakes the agent as an **operational fault** β€” a `QUALITY-WATCHDOG: …` event β€” when a [`frame-quality-diagnostics`](#-frame-quality-diagnostics) standing trigger holds for **3 consecutive frames** β€” `HFR > 3β€³` or under `10` detected stars. The consecutive-frame rule is deliberate: the single-frame `GUIDER-START` settles and post-recenter dips that skill documents can never wake anyone, and it fires **once** per degradation, re-arming only after the metric recovers. **Calibration is exempt twice over**: a known dark/bias/flat step (from `ACQUISITION-STARTING: [{kind}] …`) is skipped without even polling, and the record's own `ImageType` is a per-frame hard stop β€” darks, biases and flats have no stars at all, so without that check every calibration frame would read as a star-count failure. That `ImageType` check is also what keeps the watchdog working when the plugin attaches *after* a step's `ACQUISITION-STARTING`. `qualityWatchdog: false` turns the whole check off.

   `image-history` is used in preference to `…/equipment/camera/capture/statistics`: that one is a transient "last capture" buffer wiped by any camera reconnect, profile switch or sequence load (it answered `400 No image available` for a whole idle afternoon, while the previous night's frame was still served from `image-history`). Freshness is **proven, not assumed** β€” a sample is accepted only when its `Date` is newer than the last one, cross-checked against the `IMAGE-SAVE` event to 0.95 ms, so two saves reading one identical record can never count a single frame twice. A record with no provable `Date`, or nothing recorded yet, costs exactly **one** deferred re-check (1.5s later) and then leaves the streaks untouched: a missing sample may only *delay* a detection, never invent one, and a source that never advances cannot escalate into a retry loop (two attempts per frame, spaced at least 5s apart).

   Net effect: **~10–30 prompts per night instead of ~1,100** β€” one per milestone, fault, or sustained quality problem.

2. **Interval check** β€” every `intervalCheckMinutes` (**default 45**, previously `-1`/disabled) it prompts the agent to query observatory status (`get_sequence_state`, `get_site_equipment_status`) and decide what to do next, even when no N.I.N.A. events are firing. With routine telemetry no longer prompting, this is the only periodic driver between milestones, so it deliberately fires **even when the event socket is down** β€” a silently dead event stream is exactly when a status check matters most.

The plugin auto-reconnects on websocket disconnection with a 5-second retry. This 5s delay is **connection recovery only** β€” it fires solely on `onclose` and waits before re-establishing the socket; it never delays event delivery. Immediate events flush after a 250ms coalescing window and routine telemetry never flushes at all. On server dispose, it cleans up all timers and the socket.

### πŸ’« `PLAN.md` β€” the living wishlist

A markdown file at the project root, **edited by the user at any time** (before or during the night). It is intentionally lightweight β€” entries can be as simple as a target name:

[PLAN.md](./PLAN.md)
```markdown PLAN.md
# Tonight - 2026-10-08

- M31          "before the moon comes up"
- Veil Nebula  plan=veil-nebula_..._20260913.json   prio 1
- M33          "if high targets done"

Rules:
- min_altitude: 30
- horizon_offset_degrees: 2
- tie_break: priority, then altitude
```

## πŸ“ Project directory

Plan JSON files, `PLAN.md`, and `PROGRESS.md` live in the *project directory*. Relative paths (e.g. a `plan=` entry in `PLAN.md`, or `mosaic_csv`) resolve against it, and the project directory is simply the server process's working directory: opencode starts local MCP servers in the workspace, and the launch command uses `uv run --project` rather than `uv --directory` β€” `--directory` chdirs into `.opencode/local/mcps/nina_planner`, which would make relative paths resolve inside `.opencode/` instead of the project.

## πŸ€– `AGENTS.md` β€” Agent Instructions

Tells the agent it runs an observatory.

[.opencode/rules/AGENTS.md](./.opencode/rules/AGENTS.md)
```markdown .opencode/rules/AGENTS.md
# Observatory Controller

You are the automated observatory controller. The observatory runs NINA via the nina-planner MCP server.

## Always-on rules:
- `is_safe=false` (enclosure closed) or weather violations mean: scope stowed, if not by the NINA sequence then by you.
- Append a timestamped entry to `PROGRESS.md` after each action; never edit `PLAN.md` (the user's living wishlist).
- You are woken only by milestones, faults, safety changes, the interval check, and sustained quality problems. Routine telemetry (per-frame image saves, guider, autofocus) is **not** pushed to you β€” every wake prompt names a `get_events(since=…)` window covering the quiet gap, so read it before deciding. `QUALITY-WATCHDOG: …` in a wake means frame quality degraded and held. Device connect/disconnect events wake **only while a sequence is running**; if you hear nothing about a device, that is by design, not a missed event.

## Detailed procedures live in `.agents/skills/`:
- `safety-interrupt-recovery` β€” on any new event or interval check: NINA liveness, equipment/safety checks, stowing.
- `night-loop` β€” on interval check or `RUN-FINISHED`: pick and start the next target, or end the night.
- `nina-lifecycle` β€” start/stop/status of the NINA application.

## Emoji Communication Guidelines
- You will use emojis liberally to communicate status updates, milestones, and emotional context as things happen.
```

## πŸ“œ `PROGRESS.md` β€” Shared Progress File

`PROGRESS.md` is a shared observatory progress file in the project directory. The active session reads it on start to restore context before acting, and appends a timestamped entry after each significant action, recording what was done, the observed equipment and safety state, and any decisions. This keeps history shared across sessions.


## πŸ“‹ `opencode.json` β€” Opencode v2 Sample Configuration

[.opencode/opencode.json](./.opencode/opencode.json)
```json .opencode/opencode.json
{
  "$schema": "https://opencode.ai/config.json",
  "instructions": [ ".opencode/rules/*.md" ],
  "plugins": [
    {
      "package": "./local/plugins/nina_notify",
      "options": {
        "pluginLogs": "C:/Windows/Temp/nina-notify-debug.log"
      }
    }
  ],
  "mcp": {
    "servers": {
      "nina-planner": {
        "type": "local",
        "environment": {
          "NINA_PLANNER_LOG": "C:/Windows/Temp/nina-planner-debug.log"
        },
        "command": [
          "uv", "run", "--project", ".opencode/local/mcps/nina_planner", "-m", "nina_planner"
        ]
      }
    }
  }
}
```

Registers the opencode plugin and the `nina_planner` MCP server so both run together. The MCP server provides the tools (`load_sequence`, `get_site_equipment_status`, etc.) that the plugin-prompted agent calls.

---

## πŸŽ›οΈ Plugin Options

### πŸ”” `nina-notify` (opencode v2 plugin)

Configured in the plugin's options configuration. (see [`opencode.json`](#-opencodejson--opencode-v2-sample-configuration)).

| Option | Type | Default | Description |
|---|---|---|---|
| `ninaEndpoint` | string | `127.0.0.1:1888` | N.I.N.A. host and port (no scheme) for the websocket event stream, e.g. `192.168.0.24:1888`. The plugin connects to `ws://<ninaEndpoint>/v2/socket`. |
| `intervalCheckMinutes` | number | `45` | Minutes between autonomous status-check prompts. Accepts a float; converted to milliseconds (Γ—60,000) only when greater than 0; with 0 or less no timer is scheduled. Fires **regardless of the event-socket state**, so a dead event stream still gets attention. |
| `qualityWatchdog` | boolean | `true` | Wake the agent as an operational fault when a `frame-quality-diagnostics` standing trigger β€” `HFR > 3β€³` or fewer than `10` detected stars β€” holds for 3 consecutive **light** frames. Polls `GET /v2/api/image-history` on each `IMAGE-SAVE`, accepts a record only when its `Date` advanced, and gates on the record's `ImageType` as well as on the step kind β€” so darks, biases and flats never trigger it; re-arms once the metric recovers. |
| `pluginLogs` | string | _(none)_ | File path appended on each plugin event (websocket open/message/close/error, every injected prompt, watchdog checks, interval trigger failures). Set to a writable path (e.g. `/tmp/nina-plugins.log`) to capture diagnostic output. Omit or leave empty to disable file logging. |

---

## πŸ”§ Environment Variables

### πŸ–₯️ `nina_planner` (MCP server)

| Variable | Default | Description |
|---|---|---|
| `NINA_ENDPOINT` | `127.0.0.1:1888` | N.I.N.A. host and port for the REST API (`host:port`).  For WSL, try `192.168.0.24:1888`. When this points at a remote host (not the MCP server's own machine), `get_nina_status().lifecycle_control_available` is false and `start_nina`/`stop_nina` cannot operate on NINA. |
| `NINA_DRIVE_MOUNT` | `/mnt/<drive>` | Local mount root for the drive letter in the profile's `image_save_path`, used by `get_imaging_metadata` to resolve the imaging directory on Linux, WSL, and macOS. The value must be the local path where the Windows drive **root** is mounted (`C:\Users\me\img` becomes `<value>/Users/me/img`), e.g. `/mnt/c` in WSL, or wherever you mounted the share on macOS (such as `/System/Volumes/Data/Network`). The mount point is arbitrary and the drive need not be `C:`; the drive letter in the profile path is replaced by this value whatever it is. Defaults to `/mnt/<drive>` (e.g. `/mnt/c`). On Windows (`win32`) the profile `image_save_path` is used directly. The resolved value is also exposed as the derived `local_image_save_path` field on the profile returned by `get_site_profile`. |
| `NINA_PLANNER_LOG` | _(none)_ | File path where the server appends each NINA request/response payload for debugging. Logging is disabled when unset. A Windows-style path (e.g. `C:/Windows/Temp/nina-planner-debug.log`) on a non-Windows host is redirected to the system temp dir under the same file name. |
| `NINA_FLATS_ALTITUDE` | `80` | Altitude in degrees for flat panel calibration frames |
| `NINA_FLATS_AZIMUTH_DAWN` | `270` | Azimuth in degrees pointing west for dawn flats |
| `NINA_FLATS_AZIMUTH_DUSK` | `90` | Azimuth in degrees pointing east for dusk flats |
| `NINA_EXE_PATH` | `C:\Program Files\N.I.N.A. - Nighttime Imaging 'N' Astronomy\NINA.exe` | Windows path to `NINA.exe` used by `start_nina` to launch NINA. |

## 🐧 Setting up WSL

Make sure mirroring is setup in WSL to allow you to connect to NINA directly through localhost (otherwise you have to use your machines' IP address).  Do this by editing wslconfig and setting networking mode to mirrored.


```bash
$ vi $HOME/.wslconfig
[wsl2]
networkingMode=mirrored
```

In addition, to get the system tools to work to stop and restart NINA should it stop, make sudo passwordless with:


```bash
$ echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/90-user
```
## πŸͺŸ Setting up Windows

I installed the following packages to start:

```bash
winget install git.git      # bash
winget install Oven-sh.Bun  # bun
winget install jqlang.jq    # jq
winget install BurntSushi.ripgrep.MSVC  # rg
winget install astral-sh.uv # uv
```

Next I installed opencode v2 with:

```bash
$ bun install -g --trust @opencode/cli
```

Then to run opencode I ran in windows:

```bash
$ export OPENCODE_SERVER_PASSWORD=pass
$ opencode serve --port 4096
$ opencode --server http://127.0.0.1:4096
```


TDQS

B3.2/5.0

Scored across 14 tools

Disambiguation4/5

Most tools are clearly separated by domain: status, profile, events, logs, imaging metadata, plan progress, and sequence control are all distinct. The duplicate `_get_site_equipment_status` tool with no description creates real ambiguity, but it is the only significant overlap.

Naming Consistency4/5

Tool names follow predictable snake_case patterns such as `get_*`, `sequence_*`, and `observation_plan_*`. The leading-underscore duplicate `_get_site_equipment_status` breaks the convention and should be removed, but the rest of the naming is consistent.

Tool Count4/5

Fourteen tools is within a reasonable range for an observatory planning and operations server, and each major area has dedicated coverage. The duplicate tool slightly inflates the count, but the real scope is well-sized.

Completeness4/5

The set covers the full observation workflow: diagnostics, plan creation, progress tracking, sequence loading/starting/stopping, teardown, and safety standby. Minor gaps like plan-file inspection/editing or pause/resume commands are workaroundable, but no severe dead ends exist.

Maintenance

ActivityActive
ResponsivenessNo issues