roboport 0.0.1 → 0.2.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,878 +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
- };
195
+
196
+ // src/mcp/auth.ts
197
+ class BearerAuth {
198
+ token;
199
+ constructor(token) {
200
+ this.token = token;
201
+ }
202
+ async getHeader() {
203
+ return `Bearer ${this.token}`;
204
+ }
162
205
  }
206
+ var DEFAULT_PORT = 33418;
207
+ var DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
163
208
 
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;
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;
188
229
  }
189
- on(trigger, handler) {
190
- this.registrations.push({ trigger, handler });
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}`;
191
235
  }
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);
236
+ async onUnauthorized() {
237
+ if (this.tokens?.refreshToken) {
238
+ try {
239
+ await this.refresh();
240
+ return;
241
+ } catch {}
201
242
  }
243
+ this.tokens = undefined;
244
+ await this.storage.clear(this.storageKey);
245
+ await this.authorize();
202
246
  }
203
- async stop() {
204
- const unsubs = this.unsubs;
205
- this.unsubs = [];
206
- await Promise.all(unsubs.map((u) => u()));
207
- }
208
- buildSystem(allTools) {
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}`;
247
+ async ensureTokens() {
248
+ if (this.inFlight) {
249
+ await this.inFlight;
250
+ return;
219
251
  }
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}`;
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;
229
275
  }
230
- return system;
231
276
  }
232
- buildSkillTool() {
233
- const byName = new Map(this.skills.map((skill) => [skill.name, skill]));
234
- return new Tool({
235
- name: "Skill",
236
- 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.',
237
- inputSchema: z.object({
238
- skill: z.string().describe("Name of the skill to load (must match a listed skill).")
239
- }),
240
- execute: ({ skill }) => {
241
- const found = byName.get(skill);
242
- if (!found) {
243
- const available = [...byName.keys()].join(", ");
244
- throw new Error(`Skill "${skill}" not found. Available: ${available}`);
245
- }
246
- return `<skill name="${found.name}">
247
- ${found.content}
248
- </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.");
249
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
250
320
  });
321
+ this.tokens = this.toTokenSet(response, clientId, redirectUri);
322
+ await this.storage.save(this.storageKey, this.tokens);
251
323
  }
