pum-agent 0.1.0-beta.3

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/src/index.tsx ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env bun
2
+ import { createCliRenderer } from "@opentui/core";
3
+ import { createRoot } from "@opentui/react";
4
+ import {
5
+ createAgentSessionFromServices,
6
+ createAgentSessionRuntime,
7
+ createAgentSessionServices,
8
+ ModelRuntime,
9
+ SessionManager,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { mkdirSync } from "node:fs";
12
+ import { App } from "./app";
13
+ import { AGENT_DIR, AUTH_PATH, MODELS_PATH, sessionDir } from "./config";
14
+ import { loadSettings } from "./settings";
15
+ import { installWebSearch, webSearch } from "./web-search";
16
+ import { setWritingStyle, writingStyleExtension } from "./writing-style";
17
+ import {
18
+ explanationStrengthExtension,
19
+ setExplanationStrength,
20
+ } from "./explanation-strength";
21
+ import { createCheckModeExtension, setCheckModeConfig } from "./check-mode";
22
+ import { SubagentManager } from "./subagents/manager";
23
+ import { cleanupPendingImages } from "./image-paste";
24
+ import { shutdownSignals } from "./platform";
25
+ import { createShutdown } from "./shutdown";
26
+ import { applyPatchExtension } from "./apply-patch";
27
+
28
+ mkdirSync(AGENT_DIR, { recursive: true });
29
+
30
+ const modelRuntime = await ModelRuntime.create({
31
+ authPath: AUTH_PATH,
32
+ modelsPath: MODELS_PATH,
33
+ });
34
+ const loginRequired = process.argv[2] === "login" || (await modelRuntime.getAvailable()).length === 0;
35
+
36
+ const settings = loadSettings();
37
+ setWritingStyle(settings.writingStyle);
38
+ setExplanationStrength(settings.explanationStrength);
39
+ setCheckModeConfig({ enabled: settings.checkMode, model: settings.checkModel });
40
+ const checkModeExtension = createCheckModeExtension(modelRuntime);
41
+ const subagentManager = new SubagentManager({
42
+ modelRuntime,
43
+ agentDir: AGENT_DIR,
44
+ childExtensionFactories: [
45
+ writingStyleExtension,
46
+ explanationStrengthExtension,
47
+ checkModeExtension,
48
+ ],
49
+ });
50
+ const subagentExtension = subagentManager.mainExtension();
51
+ // Hosted web search rides on the provider, so it must be wrapped before the
52
+ // session picks a model.
53
+ webSearch.enabled = settings.webSearch;
54
+ const searchProviders = installWebSearch(modelRuntime);
55
+
56
+ // `pum -r` picks up the most recent session for this directory.
57
+ const resume = process.argv.includes("-r") || process.argv.includes("--resume");
58
+
59
+ const cwd = process.cwd();
60
+ const sessionRuntime = await createAgentSessionRuntime(
61
+ async ({ cwd, sessionManager, sessionStartEvent }) => {
62
+ const services = await createAgentSessionServices({
63
+ cwd,
64
+ agentDir: AGENT_DIR,
65
+ modelRuntime,
66
+ resourceLoaderOptions: {
67
+ extensionFactories: [
68
+ writingStyleExtension,
69
+ explanationStrengthExtension,
70
+ checkModeExtension,
71
+ applyPatchExtension,
72
+ subagentExtension,
73
+ ],
74
+ },
75
+ });
76
+ return {
77
+ ...(await createAgentSessionFromServices({
78
+ services,
79
+ sessionManager,
80
+ sessionStartEvent,
81
+ tools: [
82
+ "read", "write", "edit", "apply_patch", "bash",
83
+ "spawn_subagent", "message_agent", "list_subagents", "stop_subagent", "worktree",
84
+ ],
85
+ })),
86
+ services,
87
+ diagnostics: services.diagnostics,
88
+ };
89
+ },
90
+ {
91
+ cwd,
92
+ agentDir: AGENT_DIR,
93
+ sessionManager: resume
94
+ ? SessionManager.continueRecent(cwd, sessionDir(cwd))
95
+ : SessionManager.create(cwd, sessionDir(cwd)),
96
+ },
97
+ );
98
+
99
+ if (sessionRuntime.modelFallbackMessage) console.error(sessionRuntime.modelFallbackMessage);
100
+
101
+ const renderer = await createCliRenderer({ exitOnCtrlC: false });
102
+ const root = createRoot(renderer);
103
+ const shutdown = createShutdown({
104
+ unmount: () => root.unmount(),
105
+ cleanup: cleanupPendingImages,
106
+ dispose: () => sessionRuntime.dispose(),
107
+ destroy: () => renderer.destroy(),
108
+ exit: (code) => process.exit(code),
109
+ });
110
+ for (const signal of shutdownSignals()) {
111
+ process.on(signal, () => void shutdown(1));
112
+ }
113
+
114
+ root.render(
115
+ <App
116
+ session={sessionRuntime.session}
117
+ onNewSession={async () => {
118
+ await sessionRuntime.newSession();
119
+ return sessionRuntime.session;
120
+ }}
121
+ loadSessions={() => SessionManager.list(cwd, sessionDir(cwd))}
122
+ onSwitchSession={async (path) => {
123
+ await sessionRuntime.switchSession(path);
124
+ return sessionRuntime.session;
125
+ }}
126
+ modelRuntime={modelRuntime}
127
+ settings={settings}
128
+ searchProviders={searchProviders}
129
+ subagentManager={subagentManager}
130
+ loginRequired={loginRequired}
131
+ onExit={() => shutdown(0)}
132
+ />,
133
+ );
@@ -0,0 +1,267 @@
1
+ import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
2
+ import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+ import type { LoginPage } from "./login-popup";
4
+ import {
5
+ customProviderId,
6
+ discoverOpenAIModels,
7
+ persistCustomProvider,
8
+ providerLoginMethods,
9
+ refreshAndSelectModel,
10
+ safeError,
11
+ type LoginMethod,
12
+ } from "./login-flow";
13
+
14
+ export type LoginKey = {
15
+ name: string;
16
+ sequence?: string;
17
+ ctrl?: boolean;
18
+ meta?: boolean;
19
+ option?: boolean;
20
+ };
21
+
22
+ type PromptWaiter = {
23
+ prompt: AuthPrompt;
24
+ resolve(value: string): void;
25
+ reject(error: Error): void;
26
+ };
27
+
28
+ export class LoginController {
29
+ private page: LoginPage;
30
+ private controller?: AbortController;
31
+ private promptWaiter?: PromptWaiter;
32
+ private secret = "";
33
+ private endpoint = "";
34
+ private customKey = "";
35
+ private providerCursor = 0;
36
+ private retry?: () => void;
37
+
38
+ constructor(
39
+ private runtime: ModelRuntime,
40
+ private getSession: () => AgentSession,
41
+ private show: (page: LoginPage) => void,
42
+ private complete: (modelId?: string) => void,
43
+ private closePopup: () => void,
44
+ ) {
45
+ this.page = this.providerPage();
46
+ }
47
+
48
+ private setPage(page: LoginPage) {
49
+ this.page = page;
50
+ this.show(page);
51
+ }
52
+
53
+ private providerPage(): LoginPage {
54
+ const providers = (this.runtime as any).getProviders?.() ?? [];
55
+ const methods = providerLoginMethods(providers);
56
+ this.providerCursor = Math.min(this.providerCursor, methods.length);
57
+ return { kind: "providers", methods, cursor: this.providerCursor };
58
+ }
59
+
60
+ open() {
61
+ this.cancelOperation();
62
+ this.retry = undefined;
63
+ this.providerCursor = 0;
64
+ this.setPage(this.providerPage());
65
+ }
66
+
67
+ cancelOperation() {
68
+ this.controller?.abort();
69
+ this.controller = undefined;
70
+ this.promptWaiter?.reject(new Error("Login cancelled"));
71
+ this.promptWaiter = undefined;
72
+ this.secret = "";
73
+ }
74
+
75
+ close() {
76
+ this.cancelOperation();
77
+ this.closePopup();
78
+ }
79
+
80
+ private async finish(providerId: string, providerName: string) {
81
+ const session = this.getSession();
82
+ const selected = await refreshAndSelectModel(
83
+ this.runtime,
84
+ providerId,
85
+ (model) => session.setModel(model),
86
+ AbortSignal.timeout(15_000),
87
+ session.model,
88
+ );
89
+ this.complete(selected?.id);
90
+ this.setPage({
91
+ kind: "success",
92
+ message: selected
93
+ ? `${providerName} is ready. Selected ${selected.id}.`
94
+ : `${providerName} is configured. Open Settings to select an available model.`,
95
+ });
96
+ }
97
+
98
+ private startProvider(method: LoginMethod) {
99
+ this.retry = () => this.startProvider(method);
100
+ if (!method.canLogin) {
101
+ this.setPage({
102
+ kind: "error",
103
+ title: `${method.providerName} uses external credentials`,
104
+ message: "Configure the provider environment or credential files, then retry.",
105
+ });
106
+ return;
107
+ }
108
+ const controller = new AbortController();
109
+ this.cancelOperation();
110
+ this.controller = controller;
111
+ this.setPage({ kind: "working", providerName: method.providerName });
112
+ void this.runtime.login(method.providerId, method.authType, {
113
+ signal: controller.signal,
114
+ notify: (event: AuthEvent) => {
115
+ if (!this.promptWaiter) this.setPage({ kind: "working", providerName: method.providerName, event });
116
+ },
117
+ prompt: (prompt: AuthPrompt) => new Promise<string>((resolve, reject) => {
118
+ this.secret = "";
119
+ const rejectPrompt = (error: Error) => {
120
+ prompt.signal?.removeEventListener("abort", onAbort);
121
+ reject(error);
122
+ };
123
+ const resolvePrompt = (value: string) => {
124
+ prompt.signal?.removeEventListener("abort", onAbort);
125
+ resolve(value);
126
+ };
127
+ const onAbort = () => rejectPrompt(new Error("Login cancelled"));
128
+ prompt.signal?.addEventListener("abort", onAbort, { once: true });
129
+ this.promptWaiter = { prompt, resolve: resolvePrompt, reject: rejectPrompt };
130
+ this.setPage({ kind: "prompt", providerName: method.providerName, prompt, cursor: 0, value: "", secretLength: 0 });
131
+ }),
132
+ }).then(() => {
133
+ this.promptWaiter = undefined;
134
+ this.secret = "";
135
+ return this.finish(method.providerId, method.providerName);
136
+ }).catch((error) => {
137
+ if (controller.signal.aborted) return;
138
+ this.setPage({ kind: "error", title: `${method.providerName} login failed`, message: safeError(error, [this.secret]) });
139
+ this.secret = "";
140
+ });
141
+ }
142
+
143
+ private startCustom() {
144
+ const endpoint = this.endpoint;
145
+ const key = this.customKey;
146
+ this.retry = () => this.startCustom();
147
+ const controller = new AbortController();
148
+ this.cancelOperation();
149
+ this.controller = controller;
150
+ this.setPage({ kind: "custom-working", endpoint, message: "Discovering OpenAI-compatible models…" });
151
+ void discoverOpenAIModels(endpoint, key, { signal: controller.signal }).then(async ({ baseUrl, models }) => {
152
+ const providerId = customProviderId(baseUrl);
153
+ this.setPage({ kind: "custom-working", endpoint, message: `Saving ${models.length} discovered models…` });
154
+ await persistCustomProvider(providerId, baseUrl, models);
155
+ await this.runtime.refresh({ providers: [providerId], signal: controller.signal });
156
+ await this.runtime.login(providerId, "api_key", {
157
+ signal: controller.signal,
158
+ notify: () => {},
159
+ prompt: async () => key || "local",
160
+ });
161
+ this.customKey = "";
162
+ await this.finish(providerId, this.runtime.getProvider(providerId)?.name ?? providerId);
163
+ }).catch((error) => {
164
+ if (controller.signal.aborted) return;
165
+ this.setPage({ kind: "error", title: "Custom provider setup failed", message: safeError(error, [key]) });
166
+ });
167
+ }
168
+
169
+ private updateText(key: LoginKey, value: string, setValue: (next: string) => void): boolean {
170
+ if (key.name === "backspace") {
171
+ setValue(value.slice(0, -1));
172
+ return true;
173
+ }
174
+ const text = key.sequence ?? "";
175
+ if (!key.ctrl && !key.meta && !key.option && text.length > 0 && !/[\u0000-\u001f\u007f]/.test(text)) {
176
+ setValue(value + text);
177
+ return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ handleKey(key: LoginKey): boolean {
183
+ const enter = key.name === "return" || key.name === "enter" || key.name === "kpenter" || key.name === "linefeed";
184
+ if (key.name === "escape") {
185
+ if (this.page.kind === "providers" || this.page.kind === "success" || this.page.kind === "error") this.close();
186
+ else if (this.page.kind === "custom-key") this.setPage({ kind: "custom-endpoint", endpoint: this.endpoint });
187
+ else {
188
+ this.cancelOperation();
189
+ this.setPage(this.providerPage());
190
+ }
191
+ return true;
192
+ }
193
+ if (this.page.kind === "providers") {
194
+ const count = this.page.methods.length + 1;
195
+ if (key.name === "up" || key.name === "down") {
196
+ const step = key.name === "up" ? -1 : 1;
197
+ this.providerCursor = (this.page.cursor + step + count) % count;
198
+ this.setPage({ ...this.page, cursor: this.providerCursor });
199
+ } else if (enter) {
200
+ const method = this.page.methods[this.page.cursor];
201
+ if (method) this.startProvider(method);
202
+ else this.setPage({ kind: "custom-endpoint", endpoint: this.endpoint });
203
+ }
204
+ return true;
205
+ }
206
+ if (this.page.kind === "prompt") {
207
+ if (this.page.prompt.type === "select") {
208
+ if (key.name === "up" || key.name === "down") {
209
+ const count = this.page.prompt.options.length;
210
+ if (count) this.setPage({ ...this.page, cursor: (this.page.cursor + (key.name === "up" ? -1 : 1) + count) % count });
211
+ } else if (enter) {
212
+ const option = this.page.prompt.options[this.page.cursor];
213
+ if (option) {
214
+ this.promptWaiter?.resolve(option.id);
215
+ this.promptWaiter = undefined;
216
+ this.setPage({ kind: "working", providerName: this.page.providerName });
217
+ }
218
+ }
219
+ } else if (enter) {
220
+ const value = this.page.prompt.type === "secret" ? this.secret : this.page.value;
221
+ this.promptWaiter?.resolve(value);
222
+ this.promptWaiter = undefined;
223
+ this.setPage({ kind: "working", providerName: this.page.providerName });
224
+ } else if (this.page.prompt.type === "secret") {
225
+ const current = this.page;
226
+ this.updateText(key, this.secret, (next) => {
227
+ this.secret = next;
228
+ this.setPage({ ...current, secretLength: next.length });
229
+ });
230
+ } else {
231
+ const current = this.page;
232
+ this.updateText(key, current.value, (next) => this.setPage({ ...current, value: next }));
233
+ }
234
+ return true;
235
+ }
236
+ if (this.page.kind === "custom-endpoint") {
237
+ if (enter) {
238
+ this.endpoint = this.page.endpoint;
239
+ this.setPage({ kind: "custom-key", endpoint: this.endpoint, secretLength: this.customKey.length });
240
+ } else this.updateText(key, this.page.endpoint, (next) => {
241
+ this.endpoint = next;
242
+ this.setPage({ kind: "custom-endpoint", endpoint: next });
243
+ });
244
+ return true;
245
+ }
246
+ if (this.page.kind === "custom-key") {
247
+ if (enter) this.startCustom();
248
+ else {
249
+ const current = this.page;
250
+ this.updateText(key, this.customKey, (next) => {
251
+ this.customKey = next;
252
+ this.setPage({ ...current, secretLength: next.length });
253
+ });
254
+ }
255
+ return true;
256
+ }
257
+ if (this.page.kind === "error" && enter) {
258
+ this.retry?.();
259
+ return true;
260
+ }
261
+ if (this.page.kind === "success" && enter) {
262
+ this.close();
263
+ return true;
264
+ }
265
+ return true;
266
+ }
267
+ }
@@ -0,0 +1,170 @@
1
+ import type { AuthType, Model, Provider } from "@earendil-works/pi-ai";
2
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { MODELS_PATH } from "./config";
6
+
7
+ export type LoginMethod = {
8
+ providerId: string;
9
+ providerName: string;
10
+ authType: AuthType;
11
+ methodName: string;
12
+ loginLabel?: string;
13
+ canLogin: boolean;
14
+ };
15
+
16
+ export function providerLoginMethods(providers: readonly Provider[]): LoginMethod[] {
17
+ return providers.flatMap((provider) => {
18
+ const methods: LoginMethod[] = [];
19
+ if (provider.auth.oauth) {
20
+ methods.push({
21
+ providerId: provider.id,
22
+ providerName: provider.name,
23
+ authType: "oauth",
24
+ methodName: provider.auth.oauth.name,
25
+ loginLabel: provider.auth.oauth.loginLabel,
26
+ canLogin: true,
27
+ });
28
+ }
29
+ if (provider.auth.apiKey) {
30
+ methods.push({
31
+ providerId: provider.id,
32
+ providerName: provider.name,
33
+ authType: "api_key",
34
+ methodName: provider.auth.apiKey.name,
35
+ canLogin: typeof provider.auth.apiKey.login === "function",
36
+ });
37
+ }
38
+ return methods;
39
+ }).sort((a, b) =>
40
+ a.providerName.localeCompare(b.providerName) || a.authType.localeCompare(b.authType),
41
+ );
42
+ }
43
+
44
+ export function safeError(error: unknown, secrets: readonly string[] = []): string {
45
+ let message = error instanceof Error ? error.message : String(error);
46
+ for (const secret of secrets) {
47
+ if (secret) message = message.replaceAll(secret, "[redacted]");
48
+ }
49
+ message = message.replace(/\b(?:sk|key|token)-[A-Za-z0-9_.-]{8,}\b/gi, "[redacted]");
50
+ return message;
51
+ }
52
+
53
+ export function normalizeOpenAIEndpoint(input: string): string {
54
+ const raw = input.trim();
55
+ if (!raw) throw new Error("Enter an endpoint URL.");
56
+ let url: URL;
57
+ try {
58
+ url = new URL(raw);
59
+ } catch {
60
+ throw new Error("Enter a complete http:// or https:// endpoint URL.");
61
+ }
62
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
63
+ throw new Error("The endpoint must use http:// or https://.");
64
+ }
65
+ if (url.username || url.password) throw new Error("Remove credentials from the endpoint URL.");
66
+ if (url.search || url.hash) throw new Error("Remove the query string and fragment from the endpoint URL.");
67
+ let pathname = url.pathname.replace(/\/+$/, "");
68
+ pathname = pathname.replace(/\/(?:models|chat\/completions|responses)$/i, "");
69
+ if (!/\/v\d+(?:beta)?$/i.test(pathname)) pathname += "/v1";
70
+ url.pathname = pathname;
71
+ return url.toString().replace(/\/$/, "");
72
+ }
73
+
74
+ export type DiscoveredModel = { id: string; name?: string };
75
+
76
+ export async function discoverOpenAIModels(
77
+ endpoint: string,
78
+ apiKey: string,
79
+ options: { fetch?: typeof fetch; signal?: AbortSignal } = {},
80
+ ): Promise<{ baseUrl: string; models: DiscoveredModel[] }> {
81
+ const baseUrl = normalizeOpenAIEndpoint(endpoint);
82
+ const fetchImpl = options.fetch ?? fetch;
83
+ const response = await fetchImpl(`${baseUrl}/models`, {
84
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
85
+ signal: options.signal,
86
+ });
87
+ if (!response.ok) {
88
+ throw new Error(`Model discovery failed with HTTP ${response.status}. Check the endpoint and API key.`);
89
+ }
90
+ const body = await response.json() as { data?: unknown };
91
+ if (!Array.isArray(body.data)) {
92
+ throw new Error("The endpoint did not return an OpenAI-compatible model list at /models.");
93
+ }
94
+ const models = body.data.flatMap((entry): DiscoveredModel[] => {
95
+ if (!entry || typeof entry !== "object") return [];
96
+ const id = (entry as { id?: unknown }).id;
97
+ if (typeof id !== "string" || !id.trim()) return [];
98
+ const name = (entry as { name?: unknown }).name;
99
+ return [{ id: id.trim(), ...(typeof name === "string" && name.trim() ? { name: name.trim() } : {}) }];
100
+ });
101
+ if (models.length === 0) throw new Error("The OpenAI-compatible model list was empty.");
102
+ return { baseUrl, models };
103
+ }
104
+
105
+ export function customProviderId(baseUrl: string): string {
106
+ const host = new URL(baseUrl).host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
107
+ return `custom-${host || "provider"}`;
108
+ }
109
+
110
+ type ModelsFile = { providers?: Record<string, unknown>; [key: string]: unknown };
111
+
112
+ export async function persistCustomProvider(
113
+ providerId: string,
114
+ baseUrl: string,
115
+ models: readonly DiscoveredModel[],
116
+ path = MODELS_PATH,
117
+ ): Promise<void> {
118
+ let current: ModelsFile = {};
119
+ try {
120
+ current = JSON.parse(await readFile(path, "utf8")) as ModelsFile;
121
+ } catch (error) {
122
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
123
+ }
124
+ const next: ModelsFile = {
125
+ ...current,
126
+ providers: {
127
+ ...(current.providers ?? {}),
128
+ [providerId]: {
129
+ name: `Custom (${new URL(baseUrl).host})`,
130
+ baseUrl,
131
+ api: "openai-completions",
132
+ authHeader: true,
133
+ compat: {
134
+ supportsDeveloperRole: false,
135
+ supportsReasoningEffort: false,
136
+ },
137
+ models: models.map((model) => ({
138
+ id: model.id,
139
+ name: model.name ?? model.id,
140
+ reasoning: false,
141
+ input: ["text"],
142
+ contextWindow: 128000,
143
+ maxTokens: 16384,
144
+ })),
145
+ },
146
+ },
147
+ };
148
+ await mkdir(dirname(path), { recursive: true });
149
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
150
+ await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
151
+ await rename(temporary, path);
152
+ }
153
+
154
+ export async function refreshAndSelectModel(
155
+ runtime: ModelRuntime,
156
+ providerId: string,
157
+ setModel: (model: Model<any>) => Promise<void>,
158
+ signal?: AbortSignal,
159
+ currentModel?: Model<any>,
160
+ ): Promise<Model<any> | undefined> {
161
+ await runtime.refresh({ providers: [providerId], allowNetwork: true, signal });
162
+ const available = runtime.getAvailableSnapshot();
163
+ const current = currentModel && available.find((candidate) =>
164
+ candidate.provider === currentModel.provider && candidate.id === currentModel.id,
165
+ );
166
+ if (current) return current;
167
+ const model = available.find((candidate) => candidate.provider === providerId) ?? available[0];
168
+ if (model) await setModel(model);
169
+ return model;
170
+ }