@howaboua/pi-pet 0.1.1
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 +16 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/SECURITY.md +24 -0
- package/authoring/PET-GUIDE.md +65 -0
- package/authoring/hatch/HATCH-GUIDE.md +136 -0
- package/authoring/hatch/LICENSE.txt +201 -0
- package/authoring/hatch/NOTICE.md +5 -0
- package/authoring/hatch/pyproject.toml +8 -0
- package/authoring/hatch/references/animation-rows.md +29 -0
- package/authoring/hatch/references/final-assembly.md +67 -0
- package/authoring/hatch/references/look-directions.md +35 -0
- package/authoring/hatch/references/pet-contract.md +40 -0
- package/authoring/hatch/references/qa-rubric.md +34 -0
- package/authoring/hatch/references/reviewer-protocol.md +72 -0
- package/authoring/hatch/references/standard-assembly.md +21 -0
- package/authoring/hatch/references/visual-generation.md +59 -0
- package/authoring/hatch/scripts/approve_cardinals.py +110 -0
- package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
- package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
- package/authoring/hatch/scripts/compose_atlas.py +171 -0
- package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
- package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
- package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
- package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
- package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
- package/authoring/hatch/scripts/inspect_frames.py +278 -0
- package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
- package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
- package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
- package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
- package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
- package/authoring/hatch/scripts/render_animation_previews.py +80 -0
- package/authoring/hatch/scripts/validate_atlas.py +299 -0
- package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
- package/authoring/hatch/uv.lock +99 -0
- package/authoring/references/import-existing-pet.md +47 -0
- package/authoring/references/pet-format.md +88 -0
- package/changelog.ts +327 -0
- package/deploy/pi-pet-hyprland.conf +10 -0
- package/desktop/build.mjs +194 -0
- package/desktop/package-lock.json +887 -0
- package/desktop/package.json +16 -0
- package/dist/authoring/prepare.mjs +3 -0
- package/dist/authoring/rebuild.mjs +4 -0
- package/dist/authoring/validate.mjs +2 -0
- package/dist/desktop/main.mjs +5 -0
- package/dist/desktop/preload.cjs +1 -0
- package/dist/web/app.js +1 -0
- package/dist/web/catalog.json +1 -0
- package/dist/web/index.html +100 -0
- package/dist/web/manifest.webmanifest +16 -0
- package/dist/web/pet-icon.svg +6 -0
- package/dist/web/spritesheet.webp +0 -0
- package/dist/web/styles.css +592 -0
- package/docs/architecture.md +41 -0
- package/docs/pi-pet.png +0 -0
- package/docs/research.md +17 -0
- package/extensions/desktop-command.ts +249 -0
- package/extensions/desktop-config.ts +152 -0
- package/extensions/desktop-launcher.ts +380 -0
- package/extensions/gippity.ts +24 -0
- package/extensions/index.ts +110 -0
- package/extensions/remote-app.ts +61 -0
- package/package.json +101 -0
- package/pets/clawa/PET.md +27 -0
- package/pets/clawa/pet.json +7 -0
- package/pets/clawa/spritesheet.webp +0 -0
- package/pets/clawa/validation.json +808 -0
- package/src/desktop/attention.ts +87 -0
- package/src/desktop/bridge.ts +31 -0
- package/src/desktop/config.ts +145 -0
- package/src/desktop/cursor-provider.ts +150 -0
- package/src/desktop/main.ts +459 -0
- package/src/desktop/preload.ts +19 -0
- package/src/pet-image-size.ts +72 -0
- package/src/pet-loader.ts +278 -0
- package/src/pet-storage.ts +240 -0
- package/src/protocol/index.ts +81 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Import an existing pet
|
|
2
|
+
|
|
3
|
+
Use this route when the user already has a pet package from Codex, ChatGPT, or another Hatch Pet run. Import the package as data; do not rehatch compatible artwork.
|
|
4
|
+
|
|
5
|
+
## Find and classify the source
|
|
6
|
+
|
|
7
|
+
1. Prefer a path supplied by the user. Codex custom pets normally live at `${CODEX_HOME:-$HOME/.codex}/pets/<pet-id>/`. A source on another user-authorized host may be copied through that host's ordinary file transport.
|
|
8
|
+
2. Inspect the source before copying it. A directly compatible package contains `pet.json` and the relative PNG/WebP named by `spritesheetPath`.
|
|
9
|
+
3. Direct import requires `spriteVersionNumber: 2` and a `1536×2288` atlas. An 8×9 or `1536×1872` v1 atlas needs completion through `../hatch/HATCH-GUIDE.md`; an ordinary generated character image is a hatch reference, not a pet package.
|
|
10
|
+
|
|
11
|
+
## Recover Codex built-in assets
|
|
12
|
+
|
|
13
|
+
Codex also caches its built-in pets without custom manifests:
|
|
14
|
+
|
|
15
|
+
- TUI assets normally appear under `${CODEX_HOME:-$HOME/.codex}/cache/tui-pets/v1/assets/` as `<pet>-spritesheet-*.webp`. These are legacy `1536×1872` atlases and need the two look-direction rows completed through the hatch guide before Pi Pet can load them.
|
|
16
|
+
- Desktop assets live inside the installed or cached Codex application's `content/webview/assets/` directory as `<pet>-spritesheet-<version>-<hash>.webp`. Linux native builds commonly expose them under `~/.cache/codex-desktop-linux-native-build/codex-app/content/webview/assets/` or a `~/.cache/codex-update-manager/workspaces/<version>/codex-app/content/webview/assets/` workspace. Other roots vary by platform and install method; search the user's Codex application and cache directories for `*-spritesheet-*.webp`. Current desktop atlases may already be v2 `1536×2288` assets.
|
|
17
|
+
|
|
18
|
+
For a v2 desktop asset, copy the selected atlas into staging as `spritesheet.webp` and create the minimal `pet.json` from `pet-format.md`, using the filename's pet name as a starting point for the id and display name. Validate dimensions and inspect the rebuilt animations rather than trusting the filename version. Record the Codex application asset and original filename in `PET.md`; redistribution terms remain uncertain, but that uncertainty does not block importing it into the user's local pet library.
|
|
19
|
+
|
|
20
|
+
## Stage the package
|
|
21
|
+
|
|
22
|
+
Resolve the package root from this guide's installed path and the durable destination as `<pi-agent-directory>/pi-pet/pets/<pet-id>`. The destination directory name and `pet.json` id must match.
|
|
23
|
+
|
|
24
|
+
1. Create a fresh staging root under `<pi-agent-directory>/pi-pet/runs/<pet-id>/` with the package in a child directory named exactly `<pet-id>`; validation requires that basename to match the manifest id.
|
|
25
|
+
2. Copy `pet.json`, its referenced spritesheet, and any supplied `PET.md` or `validation.json` with ordinary filesystem commands. If a supplied `pet.pi.json` references extra local PNG/WebP assets, copy those too; leave unrelated files behind.
|
|
26
|
+
3. If `PET.md` is absent, create a short one recording the display name, description, `source_format: codex-v2`, known provenance, included standard actions, and any redistribution uncertainty. Do not invent ownership or licensing.
|
|
27
|
+
4. From the installed Pi Pet package root, validate the staged directory:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run pet:validate -- <absolute-staged-pet-directory>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Fix or reroute any contract failure; never edit dimensions or version metadata merely to silence validation.
|
|
34
|
+
|
|
35
|
+
## Place and load it
|
|
36
|
+
|
|
37
|
+
- If the durable destination is absent, move the validated `<pet-id>` staging directory into place and remove its empty staging root.
|
|
38
|
+
- If an existing durable or bundled pet has the same id and the manifest plus assets are byte-identical, keep the existing copy and do not duplicate it.
|
|
39
|
+
- If the same id contains different data, show the conflict and ask before replacement unless the user already requested replacement. Validate the replacement before removing the old directory; leave no backup after an explicit replacement.
|
|
40
|
+
|
|
41
|
+
Then rebuild from the installed package root:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm run pet:rebuild -- <pet-id>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The rebuild validates again, generates the durable miniapp, and selects the imported pet globally. A repository-level selection still wins. Tell the user to run `/reload`; slash commands are user actions. After reload, inspect the pet at actual scale and exercise one known action with `pet_show`.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Pi Pet package format
|
|
2
|
+
|
|
3
|
+
## Base package
|
|
4
|
+
|
|
5
|
+
Every package currently starts from a Codex-compatible v2 atlas:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
pets/<id>/
|
|
9
|
+
├── PET.md
|
|
10
|
+
├── pet.json
|
|
11
|
+
├── spritesheet.webp
|
|
12
|
+
├── pet.pi.json # optional extension layer
|
|
13
|
+
└── validation.json # optional retained authoring evidence
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`pet.json`:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"id": "pet-id",
|
|
21
|
+
"displayName": "Pet Name",
|
|
22
|
+
"description": "One short sentence.",
|
|
23
|
+
"spriteVersionNumber": 2,
|
|
24
|
+
"spritesheetPath": "spritesheet.webp"
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The base atlas is PNG or WebP, exactly `1536x2288`: 8 columns by 11 rows, with `192x208` cells. Rows 0–8 are the standard actions; rows 9–10 are sixteen clockwise pointer-look frames. Transparent unused cells and clean transparent RGB remain part of the visual contract.
|
|
29
|
+
|
|
30
|
+
## Pi extension layer
|
|
31
|
+
|
|
32
|
+
`pet.pi.json` adds or replaces data-driven actions without changing the Codex manifest:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"schemaVersion": 1,
|
|
37
|
+
"defaultAction": "idle",
|
|
38
|
+
"aliases": {
|
|
39
|
+
"party": "celebrate"
|
|
40
|
+
},
|
|
41
|
+
"actions": {
|
|
42
|
+
"celebrate": {
|
|
43
|
+
"asset": "celebrate.webp",
|
|
44
|
+
"frames": [
|
|
45
|
+
{ "x": 0, "y": 0, "width": 192, "height": 208, "durationMs": 120 },
|
|
46
|
+
{ "x": 192, "y": 0, "width": 192, "height": 208, "durationMs": 260 }
|
|
47
|
+
],
|
|
48
|
+
"loop": false,
|
|
49
|
+
"next": "idle"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Rules:
|
|
56
|
+
|
|
57
|
+
- `schemaVersion` is exactly `1`.
|
|
58
|
+
- Unknown fields fail validation.
|
|
59
|
+
- Action names are 1–64 lowercase characters: letters, numbers, `.`, `_`, `-`.
|
|
60
|
+
- `asset` is a relative PNG/WebP path inside the package.
|
|
61
|
+
- Each action has 1–64 frames.
|
|
62
|
+
- Coordinates are non-negative integers and every frame stays within the decoded image.
|
|
63
|
+
- Width and height are 1–4096; a decoded asset may contain at most 16 million pixels.
|
|
64
|
+
- `durationMs` is an integer from 16–60,000.
|
|
65
|
+
- `loop` is a boolean.
|
|
66
|
+
- Optional `next` names an action that exists after the overlay is merged.
|
|
67
|
+
- Optional aliases map alternate names directly to real actions. Aliases are accepted by `pet_show` but are not separate animations or shown as picker actions.
|
|
68
|
+
- Assets are regular files up to 16 MiB. Escaping symlinks are rejected.
|
|
69
|
+
|
|
70
|
+
## Standard rows
|
|
71
|
+
|
|
72
|
+
| Row | Action | Frames | Durations (ms) |
|
|
73
|
+
|---:|---|---:|---|
|
|
74
|
+
| 0 | idle | 6 | 280, 110, 110, 140, 140, 320 |
|
|
75
|
+
| 1 | running-right | 8 | 120 × 7, 220 |
|
|
76
|
+
| 2 | running-left | 8 | 120 × 7, 220 |
|
|
77
|
+
| 3 | waving | 4 | 140 × 3, 280 |
|
|
78
|
+
| 4 | jumping | 5 | 140 × 4, 280 |
|
|
79
|
+
| 5 | failed | 8 | 140 × 7, 240 |
|
|
80
|
+
| 6 | waiting | 6 | 150 × 5, 260 |
|
|
81
|
+
| 7 | running | 6 | 120 × 5, 220 |
|
|
82
|
+
| 8 | review | 6 | 150 × 5, 280 |
|
|
83
|
+
|
|
84
|
+
`running` means active processing, not locomotion. `waiting` means Pi needs user input. `review` means output is ready to inspect.
|
|
85
|
+
|
|
86
|
+
## PET.md
|
|
87
|
+
|
|
88
|
+
Use frontmatter for stable identity metadata and prose for personality, action semantics, and provenance. `PET.md` guides humans and agents; it is not parsed by the renderer and cannot override `pet.json` or `pet.pi.json`.
|
package/changelog.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Generated extension packages copy this file during changelog:extensions.
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import {
|
|
9
|
+
DynamicBorder,
|
|
10
|
+
type ExtensionAPI,
|
|
11
|
+
getAgentDir,
|
|
12
|
+
getMarkdownTheme,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
15
|
+
|
|
16
|
+
const ENTRY_TYPE = "@howaboua/pi-stuff/changelog";
|
|
17
|
+
const REGISTRATION_CHANNEL = "@howaboua/pi-stuff/changelog/register/v1";
|
|
18
|
+
const STATE_FILENAME = "howaboua-pi-stuff-changelog.json";
|
|
19
|
+
const SUPPRESS_KEY = "suppress";
|
|
20
|
+
const LOCK_STALE_MS = 30_000;
|
|
21
|
+
const LOCK_TIMEOUT_MS = 2_000;
|
|
22
|
+
|
|
23
|
+
interface PackageMetadata {
|
|
24
|
+
name: string;
|
|
25
|
+
version: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PackageRegistration extends PackageMetadata {
|
|
29
|
+
changelogPath: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RegistrationEvent {
|
|
33
|
+
accept: () => void;
|
|
34
|
+
registration: PackageRegistration;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ChangelogEntry {
|
|
38
|
+
content: string;
|
|
39
|
+
version: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ChangelogEntryData {
|
|
43
|
+
markdown: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type ChangelogState = Record<string, boolean | string>;
|
|
47
|
+
|
|
48
|
+
function packageRegistration(): PackageRegistration {
|
|
49
|
+
const packageDirectory = fileURLToPath(new URL(".", import.meta.url));
|
|
50
|
+
const metadata = JSON.parse(
|
|
51
|
+
readFileSync(join(packageDirectory, "package.json"), "utf8"),
|
|
52
|
+
) as Partial<PackageMetadata>;
|
|
53
|
+
if (typeof metadata.name !== "string" || typeof metadata.version !== "string")
|
|
54
|
+
throw new Error(`Invalid package metadata in ${packageDirectory}`);
|
|
55
|
+
return {
|
|
56
|
+
name: metadata.name,
|
|
57
|
+
version: metadata.version,
|
|
58
|
+
changelogPath: join(packageDirectory, "CHANGELOG.md"),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isRegistrationEvent(value: unknown): value is RegistrationEvent {
|
|
63
|
+
if (!value || typeof value !== "object") return false;
|
|
64
|
+
const event = value as Partial<RegistrationEvent>;
|
|
65
|
+
return (
|
|
66
|
+
typeof event.accept === "function" &&
|
|
67
|
+
typeof event.registration?.name === "string" &&
|
|
68
|
+
typeof event.registration.version === "string" &&
|
|
69
|
+
typeof event.registration.changelogPath === "string"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseChangelog(markdown: string): ChangelogEntry[] {
|
|
74
|
+
const entries: ChangelogEntry[] = [];
|
|
75
|
+
let currentLines: string[] = [];
|
|
76
|
+
let currentVersion: string | undefined;
|
|
77
|
+
|
|
78
|
+
for (const line of markdown.split("\n")) {
|
|
79
|
+
const version = line.match(/^##\s+\[?(\d+\.\d+\.\d+)\]?(?:\s.*)?$/)?.[1];
|
|
80
|
+
if (version) {
|
|
81
|
+
if (currentVersion)
|
|
82
|
+
entries.push({
|
|
83
|
+
content: currentLines.join("\n").trim(),
|
|
84
|
+
version: currentVersion,
|
|
85
|
+
});
|
|
86
|
+
currentVersion = version;
|
|
87
|
+
currentLines = [line];
|
|
88
|
+
} else if (currentVersion) {
|
|
89
|
+
currentLines.push(line);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (currentVersion)
|
|
94
|
+
entries.push({
|
|
95
|
+
content: currentLines.join("\n").trim(),
|
|
96
|
+
version: currentVersion,
|
|
97
|
+
});
|
|
98
|
+
return entries;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function compareVersions(left: string, right: string): number {
|
|
102
|
+
const leftParts = left.split(".").map(Number);
|
|
103
|
+
const rightParts = right.split(".").map(Number);
|
|
104
|
+
for (let index = 0; index < 3; index++) {
|
|
105
|
+
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
|
106
|
+
if (difference !== 0) return difference;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isStableVersion(version: string): boolean {
|
|
112
|
+
return /^\d+\.\d+\.\d+$/.test(version);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function statePath(): string {
|
|
116
|
+
return join(getAgentDir(), STATE_FILENAME);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readState(path: string): Promise<ChangelogState> {
|
|
120
|
+
let text: string;
|
|
121
|
+
try {
|
|
122
|
+
text = await readFile(path, "utf8");
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
const parsed = JSON.parse(text) as unknown;
|
|
128
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
129
|
+
throw new Error(`${path} must contain a JSON object`);
|
|
130
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
131
|
+
if (name === SUPPRESS_KEY) {
|
|
132
|
+
if (typeof value !== "boolean")
|
|
133
|
+
throw new Error(`${path} ${SUPPRESS_KEY} must be a boolean`);
|
|
134
|
+
} else if (typeof value !== "string") {
|
|
135
|
+
throw new Error(`${path} version for ${name} must be a string`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return parsed as ChangelogState;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function acquireLock(path: string): Promise<() => Promise<void>> {
|
|
142
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
143
|
+
while (true) {
|
|
144
|
+
try {
|
|
145
|
+
await mkdir(path);
|
|
146
|
+
return () => rm(path, { force: true, recursive: true });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
149
|
+
const info = await stat(path).catch(() => undefined);
|
|
150
|
+
if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
151
|
+
await rm(path, { force: true, recursive: true });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (Date.now() >= deadline)
|
|
155
|
+
throw new Error(`Timed out waiting for ${path}`);
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function writeState(path: string, state: ChangelogState): Promise<void> {
|
|
162
|
+
await mkdir(dirname(path), { mode: 0o700, recursive: true });
|
|
163
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
164
|
+
try {
|
|
165
|
+
const ordered = Object.fromEntries(
|
|
166
|
+
Object.entries(state).sort(([left], [right]) =>
|
|
167
|
+
left.localeCompare(right),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
await writeFile(temporaryPath, `${JSON.stringify(ordered, null, 2)}\n`, {
|
|
171
|
+
encoding: "utf8",
|
|
172
|
+
mode: 0o600,
|
|
173
|
+
});
|
|
174
|
+
await rename(temporaryPath, path);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
await rm(temporaryPath, { force: true });
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function packageMarkdown(
|
|
182
|
+
registration: PackageRegistration,
|
|
183
|
+
entries: ChangelogEntry[],
|
|
184
|
+
): string {
|
|
185
|
+
return [
|
|
186
|
+
`## ${registration.name}`,
|
|
187
|
+
...entries.map((entry) => entry.content.replace(/^##\s+/, "### ")),
|
|
188
|
+
].join("\n\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function claimUpdates(
|
|
192
|
+
registrations: Iterable<PackageRegistration>,
|
|
193
|
+
): Promise<{ errors: string[]; markdown?: string }> {
|
|
194
|
+
const stableRegistrations = [...registrations]
|
|
195
|
+
.filter((registration) => isStableVersion(registration.version))
|
|
196
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
197
|
+
if (stableRegistrations.length === 0) return { errors: [] };
|
|
198
|
+
const path = statePath();
|
|
199
|
+
await mkdir(dirname(path), { mode: 0o700, recursive: true });
|
|
200
|
+
const release = await acquireLock(`${path}.lock`);
|
|
201
|
+
try {
|
|
202
|
+
const state = await readState(path);
|
|
203
|
+
const errors: string[] = [];
|
|
204
|
+
const sections: string[] = [];
|
|
205
|
+
let changed = false;
|
|
206
|
+
if (state[SUPPRESS_KEY] === true) {
|
|
207
|
+
for (const registration of stableRegistrations) {
|
|
208
|
+
const seenVersion = state[registration.name];
|
|
209
|
+
if (
|
|
210
|
+
typeof seenVersion !== "string" ||
|
|
211
|
+
compareVersions(registration.version, seenVersion) > 0
|
|
212
|
+
) {
|
|
213
|
+
state[registration.name] = registration.version;
|
|
214
|
+
changed = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (changed) await writeState(path, state);
|
|
218
|
+
return { errors };
|
|
219
|
+
}
|
|
220
|
+
for (const registration of stableRegistrations) {
|
|
221
|
+
const seenVersion = state[registration.name];
|
|
222
|
+
if (
|
|
223
|
+
typeof seenVersion === "string" &&
|
|
224
|
+
compareVersions(registration.version, seenVersion) <= 0
|
|
225
|
+
)
|
|
226
|
+
continue;
|
|
227
|
+
try {
|
|
228
|
+
const entries = parseChangelog(
|
|
229
|
+
await readFile(registration.changelogPath, "utf8"),
|
|
230
|
+
).filter(
|
|
231
|
+
(entry) =>
|
|
232
|
+
compareVersions(entry.version, registration.version) <= 0 &&
|
|
233
|
+
(typeof seenVersion === "string"
|
|
234
|
+
? compareVersions(entry.version, seenVersion) > 0
|
|
235
|
+
: entry.version === registration.version),
|
|
236
|
+
);
|
|
237
|
+
if (entries.length === 0) {
|
|
238
|
+
errors.push(
|
|
239
|
+
`No ${registration.version} changelog entry found for ${registration.name}`,
|
|
240
|
+
);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
sections.push(packageMarkdown(registration, entries));
|
|
244
|
+
state[registration.name] = registration.version;
|
|
245
|
+
changed = true;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
errors.push(
|
|
248
|
+
`Could not read the ${registration.name} changelog: ${error instanceof Error ? error.message : String(error)}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (changed) await writeState(path, state);
|
|
253
|
+
return {
|
|
254
|
+
errors,
|
|
255
|
+
...(sections.length > 0 ? { markdown: sections.join("\n\n") } : {}),
|
|
256
|
+
};
|
|
257
|
+
} finally {
|
|
258
|
+
await release();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function registerCoordinator(
|
|
263
|
+
pi: ExtensionAPI,
|
|
264
|
+
registration: PackageRegistration,
|
|
265
|
+
): void {
|
|
266
|
+
let accepted = false;
|
|
267
|
+
pi.events.emit(REGISTRATION_CHANNEL, {
|
|
268
|
+
registration,
|
|
269
|
+
accept: () => {
|
|
270
|
+
accepted = true;
|
|
271
|
+
},
|
|
272
|
+
} satisfies RegistrationEvent);
|
|
273
|
+
if (accepted) return;
|
|
274
|
+
|
|
275
|
+
const registrations = new Map([[registration.name, registration]]);
|
|
276
|
+
pi.events.on(REGISTRATION_CHANNEL, (value) => {
|
|
277
|
+
if (!isRegistrationEvent(value)) return;
|
|
278
|
+
registrations.set(value.registration.name, value.registration);
|
|
279
|
+
value.accept();
|
|
280
|
+
});
|
|
281
|
+
pi.registerEntryRenderer<ChangelogEntryData>(
|
|
282
|
+
ENTRY_TYPE,
|
|
283
|
+
(entry, _options, theme) => {
|
|
284
|
+
if (!entry.data) return undefined;
|
|
285
|
+
const container = new Container();
|
|
286
|
+
container.addChild(new DynamicBorder());
|
|
287
|
+
container.addChild(
|
|
288
|
+
new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0),
|
|
289
|
+
);
|
|
290
|
+
container.addChild(new Spacer(1));
|
|
291
|
+
container.addChild(
|
|
292
|
+
new Markdown(entry.data.markdown.trim(), 1, 0, getMarkdownTheme()),
|
|
293
|
+
);
|
|
294
|
+
container.addChild(new Spacer(1));
|
|
295
|
+
container.addChild(new DynamicBorder());
|
|
296
|
+
return container;
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
pi.on("session_start", async (event, ctx) => {
|
|
300
|
+
if (event.reason === "reload" || ctx.mode !== "tui") return;
|
|
301
|
+
if (
|
|
302
|
+
ctx.sessionManager
|
|
303
|
+
.getEntries()
|
|
304
|
+
.some((entry) =>
|
|
305
|
+
["branch_summary", "compaction", "message"].includes(entry.type),
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
return;
|
|
309
|
+
try {
|
|
310
|
+
const update = await claimUpdates(registrations.values());
|
|
311
|
+
if (update.markdown)
|
|
312
|
+
pi.appendEntry<ChangelogEntryData>(ENTRY_TYPE, {
|
|
313
|
+
markdown: update.markdown,
|
|
314
|
+
});
|
|
315
|
+
for (const error of update.errors) ctx.ui.notify(error, "warning");
|
|
316
|
+
} catch (error) {
|
|
317
|
+
ctx.ui.notify(
|
|
318
|
+
`Could not update Howaboua changelog state: ${error instanceof Error ? error.message : String(error)}`,
|
|
319
|
+
"warning",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export default function howabouaPackageChangelog(pi: ExtensionAPI): void {
|
|
326
|
+
registerCoordinator(pi, packageRegistration());
|
|
327
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Keep the transparent pet borderless, fully opaque where painted, and above workspaces.
|
|
2
|
+
windowrule = tag -default-opacity, match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
3
|
+
windowrule = opacity 1 1, match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
4
|
+
windowrule = float on, match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
5
|
+
windowrule = pin on, match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
6
|
+
windowrule = border_size 0, match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
7
|
+
windowrule = no_shadow on, match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
8
|
+
windowrule = no_blur on, match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
9
|
+
windowrule = no_initial_focus on, match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
10
|
+
windowrule = move (monitor_w-window_w-40) (monitor_h-window_h-40), match:class ^electron$, match:title ^(Pi Pet|Clawa · Pi Pet)$
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream, createWriteStream, constants as fsConstants } from "node:fs";
|
|
4
|
+
import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { get } from "node:https";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { Transform } from "node:stream";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import ts from "typescript";
|
|
13
|
+
|
|
14
|
+
const desktop = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const source = join(desktop, "..", "src", "desktop");
|
|
16
|
+
const output = join(desktop, "dist", "app");
|
|
17
|
+
const esmFiles = ["attention", "bridge", "config", "cursor-provider", "main"];
|
|
18
|
+
const cjsFiles = ["bridge", "preload"];
|
|
19
|
+
const require = createRequire(join(desktop, "package.json"));
|
|
20
|
+
const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
|
|
21
|
+
const LEADING_V_PATTERN = /^v/;
|
|
22
|
+
|
|
23
|
+
function electronExecutable(platform = process.platform) {
|
|
24
|
+
if (platform === "win32") return "electron.exe";
|
|
25
|
+
if (platform === "darwin") return join("Electron.app", "Contents", "MacOS", "Electron");
|
|
26
|
+
return "electron";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function ensureElectronRuntime() {
|
|
30
|
+
const electronRoot = dirname(require.resolve("electron/package.json"));
|
|
31
|
+
const manifest = JSON.parse(await readFile(join(electronRoot, "package.json"), "utf8"));
|
|
32
|
+
const dist = join(electronRoot, "dist");
|
|
33
|
+
const executable = electronExecutable();
|
|
34
|
+
try {
|
|
35
|
+
const installedVersion = (await readFile(join(dist, "version"), "utf8")).trim().replace(LEADING_V_PATTERN, "");
|
|
36
|
+
await access(join(dist, executable), process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
|
37
|
+
if (installedVersion === manifest.version) return;
|
|
38
|
+
} catch {
|
|
39
|
+
// Repair a missing or partial runtime below.
|
|
40
|
+
}
|
|
41
|
+
const artifactArch = process.arch === "arm" ? "armv7l" : process.arch;
|
|
42
|
+
const artifact = `electron-v${manifest.version}-${process.platform}-${artifactArch}.zip`;
|
|
43
|
+
const expectedChecksum = require(join(electronRoot, "checksums.json"))[artifact];
|
|
44
|
+
if (typeof expectedChecksum !== "string")
|
|
45
|
+
throw new Error(`Electron has no runtime for ${process.platform}-${process.arch}.`);
|
|
46
|
+
const cache = process.env["electron_config_cache"] || electronCacheDirectory();
|
|
47
|
+
await mkdir(cache, { recursive: true });
|
|
48
|
+
const archive = join(cache, artifact);
|
|
49
|
+
if ((await fileChecksum(archive)) !== expectedChecksum) {
|
|
50
|
+
await rm(archive, { force: true });
|
|
51
|
+
await downloadArchive(artifact, archive, expectedChecksum, manifest.version);
|
|
52
|
+
}
|
|
53
|
+
await rm(dist, { recursive: true, force: true });
|
|
54
|
+
await mkdir(dist, { recursive: true });
|
|
55
|
+
await extractArchive(archive, dist);
|
|
56
|
+
await writeFile(join(electronRoot, "path.txt"), executable);
|
|
57
|
+
await access(join(dist, executable), process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractArchive(archive, destination) {
|
|
61
|
+
const command = process.platform === "win32" ? "powershell.exe" : "unzip";
|
|
62
|
+
const args =
|
|
63
|
+
process.platform === "win32"
|
|
64
|
+
? [
|
|
65
|
+
"-NoProfile",
|
|
66
|
+
"-NonInteractive",
|
|
67
|
+
"-Command",
|
|
68
|
+
"Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force",
|
|
69
|
+
archive,
|
|
70
|
+
destination,
|
|
71
|
+
]
|
|
72
|
+
: ["-q", archive, "-d", destination];
|
|
73
|
+
return new Promise((resolvePromise, reject) => {
|
|
74
|
+
const child = spawn(command, args, { stdio: "ignore", windowsHide: true });
|
|
75
|
+
child.once("error", reject);
|
|
76
|
+
child.once("exit", (code) => {
|
|
77
|
+
if (code === 0) resolvePromise();
|
|
78
|
+
else reject(new Error(`Electron archive extraction exited ${code ?? "without status"}.`));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function electronCacheDirectory() {
|
|
84
|
+
if (process.platform === "win32") {
|
|
85
|
+
return join(process.env["LOCALAPPDATA"] || join(homedir(), "AppData", "Local"), "electron", "Cache");
|
|
86
|
+
}
|
|
87
|
+
if (process.platform === "darwin") return join(homedir(), "Library", "Caches", "electron");
|
|
88
|
+
return join(process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache"), "electron");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function fileChecksum(path) {
|
|
92
|
+
try {
|
|
93
|
+
const checksum = createHash("sha256");
|
|
94
|
+
for await (const chunk of createReadStream(path)) checksum.update(chunk);
|
|
95
|
+
return checksum.digest("hex");
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function downloadResponse(url, path, expectedChecksum, redirects = 0) {
|
|
102
|
+
return new Promise((resolvePromise, reject) => {
|
|
103
|
+
const request = get(url, { headers: { "user-agent": "pi-pet-desktop-build" } }, (response) => {
|
|
104
|
+
const location = response.headers.location;
|
|
105
|
+
if (location && response.statusCode >= 300 && response.statusCode < 400) {
|
|
106
|
+
response.resume();
|
|
107
|
+
if (redirects >= 5) reject(new Error("Electron download redirected too many times."));
|
|
108
|
+
else resolvePromise(downloadResponse(new URL(location, url), path, expectedChecksum, redirects + 1));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (response.statusCode !== 200) {
|
|
112
|
+
response.resume();
|
|
113
|
+
reject(new Error(`Electron download failed: HTTP ${response.statusCode ?? "unknown"}.`));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const checksum = createHash("sha256");
|
|
117
|
+
let received = 0;
|
|
118
|
+
const meter = new Transform({
|
|
119
|
+
transform(chunk, _encoding, callback) {
|
|
120
|
+
received += chunk.length;
|
|
121
|
+
if (received > MAX_ARCHIVE_BYTES) callback(new Error("Electron archive is unexpectedly large."));
|
|
122
|
+
else {
|
|
123
|
+
checksum.update(chunk);
|
|
124
|
+
callback(null, chunk);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
pipeline(response, meter, createWriteStream(path, { mode: 0o600 }))
|
|
129
|
+
.then(() => {
|
|
130
|
+
if (checksum.digest("hex") !== expectedChecksum) throw new Error("Electron archive checksum does not match.");
|
|
131
|
+
resolvePromise();
|
|
132
|
+
})
|
|
133
|
+
.catch(reject);
|
|
134
|
+
});
|
|
135
|
+
request.once("error", reject);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function downloadArchive(artifact, path, expectedChecksum, version) {
|
|
140
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
141
|
+
await rm(temporary, { force: true });
|
|
142
|
+
try {
|
|
143
|
+
await downloadResponse(
|
|
144
|
+
new URL(`https://github.com/electron/electron/releases/download/v${version}/${artifact}`),
|
|
145
|
+
temporary,
|
|
146
|
+
expectedChecksum,
|
|
147
|
+
);
|
|
148
|
+
await rename(temporary, path);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
await rm(temporary, { force: true });
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function transpile(code, file, module) {
|
|
156
|
+
const result = ts.transpileModule(code, {
|
|
157
|
+
fileName: file,
|
|
158
|
+
compilerOptions: {
|
|
159
|
+
target: ts.ScriptTarget.ES2024,
|
|
160
|
+
module,
|
|
161
|
+
rewriteRelativeImportExtensions: true,
|
|
162
|
+
verbatimModuleSyntax: true,
|
|
163
|
+
},
|
|
164
|
+
reportDiagnostics: true,
|
|
165
|
+
});
|
|
166
|
+
const errors = (result.diagnostics || []).filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
|
|
167
|
+
if (errors.length > 0) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
errors.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")).join("\n"),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return result.outputText;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await ensureElectronRuntime();
|
|
176
|
+
await rm(join(desktop, "dist"), { recursive: true, force: true });
|
|
177
|
+
await mkdir(output, { recursive: true });
|
|
178
|
+
|
|
179
|
+
for (const name of esmFiles) {
|
|
180
|
+
const path = join(source, `${name}.ts`);
|
|
181
|
+
await writeFile(join(output, `${name}.js`), transpile(await readFile(path, "utf8"), path, ts.ModuleKind.ESNext));
|
|
182
|
+
}
|
|
183
|
+
for (const name of cjsFiles) {
|
|
184
|
+
const path = join(source, `${name}.ts`);
|
|
185
|
+
const compiled = transpile(await readFile(path, "utf8"), path, ts.ModuleKind.CommonJS).replaceAll(
|
|
186
|
+
/require\("(\.\/.+)\.js"\)/g,
|
|
187
|
+
'require("$1.cjs")',
|
|
188
|
+
);
|
|
189
|
+
await writeFile(join(output, `${name}.cjs`), compiled);
|
|
190
|
+
}
|
|
191
|
+
await writeFile(
|
|
192
|
+
join(output, "package.json"),
|
|
193
|
+
`${JSON.stringify({ name: "pi-pet-remote-desktop", private: true, type: "module", main: "main.js" }, null, 2)}\n`,
|
|
194
|
+
);
|