From 3c0c9026eae887b0c93d0e1fa33daefbef2baa1b Mon Sep 17 00:00:00 2001 From: setkyar Date: Thu, 17 Sep 2026 03:08:00 +0700 Subject: [PATCH 1/2] feat(skills): add schedule, notes, and settings skills Sessions can manage pi-web schedules, the project scratchpad, and settings in natural language via /skill:pi-web-* and pi-web-ctl, calling the existing HTTP APIs instead of writing SQLite. --- .pi/skills/common/pi_web.py | 669 ++++++++++++++++++ .pi/skills/common/test_pi_web.py | 358 ++++++++++ .pi/skills/pi-web-notes/SKILL.md | 24 + .pi/skills/pi-web-schedule/SKILL.md | 32 + .pi/skills/pi-web-settings/SKILL.md | 22 + Makefile | 9 +- docs/architecture/data-flow.md | 7 +- docs/sequence-flows/README.md | 1 + docs/sequence-flows/schedules.md | 5 +- docs/sequence-flows/skills.md | 44 ++ install.ps1 | 27 + install.sh | 23 + internal/rpc/oneshot.go | 5 + internal/rpc/oneshot_test.go | 16 + internal/rpc/worker.go | 43 ++ internal/rpc/worker_test.go | 23 + internal/server/scheduler_test.go | 61 ++ internal/server/schedules_api.go | 23 +- internal/server/scratchpad.go | 45 +- internal/server/scratchpad_test.go | 62 ++ internal/server/settings.go | 62 +- internal/server/settings_test.go | 27 + uninstall.ps1 | 8 + uninstall.sh | 5 + user-docs/en/README.md | 2 + user-docs/en/install.md | 1 + user-docs/en/personal-assistant.md | 18 + .../components/session/RightSidebar.svelte | 11 + .../session/right-sidebar-scratchpad.js | 16 + .../session/right-sidebar-scratchpad.test.js | 18 + web/src/index/schedules-events.js | 57 ++ web/src/index/schedules-events.test.js | 45 ++ web/src/index/scratchpad-events.js | 57 ++ web/src/index/scratchpad-events.test.js | 36 + web/src/index/settings-events.js | 57 ++ web/src/index/settings-events.test.js | 36 + web/src/routes/SchedulesPage.svelte | 14 +- web/src/routes/SessionsPage.svelte | 15 + web/src/routes/SettingsPage.svelte | 16 + web/src/session/page/session-page-runtime.js | 18 + web/src/shared/settings-live.js | 58 ++ web/src/shared/settings-live.test.js | 63 ++ web/src/shared/settings-store.js | 25 +- web/src/shared/settings-store.test.js | 13 + 44 files changed, 2123 insertions(+), 54 deletions(-) create mode 100755 .pi/skills/common/pi_web.py create mode 100644 .pi/skills/common/test_pi_web.py create mode 100644 .pi/skills/pi-web-notes/SKILL.md create mode 100644 .pi/skills/pi-web-schedule/SKILL.md create mode 100644 .pi/skills/pi-web-settings/SKILL.md create mode 100644 docs/sequence-flows/skills.md create mode 100644 internal/rpc/oneshot_test.go create mode 100644 web/src/index/schedules-events.js create mode 100644 web/src/index/schedules-events.test.js create mode 100644 web/src/index/scratchpad-events.js create mode 100644 web/src/index/scratchpad-events.test.js create mode 100644 web/src/index/settings-events.js create mode 100644 web/src/index/settings-events.test.js create mode 100644 web/src/shared/settings-live.js create mode 100644 web/src/shared/settings-live.test.js diff --git a/.pi/skills/common/pi_web.py b/.pi/skills/common/pi_web.py new file mode 100755 index 00000000..12da9fed --- /dev/null +++ b/.pi/skills/common/pi_web.py @@ -0,0 +1,669 @@ +#!/usr/bin/env python3 +"""pi-web-ctl: call the local pi-web HTTP API from pi skills. + +Talks to 127.0.0.1 using the port in pi-web-state.json. Auth uses X-Pi-Token +(never a ?token= query — that 302s and skips the API handler). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +DEFAULT_PORT = "31415" + +TZ_ALIASES = { + "sg": "Asia/Singapore", + "sgt": "Asia/Singapore", + "singapore": "Asia/Singapore", + "jst": "Asia/Tokyo", + "tokyo": "Asia/Tokyo", + "jp": "Asia/Tokyo", + "utc": "UTC", + "gmt": "UTC", + "pt": "America/Los_Angeles", + "pst": "America/Los_Angeles", + "pdt": "America/Los_Angeles", + "et": "America/New_York", + "est": "America/New_York", + "edt": "America/New_York", + "ct": "America/Chicago", + "cst": "America/Chicago", + "cdt": "America/Chicago", + "london": "Europe/London", + "uk": "Europe/London", + "bst": "Europe/London", +} + +WEEKDAYS = { + "sun": 0, + "sunday": 0, + "mon": 1, + "monday": 1, + "tue": 2, + "tues": 2, + "tuesday": 2, + "wed": 3, + "wednesday": 3, + "thu": 4, + "thur": 4, + "thurs": 4, + "thursday": 4, + "fri": 5, + "friday": 5, + "sat": 6, + "saturday": 6, +} + + +SETTING_ALIASES = { + "theme": "pi-web-theme", + "language": "pi-web:v1:locale", + "locale": "pi-web:v1:locale", + "font-ui": "pi-web:v1:font-ui", + "font-content": "pi-web:v1:font-content", + "font-code": "pi-web:v1:font-code", + "font-ui-size": "pi-web:v1:font-ui-size", + "font-content-size": "pi-web:v1:font-content-size", + "spinner": "pi-sessions:spinner-style", + "notify-on-done": "pi-share:v1:notify-on-done", + "done-sound": "pi-share:v1:done-sound", + "layout": "pi-sessions:view-layout", + "show-btw": "pi-web:v1:show-btw-in-index", + "cat": "pi-web:v1:cat:enabled", + "cat-focus": "pi-web:v1:cat:focus-min", + "cat-break": "pi-web:v1:cat:break-min", + "bedtime": "pi-web:v1:cat:bedtime", + "wakeup": "pi-web:v1:cat:wakeup", + "sleep-min": "pi-web:v1:cat:sleep-min", + "auto-title": "pi-web:v1:auto-title:enabled", + "auto-title-mode": "pi-web:v1:auto-title:mode", + "auto-title-model": "pi-web:v1:auto-title:model", + "artifacts": "pi-web:v1:artifacts:enabled", + "artifacts-include": "pi-web:v1:artifacts:include", + "thinking": "pi-web:v1:toggle:thinking", + "tools": "pi-web:v1:toggle:tools", + "tool-outputs": "pi-web:v1:toggle:tool-outputs", +} + +SETTING_KEYS = set(SETTING_ALIASES.values()) +BOOL_SETTING_KEYS = { + "pi-share:v1:notify-on-done", + "pi-web:v1:show-btw-in-index", + "pi-web:v1:cat:enabled", + "pi-web:v1:auto-title:enabled", + "pi-web:v1:artifacts:enabled", + "pi-web:v1:toggle:thinking", + "pi-web:v1:toggle:tools", + "pi-web:v1:toggle:tool-outputs", +} + + +class CtlError(Exception): + """User-facing CLI error; main() prints the message and exits 1.""" + + +def agent_dir(env=None, homedir=None): + env = os.environ if env is None else env + if env.get("PI_CODING_AGENT_DIR"): + return Path(env["PI_CODING_AGENT_DIR"]).expanduser() + home = Path.home() if homedir is None else Path(homedir) + return home / ".pi" / "agent" + + +def state_path(env=None, homedir=None): + return agent_dir(env=env, homedir=homedir) / "pi-web" / "pi-web-state.json" + + +def read_state(env=None, homedir=None): + path = state_path(env=env, homedir=homedir) + try: + return json.loads(path.read_text()) + except FileNotFoundError: + return None + except (OSError, json.JSONDecodeError) as err: + raise CtlError(f"could not read {path}: {err}") from err + + +def discover_base_url(env=None, homedir=None, state=None): + """Always loopback. State host is ignored so we never send the token off-box.""" + if state is None: + state = read_state(env=env, homedir=homedir) + port = DEFAULT_PORT + if isinstance(state, dict) and str(state.get("port") or "").strip(): + port = str(state["port"]).strip() + return f"http://127.0.0.1:{port}" + + +def read_token(env=None, homedir=None): + env = os.environ if env is None else env + from_env = (env.get("PI_WEB_TOKEN") or "").strip() + if from_env: + return from_env + home = Path.home() if homedir is None else Path(homedir) + path = home / ".config" / "pi-web" / "env" + try: + raw = path.read_text() + except OSError: + return None + for line in raw.splitlines(): + if line.startswith("PI_WEB_TOKEN="): + value = line.split("=", 1)[1].strip() + return value or None + return None + + +def request_headers(token): + headers = {"Accept": "application/json"} + if token: + headers["X-Pi-Token"] = token + return headers + + +def clamp_int(value, lo, hi, fallback): + try: + n = int(value) + except (TypeError, ValueError): + return fallback + return min(hi, max(lo, n)) + + +def parse_weekday(value): + if value is None or value == "": + return 1 + key = str(value).strip().lower() + if key in WEEKDAYS: + return WEEKDAYS[key] + try: + n = int(key) + except ValueError as err: + raise CtlError(f"unknown weekday {value!r}; use sun-sat or 0-6") from err + if n < 0 or n > 6: + raise CtlError(f"weekday must be 0-6, got {value!r}") + return n + + +def build_cron(frequency, hour=9, minute=0, weekday=1, every_hours=None): + """Match web/src/index/schedules.js buildCron, plus --every-hours.""" + m = clamp_int(minute, 0, 59, 0) + h = clamp_int(hour, 0, 23, 9) + d = clamp_int(weekday, 0, 6, 1) + if frequency in (None, "", "manual"): + return "" + if frequency == "hourly": + return f"{m} * * * *" + if frequency == "daily": + return f"{m} {h} * * *" + if frequency == "weekdays": + return f"{m} {h} * * 1-5" + if frequency == "weekly": + return f"{m} {h} * * {d}" + if frequency == "every-hours": + n = clamp_int(every_hours, 1, 23, 1) + return f"{m} */{n} * * *" + raise CtlError(f"unknown frequency {frequency!r}") + + +def resolve_timezone(value): + raw = (value or "").strip() + if not raw: + return "" + compact = raw.lower().replace(" ", "") + name = TZ_ALIASES.get(compact) or TZ_ALIASES.get(raw.lower()) or raw + try: + ZoneInfo(name) + except ZoneInfoNotFoundError as err: + aliases = ", ".join(sorted(set(TZ_ALIASES))) + raise CtlError( + f"unknown timezone {value!r}; use an IANA name or one of: {aliases}" + ) from err + except Exception as err: + # zoneinfo can raise other errors on some platforms for junk input. + raise CtlError(f"unknown timezone {value!r}: {err}") from err + return name + + +def split_model(value): + raw = (value or "").strip() + if not raw: + return "", "" + if "/" not in raw: + raise CtlError("--model must be provider/id (example: anthropic/claude-sonnet-4-5)") + provider, model_id = raw.split("/", 1) + provider, model_id = provider.strip(), model_id.strip() + if not provider or not model_id: + raise CtlError("--model must be provider/id") + return provider, model_id + + +def default_name(instructions): + line = (instructions or "").strip().splitlines()[0].strip() if instructions else "" + if not line: + return "Scheduled task" + if len(line) > 60: + return line[:57].rstrip() + "..." + return line + + +def _url_has_token_query(url): + return "token=" in url.split("?", 1)[-1].lower() if "?" in url else False + + +class Client: + def __init__(self, env=None, homedir=None, urlopen=urllib.request.urlopen, timeout=10): + self.env = os.environ if env is None else env + self.homedir = homedir + self.urlopen = urlopen + self.timeout = timeout + self.base_url = discover_base_url(env=self.env, homedir=self.homedir) + self.token = read_token(env=self.env, homedir=self.homedir) + + def health_ok(self): + url = self.base_url + "/" + req = urllib.request.Request(url, method="GET") + try: + with self.urlopen(req, timeout=1) as resp: + return getattr(resp, "status", 200) in (200, 401, 403) + except urllib.error.HTTPError as err: + return err.code in (401, 403) + except OSError: + return False + + def ensure_running(self): + if self.health_ok(): + return + raise CtlError( + f"pi-web is not running (tried {self.base_url}). Start it with /pi-web start." + ) + + def request(self, method, path, body=None): + url = self.base_url + path + if _url_has_token_query(url): + raise CtlError("refusing to put the token in the query string") + data = None + headers = request_headers(self.token) + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with self.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read() + except urllib.error.HTTPError as err: + raw = err.read() + message = _api_error_message(raw, err) + raise CtlError(message) from err + except OSError as err: + raise CtlError(f"request failed: {err}") from err + if not raw: + return {} + try: + return json.loads(raw.decode()) + except json.JSONDecodeError as err: + raise CtlError(f"pi-web returned non-JSON: {raw[:200]!r}") from err + + +def _api_error_message(raw, err): + text = raw.decode(errors="replace") if raw else "" + try: + payload = json.loads(text) + if isinstance(payload, dict) and payload.get("error"): + return str(payload["error"]) + except json.JSONDecodeError: + pass + if text.strip(): + return f"HTTP {err.code}: {text.strip()[:300]}" + return f"HTTP {err.code}" + + +def emit(payload): + json.dump(payload, sys.stdout, indent=2) + sys.stdout.write("\n") + + +def _resolve_schedule(client, token): + token = (token or "").strip() + if not token: + raise CtlError("schedule id or name is required") + data = client.request("GET", "/api/schedules") + items = data.get("schedules") or [] + for sc in items: + if sc.get("id") == token: + return sc + matches = [sc for sc in items if (sc.get("name") or "").lower() == token.lower()] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + ids = ", ".join(sc.get("id", "") for sc in matches) + raise CtlError(f"multiple schedules named {token!r}; pass an id: {ids}") + raise CtlError(f"schedule not found: {token}") + + +def _schedule_input(sc, **overrides): + body = { + "name": sc.get("name") or "", + "instructions": sc.get("instructions") or "", + "modelProvider": sc.get("modelProvider") or "", + "modelId": sc.get("modelId") or "", + "thinkingLevel": sc.get("thinkingLevel") or "", + "projectPath": sc.get("projectPath") or "", + "cronExpr": sc.get("cronExpr") or "", + "timezone": sc.get("timezone") or "", + "enabled": sc.get("enabled", True), + } + body.update(overrides) + return body + + +def cmd_schedule_list(client, _args): + emit(client.request("GET", "/api/schedules")) + + +def cmd_schedule_get(client, args): + sc = _resolve_schedule(client, args.target) + emit({"schedule": sc}) + + +def cmd_schedule_create(client, args): + instructions = args.instructions + if not (instructions or "").strip(): + raise CtlError("--instructions is required") + cron_expr = args.cron + if cron_expr is None: + frequency = args.frequency + if args.every_hours is not None: + frequency = "every-hours" + elif frequency is None: + frequency = "manual" + cron_expr = build_cron( + frequency, + hour=args.hour, + minute=args.minute, + weekday=parse_weekday(args.weekday), + every_hours=args.every_hours, + ) + timezone = resolve_timezone(args.timezone) + provider, model_id = split_model(args.model) + project = args.project if args.project is not None else os.getcwd() + name = (args.name or "").strip() or default_name(instructions) + body = { + "name": name, + "instructions": instructions, + "modelProvider": provider, + "modelId": model_id, + "thinkingLevel": (args.thinking or "").strip(), + "projectPath": project, + "cronExpr": cron_expr, + "timezone": timezone, + "enabled": not args.paused, + } + created = client.request("POST", "/api/schedules", body) + emit(created) + + +def cmd_schedule_update(client, args): + sc = _resolve_schedule(client, args.target) + overrides = {} + if args.name: + overrides["name"] = args.name + if args.instructions: + overrides["instructions"] = args.instructions + if args.project is not None: + overrides["projectPath"] = args.project + if args.timezone is not None: + overrides["timezone"] = resolve_timezone(args.timezone) + if args.thinking is not None: + overrides["thinkingLevel"] = args.thinking + if args.model is not None: + provider, model_id = split_model(args.model) + overrides["modelProvider"] = provider + overrides["modelId"] = model_id + if args.cron is not None: + overrides["cronExpr"] = args.cron + elif args.frequency or args.every_hours is not None: + frequency = "every-hours" if args.every_hours is not None else args.frequency + overrides["cronExpr"] = build_cron( + frequency, + hour=args.hour, + minute=args.minute, + weekday=parse_weekday(args.weekday), + every_hours=args.every_hours, + ) + if args.paused: + overrides["enabled"] = False + body = _schedule_input(sc, **overrides) + emit(client.request("POST", f"/api/schedule?id={sc['id']}", body)) + + +def cmd_schedule_delete(client, args): + sc = _resolve_schedule(client, args.target) + emit(client.request("DELETE", f"/api/schedule?id={sc['id']}")) + + +def cmd_schedule_enable(client, args): + sc = _resolve_schedule(client, args.target) + body = _schedule_input(sc, enabled=True) + emit(client.request("POST", f"/api/schedule?id={sc['id']}", body)) + + +def cmd_schedule_disable(client, args): + sc = _resolve_schedule(client, args.target) + body = _schedule_input(sc, enabled=False) + emit(client.request("POST", f"/api/schedule?id={sc['id']}", body)) + + +def cmd_schedule_run(client, args): + sc = _resolve_schedule(client, args.target) + emit(client.request("POST", f"/api/schedule/run?id={sc['id']}", {})) + + +def cmd_schedule_runs(client, args): + sc = _resolve_schedule(client, args.target) + emit(client.request("GET", f"/api/schedule/runs?id={sc['id']}")) + + +def _project_path(args): + if args.project is not None: + return args.project + return os.getcwd() + + +def notes_append_chunk(existing, text): + if (existing or "").strip() and text: + return "\n\n" + text + return text + + +def cmd_notes_read(client, args): + project = _project_path(args) + emit(client.request("GET", "/api/scratchpad?project=" + urllib.parse.quote(project))) + + +def cmd_notes_append(client, args): + project = _project_path(args) + text = args.text if args.text is not None else "" + existing = "" + try: + existing = (client.request("GET", "/api/scratchpad?project=" + urllib.parse.quote(project)).get("content") or "") + except CtlError: + pass + chunk = notes_append_chunk(existing, text) + emit( + client.request( + "POST", + "/api/scratchpad", + {"project": project, "content": chunk, "mode": "append"}, + ) + ) + + +def cmd_notes_replace(client, args): + project = _project_path(args) + emit( + client.request( + "POST", + "/api/scratchpad", + {"project": project, "content": args.text if args.text is not None else "", "mode": "replace"}, + ) + ) + + +def resolve_setting_key(alias): + raw = (alias or "").strip() + if not raw: + raise CtlError("setting name is required") + if raw in SETTING_ALIASES: + return SETTING_ALIASES[raw] + if raw in SETTING_KEYS: + return raw + aliases = ", ".join(sorted(SETTING_ALIASES)) + raise CtlError(f"unknown setting {alias!r}; known aliases: {aliases}") + + +def coerce_setting_value(key, value): + text = "" if value is None else str(value) + if key not in BOOL_SETTING_KEYS: + return text + lowered = text.strip().lower() + if lowered in ("1", "true", "on", "yes"): + return "true" + if lowered in ("0", "false", "off", "no"): + return "false" + raise CtlError(f"{key} expects on/off (got {value!r})") + + +def cmd_settings_get(client, args): + data = client.request("GET", "/api/settings") + settings = data.get("settings") or {} + if args.key: + key = resolve_setting_key(args.key) + emit({"key": key, "value": settings.get(key, "")}) + return + emit({"settings": settings, "aliases": SETTING_ALIASES}) + + +def cmd_settings_set(client, args): + key = resolve_setting_key(args.key) + value = coerce_setting_value(key, args.value) + emit(client.request("POST", "/api/settings", {"settings": {key: value}})) + + +def _add_schedule_write_flags(parser, *, for_update=False): + parser.add_argument("--name") + inst = parser.add_argument("--instructions") + if not for_update: + inst.required = True + parser.add_argument("--project", help="Project path (default: current directory)") + parser.add_argument("--timezone", help="IANA name or alias (sg, jst, utc, pt, et, …)") + parser.add_argument("--model", help="provider/id") + parser.add_argument("--thinking") + parser.add_argument("--hour", type=int, default=9) + parser.add_argument("--minute", type=int, default=0) + parser.add_argument("--weekday", default="mon", help="sun-sat or 0-6 (weekly)") + parser.add_argument("--paused", action="store_true", help="Create disabled / pause on update") + freq = parser.add_mutually_exclusive_group() + freq.add_argument("--daily", action="store_const", const="daily", dest="frequency") + freq.add_argument("--hourly", action="store_const", const="hourly", dest="frequency") + freq.add_argument("--weekdays", action="store_const", const="weekdays", dest="frequency") + freq.add_argument("--weekly", action="store_const", const="weekly", dest="frequency") + freq.add_argument("--manual", action="store_const", const="manual", dest="frequency") + freq.add_argument("--cron", help="Raw 5-field cron expression") + freq.add_argument("--every-hours", type=int, dest="every_hours") + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="pi-web-ctl", + description="Call the local pi-web HTTP API (schedules, notes, settings).", + ) + sub = parser.add_subparsers(dest="group", required=True) + + sched = sub.add_parser("schedule", help="Create and manage pi-web schedules") + sched_sub = sched.add_subparsers(dest="action", required=True) + + p = sched_sub.add_parser("list") + p.set_defaults(func=cmd_schedule_list) + + p = sched_sub.add_parser("get") + p.add_argument("target", help="Schedule id or exact name") + p.set_defaults(func=cmd_schedule_get) + + p = sched_sub.add_parser("create") + _add_schedule_write_flags(p) + p.set_defaults(func=cmd_schedule_create) + + p = sched_sub.add_parser("update") + p.add_argument("target", help="Schedule id or exact name") + _add_schedule_write_flags(p, for_update=True) + p.set_defaults(func=cmd_schedule_update) + + p = sched_sub.add_parser("delete") + p.add_argument("target") + p.set_defaults(func=cmd_schedule_delete) + + p = sched_sub.add_parser("enable") + p.add_argument("target") + p.set_defaults(func=cmd_schedule_enable) + + p = sched_sub.add_parser("disable") + p.add_argument("target") + p.set_defaults(func=cmd_schedule_disable) + + p = sched_sub.add_parser("run") + p.add_argument("target") + p.set_defaults(func=cmd_schedule_run) + + p = sched_sub.add_parser("runs") + p.add_argument("target") + p.set_defaults(func=cmd_schedule_runs) + + notes = sub.add_parser("notes", help="Read or write the per-project scratchpad") + notes_sub = notes.add_subparsers(dest="action", required=True) + p = notes_sub.add_parser("read") + p.add_argument("--project") + p.set_defaults(func=cmd_notes_read) + p = notes_sub.add_parser("append") + p.add_argument("--text", required=True) + p.add_argument("--project") + p.set_defaults(func=cmd_notes_append) + p = notes_sub.add_parser("replace") + p.add_argument("--text", required=True) + p.add_argument("--project") + p.set_defaults(func=cmd_notes_replace) + + settings = sub.add_parser("settings", help="Read or change pi-web settings") + settings_sub = settings.add_subparsers(dest="action", required=True) + p = settings_sub.add_parser("get") + p.add_argument("key", nargs="?", help="Alias or storage key") + p.set_defaults(func=cmd_settings_get) + p = settings_sub.add_parser("set") + p.add_argument("key") + p.add_argument("value") + p.set_defaults(func=cmd_settings_set) + + return parser + + +def main(argv=None, client=None): + parser = build_parser() + args = parser.parse_args(argv) + try: + if client is None: + client = Client() + client.ensure_running() + args.func(client, args) + except CtlError as err: + print(err, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.pi/skills/common/test_pi_web.py b/.pi/skills/common/test_pi_web.py new file mode 100644 index 00000000..801728bd --- /dev/null +++ b/.pi/skills/common/test_pi_web.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Unit tests for pi_web.py. Uses a fake urlopen — does not start pi-web.""" + +import io +import json +import sys +import tempfile +import unittest +import urllib.error +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import pi_web + + +class FakeResponse: + def __init__(self, body, status=200): + if isinstance(body, str): + body = body.encode() + self._body = body + self.status = status + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class FakeHTTPError(urllib.error.HTTPError): + def __init__(self, code, body): + super().__init__("http://127.0.0.1/x", code, "err", hdrs=None, fp=io.BytesIO(body.encode())) + + +class TestDiscover(unittest.TestCase): + def test_loopback_uses_state_port(self): + url = pi_web.discover_base_url(state={"port": "31416", "host": "100.64.0.1"}) + self.assertEqual(url, "http://127.0.0.1:31416") + + def test_missing_state_defaults_port(self): + with tempfile.TemporaryDirectory() as tmp: + env = {"PI_CODING_AGENT_DIR": tmp} + url = pi_web.discover_base_url(env=env, homedir=tmp) + self.assertEqual(url, "http://127.0.0.1:31415") + + def test_reads_state_file(self): + with tempfile.TemporaryDirectory() as tmp: + env = {"PI_CODING_AGENT_DIR": tmp} + path = Path(tmp) / "pi-web" / "pi-web-state.json" + path.parent.mkdir() + path.write_text(json.dumps({"port": "9999", "host": "127.0.0.1"})) + url = pi_web.discover_base_url(env=env) + self.assertEqual(url, "http://127.0.0.1:9999") + + def test_ignores_dev_state_filename(self): + with tempfile.TemporaryDirectory() as tmp: + env = {"PI_CODING_AGENT_DIR": tmp} + web = Path(tmp) / "pi-web" + web.mkdir() + (web / "pi-web-state-dev.json").write_text(json.dumps({"port": "31416"})) + (web / "pi-web-state.json").write_text(json.dumps({"port": "31415"})) + url = pi_web.discover_base_url(env=env) + self.assertEqual(url, "http://127.0.0.1:31415") + + +class TestToken(unittest.TestCase): + def test_env_wins(self): + with tempfile.TemporaryDirectory() as tmp: + env_file = Path(tmp) / ".config" / "pi-web" + env_file.mkdir(parents=True) + (env_file / "env").write_text("PI_WEB_TOKEN=fromfile\n") + token = pi_web.read_token(env={"PI_WEB_TOKEN": "fromenv"}, homedir=tmp) + self.assertEqual(token, "fromenv") + + def test_reads_env_file(self): + with tempfile.TemporaryDirectory() as tmp: + env_file = Path(tmp) / ".config" / "pi-web" + env_file.mkdir(parents=True) + (env_file / "env").write_text("PATH=/bin\nPI_WEB_TOKEN=secret\n") + token = pi_web.read_token(env={}, homedir=tmp) + self.assertEqual(token, "secret") + + def test_headers_use_x_pi_token_not_query(self): + headers = pi_web.request_headers("secret") + self.assertEqual(headers["X-Pi-Token"], "secret") + self.assertNotIn("token", headers.get("Accept", "").lower()) + + +class TestTimezoneAndCron(unittest.TestCase): + def test_sg_alias(self): + self.assertEqual(pi_web.resolve_timezone("sg"), "Asia/Singapore") + self.assertEqual(pi_web.resolve_timezone("SGT"), "Asia/Singapore") + self.assertEqual(pi_web.resolve_timezone("singapore"), "Asia/Singapore") + + def test_iana_passthrough(self): + self.assertEqual(pi_web.resolve_timezone("Asia/Singapore"), "Asia/Singapore") + + def test_empty_timezone(self): + self.assertEqual(pi_web.resolve_timezone(""), "") + self.assertEqual(pi_web.resolve_timezone(None), "") + + def test_invalid_timezone(self): + with self.assertRaises(pi_web.CtlError) as ctx: + pi_web.resolve_timezone("not-a-zone") + self.assertIn("unknown timezone", str(ctx.exception)) + self.assertIn("sg", str(ctx.exception)) + + def test_build_cron_matches_js(self): + self.assertEqual(pi_web.build_cron("manual"), "") + self.assertEqual(pi_web.build_cron("hourly", minute=30), "30 * * * *") + self.assertEqual(pi_web.build_cron("daily", minute=5, hour=9), "5 9 * * *") + self.assertEqual(pi_web.build_cron("weekdays", minute=0, hour=8), "0 8 * * 1-5") + self.assertEqual(pi_web.build_cron("weekly", minute=0, hour=17, weekday=5), "0 17 * * 5") + self.assertEqual(pi_web.build_cron("every-hours", every_hours=2), "0 */2 * * *") + + def test_build_cron_clamps(self): + self.assertEqual(pi_web.build_cron("daily", minute=99, hour=40), "59 23 * * *") + + def test_weekday_names(self): + self.assertEqual(pi_web.parse_weekday("mon"), 1) + self.assertEqual(pi_web.parse_weekday("Sunday"), 0) + self.assertEqual(pi_web.parse_weekday("5"), 5) + + def test_default_name(self): + self.assertEqual(pi_web.default_name("Summarize inbox"), "Summarize inbox") + self.assertEqual(pi_web.default_name(""), "Scheduled task") + long_name = "x" * 80 + self.assertEqual(len(pi_web.default_name(long_name)), 60) + + +class TestClientRequest(unittest.TestCase): + def test_sends_token_header_not_query(self): + captured = [] + + def fake_urlopen(req, timeout=None): + captured.append(req) + return FakeResponse(json.dumps({"schedules": []})) + + with tempfile.TemporaryDirectory() as tmp: + env = {"PI_CODING_AGENT_DIR": tmp, "PI_WEB_TOKEN": "s3cret"} + client = pi_web.Client(env=env, homedir=tmp, urlopen=fake_urlopen) + client.request("GET", "/api/schedules") + + req = captured[0] + self.assertEqual(req.full_url, "http://127.0.0.1:31415/api/schedules") + self.assertNotIn("token=", req.full_url) + sent = {k.lower(): v for k, v in req.header_items()} + self.assertEqual(sent.get("x-pi-token"), "s3cret") + + def test_health_failure_message(self): + def fake_urlopen(req, timeout=None): + raise OSError("connection refused") + + with tempfile.TemporaryDirectory() as tmp: + env = {"PI_CODING_AGENT_DIR": tmp} + client = pi_web.Client(env=env, homedir=tmp, urlopen=fake_urlopen) + with self.assertRaises(pi_web.CtlError) as ctx: + client.ensure_running() + self.assertIn("not running", str(ctx.exception)) + self.assertIn("/pi-web start", str(ctx.exception)) + + def test_api_error_uses_json_error_field(self): + def fake_urlopen(req, timeout=None): + raise FakeHTTPError(400, json.dumps({"error": "name is required"})) + + with tempfile.TemporaryDirectory() as tmp: + env = {"PI_CODING_AGENT_DIR": tmp} + client = pi_web.Client(env=env, homedir=tmp, urlopen=fake_urlopen) + with self.assertRaises(pi_web.CtlError) as ctx: + client.request("POST", "/api/schedules", {"name": ""}) + self.assertEqual(str(ctx.exception), "name is required") + + +class TestScheduleCommands(unittest.TestCase): + def setUp(self): + self.calls = [] + self.responses = {} + + def fake_urlopen(req, timeout=None): + self.calls.append((req.get_method(), req.full_url, req.data)) + key = (req.get_method(), req.full_url.split("?", 1)[0]) + body = self.responses.get(key) or self.responses.get(req.get_method()) + if body is None: + body = {"ok": True} + return FakeResponse(json.dumps(body)) + + self.tmp = tempfile.TemporaryDirectory() + env = {"PI_CODING_AGENT_DIR": self.tmp.name} + self.client = pi_web.Client(env=env, homedir=self.tmp.name, urlopen=fake_urlopen) + + def tearDown(self): + self.tmp.cleanup() + + def test_create_daily_sg(self): + self.responses["POST"] = { + "schedule": { + "id": "abc", + "name": "Inbox", + "cronExpr": "0 2 * * *", + "timezone": "Asia/Singapore", + "nextRunAt": "2026-09-18T18:00:00Z", + } + } + buf = io.StringIO() + args = pi_web.build_parser().parse_args( + [ + "schedule", + "create", + "--name", + "Inbox", + "--instructions", + "Summarize inbox", + "--daily", + "--hour", + "2", + "--minute", + "0", + "--timezone", + "sg", + "--project", + "/tmp/assistant", + ] + ) + with patch("sys.stdout", buf): + args.func(self.client, args) + method, url, data = self.calls[0] + self.assertEqual(method, "POST") + self.assertTrue(url.endswith("/api/schedules")) + payload = json.loads(data.decode()) + self.assertEqual(payload["cronExpr"], "0 2 * * *") + self.assertEqual(payload["timezone"], "Asia/Singapore") + self.assertEqual(payload["projectPath"], "/tmp/assistant") + self.assertEqual(payload["name"], "Inbox") + self.assertIn("abc", buf.getvalue()) + + def test_create_defaults_name_from_instructions(self): + self.responses["POST"] = {"schedule": {"id": "x", "name": "Do the thing"}} + args = pi_web.build_parser().parse_args( + ["schedule", "create", "--instructions", "Do the thing", "--manual", "--project", "/p"] + ) + with patch("sys.stdout", io.StringIO()): + args.func(self.client, args) + payload = json.loads(self.calls[0][2].decode()) + self.assertEqual(payload["name"], "Do the thing") + self.assertEqual(payload["cronExpr"], "") + + def test_delete_matches_by_name(self): + self.responses[("GET", "http://127.0.0.1:31415/api/schedules")] = { + "schedules": [{"id": "id-1", "name": "Inbox"}] + } + self.responses["DELETE"] = {"ok": True} + args = pi_web.build_parser().parse_args(["schedule", "delete", "Inbox"]) + with patch("sys.stdout", io.StringIO()): + args.func(self.client, args) + methods = [c[0] for c in self.calls] + self.assertIn("DELETE", methods) + delete = [c for c in self.calls if c[0] == "DELETE"][0] + self.assertIn("id=id-1", delete[1]) + + def test_ambiguous_name(self): + self.responses[("GET", "http://127.0.0.1:31415/api/schedules")] = { + "schedules": [ + {"id": "a", "name": "Inbox"}, + {"id": "b", "name": "Inbox"}, + ] + } + args = pi_web.build_parser().parse_args(["schedule", "get", "Inbox"]) + with self.assertRaises(pi_web.CtlError) as ctx: + args.func(self.client, args) + self.assertIn("multiple schedules", str(ctx.exception)) + + def test_split_model(self): + self.assertEqual(pi_web.split_model("anthropic/claude"), ("anthropic", "claude")) + with self.assertRaises(pi_web.CtlError): + pi_web.split_model("claude") + + +class TestNotesAndSettings(unittest.TestCase): + def test_notes_append_separator(self): + self.assertEqual(pi_web.notes_append_chunk("old", "new"), "\n\nnew") + self.assertEqual(pi_web.notes_append_chunk("", "new"), "new") + self.assertEqual(pi_web.notes_append_chunk(" ", "new"), "new") + + def test_setting_aliases(self): + self.assertEqual(pi_web.resolve_setting_key("theme"), "pi-web-theme") + self.assertEqual(pi_web.resolve_setting_key("pi-web-theme"), "pi-web-theme") + with self.assertRaises(pi_web.CtlError) as ctx: + pi_web.resolve_setting_key("nope") + self.assertIn("unknown setting", str(ctx.exception)) + + def test_bool_coerce(self): + self.assertEqual(pi_web.coerce_setting_value("pi-web:v1:auto-title:enabled", "off"), "false") + self.assertEqual(pi_web.coerce_setting_value("pi-web:v1:auto-title:enabled", "on"), "true") + self.assertEqual(pi_web.coerce_setting_value("pi-web-theme", "nord"), "nord") + + def test_notes_append_posts_mode(self): + calls = [] + + def fake_urlopen(req, timeout=None): + calls.append((req.get_method(), req.full_url, req.data)) + if req.get_method() == "GET": + return FakeResponse(json.dumps({"content": "existing"})) + return FakeResponse(json.dumps({"ok": True, "content": "existing\n\nmore"})) + + with tempfile.TemporaryDirectory() as tmp: + client = pi_web.Client( + env={"PI_CODING_AGENT_DIR": tmp}, + homedir=tmp, + urlopen=fake_urlopen, + ) + args = pi_web.build_parser().parse_args( + ["notes", "append", "--text", "more", "--project", "/p"] + ) + with patch("sys.stdout", io.StringIO()): + args.func(client, args) + post = [c for c in calls if c[0] == "POST"][0] + payload = json.loads(post[2].decode()) + self.assertEqual(payload["mode"], "append") + self.assertEqual(payload["content"], "\n\nmore") + self.assertEqual(payload["project"], "/p") + + def test_settings_set_uses_alias(self): + calls = [] + + def fake_urlopen(req, timeout=None): + calls.append((req.get_method(), req.full_url, req.data)) + return FakeResponse(json.dumps({"ok": True, "settings": {"pi-web-theme": "nord"}})) + + with tempfile.TemporaryDirectory() as tmp: + client = pi_web.Client( + env={"PI_CODING_AGENT_DIR": tmp}, + homedir=tmp, + urlopen=fake_urlopen, + ) + args = pi_web.build_parser().parse_args(["settings", "set", "theme", "nord"]) + with patch("sys.stdout", io.StringIO()): + args.func(client, args) + payload = json.loads(calls[0][2].decode()) + self.assertEqual(payload, {"settings": {"pi-web-theme": "nord"}}) + + +class TestMain(unittest.TestCase): + def test_ctl_error_exits_1(self): + class Boom: + def request(self, *args, **kwargs): + raise pi_web.CtlError("nope") + + with patch("sys.stderr", io.StringIO()): + code = pi_web.main(["schedule", "list"], client=Boom()) + self.assertEqual(code, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/.pi/skills/pi-web-notes/SKILL.md b/.pi/skills/pi-web-notes/SKILL.md new file mode 100644 index 00000000..ac2dfe3d --- /dev/null +++ b/.pi/skills/pi-web-notes/SKILL.md @@ -0,0 +1,24 @@ +--- +name: pi-web-notes +description: Read or write the pi-web per-project scratchpad (right-sidebar notes). Use when the user says notes, scratchpad, jot this down, write this here. Not for long-term memory ("remember that") and not for transcript annotations. Slash command /skill:pi-web-notes. +--- + +# pi-web notes (scratchpad) + +The right sidebar is **one markdown blob per project**. Read, append, or replace it with `pi-web-ctl`. Do not write SQLite yourself. + +```bash +pi-web-ctl notes read [--project PATH] +pi-web-ctl notes append --text TEXT [--project PATH] +pi-web-ctl notes replace --text TEXT [--project PATH] +``` + +`--project` defaults to the current working directory. + +If `pi-web-ctl` is not on `PATH`, run `python3 ~/.pi/agent/bin/pi-web-ctl`. + +## Rules + +- Default verb is **append**. Replace only when the user clearly wants a rewrite ("replace the notes", "clear the scratchpad"). +- Do not confuse with `/skill:memory` ("remember that") or session annotations (highlights on a transcript). +- After write, say that it landed in the project scratchpad (right sidebar). diff --git a/.pi/skills/pi-web-schedule/SKILL.md b/.pi/skills/pi-web-schedule/SKILL.md new file mode 100644 index 00000000..fb75499a --- /dev/null +++ b/.pi/skills/pi-web-schedule/SKILL.md @@ -0,0 +1,32 @@ +--- +name: pi-web-schedule +description: Create, list, update, disable, delete, or run-now pi-web schedules. Use when the user wants a recurring or timed job, cron, reminder, or "every day at 2am" automation in pi-web. Slash command /skill:pi-web-schedule. +--- + +# pi-web schedules + +Manage pi-web schedules through `pi-web-ctl`. Do not write SQLite yourself and do not call curl with `?token=` — the CLI talks to the local HTTP API. + +```bash +pi-web-ctl schedule list +pi-web-ctl schedule create --name NAME --instructions TEXT --daily --hour 2 --timezone sg +pi-web-ctl schedule get NAME_OR_ID +pi-web-ctl schedule update NAME_OR_ID --paused +pi-web-ctl schedule enable|disable|delete|run NAME_OR_ID +pi-web-ctl schedule runs NAME_OR_ID +``` + +If `pi-web-ctl` is not on `PATH`, run `python3 ~/.pi/agent/bin/pi-web-ctl` (Unix) or `py -3 %USERPROFILE%\.pi\agent\bin\pi-web-ctl.py` (Windows). + +Cadence flags: `--daily`, `--hourly`, `--weekdays`, `--weekly --weekday mon`, `--every-hours N`, `--manual`, or `--cron "0 2 * * *"`. Prefer flags over raw cron. Pass `--timezone` as an IANA name or alias (`sg`, `jst`, `utc`, `pt`, `et`, `ct`, `london`). The CLI resolves aliases. + +`--project` defaults to the current working directory. `--model provider/id` and `--thinking` are optional (pi defaults). `--name` defaults to the first line of `--instructions`. + +## Rules + +- A fire starts a **new empty session**. `--instructions` must be a standalone prompt. Never "continue what we were doing." +- "2am sg daily" (or similar, with a clear time + timezone + cadence) → create immediately. +- Ambiguous time ("tomorrow morning", missing timezone when the user implied a place) → ask before creating. +- After create, echo name, cadence, timezone, next run, project, and a one-line instruction summary. +- Match existing schedules by name; if several share a name, use the id from `list`. +- Recurring jobs only fire while pi-web is running. diff --git a/.pi/skills/pi-web-settings/SKILL.md b/.pi/skills/pi-web-settings/SKILL.md new file mode 100644 index 00000000..8ffd5ea8 --- /dev/null +++ b/.pi/skills/pi-web-settings/SKILL.md @@ -0,0 +1,22 @@ +--- +name: pi-web-settings +description: Read or change pi-web settings (theme, language, fonts, auto-title, notifications, artifact visibility, session display defaults, cat/pomodoro). Use when the user wants to change how pi-web looks or behaves. Slash command /skill:pi-web-settings. +--- + +# pi-web settings + +Change server-backed pi-web settings with `pi-web-ctl`. Do not write SQLite yourself. + +```bash +pi-web-ctl settings get +pi-web-ctl settings get theme +pi-web-ctl settings set theme dark +pi-web-ctl settings set language ja +pi-web-ctl settings set auto-title off +``` + +If `pi-web-ctl` is not on `PATH`, run `python3 ~/.pi/agent/bin/pi-web-ctl`. + +Aliases (CLI also accepts the raw storage key): theme, language, font-ui, font-content, font-code, auto-title, auto-title-mode, auto-title-model, notify-on-done, artifacts, thinking, tools, tool-outputs, cat, bedtime, wakeup, layout, spinner. + +Booleans accept on/off. Theme and fonts apply live; language reloads open pi-web tabs (same as the Settings picker) so chrome re-renders. Report the new value. Do not rewrite custom-languages JSON unless the user is adding a language. diff --git a/Makefile b/Makefile index d419f7f7..4124f525 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build setup frontend-setup go-setup root-setup frontend-build frontend-test frontend-knip frontend-lint frontend-format-check extension-test memory-test go-test install-test vet test check clean dev docs docs-dev release-patch release-minor release-major release-beta e2e e2e-setup +.PHONY: build setup frontend-setup go-setup root-setup frontend-build frontend-test frontend-knip frontend-lint frontend-format-check extension-test memory-test pi-web-ctl-test go-test install-test vet test check clean dev docs docs-dev release-patch release-minor release-major release-beta e2e e2e-setup BINARY ?= pi-web WEB_DIR := web @@ -53,6 +53,9 @@ extension-test: root-setup memory-test: PYTHONDONTWRITEBYTECODE=1 python3 .pi/skills/memory/scripts/test_memory.py +pi-web-ctl-test: + PYTHONDONTWRITEBYTECODE=1 python3 .pi/skills/common/test_pi_web.py + go-test: go-setup go test ./... @@ -62,9 +65,9 @@ install-test: vet: go-setup go vet ./... -test: frontend-test extension-test memory-test go-test install-test +test: frontend-test extension-test memory-test pi-web-ctl-test go-test install-test -check: frontend-lint frontend-format-check frontend-knip frontend-test extension-test memory-test frontend-build go-test install-test vet +check: frontend-lint frontend-format-check frontend-knip frontend-test extension-test memory-test pi-web-ctl-test frontend-build go-test install-test vet dev: frontend-setup go-setup @echo "Starting secondary dev instance at http://127.0.0.1:31416 (frontend watcher + Go hot-reloader)..." diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index 48f442ef..bff5372b 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -297,9 +297,10 @@ Browser POST /api/scratchpad ▼ server.handleSaveScratchpad │ - ├──▶ Decode JSON body → {"project": "...", "content": "..."} - ├──▶ UPSERT into SQLite scratchpads table (INSERT ... ON CONFLICT DO UPDATE) + ├──▶ Decode JSON body → {"project": "...", "content": "...", "mode": "replace"|"append"} + ├──▶ UPSERT into SQLite (replace is default; append concatenates atomically) + ├──▶ Broadcast SSE "scratchpad" on __all__ so an open sidebar can reload │ - └──▶ Return {"ok": true} + └──▶ Return {"ok": true, "content": "..."} ``` ``` diff --git a/docs/sequence-flows/README.md b/docs/sequence-flows/README.md index 7ed87a25..10b8ac3c 100644 --- a/docs/sequence-flows/README.md +++ b/docs/sequence-flows/README.md @@ -14,3 +14,4 @@ This directory documents the key runtime sequences in pi-web. | [btw.md](./btw.md) | Throwaway "btw" floating scratch-chats attached to a session page | | [share.md](./share.md) | Exporting a session to a private GitHub Gist | | [schedules.md](./schedules.md) | Cron/preset schedules that auto-create pi sessions and push on completion | +| [skills.md](./skills.md) | pi skills (`pi-web-ctl`) that call the local HTTP API from a session | diff --git a/docs/sequence-flows/schedules.md b/docs/sequence-flows/schedules.md index af8eca70..125ed97f 100644 --- a/docs/sequence-flows/schedules.md +++ b/docs/sequence-flows/schedules.md @@ -101,7 +101,10 @@ that elapsed while the process was down are **skipped** rather than replayed. | GET | `/api/schedule/runs?id=` | run log | The `/schedules` page itself is the SPA shell (served by the catch-all index -route); the Svelte router renders `SchedulesPage.svelte`. +route); the Svelte router renders `SchedulesPage.svelte`. Create/update/delete +(and run-now) broadcast an SSE `schedules` event on `__all__` so an open +schedules page refetches. Agents can create schedules via `/skill:pi-web-schedule` +(`pi-web-ctl`); see [skills.md](./skills.md). ## Push notifications diff --git a/docs/sequence-flows/skills.md b/docs/sequence-flows/skills.md new file mode 100644 index 00000000..d198db11 --- /dev/null +++ b/docs/sequence-flows/skills.md @@ -0,0 +1,44 @@ +# Sequence Flow: pi-web skills + +pi-web ships pi skills under `.pi/skills/`. They do not write `pi-web.sqlite` +themselves. A shared CLI (`pi-web-ctl`) discovers the running server and calls +the existing HTTP APIs. + +`pi install` copies `.pi/skills/common/pi_web.py` to `~/.pi/agent/bin/pi-web-ctl` +so the agent can invoke it from any project directory. + +## Discover → auth → API → SSE + +``` +Agent (pi session) + │ + │ pi-web-ctl schedule create … + ▼ +Read ~/.pi/agent/pi-web/pi-web-state.json (regular file, not *-dev.json) + │ + ├── port → http://127.0.0.1:{port} (always loopback) + └── PI_WEB_TOKEN / ~/.config/pi-web/env + │ + ▼ + X-Pi-Token header (never ?token= — that 302s past the handler) + │ + ▼ + POST /api/schedules + │ + ▼ + SSE event: schedules on __all__ + │ + ▼ + /schedules page silent-refetches /api/schedules +``` + +v1 skills: + +| Skill | CLI | API | +|---|---|---| +| `pi-web-schedule` | `pi-web-ctl schedule …` | `/api/schedules`, `/api/schedule` | +| `pi-web-notes` | `pi-web-ctl notes …` | `/api/scratchpad` | +| `pi-web-settings` | `pi-web-ctl settings …` | `/api/settings` | + +If the server is down the CLI exits with “pi-web is not running. Start it with +`/pi-web start`.” Schedules cannot fire unless the server is up anyway. diff --git a/install.ps1 b/install.ps1 index 6c4e2b9b..f3ba927c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -119,6 +119,31 @@ function Install-Binary($src, $tag) { Info "pi-web $tag installed to $Binary" } +function Install-Ctl { + if (-not $PSScriptRoot) { return } + $src = Join-Path $PSScriptRoot '.pi\skills\common\pi_web.py' + if (-not (Test-Path $src)) { return } + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + $py = Join-Path $InstallDir 'pi-web-ctl.py' + Copy-Item $src $py -Force + $cmdPath = Join-Path $InstallDir 'pi-web-ctl.cmd' + @" +@echo off +setlocal +where python >nul 2>nul && ( + python "%~dp0pi-web-ctl.py" %* + exit /b %ERRORLEVEL% +) +where py >nul 2>nul && ( + py -3 "%~dp0pi-web-ctl.py" %* + exit /b %ERRORLEVEL% +) +echo pi-web-ctl requires Python 3 >&2 +exit /b 1 +"@ | Set-Content -Path $cmdPath -Encoding ASCII + Info "pi-web-ctl installed to $cmdPath" +} + function Set-EnvFileVar($file, $key, $value) { $lines = @() if (Test-Path $file) { $lines = @(Get-Content $file) } @@ -195,6 +220,7 @@ function Main { $installed = Get-InstalledVersion if ((Test-Path $Binary) -and $installed -eq $tag) { + Install-Ctl Info "Already up-to-date ($tag)." Write-Host '' return @@ -207,6 +233,7 @@ function Main { if ((Test-Path $Binary) -and -not $inplace) { Stop-PiWeb } Install-Binary $tmpBinary $tag + Install-Ctl # In-place self-update: pi-web triggered this and restarts itself afterward. # Skip env/auto-start setup so we don't kill the npm process running this diff --git a/install.sh b/install.sh index 5fcdbcf0..3867069a 100755 --- a/install.sh +++ b/install.sh @@ -216,6 +216,26 @@ install_binary() { return 0 } +# Copy the skill CLI next to the binary so `pi-web-ctl` works from any cwd. +# Missing source (standalone binary-only install) is a no-op — skills ship +# with the npm package, not the GitHub release tarball. +install_ctl() { + local src="${SRC_DIR}/.pi/skills/common/pi_web.py" + if [[ ! -f "$src" ]]; then + return 0 + fi + mkdir -p "$INSTALL_DIR" + local dest="${INSTALL_DIR}/pi-web-ctl" + if [[ ! -w "$INSTALL_DIR" ]]; then + sudo cp "$src" "$dest" + sudo chmod +x "$dest" + else + cp "$src" "$dest" + chmod +x "$dest" + fi + info "pi-web-ctl installed to ${dest}" +} + # ── Fetch config file from repo (for standalone installs) ────────── fetch_config() { local file="$1" @@ -409,6 +429,7 @@ main() { fi if ! needs_update "$tag"; then + install_ctl info "Already up-to-date (${tag})." echo "" exit 0 @@ -432,11 +453,13 @@ main() { # via its own /api/restart. Skip env/service setup so we don't restart (and # kill) the npm process running this script, or clobber the service's PATH. if [[ -n "${PI_WEB_INPLACE_UPDATE:-}" ]]; then + install_ctl info "Binary updated to ${tag}; pi-web will restart to apply it." echo "" exit 0 fi + install_ctl setup_env case "$(uname -s)" in diff --git a/internal/rpc/oneshot.go b/internal/rpc/oneshot.go index 92143c5a..0fc83c1c 100644 --- a/internal/rpc/oneshot.go +++ b/internal/rpc/oneshot.go @@ -14,12 +14,17 @@ import ( // OneShot spawns `pi --mode rpc`, sends a single command, awaits the matching // response, and tears the subprocess down. It exists so sessionless RPCs (e.g. // get_available_models) don't reimplement spawn/scan/timeout machinery. +// +// The subprocess cwd is detached from pi-web's (see detachedPiDir). Extensions +// still load — including globally installed ones that register models — but +// project extensions from this checkout are not loaded on top of that copy. func OneShot(ctx context.Context, command string, extraFields map[string]any) (json.RawMessage, error) { if _, err := exec.LookPath("pi"); err != nil { return nil, fmt.Errorf("pi executable not found: %w", err) } cmd := exec.CommandContext(ctx, "pi", "--mode", "rpc") + cmd.Dir = detachedPiDir() stdin, err := cmd.StdinPipe() if err != nil { return nil, err diff --git a/internal/rpc/oneshot_test.go b/internal/rpc/oneshot_test.go new file mode 100644 index 00000000..9150014f --- /dev/null +++ b/internal/rpc/oneshot_test.go @@ -0,0 +1,16 @@ +package rpc + +import ( + "os" + "testing" +) + +func TestDetachedPiDirIsNotEmpty(t *testing.T) { + dir := detachedPiDir() + if dir == "" { + t.Fatal("detachedPiDir must be a real directory so pi does not inherit pi-web's checkout") + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + t.Fatalf("detachedPiDir = %q, stat err %v", dir, err) + } +} diff --git a/internal/rpc/worker.go b/internal/rpc/worker.go index 1f2298e4..08f8a8d5 100644 --- a/internal/rpc/worker.go +++ b/internal/rpc/worker.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "os" "os/exec" "strings" "sync" @@ -65,11 +66,53 @@ func (w *piRPCWorker) StartedAt() time.Time { return w.startedAt } +// workerDir is the cwd for `pi --mode rpc`. Extensions load at process start +// from cwd, so inheriting pi-web's own checkout would load this repo's +// .pi/extensions on top of the globally installed package and crash on a +// duplicate tool (pi_web_ask_user_question). Prefer the session's project +// directory; fall back to temp so the server cwd is never inherited. +func workerDir(sessionPath string) string { + if cwd := sessionHeaderCWD(sessionPath); cwd != "" { + if info, err := os.Stat(cwd); err == nil && info.IsDir() { + return cwd + } + } + return detachedPiDir() +} + +// detachedPiDir is a cwd with no project .pi/extensions. The installed +// LaunchAgent uses /tmp for the same reason: pi loads project extensions from +// cwd, and this repo's tools collide with `pi install npm:@ygncode/pi-web`. +func detachedPiDir() string { + return os.TempDir() +} + +func sessionHeaderCWD(sessionPath string) string { + f, err := os.Open(sessionPath) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + if !sc.Scan() { + return "" + } + var hdr struct { + Type string `json:"type"` + CWD string `json:"cwd"` + } + if json.Unmarshal(sc.Bytes(), &hdr) != nil || hdr.Type != "session" { + return "" + } + return strings.TrimSpace(hdr.CWD) +} + func NewPiWorkerWithStream(sessionPath string, streamSink StreamEventSink) (workers.ChatWorker, error) { if _, err := exec.LookPath("pi"); err != nil { return nil, fmt.Errorf("pi executable not found: %w", err) } cmd := exec.Command("pi", "--mode", "rpc") + cmd.Dir = workerDir(sessionPath) stdin, err := cmd.StdinPipe() if err != nil { return nil, err diff --git a/internal/rpc/worker_test.go b/internal/rpc/worker_test.go index 406775da..43a90958 100644 --- a/internal/rpc/worker_test.go +++ b/internal/rpc/worker_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "io" + "os" + "strconv" "strings" "testing" "time" @@ -30,6 +32,27 @@ func waitForPending(t *testing.T, w *piRPCWorker, id string) { t.Fatalf("pending request %q never registered", id) } +func TestWorkerDirUsesSessionCWD(t *testing.T) { + dir := t.TempDir() + path := dir + "/sess.jsonl" + if err := os.WriteFile(path, []byte(`{"type":"session","cwd":`+strconv.Quote(dir)+`}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := workerDir(path); got != dir { + t.Fatalf("workerDir = %q, want session cwd %q", got, dir) + } +} + +func TestWorkerDirFallsBackToTempWhenCWDMissing(t *testing.T) { + path := t.TempDir() + "/sess.jsonl" + if err := os.WriteFile(path, []byte(`{"type":"session","cwd":""}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := workerDir(path); got != detachedPiDir() { + t.Fatalf("workerDir = %q, want detached dir %q", got, detachedPiDir()) + } +} + func TestStatusReportsRunningDuringRecentStreamActivity(t *testing.T) { w := &piRPCWorker{ status: workers.WorkerStatus{State: workers.WorkerStateIdle}, diff --git a/internal/server/scheduler_test.go b/internal/server/scheduler_test.go index d0567185..00eab1aa 100644 --- a/internal/server/scheduler_test.go +++ b/internal/server/scheduler_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -185,6 +186,66 @@ func TestSchedulesAPICreateListRun(t *testing.T) { } } +func TestSchedulesAPICreateBroadcastsSSE(t *testing.T) { + s, _ := newScheduleTestServer(t) + client := s.addClient(globalSessID) + defer s.removeClient(client) + + body, _ := json.Marshal(map[string]any{ + "name": "SSE sched", + "instructions": "do it", + "cronExpr": "0 9 * * *", + "timezone": "UTC", + }) + w := httptest.NewRecorder() + s.handleApiSchedules(w, httptest.NewRequest(http.MethodPost, "/api/schedules", bytes.NewReader(body))) + if w.Code != http.StatusCreated { + t.Fatalf("create status = %d, body %s", w.Code, w.Body.String()) + } + + select { + case msg := <-client.ch: + if !strings.Contains(msg, "event: schedules") { + t.Fatalf("sse = %q, want schedules event", msg) + } + if !strings.Contains(msg, `"action":"created"`) { + t.Fatalf("sse = %q, want action=created", msg) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for schedules SSE") + } +} + +func TestSchedulesAPIDeleteBroadcastsSSE(t *testing.T) { + s, _ := newScheduleTestServer(t) + created, err := s.schedules.Create(schedules.Schedule{ + ID: "del-1", Name: "Gone", Instructions: "x", Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + client := s.addClient(globalSessID) + defer s.removeClient(client) + + w := httptest.NewRecorder() + s.handleApiSchedule(w, httptest.NewRequest(http.MethodDelete, "/api/schedule?id="+created.ID, nil)) + if w.Code != http.StatusOK { + t.Fatalf("delete status = %d, body %s", w.Code, w.Body.String()) + } + + select { + case msg := <-client.ch: + if !strings.Contains(msg, `"action":"deleted"`) { + t.Fatalf("sse = %q, want action=deleted", msg) + } + if !strings.Contains(msg, created.ID) { + t.Fatalf("sse = %q, want id %s", msg, created.ID) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for schedules SSE") + } +} + func TestSchedulesAPIValidation(t *testing.T) { s, _ := newScheduleTestServer(t) // Missing name. diff --git a/internal/server/schedules_api.go b/internal/server/schedules_api.go index 9e4c6831..b0d4eac9 100644 --- a/internal/server/schedules_api.go +++ b/internal/server/schedules_api.go @@ -103,7 +103,9 @@ func (s *Server) handleApiSchedules(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, http.StatusCreated, map[string]any{"schedule": s.withNextRun(created)}) + out := s.withNextRun(created) + s.notifySchedulesChanged("created", out) + writeJSON(w, http.StatusCreated, map[string]any{"schedule": out}) default: w.Header().Set("Allow", "GET, POST") writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -150,12 +152,15 @@ func (s *Server) handleApiSchedule(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, 0, map[string]any{"schedule": s.withNextRun(updated)}) + out := s.withNextRun(updated) + s.notifySchedulesChanged("updated", out) + writeJSON(w, 0, map[string]any{"schedule": out}) case http.MethodDelete: if err := s.schedules.Delete(id); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } + s.notifySchedulesChanged("deleted", existing) writeJSON(w, 0, map[string]any{"ok": true}) default: w.Header().Set("Allow", "GET, POST, PUT, DELETE") @@ -193,6 +198,11 @@ func (s *Server) handleApiScheduleRun(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } + if updated, getErr := s.schedules.Get(id); getErr == nil { + s.notifySchedulesChanged("ran", s.withNextRun(updated)) + } else { + s.notifySchedulesChanged("ran", sc) + } writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "sessionId": sessionID}) } @@ -218,3 +228,12 @@ func (s *Server) handleApiScheduleRuns(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 0, map[string]any{"runs": runs}) } + +// notifySchedulesChanged tells index/schedules SSE listeners to refetch. +// The payload is small; the page reloads /api/schedules for canonical state. +func (s *Server) notifySchedulesChanged(action string, sc schedules.Schedule) { + payload := map[string]any{"action": action, "id": sc.ID} + if msg, err := formatSSEJSONEvent("schedules", payload); err == nil { + s.broadcast(globalSessID, msg) + } +} diff --git a/internal/server/scratchpad.go b/internal/server/scratchpad.go index cc5917fe..aa0ad2e5 100644 --- a/internal/server/scratchpad.go +++ b/internal/server/scratchpad.go @@ -3,6 +3,7 @@ package server import ( "database/sql" "net/http" + "strings" "time" ) @@ -56,6 +57,7 @@ func (s *Server) handleSaveScratchpad(w http.ResponseWriter, r *http.Request) { var body struct { Project string `json:"project"` Content string `json:"content"` + Mode string `json:"mode"` } if !decodeJSONBody(w, r, &body) { return @@ -66,19 +68,52 @@ func (s *Server) handleSaveScratchpad(w http.ResponseWriter, r *http.Request) { return } + mode := strings.TrimSpace(strings.ToLower(body.Mode)) + if mode == "" { + mode = "replace" + } + if mode != "replace" && mode != "append" { + writeJSONError(w, http.StatusBadRequest, "mode must be replace or append") + return + } + if s.db == nil { writeJSONError(w, http.StatusInternalServerError, "database is unavailable") return } - _, err := s.db.Exec(`INSERT INTO scratchpads (project_path, content, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(project_path) DO UPDATE SET content=excluded.content, updated_at=excluded.updated_at`, - body.Project, body.Content, time.Now()) + var err error + if mode == "append" { + _, err = s.db.Exec(`INSERT INTO scratchpads (project_path, content, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(project_path) DO UPDATE SET + content = scratchpads.content || excluded.content, + updated_at = excluded.updated_at`, + body.Project, body.Content, time.Now()) + } else { + _, err = s.db.Exec(`INSERT INTO scratchpads (project_path, content, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(project_path) DO UPDATE SET content=excluded.content, updated_at=excluded.updated_at`, + body.Project, body.Content, time.Now()) + } if err != nil { writeJSONError(w, http.StatusInternalServerError, "failed to save scratchpad: "+err.Error()) return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + content := body.Content + if mode == "append" { + if stored, lookupErr := s.lookupScratchpad(body.Project); lookupErr == nil { + content = stored + } + } + s.notifyScratchpadChanged(body.Project, content) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "content": content}) +} + +func (s *Server) notifyScratchpadChanged(project, content string) { + payload := map[string]any{"project": project, "content": content} + if msg, err := formatSSEJSONEvent("scratchpad", payload); err == nil { + s.broadcast(globalSessID, msg) + } } diff --git a/internal/server/scratchpad_test.go b/internal/server/scratchpad_test.go index 8adbe080..41237b4a 100644 --- a/internal/server/scratchpad_test.go +++ b/internal/server/scratchpad_test.go @@ -7,7 +7,9 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" + "time" _ "modernc.org/sqlite" ) @@ -170,6 +172,66 @@ func TestHandleSaveScratchpad(t *testing.T) { } } +func TestHandleSaveScratchpadAppend(t *testing.T) { + db := newTestDB(t) + s := &Server{db: db} + + body := bytes.NewBufferString(`{"project":"/p","content":"hello"}`) + s.handleSaveScratchpad(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/scratchpad", body)) + + body2 := bytes.NewBufferString(`{"project":"/p","content":" world","mode":"append"}`) + w := httptest.NewRecorder() + s.handleSaveScratchpad(w, httptest.NewRequest(http.MethodPost, "/api/scratchpad", body2)) + if w.Code != http.StatusOK { + t.Fatalf("append status = %d, body %s", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp["content"] != "hello world" { + t.Errorf("content = %v, want concatenated", resp["content"]) + } + var stored string + if err := db.QueryRow("SELECT content FROM scratchpads WHERE project_path = ?", "/p").Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != "hello world" { + t.Errorf("stored = %q", stored) + } +} + +func TestHandleSaveScratchpadRejectsBadMode(t *testing.T) { + s := &Server{db: newTestDB(t)} + body := bytes.NewBufferString(`{"project":"/p","content":"x","mode":"merge"}`) + w := httptest.NewRecorder() + s.handleSaveScratchpad(w, httptest.NewRequest(http.MethodPost, "/api/scratchpad", body)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestHandleSaveScratchpadBroadcastsSSE(t *testing.T) { + s := &Server{db: newTestDB(t)} + client := s.addClient(globalSessID) + defer s.removeClient(client) + + body := bytes.NewBufferString(`{"project":"/p","content":"note"}`) + s.handleSaveScratchpad(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/scratchpad", body)) + + select { + case msg := <-client.ch: + if !strings.Contains(msg, "event: scratchpad") { + t.Fatalf("sse = %q", msg) + } + if !strings.Contains(msg, `"project":"/p"`) { + t.Fatalf("sse missing project: %q", msg) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for scratchpad SSE") + } +} + func TestHandleSessionUsesSPAShell(t *testing.T) { s := &Server{ renderAppShell: func(w io.Writer, bootstrap string) error { diff --git a/internal/server/settings.go b/internal/server/settings.go index 2e2adef4..1c576119 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -41,32 +41,32 @@ func (s *Server) handleAppShell(w http.ResponseWriter, r *http.Request, bootstra // live-timer state (sidebar widths, focus countdown, tree toggles) is NOT // listed here — it stays in localStorage only. var settingDefaults = map[string]string{ - "pi-web-theme": "dark", - "pi-web:v1:locale": "en", - "pi-web:v1:custom-languages": "", - "pi-web:v1:font-ui": "mono", - "pi-web:v1:font-content": "mono", - "pi-web:v1:font-code": "mono", - "pi-web:v1:font-ui-size": "12", - "pi-web:v1:font-content-size": "13", - "pi-sessions:spinner-style": "runcat", - "pi-share:v1:notify-on-done": "false", - "pi-share:v1:done-sound": "cat.mp3", - "pi-sessions:view-layout": "timeline", - "pi-web:v1:show-btw-in-index": "false", - "pi-web:v1:cat:enabled": "true", - "pi-web:v1:cat:focus-min": "25", - "pi-web:v1:cat:break-min": "5", - "pi-web:v1:cat:bedtime": "23:00", - "pi-web:v1:cat:wakeup": "07:00", - "pi-web:v1:cat:sleep-min": "2", - settingAutoTitleEnabled: "true", - settingAutoTitleMode: "each-turn", - settingAutoTitleModel: "", - "pi-web:v1:artifacts:enabled": "true", - "pi-web:v1:artifacts:include": "*.md, *.html", - "pi-web:v1:toggle:thinking": "true", - "pi-web:v1:toggle:tools": "true", + "pi-web-theme": "dark", + "pi-web:v1:locale": "en", + "pi-web:v1:custom-languages": "", + "pi-web:v1:font-ui": "mono", + "pi-web:v1:font-content": "mono", + "pi-web:v1:font-code": "mono", + "pi-web:v1:font-ui-size": "12", + "pi-web:v1:font-content-size": "13", + "pi-sessions:spinner-style": "runcat", + "pi-share:v1:notify-on-done": "false", + "pi-share:v1:done-sound": "cat.mp3", + "pi-sessions:view-layout": "timeline", + "pi-web:v1:show-btw-in-index": "false", + "pi-web:v1:cat:enabled": "true", + "pi-web:v1:cat:focus-min": "25", + "pi-web:v1:cat:break-min": "5", + "pi-web:v1:cat:bedtime": "23:00", + "pi-web:v1:cat:wakeup": "07:00", + "pi-web:v1:cat:sleep-min": "2", + settingAutoTitleEnabled: "true", + settingAutoTitleMode: "each-turn", + settingAutoTitleModel: "", + "pi-web:v1:artifacts:enabled": "true", + "pi-web:v1:artifacts:include": "*.md, *.html", + "pi-web:v1:toggle:thinking": "true", + "pi-web:v1:toggle:tools": "true", "pi-web:v1:toggle:tool-outputs": "false", } @@ -235,5 +235,13 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) { } } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "settings": s.getSettings()}) + out := s.getSettings() + s.notifySettingsChanged(out) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "settings": out}) +} + +func (s *Server) notifySettingsChanged(settings map[string]string) { + if msg, err := formatSSEJSONEvent("settings", map[string]any{"settings": settings}); err == nil { + s.broadcast(globalSessID, msg) + } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 8b981623..5aecc374 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -6,7 +6,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "time" _ "modernc.org/sqlite" ) @@ -237,6 +239,31 @@ func TestGetPostHandlerRejectsOtherMethods(t *testing.T) { } } +func TestHandleSaveSettingsBroadcastsSSE(t *testing.T) { + s := &Server{db: newSettingsTestDB(t)} + client := s.addClient(globalSessID) + defer s.removeClient(client) + + body := bytes.NewBufferString(`{"settings":{"pi-web-theme":"nord"}}`) + w := httptest.NewRecorder() + s.handleSaveSettings(w, httptest.NewRequest(http.MethodPost, "/api/settings", body)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body %s", w.Code, w.Body.String()) + } + + select { + case msg := <-client.ch: + if !strings.Contains(msg, "event: settings") { + t.Fatalf("sse = %q", msg) + } + if !strings.Contains(msg, `"pi-web-theme":"nord"`) { + t.Fatalf("sse missing theme: %q", msg) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for settings SSE") + } +} + func TestHandleGetSettingsWrongMethod(t *testing.T) { s := &Server{db: newSettingsTestDB(t)} req := httptest.NewRequest(http.MethodDelete, "/api/settings", nil) diff --git a/uninstall.ps1 b/uninstall.ps1 index f41f4583..57a83ced 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -41,6 +41,14 @@ if (Test-Path $Binary) { Skip "binary not found at $Binary" } Remove-Item "$Binary.old" -Force -ErrorAction SilentlyContinue +$ctlDir = Split-Path $Binary +foreach ($name in @('pi-web-ctl', 'pi-web-ctl.py', 'pi-web-ctl.cmd')) { + $ctl = Join-Path $ctlDir $name + if (Test-Path $ctl) { + Info "Removing skill CLI: $ctl" + Remove-Item $ctl -Force -ErrorAction SilentlyContinue + } +} # Remove version file $versionFile = Join-Path $HOME '.pi\agent\pi-web-version' diff --git a/uninstall.sh b/uninstall.sh index f112513b..f8fa7679 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -51,6 +51,11 @@ remove_binary() { else skip "binary not found at ${BINARY}" fi + local ctl="${BINARY%/*}/pi-web-ctl" + if [[ -f "$ctl" ]]; then + info "Removing skill CLI: ${ctl}" + rm -f "$ctl" + fi } # ── Remove version file ───────────────────────────────────────────── diff --git a/user-docs/en/README.md b/user-docs/en/README.md index c5feab4f..b20a474e 100644 --- a/user-docs/en/README.md +++ b/user-docs/en/README.md @@ -68,6 +68,8 @@ Want more than coding? Turn it into a dedicated [personal assistant](personal-as | 🔔 **Notification sounds** | Customizable notification chimes for session events — stay in the loop even when pi-web is in another tab. | | ⌨️ **Keyboard shortcuts** | Vim-style navigation, quick actions — [full reference →](keyboard-shortcuts.md) | | 🤖 **Personal assistant** | Turn pi-web into your own AI assistant that lives on your computer — like OpenClaw or Hermes. [Set it up →](personal-assistant.md) | +| 🗓️ **Talk to schedules** | From a pi session, say “add a schedule at 2am Singapore time to …” — `/skill:pi-web-schedule`. | +| 📝 **Talk to notes & settings** | “Write this in the notes” (`/skill:pi-web-notes`) or “switch to dark mode” (`/skill:pi-web-settings`). | --- diff --git a/user-docs/en/install.md b/user-docs/en/install.md index a4f251e5..c23cb227 100644 --- a/user-docs/en/install.md +++ b/user-docs/en/install.md @@ -20,6 +20,7 @@ - Download a session as JSONL - Share static snapshots as secret GitHub Gists - `/web`, `/remote`, `/refresh`, `/pi-web token` and `/pi-web set-token` pi extensions for opening sessions, remote QR, session sync, and token management +- `/skill:pi-web-schedule`, `/skill:pi-web-notes`, `/skill:pi-web-settings` (`pi-web-ctl`) so a session can manage schedules, the project scratchpad, and settings in natural language ## Requirements diff --git a/user-docs/en/personal-assistant.md b/user-docs/en/personal-assistant.md index 1b0167fb..da4b37a0 100644 --- a/user-docs/en/personal-assistant.md +++ b/user-docs/en/personal-assistant.md @@ -75,6 +75,24 @@ You can put anything in your assistant folder that helps pi be more useful: pi can read files in the folder, so the more context you give it, the better it gets. +## Ask pi-web to do things + +After `pi install npm:@ygncode/pi-web@beta`, sessions can talk to pi-web itself. +Try: + +- “Add a schedule at 2am Singapore time to summarize my inbox” +- “List my pi-web schedules” +- “Pause the inbox schedule” +- “Write this down in the notes” +- “Switch pi-web to dark mode / turn auto-title off” + +The bundled **/skill:pi-web-schedule** skill turns that into a real pi-web +schedule (same ones you edit at `/schedules`). Each firing starts a **new** +session, so the instructions have to stand alone — “summarize unread mail in +~/inbox” works; “continue what we were doing” does not. + +Schedules only run while pi-web is running. + --- > 💡 **Tip:** Start simple. Just a few lines about who you are and how you want the assistant to behave. Iterate over time as you learn what works. diff --git a/web/src/components/session/RightSidebar.svelte b/web/src/components/session/RightSidebar.svelte index ebbcf073..4c824698 100644 --- a/web/src/components/session/RightSidebar.svelte +++ b/web/src/components/session/RightSidebar.svelte @@ -6,6 +6,7 @@ import AnnotationLayer from './AnnotationLayer.svelte'; import { sessionRuntime } from '../../session/session-runtime.js'; import { createScratchpadController } from './right-sidebar-scratchpad.js'; + import { createScratchpadEvents } from '../../index/scratchpad-events.js'; let { scratchpad = '', projectPath = '', annotationConfig = {} } = $props(); @@ -130,6 +131,16 @@ }); loadScratchpad = scratchpadController.load; if (textarea) cleanups.push(scratchpadController.bind()); + const scratchpadEvents = createScratchpadEvents({ + onChange: (payload) => { + if (!payload || payload.project !== projectPath) return; + scratchpadController.applyRemote(payload.content ?? ''); + }, + }); + try { + scratchpadEvents.connect(); + cleanups.push(() => scratchpadEvents.cleanup()); + } catch {} function getRightSidebarBounds() { const rootStyles = windowImpl.getComputedStyle(documentImpl.documentElement); diff --git a/web/src/components/session/right-sidebar-scratchpad.js b/web/src/components/session/right-sidebar-scratchpad.js index 82740665..d1fdf9d5 100644 --- a/web/src/components/session/right-sidebar-scratchpad.js +++ b/web/src/components/session/right-sidebar-scratchpad.js @@ -60,6 +60,20 @@ export function createScratchpadController({ if (textarea) lastSaved = textarea.value; } + function isDirty() { + return !!(textarea && textarea.value !== lastSaved); + } + + function applyRemote(content) { + if (!textarea) return false; + if (isDirty()) return false; + const next = content ?? ''; + textarea.value = next; + lastSaved = next; + setStatus('Saved', 'saved'); + return true; + } + function bind() { textarea?.addEventListener('input', onInput); return () => { @@ -73,6 +87,8 @@ export function createScratchpadController({ save, setStatus, adoptCurrentValue, + isDirty, + applyRemote, bind, }; } diff --git a/web/src/components/session/right-sidebar-scratchpad.test.js b/web/src/components/session/right-sidebar-scratchpad.test.js index a9be61cb..a699ad03 100644 --- a/web/src/components/session/right-sidebar-scratchpad.test.js +++ b/web/src/components/session/right-sidebar-scratchpad.test.js @@ -127,4 +127,22 @@ describe('createScratchpadController', () => { expect(statusEl.textContent).toBe('Save failed'); expect(statusEl.className).toBe('scratchpad-status'); }); + + it('applies remote content when clean and skips when dirty', () => { + const { textarea, statusEl } = renderScratchpad('saved'); + const scratchpad = createScratchpadController({ + projectPath: '/proj', + textarea, + statusEl, + }); + scratchpad.adoptCurrentValue(); + + expect(scratchpad.applyRemote('from skill')).toBe(true); + expect(textarea.value).toBe('from skill'); + + textarea.value = 'local edit'; + expect(scratchpad.isDirty()).toBe(true); + expect(scratchpad.applyRemote('ignored')).toBe(false); + expect(textarea.value).toBe('local edit'); + }); }); diff --git a/web/src/index/schedules-events.js b/web/src/index/schedules-events.js new file mode 100644 index 00000000..0505939a --- /dev/null +++ b/web/src/index/schedules-events.js @@ -0,0 +1,57 @@ +function parseJSON(data) { + try { + return JSON.parse(data); + } catch { + return null; + } +} + +export function createSchedulesEvents({ + topic = '__all__', + EventSourceImpl = globalThis.EventSource, + windowImpl = globalThis.window, + onChange = () => {}, +} = {}) { + let stream = null; + let pagehideHandler = null; + let pageshowHandler = null; + + function closeStream() { + if (stream) { + stream.close(); + stream = null; + } + } + + function cleanup() { + closeStream(); + if (pagehideHandler && windowImpl?.removeEventListener) { + windowImpl.removeEventListener('pagehide', pagehideHandler); + pagehideHandler = null; + } + if (pageshowHandler && windowImpl?.removeEventListener) { + windowImpl.removeEventListener('pageshow', pageshowHandler); + pageshowHandler = null; + } + } + + function connect() { + if (!EventSourceImpl) return; + cleanup(); + const es = new EventSourceImpl(`/events?id=${encodeURIComponent(topic)}`); + stream = es; + es.addEventListener('schedules', (event) => { + onChange(parseJSON(event.data)); + }); + if (windowImpl?.addEventListener) { + pagehideHandler = () => closeStream(); + pageshowHandler = () => { + if (!stream) connect(); + }; + windowImpl.addEventListener('pagehide', pagehideHandler); + windowImpl.addEventListener('pageshow', pageshowHandler); + } + } + + return { connect, cleanup }; +} diff --git a/web/src/index/schedules-events.test.js b/web/src/index/schedules-events.test.js new file mode 100644 index 00000000..ec8d21ef --- /dev/null +++ b/web/src/index/schedules-events.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { createSchedulesEvents } from './schedules-events.js'; + +class FakeEventSource { + constructor(url) { + this.url = url; + this.listeners = {}; + this.close = vi.fn(); + FakeEventSource.instances.push(this); + } + addEventListener(name, fn) { + (this.listeners[name] ||= []).push(fn); + } + emit(name, data) { + for (const fn of this.listeners[name] || []) fn({ data }); + } +} +FakeEventSource.instances = []; + +describe('createSchedulesEvents', () => { + beforeEach(() => { + FakeEventSource.instances = []; + }); + + it('subscribes to __all__ and forwards schedules events', () => { + const onChange = vi.fn(); + const sub = createSchedulesEvents({ + EventSourceImpl: FakeEventSource, + onChange, + }); + sub.connect(); + + const es = FakeEventSource.instances[0]; + expect(es.url).toBe('/events?id=__all__'); + + es.emit('schedules', JSON.stringify({ action: 'created', id: 'abc' })); + expect(onChange).toHaveBeenCalledWith({ action: 'created', id: 'abc' }); + + es.emit('schedules', 'not-json'); + expect(onChange).toHaveBeenLastCalledWith(null); + + sub.cleanup(); + expect(es.close).toHaveBeenCalled(); + }); +}); diff --git a/web/src/index/scratchpad-events.js b/web/src/index/scratchpad-events.js new file mode 100644 index 00000000..0f108992 --- /dev/null +++ b/web/src/index/scratchpad-events.js @@ -0,0 +1,57 @@ +function parseJSON(data) { + try { + return JSON.parse(data); + } catch { + return null; + } +} + +export function createScratchpadEvents({ + topic = '__all__', + EventSourceImpl = globalThis.EventSource, + windowImpl = globalThis.window, + onChange = () => {}, +} = {}) { + let stream = null; + let pagehideHandler = null; + let pageshowHandler = null; + + function closeStream() { + if (stream) { + stream.close(); + stream = null; + } + } + + function cleanup() { + closeStream(); + if (pagehideHandler && windowImpl?.removeEventListener) { + windowImpl.removeEventListener('pagehide', pagehideHandler); + pagehideHandler = null; + } + if (pageshowHandler && windowImpl?.removeEventListener) { + windowImpl.removeEventListener('pageshow', pageshowHandler); + pageshowHandler = null; + } + } + + function connect() { + if (!EventSourceImpl) return; + cleanup(); + const es = new EventSourceImpl(`/events?id=${encodeURIComponent(topic)}`); + stream = es; + es.addEventListener('scratchpad', (event) => { + onChange(parseJSON(event.data)); + }); + if (windowImpl?.addEventListener) { + pagehideHandler = () => closeStream(); + pageshowHandler = () => { + if (!stream) connect(); + }; + windowImpl.addEventListener('pagehide', pagehideHandler); + windowImpl.addEventListener('pageshow', pageshowHandler); + } + } + + return { connect, cleanup }; +} diff --git a/web/src/index/scratchpad-events.test.js b/web/src/index/scratchpad-events.test.js new file mode 100644 index 00000000..6a194c38 --- /dev/null +++ b/web/src/index/scratchpad-events.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { createScratchpadEvents } from './scratchpad-events.js'; + +class FakeEventSource { + constructor(url) { + this.url = url; + this.listeners = {}; + this.close = vi.fn(); + FakeEventSource.instances.push(this); + } + addEventListener(name, fn) { + (this.listeners[name] ||= []).push(fn); + } + emit(name, data) { + for (const fn of this.listeners[name] || []) fn({ data }); + } +} +FakeEventSource.instances = []; + +describe('createScratchpadEvents', () => { + beforeEach(() => { + FakeEventSource.instances = []; + }); + + it('forwards scratchpad events', () => { + const onChange = vi.fn(); + const sub = createScratchpadEvents({ EventSourceImpl: FakeEventSource, onChange }); + sub.connect(); + FakeEventSource.instances[0].emit( + 'scratchpad', + JSON.stringify({ project: '/p', content: 'hi' }), + ); + expect(onChange).toHaveBeenCalledWith({ project: '/p', content: 'hi' }); + sub.cleanup(); + }); +}); diff --git a/web/src/index/settings-events.js b/web/src/index/settings-events.js new file mode 100644 index 00000000..c6ae7f64 --- /dev/null +++ b/web/src/index/settings-events.js @@ -0,0 +1,57 @@ +function parseJSON(data) { + try { + return JSON.parse(data); + } catch { + return null; + } +} + +export function createSettingsEvents({ + topic = '__all__', + EventSourceImpl = globalThis.EventSource, + windowImpl = globalThis.window, + onChange = () => {}, +} = {}) { + let stream = null; + let pagehideHandler = null; + let pageshowHandler = null; + + function closeStream() { + if (stream) { + stream.close(); + stream = null; + } + } + + function cleanup() { + closeStream(); + if (pagehideHandler && windowImpl?.removeEventListener) { + windowImpl.removeEventListener('pagehide', pagehideHandler); + pagehideHandler = null; + } + if (pageshowHandler && windowImpl?.removeEventListener) { + windowImpl.removeEventListener('pageshow', pageshowHandler); + pageshowHandler = null; + } + } + + function connect() { + if (!EventSourceImpl) return; + cleanup(); + const es = new EventSourceImpl(`/events?id=${encodeURIComponent(topic)}`); + stream = es; + es.addEventListener('settings', (event) => { + onChange(parseJSON(event.data)); + }); + if (windowImpl?.addEventListener) { + pagehideHandler = () => closeStream(); + pageshowHandler = () => { + if (!stream) connect(); + }; + windowImpl.addEventListener('pagehide', pagehideHandler); + windowImpl.addEventListener('pageshow', pageshowHandler); + } + } + + return { connect, cleanup }; +} diff --git a/web/src/index/settings-events.test.js b/web/src/index/settings-events.test.js new file mode 100644 index 00000000..59e51c11 --- /dev/null +++ b/web/src/index/settings-events.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { createSettingsEvents } from './settings-events.js'; + +class FakeEventSource { + constructor(url) { + this.url = url; + this.listeners = {}; + this.close = vi.fn(); + FakeEventSource.instances.push(this); + } + addEventListener(name, fn) { + (this.listeners[name] ||= []).push(fn); + } + emit(name, data) { + for (const fn of this.listeners[name] || []) fn({ data }); + } +} +FakeEventSource.instances = []; + +describe('createSettingsEvents', () => { + beforeEach(() => { + FakeEventSource.instances = []; + }); + + it('forwards settings events', () => { + const onChange = vi.fn(); + const sub = createSettingsEvents({ EventSourceImpl: FakeEventSource, onChange }); + sub.connect(); + FakeEventSource.instances[0].emit( + 'settings', + JSON.stringify({ settings: { 'pi-web-theme': 'nord' } }), + ); + expect(onChange).toHaveBeenCalledWith({ settings: { 'pi-web-theme': 'nord' } }); + sub.cleanup(); + }); +}); diff --git a/web/src/routes/SchedulesPage.svelte b/web/src/routes/SchedulesPage.svelte index a8f76b7a..d31d533c 100644 --- a/web/src/routes/SchedulesPage.svelte +++ b/web/src/routes/SchedulesPage.svelte @@ -35,6 +35,7 @@ defaultFetchModels, defaultFetchRecent, } from '../index/schedules.js'; + import { createSchedulesEvents } from '../index/schedules-events.js'; let schedules = $state([]); let loading = $state(true); @@ -98,8 +99,8 @@ `${String(form.hour).padStart(2, '0')}:${String(form.minute).padStart(2, '0')}`, ); - async function refresh() { - loading = true; + async function refresh({ silent = false } = {}) { + if (!silent) loading = true; loadError = ''; try { const data = await defaultFetchSchedules(); @@ -107,7 +108,7 @@ } catch (err) { loadError = err.message || String(err); } finally { - loading = false; + if (!silent) loading = false; } } @@ -123,6 +124,13 @@ recent = Array.isArray(data.locations) ? data.locations : []; }) .catch(() => {}); + const events = createSchedulesEvents({ + onChange: () => { + refresh({ silent: true }); + }, + }); + events.connect(); + return () => events.cleanup(); }); function openCreate() { diff --git a/web/src/routes/SessionsPage.svelte b/web/src/routes/SessionsPage.svelte index 26d29546..de563926 100644 --- a/web/src/routes/SessionsPage.svelte +++ b/web/src/routes/SessionsPage.svelte @@ -7,6 +7,8 @@ import ProjectsModal from '../components/index/ProjectsModal.svelte'; import SessionsList from '../components/index/SessionsList.svelte'; import { createStatusEvents } from '../shared/status-events.js'; + import { createSettingsEvents } from '../index/settings-events.js'; + import { applyRemoteSettings } from '../shared/settings-live.js'; import { openSessionPalette, refreshSessionPalette } from '../shared/command-palette-runtime.js'; import { setupKeyboardNav } from '../shared/keyboard-nav.js'; import { matchesAction } from '../shared/keybindings.js'; @@ -252,6 +254,18 @@ try { statusEvents.connect(); } catch {} + const settingsEvents = createSettingsEvents({ + onChange: (payload) => { + applyRemoteSettings(payload, { + storage: localStorage, + documentImpl: document, + windowImpl: window, + }); + }, + }); + try { + settingsEvents.connect(); + } catch {} const keydown = (e) => { if (matchesAction('toggle-theme', e)) { @@ -284,6 +298,7 @@ window.removeEventListener('keydown', keydown, { capture: true }); window.removeEventListener('click', click); statusEvents.cleanup?.(); + settingsEvents.cleanup?.(); if (reloadTimer) clearTimeout(reloadTimer); }; }); diff --git a/web/src/routes/SettingsPage.svelte b/web/src/routes/SettingsPage.svelte index a41e3cdf..ab744a1a 100644 --- a/web/src/routes/SettingsPage.svelte +++ b/web/src/routes/SettingsPage.svelte @@ -12,6 +12,8 @@ import { t } from '../shared/i18n.js'; import { navigate } from '../shared/navigation.js'; import { loadSettings, persistSetting } from '../settings/settings-support.js'; + import { createSettingsEvents } from '../index/settings-events.js'; + import { applyRemoteSettings } from '../shared/settings-live.js'; let settings = $state({}); let savedVisible = $state(false); @@ -123,10 +125,24 @@ settings = loaded || {}; }) .catch(() => {}); + const settingsEvents = createSettingsEvents({ + onChange: (payload) => { + const next = applyRemoteSettings(payload, { + storage: localStorage, + documentImpl: document, + windowImpl: window, + }); + if (next) settings = { ...settings, ...next }; + }, + }); + try { + settingsEvents.connect(); + } catch {} return () => { document.title = previousTitle; clearTimeout(savedTimer); mq?.removeEventListener('change', updateMobile); + settingsEvents.cleanup?.(); }; }); diff --git a/web/src/session/page/session-page-runtime.js b/web/src/session/page/session-page-runtime.js index f3d31813..710e5f32 100644 --- a/web/src/session/page/session-page-runtime.js +++ b/web/src/session/page/session-page-runtime.js @@ -8,6 +8,8 @@ import * as sidebarApi from '../ui/sidebar.js'; import * as searchFiltersApi from '../ui/search-filters.js'; import * as toggleStateApi from '../ui/toggle-state.js'; import { configureSettingsSync, hydrateSettings } from '../../shared/settings-store.js'; +import { applyRemoteSettings } from '../../shared/settings-live.js'; +import { createSettingsEvents } from '../../index/settings-events.js'; import { getSessionRuntime } from '../session-runtime-context.js'; export function startSessionPageRuntime({ @@ -78,8 +80,24 @@ export function startSessionPageRuntime({ ); const disposeGlobals = setupSessionGlobals({ windowImpl, documentImpl }); + const settingsEvents = createSettingsEvents({ + EventSourceImpl: windowImpl.EventSource, + windowImpl, + onChange: (payload) => { + applyRemoteSettings(payload, { + storage: windowImpl.localStorage, + documentImpl, + windowImpl, + }); + ui.toggleController.reload(); + }, + }); + try { + settingsEvents.connect(); + } catch {} return () => { + settingsEvents.cleanup?.(); disposeGlobals?.(); contentWiring.dispose?.(); }; diff --git a/web/src/shared/settings-live.js b/web/src/shared/settings-live.js new file mode 100644 index 00000000..230b5bbc --- /dev/null +++ b/web/src/shared/settings-live.js @@ -0,0 +1,58 @@ +import { applyFonts } from './fonts.js'; +import { applySettingsFromServer } from './settings-store.js'; + +const LOCALE_KEY = 'pi-web:v1:locale'; +const CUSTOM_LANGUAGES_KEY = 'pi-web:v1:custom-languages'; + +function readStored(storage, key) { + try { + return storage?.getItem(key); + } catch { + return null; + } +} + +/** + * Apply a settings SSE payload the same way the Settings UI does: + * theme and fonts update live; locale/custom-languages reload the page + * because i18n chrome is not reactive (see i18n.js). + */ +export function applyRemoteSettings( + payload, + { storage, documentImpl, windowImpl, reload = (win) => win?.location?.reload?.() } = {}, +) { + const settings = payload?.settings; + const prevLocale = readStored(storage, LOCALE_KEY); + const prevCustom = readStored(storage, CUSTOM_LANGUAGES_KEY); + const applied = applySettingsFromServer(settings, { storage }); + if (!applied) return null; + + const theme = applied['pi-web-theme']; + if (theme && documentImpl?.documentElement) { + documentImpl.documentElement.dataset.theme = theme; + try { + documentImpl.cookie = `pi-web-theme=${theme};path=/;SameSite=Lax;max-age=31536000`; + } catch { + // ignore + } + } + + if (documentImpl) { + applyFonts(documentImpl, { + ui: applied['pi-web:v1:font-ui'], + content: applied['pi-web:v1:font-content'], + code: applied['pi-web:v1:font-code'], + uiSize: applied['pi-web:v1:font-ui-size'], + contentSize: applied['pi-web:v1:font-content-size'], + }); + } + + const nextLocale = applied[LOCALE_KEY]; + const nextCustom = applied[CUSTOM_LANGUAGES_KEY]; + const localeChanged = nextLocale != null && String(nextLocale) !== String(prevLocale ?? ''); + const customChanged = nextCustom != null && String(nextCustom) !== String(prevCustom ?? ''); + if (localeChanged || customChanged) { + reload(windowImpl); + } + return applied; +} diff --git a/web/src/shared/settings-live.test.js b/web/src/shared/settings-live.test.js new file mode 100644 index 00000000..eedf7fcb --- /dev/null +++ b/web/src/shared/settings-live.test.js @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { applyRemoteSettings } from './settings-live.js'; + +function fakeStorage(seed = {}) { + const map = new Map(Object.entries(seed)); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + }; +} + +function fakeDocument() { + const props = {}; + return { + documentElement: { + dataset: {}, + style: { setProperty: vi.fn((k, v) => (props[k] = v)), _props: props }, + }, + cookie: '', + }; +} + +describe('applyRemoteSettings', () => { + it('applies theme and fonts without reloading', () => { + const storage = fakeStorage({ 'pi-web:v1:locale': 'en' }); + const documentImpl = fakeDocument(); + const reload = vi.fn(); + applyRemoteSettings( + { + settings: { + 'pi-web-theme': 'nord', + 'pi-web:v1:locale': 'en', + 'pi-web:v1:font-ui': 'sans', + }, + }, + { storage, documentImpl, windowImpl: {}, reload }, + ); + expect(documentImpl.documentElement.dataset.theme).toBe('nord'); + expect(documentImpl.documentElement.style._props['--font-sans']).toContain('Inter'); + expect(reload).not.toHaveBeenCalled(); + }); + + it('reloads when locale changes, matching the Settings language picker', () => { + const storage = fakeStorage({ 'pi-web:v1:locale': 'en' }); + const reload = vi.fn(); + applyRemoteSettings( + { settings: { 'pi-web:v1:locale': 'ja' } }, + { storage, documentImpl: fakeDocument(), windowImpl: {}, reload }, + ); + expect(storage.getItem('pi-web:v1:locale')).toBe('ja'); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('does not reload when locale is unchanged', () => { + const storage = fakeStorage({ 'pi-web:v1:locale': 'ja' }); + const reload = vi.fn(); + applyRemoteSettings( + { settings: { 'pi-web:v1:locale': 'ja', 'pi-web-theme': 'dark' } }, + { storage, documentImpl: fakeDocument(), windowImpl: {}, reload }, + ); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/shared/settings-store.js b/web/src/shared/settings-store.js index 1197ec43..d42e75f7 100644 --- a/web/src/shared/settings-store.js +++ b/web/src/shared/settings-store.js @@ -110,6 +110,20 @@ export function writeSettings(values, { storage = defaultStorage() } = {}) { if (Object.keys(toSync).length > 0) postSettings(toSync); } +export function applySettingsFromServer(settings, { storage = defaultStorage() } = {}) { + if (!settings || typeof settings !== 'object') return null; + for (const key of SERVER_SETTING_KEYS) { + if (key in settings && settings[key] != null) { + try { + storage?.setItem(key, String(settings[key])); + } catch { + // ignore + } + } + } + return settings; +} + /** * Pull server-backed settings from the server and seed the localStorage cache. * Call once on page load. Resolves to the settings object (or null on failure). @@ -124,16 +138,7 @@ export async function hydrateSettings({ fetchImpl = syncFetch, storage = default const data = await resp.json(); const settings = data && data.settings ? data.settings : null; if (!settings) return null; - for (const key of SERVER_SETTING_KEYS) { - if (key in settings && settings[key] != null) { - try { - storage?.setItem(key, String(settings[key])); - } catch { - // ignore - } - } - } - return settings; + return applySettingsFromServer(settings, { storage }); } catch { return null; } diff --git a/web/src/shared/settings-store.test.js b/web/src/shared/settings-store.test.js index b70b1b7f..d68b2179 100644 --- a/web/src/shared/settings-store.test.js +++ b/web/src/shared/settings-store.test.js @@ -6,6 +6,7 @@ import { writeSetting, writeSettings, hydrateSettings, + applySettingsFromServer, } from './settings-store.js'; function fakeStorage() { @@ -105,3 +106,15 @@ describe('hydrateSettings', () => { expect(result).toBeNull(); }); }); + +describe('applySettingsFromServer', () => { + it('writes known keys without posting', () => { + const storage = fakeStorage(); + const fetchImpl = vi.fn(); + configureSettingsSync({ fetchImpl }); + applySettingsFromServer({ 'pi-web-theme': 'light', unknown: 'x' }, { storage }); + expect(storage.getItem('pi-web-theme')).toBe('light'); + expect(storage.getItem('unknown')).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); From 5f0af8c7bead976d46927f03a7cf8ade9c0250c5 Mon Sep 17 00:00:00 2001 From: setkyar Date: Thu, 17 Sep 2026 03:17:55 +0700 Subject: [PATCH 2/2] refactor(web): multiplex skill SSE events onto one shared stream The three new subscriptions (schedules, scratchpad, settings) were byte-identical 57-line modules differing only in the event name, and each opened its own EventSource to /events?id=__all__. A session tab already holds the per-session stream, so a session page took three connections and two tabs hit the browser's six-per-host HTTP/1.1 cap, stalling fetches and saves. createAppEvents takes the event name and shares one EventSource per topic with a listener registry, closing it when the last subscriber leaves. Also skip the textarea write in applyRemote when the content already matches: our own debounced save echoes back over SSE, and reassigning value moves the caret for no reason. --- docs/architecture/frontend.md | 3 + .../components/session/RightSidebar.svelte | 7 +- .../session/right-sidebar-scratchpad.js | 3 + web/src/index/schedules-events.js | 57 -------- web/src/index/schedules-events.test.js | 45 ------- web/src/index/scratchpad-events.js | 57 -------- web/src/index/scratchpad-events.test.js | 36 ----- web/src/index/settings-events.js | 57 -------- web/src/index/settings-events.test.js | 36 ----- web/src/routes/SchedulesPage.svelte | 7 +- web/src/routes/SessionsPage.svelte | 7 +- web/src/routes/SettingsPage.svelte | 7 +- web/src/session/page/session-page-runtime.js | 7 +- web/src/shared/app-events.js | 127 ++++++++++++++++++ web/src/shared/app-events.test.js | 100 ++++++++++++++ 15 files changed, 253 insertions(+), 303 deletions(-) delete mode 100644 web/src/index/schedules-events.js delete mode 100644 web/src/index/schedules-events.test.js delete mode 100644 web/src/index/scratchpad-events.js delete mode 100644 web/src/index/scratchpad-events.test.js delete mode 100644 web/src/index/settings-events.js delete mode 100644 web/src/index/settings-events.test.js create mode 100644 web/src/shared/app-events.js create mode 100644 web/src/shared/app-events.test.js diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 82aeb039..4f122822 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -83,6 +83,9 @@ The index route listens to `/events?id=__all__` for `new-session`, `status-snaps - `web/src/shared/api.js` — JSON fetch helpers - `web/src/shared/status-events.js` — shared status SSE lifecycle +- `web/src/shared/app-events.js` — named `/events` subscriptions (schedules, scratchpad, + settings) multiplexed onto one EventSource per topic, so extra consumers do not eat + the browser's six-connection-per-host budget - `web/src/shared/storage.js` — localStorage helpers - `web/src/shared/escape.js` — HTML escaping - `web/src/shared/theme.js` — theme toggle (dark/light/nord/dracula/custom) diff --git a/web/src/components/session/RightSidebar.svelte b/web/src/components/session/RightSidebar.svelte index 4c824698..92b4ddda 100644 --- a/web/src/components/session/RightSidebar.svelte +++ b/web/src/components/session/RightSidebar.svelte @@ -6,7 +6,7 @@ import AnnotationLayer from './AnnotationLayer.svelte'; import { sessionRuntime } from '../../session/session-runtime.js'; import { createScratchpadController } from './right-sidebar-scratchpad.js'; - import { createScratchpadEvents } from '../../index/scratchpad-events.js'; + import { createAppEvents } from '../../shared/app-events.js'; let { scratchpad = '', projectPath = '', annotationConfig = {} } = $props(); @@ -131,8 +131,9 @@ }); loadScratchpad = scratchpadController.load; if (textarea) cleanups.push(scratchpadController.bind()); - const scratchpadEvents = createScratchpadEvents({ - onChange: (payload) => { + const scratchpadEvents = createAppEvents({ + event: 'scratchpad', + onEvent: (payload) => { if (!payload || payload.project !== projectPath) return; scratchpadController.applyRemote(payload.content ?? ''); }, diff --git a/web/src/components/session/right-sidebar-scratchpad.js b/web/src/components/session/right-sidebar-scratchpad.js index d1fdf9d5..19792f1f 100644 --- a/web/src/components/session/right-sidebar-scratchpad.js +++ b/web/src/components/session/right-sidebar-scratchpad.js @@ -68,6 +68,9 @@ export function createScratchpadController({ if (!textarea) return false; if (isDirty()) return false; const next = content ?? ''; + // Our own debounced save echoes back over SSE; reassigning value would + // move the caret for no reason. + if (textarea.value === next) return true; textarea.value = next; lastSaved = next; setStatus('Saved', 'saved'); diff --git a/web/src/index/schedules-events.js b/web/src/index/schedules-events.js deleted file mode 100644 index 0505939a..00000000 --- a/web/src/index/schedules-events.js +++ /dev/null @@ -1,57 +0,0 @@ -function parseJSON(data) { - try { - return JSON.parse(data); - } catch { - return null; - } -} - -export function createSchedulesEvents({ - topic = '__all__', - EventSourceImpl = globalThis.EventSource, - windowImpl = globalThis.window, - onChange = () => {}, -} = {}) { - let stream = null; - let pagehideHandler = null; - let pageshowHandler = null; - - function closeStream() { - if (stream) { - stream.close(); - stream = null; - } - } - - function cleanup() { - closeStream(); - if (pagehideHandler && windowImpl?.removeEventListener) { - windowImpl.removeEventListener('pagehide', pagehideHandler); - pagehideHandler = null; - } - if (pageshowHandler && windowImpl?.removeEventListener) { - windowImpl.removeEventListener('pageshow', pageshowHandler); - pageshowHandler = null; - } - } - - function connect() { - if (!EventSourceImpl) return; - cleanup(); - const es = new EventSourceImpl(`/events?id=${encodeURIComponent(topic)}`); - stream = es; - es.addEventListener('schedules', (event) => { - onChange(parseJSON(event.data)); - }); - if (windowImpl?.addEventListener) { - pagehideHandler = () => closeStream(); - pageshowHandler = () => { - if (!stream) connect(); - }; - windowImpl.addEventListener('pagehide', pagehideHandler); - windowImpl.addEventListener('pageshow', pageshowHandler); - } - } - - return { connect, cleanup }; -} diff --git a/web/src/index/schedules-events.test.js b/web/src/index/schedules-events.test.js deleted file mode 100644 index ec8d21ef..00000000 --- a/web/src/index/schedules-events.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { createSchedulesEvents } from './schedules-events.js'; - -class FakeEventSource { - constructor(url) { - this.url = url; - this.listeners = {}; - this.close = vi.fn(); - FakeEventSource.instances.push(this); - } - addEventListener(name, fn) { - (this.listeners[name] ||= []).push(fn); - } - emit(name, data) { - for (const fn of this.listeners[name] || []) fn({ data }); - } -} -FakeEventSource.instances = []; - -describe('createSchedulesEvents', () => { - beforeEach(() => { - FakeEventSource.instances = []; - }); - - it('subscribes to __all__ and forwards schedules events', () => { - const onChange = vi.fn(); - const sub = createSchedulesEvents({ - EventSourceImpl: FakeEventSource, - onChange, - }); - sub.connect(); - - const es = FakeEventSource.instances[0]; - expect(es.url).toBe('/events?id=__all__'); - - es.emit('schedules', JSON.stringify({ action: 'created', id: 'abc' })); - expect(onChange).toHaveBeenCalledWith({ action: 'created', id: 'abc' }); - - es.emit('schedules', 'not-json'); - expect(onChange).toHaveBeenLastCalledWith(null); - - sub.cleanup(); - expect(es.close).toHaveBeenCalled(); - }); -}); diff --git a/web/src/index/scratchpad-events.js b/web/src/index/scratchpad-events.js deleted file mode 100644 index 0f108992..00000000 --- a/web/src/index/scratchpad-events.js +++ /dev/null @@ -1,57 +0,0 @@ -function parseJSON(data) { - try { - return JSON.parse(data); - } catch { - return null; - } -} - -export function createScratchpadEvents({ - topic = '__all__', - EventSourceImpl = globalThis.EventSource, - windowImpl = globalThis.window, - onChange = () => {}, -} = {}) { - let stream = null; - let pagehideHandler = null; - let pageshowHandler = null; - - function closeStream() { - if (stream) { - stream.close(); - stream = null; - } - } - - function cleanup() { - closeStream(); - if (pagehideHandler && windowImpl?.removeEventListener) { - windowImpl.removeEventListener('pagehide', pagehideHandler); - pagehideHandler = null; - } - if (pageshowHandler && windowImpl?.removeEventListener) { - windowImpl.removeEventListener('pageshow', pageshowHandler); - pageshowHandler = null; - } - } - - function connect() { - if (!EventSourceImpl) return; - cleanup(); - const es = new EventSourceImpl(`/events?id=${encodeURIComponent(topic)}`); - stream = es; - es.addEventListener('scratchpad', (event) => { - onChange(parseJSON(event.data)); - }); - if (windowImpl?.addEventListener) { - pagehideHandler = () => closeStream(); - pageshowHandler = () => { - if (!stream) connect(); - }; - windowImpl.addEventListener('pagehide', pagehideHandler); - windowImpl.addEventListener('pageshow', pageshowHandler); - } - } - - return { connect, cleanup }; -} diff --git a/web/src/index/scratchpad-events.test.js b/web/src/index/scratchpad-events.test.js deleted file mode 100644 index 6a194c38..00000000 --- a/web/src/index/scratchpad-events.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { createScratchpadEvents } from './scratchpad-events.js'; - -class FakeEventSource { - constructor(url) { - this.url = url; - this.listeners = {}; - this.close = vi.fn(); - FakeEventSource.instances.push(this); - } - addEventListener(name, fn) { - (this.listeners[name] ||= []).push(fn); - } - emit(name, data) { - for (const fn of this.listeners[name] || []) fn({ data }); - } -} -FakeEventSource.instances = []; - -describe('createScratchpadEvents', () => { - beforeEach(() => { - FakeEventSource.instances = []; - }); - - it('forwards scratchpad events', () => { - const onChange = vi.fn(); - const sub = createScratchpadEvents({ EventSourceImpl: FakeEventSource, onChange }); - sub.connect(); - FakeEventSource.instances[0].emit( - 'scratchpad', - JSON.stringify({ project: '/p', content: 'hi' }), - ); - expect(onChange).toHaveBeenCalledWith({ project: '/p', content: 'hi' }); - sub.cleanup(); - }); -}); diff --git a/web/src/index/settings-events.js b/web/src/index/settings-events.js deleted file mode 100644 index c6ae7f64..00000000 --- a/web/src/index/settings-events.js +++ /dev/null @@ -1,57 +0,0 @@ -function parseJSON(data) { - try { - return JSON.parse(data); - } catch { - return null; - } -} - -export function createSettingsEvents({ - topic = '__all__', - EventSourceImpl = globalThis.EventSource, - windowImpl = globalThis.window, - onChange = () => {}, -} = {}) { - let stream = null; - let pagehideHandler = null; - let pageshowHandler = null; - - function closeStream() { - if (stream) { - stream.close(); - stream = null; - } - } - - function cleanup() { - closeStream(); - if (pagehideHandler && windowImpl?.removeEventListener) { - windowImpl.removeEventListener('pagehide', pagehideHandler); - pagehideHandler = null; - } - if (pageshowHandler && windowImpl?.removeEventListener) { - windowImpl.removeEventListener('pageshow', pageshowHandler); - pageshowHandler = null; - } - } - - function connect() { - if (!EventSourceImpl) return; - cleanup(); - const es = new EventSourceImpl(`/events?id=${encodeURIComponent(topic)}`); - stream = es; - es.addEventListener('settings', (event) => { - onChange(parseJSON(event.data)); - }); - if (windowImpl?.addEventListener) { - pagehideHandler = () => closeStream(); - pageshowHandler = () => { - if (!stream) connect(); - }; - windowImpl.addEventListener('pagehide', pagehideHandler); - windowImpl.addEventListener('pageshow', pageshowHandler); - } - } - - return { connect, cleanup }; -} diff --git a/web/src/index/settings-events.test.js b/web/src/index/settings-events.test.js deleted file mode 100644 index 59e51c11..00000000 --- a/web/src/index/settings-events.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { createSettingsEvents } from './settings-events.js'; - -class FakeEventSource { - constructor(url) { - this.url = url; - this.listeners = {}; - this.close = vi.fn(); - FakeEventSource.instances.push(this); - } - addEventListener(name, fn) { - (this.listeners[name] ||= []).push(fn); - } - emit(name, data) { - for (const fn of this.listeners[name] || []) fn({ data }); - } -} -FakeEventSource.instances = []; - -describe('createSettingsEvents', () => { - beforeEach(() => { - FakeEventSource.instances = []; - }); - - it('forwards settings events', () => { - const onChange = vi.fn(); - const sub = createSettingsEvents({ EventSourceImpl: FakeEventSource, onChange }); - sub.connect(); - FakeEventSource.instances[0].emit( - 'settings', - JSON.stringify({ settings: { 'pi-web-theme': 'nord' } }), - ); - expect(onChange).toHaveBeenCalledWith({ settings: { 'pi-web-theme': 'nord' } }); - sub.cleanup(); - }); -}); diff --git a/web/src/routes/SchedulesPage.svelte b/web/src/routes/SchedulesPage.svelte index d31d533c..3b328a05 100644 --- a/web/src/routes/SchedulesPage.svelte +++ b/web/src/routes/SchedulesPage.svelte @@ -35,7 +35,7 @@ defaultFetchModels, defaultFetchRecent, } from '../index/schedules.js'; - import { createSchedulesEvents } from '../index/schedules-events.js'; + import { createAppEvents } from '../shared/app-events.js'; let schedules = $state([]); let loading = $state(true); @@ -124,8 +124,9 @@ recent = Array.isArray(data.locations) ? data.locations : []; }) .catch(() => {}); - const events = createSchedulesEvents({ - onChange: () => { + const events = createAppEvents({ + event: 'schedules', + onEvent: () => { refresh({ silent: true }); }, }); diff --git a/web/src/routes/SessionsPage.svelte b/web/src/routes/SessionsPage.svelte index de563926..cd69357a 100644 --- a/web/src/routes/SessionsPage.svelte +++ b/web/src/routes/SessionsPage.svelte @@ -7,7 +7,7 @@ import ProjectsModal from '../components/index/ProjectsModal.svelte'; import SessionsList from '../components/index/SessionsList.svelte'; import { createStatusEvents } from '../shared/status-events.js'; - import { createSettingsEvents } from '../index/settings-events.js'; + import { createAppEvents } from '../shared/app-events.js'; import { applyRemoteSettings } from '../shared/settings-live.js'; import { openSessionPalette, refreshSessionPalette } from '../shared/command-palette-runtime.js'; import { setupKeyboardNav } from '../shared/keyboard-nav.js'; @@ -254,8 +254,9 @@ try { statusEvents.connect(); } catch {} - const settingsEvents = createSettingsEvents({ - onChange: (payload) => { + const settingsEvents = createAppEvents({ + event: 'settings', + onEvent: (payload) => { applyRemoteSettings(payload, { storage: localStorage, documentImpl: document, diff --git a/web/src/routes/SettingsPage.svelte b/web/src/routes/SettingsPage.svelte index ab744a1a..14851b08 100644 --- a/web/src/routes/SettingsPage.svelte +++ b/web/src/routes/SettingsPage.svelte @@ -12,7 +12,7 @@ import { t } from '../shared/i18n.js'; import { navigate } from '../shared/navigation.js'; import { loadSettings, persistSetting } from '../settings/settings-support.js'; - import { createSettingsEvents } from '../index/settings-events.js'; + import { createAppEvents } from '../shared/app-events.js'; import { applyRemoteSettings } from '../shared/settings-live.js'; let settings = $state({}); @@ -125,8 +125,9 @@ settings = loaded || {}; }) .catch(() => {}); - const settingsEvents = createSettingsEvents({ - onChange: (payload) => { + const settingsEvents = createAppEvents({ + event: 'settings', + onEvent: (payload) => { const next = applyRemoteSettings(payload, { storage: localStorage, documentImpl: document, diff --git a/web/src/session/page/session-page-runtime.js b/web/src/session/page/session-page-runtime.js index 710e5f32..1617ded8 100644 --- a/web/src/session/page/session-page-runtime.js +++ b/web/src/session/page/session-page-runtime.js @@ -9,7 +9,7 @@ import * as searchFiltersApi from '../ui/search-filters.js'; import * as toggleStateApi from '../ui/toggle-state.js'; import { configureSettingsSync, hydrateSettings } from '../../shared/settings-store.js'; import { applyRemoteSettings } from '../../shared/settings-live.js'; -import { createSettingsEvents } from '../../index/settings-events.js'; +import { createAppEvents } from '../../shared/app-events.js'; import { getSessionRuntime } from '../session-runtime-context.js'; export function startSessionPageRuntime({ @@ -80,10 +80,11 @@ export function startSessionPageRuntime({ ); const disposeGlobals = setupSessionGlobals({ windowImpl, documentImpl }); - const settingsEvents = createSettingsEvents({ + const settingsEvents = createAppEvents({ + event: 'settings', EventSourceImpl: windowImpl.EventSource, windowImpl, - onChange: (payload) => { + onEvent: (payload) => { applyRemoteSettings(payload, { storage: windowImpl.localStorage, documentImpl, diff --git a/web/src/shared/app-events.js b/web/src/shared/app-events.js new file mode 100644 index 00000000..437c5524 --- /dev/null +++ b/web/src/shared/app-events.js @@ -0,0 +1,127 @@ +// Shared `/events` subscriptions for named SSE events (schedules, scratchpad, +// settings). Consumers share one EventSource per topic: browsers cap parallel +// HTTP/1.1 connections per host at six, and a session tab already holds the +// per-session stream, so a stream per consumer starves fetches once a couple of +// tabs are open. +function parseJSON(data) { + try { + return JSON.parse(data); + } catch { + return null; + } +} + +const streamsByImpl = new Map(); + +function topicsFor(EventSourceImpl) { + let topics = streamsByImpl.get(EventSourceImpl); + if (!topics) { + topics = new Map(); + streamsByImpl.set(EventSourceImpl, topics); + } + return topics; +} + +function attachDispatcher(entry, event) { + if (entry.attached.has(event)) return; + entry.attached.add(event); + entry.stream.addEventListener(event, (message) => { + const payload = parseJSON(message.data); + for (const handler of entry.listeners.get(event) ?? []) handler(payload); + }); +} + +function openStream(EventSourceImpl, topic, entry) { + if (!entry.stream) { + entry.stream = new EventSourceImpl(`/events?id=${encodeURIComponent(topic)}`); + entry.attached = new Set(); + bindPageLifecycle(EventSourceImpl, topic, entry); + } + for (const event of entry.listeners.keys()) attachDispatcher(entry, event); +} + +function closeStream(entry) { + if (!entry.stream) return; + entry.stream.close(); + entry.stream = null; + entry.attached = new Set(); +} + +// `beforeunload` would opt the page out of the bfcache; `pagehide`/`pageshow` +// drop and restore the stream without hurting back/forward navigation. +function bindPageLifecycle(EventSourceImpl, topic, entry) { + const windowImpl = entry.windowImpl; + if (!windowImpl?.addEventListener || entry.pagehideHandler) return; + entry.pagehideHandler = () => closeStream(entry); + entry.pageshowHandler = () => { + if (!entry.stream) openStream(EventSourceImpl, topic, entry); + }; + windowImpl.addEventListener('pagehide', entry.pagehideHandler); + windowImpl.addEventListener('pageshow', entry.pageshowHandler); +} + +function unbindPageLifecycle(entry) { + const windowImpl = entry.windowImpl; + if (!windowImpl?.removeEventListener) return; + if (entry.pagehideHandler) windowImpl.removeEventListener('pagehide', entry.pagehideHandler); + if (entry.pageshowHandler) windowImpl.removeEventListener('pageshow', entry.pageshowHandler); + entry.pagehideHandler = null; + entry.pageshowHandler = null; +} + +/** + * Subscribe to one named SSE event on the shared `/events` stream for `topic`. + * Returns the same `{ connect, cleanup }` shape as createStatusEvents. + */ +export function createAppEvents({ + event, + topic = '__all__', + EventSourceImpl = globalThis.EventSource, + windowImpl = globalThis.window, + onEvent = () => {}, +} = {}) { + let subscribed = false; + + function connect() { + if (subscribed || !EventSourceImpl || !event) return; + subscribed = true; + const topics = topicsFor(EventSourceImpl); + let entry = topics.get(topic); + if (!entry) { + entry = { + stream: null, + attached: new Set(), + listeners: new Map(), + windowImpl, + pagehideHandler: null, + pageshowHandler: null, + }; + topics.set(topic, entry); + } + let handlers = entry.listeners.get(event); + if (!handlers) { + handlers = new Set(); + entry.listeners.set(event, handlers); + } + handlers.add(onEvent); + openStream(EventSourceImpl, topic, entry); + } + + function cleanup() { + if (!subscribed) return; + subscribed = false; + const topics = streamsByImpl.get(EventSourceImpl); + const entry = topics?.get(topic); + if (!entry) return; + const handlers = entry.listeners.get(event); + handlers?.delete(onEvent); + if (handlers && handlers.size === 0) entry.listeners.delete(event); + if (entry.listeners.size > 0) return; + closeStream(entry); + unbindPageLifecycle(entry); + topics.delete(topic); + if (topics.size === 0) streamsByImpl.delete(EventSourceImpl); + } + + return { connect, cleanup }; +} diff --git a/web/src/shared/app-events.test.js b/web/src/shared/app-events.test.js new file mode 100644 index 00000000..f60fb12b --- /dev/null +++ b/web/src/shared/app-events.test.js @@ -0,0 +1,100 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { createAppEvents } from './app-events.js'; + +class FakeEventSource { + constructor(url) { + this.url = url; + this.listeners = {}; + this.close = vi.fn(); + FakeEventSource.instances.push(this); + } + addEventListener(name, fn) { + (this.listeners[name] ||= []).push(fn); + } + emit(name, data) { + for (const fn of this.listeners[name] || []) fn({ data }); + } +} +FakeEventSource.instances = []; + +function fakeWindow() { + const handlers = {}; + return { + handlers, + addEventListener: (name, fn) => (handlers[name] ||= []).push(fn), + removeEventListener: (name, fn) => { + handlers[name] = (handlers[name] || []).filter((h) => h !== fn); + }, + fire: (name) => { + for (const fn of handlers[name] || []) fn(); + }, + }; +} + +function subscribe(event, onEvent, windowImpl = fakeWindow()) { + const sub = createAppEvents({ event, onEvent, EventSourceImpl: FakeEventSource, windowImpl }); + sub.connect(); + return sub; +} + +describe('createAppEvents', () => { + beforeEach(() => { + FakeEventSource.instances = []; + }); + + it('subscribes to __all__ and forwards parsed payloads', () => { + const onEvent = vi.fn(); + const sub = subscribe('schedules', onEvent); + + const es = FakeEventSource.instances[0]; + expect(es.url).toBe('/events?id=__all__'); + + es.emit('schedules', JSON.stringify({ action: 'created', id: 'abc' })); + expect(onEvent).toHaveBeenCalledWith({ action: 'created', id: 'abc' }); + + es.emit('schedules', 'not-json'); + expect(onEvent).toHaveBeenLastCalledWith(null); + + sub.cleanup(); + expect(es.close).toHaveBeenCalled(); + }); + + it('shares one stream across events and closes it with the last listener', () => { + const onSettings = vi.fn(); + const onScratchpad = vi.fn(); + const windowImpl = fakeWindow(); + const settings = subscribe('settings', onSettings, windowImpl); + const scratchpad = subscribe('scratchpad', onScratchpad, windowImpl); + + expect(FakeEventSource.instances).toHaveLength(1); + const es = FakeEventSource.instances[0]; + es.emit('settings', JSON.stringify({ settings: { 'pi-web-theme': 'nord' } })); + es.emit('scratchpad', JSON.stringify({ project: '/p', content: 'hi' })); + expect(onSettings).toHaveBeenCalledWith({ settings: { 'pi-web-theme': 'nord' } }); + expect(onScratchpad).toHaveBeenCalledWith({ project: '/p', content: 'hi' }); + + settings.cleanup(); + expect(es.close).not.toHaveBeenCalled(); + es.emit('scratchpad', JSON.stringify({ project: '/p', content: 'still live' })); + expect(onScratchpad).toHaveBeenLastCalledWith({ project: '/p', content: 'still live' }); + + scratchpad.cleanup(); + expect(es.close).toHaveBeenCalled(); + }); + + it('drops the stream on pagehide and reconnects on pageshow', () => { + const onEvent = vi.fn(); + const windowImpl = fakeWindow(); + const sub = subscribe('settings', onEvent, windowImpl); + + windowImpl.fire('pagehide'); + expect(FakeEventSource.instances[0].close).toHaveBeenCalled(); + + windowImpl.fire('pageshow'); + expect(FakeEventSource.instances).toHaveLength(2); + FakeEventSource.instances[1].emit('settings', JSON.stringify({ settings: {} })); + expect(onEvent).toHaveBeenCalledWith({ settings: {} }); + + sub.cleanup(); + }); +});