sportsing 0.2.0 → 0.3.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/README.md CHANGED
@@ -27,6 +27,13 @@ bun install -g sportsing # or: npm install -g sportsing
27
27
  sportsing --version
28
28
  ```
29
29
 
30
+ To update later, run `sportsing upgrade`. It checks npm for the latest release
31
+ and reinstalls it with whichever of npm or bun installed your copy.
32
+ `sportsing upgrade --check` only compares your version with the latest and
33
+ installs nothing. A source checkout, a compiled binary or an `npx`/`bunx`
34
+ run isn't updated in place. For those, the command prints how to update
35
+ instead.
36
+
30
37
  From source:
31
38
 
32
39
  ```sh
@@ -90,7 +97,7 @@ through playoffs. Times are local; preseason games are tagged `PRE`, postseason
90
97
  | `season [team]` | Your favorites' season: record, home/away splits, playoff race |
91
98
  | `bracket [--season YYYY]` | Playoff bracket (projected before the postseason) |
92
99
  | `fav [add\|rm\|list]` | Manage NBA favorites (`fav add UTAH`, `fav add Jazz`) |
93
- | `watch [team] [--wait] [--provider X] [--url L] [--smoke]` | Open the game where you can watch it — see **Where to watch** below |
100
+ | `watch [team] [--wait] [--provider X] [--url L] [--smoke] [--supervised]` | Open the game where you can watch it — see **Where to watch** below |
94
101
  | `live [--notify [--quiet]]` | Auto-refreshing live board; `--notify` alerts for favorites — see **Live fav-alerts** below |
95
102
  | `analyze <team> [team]` | AI read of the live or latest game: shooting, boards, turnovers, leaders (answered by `serve`; `--prompt` prints it) |
96
103
  | `predict <team> [team]` | AI prediction for the next game from both teams' recent form |
@@ -167,7 +174,8 @@ sportsing next # each favorite's next game + countdown, soonest first
167
174
  sportsing me # last result + next game per favorite team
