@lelouchhe/webagent 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ import { spawn } from "node:child_process";
2
+ import { WebSocket, WebSocketServer } from "ws";
3
+ import { WsMessageSchema, errorMessage } from "./types.js";
4
+ function interruptBashProc(proc) {
5
+ if (!proc)
6
+ return;
7
+ if (typeof proc.pid === "number") {
8
+ try {
9
+ process.kill(-proc.pid, "SIGINT");
10
+ return;
11
+ }
12
+ catch {
13
+ // Fall through to direct child kill when the process is not a group leader.
14
+ }
15
+ }
16
+ proc.kill("SIGINT");
17
+ }
18
+ export function broadcast(wss, event, exclude) {
19
+ const msg = JSON.stringify(event);
20
+ for (const client of wss.clients) {
21
+ if (client.readyState === WebSocket.OPEN && client !== exclude) {
22
+ try {
23
+ client.send(msg);
24
+ }
25
+ catch { /* client gone mid-send */ }
26
+ }
27
+ }
28
+ }
29
+ function send(ws, event) {
30
+ if (ws.readyState === WebSocket.OPEN) {
31
+ try {
32
+ ws.send(JSON.stringify(event));
33
+ }
34
+ catch { /* client gone mid-send */ }
35
+ }
36
+ }
37
+ export function setupWsHandler(deps) {
38
+ const { wss, store, sessions, titleService, getBridge, limits } = deps;
39
+ wss.on("connection", (ws) => {
40
+ console.log(`[ws] client connected (total: ${wss.clients.size})`);
41
+ const pingInterval = setInterval(() => {
42
+ if (ws.readyState === WebSocket.OPEN)
43
+ ws.ping();
44
+ }, 30_000);
45
+ ws.on("message", async (raw) => {
46
+ // Parse & validate
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(raw.toString());
50
+ }
51
+ catch {
52
+ send(ws, { type: "error", message: "Invalid JSON" });
53
+ return;
54
+ }
55
+ const result = WsMessageSchema.safeParse(parsed);
56
+ if (!result.success) {
57
+ send(ws, { type: "error", message: `Invalid message: ${result.error.message}` });
58
+ return;
59
+ }
60
+ const msg = result.data;
61
+ try {
62
+ const bridge = getBridge();
63
+ switch (msg.type) {
64
+ case "new_session": {
65
+ if (!bridge) {
66
+ send(ws, { type: "error", message: "Agent not ready yet" });
67
+ return;
68
+ }
69
+ const created = await sessions.createSession(bridge, msg.cwd, msg.inheritFromSessionId);
70
+ if (created.configOptions.length) {
71
+ send(ws, {
72
+ type: "config_option_update",
73
+ sessionId: created.sessionId,
74
+ configOptions: created.configOptions,
75
+ });
76
+ }
77
+ break;
78
+ }
79
+ case "resume_session": {
80
+ if (!bridge) {
81
+ send(ws, { type: "error", message: "Agent not ready yet" });
82
+ return;
83
+ }
84
+ try {
85
+ const event = await sessions.resumeSession(bridge, msg.sessionId);
86
+ send(ws, event);
87
+ }
88
+ catch {
89
+ send(ws, { type: "session_expired", sessionId: msg.sessionId });
90
+ }
91
+ break;
92
+ }
93
+ case "delete_session": {
94
+ sessions.deleteSession(msg.sessionId);
95
+ broadcast(wss, { type: "session_deleted", sessionId: msg.sessionId });
96
+ console.log(`[session] deleted: ${msg.sessionId.slice(0, 8)}…`);
97
+ break;
98
+ }
99
+ case "prompt": {
100
+ if (!bridge) {
101
+ send(ws, { type: "error", message: "No active bridge" });
102
+ return;
103
+ }
104
+ const images = msg.images;
105
+ const userData = {
106
+ text: msg.text,
107
+ ...(images && { images: images.map((i) => ({ path: i.path, mimeType: i.mimeType })) }),
108
+ };
109
+ store.saveEvent(msg.sessionId, "user_message", userData);
110
+ store.updateSessionLastActive(msg.sessionId);
111
+ // Generate title once the session actually gets one; canceled/failed attempts can retry later.
112
+ if (!sessions.sessionHasTitle.has(msg.sessionId)) {
113
+ titleService.generate(bridge, msg.text, msg.sessionId, (title) => {
114
+ broadcast(wss, { type: "session_title_updated", sessionId: msg.sessionId, title });
115
+ });
116
+ }
117
+ // Broadcast to other clients
118
+ const userEvent = JSON.stringify({ type: "user_message", sessionId: msg.sessionId, ...userData });
119
+ for (const client of wss.clients) {
120
+ if (client !== ws && client.readyState === WebSocket.OPEN) {
121
+ client.send(userEvent);
122
+ }
123
+ }
124
+ sessions.activePrompts.add(msg.sessionId);
125
+ bridge.prompt(msg.sessionId, msg.text, images).catch((err) => {
126
+ send(ws, { type: "error", message: errorMessage(err) });
127
+ });
128
+ break;
129
+ }
130
+ case "permission_response": {
131
+ if (!bridge)
132
+ return;
133
+ if (msg.denied) {
134
+ bridge.denyPermission(msg.requestId);
135
+ }
136
+ else if (msg.optionId) {
137
+ bridge.resolvePermission(msg.requestId, msg.optionId);
138
+ }
139
+ if (msg.sessionId) {
140
+ store.saveEvent(msg.sessionId, "permission_response", {
141
+ requestId: msg.requestId,
142
+ optionName: msg.optionName || "",
143
+ denied: !!msg.denied,
144
+ });
145
+ }
146
+ broadcast(wss, {
147
+ type: "permission_resolved",
148
+ sessionId: msg.sessionId,
149
+ requestId: msg.requestId,
150
+ optionName: msg.optionName || "",
151
+ denied: !!msg.denied,
152
+ });
153
+ break;
154
+ }
155
+ case "cancel": {
156
+ interruptBashProc(sessions.runningBashProcs.get(msg.sessionId));
157
+ if (bridge) {
158
+ await titleService.cancel(msg.sessionId, bridge);
159
+ }
160
+ await bridge?.cancel(msg.sessionId);
161
+ break;
162
+ }
163
+ case "set_config_option": {
164
+ if (!bridge) {
165
+ send(ws, { type: "error", message: "Agent not ready yet" });
166
+ return;
167
+ }
168
+ try {
169
+ const configOptions = await bridge.setConfigOption(msg.sessionId, msg.configId, msg.value);
170
+ for (const opt of configOptions) {
171
+ store.updateSessionConfig(msg.sessionId, opt.id, opt.currentValue);
172
+ }
173
+ send(ws, { type: "config_set", configId: msg.configId, value: msg.value });
174
+ if (configOptions.length) {
175
+ broadcast(wss, { type: "config_option_update", sessionId: msg.sessionId, configOptions }, ws);
176
+ }
177
+ }
178
+ catch (err) {
179
+ send(ws, { type: "error", message: `Failed to set ${msg.configId}: ${errorMessage(err)}` });
180
+ }
181
+ break;
182
+ }
183
+ case "bash_exec": {
184
+ if (sessions.runningBashProcs.has(msg.sessionId)) {
185
+ send(ws, { type: "error", message: "A bash command is already running in this session" });
186
+ return;
187
+ }
188
+ const cwd = sessions.getSessionCwd(msg.sessionId);
189
+ store.saveEvent(msg.sessionId, "bash_command", { command: msg.command });
190
+ // Broadcast to other clients
191
+ const bashEvent = JSON.stringify({
192
+ type: "bash_command", sessionId: msg.sessionId, command: msg.command,
193
+ });
194
+ for (const client of wss.clients) {
195
+ if (client !== ws && client.readyState === WebSocket.OPEN) {
196
+ client.send(bashEvent);
197
+ }
198
+ }
199
+ const child = spawn("bash", ["-c", msg.command], {
200
+ cwd,
201
+ detached: true,
202
+ env: { ...process.env, TERM: "dumb" },
203
+ stdio: ["ignore", "pipe", "pipe"],
204
+ });
205
+ sessions.runningBashProcs.set(msg.sessionId, child);
206
+ let output = "";
207
+ let outputTruncated = false;
208
+ const onData = (stream) => (chunk) => {
209
+ const text = chunk.toString();
210
+ if (!outputTruncated) {
211
+ output += text;
212
+ if (output.length > limits.bash_output) {
213
+ output = output.slice(-limits.bash_output);
214
+ outputTruncated = true;
215
+ }
216
+ }
217
+ else {
218
+ // Keep only the tail within the limit
219
+ output = (output + text).slice(-limits.bash_output);
220
+ }
221
+ broadcast(wss, { type: "bash_output", sessionId: msg.sessionId, text, stream });
222
+ };
223
+ child.stdout.on("data", onData("stdout"));
224
+ child.stderr.on("data", onData("stderr"));
225
+ child.on("close", (code, signal) => {
226
+ sessions.runningBashProcs.delete(msg.sessionId);
227
+ const stored = outputTruncated ? "[truncated]\n" + output : output;
228
+ store.saveEvent(msg.sessionId, "bash_result", { output: stored, code, signal });
229
+ broadcast(wss, { type: "bash_done", sessionId: msg.sessionId, code, signal });
230
+ });
231
+ child.on("error", (err) => {
232
+ sessions.runningBashProcs.delete(msg.sessionId);
233
+ const errMsg = errorMessage(err);
234
+ store.saveEvent(msg.sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
235
+ broadcast(wss, { type: "bash_done", sessionId: msg.sessionId, code: -1, signal: null, error: errMsg });
236
+ });
237
+ break;
238
+ }
239
+ case "bash_cancel": {
240
+ interruptBashProc(sessions.runningBashProcs.get(msg.sessionId));
241
+ break;
242
+ }
243
+ }
244
+ }
245
+ catch (err) {
246
+ send(ws, { type: "error", message: errorMessage(err) });
247
+ }
248
+ });
249
+ ws.on("close", () => {
250
+ clearInterval(pingInterval);
251
+ console.log(`[ws] client disconnected (total: ${wss.clients.size})`);
252
+ });
253
+ });
254
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lelouchhe/webagent",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A terminal-style web UI for ACP-compatible agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,12 +25,12 @@
25
25
  },
