pi-soly 1.11.2 → 1.12.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/README.md +60 -14
- package/artifact/index.ts +241 -0
- package/artifact/render.ts +239 -0
- package/artifact/server.ts +384 -0
- package/artifact/session.ts +368 -0
- package/ask/README.md +20 -8
- package/ask/index.ts +100 -36
- package/ask/picker.ts +345 -29
- package/commands.ts +246 -30
- package/config.ts +124 -8
- package/core.ts +48 -236
- package/deck/deck.ts +386 -0
- package/deck/index.ts +113 -0
- package/index.ts +153 -37
- package/iteration.ts +1 -9
- package/mcp/commands.ts +12 -0
- package/mcp/direct-tools.ts +19 -2
- package/mcp/index.ts +144 -2
- package/mcp/init.ts +11 -0
- package/mcp/lifecycle.ts +9 -2
- package/mcp/server-manager.ts +30 -18
- package/mcp/state.ts +7 -0
- package/mcp/tool-cache.ts +135 -0
- package/nudge.ts +20 -3
- package/package.json +10 -3
- package/skills/soly-framework/SKILL.md +64 -37
- package/tool-hints.ts +62 -0
- package/tools.ts +28 -100
- package/util.ts +250 -0
- package/visual/chrome.ts +166 -0
- package/visual/colors.ts +24 -0
- package/visual/data.ts +62 -0
- package/visual/footer.ts +129 -0
- package/visual/format.ts +73 -0
- package/visual/glyphs.ts +35 -0
- package/visual/gradient.ts +122 -0
- package/visual/index.ts +18 -0
- package/visual/list-panel.ts +207 -0
- package/visual/segments.ts +136 -0
- package/visual/style.ts +34 -0
- package/visual/topbar.ts +55 -0
- package/visual/welcome.ts +265 -0
- package/visual/working.ts +66 -0
- package/workflows/execute.ts +17 -7
- package/workflows/index.ts +91 -44
- package/workflows/inspect.ts +23 -26
- package/workflows/migrate.ts +85 -0
- package/workflows/parser.ts +4 -2
- package/workflows/planning.ts +9 -8
- package/workflows/verify.ts +194 -0
- package/workflows-data/discuss-phase.md +10 -6
- package/agents-install.ts +0 -106
- package/ask/prompt.ts +0 -41
- package/ask/tests/prompt.test.ts +0 -54
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// server.ts — per-project HTTP server for browsing artifacts (shared)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// One lightweight HTTP server PER PROJECT (not per session): it binds a pinned
|
|
6
|
+
// 127.0.0.1 port + route token derived from the cwd, so every pi window opened
|
|
7
|
+
// in the same folder resolves the SAME gallery URL. A second window discovers
|
|
8
|
+
// the already-running server via the on-disk registry (see session.ts) and
|
|
9
|
+
// reuses it as a remote client instead of starting its own — it mutates the
|
|
10
|
+
// owner's list over HTTP (POST register/remove/clear).
|
|
11
|
+
//
|
|
12
|
+
// Routes (all under the pinned `/<token>/`): a vanilla-JS gallery SPA at `/`,
|
|
13
|
+
// the entry list as JSON at `/list`, write endpoints `/register` `/remove`
|
|
14
|
+
// `/clear`, a live SSE stream at `/events`, and any file in the project dir at
|
|
15
|
+
// `/a/<path>` with a proper MIME type (so artifacts pull sibling CSS/JS/images).
|
|
16
|
+
// Built on node:http (no deps).
|
|
17
|
+
//
|
|
18
|
+
// Security: binds 127.0.0.1 only, namespaces routes under the project `token`,
|
|
19
|
+
// and confines file serving to the project dir. Lifecycle: started lazily on
|
|
20
|
+
// the first artifact; the owning window stops it on session_shutdown (other
|
|
21
|
+
// windows are just clients and leave it alone). Survives owner hand-off: the
|
|
22
|
+
// next window re-binds the same pinned port → same URL.
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
import * as http from "node:http";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { randomBytes } from "node:crypto";
|
|
29
|
+
import { atomicWriteFileSync } from "../util.ts";
|
|
30
|
+
import { buildGalleryShell, type GalleryEntry } from "./render.ts";
|
|
31
|
+
|
|
32
|
+
/** A gallery entry together with its served URL. */
|
|
33
|
+
export type ArtifactEntry = GalleryEntry & { url: string };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What every surface (the tool, `/artifacts`, the footer) needs from the
|
|
37
|
+
* artifact server — implemented by the local {@link ArtifactServer} (this
|
|
38
|
+
* process owns it) and by a remote client (another pi window owns it). Lets a
|
|
39
|
+
* second window in the same project reuse the first's already-running server.
|
|
40
|
+
*/
|
|
41
|
+
export interface ArtifactHandle {
|
|
42
|
+
/** Number of artifacts currently registered. */
|
|
43
|
+
readonly count: number;
|
|
44
|
+
/** Stable gallery URL (sidebar + iframe SPA). */
|
|
45
|
+
galleryUrl(): string;
|
|
46
|
+
/** Snapshot of entries (newest first), each with its served URL. */
|
|
47
|
+
list(): ArtifactEntry[];
|
|
48
|
+
/** Add (or update by `id`) an artifact; returns its served URL. */
|
|
49
|
+
register(title: string, file: string, id?: string): Promise<string>;
|
|
50
|
+
/** Remove an artifact by id (and delete its file). Returns whether it existed. */
|
|
51
|
+
remove(id: string): boolean;
|
|
52
|
+
/** Remove every artifact. Returns how many. */
|
|
53
|
+
clear(): number;
|
|
54
|
+
/** One-shot latch — true the first call only (for "open gallery once"). */
|
|
55
|
+
consumeFirstOpen(): boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MIME: Record<string, string> = {
|
|
59
|
+
".html": "text/html; charset=utf-8",
|
|
60
|
+
".htm": "text/html; charset=utf-8",
|
|
61
|
+
".css": "text/css; charset=utf-8",
|
|
62
|
+
".js": "text/javascript; charset=utf-8",
|
|
63
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
64
|
+
".json": "application/json; charset=utf-8",
|
|
65
|
+
".svg": "image/svg+xml",
|
|
66
|
+
".png": "image/png",
|
|
67
|
+
".jpg": "image/jpeg",
|
|
68
|
+
".jpeg": "image/jpeg",
|
|
69
|
+
".gif": "image/gif",
|
|
70
|
+
".webp": "image/webp",
|
|
71
|
+
".ico": "image/x-icon",
|
|
72
|
+
".txt": "text/plain; charset=utf-8",
|
|
73
|
+
".md": "text/plain; charset=utf-8",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function mimeFor(file: string): string {
|
|
77
|
+
return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read + parse a small JSON request body (capped at 1 MiB; null on any error). */
|
|
81
|
+
function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
let data = "";
|
|
84
|
+
req.on("data", (c: Buffer | string) => {
|
|
85
|
+
data += c;
|
|
86
|
+
if (data.length > 1 << 20) {
|
|
87
|
+
req.destroy();
|
|
88
|
+
resolve(null);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
req.on("end", () => {
|
|
92
|
+
try {
|
|
93
|
+
resolve(data ? JSON.parse(data) : null);
|
|
94
|
+
} catch {
|
|
95
|
+
resolve(null);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
req.on("error", () => resolve(null));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class ArtifactServer implements ArtifactHandle {
|
|
103
|
+
private server: http.Server | null = null;
|
|
104
|
+
private port: number;
|
|
105
|
+
private readonly token: string;
|
|
106
|
+
private readonly entries: GalleryEntry[] = [];
|
|
107
|
+
private readonly clients = new Set<http.ServerResponse>();
|
|
108
|
+
private opened = false;
|
|
109
|
+
private readonly manifestPath: string;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param dir absolute artifact directory (files are served from here)
|
|
113
|
+
* @param preferredPort pinned per-project port (falls back to ephemeral if busy)
|
|
114
|
+
* @param token pinned per-project route token (namespaces every route)
|
|
115
|
+
*/
|
|
116
|
+
constructor(private readonly dir: string, preferredPort: number, token: string) {
|
|
117
|
+
this.manifestPath = path.join(dir, "index.json");
|
|
118
|
+
this.port = preferredPort;
|
|
119
|
+
this.token = token;
|
|
120
|
+
// Restore artifacts persisted by a previous session/reload for this project.
|
|
121
|
+
this.entries.push(...this.loadManifest());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Actual bound port (== preferredPort unless it was busy and we fell back). */
|
|
125
|
+
get boundPort(): number {
|
|
126
|
+
return this.port;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read the on-disk manifest, keeping only entries whose file still exists. */
|
|
130
|
+
private loadManifest(): GalleryEntry[] {
|
|
131
|
+
try {
|
|
132
|
+
const raw = JSON.parse(fs.readFileSync(this.manifestPath, "utf-8")) as GalleryEntry[];
|
|
133
|
+
if (!Array.isArray(raw)) return [];
|
|
134
|
+
return raw
|
|
135
|
+
.filter((e) => e && typeof e.file === "string" && fs.existsSync(path.join(this.dir, e.file)))
|
|
136
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Persist the entry list so it survives /reload and pi restarts. */
|
|
143
|
+
private persist(): void {
|
|
144
|
+
try {
|
|
145
|
+
atomicWriteFileSync(this.manifestPath, JSON.stringify(this.entries));
|
|
146
|
+
} catch {
|
|
147
|
+
// best effort
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get started(): boolean {
|
|
152
|
+
return this.server !== null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** True exactly once — for "open the gallery in the browser the first time". */
|
|
156
|
+
consumeFirstOpen(): boolean {
|
|
157
|
+
if (this.opened) return false;
|
|
158
|
+
this.opened = true;
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
galleryUrl(): string {
|
|
163
|
+
return `http://127.0.0.1:${this.port}/${this.token}/`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
artifactUrl(file: string): string {
|
|
167
|
+
return `http://127.0.0.1:${this.port}/${this.token}/a/${encodeURIComponent(path.basename(file))}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Number of artifacts registered this session. */
|
|
171
|
+
get count(): number {
|
|
172
|
+
return this.entries.length;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Snapshot of the current entries (newest first), each with its URL. */
|
|
176
|
+
list(): (GalleryEntry & { url: string })[] {
|
|
177
|
+
return this.entries.map((e) => ({ ...e, url: this.artifactUrl(e.file) }));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Remove an artifact by id (and delete its file). Returns whether it existed. */
|
|
181
|
+
remove(id: string): boolean {
|
|
182
|
+
const i = this.entries.findIndex((e) => e.id === id);
|
|
183
|
+
if (i < 0) return false;
|
|
184
|
+
const [e] = this.entries.splice(i, 1);
|
|
185
|
+
if (e) this.deleteFile(e.file);
|
|
186
|
+
this.persist();
|
|
187
|
+
this.broadcast();
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Remove every artifact (and delete the files). Returns how many. */
|
|
192
|
+
clear(): number {
|
|
193
|
+
const n = this.entries.length;
|
|
194
|
+
for (const e of this.entries) this.deleteFile(e.file);
|
|
195
|
+
this.entries.length = 0;
|
|
196
|
+
this.persist();
|
|
197
|
+
this.broadcast();
|
|
198
|
+
return n;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private deleteFile(base: string): void {
|
|
202
|
+
try {
|
|
203
|
+
fs.unlinkSync(path.join(this.dir, base));
|
|
204
|
+
} catch {
|
|
205
|
+
// best effort
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Notify open gallery tabs (SSE) that the list changed. */
|
|
210
|
+
private broadcast(): void {
|
|
211
|
+
for (const res of this.clients) {
|
|
212
|
+
try {
|
|
213
|
+
res.write("data: update\n\n");
|
|
214
|
+
} catch {
|
|
215
|
+
// dropped client — cleaned up on its 'close'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Start listening on 127.0.0.1 — the pinned project port if free, else an
|
|
221
|
+
* ephemeral fallback (URL won't be stable that session, but the registry
|
|
222
|
+
* still points other windows here). Idempotent. */
|
|
223
|
+
async ensureStarted(): Promise<void> {
|
|
224
|
+
if (this.server) return;
|
|
225
|
+
const server = http.createServer((req, res) => this.handle(req, res));
|
|
226
|
+
try {
|
|
227
|
+
await this.bind(server, this.port);
|
|
228
|
+
} catch {
|
|
229
|
+
this.port = 0; // pinned port busy → ephemeral
|
|
230
|
+
await this.bind(server, 0);
|
|
231
|
+
}
|
|
232
|
+
this.server = server;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Listen on `port` and record the address actually bound. */
|
|
236
|
+
private bind(server: http.Server, port: number): Promise<void> {
|
|
237
|
+
return new Promise<void>((resolve, reject) => {
|
|
238
|
+
const onError = (err: NodeJS.ErrnoException) => reject(err);
|
|
239
|
+
server.once("error", onError);
|
|
240
|
+
server.listen(port, "127.0.0.1", () => {
|
|
241
|
+
server.off("error", onError);
|
|
242
|
+
const addr = server.address();
|
|
243
|
+
this.port = typeof addr === "object" && addr ? addr.port : 0;
|
|
244
|
+
resolve();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Record (or, when `id` matches an existing entry, update in place) an
|
|
250
|
+
* artifact and notify open gallery tabs. Returns its URL. Async so the
|
|
251
|
+
* shared {@link ArtifactHandle} contract also covers a remote client. */
|
|
252
|
+
async register(title: string, file: string, id?: string): Promise<string> {
|
|
253
|
+
const base = path.basename(file);
|
|
254
|
+
const existing = id ? this.entries.find((e) => e.id === id) : undefined;
|
|
255
|
+
if (existing) {
|
|
256
|
+
existing.title = title;
|
|
257
|
+
existing.file = base;
|
|
258
|
+
existing.createdAt = Date.now();
|
|
259
|
+
} else {
|
|
260
|
+
this.entries.unshift({
|
|
261
|
+
id: id ?? randomBytes(4).toString("hex"),
|
|
262
|
+
title,
|
|
263
|
+
file: base,
|
|
264
|
+
createdAt: Date.now(),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
this.persist();
|
|
268
|
+
this.broadcast();
|
|
269
|
+
return this.artifactUrl(base);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
stop(): void {
|
|
273
|
+
for (const res of this.clients) {
|
|
274
|
+
try {
|
|
275
|
+
res.end();
|
|
276
|
+
} catch {
|
|
277
|
+
// ignore
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
this.clients.clear();
|
|
281
|
+
try {
|
|
282
|
+
this.server?.close();
|
|
283
|
+
} catch {
|
|
284
|
+
// ignore
|
|
285
|
+
}
|
|
286
|
+
this.server = null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
290
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
|
291
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
292
|
+
if (parts[0] !== this.token) {
|
|
293
|
+
res.writeHead(404);
|
|
294
|
+
res.end("not found");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const rest = parts.slice(1);
|
|
298
|
+
if (rest.length === 0) {
|
|
299
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
300
|
+
res.end(buildGalleryShell(this.token));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Write endpoints — let other pi windows in this project mutate the one
|
|
304
|
+
// running server (register/remove/clear) over HTTP.
|
|
305
|
+
if (req.method === "POST" && rest[0] === "register") return this.apiRegister(req, res);
|
|
306
|
+
if (req.method === "POST" && rest[0] === "remove") return this.apiMutate(req, res, (b) => this.remove(String(b.id)));
|
|
307
|
+
if (req.method === "POST" && rest[0] === "clear") return this.apiMutate(req, res, () => this.clear());
|
|
308
|
+
if (rest[0] === "list") {
|
|
309
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
310
|
+
res.end(JSON.stringify(this.entries));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (rest[0] === "events") {
|
|
314
|
+
this.serveEvents(res);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (rest[0] === "a" && rest.length > 1) {
|
|
318
|
+
this.serveFile(rest.slice(1).map((p) => decodeURIComponent(p)).join("/"), res);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
res.writeHead(404);
|
|
322
|
+
res.end("not found");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** POST /<token>/register {title, file, id?} → add/update + return entries. */
|
|
326
|
+
private async apiRegister(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
327
|
+
const b = (await readJsonBody(req)) as { title?: string; file?: string; id?: string } | null;
|
|
328
|
+
if (!b || typeof b.title !== "string" || typeof b.file !== "string") {
|
|
329
|
+
res.writeHead(400);
|
|
330
|
+
res.end("bad request");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
await this.register(b.title, b.file, b.id);
|
|
334
|
+
this.jsonEntries(res);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** POST /<token>/(remove|clear) → apply mutation + return entries. */
|
|
338
|
+
private async apiMutate(
|
|
339
|
+
req: http.IncomingMessage,
|
|
340
|
+
res: http.ServerResponse,
|
|
341
|
+
fn: (b: Record<string, unknown>) => void,
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
const b = ((await readJsonBody(req)) ?? {}) as Record<string, unknown>;
|
|
344
|
+
fn(b);
|
|
345
|
+
this.jsonEntries(res);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private jsonEntries(res: http.ServerResponse): void {
|
|
349
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
350
|
+
res.end(JSON.stringify({ entries: this.entries }));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private serveEvents(res: http.ServerResponse): void {
|
|
354
|
+
res.writeHead(200, {
|
|
355
|
+
"content-type": "text/event-stream",
|
|
356
|
+
"cache-control": "no-cache",
|
|
357
|
+
connection: "keep-alive",
|
|
358
|
+
});
|
|
359
|
+
res.write(": connected\n\n");
|
|
360
|
+
this.clients.add(res);
|
|
361
|
+
res.on("close", () => this.clients.delete(res));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Serve a file from the session dir, confined to it (no traversal). */
|
|
365
|
+
private serveFile(rel: string, res: http.ServerResponse): void {
|
|
366
|
+
const root = path.resolve(this.dir);
|
|
367
|
+
const target = path.resolve(root, rel);
|
|
368
|
+
if (target !== root && !target.startsWith(root + path.sep)) {
|
|
369
|
+
res.writeHead(403);
|
|
370
|
+
res.end("forbidden");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
let body: Buffer;
|
|
374
|
+
try {
|
|
375
|
+
body = fs.readFileSync(target);
|
|
376
|
+
} catch {
|
|
377
|
+
res.writeHead(404);
|
|
378
|
+
res.end("artifact not found");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
res.writeHead(200, { "content-type": mimeFor(target) });
|
|
382
|
+
res.end(body);
|
|
383
|
+
}
|
|
384
|
+
}
|