@plannotator/pi-extension 0.28.48 → 0.28.49
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/generated/config.ts +117 -17
- package/generated/guide-viewer-manifest.ts +2 -2
- package/generated/inbox/connection.ts +54 -1
- package/generated/inbox/project-key.ts +10 -0
- package/generated/shape/core.ts +1070 -0
- package/generated/shape/format.ts +5 -0
- package/generated/shape/routes.ts +283 -0
- package/generated/shape/store.ts +687 -0
- package/generated/tailnet-audience.ts +13 -6
- package/generated/ulid.ts +21 -0
- package/package.json +1 -1
- package/plannotator.html +431 -425
- package/release.json +1 -1
- package/review-editor.html +406 -400
- package/server/code-nav-runtime.ts +47 -0
- package/server/serverReview.ts +2 -42
- package/server/serverShape.test.ts +147 -0
- package/server/serverShape.ts +118 -0
- package/skills/plannotator/SKILL.md +9 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/shared/shape/format.ts
|
|
2
|
+
// The Shape format lives in @plannotator/core (browser-safe), so the board's UI
|
|
3
|
+
// in @plannotator/ui reads the same types and diff. This shim keeps the store's
|
|
4
|
+
// imports in one place, as shared does for its other core modules.
|
|
5
|
+
export * from './core.ts';
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/shared/shape/routes.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shape's HTTP routes, runtime-neutral: one handler both servers mount (the
|
|
4
|
+
* Inbox's Bun server and Pi's), so a board reads and writes the same way
|
|
5
|
+
* wherever it is open (SPEC.md §3, "Routes"). Each server turns the result
|
|
6
|
+
* into its own response: JSON, or a file it streams.
|
|
7
|
+
*
|
|
8
|
+
* GET /api/shapes[?preview=1] every shape, newest change first (preview: each one's latest Picture and refs, for a grid)
|
|
9
|
+
* GET /api/shapes/:id[?n=] one shape: its latest (or nth) version, notes, seen
|
|
10
|
+
* GET /api/shapes/:id/versions its history
|
|
11
|
+
* POST /api/shapes/:id/notes the person's Change it or Ask
|
|
12
|
+
* POST /api/shapes/:id/answer an answer to an Ask (the session's or Ask AI's)
|
|
13
|
+
* POST /api/shapes/:id/seen { n }: the person has looked at version n
|
|
14
|
+
* POST /api/shapes/:id/state { state: "active" | "finished" }
|
|
15
|
+
* GET /api/shapes/:id/files/:name an image the shape keeps (files/…)
|
|
16
|
+
* GET /api/shapes/:id/code/file?path= a project file's text, for a file reference and the Code view
|
|
17
|
+
* POST /api/shapes/:id/code/hover hover insights for a code token (code review's code-nav)
|
|
18
|
+
* GET /api/shapes/events the event feed (SSE): `event: shapes` `{ ids }` when shapes change
|
|
19
|
+
*
|
|
20
|
+
* The event feed is each server's own stream (`shapeEventFrame`); the watch
|
|
21
|
+
* below tells it what changed. Agents write through the CLI, never here. The
|
|
22
|
+
* code routes read inside the shape's project folder only, through the
|
|
23
|
+
* runtime's code-nav (`ShapeRouteDeps.code`).
|
|
24
|
+
*/
|
|
25
|
+
import { extname } from "node:path";
|
|
26
|
+
import { isShapeId, type ShapeNote, type ShapeRecord, type ShapeSession, type ShapeVersion } from "./format.ts";
|
|
27
|
+
import type { ShapeStore, ShapeSummary } from "./store.ts";
|
|
28
|
+
|
|
29
|
+
export type ShapeRouteResult =
|
|
30
|
+
| { status: number; body: unknown }
|
|
31
|
+
| { status: 200; file: { path: string; contentType: string } };
|
|
32
|
+
|
|
33
|
+
export interface ShapeRouteRequest {
|
|
34
|
+
method: string;
|
|
35
|
+
/** The URL's path, `/api/shapes/…`. */
|
|
36
|
+
pathname: string;
|
|
37
|
+
params: URLSearchParams;
|
|
38
|
+
/** The JSON body of a POST; it throws on bad JSON. */
|
|
39
|
+
json: () => Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The session that drew a shape, as the server knows it now. */
|
|
43
|
+
export interface ShapeSessionState {
|
|
44
|
+
state: "live" | "busy" | "gone";
|
|
45
|
+
agent: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ShapeRouteDeps {
|
|
49
|
+
store: ShapeStore;
|
|
50
|
+
/** Live or gone, for the board's Ask footer. Absent: gone. */
|
|
51
|
+
sessionOf?: (shape: ShapeRecord) => ShapeSessionState;
|
|
52
|
+
/** A shape changed through these routes (a note, seen, a state); the event feed tells the pages. */
|
|
53
|
+
changed?: (id: string) => void;
|
|
54
|
+
/** The runtime's code-nav, rooted at the shape's project folder. Absent: the code routes answer 404. */
|
|
55
|
+
code?: {
|
|
56
|
+
/** `{ content }`, or an error status and body (code review's `/api/code-nav/file`). */
|
|
57
|
+
file(root: string, path: string | null): Promise<{ status: number; body: unknown }>;
|
|
58
|
+
/** Code review's `/api/code-nav/hover` answer for this body. */
|
|
59
|
+
hover(root: string, body: unknown): Promise<{ status: number; body: unknown }>;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The event feed's frame: these shapes changed (read them again). */
|
|
64
|
+
export function shapeEventFrame(ids: readonly string[]): string {
|
|
65
|
+
return `event: shapes\ndata: ${JSON.stringify({ ids })}\n\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const SHAPE_EVENTS_PATH = "/api/shapes/events";
|
|
69
|
+
|
|
70
|
+
const IMAGE_TYPES: Record<string, string> = {
|
|
71
|
+
".png": "image/png",
|
|
72
|
+
".jpg": "image/jpeg",
|
|
73
|
+
".jpeg": "image/jpeg",
|
|
74
|
+
".gif": "image/gif",
|
|
75
|
+
".webp": "image/webp",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const AGENT_NAMES: Record<string, string> = {
|
|
79
|
+
"claude-code": "Claude Code",
|
|
80
|
+
codex: "Codex",
|
|
81
|
+
pi: "Pi",
|
|
82
|
+
opencode: "OpenCode",
|
|
83
|
+
droid: "Droid",
|
|
84
|
+
amp: "Amp",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function shapeAgentName(session: ShapeSession | null): string {
|
|
88
|
+
if (!session) return "the agent";
|
|
89
|
+
return AGENT_NAMES[session.host] ?? session.host;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function unlocked(store: ShapeStore, id: string, waitMs = 5_000): Promise<boolean> {
|
|
93
|
+
const until = Date.now() + waitMs;
|
|
94
|
+
while (store.locked(id)) {
|
|
95
|
+
if (Date.now() > until) return false;
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const bad = (message: string, status = 400): ShapeRouteResult => ({ status, body: { error: message } });
|
|
102
|
+
|
|
103
|
+
/** Shape's routes; null when the path is not one of them. */
|
|
104
|
+
export async function handleShapeRoute(req: ShapeRouteRequest, deps: ShapeRouteDeps): Promise<ShapeRouteResult | null> {
|
|
105
|
+
if (req.pathname !== "/api/shapes" && !req.pathname.startsWith("/api/shapes/")) return null;
|
|
106
|
+
const { store } = deps;
|
|
107
|
+
const parts = req.pathname.split("/").slice(3).map((p) => decodeURIComponent(p));
|
|
108
|
+
const method = req.method.toUpperCase();
|
|
109
|
+
|
|
110
|
+
if (parts.length === 0) {
|
|
111
|
+
if (method !== "GET") return bad("method not allowed", 405);
|
|
112
|
+
const all = req.params.get("state") === "all";
|
|
113
|
+
const shapes: (ShapeSummary & { preview?: { picture: unknown; refs: unknown } | null })[] = store.list({ state: all ? "all" : "active" });
|
|
114
|
+
if (req.params.get("preview") === "1") {
|
|
115
|
+
for (const s of shapes) {
|
|
116
|
+
const latest = store.read(s.id);
|
|
117
|
+
s.preview = latest ? { picture: latest.views.picture ?? null, refs: latest.refs ?? [] } : null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { status: 200, body: { shapes } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const id = parts[0]!;
|
|
124
|
+
if (id === "events") return null; // the server's own stream
|
|
125
|
+
if (!isShapeId(id)) return bad("not a shape id", 404);
|
|
126
|
+
const shape = store.get(id);
|
|
127
|
+
if (!shape) return bad(`No shape ${id}.`, 404);
|
|
128
|
+
const sub = parts[1];
|
|
129
|
+
|
|
130
|
+
if (!sub) {
|
|
131
|
+
if (method !== "GET") return bad("method not allowed", 405);
|
|
132
|
+
const nRaw = req.params.get("n");
|
|
133
|
+
const n = nRaw === null ? undefined : Number(nRaw);
|
|
134
|
+
if (n !== undefined && (!Number.isInteger(n) || n < 1)) return bad("n is a version number");
|
|
135
|
+
const version: ShapeVersion | null = store.read(id, n);
|
|
136
|
+
if (!version) return bad(`${id} has no version ${nRaw}.`, 404);
|
|
137
|
+
const session = deps.sessionOf?.(shape) ?? { state: "gone", agent: shapeAgentName(shape.session) };
|
|
138
|
+
return {
|
|
139
|
+
status: 200,
|
|
140
|
+
body: { shape, version, seen: store.seen()[id] ?? null, notes: store.notes(id), session },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (sub === "versions" && parts.length === 2) {
|
|
145
|
+
if (method !== "GET") return bad("method not allowed", 405);
|
|
146
|
+
return { status: 200, body: { versions: store.versions(id) } };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (sub === "files" && parts.length === 3) {
|
|
150
|
+
if (method !== "GET") return bad("method not allowed", 405);
|
|
151
|
+
const name = parts[2]!;
|
|
152
|
+
const type = IMAGE_TYPES[extname(name).toLowerCase()];
|
|
153
|
+
const path = type ? store.filePath(id, name) : null;
|
|
154
|
+
if (!path || !type) return bad("not found", 404);
|
|
155
|
+
return { status: 200, file: { path, contentType: type } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (sub === "code" && parts.length === 3 && deps.code) {
|
|
159
|
+
const root = shape.project.root;
|
|
160
|
+
if (parts[2] === "file" && method === "GET") return deps.code.file(root, req.params.get("path"));
|
|
161
|
+
if (parts[2] === "hover" && method === "POST") {
|
|
162
|
+
let body: unknown;
|
|
163
|
+
try {
|
|
164
|
+
body = await req.json();
|
|
165
|
+
} catch {
|
|
166
|
+
return bad("the body must be JSON");
|
|
167
|
+
}
|
|
168
|
+
return deps.code.hover(root, body);
|
|
169
|
+
}
|
|
170
|
+
return bad("not found", 404);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (parts.length !== 2 || method !== "POST") return bad("not found", 404);
|
|
174
|
+
// An agent's CLI may hold the lock a moment: wait here, not in the store's blocking wait.
|
|
175
|
+
if (!(await unlocked(store, id))) return bad("The shape is busy; try again.", 503);
|
|
176
|
+
let body: unknown;
|
|
177
|
+
try {
|
|
178
|
+
body = await req.json();
|
|
179
|
+
} catch {
|
|
180
|
+
return bad("the body must be JSON");
|
|
181
|
+
}
|
|
182
|
+
const input = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
|
|
183
|
+
|
|
184
|
+
if (sub === "notes") {
|
|
185
|
+
const result = store.addNote(id, input);
|
|
186
|
+
if (!result.ok) return result.code === "invalid" ? { status: 400, body: { error: result.errors.join("; "), errors: result.errors } } : bad(result.message, result.code === "not_found" ? 404 : 409);
|
|
187
|
+
deps.changed?.(id);
|
|
188
|
+
return { status: 200, body: { note: result.note satisfies ShapeNote } };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (sub === "answer") {
|
|
192
|
+
const re = typeof input.re === "string" ? input.re : null;
|
|
193
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
194
|
+
const from = input.from as ShapeSession | undefined;
|
|
195
|
+
if (!text) return bad("text is required");
|
|
196
|
+
if (!from || typeof from.host !== "string" || typeof from.id !== "string") return bad("from must be { host, id }");
|
|
197
|
+
const result = store.answer(id, { re, text, from: { host: from.host, id: from.id } });
|
|
198
|
+
if (!result.ok) return result.code === "invalid" ? bad(result.errors.join("; ")) : bad(result.message, result.code === "not_found" ? 404 : 409);
|
|
199
|
+
deps.changed?.(id);
|
|
200
|
+
return { status: 200, body: { note: result.note } };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (sub === "seen") {
|
|
204
|
+
const n = input.n;
|
|
205
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return bad("n is a version number");
|
|
206
|
+
store.markSeen(id, n);
|
|
207
|
+
deps.changed?.(id);
|
|
208
|
+
return { status: 200, body: { seen: store.seen()[id] ?? null } };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (sub === "state") {
|
|
212
|
+
if (input.state !== "active" && input.state !== "finished") return bad('state is "active" or "finished"');
|
|
213
|
+
const next = store.setState(id, input.state);
|
|
214
|
+
deps.changed?.(id);
|
|
215
|
+
return { status: 200, body: { shape: next } };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return bad("not found", 404);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What changed in the store, for the event feed. Agents write from their own
|
|
223
|
+
* processes (the CLI), so the server looks: each tick it compares every
|
|
224
|
+
* shape's fingerprint (latest version, drawing, notes, seen, state) and
|
|
225
|
+
* names the ones that moved. Cheap: one directory walk, small files.
|
|
226
|
+
*/
|
|
227
|
+
export function createShapeWatch(store: ShapeStore, options: { intervalMs?: number } = {}) {
|
|
228
|
+
const listeners = new Set<(ids: string[]) => void>();
|
|
229
|
+
let last = new Map<string, string>();
|
|
230
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
231
|
+
|
|
232
|
+
const fingerprints = () => {
|
|
233
|
+
const seen = store.seen();
|
|
234
|
+
const next = new Map<string, string>();
|
|
235
|
+
for (const s of store.list({ state: "all" })) {
|
|
236
|
+
const notes = store.notes(s.id);
|
|
237
|
+
const lastNote = notes[notes.length - 1];
|
|
238
|
+
next.set(s.id, [s.latest, s.updated, s.state, s.drawing ? 1 : 0, s.waiting, notes.length, lastNote?.delivered ?? "", seen[s.id] ?? 0].join("|"));
|
|
239
|
+
}
|
|
240
|
+
return next;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const tick = () => {
|
|
244
|
+
let next: Map<string, string>;
|
|
245
|
+
try {
|
|
246
|
+
next = fingerprints();
|
|
247
|
+
} catch {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const moved: string[] = [];
|
|
251
|
+
for (const [id, fp] of next) if (last.get(id) !== fp) moved.push(id);
|
|
252
|
+
for (const id of last.keys()) if (!next.has(id)) moved.push(id);
|
|
253
|
+
last = next;
|
|
254
|
+
if (moved.length) for (const l of listeners) l(moved);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
/** Listen; returns the unsubscribe. The watch runs only while someone listens. */
|
|
259
|
+
subscribe(listener: (ids: string[]) => void): () => void {
|
|
260
|
+
if (!listeners.size) {
|
|
261
|
+
try {
|
|
262
|
+
last = fingerprints();
|
|
263
|
+
} catch {
|
|
264
|
+
last = new Map();
|
|
265
|
+
}
|
|
266
|
+
timer = setInterval(tick, options.intervalMs ?? 1000);
|
|
267
|
+
(timer as { unref?: () => void }).unref?.();
|
|
268
|
+
}
|
|
269
|
+
listeners.add(listener);
|
|
270
|
+
return () => {
|
|
271
|
+
listeners.delete(listener);
|
|
272
|
+
if (!listeners.size && timer) {
|
|
273
|
+
clearInterval(timer);
|
|
274
|
+
timer = null;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
/** Look now (after a write through the routes). */
|
|
279
|
+
poke: tick,
|
|
280
|
+
/** How many listen (0: the watch reads nothing). Tests read it. */
|
|
281
|
+
watching: () => listeners.size,
|
|
282
|
+
};
|
|
283
|
+
}
|