roboport 0.1.0 → 0.3.0

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/mcp/index.js CHANGED
@@ -1,887 +1,889 @@
1
1
  // @bun
2
- // src/core/agent.ts
3
- import { z } from "zod";
4
-
5
- // src/core/session.ts
6
- class Turn {
7
- queue = [];
8
- waiters = [];
9
- ended = false;
10
- iterated = false;
11
- resultPromise;
12
- abortController = new AbortController;
13
- constructor(runner) {
14
- this.resultPromise = runner({
15
- emit: (event) => this.emit(event),
16
- signal: this.abortController.signal
17
- }).then((messages) => {
18
- this.close();
19
- return messages;
20
- }, (error) => {
21
- this.close();
22
- throw error;
23
- });
24
- this.resultPromise.catch(() => {});
25
- }
26
- emit(event) {
27
- const waiter = this.waiters.shift();
28
- if (waiter) {
29
- waiter({ value: event, done: false });
30
- } else {
31
- this.queue.push(event);
32
- }
33
- }
34
- close() {
35
- this.ended = true;
36
- while (this.waiters.length > 0) {
37
- const waiter = this.waiters.shift();
38
- waiter?.({ value: undefined, done: true });
2
+ // src/mcp/oauth.ts
3
+ function base64UrlEncode(bytes) {
4
+ let str = "";
5
+ for (const byte of bytes)
6
+ str += String.fromCharCode(byte);
7
+ return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8
+ }
9
+ function randomBytes(length) {
10
+ const bytes = new Uint8Array(length);
11
+ crypto.getRandomValues(bytes);
12
+ return bytes;
13
+ }
14
+ async function sha256(input) {
15
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
16
+ return new Uint8Array(hash);
17
+ }
18
+ async function generatePkce() {
19
+ const verifier = base64UrlEncode(randomBytes(32));
20
+ const challenge = base64UrlEncode(await sha256(verifier));
21
+ return { verifier, challenge };
22
+ }
23
+ function generateState() {
24
+ return base64UrlEncode(randomBytes(16));
25
+ }
26
+ async function discover(serverUrl) {
27
+ const u = new URL(serverUrl);
28
+ const origin = `${u.protocol}//${u.host}`;
29
+ let authServer = origin;
30
+ try {
31
+ const res2 = await fetch(`${origin}/.well-known/oauth-protected-resource`);
32
+ if (res2.ok) {
33
+ const meta = await res2.json();
34
+ if (meta.authorization_servers?.[0])
35
+ authServer = meta.authorization_servers[0];
39
36
  }
37
+ } catch {}
38
+ const asUrl = new URL(authServer);
39
+ const asOrigin = `${asUrl.protocol}//${asUrl.host}`;
40
+ const res = await fetch(`${asOrigin}/.well-known/oauth-authorization-server`);
41
+ if (!res.ok) {
42
+ throw new Error(`OAuth discovery failed for ${asOrigin}: ${res.status} ${await res.text()}`);
40
43
  }
41
- abort(reason) {
42
- this.abortController.abort(reason);
44
+ return await res.json();
45
+ }
46
+ async function registerClient(registrationEndpoint, redirectUri) {
47
+ const res = await fetch(registrationEndpoint, {
48
+ method: "POST",
49
+ headers: { "content-type": "application/json" },
50
+ body: JSON.stringify({
51
+ client_name: "roboport",
52
+ redirect_uris: [redirectUri],
53
+ grant_types: ["authorization_code", "refresh_token"],
54
+ response_types: ["code"],
55
+ token_endpoint_auth_method: "none"
56
+ })
57
+ });
58
+ if (!res.ok) {
59
+ throw new Error(`OAuth client registration failed: ${res.status} ${await res.text()}`);
43
60
  }
44
- [Symbol.asyncIterator]() {
45
- if (this.iterated) {
46
- throw new Error("Turn can only be iterated once.");
47
- }
48
- this.iterated = true;
49
- return {
50
- next: () => {
51
- if (this.queue.length > 0) {
52
- const value = this.queue.shift();
53
- return Promise.resolve({ value, done: false });
54
- }
55
- if (this.ended) {
56
- return Promise.resolve({
57
- value: undefined,
58
- done: true
61
+ const data = await res.json();
62
+ return data.client_id;
63
+ }
64
+ function openBrowser(url) {
65
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
66
+ Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
67
+ }
68
+ function captureAuthorizationCode(port, expectedState, timeoutMs) {
69
+ return new Promise((resolve, reject) => {
70
+ const server = Bun.serve({
71
+ port,
72
+ hostname: "127.0.0.1",
73
+ fetch(req) {
74
+ const u = new URL(req.url);
75
+ const error = u.searchParams.get("error");
76
+ if (error) {
77
+ const desc = u.searchParams.get("error_description") ?? "";
78
+ finish(() => reject(new Error(`OAuth error: ${error} ${desc}`)));
79
+ return new Response("Authentication failed. You can close this tab.", {
80
+ status: 400
59
81
  });
60
82
  }
61
- return new Promise((resolve) => this.waiters.push(resolve));
62
- },
63
- return: async () => {
64
- this.abort("iteration ended");
65
- await this.resultPromise.catch(() => {});
66
- return {
67
- value: undefined,
68
- done: true
69
- };
83
+ const code = u.searchParams.get("code");
84
+ const state = u.searchParams.get("state");
85
+ if (!code || state !== expectedState) {
86
+ finish(() => reject(new Error("OAuth state mismatch or missing code.")));
87
+ return new Response("Invalid OAuth response.", { status: 400 });
88
+ }
89
+ finish(() => resolve(code));
90
+ return new Response("<html><body>Authenticated. You can close this tab.</body></html>", { headers: { "content-type": "text/html" } });
70
91
  }
71
- };
92
+ });
93
+ const timer = setTimeout(() => {
94
+ finish(() => reject(new Error("OAuth flow timed out.")));
95
+ }, timeoutMs);
96
+ function finish(action) {
97
+ clearTimeout(timer);
98
+ setTimeout(() => server.stop(true), 50);
99
+ action();
100
+ }
101
+ });
102
+ }
103
+ async function exchangeCode(opts) {
104
+ const body = new URLSearchParams({
105
+ grant_type: "authorization_code",
106
+ code: opts.code,
107
+ redirect_uri: opts.redirectUri,
108
+ client_id: opts.clientId,
109
+ code_verifier: opts.verifier
110
+ });
111
+ const res = await fetch(opts.tokenEndpoint, {
112
+ method: "POST",
113
+ headers: { "content-type": "application/x-www-form-urlencoded" },
114
+ body
115
+ });
116
+ if (!res.ok) {
117
+ throw new Error(`OAuth token exchange failed: ${res.status} ${await res.text()}`);
72
118
  }
73
- then(onfulfilled, onrejected) {
74
- return this.resultPromise.then(onfulfilled, onrejected);
119
+ return await res.json();
120
+ }
121
+ async function refreshTokens(opts) {
122
+ const body = new URLSearchParams({
123
+ grant_type: "refresh_token",
124
+ refresh_token: opts.refreshToken,
125
+ client_id: opts.clientId
126
+ });
127
+ const res = await fetch(opts.tokenEndpoint, {
128
+ method: "POST",
129
+ headers: { "content-type": "application/x-www-form-urlencoded" },
130
+ body
131
+ });
132
+ if (!res.ok) {
133
+ throw new Error(`OAuth refresh failed: ${res.status} ${await res.text()}`);
75
134
  }
135
+ return await res.json();
76
136
  }
77
137
 
78
- class Session {
79
- internals;
80
- state;
81
- constructor(internals, state) {
82
- this.internals = internals;
83
- this.state = state;
138
+ // src/mcp/storage.ts
139
+ import { chmod, mkdir, readFile, writeFile } from "fs/promises";
140
+ import { homedir } from "os";
141
+ import { dirname, join } from "path";
142
+ var DEFAULT_PATH = join(homedir(), ".roboport", "mcp-auth.json");
143
+
144
+ class FileStorage {
145
+ path;
146
+ cache;
147
+ constructor(path) {
148
+ this.path = path ?? DEFAULT_PATH;
84
149
  }
85
- get messages() {
86
- return this.state.messages;
150
+ async read() {
151
+ if (this.cache)
152
+ return this.cache;
153
+ try {
154
+ const raw = await readFile(this.path, "utf8");
155
+ this.cache = JSON.parse(raw);
156
+ } catch {
157
+ this.cache = {};
158
+ }
159
+ return this.cache;
87
160
  }
88
- send(prompt) {
89
- return this.internals.send(prompt);
161
+ async write(data) {
162
+ await mkdir(dirname(this.path), { recursive: true });
163
+ await writeFile(this.path, JSON.stringify(data, null, 2), "utf8");
164
+ await chmod(this.path, 384);
165
+ this.cache = data;
90
166
  }
91
- async close() {
92
- await this.internals.close();
167
+ async load(key) {
168
+ const data = await this.read();
169
+ return data[key] ?? null;
93
170
  }
94
- async[Symbol.asyncDispose]() {
95
- await this.close();
171
+ async save(key, tokens) {
172
+ const data = await this.read();
173
+ data[key] = tokens;
174
+ await this.write(data);
175
+ }
176
+ async clear(key) {
177
+ const data = await this.read();
178
+ delete data[key];
179
+ await this.write(data);
96
180
  }
97
181
  }
98
182
 
99
- // src/core/tool.ts
100
- import * as z4 from "zod/v4/core";
101
- function hasParseMethod(schema) {
102
- return typeof schema === "object" && schema !== null && "parse" in schema && typeof schema.parse === "function";
103
- }
104
-
105
- class Tool {
106
- name;
107
- description;
108
- inputSchema;
109
- jsonSchema;
110
- execute;
111
- deferred;
112
- constructor(init) {
113
- this.name = init.name;
114
- this.description = init.description;
115
- this.deferred = init.deferred ?? false;
116
- if ("inputSchema" in init) {
117
- this.inputSchema = init.inputSchema;
118
- this.execute = init.execute;
119
- } else {
120
- this.jsonSchema = init.jsonSchema;
121
- this.execute = init.execute;
122
- }
183
+ class MemoryStorage {
184
+ data = new Map;
185
+ async load(key) {
186
+ return this.data.get(key) ?? null;
123
187
  }
124
- toJsonSchema() {
125
- if (this.jsonSchema !== undefined)
126
- return this.jsonSchema;
127
- if (this.inputSchema === undefined) {
128
- throw new Error(`Tool "${this.name}" has neither inputSchema nor jsonSchema.`);
129
- }
130
- return z4.toJSONSchema(this.inputSchema);
188
+ async save(key, tokens) {
189
+ this.data.set(key, tokens);
131
190
  }
132
- parse(input) {
133
- const schema = this.inputSchema;
134
- if (!schema)
135
- return input;
136
- if (hasParseMethod(schema))
137
- return schema.parse(input);
138
- return z4.parse(schema, input);
191
+ async clear(key) {
192
+ this.data.delete(key);
139
193
  }
140
194
  }
141
- function createRegistry(tools) {
142
- const byName = new Map(tools.map((tool) => [tool.name, tool]));
143
- const loadedNames = new Set(tools.filter((tool) => !tool.deferred).map((tool) => tool.name));
144
- return {
145
- loaded: () => tools.filter((tool) => loadedNames.has(tool.name)),
146
- deferred: () => tools.filter((tool) => tool.deferred && !loadedNames.has(tool.name)),
147
- load: (names) => {
148
- const loaded = [];
149
- const missing = [];
150
- for (const name of names) {
151
- const tool = byName.get(name);
152
- if (!tool) {
153
- missing.push(name);
154
- continue;
155
- }
156
- loadedNames.add(name);
157
- loaded.push(tool);
158
- }
159
- return { loaded, missing };
160
- }
161
- };
162
- }
163
195
 
164
- // src/core/agent.ts
165
- class Agent {
166
- model;
167
- system;
168
- tools;
169
- skills;
170
- mcp;
171
- cwd;
172
- registrations = [];
173
- unsubs = [];
174
- constructor({
175
- model,
176
- system,
177
- tools,
178
- skills,
179
- mcp,
180
- cwd
181
- }) {
182
- this.model = model;
183
- this.system = system;
184
- this.tools = tools;
185
- this.skills = skills;
186
- this.mcp = mcp ?? [];
187
- this.cwd = cwd;
196
+ // src/mcp/auth.ts
197
+ class BearerAuth {
198
+ token;
199
+ constructor(token) {
200
+ this.token = token;
188
201
  }
189
- on(trigger, handler) {
190
- this.registrations.push({ trigger, handler });
202
+ async getHeader() {
203
+ return `Bearer ${this.token}`;
191
204
  }
192
- async start() {
193
- for (const { trigger, handler } of this.registrations) {
194
- const unsub = await trigger.start((event) => {
195
- Promise.resolve().then(() => handler(event)).catch((error) => {
196
- const message = error instanceof Error ? error.message : String(error);
197
- console.error(`[roboport] trigger "${trigger.name}" handler failed: ${message}`);
198
- });
199
- });
200
- this.unsubs.push(unsub);
201
- }
205
+ }
206
+ var DEFAULT_PORT = 33418;
207
+ var DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
208
+
209
+ class OAuthAuth {
210
+ serverUrl;
211
+ storage;
212
+ storageKey;
213
+ redirectPort;
214
+ scopes;
215
+ flowTimeoutMs;
216
+ clientId;
217
+ tokens;
218
+ loaded = false;
219
+ metadata;
220
+ inFlight;
221
+ constructor(opts) {
222
+ this.serverUrl = opts.serverUrl;
223
+ this.storage = opts.storage ?? new FileStorage;
224
+ this.storageKey = opts.storageKey;
225
+ this.redirectPort = opts.redirectPort ?? DEFAULT_PORT;
226
+ this.scopes = opts.scopes;
227
+ this.flowTimeoutMs = opts.flowTimeoutMs ?? DEFAULT_TIMEOUT_MS;
228
+ this.clientId = opts.clientId;
202
229
  }
203
- async stop() {
204
- const unsubs = this.unsubs;
205
- this.unsubs = [];
206
- await Promise.all(unsubs.map((u) => u()));
230
+ async getHeader() {
231
+ await this.ensureTokens();
232
+ if (!this.tokens)
233
+ throw new Error("OAuth: no tokens after auth flow.");
234
+ return `Bearer ${this.tokens.accessToken}`;
207
235
  }
208
- buildSystem(allTools, systemExtension) {
209
- let system = this.system;
210
- if (this.skills.length > 0) {
211
- const skillsList = this.skills.map((skill) => `- ${skill.name}: ${skill.description}`).join(`
212
- `);
213
- system = `${system}
214
-
215
- # Skills
216
- The following skills are available. When a task matches one, call the \`Skill\` tool with that skill's name to load its full content before proceeding.
217
-
218
- ${skillsList}`;
236
+ async onUnauthorized() {
237
+ if (this.tokens?.refreshToken) {
238
+ try {
239
+ await this.refresh();
240
+ return;
241
+ } catch {}
219
242
  }
220
- const deferred = allTools.filter((tool) => tool.deferred);
221
- if (deferred.length > 0) {
222
- const list = deferred.map((tool) => `- ${tool.name}`).join(`
223
- `);
224
- system = `${system}
225
-
226
- # Deferred tools
227
- These tools are available but their schemas are not loaded. Use ToolSearch to load them before calling.
228
- ${list}`;
243
+ this.tokens = undefined;
244
+ await this.storage.clear(this.storageKey);
245
+ await this.authorize();
246
+ }
247
+ async ensureTokens() {
248
+ if (this.inFlight) {
249
+ await this.inFlight;
250
+ return;
229
251
  }
230
- if (systemExtension) {
231
- system = `${system}
232
-
233
- ${systemExtension}`;
252
+ this.inFlight = (async () => {
253
+ if (!this.loaded) {
254
+ this.tokens = await this.storage.load(this.storageKey) ?? undefined;
255
+ this.loaded = true;
256
+ }
257
+ if (!this.tokens) {
258
+ await this.authorize();
259
+ return;
260
+ }
261
+ if (this.isExpired(this.tokens)) {
262
+ if (this.tokens.refreshToken) {
263
+ try {
264
+ await this.refresh();
265
+ return;
266
+ } catch {}
267
+ }
268
+ await this.authorize();
269
+ }
270
+ })();
271
+ try {
272
+ await this.inFlight;
273
+ } finally {
274
+ this.inFlight = undefined;
234
275
  }
235
- return system;
236
276
  }
237
- buildSkillTool() {
238
- const byName = new Map(this.skills.map((skill) => [skill.name, skill]));
239
- return new Tool({
240
- name: "Skill",
241
- description: 'Load the full content of a skill listed under "# Skills" in the system prompt. Call this when you decide a listed skill applies to the current task; the returned content extends your instructions for the rest of the session.',
242
- inputSchema: z.object({
243
- skill: z.string().describe("Name of the skill to load (must match a listed skill).")
244
- }),
245
- execute: ({ skill }) => {
246
- const found = byName.get(skill);
247
- if (!found) {
248
- const available = [...byName.keys()].join(", ");
249
- throw new Error(`Skill "${skill}" not found. Available: ${available}`);
250
- }
251
- return `<skill name="${found.name}">
252
- ${found.content}
253
- </skill>`;
277
+ isExpired(tokens) {
278
+ if (!tokens.expiresAt)
279
+ return false;
280
+ return Date.now() / 1000 >= tokens.expiresAt - 30;
281
+ }
282
+ async getMetadata() {
283
+ if (!this.metadata)
284
+ this.metadata = await discover(this.serverUrl);
285
+ return this.metadata;
286
+ }
287
+ async authorize() {
288
+ const meta = await this.getMetadata();
289
+ const redirectUri = this.tokens?.redirectUri ?? `http://127.0.0.1:${this.redirectPort}/callback`;
290
+ let clientId = this.tokens?.clientId ?? this.clientId;
291
+ if (!clientId) {
292
+ if (!meta.registration_endpoint) {
293
+ throw new Error("OAuth server does not support dynamic client registration; pass a clientId.");
254
294
  }
295
+ clientId = await registerClient(meta.registration_endpoint, redirectUri);
296
+ }
297
+ const { verifier, challenge } = await generatePkce();
298
+ const state = generateState();
299
+ const port = parseInt(new URL(redirectUri).port, 10);
300
+ const codePromise = captureAuthorizationCode(port, state, this.flowTimeoutMs);
301
+ const authUrl = new URL(meta.authorization_endpoint);
302
+ authUrl.searchParams.set("response_type", "code");
303
+ authUrl.searchParams.set("client_id", clientId);
304
+ authUrl.searchParams.set("redirect_uri", redirectUri);
305
+ authUrl.searchParams.set("code_challenge", challenge);
306
+ authUrl.searchParams.set("code_challenge_method", "S256");
307
+ authUrl.searchParams.set("state", state);
308
+ if (this.scopes?.length) {
309
+ authUrl.searchParams.set("scope", this.scopes.join(" "));
310
+ }
311
+ console.error(`[mcp:oauth] Opening browser for ${this.storageKey} authentication...`);
312
+ openBrowser(authUrl.toString());
313
+ const code = await codePromise;
314
+ const response = await exchangeCode({
315
+ tokenEndpoint: meta.token_endpoint,
316
+ code,
317
+ clientId,
318
+ redirectUri,
319
+ verifier
255
320
  });
321
+ this.tokens = this.toTokenSet(response, clientId, redirectUri);
322
+ await this.storage.save(this.storageKey, this.tokens);
256
323
  }
257
- session(init) {
258
- const initialMessages = init?.messages ? [...init.messages] : [];
259
- const sessionCwd = init?.cwd ?? this.cwd ?? process.cwd();
260
- const systemExtension = init?.systemExtension;
261
- const state = {
262
- messages: initialMessages,
263
- store: new Map
264
- };
265
- let activeTurn = null;
266
- let mcpConnected = false;
267
- let allTools = null;
268
- let registry = null;
269
- let ctx = null;
270
- const ensureReady = async () => {
271
- if (!allTools || !registry || !ctx) {
272
- const mcpToolGroups = await Promise.all(this.mcp.map((mcp) => mcp.connect()));
273
- mcpConnected = true;
274
- allTools = [
275
- ...this.tools,
276
- ...mcpToolGroups.flat(),
277
- ...this.skills.length > 0 ? [this.buildSkillTool()] : []
278
- ];
279
- registry = createRegistry(allTools);
280
- ctx = {
281
- complete: async (p) => {
282
- const response = await this.model.createMessage({
283
- messages: [{ role: "user", content: p }]
284
- });
285
- return response.content.filter((block) => block.type === "text").map((block) => block.text).join(`
286
- `);
287
- },
288
- searchWeb: (query, opts) => this.model.searchWeb(query, opts),
289
- session: state,
290
- tools: registry,
291
- cwd: sessionCwd
292
- };
293
- const systemMessage = {
294
- role: "system",
295
- content: this.buildSystem(allTools, systemExtension)
296
- };
297
- if (state.messages[0]?.role === "system") {
298
- state.messages[0] = systemMessage;
299
- } else {
300
- state.messages.unshift(systemMessage);
301
- }
302
- }
303
- return { tools: allTools, registry, ctx };
304
- };
305
- const internals = {
306
- send: (prompt) => {
307
- if (activeTurn !== null) {
308
- throw new Error("Session.send() called while another turn is in flight.");
309
- }
310
- const turn = new Turn(async (turnCtx) => {
311
- try {
312
- const ready = await ensureReady();
313
- state.messages.push(toUserMessage(prompt));
314
- await runAgentLoop({
315
- model: this.model,
316
- state,
317
- registry: ready.registry,
318
- ctx: ready.ctx,
319
- emit: turnCtx.emit,
320
- signal: turnCtx.signal
321
- });
322
- return [...state.messages];
323
- } finally {
324
- activeTurn = null;
325
- }
326
- });
327
- activeTurn = turn;
328
- return turn;
329
- },
330
- close: async () => {
331
- const pending = activeTurn;
332
- if (pending) {
333
- pending.abort("session closed");
334
- await Promise.resolve(pending).catch(() => {});
335
- }
336
- if (mcpConnected) {
337
- await Promise.all(this.mcp.map((mcp) => mcp.disconnect()));
338
- mcpConnected = false;
339
- }
340
- }
324
+ async refresh() {
325
+ if (!this.tokens?.refreshToken || !this.tokens.clientId) {
326
+ throw new Error("OAuth refresh: missing refresh token or client id.");
327
+ }
328
+ const meta = await this.getMetadata();
329
+ const response = await refreshTokens({
330
+ tokenEndpoint: meta.token_endpoint,
331
+ refreshToken: this.tokens.refreshToken,
332
+ clientId: this.tokens.clientId
333
+ });
334
+ this.tokens = this.toTokenSet(response, this.tokens.clientId, this.tokens.redirectUri, this.tokens.refreshToken);
335
+ await this.storage.save(this.storageKey, this.tokens);
336
+ }
337
+ toTokenSet(response, clientId, redirectUri, fallbackRefresh) {
338
+ const expiresAt = response.expires_in ? Math.floor(Date.now() / 1000) + response.expires_in : undefined;
339
+ return {
340
+ accessToken: response.access_token,
341
+ refreshToken: response.refresh_token ?? fallbackRefresh,
342
+ expiresAt,
343
+ clientId,
344
+ redirectUri
341
345
  };
342
- return new Session(internals, state);
343
346
  }
344
347
  }
345
- function toUserMessage(prompt) {
346
- if (typeof prompt === "string")
347
- return { role: "user", content: prompt };
348
- return { role: "user", content: prompt };
349
- }
350
- async function runAgentLoop({
351
- model,
352
- state,
353
- registry,
354
- ctx,
355
- emit,
356
- signal
357
- }) {
358
- while (true) {
359
- if (signal.aborted)
360
- break;
361
- const active = registry.loaded();
362
- const toolByName = new Map(active.map((tool) => [tool.name, tool]));
363
- emit({ type: "message-start" });
364
- const assistantContent = [];
365
- let stopReason = "end_turn";
366
- let usage = { inputTokens: 0, outputTokens: 0 };
367
- try {
368
- for await (const event of model.streamMessage({
369
- messages: state.messages,
370
- tools: active,
371
- signal
372
- })) {
373
- switch (event.type) {
374
- case "text-delta":
375
- emit({ type: "text-delta", text: event.text });
376
- break;
377
- case "text-end":
378
- assistantContent.push({ type: "text", text: event.text });
379
- emit({ type: "text", text: event.text });
380
- break;
381
- case "thinking-delta":
382
- emit({ type: "thinking-delta", text: event.text });
383
- break;
384
- case "thinking-end":
385
- assistantContent.push({
386
- type: "thinking",
387
- text: event.text,
388
- ...event.signature !== undefined ? { signature: event.signature } : {},
389
- ...event.redactedData !== undefined ? { redactedData: event.redactedData } : {}
390
- });
391
- emit({
392
- type: "thinking",
393
- text: event.text,
394
- ...event.signature !== undefined ? { signature: event.signature } : {}
395
- });
396
- break;
397
- case "tool-call":
398
- assistantContent.push({
399
- type: "tool-call",
400
- toolCallId: event.toolCallId,
401
- toolName: event.toolName,
402
- input: event.input
403
- });
404
- emit({
405
- type: "tool-call",
406
- toolCallId: event.toolCallId,
407
- toolName: event.toolName,
408
- input: event.input
409
- });
410
- break;
411
- case "message-end":
412
- stopReason = event.stopReason;
413
- usage = event.usage;
414
- break;
415
- default:
416
- break;
417
- }
418
- }
419
- } catch (error) {
420
- if (signal.aborted) {
421
- state.messages.push({ role: "assistant", content: assistantContent });
422
- break;
423
- }
424
- const err = error instanceof Error ? error : new Error(String(error));
425
- emit({ type: "error", error: err });
426
- throw err;
427
- }
428
- state.messages.push({ role: "assistant", content: assistantContent });
429
- emit({ type: "message-end", usage });
430
- if (stopReason !== "tool_use") {
431
- emit({ type: "turn-end" });
432
- break;
348
+
349
+ // src/core/agent.ts
350
+ import { z } from "zod";
351
+
352
+ // src/core/session.ts
353
+ class Turn {
354
+ queue = [];
355
+ waiters = [];
356
+ ended = false;
357
+ iterated = false;
358
+ resultPromise;
359
+ abortController = new AbortController;
360
+ constructor(runner) {
361
+ this.resultPromise = runner({
362
+ emit: (event) => this.emit(event),
363
+ signal: this.abortController.signal
364
+ }).then((messages) => {
365
+ this.close();
366
+ return messages;
367
+ }, (error) => {
368
+ this.close();
369
+ throw error;
370
+ });
371
+ this.resultPromise.catch(() => {});
372
+ }
373
+ emit(event) {
374
+ const waiter = this.waiters.shift();
375
+ if (waiter) {
376
+ waiter({ value: event, done: false });
377
+ } else {
378
+ this.queue.push(event);
433
379
  }
434
- const toolCalls = assistantContent.filter((block) => block.type === "tool-call");
435
- const results = [];
436
- for (const call of toolCalls) {
437
- if (signal.aborted)
438
- break;
439
- const tool = toolByName.get(call.toolName);
440
- const result = await runTool(tool, call, ctx);
441
- results.push(result);
442
- emit({
443
- type: "tool-result",
444
- toolCallId: result.toolCallId,
445
- toolName: result.toolName,
446
- output: result.output,
447
- isError: typeof result.output === "string" ? result.output.startsWith("Error:") : false
448
- });
380
+ }
381
+ close() {
382
+ this.ended = true;
383
+ while (this.waiters.length > 0) {
384
+ const waiter = this.waiters.shift();
385
+ waiter?.({ value: undefined, done: true });
449
386
  }
450
- state.messages.push({ role: "tool", content: results });
451
- if (signal.aborted)
452
- break;
453
387
  }
454
- }
455
- async function runTool(tool, call, ctx) {
456
- if (!tool) {
457
- return {
458
- type: "tool-result",
459
- toolCallId: call.toolCallId,
460
- toolName: call.toolName,
461
- output: `Error: tool "${call.toolName}" not found`
462
- };
388
+ abort(reason) {
389
+ this.abortController.abort(reason);
463
390
  }
464
- try {
465
- const parsed = tool.parse(call.input);
466
- const output = await tool.execute(parsed, ctx);
467
- return {
468
- type: "tool-result",
469
- toolCallId: call.toolCallId,
470
- toolName: call.toolName,
471
- output
472
- };
473
- } catch (error) {
391
+ [Symbol.asyncIterator]() {
392
+ if (this.iterated) {
393
+ throw new Error("Turn can only be iterated once.");
394
+ }
395
+ this.iterated = true;
474
396
  return {
475
- type: "tool-result",
476
- toolCallId: call.toolCallId,
477
- toolName: call.toolName,
478
- output: `Error: ${error instanceof Error ? error.message : String(error)}`
479
- };
480
- }
481
- }
482
-
483
- // src/core/model.ts
484
- class Model {
485
- async createMessage(params) {
486
- const content = [];
487
- let id = "";
488
- let stopReason = "end_turn";
489
- let usage = { inputTokens: 0, outputTokens: 0 };
490
- for await (const event of this.streamMessage(params)) {
491
- switch (event.type) {
492
- case "text-end":
493
- content.push({ type: "text", text: event.text });
494
- break;
495
- case "thinking-end":
496
- content.push({
497
- type: "thinking",
498
- text: event.text,
499
- ...event.signature !== undefined ? { signature: event.signature } : {},
500
- ...event.redactedData !== undefined ? { redactedData: event.redactedData } : {}
501
- });
502
- break;
503
- case "tool-call":
504
- content.push({
505
- type: "tool-call",
506
- toolCallId: event.toolCallId,
507
- toolName: event.toolName,
508
- input: event.input
509
- });
510
- break;
511
- case "message-end":
512
- id = event.id;
513
- stopReason = event.stopReason;
514
- usage = event.usage;
515
- break;
516
- default:
517
- break;
397
+ next: () => {
398
+ if (this.queue.length > 0) {
399
+ const value = this.queue.shift();
400
+ return Promise.resolve({ value, done: false });
401
+ }
402
+ if (this.ended) {
403
+ return Promise.resolve({
404
+ value: undefined,
405
+ done: true
406
+ });
407
+ }
408
+ return new Promise((resolve) => this.waiters.push(resolve));
409
+ },
410
+ return: async () => {
411
+ this.abort("iteration ended");
412
+ await this.resultPromise.catch(() => {});
413
+ return {
414
+ value: undefined,
415
+ done: true
416
+ };
518
417
  }
519
- }
520
- return { id, content, stopReason, usage };
418
+ };
419
+ }
420
+ then(onfulfilled, onrejected) {
421
+ return this.resultPromise.then(onfulfilled, onrejected);
521
422
  }
522
423
  }
523
424
 
524
- // src/core/skill.ts
525
- class Skill {
526
- name;
527
- description;
528
- content;
529
- constructor({
530
- name,
531
- description,
532
- content
533
- }) {
534
- this.name = name;
535
- this.description = description;
536
- this.content = content;
425
+ class Session {
426
+ internals;
427
+ state;
428
+ constructor(internals, state) {
429
+ this.internals = internals;
430
+ this.state = state;
431
+ }
432
+ get messages() {
433
+ return this.state.messages;
434
+ }
435
+ send(prompt) {
436
+ return this.internals.send(prompt);
437
+ }
438
+ async close() {
439
+ await this.internals.close();
440
+ }
441
+ async[Symbol.asyncDispose]() {
442
+ await this.close();
537
443
  }
538
444
  }
539
445
 
540
- // src/mcp/oauth.ts
541
- function base64UrlEncode(bytes) {
542
- let str = "";
543
- for (const byte of bytes)
544
- str += String.fromCharCode(byte);
545
- return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
546
- }
547
- function randomBytes(length) {
548
- const bytes = new Uint8Array(length);
549
- crypto.getRandomValues(bytes);
550
- return bytes;
551
- }
552
- async function sha256(input) {
553
- const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
554
- return new Uint8Array(hash);
555
- }
556
- async function generatePkce() {
557
- const verifier = base64UrlEncode(randomBytes(32));
558
- const challenge = base64UrlEncode(await sha256(verifier));
559
- return { verifier, challenge };
560
- }
561
- function generateState() {
562
- return base64UrlEncode(randomBytes(16));
446
+ // src/core/tool.ts
447
+ import * as z4 from "zod/v4/core";
448
+ function hasParseMethod(schema) {
449
+ return typeof schema === "object" && schema !== null && "parse" in schema && typeof schema.parse === "function";
563
450
  }
564
- async function discover(serverUrl) {
565
- const u = new URL(serverUrl);
566
- const origin = `${u.protocol}//${u.host}`;
567
- let authServer = origin;
568
- try {
569
- const res2 = await fetch(`${origin}/.well-known/oauth-protected-resource`);
570
- if (res2.ok) {
571
- const meta = await res2.json();
572
- if (meta.authorization_servers?.[0])
573
- authServer = meta.authorization_servers[0];
451
+
452
+ class Tool {
453
+ name;
454
+ description;
455
+ inputSchema;
456
+ jsonSchema;
457
+ execute;
458
+ deferred;
459
+ constructor(init) {
460
+ this.name = init.name;
461
+ this.description = init.description;
462
+ this.deferred = init.deferred ?? false;
463
+ if ("inputSchema" in init) {
464
+ this.inputSchema = init.inputSchema;
465
+ this.execute = init.execute;
466
+ } else {
467
+ this.jsonSchema = init.jsonSchema;
468
+ this.execute = init.execute;
574
469
  }
575
- } catch {}
576
- const asUrl = new URL(authServer);
577
- const asOrigin = `${asUrl.protocol}//${asUrl.host}`;
578
- const res = await fetch(`${asOrigin}/.well-known/oauth-authorization-server`);
579
- if (!res.ok) {
580
- throw new Error(`OAuth discovery failed for ${asOrigin}: ${res.status} ${await res.text()}`);
581
470
  }
582
- return await res.json();
583
- }
584
- async function registerClient(registrationEndpoint, redirectUri) {
585
- const res = await fetch(registrationEndpoint, {
586
- method: "POST",
587
- headers: { "content-type": "application/json" },
588
- body: JSON.stringify({
589
- client_name: "roboport",
590
- redirect_uris: [redirectUri],
591
- grant_types: ["authorization_code", "refresh_token"],
592
- response_types: ["code"],
593
- token_endpoint_auth_method: "none"
594
- })
595
- });
596
- if (!res.ok) {
597
- throw new Error(`OAuth client registration failed: ${res.status} ${await res.text()}`);
471
+ toJsonSchema() {
472
+ if (this.jsonSchema !== undefined)
473
+ return this.jsonSchema;
474
+ if (this.inputSchema === undefined) {
475
+ throw new Error(`Tool "${this.name}" has neither inputSchema nor jsonSchema.`);
476
+ }
477
+ return z4.toJSONSchema(this.inputSchema);
478
+ }
479
+ parse(input) {
480
+ const schema = this.inputSchema;
481
+ if (!schema)
482
+ return input;
483
+ if (hasParseMethod(schema))
484
+ return schema.parse(input);
485
+ return z4.parse(schema, input);
598
486
  }
599
- const data = await res.json();
600
- return data.client_id;
601
- }
602
- function openBrowser(url) {
603
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
604
- Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
605
487
  }
606
- function captureAuthorizationCode(port, expectedState, timeoutMs) {
607
- return new Promise((resolve, reject) => {
608
- const server = Bun.serve({
609
- port,
610
- hostname: "127.0.0.1",
611
- fetch(req) {
612
- const u = new URL(req.url);
613
- const error = u.searchParams.get("error");
614
- if (error) {
615
- const desc = u.searchParams.get("error_description") ?? "";
616
- finish(() => reject(new Error(`OAuth error: ${error} ${desc}`)));
617
- return new Response("Authentication failed. You can close this tab.", {
618
- status: 400
619
- });
620
- }
621
- const code = u.searchParams.get("code");
622
- const state = u.searchParams.get("state");
623
- if (!code || state !== expectedState) {
624
- finish(() => reject(new Error("OAuth state mismatch or missing code.")));
625
- return new Response("Invalid OAuth response.", { status: 400 });
488
+ function createRegistry(tools) {
489
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
490
+ const loadedNames = new Set(tools.filter((tool) => !tool.deferred).map((tool) => tool.name));
491
+ return {
492
+ loaded: () => tools.filter((tool) => loadedNames.has(tool.name)),
493
+ deferred: () => tools.filter((tool) => tool.deferred && !loadedNames.has(tool.name)),
494
+ load: (names) => {
495
+ const loaded = [];
496
+ const missing = [];
497
+ for (const name of names) {
498
+ const tool = byName.get(name);
499
+ if (!tool) {
500
+ missing.push(name);
501
+ continue;
626
502
  }
627
- finish(() => resolve(code));
628
- return new Response("<html><body>Authenticated. You can close this tab.</body></html>", { headers: { "content-type": "text/html" } });
503
+ loadedNames.add(name);
504
+ loaded.push(tool);
629
505
  }
630
- });
631
- const timer = setTimeout(() => {
632
- finish(() => reject(new Error("OAuth flow timed out.")));
633
- }, timeoutMs);
634
- function finish(action) {
635
- clearTimeout(timer);
636
- setTimeout(() => server.stop(true), 50);
637
- action();
506
+ return { loaded, missing };
638
507
  }
639
- });
640
- }
641
- async function exchangeCode(opts) {
642
- const body = new URLSearchParams({
643
- grant_type: "authorization_code",
644
- code: opts.code,
645
- redirect_uri: opts.redirectUri,
646
- client_id: opts.clientId,
647
- code_verifier: opts.verifier
648
- });
649
- const res = await fetch(opts.tokenEndpoint, {
650
- method: "POST",
651
- headers: { "content-type": "application/x-www-form-urlencoded" },
652
- body
653
- });
654
- if (!res.ok) {
655
- throw new Error(`OAuth token exchange failed: ${res.status} ${await res.text()}`);
656
- }
657
- return await res.json();
508
+ };
658
509
  }
659
- async function refreshTokens(opts) {
660
- const body = new URLSearchParams({
661
- grant_type: "refresh_token",
662
- refresh_token: opts.refreshToken,
663
- client_id: opts.clientId
664
- });
665
- const res = await fetch(opts.tokenEndpoint, {
666
- method: "POST",
667
- headers: { "content-type": "application/x-www-form-urlencoded" },
668
- body
669
- });
670
- if (!res.ok) {
671
- throw new Error(`OAuth refresh failed: ${res.status} ${await res.text()}`);
510
+
511
+ // src/core/agent.ts
512
+ class Agent {
513
+ model;
514
+ system;
515
+ tools;
516
+ skills;
517
+ mcp;
518
+ cwd;
519
+ registrations = [];
520
+ unsubs = [];
521
+ constructor({
522
+ model,
523
+ system,
524
+ tools,
525
+ skills,
526
+ mcp,
527
+ cwd
528
+ }) {
529
+ this.model = model;
530
+ this.system = system;
531
+ this.tools = tools;
532
+ this.skills = skills;
533
+ this.mcp = mcp ?? [];
534
+ this.cwd = cwd;
672
535
  }
673
- return await res.json();
674
- }
675
-
676
- // src/mcp/storage.ts
677
- import { chmod, mkdir, readFile, writeFile } from "fs/promises";
678
- import { homedir } from "os";
679
- import { dirname, join } from "path";
680
- var DEFAULT_PATH = join(homedir(), ".roboport", "mcp-auth.json");
681
-
682
- class FileStorage {
683
- path;
684
- cache;
685
- constructor(path) {
686
- this.path = path ?? DEFAULT_PATH;
536
+ on(trigger, handler) {
537
+ this.registrations.push({ trigger, handler });
687
538
  }
688
- async read() {
689
- if (this.cache)
690
- return this.cache;
691
- try {
692
- const raw = await readFile(this.path, "utf8");
693
- this.cache = JSON.parse(raw);
694
- } catch {
695
- this.cache = {};
539
+ async start() {
540
+ for (const { trigger, handler } of this.registrations) {
541
+ const unsub = await trigger.start((event) => {
542
+ Promise.resolve().then(() => handler(event)).catch((error) => {
543
+ const message = error instanceof Error ? error.message : String(error);
544
+ console.error(`[roboport] trigger "${trigger.name}" handler failed: ${message}`);
545
+ });
546
+ });
547
+ this.unsubs.push(unsub);
696
548
  }
697
- return this.cache;
698
- }
699
- async write(data) {
700
- await mkdir(dirname(this.path), { recursive: true });
701
- await writeFile(this.path, JSON.stringify(data, null, 2), "utf8");
702
- await chmod(this.path, 384);
703
- this.cache = data;
704
- }
705
- async load(key) {
706
- const data = await this.read();
707
- return data[key] ?? null;
708
- }
709
- async save(key, tokens) {
710
- const data = await this.read();
711
- data[key] = tokens;
712
- await this.write(data);
713
549
  }
714
- async clear(key) {
715
- const data = await this.read();
716
- delete data[key];
717
- await this.write(data);
550
+ async stop() {
551
+ const unsubs = this.unsubs;
552
+ this.unsubs = [];
553
+ await Promise.all(unsubs.map((u) => u()));
718
554
  }
719
- }
555
+ buildSystem(allTools, systemExtension) {
556
+ let system = this.system;
557
+ if (this.skills.length > 0) {
558
+ const skillsList = this.skills.map((skill) => `- ${skill.name}: ${skill.description}`).join(`
559
+ `);
560
+ system = `${system}
720
561
 
721
- class MemoryStorage {
722
- data = new Map;
723
- async load(key) {
724
- return this.data.get(key) ?? null;
725
- }
726
- async save(key, tokens) {
727
- this.data.set(key, tokens);
728
- }
729
- async clear(key) {
730
- this.data.delete(key);
731
- }
732
- }
562
+ # Skills
563
+ The following skills are available. When a task matches one, call the \`Skill\` tool with that skill's name to load its full content before proceeding.
733
564
 
734
- // src/mcp/auth.ts
735
- class BearerAuth {
736
- token;
737
- constructor(token) {
738
- this.token = token;
739
- }
740
- async getHeader() {
741
- return `Bearer ${this.token}`;
742
- }
743
- }
744
- var DEFAULT_PORT = 33418;
745
- var DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
565
+ ${skillsList}`;
566
+ }
567
+ const deferred = allTools.filter((tool) => tool.deferred);
568
+ if (deferred.length > 0) {
569
+ const list = deferred.map((tool) => `- ${tool.name}`).join(`
570
+ `);
571
+ system = `${system}
746
572
 
747
- class OAuthAuth {
748
- serverUrl;
749
- storage;
750
- storageKey;
751
- redirectPort;
752
- scopes;
753
- flowTimeoutMs;
754
- tokens;
755
- loaded = false;
756
- metadata;
757
- inFlight;
758
- constructor(opts) {
759
- this.serverUrl = opts.serverUrl;
760
- this.storage = opts.storage ?? new FileStorage;
761
- this.storageKey = opts.storageKey;
762
- this.redirectPort = opts.redirectPort ?? DEFAULT_PORT;
763
- this.scopes = opts.scopes;
764
- this.flowTimeoutMs = opts.flowTimeoutMs ?? DEFAULT_TIMEOUT_MS;
765
- }
766
- async getHeader() {
767
- await this.ensureTokens();
768
- if (!this.tokens)
769
- throw new Error("OAuth: no tokens after auth flow.");
770
- return `Bearer ${this.tokens.accessToken}`;
771
- }
772
- async onUnauthorized() {
773
- if (this.tokens?.refreshToken) {
774
- try {
775
- await this.refresh();
776
- return;
777
- } catch {}
573
+ # Deferred tools
574
+ These tools are available but their schemas are not loaded. Use ToolSearch to load them before calling.
575
+ ${list}`;
778
576
  }
779
- this.tokens = undefined;
780
- await this.storage.clear(this.storageKey);
781
- await this.authorize();
782
- }
783
- async ensureTokens() {
784
- if (this.inFlight) {
785
- await this.inFlight;
786
- return;
577
+ if (systemExtension) {
578
+ system = `${system}
579
+
580
+ ${systemExtension}`;
787
581
  }
788
- this.inFlight = (async () => {
789
- if (!this.loaded) {
790
- this.tokens = await this.storage.load(this.storageKey) ?? undefined;
791
- this.loaded = true;
582
+ return system;
583
+ }
584
+ buildSkillTool() {
585
+ const byName = new Map(this.skills.map((skill) => [skill.name, skill]));
586
+ return new Tool({
587
+ name: "Skill",
588
+ description: 'Load the full content of a skill listed under "# Skills" in the system prompt. Call this when you decide a listed skill applies to the current task; the returned content extends your instructions for the rest of the session.',
589
+ inputSchema: z.object({
590
+ skill: z.string().describe("Name of the skill to load (must match a listed skill).")
591
+ }),
592
+ execute: ({ skill }) => {
593
+ const found = byName.get(skill);
594
+ if (!found) {
595
+ const available = [...byName.keys()].join(", ");
596
+ throw new Error(`Skill "${skill}" not found. Available: ${available}`);
597
+ }
598
+ return `<skill name="${found.name}">
599
+ ${found.content}
600
+ </skill>`;
792
601
  }
793
- if (!this.tokens) {
794
- await this.authorize();
795
- return;
602
+ });
603
+ }
604
+ session(init) {
605
+ const initialMessages = init?.messages ? [...init.messages] : [];
606
+ const sessionCwd = init?.cwd ?? this.cwd ?? process.cwd();
607
+ const systemExtension = init?.systemExtension;
608
+ const state = {
609
+ messages: initialMessages,
610
+ store: new Map
611
+ };
612
+ let activeTurn = null;
613
+ let mcpConnected = false;
614
+ let allTools = null;
615
+ let registry = null;
616
+ let ctx = null;
617
+ const ensureReady = async () => {
618
+ if (!allTools || !registry || !ctx) {
619
+ const mcpToolGroups = await Promise.all(this.mcp.map((mcp) => mcp.connect()));
620
+ mcpConnected = true;
621
+ allTools = [
622
+ ...this.tools,
623
+ ...mcpToolGroups.flat(),
624
+ ...this.skills.length > 0 ? [this.buildSkillTool()] : []
625
+ ];
626
+ registry = createRegistry(allTools);
627
+ ctx = {
628
+ complete: async (p) => {
629
+ const response = await this.model.createMessage({
630
+ messages: [{ role: "user", content: p }]
631
+ });
632
+ return response.content.filter((block) => block.type === "text").map((block) => block.text).join(`
633
+ `);
634
+ },
635
+ searchWeb: (query, opts) => this.model.searchWeb(query, opts),
636
+ session: state,
637
+ tools: registry,
638
+ cwd: sessionCwd
639
+ };
640
+ const systemMessage = {
641
+ role: "system",
642
+ content: this.buildSystem(allTools, systemExtension)
643
+ };
644
+ if (state.messages[0]?.role === "system") {
645
+ state.messages[0] = systemMessage;
646
+ } else {
647
+ state.messages.unshift(systemMessage);
648
+ }
796
649
  }
797
- if (this.isExpired(this.tokens)) {
798
- if (this.tokens.refreshToken) {
650
+ return { tools: allTools, registry, ctx };
651
+ };
652
+ const internals = {
653
+ send: (prompt) => {
654
+ if (activeTurn !== null) {
655
+ throw new Error("Session.send() called while another turn is in flight.");
656
+ }
657
+ const turn = new Turn(async (turnCtx) => {
799
658
  try {
800
- await this.refresh();
801
- return;
802
- } catch {}
659
+ const ready = await ensureReady();
660
+ state.messages.push(toUserMessage(prompt));
661
+ await runAgentLoop({
662
+ model: this.model,
663
+ state,
664
+ registry: ready.registry,
665
+ ctx: ready.ctx,
666
+ emit: turnCtx.emit,
667
+ signal: turnCtx.signal
668
+ });
669
+ return [...state.messages];
670
+ } finally {
671
+ activeTurn = null;
672
+ }
673
+ });
674
+ activeTurn = turn;
675
+ return turn;
676
+ },
677
+ close: async () => {
678
+ const pending = activeTurn;
679
+ if (pending) {
680
+ pending.abort("session closed");
681
+ await Promise.resolve(pending).catch(() => {});
682
+ }
683
+ if (mcpConnected) {
684
+ await Promise.all(this.mcp.map((mcp) => mcp.disconnect()));
685
+ mcpConnected = false;
803
686
  }
804
- await this.authorize();
805
687
  }
806
- })();
807
- try {
808
- await this.inFlight;
809
- } finally {
810
- this.inFlight = undefined;
811
- }
812
- }
813
- isExpired(tokens) {
814
- if (!tokens.expiresAt)
815
- return false;
816
- return Date.now() / 1000 >= tokens.expiresAt - 30;
817
- }
818
- async getMetadata() {
819
- if (!this.metadata)
820
- this.metadata = await discover(this.serverUrl);
821
- return this.metadata;
688
+ };
689
+ return new Session(internals, state);
822
690
  }
823
- async authorize() {
824
- const meta = await this.getMetadata();
825
- const redirectUri = this.tokens?.redirectUri ?? `http://127.0.0.1:${this.redirectPort}/callback`;
826
- let clientId = this.tokens?.clientId;
827
- if (!clientId) {
828
- if (!meta.registration_endpoint) {
829
- throw new Error("OAuth server does not support dynamic client registration; provide a client_id manually.");
691
+ }
692
+ function toUserMessage(prompt) {
693
+ if (typeof prompt === "string")
694
+ return { role: "user", content: prompt };
695
+ return { role: "user", content: prompt };
696
+ }
697
+ async function runAgentLoop({
698
+ model,
699
+ state,
700
+ registry,
701
+ ctx,
702
+ emit,
703
+ signal
704
+ }) {
705
+ while (true) {
706
+ if (signal.aborted)
707
+ break;
708
+ const active = registry.loaded();
709
+ const toolByName = new Map(active.map((tool) => [tool.name, tool]));
710
+ emit({ type: "message-start" });
711
+ const assistantContent = [];
712
+ let stopReason = "end_turn";
713
+ let usage = { inputTokens: 0, outputTokens: 0 };
714
+ try {
715
+ for await (const event of model.streamMessage({
716
+ messages: state.messages,
717
+ tools: active,
718
+ signal
719
+ })) {
720
+ switch (event.type) {
721
+ case "text-delta":
722
+ emit({ type: "text-delta", text: event.text });
723
+ break;
724
+ case "text-end":
725
+ assistantContent.push({ type: "text", text: event.text });
726
+ emit({ type: "text", text: event.text });
727
+ break;
728
+ case "thinking-delta":
729
+ emit({ type: "thinking-delta", text: event.text });
730
+ break;
731
+ case "thinking-end":
732
+ assistantContent.push({
733
+ type: "thinking",
734
+ text: event.text,
735
+ ...event.signature !== undefined ? { signature: event.signature } : {},
736
+ ...event.redactedData !== undefined ? { redactedData: event.redactedData } : {}
737
+ });
738
+ emit({
739
+ type: "thinking",
740
+ text: event.text,
741
+ ...event.signature !== undefined ? { signature: event.signature } : {}
742
+ });
743
+ break;
744
+ case "tool-call":
745
+ assistantContent.push({
746
+ type: "tool-call",
747
+ toolCallId: event.toolCallId,
748
+ toolName: event.toolName,
749
+ input: event.input
750
+ });
751
+ emit({
752
+ type: "tool-call",
753
+ toolCallId: event.toolCallId,
754
+ toolName: event.toolName,
755
+ input: event.input
756
+ });
757
+ break;
758
+ case "message-end":
759
+ stopReason = event.stopReason;
760
+ usage = event.usage;
761
+ break;
762
+ default:
763
+ break;
764
+ }
830
765
  }
831
- clientId = await registerClient(meta.registration_endpoint, redirectUri);
766
+ } catch (error) {
767
+ if (signal.aborted) {
768
+ state.messages.push({ role: "assistant", content: assistantContent });
769
+ break;
770
+ }
771
+ const err = error instanceof Error ? error : new Error(String(error));
772
+ emit({ type: "error", error: err });
773
+ throw err;
832
774
  }
833
- const { verifier, challenge } = await generatePkce();
834
- const state = generateState();
835
- const port = parseInt(new URL(redirectUri).port, 10);
836
- const codePromise = captureAuthorizationCode(port, state, this.flowTimeoutMs);
837
- const authUrl = new URL(meta.authorization_endpoint);
838
- authUrl.searchParams.set("response_type", "code");
839
- authUrl.searchParams.set("client_id", clientId);
840
- authUrl.searchParams.set("redirect_uri", redirectUri);
841
- authUrl.searchParams.set("code_challenge", challenge);
842
- authUrl.searchParams.set("code_challenge_method", "S256");
843
- authUrl.searchParams.set("state", state);
844
- if (this.scopes?.length) {
845
- authUrl.searchParams.set("scope", this.scopes.join(" "));
775
+ state.messages.push({ role: "assistant", content: assistantContent });
776
+ emit({ type: "message-end", usage });
777
+ if (stopReason !== "tool_use") {
778
+ emit({ type: "turn-end" });
779
+ break;
846
780
  }
847
- console.error(`[mcp:oauth] Opening browser for ${this.storageKey} authentication...`);
848
- openBrowser(authUrl.toString());
849
- const code = await codePromise;
850
- const response = await exchangeCode({
851
- tokenEndpoint: meta.token_endpoint,
852
- code,
853
- clientId,
854
- redirectUri,
855
- verifier
856
- });
857
- this.tokens = this.toTokenSet(response, clientId, redirectUri);
858
- await this.storage.save(this.storageKey, this.tokens);
859
- }
860
- async refresh() {
861
- if (!this.tokens?.refreshToken || !this.tokens.clientId) {
862
- throw new Error("OAuth refresh: missing refresh token or client id.");
781
+ const toolCalls = assistantContent.filter((block) => block.type === "tool-call");
782
+ const results = [];
783
+ for (const call of toolCalls) {
784
+ if (signal.aborted)
785
+ break;
786
+ const tool = toolByName.get(call.toolName);
787
+ const result = await runTool(tool, call, ctx);
788
+ results.push(result);
789
+ emit({
790
+ type: "tool-result",
791
+ toolCallId: result.toolCallId,
792
+ toolName: result.toolName,
793
+ output: result.output,
794
+ isError: typeof result.output === "string" ? result.output.startsWith("Error:") : false
795
+ });
863
796
  }
864
- const meta = await this.getMetadata();
865
- const response = await refreshTokens({
866
- tokenEndpoint: meta.token_endpoint,
867
- refreshToken: this.tokens.refreshToken,
868
- clientId: this.tokens.clientId
869
- });
870
- this.tokens = this.toTokenSet(response, this.tokens.clientId, this.tokens.redirectUri, this.tokens.refreshToken);
871
- await this.storage.save(this.storageKey, this.tokens);
797
+ state.messages.push({ role: "tool", content: results });
798
+ if (signal.aborted)
799
+ break;
872
800
  }
873
- toTokenSet(response, clientId, redirectUri, fallbackRefresh) {
874
- const expiresAt = response.expires_in ? Math.floor(Date.now() / 1000) + response.expires_in : undefined;
801
+ }
802
+ async function runTool(tool, call, ctx) {
803
+ if (!tool) {
875
804
  return {
876
- accessToken: response.access_token,
877
- refreshToken: response.refresh_token ?? fallbackRefresh,
878
- expiresAt,
879
- clientId,
880
- redirectUri
805
+ type: "tool-result",
806
+ toolCallId: call.toolCallId,
807
+ toolName: call.toolName,
808
+ output: `Error: tool "${call.toolName}" not found`
809
+ };
810
+ }
811
+ try {
812
+ const parsed = tool.parse(call.input);
813
+ const output = await tool.execute(parsed, ctx);
814
+ return {
815
+ type: "tool-result",
816
+ toolCallId: call.toolCallId,
817
+ toolName: call.toolName,
818
+ output
819
+ };
820
+ } catch (error) {
821
+ return {
822
+ type: "tool-result",
823
+ toolCallId: call.toolCallId,
824
+ toolName: call.toolName,
825
+ output: `Error: ${error instanceof Error ? error.message : String(error)}`
881
826
  };
882
827
  }
883
828
  }
884
829
 
830
+ // src/core/model.ts
831
+ class Model {
832
+ async createMessage(params) {
833
+ const content = [];
834
+ let id = "";
835
+ let stopReason = "end_turn";
836
+ let usage = { inputTokens: 0, outputTokens: 0 };
837
+ for await (const event of this.streamMessage(params)) {
838
+ switch (event.type) {
839
+ case "text-end":
840
+ content.push({ type: "text", text: event.text });
841
+ break;
842
+ case "thinking-end":
843
+ content.push({
844
+ type: "thinking",
845
+ text: event.text,
846
+ ...event.signature !== undefined ? { signature: event.signature } : {},
847
+ ...event.redactedData !== undefined ? { redactedData: event.redactedData } : {}
848
+ });
849
+ break;
850
+ case "tool-call":
851
+ content.push({
852
+ type: "tool-call",
853
+ toolCallId: event.toolCallId,
854
+ toolName: event.toolName,
855
+ input: event.input
856
+ });
857
+ break;
858
+ case "message-end":
859
+ id = event.id;
860
+ stopReason = event.stopReason;
861
+ usage = event.usage;
862
+ break;
863
+ default:
864
+ break;
865
+ }
866
+ }
867
+ return { id, content, stopReason, usage };
868
+ }
869
+ }
870
+
871
+ // src/core/skill.ts
872
+ class Skill {
873
+ name;
874
+ description;
875
+ content;
876
+ constructor({
877
+ name,
878
+ description,
879
+ content
880
+ }) {
881
+ this.name = name;
882
+ this.description = description;
883
+ this.content = content;
884
+ }
885
+ }
886
+
885
887
  // src/mcp/clients/grafana.ts
886
888
  var EMPTY_OBJECT = {
887
889
  type: "object",
@@ -1272,6 +1274,9 @@ class Mcp2 {
1272
1274
  transport,
1273
1275
  deferred
1274
1276
  }) {
1277
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
1278
+ throw new Error(`Invalid MCP name "${name}": use only letters, digits, underscores, and hyphens.`);
1279
+ }
1275
1280
  this.name = name;
1276
1281
  this.transport = transport;
1277
1282
  this.deferred = deferred ?? true;
@@ -1360,6 +1365,11 @@ class Mcp4 extends Mcp2 {
1360
1365
  var tenderly_default = Mcp4;
1361
1366
  export {
1362
1367
  tenderly_default as Tenderly,
1368
+ OAuthAuth,
1369
+ MemoryStorage,
1370
+ Mcp2 as Mcp,
1363
1371
  linear_default as Linear,
1364
- grafana_default as Grafana
1372
+ grafana_default as Grafana,
1373
+ FileStorage,
1374
+ BearerAuth
1365
1375
  };