sportsing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Pardini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # ⚽ sportsing
2
+
3
+ Sports in your terminal — the FIFA World Cup 2026 schedule, favorites, live
4
+ scores, **ambient fav-alerts**, browser streaming, highlights, stats, and AI
5
+ analysis. No npm dependencies; ships as a self-contained [Bun](https://bun.sh)
6
+ binary.
7
+
8
+ ```
9
+ sportsing fifa today
10
+ sportsing fifa next --team USA
11
+ sportsing today # the `fifa` prefix is optional during the World Cup
12
+ ```
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ bun install # dev deps (TypeScript, types)
18
+ bun run build # compiles a standalone binary → dist/sportsing
19
+ ```
20
+
21
+ Then run `./dist/sportsing …`, or put it on your `PATH`. For live data, add a
22
+ free [football-data.org](https://www.football-data.org) API key:
23
+
24
+ ```sh
25
+ sportsing fifa setup # paste your key (or set FOOTBALL_DATA_API_KEY)
26
+ ```
27
+
28
+ Without a key, fixtures fall back to the offline openfootball schedule (no live
29
+ scores or tables).
30
+
31
+ ## Commands
32
+
33
+ `sportsing fifa <command>` (or just `sportsing <command>` during the Cup):
34
+
35
+ | Command | What it does |
36
+ |---|---|
37
+ | `today` / `next` | Today's matches / next upcoming match + countdown |
38
+ | `live [--notify [--quiet]]` | Auto-refreshing live scoreboard — see **Live fav-alerts** below |
39
+ | `watch [team] [flags]` | Open the broadcast in your own browser — see **Watch** below |
40
+ | `highlights <team>` | Open a highlights search |
41
+ | `fixtures` / `schedule` / `results` | Fixtures, whole-tournament schedule, finished games (`--mine`) |
42
+ | `table [A-L]` / `bracket` | Group standings / knockout bracket |
43
+ | `teams` / `scorers` / `stats <team>` | Teams, Golden Boot race, match stats (`--json`) |
44
+ | `analyze` / `predict <team>` | AI tactical read / prediction (answered by `serve`) |
45
+ | `serve` | Run the AI answer loop that powers `analyze`, `predict`, and the overlay's "Ask Claude" (see **AI** below) |
46
+ | `fav [add\|rm\|list]` / `me` | Manage favorite teams / your dashboard |
47
+ | `setup [key]` | Add your football-data.org API key |
48
+
49
+ Run `sportsing fifa help` for the full list (`serve` and `ask` are the AI-bus
50
+ commands; `ask` is low-level plumbing that `serve` wraps).
51
+
52
+ ## Watch
53
+
54
+ `sportsing fifa watch [team] [team]` opens the broadcast in your own browser
55
+ (your real Chrome, via [ui-leaf](https://www.npmjs.com/package/@openthink/ui-leaf)):
56
+
57
+ - **`--wait`** — block until the match goes live, then open it (deep-linked to the
58
+ game with the stats overlay). With no team, waits for the *next* match overall.
59
+ - **`--overlay`** — inject the live-stats panel onto the page (needs a resolved
60
+ match; `--wait` always opens with it).
61
+ - **`--provider peacock|fubo`** — override the configured default (Fubo by default;
62
+ Peacock is Spanish/Telemundo).
63
+ - **`--url <link>`** — jump straight to a specific game link, skipping the hub.
64
+ - **`--lang english|spanish`** — preferred broadcast language (default `english`),
65
+ for providers that carry both airings (Fubo = Fox/English + Telemundo/Spanish;
66
+ Peacock is Spanish-only). The flag is accepted and carried now; the
67
+ language-biased deep-link selection is not wired yet (a notice prints when a
68
+ non-default language is requested).
69
+
70
+ - **`--smoke`** — open the window, confirm it came up, tear it down, exit 0. For
71
+ scripts/CI. `watch` is otherwise **interactive** — it blocks until you close the
72
+ window — so run without a controlling TTY (e.g. `< /dev/null`) it refuses rather
73
+ than hanging. Use `--smoke` to verify the launch path instead.
74
+ - **`--supervised`** — opt into running headless (no TTY) without that refusal, for
75
+ a pidfile-managed background watcher. This is what `/loop agent-setup` uses to
76
+ keep `watch --wait` alive; it still blocks (it's reaped via the pidfile), so it's
77
+ not a smoke-test — use `--smoke` for that.
78
+
79
+ For a hands-off, agent-driven session — open the game **and** keep "Ask Claude" /
80
+ "Get caught up" answered — use **`/loop agent-setup`** instead of running `watch`
81
+ yourself (see the **AI** section below).
82
+
83
+ ## Live fav-alerts
84
+
85
+ Turn `live` into an ambient alerter that pings you when your favorite teams play:
86
+
87
+ ```sh
88
+ sportsing fifa fav add USA # set up favorites first
89
+ sportsing fifa live --notify # live board + OS notifications
90
+ sportsing fifa live --notify --quiet & # headless: alerts only, backgroundable
91
+ ```
92
+
93
+ Each refresh diffs the latest scores against the previous tick and raises an OS
94
+ notification for every **new** favorite-team event — so each kickoff, goal, and
95
+ full-time alerts exactly once:
96
+
97
+ - **Kickoff** — *click the notification to start watching* (launches
98
+ `sportsing fifa watch <team>` for that match).
99
+ - **Goal** — the scorer and the resulting scoreline (with a sound).
100
+ - **Full time** — the final scoreline.
101
+
102
+ Flags:
103
+
104
+ - **`--notify`** — fire the alerts. With no favorites set, it warns and runs the
105
+ board normally. Without it, `live` behaves exactly as before.
106
+ - **`--quiet`** — suppress the full-screen scoreboard so the command can be
107
+ backgrounded (`&`) as a pure ambient alerter without redrawing your terminal.
108
+ Only meaningful together with `--notify` — used alone it prints a hint and
109
+ exits. `Ctrl-C` stops it.
110
+
111
+ ### Click-to-watch requires `terminal-notifier`
112
+
113
+ Clickable kickoff notifications use
114
+ [`terminal-notifier`](https://github.com/julienXX/terminal-notifier) (macOS):
115
+
116
+ ```sh
117
+ brew install terminal-notifier
118
+ ```
119
+
120
+ Notifications **degrade gracefully** when it's absent: on macOS they fall back to
121
+ `osascript` (plain banner, no click action); on Linux to `notify-send`; otherwise
122
+ to a terminal bell. Nothing errors — you just don't get the one-click-to-watch
123
+ behavior without `terminal-notifier`.
124
+
125
+ ## AI (analyze / predict / overlay "Ask Claude" + "Get caught up")
126
+
127
+ sportsing never spawns a local model. AI features route to an **external** Claude
128
+ agent over a file bus — opening a game is **not** enough; something must be serving
129
+ the bus or the overlay's Ask Claude / Get caught up panels show "○ No agent".
130
+
131
+ ### Agent-driven watch session — `/loop agent-setup` (the blessed setup)
132
+
133
+ In a Claude session, drop in:
134
+
135
+ ```
136
+ /loop agent-setup [team]
137
+ ```
138
+
139
+ One supervisor loop that **is** the whole setup: it opens your game and keeps that
140
+ `watch --wait` window alive (relaunching it if it dies), and it serves the bus so
141
+ **Ask Claude** and **Get caught up** (catchup) are actually answered — by that
142
+ Claude session itself (no local model is ever spawned). `sportsing fifa agent-setup`
143
+ prints this recipe; `sportsing fifa` and the watch nag point at it.
144
+
145
+ > **The cost, honestly:** the loop consumes that Claude session as the always-on
146
+ > answerer for as long as it runs — that's the trade you're choosing. Stop the loop
147
+ > and the heartbeat goes stale within ~90s, so the panels return to "○ No agent".
148
+ > Run it in a minimal-tool session (it answers untrusted viewer text).
149
+
150
+ ### Low-level primitive — `serve`
151
+
152
+ `sportsing fifa serve` is the bare answerer loop (it powers `analyze` / `predict`
153
+ too). `agent-setup` supersedes the old manual two-step for the agent-driven flow,
154
+ but `serve` remains the primitive if you want to compose it yourself:
155
+
156
+ ```sh
157
+ sportsing fifa watch --wait # (backgrounded) opens the game when it's live
158
+ /loop sportsing serve # answer-only loop — no watch supervision
159
+ ```
160
+
161
+ > **Run the answerer in a minimal-tool session.** Whether via `agent-setup` or
162
+ > `serve`, the loop reads **untrusted** text (viewer questions + raw API fields)
163
+ > into a tool-capable Claude session. Give that session no MCP/file tools and only
164
+ > the `sportsing ask --reply` Bash capability, so a prompt-injection in a question
165
+ > can't reach anything dangerous. `serve` prints this reminder each tick.
166
+
167
+ ## Development
168
+
169
+ This is a stamp-governed repo — read [`AGENTS.md`](./AGENTS.md) before any git
170
+ operation. Changes flow through `stamp review` → gate → `stamp merge`, never a
171
+ direct push to `main`.
172
+
173
+ ```sh
174
+ bun run typecheck
175
+ bun test
176
+ bun run build
177
+ ```
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "sportsing",
3
+ "version": "0.1.0",
4
+ "description": "Sportsing — the FIFA World Cup 2026 in your terminal: schedule, favorites, live scores, ambient fav-alerts, browser streaming, highlights, stats, and AI analysis.",
5
+ "type": "module",
6
+ "bin": {
7
+ "sportsing": "src/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "!src/**/*.test.ts",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "bun": ">=1.1.0"
17
+ },
18
+ "scripts": {
19
+ "start": "bun run src/index.ts",
20
+ "build": "bun build src/index.ts --compile --outfile dist/sportsing",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "bun test"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/MicroMediaSites/sportsing.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/MicroMediaSites/sportsing/issues"
30
+ },
31
+ "homepage": "https://github.com/MicroMediaSites/sportsing#readme",
32
+ "keywords": [
33
+ "fifa",
34
+ "world-cup",
35
+ "wc2026",
36
+ "soccer",
37
+ "football",
38
+ "cli",
39
+ "terminal",
40
+ "bun",
41
+ "live-scores",
42
+ "sports"
43
+ ],
44
+ "license": "MIT",
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^25.9.2",
50
+ "bun-types": "^1.3.14",
51
+ "typescript": "^6.0.3"
52
+ }
53
+ }
package/src/ansi.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Tiny ANSI helper — no dependencies. Honors NO_COLOR and non-TTY output.
2
+
3
+ const enabled = process.env.NO_COLOR == null && process.stdout.isTTY === true;
4
+
5
+ const wrap = (open: number, close: number) => (s: string | number) =>
6
+ enabled ? `\x1b[${open}m${s}\x1b[${close}m` : String(s);
7
+
8
+ export const c = {
9
+ reset: "\x1b[0m",
10
+ bold: wrap(1, 22),
11
+ dim: wrap(2, 22),
12
+ italic: wrap(3, 23),
13
+ underline: wrap(4, 24),
14
+ red: wrap(31, 39),
15
+ green: wrap(32, 39),
16
+ yellow: wrap(33, 39),
17
+ blue: wrap(34, 39),
18
+ magenta: wrap(35, 39),
19
+ cyan: wrap(36, 39),
20
+ gray: wrap(90, 39),
21
+ white: wrap(97, 39),
22
+ bgGreen: wrap(42, 49),
23
+ bgRed: wrap(41, 49),
24
+ };
25
+
26
+ /** Visible length, ignoring ANSI escape codes — for column alignment. */
27
+ export function visibleLen(s: string): number {
28
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
29
+ }
30
+
31
+ /** Pad a (possibly colored) string to a visible width. */
32
+ export function pad(s: string, width: number, align: "left" | "right" = "left"): string {
33
+ const gap = Math.max(0, width - visibleLen(s));
34
+ const spaces = " ".repeat(gap);
35
+ return align === "left" ? s + spaces : spaces + s;
36
+ }
package/src/api.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { join } from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import { CACHE_DIR, getApiKey } from "./config.ts";
4
+ import type {
5
+ MatchesResponse,
6
+ StandingsResponse,
7
+ ScorersResponse,
8
+ Match,
9
+ } from "./types.ts";
10
+
11
+ const BASE = "https://api.football-data.org/v4";
12
+ const COMP = "WC"; // FIFA World Cup
13
+ const OPENFOOTBALL =
14
+ "https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json";
15
+
16
+ export class ApiError extends Error {
17
+ constructor(public status: number, message: string) {
18
+ super(message);
19
+ }
20
+ }
21
+
22
+ export class NoKeyError extends Error {}
23
+
24
+ /** Disk cache so we stay under the 10 req/min free-tier limit. Shared with espn.ts. */
25
+ export async function cached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
26
+ await mkdir(CACHE_DIR, { recursive: true });
27
+ const file = join(CACHE_DIR, key + ".json");
28
+ try {
29
+ const stat = await Bun.file(file).stat();
30
+ if (Date.now() - stat.mtimeMs < ttlMs) {
31
+ return (await Bun.file(file).json()) as T;
32
+ }
33
+ } catch {
34
+ /* miss */
35
+ }
36
+ const fresh = await fetcher();
37
+ await Bun.write(file, JSON.stringify(fresh));
38
+ return fresh;
39
+ }
40
+
41
+ async function get<T>(path: string): Promise<T> {
42
+ const key = await getApiKey();
43
+ if (!key) throw new NoKeyError();
44
+ const res = await fetch(BASE + path, { headers: { "X-Auth-Token": key } });
45
+ if (res.status === 429) {
46
+ throw new ApiError(429, "Rate limited (free tier = 10 req/min). Try again shortly.");
47
+ }
48
+ if (res.status === 403) {
49
+ throw new ApiError(403, "Forbidden — check your API key or plan.");
50
+ }
51
+ if (!res.ok) {
52
+ let detail = res.statusText;
53
+ try {
54
+ detail = ((await res.json()) as any).message ?? detail;
55
+ } catch {}
56
+ throw new ApiError(res.status, detail);
57
+ }
58
+ return (await res.json()) as T;
59
+ }
60
+
61
+ const q = (params: Record<string, string | number | undefined>) => {
62
+ const sp = new URLSearchParams();
63
+ for (const [k, v] of Object.entries(params)) if (v != null) sp.set(k, String(v));
64
+ const s = sp.toString();
65
+ return s ? "?" + s : "";
66
+ };
67
+
68
+ export interface MatchFilter {
69
+ status?: string; // SCHEDULED,TIMED,IN_PLAY,...
70
+ dateFrom?: string; // YYYY-MM-DD
71
+ dateTo?: string;
72
+ stage?: string;
73
+ }
74
+
75
+ /** All matches, optionally filtered. TTL short so live scores stay fresh. */
76
+ export function getMatches(filter: MatchFilter = {}, ttlMs = 45_000): Promise<MatchesResponse> {
77
+ const cacheKey =
78
+ "matches_" +
79
+ (Object.entries(filter)
80
+ .filter(([, v]) => v != null)
81
+ .map(([k, v]) => `${k}-${v}`)
82
+ .join("_") || "all");
83
+ return cached(cacheKey, ttlMs, () =>
84
+ get<MatchesResponse>(`/competitions/${COMP}/matches${q({ ...filter })}`),
85
+ );
86
+ }
87
+
88
+ export function getStandings(ttlMs = 5 * 60_000): Promise<StandingsResponse> {
89
+ return cached("standings", ttlMs, () =>
90
+ get<StandingsResponse>(`/competitions/${COMP}/standings`),
91
+ );
92
+ }
93
+
94
+ export function getScorers(ttlMs = 10 * 60_000): Promise<ScorersResponse> {
95
+ return cached("scorers", ttlMs, () =>
96
+ get<ScorersResponse>(`/competitions/${COMP}/scorers?limit=20`),
97
+ );
98
+ }
99
+
100
+ const ROUND_TO_STAGE: Record<string, Match["stage"]> = {
101
+ "Round of 32": "LAST_32",
102
+ "Round of 16": "LAST_16",
103
+ "Quarter-final": "QUARTER_FINALS",
104
+ "Semi-final": "SEMI_FINALS",
105
+ "Match for third place": "THIRD_PLACE",
106
+ Final: "FINAL",
107
+ };
108
+
109
+ /** Parse openfootball times like "13:00 UTC-6" into a true UTC ISO string. */
110
+ function offToUtcIso(date: string, time: string | undefined): string {
111
+ if (!date) return new Date().toISOString();
112
+ const m = (time ?? "").match(/(\d{1,2}):(\d{2})\s*UTC([+-]\d{1,2})?/);
113
+ const [y, mo, d] = date.split("-").map(Number) as [number, number, number];
114
+ if (!m) return new Date(Date.UTC(y, mo - 1, d, 0, 0)).toISOString();
115
+ const hour = Number(m[1]);
116
+ const min = Number(m[2]);
117
+ const off = m[3] ? Number(m[3]) : 0; // local = UTC+off, so UTC = local - off
118
+ return new Date(Date.UTC(y, mo - 1, d, hour - off, min)).toISOString();
119
+ }
120
+
121
+ const teamName = (t: any): string =>
122
+ typeof t === "string" ? t : t?.name ?? "TBD";
123
+
124
+ /**
125
+ * Keyless fallback: openfootball public-domain WC 2026 schedule.
126
+ * Returns a Match[]-shaped array (fixtures only — no live scores / standings).
127
+ */
128
+ export async function getOpenFootballMatches(): Promise<Match[]> {
129
+ return cached("openfootball", 24 * 60 * 60_000, async () => {
130
+ const res = await fetch(OPENFOOTBALL);
131
+ if (!res.ok)
132
+ throw new ApiError(
133
+ res.status,
134
+ "Could not fetch the offline fixture schedule — network unavailable. Run `sportsing setup` to add a free API key for live data.",
135
+ );
136
+ const data = (await res.json()) as any;
137
+ const out: Match[] = [];
138
+ let id = 1;
139
+ for (const g of data.matches ?? []) {
140
+ if (!g.team1 && !g.team2) continue;
141
+ const groupLetter = g.group ? String(g.group).replace(/^Group\s+/i, "") : null;
142
+ out.push({
143
+ id: g.num ?? id++,
144
+ utcDate: offToUtcIso(g.date, g.time),
145
+ status: "SCHEDULED",
146
+ stage: ROUND_TO_STAGE[g.round as string] ?? "GROUP_STAGE",
147
+ group: groupLetter ? `GROUP_${groupLetter}` : null,
148
+ matchday: null,
149
+ homeTeam: { name: teamName(g.team1) },
150
+ awayTeam: { name: teamName(g.team2) },
151
+ score: { winner: null, fullTime: { home: null, away: null } },
152
+ venue: g.ground ?? null,
153
+ });
154
+ }
155
+ return out;
156
+ });
157
+ }
package/src/ask-bus.ts ADDED
@@ -0,0 +1,172 @@
1
+ // The "ask bus" — a file-based mailbox that bridges sportsing's AI features to
2
+ // an EXTERNAL Claude agent the user keeps running. sportsing NEVER spawns
3
+ // `claude -p` or an agent-sdk call; instead it posts a question to this bus, and
4
+ // a separate Claude session (looping `sportsing fifa ask --next`) picks it up,
5
+ // answers succinctly, and posts the answer back. The overlay / analyze / predict
6
+ // caller waits for the answer and renders it.
7
+ //
8
+ // Protocol (in ~/.cache/sportsing/ask/):
9
+ // q-<id>.json — a question (written by the caller, read by the agent)
10
+ // a-<id>.json — its answer (written by the agent, read by the caller)
11
+ // Files are written atomically (.tmp + rename) so a watcher never reads a
12
+ // partial file. The waiting caller deletes both once it has the answer.
13
+
14
+ import { mkdir, readdir, readFile, writeFile, rename, unlink } from "fs/promises";
15
+ import { join } from "path";
16
+ import { CACHE_DIR } from "./config.ts";
17
+
18
+ const BUS_DIR = join(CACHE_DIR, "ask");
19
+ const Q_PREFIX = "q-";
20
+ const A_PREFIX = "a-";
21
+ const STALE_MS = 5 * 60_000; // a question nobody answered in 5 min is abandoned
22
+
23
+ export type AskSource = "overlay" | "analyze" | "predict" | "catchup";
24
+
25
+ export interface AskQuestion {
26
+ id: string;
27
+ source: AskSource;
28
+ /** The full, self-contained prompt the agent should answer. */
29
+ question: string;
30
+ /** Short human label (e.g. "BRA 1-0 ARG, 56'") shown to the agent for context. */
31
+ context?: string;
32
+ /** How to answer (length/format guidance), surfaced to the agent. */
33
+ hint: string;
34
+ /** Hard cap applied to the answer at delivery time (overlay), or null for none. */
35
+ maxChars: number | null;
36
+ ts: number;
37
+ }
38
+
39
+ async function ensureDir(): Promise<void> {
40
+ await mkdir(BUS_DIR, { recursive: true });
41
+ }
42
+
43
+ const ID_RE = /^ask_[a-z0-9]+$/;
44
+
45
+ function newId(): string {
46
+ return "ask_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
47
+ }
48
+
49
+ // IDs become file paths, so a caller-supplied id (e.g. `ask --reply <id>`) must
50
+ // be validated before it touches the filesystem — otherwise `../…` could escape
51
+ // the bus dir on read or write.
52
+ function assertId(id: string): void {
53
+ if (typeof id !== "string" || !ID_RE.test(id)) throw new Error(`invalid ask id: ${JSON.stringify(id)}`);
54
+ }
55
+
56
+ const HEARTBEAT_FILE = join(BUS_DIR, ".serving");
57
+
58
+ /** A serving agent (`serve` / `ask --next`) refreshes this while it waits, so a
59
+ * caller can tell whether anyone is listening before posting a question that
60
+ * would otherwise just time out. */
61
+ export async function touchHeartbeat(): Promise<void> {
62
+ await ensureDir();
63
+ await writeFile(HEARTBEAT_FILE, String(Date.now())).catch(() => {});
64
+ }
65
+
66
+ /** True if a serving agent refreshed the heartbeat within `maxAgeMs` (default
67
+ * 90s — comfortably longer than a `serve` loop's gap between ticks). */
68
+ export async function isServing(maxAgeMs = 90_000): Promise<boolean> {
69
+ try {
70
+ const ts = Number(await readFile(HEARTBEAT_FILE, "utf8"));
71
+ return Number.isFinite(ts) && Date.now() - ts < maxAgeMs;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ async function writeJsonAtomic(path: string, data: unknown): Promise<void> {
78
+ const tmp = path + ".tmp";
79
+ await writeFile(tmp, JSON.stringify(data));
80
+ await rename(tmp, path); // atomic on the same filesystem
81
+ }
82
+
83
+ /** Post a question to the bus; returns its id. */
84
+ export async function postQuestion(q: Omit<AskQuestion, "id" | "ts">): Promise<string> {
85
+ await ensureDir();
86
+ const id = newId();
87
+ const full: AskQuestion = { ...q, id, ts: Date.now() };
88
+ await writeJsonAtomic(join(BUS_DIR, Q_PREFIX + id + ".json"), full);
89
+ return id;
90
+ }
91
+
92
+ /** Wait until an answer for `id` lands (or `timeoutMs` elapses). Cleans up both
93
+ * the question and answer files on the way out. Returns the answer, or null on
94
+ * timeout (no agent answered). */
95
+ export async function waitForAnswer(id: string, timeoutMs: number): Promise<string | null> {
96
+ assertId(id);
97
+ await ensureDir();
98
+ const aPath = join(BUS_DIR, A_PREFIX + id + ".json");
99
+ const qPath = join(BUS_DIR, Q_PREFIX + id + ".json");
100
+ const deadline = Date.now() + timeoutMs;
101
+ try {
102
+ while (Date.now() < deadline) {
103
+ try {
104
+ const { answer } = JSON.parse(await readFile(aPath, "utf8"));
105
+ return typeof answer === "string" ? answer : "";
106
+ } catch {
107
+ /* not answered yet */
108
+ }
109
+ await new Promise((r) => setTimeout(r, 300));
110
+ }
111
+ return null;
112
+ } finally {
113
+ await unlink(qPath).catch(() => {});
114
+ await unlink(aPath).catch(() => {});
115
+ }
116
+ }
117
+
118
+ /** Pending (unanswered, non-stale) questions, oldest first. */
119
+ export async function listPending(): Promise<AskQuestion[]> {
120
+ await ensureDir();
121
+ const files = await readdir(BUS_DIR).catch(() => [] as string[]);
122
+ const answered = new Set(files.filter((f) => f.startsWith(A_PREFIX)).map((f) => f.slice(A_PREFIX.length)));
123
+ const now = Date.now();
124
+ const out: AskQuestion[] = [];
125
+ for (const f of files) {
126
+ if (!f.startsWith(Q_PREFIX) || !f.endsWith(".json")) continue;
127
+ if (answered.has(f.slice(Q_PREFIX.length))) continue; // already has an answer waiting
128
+ try {
129
+ const q: AskQuestion = JSON.parse(await readFile(join(BUS_DIR, f), "utf8"));
130
+ // Validate shape so a malformed file (e.g. non-numeric ts) is skipped
131
+ // visibly rather than producing NaN comparisons downstream.
132
+ if (ID_RE.test(q?.id ?? "") && typeof q.question === "string" && typeof q.ts === "number" && now - q.ts <= STALE_MS) {
133
+ out.push(q);
134
+ }
135
+ } catch {
136
+ /* partial/malformed — skip */
137
+ }
138
+ }
139
+ return out.sort((a, b) => a.ts - b.ts);
140
+ }
141
+
142
+ /** The oldest pending question, optionally blocking up to `waitMs` for one. When
143
+ * `markServing` is set, refreshes the serving heartbeat each poll so callers can
144
+ * detect that an agent is listening (used by `serve` / `ask --next`). */
145
+ export async function nextPending(waitMs = 0, markServing = false): Promise<AskQuestion | null> {
146
+ const deadline = Date.now() + waitMs;
147
+ for (;;) {
148
+ if (markServing) await touchHeartbeat();
149
+ const pending = await listPending();
150
+ if (pending.length) return pending[0]!;
151
+ if (Date.now() >= deadline) return null;
152
+ await new Promise((r) => setTimeout(r, 500));
153
+ }
154
+ }
155
+
156
+ /** Deliver an answer for `id`. Applies the question's maxChars cap if set.
157
+ * Returns false if the question is unknown (timed out / already cleaned up). */
158
+ export async function postAnswer(id: string, answer: string): Promise<boolean> {
159
+ assertId(id);
160
+ await ensureDir();
161
+ let maxChars: number | null = null;
162
+ try {
163
+ const q: AskQuestion = JSON.parse(await readFile(join(BUS_DIR, Q_PREFIX + id + ".json"), "utf8"));
164
+ maxChars = q.maxChars;
165
+ } catch {
166
+ return false; // unknown id
167
+ }
168
+ let text = answer.trim();
169
+ if (maxChars && text.length > maxChars) text = text.slice(0, maxChars - 1).trimEnd() + "…";
170
+ await writeJsonAtomic(join(BUS_DIR, A_PREFIX + id + ".json"), { id, answer: text });
171
+ return true;
172
+ }