pi-widget-host 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ This project follows semantic versioning.
6
+
7
+ ## [0.2.0] - 2026-06-15
8
+
9
+ ### Added
10
+
11
+ - Shared-slot host extension with `/widget-host:setup`, `/widget-host:status`, `/widget-host:policy`, `/widget-host:providers`, `/widget-host:mute`, and `/widget-host:unmute`.
12
+ - User-global host config under `~/.pi/agent/pi-widget-host-config.json`.
13
+ - `globalThis` registry protocol for provider publish/list/remove/subscribe flows.
14
+ - Built-in demo provider with silent empty slot behavior.
15
+ - Preset-first time block policy plus event boost for `playing-now` and `matchday`.
16
+ - Protocol and package docs for the Host-only MVP.
17
+
18
+ ### Changed
19
+
20
+ - Replaced template placeholders and removed template-only skill, prompt, and theme resources.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Keisu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # Pi Widget Host
2
+
3
+ [![CI](https://github.com/eiei114/pi-widget-host/actions/workflows/ci.yml/badge.svg)](https://github.com/eiei114/pi-widget-host/actions/workflows/ci.yml)
4
+ [![Publish](https://github.com/eiei114/pi-widget-host/actions/workflows/publish.yml/badge.svg)](https://github.com/eiei114/pi-widget-host/actions/workflows/publish.yml)
5
+ [![npm version](https://img.shields.io/npm/v/pi-widget-host.svg)](https://www.npmjs.com/package/pi-widget-host)
6
+ [![npm downloads](https://img.shields.io/npm/dm/pi-widget-host.svg)](https://www.npmjs.com/package/pi-widget-host)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![Pi package](https://img.shields.io/badge/pi-package-purple.svg)](https://pi.dev/packages)
9
+ [![Trusted Publishing](https://img.shields.io/badge/npm-Trusted%20Publishing-blue.svg)](docs/release.md)
10
+
11
+ > Shared-slot host for managing one prompt-top Pi widget across multiple providers.
12
+
13
+ ## What this is
14
+
15
+ `pi-widget-host` owns one shared widget slot in Pi and picks a single winner from registered providers.
16
+
17
+ v1 is a **Host-only MVP**:
18
+
19
+ - user-global config under `~/.pi/agent/`
20
+ - preset-first time-block policy
21
+ - `globalThis` registry protocol for future provider packages
22
+ - built-in demo provider for setup and dogfooding
23
+ - silent empty slot when nothing should render
24
+
25
+ ## Features
26
+
27
+ - `/widget-host:setup` guided first-run flow
28
+ - `/widget-host:status`, `/widget-host:policy`, `/widget-host:providers`
29
+ - `/widget-host:mute` and `/widget-host:unmute`
30
+ - event boost for `playing-now` and `matchday`
31
+ - known host tags: `music`, `sports`, `playing-now`, `matchday`, `idle`
32
+
33
+ ## Install
34
+
35
+ Published package:
36
+
37
+ ```bash
38
+ pi install npm:pi-widget-host
39
+ ```
40
+
41
+ GitHub install:
42
+
43
+ ```bash
44
+ pi install git:github.com/eiei114/pi-widget-host
45
+ ```
46
+
47
+ Local dev:
48
+
49
+ ```bash
50
+ pi -e .
51
+ ```
52
+
53
+ ## Quick start
54
+
55
+ Run the host locally, then open these commands:
56
+
57
+ ```txt
58
+ /widget-host:setup
59
+ /widget-host:status
60
+ /widget-host:providers
61
+ ```
62
+
63
+ `/widget-host:setup` asks whether to enable the built-in demo provider and which preset policy to save.
64
+
65
+ ## Commands
66
+
67
+ - `/widget-host:setup` — save initial preset and optionally enable the demo provider
68
+ - `/widget-host:status` — report setup status, preset, and active provider
69
+ - `/widget-host:policy` — show or change the saved preset
70
+ - `/widget-host:providers` — inspect known provider entries and effective state
71
+ - `/widget-host:mute` — mute one provider in host config
72
+ - `/widget-host:unmute` — unmute one provider in host config
73
+
74
+ Commands take no inline arguments. Input is collected with Pi UI prompts.
75
+
76
+ ## Registry protocol
77
+
78
+ Future provider packages can publish to the host without importing this package at runtime.
79
+
80
+ - backing: `globalThis`
81
+ - symbol: `Symbol.for("pi-widget-host.registry.v1")`
82
+ - required fields: `providerId`, `available`, `lines`, `updatedAt`
83
+ - optional fields: `priority`, `tags`, `mode`, `ttlMs`
84
+
85
+ See [`docs/protocol.md`](docs/protocol.md).
86
+
87
+ ## Built-in demo provider
88
+
89
+ The built-in demo provider exists to prove the host loop first:
90
+
91
+ - setup enables it explicitly
92
+ - it publishes through the same registry protocol as future packages
93
+ - it renders provider-owned lines through the shared host slot
94
+ - if muted, disabled, or filtered out by policy, the slot disappears
95
+
96
+ ## Package contents
97
+
98
+ | Path | Purpose |
99
+ |---|---|
100
+ | `extensions/` | Pi extension entrypoint and command registration |
101
+ | `lib/` | config store, registry protocol, policy evaluation, demo provider |
102
+ | `docs/` | supporting docs such as release and protocol notes |
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ npm install
108
+ npm run ci
109
+ ```
110
+
111
+ ## Release
112
+
113
+ This package uses npm Trusted Publishing.
114
+
115
+ ```bash
116
+ npm version patch
117
+ git push
118
+ ```
119
+
120
+ See [`docs/release.md`](docs/release.md).
121
+
122
+ ## Security
123
+
124
+ Pi packages execute with your local permissions. Review extensions before installing third-party packages.
125
+
126
+ For vulnerability reporting, see [`SECURITY.md`](SECURITY.md).
127
+
128
+ ## Links
129
+
130
+ - npm: https://www.npmjs.com/package/pi-widget-host
131
+ - GitHub: https://github.com/eiei114/pi-widget-host
132
+ - Issues: https://github.com/eiei114/pi-widget-host/issues
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,53 @@
1
+ # Registry protocol
2
+
3
+ `pi-widget-host` exposes a process-local provider registry on `globalThis`.
4
+
5
+ ## Symbol
6
+
7
+ ```ts
8
+ Symbol.for("pi-widget-host.registry.v1")
9
+ ```
10
+
11
+ ## Registry shape
12
+
13
+ ```ts
14
+ type ProviderEntry = {
15
+ providerId: string;
16
+ available: boolean;
17
+ lines: string[];
18
+ updatedAt: string;
19
+ priority?: number;
20
+ tags?: string[];
21
+ mode?: string;
22
+ ttlMs?: number;
23
+ };
24
+
25
+ type WidgetHostRegistry = {
26
+ version: 1;
27
+ set(entry: ProviderEntry): void;
28
+ remove(providerId: string): void;
29
+ list(): ProviderEntry[];
30
+ subscribe(listener: () => void): () => void;
31
+ };
32
+ ```
33
+
34
+ ## Required fields
35
+
36
+ - `providerId`
37
+ - `available`
38
+ - `lines`
39
+ - `updatedAt`
40
+
41
+ ## Optional fields
42
+
43
+ - `priority`
44
+ - `tags`
45
+ - `mode`
46
+ - `ttlMs`
47
+
48
+ ## Notes
49
+
50
+ - unknown tags are ignored safely by the host
51
+ - `ttlMs` + `updatedAt` drive stale exclusion
52
+ - providers render final lines themselves; the host only picks a winner
53
+ - the host can mute providers from config without uninstalling the package
@@ -0,0 +1,57 @@
1
+ # Release
2
+
3
+ This package uses npm Trusted Publishing with GitHub Actions OIDC.
4
+
5
+ Do not add `NPM_TOKEN` or long-lived npm tokens to GitHub Secrets.
6
+
7
+ ## One-time npm setup
8
+
9
+ On npmjs.com, configure Trusted Publishing for this package:
10
+
11
+ - Publisher: GitHub Actions
12
+ - Repository: this GitHub repository
13
+ - Workflow filename: `publish.yml`
14
+
15
+ ## Publish
16
+
17
+ ```bash
18
+ npm version patch
19
+ git push
20
+ ```
21
+
22
+ On `main`, `.github/workflows/auto-release.yml` checks `package.json` version. If `v<version>` does not exist yet, it creates the tag, creates the GitHub Release, then explicitly dispatches `.github/workflows/publish.yml` for that tag.
23
+
24
+ The `v*.*.*` tag also triggers `.github/workflows/publish.yml`, which runs CI and publishes to npm when tags are pushed manually.
25
+ Publishing also runs when a GitHub Release is published, and can be run manually from GitHub Actions with `workflow_dispatch`.
26
+
27
+ The workflow skips `name@version` if that exact package version already exists on npm.
28
+
29
+ ## Workflow guardrail
30
+
31
+ Do not ship a new Pi OSS package or version bump with only `package.json` changes.
32
+ The repository must include the release workflow pair:
33
+
34
+ - `.github/workflows/auto-release.yml` creates `v<version>` tags and GitHub Releases from `main` version bumps.
35
+ - `.github/workflows/publish.yml` publishes to npm through Trusted Publishing.
36
+
37
+ Important: tags or releases created by `GITHUB_TOKEN` do not reliably fan out into another workflow through normal `push.tags` or `release.published` triggers. The template keeps publishing reliable by having `auto-release.yml` explicitly dispatch `publish.yml` after creating the tag/release. If you change the release flow, keep one explicit handoff path: `workflow_dispatch` from auto-release, `repository_dispatch`, or `workflow_run` on the auto-release workflow.
38
+
39
+ ## GitHub Actions requirements
40
+
41
+ - `permissions: id-token: write`
42
+ - `permissions: actions: write` on auto-release so it can dispatch `publish.yml`
43
+ - `auto-release.yml` must call `gh workflow run publish.yml --ref "$TAG" -f ref="$TAG"`, or `publish.yml` must have an equivalent explicit handoff trigger such as `workflow_run`
44
+ - GitHub-hosted runner
45
+ - Node.js 24, so the release job uses a current npm CLI for Trusted Publishing
46
+ - No `NPM_TOKEN`
47
+ - `npm publish` from the configured workflow file
48
+
49
+ ## First release checklist
50
+
51
+ - [ ] `package.json` name is final
52
+ - [ ] `repository.url` points to the real GitHub repository
53
+ - [ ] npm Trusted Publisher is configured
54
+ - [ ] `npm run ci` passes
55
+ - [ ] `npm pack --dry-run` contains only intended files
56
+ - [ ] CHANGELOG.md has the release date
57
+
@@ -0,0 +1,258 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { readHostConfig, updateHostConfig, writeHostConfig } from "../lib/config.ts";
3
+ import { stopDemoProviderHeartbeat, syncBuiltInDemoProvider } from "../lib/demo-provider.ts";
4
+ import { DEFAULT_PRESET_ID, describePreset, evaluateProviderEntries, getPreset, getRemainingTtlMs, PRESET_OPTIONS } from "../lib/policy.ts";
5
+ import { getWidgetHostRegistry, listProviderEntries, removeProviderEntry } from "../lib/registry.ts";
6
+ import { DEMO_PROVIDER_ID, WIDGET_ID, type HostConfig, type PolicyEvaluationResult, type ProviderState } from "../lib/types.ts";
7
+
8
+ type NotifyLevel = "info" | "warning" | "error";
9
+
10
+ interface HostUiBridge {
11
+ setWidget: (id: string, lines: string[] | undefined) => void;
12
+ notify: (message: string, level: NotifyLevel) => void;
13
+ setStatus: (key: string, value: string | undefined) => void;
14
+ select: (prompt: string, options: string[]) => Promise<string | undefined>;
15
+ }
16
+
17
+ let uiBridge: HostUiBridge | undefined;
18
+ let registryDispose: (() => void) | undefined;
19
+ let staleTimer: NodeJS.Timeout | undefined;
20
+
21
+ function bindUi(ctx: { hasUI: boolean; ui: HostUiBridge }): void {
22
+ if (!ctx.hasUI) return;
23
+ uiBridge = ctx.ui;
24
+ }
25
+
26
+ function clearStaleTimer(): void {
27
+ if (!staleTimer) return;
28
+ clearTimeout(staleTimer);
29
+ staleTimer = undefined;
30
+ }
31
+
32
+ function summarizeProvider(state: ProviderState): string {
33
+ const tags = state.knownTags.length > 0 ? state.knownTags.join(",") : "none";
34
+ const detail = state.reasons.length > 0 ? ` | ${state.reasons.join("; ")}` : "";
35
+ return `${state.providerId} [${state.effectiveStatus}] prio=${state.effectivePriority} tags=${tags}${detail}`;
36
+ }
37
+
38
+ function summarizeStatus(config: HostConfig, result: PolicyEvaluationResult): string {
39
+ const active = result.activeProvider?.providerId ?? "none";
40
+ return [
41
+ `Setup: ${config.setupComplete ? "done" : "not yet"}`,
42
+ `Preset: ${result.preset.label} (${result.timeBlock})`,
43
+ `Demo provider: ${config.demoProviderEnabled ? "enabled" : "disabled"}`,
44
+ `Active provider: ${active}`,
45
+ ].join("\n");
46
+ }
47
+
48
+ function summarizeProviders(result: PolicyEvaluationResult): string {
49
+ if (result.providerStates.length === 0) {
50
+ return `Providers (${result.timeBlock}): none registered`;
51
+ }
52
+
53
+ return [`Providers (${result.timeBlock}):`, ...result.providerStates.map((state) => `- ${summarizeProvider(state)}`)].join("\n");
54
+ }
55
+
56
+ async function refreshHostWidget(ctx?: { hasUI: boolean; ui: HostUiBridge }): Promise<{ config: HostConfig; result: PolicyEvaluationResult }> {
57
+ if (ctx) bindUi(ctx);
58
+
59
+ const config = await readHostConfig();
60
+ await syncBuiltInDemoProvider(config);
61
+
62
+ const result = evaluateProviderEntries(listProviderEntries(), config, new Date());
63
+ uiBridge?.setWidget(WIDGET_ID, result.activeProvider?.lines);
64
+ uiBridge?.setStatus("widget-host", `${result.preset.label} | active: ${result.activeProvider?.providerId ?? "none"}`);
65
+
66
+ clearStaleTimer();
67
+ const nextTtlMs = result.providerStates
68
+ .map((state) => getRemainingTtlMs(state, Date.now()))
69
+ .filter((value): value is number => typeof value === "number" && value > 0)
70
+ .sort((a, b) => a - b)[0];
71
+
72
+ if (typeof nextTtlMs === "number") {
73
+ staleTimer = setTimeout(() => {
74
+ void refreshHostWidget().catch(() => undefined);
75
+ }, Math.max(nextTtlMs + 25, 25));
76
+ }
77
+
78
+ return { config, result };
79
+ }
80
+
81
+ function ensureRegistryWatcher(): void {
82
+ if (registryDispose) return;
83
+ registryDispose = getWidgetHostRegistry().subscribe(() => {
84
+ void refreshHostWidget().catch(() => undefined);
85
+ });
86
+ }
87
+
88
+ async function selectPreset(ctx: { ui: Pick<HostUiBridge, "select"> }, prompt: string): Promise<string | undefined> {
89
+ const labels = PRESET_OPTIONS.map((preset) => `${preset.label} — ${preset.description}`);
90
+ const picked = await ctx.ui.select(prompt, labels);
91
+ if (!picked) return undefined;
92
+ const index = labels.indexOf(picked);
93
+ return index >= 0 ? PRESET_OPTIONS[index]?.id : undefined;
94
+ }
95
+
96
+ function ensureInteractive(ctx: { hasUI: boolean }): boolean {
97
+ if (ctx.hasUI) return true;
98
+ uiBridge?.notify("widget-host commands need Pi UI mode.", "warning");
99
+ return false;
100
+ }
101
+
102
+ export default function widgetHostExtension(pi: ExtensionAPI) {
103
+ pi.on("session_start", async (_event, ctx) => {
104
+ if (!ctx.hasUI) return;
105
+ bindUi(ctx as { hasUI: boolean; ui: HostUiBridge });
106
+ ensureRegistryWatcher();
107
+ await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
108
+ });
109
+
110
+ pi.on("session_shutdown", async () => {
111
+ clearStaleTimer();
112
+ registryDispose?.();
113
+ registryDispose = undefined;
114
+ stopDemoProviderHeartbeat();
115
+ removeProviderEntry(DEMO_PROVIDER_ID);
116
+ uiBridge = undefined;
117
+ });
118
+
119
+ pi.registerCommand("widget-host:setup", {
120
+ description: "Configure pi-widget-host and optionally enable the built-in demo provider.",
121
+ handler: async (_args, ctx) => {
122
+ if (!ensureInteractive(ctx)) return;
123
+ bindUi(ctx as { hasUI: boolean; ui: HostUiBridge });
124
+ ensureRegistryWatcher();
125
+
126
+ const current = await readHostConfig();
127
+ const demoChoice = await ctx.ui.select("widget-host setup | built-in demo provider", [
128
+ "Enable demo provider",
129
+ "Keep host silent",
130
+ ]);
131
+ if (!demoChoice) return;
132
+
133
+ const presetId = await selectPreset(ctx as { ui: Pick<HostUiBridge, "select"> }, "widget-host setup | choose a preset");
134
+ if (!presetId) return;
135
+
136
+ const next = await writeHostConfig({
137
+ ...current,
138
+ setupComplete: true,
139
+ demoProviderEnabled: demoChoice === "Enable demo provider",
140
+ presetId,
141
+ });
142
+
143
+ await syncBuiltInDemoProvider(next);
144
+ const { result } = await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
145
+ ctx.ui.notify(`Saved setup. ${summarizeStatus(next, result)}`, "info");
146
+ },
147
+ });
148
+
149
+ pi.registerCommand("widget-host:status", {
150
+ description: "Show current host setup, preset, and active provider state.",
151
+ handler: async (_args, ctx) => {
152
+ bindUi(ctx as { hasUI: boolean; ui: HostUiBridge });
153
+ ensureRegistryWatcher();
154
+ const { config, result } = await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
155
+ if (ctx.hasUI) {
156
+ ctx.ui.notify(summarizeStatus(config, result), "info");
157
+ }
158
+ },
159
+ });
160
+
161
+ pi.registerCommand("widget-host:policy", {
162
+ description: "Inspect or change the saved preset-first host policy.",
163
+ handler: async (_args, ctx) => {
164
+ if (!ensureInteractive(ctx)) return;
165
+ bindUi(ctx as { hasUI: boolean; ui: HostUiBridge });
166
+ ensureRegistryWatcher();
167
+
168
+ const current = await readHostConfig();
169
+ const currentPreset = getPreset(current.presetId || DEFAULT_PRESET_ID);
170
+ const action = await ctx.ui.select(`widget-host policy | current: ${currentPreset.label}`, ["Show policy", "Change preset"]);
171
+ if (!action) return;
172
+
173
+ if (action === "Show policy") {
174
+ ctx.ui.notify(describePreset(currentPreset), "info");
175
+ return;
176
+ }
177
+
178
+ const presetId = await selectPreset(ctx as { ui: Pick<HostUiBridge, "select"> }, "widget-host policy | choose a preset");
179
+ if (!presetId) return;
180
+
181
+ const next = await updateHostConfig((config) => ({
182
+ ...config,
183
+ setupComplete: true,
184
+ presetId,
185
+ }));
186
+
187
+ const { result } = await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
188
+ ctx.ui.notify(`Policy saved: ${getPreset(next.presetId).label}. Active provider: ${result.activeProvider?.providerId ?? "none"}`, "info");
189
+ },
190
+ });
191
+
192
+ pi.registerCommand("widget-host:providers", {
193
+ description: "List known providers and their effective host state.",
194
+ handler: async (_args, ctx) => {
195
+ bindUi(ctx as { hasUI: boolean; ui: HostUiBridge });
196
+ ensureRegistryWatcher();
197
+ const { result } = await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
198
+ if (ctx.hasUI) {
199
+ ctx.ui.notify(summarizeProviders(result), "info");
200
+ }
201
+ },
202
+ });
203
+
204
+ pi.registerCommand("widget-host:mute", {
205
+ description: "Mute one registered provider in the global host config.",
206
+ handler: async (_args, ctx) => {
207
+ if (!ensureInteractive(ctx)) return;
208
+ bindUi(ctx as { hasUI: boolean; ui: HostUiBridge });
209
+ ensureRegistryWatcher();
210
+
211
+ const { result } = await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
212
+ const candidates = result.providerStates.filter((state) => !state.isMuted).map((state) => `${state.providerId} — ${state.effectiveStatus}`);
213
+ if (candidates.length === 0) {
214
+ ctx.ui.notify("No unmuted providers available.", "warning");
215
+ return;
216
+ }
217
+
218
+ const picked = await ctx.ui.select("widget-host mute | choose provider", candidates);
219
+ if (!picked) return;
220
+ const providerId = picked.split(" — ")[0]?.trim();
221
+ if (!providerId) return;
222
+
223
+ await updateHostConfig((config) => ({
224
+ ...config,
225
+ mutedProviderIds: [...new Set([...config.mutedProviderIds, providerId])],
226
+ }));
227
+
228
+ const { result: next } = await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
229
+ ctx.ui.notify(`Muted ${providerId}. Active provider: ${next.activeProvider?.providerId ?? "none"}`, "info");
230
+ },
231
+ });
232
+
233
+ pi.registerCommand("widget-host:unmute", {
234
+ description: "Unmute one provider in the global host config.",
235
+ handler: async (_args, ctx) => {
236
+ if (!ensureInteractive(ctx)) return;
237
+ bindUi(ctx as { hasUI: boolean; ui: HostUiBridge });
238
+ ensureRegistryWatcher();
239
+
240
+ const { config } = await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
241
+ if (config.mutedProviderIds.length === 0) {
242
+ ctx.ui.notify("No muted providers stored in config.", "warning");
243
+ return;
244
+ }
245
+
246
+ const providerId = await ctx.ui.select("widget-host unmute | choose provider", config.mutedProviderIds);
247
+ if (!providerId) return;
248
+
249
+ await updateHostConfig((nextConfig) => ({
250
+ ...nextConfig,
251
+ mutedProviderIds: nextConfig.mutedProviderIds.filter((id) => id !== providerId),
252
+ }));
253
+
254
+ const { result } = await refreshHostWidget(ctx as { hasUI: boolean; ui: HostUiBridge });
255
+ ctx.ui.notify(`Unmuted ${providerId}. Active provider: ${result.activeProvider?.providerId ?? "none"}`, "info");
256
+ },
257
+ });
258
+ }
package/lib/config.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { DEFAULT_PRESET_ID, getPreset } from "./policy.ts";
4
+ import { resolveConfigPath, type HostConfig } from "./types.ts";
5
+
6
+ export function createDefaultConfig(): HostConfig {
7
+ return {
8
+ schemaVersion: 1,
9
+ setupComplete: false,
10
+ demoProviderEnabled: false,
11
+ presetId: DEFAULT_PRESET_ID,
12
+ mutedProviderIds: [],
13
+ };
14
+ }
15
+
16
+ function normalizeConfig(input: unknown): HostConfig {
17
+ const value = typeof input === "object" && input !== null ? (input as Partial<HostConfig>) : {};
18
+ const presetId = typeof value.presetId === "string" ? getPreset(value.presetId).id : DEFAULT_PRESET_ID;
19
+ const mutedProviderIds = Array.isArray(value.mutedProviderIds)
20
+ ? [...new Set(value.mutedProviderIds.filter((item): item is string => typeof item === "string" && item.trim().length > 0))]
21
+ : [];
22
+
23
+ return {
24
+ schemaVersion: 1,
25
+ setupComplete: value.setupComplete === true,
26
+ demoProviderEnabled: value.demoProviderEnabled === true,
27
+ presetId,
28
+ mutedProviderIds,
29
+ };
30
+ }
31
+
32
+ export async function readHostConfig(): Promise<HostConfig> {
33
+ try {
34
+ const content = await readFile(resolveConfigPath(), "utf8");
35
+ return normalizeConfig(JSON.parse(content));
36
+ } catch {
37
+ return createDefaultConfig();
38
+ }
39
+ }
40
+
41
+ export async function writeHostConfig(config: HostConfig): Promise<HostConfig> {
42
+ const normalized = normalizeConfig(config);
43
+ const path = resolveConfigPath();
44
+ await mkdir(dirname(path), { recursive: true });
45
+ await writeFile(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
46
+ return normalized;
47
+ }
48
+
49
+ export async function updateHostConfig(mutator: (config: HostConfig) => HostConfig): Promise<HostConfig> {
50
+ const current = await readHostConfig();
51
+ return writeHostConfig(mutator(current));
52
+ }
@@ -0,0 +1,84 @@
1
+ import { readHostConfig } from "./config.ts";
2
+ import { detectTimeBlock } from "./policy.ts";
3
+ import { publishProviderEntry, removeProviderEntry } from "./registry.ts";
4
+ import { DEMO_PROVIDER_ID, type HostConfig, type ProviderEntry } from "./types.ts";
5
+
6
+ const HEARTBEAT_MS = 30_000;
7
+ const ENTRY_TTL_MS = 90_000;
8
+
9
+ let heartbeat: NodeJS.Timeout | undefined;
10
+
11
+ function baseTagsForBlock(block: ReturnType<typeof detectTimeBlock>): string[] {
12
+ switch (block) {
13
+ case "morning":
14
+ return ["music", "idle"];
15
+ case "day":
16
+ return ["sports", "idle"];
17
+ case "evening":
18
+ return ["music", "sports"];
19
+ case "night":
20
+ default:
21
+ return ["idle", "music"];
22
+ }
23
+ }
24
+
25
+ function readEventTags(): string[] {
26
+ const tags: string[] = [];
27
+ if (["1", "true", "yes"].includes(String(process.env.PI_WIDGET_HOST_DEMO_PLAYING_NOW ?? "").toLowerCase())) {
28
+ tags.push("playing-now");
29
+ }
30
+ if (["1", "true", "yes"].includes(String(process.env.PI_WIDGET_HOST_DEMO_MATCHDAY ?? "").toLowerCase())) {
31
+ tags.push("matchday");
32
+ }
33
+ return tags;
34
+ }
35
+
36
+ export function buildDemoProviderEntry(config: HostConfig, now = new Date()): ProviderEntry {
37
+ const block = detectTimeBlock(now);
38
+ const tags = [...new Set([...baseTagsForBlock(block), ...readEventTags()])];
39
+
40
+ return {
41
+ providerId: DEMO_PROVIDER_ID,
42
+ available: true,
43
+ updatedAt: now.toISOString(),
44
+ priority: 15,
45
+ ttlMs: ENTRY_TTL_MS,
46
+ tags,
47
+ mode: block,
48
+ lines: [`Widget Host Demo | ${block}`, `preset=${config.presetId} | tags=${tags.join(",")}`],
49
+ };
50
+ }
51
+
52
+ async function heartbeatTick(): Promise<void> {
53
+ const config = await readHostConfig();
54
+ if (!config.demoProviderEnabled) {
55
+ removeProviderEntry(DEMO_PROVIDER_ID);
56
+ stopDemoProviderHeartbeat();
57
+ return;
58
+ }
59
+ publishProviderEntry(buildDemoProviderEntry(config));
60
+ }
61
+
62
+ function ensureHeartbeat(): void {
63
+ if (heartbeat) return;
64
+ heartbeat = setInterval(() => {
65
+ void heartbeatTick().catch(() => undefined);
66
+ }, HEARTBEAT_MS);
67
+ }
68
+
69
+ export async function syncBuiltInDemoProvider(config: HostConfig): Promise<void> {
70
+ if (!config.demoProviderEnabled) {
71
+ removeProviderEntry(DEMO_PROVIDER_ID);
72
+ stopDemoProviderHeartbeat();
73
+ return;
74
+ }
75
+
76
+ publishProviderEntry(buildDemoProviderEntry(config));
77
+ ensureHeartbeat();
78
+ }
79
+
80
+ export function stopDemoProviderHeartbeat(): void {
81
+ if (!heartbeat) return;
82
+ clearInterval(heartbeat);
83
+ heartbeat = undefined;
84
+ }
package/lib/policy.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { type HostConfig, type HostTimeBlock, type KnownHostTag, type PolicyEvaluationResult, type PolicyPreset, type ProviderEntry, type ProviderState } from "./types.ts";
2
+
3
+ export const DEFAULT_PRESET_ID = "always-demo";
4
+ export const KNOWN_HOST_TAGS: readonly KnownHostTag[] = ["music", "sports", "playing-now", "matchday", "idle"] as const;
5
+
6
+ export const PRESET_OPTIONS: readonly PolicyPreset[] = [
7
+ {
8
+ id: DEFAULT_PRESET_ID,
9
+ label: "Always demo",
10
+ description: "Keep the demo provider eligible in every time block.",
11
+ blocks: {
12
+ morning: { preferredTags: ["music", "idle"] },
13
+ day: { preferredTags: ["sports", "idle"] },
14
+ evening: { preferredTags: ["music", "sports"] },
15
+ night: { preferredTags: ["idle", "music"] },
16
+ },
17
+ },
18
+ {
19
+ id: "focus-day",
20
+ label: "Focus day",
21
+ description: "Show the shared slot during morning, day, and evening. Stay silent at night.",
22
+ blocks: {
23
+ morning: { preferredTags: ["music", "idle"] },
24
+ day: { preferredTags: ["sports", "idle"] },
25
+ evening: { preferredTags: ["music", "sports"] },
26
+ night: { allowedProviderIds: [] },
27
+ },
28
+ },
29
+ {
30
+ id: "night-owl",
31
+ label: "Night owl",
32
+ description: "Stay quiet during work hours and show the shared slot in evening and night.",
33
+ blocks: {
34
+ morning: { allowedProviderIds: [] },
35
+ day: { allowedProviderIds: [] },
36
+ evening: { preferredTags: ["music", "sports"] },
37
+ night: { preferredTags: ["idle", "music"] },
38
+ },
39
+ },
40
+ ];
41
+
42
+ export function getPreset(id: string | undefined): PolicyPreset {
43
+ return PRESET_OPTIONS.find((preset) => preset.id === id) ?? PRESET_OPTIONS[0]!;
44
+ }
45
+
46
+ export function detectTimeBlock(now = new Date()): HostTimeBlock {
47
+ const hour = now.getHours();
48
+ if (hour >= 5 && hour < 11) return "morning";
49
+ if (hour >= 11 && hour < 17) return "day";
50
+ if (hour >= 17 && hour < 22) return "evening";
51
+ return "night";
52
+ }
53
+
54
+ export function getRemainingTtlMs(entry: Pick<ProviderEntry, "updatedAt" | "ttlMs">, nowMs = Date.now()): number | undefined {
55
+ if (typeof entry.ttlMs !== "number" || entry.ttlMs <= 0) return undefined;
56
+ const updatedAtMs = Date.parse(entry.updatedAt);
57
+ if (!Number.isFinite(updatedAtMs)) return undefined;
58
+ return updatedAtMs + entry.ttlMs - nowMs;
59
+ }
60
+
61
+ export function isEntryStale(entry: Pick<ProviderEntry, "updatedAt" | "ttlMs">, now = new Date()): boolean {
62
+ const remaining = getRemainingTtlMs(entry, now.getTime());
63
+ return typeof remaining === "number" ? remaining <= 0 : false;
64
+ }
65
+
66
+ function tagScore(knownTags: KnownHostTag[], preferredTags: readonly KnownHostTag[] | undefined): number {
67
+ if (!preferredTags || preferredTags.length === 0) return 0;
68
+ const scores = preferredTags
69
+ .map((tag, index) => (knownTags.includes(tag) ? Math.max(40 - index * 10, 10) : 0))
70
+ .filter((value) => value > 0);
71
+ return scores.length > 0 ? Math.max(...scores) : 0;
72
+ }
73
+
74
+ function eventBoost(knownTags: KnownHostTag[]): number {
75
+ let boost = 0;
76
+ if (knownTags.includes("playing-now")) boost += 100;
77
+ if (knownTags.includes("matchday")) boost += 80;
78
+ return boost;
79
+ }
80
+
81
+ function normalizeKnownTags(tags: string[] | undefined): KnownHostTag[] {
82
+ const known = new Set<KnownHostTag>();
83
+ for (const tag of tags ?? []) {
84
+ if ((KNOWN_HOST_TAGS as readonly string[]).includes(tag)) {
85
+ known.add(tag as KnownHostTag);
86
+ }
87
+ }
88
+ return [...known];
89
+ }
90
+
91
+ export function describePreset(preset: PolicyPreset): string {
92
+ const lines = [`Preset: ${preset.label}`, preset.description, "Blocks:"];
93
+ for (const block of ["morning", "day", "evening", "night"] as const) {
94
+ const policy = preset.blocks[block];
95
+ const allowed = policy.allowedProviderIds && policy.allowedProviderIds.length > 0 ? policy.allowedProviderIds.join(", ") : "silent";
96
+ const tags = policy.preferredTags && policy.preferredTags.length > 0 ? policy.preferredTags.join(", ") : "none";
97
+ lines.push(`- ${block}: allowed=${allowed}; preferred tags=${tags}`);
98
+ }
99
+ return lines.join("\n");
100
+ }
101
+
102
+ export function evaluateProviderEntries(entries: readonly ProviderEntry[], config: HostConfig, now = new Date()): PolicyEvaluationResult {
103
+ const preset = getPreset(config.presetId);
104
+ const timeBlock = detectTimeBlock(now);
105
+ const blockPolicy = preset.blocks[timeBlock];
106
+
107
+ const providerStates = entries.map<ProviderState>((entry) => {
108
+ const knownTags = normalizeKnownTags(entry.tags);
109
+ const isMuted = config.mutedProviderIds.includes(entry.providerId);
110
+ const isStale = isEntryStale(entry, now);
111
+ const hasLines = Array.isArray(entry.lines) && entry.lines.length > 0;
112
+ const isAllowed = blockPolicy.allowedProviderIds === undefined || blockPolicy.allowedProviderIds.includes(entry.providerId);
113
+ const reasons: string[] = [];
114
+
115
+ if (!entry.available) reasons.push("provider reported unavailable");
116
+ if (!hasLines) reasons.push("no render lines");
117
+ if (!isAllowed) reasons.push("blocked by current preset");
118
+ if (isMuted) reasons.push("muted in host config");
119
+ if (isStale) reasons.push("stale ttl");
120
+
121
+ const effectivePriority = (entry.priority ?? 0) + tagScore(knownTags, blockPolicy.preferredTags) + eventBoost(knownTags);
122
+
123
+ let effectiveStatus: ProviderState["effectiveStatus"] = "eligible";
124
+ if (isMuted) {
125
+ effectiveStatus = "muted";
126
+ } else if (isStale) {
127
+ effectiveStatus = "stale";
128
+ } else if (!entry.available || !hasLines || !isAllowed) {
129
+ effectiveStatus = "unavailable";
130
+ }
131
+
132
+ return {
133
+ ...entry,
134
+ effectivePriority,
135
+ effectiveStatus,
136
+ knownTags,
137
+ reasons,
138
+ isMuted,
139
+ isStale,
140
+ };
141
+ });
142
+
143
+ const winner = providerStates
144
+ .filter((state) => state.effectiveStatus === "eligible")
145
+ .sort((left, right) => right.effectivePriority - left.effectivePriority || left.providerId.localeCompare(right.providerId))[0];
146
+
147
+ if (winner) {
148
+ winner.effectiveStatus = "active";
149
+ }
150
+
151
+ return {
152
+ timeBlock,
153
+ preset,
154
+ providerStates,
155
+ activeProvider: winner,
156
+ };
157
+ }
@@ -0,0 +1,89 @@
1
+ import { REGISTRY_SYMBOL, type ProviderEntry } from "./types.ts";
2
+
3
+ type Listener = () => void;
4
+
5
+ export interface WidgetHostRegistry {
6
+ version: 1;
7
+ set: (entry: ProviderEntry) => void;
8
+ remove: (providerId: string) => void;
9
+ list: () => ProviderEntry[];
10
+ subscribe: (listener: Listener) => () => void;
11
+ clear: () => void;
12
+ }
13
+
14
+ function normalizeEntry(entry: ProviderEntry): ProviderEntry {
15
+ return {
16
+ providerId: entry.providerId.trim(),
17
+ available: entry.available === true,
18
+ lines: Array.isArray(entry.lines) ? entry.lines.map((line) => String(line)) : [],
19
+ updatedAt: new Date(entry.updatedAt).toISOString(),
20
+ priority: typeof entry.priority === "number" ? entry.priority : 0,
21
+ tags: Array.isArray(entry.tags) ? [...new Set(entry.tags.map((tag) => String(tag).trim()).filter(Boolean))] : [],
22
+ mode: typeof entry.mode === "string" && entry.mode.trim().length > 0 ? entry.mode.trim() : undefined,
23
+ ttlMs: typeof entry.ttlMs === "number" && entry.ttlMs > 0 ? entry.ttlMs : undefined,
24
+ };
25
+ }
26
+
27
+ function sameEntry(left: ProviderEntry | undefined, right: ProviderEntry): boolean {
28
+ return JSON.stringify(left) === JSON.stringify(right);
29
+ }
30
+
31
+ function createRegistry(): WidgetHostRegistry {
32
+ const entries = new Map<string, ProviderEntry>();
33
+ const listeners = new Set<Listener>();
34
+ const notify = () => {
35
+ for (const listener of listeners) {
36
+ listener();
37
+ }
38
+ };
39
+
40
+ return {
41
+ version: 1,
42
+ set(entry) {
43
+ const normalized = normalizeEntry(entry);
44
+ const previous = entries.get(normalized.providerId);
45
+ if (sameEntry(previous, normalized)) return;
46
+ entries.set(normalized.providerId, normalized);
47
+ notify();
48
+ },
49
+ remove(providerId) {
50
+ if (!entries.delete(providerId)) return;
51
+ notify();
52
+ },
53
+ list() {
54
+ return [...entries.values()].map((entry) => ({ ...entry, lines: [...entry.lines], tags: [...(entry.tags ?? [])] }));
55
+ },
56
+ subscribe(listener) {
57
+ listeners.add(listener);
58
+ return () => {
59
+ listeners.delete(listener);
60
+ };
61
+ },
62
+ clear() {
63
+ if (entries.size === 0) return;
64
+ entries.clear();
65
+ notify();
66
+ },
67
+ };
68
+ }
69
+
70
+ export function getWidgetHostRegistry(): WidgetHostRegistry {
71
+ const root = globalThis as typeof globalThis & Record<symbol, WidgetHostRegistry | undefined>;
72
+ const existing = root[REGISTRY_SYMBOL];
73
+ if (existing) return existing;
74
+ const registry = createRegistry();
75
+ root[REGISTRY_SYMBOL] = registry;
76
+ return registry;
77
+ }
78
+
79
+ export function publishProviderEntry(entry: ProviderEntry): void {
80
+ getWidgetHostRegistry().set(entry);
81
+ }
82
+
83
+ export function removeProviderEntry(providerId: string): void {
84
+ getWidgetHostRegistry().remove(providerId);
85
+ }
86
+
87
+ export function listProviderEntries(): ProviderEntry[] {
88
+ return getWidgetHostRegistry().list();
89
+ }
package/lib/types.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export const PACKAGE_NAME = "pi-widget-host";
5
+ export const WIDGET_ID = PACKAGE_NAME;
6
+ export const DEMO_PROVIDER_ID = `${PACKAGE_NAME}.demo`;
7
+ export const REGISTRY_SYMBOL = Symbol.for("pi-widget-host.registry.v1");
8
+
9
+ export type HostTimeBlock = "morning" | "day" | "evening" | "night";
10
+ export type KnownHostTag = "music" | "sports" | "playing-now" | "matchday" | "idle";
11
+
12
+ export interface ProviderEntry {
13
+ providerId: string;
14
+ available: boolean;
15
+ lines: string[];
16
+ updatedAt: string;
17
+ priority?: number;
18
+ tags?: string[];
19
+ mode?: string;
20
+ ttlMs?: number;
21
+ }
22
+
23
+ export interface BlockPolicy {
24
+ allowedProviderIds?: string[];
25
+ preferredTags?: KnownHostTag[];
26
+ }
27
+
28
+ export interface PolicyPreset {
29
+ id: string;
30
+ label: string;
31
+ description: string;
32
+ blocks: Record<HostTimeBlock, BlockPolicy>;
33
+ }
34
+
35
+ export interface HostConfig {
36
+ schemaVersion: 1;
37
+ setupComplete: boolean;
38
+ demoProviderEnabled: boolean;
39
+ presetId: string;
40
+ mutedProviderIds: string[];
41
+ }
42
+
43
+ export interface ProviderState extends ProviderEntry {
44
+ effectiveStatus: "active" | "eligible" | "muted" | "stale" | "unavailable";
45
+ effectivePriority: number;
46
+ knownTags: KnownHostTag[];
47
+ reasons: string[];
48
+ isMuted: boolean;
49
+ isStale: boolean;
50
+ }
51
+
52
+ export interface PolicyEvaluationResult {
53
+ timeBlock: HostTimeBlock;
54
+ preset: PolicyPreset;
55
+ providerStates: ProviderState[];
56
+ activeProvider?: ProviderState;
57
+ }
58
+
59
+ export function resolveAgentDir(): string {
60
+ const override = process.env.PI_WIDGET_HOST_AGENT_DIR?.trim();
61
+ return override ? override : join(homedir(), ".pi", "agent");
62
+ }
63
+
64
+ export function resolveConfigPath(): string {
65
+ return join(resolveAgentDir(), `${PACKAGE_NAME}-config.json`);
66
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "pi-widget-host",
3
+ "version": "0.2.0",
4
+ "description": "Host package for managing one shared Pi widget slot across multiple providers with preset policies and a built-in demo provider.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Keisu",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "widget",
12
+ "host",
13
+ "display-policy"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/eiei114/pi-widget-host.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/eiei114/pi-widget-host/issues"
21
+ },
22
+ "homepage": "https://github.com/eiei114/pi-widget-host#readme",
23
+ "files": [
24
+ "extensions/",
25
+ "lib/",
26
+ "docs/",
27
+ "README.md",
28
+ "LICENSE",
29
+ "CHANGELOG.md"
30
+ ],
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "node --import tsx --test tests/*.test.ts",
34
+ "ci": "npm run typecheck && npm test && npm run pack:check",
35
+ "pack:check": "npm pack --dry-run"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./extensions/index.ts"
43
+ ]
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": "*"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@earendil-works/pi-coding-agent": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@earendil-works/pi-coding-agent": "latest",
58
+ "@types/node": "^25.9.1",
59
+ "tsx": "^4.20.6",
60
+ "typescript": "^6.0.3"
61
+ }
62
+ }