26
26
  "files": [
27
27
  "bin/",
28
- "src/",
28
+ "lib/",
29
29
  "dist/",
30
30
  "config.toml"
31
31
  ],
32
32
  "scripts": {
33
- "prepublishOnly": "npm run build",
33
+ "prepublishOnly": "npm run build && npx tsc -p tsconfig.build.json",
34
34
  "build": "node scripts/build.js",
35
35
  "start": "node --experimental-strip-types src/server.ts --config config.toml",
36
36
  "test": "node --experimental-strip-types --test test/*.test.ts",
@@ -51,6 +51,7 @@
51
51
  "@types/node": "^25.3.3",
52
52
  "@types/ws": "^8.18.1",
53
53
  "happy-dom": "^20.8.3",
54
- "playwright": "^1.58.2"
54
+ "playwright": "^1.58.2",
55
+ "typescript": "^5.9.3"
55
56
  }
56
57
  }
@@ -1,10 +0,0 @@
1
- // Boot entry point — imports all modules and starts the app
2
-
3
- import './render.mmjqzu9r.js'; // theme, click-to-collapse listeners
4
- import './commands.mmjqzu9r.js'; // slash menu listeners
5
- import './images.mmjqzu9r.js'; // attach/paste listeners
6
- import './input.mmjqzu9r.js'; // keyboard/send listeners
7
- import { connect } from './connection.mmjqzu9r.js';
8
-
9
- connect();
10
- if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
package/src/bridge.ts DELETED
@@ -1,317 +0,0 @@
1
- import { spawn, ChildProcess } from "node:child_process";
2
- import { Writable, Readable } from "node:stream";
3
- import { EventEmitter } from "node:events";
4
- import * as acp from "@agentclientprotocol/sdk";
5
- import type { AgentEvent, ConfigOption } from "./types.ts";
6
-
7
- export class AgentBridge extends EventEmitter {
8
- private proc: ChildProcess | null = null;
9
- private conn: acp.ClientSideConnection | null = null;
10
- private permissionResolvers = new Map<string, (resp: acp.RequestPermissionResponse) => void>();
11
- private permissionRequestSessions = new Map<string, string>();
12
- private silentSessions = new Set<string>(); // Sessions that don't emit events
13
- private silentBuffers = new Map<string, string>(); // Text buffers for silent sessions
14
- readonly agentCmd: string;
15
-
16
- constructor(agentCmd: string) {
17
- super();
18
- this.agentCmd = agentCmd;
19
- }
20
-
21
- async start(): Promise<void> {
22
- const [cmd, ...args] = this.agentCmd.split(/\s+/);
23
- this.proc = spawn(cmd, args, {
24
- stdio: ["pipe", "pipe", "inherit"],
25
- });
26
-
27
- if (!this.proc.stdin || !this.proc.stdout) {
28
- throw new Error(`Failed to start: ${this.agentCmd}`);
29
- }
30
-
31
- const input = Writable.toWeb(this.proc.stdin);
32
- const output = Readable.toWeb(this.proc.stdout) as ReadableStream<Uint8Array>;
33
- const stream = acp.ndJsonStream(input, output);
34
-
35
- const client: acp.Client = {
36
- requestPermission: async (params) => this.handlePermission(params),
37
- sessionUpdate: async (params) => this.handleSessionUpdate(params),
38
- readTextFile: async (params) => this.handleReadFile(params),
39
- writeTextFile: async (params) => this.handleWriteFile(params),
40
- };
41
-
42
- this.conn = new acp.ClientSideConnection((_agent) => client, stream);
43
-
44
- const init = await this.conn.initialize({
45
- protocolVersion: acp.PROTOCOL_VERSION,
46
- clientCapabilities: {
47
- fs: { readTextFile: true, writeTextFile: true },
48
- terminal: true,
49
- },
50
- });
51
-
52
- const agentInfo = init.agentInfo;
53
- this.emit("event", {
54
- type: "connected",
55
- agent: {
56
- name: agentInfo?.name ?? "unknown",
57
- version: agentInfo?.version ?? "?",
58
- },
59
- configOptions: [],
60
- } satisfies AgentEvent);
61
- }
62
-
63
- async newSession(cwd: string, opts?: { silent?: boolean }): Promise<string> {
64
- if (!this.conn) throw new Error("Not connected");
65
- const session = await this.conn.newSession({ cwd, mcpServers: [] });
66
- if (!opts?.silent) {
67
- this.emit("event", {
68
- type: "session_created",
69
- sessionId: session.sessionId,
70
- cwd,
71
- configOptions: (session as any).configOptions ?? [],
72
- } satisfies AgentEvent);
73
- }
74
- return session.sessionId;
75
- }
76
-
77
- async loadSession(sessionId: string, cwd: string): Promise<{ sessionId: string; configOptions: ConfigOption[] }> {
78
- if (!this.conn) throw new Error("Not connected");
79
- const session = await this.conn.loadSession({ sessionId, cwd, mcpServers: [] });
80
- this.emit("event", {
81
- type: "session_created",
82
- sessionId: session.sessionId,
83
- cwd,
84
- configOptions: (session as any).configOptions ?? [],
85
- } satisfies AgentEvent);
86
- return { sessionId: session.sessionId, configOptions: (session as any).configOptions ?? [] };
87
- }
88
-
89
- async setConfigOption(sessionId: string, configId: string, value: string): Promise<ConfigOption[]> {
90
- if (!this.conn) throw new Error("Not connected");
91
- const result = await this.conn.setSessionConfigOption({ sessionId, configId, value });
92
- return (result as any).configOptions ?? [];
93
- }
94
-
95
- async prompt(
96
- sessionId: string,
97
- text: string,
98
- images?: Array<{ data: string; mimeType: string }>,
99
- ): Promise<void> {
100
- if (!this.conn) throw new Error("Not connected");
101
- try {
102
- const promptParts: Array<
103
- | { type: "text"; text: string }
104
- | { type: "image"; data: string; mimeType: string }
105
- > = [];
106
- if (images) {
107
- for (const img of images) {
108
- promptParts.push({ type: "image", data: img.data, mimeType: img.mimeType });
109
- }
110
- }
111
- promptParts.push({ type: "text", text });
112
- const result = await this.conn.prompt({
113
- sessionId,
114
- prompt: promptParts,
115
- });
116
- this.emit("event", {
117
- type: "prompt_done",
118
- sessionId,
119
- stopReason: result.stopReason ?? "end_turn",
120
- } satisfies AgentEvent);
121
- } catch (err: unknown) {
122
- const message = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
123
- if (/cancel/i.test(message)) {
124
- this.emit("event", {
125
- type: "prompt_done",
126
- sessionId,
127
- stopReason: "cancelled",
128
- } satisfies AgentEvent);
129
- return;
130
- }
131
- this.emit("event", { type: "error", sessionId, message } satisfies AgentEvent);
132
- }
133
- }
134
-
135
- async cancel(sessionId: string): Promise<void> {
136
- for (const [requestId, requestSessionId] of this.permissionRequestSessions) {
137
- if (requestSessionId === sessionId) {
138
- this.denyPermission(requestId);
139
- }
140
- }
141
- await this.conn?.cancel({ sessionId });
142
- }
143
-
144
- /** Send a prompt and collect the full text response without emitting events. */
145
- async promptForText(sessionId: string, text: string): Promise<string> {
146
- if (!this.conn) throw new Error("Not connected");
147
- this.silentSessions.add(sessionId);
148
- this.silentBuffers.set(sessionId, "");
149
- try {
150
- await this.conn.prompt({ sessionId, prompt: [{ type: "text", text }] });
151
- return this.silentBuffers.get(sessionId) ?? "";
152
- } catch (err: unknown) {
153
- const message = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
154
- if (/cancel/i.test(message)) {
155
- return "";
156
- }
157
- throw err;
158
- } finally {
159
- this.silentSessions.delete(sessionId);
160
- this.silentBuffers.delete(sessionId);
161
- }
162
- }
163
-
164
- resolvePermission(requestId: string, optionId: string): void {
165
- const resolve = this.permissionResolvers.get(requestId);
166
- if (resolve) {
167
- resolve({ outcome: { outcome: "selected", optionId } });
168
- this.permissionResolvers.delete(requestId);
169
- this.permissionRequestSessions.delete(requestId);
170
- }
171
- }
172
-
173
- denyPermission(requestId: string): void {
174
- const resolve = this.permissionResolvers.get(requestId);
175
- if (resolve) {
176
- resolve({ outcome: { outcome: "cancelled" } });
177
- this.permissionResolvers.delete(requestId);
178
- this.permissionRequestSessions.delete(requestId);
179
- }
180
- }
181
-
182
- async shutdown(): Promise<void> {
183
- // Reject all pending permissions
184
- for (const [id, resolve] of this.permissionResolvers) {
185
- resolve({ outcome: { outcome: "cancelled" } });
186
- }
187
- this.permissionResolvers.clear();
188
- this.permissionRequestSessions.clear();
189
-
190
- if (this.proc && this.proc.exitCode === null) {
191
- this.proc.kill();
192
- await new Promise<void>((resolve) => {
193
- const timer = setTimeout(() => {
194
- this.proc?.kill("SIGKILL");
195
- resolve();
196
- }, 5000);
197
- this.proc?.on("exit", () => {
198
- clearTimeout(timer);
199
- resolve();
200
- });
201
- });
202
- }
203
- this.proc = null;
204
- this.conn = null;
205
- }
206
-
207
- // --- ACP Client callbacks ---
208
-
209
- private handlePermission(params: acp.RequestPermissionRequest): Promise<acp.RequestPermissionResponse> {
210
- const requestId = crypto.randomUUID();
211
- const title = params.toolCall?.title ?? "Permission requested";
212
- const toolCallId = params.toolCall?.toolCallId ?? null;
213
-
214
- return new Promise((resolve) => {
215
- // Register resolver BEFORE emitting, so synchronous auto-approve can find it
216
- this.permissionResolvers.set(requestId, resolve);
217
- this.permissionRequestSessions.set(requestId, params.sessionId);
218
- this.emit("event", {
219
- type: "permission_request",
220
- requestId,
221
- sessionId: params.sessionId,
222
- title,
223
- toolCallId,
224
- options: params.options,
225
- } satisfies AgentEvent);
226
- });
227
- }
228
-
229
- private handleSessionUpdate(params: acp.SessionNotification): Promise<void> {
230
- const update = params.update;
231
- const sessionId = params.sessionId;
232
-
233
- // Silent sessions: only buffer text, don't emit events
234
- if (this.silentSessions.has(sessionId)) {
235
- if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
236
- const buf = (this.silentBuffers.get(sessionId) ?? "") + update.content.text;
237
- this.silentBuffers.set(sessionId, buf);
238
- }
239
- return Promise.resolve();
240
- }
241
-
242
- switch (update.sessionUpdate) {
243
- case "agent_message_chunk":
244
- if (update.content.type === "text") {
245
- this.emit("event", {
246
- type: "message_chunk",
247
- sessionId,
248
- text: update.content.text,
249
- } satisfies AgentEvent);
250
- }
251
- break;
252
-
253
- case "agent_thought_chunk":
254
- if (update.content.type === "text") {
255
- this.emit("event", {
256
- type: "thought_chunk",
257
- sessionId,
258
- text: update.content.text,
259
- } satisfies AgentEvent);
260
- }
261
- break;
262
-
263
- case "tool_call":
264
- this.emit("event", {
265
- type: "tool_call",
266
- sessionId,
267
- id: update.toolCallId ?? "",
268
- title: update.title ?? "",
269
- kind: update.kind ?? "unknown",
270
- rawInput: update.rawInput,
271
- } satisfies AgentEvent);
272
- break;
273
-
274
- case "tool_call_update":
275
- this.emit("event", {
276
- type: "tool_call_update",
277
- sessionId,
278
- id: update.toolCallId ?? "",
279
- status: update.status ?? "",
280
- content: update.content,
281
- } satisfies AgentEvent);
282
- break;
283
-
284
- case "plan":
285
- this.emit("event", {
286
- type: "plan",
287
- sessionId,
288
- entries: update.entries ?? [],
289
- } satisfies AgentEvent);
290
- break;
291
-
292
- case "config_option_update":
293
- this.emit("event", {
294
- type: "config_option_update",
295
- sessionId,
296
- configOptions: (update as any).configOptions ?? [],
297
- } satisfies AgentEvent);
298
- break;
299
- }
300
-
301
- return Promise.resolve();
302
- }
303
-
304
- private async handleReadFile(params: acp.ReadTextFileRequest): Promise<acp.ReadTextFileResponse> {
305
- const { readFile } = await import("node:fs/promises");
306
- const content = await readFile(params.path, "utf-8");
307
- return { content };
308
- }
309
-
310
- private async handleWriteFile(params: acp.WriteTextFileRequest): Promise<acp.WriteTextFileResponse> {
311
- const { writeFile, mkdir } = await import("node:fs/promises");
312
- const { dirname } = await import("node:path");
313
- await mkdir(dirname(params.path), { recursive: true });
314
- await writeFile(params.path, params.content);
315
- return {};
316
- }
317
- }
package/src/config.ts DELETED
@@ -1,65 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { parse as parseTOML } from "smol-toml";
3
- import { z } from "zod";
4
-
5
- const ConfigSchema = z.object({
6
- port: z.number().int().positive().default(6800),
7
- data_dir: z.string().default("data"),
8
- default_cwd: z.string().default(process.cwd()),
9
- public_dir: z.string().default("dist"),
10
- agent_cmd: z.string().default("copilot --acp"),
11
-
12
- limits: z.object({
13
- bash_output: z.number().int().positive().default(1_048_576), // 1 MB
14
- image_upload: z.number().int().positive().default(10_485_760), // 10 MB
15
- cancel_timeout: z.number().int().nonnegative().default(10_000), // 10s; 0 disables
16
- }).default({
17
- bash_output: 1_048_576,
18
- image_upload: 10_485_760,
19
- cancel_timeout: 10_000,
20
- }),
21
- });
22
-
23
- export type Config = z.infer<typeof ConfigSchema>;
24
-
25
- let _config: Config | null = null;
26
-
27
- function parseArgs(): string | null {
28
- const idx = process.argv.indexOf("--config");
29
- if (idx !== -1 && idx + 1 < process.argv.length) {
30
- return process.argv[idx + 1];
31
- }
32
- return null;
33
- }
34
-
35
- export function loadConfig(): Config {
36
- const configPath = parseArgs();
37
- let raw: Record<string, unknown> = {};
38
-
39
- if (configPath) {
40
- try {
41
- const content = readFileSync(configPath, "utf-8");
42
- raw = parseTOML(content) as Record<string, unknown>;
43
- console.log(`[config] loaded: ${configPath}`);
44
- } catch (err) {
45
- console.error(`[config] failed to read ${configPath}:`, err);
46
- process.exit(1);
47
- }
48
- } else {
49
- console.log("[config] no --config provided, using defaults");
50
- }
51
-
52
- const result = ConfigSchema.safeParse(raw);
53
- if (!result.success) {
54
- console.error("[config] validation error:", result.error.format());
55
- process.exit(1);
56
- }
57
-
58
- _config = result.data;
59
- return _config;
60
- }
61
-
62
- export function getConfig(): Config {
63
- if (!_config) throw new Error("Config not loaded. Call loadConfig() first.");
64
- return _config;
65
- }