252
- session(init) {
253
- const initialMessages = init?.messages ? [...init.messages] : [];
254
- const sessionCwd = init?.cwd ?? this.cwd ?? process.cwd();
255
- const state = {
256
- messages: initialMessages,
257
- store: new Map
258
- };
259
- let activeTurn = null;
260
- let mcpConnected = false;
261
- let allTools = null;
262
- let registry = null;
263
- let ctx = null;
264
- const ensureReady = async () => {
265
- if (!allTools || !registry || !ctx) {
266
- const mcpToolGroups = await Promise.all(this.mcp.map((mcp) => mcp.connect()));
267
- mcpConnected = true;
268
- allTools = [
269
- ...this.tools,
270
- ...mcpToolGroups.flat(),
271
- ...this.skills.length > 0 ? [this.buildSkillTool()] : []
272
- ];
273
- registry = createRegistry(allTools);
274
- ctx = {
275
- complete: async (p) => {
276
- const response = await this.model.createMessage({
277
- messages: [{ role: "user", content: p }]
278
- });
279
- return response.content.filter((block) => block.type === "text").map((block) => block.text).join(`
280
- `);
281
- },
282
- searchWeb: (query, opts) => this.model.searchWeb(query, opts),
283
- session: state,
284
- tools: registry,
285
- cwd: sessionCwd
286
- };
287
- if (state.messages.length === 0 || state.messages[0]?.role !== "system") {
288
- state.messages.unshift({
289
- role: "system",
290
- content: this.buildSystem(allTools)
291
- });
292
- }
293
- }
294
- return { tools: allTools, registry, ctx };
295
- };
296
- const internals = {
297
- send: (prompt) => {
298
- if (activeTurn !== null) {
299
- throw new Error("Session.send() called while another turn is in flight.");
300
- }
301
- const turn = new Turn(async (turnCtx) => {
302
- try {
303
- const ready = await ensureReady();
304
- state.messages.push(toUserMessage(prompt));
305
- await runAgentLoop({
306
- model: this.model,
307
- state,
308
- registry: ready.registry,
309
- ctx: ready.ctx,
310
- emit: turnCtx.emit,
311
- signal: turnCtx.signal
312
- });
313
- return [...state.messages];
314
- } finally {
315
- activeTurn = null;
316
- }
317
- });
318
- activeTurn = turn;
319
- return turn;
320
- },
321
- close: async () => {
322
- const pending = activeTurn;
323
- if (pending) {
324
- pending.abort("session closed");
325
- await Promise.resolve(pending).catch(() => {});
326
- }
327
- if (mcpConnected) {
328
- await Promise.all(this.mcp.map((mcp) => mcp.disconnect()));
329
- mcpConnected = false;
330
- }
331
- }
332
- };
333
- return new Session(internals, state);
334
- }
335
- }
336
- function toUserMessage(prompt) {
337
- if (typeof prompt === "string")
338
- return { role: "user", content: prompt };
339
- return { role: "user", content: prompt };
340
- }
341
- async function runAgentLoop({
342
- model,
343
- state,
344
- registry,
345
- ctx,
346
- emit,
347
- signal
348
- }) {
349
- while (true) {
350
- if (signal.aborted)
351
- break;
352
- const active = registry.loaded();
353
- const toolByName = new Map(active.map((tool) => [tool.name, tool]));
354
- emit({ type: "message-start" });
355
- const assistantContent = [];
356
- let stopReason = "end_turn";
357
- let usage = { inputTokens: 0, outputTokens: 0 };
358
- try {
359
- for await (const event of model.streamMessage({
360
- messages: state.messages,
361
- tools: active,
362
- signal
363
- })) {
364
- switch (event.type) {
365
- case "text-delta":
366
- emit({ type: "text-delta", text: event.text });
367
- break;
368
- case "text-end":
369
- assistantContent.push({ type: "text", text: event.text });
370
- emit({ type: "text", text: event.text });
371
- break;
372
- case "thinking-delta":
373
- emit({ type: "thinking-delta", text: event.text });
374
- break;
375
- case "thinking-end":
376
- assistantContent.push({
377
- type: "thinking",
378
- text: event.text,
379
- ...event.signature !== undefined ? { signature: event.signature } : {},
380
- ...event.redactedData !== undefined ? { redactedData: event.redactedData } : {}
381
- });
382
- emit({
383
- type: "thinking",
384
- text: event.text,
385
- ...event.signature !== undefined ? { signature: event.signature } : {}
386
- });
387
- break;
388
- case "tool-call":
389
- assistantContent.push({
390
- type: "tool-call",
391
- toolCallId: event.toolCallId,
392
- toolName: event.toolName,
393
- input: event.input
394
- });
395
- emit({
396
- type: "tool-call",
397
- toolCallId: event.toolCallId,
398
- toolName: event.toolName,
399
- input: event.input
400
- });
401
- break;
402
- case "message-end":
403
- stopReason = event.stopReason;
404
- usage = event.usage;
405
- break;
406
- default:
407
- break;
408
- }
409
- }
410
- } catch (error) {
411
- if (signal.aborted) {
412
- state.messages.push({ role: "assistant", content: assistantContent });
413
- break;
414
- }
415
- const err = error instanceof Error ? error : new Error(String(error));
416
- emit({ type: "error", error: err });
417
- throw err;
418
- }
419
- state.messages.push({ role: "assistant", content: assistantContent });
420
- emit({ type: "message-end", usage });
421
- if (stopReason !== "tool_use") {
422
- emit({ type: "turn-end" });
423
- break;
424
- }
425
- const toolCalls = assistantContent.filter((block) => block.type === "tool-call");
426
- const results = [];
427
- for (const call of toolCalls) {
428
- if (signal.aborted)
429
- break;
430
- const tool = toolByName.get(call.toolName);
431
- const result = await runTool(tool, call, ctx);
432
- results.push(result);
433
- emit({
434
- type: "tool-result",
435
- toolCallId: result.toolCallId,
436
- toolName: result.toolName,
437
- output: result.output,
438
- isError: typeof result.output === "string" ? result.output.startsWith("Error:") : false
439
- });
440
- }
441
- state.messages.push({ role: "tool", content: results });
442
- if (signal.aborted)
443
- break;
444
- }
445
- }
446
- async function runTool(tool, call, ctx) {
447
- if (!tool) {
448
- return {
449
- type: "tool-result",
450
- toolCallId: call.toolCallId,
451
- toolName: call.toolName,
452
- output: `Error: tool "${call.toolName}" not found`
453
- };
454
- }
455
- try {
456
- const parsed = tool.parse(call.input);
457
- const output = await tool.execute(parsed, ctx);
458
- return {
459
- type: "tool-result",
460
- toolCallId: call.toolCallId,
461
- toolName: call.toolName,
462
- output
463
- };
464
- } catch (error) {
465
- return {
466
- type: "tool-result",
467
- toolCallId: call.toolCallId,
468
- toolName: call.toolName,
469
- output: `Error: ${error instanceof Error ? error.message : String(error)}`
470
- };
471
- }
472
- }
473
-
474
- // src/core/model.ts
475
- class Model {
476
- async createMessage(params) {
477
- const content = [];
478
- let id = "";
479
- let stopReason = "end_turn";
480
- let usage = { inputTokens: 0, outputTokens: 0 };
481
- for await (const event of this.streamMessage(params)) {
482
- switch (event.type) {
483
- case "text-end":
484
- content.push({ type: "text", text: event.text });
485
- break;
486
- case "thinking-end":
487
- content.push({
488
- type: "thinking",
489
- text: event.text,
490
- ...event.signature !== undefined ? { signature: event.signature } : {},
491
- ...event.redactedData !== undefined ? { redactedData: event.redactedData } : {}
492
- });
493
- break;
494
- case "tool-call":
495
- content.push({
496
- type: "tool-call",
497
- toolCallId: event.toolCallId,
498
- toolName: event.toolName,
499
- input: event.input
500
- });
501
- break;
502
- case "message-end":
503
- id = event.id;
504
- stopReason = event.stopReason;
505
- usage = event.usage;
506
- break;
507
- default:
508
- break;
509
- }
324
+ async refresh() {
325
+ if (!this.tokens?.refreshToken || !this.tokens.clientId) {
326
+ throw new Error("OAuth refresh: missing refresh token or client id.");
510
327
  }
511
- return { id, content, stopReason, usage };
512
- }
513
- }
514
-
515
- // src/core/skill.ts
516
- class Skill {
517
- name;
518
- description;
519
- content;
520
- constructor({
521
- name,
522
- description,
523
- content
524
- }) {
525
- this.name = name;
526
- this.description = description;
527
- this.content = content;
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
345
+ };
528
346
  }
529
347
  }
530
-
531
- // src/mcp/oauth.ts
532
- function base64UrlEncode(bytes) {
533
- let str = "";
534
- for (const byte of bytes)
535
- str += String.fromCharCode(byte);
536
- return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
537
- }
538
- function randomBytes(length) {
539
- const bytes = new Uint8Array(length);
540
- crypto.getRandomValues(bytes);
541
- return bytes;
542
- }
543
- async function sha256(input) {
544
- const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
545
- return new Uint8Array(hash);
546
- }
547
- async function generatePkce() {
548
- const verifier = base64UrlEncode(randomBytes(32));
549
- const challenge = base64UrlEncode(await sha256(verifier));
550
- return { verifier, challenge };
551
- }
552
- function generateState() {
553
- return base64UrlEncode(randomBytes(16));
554
- }
555
- async function discover(serverUrl) {
556
- const u = new URL(serverUrl);
557
- const origin = `${u.protocol}//${u.host}`;
558
- let authServer = origin;
559
- try {
560
- const res2 = await fetch(`${origin}/.well-known/oauth-protected-resource`);
561
- if (res2.ok) {
562
- const meta = await res2.json();
563
- if (meta.authorization_servers?.[0])
564
- authServer = meta.authorization_servers[0];
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);
565
379
  }
566
- } catch {}
567
- const asUrl = new URL(authServer);
568
- const asOrigin = `${asUrl.protocol}//${asUrl.host}`;
569
- const res = await fetch(`${asOrigin}/.well-known/oauth-authorization-server`);
570
- if (!res.ok) {
571
- throw new Error(`OAuth discovery failed for ${asOrigin}: ${res.status} ${await res.text()}`);
572
380
  }
573
- return await res.json();
574
- }
575
- async function registerClient(registrationEndpoint, redirectUri) {
576
- const res = await fetch(registrationEndpoint, {
577
- method: "POST",
578
- headers: { "content-type": "application/json" },
579
- body: JSON.stringify({
580
- client_name: "roboport",
581
- redirect_uris: [redirectUri],
582
- grant_types: ["authorization_code", "refresh_token"],
583
- response_types: ["code"],
584
- token_endpoint_auth_method: "none"
585
- })
586
- });
587
- if (!res.ok) {
588
- throw new Error(`OAuth client registration failed: ${res.status} ${await res.text()}`);
381
+ close() {
382
+ this.ended = true;
383
+ while (this.waiters.length > 0) {
384
+ const waiter = this.waiters.shift();
385
+ waiter?.({ value: undefined, done: true });
386
+ }
589
387
  }
590
- const data = await res.json();
591
- return data.client_id;
592
- }
593
- function openBrowser(url) {
594
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
595
- Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
596
- }
597
- function captureAuthorizationCode(port, expectedState, timeoutMs) {
598
- return new Promise((resolve, reject) => {
599
- const server = Bun.serve({
600
- port,
601
- hostname: "127.0.0.1",
602
- fetch(req) {
603
- const u = new URL(req.url);
604
- const error = u.searchParams.get("error");
605
- if (error) {
606
- const desc = u.searchParams.get("error_description") ?? "";
607
- finish(() => reject(new Error(`OAuth error: ${error} ${desc}`)));
608
- return new Response("Authentication failed. You can close this tab.", {
609
- status: 400
610
- });
388
+ abort(reason) {
389
+ this.abortController.abort(reason);
390
+ }
391
+ [Symbol.asyncIterator]() {
392
+ if (this.iterated) {
393
+ throw new Error("Turn can only be iterated once.");
394
+ }
395
+ this.iterated = true;
396
+ return {
397
+ next: () => {
398
+ if (this.queue.length > 0) {
399
+ const value = this.queue.shift();
400
+ return Promise.resolve({ value, done: false });
611
401
  }
612
- const code = u.searchParams.get("code");
613
- const state = u.searchParams.get("state");
614
- if (!code || state !== expectedState) {
615
- finish(() => reject(new Error("OAuth state mismatch or missing code.")));
616
- return new Response("Invalid OAuth response.", { status: 400 });
402
+ if (this.ended) {
403
+ return Promise.resolve({
404
+ value: undefined,
405
+ done: true
406
+ });
617
407
  }
618
- finish(() => resolve(code));
619
- return new Response("<html><body>Authenticated. You can close this tab.</body></html>", { headers: { "content-type": "text/html" } });
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
+ };
620
417
  }
621
- });
622
- const timer = setTimeout(() => {
623
- finish(() => reject(new Error("OAuth flow timed out.")));
624
- }, timeoutMs);
625
- function finish(action) {
626
- clearTimeout(timer);
627
- setTimeout(() => server.stop(true), 50);
628
- action();
629
- }
630
- });
631
- }
632
- async function exchangeCode(opts) {
633
- const body = new URLSearchParams({
634
- grant_type: "authorization_code",
635
- code: opts.code,
636
- redirect_uri: opts.redirectUri,
637
- client_id: opts.clientId,
638
- code_verifier: opts.verifier
639
- });
640
- const res = await fetch(opts.tokenEndpoint, {
641
- method: "POST",
642
- headers: { "content-type": "application/x-www-form-urlencoded" },
643
- body
644
- });
645
- if (!res.ok) {
646
- throw new Error(`OAuth token exchange failed: ${res.status} ${await res.text()}`);
418
+ };
647
419
  }
648
- return await res.json();
649
- }
650
- async function refreshTokens(opts) {
651
- const body = new URLSearchParams({
652
- grant_type: "refresh_token",
653
- refresh_token: opts.refreshToken,
654
- client_id: opts.clientId
655
- });
656
- const res = await fetch(opts.tokenEndpoint, {
657
- method: "POST",
658
- headers: { "content-type": "application/x-www-form-urlencoded" },
659
- body
660
- });
661
- if (!res.ok) {
662
- throw new Error(`OAuth refresh failed: ${res.status} ${await res.text()}`);
420
+ then(onfulfilled, onrejected) {
421
+ return this.resultPromise.then(onfulfilled, onrejected);
663
422
  }
664
- return await res.json();
665
423
  }
666
424
 
667
- // src/mcp/storage.ts
668
- import { chmod, mkdir, readFile, writeFile } from "fs/promises";
669
- import { homedir } from "os";
670
- import { dirname, join } from "path";
671
- var DEFAULT_PATH = join(homedir(), ".roboport", "mcp-auth.json");
672
-
673
- class FileStorage {
674
- path;
675
- cache;
676
- constructor(path) {
677
- this.path = path ?? DEFAULT_PATH;
678
- }
679
- async read() {
680
- if (this.cache)
681
- return this.cache;
682
- try {
683
- const raw = await readFile(this.path, "utf8");
684
- this.cache = JSON.parse(raw);
685
- } catch {
686
- this.cache = {};
687
- }
688
- return this.cache;
425
+ class Session {
426
+ internals;
427
+ state;
428
+ constructor(internals, state) {
429
+ this.internals = internals;
430
+ this.state = state;
689
431
  }
690
- async write(data) {
691
- await mkdir(dirname(this.path), { recursive: true });
692
- await writeFile(this.path, JSON.stringify(data, null, 2), "utf8");
693
- await chmod(this.path, 384);
694
- this.cache = data;
432
+ get messages() {
433
+ return this.state.messages;
695
434
  }
696
- async load(key) {
697
- const data = await this.read();
698
- return data[key] ?? null;
435
+ send(prompt) {
436
+ return this.internals.send(prompt);
699
437
  }
700
- async save(key, tokens) {
701
- const data = await this.read();
702
- data[key] = tokens;
703
- await this.write(data);
438
+ async close() {
439
+ await this.internals.close();
704
440
  }
705
- async clear(key) {
706
- const data = await this.read();
707
- delete data[key];
708
- await this.write(data);
441
+ async[Symbol.asyncDispose]() {
442
+ await this.close();
709
443
  }
710
444
  }
711
445
 
712
- class MemoryStorage {
713
- data = new Map;
714
- async load(key) {
715
- return this.data.get(key) ?? null;
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";
450
+ }
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;
469
+ }
716
470
  }
717
- async save(key, tokens) {
718
- this.data.set(key, tokens);
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);
719
478
  }
720
- async clear(key) {
721
- this.data.delete(key);
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);
722
486
  }
723
487
  }
724
-
725
- // src/mcp/auth.ts
726
- class BearerAuth {
727
- token;
728
- constructor(token) {
729
- this.token = token;
730
- }
731
- async getHeader() {
732
- return `Bearer ${this.token}`;
733
- }
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;
502
+ }
503
+ loadedNames.add(name);
504
+ loaded.push(tool);
505
+ }
506
+ return { loaded, missing };
507
+ }
508
+ };
734
509
  }
735
- var DEFAULT_PORT = 33418;
736
- var DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
737
510
 
738
- class OAuthAuth {
739
- serverUrl;
740
- storage;
741
- storageKey;
742
- redirectPort;
743
- scopes;
744
- flowTimeoutMs;
745
- tokens;
746
- loaded = false;
747
- metadata;
748
- inFlight;
749
- constructor(opts) {
750
- this.serverUrl = opts.serverUrl;
751
- this.storage = opts.storage ?? new FileStorage;
752
- this.storageKey = opts.storageKey;
753
- this.redirectPort = opts.redirectPort ?? DEFAULT_PORT;
754
- this.scopes = opts.scopes;
755
- this.flowTimeoutMs = opts.flowTimeoutMs ?? DEFAULT_TIMEOUT_MS;
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;
756
535
  }
757
- async getHeader() {
758
- await this.ensureTokens();
759
- if (!this.tokens)
760
- throw new Error("OAuth: no tokens after auth flow.");
761
- return `Bearer ${this.tokens.accessToken}`;
536
+ on(trigger, handler) {
537
+ this.registrations.push({ trigger, handler });
762
538
  }
763
- async onUnauthorized() {
764
- if (this.tokens?.refreshToken) {
765
- try {
766
- await this.refresh();
767
- return;
768
- } catch {}
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);
769
548
  }
770
- this.tokens = undefined;
771
- await this.storage.clear(this.storageKey);
772
- await this.authorize();
773
549
  }
774
- async ensureTokens() {
775
- if (this.inFlight) {
776
- await this.inFlight;
777
- return;
550
+ async stop() {
551
+ const unsubs = this.unsubs;
552
+ this.unsubs = [];
553
+ await Promise.all(unsubs.map((u) => u()));
554
+ }
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}
561
+
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.
564
+
565
+ ${skillsList}`;
778
566
  }
779
- this.inFlight = (async () => {
780
- if (!this.loaded) {
781
- this.tokens = await this.storage.load(this.storageKey) ?? undefined;
782
- this.loaded = true;
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}
572
+
573
+ # Deferred tools
574
+ These tools are available but their schemas are not loaded. Use ToolSearch to load them before calling.
575
+ ${list}`;
576
+ }
577
+ if (systemExtension) {
578
+ system = `${system}
579
+
580
+ ${systemExtension}`;
581
+ }
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>`;
783
601
  }
784
- if (!this.tokens) {
785
- await this.authorize();
786
- 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
+ }
787
649
  }
788
- if (this.isExpired(this.tokens)) {
789
- 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) => {
790
658
  try {
791
- await this.refresh();
792
- return;
793
- } 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;
794
686
  }
795
- await this.authorize();
796
687
  }
797
- })();
798
- try {
799
- await this.inFlight;
800
- } finally {
801
- this.inFlight = undefined;
802
- }
803
- }
804
- isExpired(tokens) {
805
- if (!tokens.expiresAt)
806
- return false;
807
- return Date.now() / 1000 >= tokens.expiresAt - 30;
808
- }
809
- async getMetadata() {
810
- if (!this.metadata)
811
- this.metadata = await discover(this.serverUrl);
812
- return this.metadata;
688
+ };
689
+ return new Session(internals, state);
813
690
  }
814
- async authorize() {
815
- const meta = await this.getMetadata();
816
- const redirectUri = this.tokens?.redirectUri ?? `http://127.0.0.1:${this.redirectPort}/callback`;
817
- let clientId = this.tokens?.clientId;
818
- if (!clientId) {
819
- if (!meta.registration_endpoint) {
820
- 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
+ }
821
765
  }
822
- 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;
823
774
  }
824
- const { verifier, challenge } = await generatePkce();
825
- const state = generateState();
826
- const port = parseInt(new URL(redirectUri).port, 10);
827
- const codePromise = captureAuthorizationCode(port, state, this.flowTimeoutMs);
828
- const authUrl = new URL(meta.authorization_endpoint);
829
- authUrl.searchParams.set("response_type", "code");
830
- authUrl.searchParams.set("client_id", clientId);
831
- authUrl.searchParams.set("redirect_uri", redirectUri);
832
- authUrl.searchParams.set("code_challenge", challenge);
833
- authUrl.searchParams.set("code_challenge_method", "S256");
834
- authUrl.searchParams.set("state", state);
835
- if (this.scopes?.length) {
836
- 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;
837
780
  }
838
- console.error(`[mcp:oauth] Opening browser for ${this.storageKey} authentication...`);
839
- openBrowser(authUrl.toString());
840
- const code = await codePromise;
841
- const response = await exchangeCode({
842
- tokenEndpoint: meta.token_endpoint,
843
- code,
844
- clientId,
845
- redirectUri,
846
- verifier
847
- });
848
- this.tokens = this.toTokenSet(response, clientId, redirectUri);
849
- await this.storage.save(this.storageKey, this.tokens);
850
- }
851
- async refresh() {
852
- if (!this.tokens?.refreshToken || !this.tokens.clientId) {
853
- 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
+ });
854
796
  }
855
- const meta = await this.getMetadata();
856
- const response = await refreshTokens({
857
- tokenEndpoint: meta.token_endpoint,
858
- refreshToken: this.tokens.refreshToken,
859
- clientId: this.tokens.clientId
860
- });
861
- this.tokens = this.toTokenSet(response, this.tokens.clientId, this.tokens.redirectUri, this.tokens.refreshToken);
862
- await this.storage.save(this.storageKey, this.tokens);
797
+ state.messages.push({ role: "tool", content: results });
798
+ if (signal.aborted)
799
+ break;
863
800
  }