168
175
  ```
169
176
 
170
- Bare `live --notify` alerts across every sport too (see **Live fav-alerts**).
177
+ Bare `live --notify` alerts across every sport too (see **Live fav-alerts**),
178
+ and `sportsing daemon` opens your teams' games when they start (see **Daemon**).
171
179
  Every other command needs a sport — a bare one (e.g. `sportsing standings`)
172
180
  prints the sports that have it and exits non-zero.
173
181
 
@@ -175,6 +183,47 @@ Favorites are stored per sport as `<sport>:<team>` (`nba:UTAH`, `nhl:UTAH`,
175
183
  `fifa:USA`) in `~/.config/sportsing/config.json`; add and remove them with each
176
184
  sport's `fav` command.
177
185
 
186
+ ## Daemon — open your teams' games automatically
187
+
188
+ `sportsing daemon` is an always-on watcher (a per-user launchd agent on macOS):
189
+ it polls every sport you have a favorite in and, when a favorite's game starts,
190
+ does what `<sport> watch` would — Fubo / League Pass **opens the game window**;
191
+ an over-the-air-only game gets a notification naming the channel; an
192
+ unwatchable one gets a notification saying why. It's set up once and survives
193
+ logins and `sportsing upgrade`.
194
+
195
+ ```sh
196
+ sportsing daemon install # write + load ~/Library/LaunchAgents/com.sportsing.daemon.plist
197
+ sportsing daemon status # is it running, what it's waiting for, what it last did
198
+ sportsing daemon logs [-f] # its log (~/Library/Logs/sportsing/daemon.log)
199
+ sportsing daemon mode notify # notify (click to watch) instead of auto-opening; `open` is the default
200
+ sportsing daemon uninstall # stop + remove it
201
+ ```
202
+
203
+ - **Status at a glance.** Bare `today` / `next` / `me` end with a one-line
204
+ footer — `daemon: on — waiting for DEN @ UTAH 7:00 PM`, `daemon: off —
205
+ sportsing daemon install`, or `daemon: stuck — last poll 47m ago`.
206
+ `daemon status` shows installed / pid + uptime / last poll / next game and
207
+ where it'll open / last game acted on, flags **STUCK** when the last poll is
208
+ older than ~3 poll intervals, and exits 0 only when it's running and healthy
209
+ (scriptable).
210
+ - **Each game is acted on once** (remembered in
211
+ `~/.cache/sportsing/daemon-state.json`): close the window and it stays closed;
212
+ a crash or restart doesn't open it twice. A game already under way that it
213
+ hasn't acted on yet (say the Mac woke mid-game) is opened then.
214
+ - **Polling** is sparse (every 10 min) until 15 minutes before a favorite's
215
+ scheduled start, then every 30s until it goes live — including a delayed
216
+ start (up to 3h late).
217
+ - **One sport failing** (e.g. FIFA without an API key) shows as a warning in
218
+ `status`; the others keep working.
219
+ - **Install from a global install** (`npm install -g sportsing` or
220
+ `bun add -g sportsing`): the agent runs that install's stable `sportsing`
221
+ bin, and `sportsing upgrade` restarts it on the new version. Preview the
222
+ plist without installing: `sportsing daemon install --dry-run`.
223
+ - The daemon opens the window as a detached `sportsing <sport> watch <team>
224
+ --supervised` (no terminal needed; closing the window ends it). Set up where
225
+ you watch first (`sportsing subscriptions set …`, see **Where to watch**).
226
+
178
227
  ## Upgrading to 0.2.0
179
228
 
180
229
  - **Breaking — bare commands need a sport.** In 0.1.x every bare command ran as a
@@ -305,6 +354,11 @@ One supervisor loop that **is** the whole setup: it opens your game and keeps th
305
354
  Claude session itself (no local model is ever spawned). `sportsing fifa agent-setup`
306
355
  prints this recipe; `sportsing fifa` and the watch nag point at it.
307
356
 
357
+ > **NBA / NHL:** just to have your team's games open when they start, you don't
358
+ > need this loop — use **`sportsing daemon install`** (see **Daemon**). It runs
359
+ > without a Claude session and works across every sport with a favorite.
360
+ > `/loop agent-setup` is for the FIFA overlay's Ask Claude / Get caught up.
361
+
308
362
  > **The cost, honestly:** the loop consumes that Claude session as the always-on
309
363
  > answerer for as long as it runs — that's the trade you're choosing. Stop the loop
310
364
  > and the heartbeat goes stale within ~90s, so the panels return to "○ No agent".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sportsing",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sportsing — sports in your terminal: the FIFA World Cup 2026, the NBA, and the NHL. Schedules, favorites across every sport, live scores, standings, where-to-watch, browser streaming, highlights, stats, and AI analysis.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,13 +19,17 @@ import { fifa, fifaHasCommand } from "../sports/fifa.ts";
19
19
  import { NBA, nba } from "../sports/nba.ts";
20
20
  import { NHL, nhl } from "../sports/nhl.ts";
21
21
  import type { Match } from "../types.ts";
22
- import { fifaAlerter } from "./live.ts";
22
+ import { matchState } from "../match-util.ts";
23
+ import { fifaAlerter, fifaDaemonGames } from "./live.ts";
24
+ import type { DaemonGame } from "../daemon.ts";
25
+ import { daemonFooter } from "../daemon-io.ts";
23
26
  import { EXAMPLE_TEAM, addDays, localDateOf, matchHasTeam, withFallback, ymd } from "./_lib.ts";
24
27
  import {
25
28
  favoriteIds,
26
29
  gameHasTeam,
27
30
  gamesOnDays,
28
31
  leagueAlerter,
32
+ leagueDaemonGames,
29
33
  leagueHasCommand,
30
34
  parseOffset,
31
35
  teamSeason,
@@ -63,16 +67,13 @@ export interface Sport {
63
67
  favoriteTeams: () => Promise<TeamRows[] | null>;
64
68
  /** Favorite-team live alerter; null when the sport has no favorites. */
65
69
  alerter: () => Promise<Alerter | null>;
70
+ /** Favorite games for `sportsing daemon`; null when the sport has no favorites. */
71
+ daemonGames: () => Promise<DaemonGame[] | null>;
66
72
  }
67
73
 
68
74
  // ── FIFA (football-data `Match`) ─────────────────────────────────────────────
69
75
 
70
- /** A football-data match status as a sport-neutral game state. */
71
- export function matchState(m: Pick<Match, "status">): GameState {
72
- if (m.status === "IN_PLAY" || m.status === "PAUSED") return "in";
73
- if (m.status === "FINISHED" || m.status === "AWARDED") return "post";
74
- return "pre";
75
- }
76
+ export { matchState };
76
77
 
77
78
  const matchRow = (m: Match): Row => ({ id: String(m.id), start: m.utcDate, state: matchState(m), line: matchLine(m) });
78
79
 
@@ -89,6 +90,7 @@ const FIFA: Sport = {
89
90
  run: fifa,
90
91
  has: fifaHasCommand,
91
92
  alerter: fifaAlerter,
93
+ daemonGames: fifaDaemonGames,
92
94
  async favoritesOn(day) {
93
95
  const favs = await getFavorites("fifa");
94
96
  if (favs.length === 0) return null;
@@ -132,6 +134,7 @@ function league(cfg: LeagueConfig, run: Sport["run"]): Sport {
132
134
  run,
133
135
  has: leagueHasCommand,
134
136
  alerter: () => leagueAlerter(cfg),
137
+ daemonGames: () => leagueDaemonGames(cfg),
135
138
  async favoritesOn(day) {
136
139
  const favs = await favoriteTeamsOf();
137
140
  if (!favs) return null;
@@ -240,7 +243,15 @@ async function perSport<T>(sports: Sport[], load: (s: Sport) => Promise<T | null
240
243
  }
241
244
 
242
245
  /** `today [--tomorrow|--yesterday|--offset N]` — your teams' games on one local day. */
243
- export async function today(sports: Sport[], args: string[]): Promise<void> {
246
+ /** Supplies the one-line daemon footer under today / next / me (null = none). */
247
+ export type Footer = () => string | null;
248
+
249
+ function printFooter(footer?: Footer): void {
250
+ const line = footer?.();
251
+ if (line) console.log("\n" + c.dim(line));
252
+ }
253
+
254
+ export async function today(sports: Sport[], args: string[], footer?: Footer): Promise<void> {
244
255
  const offset = parseOffset(args);
245
256
  const day = addDays(new Date(), offset);
246
257
  const loaded = await perSport(sports, (s) => s.favoritesOn(day));
@@ -251,16 +262,16 @@ export async function today(sports: Sport[], args: string[]): Promise<void> {
251
262
 
252
263
  const label = offset === 0 ? "Today" : offset === 1 ? "Tomorrow" : offset === -1 ? "Yesterday" : ymd(day);
253
264
  console.log(c.bold(c.cyan(`★ Your teams — ${label} (${ymd(day)})`)));
254
- if (rows.length === 0) {
255
- console.log(c.dim("\nNo games for your teams."));
256
- return;
265
+ if (rows.length === 0) console.log(c.dim("\nNo games for your teams."));
266
+ else {
267
+ console.log();
268
+ for (const { sport, row } of rows) console.log(" " + tagged(sport, row.line));
257
269
  }
258
- console.log();
259
- for (const { sport, row } of rows) console.log(" " + tagged(sport, row.line));
270
+ printFooter(footer);
260
271
  }
261
272
 
262
273
  /** `next` — each favorite team's next game, soonest first, with countdowns. */
263
- export async function next(sports: Sport[]): Promise<void> {
274
+ export async function next(sports: Sport[], footer?: Footer): Promise<void> {
264
275
  const loaded = await perSport(sports, (s) => s.favoriteTeams());
265
276
  if (!loaded) return;
266
277
  const now = Date.now();
@@ -269,18 +280,16 @@ export async function next(sports: Sport[]): Promise<void> {
269
280
  .sort((a, b) => byStart(a.row, b.row));
270
281
 
271
282
  console.log(c.bold(c.cyan("★ Your teams — Next up")));
272
- if (rows.length === 0) {
273
- console.log(c.dim("\nNo upcoming games for your teams."));
274
- return;
275
- }
283
+ if (rows.length === 0) console.log(c.dim("\nNo upcoming games for your teams."));
276
284
  for (const { sport, row } of rows) {
277
285
  console.log("\n " + tagged(sport, row.line));
278
286
  console.log(` ${c.bold(fmtDate(row.start))} ${c.green("— starts " + relativeTime(row.start))}`);
279
287
  }
288
+ printFooter(footer);
280
289
  }
281
290
 
282
291
  /** `me` — every favorite team, every sport: last result and next game. */
283
- export async function me(sports: Sport[]): Promise<void> {
292
+ export async function me(sports: Sport[], footer?: Footer): Promise<void> {
284
293
  const loaded = await perSport(sports, (s) => s.favoriteTeams());
285
294
  if (!loaded) return;
286
295
  const now = Date.now();
@@ -295,6 +304,7 @@ export async function me(sports: Sport[]): Promise<void> {
295
304
  if (!last && !next) console.log(c.dim(" no games found"));
296
305
  }
297
306
  }
307
+ printFooter(footer);
298
308
  }
299
309
 
300
310
  /** Startup line for bare `live`, e.g. "NBA: Utah Jazz · NHL: Utah Mammoth". */
@@ -342,10 +352,10 @@ export async function live(sports: Sport[], args: string[]): Promise<void> {
342
352
 
343
353
  /** Dispatch a bare `sportsing <cmd> …` (no sport prefix). */
344
354
  export async function bare(cmd: string, args: string[]): Promise<void> {
345
- if (cmd === "today" || cmd === "t") return today(SPORTS, args);
355
+ if (cmd === "today" || cmd === "t") return today(SPORTS, args, daemonFooter);
346
356
  if (cmd === "live") return live(SPORTS, args);
347
357
  const run = cmd === "next" || cmd === "n" ? next : cmd === "me" ? me : null;
348
- if (run && args.length === 0) return run(SPORTS);
358
+ if (run && args.length === 0) return run(SPORTS, daemonFooter);
349
359
  // Options on `next`/`me` (e.g. `--team`) are per-sport: say so rather than ignore them.
350
360
  const [first, ...rest] = bareHint(cmd, args, SPORTS, run !== null);
351
361
  console.error(c.red(first!));
@@ -0,0 +1,335 @@
1
+ // `sportsing daemon …` — the always-on watcher (see src/daemon.ts for the
2
+ // pure logic and src/daemon-io.ts for files + launchd).
3
+ //
4
+ // install [--dry-run] write + load the LaunchAgent (dry run: print the plist)
5
+ // uninstall unload + remove it
6
+ // status [--json] installed / running / last poll / next game / last action; exit 0 only if healthy
7
+ // logs [-f] [-n N] tail the log
8
+ // mode [open|notify] show or set what a game start does
9
+ // run [--once] [--dry-run] the poll loop launchd runs (also usable in the foreground)
10
+
11
+ import { spawn } from "child_process";
12
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
13
+ import { homedir } from "os";
14
+ import { dirname } from "path";
15
+ import { c } from "../ansi.ts";
16
+ import { notify } from "../notify.ts";
17
+ import { getDaemonMode, setDaemonMode } from "../config.ts";
18
+ import { inTerminal, selfInvocation, shQuote } from "../click-to-watch.ts";
19
+ import {
20
+ DAEMON_LABEL,
21
+ DAEMON_MODES,
22
+ agentPath,
23
+ daemonProgram,
24
+ fmtDuration,
25
+ isHealthy,
26
+ launchAgentPlist,
27
+ nextUp,
28
+ parseDaemonMode,
29
+ pollDelayMs,
30
+ pruneActed,
31
+ routeWhere,
32
+ staleAfterMs,
33
+ startsToAct,
34
+ whenLabel,
35
+ type DaemonGame,
36
+ type DaemonMode,
37
+ type DaemonState,
38
+ type DaemonStatus,
39
+ type LastAction,
40
+ } from "../daemon.ts";
41
+ import { LOG_DIR, LOG_FILE, PLIST_PATH, STATE_FILE, gatherStatus, readState, systemLaunchd, writeState, type Launchd } from "../daemon-io.ts";
42
+ import { describeInstall, detectInstall } from "../upgrade.ts";
43
+ import { SPORTS, type Sport } from "./cross.ts";
44
+ import { runningScript } from "./upgrade.ts";
45
+
46
+ function usage(): void {
47
+ console.log(`${c.bold("sportsing daemon")} — always-on watcher: opens your teams' games when they start
48
+
49
+ sportsing daemon install [--dry-run] install + start the LaunchAgent (runs at login, restarts if it dies)
50
+ sportsing daemon uninstall stop + remove it
51
+ sportsing daemon status [--json] is it running, what it's waiting for, what it last did (exit 0 = healthy)
52
+ sportsing daemon logs [-f] [-n N] show (or follow) its log
53
+ sportsing daemon mode [open|notify] what a game start does: open the window (default) or notify with click-to-watch
54
+ sportsing daemon run [--once] [--dry-run] the loop itself, in the foreground (--dry-run: act on nothing)`);
55
+ }
56
+
57
+ function fail(msg: string, hint?: string): void {
58
+ console.error(c.red(msg));
59
+ if (hint) console.error(c.dim(hint));
60
+ process.exitCode = 1;
61
+ }
62
+
63
+ const tilde = (p: string) => (p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p);
64
+
65
+ // ── install / uninstall ──────────────────────────────────────────────────────
66
+
67
+ /** The plist for this install, or an error saying why there isn't one. */
68
+ export function plistForThisInstall(): { plist: string; program: string[] } | { error: string } {
69
+ const install = detectInstall(runningScript());
70
+ const program = daemonProgram(install, { execPath: process.execPath, bunOnPath: Bun.which("bun"), exists: existsSync });
71
+ if (!program) {
72
+ return {
73
+ error: `Can't install the daemon from a ${describeInstall(install)} — it needs a stable path to run. Install sportsing globally first: npm install -g sportsing (or bun add -g sportsing).`,
74
+ };
75
+ }
76
+ const plist = launchAgentPlist({ label: DAEMON_LABEL, program, logPath: LOG_FILE, path: agentPath(process.env.PATH), home: homedir() });
77
+ return { plist, program };
78
+ }
79
+
80
+ export function install(args: string[], launchd: Launchd): void {
81
+ const made = plistForThisInstall();
82
+ if ("error" in made) return fail(made.error);
83
+ if (args.includes("--dry-run")) {
84
+ console.log(c.dim(`# would write ${PLIST_PATH} and run: launchctl bootstrap gui/<uid> ${tilde(PLIST_PATH)}`));
85
+ process.stdout.write(made.plist);
86
+ return;
87
+ }
88
+ // Idempotent: unload a previous copy, rewrite the plist, load it fresh.
89
+ if (launchd.print(DAEMON_LABEL).loaded) launchd.bootout(DAEMON_LABEL);
90
+ mkdirSync(LOG_DIR, { recursive: true });
91
+ mkdirSync(dirname(PLIST_PATH), { recursive: true });
92
+ writeFileSync(PLIST_PATH, made.plist);
93
+ try {
94
+ launchd.bootstrap(PLIST_PATH);
95
+ } catch (e) {
96
+ return fail(`Wrote ${tilde(PLIST_PATH)} but couldn't load it: ${(e as Error).message}`);
97
+ }
98
+ console.log(c.green(`✓ sportsing daemon installed and started (${DAEMON_LABEL}).`));
99
+ console.log(c.dim(` runs: ${made.program.join(" ")}`));
100
+ console.log(c.dim(` log: ${tilde(LOG_FILE)}`));
101
+ console.log(c.dim(" check it with: sportsing daemon status"));
102
+ }
103
+
104
+ export function uninstall(launchd: Launchd): void {
105
+ const loaded = launchd.print(DAEMON_LABEL).loaded;
106
+ if (loaded) {
107
+ try {
108
+ launchd.bootout(DAEMON_LABEL);
109
+ } catch (e) {
110
+ return fail(`Couldn't stop the daemon: ${(e as Error).message}`);
111
+ }
112
+ }
113
+ const had = existsSync(PLIST_PATH);
114
+ rmSync(PLIST_PATH, { force: true });
115
+ console.log(loaded || had ? c.green("✓ sportsing daemon stopped and removed.") : c.dim("sportsing daemon wasn't installed — nothing to do."));
116
+ }
117
+
118
+ // ── status ───────────────────────────────────────────────────────────────────
119
+
120
+ const LEVEL_COLOR = { on: c.green, starting: c.yellow, stuck: c.red, down: c.red, off: c.yellow } as const;
121
+
122
+ /** `daemon status` text, one fact per line. */
123
+ export function statusLines(s: DaemonStatus, now: number): string[] {
124
+ const st = s.state && s.state.pid === s.pid ? s.state : null;
125
+ const head = `sportsing daemon: ${LEVEL_COLOR[s.level](c.bold(s.level))}`;
126
+ const row = (k: string, v: string) => ` ${c.dim(k.padEnd(11))} ${v}`;
127
+ const out = [head];
128
+ if (s.level === "off") {
129
+ out.push(row("installed", "no — install it with: sportsing daemon install"));
130
+ return out;
131
+ }
132
+ out.push(row("installed", s.installed ? tilde(PLIST_PATH) : "no plist (running in the foreground?)"));
133
+ if (s.pid === null) out.push(row("running", c.red("no") + c.dim(" — see: sportsing daemon logs")));
134
+ else {
135
+ const up = st ? ` · up ${fmtDuration(now - Date.parse(st.startedAt))} · v${st.version} · mode ${st.mode}` : "";
136
+ out.push(row("running", `pid ${s.pid}${up}`));
137
+ }
138
+ const last = s.state;
139
+ if (last?.heartbeat) {
140
+ const age = now - Date.parse(last.heartbeat);
141
+ const ago = `${new Date(last.heartbeat).toLocaleTimeString()} (${fmtDuration(age)} ago)`;
142
+ const stuck = s.level === "stuck" ? c.red(` — STUCK: no poll for over ${fmtDuration(staleAfterMs(last.pollMs))}`) : c.dim(` · polling every ${fmtDuration(last.pollMs)}`);
143
+ out.push(row("last poll", ago + stuck));
144
+ } else out.push(row("last poll", c.dim("none yet")));
145
+ if (last) {
146
+ const n = last.next;
147
+ out.push(row("next game", n ? `${n.icon} ${n.fixture} · ${whenLabel(n.start, now)} → ${n.where}` : c.dim("no favorite games coming up")));
148
+ const a = last.lastAction;
149
+ out.push(row("last acted", a ? `${a.icon} ${a.did} ${a.fixture} · ${whenLabel(a.at, now)}${a.detail ? c.dim(` (${a.detail})`) : ""}` : c.dim("nothing yet")));
150
+ for (const [sport, err] of Object.entries(last.errors)) out.push(row("warning", c.yellow(`${sport}: ${err}`)));
151
+ }
152
+ out.push(row("log", tilde(LOG_FILE)));
153
+ return out;
154
+ }
155
+
156
+ function status(args: string[], launchd: Launchd): void {
157
+ const now = Date.now();
158
+ const s = gatherStatus(launchd, now);
159
+ if (args.includes("--json")) console.log(JSON.stringify({ ...s, healthy: isHealthy(s) }, null, 2));
160
+ else for (const l of statusLines(s, now)) console.log(l);
161
+ if (!isHealthy(s)) process.exitCode = 1;
162
+ }
163
+
164
+ // ── logs ─────────────────────────────────────────────────────────────────────
165
+
166
+ async function logs(args: string[]): Promise<void> {
167
+ if (!existsSync(LOG_FILE)) return fail(`No daemon log yet (${tilde(LOG_FILE)}).`, "It's created when the daemon first runs: sportsing daemon install");
168
+ const nIdx = args.indexOf("-n");
169
+ const n = nIdx >= 0 ? Number(args[nIdx + 1]) : 50;
170
+ const follow = args.includes("-f") || args.includes("--follow");
171
+ const cmd = ["tail", "-n", String(Number.isInteger(n) && n > 0 ? n : 50), ...(follow ? ["-f"] : []), LOG_FILE];
172
+ const proc = Bun.spawn(cmd, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
173
+ process.exitCode = await proc.exited;
174
+ }
175
+
176
+ // ── mode ─────────────────────────────────────────────────────────────────────
177
+
178
+ async function mode(args: string[]): Promise<void> {
179
+ if (args.length === 0) {
180
+ console.log(`daemon mode: ${c.bold(await getDaemonMode())} ${c.dim("(open = open the game window · notify = notification, click to watch)")}`);
181
+ return;
182
+ }
183
+ const m = parseDaemonMode(args[0]);
184
+ if (!m) return fail(`Unknown mode "${args[0]}". Use one of: ${DAEMON_MODES.join(", ")}.`);
185
+ await setDaemonMode(m);
186
+ console.log(c.green(`✓ daemon mode: ${m}`) + c.dim(" — takes effect from the next game start."));
187
+ }
188
+
189
+ // ── run ──────────────────────────────────────────────────────────────────────
190
+
191
+ function log(msg: string): void {
192
+ console.log(`${new Date().toISOString()} ${msg}`);
193
+ }
194
+
195
+ /** Every sport's favorite games; a sport that fails is recorded and skipped. */
196
+ async function collect(sports: Sport[]): Promise<{ games: DaemonGame[]; errors: Record<string, string> }> {
197
+ const settled = await Promise.allSettled(sports.map((s) => s.daemonGames()));
198
+ const games: DaemonGame[] = [];
199
+ const errors: Record<string, string> = {};
200
+ settled.forEach((r, i) => {
201
+ if (r.status === "fulfilled") games.push(...(r.value ?? []));
202
+ else errors[sports[i]!.label] = r.reason instanceof Error ? r.reason.message : String(r.reason);
203
+ });
204
+ return { games, errors };
205
+ }
206
+
207
+ /** Run `args` as a detached sportsing child (its own process group, so it
208
+ * outlives a daemon restart), output into the daemon's log. */
209
+ function spawnDetached(exe: string[], args: string[]): void {
210
+ const child = spawn(exe[0]!, [...exe.slice(1), ...args], { detached: true, stdio: ["ignore", "inherit", "inherit"] });
211
+ child.on("error", (e) => log(`couldn't start ${args.join(" ")}: ${e.message}`));
212
+ child.unref();
213
+ }
214
+
215
+ /** Act on one game start. */
216
+ function act(g: DaemonGame, mode: DaemonMode, exe: string[], dry: boolean): LastAction {
217
+ const base = { at: new Date().toISOString(), key: g.key, icon: g.icon, fixture: g.fixture };
218
+ const click = `${exe.map(shQuote).join(" ")} ${g.clickArgs.map(shQuote).join(" ")}`;
219
+ const title = `${g.icon} ${g.fixture} is under way`;
220
+ if (g.route.kind === "open" && mode === "open") {
221
+ if (dry) return { ...base, did: "dry-run", detail: `would open: ${g.openArgs.join(" ")}` };
222
+ spawnDetached(exe, g.openArgs);
223
+ notify(title, `Opening — ${g.route.note}`, { group: `sportsing-daemon-${g.key}` });
224
+ return { ...base, did: "opened", detail: g.route.note };
225
+ }
226
+ const body = g.route.kind === "open" ? `Click to watch — ${g.route.note}` : g.route.kind === "tune" ? `📺 ${g.route.message}` : `✗ ${g.route.message}`;
227
+ if (dry) return { ...base, did: "dry-run", detail: `would notify: ${body}` };
228
+ notify(title, body, { group: `sportsing-daemon-${g.key}`, onClick: g.route.kind === "open" ? inTerminal(click) : undefined });
229
+ return { ...base, did: "notified", detail: body };
230
+ }
231
+
232
+ /**
233
+ * The poll loop. Each poll: fetch every sport's favorite games, act on any
234
+ * live one not acted on before (recorded in the state file *before* acting,
235
+ * so a crash or restart never double-opens), record the heartbeat and next
236
+ * game, then sleep — sparse when nothing's near, tight around a start.
237
+ */
238
+ export async function run(args: string[], version: string, sports: Sport[] = SPORTS): Promise<void> {
239
+ const once = args.includes("--once");
240
+ const dry = args.includes("--dry-run");
241
+ const exe = selfInvocation();
242
+ const prev = readState();
243
+ const state: DaemonState = {
244
+ pid: process.pid,
245
+ startedAt: new Date().toISOString(),
246
+ version,
247
+ mode: await getDaemonMode(),
248
+ heartbeat: null,
249
+ pollMs: 0,
250
+ next: null,
251
+ lastAction: prev?.lastAction ?? null,
252
+ acted: prev?.acted ?? {},
253
+ errors: {},
254
+ };
255
+ const save = () => {
256
+ if (!dry) writeState(state);
257
+ };
258
+ log(`sportsing daemon ${version} started (pid ${process.pid}${dry ? ", dry run" : ""}; state ${tilde(STATE_FILE)})`);
259
+ save();
260
+
261
+ let stop = false;
262
+ let wake: (() => void) | null = null;
263
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
264
+ process.on(sig, () => {
265
+ log(`${sig} — stopping`);
266
+ stop = true;
267
+ wake?.();
268
+ });
269
+ }
270
+
271
+ while (!stop) {
272
+ state.mode = await getDaemonMode().catch(() => state.mode);
273
+ const { games, errors } = await collect(sports);
274
+ const now = Date.now();
275
+ state.errors = errors;
276
+ for (const [sport, err] of Object.entries(errors)) log(`${sport}: ${err}`);
277
+
278
+ state.acted = pruneActed(state.acted, now);
279
+ for (const g of startsToAct(games, state.acted)) {
280
+ state.acted[g.key] = new Date(now).toISOString();
281
+ save(); // persist the dedupe before acting
282
+ try {
283
+ state.lastAction = act(g, state.mode, exe, dry);
284
+ } catch (e) {
285
+ state.lastAction = { at: new Date().toISOString(), key: g.key, icon: g.icon, fixture: g.fixture, did: "failed", detail: (e as Error).message };
286
+ }
287
+ log(`${state.lastAction.did} ${g.fixture} — ${state.lastAction.detail}`);
288
+ }
289
+
290
+ const n = nextUp(games, now);
291
+ state.next = n && { key: n.key, icon: n.icon, fixture: n.fixture, start: n.start, where: routeWhere(n.route, state.mode) };
292
+ state.pollMs = pollDelayMs(games, now);
293
+ state.heartbeat = new Date(now).toISOString();
294
+ save();
295
+ const waiting = n ? `next ${n.fixture} ${new Date(n.start).toLocaleString()} (${state.next!.where})` : "no favorite games coming up";
296
+ log(`poll: ${games.length} favorite game(s); ${waiting}; next poll in ${fmtDuration(state.pollMs)}`);
297
+ if (once) break;
298
+ await new Promise<void>((r) => {
299
+ const t = setTimeout(r, state.pollMs);
300
+ wake = () => {
301
+ clearTimeout(t);
302
+ r();
303
+ };
304
+ });
305
+ }
306
+ }
307
+
308
+ // ── dispatch ─────────────────────────────────────────────────────────────────
309
+
310
+ export async function daemon(args: string[], version: string, launchd: Launchd = systemLaunchd()): Promise<void> {
311
+ const [sub, ...rest] = args;
312
+ switch (sub) {
313
+ case "install":
314
+ return install(rest, launchd);
315
+ case "uninstall":
316
+ return uninstall(launchd);
317
+ case "status":
318
+ return status(rest, launchd);
319
+ case "logs":
320
+ case "log":
321
+ return logs(rest);
322
+ case "mode":
323
+ return mode(rest);
324
+ case "run":
325
+ return run(rest, version);
326
+ case undefined:
327
+ case "help":
328
+ case "--help":
329
+ case "-h":
330
+ return usage();
331
+ default:
332
+ usage();
333
+ return fail(`Unknown daemon command: ${sub}`);
334
+ }
335
+ }
@@ -36,6 +36,7 @@ import { LIVE_REFRESH_MS, raise, type Alert, type Alerter } from "../alerts.ts";
36
36
  import { selfInvocation } from "../click-to-watch.ts";
