@flint-dev/sdk 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@flint-dev/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "TypeScript client SDK for Flint app servers",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Escalona",
7
7
  "files": [
8
- "dist"
8
+ "dist",
9
+ "src"
9
10
  ],
10
11
  "type": "module",
11
12
  "main": "dist/index.js",
@@ -13,6 +14,7 @@
13
14
  "exports": {
14
15
  ".": {
15
16
  "types": "./dist/index.d.ts",
17
+ "bun": "./src/index.ts",
16
18
  "import": "./dist/index.js"
17
19
  }
18
20
  },
@@ -0,0 +1,524 @@
1
+ /**
2
+ * App Server Client
3
+ *
4
+ * Spawns an app-server-compatible child process and communicates
5
+ * over stdio using JSON-RPC 2.0. Translates app-server notifications
6
+ * to AgentEvent for TUI consumption.
7
+ */
8
+
9
+ import type { AgentEvent } from "./types";
10
+
11
+ interface JsonRpcRequest {
12
+ id: number;
13
+ method: string;
14
+ params?: unknown;
15
+ }
16
+
17
+ interface JsonRpcResponse {
18
+ id: number;
19
+ result?: unknown;
20
+ error?: { code: number; message: string; data?: unknown };
21
+ }
22
+
23
+ interface JsonRpcNotification {
24
+ method: string;
25
+ params?: unknown;
26
+ }
27
+
28
+ type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification;
29
+
30
+ export interface AppServerClientOptions {
31
+ /** Command to spawn the app server */
32
+ command: string;
33
+ /** Arguments to pass to the command */
34
+ args?: string[];
35
+ /** Working directory for the app server and Claude SDK */
36
+ cwd: string;
37
+ /** Environment variables for the app server process */
38
+ env?: Record<string, string>;
39
+ }
40
+
41
+ export interface CreateThreadOptions {
42
+ /** Model to use for the thread (e.g. "claude-sonnet-4-5-20250929") */
43
+ model?: string;
44
+ /** Optional provider-specific system prompt override */
45
+ systemPrompt?: string;
46
+ /** Optional MCP server config (provider-specific) */
47
+ mcpServers?: Record<string, unknown>;
48
+ }
49
+
50
+ export interface PromptOptions {
51
+ /** Model override for this turn */
52
+ model?: string;
53
+ }
54
+
55
+ export interface ResumeThreadOptions {
56
+ /** Override working directory when resuming */
57
+ cwd?: string;
58
+ /** Override model when resuming */
59
+ model?: string;
60
+ /** Optional MCP server config (provider-specific) */
61
+ mcpServers?: Record<string, unknown>;
62
+ }
63
+
64
+ export class AppServerClient {
65
+ private stdin: import("bun").FileSink | null = null;
66
+ private stdout: ReadableStream<Uint8Array> | null = null;
67
+ private stderr: ReadableStream<Uint8Array> | null = null;
68
+ private proc: ReturnType<typeof Bun.spawn> | null = null;
69
+ private nextId = 1;
70
+ private pendingRequests = new Map<
71
+ number,
72
+ { resolve: (value: unknown) => void; reject: (error: Error) => void }
73
+ >();
74
+ private notificationListeners: Array<(notification: JsonRpcNotification) => void> = [];
75
+ private buffer = "";
76
+ private stderrBuffer = "";
77
+ private stderrHistory: string[] = [];
78
+ private isClosing = false;
79
+ private initialized = false;
80
+ private threadId: string | null = null;
81
+ private currentTurnId: string | null = null;
82
+ private readonly command: string;
83
+ private readonly args: string[];
84
+ private readonly cwd: string;
85
+ private readonly env: Record<string, string> | undefined;
86
+
87
+ constructor(options: AppServerClientOptions) {
88
+ this.command = options.command;
89
+ this.args = options.args ?? [];
90
+ this.cwd = options.cwd;
91
+ this.env = options.env;
92
+ }
93
+
94
+ /** Start the app server process and initialize it. */
95
+ async start(): Promise<void> {
96
+ this.isClosing = false;
97
+ const proc = Bun.spawn([this.command, ...this.args], {
98
+ stdin: "pipe",
99
+ stdout: "pipe",
100
+ stderr: "pipe",
101
+ cwd: this.cwd,
102
+ ...(this.env && { env: { ...process.env, ...this.env } }),
103
+ });
104
+ this.proc = proc;
105
+ this.stdin = proc.stdin as import("bun").FileSink;
106
+ this.stdout = proc.stdout as ReadableStream<Uint8Array>;
107
+ this.stderr = proc.stderr as ReadableStream<Uint8Array>;
108
+
109
+ // Read stdout in background
110
+ this.readStdout();
111
+ this.readStderr();
112
+ this.watchProcessExit(proc);
113
+
114
+ // Initialize the server
115
+ await this.request("initialize", {
116
+ clientInfo: {
117
+ name: "flint-tui",
118
+ version: "0.1.0",
119
+ },
120
+ });
121
+
122
+ // Send initialized notification (Codex protocol)
123
+ this.notify("initialized");
124
+ this.initialized = true;
125
+ }
126
+
127
+ /** Create a new thread. Returns the thread ID. */
128
+ async createThread(options?: CreateThreadOptions): Promise<string> {
129
+ const result = (await this.request("thread/start", {
130
+ cwd: this.cwd,
131
+ ...(options?.model && { model: options.model }),
132
+ ...(options?.systemPrompt && { systemPrompt: options.systemPrompt }),
133
+ ...(options?.mcpServers && { mcpServers: options.mcpServers }),
134
+ })) as { thread: { id: string } };
135
+ this.threadId = result.thread.id;
136
+ return this.threadId;
137
+ }
138
+
139
+ /** Resume an existing thread. Returns the thread ID. */
140
+ async resumeThread(threadId: string, options?: ResumeThreadOptions): Promise<string> {
141
+ const result = (await this.request("thread/resume", {
142
+ threadId,
143
+ ...(options?.cwd && { cwd: options.cwd }),
144
+ ...(options?.model && { model: options.model }),
145
+ ...(options?.mcpServers && { mcpServers: options.mcpServers }),
146
+ })) as { thread: { id: string } };
147
+ this.threadId = result.thread.id;
148
+ return this.threadId;
149
+ }
150
+
151
+ /** Get the active thread ID (if any). */
152
+ getThreadId(): string | null {
153
+ return this.threadId;
154
+ }
155
+
156
+ /** Send a prompt and yield AgentEvents as they stream in. */
157
+ async *prompt(prompt: string, options?: PromptOptions): AsyncGenerator<AgentEvent> {
158
+ if (!this.threadId) {
159
+ await this.createThread();
160
+ }
161
+
162
+ // Set up notification listener before sending request
163
+ const events: AgentEvent[] = [];
164
+ let resolve: (() => void) | null = null;
165
+ let done = false;
166
+
167
+ const listener = (notification: JsonRpcNotification) => {
168
+ const translated = this.translateNotification(notification);
169
+ for (const event of translated) {
170
+ events.push(event);
171
+ resolve?.();
172
+ }
173
+
174
+ // Check for terminal notifications
175
+ if (notification.method === "turn/completed") {
176
+ done = true;
177
+ resolve?.();
178
+ }
179
+ };
180
+
181
+ this.notificationListeners.push(listener);
182
+
183
+ try {
184
+ // Start the turn
185
+ await this.request("turn/start", {
186
+ threadId: this.threadId,
187
+ input: [{ type: "text", text: prompt }],
188
+ ...(options?.model && { model: options.model }),
189
+ });
190
+
191
+ // Yield events as they come in
192
+ while (!done) {
193
+ if (events.length === 0) {
194
+ await new Promise<void>((r) => {
195
+ resolve = r;
196
+ });
197
+ }
198
+ while (events.length > 0) {
199
+ yield events.shift()!;
200
+ }
201
+ }
202
+ // Drain remaining
203
+ while (events.length > 0) {
204
+ yield events.shift()!;
205
+ }
206
+ } finally {
207
+ const idx = this.notificationListeners.indexOf(listener);
208
+ if (idx !== -1) this.notificationListeners.splice(idx, 1);
209
+ }
210
+ }
211
+
212
+ /** Interrupt the current turn. */
213
+ async interrupt(): Promise<void> {
214
+ if (!this.threadId || !this.currentTurnId) return;
215
+ try {
216
+ await this.request("turn/interrupt", {
217
+ threadId: this.threadId,
218
+ turnId: this.currentTurnId,
219
+ });
220
+ } catch {
221
+ // May fail if no turn in progress
222
+ }
223
+ }
224
+
225
+ /** Stop the app server process. */
226
+ close(): void {
227
+ this.isClosing = true;
228
+ if (this.proc) {
229
+ try {
230
+ this.stdin?.end();
231
+ } catch {
232
+ // Already closed
233
+ }
234
+ this.proc.kill();
235
+ this.proc = null;
236
+ this.stdin = null;
237
+ this.stdout = null;
238
+ this.stderr = null;
239
+ }
240
+ this.initialized = false;
241
+ this.threadId = null;
242
+ this.currentTurnId = null;
243
+ }
244
+
245
+ // ── Private ──────────────────────────────────────────────────────────────
246
+
247
+ private async request(method: string, params?: unknown): Promise<unknown> {
248
+ if (!this.stdin) throw new Error("App server not started");
249
+
250
+ const id = this.nextId++;
251
+ const request: JsonRpcRequest = {
252
+ id,
253
+ method,
254
+ ...(params !== undefined && { params }),
255
+ };
256
+
257
+ if (process.env.DEBUG) {
258
+ console.error(`[sdk] → ${method}`, params ? JSON.stringify(params) : "");
259
+ }
260
+
261
+ return new Promise<unknown>((resolve, reject) => {
262
+ this.pendingRequests.set(id, { resolve, reject });
263
+ this.stdin!.write(JSON.stringify(request) + "\n");
264
+ });
265
+ }
266
+
267
+ private notify(method: string, params?: unknown): void {
268
+ if (!this.stdin) throw new Error("App server not started");
269
+ const notification: JsonRpcNotification = {
270
+ method,
271
+ ...(params !== undefined && { params }),
272
+ };
273
+ this.stdin.write(JSON.stringify(notification) + "\n");
274
+ }
275
+
276
+ private async readStdout(): Promise<void> {
277
+ if (!this.stdout) return;
278
+
279
+ const reader = this.stdout.getReader();
280
+ const decoder = new TextDecoder();
281
+
282
+ try {
283
+ while (true) {
284
+ const { done, value } = await reader.read();
285
+ if (done) break;
286
+
287
+ this.buffer += decoder.decode(value, { stream: true });
288
+
289
+ let newlineIdx: number;
290
+ while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
291
+ const line = this.buffer.slice(0, newlineIdx);
292
+ this.buffer = this.buffer.slice(newlineIdx + 1);
293
+
294
+ if (line.trim()) {
295
+ this.handleMessage(line);
296
+ }
297
+ }
298
+ }
299
+ } catch {
300
+ // Process exited
301
+ }
302
+ }
303
+
304
+ private async readStderr(): Promise<void> {
305
+ if (!this.stderr) return;
306
+
307
+ const reader = this.stderr.getReader();
308
+ const decoder = new TextDecoder();
309
+
310
+ try {
311
+ while (true) {
312
+ const { done, value } = await reader.read();
313
+ if (done) break;
314
+
315
+ this.stderrBuffer += decoder.decode(value, { stream: true });
316
+
317
+ let newlineIdx: number;
318
+ while ((newlineIdx = this.stderrBuffer.indexOf("\n")) !== -1) {
319
+ const line = this.stderrBuffer.slice(0, newlineIdx);
320
+ this.stderrBuffer = this.stderrBuffer.slice(newlineIdx + 1);
321
+ this.pushStderrLine(line);
322
+ }
323
+ }
324
+ } catch {
325
+ // Process exited
326
+ }
327
+
328
+ if (this.stderrBuffer.trim()) {
329
+ this.pushStderrLine(this.stderrBuffer);
330
+ this.stderrBuffer = "";
331
+ }
332
+ }
333
+
334
+ private pushStderrLine(line: string): void {
335
+ const trimmed = line.trim();
336
+ if (!trimmed) return;
337
+
338
+ this.stderrHistory.push(trimmed);
339
+ if (this.stderrHistory.length > 60) {
340
+ this.stderrHistory.shift();
341
+ }
342
+
343
+ if (process.env.DEBUG) {
344
+ console.error(`[sdk][${this.command} stderr] ${trimmed}`);
345
+ }
346
+ }
347
+
348
+ private stderrTail(maxLines = 8): string {
349
+ return this.stderrHistory.slice(-maxLines).join("\n");
350
+ }
351
+
352
+ private async watchProcessExit(proc: ReturnType<typeof Bun.spawn>): Promise<void> {
353
+ const exitCode = await proc.exited;
354
+ if (this.proc !== proc || this.isClosing) return;
355
+
356
+ const tail = this.stderrTail();
357
+ const message = tail
358
+ ? `App server exited with code ${exitCode}\nRecent stderr:\n${tail}`
359
+ : `App server exited with code ${exitCode}`;
360
+
361
+ const error = new Error(message);
362
+ for (const [, pending] of this.pendingRequests) {
363
+ pending.reject(error);
364
+ }
365
+ this.pendingRequests.clear();
366
+ }
367
+
368
+ private handleMessage(line: string): void {
369
+ let msg: JsonRpcMessage;
370
+ try {
371
+ msg = JSON.parse(line) as JsonRpcMessage;
372
+ } catch {
373
+ return;
374
+ }
375
+
376
+ // Response (has id)
377
+ if ("id" in msg && msg.id != null) {
378
+ const response = msg as JsonRpcResponse;
379
+ const pending = this.pendingRequests.get(response.id);
380
+ if (pending) {
381
+ this.pendingRequests.delete(response.id);
382
+ if (response.error) {
383
+ pending.reject(new Error(response.error.message));
384
+ } else {
385
+ pending.resolve(response.result);
386
+ }
387
+ }
388
+ return;
389
+ }
390
+
391
+ // Notification (no id)
392
+ const notification = msg as JsonRpcNotification;
393
+ for (const listener of this.notificationListeners) {
394
+ listener(notification);
395
+ }
396
+ }
397
+
398
+ /** Translate app-server JSON-RPC notifications to AgentEvents. */
399
+ private translateNotification(notification: JsonRpcNotification): AgentEvent[] {
400
+ const events: AgentEvent[] = [];
401
+ const params = (notification.params ?? {}) as Record<string, unknown>;
402
+
403
+ switch (notification.method) {
404
+ case "item/agentMessage/delta": {
405
+ events.push({ type: "text", delta: params.delta as string });
406
+ break;
407
+ }
408
+
409
+ case "item/reasoning/textDelta": {
410
+ events.push({ type: "reasoning", delta: params.delta as string });
411
+ break;
412
+ }
413
+
414
+ case "item/started": {
415
+ const item = params.item as Record<string, unknown>;
416
+ if (!item) break;
417
+
418
+ const itemType = item.type as string;
419
+ const itemId = item.id as string;
420
+
421
+ if (itemType === "commandExecution") {
422
+ events.push({
423
+ type: "tool_start",
424
+ id: itemId,
425
+ name: "Bash",
426
+ input: { command: item.command, cwd: item.cwd },
427
+ });
428
+ } else if (itemType === "fileChange") {
429
+ const changes = item.changes as Array<{ path: string; kind: { type: string } }>;
430
+ const firstChange = changes?.[0];
431
+ const kindType = firstChange?.kind?.type;
432
+ const toolName = kindType === "add" ? "Write" : "Edit";
433
+ events.push({
434
+ type: "tool_start",
435
+ id: itemId,
436
+ name: toolName,
437
+ input: { file_path: firstChange?.path },
438
+ });
439
+ } else if (itemType === "mcpToolCall") {
440
+ events.push({
441
+ type: "tool_start",
442
+ id: itemId,
443
+ name: String(item.tool ?? "tool"),
444
+ input: item.arguments,
445
+ });
446
+ }
447
+ break;
448
+ }
449
+
450
+ case "item/completed": {
451
+ const item = params.item as Record<string, unknown>;
452
+ if (!item) break;
453
+
454
+ const itemId = item.id as string;
455
+ const itemType = item.type as string;
456
+
457
+ if (itemType === "commandExecution") {
458
+ const exitCode = item.exitCode as number | undefined;
459
+ events.push({
460
+ type: "tool_end",
461
+ id: itemId,
462
+ result: item.aggregatedOutput,
463
+ isError: (exitCode ?? 0) !== 0,
464
+ });
465
+ } else if (itemType === "fileChange") {
466
+ events.push({
467
+ type: "tool_end",
468
+ id: itemId,
469
+ result: undefined,
470
+ isError: false,
471
+ });
472
+ } else if (itemType === "mcpToolCall") {
473
+ events.push({
474
+ type: "tool_end",
475
+ id: itemId,
476
+ result: item.result,
477
+ isError: false,
478
+ });
479
+ }
480
+ break;
481
+ }
482
+
483
+ case "turn/started": {
484
+ const turn = params.turn as Record<string, unknown>;
485
+ if (turn) {
486
+ this.currentTurnId = turn.id as string;
487
+ }
488
+ break;
489
+ }
490
+
491
+ case "turn/completed": {
492
+ const turn = params.turn as Record<string, unknown>;
493
+ if (turn) {
494
+ const status = turn.status as string;
495
+ if (status === "failed") {
496
+ const error = turn.error as { message: string } | undefined;
497
+ events.push({
498
+ type: "error",
499
+ message: error?.message ?? "Unknown error",
500
+ });
501
+ } else {
502
+ events.push({ type: "done" });
503
+ }
504
+ } else {
505
+ events.push({ type: "done" });
506
+ }
507
+ this.currentTurnId = null;
508
+ break;
509
+ }
510
+
511
+ case "error": {
512
+ // Separate error notification — already handled via turn/completed
513
+ break;
514
+ }
515
+
516
+ // Ignored delta notifications that we don't translate to AgentEvent
517
+ case "item/commandExecution/outputDelta":
518
+ case "item/fileChange/outputDelta":
519
+ break;
520
+ }
521
+
522
+ return events;
523
+ }
524
+ }
@@ -0,0 +1,13 @@
1
+ import { AppServerClient, type AppServerClientOptions } from "./app-server-client";
2
+ import { getProvider, type ProviderConfig } from "./providers";
3
+
4
+ export type CreateClientOptions = ({ provider: string } & ProviderConfig) | AppServerClientOptions;
5
+
6
+ export function createClient(options: CreateClientOptions): AppServerClient {
7
+ if ("provider" in options) {
8
+ const { provider, ...config } = options;
9
+ const resolved = getProvider(provider).resolve(config);
10
+ return new AppServerClient(resolved);
11
+ }
12
+ return new AppServerClient(options);
13
+ }
@@ -0,0 +1,201 @@
1
+ export type GatewayRoutingMode =
2
+ | "main"
3
+ | "per-peer"
4
+ | "per-channel-peer"
5
+ | "per-account-channel-peer";
6
+
7
+ export type GatewayChatType = "direct" | "group" | "channel";
8
+
9
+ export interface GatewayHealth {
10
+ ok: boolean;
11
+ provider: string;
12
+ defaultRoutingMode: GatewayRoutingMode;
13
+ }
14
+
15
+ export interface GatewayThreadRecord {
16
+ threadId: string;
17
+ routingMode: GatewayRoutingMode;
18
+ provider: string;
19
+ mcpProfileIds?: string[];
20
+ channel: string;
21
+ userId: string;
22
+ chatType: GatewayChatType;
23
+ peerId: string;
24
+ accountId?: string;
25
+ identityId?: string;
26
+ channelThreadId?: string;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ }
30
+
31
+ export interface GatewayCreateThreadRequest {
32
+ channel: string;
33
+ userId: string;
34
+ text: string;
35
+ mcpProfileIds?: string[];
36
+ provider?: string;
37
+ chatType?: GatewayChatType;
38
+ peerId?: string;
39
+ accountId?: string;
40
+ identityId?: string;
41
+ channelThreadId?: string;
42
+ routingMode?: GatewayRoutingMode;
43
+ idempotencyKey?: string;
44
+ }
45
+
46
+ export interface GatewaySendThreadRequest {
47
+ text: string;
48
+ idempotencyKey?: string;
49
+ }
50
+
51
+ export interface GatewayReply {
52
+ threadId: string;
53
+ routingMode: GatewayRoutingMode;
54
+ provider: string;
55
+ reply: string;
56
+ durationMs?: number;
57
+ idempotencyKey?: string;
58
+ cached?: boolean;
59
+ }
60
+
61
+ export interface GatewayClientOptions {
62
+ baseUrl: string;
63
+ headers?: ConstructorParameters<typeof Headers>[0];
64
+ fetch?: typeof fetch;
65
+ }
66
+
67
+ export class GatewayHttpError extends Error {
68
+ readonly status: number;
69
+ readonly body: unknown;
70
+
71
+ constructor(status: number, body: unknown) {
72
+ const errorMessage =
73
+ body && typeof body === "object" && "error" in body && typeof body.error === "string"
74
+ ? body.error
75
+ : `Gateway request failed with status ${status}`;
76
+ super(errorMessage);
77
+ this.name = "GatewayHttpError";
78
+ this.status = status;
79
+ this.body = body;
80
+ }
81
+ }
82
+
83
+ export class GatewayClient {
84
+ private readonly baseUrl: string;
85
+ private readonly defaultHeaders: Headers;
86
+ private readonly fetchImpl: typeof fetch;
87
+
88
+ constructor(options: GatewayClientOptions) {
89
+ this.baseUrl = options.baseUrl.trim().replace(/\/+$/, "");
90
+ if (!this.baseUrl) {
91
+ throw new Error("GatewayClient requires a non-empty baseUrl.");
92
+ }
93
+ this.defaultHeaders = new Headers(options.headers);
94
+ this.fetchImpl = options.fetch ?? fetch;
95
+ }
96
+
97
+ async health(): Promise<GatewayHealth> {
98
+ return this.requestJson<GatewayHealth>("GET", "/v1/health");
99
+ }
100
+
101
+ async listThreads(): Promise<GatewayThreadRecord[]> {
102
+ const result = await this.requestJson<{ data: GatewayThreadRecord[] }>("GET", "/v1/threads");
103
+ return result.data;
104
+ }
105
+
106
+ async getThread(threadId: string): Promise<GatewayThreadRecord | undefined> {
107
+ const response = await this.request("GET", `/v1/threads/${encodeURIComponent(threadId)}`);
108
+ if (response.status === 404) {
109
+ return undefined;
110
+ }
111
+ return this.unwrapData<GatewayThreadRecord>(response);
112
+ }
113
+
114
+ async createThread(
115
+ payload: GatewayCreateThreadRequest,
116
+ idempotencyKey?: string,
117
+ ): Promise<GatewayReply> {
118
+ return this.requestJson<GatewayReply>("POST", "/v1/threads", payload, idempotencyKey);
119
+ }
120
+
121
+ async sendThreadMessage(
122
+ threadId: string,
123
+ payload: GatewaySendThreadRequest | string,
124
+ idempotencyKey?: string,
125
+ ): Promise<GatewayReply> {
126
+ const body = typeof payload === "string" ? { text: payload } : payload;
127
+ return this.requestJson<GatewayReply>(
128
+ "POST",
129
+ `/v1/threads/${encodeURIComponent(threadId)}`,
130
+ body,
131
+ idempotencyKey,
132
+ );
133
+ }
134
+
135
+ async interruptThread(threadId: string): Promise<boolean> {
136
+ const response = await this.request("POST", `/v1/threads/${encodeURIComponent(threadId)}/interrupt`);
137
+ if (response.status === 409) {
138
+ return false;
139
+ }
140
+ const result = await this.parseJsonOrThrow<{ interrupted: boolean }>(response);
141
+ return result.interrupted;
142
+ }
143
+
144
+ private async unwrapData<T>(response: Response): Promise<T> {
145
+ const parsed = await this.parseJsonOrThrow<{ data?: T }>(response);
146
+ if (!parsed || typeof parsed !== "object" || !("data" in parsed)) {
147
+ throw new Error("Gateway response is missing data.");
148
+ }
149
+ return parsed.data as T;
150
+ }
151
+
152
+ private async requestJson<T>(
153
+ method: string,
154
+ path: string,
155
+ body?: unknown,
156
+ idempotencyKey?: string,
157
+ ): Promise<T> {
158
+ const response = await this.request(method, path, body, idempotencyKey);
159
+ return this.parseJsonOrThrow<T>(response);
160
+ }
161
+
162
+ private async parseJsonOrThrow<T>(response: Response): Promise<T> {
163
+ const text = await response.text();
164
+ let parsed: unknown;
165
+ if (text.length > 0) {
166
+ try {
167
+ parsed = JSON.parse(text) as unknown;
168
+ } catch {
169
+ parsed = undefined;
170
+ }
171
+ }
172
+ if (!response.ok) {
173
+ throw new GatewayHttpError(response.status, parsed);
174
+ }
175
+ return parsed as T;
176
+ }
177
+
178
+ private request(
179
+ method: string,
180
+ path: string,
181
+ body?: unknown,
182
+ idempotencyKey?: string,
183
+ ): Promise<Response> {
184
+ const headers = new Headers(this.defaultHeaders);
185
+ if (idempotencyKey?.trim()) {
186
+ headers.set("idempotency-key", idempotencyKey.trim());
187
+ }
188
+
189
+ const requestInit: RequestInit = {
190
+ method,
191
+ headers,
192
+ };
193
+
194
+ if (body !== undefined) {
195
+ headers.set("content-type", "application/json");
196
+ requestInit.body = JSON.stringify(body);
197
+ }
198
+
199
+ return this.fetchImpl(`${this.baseUrl}${path}`, requestInit);
200
+ }
201
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export {
2
+ AppServerClient,
3
+ type AppServerClientOptions,
4
+ type CreateThreadOptions,
5
+ type PromptOptions,
6
+ type ResumeThreadOptions,
7
+ } from "./app-server-client";
8
+ export type { AgentEvent } from "./types";
9
+ export { createClient, type CreateClientOptions } from "./create-client";
10
+ export { registerProvider, type Provider, type ProviderConfig } from "./providers";
11
+ export {
12
+ GatewayClient,
13
+ GatewayHttpError,
14
+ type GatewayChatType,
15
+ type GatewayClientOptions,
16
+ type GatewayCreateThreadRequest,
17
+ type GatewayHealth,
18
+ type GatewayReply,
19
+ type GatewayRoutingMode,
20
+ type GatewaySendThreadRequest,
21
+ type GatewayThreadRecord,
22
+ } from "./gateway-client";
@@ -0,0 +1,81 @@
1
+ import { join, dirname } from "path";
2
+ import { createRequire } from "module";
3
+ import type { AppServerClientOptions } from "./app-server-client";
4
+
5
+ export interface ProviderConfig {
6
+ cwd: string;
7
+ env?: Record<string, string>;
8
+ }
9
+
10
+ export interface Provider {
11
+ resolve(config: ProviderConfig): AppServerClientOptions;
12
+ }
13
+
14
+ const claudeProvider: Provider = {
15
+ resolve(config) {
16
+ const pkgPath = resolvePackagePath("@flint-dev/claude-app-server/package.json");
17
+ const entry = join(dirname(pkgPath), "src/index.ts");
18
+ return {
19
+ command: "bun",
20
+ args: ["run", entry],
21
+ cwd: config.cwd,
22
+ env: config.env,
23
+ };
24
+ },
25
+ };
26
+
27
+ const codexProvider: Provider = {
28
+ resolve(config) {
29
+ return {
30
+ command: "codex",
31
+ args: ["app-server"],
32
+ cwd: config.cwd,
33
+ env: config.env,
34
+ };
35
+ },
36
+ };
37
+
38
+ const piProvider: Provider = {
39
+ resolve(config) {
40
+ const pkgPath = resolvePackagePath("@flint-dev/pi-app-server/package.json");
41
+ const entry = join(dirname(pkgPath), "src/index.ts");
42
+ return {
43
+ command: "bun",
44
+ args: ["run", entry],
45
+ cwd: config.cwd,
46
+ env: config.env,
47
+ };
48
+ },
49
+ };
50
+
51
+ const builtins = new Map<string, Provider>([
52
+ ["claude", claudeProvider],
53
+ ["codex", codexProvider],
54
+ ["pi", piProvider],
55
+ ]);
56
+
57
+ const custom = new Map<string, Provider>();
58
+
59
+ export function registerProvider(name: string, provider: Provider): void {
60
+ custom.set(name, provider);
61
+ }
62
+
63
+ export function getProvider(name: string): Provider {
64
+ const provider = custom.get(name) ?? builtins.get(name);
65
+ if (!provider) {
66
+ throw new Error(`Unknown provider: "${name}". Register it with registerProvider().`);
67
+ }
68
+ return provider;
69
+ }
70
+
71
+ function resolvePackagePath(specifier: string): string {
72
+ const consumerRequire = createRequire(join(process.cwd(), "__placeholder__.js"));
73
+ try {
74
+ // First try consumer resolution (published package use-case).
75
+ return consumerRequire.resolve(specifier);
76
+ } catch {
77
+ // Fall back to SDK-local resolution (workspace/dev use-case).
78
+ const sdkRequire = createRequire(import.meta.url);
79
+ return sdkRequire.resolve(specifier);
80
+ }
81
+ }
package/src/types.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type AgentEvent =
2
+ | { type: "init"; sessionId: string }
3
+ | { type: "text"; delta: string }
4
+ | { type: "reasoning"; delta: string }
5
+ | { type: "tool_start"; id: string; name: string; input?: unknown; parentId?: string | null }
6
+ | { type: "tool_end"; id: string; result?: unknown; isError: boolean; parentId?: string | null }
7
+ | { type: "done"; usage?: { input: number; output: number } }
8
+ | { type: "error"; message: string };