@preapexis/piexis 0.2.4 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -2
- package/apps/server/src/index.ts +97 -0
- package/apps/server/src/session-manager.ts +95 -0
- package/apps/server/src/workspace.ts +39 -0
- package/{tsconfig.json → apps/server/tsconfig.json} +3 -4
- package/apps/web/dist/assets/index-D4n3_lAw.js +10 -0
- package/apps/web/dist/assets/index-igiK0FPY.css +1 -0
- package/apps/web/dist/index.html +3 -0
- package/bin/piexis-web.mjs +24 -0
- package/extensions/webui.ts +45 -0
- package/package.json +38 -3
- package/packages/pi-integration/src/rpc-client.ts +104 -0
- package/packages/shared/src/index.ts +27 -0
- package/themes/midnight.json +81 -0
- package/.github/workflows/publish.yml +0 -35
- package/AGENTS.md +0 -38
- package/scripts/release.mjs +0 -32
- package/skills-lock.json +0 -365
- package/tests/agents.test.mjs +0 -605
- package/tests/ask-question-pty.test.mjs +0 -16
- package/tests/ask-question.test.mjs +0 -269
- package/tests/fixtures/agents-provider.ts +0 -55
- package/tests/fixtures/ask-question-pty.py +0 -91
- package/tests/fixtures/ask-question-terminal.mjs +0 -69
- package/tests/fixtures/recommended-tools-terminal.mjs +0 -103
- package/tests/init-workflow.test.mjs +0 -31
- package/tests/recommended-tools-pty.test.mjs +0 -16
- package/tests/recommended-tools.test.mjs +0 -423
- package/tests/resources.test.mjs +0 -66
- package/tests/skills-routing.test.mjs +0 -54
- package/tests/ui-tip-reload.test.mjs +0 -70
- package/tests/ui.test.mjs +0 -450
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ PieXis is a Pi package with interactive questions, coding skills, prompts, and t
|
|
|
8
8
|
pi install npm:@preapexis/piexis
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Restart Pi or run `/reload` after installation. The package loads AskQuestion, agent profiles/subagents, the custom header/footer, automatic recommended-tool setup, prompts, skills, and
|
|
11
|
+
Restart Pi or run `/reload` after installation. The package loads AskQuestion, agent profiles/subagents, the custom header/footer, automatic recommended-tool setup, prompts, skills, and themes.
|
|
12
12
|
|
|
13
13
|
## Resources
|
|
14
14
|
|
|
@@ -19,7 +19,7 @@ Restart Pi or run `/reload` after installation. The package loads AskQuestion, a
|
|
|
19
19
|
- **Skills** cover implementation, debugging, review, design, refactoring, validation, and document workflows. Bundled **caveman** adds concise chat style; **ponytail** guides the smallest complete coding change. Only their core instructions are included, not upstream hooks or companion skills. Source revisions and licenses live beside each skill.
|
|
20
20
|
- **Prompts** include `/init`, `/commit`, and `/summary`. `/init` adds workflow guidance to `AGENTS.md`: caveman for responses, grilling for clarification questions, and ponytail for building. These are agent instructions, not runtime enforcement.
|
|
21
21
|
|
|
22
|
-
- **
|
|
22
|
+
- **Themes** include warm **orange** and **midnight**, a dark slate palette with cyan, violet, and mint accents. Run `/reload`, then choose a palette in `/settings` → **Theme**.
|
|
23
23
|
|
|
24
24
|
Caveman and ponytail ship with this package; no separate installation is needed. Their upstream standalone installation commands are:
|
|
25
25
|
|
|
@@ -110,6 +110,26 @@ PieXis no longer provides permission modes, tool approvals, classifier review, p
|
|
|
110
110
|
|
|
111
111
|
Existing `piexis` settings, legacy configuration files, session records, and retained task worktrees are not deleted automatically. Old classifier, execution-model, and background-model settings are no longer read. Remove obsolete configuration manually if desired, preserving unrelated settings and any work you need. Remove explicit references to deleted extensions from your Pi resource settings or launch arguments.
|
|
112
112
|
|
|
113
|
+
## PIXIS WebUI (preview)
|
|
114
|
+
|
|
115
|
+
PIXIS 2.0 adds a local WebUI without replacing the Pi TUI. Each browser session observes a backend-owned, long-lived Pi RPC process. After installing or updating Piexis, start Pi inside the project and run:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
/webui
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Pi displays a local URL containing a per-launch access token; open that URL in a browser. The TUI remains usable. The package also exposes a PowerShell-friendly command for starting outside Pi:
|
|
122
|
+
|
|
123
|
+
```powershell
|
|
124
|
+
piexis-web --workspace C:\path\to\your-project
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The server uses the requested workspace and starts Pi with documented `--mode rpc`. Pi must be installed, authenticated, and available as `pi` (or set `PIXIS_PI_BIN`). Use `.env.example` as the configuration reference. `npm run dev` remains available only for developing Piexis itself and serves the Vite development client on port 5173.
|
|
128
|
+
|
|
129
|
+
The current preview supports genuine streamed assistant text, tool lifecycle cards, abort, Pi extension dialog forwarding, multiple observed backend sessions, and read-only Git status. It binds the agent server to `127.0.0.1`, requires a per-launch token and exact browser origin, validates WebSocket messages, confines file paths before Git diffs, and never sends secrets to the browser. Localhost alone is not treated as authentication.
|
|
130
|
+
|
|
131
|
+
Current limitations: session list is limited to sessions created in the WebUI process (it does not yet enumerate historical Pi session files); file explorer, Monaco editor/diff rendering, and terminal panel are planned; Git controls are read-only. The existing Pi TUI remains unchanged and can run separately.
|
|
132
|
+
|
|
113
133
|
## Select package resources
|
|
114
134
|
|
|
115
135
|
Use Pi's native resource filtering with `pi config` (or `pi config --local` for a trusted project), or narrow resources in settings:
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import Fastify from "fastify";
|
|
5
|
+
import websocket from "@fastify/websocket";
|
|
6
|
+
import fastifyStatic from "@fastify/static";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { SessionManager, type ManagedSession } from "./session-manager.ts";
|
|
9
|
+
import { gitDiff, gitStatus } from "./workspace.ts";
|
|
10
|
+
import type { ClientCommand, ServerMessage } from "../../../packages/shared/src/index.ts";
|
|
11
|
+
|
|
12
|
+
const port = Number(process.env.PIXIS_PORT || 4317);
|
|
13
|
+
const workspace = path.resolve(process.env.PIXIS_WORKSPACE || process.cwd());
|
|
14
|
+
const token = process.env.PIXIS_TOKEN || randomBytes(32).toString("base64url");
|
|
15
|
+
const allowedOrigin = process.env.PIXIS_ORIGIN || `http://127.0.0.1:${port}`;
|
|
16
|
+
const webRoot = path.resolve(process.env.PIXIS_WEB_ROOT || path.join(path.dirname(fileURLToPath(import.meta.url)), "../../web/dist"));
|
|
17
|
+
const clients = new Set<{ send(data: string): void; readyState: number }>();
|
|
18
|
+
const app = Fastify({ logger: { level: process.env.PIXIS_LOG_LEVEL || "warn" } });
|
|
19
|
+
|
|
20
|
+
const commandSchema = z.discriminatedUnion("type", [
|
|
21
|
+
z.object({ type: z.literal("list_sessions") }),
|
|
22
|
+
z.object({ type: z.literal("subscribe"), sessionId: z.string().uuid() }),
|
|
23
|
+
z.object({ type: z.literal("create_session") }),
|
|
24
|
+
z.object({ type: z.literal("prompt"), sessionId: z.string().uuid(), message: z.string().trim().min(1).max(100_000) }),
|
|
25
|
+
z.object({ type: z.literal("abort"), sessionId: z.string().uuid() }),
|
|
26
|
+
z.object({ type: z.literal("extension_ui_response"), sessionId: z.string().uuid(), id: z.string().min(1), value: z.string().optional(), confirmed: z.boolean().optional(), cancelled: z.boolean().optional() }),
|
|
27
|
+
z.object({ type: z.literal("git_status"), sessionId: z.string().uuid() }),
|
|
28
|
+
z.object({ type: z.literal("git_diff"), sessionId: z.string().uuid(), path: z.string().min(1).max(4096) })
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function secureEqual(a: string, b: string) {
|
|
32
|
+
const left = createHash("sha256").update(a).digest();
|
|
33
|
+
const right = createHash("sha256").update(b).digest();
|
|
34
|
+
return timingSafeEqual(left, right);
|
|
35
|
+
}
|
|
36
|
+
function send(client: { send(data: string): void; readyState: number }, message: ServerMessage) {
|
|
37
|
+
if (client.readyState === 1) client.send(JSON.stringify(message));
|
|
38
|
+
}
|
|
39
|
+
function broadcast(message: ServerMessage) { for (const client of clients) send(client, message); }
|
|
40
|
+
|
|
41
|
+
const manager = new SessionManager(workspace, (session, type, data) => {
|
|
42
|
+
broadcast(type === "pi_event" ? { type, sessionId: session.id, event: data } : { type, sessionId: session.id, request: data });
|
|
43
|
+
broadcast({ type: "session", session: manager.summary(session) });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await app.register(websocket);
|
|
47
|
+
await app.register(fastifyStatic, { root: webRoot, wildcard: false });
|
|
48
|
+
app.get("/health", async () => ({ ok: true }));
|
|
49
|
+
app.get("/ws", { websocket: true }, (socket, request) => {
|
|
50
|
+
const origin = request.headers.origin;
|
|
51
|
+
const suppliedToken = (request.query as { token?: string }).token;
|
|
52
|
+
if (origin !== allowedOrigin || !suppliedToken || !secureEqual(suppliedToken, token)) {
|
|
53
|
+
socket.close(1008, "Unauthorized PIXIS client");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
clients.add(socket);
|
|
57
|
+
socket.on("close", () => clients.delete(socket));
|
|
58
|
+
socket.on("message", async (raw: Buffer) => {
|
|
59
|
+
try {
|
|
60
|
+
const command = commandSchema.parse(JSON.parse(raw.toString("utf8"))) as ClientCommand;
|
|
61
|
+
await handle(socket, command);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
send(socket, { type: "error", message: error instanceof Error ? error.message : "Invalid request" });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function handle(client: { send(data: string): void; readyState: number }, command: ClientCommand) {
|
|
69
|
+
if (command.type === "list_sessions") {
|
|
70
|
+
const first = manager.sessions.values().next().value as ManagedSession | undefined;
|
|
71
|
+
if (first) return snapshot(client, first);
|
|
72
|
+
return send(client, { type: "snapshot", sessions: [], sessionId: "", state: {}, messages: [] });
|
|
73
|
+
}
|
|
74
|
+
if (command.type === "create_session") {
|
|
75
|
+
const session = await manager.create();
|
|
76
|
+
broadcast({ type: "session", session: manager.summary(session) });
|
|
77
|
+
return snapshot(client, session);
|
|
78
|
+
}
|
|
79
|
+
const session = manager.get(command.sessionId);
|
|
80
|
+
if (command.type === "subscribe") return snapshot(client, session);
|
|
81
|
+
if (command.type === "prompt") await manager.prompt(session.id, command.message);
|
|
82
|
+
if (command.type === "abort") await manager.abort(session.id);
|
|
83
|
+
if (command.type === "extension_ui_response") session.client.send({ type: "extension_ui_response", id: command.id, value: command.value, confirmed: command.confirmed, cancelled: command.cancelled });
|
|
84
|
+
if (command.type === "git_status") send(client, { type: "workspace", sessionId: session.id, action: "git_status", data: await gitStatus(workspace) });
|
|
85
|
+
if (command.type === "git_diff") send(client, { type: "workspace", sessionId: session.id, action: "git_diff", data: await gitDiff(workspace, command.path) });
|
|
86
|
+
broadcast({ type: "session", session: manager.summary(session) });
|
|
87
|
+
}
|
|
88
|
+
function snapshot(client: { send(data: string): void; readyState: number }, session: ManagedSession) {
|
|
89
|
+
send(client, { type: "snapshot", sessions: [...manager.sessions.values()].map((item) => manager.summary(item)), sessionId: session.id, state: session.state, messages: session.messages });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const shutdown = async () => { manager.dispose(); await app.close(); };
|
|
93
|
+
process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown);
|
|
94
|
+
await app.listen({ port, host: "127.0.0.1" });
|
|
95
|
+
console.log(`PIXIS server listening on http://127.0.0.1:${port}`);
|
|
96
|
+
console.log(`Open http://127.0.0.1:${port}/#token=${token}`);
|
|
97
|
+
if (!process.env.PIXIS_TOKEN) console.log(`PIXIS token (set PIXIS_TOKEN to persist it): ${token}`);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { PiRpcClient } from "../../../packages/pi-integration/src/rpc-client.ts";
|
|
3
|
+
import type { SessionStatus, SessionSummary } from "../../../packages/shared/src/index.ts";
|
|
4
|
+
|
|
5
|
+
export interface ManagedSession {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
status: SessionStatus;
|
|
9
|
+
client: PiRpcClient;
|
|
10
|
+
state: unknown;
|
|
11
|
+
messages: unknown[];
|
|
12
|
+
sessionFile?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class SessionManager {
|
|
17
|
+
readonly sessions = new Map<string, ManagedSession>();
|
|
18
|
+
constructor(private readonly cwd: string, private readonly emit: (session: ManagedSession, type: "pi_event" | "extension_ui", data: unknown) => void) {}
|
|
19
|
+
|
|
20
|
+
async create(): Promise<ManagedSession> {
|
|
21
|
+
const id = randomUUID();
|
|
22
|
+
const client = new PiRpcClient(this.cwd);
|
|
23
|
+
const session: ManagedSession = { id, name: "New session", status: "starting", client, state: {}, messages: [] };
|
|
24
|
+
this.sessions.set(id, session);
|
|
25
|
+
client.on("record", (record) => {
|
|
26
|
+
if (record.type === "extension_ui_request") {
|
|
27
|
+
this.emit(session, "extension_ui", record);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this.#applyEvent(session, record);
|
|
31
|
+
this.emit(session, "pi_event", record);
|
|
32
|
+
});
|
|
33
|
+
client.on("exit", (error) => {
|
|
34
|
+
session.status = "failed";
|
|
35
|
+
this.emit(session, "pi_event", { type: "runtime_error", message: error.message });
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
const response = await client.command({ type: "get_state" });
|
|
39
|
+
if (!response.success) throw new Error(response.error || "Pi rejected get_state");
|
|
40
|
+
session.state = response.data ?? {};
|
|
41
|
+
const data = response.data as { sessionId?: string; sessionFile?: string; sessionName?: string; model?: { id?: string } };
|
|
42
|
+
session.sessionFile = data.sessionFile;
|
|
43
|
+
session.name = data.sessionName || "New session";
|
|
44
|
+
session.model = data.model?.id;
|
|
45
|
+
const messages = await client.command({ type: "get_messages" });
|
|
46
|
+
if (messages.success) session.messages = ((messages.data as { messages?: unknown[] })?.messages ?? []);
|
|
47
|
+
session.status = "ready";
|
|
48
|
+
return session;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
session.status = "failed";
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async prompt(id: string, message: string): Promise<void> {
|
|
56
|
+
const session = this.get(id);
|
|
57
|
+
if (session.status === "running" || session.status === "cancelling") throw new Error("This session already has an active operation. Stop it before sending another prompt.");
|
|
58
|
+
session.status = "running";
|
|
59
|
+
const response = await session.client.command({ type: "prompt", message });
|
|
60
|
+
if (!response.success) {
|
|
61
|
+
session.status = "ready";
|
|
62
|
+
throw new Error(response.error || "Pi rejected prompt");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async abort(id: string): Promise<void> {
|
|
67
|
+
const session = this.get(id);
|
|
68
|
+
if (session.status !== "running") return;
|
|
69
|
+
session.status = "cancelling";
|
|
70
|
+
const response = await session.client.command({ type: "abort" });
|
|
71
|
+
if (!response.success) throw new Error(response.error || "Pi failed to stop");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get(id: string): ManagedSession {
|
|
75
|
+
const session = this.sessions.get(id);
|
|
76
|
+
if (!session) throw new Error("Unknown session");
|
|
77
|
+
return session;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
summary(session: ManagedSession): SessionSummary {
|
|
81
|
+
return { id: session.id, name: session.name, status: session.status, model: session.model, sessionFile: session.sessionFile };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
dispose(): void { for (const session of this.sessions.values()) session.client.dispose(); }
|
|
85
|
+
|
|
86
|
+
#applyEvent(session: ManagedSession, event: Record<string, unknown>): void {
|
|
87
|
+
if (event.type === "agent_start") session.status = "running";
|
|
88
|
+
if (event.type === "agent_settled") session.status = event.aborted === true ? "ready" : "ready";
|
|
89
|
+
if (event.type === "message_end") {
|
|
90
|
+
const message = event.message as { role?: string } | undefined;
|
|
91
|
+
if (message?.role === "assistant" || message?.role === "user") session.messages.push(event.message);
|
|
92
|
+
}
|
|
93
|
+
if (event.type === "session_info_changed" && typeof event.name === "string") session.name = event.name;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
export async function workspacePath(root: string, candidate: string): Promise<string> {
|
|
9
|
+
if (!candidate || path.isAbsolute(candidate) || candidate.includes("\0")) throw new Error("Invalid workspace path");
|
|
10
|
+
const base = await realpath(root);
|
|
11
|
+
const resolved = path.resolve(base, candidate);
|
|
12
|
+
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) throw new Error("Path escapes workspace");
|
|
13
|
+
const actual = await realpath(resolved);
|
|
14
|
+
if (actual !== base && !actual.startsWith(`${base}${path.sep}`)) throw new Error("Path escapes workspace");
|
|
15
|
+
return actual;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function git(root: string, args: string[]): Promise<string> {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await execFileAsync("git", ["-C", root, ...args], { windowsHide: true, maxBuffer: 2 * 1024 * 1024 });
|
|
21
|
+
return stdout;
|
|
22
|
+
} catch (error: unknown) {
|
|
23
|
+
const message = error instanceof Error ? error.message : "Git command failed";
|
|
24
|
+
throw new Error(message.includes("not a git repository") ? "This workspace is not a Git repository." : message);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function gitStatus(root: string) {
|
|
29
|
+
const [branch, porcelain] = await Promise.all([git(root, ["branch", "--show-current"]), git(root, ["status", "--porcelain=v1"])]);
|
|
30
|
+
return {
|
|
31
|
+
branch: branch.trim() || "detached HEAD",
|
|
32
|
+
files: porcelain.split("\n").filter(Boolean).map((line) => ({ status: line.slice(0, 2).trim() || "?", path: line.slice(3) }))
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function gitDiff(root: string, relativePath: string) {
|
|
37
|
+
await workspacePath(root, relativePath);
|
|
38
|
+
return git(root, ["diff", "--no-ext-diff", "--", relativePath]);
|
|
39
|
+
}
|
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
"module": "NodeNext",
|
|
5
5
|
"moduleResolution": "NodeNext",
|
|
6
6
|
"allowImportingTsExtensions": true,
|
|
7
|
-
"resolveJsonModule": true,
|
|
8
|
-
"noEmit": true,
|
|
9
7
|
"strict": true,
|
|
10
8
|
"skipLibCheck": true,
|
|
11
|
-
"types": ["node"]
|
|
9
|
+
"types": ["node"],
|
|
10
|
+
"noEmit": true
|
|
12
11
|
},
|
|
13
|
-
"include": ["
|
|
12
|
+
"include": ["src/**/*.ts", "../../packages/**/*.ts"]
|
|
14
13
|
}
|