sparkecoder 0.1.3 → 0.1.5

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/dist/index.js CHANGED
@@ -8,12 +8,12 @@ var __export = (target, all) => {
8
8
  import {
9
9
  streamText,
10
10
  generateText as generateText2,
11
- tool as tool7,
11
+ tool as tool6,
12
12
  stepCountIs
13
13
  } from "ai";
14
14
  import { gateway as gateway2 } from "@ai-sdk/gateway";
15
- import { z as z8 } from "zod";
16
- import { nanoid as nanoid2 } from "nanoid";
15
+ import { z as z7 } from "zod";
16
+ import { nanoid as nanoid3 } from "nanoid";
17
17
 
18
18
  // src/db/index.ts
19
19
  import Database from "better-sqlite3";
@@ -24,6 +24,9 @@ import { nanoid } from "nanoid";
24
24
  // src/db/schema.ts
25
25
  var schema_exports = {};
26
26
  __export(schema_exports, {
27
+ activeStreams: () => activeStreams,
28
+ checkpoints: () => checkpoints,
29
+ fileBackups: () => fileBackups,
27
30
  loadedSkills: () => loadedSkills,
28
31
  messages: () => messages,
29
32
  sessions: () => sessions,
@@ -99,6 +102,37 @@ var terminals = sqliteTable("terminals", {
99
102
  createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
100
103
  stoppedAt: integer("stopped_at", { mode: "timestamp" })
101
104
  });
105
+ var activeStreams = sqliteTable("active_streams", {
106
+ id: text("id").primaryKey(),
107
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
108
+ streamId: text("stream_id").notNull().unique(),
109
+ // Unique stream identifier
110
+ status: text("status", { enum: ["active", "finished", "error"] }).notNull().default("active"),
111
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
112
+ finishedAt: integer("finished_at", { mode: "timestamp" })
113
+ });
114
+ var checkpoints = sqliteTable("checkpoints", {
115
+ id: text("id").primaryKey(),
116
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
117
+ // The message sequence number this checkpoint was created BEFORE
118
+ // (i.e., the state before this user message was processed)
119
+ messageSequence: integer("message_sequence").notNull(),
120
+ // Optional git commit hash if in a git repo
121
+ gitHead: text("git_head"),
122
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
123
+ });
124
+ var fileBackups = sqliteTable("file_backups", {
125
+ id: text("id").primaryKey(),
126
+ checkpointId: text("checkpoint_id").notNull().references(() => checkpoints.id, { onDelete: "cascade" }),
127
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
128
+ // Relative path from working directory
129
+ filePath: text("file_path").notNull(),
130
+ // Original content (null means file didn't exist before)
131
+ originalContent: text("original_content"),
132
+ // Whether the file existed before this checkpoint
133
+ existed: integer("existed", { mode: "boolean" }).notNull().default(true),
134
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
135
+ });
102
136
 
103
137
  // src/db/index.ts
104
138
  var db = null;
@@ -108,14 +142,7 @@ function initDatabase(dbPath) {
108
142
  sqlite.pragma("journal_mode = WAL");
109
143
  db = drizzle(sqlite, { schema: schema_exports });
110
144
  sqlite.exec(`
111
- DROP TABLE IF EXISTS terminals;
112
- DROP TABLE IF EXISTS loaded_skills;
113
- DROP TABLE IF EXISTS todo_items;
114
- DROP TABLE IF EXISTS tool_executions;
115
- DROP TABLE IF EXISTS messages;
116
- DROP TABLE IF EXISTS sessions;
117
-
118
- CREATE TABLE sessions (
145
+ CREATE TABLE IF NOT EXISTS sessions (
119
146
  id TEXT PRIMARY KEY,
120
147
  name TEXT,
121
148
  working_directory TEXT NOT NULL,
@@ -126,7 +153,7 @@ function initDatabase(dbPath) {
126
153
  updated_at INTEGER NOT NULL
127
154
  );
128
155
 
129
- CREATE TABLE messages (
156
+ CREATE TABLE IF NOT EXISTS messages (
130
157
  id TEXT PRIMARY KEY,
131
158
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
132
159
  model_message TEXT NOT NULL,
@@ -134,7 +161,7 @@ function initDatabase(dbPath) {
134
161
  created_at INTEGER NOT NULL
135
162
  );
136
163
 
137
- CREATE TABLE tool_executions (
164
+ CREATE TABLE IF NOT EXISTS tool_executions (
138
165
  id TEXT PRIMARY KEY,
139
166
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
140
167
  message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
@@ -149,7 +176,7 @@ function initDatabase(dbPath) {
149
176
  completed_at INTEGER
150
177
  );
151
178
 
152
- CREATE TABLE todo_items (
179
+ CREATE TABLE IF NOT EXISTS todo_items (
153
180
  id TEXT PRIMARY KEY,
154
181
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
155
182
  content TEXT NOT NULL,
@@ -159,14 +186,14 @@ function initDatabase(dbPath) {
159
186
  updated_at INTEGER NOT NULL
160
187
  );
161
188
 
162
- CREATE TABLE loaded_skills (
189
+ CREATE TABLE IF NOT EXISTS loaded_skills (
163
190
  id TEXT PRIMARY KEY,
164
191
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
165
192
  skill_name TEXT NOT NULL,
166
193
  loaded_at INTEGER NOT NULL
167
194
  );
168
195
 
169
- CREATE TABLE terminals (
196
+ CREATE TABLE IF NOT EXISTS terminals (
170
197
  id TEXT PRIMARY KEY,
171
198
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
172
199
  name TEXT,
@@ -180,11 +207,45 @@ function initDatabase(dbPath) {
180
207
  stopped_at INTEGER
181
208
  );
182
209
 
210
+ -- Table for tracking active streams (for resumable streams)
211
+ CREATE TABLE IF NOT EXISTS active_streams (
212
+ id TEXT PRIMARY KEY,
213
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
214
+ stream_id TEXT NOT NULL UNIQUE,
215
+ status TEXT NOT NULL DEFAULT 'active',
216
+ created_at INTEGER NOT NULL,
217
+ finished_at INTEGER
218
+ );
219
+
220
+ -- Checkpoints table - created before each user message
221
+ CREATE TABLE IF NOT EXISTS checkpoints (
222
+ id TEXT PRIMARY KEY,
223
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
224
+ message_sequence INTEGER NOT NULL,
225
+ git_head TEXT,
226
+ created_at INTEGER NOT NULL
227
+ );
228
+
229
+ -- File backups table - stores original file content
230
+ CREATE TABLE IF NOT EXISTS file_backups (
231
+ id TEXT PRIMARY KEY,
232
+ checkpoint_id TEXT NOT NULL REFERENCES checkpoints(id) ON DELETE CASCADE,
233
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
234
+ file_path TEXT NOT NULL,
235
+ original_content TEXT,
236
+ existed INTEGER NOT NULL DEFAULT 1,
237
+ created_at INTEGER NOT NULL
238
+ );
239
+
183
240
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
184
241
  CREATE INDEX IF NOT EXISTS idx_tool_executions_session ON tool_executions(session_id);
185
242
  CREATE INDEX IF NOT EXISTS idx_todo_items_session ON todo_items(session_id);
186
243
  CREATE INDEX IF NOT EXISTS idx_loaded_skills_session ON loaded_skills(session_id);
187
244
  CREATE INDEX IF NOT EXISTS idx_terminals_session ON terminals(session_id);
245
+ CREATE INDEX IF NOT EXISTS idx_active_streams_session ON active_streams(session_id);
246
+ CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id);
247
+ CREATE INDEX IF NOT EXISTS idx_file_backups_checkpoint ON file_backups(checkpoint_id);
248
+ CREATE INDEX IF NOT EXISTS idx_file_backups_session ON file_backups(session_id);
188
249
  `);
189
250
  return db;
190
251
  }
@@ -222,6 +283,12 @@ var sessionQueries = {
222
283
  updateStatus(id, status) {
223
284
  return getDb().update(sessions).set({ status, updatedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.id, id)).returning().get();
224
285
  },
286
+ updateModel(id, model) {
287
+ return getDb().update(sessions).set({ model, updatedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.id, id)).returning().get();
288
+ },
289
+ update(id, updates) {
290
+ return getDb().update(sessions).set({ ...updates, updatedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.id, id)).returning().get();
291
+ },
225
292
  delete(id) {
226
293
  const result = getDb().delete(sessions).where(eq(sessions.id, id)).run();
227
294
  return result.changes > 0;
@@ -295,6 +362,19 @@ var messageQueries = {
295
362
  deleteBySession(sessionId) {
296
363
  const result = getDb().delete(messages).where(eq(messages.sessionId, sessionId)).run();
297
364
  return result.changes;
365
+ },
366
+ /**
367
+ * Delete all messages with sequence >= the given sequence number
368
+ * (Used when reverting to a checkpoint)
369
+ */
370
+ deleteFromSequence(sessionId, fromSequence) {
371
+ const result = getDb().delete(messages).where(
372
+ and(
373
+ eq(messages.sessionId, sessionId),
374
+ sql`sequence >= ${fromSequence}`
375
+ )
376
+ ).run();
377
+ return result.changes;
298
378
  }
299
379
  };
300
380
  var toolExecutionQueries = {
@@ -338,6 +418,19 @@ var toolExecutionQueries = {
338
418
  },
339
419
  getBySession(sessionId) {
340
420
  return getDb().select().from(toolExecutions).where(eq(toolExecutions.sessionId, sessionId)).orderBy(toolExecutions.startedAt).all();
421
+ },
422
+ /**
423
+ * Delete all tool executions after a given timestamp
424
+ * (Used when reverting to a checkpoint)
425
+ */
426
+ deleteAfterTime(sessionId, afterTime) {
427
+ const result = getDb().delete(toolExecutions).where(
428
+ and(
429
+ eq(toolExecutions.sessionId, sessionId),
430
+ sql`started_at > ${afterTime.getTime()}`
431
+ )
432
+ ).run();
433
+ return result.changes;
341
434
  }
342
435
  };
343
436
  var todoQueries = {
@@ -403,54 +496,154 @@ var skillQueries = {
403
496
  return !!result;
404
497
  }
405
498
  };
406
- var terminalQueries = {
499
+ var activeStreamQueries = {
500
+ create(sessionId, streamId) {
501
+ const id = nanoid();
502
+ const result = getDb().insert(activeStreams).values({
503
+ id,
504
+ sessionId,
505
+ streamId,
506
+ status: "active",
507
+ createdAt: /* @__PURE__ */ new Date()
508
+ }).returning().get();
509
+ return result;
510
+ },
511
+ getBySessionId(sessionId) {
512
+ return getDb().select().from(activeStreams).where(
513
+ and(
514
+ eq(activeStreams.sessionId, sessionId),
515
+ eq(activeStreams.status, "active")
516
+ )
517
+ ).get();
518
+ },
519
+ getByStreamId(streamId) {
520
+ return getDb().select().from(activeStreams).where(eq(activeStreams.streamId, streamId)).get();
521
+ },
522
+ finish(streamId) {
523
+ return getDb().update(activeStreams).set({ status: "finished", finishedAt: /* @__PURE__ */ new Date() }).where(eq(activeStreams.streamId, streamId)).returning().get();
524
+ },
525
+ markError(streamId) {
526
+ return getDb().update(activeStreams).set({ status: "error", finishedAt: /* @__PURE__ */ new Date() }).where(eq(activeStreams.streamId, streamId)).returning().get();
527
+ },
528
+ deleteBySession(sessionId) {
529
+ const result = getDb().delete(activeStreams).where(eq(activeStreams.sessionId, sessionId)).run();
530
+ return result.changes;
531
+ }
532
+ };
533
+ var checkpointQueries = {
407
534
  create(data) {
408
535
  const id = nanoid();
409
- const result = getDb().insert(terminals).values({
536
+ const result = getDb().insert(checkpoints).values({
410
537
  id,
411
- ...data,
538
+ sessionId: data.sessionId,
539
+ messageSequence: data.messageSequence,
540
+ gitHead: data.gitHead,
412
541
  createdAt: /* @__PURE__ */ new Date()
413
542
  }).returning().get();
414
543
  return result;
415
544
  },
416
545
  getById(id) {
417
- return getDb().select().from(terminals).where(eq(terminals.id, id)).get();
546
+ return getDb().select().from(checkpoints).where(eq(checkpoints.id, id)).get();
418
547
  },
419
548
  getBySession(sessionId) {
420
- return getDb().select().from(terminals).where(eq(terminals.sessionId, sessionId)).orderBy(desc(terminals.createdAt)).all();
549
+ return getDb().select().from(checkpoints).where(eq(checkpoints.sessionId, sessionId)).orderBy(checkpoints.messageSequence).all();
421
550
  },
422
- getRunning(sessionId) {
423
- return getDb().select().from(terminals).where(
551
+ getByMessageSequence(sessionId, messageSequence) {
552
+ return getDb().select().from(checkpoints).where(
424
553
  and(
425
- eq(terminals.sessionId, sessionId),
426
- eq(terminals.status, "running")
554
+ eq(checkpoints.sessionId, sessionId),
555
+ eq(checkpoints.messageSequence, messageSequence)
427
556
  )
428
- ).all();
557
+ ).get();
429
558
  },
430
- updateStatus(id, status, exitCode, error) {
431
- return getDb().update(terminals).set({
432
- status,
433
- exitCode,
434
- error,
435
- stoppedAt: status !== "running" ? /* @__PURE__ */ new Date() : void 0
436
- }).where(eq(terminals.id, id)).returning().get();
559
+ getLatest(sessionId) {
560
+ return getDb().select().from(checkpoints).where(eq(checkpoints.sessionId, sessionId)).orderBy(desc(checkpoints.messageSequence)).limit(1).get();
437
561
  },
438
- updatePid(id, pid) {
439
- return getDb().update(terminals).set({ pid }).where(eq(terminals.id, id)).returning().get();
562
+ /**
563
+ * Delete all checkpoints after a given sequence number
564
+ * (Used when reverting to a checkpoint)
565
+ */
566
+ deleteAfterSequence(sessionId, messageSequence) {
567
+ const result = getDb().delete(checkpoints).where(
568
+ and(
569
+ eq(checkpoints.sessionId, sessionId),
570
+ sql`message_sequence > ${messageSequence}`
571
+ )
572
+ ).run();
573
+ return result.changes;
440
574
  },
441
- delete(id) {
442
- const result = getDb().delete(terminals).where(eq(terminals.id, id)).run();
443
- return result.changes > 0;
575
+ deleteBySession(sessionId) {
576
+ const result = getDb().delete(checkpoints).where(eq(checkpoints.sessionId, sessionId)).run();
577
+ return result.changes;
578
+ }
579
+ };
580
+ var fileBackupQueries = {
581
+ create(data) {
582
+ const id = nanoid();
583
+ const result = getDb().insert(fileBackups).values({
584
+ id,
585
+ checkpointId: data.checkpointId,
586
+ sessionId: data.sessionId,
587
+ filePath: data.filePath,
588
+ originalContent: data.originalContent,
589
+ existed: data.existed,
590
+ createdAt: /* @__PURE__ */ new Date()
591
+ }).returning().get();
592
+ return result;
593
+ },
594
+ getByCheckpoint(checkpointId) {
595
+ return getDb().select().from(fileBackups).where(eq(fileBackups.checkpointId, checkpointId)).all();
596
+ },
597
+ getBySession(sessionId) {
598
+ return getDb().select().from(fileBackups).where(eq(fileBackups.sessionId, sessionId)).orderBy(fileBackups.createdAt).all();
599
+ },
600
+ /**
601
+ * Get all file backups from a given checkpoint sequence onwards (inclusive)
602
+ * (Used when reverting - need to restore these files)
603
+ *
604
+ * When reverting to checkpoint X, we need backups from checkpoint X and all later ones
605
+ * because checkpoint X's backups represent the state BEFORE processing message X.
606
+ */
607
+ getFromSequence(sessionId, messageSequence) {
608
+ const checkpointsFrom = getDb().select().from(checkpoints).where(
609
+ and(
610
+ eq(checkpoints.sessionId, sessionId),
611
+ sql`message_sequence >= ${messageSequence}`
612
+ )
613
+ ).all();
614
+ if (checkpointsFrom.length === 0) {
615
+ return [];
616
+ }
617
+ const checkpointIds = checkpointsFrom.map((c) => c.id);
618
+ const allBackups = [];
619
+ for (const cpId of checkpointIds) {
620
+ const backups = getDb().select().from(fileBackups).where(eq(fileBackups.checkpointId, cpId)).all();
621
+ allBackups.push(...backups);
622
+ }
623
+ return allBackups;
624
+ },
625
+ /**
626
+ * Check if a file already has a backup in the current checkpoint
627
+ */
628
+ hasBackup(checkpointId, filePath) {
629
+ const result = getDb().select().from(fileBackups).where(
630
+ and(
631
+ eq(fileBackups.checkpointId, checkpointId),
632
+ eq(fileBackups.filePath, filePath)
633
+ )
634
+ ).get();
635
+ return !!result;
444
636
  },
445
637
  deleteBySession(sessionId) {
446
- const result = getDb().delete(terminals).where(eq(terminals.sessionId, sessionId)).run();
638
+ const result = getDb().delete(fileBackups).where(eq(fileBackups.sessionId, sessionId)).run();
447
639
  return result.changes;
448
640
  }
449
641
  };
450
642
 
451
643
  // src/config/index.ts
452
- import { existsSync, readFileSync } from "fs";
453
- import { resolve, dirname } from "path";
644
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from "fs";
645
+ import { resolve, dirname, join } from "path";
646
+ import { homedir, platform } from "os";
454
647
 
455
648
  // src/config/types.ts
456
649
  import { z } from "zod";
@@ -511,6 +704,24 @@ var CONFIG_FILE_NAMES = [
511
704
  "sparkecoder.json",
512
705
  ".sparkecoder.json"
513
706
  ];
707
+ function getAppDataDirectory() {
708
+ const appName = "sparkecoder";
709
+ switch (platform()) {
710
+ case "darwin":
711
+ return join(homedir(), "Library", "Application Support", appName);
712
+ case "win32":
713
+ return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), appName);
714
+ default:
715
+ return join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), appName);
716
+ }
717
+ }
718
+ function ensureAppDataDirectory() {
719
+ const dir = getAppDataDirectory();
720
+ if (!existsSync(dir)) {
721
+ mkdirSync(dir, { recursive: true });
722
+ }
723
+ return dir;
724
+ }
514
725
  var cachedConfig = null;
515
726
  function findConfigFile(startDir) {
516
727
  let currentDir = startDir;
@@ -523,6 +734,13 @@ function findConfigFile(startDir) {
523
734
  }
524
735
  currentDir = dirname(currentDir);
525
736
  }
737
+ const appDataDir = getAppDataDirectory();
738
+ for (const fileName of CONFIG_FILE_NAMES) {
739
+ const configPath = join(appDataDir, fileName);
740
+ if (existsSync(configPath)) {
741
+ return configPath;
742
+ }
743
+ }
526
744
  return null;
527
745
  }
528
746
  function loadConfig(configPath, workingDirectory) {
@@ -557,7 +775,14 @@ function loadConfig(configPath, workingDirectory) {
557
775
  rawConfig.databasePath = process.env.DATABASE_PATH;
558
776
  }
559
777
  const config = SparkcoderConfigSchema.parse(rawConfig);
560
- const resolvedWorkingDirectory = config.workingDirectory ? resolve(configDir, config.workingDirectory) : cwd;
778
+ let resolvedWorkingDirectory;
779
+ if (workingDirectory) {
780
+ resolvedWorkingDirectory = workingDirectory;
781
+ } else if (config.workingDirectory && config.workingDirectory !== "." && config.workingDirectory.startsWith("/")) {
782
+ resolvedWorkingDirectory = config.workingDirectory;
783
+ } else {
784
+ resolvedWorkingDirectory = process.cwd();
785
+ }
561
786
  const resolvedSkillsDirectories = [
562
787
  resolve(configDir, config.skills?.directory || "./skills"),
563
788
  // Built-in skills
@@ -572,7 +797,13 @@ function loadConfig(configPath, workingDirectory) {
572
797
  return false;
573
798
  }
574
799
  });
575
- const resolvedDatabasePath = resolve(configDir, config.databasePath || "./sparkecoder.db");
800
+ let resolvedDatabasePath;
801
+ if (config.databasePath && config.databasePath !== "./sparkecoder.db") {
802
+ resolvedDatabasePath = resolve(configDir, config.databasePath);
803
+ } else {
804
+ const appDataDir = ensureAppDataDirectory();
805
+ resolvedDatabasePath = join(appDataDir, "sparkecoder.db");
806
+ }
576
807
  const resolved = {
577
808
  ...config,
578
809
  server: {
@@ -606,12 +837,104 @@ function requiresApproval(toolName, sessionConfig) {
606
837
  }
607
838
  return false;
608
839
  }
840
+ var API_KEYS_FILE = "api-keys.json";
841
+ var PROVIDER_ENV_MAP = {
842
+ anthropic: "ANTHROPIC_API_KEY",
843
+ openai: "OPENAI_API_KEY",
844
+ google: "GOOGLE_GENERATIVE_AI_API_KEY",
845
+ xai: "XAI_API_KEY",
846
+ "ai-gateway": "AI_GATEWAY_API_KEY"
847
+ };
848
+ var SUPPORTED_PROVIDERS = Object.keys(PROVIDER_ENV_MAP);
849
+ function getApiKeysPath() {
850
+ const appDir = ensureAppDataDirectory();
851
+ return join(appDir, API_KEYS_FILE);
852
+ }
853
+ function loadStoredApiKeys() {
854
+ const keysPath = getApiKeysPath();
855
+ if (!existsSync(keysPath)) {
856
+ return {};
857
+ }
858
+ try {
859
+ const content = readFileSync(keysPath, "utf-8");
860
+ return JSON.parse(content);
861
+ } catch {
862
+ return {};
863
+ }
864
+ }
865
+ function saveStoredApiKeys(keys) {
866
+ const keysPath = getApiKeysPath();
867
+ writeFileSync(keysPath, JSON.stringify(keys, null, 2), { mode: 384 });
868
+ }
869
+ function loadApiKeysIntoEnv() {
870
+ const storedKeys = loadStoredApiKeys();
871
+ for (const [provider, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
872
+ if (!process.env[envVar] && storedKeys[provider]) {
873
+ process.env[envVar] = storedKeys[provider];
874
+ }
875
+ }
876
+ }
877
+ function setApiKey(provider, apiKey) {
878
+ const normalizedProvider = provider.toLowerCase();
879
+ const envVar = PROVIDER_ENV_MAP[normalizedProvider];
880
+ if (!envVar) {
881
+ throw new Error(`Unknown provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`);
882
+ }
883
+ const storedKeys = loadStoredApiKeys();
884
+ storedKeys[normalizedProvider] = apiKey;
885
+ saveStoredApiKeys(storedKeys);
886
+ process.env[envVar] = apiKey;
887
+ }
888
+ function removeApiKey(provider) {
889
+ const normalizedProvider = provider.toLowerCase();
890
+ const envVar = PROVIDER_ENV_MAP[normalizedProvider];
891
+ if (!envVar) {
892
+ throw new Error(`Unknown provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`);
893
+ }
894
+ const storedKeys = loadStoredApiKeys();
895
+ delete storedKeys[normalizedProvider];
896
+ saveStoredApiKeys(storedKeys);
897
+ }
898
+ function getApiKeyStatus() {
899
+ const storedKeys = loadStoredApiKeys();
900
+ return SUPPORTED_PROVIDERS.map((provider) => {
901
+ const envVar = PROVIDER_ENV_MAP[provider];
902
+ const envValue = process.env[envVar];
903
+ const storedValue = storedKeys[provider];
904
+ let source = "none";
905
+ let value;
906
+ if (envValue) {
907
+ if (storedValue && envValue === storedValue) {
908
+ source = "storage";
909
+ } else {
910
+ source = "env";
911
+ }
912
+ value = envValue;
913
+ } else if (storedValue) {
914
+ source = "storage";
915
+ value = storedValue;
916
+ }
917
+ return {
918
+ provider,
919
+ envVar,
920
+ configured: !!value,
921
+ source,
922
+ maskedKey: value ? maskApiKey(value) : null
923
+ };
924
+ });
925
+ }
926
+ function maskApiKey(key) {
927
+ if (key.length <= 12) {
928
+ return "****" + key.slice(-4);
929
+ }
930
+ return key.slice(0, 4) + "..." + key.slice(-4);
931
+ }
609
932
 
610
933
  // src/tools/bash.ts
611
934
  import { tool } from "ai";
612
935
  import { z as z2 } from "zod";
613
- import { exec } from "child_process";
614
- import { promisify } from "util";
936
+ import { exec as exec2 } from "child_process";
937
+ import { promisify as promisify2 } from "util";
615
938
 
616
939
  // src/utils/truncate.ts
617
940
  var MAX_OUTPUT_CHARS = 1e4;
@@ -634,9 +957,318 @@ function calculateContextSize(messages2) {
634
957
  }, 0);
635
958
  }
636
959
 
637
- // src/tools/bash.ts
960
+ // src/terminal/tmux.ts
961
+ var tmux_exports = {};
962
+ __export(tmux_exports, {
963
+ generateTerminalId: () => generateTerminalId,
964
+ getLogDir: () => getLogDir,
965
+ getLogs: () => getLogs,
966
+ getMeta: () => getMeta,
967
+ getSessionName: () => getSessionName,
968
+ isRunning: () => isRunning,
969
+ isTmuxAvailable: () => isTmuxAvailable,
970
+ killTerminal: () => killTerminal,
971
+ listSessionTerminals: () => listSessionTerminals,
972
+ listSessions: () => listSessions,
973
+ runBackground: () => runBackground,
974
+ runSync: () => runSync,
975
+ sendInput: () => sendInput,
976
+ sendKey: () => sendKey
977
+ });
978
+ import { exec } from "child_process";
979
+ import { promisify } from "util";
980
+ import { mkdir, writeFile, readFile } from "fs/promises";
981
+ import { existsSync as existsSync2 } from "fs";
982
+ import { join as join2 } from "path";
983
+ import { nanoid as nanoid2 } from "nanoid";
638
984
  var execAsync = promisify(exec);
639
- var COMMAND_TIMEOUT = 6e4;
985
+ var SESSION_PREFIX = "spark_";
986
+ var LOG_BASE_DIR = ".sparkecoder/sessions";
987
+ var tmuxAvailableCache = null;
988
+ async function isTmuxAvailable() {
989
+ if (tmuxAvailableCache !== null) {
990
+ return tmuxAvailableCache;
991
+ }
992
+ try {
993
+ const { stdout } = await execAsync("tmux -V");
994
+ tmuxAvailableCache = true;
995
+ return true;
996
+ } catch (error) {
997
+ tmuxAvailableCache = false;
998
+ console.log(`[tmux] Not available: ${error instanceof Error ? error.message : "unknown error"}`);
999
+ return false;
1000
+ }
1001
+ }
1002
+ function generateTerminalId() {
1003
+ return "t" + nanoid2(9);
1004
+ }
1005
+ function getSessionName(terminalId) {
1006
+ return `${SESSION_PREFIX}${terminalId}`;
1007
+ }
1008
+ function getLogDir(terminalId, workingDirectory, sessionId) {
1009
+ if (sessionId) {
1010
+ return join2(workingDirectory, LOG_BASE_DIR, sessionId, "terminals", terminalId);
1011
+ }
1012
+ return join2(workingDirectory, ".sparkecoder/terminals", terminalId);
1013
+ }
1014
+ function shellEscape(str) {
1015
+ return `'${str.replace(/'/g, "'\\''")}'`;
1016
+ }
1017
+ async function initLogDir(terminalId, meta, workingDirectory) {
1018
+ const logDir = getLogDir(terminalId, workingDirectory, meta.sessionId);
1019
+ await mkdir(logDir, { recursive: true });
1020
+ await writeFile(join2(logDir, "meta.json"), JSON.stringify(meta, null, 2));
1021
+ await writeFile(join2(logDir, "output.log"), "");
1022
+ return logDir;
1023
+ }
1024
+ async function pollUntil(condition, options) {
1025
+ const { timeout, interval = 100 } = options;
1026
+ const startTime = Date.now();
1027
+ while (Date.now() - startTime < timeout) {
1028
+ if (await condition()) {
1029
+ return true;
1030
+ }
1031
+ await new Promise((r) => setTimeout(r, interval));
1032
+ }
1033
+ return false;
1034
+ }
1035
+ async function runSync(command, workingDirectory, options) {
1036
+ if (!options) {
1037
+ throw new Error("runSync: options parameter is required (must include sessionId)");
1038
+ }
1039
+ const id = options.terminalId || generateTerminalId();
1040
+ const session = getSessionName(id);
1041
+ const logDir = await initLogDir(id, {
1042
+ id,
1043
+ command,
1044
+ cwd: workingDirectory,
1045
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1046
+ sessionId: options.sessionId,
1047
+ background: false
1048
+ }, workingDirectory);
1049
+ const logFile = join2(logDir, "output.log");
1050
+ const exitCodeFile = join2(logDir, "exit_code");
1051
+ const timeout = options.timeout || 12e4;
1052
+ try {
1053
+ const wrappedCommand = `(${command}) 2>&1 | tee -a ${shellEscape(logFile)}; echo $? > ${shellEscape(exitCodeFile)}`;
1054
+ await execAsync(
1055
+ `tmux new-session -d -s ${session} -c ${shellEscape(workingDirectory)} ${shellEscape(wrappedCommand)}`,
1056
+ { timeout: 5e3 }
1057
+ );
1058
+ try {
1059
+ await execAsync(
1060
+ `tmux pipe-pane -t ${session} -o 'cat >> ${shellEscape(logFile)}'`,
1061
+ { timeout: 1e3 }
1062
+ );
1063
+ } catch {
1064
+ }
1065
+ const completed = await pollUntil(
1066
+ async () => {
1067
+ try {
1068
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
1069
+ return false;
1070
+ } catch {
1071
+ return true;
1072
+ }
1073
+ },
1074
+ { timeout, interval: 100 }
1075
+ );
1076
+ if (!completed) {
1077
+ try {
1078
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
1079
+ } catch {
1080
+ }
1081
+ let output2 = "";
1082
+ try {
1083
+ output2 = await readFile(logFile, "utf-8");
1084
+ } catch {
1085
+ }
1086
+ return {
1087
+ id,
1088
+ output: output2.trim(),
1089
+ exitCode: 124,
1090
+ // Standard timeout exit code
1091
+ status: "error"
1092
+ };
1093
+ }
1094
+ await new Promise((r) => setTimeout(r, 50));
1095
+ let output = "";
1096
+ try {
1097
+ output = await readFile(logFile, "utf-8");
1098
+ } catch {
1099
+ }
1100
+ let exitCode = 0;
1101
+ try {
1102
+ if (existsSync2(exitCodeFile)) {
1103
+ const exitCodeStr = await readFile(exitCodeFile, "utf-8");
1104
+ exitCode = parseInt(exitCodeStr.trim(), 10) || 0;
1105
+ }
1106
+ } catch {
1107
+ }
1108
+ return {
1109
+ id,
1110
+ output: output.trim(),
1111
+ exitCode,
1112
+ status: "completed"
1113
+ };
1114
+ } catch (error) {
1115
+ try {
1116
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
1117
+ } catch {
1118
+ }
1119
+ throw error;
1120
+ }
1121
+ }
1122
+ async function runBackground(command, workingDirectory, options) {
1123
+ if (!options) {
1124
+ throw new Error("runBackground: options parameter is required (must include sessionId)");
1125
+ }
1126
+ const id = options.terminalId || generateTerminalId();
1127
+ const session = getSessionName(id);
1128
+ const logDir = await initLogDir(id, {
1129
+ id,
1130
+ command,
1131
+ cwd: workingDirectory,
1132
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1133
+ sessionId: options.sessionId,
1134
+ background: true
1135
+ }, workingDirectory);
1136
+ const logFile = join2(logDir, "output.log");
1137
+ const wrappedCommand = `(${command}) 2>&1 | tee -a ${shellEscape(logFile)}`;
1138
+ await execAsync(
1139
+ `tmux new-session -d -s ${session} -c ${shellEscape(workingDirectory)} ${shellEscape(wrappedCommand)}`,
1140
+ { timeout: 5e3 }
1141
+ );
1142
+ return {
1143
+ id,
1144
+ output: "",
1145
+ exitCode: 0,
1146
+ status: "running"
1147
+ };
1148
+ }
1149
+ async function getLogs(terminalId, workingDirectory, options = {}) {
1150
+ const session = getSessionName(terminalId);
1151
+ const logDir = getLogDir(terminalId, workingDirectory, options.sessionId);
1152
+ const logFile = join2(logDir, "output.log");
1153
+ let isRunning2 = false;
1154
+ try {
1155
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 5e3 });
1156
+ isRunning2 = true;
1157
+ } catch {
1158
+ }
1159
+ if (isRunning2) {
1160
+ try {
1161
+ const lines = options.tail || 1e3;
1162
+ const { stdout } = await execAsync(
1163
+ `tmux capture-pane -t ${session} -p -S -${lines}`,
1164
+ { timeout: 5e3, maxBuffer: 10 * 1024 * 1024 }
1165
+ );
1166
+ return { output: stdout.trim(), status: "running" };
1167
+ } catch {
1168
+ }
1169
+ }
1170
+ try {
1171
+ let output = await readFile(logFile, "utf-8");
1172
+ if (options.tail) {
1173
+ const lines = output.split("\n");
1174
+ output = lines.slice(-options.tail).join("\n");
1175
+ }
1176
+ return { output: output.trim(), status: isRunning2 ? "running" : "stopped" };
1177
+ } catch {
1178
+ return { output: "", status: "unknown" };
1179
+ }
1180
+ }
1181
+ async function isRunning(terminalId) {
1182
+ const session = getSessionName(terminalId);
1183
+ try {
1184
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 5e3 });
1185
+ return true;
1186
+ } catch {
1187
+ return false;
1188
+ }
1189
+ }
1190
+ async function killTerminal(terminalId) {
1191
+ const session = getSessionName(terminalId);
1192
+ try {
1193
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
1194
+ return true;
1195
+ } catch {
1196
+ return false;
1197
+ }
1198
+ }
1199
+ async function listSessions() {
1200
+ try {
1201
+ const { stdout } = await execAsync(
1202
+ `tmux list-sessions -F '#{session_name}' 2>/dev/null || true`,
1203
+ { timeout: 5e3 }
1204
+ );
1205
+ return stdout.trim().split("\n").filter((name) => name.startsWith(SESSION_PREFIX)).map((name) => name.slice(SESSION_PREFIX.length));
1206
+ } catch {
1207
+ return [];
1208
+ }
1209
+ }
1210
+ async function getMeta(terminalId, workingDirectory, sessionId) {
1211
+ const logDir = getLogDir(terminalId, workingDirectory, sessionId);
1212
+ const metaFile = join2(logDir, "meta.json");
1213
+ try {
1214
+ const content = await readFile(metaFile, "utf-8");
1215
+ return JSON.parse(content);
1216
+ } catch {
1217
+ return null;
1218
+ }
1219
+ }
1220
+ async function listSessionTerminals(sessionId, workingDirectory) {
1221
+ const terminalsDir = join2(workingDirectory, LOG_BASE_DIR, sessionId, "terminals");
1222
+ const terminals3 = [];
1223
+ try {
1224
+ const { readdir: readdir2 } = await import("fs/promises");
1225
+ const entries = await readdir2(terminalsDir, { withFileTypes: true });
1226
+ for (const entry of entries) {
1227
+ if (entry.isDirectory()) {
1228
+ const meta = await getMeta(entry.name, workingDirectory, sessionId);
1229
+ if (meta) {
1230
+ terminals3.push(meta);
1231
+ }
1232
+ }
1233
+ }
1234
+ } catch {
1235
+ }
1236
+ return terminals3;
1237
+ }
1238
+ async function sendInput(terminalId, input, options = {}) {
1239
+ const session = getSessionName(terminalId);
1240
+ const { pressEnter = true } = options;
1241
+ try {
1242
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
1243
+ await execAsync(
1244
+ `tmux send-keys -t ${session} -l ${shellEscape(input)}`,
1245
+ { timeout: 1e3 }
1246
+ );
1247
+ if (pressEnter) {
1248
+ await execAsync(
1249
+ `tmux send-keys -t ${session} Enter`,
1250
+ { timeout: 1e3 }
1251
+ );
1252
+ }
1253
+ return true;
1254
+ } catch {
1255
+ return false;
1256
+ }
1257
+ }
1258
+ async function sendKey(terminalId, key) {
1259
+ const session = getSessionName(terminalId);
1260
+ try {
1261
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
1262
+ await execAsync(`tmux send-keys -t ${session} ${key}`, { timeout: 1e3 });
1263
+ return true;
1264
+ } catch {
1265
+ return false;
1266
+ }
1267
+ }
1268
+
1269
+ // src/tools/bash.ts
1270
+ var execAsync2 = promisify2(exec2);
1271
+ var COMMAND_TIMEOUT = 12e4;
640
1272
  var MAX_OUTPUT_CHARS2 = 1e4;
641
1273
  var BLOCKED_COMMANDS = [
642
1274
  "rm -rf /",
@@ -653,66 +1285,226 @@ function isBlockedCommand(command) {
653
1285
  );
654
1286
  }
655
1287
  var bashInputSchema = z2.object({
656
- command: z2.string().describe("The bash command to execute. Can be a single command or a pipeline.")
1288
+ command: z2.string().optional().describe("The command to execute. Required for running new commands."),
1289
+ background: z2.boolean().default(false).describe("Run the command in background mode (for dev servers, watchers). Returns immediately with terminal ID."),
1290
+ id: z2.string().optional().describe("Terminal ID. Use to get logs from, send input to, or kill an existing terminal."),
1291
+ kill: z2.boolean().optional().describe("Kill the terminal with the given ID."),
1292
+ tail: z2.number().optional().describe("Number of lines to return from the end of output (for logs)."),
1293
+ input: z2.string().optional().describe("Send text input to an interactive terminal (requires id). Used for responding to prompts."),
1294
+ key: z2.enum(["Enter", "Escape", "Up", "Down", "Left", "Right", "Tab", "C-c", "C-d", "y", "n"]).optional().describe('Send a special key to an interactive terminal (requires id). Use "y" or "n" for yes/no prompts.')
657
1295
  });
658
- function createBashTool(options) {
1296
+ var useTmux = null;
1297
+ async function shouldUseTmux() {
1298
+ if (useTmux === null) {
1299
+ useTmux = await isTmuxAvailable();
1300
+ if (!useTmux) {
1301
+ console.warn("[bash] tmux not available, using fallback exec mode");
1302
+ }
1303
+ }
1304
+ return useTmux;
1305
+ }
1306
+ async function execFallback(command, workingDirectory, onOutput) {
1307
+ try {
1308
+ const { stdout, stderr } = await execAsync2(command, {
1309
+ cwd: workingDirectory,
1310
+ timeout: COMMAND_TIMEOUT,
1311
+ maxBuffer: 10 * 1024 * 1024
1312
+ });
1313
+ const output = truncateOutput(stdout + (stderr ? `
1314
+ ${stderr}` : ""), MAX_OUTPUT_CHARS2);
1315
+ onOutput?.(output);
1316
+ return {
1317
+ success: true,
1318
+ output,
1319
+ exitCode: 0
1320
+ };
1321
+ } catch (error) {
1322
+ const output = truncateOutput(
1323
+ (error.stdout || "") + (error.stderr ? `
1324
+ ${error.stderr}` : ""),
1325
+ MAX_OUTPUT_CHARS2
1326
+ );
1327
+ onOutput?.(output || error.message);
1328
+ if (error.killed) {
1329
+ return {
1330
+ success: false,
1331
+ error: `Command timed out after ${COMMAND_TIMEOUT / 1e3} seconds`,
1332
+ output,
1333
+ exitCode: 124
1334
+ };
1335
+ }
1336
+ return {
1337
+ success: false,
1338
+ error: error.message,
1339
+ output,
1340
+ exitCode: error.code ?? 1
1341
+ };
1342
+ }
1343
+ }
1344
+ function createBashTool(options) {
659
1345
  return tool({
660
- description: `Execute a bash command in the terminal. The command runs in the working directory: ${options.workingDirectory}.
661
- Use this for running shell commands, scripts, git operations, package managers (npm, pip, etc.), and other CLI tools.
662
- Long outputs will be automatically truncated. Commands have a 60 second timeout.
663
- IMPORTANT: Avoid destructive commands. Be careful with rm, chmod, and similar operations.`,
1346
+ description: `Execute commands in the terminal. Every command runs in its own session with logs saved to disk.
1347
+
1348
+ **Run a command (default - waits for completion):**
1349
+ bash({ command: "npm install" })
1350
+ bash({ command: "git status" })
1351
+
1352
+ **Run in background (for dev servers, watchers, or interactive commands):**
1353
+ bash({ command: "npm run dev", background: true })
1354
+ \u2192 Returns { id: "abc123" } - save this ID
1355
+
1356
+ **Check on a background process:**
1357
+ bash({ id: "abc123" })
1358
+ bash({ id: "abc123", tail: 50 }) // last 50 lines only
1359
+
1360
+ **Stop a background process:**
1361
+ bash({ id: "abc123", kill: true })
1362
+
1363
+ **Respond to interactive prompts (for yes/no questions, etc.):**
1364
+ bash({ id: "abc123", key: "y" }) // send 'y' for yes
1365
+ bash({ id: "abc123", key: "n" }) // send 'n' for no
1366
+ bash({ id: "abc123", key: "Enter" }) // press Enter
1367
+ bash({ id: "abc123", input: "my text" }) // send text input
1368
+
1369
+ **IMPORTANT for interactive commands:**
1370
+ - Use --yes, -y, or similar flags to avoid prompts when available
1371
+ - For create-next-app: add --yes to accept defaults
1372
+ - For npm: add --yes or -y to skip confirmation
1373
+ - If prompts are unavoidable, run in background mode and use input/key to respond
1374
+
1375
+ Logs are saved to .sparkecoder/terminals/{id}/output.log`,
664
1376
  inputSchema: bashInputSchema,
665
- execute: async ({ command }) => {
1377
+ execute: async (inputArgs) => {
1378
+ const { command, background, id, kill, tail, input: textInput, key } = inputArgs;
1379
+ if (id) {
1380
+ if (kill) {
1381
+ const success = await killTerminal(id);
1382
+ return {
1383
+ success,
1384
+ id,
1385
+ status: success ? "stopped" : "not_found",
1386
+ message: success ? `Terminal ${id} stopped` : `Terminal ${id} not found or already stopped`
1387
+ };
1388
+ }
1389
+ if (textInput !== void 0) {
1390
+ const success = await sendInput(id, textInput, { pressEnter: true });
1391
+ if (!success) {
1392
+ return {
1393
+ success: false,
1394
+ id,
1395
+ error: `Terminal ${id} not found or not running`
1396
+ };
1397
+ }
1398
+ await new Promise((r) => setTimeout(r, 300));
1399
+ const { output: output2, status: status2 } = await getLogs(id, options.workingDirectory, { tail: tail || 50, sessionId: options.sessionId });
1400
+ const truncatedOutput2 = truncateOutput(output2, MAX_OUTPUT_CHARS2);
1401
+ return {
1402
+ success: true,
1403
+ id,
1404
+ output: truncatedOutput2,
1405
+ status: status2,
1406
+ message: `Sent input "${textInput}" to terminal`
1407
+ };
1408
+ }
1409
+ if (key) {
1410
+ const success = await sendKey(id, key);
1411
+ if (!success) {
1412
+ return {
1413
+ success: false,
1414
+ id,
1415
+ error: `Terminal ${id} not found or not running`
1416
+ };
1417
+ }
1418
+ await new Promise((r) => setTimeout(r, 300));
1419
+ const { output: output2, status: status2 } = await getLogs(id, options.workingDirectory, { tail: tail || 50, sessionId: options.sessionId });
1420
+ const truncatedOutput2 = truncateOutput(output2, MAX_OUTPUT_CHARS2);
1421
+ return {
1422
+ success: true,
1423
+ id,
1424
+ output: truncatedOutput2,
1425
+ status: status2,
1426
+ message: `Sent key "${key}" to terminal`
1427
+ };
1428
+ }
1429
+ const { output, status } = await getLogs(id, options.workingDirectory, { tail, sessionId: options.sessionId });
1430
+ const truncatedOutput = truncateOutput(output, MAX_OUTPUT_CHARS2);
1431
+ return {
1432
+ success: true,
1433
+ id,
1434
+ output: truncatedOutput,
1435
+ status
1436
+ };
1437
+ }
1438
+ if (!command) {
1439
+ return {
1440
+ success: false,
1441
+ error: 'Either "command" (to run a new command) or "id" (to check/kill/send input) is required'
1442
+ };
1443
+ }
666
1444
  if (isBlockedCommand(command)) {
667
1445
  return {
668
1446
  success: false,
669
1447
  error: "This command is blocked for safety reasons.",
670
- stdout: "",
671
- stderr: "",
1448
+ output: "",
672
1449
  exitCode: 1
673
1450
  };
674
1451
  }
675
- try {
676
- const { stdout, stderr } = await execAsync(command, {
677
- cwd: options.workingDirectory,
678
- timeout: COMMAND_TIMEOUT,
679
- maxBuffer: 10 * 1024 * 1024,
680
- // 10MB buffer
681
- shell: "/bin/bash"
682
- });
683
- const truncatedStdout = truncateOutput(stdout, MAX_OUTPUT_CHARS2);
684
- const truncatedStderr = truncateOutput(stderr, MAX_OUTPUT_CHARS2 / 2);
685
- if (options.onOutput) {
686
- options.onOutput(truncatedStdout);
1452
+ const canUseTmux = await shouldUseTmux();
1453
+ if (background) {
1454
+ if (!canUseTmux) {
1455
+ return {
1456
+ success: false,
1457
+ error: "Background mode requires tmux to be installed. Install with: brew install tmux (macOS) or apt install tmux (Linux)"
1458
+ };
687
1459
  }
1460
+ const terminalId = generateTerminalId();
1461
+ options.onProgress?.({ terminalId, status: "started", command });
1462
+ const result = await runBackground(command, options.workingDirectory, {
1463
+ sessionId: options.sessionId,
1464
+ terminalId
1465
+ });
688
1466
  return {
689
1467
  success: true,
690
- stdout: truncatedStdout,
691
- stderr: truncatedStderr,
692
- exitCode: 0
1468
+ id: result.id,
1469
+ status: "running",
1470
+ message: `Started background process. Use bash({ id: "${result.id}" }) to check logs.`
693
1471
  };
694
- } catch (error) {
695
- const stdout = error.stdout ? truncateOutput(error.stdout, MAX_OUTPUT_CHARS2) : "";
696
- const stderr = error.stderr ? truncateOutput(error.stderr, MAX_OUTPUT_CHARS2) : "";
697
- if (options.onOutput) {
698
- options.onOutput(stderr || error.message);
699
- }
700
- if (error.killed) {
1472
+ }
1473
+ if (canUseTmux) {
1474
+ const terminalId = generateTerminalId();
1475
+ options.onProgress?.({ terminalId, status: "started", command });
1476
+ try {
1477
+ const result = await runSync(command, options.workingDirectory, {
1478
+ sessionId: options.sessionId,
1479
+ timeout: COMMAND_TIMEOUT,
1480
+ terminalId
1481
+ });
1482
+ const truncatedOutput = truncateOutput(result.output, MAX_OUTPUT_CHARS2);
1483
+ options.onOutput?.(truncatedOutput);
1484
+ options.onProgress?.({ terminalId, status: "completed", command });
1485
+ return {
1486
+ success: result.exitCode === 0,
1487
+ id: result.id,
1488
+ output: truncatedOutput,
1489
+ exitCode: result.exitCode,
1490
+ status: result.status
1491
+ };
1492
+ } catch (error) {
1493
+ options.onProgress?.({ terminalId, status: "completed", command });
701
1494
  return {
702
1495
  success: false,
703
- error: `Command timed out after ${COMMAND_TIMEOUT / 1e3} seconds`,
704
- stdout,
705
- stderr,
706
- exitCode: 124
707
- // Standard timeout exit code
1496
+ error: error.message,
1497
+ output: "",
1498
+ exitCode: 1
708
1499
  };
709
1500
  }
1501
+ } else {
1502
+ const result = await execFallback(command, options.workingDirectory, options.onOutput);
710
1503
  return {
711
- success: false,
712
- error: error.message,
713
- stdout,
714
- stderr,
715
- exitCode: error.code ?? 1
1504
+ success: result.success,
1505
+ output: result.output,
1506
+ exitCode: result.exitCode,
1507
+ error: result.error
716
1508
  };
717
1509
  }
718
1510
  }
@@ -722,9 +1514,9 @@ IMPORTANT: Avoid destructive commands. Be careful with rm, chmod, and similar op
722
1514
  // src/tools/read-file.ts
723
1515
  import { tool as tool2 } from "ai";
724
1516
  import { z as z3 } from "zod";
725
- import { readFile, stat } from "fs/promises";
1517
+ import { readFile as readFile2, stat } from "fs/promises";
726
1518
  import { resolve as resolve2, relative, isAbsolute } from "path";
727
- import { existsSync as existsSync2 } from "fs";
1519
+ import { existsSync as existsSync3 } from "fs";
728
1520
  var MAX_FILE_SIZE = 5 * 1024 * 1024;
729
1521
  var MAX_OUTPUT_CHARS3 = 5e4;
730
1522
  var readFileInputSchema = z3.object({
@@ -749,7 +1541,7 @@ Use this to understand existing code, check file contents, or gather context.`,
749
1541
  content: null
750
1542
  };
751
1543
  }
752
- if (!existsSync2(absolutePath)) {
1544
+ if (!existsSync3(absolutePath)) {
753
1545
  return {
754
1546
  success: false,
755
1547
  error: `File not found: ${path}`,
@@ -771,7 +1563,7 @@ Use this to understand existing code, check file contents, or gather context.`,
771
1563
  content: null
772
1564
  };
773
1565
  }
774
- let content = await readFile(absolutePath, "utf-8");
1566
+ let content = await readFile2(absolutePath, "utf-8");
775
1567
  if (startLine !== void 0 || endLine !== void 0) {
776
1568
  const lines = content.split("\n");
777
1569
  const start = (startLine ?? 1) - 1;
@@ -819,9 +1611,198 @@ Use this to understand existing code, check file contents, or gather context.`,
819
1611
  // src/tools/write-file.ts
820
1612
  import { tool as tool3 } from "ai";
821
1613
  import { z as z4 } from "zod";
822
- import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
823
- import { resolve as resolve3, relative as relative2, isAbsolute as isAbsolute2, dirname as dirname2 } from "path";
824
- import { existsSync as existsSync3 } from "fs";
1614
+ import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
1615
+ import { resolve as resolve4, relative as relative3, isAbsolute as isAbsolute2, dirname as dirname3 } from "path";
1616
+ import { existsSync as existsSync5 } from "fs";
1617
+
1618
+ // src/checkpoints/index.ts
1619
+ import { readFile as readFile3, writeFile as writeFile2, unlink, mkdir as mkdir2 } from "fs/promises";
1620
+ import { existsSync as existsSync4 } from "fs";
1621
+ import { resolve as resolve3, relative as relative2, dirname as dirname2 } from "path";
1622
+ import { exec as exec3 } from "child_process";
1623
+ import { promisify as promisify3 } from "util";
1624
+ var execAsync3 = promisify3(exec3);
1625
+ async function getGitHead(workingDirectory) {
1626
+ try {
1627
+ const { stdout } = await execAsync3("git rev-parse HEAD", {
1628
+ cwd: workingDirectory,
1629
+ timeout: 5e3
1630
+ });
1631
+ return stdout.trim();
1632
+ } catch {
1633
+ return void 0;
1634
+ }
1635
+ }
1636
+ var activeManagers = /* @__PURE__ */ new Map();
1637
+ function getCheckpointManager(sessionId, workingDirectory) {
1638
+ let manager = activeManagers.get(sessionId);
1639
+ if (!manager) {
1640
+ manager = {
1641
+ sessionId,
1642
+ workingDirectory,
1643
+ currentCheckpointId: null
1644
+ };
1645
+ activeManagers.set(sessionId, manager);
1646
+ }
1647
+ return manager;
1648
+ }
1649
+ async function createCheckpoint(sessionId, workingDirectory, messageSequence) {
1650
+ const gitHead = await getGitHead(workingDirectory);
1651
+ const checkpoint = checkpointQueries.create({
1652
+ sessionId,
1653
+ messageSequence,
1654
+ gitHead
1655
+ });
1656
+ const manager = getCheckpointManager(sessionId, workingDirectory);
1657
+ manager.currentCheckpointId = checkpoint.id;
1658
+ return checkpoint;
1659
+ }
1660
+ async function backupFile(sessionId, workingDirectory, filePath) {
1661
+ const manager = getCheckpointManager(sessionId, workingDirectory);
1662
+ if (!manager.currentCheckpointId) {
1663
+ console.warn("[checkpoint] No active checkpoint, skipping file backup");
1664
+ return null;
1665
+ }
1666
+ const absolutePath = resolve3(workingDirectory, filePath);
1667
+ const relativePath = relative2(workingDirectory, absolutePath);
1668
+ if (fileBackupQueries.hasBackup(manager.currentCheckpointId, relativePath)) {
1669
+ return null;
1670
+ }
1671
+ let originalContent = null;
1672
+ let existed = false;
1673
+ if (existsSync4(absolutePath)) {
1674
+ try {
1675
+ originalContent = await readFile3(absolutePath, "utf-8");
1676
+ existed = true;
1677
+ } catch (error) {
1678
+ console.warn(`[checkpoint] Failed to read file for backup: ${error.message}`);
1679
+ }
1680
+ }
1681
+ const backup = fileBackupQueries.create({
1682
+ checkpointId: manager.currentCheckpointId,
1683
+ sessionId,
1684
+ filePath: relativePath,
1685
+ originalContent,
1686
+ existed
1687
+ });
1688
+ return backup;
1689
+ }
1690
+ async function revertToCheckpoint(sessionId, checkpointId) {
1691
+ const session = sessionQueries.getById(sessionId);
1692
+ if (!session) {
1693
+ return {
1694
+ success: false,
1695
+ filesRestored: 0,
1696
+ filesDeleted: 0,
1697
+ messagesDeleted: 0,
1698
+ checkpointsDeleted: 0,
1699
+ error: "Session not found"
1700
+ };
1701
+ }
1702
+ const checkpoint = checkpointQueries.getById(checkpointId);
1703
+ if (!checkpoint || checkpoint.sessionId !== sessionId) {
1704
+ return {
1705
+ success: false,
1706
+ filesRestored: 0,
1707
+ filesDeleted: 0,
1708
+ messagesDeleted: 0,
1709
+ checkpointsDeleted: 0,
1710
+ error: "Checkpoint not found"
1711
+ };
1712
+ }
1713
+ const workingDirectory = session.workingDirectory;
1714
+ const backupsToRevert = fileBackupQueries.getFromSequence(sessionId, checkpoint.messageSequence);
1715
+ const fileToEarliestBackup = /* @__PURE__ */ new Map();
1716
+ for (const backup of backupsToRevert) {
1717
+ if (!fileToEarliestBackup.has(backup.filePath)) {
1718
+ fileToEarliestBackup.set(backup.filePath, backup);
1719
+ }
1720
+ }
1721
+ let filesRestored = 0;
1722
+ let filesDeleted = 0;
1723
+ for (const [filePath, backup] of fileToEarliestBackup) {
1724
+ const absolutePath = resolve3(workingDirectory, filePath);
1725
+ try {
1726
+ if (backup.existed && backup.originalContent !== null) {
1727
+ const dir = dirname2(absolutePath);
1728
+ if (!existsSync4(dir)) {
1729
+ await mkdir2(dir, { recursive: true });
1730
+ }
1731
+ await writeFile2(absolutePath, backup.originalContent, "utf-8");
1732
+ filesRestored++;
1733
+ } else if (!backup.existed) {
1734
+ if (existsSync4(absolutePath)) {
1735
+ await unlink(absolutePath);
1736
+ filesDeleted++;
1737
+ }
1738
+ }
1739
+ } catch (error) {
1740
+ console.error(`Failed to restore ${filePath}: ${error.message}`);
1741
+ }
1742
+ }
1743
+ const messagesDeleted = messageQueries.deleteFromSequence(sessionId, checkpoint.messageSequence);
1744
+ toolExecutionQueries.deleteAfterTime(sessionId, checkpoint.createdAt);
1745
+ const checkpointsDeleted = checkpointQueries.deleteAfterSequence(sessionId, checkpoint.messageSequence);
1746
+ const manager = getCheckpointManager(sessionId, workingDirectory);
1747
+ manager.currentCheckpointId = checkpoint.id;
1748
+ return {
1749
+ success: true,
1750
+ filesRestored,
1751
+ filesDeleted,
1752
+ messagesDeleted,
1753
+ checkpointsDeleted
1754
+ };
1755
+ }
1756
+ function getCheckpoints(sessionId) {
1757
+ return checkpointQueries.getBySession(sessionId);
1758
+ }
1759
+ async function getSessionDiff(sessionId) {
1760
+ const session = sessionQueries.getById(sessionId);
1761
+ if (!session) {
1762
+ return { files: [] };
1763
+ }
1764
+ const workingDirectory = session.workingDirectory;
1765
+ const allBackups = fileBackupQueries.getBySession(sessionId);
1766
+ const fileToOriginalBackup = /* @__PURE__ */ new Map();
1767
+ for (const backup of allBackups) {
1768
+ if (!fileToOriginalBackup.has(backup.filePath)) {
1769
+ fileToOriginalBackup.set(backup.filePath, backup);
1770
+ }
1771
+ }
1772
+ const files = [];
1773
+ for (const [filePath, originalBackup] of fileToOriginalBackup) {
1774
+ const absolutePath = resolve3(workingDirectory, filePath);
1775
+ let currentContent = null;
1776
+ let currentExists = false;
1777
+ if (existsSync4(absolutePath)) {
1778
+ try {
1779
+ currentContent = await readFile3(absolutePath, "utf-8");
1780
+ currentExists = true;
1781
+ } catch {
1782
+ }
1783
+ }
1784
+ let status;
1785
+ if (!originalBackup.existed && currentExists) {
1786
+ status = "created";
1787
+ } else if (originalBackup.existed && !currentExists) {
1788
+ status = "deleted";
1789
+ } else {
1790
+ status = "modified";
1791
+ }
1792
+ files.push({
1793
+ path: filePath,
1794
+ status,
1795
+ originalContent: originalBackup.originalContent,
1796
+ currentContent
1797
+ });
1798
+ }
1799
+ return { files };
1800
+ }
1801
+ function clearCheckpointManager(sessionId) {
1802
+ activeManagers.delete(sessionId);
1803
+ }
1804
+
1805
+ // src/tools/write-file.ts
825
1806
  var writeFileInputSchema = z4.object({
826
1807
  path: z4.string().describe("The path to the file. Can be relative to working directory or absolute."),
827
1808
  mode: z4.enum(["full", "str_replace"]).describe('Write mode: "full" for complete file write, "str_replace" for targeted string replacement'),
@@ -850,8 +1831,8 @@ Working directory: ${options.workingDirectory}`,
850
1831
  inputSchema: writeFileInputSchema,
851
1832
  execute: async ({ path, mode, content, old_string, new_string }) => {
852
1833
  try {
853
- const absolutePath = isAbsolute2(path) ? path : resolve3(options.workingDirectory, path);
854
- const relativePath = relative2(options.workingDirectory, absolutePath);
1834
+ const absolutePath = isAbsolute2(path) ? path : resolve4(options.workingDirectory, path);
1835
+ const relativePath = relative3(options.workingDirectory, absolutePath);
855
1836
  if (relativePath.startsWith("..") && !isAbsolute2(path)) {
856
1837
  return {
857
1838
  success: false,
@@ -865,16 +1846,17 @@ Working directory: ${options.workingDirectory}`,
865
1846
  error: 'Content is required for "full" mode'
866
1847
  };
867
1848
  }
868
- const dir = dirname2(absolutePath);
869
- if (!existsSync3(dir)) {
870
- await mkdir(dir, { recursive: true });
1849
+ await backupFile(options.sessionId, options.workingDirectory, absolutePath);
1850
+ const dir = dirname3(absolutePath);
1851
+ if (!existsSync5(dir)) {
1852
+ await mkdir3(dir, { recursive: true });
871
1853
  }
872
- const existed = existsSync3(absolutePath);
873
- await writeFile(absolutePath, content, "utf-8");
1854
+ const existed = existsSync5(absolutePath);
1855
+ await writeFile3(absolutePath, content, "utf-8");
874
1856
  return {
875
1857
  success: true,
876
1858
  path: absolutePath,
877
- relativePath: relative2(options.workingDirectory, absolutePath),
1859
+ relativePath: relative3(options.workingDirectory, absolutePath),
878
1860
  mode: "full",
879
1861
  action: existed ? "replaced" : "created",
880
1862
  bytesWritten: Buffer.byteLength(content, "utf-8"),
@@ -887,13 +1869,14 @@ Working directory: ${options.workingDirectory}`,
887
1869
  error: 'Both old_string and new_string are required for "str_replace" mode'
888
1870
  };
889
1871
  }
890
- if (!existsSync3(absolutePath)) {
1872
+ if (!existsSync5(absolutePath)) {
891
1873
  return {
892
1874
  success: false,
893
1875
  error: `File not found: ${path}. Use "full" mode to create new files.`
894
1876
  };
895
1877
  }
896
- const currentContent = await readFile2(absolutePath, "utf-8");
1878
+ await backupFile(options.sessionId, options.workingDirectory, absolutePath);
1879
+ const currentContent = await readFile4(absolutePath, "utf-8");
897
1880
  if (!currentContent.includes(old_string)) {
898
1881
  const lines = currentContent.split("\n");
899
1882
  const preview = lines.slice(0, 20).join("\n");
@@ -914,13 +1897,13 @@ Working directory: ${options.workingDirectory}`,
914
1897
  };
915
1898
  }
916
1899
  const newContent = currentContent.replace(old_string, new_string);
917
- await writeFile(absolutePath, newContent, "utf-8");
1900
+ await writeFile3(absolutePath, newContent, "utf-8");
918
1901
  const oldLines = old_string.split("\n").length;
919
1902
  const newLines = new_string.split("\n").length;
920
1903
  return {
921
1904
  success: true,
922
1905
  path: absolutePath,
923
- relativePath: relative2(options.workingDirectory, absolutePath),
1906
+ relativePath: relative3(options.workingDirectory, absolutePath),
924
1907
  mode: "str_replace",
925
1908
  linesRemoved: oldLines,
926
1909
  linesAdded: newLines,
@@ -1071,9 +2054,9 @@ import { tool as tool5 } from "ai";
1071
2054
  import { z as z6 } from "zod";
1072
2055
 
1073
2056
  // src/skills/index.ts
1074
- import { readFile as readFile3, readdir } from "fs/promises";
1075
- import { resolve as resolve4, basename, extname } from "path";
1076
- import { existsSync as existsSync4 } from "fs";
2057
+ import { readFile as readFile5, readdir } from "fs/promises";
2058
+ import { resolve as resolve5, basename, extname } from "path";
2059
+ import { existsSync as existsSync6 } from "fs";
1077
2060
  function parseSkillFrontmatter(content) {
1078
2061
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1079
2062
  if (!frontmatterMatch) {
@@ -1104,15 +2087,15 @@ function getSkillNameFromPath(filePath) {
1104
2087
  return basename(filePath, extname(filePath)).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1105
2088
  }
1106
2089
  async function loadSkillsFromDirectory(directory) {
1107
- if (!existsSync4(directory)) {
2090
+ if (!existsSync6(directory)) {
1108
2091
  return [];
1109
2092
  }
1110
2093
  const skills = [];
1111
2094
  const files = await readdir(directory);
1112
2095
  for (const file of files) {
1113
2096
  if (!file.endsWith(".md")) continue;
1114
- const filePath = resolve4(directory, file);
1115
- const content = await readFile3(filePath, "utf-8");
2097
+ const filePath = resolve5(directory, file);
2098
+ const content = await readFile5(filePath, "utf-8");
1116
2099
  const parsed = parseSkillFrontmatter(content);
1117
2100
  if (parsed) {
1118
2101
  skills.push({
@@ -1154,7 +2137,7 @@ async function loadSkillContent(skillName, directories) {
1154
2137
  if (!skill) {
1155
2138
  return null;
1156
2139
  }
1157
- const content = await readFile3(skill.filePath, "utf-8");
2140
+ const content = await readFile5(skill.filePath, "utf-8");
1158
2141
  const parsed = parseSkillFrontmatter(content);
1159
2142
  return {
1160
2143
  ...skill,
@@ -1252,551 +2235,209 @@ Once loaded, a skill's content will be available in the conversation context.`,
1252
2235
  });
1253
2236
  }
1254
2237
 
1255
- // src/tools/terminal.ts
1256
- import { tool as tool6 } from "ai";
1257
- import { z as z7 } from "zod";
2238
+ // src/tools/index.ts
2239
+ function createTools(options) {
2240
+ return {
2241
+ bash: createBashTool({
2242
+ workingDirectory: options.workingDirectory,
2243
+ sessionId: options.sessionId,
2244
+ onOutput: options.onBashOutput,
2245
+ onProgress: options.onBashProgress
2246
+ }),
2247
+ read_file: createReadFileTool({
2248
+ workingDirectory: options.workingDirectory
2249
+ }),
2250
+ write_file: createWriteFileTool({
2251
+ workingDirectory: options.workingDirectory,
2252
+ sessionId: options.sessionId
2253
+ }),
2254
+ todo: createTodoTool({
2255
+ sessionId: options.sessionId
2256
+ }),
2257
+ load_skill: createLoadSkillTool({
2258
+ sessionId: options.sessionId,
2259
+ skillsDirectories: options.skillsDirectories
2260
+ })
2261
+ };
2262
+ }
1258
2263
 
1259
- // src/terminal/manager.ts
1260
- import { spawn } from "child_process";
1261
- import { EventEmitter } from "events";
1262
- var LogBuffer = class {
1263
- buffer = [];
1264
- maxSize;
1265
- totalBytes = 0;
1266
- maxBytes;
1267
- constructor(maxBytes = 50 * 1024) {
1268
- this.maxBytes = maxBytes;
1269
- this.maxSize = 1e3;
1270
- }
1271
- append(data) {
1272
- const lines = data.split("\n");
1273
- for (const line of lines) {
1274
- if (line) {
1275
- this.buffer.push(line);
1276
- this.totalBytes += line.length;
1277
- }
1278
- }
1279
- while (this.totalBytes > this.maxBytes && this.buffer.length > 1) {
1280
- const removed = this.buffer.shift();
1281
- if (removed) {
1282
- this.totalBytes -= removed.length;
1283
- }
1284
- }
1285
- while (this.buffer.length > this.maxSize) {
1286
- const removed = this.buffer.shift();
1287
- if (removed) {
1288
- this.totalBytes -= removed.length;
1289
- }
1290
- }
2264
+ // src/agent/context.ts
2265
+ import { generateText } from "ai";
2266
+ import { gateway } from "@ai-sdk/gateway";
2267
+
2268
+ // src/agent/prompts.ts
2269
+ import os from "os";
2270
+ function getSearchInstructions() {
2271
+ const platform3 = process.platform;
2272
+ const common = `- **Prefer \`read_file\` over shell commands** for reading files - don't use \`cat\`, \`head\`, or \`tail\` when \`read_file\` is available
2273
+ - **Avoid unbounded searches** - always scope searches with glob patterns and directory paths to prevent overwhelming output
2274
+ - **Search strategically**: Start with specific patterns and directories, then broaden only if needed`;
2275
+ if (platform3 === "win32") {
2276
+ return `${common}
2277
+ - **Find files**: \`dir /s /b *.ts\` or PowerShell: \`Get-ChildItem -Recurse -Filter *.ts\`
2278
+ - **Search content**: \`findstr /s /n "pattern" *.ts\` or PowerShell: \`Select-String -Pattern "pattern" -Path *.ts -Recurse\`
2279
+ - **If ripgrep (\`rg\`) is installed**: \`rg "pattern" -t ts src/\` - faster and respects .gitignore`;
1291
2280
  }
1292
- getAll() {
1293
- return this.buffer.join("\n");
2281
+ return `${common}
2282
+ - **Find files**: \`find . -name "*.ts"\` or \`find src/ -type f -name "*.tsx"\`
2283
+ - **Search content**: \`grep -rn "pattern" --include="*.ts" src/\` - use \`-l\` for filenames only, \`-c\` for counts
2284
+ - **If ripgrep (\`rg\`) is installed**: \`rg "pattern" -t ts src/\` - faster and respects .gitignore`;
2285
+ }
2286
+ async function buildSystemPrompt(options) {
2287
+ const { workingDirectory, skillsDirectories, sessionId, customInstructions } = options;
2288
+ const skills = await loadAllSkills(skillsDirectories);
2289
+ const skillsContext = formatSkillsForContext(skills);
2290
+ const todos = todoQueries.getBySession(sessionId);
2291
+ const todosContext = formatTodosForContext(todos);
2292
+ const platform3 = process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux";
2293
+ const currentDate = (/* @__PURE__ */ new Date()).toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" });
2294
+ const searchInstructions = getSearchInstructions();
2295
+ const systemPrompt = `You are SparkECoder, an expert AI coding assistant. You help developers write, debug, and improve code.
2296
+
2297
+ ## Environment
2298
+ - **Platform**: ${platform3} (${os.release()})
2299
+ - **Date**: ${currentDate}
2300
+ - **Working Directory**: ${workingDirectory}
2301
+
2302
+ ## Core Capabilities
2303
+ You have access to powerful tools for:
2304
+ - **bash**: Execute commands in the terminal (see below for details)
2305
+ - **read_file**: Read file contents to understand code and context
2306
+ - **write_file**: Create new files or edit existing ones (supports targeted string replacement)
2307
+ - **todo**: Manage your task list to track progress on complex operations
2308
+ - **load_skill**: Load specialized knowledge documents for specific tasks
2309
+
2310
+
2311
+ IMPORTANT: If you have zero context of where you are working, always explore it first to understand the structure before doing things for the user.
2312
+
2313
+ Use the TODO tool to manage your task list to track progress on complex operations. Always ask the user what they want to do specifically before doing it, and make a plan.
2314
+ Step 1 of the plan should be researching files and understanding the components/structure of what you're working on (if you don't already have context), then after u have done that, plan out the rest of the tasks u need to do.
2315
+ You can clear the todo and restart it, and do multiple things inside of one session.
2316
+
2317
+ ### bash Tool
2318
+ The bash tool runs commands in the terminal. Every command runs in its own session with logs saved to disk.
2319
+
2320
+ **Run a command (default - waits for completion):**
2321
+ \`\`\`
2322
+ bash({ command: "npm install" })
2323
+ bash({ command: "git status" })
2324
+ \`\`\`
2325
+
2326
+ **Run in background (for dev servers, watchers):**
2327
+ \`\`\`
2328
+ bash({ command: "npm run dev", background: true })
2329
+ \u2192 Returns { id: "abc123" } - save this ID to check logs or stop it later
2330
+ \`\`\`
2331
+
2332
+ **Check on a background process:**
2333
+ \`\`\`
2334
+ bash({ id: "abc123" }) // get full output
2335
+ bash({ id: "abc123", tail: 50 }) // last 50 lines only
2336
+ \`\`\`
2337
+
2338
+ **Stop a background process:**
2339
+ \`\`\`
2340
+ bash({ id: "abc123", kill: true })
2341
+ \`\`\`
2342
+
2343
+ **Respond to interactive prompts (for yes/no questions, etc.):**
2344
+ \`\`\`
2345
+ bash({ id: "abc123", key: "y" }) // send 'y' for yes
2346
+ bash({ id: "abc123", key: "n" }) // send 'n' for no
2347
+ bash({ id: "abc123", key: "Enter" }) // press Enter
2348
+ bash({ id: "abc123", input: "my text" }) // send text input
2349
+ \`\`\`
2350
+
2351
+ **IMPORTANT - Handling Interactive Commands:**
2352
+ - ALWAYS prefer non-interactive flags when available:
2353
+ - \`npm init --yes\` or \`npm install --yes\`
2354
+ - \`npx create-next-app --yes\` (accepts all defaults)
2355
+ - \`npx create-react-app --yes\`
2356
+ - \`git commit --no-edit\`
2357
+ - \`apt-get install -y\`
2358
+ - If a command might prompt for input, run it in background mode first
2359
+ - Check the output to see if it's waiting for input
2360
+ - Use \`key: "y"\` or \`key: "n"\` for yes/no prompts
2361
+ - Use \`input: "text"\` for text input prompts
2362
+
2363
+ Logs are saved to \`.sparkecoder/terminals/{id}/output.log\` and can be read with \`read_file\` if needed.
2364
+
2365
+ ## Guidelines
2366
+
2367
+ ### Code Quality
2368
+ - Write clean, maintainable, well-documented code
2369
+ - Follow existing code style and conventions in the project
2370
+ - Use meaningful variable and function names
2371
+ - Add comments for complex logic
2372
+
2373
+ ### Problem Solving
2374
+ - Before making changes, understand the existing code structure
2375
+ - Break complex tasks into smaller, manageable steps using the todo tool
2376
+ - Test changes when possible using the bash tool
2377
+ - Handle errors gracefully and provide helpful error messages
2378
+
2379
+ ### File Operations
2380
+ - Use \`read_file\` to understand code before modifying
2381
+ - Use \`write_file\` with mode "str_replace" for targeted edits to existing files
2382
+ - Use \`write_file\` with mode "full" only for new files or complete rewrites
2383
+ - Always verify changes by reading files after modifications
2384
+
2385
+ ### Searching and Exploration
2386
+ ${searchInstructions}
2387
+
2388
+ Follow these principles when designing and implementing software:
2389
+
2390
+ 1. **Modularity** \u2014 Write simple parts connected by clean interfaces
2391
+ 2. **Clarity** \u2014 Clarity is better than cleverness
2392
+ 3. **Composition** \u2014 Design programs to be connected to other programs
2393
+ 4. **Separation** \u2014 Separate policy from mechanism; separate interfaces from engines
2394
+ 5. **Simplicity** \u2014 Design for simplicity; add complexity only where you must
2395
+ 6. **Parsimony** \u2014 Write a big program only when it is clear by demonstration that nothing else will do
2396
+ 7. **Transparency** \u2014 Design for visibility to make inspection and debugging easier
2397
+ 8. **Robustness** \u2014 Robustness is the child of transparency and simplicity
2398
+ 9. **Representation** \u2014 Fold knowledge into data so program logic can be stupid and robust
2399
+ 10. **Least Surprise** \u2014 In interface design, always do the least surprising thing
2400
+ 11. **Silence** \u2014 When a program has nothing surprising to say, it should say nothing
2401
+ 12. **Repair** \u2014 When you must fail, fail noisily and as soon as possible
2402
+ 13. **Economy** \u2014 Programmer time is expensive; conserve it in preference to machine time
2403
+ 14. **Generation** \u2014 Avoid hand-hacking; write programs to write programs when you can
2404
+ 15. **Optimization** \u2014 Prototype before polishing. Get it working before you optimize it
2405
+ 16. **Diversity** \u2014 Distrust all claims for "one true way"
2406
+ 17. **Extensibility** \u2014 Design for the future, because it will be here sooner than you think
2407
+
2408
+
2409
+ ### Communication
2410
+ - Explain your reasoning and approach
2411
+ - Be concise but thorough
2412
+ - Ask clarifying questions when requirements are ambiguous
2413
+ - Report progress on multi-step tasks
2414
+
2415
+ ## Skills
2416
+ ${skillsContext}
2417
+
2418
+ ## Current Task List
2419
+ ${todosContext}
2420
+
2421
+ ${customInstructions ? `## Custom Instructions
2422
+ ${customInstructions}` : ""}
2423
+
2424
+ Remember: You are a helpful, capable coding assistant. Take initiative, be thorough, and deliver high-quality results.`;
2425
+ return systemPrompt;
2426
+ }
2427
+ function formatTodosForContext(todos) {
2428
+ if (todos.length === 0) {
2429
+ return "No active tasks. Use the todo tool to create a plan for complex operations.";
1294
2430
  }
1295
- getTail(lines) {
1296
- const start = Math.max(0, this.buffer.length - lines);
1297
- return this.buffer.slice(start).join("\n");
1298
- }
1299
- clear() {
1300
- this.buffer = [];
1301
- this.totalBytes = 0;
1302
- }
1303
- get lineCount() {
1304
- return this.buffer.length;
1305
- }
1306
- };
1307
- var TerminalManager = class _TerminalManager extends EventEmitter {
1308
- processes = /* @__PURE__ */ new Map();
1309
- static instance = null;
1310
- constructor() {
1311
- super();
1312
- }
1313
- static getInstance() {
1314
- if (!_TerminalManager.instance) {
1315
- _TerminalManager.instance = new _TerminalManager();
1316
- }
1317
- return _TerminalManager.instance;
1318
- }
1319
- /**
1320
- * Spawn a new background process
1321
- */
1322
- spawn(options) {
1323
- const { sessionId, command, cwd, name, env } = options;
1324
- const parts = this.parseCommand(command);
1325
- const executable = parts[0];
1326
- const args = parts.slice(1);
1327
- const terminal = terminalQueries.create({
1328
- sessionId,
1329
- name: name || null,
1330
- command,
1331
- cwd: cwd || process.cwd(),
1332
- status: "running"
1333
- });
1334
- const proc = spawn(executable, args, {
1335
- cwd: cwd || process.cwd(),
1336
- shell: true,
1337
- stdio: ["pipe", "pipe", "pipe"],
1338
- env: { ...process.env, ...env },
1339
- detached: false
1340
- });
1341
- if (proc.pid) {
1342
- terminalQueries.updatePid(terminal.id, proc.pid);
1343
- }
1344
- const logs = new LogBuffer();
1345
- proc.stdout?.on("data", (data) => {
1346
- const text2 = data.toString();
1347
- logs.append(text2);
1348
- this.emit("stdout", { terminalId: terminal.id, data: text2 });
1349
- });
1350
- proc.stderr?.on("data", (data) => {
1351
- const text2 = data.toString();
1352
- logs.append(`[stderr] ${text2}`);
1353
- this.emit("stderr", { terminalId: terminal.id, data: text2 });
1354
- });
1355
- proc.on("exit", (code, signal) => {
1356
- const exitCode = code ?? (signal ? 128 : 0);
1357
- terminalQueries.updateStatus(terminal.id, "stopped", exitCode);
1358
- this.emit("exit", { terminalId: terminal.id, code: exitCode, signal });
1359
- const managed2 = this.processes.get(terminal.id);
1360
- if (managed2) {
1361
- managed2.terminal = { ...managed2.terminal, status: "stopped", exitCode };
1362
- }
1363
- });
1364
- proc.on("error", (err) => {
1365
- terminalQueries.updateStatus(terminal.id, "error", void 0, err.message);
1366
- this.emit("error", { terminalId: terminal.id, error: err.message });
1367
- const managed2 = this.processes.get(terminal.id);
1368
- if (managed2) {
1369
- managed2.terminal = { ...managed2.terminal, status: "error", error: err.message };
1370
- }
1371
- });
1372
- const managed = {
1373
- id: terminal.id,
1374
- process: proc,
1375
- logs,
1376
- terminal: { ...terminal, pid: proc.pid ?? null }
1377
- };
1378
- this.processes.set(terminal.id, managed);
1379
- return this.toTerminalInfo(managed.terminal);
1380
- }
1381
- /**
1382
- * Get logs from a terminal
1383
- */
1384
- getLogs(terminalId, tail) {
1385
- const managed = this.processes.get(terminalId);
1386
- if (!managed) {
1387
- return null;
1388
- }
1389
- return {
1390
- logs: tail ? managed.logs.getTail(tail) : managed.logs.getAll(),
1391
- lineCount: managed.logs.lineCount
1392
- };
1393
- }
1394
- /**
1395
- * Get terminal status
1396
- */
1397
- getStatus(terminalId) {
1398
- const managed = this.processes.get(terminalId);
1399
- if (managed) {
1400
- if (managed.process.exitCode !== null) {
1401
- managed.terminal = {
1402
- ...managed.terminal,
1403
- status: "stopped",
1404
- exitCode: managed.process.exitCode
1405
- };
1406
- }
1407
- return this.toTerminalInfo(managed.terminal);
1408
- }
1409
- const terminal = terminalQueries.getById(terminalId);
1410
- if (terminal) {
1411
- return this.toTerminalInfo(terminal);
1412
- }
1413
- return null;
1414
- }
1415
- /**
1416
- * Kill a terminal process
1417
- */
1418
- kill(terminalId, signal = "SIGTERM") {
1419
- const managed = this.processes.get(terminalId);
1420
- if (!managed) {
1421
- return false;
1422
- }
1423
- try {
1424
- managed.process.kill(signal);
1425
- return true;
1426
- } catch (err) {
1427
- console.error(`Failed to kill terminal ${terminalId}:`, err);
1428
- return false;
1429
- }
1430
- }
1431
- /**
1432
- * Write to a terminal's stdin
1433
- */
1434
- write(terminalId, input) {
1435
- const managed = this.processes.get(terminalId);
1436
- if (!managed || !managed.process.stdin) {
1437
- return false;
1438
- }
1439
- try {
1440
- managed.process.stdin.write(input);
1441
- return true;
1442
- } catch (err) {
1443
- console.error(`Failed to write to terminal ${terminalId}:`, err);
1444
- return false;
1445
- }
1446
- }
1447
- /**
1448
- * List all terminals for a session
1449
- */
1450
- list(sessionId) {
1451
- const terminals3 = terminalQueries.getBySession(sessionId);
1452
- return terminals3.map((t) => {
1453
- const managed = this.processes.get(t.id);
1454
- if (managed) {
1455
- return this.toTerminalInfo(managed.terminal);
1456
- }
1457
- return this.toTerminalInfo(t);
1458
- });
1459
- }
1460
- /**
1461
- * Get all running terminals for a session
1462
- */
1463
- getRunning(sessionId) {
1464
- return this.list(sessionId).filter((t) => t.status === "running");
1465
- }
1466
- /**
1467
- * Kill all terminals for a session (cleanup)
1468
- */
1469
- killAll(sessionId) {
1470
- let killed = 0;
1471
- for (const [id, managed] of this.processes) {
1472
- if (managed.terminal.sessionId === sessionId) {
1473
- if (this.kill(id)) {
1474
- killed++;
1475
- }
1476
- }
1477
- }
1478
- return killed;
1479
- }
1480
- /**
1481
- * Clean up stopped terminals from memory (keep DB records)
1482
- */
1483
- cleanup(sessionId) {
1484
- let cleaned = 0;
1485
- for (const [id, managed] of this.processes) {
1486
- if (sessionId && managed.terminal.sessionId !== sessionId) {
1487
- continue;
1488
- }
1489
- if (managed.terminal.status !== "running") {
1490
- this.processes.delete(id);
1491
- cleaned++;
1492
- }
1493
- }
1494
- return cleaned;
1495
- }
1496
- /**
1497
- * Parse a command string into executable and arguments
1498
- */
1499
- parseCommand(command) {
1500
- const parts = [];
1501
- let current = "";
1502
- let inQuote = false;
1503
- let quoteChar = "";
1504
- for (const char of command) {
1505
- if ((char === '"' || char === "'") && !inQuote) {
1506
- inQuote = true;
1507
- quoteChar = char;
1508
- } else if (char === quoteChar && inQuote) {
1509
- inQuote = false;
1510
- quoteChar = "";
1511
- } else if (char === " " && !inQuote) {
1512
- if (current) {
1513
- parts.push(current);
1514
- current = "";
1515
- }
1516
- } else {
1517
- current += char;
1518
- }
1519
- }
1520
- if (current) {
1521
- parts.push(current);
1522
- }
1523
- return parts.length > 0 ? parts : [command];
1524
- }
1525
- toTerminalInfo(terminal) {
1526
- return {
1527
- id: terminal.id,
1528
- name: terminal.name,
1529
- command: terminal.command,
1530
- cwd: terminal.cwd,
1531
- pid: terminal.pid,
1532
- status: terminal.status,
1533
- exitCode: terminal.exitCode,
1534
- error: terminal.error,
1535
- createdAt: terminal.createdAt,
1536
- stoppedAt: terminal.stoppedAt
1537
- };
1538
- }
1539
- };
1540
- function getTerminalManager() {
1541
- return TerminalManager.getInstance();
1542
- }
1543
-
1544
- // src/tools/terminal.ts
1545
- var TerminalInputSchema = z7.object({
1546
- action: z7.enum(["spawn", "logs", "status", "kill", "write", "list"]).describe(
1547
- "The action to perform: spawn (start process), logs (get output), status (check if running), kill (stop process), write (send stdin), list (show all)"
1548
- ),
1549
- // For spawn
1550
- command: z7.string().optional().describe('For spawn: The command to run (e.g., "npm run dev")'),
1551
- cwd: z7.string().optional().describe("For spawn: Working directory for the command"),
1552
- name: z7.string().optional().describe('For spawn: Optional friendly name (e.g., "dev-server")'),
1553
- // For logs, status, kill, write
1554
- terminalId: z7.string().optional().describe("For logs/status/kill/write: The terminal ID"),
1555
- tail: z7.number().optional().describe("For logs: Number of lines to return from the end"),
1556
- // For kill
1557
- signal: z7.enum(["SIGTERM", "SIGKILL"]).optional().describe("For kill: Signal to send (default: SIGTERM)"),
1558
- // For write
1559
- input: z7.string().optional().describe("For write: The input to send to stdin")
1560
- });
1561
- function createTerminalTool(options) {
1562
- const { sessionId, workingDirectory } = options;
1563
- return tool6({
1564
- description: `Manage background terminal processes. Use this for long-running commands like dev servers, watchers, or any process that shouldn't block.
1565
-
1566
- Actions:
1567
- - spawn: Start a new background process. Requires 'command'. Returns terminal ID.
1568
- - logs: Get output from a terminal. Requires 'terminalId'. Optional 'tail' for recent lines.
1569
- - status: Check if a terminal is still running. Requires 'terminalId'.
1570
- - kill: Stop a terminal process. Requires 'terminalId'. Optional 'signal'.
1571
- - write: Send input to a terminal's stdin. Requires 'terminalId' and 'input'.
1572
- - list: Show all terminals for this session. No other params needed.
1573
-
1574
- Example workflow:
1575
- 1. spawn with command="npm run dev", name="dev-server" \u2192 { id: "abc123", status: "running" }
1576
- 2. logs with terminalId="abc123", tail=10 \u2192 "Ready on http://localhost:3000"
1577
- 3. kill with terminalId="abc123" \u2192 { success: true }`,
1578
- inputSchema: TerminalInputSchema,
1579
- execute: async (input) => {
1580
- const manager = getTerminalManager();
1581
- switch (input.action) {
1582
- case "spawn": {
1583
- if (!input.command) {
1584
- return { success: false, error: 'spawn requires a "command" parameter' };
1585
- }
1586
- const terminal = manager.spawn({
1587
- sessionId,
1588
- command: input.command,
1589
- cwd: input.cwd || workingDirectory,
1590
- name: input.name
1591
- });
1592
- return {
1593
- success: true,
1594
- terminal: formatTerminal(terminal),
1595
- message: `Started "${input.command}" with terminal ID: ${terminal.id}`
1596
- };
1597
- }
1598
- case "logs": {
1599
- if (!input.terminalId) {
1600
- return { success: false, error: 'logs requires a "terminalId" parameter' };
1601
- }
1602
- const result = manager.getLogs(input.terminalId, input.tail);
1603
- if (!result) {
1604
- return {
1605
- success: false,
1606
- error: `Terminal not found: ${input.terminalId}`
1607
- };
1608
- }
1609
- return {
1610
- success: true,
1611
- terminalId: input.terminalId,
1612
- logs: result.logs,
1613
- lineCount: result.lineCount
1614
- };
1615
- }
1616
- case "status": {
1617
- if (!input.terminalId) {
1618
- return { success: false, error: 'status requires a "terminalId" parameter' };
1619
- }
1620
- const status = manager.getStatus(input.terminalId);
1621
- if (!status) {
1622
- return {
1623
- success: false,
1624
- error: `Terminal not found: ${input.terminalId}`
1625
- };
1626
- }
1627
- return {
1628
- success: true,
1629
- terminal: formatTerminal(status)
1630
- };
1631
- }
1632
- case "kill": {
1633
- if (!input.terminalId) {
1634
- return { success: false, error: 'kill requires a "terminalId" parameter' };
1635
- }
1636
- const success = manager.kill(input.terminalId, input.signal);
1637
- if (!success) {
1638
- return {
1639
- success: false,
1640
- error: `Failed to kill terminal: ${input.terminalId}`
1641
- };
1642
- }
1643
- return {
1644
- success: true,
1645
- message: `Sent ${input.signal || "SIGTERM"} to terminal ${input.terminalId}`
1646
- };
1647
- }
1648
- case "write": {
1649
- if (!input.terminalId) {
1650
- return { success: false, error: 'write requires a "terminalId" parameter' };
1651
- }
1652
- if (!input.input) {
1653
- return { success: false, error: 'write requires an "input" parameter' };
1654
- }
1655
- const success = manager.write(input.terminalId, input.input);
1656
- if (!success) {
1657
- return {
1658
- success: false,
1659
- error: `Failed to write to terminal: ${input.terminalId}`
1660
- };
1661
- }
1662
- return {
1663
- success: true,
1664
- message: `Sent input to terminal ${input.terminalId}`
1665
- };
1666
- }
1667
- case "list": {
1668
- const terminals3 = manager.list(sessionId);
1669
- return {
1670
- success: true,
1671
- terminals: terminals3.map(formatTerminal),
1672
- count: terminals3.length,
1673
- running: terminals3.filter((t) => t.status === "running").length
1674
- };
1675
- }
1676
- default:
1677
- return { success: false, error: `Unknown action: ${input.action}` };
1678
- }
1679
- }
1680
- });
1681
- }
1682
- function formatTerminal(t) {
1683
- return {
1684
- id: t.id,
1685
- name: t.name,
1686
- command: t.command,
1687
- cwd: t.cwd,
1688
- pid: t.pid,
1689
- status: t.status,
1690
- exitCode: t.exitCode,
1691
- error: t.error,
1692
- createdAt: t.createdAt.toISOString(),
1693
- stoppedAt: t.stoppedAt?.toISOString() || null
1694
- };
1695
- }
1696
-
1697
- // src/tools/index.ts
1698
- function createTools(options) {
1699
- return {
1700
- bash: createBashTool({
1701
- workingDirectory: options.workingDirectory,
1702
- onOutput: options.onBashOutput
1703
- }),
1704
- read_file: createReadFileTool({
1705
- workingDirectory: options.workingDirectory
1706
- }),
1707
- write_file: createWriteFileTool({
1708
- workingDirectory: options.workingDirectory
1709
- }),
1710
- todo: createTodoTool({
1711
- sessionId: options.sessionId
1712
- }),
1713
- load_skill: createLoadSkillTool({
1714
- sessionId: options.sessionId,
1715
- skillsDirectories: options.skillsDirectories
1716
- }),
1717
- terminal: createTerminalTool({
1718
- sessionId: options.sessionId,
1719
- workingDirectory: options.workingDirectory
1720
- })
1721
- };
1722
- }
1723
-
1724
- // src/agent/context.ts
1725
- import { generateText } from "ai";
1726
- import { gateway } from "@ai-sdk/gateway";
1727
-
1728
- // src/agent/prompts.ts
1729
- async function buildSystemPrompt(options) {
1730
- const { workingDirectory, skillsDirectories, sessionId, customInstructions } = options;
1731
- const skills = await loadAllSkills(skillsDirectories);
1732
- const skillsContext = formatSkillsForContext(skills);
1733
- const todos = todoQueries.getBySession(sessionId);
1734
- const todosContext = formatTodosForContext(todos);
1735
- const systemPrompt = `You are Sparkecoder, an expert AI coding assistant. You help developers write, debug, and improve code.
1736
-
1737
- ## Working Directory
1738
- You are working in: ${workingDirectory}
1739
-
1740
- ## Core Capabilities
1741
- You have access to powerful tools for:
1742
- - **bash**: Execute shell commands, run scripts, install packages, use git
1743
- - **read_file**: Read file contents to understand code and context
1744
- - **write_file**: Create new files or edit existing ones (supports targeted string replacement)
1745
- - **todo**: Manage your task list to track progress on complex operations
1746
- - **load_skill**: Load specialized knowledge documents for specific tasks
1747
-
1748
- ## Guidelines
1749
-
1750
- ### Code Quality
1751
- - Write clean, maintainable, well-documented code
1752
- - Follow existing code style and conventions in the project
1753
- - Use meaningful variable and function names
1754
- - Add comments for complex logic
1755
-
1756
- ### Problem Solving
1757
- - Before making changes, understand the existing code structure
1758
- - Break complex tasks into smaller, manageable steps using the todo tool
1759
- - Test changes when possible using the bash tool
1760
- - Handle errors gracefully and provide helpful error messages
1761
-
1762
- ### File Operations
1763
- - Use \`read_file\` to understand code before modifying
1764
- - Use \`write_file\` with mode "str_replace" for targeted edits to existing files
1765
- - Use \`write_file\` with mode "full" only for new files or complete rewrites
1766
- - Always verify changes by reading files after modifications
1767
-
1768
- ### Communication
1769
- - Explain your reasoning and approach
1770
- - Be concise but thorough
1771
- - Ask clarifying questions when requirements are ambiguous
1772
- - Report progress on multi-step tasks
1773
-
1774
- ## Skills
1775
- ${skillsContext}
1776
-
1777
- ## Current Task List
1778
- ${todosContext}
1779
-
1780
- ${customInstructions ? `## Custom Instructions
1781
- ${customInstructions}` : ""}
1782
-
1783
- Remember: You are a helpful, capable coding assistant. Take initiative, be thorough, and deliver high-quality results.`;
1784
- return systemPrompt;
1785
- }
1786
- function formatTodosForContext(todos) {
1787
- if (todos.length === 0) {
1788
- return "No active tasks. Use the todo tool to create a plan for complex operations.";
1789
- }
1790
- const statusEmoji = {
1791
- pending: "\u2B1C",
1792
- in_progress: "\u{1F504}",
1793
- completed: "\u2705",
1794
- cancelled: "\u274C"
1795
- };
1796
- const lines = ["Current tasks:"];
1797
- for (const todo of todos) {
1798
- const emoji = statusEmoji[todo.status] || "\u2022";
1799
- lines.push(`${emoji} [${todo.id}] ${todo.content}`);
2431
+ const statusEmoji = {
2432
+ pending: "\u2B1C",
2433
+ in_progress: "\u{1F504}",
2434
+ completed: "\u2705",
2435
+ cancelled: "\u274C"
2436
+ };
2437
+ const lines = ["Current tasks:"];
2438
+ for (const todo of todos) {
2439
+ const emoji = statusEmoji[todo.status] || "\u2022";
2440
+ lines.push(`${emoji} [${todo.id}] ${todo.content}`);
1800
2441
  }
1801
2442
  return lines.join("\n");
1802
2443
  }
@@ -1921,12 +2562,24 @@ var approvalResolvers = /* @__PURE__ */ new Map();
1921
2562
  var Agent = class _Agent {
1922
2563
  session;
1923
2564
  context;
1924
- tools;
2565
+ baseTools;
1925
2566
  pendingApprovals = /* @__PURE__ */ new Map();
1926
2567
  constructor(session, context, tools) {
1927
2568
  this.session = session;
1928
2569
  this.context = context;
1929
- this.tools = tools;
2570
+ this.baseTools = tools;
2571
+ }
2572
+ /**
2573
+ * Create tools with optional progress callbacks
2574
+ */
2575
+ createToolsWithCallbacks(options) {
2576
+ const config = getConfig();
2577
+ return createTools({
2578
+ sessionId: this.session.id,
2579
+ workingDirectory: this.session.workingDirectory,
2580
+ skillsDirectories: config.resolvedSkillsDirectories,
2581
+ onBashProgress: options.onToolProgress ? (progress) => options.onToolProgress({ toolName: "bash", data: progress }) : void 0
2582
+ });
1930
2583
  }
1931
2584
  /**
1932
2585
  * Create or resume an agent session
@@ -1978,7 +2631,9 @@ var Agent = class _Agent {
1978
2631
  */
1979
2632
  async stream(options) {
1980
2633
  const config = getConfig();
1981
- this.context.addUserMessage(options.prompt);
2634
+ if (!options.skipSaveUserMessage) {
2635
+ this.context.addUserMessage(options.prompt);
2636
+ }
1982
2637
  sessionQueries.updateStatus(this.session.id, "active");
1983
2638
  const systemPrompt = await buildSystemPrompt({
1984
2639
  workingDirectory: this.session.workingDirectory,
@@ -1986,15 +2641,30 @@ var Agent = class _Agent {
1986
2641
  sessionId: this.session.id
1987
2642
  });
1988
2643
  const messages2 = await this.context.getMessages();
1989
- const wrappedTools = this.wrapToolsWithApproval(options);
2644
+ const tools = options.onToolProgress ? this.createToolsWithCallbacks({ onToolProgress: options.onToolProgress }) : this.baseTools;
2645
+ const wrappedTools = this.wrapToolsWithApproval(options, tools);
1990
2646
  const stream = streamText({
1991
2647
  model: gateway2(this.session.model),
1992
2648
  system: systemPrompt,
1993
2649
  messages: messages2,
1994
2650
  tools: wrappedTools,
1995
- stopWhen: stepCountIs(20),
2651
+ stopWhen: stepCountIs(500),
2652
+ // Forward abort signal if provided
2653
+ abortSignal: options.abortSignal,
2654
+ // Enable extended thinking/reasoning for models that support it
2655
+ providerOptions: {
2656
+ anthropic: {
2657
+ thinking: {
2658
+ type: "enabled",
2659
+ budgetTokens: 1e4
2660
+ }
2661
+ }
2662
+ },
1996
2663
  onStepFinish: async (step) => {
1997
2664
  options.onStepFinish?.(step);
2665
+ },
2666
+ onAbort: ({ steps }) => {
2667
+ options.onAbort?.({ steps });
1998
2668
  }
1999
2669
  });
2000
2670
  const saveResponseMessages = async () => {
@@ -2022,13 +2692,23 @@ var Agent = class _Agent {
2022
2692
  sessionId: this.session.id
2023
2693
  });
2024
2694
  const messages2 = await this.context.getMessages();
2025
- const wrappedTools = this.wrapToolsWithApproval(options);
2695
+ const tools = options.onToolProgress ? this.createToolsWithCallbacks({ onToolProgress: options.onToolProgress }) : this.baseTools;
2696
+ const wrappedTools = this.wrapToolsWithApproval(options, tools);
2026
2697
  const result = await generateText2({
2027
2698
  model: gateway2(this.session.model),
2028
2699
  system: systemPrompt,
2029
2700
  messages: messages2,
2030
2701
  tools: wrappedTools,
2031
- stopWhen: stepCountIs(20)
2702
+ stopWhen: stepCountIs(500),
2703
+ // Enable extended thinking/reasoning for models that support it
2704
+ providerOptions: {
2705
+ anthropic: {
2706
+ thinking: {
2707
+ type: "enabled",
2708
+ budgetTokens: 1e4
2709
+ }
2710
+ }
2711
+ }
2032
2712
  });
2033
2713
  const responseMessages = result.response.messages;
2034
2714
  this.context.addResponseMessages(responseMessages);
@@ -2040,20 +2720,21 @@ var Agent = class _Agent {
2040
2720
  /**
2041
2721
  * Wrap tools to add approval checking
2042
2722
  */
2043
- wrapToolsWithApproval(options) {
2723
+ wrapToolsWithApproval(options, tools) {
2044
2724
  const sessionConfig = this.session.config;
2045
2725
  const wrappedTools = {};
2046
- for (const [name, originalTool] of Object.entries(this.tools)) {
2726
+ const toolsToWrap = tools || this.baseTools;
2727
+ for (const [name, originalTool] of Object.entries(toolsToWrap)) {
2047
2728
  const needsApproval = requiresApproval(name, sessionConfig ?? void 0);
2048
2729
  if (!needsApproval) {
2049
2730
  wrappedTools[name] = originalTool;
2050
2731
  continue;
2051
2732
  }
2052
- wrappedTools[name] = tool7({
2733
+ wrappedTools[name] = tool6({
2053
2734
  description: originalTool.description || "",
2054
- inputSchema: originalTool.inputSchema || z8.object({}),
2735
+ inputSchema: originalTool.inputSchema || z7.object({}),
2055
2736
  execute: async (input, toolOptions) => {
2056
- const toolCallId = toolOptions.toolCallId || nanoid2();
2737
+ const toolCallId = toolOptions.toolCallId || nanoid3();
2057
2738
  const execution = toolExecutionQueries.create({
2058
2739
  sessionId: this.session.id,
2059
2740
  toolName: name,
@@ -2065,8 +2746,8 @@ var Agent = class _Agent {
2065
2746
  this.pendingApprovals.set(toolCallId, execution);
2066
2747
  options.onApprovalRequired?.(execution);
2067
2748
  sessionQueries.updateStatus(this.session.id, "waiting");
2068
- const approved = await new Promise((resolve5) => {
2069
- approvalResolvers.set(toolCallId, { resolve: resolve5, sessionId: this.session.id });
2749
+ const approved = await new Promise((resolve7) => {
2750
+ approvalResolvers.set(toolCallId, { resolve: resolve7, sessionId: this.session.id });
2070
2751
  });
2071
2752
  const resolverData = approvalResolvers.get(toolCallId);
2072
2753
  approvalResolvers.delete(toolCallId);
@@ -2160,29 +2841,34 @@ var Agent = class _Agent {
2160
2841
  };
2161
2842
 
2162
2843
  // src/server/index.ts
2844
+ import "dotenv/config";
2163
2845
  import { Hono as Hono5 } from "hono";
2164
2846
  import { serve } from "@hono/node-server";
2165
2847
  import { cors } from "hono/cors";
2166
2848
  import { logger } from "hono/logger";
2167
- import { existsSync as existsSync5, mkdirSync } from "fs";
2849
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2 } from "fs";
2850
+ import { resolve as resolve6, dirname as dirname4, join as join3 } from "path";
2851
+ import { spawn } from "child_process";
2852
+ import { createServer as createNetServer } from "net";
2853
+ import { fileURLToPath } from "url";
2168
2854
 
2169
2855
  // src/server/routes/sessions.ts
2170
2856
  import { Hono } from "hono";
2171
2857
  import { zValidator } from "@hono/zod-validator";
2172
- import { z as z9 } from "zod";
2858
+ import { z as z8 } from "zod";
2173
2859
  var sessions2 = new Hono();
2174
- var createSessionSchema = z9.object({
2175
- name: z9.string().optional(),
2176
- workingDirectory: z9.string().optional(),
2177
- model: z9.string().optional(),
2178
- toolApprovals: z9.record(z9.string(), z9.boolean()).optional()
2860
+ var createSessionSchema = z8.object({
2861
+ name: z8.string().optional(),
2862
+ workingDirectory: z8.string().optional(),
2863
+ model: z8.string().optional(),
2864
+ toolApprovals: z8.record(z8.string(), z8.boolean()).optional()
2179
2865
  });
2180
- var paginationQuerySchema = z9.object({
2181
- limit: z9.string().optional(),
2182
- offset: z9.string().optional()
2866
+ var paginationQuerySchema = z8.object({
2867
+ limit: z8.string().optional(),
2868
+ offset: z8.string().optional()
2183
2869
  });
2184
- var messagesQuerySchema = z9.object({
2185
- limit: z9.string().optional()
2870
+ var messagesQuerySchema = z8.object({
2871
+ limit: z8.string().optional()
2186
2872
  });
2187
2873
  sessions2.get(
2188
2874
  "/",
@@ -2192,16 +2878,22 @@ sessions2.get(
2192
2878
  const limit = parseInt(query.limit || "50");
2193
2879
  const offset = parseInt(query.offset || "0");
2194
2880
  const allSessions = sessionQueries.list(limit, offset);
2195
- return c.json({
2196
- sessions: allSessions.map((s) => ({
2881
+ const sessionsWithStreamInfo = allSessions.map((s) => {
2882
+ const activeStream = activeStreamQueries.getBySessionId(s.id);
2883
+ return {
2197
2884
  id: s.id,
2198
2885
  name: s.name,
2199
2886
  workingDirectory: s.workingDirectory,
2200
2887
  model: s.model,
2201
2888
  status: s.status,
2889
+ config: s.config,
2890
+ isStreaming: !!activeStream,
2202
2891
  createdAt: s.createdAt.toISOString(),
2203
2892
  updatedAt: s.updatedAt.toISOString()
2204
- })),
2893
+ };
2894
+ });
2895
+ return c.json({
2896
+ sessions: sessionsWithStreamInfo,
2205
2897
  count: allSessions.length,
2206
2898
  limit,
2207
2899
  offset
@@ -2315,13 +3007,63 @@ sessions2.get("/:id/tools", async (c) => {
2315
3007
  count: executions.length
2316
3008
  });
2317
3009
  });
3010
+ var updateSessionSchema = z8.object({
3011
+ model: z8.string().optional(),
3012
+ name: z8.string().optional(),
3013
+ toolApprovals: z8.record(z8.string(), z8.boolean()).optional()
3014
+ });
3015
+ sessions2.patch(
3016
+ "/:id",
3017
+ zValidator("json", updateSessionSchema),
3018
+ async (c) => {
3019
+ const id = c.req.param("id");
3020
+ const body = c.req.valid("json");
3021
+ const session = sessionQueries.getById(id);
3022
+ if (!session) {
3023
+ return c.json({ error: "Session not found" }, 404);
3024
+ }
3025
+ const updates = {};
3026
+ if (body.model) updates.model = body.model;
3027
+ if (body.name !== void 0) updates.name = body.name;
3028
+ if (body.toolApprovals !== void 0) {
3029
+ const existingConfig = session.config || {};
3030
+ const existingToolApprovals = existingConfig.toolApprovals || {};
3031
+ updates.config = {
3032
+ ...existingConfig,
3033
+ toolApprovals: {
3034
+ ...existingToolApprovals,
3035
+ ...body.toolApprovals
3036
+ }
3037
+ };
3038
+ }
3039
+ const updatedSession = Object.keys(updates).length > 0 ? sessionQueries.update(id, updates) || session : session;
3040
+ return c.json({
3041
+ id: updatedSession.id,
3042
+ name: updatedSession.name,
3043
+ model: updatedSession.model,
3044
+ status: updatedSession.status,
3045
+ workingDirectory: updatedSession.workingDirectory,
3046
+ config: updatedSession.config,
3047
+ updatedAt: updatedSession.updatedAt.toISOString()
3048
+ });
3049
+ }
3050
+ );
2318
3051
  sessions2.delete("/:id", async (c) => {
2319
3052
  const id = c.req.param("id");
2320
3053
  try {
2321
- const manager = getTerminalManager();
2322
- manager.killAll(id);
3054
+ const session = sessionQueries.getById(id);
3055
+ if (session) {
3056
+ const terminalIds = await listSessions();
3057
+ for (const tid of terminalIds) {
3058
+ const meta = await getMeta(tid, session.workingDirectory);
3059
+ if (meta && meta.sessionId === id) {
3060
+ await killTerminal(tid);
3061
+ }
3062
+ }
3063
+ }
2323
3064
  } catch (e) {
2324
3065
  }
3066
+ clearCheckpointManager(id);
2325
3067
  const deleted = sessionQueries.delete(id);
2326
3068
  if (!deleted) {
2327
3069
  return c.json({ error: "Session not found" }, 404);
@@ -2338,160 +3080,488 @@ sessions2.post("/:id/clear", async (c) => {
2338
3080
  agent.clearContext();
2339
3081
  return c.json({ success: true, sessionId: id });
2340
3082
  });
3083
+ sessions2.get("/:id/todos", async (c) => {
3084
+ const id = c.req.param("id");
3085
+ const session = sessionQueries.getById(id);
3086
+ if (!session) {
3087
+ return c.json({ error: "Session not found" }, 404);
3088
+ }
3089
+ const todos = todoQueries.getBySession(id);
3090
+ const pending = todos.filter((t) => t.status === "pending");
3091
+ const inProgress = todos.filter((t) => t.status === "in_progress");
3092
+ const completed = todos.filter((t) => t.status === "completed");
3093
+ const cancelled = todos.filter((t) => t.status === "cancelled");
3094
+ const nextTodo = inProgress[0] || pending[0] || null;
3095
+ return c.json({
3096
+ todos: todos.map((t) => ({
3097
+ id: t.id,
3098
+ content: t.content,
3099
+ status: t.status,
3100
+ order: t.order,
3101
+ createdAt: t.createdAt.toISOString(),
3102
+ updatedAt: t.updatedAt.toISOString()
3103
+ })),
3104
+ stats: {
3105
+ total: todos.length,
3106
+ pending: pending.length,
3107
+ inProgress: inProgress.length,
3108
+ completed: completed.length,
3109
+ cancelled: cancelled.length
3110
+ },
3111
+ nextTodo: nextTodo ? {
3112
+ id: nextTodo.id,
3113
+ content: nextTodo.content,
3114
+ status: nextTodo.status
3115
+ } : null
3116
+ });
3117
+ });
3118
+ sessions2.get("/:id/checkpoints", async (c) => {
3119
+ const id = c.req.param("id");
3120
+ const session = sessionQueries.getById(id);
3121
+ if (!session) {
3122
+ return c.json({ error: "Session not found" }, 404);
3123
+ }
3124
+ const checkpoints2 = getCheckpoints(id);
3125
+ return c.json({
3126
+ sessionId: id,
3127
+ checkpoints: checkpoints2.map((cp) => ({
3128
+ id: cp.id,
3129
+ messageSequence: cp.messageSequence,
3130
+ gitHead: cp.gitHead,
3131
+ createdAt: cp.createdAt.toISOString()
3132
+ })),
3133
+ count: checkpoints2.length
3134
+ });
3135
+ });
3136
+ sessions2.post("/:id/revert/:checkpointId", async (c) => {
3137
+ const sessionId = c.req.param("id");
3138
+ const checkpointId = c.req.param("checkpointId");
3139
+ const session = sessionQueries.getById(sessionId);
3140
+ if (!session) {
3141
+ return c.json({ error: "Session not found" }, 404);
3142
+ }
3143
+ const activeStream = activeStreamQueries.getBySessionId(sessionId);
3144
+ if (activeStream) {
3145
+ return c.json({
3146
+ error: "Cannot revert while a stream is active. Stop the stream first.",
3147
+ streamId: activeStream.streamId
3148
+ }, 409);
3149
+ }
3150
+ const result = await revertToCheckpoint(sessionId, checkpointId);
3151
+ if (!result.success) {
3152
+ return c.json({ error: result.error }, 400);
3153
+ }
3154
+ return c.json({
3155
+ success: true,
3156
+ sessionId,
3157
+ checkpointId,
3158
+ filesRestored: result.filesRestored,
3159
+ filesDeleted: result.filesDeleted,
3160
+ messagesDeleted: result.messagesDeleted,
3161
+ checkpointsDeleted: result.checkpointsDeleted
3162
+ });
3163
+ });
3164
+ sessions2.get("/:id/diff", async (c) => {
3165
+ const id = c.req.param("id");
3166
+ const session = sessionQueries.getById(id);
3167
+ if (!session) {
3168
+ return c.json({ error: "Session not found" }, 404);
3169
+ }
3170
+ const diff = await getSessionDiff(id);
3171
+ return c.json({
3172
+ sessionId: id,
3173
+ files: diff.files.map((f) => ({
3174
+ path: f.path,
3175
+ status: f.status,
3176
+ hasOriginal: f.originalContent !== null,
3177
+ hasCurrent: f.currentContent !== null
3178
+ // Optionally include content (can be large)
3179
+ // originalContent: f.originalContent,
3180
+ // currentContent: f.currentContent,
3181
+ })),
3182
+ summary: {
3183
+ created: diff.files.filter((f) => f.status === "created").length,
3184
+ modified: diff.files.filter((f) => f.status === "modified").length,
3185
+ deleted: diff.files.filter((f) => f.status === "deleted").length,
3186
+ total: diff.files.length
3187
+ }
3188
+ });
3189
+ });
3190
+ sessions2.get("/:id/diff/:filePath", async (c) => {
3191
+ const sessionId = c.req.param("id");
3192
+ const filePath = decodeURIComponent(c.req.param("filePath"));
3193
+ const session = sessionQueries.getById(sessionId);
3194
+ if (!session) {
3195
+ return c.json({ error: "Session not found" }, 404);
3196
+ }
3197
+ const diff = await getSessionDiff(sessionId);
3198
+ const fileDiff = diff.files.find((f) => f.path === filePath);
3199
+ if (!fileDiff) {
3200
+ return c.json({ error: "File not found in diff" }, 404);
3201
+ }
3202
+ return c.json({
3203
+ sessionId,
3204
+ path: fileDiff.path,
3205
+ status: fileDiff.status,
3206
+ originalContent: fileDiff.originalContent,
3207
+ currentContent: fileDiff.currentContent
3208
+ });
3209
+ });
2341
3210
 
2342
3211
  // src/server/routes/agents.ts
2343
3212
  import { Hono as Hono2 } from "hono";
2344
3213
  import { zValidator as zValidator2 } from "@hono/zod-validator";
2345
- import { streamSSE } from "hono/streaming";
2346
- import { z as z10 } from "zod";
3214
+ import { z as z9 } from "zod";
3215
+
3216
+ // src/server/resumable-stream.ts
3217
+ import { createResumableStreamContext } from "resumable-stream/generic";
3218
+ var store = /* @__PURE__ */ new Map();
3219
+ var channels = /* @__PURE__ */ new Map();
3220
+ var cleanupInterval = setInterval(() => {
3221
+ const now = Date.now();
3222
+ for (const [key, data] of store.entries()) {
3223
+ if (data.expiresAt && data.expiresAt < now) {
3224
+ store.delete(key);
3225
+ }
3226
+ }
3227
+ }, 6e4);
3228
+ cleanupInterval.unref();
3229
+ var publisher = {
3230
+ connect: async () => {
3231
+ },
3232
+ publish: async (channel, message) => {
3233
+ const subscribers = channels.get(channel);
3234
+ if (subscribers) {
3235
+ for (const callback of subscribers) {
3236
+ setImmediate(() => callback(message));
3237
+ }
3238
+ }
3239
+ },
3240
+ set: async (key, value, options) => {
3241
+ const expiresAt = options?.EX ? Date.now() + options.EX * 1e3 : void 0;
3242
+ store.set(key, { value, expiresAt });
3243
+ if (options?.EX) {
3244
+ setTimeout(() => store.delete(key), options.EX * 1e3);
3245
+ }
3246
+ },
3247
+ get: async (key) => {
3248
+ const data = store.get(key);
3249
+ if (!data) return null;
3250
+ if (data.expiresAt && data.expiresAt < Date.now()) {
3251
+ store.delete(key);
3252
+ return null;
3253
+ }
3254
+ return data.value;
3255
+ },
3256
+ incr: async (key) => {
3257
+ const data = store.get(key);
3258
+ const current = data ? parseInt(data.value, 10) : 0;
3259
+ const next = (isNaN(current) ? 0 : current) + 1;
3260
+ store.set(key, { value: String(next), expiresAt: data?.expiresAt });
3261
+ return next;
3262
+ }
3263
+ };
3264
+ var subscriber = {
3265
+ connect: async () => {
3266
+ },
3267
+ subscribe: async (channel, callback) => {
3268
+ if (!channels.has(channel)) {
3269
+ channels.set(channel, /* @__PURE__ */ new Set());
3270
+ }
3271
+ channels.get(channel).add(callback);
3272
+ },
3273
+ unsubscribe: async (channel) => {
3274
+ channels.delete(channel);
3275
+ }
3276
+ };
3277
+ var streamContext = createResumableStreamContext({
3278
+ // Background task handler - just let promises run and log errors
3279
+ waitUntil: (promise) => {
3280
+ promise.catch((err) => {
3281
+ console.error("[ResumableStream] Background task error:", err);
3282
+ });
3283
+ },
3284
+ publisher,
3285
+ subscriber
3286
+ });
3287
+
3288
+ // src/server/routes/agents.ts
3289
+ import { nanoid as nanoid4 } from "nanoid";
2347
3290
  var agents = new Hono2();
2348
- var runPromptSchema = z10.object({
2349
- prompt: z10.string().min(1)
3291
+ var runPromptSchema = z9.object({
3292
+ prompt: z9.string().min(1)
2350
3293
  });
2351
- var quickStartSchema = z10.object({
2352
- prompt: z10.string().min(1),
2353
- name: z10.string().optional(),
2354
- workingDirectory: z10.string().optional(),
2355
- model: z10.string().optional(),
2356
- toolApprovals: z10.record(z10.string(), z10.boolean()).optional()
3294
+ var quickStartSchema = z9.object({
3295
+ prompt: z9.string().min(1),
3296
+ name: z9.string().optional(),
3297
+ workingDirectory: z9.string().optional(),
3298
+ model: z9.string().optional(),
3299
+ toolApprovals: z9.record(z9.string(), z9.boolean()).optional()
2357
3300
  });
2358
- var rejectSchema = z10.object({
2359
- reason: z10.string().optional()
3301
+ var rejectSchema = z9.object({
3302
+ reason: z9.string().optional()
2360
3303
  }).optional();
2361
- agents.post(
2362
- "/:id/run",
2363
- zValidator2("json", runPromptSchema),
2364
- async (c) => {
2365
- const id = c.req.param("id");
2366
- const { prompt } = c.req.valid("json");
2367
- const session = sessionQueries.getById(id);
2368
- if (!session) {
2369
- return c.json({ error: "Session not found" }, 404);
2370
- }
2371
- c.header("Content-Type", "text/event-stream");
2372
- c.header("Cache-Control", "no-cache");
2373
- c.header("Connection", "keep-alive");
2374
- c.header("x-vercel-ai-ui-message-stream", "v1");
2375
- return streamSSE(c, async (stream) => {
3304
+ var streamAbortControllers = /* @__PURE__ */ new Map();
3305
+ function createAgentStreamProducer(sessionId, prompt, streamId) {
3306
+ return () => {
3307
+ const { readable, writable } = new TransformStream();
3308
+ const writer = writable.getWriter();
3309
+ let writerClosed = false;
3310
+ const abortController = new AbortController();
3311
+ streamAbortControllers.set(streamId, abortController);
3312
+ const writeSSE = async (data) => {
3313
+ if (writerClosed) return;
3314
+ try {
3315
+ await writer.write(`data: ${data}
3316
+
3317
+ `);
3318
+ } catch (err) {
3319
+ writerClosed = true;
3320
+ }
3321
+ };
3322
+ const safeClose = async () => {
3323
+ if (writerClosed) return;
3324
+ try {
3325
+ writerClosed = true;
3326
+ await writer.close();
3327
+ } catch {
3328
+ }
3329
+ };
3330
+ const cleanupAbortController = () => {
3331
+ streamAbortControllers.delete(streamId);
3332
+ };
3333
+ (async () => {
3334
+ let isAborted = false;
2376
3335
  try {
2377
- const agent = await Agent.create({ sessionId: id });
3336
+ const agent = await Agent.create({ sessionId });
3337
+ await writeSSE(JSON.stringify({ type: "data-stream-id", streamId }));
3338
+ await writeSSE(JSON.stringify({
3339
+ type: "data-user-message",
3340
+ data: { id: `user_${Date.now()}`, content: prompt }
3341
+ }));
2378
3342
  const messageId = `msg_${Date.now()}`;
2379
- await stream.writeSSE({
2380
- data: JSON.stringify({ type: "start", messageId })
2381
- });
3343
+ await writeSSE(JSON.stringify({ type: "start", messageId }));
2382
3344
  let textId = `text_${Date.now()}`;
2383
3345
  let textStarted = false;
2384
3346
  const result = await agent.stream({
2385
3347
  prompt,
2386
- onToolCall: async (toolCall) => {
2387
- await stream.writeSSE({
2388
- data: JSON.stringify({
2389
- type: "tool-input-start",
2390
- toolCallId: toolCall.toolCallId,
2391
- toolName: toolCall.toolName
2392
- })
2393
- });
2394
- await stream.writeSSE({
2395
- data: JSON.stringify({
2396
- type: "tool-input-available",
2397
- toolCallId: toolCall.toolCallId,
2398
- toolName: toolCall.toolName,
2399
- input: toolCall.input
2400
- })
2401
- });
3348
+ abortSignal: abortController.signal,
3349
+ // Use our managed abort controller, NOT client signal
3350
+ skipSaveUserMessage: true,
3351
+ // User message is saved in the route before streaming
3352
+ // Note: tool-input-start/available events are sent from the stream loop
3353
+ // when we see tool-call-streaming-start and tool-call events.
3354
+ // We only use onToolCall/onToolResult for non-streaming scenarios or
3355
+ // tools that need special handling (like approval requests).
3356
+ onToolCall: async () => {
2402
3357
  },
2403
- onToolResult: async (result2) => {
2404
- await stream.writeSSE({
2405
- data: JSON.stringify({
2406
- type: "tool-output-available",
2407
- toolCallId: result2.toolCallId,
2408
- output: result2.output
2409
- })
2410
- });
3358
+ onToolResult: async () => {
2411
3359
  },
2412
3360
  onApprovalRequired: async (execution) => {
2413
- await stream.writeSSE({
2414
- data: JSON.stringify({
2415
- type: "data-approval-required",
2416
- data: {
2417
- id: execution.id,
2418
- toolCallId: execution.toolCallId,
2419
- toolName: execution.toolName,
2420
- input: execution.input
2421
- }
2422
- })
2423
- });
3361
+ await writeSSE(JSON.stringify({
3362
+ type: "data-approval-required",
3363
+ data: {
3364
+ id: execution.id,
3365
+ toolCallId: execution.toolCallId,
3366
+ toolName: execution.toolName,
3367
+ input: execution.input
3368
+ }
3369
+ }));
3370
+ },
3371
+ onToolProgress: async (progress) => {
3372
+ await writeSSE(JSON.stringify({
3373
+ type: "tool-progress",
3374
+ toolName: progress.toolName,
3375
+ data: progress.data
3376
+ }));
2424
3377
  },
2425
3378
  onStepFinish: async () => {
2426
- await stream.writeSSE({
2427
- data: JSON.stringify({ type: "finish-step" })
2428
- });
3379
+ await writeSSE(JSON.stringify({ type: "finish-step" }));
2429
3380
  if (textStarted) {
2430
- await stream.writeSSE({
2431
- data: JSON.stringify({ type: "text-end", id: textId })
2432
- });
3381
+ await writeSSE(JSON.stringify({ type: "text-end", id: textId }));
2433
3382
  textStarted = false;
2434
3383
  textId = `text_${Date.now()}`;
2435
3384
  }
3385
+ },
3386
+ onAbort: async ({ steps }) => {
3387
+ isAborted = true;
3388
+ console.log(`Stream aborted after ${steps.length} steps`);
2436
3389
  }
2437
3390
  });
3391
+ let reasoningId = `reasoning_${Date.now()}`;
3392
+ let reasoningStarted = false;
2438
3393
  for await (const part of result.stream.fullStream) {
2439
3394
  if (part.type === "text-delta") {
2440
3395
  if (!textStarted) {
2441
- await stream.writeSSE({
2442
- data: JSON.stringify({ type: "text-start", id: textId })
2443
- });
3396
+ await writeSSE(JSON.stringify({ type: "text-start", id: textId }));
2444
3397
  textStarted = true;
2445
3398
  }
2446
- await stream.writeSSE({
2447
- data: JSON.stringify({ type: "text-delta", id: textId, delta: part.text })
2448
- });
3399
+ await writeSSE(JSON.stringify({ type: "text-delta", id: textId, delta: part.text }));
3400
+ } else if (part.type === "reasoning-start") {
3401
+ await writeSSE(JSON.stringify({ type: "reasoning-start", id: reasoningId }));
3402
+ reasoningStarted = true;
3403
+ } else if (part.type === "reasoning-delta") {
3404
+ await writeSSE(JSON.stringify({ type: "reasoning-delta", id: reasoningId, delta: part.text }));
3405
+ } else if (part.type === "reasoning-end") {
3406
+ if (reasoningStarted) {
3407
+ await writeSSE(JSON.stringify({ type: "reasoning-end", id: reasoningId }));
3408
+ reasoningStarted = false;
3409
+ reasoningId = `reasoning_${Date.now()}`;
3410
+ }
3411
+ } else if (part.type === "tool-call-streaming-start") {
3412
+ const p = part;
3413
+ await writeSSE(JSON.stringify({
3414
+ type: "tool-input-start",
3415
+ toolCallId: p.toolCallId,
3416
+ toolName: p.toolName
3417
+ }));
3418
+ } else if (part.type === "tool-call-delta") {
3419
+ const p = part;
3420
+ await writeSSE(JSON.stringify({
3421
+ type: "tool-input-delta",
3422
+ toolCallId: p.toolCallId,
3423
+ argsTextDelta: p.argsTextDelta
3424
+ }));
2449
3425
  } else if (part.type === "tool-call") {
2450
- await stream.writeSSE({
2451
- data: JSON.stringify({
2452
- type: "tool-input-available",
2453
- toolCallId: part.toolCallId,
2454
- toolName: part.toolName,
2455
- input: part.input
2456
- })
2457
- });
3426
+ await writeSSE(JSON.stringify({
3427
+ type: "tool-input-available",
3428
+ toolCallId: part.toolCallId,
3429
+ toolName: part.toolName,
3430
+ input: part.input
3431
+ }));
2458
3432
  } else if (part.type === "tool-result") {
2459
- await stream.writeSSE({
2460
- data: JSON.stringify({
2461
- type: "tool-output-available",
2462
- toolCallId: part.toolCallId,
2463
- output: part.output
2464
- })
2465
- });
3433
+ await writeSSE(JSON.stringify({
3434
+ type: "tool-output-available",
3435
+ toolCallId: part.toolCallId,
3436
+ output: part.output
3437
+ }));
2466
3438
  } else if (part.type === "error") {
2467
3439
  console.error("Stream error:", part.error);
2468
- await stream.writeSSE({
2469
- data: JSON.stringify({ type: "error", errorText: String(part.error) })
2470
- });
3440
+ await writeSSE(JSON.stringify({ type: "error", errorText: String(part.error) }));
2471
3441
  }
2472
3442
  }
2473
3443
  if (textStarted) {
2474
- await stream.writeSSE({
2475
- data: JSON.stringify({ type: "text-end", id: textId })
2476
- });
3444
+ await writeSSE(JSON.stringify({ type: "text-end", id: textId }));
2477
3445
  }
2478
- await result.saveResponseMessages();
2479
- await stream.writeSSE({
2480
- data: JSON.stringify({ type: "finish" })
2481
- });
2482
- await stream.writeSSE({ data: "[DONE]" });
3446
+ if (reasoningStarted) {
3447
+ await writeSSE(JSON.stringify({ type: "reasoning-end", id: reasoningId }));
3448
+ }
3449
+ if (!isAborted) {
3450
+ await result.saveResponseMessages();
3451
+ }
3452
+ if (isAborted) {
3453
+ await writeSSE(JSON.stringify({ type: "abort" }));
3454
+ } else {
3455
+ await writeSSE(JSON.stringify({ type: "finish" }));
3456
+ }
3457
+ activeStreamQueries.finish(streamId);
2483
3458
  } catch (error) {
2484
- await stream.writeSSE({
2485
- data: JSON.stringify({
2486
- type: "error",
2487
- errorText: error.message
2488
- })
2489
- });
2490
- await stream.writeSSE({ data: "[DONE]" });
3459
+ if (error.name === "AbortError" || error.message?.includes("aborted")) {
3460
+ await writeSSE(JSON.stringify({ type: "abort" }));
3461
+ } else {
3462
+ console.error("Agent error:", error);
3463
+ await writeSSE(JSON.stringify({ type: "error", errorText: error.message }));
3464
+ activeStreamQueries.markError(streamId);
3465
+ }
3466
+ } finally {
3467
+ cleanupAbortController();
3468
+ await writeSSE("[DONE]");
3469
+ await safeClose();
3470
+ }
3471
+ })();
3472
+ return readable;
3473
+ };
3474
+ }
3475
+ agents.post(
3476
+ "/:id/run",
3477
+ zValidator2("json", runPromptSchema),
3478
+ async (c) => {
3479
+ const id = c.req.param("id");
3480
+ const { prompt } = c.req.valid("json");
3481
+ const session = sessionQueries.getById(id);
3482
+ if (!session) {
3483
+ return c.json({ error: "Session not found" }, 404);
3484
+ }
3485
+ const nextSequence = messageQueries.getNextSequence(id);
3486
+ await createCheckpoint(id, session.workingDirectory, nextSequence);
3487
+ messageQueries.create(id, { role: "user", content: prompt });
3488
+ const streamId = `stream_${id}_${nanoid4(10)}`;
3489
+ activeStreamQueries.create(id, streamId);
3490
+ const stream = await streamContext.resumableStream(
3491
+ streamId,
3492
+ createAgentStreamProducer(id, prompt, streamId)
3493
+ );
3494
+ if (!stream) {
3495
+ return c.json({ error: "Failed to create stream" }, 500);
3496
+ }
3497
+ const encodedStream = stream.pipeThrough(new TextEncoderStream());
3498
+ return new Response(encodedStream, {
3499
+ headers: {
3500
+ "Content-Type": "text/event-stream",
3501
+ "Cache-Control": "no-cache",
3502
+ "Connection": "keep-alive",
3503
+ "x-vercel-ai-ui-message-stream": "v1",
3504
+ "x-stream-id": streamId
2491
3505
  }
2492
3506
  });
2493
3507
  }
2494
3508
  );
3509
+ agents.get("/:id/watch", async (c) => {
3510
+ const sessionId = c.req.param("id");
3511
+ const resumeAt = c.req.query("resumeAt");
3512
+ const explicitStreamId = c.req.query("streamId");
3513
+ const session = sessionQueries.getById(sessionId);
3514
+ if (!session) {
3515
+ return c.json({ error: "Session not found" }, 404);
3516
+ }
3517
+ let streamId = explicitStreamId;
3518
+ if (!streamId) {
3519
+ const activeStream = activeStreamQueries.getBySessionId(sessionId);
3520
+ if (!activeStream) {
3521
+ return c.json({ error: "No active stream for this session", hint: "Start a new run with POST /agents/:id/run" }, 404);
3522
+ }
3523
+ streamId = activeStream.streamId;
3524
+ }
3525
+ const stream = await streamContext.resumeExistingStream(
3526
+ streamId,
3527
+ resumeAt ? parseInt(resumeAt, 10) : void 0
3528
+ );
3529
+ if (!stream) {
3530
+ return c.json({
3531
+ error: "Stream is no longer active",
3532
+ streamId,
3533
+ hint: "The stream may have finished. Check /agents/:id/approvals or start a new run."
3534
+ }, 422);
3535
+ }
3536
+ const encodedStream = stream.pipeThrough(new TextEncoderStream());
3537
+ return new Response(encodedStream, {
3538
+ headers: {
3539
+ "Content-Type": "text/event-stream",
3540
+ "Cache-Control": "no-cache",
3541
+ "Connection": "keep-alive",
3542
+ "x-vercel-ai-ui-message-stream": "v1",
3543
+ "x-stream-id": streamId
3544
+ }
3545
+ });
3546
+ });
3547
+ agents.get("/:id/stream", async (c) => {
3548
+ const sessionId = c.req.param("id");
3549
+ const session = sessionQueries.getById(sessionId);
3550
+ if (!session) {
3551
+ return c.json({ error: "Session not found" }, 404);
3552
+ }
3553
+ const activeStream = activeStreamQueries.getBySessionId(sessionId);
3554
+ return c.json({
3555
+ sessionId,
3556
+ hasActiveStream: !!activeStream,
3557
+ stream: activeStream ? {
3558
+ id: activeStream.id,
3559
+ streamId: activeStream.streamId,
3560
+ status: activeStream.status,
3561
+ createdAt: activeStream.createdAt.toISOString()
3562
+ } : null
3563
+ });
3564
+ });
2495
3565
  agents.post(
2496
3566
  "/:id/generate",
2497
3567
  zValidator2("json", runPromptSchema),
@@ -2577,6 +3647,28 @@ agents.get("/:id/approvals", async (c) => {
2577
3647
  count: pendingApprovals.length
2578
3648
  });
2579
3649
  });
3650
+ agents.post("/:id/abort", async (c) => {
3651
+ const sessionId = c.req.param("id");
3652
+ const session = sessionQueries.getById(sessionId);
3653
+ if (!session) {
3654
+ return c.json({ error: "Session not found" }, 404);
3655
+ }
3656
+ const activeStream = activeStreamQueries.getBySessionId(sessionId);
3657
+ if (!activeStream) {
3658
+ return c.json({ error: "No active stream for this session" }, 404);
3659
+ }
3660
+ const abortController = streamAbortControllers.get(activeStream.streamId);
3661
+ if (abortController) {
3662
+ abortController.abort();
3663
+ streamAbortControllers.delete(activeStream.streamId);
3664
+ return c.json({ success: true, streamId: activeStream.streamId, aborted: true });
3665
+ }
3666
+ return c.json({
3667
+ success: false,
3668
+ streamId: activeStream.streamId,
3669
+ message: "Stream may have already finished or was not found"
3670
+ });
3671
+ });
2580
3672
  agents.post(
2581
3673
  "/quick",
2582
3674
  zValidator2("json", quickStartSchema),
@@ -2590,14 +3682,41 @@ agents.post(
2590
3682
  sessionConfig: body.toolApprovals ? { toolApprovals: body.toolApprovals } : void 0
2591
3683
  });
2592
3684
  const session = agent.getSession();
2593
- c.header("Content-Type", "text/event-stream");
2594
- c.header("Cache-Control", "no-cache");
2595
- c.header("Connection", "keep-alive");
2596
- c.header("x-vercel-ai-ui-message-stream", "v1");
2597
- return streamSSE(c, async (stream) => {
2598
- try {
2599
- await stream.writeSSE({
2600
- data: JSON.stringify({
3685
+ const streamId = `stream_${session.id}_${nanoid4(10)}`;
3686
+ await createCheckpoint(session.id, session.workingDirectory, 0);
3687
+ activeStreamQueries.create(session.id, streamId);
3688
+ const createQuickStreamProducer = () => {
3689
+ const { readable, writable } = new TransformStream();
3690
+ const writer = writable.getWriter();
3691
+ let writerClosed = false;
3692
+ const abortController = new AbortController();
3693
+ streamAbortControllers.set(streamId, abortController);
3694
+ const writeSSE = async (data) => {
3695
+ if (writerClosed) return;
3696
+ try {
3697
+ await writer.write(`data: ${data}
3698
+
3699
+ `);
3700
+ } catch (err) {
3701
+ writerClosed = true;
3702
+ }
3703
+ };
3704
+ const safeClose = async () => {
3705
+ if (writerClosed) return;
3706
+ try {
3707
+ writerClosed = true;
3708
+ await writer.close();
3709
+ } catch {
3710
+ }
3711
+ };
3712
+ const cleanupAbortController = () => {
3713
+ streamAbortControllers.delete(streamId);
3714
+ };
3715
+ (async () => {
3716
+ let isAborted = false;
3717
+ try {
3718
+ await writeSSE(JSON.stringify({ type: "data-stream-id", streamId }));
3719
+ await writeSSE(JSON.stringify({
2601
3720
  type: "data-session",
2602
3721
  data: {
2603
3722
  id: session.id,
@@ -2605,63 +3724,134 @@ agents.post(
2605
3724
  workingDirectory: session.workingDirectory,
2606
3725
  model: session.model
2607
3726
  }
2608
- })
2609
- });
2610
- const messageId = `msg_${Date.now()}`;
2611
- await stream.writeSSE({
2612
- data: JSON.stringify({ type: "start", messageId })
2613
- });
2614
- let textId = `text_${Date.now()}`;
2615
- let textStarted = false;
2616
- const result = await agent.stream({
2617
- prompt: body.prompt,
2618
- onStepFinish: async () => {
2619
- await stream.writeSSE({
2620
- data: JSON.stringify({ type: "finish-step" })
2621
- });
2622
- if (textStarted) {
2623
- await stream.writeSSE({
2624
- data: JSON.stringify({ type: "text-end", id: textId })
2625
- });
2626
- textStarted = false;
2627
- textId = `text_${Date.now()}`;
3727
+ }));
3728
+ const messageId = `msg_${Date.now()}`;
3729
+ await writeSSE(JSON.stringify({ type: "start", messageId }));
3730
+ let textId = `text_${Date.now()}`;
3731
+ let textStarted = false;
3732
+ const result = await agent.stream({
3733
+ prompt: body.prompt,
3734
+ abortSignal: abortController.signal,
3735
+ // Use our managed abort controller, NOT client signal
3736
+ onToolProgress: async (progress) => {
3737
+ await writeSSE(JSON.stringify({
3738
+ type: "tool-progress",
3739
+ toolName: progress.toolName,
3740
+ data: progress.data
3741
+ }));
3742
+ },
3743
+ onStepFinish: async () => {
3744
+ await writeSSE(JSON.stringify({ type: "finish-step" }));
3745
+ if (textStarted) {
3746
+ await writeSSE(JSON.stringify({ type: "text-end", id: textId }));
3747
+ textStarted = false;
3748
+ textId = `text_${Date.now()}`;
3749
+ }
3750
+ },
3751
+ onAbort: async ({ steps }) => {
3752
+ isAborted = true;
3753
+ console.log(`Stream aborted after ${steps.length} steps`);
2628
3754
  }
2629
- }
2630
- });
2631
- for await (const part of result.stream.fullStream) {
2632
- if (part.type === "text-delta") {
2633
- if (!textStarted) {
2634
- await stream.writeSSE({
2635
- data: JSON.stringify({ type: "text-start", id: textId })
2636
- });
2637
- textStarted = true;
3755
+ });
3756
+ let reasoningId = `reasoning_${Date.now()}`;
3757
+ let reasoningStarted = false;
3758
+ for await (const part of result.stream.fullStream) {
3759
+ if (part.type === "text-delta") {
3760
+ if (!textStarted) {
3761
+ await writeSSE(JSON.stringify({ type: "text-start", id: textId }));
3762
+ textStarted = true;
3763
+ }
3764
+ await writeSSE(JSON.stringify({ type: "text-delta", id: textId, delta: part.text }));
3765
+ } else if (part.type === "reasoning-start") {
3766
+ await writeSSE(JSON.stringify({ type: "reasoning-start", id: reasoningId }));
3767
+ reasoningStarted = true;
3768
+ } else if (part.type === "reasoning-delta") {
3769
+ await writeSSE(JSON.stringify({ type: "reasoning-delta", id: reasoningId, delta: part.text }));
3770
+ } else if (part.type === "reasoning-end") {
3771
+ if (reasoningStarted) {
3772
+ await writeSSE(JSON.stringify({ type: "reasoning-end", id: reasoningId }));
3773
+ reasoningStarted = false;
3774
+ reasoningId = `reasoning_${Date.now()}`;
3775
+ }
3776
+ } else if (part.type === "tool-call-streaming-start") {
3777
+ const p = part;
3778
+ await writeSSE(JSON.stringify({
3779
+ type: "tool-input-start",
3780
+ toolCallId: p.toolCallId,
3781
+ toolName: p.toolName
3782
+ }));
3783
+ } else if (part.type === "tool-call-delta") {
3784
+ const p = part;
3785
+ await writeSSE(JSON.stringify({
3786
+ type: "tool-input-delta",
3787
+ toolCallId: p.toolCallId,
3788
+ argsTextDelta: p.argsTextDelta
3789
+ }));
3790
+ } else if (part.type === "tool-call") {
3791
+ await writeSSE(JSON.stringify({
3792
+ type: "tool-input-available",
3793
+ toolCallId: part.toolCallId,
3794
+ toolName: part.toolName,
3795
+ input: part.input
3796
+ }));
3797
+ } else if (part.type === "tool-result") {
3798
+ await writeSSE(JSON.stringify({
3799
+ type: "tool-output-available",
3800
+ toolCallId: part.toolCallId,
3801
+ output: part.output
3802
+ }));
3803
+ } else if (part.type === "error") {
3804
+ console.error("Stream error:", part.error);
3805
+ await writeSSE(JSON.stringify({ type: "error", errorText: String(part.error) }));
2638
3806
  }
2639
- await stream.writeSSE({
2640
- data: JSON.stringify({ type: "text-delta", id: textId, delta: part.text })
2641
- });
2642
- } else if (part.type === "error") {
2643
- console.error("Stream error:", part.error);
2644
- await stream.writeSSE({
2645
- data: JSON.stringify({ type: "error", errorText: String(part.error) })
2646
- });
2647
3807
  }
3808
+ if (textStarted) {
3809
+ await writeSSE(JSON.stringify({ type: "text-end", id: textId }));
3810
+ }
3811
+ if (reasoningStarted) {
3812
+ await writeSSE(JSON.stringify({ type: "reasoning-end", id: reasoningId }));
3813
+ }
3814
+ if (!isAborted) {
3815
+ await result.saveResponseMessages();
3816
+ }
3817
+ if (isAborted) {
3818
+ await writeSSE(JSON.stringify({ type: "abort" }));
3819
+ } else {
3820
+ await writeSSE(JSON.stringify({ type: "finish" }));
3821
+ }
3822
+ activeStreamQueries.finish(streamId);
3823
+ } catch (error) {
3824
+ if (error.name === "AbortError" || error.message?.includes("aborted")) {
3825
+ await writeSSE(JSON.stringify({ type: "abort" }));
3826
+ } else {
3827
+ console.error("Agent error:", error);
3828
+ await writeSSE(JSON.stringify({ type: "error", errorText: error.message }));
3829
+ activeStreamQueries.markError(streamId);
3830
+ }
3831
+ } finally {
3832
+ cleanupAbortController();
3833
+ await writeSSE("[DONE]");
3834
+ await safeClose();
2648
3835
  }
2649
- if (textStarted) {
2650
- await stream.writeSSE({
2651
- data: JSON.stringify({ type: "text-end", id: textId })
2652
- });
2653
- }
2654
- await result.saveResponseMessages();
2655
- await stream.writeSSE({
2656
- data: JSON.stringify({ type: "finish" })
2657
- });
2658
- await stream.writeSSE({ data: "[DONE]" });
2659
- } catch (error) {
2660
- console.error("Agent error:", error);
2661
- await stream.writeSSE({
2662
- data: JSON.stringify({ type: "error", errorText: error.message })
2663
- });
2664
- await stream.writeSSE({ data: "[DONE]" });
3836
+ })();
3837
+ return readable;
3838
+ };
3839
+ const stream = await streamContext.resumableStream(
3840
+ streamId,
3841
+ createQuickStreamProducer
3842
+ );
3843
+ if (!stream) {
3844
+ return c.json({ error: "Failed to create stream" }, 500);
3845
+ }
3846
+ const encodedStream = stream.pipeThrough(new TextEncoderStream());
3847
+ return new Response(encodedStream, {
3848
+ headers: {
3849
+ "Content-Type": "text/event-stream",
3850
+ "Cache-Control": "no-cache",
3851
+ "Connection": "keep-alive",
3852
+ "x-vercel-ai-ui-message-stream": "v1",
3853
+ "x-stream-id": streamId,
3854
+ "x-session-id": session.id
2665
3855
  }
2666
3856
  });
2667
3857
  }
@@ -2669,16 +3859,23 @@ agents.post(
2669
3859
 
2670
3860
  // src/server/routes/health.ts
2671
3861
  import { Hono as Hono3 } from "hono";
3862
+ import { zValidator as zValidator3 } from "@hono/zod-validator";
3863
+ import { z as z10 } from "zod";
2672
3864
  var health = new Hono3();
2673
3865
  health.get("/", async (c) => {
2674
3866
  const config = getConfig();
3867
+ const apiKeyStatus = getApiKeyStatus();
3868
+ const gatewayKey = apiKeyStatus.find((s) => s.provider === "ai-gateway");
3869
+ const hasApiKey = gatewayKey?.configured ?? false;
2675
3870
  return c.json({
2676
3871
  status: "ok",
2677
3872
  version: "0.1.0",
2678
3873
  uptime: process.uptime(),
3874
+ apiKeyConfigured: hasApiKey,
2679
3875
  config: {
2680
3876
  workingDirectory: config.resolvedWorkingDirectory,
2681
3877
  defaultModel: config.defaultModel,
3878
+ defaultToolApprovals: config.toolApprovals || {},
2682
3879
  port: config.server.port
2683
3880
  },
2684
3881
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -2702,10 +3899,54 @@ health.get("/ready", async (c) => {
2702
3899
  );
2703
3900
  }
2704
3901
  });
3902
+ health.get("/api-keys", async (c) => {
3903
+ const status = getApiKeyStatus();
3904
+ return c.json({
3905
+ providers: status,
3906
+ supportedProviders: SUPPORTED_PROVIDERS
3907
+ });
3908
+ });
3909
+ var setApiKeySchema = z10.object({
3910
+ provider: z10.string(),
3911
+ apiKey: z10.string().min(1)
3912
+ });
3913
+ health.post(
3914
+ "/api-keys",
3915
+ zValidator3("json", setApiKeySchema),
3916
+ async (c) => {
3917
+ const { provider, apiKey } = c.req.valid("json");
3918
+ try {
3919
+ setApiKey(provider, apiKey);
3920
+ const status = getApiKeyStatus();
3921
+ const providerStatus = status.find((s) => s.provider === provider.toLowerCase());
3922
+ return c.json({
3923
+ success: true,
3924
+ provider: provider.toLowerCase(),
3925
+ maskedKey: providerStatus?.maskedKey,
3926
+ message: `API key for ${provider} saved successfully`
3927
+ });
3928
+ } catch (error) {
3929
+ return c.json({ error: error.message }, 400);
3930
+ }
3931
+ }
3932
+ );
3933
+ health.delete("/api-keys/:provider", async (c) => {
3934
+ const provider = c.req.param("provider");
3935
+ try {
3936
+ removeApiKey(provider);
3937
+ return c.json({
3938
+ success: true,
3939
+ provider: provider.toLowerCase(),
3940
+ message: `API key for ${provider} removed`
3941
+ });
3942
+ } catch (error) {
3943
+ return c.json({ error: error.message }, 400);
3944
+ }
3945
+ });
2705
3946
 
2706
3947
  // src/server/routes/terminals.ts
2707
3948
  import { Hono as Hono4 } from "hono";
2708
- import { zValidator as zValidator3 } from "@hono/zod-validator";
3949
+ import { zValidator as zValidator4 } from "@hono/zod-validator";
2709
3950
  import { z as z11 } from "zod";
2710
3951
  var terminals2 = new Hono4();
2711
3952
  var spawnSchema = z11.object({
@@ -2715,7 +3956,7 @@ var spawnSchema = z11.object({
2715
3956
  });
2716
3957
  terminals2.post(
2717
3958
  "/:sessionId/terminals",
2718
- zValidator3("json", spawnSchema),
3959
+ zValidator4("json", spawnSchema),
2719
3960
  async (c) => {
2720
3961
  const sessionId = c.req.param("sessionId");
2721
3962
  const body = c.req.valid("json");
@@ -2723,14 +3964,21 @@ terminals2.post(
2723
3964
  if (!session) {
2724
3965
  return c.json({ error: "Session not found" }, 404);
2725
3966
  }
2726
- const manager = getTerminalManager();
2727
- const terminal = manager.spawn({
2728
- sessionId,
3967
+ const hasTmux = await isTmuxAvailable();
3968
+ if (!hasTmux) {
3969
+ return c.json({ error: "tmux is not installed. Background terminals require tmux." }, 400);
3970
+ }
3971
+ const workingDirectory = body.cwd || session.workingDirectory;
3972
+ const result = await runBackground(body.command, workingDirectory, { sessionId });
3973
+ return c.json({
3974
+ id: result.id,
3975
+ name: body.name || null,
2729
3976
  command: body.command,
2730
- cwd: body.cwd || session.workingDirectory,
2731
- name: body.name
2732
- });
2733
- return c.json(terminal, 201);
3977
+ cwd: workingDirectory,
3978
+ status: result.status,
3979
+ pid: null
3980
+ // tmux doesn't expose PID directly
3981
+ }, 201);
2734
3982
  }
2735
3983
  );
2736
3984
  terminals2.get("/:sessionId/terminals", async (c) => {
@@ -2739,8 +3987,20 @@ terminals2.get("/:sessionId/terminals", async (c) => {
2739
3987
  if (!session) {
2740
3988
  return c.json({ error: "Session not found" }, 404);
2741
3989
  }
2742
- const manager = getTerminalManager();
2743
- const terminalList = manager.list(sessionId);
3990
+ const sessionTerminals = await listSessionTerminals(sessionId, session.workingDirectory);
3991
+ const terminalList = await Promise.all(
3992
+ sessionTerminals.map(async (meta) => {
3993
+ const running = await isRunning(meta.id);
3994
+ return {
3995
+ id: meta.id,
3996
+ name: null,
3997
+ command: meta.command,
3998
+ cwd: meta.cwd,
3999
+ status: running ? "running" : "stopped",
4000
+ createdAt: meta.createdAt
4001
+ };
4002
+ })
4003
+ );
2744
4004
  return c.json({
2745
4005
  sessionId,
2746
4006
  terminals: terminalList,
@@ -2751,31 +4011,47 @@ terminals2.get("/:sessionId/terminals", async (c) => {
2751
4011
  terminals2.get("/:sessionId/terminals/:terminalId", async (c) => {
2752
4012
  const sessionId = c.req.param("sessionId");
2753
4013
  const terminalId = c.req.param("terminalId");
2754
- const manager = getTerminalManager();
2755
- const terminal = manager.getStatus(terminalId);
2756
- if (!terminal) {
4014
+ const session = sessionQueries.getById(sessionId);
4015
+ if (!session) {
4016
+ return c.json({ error: "Session not found" }, 404);
4017
+ }
4018
+ const meta = await getMeta(terminalId, session.workingDirectory, sessionId);
4019
+ if (!meta) {
2757
4020
  return c.json({ error: "Terminal not found" }, 404);
2758
4021
  }
2759
- return c.json(terminal);
4022
+ const running = await isRunning(terminalId);
4023
+ return c.json({
4024
+ id: terminalId,
4025
+ command: meta.command,
4026
+ cwd: meta.cwd,
4027
+ status: running ? "running" : "stopped",
4028
+ createdAt: meta.createdAt,
4029
+ exitCode: running ? null : 0
4030
+ // We don't track exit codes in tmux mode
4031
+ });
2760
4032
  });
2761
4033
  var logsQuerySchema = z11.object({
2762
4034
  tail: z11.string().optional().transform((v) => v ? parseInt(v, 10) : void 0)
2763
4035
  });
2764
4036
  terminals2.get(
2765
4037
  "/:sessionId/terminals/:terminalId/logs",
2766
- zValidator3("query", logsQuerySchema),
4038
+ zValidator4("query", logsQuerySchema),
2767
4039
  async (c) => {
4040
+ const sessionId = c.req.param("sessionId");
2768
4041
  const terminalId = c.req.param("terminalId");
2769
4042
  const query = c.req.valid("query");
2770
- const manager = getTerminalManager();
2771
- const result = manager.getLogs(terminalId, query.tail);
2772
- if (!result) {
4043
+ const session = sessionQueries.getById(sessionId);
4044
+ if (!session) {
4045
+ return c.json({ error: "Session not found" }, 404);
4046
+ }
4047
+ const result = await getLogs(terminalId, session.workingDirectory, { tail: query.tail, sessionId });
4048
+ if (result.status === "unknown") {
2773
4049
  return c.json({ error: "Terminal not found" }, 404);
2774
4050
  }
2775
4051
  return c.json({
2776
4052
  terminalId,
2777
- logs: result.logs,
2778
- lineCount: result.lineCount
4053
+ logs: result.output,
4054
+ lineCount: result.output.split("\n").length
2779
4055
  });
2780
4056
  }
2781
4057
  );
@@ -2784,16 +4060,14 @@ var killSchema = z11.object({
2784
4060
  });
2785
4061
  terminals2.post(
2786
4062
  "/:sessionId/terminals/:terminalId/kill",
2787
- zValidator3("json", killSchema.optional()),
4063
+ zValidator4("json", killSchema.optional()),
2788
4064
  async (c) => {
2789
4065
  const terminalId = c.req.param("terminalId");
2790
- const body = await c.req.json().catch(() => ({}));
2791
- const manager = getTerminalManager();
2792
- const success = manager.kill(terminalId, body.signal);
4066
+ const success = await killTerminal(terminalId);
2793
4067
  if (!success) {
2794
- return c.json({ error: "Failed to kill terminal" }, 400);
4068
+ return c.json({ error: "Failed to kill terminal (may already be stopped)" }, 400);
2795
4069
  }
2796
- return c.json({ success: true, message: `Sent ${body.signal || "SIGTERM"} to terminal` });
4070
+ return c.json({ success: true, message: "Terminal killed" });
2797
4071
  }
2798
4072
  );
2799
4073
  var writeSchema = z11.object({
@@ -2801,97 +4075,164 @@ var writeSchema = z11.object({
2801
4075
  });
2802
4076
  terminals2.post(
2803
4077
  "/:sessionId/terminals/:terminalId/write",
2804
- zValidator3("json", writeSchema),
4078
+ zValidator4("json", writeSchema),
2805
4079
  async (c) => {
2806
- const terminalId = c.req.param("terminalId");
2807
- const body = c.req.valid("json");
2808
- const manager = getTerminalManager();
2809
- const success = manager.write(terminalId, body.input);
2810
- if (!success) {
2811
- return c.json({ error: "Failed to write to terminal" }, 400);
2812
- }
2813
- return c.json({ success: true });
4080
+ return c.json({
4081
+ error: "stdin writing not supported in tmux mode. Use tmux send-keys directly if needed.",
4082
+ hint: 'tmux send-keys -t spark_{terminalId} "your input"'
4083
+ }, 501);
2814
4084
  }
2815
4085
  );
2816
4086
  terminals2.post("/:sessionId/terminals/kill-all", async (c) => {
2817
4087
  const sessionId = c.req.param("sessionId");
2818
- const manager = getTerminalManager();
2819
- const killed = manager.killAll(sessionId);
4088
+ const session = sessionQueries.getById(sessionId);
4089
+ if (!session) {
4090
+ return c.json({ error: "Session not found" }, 404);
4091
+ }
4092
+ const terminalIds = await listSessions();
4093
+ let killed = 0;
4094
+ for (const id of terminalIds) {
4095
+ const meta = await getMeta(id, session.workingDirectory);
4096
+ if (meta && meta.sessionId === sessionId) {
4097
+ const success = await killTerminal(id);
4098
+ if (success) killed++;
4099
+ }
4100
+ }
2820
4101
  return c.json({ success: true, killed });
2821
4102
  });
2822
- terminals2.get("/:sessionId/terminals/:terminalId/stream", async (c) => {
4103
+ terminals2.get("/stream/:terminalId", async (c) => {
2823
4104
  const terminalId = c.req.param("terminalId");
2824
- const manager = getTerminalManager();
2825
- const terminal = manager.getStatus(terminalId);
2826
- if (!terminal) {
4105
+ const sessions3 = sessionQueries.list();
4106
+ let terminalMeta = null;
4107
+ let workingDirectory = process.cwd();
4108
+ let foundSessionId;
4109
+ for (const session of sessions3) {
4110
+ terminalMeta = await getMeta(terminalId, session.workingDirectory, session.id);
4111
+ if (terminalMeta) {
4112
+ workingDirectory = session.workingDirectory;
4113
+ foundSessionId = session.id;
4114
+ break;
4115
+ }
4116
+ }
4117
+ if (!terminalMeta) {
4118
+ for (const session of sessions3) {
4119
+ terminalMeta = await getMeta(terminalId, session.workingDirectory);
4120
+ if (terminalMeta) {
4121
+ workingDirectory = session.workingDirectory;
4122
+ foundSessionId = terminalMeta.sessionId;
4123
+ break;
4124
+ }
4125
+ }
4126
+ }
4127
+ const isActive = await isRunning(terminalId);
4128
+ if (!terminalMeta && !isActive) {
2827
4129
  return c.json({ error: "Terminal not found" }, 404);
2828
4130
  }
2829
- c.header("Content-Type", "text/event-stream");
2830
- c.header("Cache-Control", "no-cache");
2831
- c.header("Connection", "keep-alive");
2832
4131
  return new Response(
2833
4132
  new ReadableStream({
2834
- start(controller) {
4133
+ async start(controller) {
2835
4134
  const encoder = new TextEncoder();
2836
- const initialLogs = manager.getLogs(terminalId);
2837
- if (initialLogs) {
2838
- controller.enqueue(
2839
- encoder.encode(`event: logs
2840
- data: ${JSON.stringify({ logs: initialLogs.logs })}
4135
+ let lastOutput = "";
4136
+ let isRunning2 = true;
4137
+ let pollCount = 0;
4138
+ const maxPolls = 600;
4139
+ controller.enqueue(
4140
+ encoder.encode(`event: status
4141
+ data: ${JSON.stringify({ terminalId, status: "connected" })}
2841
4142
 
2842
4143
  `)
2843
- );
2844
- }
2845
- const onStdout = ({ terminalId: tid, data }) => {
2846
- if (tid === terminalId) {
2847
- controller.enqueue(
2848
- encoder.encode(`event: stdout
2849
- data: ${JSON.stringify({ data })}
4144
+ );
4145
+ while (isRunning2 && pollCount < maxPolls) {
4146
+ try {
4147
+ const result = await getLogs(terminalId, workingDirectory, { sessionId: foundSessionId });
4148
+ if (result.output !== lastOutput) {
4149
+ const newContent = result.output.slice(lastOutput.length);
4150
+ if (newContent) {
4151
+ controller.enqueue(
4152
+ encoder.encode(`event: stdout
4153
+ data: ${JSON.stringify({ data: newContent })}
2850
4154
 
2851
4155
  `)
2852
- );
2853
- }
2854
- };
2855
- const onStderr = ({ terminalId: tid, data }) => {
2856
- if (tid === terminalId) {
2857
- controller.enqueue(
2858
- encoder.encode(`event: stderr
2859
- data: ${JSON.stringify({ data })}
4156
+ );
4157
+ }
4158
+ lastOutput = result.output;
4159
+ }
4160
+ isRunning2 = result.status === "running";
4161
+ if (!isRunning2) {
4162
+ controller.enqueue(
4163
+ encoder.encode(`event: exit
4164
+ data: ${JSON.stringify({ status: "stopped" })}
2860
4165
 
2861
4166
  `)
2862
- );
4167
+ );
4168
+ break;
4169
+ }
4170
+ await new Promise((r) => setTimeout(r, 200));
4171
+ pollCount++;
4172
+ } catch {
4173
+ break;
2863
4174
  }
2864
- };
2865
- const onExit = ({ terminalId: tid, code, signal }) => {
2866
- if (tid === terminalId) {
2867
- controller.enqueue(
2868
- encoder.encode(`event: exit
2869
- data: ${JSON.stringify({ code, signal })}
4175
+ }
4176
+ controller.close();
4177
+ }
4178
+ }),
4179
+ {
4180
+ headers: {
4181
+ "Content-Type": "text/event-stream",
4182
+ "Cache-Control": "no-cache",
4183
+ "Connection": "keep-alive"
4184
+ }
4185
+ }
4186
+ );
4187
+ });
4188
+ terminals2.get("/:sessionId/terminals/:terminalId/stream", async (c) => {
4189
+ const sessionId = c.req.param("sessionId");
4190
+ const terminalId = c.req.param("terminalId");
4191
+ const session = sessionQueries.getById(sessionId);
4192
+ if (!session) {
4193
+ return c.json({ error: "Session not found" }, 404);
4194
+ }
4195
+ const meta = await getMeta(terminalId, session.workingDirectory, sessionId);
4196
+ if (!meta) {
4197
+ return c.json({ error: "Terminal not found" }, 404);
4198
+ }
4199
+ return new Response(
4200
+ new ReadableStream({
4201
+ async start(controller) {
4202
+ const encoder = new TextEncoder();
4203
+ let lastOutput = "";
4204
+ let isRunning2 = true;
4205
+ while (isRunning2) {
4206
+ try {
4207
+ const result = await getLogs(terminalId, session.workingDirectory, { sessionId });
4208
+ if (result.output !== lastOutput) {
4209
+ const newContent = result.output.slice(lastOutput.length);
4210
+ if (newContent) {
4211
+ controller.enqueue(
4212
+ encoder.encode(`event: stdout
4213
+ data: ${JSON.stringify({ data: newContent })}
2870
4214
 
2871
4215
  `)
2872
- );
2873
- cleanup();
2874
- controller.close();
2875
- }
2876
- };
2877
- const cleanup = () => {
2878
- manager.off("stdout", onStdout);
2879
- manager.off("stderr", onStderr);
2880
- manager.off("exit", onExit);
2881
- };
2882
- manager.on("stdout", onStdout);
2883
- manager.on("stderr", onStderr);
2884
- manager.on("exit", onExit);
2885
- if (terminal.status !== "running") {
2886
- controller.enqueue(
2887
- encoder.encode(`event: exit
2888
- data: ${JSON.stringify({ code: terminal.exitCode, status: terminal.status })}
4216
+ );
4217
+ }
4218
+ lastOutput = result.output;
4219
+ }
4220
+ isRunning2 = result.status === "running";
4221
+ if (!isRunning2) {
4222
+ controller.enqueue(
4223
+ encoder.encode(`event: exit
4224
+ data: ${JSON.stringify({ status: "stopped" })}
2889
4225
 
2890
4226
  `)
2891
- );
2892
- cleanup();
2893
- controller.close();
4227
+ );
4228
+ break;
4229
+ }
4230
+ await new Promise((r) => setTimeout(r, 500));
4231
+ } catch {
4232
+ break;
4233
+ }
2894
4234
  }
4235
+ controller.close();
2895
4236
  }
2896
4237
  }),
2897
4238
  {
@@ -2904,16 +4245,215 @@ data: ${JSON.stringify({ code: terminal.exitCode, status: terminal.status })}
2904
4245
  );
2905
4246
  });
2906
4247
 
4248
+ // src/utils/dependencies.ts
4249
+ import { exec as exec4 } from "child_process";
4250
+ import { promisify as promisify4 } from "util";
4251
+ import { platform as platform2 } from "os";
4252
+ var execAsync4 = promisify4(exec4);
4253
+ function getInstallInstructions() {
4254
+ const os2 = platform2();
4255
+ if (os2 === "darwin") {
4256
+ return `
4257
+ Install tmux on macOS:
4258
+ brew install tmux
4259
+
4260
+ If you don't have Homebrew, install it first:
4261
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
4262
+ `.trim();
4263
+ }
4264
+ if (os2 === "linux") {
4265
+ return `
4266
+ Install tmux on Linux:
4267
+ # Ubuntu/Debian
4268
+ sudo apt-get update && sudo apt-get install -y tmux
4269
+
4270
+ # Fedora/RHEL
4271
+ sudo dnf install -y tmux
4272
+
4273
+ # Arch Linux
4274
+ sudo pacman -S tmux
4275
+ `.trim();
4276
+ }
4277
+ return `
4278
+ Install tmux:
4279
+ Please install tmux for your operating system.
4280
+ Visit: https://github.com/tmux/tmux/wiki/Installing
4281
+ `.trim();
4282
+ }
4283
+ async function checkTmux() {
4284
+ try {
4285
+ const { stdout } = await execAsync4("tmux -V", { timeout: 5e3 });
4286
+ const version = stdout.trim();
4287
+ return {
4288
+ available: true,
4289
+ version
4290
+ };
4291
+ } catch (error) {
4292
+ return {
4293
+ available: false,
4294
+ error: "tmux is not installed or not in PATH",
4295
+ installInstructions: getInstallInstructions()
4296
+ };
4297
+ }
4298
+ }
4299
+ async function checkDependencies(options = {}) {
4300
+ const { quiet = false, exitOnFailure = true } = options;
4301
+ const tmuxCheck = await checkTmux();
4302
+ if (!tmuxCheck.available) {
4303
+ if (!quiet) {
4304
+ console.error("\n\u274C Missing required dependency: tmux");
4305
+ console.error("");
4306
+ console.error("SparkECoder requires tmux for terminal session management.");
4307
+ console.error("");
4308
+ if (tmuxCheck.installInstructions) {
4309
+ console.error(tmuxCheck.installInstructions);
4310
+ }
4311
+ console.error("");
4312
+ console.error("After installing tmux, run sparkecoder again.");
4313
+ console.error("");
4314
+ }
4315
+ if (exitOnFailure) {
4316
+ process.exit(1);
4317
+ }
4318
+ return false;
4319
+ }
4320
+ if (!quiet) {
4321
+ }
4322
+ return true;
4323
+ }
4324
+
2907
4325
  // src/server/index.ts
2908
4326
  var serverInstance = null;
2909
- async function createApp() {
4327
+ var webUIProcess = null;
4328
+ var DEFAULT_WEB_PORT = 6969;
4329
+ var WEB_PORT_SEQUENCE = [6969, 6970, 6971, 6972, 6973, 6974, 6975, 6976, 6977, 6978];
4330
+ function getWebDirectory() {
4331
+ try {
4332
+ const currentDir = dirname4(fileURLToPath(import.meta.url));
4333
+ const webDir = resolve6(currentDir, "..", "web");
4334
+ if (existsSync7(webDir) && existsSync7(join3(webDir, "package.json"))) {
4335
+ return webDir;
4336
+ }
4337
+ const altWebDir = resolve6(currentDir, "..", "..", "web");
4338
+ if (existsSync7(altWebDir) && existsSync7(join3(altWebDir, "package.json"))) {
4339
+ return altWebDir;
4340
+ }
4341
+ return null;
4342
+ } catch {
4343
+ return null;
4344
+ }
4345
+ }
4346
+ async function isSparkcoderWebRunning(port) {
4347
+ try {
4348
+ const response = await fetch(`http://localhost:${port}/api/health`, {
4349
+ signal: AbortSignal.timeout(1e3)
4350
+ });
4351
+ if (response.ok) {
4352
+ const data = await response.json();
4353
+ return data.name === "sparkecoder-web";
4354
+ }
4355
+ return false;
4356
+ } catch {
4357
+ return false;
4358
+ }
4359
+ }
4360
+ function isPortInUse(port) {
4361
+ return new Promise((resolve7) => {
4362
+ const server = createNetServer();
4363
+ server.once("error", (err) => {
4364
+ if (err.code === "EADDRINUSE") {
4365
+ resolve7(true);
4366
+ } else {
4367
+ resolve7(false);
4368
+ }
4369
+ });
4370
+ server.once("listening", () => {
4371
+ server.close();
4372
+ resolve7(false);
4373
+ });
4374
+ server.listen(port, "0.0.0.0");
4375
+ });
4376
+ }
4377
+ async function findWebPort(preferredPort) {
4378
+ if (await isSparkcoderWebRunning(preferredPort)) {
4379
+ return { port: preferredPort, alreadyRunning: true };
4380
+ }
4381
+ if (!await isPortInUse(preferredPort)) {
4382
+ return { port: preferredPort, alreadyRunning: false };
4383
+ }
4384
+ for (const port of WEB_PORT_SEQUENCE) {
4385
+ if (port === preferredPort) continue;
4386
+ if (await isSparkcoderWebRunning(port)) {
4387
+ return { port, alreadyRunning: true };
4388
+ }
4389
+ if (!await isPortInUse(port)) {
4390
+ return { port, alreadyRunning: false };
4391
+ }
4392
+ }
4393
+ return { port: preferredPort, alreadyRunning: false };
4394
+ }
4395
+ async function startWebUI(apiPort, webPort = DEFAULT_WEB_PORT, quiet = false) {
4396
+ const webDir = getWebDirectory();
4397
+ if (!webDir) {
4398
+ if (!quiet) console.log(" \u26A0 Web UI not found, skipping...");
4399
+ return { process: null, port: webPort };
4400
+ }
4401
+ const { port: actualPort, alreadyRunning } = await findWebPort(webPort);
4402
+ if (alreadyRunning) {
4403
+ if (!quiet) console.log(` \u2713 Web UI already running at http://localhost:${actualPort}`);
4404
+ return { process: null, port: actualPort };
4405
+ }
4406
+ const useNpm = existsSync7(join3(webDir, "package-lock.json"));
4407
+ const command = useNpm ? "npm" : "npx";
4408
+ const args = useNpm ? ["run", "dev", "--", "-p", String(actualPort)] : ["next", "dev", "-p", String(actualPort)];
4409
+ const child = spawn(command, args, {
4410
+ cwd: webDir,
4411
+ stdio: ["ignore", "pipe", "pipe"],
4412
+ env: {
4413
+ ...process.env,
4414
+ NEXT_PUBLIC_API_URL: `http://127.0.0.1:${apiPort}`
4415
+ },
4416
+ detached: false
4417
+ });
4418
+ let started = false;
4419
+ child.stdout?.on("data", (data) => {
4420
+ const output = data.toString();
4421
+ if (!started && (output.includes("Ready") || output.includes("started") || output.includes("localhost"))) {
4422
+ started = true;
4423
+ if (!quiet) console.log(` \u2713 Web UI running at http://localhost:${actualPort}`);
4424
+ }
4425
+ });
4426
+ if (!quiet) {
4427
+ child.stderr?.on("data", (data) => {
4428
+ const output = data.toString();
4429
+ if (output.toLowerCase().includes("error")) {
4430
+ console.error(` Web UI error: ${output.trim()}`);
4431
+ }
4432
+ });
4433
+ }
4434
+ child.on("exit", () => {
4435
+ webUIProcess = null;
4436
+ });
4437
+ webUIProcess = child;
4438
+ return { process: child, port: actualPort };
4439
+ }
4440
+ function stopWebUI() {
4441
+ if (webUIProcess) {
4442
+ webUIProcess.kill("SIGTERM");
4443
+ webUIProcess = null;
4444
+ }
4445
+ }
4446
+ async function createApp(options = {}) {
2910
4447
  const app = new Hono5();
2911
4448
  app.use("*", cors());
2912
- app.use("*", logger());
4449
+ if (!options.quiet) {
4450
+ app.use("*", logger());
4451
+ }
2913
4452
  app.route("/health", health);
2914
4453
  app.route("/sessions", sessions2);
2915
4454
  app.route("/agents", agents);
2916
4455
  app.route("/sessions", terminals2);
4456
+ app.route("/terminals", terminals2);
2917
4457
  app.get("/openapi.json", async (c) => {
2918
4458
  return c.json(generateOpenAPISpec());
2919
4459
  });
@@ -2922,7 +4462,7 @@ async function createApp() {
2922
4462
  <html lang="en">
2923
4463
  <head>
2924
4464
  <meta charset="UTF-8">
2925
- <title>Sparkecoder API - Swagger UI</title>
4465
+ <title>SparkECoder API - Swagger UI</title>
2926
4466
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
2927
4467
  </head>
2928
4468
  <body>
@@ -2942,7 +4482,7 @@ async function createApp() {
2942
4482
  });
2943
4483
  app.get("/", (c) => {
2944
4484
  return c.json({
2945
- name: "Sparkecoder API",
4485
+ name: "SparkECoder API",
2946
4486
  version: "0.1.0",
2947
4487
  description: "A powerful coding agent CLI with HTTP API",
2948
4488
  docs: "/openapi.json",
@@ -2957,38 +4497,52 @@ async function createApp() {
2957
4497
  return app;
2958
4498
  }
2959
4499
  async function startServer(options = {}) {
4500
+ const depsOk = await checkDependencies({ quiet: options.quiet, exitOnFailure: false });
4501
+ if (!depsOk) {
4502
+ throw new Error("Missing required dependency: tmux. See above for installation instructions.");
4503
+ }
2960
4504
  const config = await loadConfig(options.configPath, options.workingDirectory);
4505
+ loadApiKeysIntoEnv();
2961
4506
  if (options.workingDirectory) {
2962
4507
  config.resolvedWorkingDirectory = options.workingDirectory;
2963
4508
  }
2964
- if (!existsSync5(config.resolvedWorkingDirectory)) {
2965
- mkdirSync(config.resolvedWorkingDirectory, { recursive: true });
2966
- console.log(`\u{1F4C1} Created agent workspace: ${config.resolvedWorkingDirectory}`);
4509
+ if (!existsSync7(config.resolvedWorkingDirectory)) {
4510
+ mkdirSync2(config.resolvedWorkingDirectory, { recursive: true });
4511
+ if (!options.quiet) console.log(`\u{1F4C1} Created agent workspace: ${config.resolvedWorkingDirectory}`);
2967
4512
  }
2968
4513
  initDatabase(config.resolvedDatabasePath);
2969
4514
  const port = options.port || config.server.port;
2970
4515
  const host = options.host || config.server.host || "0.0.0.0";
2971
- const app = await createApp();
2972
- console.log(`
2973
- \u{1F680} Sparkecoder API Server`);
2974
- console.log(` \u2192 Running at http://${host}:${port}`);
2975
- console.log(` \u2192 Working directory: ${config.resolvedWorkingDirectory}`);
2976
- console.log(` \u2192 Default model: ${config.defaultModel}`);
2977
- console.log(` \u2192 OpenAPI spec: http://${host}:${port}/openapi.json
4516
+ const app = await createApp({ quiet: options.quiet });
4517
+ if (!options.quiet) {
4518
+ console.log(`
4519
+ \u{1F680} SparkECoder API Server`);
4520
+ console.log(` \u2192 Running at http://${host}:${port}`);
4521
+ console.log(` \u2192 Working directory: ${config.resolvedWorkingDirectory}`);
4522
+ console.log(` \u2192 Default model: ${config.defaultModel}`);
4523
+ console.log(` \u2192 OpenAPI spec: http://${host}:${port}/openapi.json
2978
4524
  `);
4525
+ }
2979
4526
  serverInstance = serve({
2980
4527
  fetch: app.fetch,
2981
4528
  port,
2982
4529
  hostname: host
2983
4530
  });
2984
- return { app, port, host };
4531
+ let webPort;
4532
+ if (options.webUI !== false) {
4533
+ const result = await startWebUI(port, options.webPort || DEFAULT_WEB_PORT, options.quiet);
4534
+ webPort = result.port;
4535
+ }
4536
+ return { app, port, host, webPort };
2985
4537
  }
2986
4538
  function stopServer() {
2987
- try {
2988
- const manager = getTerminalManager();
2989
- manager.cleanup();
2990
- } catch (e) {
2991
- }
4539
+ stopWebUI();
4540
+ listSessions().then(async (sessions3) => {
4541
+ for (const id of sessions3) {
4542
+ await killTerminal(id);
4543
+ }
4544
+ }).catch(() => {
4545
+ });
2992
4546
  if (serverInstance) {
2993
4547
  serverInstance.close();
2994
4548
  serverInstance = null;
@@ -2999,7 +4553,7 @@ function generateOpenAPISpec() {
2999
4553
  return {
3000
4554
  openapi: "3.1.0",
3001
4555
  info: {
3002
- title: "Sparkecoder API",
4556
+ title: "SparkECoder API",
3003
4557
  version: "0.1.0",
3004
4558
  description: "A powerful coding agent CLI with HTTP API for development environments. Supports streaming responses following the Vercel AI SDK data stream protocol."
3005
4559
  },
@@ -3454,25 +5008,32 @@ var VERSION = "0.1.0";
3454
5008
  export {
3455
5009
  Agent,
3456
5010
  VERSION,
5011
+ backupFile,
5012
+ checkpointQueries,
5013
+ clearCheckpointManager,
3457
5014
  closeDatabase,
3458
5015
  createApp,
3459
5016
  createBashTool,
5017
+ createCheckpoint,
3460
5018
  createLoadSkillTool,
3461
5019
  createReadFileTool,
3462
- createTerminalTool,
3463
5020
  createTodoTool,
3464
5021
  createTools,
3465
5022
  createWriteFileTool,
5023
+ fileBackupQueries,
5024
+ getCheckpointManager,
5025
+ getCheckpoints,
3466
5026
  getDb,
3467
- getTerminalManager,
5027
+ getSessionDiff,
3468
5028
  initDatabase,
3469
5029
  loadConfig,
3470
5030
  messageQueries,
5031
+ revertToCheckpoint,
3471
5032
  sessionQueries,
3472
5033
  skillQueries,
3473
5034
  startServer,
3474
5035
  stopServer,
3475
- terminalQueries,
5036
+ tmux_exports as tmux,
3476
5037
  todoQueries,
3477
5038
  toolExecutionQueries
3478
5039
  };