@preapexis/piexis 0.2.7 → 0.2.9

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.
@@ -0,0 +1,221 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { basename, relative, resolve, sep } from "node:path";
3
+
4
+ export type SubagentScope = { mode: "read" | "write"; paths: string[]; broad: boolean };
5
+
6
+ const sharedRootFiles = new Set([
7
+ "package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
8
+ "bun.lock", "bun.lockb", ".npmrc", "biome.json", "turbo.json", "nx.json",
9
+ ]);
10
+ const sharedRootPatterns = [
11
+ /^tsconfig(?:\.[^/]+)?\.json$/,
12
+ /^(?:vite|webpack|rollup|esbuild|eslint|prettier)\.config\.[^/]+$/,
13
+ /^\.eslintrc(?:\.[^/]+)?$/,
14
+ /^\.prettierrc(?:\.[^/]+)?$/,
15
+ ];
16
+
17
+ function isSharedPath(path: string): boolean {
18
+ path = path.toLowerCase();
19
+ const first = path.split("/")[0];
20
+ if ([".pi", ".git", ".github"].includes(first)) return true;
21
+ const name = basename(path);
22
+ if (sharedRootFiles.has(name) || /^tsconfig(?:\.[^/]+)?\.json$/.test(name)) return true;
23
+ return !path.includes("/") && sharedRootPatterns.some((pattern) => pattern.test(path));
24
+ }
25
+
26
+ async function rejectSymlinkComponents(cwd: string, path: string): Promise<void> {
27
+ let current = cwd;
28
+ for (const segment of path.split("/")) {
29
+ current = resolve(current, segment);
30
+ try {
31
+ if ((await lstat(current)).isSymbolicLink()) throw new Error(`Write path contains a symbolic-link component: ${path}`);
32
+ } catch (error) {
33
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
34
+ throw error;
35
+ }
36
+ }
37
+ }
38
+
39
+ /** Validates a declared scope without resolving symlinks outside the workspace. */
40
+ export async function normalizeSubagentScope(
41
+ cwd: string,
42
+ input: { mode?: "read" | "write"; paths?: string[] },
43
+ readOnly = false,
44
+ ): Promise<SubagentScope> {
45
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new TypeError("Subagent scope must be an object");
46
+ const mode = input.mode === undefined ? (readOnly ? "read" : "write") : input.mode;
47
+ if (mode !== "read" && mode !== "write") throw new TypeError("Subagent scope mode must be read or write");
48
+ if (readOnly && input.mode === "write") throw new Error("A read-only subagent cannot declare write scope");
49
+ if (input.paths !== undefined && !Array.isArray(input.paths)) throw new TypeError("Subagent scope paths must be an array");
50
+
51
+ const root = resolve(cwd);
52
+ const paths: string[] = [];
53
+ for (const value of input.paths ?? []) {
54
+ if (typeof value !== "string") throw new TypeError("Subagent scope paths must contain strings");
55
+ if (!value || value.includes("\0") || value.includes("\\") || /^(?:[A-Za-z]:|\/)/.test(value)) {
56
+ throw new Error(`Invalid repository-relative path: ${JSON.stringify(value)}`);
57
+ }
58
+ const absolute = resolve(root, value);
59
+ const fromRoot = relative(root, absolute);
60
+ if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) throw new Error(`Path escapes workspace: ${value}`);
61
+ paths.push(fromRoot ? fromRoot.split(sep).join("/") : ".");
62
+ }
63
+ const normalized = [...new Set(paths)];
64
+ if (mode === "write") await Promise.all(normalized.filter((path) => path !== ".").map((path) => rejectSymlinkComponents(root, path)));
65
+ const broad = mode === "write" && (normalized.length === 0 || normalized.includes(".") || normalized.some(isSharedPath));
66
+ return { mode, paths: normalized, broad };
67
+ }
68
+
69
+ function overlaps(a: string, b: string): boolean {
70
+ // Case-fold conservatively on platforms whose default filesystems are case-insensitive.
71
+ if (process.platform === "win32" || process.platform === "darwin") { a = a.toLowerCase(); b = b.toLowerCase(); }
72
+ return a === "." || b === "." || a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
73
+ }
74
+
75
+ export function scopesConflict(a: SubagentScope, b: SubagentScope): boolean {
76
+ if (a.mode === "read" || b.mode === "read") return false;
77
+ if (a.broad || b.broad) return true;
78
+ return a.paths.some((left) => b.paths.some((right) => overlaps(left, right)));
79
+ }
80
+
81
+ export type SchedulerStatus = { status: "queued" | "running" | "completed" | "failed" | "aborted"; reason?: string };
82
+
83
+ type Job<T> = {
84
+ scope: SubagentScope;
85
+ controller: AbortController;
86
+ serial: boolean;
87
+ run: () => Promise<T>;
88
+ onStatus?: (status: SchedulerStatus) => void;
89
+ resolve: (value: T) => void;
90
+ reject: (reason?: unknown) => void;
91
+ abort: () => void;
92
+ state: "queued" | "active" | "settled";
93
+ lastStatus?: string;
94
+ done: Promise<void>;
95
+ finish: () => void;
96
+ };
97
+
98
+ export class SubagentScheduler {
99
+ #queued: Job<unknown>[] = [];
100
+ #active = new Set<Job<unknown>>();
101
+ #draining = false;
102
+ #stopping = false;
103
+ #shutdown?: Promise<void>;
104
+ readonly #limit: number;
105
+
106
+ constructor(limit = 2) {
107
+ if (!Number.isInteger(limit) || limit < 1) throw new RangeError("Subagent scheduler limit must be at least one");
108
+ this.#limit = limit;
109
+ }
110
+
111
+ get size(): number { return this.#queued.length + this.#active.size; }
112
+
113
+ schedule<T>(options: { scope: SubagentScope; controller: AbortController; serial?: boolean; run: () => Promise<T>; onStatus?: (status: SchedulerStatus) => void }): Promise<T> {
114
+ if (this.#stopping) return Promise.reject(this.#abortError("Subagent scheduler is shut down"));
115
+ options.controller.signal.throwIfAborted();
116
+ return new Promise<T>((resolve, reject) => {
117
+ let finish!: () => void;
118
+ const done = new Promise<void>((doneResolve) => { finish = doneResolve; });
119
+ const job: Job<T> = {
120
+ ...options, serial: options.serial === true, resolve, reject, state: "queued", done, finish,
121
+ abort: () => this.#cancelQueued(job as unknown as Job<unknown>),
122
+ };
123
+ options.controller.signal.addEventListener("abort", job.abort, { once: true });
124
+ this.#queued.push(job as Job<unknown>);
125
+ this.#drain();
126
+ });
127
+ }
128
+
129
+ async shutdown(reason?: unknown): Promise<void> {
130
+ if (this.#shutdown) return this.#shutdown;
131
+ this.#stopping = true;
132
+ this.#shutdown = (async () => {
133
+ for (const job of [...this.#queued]) {
134
+ job.controller.abort(reason);
135
+ this.#cancelQueued(job);
136
+ }
137
+ for (const job of this.#active) job.controller.abort(reason);
138
+ await Promise.all([...this.#active].map((job) => job.done));
139
+ })();
140
+ return this.#shutdown;
141
+ }
142
+
143
+ #drain(): void {
144
+ if (this.#draining || this.#stopping) return;
145
+ this.#draining = true;
146
+ try {
147
+ let started: boolean;
148
+ do {
149
+ started = false;
150
+ for (const job of [...this.#queued]) {
151
+ if (this.#stopping) break;
152
+ if (job.state !== "queued") continue;
153
+ const reason = this.#blockingReason(job);
154
+ if (reason) { this.#status(job, { status: "queued", reason }); continue; }
155
+ this.#queued.splice(this.#queued.indexOf(job), 1);
156
+ job.controller.signal.removeEventListener("abort", job.abort);
157
+ this.#start(job);
158
+ started = true;
159
+ }
160
+ } while (started && !this.#shutdown);
161
+ } finally { this.#draining = false; }
162
+ }
163
+
164
+ #blockingReason(job: Job<unknown>): string | undefined {
165
+ if (this.#active.size >= this.#limit || job.serial && this.#active.size > 0 || [...this.#active].some((active) => active.serial)) return "capacity";
166
+ const conflict = [...this.#active].find((active) => scopesConflict(job.scope, active.scope));
167
+ if (!conflict) return undefined;
168
+ if (job.scope.broad || conflict.scope.broad) return "conflicting broad write";
169
+ const left = job.scope.paths.find((path) => conflict.scope.paths.some((other) => overlaps(path, other)))!;
170
+ return `conflicting path: ${left}`;
171
+ }
172
+
173
+ #start(job: Job<unknown>): void {
174
+ job.state = "active";
175
+ this.#active.add(job);
176
+ this.#status(job, { status: "running" });
177
+ let promise: Promise<unknown>;
178
+ try { job.controller.signal.throwIfAborted(); promise = Promise.resolve(job.run()); }
179
+ catch (error) { promise = Promise.reject(error); }
180
+ promise.then(
181
+ (value) => this.#settle(job, "completed", value),
182
+ (error) => this.#settle(job, job.controller.signal.aborted ? "aborted" : "failed", error),
183
+ );
184
+ }
185
+
186
+ #settle(job: Job<unknown>, status: SchedulerStatus["status"], value: unknown): void {
187
+ if (job.state === "settled") return;
188
+ if (status === "completed" && job.controller.signal.aborted) { status = "aborted"; value = job.controller.signal.reason; }
189
+ job.state = "settled";
190
+ this.#active.delete(job);
191
+ job.finish();
192
+ this.#status(job, { status });
193
+ if (status === "completed") job.resolve(value); else job.reject(value);
194
+ this.#drain();
195
+ }
196
+
197
+ #cancelQueued(job: Job<unknown>): void {
198
+ if (job.state !== "queued") return;
199
+ job.state = "settled";
200
+ const index = this.#queued.indexOf(job);
201
+ if (index >= 0) this.#queued.splice(index, 1);
202
+ job.finish();
203
+ job.controller.signal.removeEventListener("abort", job.abort);
204
+ this.#status(job, { status: "aborted" });
205
+ try { job.controller.signal.throwIfAborted(); } catch (error) { job.reject(error); }
206
+ this.#drain();
207
+ }
208
+
209
+ #status(job: Job<unknown>, status: SchedulerStatus): void {
210
+ const key = `${status.status}\0${status.reason ?? ""}`;
211
+ if (job.lastStatus === key) return;
212
+ job.lastStatus = key;
213
+ try { job.onStatus?.(status); } catch { /* Status observers cannot retain scheduler reservations. */ }
214
+ }
215
+
216
+ #abortError(message: string): Error {
217
+ const error = new Error(message);
218
+ error.name = "AbortError";
219
+ return error;
220
+ }
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preapexis/piexis",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "type": "module",
5
5
  "description": "Extensions, skills, prompts, and themes for pi",
6
6
  "repository": {
@@ -64,19 +64,29 @@
64
64
  ]
65
65
  },
66
66
  "devDependencies": {
67
+ "@fontsource-variable/geist": "^5.3.0",
68
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
69
+ "@tailwindcss/vite": "^4.3.3",
67
70
  "@types/node": "24.10.0",
68
71
  "@types/react": "^19.3.0",
69
72
  "@types/react-dom": "^19.3.0",
70
73
  "@vitejs/plugin-react": "^6.1.2",
74
+ "tailwindcss": "^4.3.3",
71
75
  "typescript": "5.9.3",
72
76
  "vite": "^8.3.4"
73
77
  },
74
78
  "dependencies": {
75
79
  "@fastify/static": "^10.1.6",
76
80
  "@fastify/websocket": "^11.3.4",
81
+ "@radix-ui/react-dialog": "^1.2.0",
82
+ "@radix-ui/react-tabs": "^1.1.22",
83
+ "clsx": "^2.1.1",
77
84
  "fastify": "^5.12.5",
78
85
  "react": "^19.3.0",
79
86
  "react-dom": "^19.3.0",
87
+ "react-markdown": "^10.1.0",
88
+ "remark-gfm": "^4.0.1",
89
+ "tailwind-merge": "^3.7.0",
80
90
  "tsx": "^4.23.15",
81
91
  "zod": "^4.6.5"
82
92
  }
@@ -16,6 +16,10 @@
16
16
  "type": "boolean",
17
17
  "description": "Allow read-only exploration to use small_model."
18
18
  },
19
+ "auto_profile": {
20
+ "type": "boolean",
21
+ "description": "Automatically select a profile for clear requests."
22
+ },
19
23
  "subagent_variant_overrides": {
20
24
  "type": "object",
21
25
  "additionalProperties": false,