37
37
  import { isGameSport } from "../game-events.ts";
38
38
  import { leagueFeed } from "../league-alerts.ts";
39
+ import { leagueDaemonGame, leagueRoute, type DaemonGame } from "../daemon.ts";
39
40
 
40
41
  export interface LeagueConfig {
41
42
  /** CLI namespace and favorites key, e.g. "nba" (`sportsing nba …`, `nba:UTAH`). */
@@ -552,7 +553,7 @@ function pickOrReport(key: string, sport: string): { key: string; label: string;
552
553
  }
553
554
 
554
555
  /**
555
- * `watch [team] [--wait] [--overlay] [--provider X] [--url L] [--size WxH] [--smoke]` —
556
+ * `watch [team] [--wait] [--overlay] [--provider X] [--url L] [--size WxH] [--smoke] [--supervised]` —
556
557
  * open the game's stream in that provider's persistent Chrome profile, so an
557
558
  * existing login is reused. No team = your favorites.
558
559
  *
@@ -566,7 +567,8 @@ function pickOrReport(key: string, sport: string): { key: string; label: string;
566
567
  *
567
568
  * `--wait` blocks until the next game is live, then opens it. `--smoke` opens
568
569
  * the window, confirms it came up over CDP, and tears it down (bounded; for
569
- * scripts and agents).
570
+ * scripts and agents). `--supervised` lets it run with no TTY on purpose (the
571
+ * daemon opens a game this way; the window's close ends it).
570
572
  * `--overlay` adds the live-stats overlay, which follows the game you open.
571
573
  */
572
574
  async function watch(cfg: LeagueConfig, args: string[]): Promise<void> {
@@ -609,8 +611,10 @@ async function watch(cfg: LeagueConfig, args: string[]): Promise<void> {
609
611
  const target = url ?? pick.hub;
610
612
  return overlay ? smokeLeagueOverlay(cfg, target, pick.label, windowSize) : smokeWatch(target, pick.label, windowSize);
611
613
  }
612
- // watch blocks until the window is closed; with no TTY nothing ever closes it.
613
- if (process.stdin.isTTY !== true) {
614
+ // watch blocks until the window is closed; with no TTY nothing ever closes it
615
+ // — unless `--supervised`: the daemon deliberately runs it detached, with no
616
+ // TTY, to open a game window that the user closes.
617
+ if (process.stdin.isTTY !== true && !args.includes("--supervised")) {
614
618
  console.error(c.yellow("`watch` is interactive — it opens a stream window and blocks until you close it."));
615
619
  console.error(c.dim(`Run it in a terminal, or use \`sportsing ${cfg.sport} watch --smoke\` to just confirm the window opens.`));
616
620
  process.exitCode = 1;
@@ -619,7 +623,7 @@ async function watch(cfg: LeagueConfig, args: string[]): Promise<void> {
619
623
 
620
624
  if (scope === undefined) scope = await watchScope(cfg, args);
621
625
  if (!scope) {
622
- console.error(c.red(`Usage: sportsing ${cfg.sport} watch <team> [--wait] [--overlay] [--provider ${pick.key}] [--url <link>] [--smoke]`));
626
+ console.error(c.red(`Usage: sportsing ${cfg.sport} watch <team> [--wait] [--overlay] [--provider ${pick.key}] [--url <link>] [--smoke] [--supervised]`));
623
627
  console.error(c.dim(`Or add a favorite (sportsing ${cfg.sport} fav add ${EXAMPLE_TEAM[cfg.sport] ?? "<team>"}) and omit the team.`));
624
628
  process.exitCode = 1;
625
629
  return;
@@ -673,6 +677,30 @@ export async function leagueAlerter(cfg: LeagueConfig): Promise<Alerter | null>
673
677
  return { label: cfg.label, teams: alerts.teams, poll: async () => alerts.feed(await liveGames(cfg)) };
674
678
  }
675
679
 
680
+ /**
681
+ * The league's favorite games for `sportsing daemon`: live and nearby ones
682
+ * from the scoreboards (local yesterday → tomorrow), each routed the way
683
+ * `watch` would. When none of those is upcoming, the favorites' next scheduled
684
+ * game is added from their season schedules (5-min cache) so status can say
685
+ * what it's waiting for. Null with no (resolvable) favorites.
686
+ */
687
+ export async function leagueDaemonGames(cfg: LeagueConfig): Promise<DaemonGame[] | null> {
688
+ const favs = await getFavorites(cfg.sport);
689
+ if (favs.length === 0) return null;
690
+ const ids = favoriteIds(cfg, await getTeams(cfg.league), favs);
691
+ if (ids.size === 0) return null;
692
+ const [all, watch, fallback] = await Promise.all([liveGames(cfg), watchContext(cfg), watchFallback(cfg)]);
693
+ const nearby = all.filter((g) => gameHasTeam(g, ids));
694
+ const now = Date.now();
695
+ let later: Game | null = null;
696
+ if (!nearby.some((g) => g.state === "pre" && Date.parse(g.date) >= now)) {
697
+ later = firstUpcoming(await scopedSeasons(cfg, ids), now);
698
+ if (later && nearby.some((g) => g.id === later!.id)) later = null;
699
+ }
700
+ const games = later ? [...nearby, later] : nearby;
701
+ return games.map((g) => leagueDaemonGame(cfg.sport, cfg.icon, g, ids, leagueRoute(g, watch, fallback)));
702
+ }
703
+
676
704
  /** Full-screen live board: in play, later today, finished today. */
677
705
  function renderLiveBoard(cfg: LeagueConfig, games: Game[]): void {
678
706
  const { live, upcoming, done } = liveBoard(games, ymd(new Date()));