864
- toTokenSet(response, clientId, redirectUri, fallbackRefresh) {
865
- 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) {
866
804
  return {
867
- accessToken: response.access_token,
868
- refreshToken: response.refresh_token ?? fallbackRefresh,
869
- expiresAt,
870
- clientId,
871
- 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)}`
872
826
  };
873
827
  }
874
828
  }
875
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
+
876
887
  // src/mcp/clients/grafana.ts
877
888
  var EMPTY_OBJECT = {
878
889
  type: "object",
@@ -1263,6 +1274,9 @@ class Mcp2 {
1263
1274
  transport,
1264
1275
  deferred
1265
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
+ }
1266
1280
  this.name = name;
1267
1281
  this.transport = transport;
1268
1282
  this.deferred = deferred ?? true;
@@ -1351,6 +1365,11 @@ class Mcp4 extends Mcp2 {
1351
1365
  var tenderly_default = Mcp4;
1352
1366
  export {
1353
1367
  tenderly_default as Tenderly,
1368
+ OAuthAuth,
1369
+ MemoryStorage,
1370
+ Mcp2 as Mcp,
1354
1371
  linear_default as Linear,
1355
- grafana_default as Grafana
1372
+ grafana_default as Grafana,
1373
+ FileStorage,
1374
+ BearerAuth
1356
1375
  };