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 +20 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/docs/protocol.md +53 -0
- package/docs/release.md +57 -0
- package/extensions/index.ts +258 -0
- package/lib/config.ts +52 -0
- package/lib/demo-provider.ts +84 -0
- package/lib/policy.ts +157 -0
- package/lib/registry.ts +89 -0
- package/lib/types.ts +66 -0
- package/package.json +62 -0
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
|
+
[](https://github.com/eiei114/pi-widget-host/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/eiei114/pi-widget-host/actions/workflows/publish.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/pi-widget-host)
|
|
6
|
+
[](https://www.npmjs.com/package/pi-widget-host)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://pi.dev/packages)
|
|
9
|
+
[](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
|
package/docs/protocol.md
ADDED
|
@@ -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
|
package/docs/release.md
ADDED
|
@@ -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
|
+
}
|
package/lib/registry.ts
ADDED
|
@@ -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
|
+
}
|